feat(ai-search): add interactive WebGL Orb, Markdown support, and Sentry tracking
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m18s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 3m55s
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m18s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 3m55s
This commit is contained in:
@@ -83,7 +83,7 @@ jobs:
|
|||||||
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
||||||
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
||||||
ENV_FILE=".env.branch-${SLUG}"
|
ENV_FILE=".env.branch-${SLUG}"
|
||||||
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
|
TRAEFIK_HOST="${SLUG}.branch.${DOMAIN}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Standardize Traefik Rule (escaped backticks for Traefik v3)
|
# Standardize Traefik Rule (escaped backticks for Traefik v3)
|
||||||
|
|||||||
@@ -1,138 +1,157 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse, NextRequest } from 'next/server'; // Added NextRequest
|
||||||
import { searchProducts } from '../../../src/lib/qdrant';
|
import { searchProducts } from '../../../src/lib/qdrant';
|
||||||
import redis from '../../../src/lib/redis';
|
import redis from '../../../src/lib/redis';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
// Config and constants
|
// Config and constants
|
||||||
const RATE_LIMIT_POINTS = 5; // 5 requests
|
const RATE_LIMIT_POINTS = 5; // 5 requests
|
||||||
const RATE_LIMIT_DURATION = 60 * 1; // per 1 minute
|
const RATE_LIMIT_DURATION = 60 * 1; // per 1 minute
|
||||||
|
|
||||||
const requestSchema = z.object({
|
// Removed requestSchema as it's replaced by direct parsing
|
||||||
query: z.string().min(1).max(500),
|
|
||||||
_honeypot: z.string().max(0).optional(), // Honeypot trap: must be empty
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: NextRequest) {
|
||||||
|
// Changed req type to NextRequest
|
||||||
|
try {
|
||||||
|
const { messages, visitorId, honeypot } = await req.json();
|
||||||
|
|
||||||
|
// 1. Basic Validation
|
||||||
|
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Valid messages array is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestMessage = messages[messages.length - 1].content;
|
||||||
|
const isBot = honeypot && honeypot.length > 0;
|
||||||
|
|
||||||
|
// Check if the input itself is obviously spam/too long
|
||||||
|
if (latestMessage.length > 500) {
|
||||||
|
return NextResponse.json({ error: 'Message too long' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Honeypot check
|
||||||
|
if (isBot) {
|
||||||
|
console.warn('Honeypot triggered in AI search');
|
||||||
|
// Tarpit the bot
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||||
|
return NextResponse.json({
|
||||||
|
answerText: 'Vielen Dank für Ihre Anfrage.',
|
||||||
|
products: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Rate Limiting via Redis
|
||||||
try {
|
try {
|
||||||
// 1. IP extraction for Rate Limiting
|
if (visitorId) {
|
||||||
const forwardedFor = req.headers.get('x-forwarded-for');
|
const requestCount = await redis.incr(`ai_search_rate_limit:${visitorId}`);
|
||||||
const realIp = req.headers.get('x-real-ip');
|
if (requestCount === 1) {
|
||||||
const ip = forwardedFor?.split(',')[0] || realIp || 'anon';
|
await redis.expire(`ai_search_rate_limit:${visitorId}`, RATE_LIMIT_DURATION); // Use constant
|
||||||
const rateLimitKey = `rate_limit:ai_search:${ip}`;
|
|
||||||
|
|
||||||
// Redis Rate Limiting
|
|
||||||
try {
|
|
||||||
const current = await redis.incr(rateLimitKey);
|
|
||||||
if (current === 1) {
|
|
||||||
await redis.expire(rateLimitKey, RATE_LIMIT_DURATION);
|
|
||||||
}
|
|
||||||
if (current > RATE_LIMIT_POINTS) {
|
|
||||||
return NextResponse.json({ error: 'Rate limit exceeded. Try again later.' }, { status: 429 });
|
|
||||||
}
|
|
||||||
} catch (redisError) {
|
|
||||||
console.warn('Redis error during rate limiting:', redisError);
|
|
||||||
// Fallback: proceed if Redis is down, to maintain availability
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Validate request
|
if (requestCount > RATE_LIMIT_POINTS) {
|
||||||
const json = await req.json().catch(() => ({}));
|
// Use constant
|
||||||
const parseResult = requestSchema.safeParse(json);
|
return NextResponse.json(
|
||||||
|
{
|
||||||
if (!parseResult.success) {
|
error: 'Rate limit exceeded. Please try again later.',
|
||||||
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
},
|
||||||
|
{ status: 429 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} catch (redisError) {
|
||||||
|
// Renamed variable for clarity
|
||||||
|
console.error('Redis Rate Limiting Error:', redisError); // Changed to error for consistency
|
||||||
|
Sentry.captureException(redisError, { tags: { context: 'ai-search-rate-limit' } });
|
||||||
|
// Fail open if Redis is down
|
||||||
|
}
|
||||||
|
|
||||||
const { query, _honeypot } = parseResult.data;
|
// 4. Fetch Context from Qdrant based on the latest message
|
||||||
|
let contextStr = '';
|
||||||
|
let foundProducts: any[] = [];
|
||||||
|
|
||||||
// 3. Honeypot check
|
try {
|
||||||
// If the honeypot field has any content, this is a bot.
|
const searchResults = await searchProducts(latestMessage, 5);
|
||||||
if (_honeypot && _honeypot.length > 0) {
|
|
||||||
// Return a fake success mask
|
|
||||||
return NextResponse.json({ answer: 'Searching...' }, { status: 200 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Qdrant Context Retrieval
|
if (searchResults && searchResults.length > 0) {
|
||||||
const searchResults = await searchProducts(query, 5);
|
const productDescriptions = searchResults
|
||||||
|
.filter((p) => p.payload?.type === 'product' || !p.payload?.type)
|
||||||
|
.map((p: any) => p.payload?.content)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
// Build context block
|
const knowledgeDescriptions = searchResults
|
||||||
const contextText = searchResults.map((res: any) => {
|
.filter((p) => p.payload?.type === 'knowledge')
|
||||||
const payload = res.payload;
|
.map((p: any) => p.payload?.content)
|
||||||
return `Product ID: ${payload?.id}
|
.join('\n\n');
|
||||||
Name: ${payload?.title}
|
|
||||||
SKU: ${payload?.sku}
|
|
||||||
Description: ${payload?.description}
|
|
||||||
Slug: ${payload?.slug}
|
|
||||||
---`;
|
|
||||||
}).join('\n');
|
|
||||||
|
|
||||||
// 5. OpenRouter Integration (gemini-3-flash-preview)
|
contextStr = `KATALOG & PRODUKTE:\n${productDescriptions}\n\nKABELWISSEN (Handbuch):\n${knowledgeDescriptions}`;
|
||||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
|
||||||
if (!openRouterKey) {
|
|
||||||
return NextResponse.json({ error: 'Server configuration error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const systemPrompt = `You are the KLZ Cables AI Search Assistant, an intelligent, helpful, and highly specialized assistant strictly for the KLZ Cables website.
|
foundProducts = searchResults
|
||||||
Your primary goal is to help users find the correct industrial cables and products based ONLY on the context provided.
|
.filter((p) => (p.payload?.type === 'product' || !p.payload?.type) && p.payload?.data)
|
||||||
Follow these strict rules:
|
.map((p: any) => p.payload?.data);
|
||||||
1. ONLY answer questions related to products, search queries, cables, or industrial electronics.
|
}
|
||||||
2. If the user asks a question entirely unrelated to products or the company (e.g., "What is the capital of France?", "Write a poem", "What is 2+2?"), REFUSE to answer it. Instead, reply with a funny, sarcastic, or humorous comment about how you only know about cables and wires.
|
} catch (e) {
|
||||||
3. Base your product answers strictly on the CONTEXT provided below. Do not hallucinate products.
|
console.error('Qdrant Search Error:', e);
|
||||||
4. Output your response as a valid JSON object matching this schema exactly, do not use Markdown codeblocks, output RAW JSON:
|
Sentry.captureException(e, { tags: { context: 'ai-search-qdrant' } });
|
||||||
{
|
// We can still proceed without context if Qdrant fails
|
||||||
"answerText": "A friendly description or answer based on the search.",
|
}
|
||||||
"products": [
|
|
||||||
{ "id": "Context Product ID", "title": "Product Title", "sku": "Product SKU", "slug": "slug" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
If you find relevant products in the context, add them to the "products" array. If no products match, use an empty array.
|
// 5. Generate AI Response via OpenRouter (Mistral for DSGVO)
|
||||||
|
const systemPrompt = `Du bist ein professioneller und extrem kompetenter Sales-Engineer / Consultant der Firma "KLZ Cables".
|
||||||
|
Deine Aufgabe ist es, Kunden und Interessenten bei der Auswahl von Mittelspannungskabeln, Starkstromkabeln und Infrastrukturausrüstung beratend zur Seite zu stehen.
|
||||||
|
|
||||||
CONTEXT:
|
WICHTIGE REGELN:
|
||||||
${contextText}
|
1. ANTWORTE IMMER IN DER SPRACHE DES BENUTZERS. Wenn der Benutzer Deutsch spricht, antworte auf Deutsch.
|
||||||
|
2. Wenn der Kunde vage ist (z.B. "Ich will einen Windpark bauen"), würge ihn NICHT ab. Stelle stattdessen gezielte, professionelle Rückfragen als Berater (z.B. "Für einen Windpark benötigen wir einige Rahmendaten: Reden wir über die Parkverkabelung (Mittelspannung, z.B. 20kV oder 33kV) oder die Netzanbindung? Welche Querschnitte oder Ströme erwarten Sie?").
|
||||||
|
3. Nutze das bereitgestellte KABELWISSEN und KATALOG-Gedächtnis unten, um deine Antworten zu fundieren.
|
||||||
|
4. Bleibe stets professionell, lösungsorientiert und leicht technisch (Industrial Aesthetic). Du kannst humorvoll sein, wenn der Nutzer offensichtlich Quatsch fragt, aber lenke es immer elegant zurück zu Kabeln oder Energieinfrastruktur.
|
||||||
|
5. Antworte in reinem Text (kein Markdown für die Antwort, es sei denn es sind einfache Absätze oder Listen).
|
||||||
|
6. Wenn genügend Informationen vorhanden sind, präsentiere passende Kabel aus dem Katalog.
|
||||||
|
7. Oute dich als Berater von KLZ Cables.
|
||||||
|
|
||||||
|
VERFÜGBARER KONTEXT:
|
||||||
|
${contextStr ? contextStr : 'Keine spezifischen Katalogdaten für diese Anfrage gefunden.'}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||||
method: 'POST',
|
if (!openRouterKey) {
|
||||||
headers: {
|
throw new Error('OPENROUTER_API_KEY is not set');
|
||||||
'Authorization': `Bearer ${openRouterKey}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://klz-cables.com',
|
|
||||||
'X-Title': 'KLZ Cables Search AI',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: 'google/gemini-3-flash-preview',
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: systemPrompt },
|
|
||||||
{ role: 'user', content: query }
|
|
||||||
],
|
|
||||||
response_format: { type: "json_object" }
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorBody = await response.text();
|
|
||||||
throw new Error(`OpenRouter error: ${response.status} ${errorBody}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const completion = await response.json();
|
|
||||||
const rawContent = completion.choices?.[0]?.message?.content;
|
|
||||||
|
|
||||||
let answerJson;
|
|
||||||
try {
|
|
||||||
// Remove any potential markdown json block markers
|
|
||||||
const sanitizedObjStr = rawContent.replace(/^```json\s*/, '').replace(/\s*```$/, '');
|
|
||||||
answerJson = JSON.parse(sanitizedObjStr);
|
|
||||||
} catch (parseError) {
|
|
||||||
console.error('Failed to parse AI response:', rawContent);
|
|
||||||
answerJson = {
|
|
||||||
answerText: rawContent || "Sorry, I had trouble thinking about cables right now.",
|
|
||||||
products: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(answerJson);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('AI Search API Error:', error);
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchRes = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${openRouterKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://klz-cables.com',
|
||||||
|
'X-Title': 'KLZ Cables Search AI',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'mistralai/mistral-large-2407',
|
||||||
|
temperature: 0.3,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
...messages.map((m: any) => ({
|
||||||
|
role: m.role,
|
||||||
|
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fetchRes.ok) {
|
||||||
|
const errBody = await fetchRes.text();
|
||||||
|
throw new Error(`OpenRouter API Error: ${errBody}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fetchRes.json();
|
||||||
|
const text = data.choices[0].message.content;
|
||||||
|
|
||||||
|
// Return the AI's answer along with any found products
|
||||||
|
return NextResponse.json({
|
||||||
|
answerText: text,
|
||||||
|
products: foundProducts,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('AI Search API Error:', error);
|
||||||
|
Sentry.captureException(error, { tags: { context: 'ai-search-api' } });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import { useTranslations, useLocale } from 'next-intl';
|
|||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useAnalytics } from '../analytics/useAnalytics';
|
import { useAnalytics } from '../analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from '../analytics/analytics-events';
|
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||||
|
import AIOrb from '../search/AIOrb';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Search, Sparkles } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
import { AISearchResults } from '../search/AISearchResults';
|
import { AISearchResults } from '../search/AISearchResults';
|
||||||
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
||||||
|
|
||||||
@@ -76,24 +77,26 @@ export default function Hero({ data }: { data?: any }) {
|
|||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSearchSubmit}
|
onSubmit={handleSearchSubmit}
|
||||||
className="w-full max-w-2xl bg-white/10 backdrop-blur-md border border-white/20 rounded-full p-2 flex items-center mt-8 mb-10 transition-all focus-within:bg-white/15 focus-within:border-accent"
|
className="w-full max-w-2xl bg-white/10 backdrop-blur-md border border-white/20 rounded-2xl p-2 flex items-center mt-8 mb-10 transition-all focus-within:bg-white/15 focus-within:border-accent shadow-lg relative"
|
||||||
>
|
>
|
||||||
<Sparkles className="w-6 h-6 text-accent ml-4 hidden sm:block" />
|
<div className="absolute left-2 w-12 h-12 flex items-center justify-center opacity-80 pointer-events-none">
|
||||||
|
<AIOrb isThinking={false} />
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Suchen Sie nach einem Kabel (z.B. N2XY, NYM-J)..."
|
placeholder="Projekt beschreiben oder Kabel suchen..."
|
||||||
className="flex-1 bg-transparent border-none text-white px-4 py-3 placeholder:text-white/60 focus:outline-none text-lg"
|
className="flex-1 bg-transparent border-none text-white pl-12 pr-2 py-4 placeholder:text-white/50 focus:outline-none text-lg lg:text-xl"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="rounded-full px-8 py-3 shrink-0"
|
className="rounded-xl px-6 py-4 shrink-0 flex items-center shadow-md font-bold cursor-pointer hover:bg-accent hover:brightness-110"
|
||||||
>
|
>
|
||||||
<Search className="w-5 h-5 mr-2 -ml-2" />
|
Fragen
|
||||||
Suchen
|
<ChevronRight className="w-5 h-5 ml-2 -mr-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -103,7 +106,7 @@ export default function Hero({ data }: { data?: any }) {
|
|||||||
href="/contact"
|
href="/contact"
|
||||||
variant="white"
|
variant="white"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-transform"
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-all outline-none"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
label: data?.ctaLabel || t('cta'),
|
label: data?.ctaLabel || t('cta'),
|
||||||
|
|||||||
88
components/search/AIOrb.tsx
Normal file
88
components/search/AIOrb.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/* eslint-disable react/no-unknown-property */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { Canvas, useFrame } from '@react-three/fiber';
|
||||||
|
import { Sphere, MeshDistortMaterial, Environment, Float } from '@react-three/drei';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
interface AIOrbProps {
|
||||||
|
isThinking: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Orb({ isThinking }: AIOrbProps) {
|
||||||
|
const meshRef = useRef<THREE.Mesh>(null);
|
||||||
|
const materialRef = useRef<any>(null);
|
||||||
|
|
||||||
|
// Dynamic properties based on state
|
||||||
|
const targetDistort = isThinking ? 0.6 : 0.3;
|
||||||
|
const targetSpeed = isThinking ? 5 : 2;
|
||||||
|
const color = isThinking ? '#00FF88' : '#00A3FF'; // Green/Blue based on thinking state
|
||||||
|
|
||||||
|
useFrame((state) => {
|
||||||
|
if (!materialRef.current) return;
|
||||||
|
|
||||||
|
// Smoothly interpolate material properties
|
||||||
|
materialRef.current.distort = THREE.MathUtils.lerp(
|
||||||
|
materialRef.current.distort,
|
||||||
|
targetDistort,
|
||||||
|
0.1,
|
||||||
|
);
|
||||||
|
materialRef.current.speed = THREE.MathUtils.lerp(materialRef.current.speed, targetSpeed, 0.1);
|
||||||
|
|
||||||
|
// Smooth color transition
|
||||||
|
const currentColor = materialRef.current.color;
|
||||||
|
const targetColorObj = new THREE.Color(color);
|
||||||
|
currentColor.lerp(targetColorObj, 0.05);
|
||||||
|
|
||||||
|
// Slow rotation
|
||||||
|
if (meshRef.current) {
|
||||||
|
meshRef.current.rotation.x = state.clock.getElapsedTime() * 0.2;
|
||||||
|
meshRef.current.rotation.y = state.clock.getElapsedTime() * 0.3;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Float
|
||||||
|
speed={isThinking ? 4 : 2}
|
||||||
|
rotationIntensity={isThinking ? 2 : 1}
|
||||||
|
floatIntensity={isThinking ? 2 : 1}
|
||||||
|
>
|
||||||
|
<Sphere ref={meshRef} args={[1, 64, 64]} scale={1.5}>
|
||||||
|
<MeshDistortMaterial
|
||||||
|
ref={materialRef}
|
||||||
|
color="#00A3FF"
|
||||||
|
envMapIntensity={2}
|
||||||
|
clearcoat={1}
|
||||||
|
clearcoatRoughness={0}
|
||||||
|
metalness={0.8}
|
||||||
|
roughness={0.1}
|
||||||
|
distort={0.3}
|
||||||
|
speed={2}
|
||||||
|
/>
|
||||||
|
</Sphere>
|
||||||
|
</Float>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AIOrb({ isThinking = false }: AIOrbProps) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full min-w-[32px] min-h-[32px] relative flex items-center justify-center">
|
||||||
|
{/* Ambient glow effect behind the orb */}
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 rounded-full blur-xl opacity-50 transition-colors duration-1000 ${isThinking ? 'bg-[#00FF88]/50' : 'bg-[#00A3FF]/40'}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Canvas
|
||||||
|
camera={{ position: [0, 0, 4], fov: 45 }}
|
||||||
|
className="w-full h-full cursor-pointer z-10 block"
|
||||||
|
>
|
||||||
|
<ambientLight intensity={0.5} />
|
||||||
|
<directionalLight position={[10, 10, 5]} intensity={1.5} />
|
||||||
|
<directionalLight position={[-10, -10, -5]} intensity={0.5} color="#00FF88" />
|
||||||
|
<Orb isThinking={isThinking} />
|
||||||
|
<Environment preset="city" />
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,230 +1,323 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { Search, X, Sparkles, ChevronRight, MessageSquareWarning } from 'lucide-react';
|
||||||
import { Search, Loader2, X, Sparkles, ChevronRight, MessageSquareWarning } from 'lucide-react';
|
|
||||||
import { Button, cn } from '@/components/ui';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useAnalytics } from '../analytics/useAnalytics';
|
import { useAnalytics } from '../analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from '../analytics/analytics-events';
|
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||||
import Image from 'next/image';
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import AIOrb from './AIOrb';
|
||||||
|
|
||||||
interface ProductMatch {
|
interface ProductMatch {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
sku: string;
|
sku: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AIResponse {
|
interface Message {
|
||||||
answerText: string;
|
role: 'user' | 'assistant';
|
||||||
products: ProductMatch[];
|
content: string;
|
||||||
|
products?: ProductMatch[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
initialQuery?: string;
|
initialQuery?: string;
|
||||||
triggerSearch?: boolean; // If true, immediately searches on mount with initialQuery
|
triggerSearch?: boolean; // If true, immediately searches on mount with initialQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AISearchResults({ isOpen, onClose, initialQuery = '', triggerSearch = false }: ComponentProps) {
|
export function AISearchResults({
|
||||||
const t = useTranslations('Search');
|
isOpen,
|
||||||
const { trackEvent } = useAnalytics();
|
onClose,
|
||||||
|
initialQuery = '',
|
||||||
|
triggerSearch = false,
|
||||||
|
}: ComponentProps) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
const [query, setQuery] = useState(initialQuery);
|
const [query, setQuery] = useState('');
|
||||||
const [honeypot, setHoneypot] = useState('');
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [honeypot, setHoneypot] = useState('');
|
||||||
const [response, setResponse] = useState<AIResponse | null>(null);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
// Slight delay to allow animation to start before focus
|
setTimeout(() => inputRef.current?.focus(), 100);
|
||||||
setTimeout(() => inputRef.current?.focus(), 100);
|
|
||||||
|
|
||||||
if (triggerSearch && initialQuery && !response) {
|
if (triggerSearch && initialQuery && messages.length === 0) {
|
||||||
handleSearch(initialQuery);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
document.body.style.overflow = 'unset';
|
|
||||||
}
|
|
||||||
return () => { document.body.style.overflow = 'unset'; };
|
|
||||||
}, [isOpen, triggerSearch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setQuery(initialQuery);
|
setQuery(initialQuery);
|
||||||
}, [initialQuery]);
|
handleSearch(initialQuery);
|
||||||
|
} else if (!triggerSearch) {
|
||||||
const handleSearch = async (searchQuery: string = query) => {
|
setQuery('');
|
||||||
if (!searchQuery.trim()) return;
|
}
|
||||||
|
} else {
|
||||||
setIsLoading(true);
|
document.body.style.overflow = 'unset';
|
||||||
setError(null);
|
setQuery('');
|
||||||
setResponse(null);
|
setMessages([]);
|
||||||
|
setError(null);
|
||||||
trackEvent(AnalyticsEvents.FORM_SUBMIT, {
|
setIsLoading(false);
|
||||||
type: 'ai_search',
|
}
|
||||||
query: searchQuery
|
return () => {
|
||||||
});
|
document.body.style.overflow = 'unset';
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/ai-search', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ query: searchQuery, _honeypot: honeypot })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(data.error || 'Failed to fetch search results');
|
|
||||||
}
|
|
||||||
|
|
||||||
setResponse(data);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
setError(err.message || 'An error occurred while searching. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
}, [isOpen, triggerSearch]);
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
useEffect(() => {
|
||||||
if (e.key === 'Enter') {
|
if (isOpen && initialQuery && messages.length === 0) {
|
||||||
e.preventDefault();
|
setQuery(initialQuery);
|
||||||
handleSearch();
|
}
|
||||||
}
|
}, [initialQuery, isOpen]);
|
||||||
if (e.key === 'Escape') {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
useEffect(() => {
|
||||||
|
// Auto-scroll to bottom of chat
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages, isLoading]);
|
||||||
|
|
||||||
return (
|
const handleSearch = async (searchQuery: string = query) => {
|
||||||
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-16 md:pt-24 px-4 bg-primary/95 backdrop-blur-xl transition-all duration-300 animate-in fade-in">
|
if (!searchQuery.trim() || isLoading) return;
|
||||||
<div
|
|
||||||
className="absolute inset-0"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
const newUserMessage: Message = { role: 'user', content: searchQuery };
|
||||||
ref={modalRef}
|
const newMessagesContext = [...messages, newUserMessage];
|
||||||
className="relative w-full max-w-4xl bg-[#002b49]/90 border border-white/10 rounded-3xl shadow-2xl shadow-black/50 overflow-hidden flex flex-col h-[75vh] animate-in slide-in-from-bottom-10"
|
|
||||||
>
|
|
||||||
{/* Header - Search Bar */}
|
|
||||||
<div className="p-6 md:p-8 flex items-center border-b border-white/10 relative z-10 bg-gradient-to-r from-primary/80 to-[#00223A]/80">
|
|
||||||
<Sparkles className="w-6 h-6 text-accent shrink-0 mr-4" />
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
placeholder={"What are you looking for?"}
|
|
||||||
className="w-full bg-transparent border-none text-white text-xl md:text-3xl font-extrabold focus:outline-none placeholder:text-white/30"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="hidden"
|
|
||||||
value={honeypot}
|
|
||||||
onChange={(e) => setHoneypot(e.target.value)}
|
|
||||||
tabIndex={-1}
|
|
||||||
autoComplete="off"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 className="w-8 h-8 text-white/50 animate-spin shrink-0 ml-4" />
|
|
||||||
) : query ? (
|
|
||||||
<button
|
|
||||||
onClick={() => handleSearch()}
|
|
||||||
className="text-white hover:text-accent transition-colors ml-4 shrink-0"
|
|
||||||
aria-label="Search"
|
|
||||||
>
|
|
||||||
<Search className="w-8 h-8" />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<div className="w-px h-10 bg-white/10 mx-6 hidden md:block" />
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-white/50 hover:text-white transition-colors shrink-0"
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<X className="w-8 h-8 md:w-10 md:h-10" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content Area */}
|
setMessages(newMessagesContext);
|
||||||
<div className="flex-1 overflow-y-auto p-6 md:p-8 relative">
|
setQuery('');
|
||||||
{!response && !isLoading && !error && (
|
setIsLoading(true);
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center opacity-50 space-y-4">
|
setError(null);
|
||||||
<Search className="w-16 h-16" />
|
|
||||||
<p className="text-xl md:text-2xl font-bold">Describe what you need, and our AI will find it.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
trackEvent(AnalyticsEvents.FORM_SUBMIT, {
|
||||||
<div className="flex items-start space-x-4 bg-red-500/10 border border-red-500/20 p-6 rounded-2xl">
|
type: 'ai_search',
|
||||||
<MessageSquareWarning className="w-8 h-8 text-red-400 shrink-0" />
|
query: searchQuery,
|
||||||
<div>
|
});
|
||||||
<h3 className="text-xl font-bold text-red-200">Encountered an error</h3>
|
|
||||||
<p className="text-red-300 mt-2">{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{response && (
|
try {
|
||||||
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
const res = await fetch('/api/ai-search', {
|
||||||
{/* AI Answer */}
|
method: 'POST',
|
||||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 md:p-8 relative overflow-hidden group">
|
headers: { 'Content-Type': 'application/json' },
|
||||||
<div className="absolute top-0 left-0 w-1 h-full bg-accent" />
|
body: JSON.stringify({
|
||||||
<Sparkles className="absolute top-4 right-4 w-6h-6 text-accent/20 group-hover:text-accent/40 transition-colors" />
|
messages: newMessagesContext,
|
||||||
<h3 className="text-sm font-bold tracking-widest uppercase text-accent mb-4">AI Assistant</h3>
|
_honeypot: honeypot,
|
||||||
<p className="text-lg md:text-xl text-white/90 leading-relaxed font-medium">
|
}),
|
||||||
{response.answerText}
|
});
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Product Matches */}
|
const data = await res.json();
|
||||||
{response.products && response.products.length > 0 && (
|
|
||||||
<div className="space-y-4">
|
if (!res.ok) {
|
||||||
<h3 className="text-sm font-bold tracking-widest uppercase text-white/50">Matching Products</h3>
|
throw new Error(data.error || 'Failed to fetch search results');
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
}
|
||||||
{response.products.map((product, idx) => (
|
|
||||||
<Link
|
setMessages((prev) => [
|
||||||
key={idx}
|
...prev,
|
||||||
href={`/produkte/${product.slug}`}
|
{
|
||||||
onClick={() => {
|
role: 'assistant',
|
||||||
onClose();
|
content: data.answerText,
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
products: data.products,
|
||||||
target: product.slug,
|
},
|
||||||
location: 'ai_search_results'
|
]);
|
||||||
});
|
|
||||||
}}
|
// Re-focus input after response so user can continue typing easily
|
||||||
className="group flex flex-col justify-between bg-white text-primary rounded-xl p-6 hover:shadow-2xl hover:-translate-y-1 transition-all duration-300"
|
setTimeout(() => inputRef.current?.focus(), 100);
|
||||||
>
|
} catch (err: any) {
|
||||||
<div>
|
console.error(err);
|
||||||
<p className="text-xs font-bold text-primary/50 tracking-wider mb-2">{product.sku}</p>
|
setError(err.message || 'An error occurred while chatting. Please try again.');
|
||||||
<h4 className="text-xl md:text-2xl font-extrabold mb-4 group-hover:text-accent transition-colors">{product.title}</h4>
|
trackEvent(AnalyticsEvents.ERROR, {
|
||||||
</div>
|
location: 'ai_search_results',
|
||||||
<div className="flex items-center text-sm font-bold tracking-widest uppercase">
|
message: err.message,
|
||||||
<span className="group-hover:text-accent transition-colors">Details</span>
|
query: searchQuery,
|
||||||
<ChevronRight className="w-4 h-4 ml-1 group-hover:text-accent transition-colors group-hover:translate-x-1" />
|
});
|
||||||
</div>
|
} finally {
|
||||||
</Link>
|
setIsLoading(false);
|
||||||
))}
|
}
|
||||||
</div>
|
};
|
||||||
</div>
|
|
||||||
)}
|
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
</div>
|
if (e.key === 'Enter') {
|
||||||
)}
|
e.preventDefault();
|
||||||
</div>
|
handleSearch();
|
||||||
</div>
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
// Handle clicking outside to close
|
||||||
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-start justify-center pt-16 md:pt-24 px-4 bg-primary/95 backdrop-blur-xl transition-all duration-300 animate-in fade-in"
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
className="relative w-full max-w-4xl bg-[#002b49]/90 border border-white/10 rounded-3xl shadow-2xl shadow-black/50 overflow-hidden flex flex-col h-[75vh] animate-in slide-in-from-bottom-10"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 md:p-6 flex items-center justify-between border-b border-white/10 relative z-10 bg-[#001c30]">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Sparkles className="w-5 h-5 text-accent mr-3" />
|
||||||
|
<h2 className="text-white font-bold tracking-widest uppercase text-sm">
|
||||||
|
KLZ AI Consultant
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-white/50 hover:text-white transition-colors p-2"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
|
{/* Chat History Area */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 md:p-8 relative space-y-6 scroll-smooth">
|
||||||
|
{messages.length === 0 && !isLoading && !error && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-center opacity-50 space-y-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<AIOrb isThinking={false} />
|
||||||
|
<p className="text-xl md:text-2xl font-bold mt-6">I am your technical consultant.</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Describe your project, ask for specific cables, or tell me your requirements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((msg, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-[85%] rounded-2xl p-5 ${msg.role === 'user' ? 'bg-accent text-primary rounded-tr-sm' : 'bg-white/10 border border-white/10 text-white rounded-tl-sm'}`}
|
||||||
|
>
|
||||||
|
{msg.role === 'assistant' && (
|
||||||
|
<h3 className="text-xs font-bold tracking-widest uppercase text-accent/80 mb-2 flex items-center">
|
||||||
|
<Sparkles className="w-3 h-3 mr-1" />
|
||||||
|
AI Assistant
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
<div className="text-base md:text-lg leading-relaxed font-medium prose prose-invert prose-p:leading-relaxed prose-pre:bg-black/50 prose-a:text-accent prose-strong:text-accent prose-ul:list-disc prose-ol:list-decimal">
|
||||||
|
{msg.role === 'assistant' ? (
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
||||||
|
) : (
|
||||||
|
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Matches inside Assistant Message */}
|
||||||
|
{msg.role === 'assistant' && msg.products && msg.products.length > 0 && (
|
||||||
|
<div className="mt-6 space-y-3 border-t border-white/10 pt-4">
|
||||||
|
<h4 className="text-xs font-bold tracking-widest uppercase text-white/50">
|
||||||
|
Empfohlene Produkte
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{msg.products.map((product, idx) => (
|
||||||
|
<Link
|
||||||
|
key={idx}
|
||||||
|
href={`/produkte/${product.slug}`}
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
target: product.slug,
|
||||||
|
location: 'ai_search_results',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="group flex flex-col justify-between bg-white text-primary rounded-lg p-4 hover:shadow-lg hover:-translate-y-1 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-primary/50 tracking-wider mb-1">
|
||||||
|
{product.sku}
|
||||||
|
</p>
|
||||||
|
<h5 className="text-sm font-extrabold mb-2 group-hover:text-accent transition-colors line-clamp-2">
|
||||||
|
{product.title}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end text-[10px] font-bold tracking-widest uppercase mt-2">
|
||||||
|
<span className="group-hover:text-accent transition-colors">
|
||||||
|
Details
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="w-3 h-3 ml-1 group-hover:text-accent transition-colors group-hover:translate-x-1" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="bg-transparent rounded-2xl p-2 w-24 flex justify-center">
|
||||||
|
<AIOrb isThinking={true} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start space-x-4 bg-red-500/10 border border-red-500/20 p-4 rounded-xl mt-4">
|
||||||
|
<MessageSquareWarning className="w-6 h-6 text-red-400 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-red-200">System Error</h3>
|
||||||
|
<p className="text-xs text-red-300 mt-1">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Area */}
|
||||||
|
<div className="p-4 md:p-6 bg-[#001c30] border-t border-white/10">
|
||||||
|
<div className="relative flex items-center bg-white/5 border border-white/10 rounded-xl focus-within:border-accent/50 focus-within:bg-white/10 transition-all">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder="Type your question or requirements..."
|
||||||
|
className="flex-1 bg-transparent border-none text-white text-base md:text-lg p-4 focus:outline-none placeholder:text-white/30"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="hidden"
|
||||||
|
value={honeypot}
|
||||||
|
onChange={(e) => setHoneypot(e.target.value)}
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSearch()}
|
||||||
|
disabled={!query.trim() || isLoading}
|
||||||
|
className="p-4 text-white/50 hover:text-accent disabled:opacity-50 disabled:hover:text-white/50 transition-colors shrink-0 cursor-pointer"
|
||||||
|
aria-label="Send message"
|
||||||
|
>
|
||||||
|
<Search className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-center mt-3">
|
||||||
|
<span className="text-[10px] uppercase tracking-widest font-bold text-white/30">
|
||||||
|
Press Enter to send • Esc to close
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ services:
|
|||||||
cpus: '4'
|
cpus: '4'
|
||||||
memory: 8G
|
memory: 8G
|
||||||
command: >
|
command: >
|
||||||
sh -c "pnpm install && pnpm next dev --webpack --hostname 0.0.0.0"
|
sh -c "pnpm install --no-frozen-lockfile && pnpm next dev --webpack --hostname 0.0.0.0"
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
|
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ services:
|
|||||||
klz-qdrant:
|
klz-qdrant:
|
||||||
image: qdrant/qdrant:v1.13.2
|
image: qdrant/qdrant:v1.13.2
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6333:6333"
|
||||||
environment:
|
environment:
|
||||||
QDRANT__SERVICE__HTTP_PORT: 6333
|
QDRANT__SERVICE__HTTP_PORT: 6333
|
||||||
QDRANT__SERVICE__GRPC_PORT: 6334
|
QDRANT__SERVICE__GRPC_PORT: 6334
|
||||||
|
|||||||
20
package.json
20
package.json
@@ -5,10 +5,11 @@
|
|||||||
"packageManager": "pnpm@10.18.3",
|
"packageManager": "pnpm@10.18.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/google": "^3.0.31",
|
"@ai-sdk/google": "^3.0.31",
|
||||||
"@mintel/mail": "^1.8.21",
|
"@ai-sdk/openai": "^3.0.36",
|
||||||
"@mintel/next-config": "^1.8.21",
|
"@mintel/mail": "^1.9.0",
|
||||||
"@mintel/next-feedback": "^1.8.21",
|
"@mintel/next-config": "^1.9.0",
|
||||||
"@mintel/next-utils": "^1.8.21",
|
"@mintel/next-feedback": "^1.9.0",
|
||||||
|
"@mintel/next-utils": "^1.9.0",
|
||||||
"@payloadcms/db-postgres": "^3.77.0",
|
"@payloadcms/db-postgres": "^3.77.0",
|
||||||
"@payloadcms/email-nodemailer": "^3.77.0",
|
"@payloadcms/email-nodemailer": "^3.77.0",
|
||||||
"@payloadcms/next": "^3.77.0",
|
"@payloadcms/next": "^3.77.0",
|
||||||
@@ -17,6 +18,8 @@
|
|||||||
"@qdrant/js-client-rest": "^1.17.0",
|
"@qdrant/js-client-rest": "^1.17.0",
|
||||||
"@react-email/components": "^1.0.7",
|
"@react-email/components": "^1.0.7",
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
|
"@react-three/drei": "^10.7.7",
|
||||||
|
"@react-three/fiber": "^9.5.0",
|
||||||
"@sentry/nextjs": "^10.39.0",
|
"@sentry/nextjs": "^10.39.0",
|
||||||
"@types/recharts": "^2.0.1",
|
"@types/recharts": "^2.0.1",
|
||||||
"ai": "^6.0.101",
|
"ai": "^6.0.101",
|
||||||
@@ -42,13 +45,17 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-email": "^5.2.5",
|
"react-email": "^5.2.5",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"require-in-the-middle": "^8.0.1",
|
"require-in-the-middle": "^8.0.1",
|
||||||
"resend": "^3.5.0",
|
"resend": "^3.5.0",
|
||||||
"schema-dts": "^1.1.5",
|
"schema-dts": "^1.1.5",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"svg-to-pdfkit": "^0.1.8",
|
"svg-to-pdfkit": "^0.1.8",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"three": "^0.183.1",
|
||||||
"xlsx": "npm:@e965/xlsx@^0.20.3",
|
"xlsx": "npm:@e965/xlsx@^0.20.3",
|
||||||
"zod": "3.25.76"
|
"zod": "3.25.76"
|
||||||
},
|
},
|
||||||
@@ -57,8 +64,8 @@
|
|||||||
"@commitlint/config-conventional": "^20.4.0",
|
"@commitlint/config-conventional": "^20.4.0",
|
||||||
"@cspell/dict-de-de": "^4.1.2",
|
"@cspell/dict-de-de": "^4.1.2",
|
||||||
"@lhci/cli": "^0.15.1",
|
"@lhci/cli": "^0.15.1",
|
||||||
"@mintel/eslint-config": "1.8.21",
|
"@mintel/eslint-config": "^1.9.0",
|
||||||
"@mintel/tsconfig": "^1.8.21",
|
"@mintel/tsconfig": "^1.9.0",
|
||||||
"@next/bundle-analyzer": "^16.1.6",
|
"@next/bundle-analyzer": "^16.1.6",
|
||||||
"@tailwindcss/cli": "^4.1.18",
|
"@tailwindcss/cli": "^4.1.18",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
@@ -84,6 +91,7 @@
|
|||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"pa11y-ci": "^4.0.1",
|
"pa11y-ci": "^4.0.1",
|
||||||
|
"pdf-parse": "^2.4.5",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"puppeteer": "^24.37.3",
|
"puppeteer": "^24.37.3",
|
||||||
|
|||||||
1143
pnpm-lock.yaml
generated
1143
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,15 @@
|
|||||||
import { QdrantClient } from '@qdrant/js-client-rest';
|
import { QdrantClient } from '@qdrant/js-client-rest';
|
||||||
|
|
||||||
const qdrantUrl = process.env.QDRANT_URL || 'http://localhost:6333';
|
const isDockerContainer =
|
||||||
|
process.env.IS_DOCKER === 'true' || process.env.HOSTNAME?.includes('klz-app');
|
||||||
|
const qdrantUrl =
|
||||||
|
process.env.QDRANT_URL ||
|
||||||
|
(isDockerContainer ? 'http://klz-qdrant:6333' : 'http://localhost:6333');
|
||||||
const qdrantApiKey = process.env.QDRANT_API_KEY || '';
|
const qdrantApiKey = process.env.QDRANT_API_KEY || '';
|
||||||
|
|
||||||
export const qdrant = new QdrantClient({
|
export const qdrant = new QdrantClient({
|
||||||
url: qdrantUrl,
|
url: qdrantUrl,
|
||||||
apiKey: qdrantApiKey || undefined,
|
apiKey: qdrantApiKey || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const COLLECTION_NAME = 'klz_products';
|
export const COLLECTION_NAME = 'klz_products';
|
||||||
@@ -15,110 +19,116 @@ export const VECTOR_SIZE = 1536; // OpenAI text-embedding-3-small
|
|||||||
* Ensure the collection exists in Qdrant.
|
* Ensure the collection exists in Qdrant.
|
||||||
*/
|
*/
|
||||||
export async function ensureCollection() {
|
export async function ensureCollection() {
|
||||||
try {
|
try {
|
||||||
const collections = await qdrant.getCollections();
|
const collections = await qdrant.getCollections();
|
||||||
const exists = collections.collections.some(c => c.name === COLLECTION_NAME);
|
const exists = collections.collections.some((c) => c.name === COLLECTION_NAME);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
await qdrant.createCollection(COLLECTION_NAME, {
|
await qdrant.createCollection(COLLECTION_NAME, {
|
||||||
vectors: {
|
vectors: {
|
||||||
size: VECTOR_SIZE,
|
size: VECTOR_SIZE,
|
||||||
distance: 'Cosine',
|
distance: 'Cosine',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(`Successfully created Qdrant collection: ${COLLECTION_NAME}`);
|
console.log(`Successfully created Qdrant collection: ${COLLECTION_NAME}`);
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error ensuring Qdrant collection:', error);
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error ensuring Qdrant collection:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate an embedding for a given text using OpenRouter (OpenAI embedding proxy)
|
* Generate an embedding for a given text using OpenRouter (OpenAI embedding proxy)
|
||||||
*/
|
*/
|
||||||
export async function generateEmbedding(text: string): Promise<number[]> {
|
export async function generateEmbedding(text: string): Promise<number[]> {
|
||||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||||
if (!openRouterKey) {
|
if (!openRouterKey) {
|
||||||
throw new Error('OPENROUTER_API_KEY is not set');
|
throw new Error('OPENROUTER_API_KEY is not set');
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('https://openrouter.ai/api/v1/embeddings', {
|
const response = await fetch('https://openrouter.ai/api/v1/embeddings', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${openRouterKey}`,
|
Authorization: `Bearer ${openRouterKey}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://klz-cables.com',
|
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://klz-cables.com',
|
||||||
'X-Title': 'KLZ Cables Search AI',
|
'X-Title': 'KLZ Cables Search AI',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'openai/text-embedding-3-small',
|
model: 'openai/text-embedding-3-small',
|
||||||
input: text,
|
input: text,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorBody = await response.text();
|
const errorBody = await response.text();
|
||||||
throw new Error(`Failed to generate embedding: ${response.status} ${response.statusText} ${errorBody}`);
|
throw new Error(
|
||||||
}
|
`Failed to generate embedding: ${response.status} ${response.statusText} ${errorBody}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.data[0].embedding;
|
return data.data[0].embedding;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upsert a product into Qdrant
|
* Upsert a product into Qdrant
|
||||||
*/
|
*/
|
||||||
export async function upsertProductVector(id: string | number, text: string, payload: Record<string, any>) {
|
export async function upsertProductVector(
|
||||||
try {
|
id: string | number,
|
||||||
await ensureCollection();
|
text: string,
|
||||||
const vector = await generateEmbedding(text);
|
payload: Record<string, any>,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await ensureCollection();
|
||||||
|
const vector = await generateEmbedding(text);
|
||||||
|
|
||||||
await qdrant.upsert(COLLECTION_NAME, {
|
await qdrant.upsert(COLLECTION_NAME, {
|
||||||
wait: true,
|
wait: true,
|
||||||
points: [
|
points: [
|
||||||
{
|
{
|
||||||
id: id,
|
id: id,
|
||||||
vector,
|
vector,
|
||||||
payload,
|
payload,
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error writing to Qdrant:', error);
|
console.error('Error writing to Qdrant:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a product from Qdrant
|
* Delete a product from Qdrant
|
||||||
*/
|
*/
|
||||||
export async function deleteProductVector(id: string | number) {
|
export async function deleteProductVector(id: string | number) {
|
||||||
try {
|
try {
|
||||||
await ensureCollection();
|
await ensureCollection();
|
||||||
await qdrant.delete(COLLECTION_NAME, {
|
await qdrant.delete(COLLECTION_NAME, {
|
||||||
wait: true,
|
wait: true,
|
||||||
points: [id] as [string | number],
|
points: [id] as [string | number],
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting from Qdrant:', error);
|
console.error('Error deleting from Qdrant:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search products in Qdrant
|
* Search products in Qdrant
|
||||||
*/
|
*/
|
||||||
export async function searchProducts(query: string, limit = 5) {
|
export async function searchProducts(query: string, limit = 5) {
|
||||||
try {
|
try {
|
||||||
await ensureCollection();
|
await ensureCollection();
|
||||||
const vector = await generateEmbedding(query);
|
const vector = await generateEmbedding(query);
|
||||||
|
|
||||||
const results = await qdrant.search(COLLECTION_NAME, {
|
const results = await qdrant.search(COLLECTION_NAME, {
|
||||||
vector,
|
vector,
|
||||||
limit,
|
limit,
|
||||||
with_payload: true,
|
with_payload: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error searching in Qdrant:', error);
|
console.error('Error searching in Qdrant:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
const redisUrl = process.env.REDIS_URL || 'redis://klz-redis:6379';
|
const isDockerContainer =
|
||||||
|
process.env.IS_DOCKER === 'true' || process.env.HOSTNAME?.includes('klz-app');
|
||||||
|
const redisUrl =
|
||||||
|
process.env.REDIS_URL ||
|
||||||
|
(isDockerContainer ? 'redis://klz-redis:6379' : 'redis://localhost:6379');
|
||||||
|
|
||||||
// Only create a single instance in Node.js
|
// Only create a single instance in Node.js
|
||||||
const globalForRedis = global as unknown as { redis: Redis };
|
const globalForRedis = global as unknown as { redis: Redis };
|
||||||
|
|
||||||
export const redis = globalForRedis.redis || new Redis(redisUrl, {
|
export const redis =
|
||||||
|
globalForRedis.redis ||
|
||||||
|
new Redis(redisUrl, {
|
||||||
maxRetriesPerRequest: 3,
|
maxRetriesPerRequest: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
globalForRedis.redis = redis;
|
globalForRedis.redis = redis;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default redis;
|
export default redis;
|
||||||
|
|||||||
63
src/scripts/ingest-pdf.ts
Normal file
63
src/scripts/ingest-pdf.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
// Override Qdrant URL for local script execution outside docker
|
||||||
|
process.env.QDRANT_URL = process.env.QDRANT_URL || 'http://localhost:6333';
|
||||||
|
|
||||||
|
import { upsertProductVector } from '../lib/qdrant';
|
||||||
|
|
||||||
|
// Ingests the extracted Kabelhandbuch text into Qdrant as distinct knowledge topics.
|
||||||
|
async function ingestPDF(txtPath: string) {
|
||||||
|
if (!fs.existsSync(txtPath)) {
|
||||||
|
console.error(`File not found: ${txtPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = fs.readFileSync(txtPath, 'utf8');
|
||||||
|
|
||||||
|
// Simple sentence/paragraph chunking
|
||||||
|
// We split by standard paragraph breaks (double newline) or large content blocks.
|
||||||
|
const chunks = text
|
||||||
|
.split(/\n\s*\n/)
|
||||||
|
.map((c) => c.trim())
|
||||||
|
.filter((c) => c.length > 50);
|
||||||
|
|
||||||
|
console.log(`Extracted ${text.length} characters from PDF.`);
|
||||||
|
console.log(`Generated ${chunks.length} chunks for vector ingestion.\n`);
|
||||||
|
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
// We limit chuck sizes to ensure Openrouter embedding models don't timeout/fail,
|
||||||
|
// stringing multiple paragraphs if they are short, or cutting them if too long.
|
||||||
|
// For baseline, we'll index every chunk individually mapped as 'knowledge' with a unique ID
|
||||||
|
|
||||||
|
const chunkText = chunks[i];
|
||||||
|
|
||||||
|
// Generate a synthetic ID that won't collide with Payload Product IDs
|
||||||
|
// Qdrant strictly requires UUID or unsigned int.
|
||||||
|
const syntheticId = crypto.randomUUID();
|
||||||
|
|
||||||
|
const payloadData = {
|
||||||
|
type: 'knowledge', // Custom flag to differentiate from 'product'
|
||||||
|
title: `Kabelhandbuch Wissen - Bereich ${i + 1}`,
|
||||||
|
content: chunkText,
|
||||||
|
source: 'Kabelhandbuch KLZ.pdf',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use the existing upsert function since it just embeds the text and stores the payload
|
||||||
|
await upsertProductVector(syntheticId, chunkText, payloadData);
|
||||||
|
console.log(`✅ Upserted chunk ${i + 1}/${chunks.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🎉 PDF Ingestion Complete!');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse PDF:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run mapping
|
||||||
|
const targetTxt = '/Users/marcmintel/Downloads/kabelhandbuch.txt';
|
||||||
|
ingestPDF(targetTxt);
|
||||||
20
test-chat2.mjs
Normal file
20
test-chat2.mjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
const messages = [
|
||||||
|
{ role: 'user', content: 'Ich will einen Windpark bauen' }
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('Sending message:', messages[0].content);
|
||||||
|
|
||||||
|
const res = await fetch('http://localhost:3000/api/ai-search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ messages })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
console.log('\nAI Response:', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
test().catch(console.error);
|
||||||
16
test-simple.mjs
Normal file
16
test-simple.mjs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { generateText } from 'ai';
|
||||||
|
import { createOpenAI } from '@ai-sdk/openai';
|
||||||
|
|
||||||
|
const openrouter = createOpenAI({
|
||||||
|
baseURL: 'https://openrouter.ai/api/v1',
|
||||||
|
apiKey: process.env.OPENROUTER_API_KEY,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const { text } = await generateText({
|
||||||
|
model: openrouter('mistralai/mistral-large-2407'),
|
||||||
|
prompt: 'Hello world! Reply in one word.',
|
||||||
|
});
|
||||||
|
console.log('Result:', text);
|
||||||
|
}
|
||||||
|
run();
|
||||||
Reference in New Issue
Block a user