diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 0ad72084..24e868a0 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -83,7 +83,7 @@ jobs: SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//') IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}" ENV_FILE=".env.branch-${SLUG}" - TRAEFIK_HOST="${SLUG}.branch.mintel.me" + TRAEFIK_HOST="${SLUG}.branch.${DOMAIN}" fi # Standardize Traefik Rule (escaped backticks for Traefik v3) diff --git a/app/api/ai-search/route.ts b/app/api/ai-search/route.ts index d434ea8a..f5ed898f 100644 --- a/app/api/ai-search/route.ts +++ b/app/api/ai-search/route.ts @@ -1,138 +1,157 @@ -import { NextResponse } from 'next/server'; +import { NextResponse, NextRequest } from 'next/server'; // Added NextRequest import { searchProducts } from '../../../src/lib/qdrant'; import redis from '../../../src/lib/redis'; import { z } from 'zod'; - +import * as Sentry from '@sentry/nextjs'; // Config and constants const RATE_LIMIT_POINTS = 5; // 5 requests const RATE_LIMIT_DURATION = 60 * 1; // per 1 minute -const requestSchema = z.object({ - query: z.string().min(1).max(500), - _honeypot: z.string().max(0).optional(), // Honeypot trap: must be empty -}); +// Removed requestSchema as it's replaced by direct parsing -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 { - // 1. IP extraction for Rate Limiting - const forwardedFor = req.headers.get('x-forwarded-for'); - const realIp = req.headers.get('x-real-ip'); - const ip = forwardedFor?.split(',')[0] || realIp || 'anon'; - 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 + if (visitorId) { + const requestCount = await redis.incr(`ai_search_rate_limit:${visitorId}`); + if (requestCount === 1) { + await redis.expire(`ai_search_rate_limit:${visitorId}`, RATE_LIMIT_DURATION); // Use constant } - // 2. Validate request - const json = await req.json().catch(() => ({})); - const parseResult = requestSchema.safeParse(json); - - if (!parseResult.success) { - return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + if (requestCount > RATE_LIMIT_POINTS) { + // Use constant + return NextResponse.json( + { + error: 'Rate limit exceeded. Please try again later.', + }, + { 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 - // If the honeypot field has any content, this is a bot. - if (_honeypot && _honeypot.length > 0) { - // Return a fake success mask - return NextResponse.json({ answer: 'Searching...' }, { status: 200 }); - } + try { + const searchResults = await searchProducts(latestMessage, 5); - // 4. Qdrant Context Retrieval - const searchResults = await searchProducts(query, 5); + if (searchResults && searchResults.length > 0) { + 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 contextText = searchResults.map((res: any) => { - const payload = res.payload; - return `Product ID: ${payload?.id} -Name: ${payload?.title} -SKU: ${payload?.sku} -Description: ${payload?.description} -Slug: ${payload?.slug} ----`; - }).join('\n'); + const knowledgeDescriptions = searchResults + .filter((p) => p.payload?.type === 'knowledge') + .map((p: any) => p.payload?.content) + .join('\n\n'); - // 5. OpenRouter Integration (gemini-3-flash-preview) - const openRouterKey = process.env.OPENROUTER_API_KEY; - if (!openRouterKey) { - return NextResponse.json({ error: 'Server configuration error' }, { status: 500 }); - } + contextStr = `KATALOG & PRODUKTE:\n${productDescriptions}\n\nKABELWISSEN (Handbuch):\n${knowledgeDescriptions}`; - const systemPrompt = `You are the KLZ Cables AI Search Assistant, an intelligent, helpful, and highly specialized assistant strictly for the KLZ Cables website. -Your primary goal is to help users find the correct industrial cables and products based ONLY on the context provided. -Follow these strict rules: -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. -3. Base your product answers strictly on the CONTEXT provided below. Do not hallucinate products. -4. Output your response as a valid JSON object matching this schema exactly, do not use Markdown codeblocks, output RAW JSON: -{ - "answerText": "A friendly description or answer based on the search.", - "products": [ - { "id": "Context Product ID", "title": "Product Title", "sku": "Product SKU", "slug": "slug" } - ] -} + foundProducts = searchResults + .filter((p) => (p.payload?.type === 'product' || !p.payload?.type) && p.payload?.data) + .map((p: any) => p.payload?.data); + } + } catch (e) { + console.error('Qdrant Search Error:', e); + Sentry.captureException(e, { tags: { context: 'ai-search-qdrant' } }); + // We can still proceed without context if Qdrant fails + } -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: -${contextText} +WICHTIGE REGELN: +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', { - 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: '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 openRouterKey = process.env.OPENROUTER_API_KEY; + if (!openRouterKey) { + throw new Error('OPENROUTER_API_KEY is not set'); } + + 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 }); + } } diff --git a/components/home/Hero.tsx b/components/home/Hero.tsx index 07324fd5..998c4a94 100644 --- a/components/home/Hero.tsx +++ b/components/home/Hero.tsx @@ -6,8 +6,9 @@ import { useTranslations, useLocale } from 'next-intl'; import dynamic from 'next/dynamic'; import { useAnalytics } from '../analytics/useAnalytics'; import { AnalyticsEvents } from '../analytics/analytics-events'; +import AIOrb from '../search/AIOrb'; import { useState } from 'react'; -import { Search, Sparkles } from 'lucide-react'; +import { ChevronRight } from 'lucide-react'; import { AISearchResults } from '../search/AISearchResults'; const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false }); @@ -76,24 +77,26 @@ export default function Hero({ data }: { data?: any }) {
- +
+ +
setSearchQuery(e.target.value)} - placeholder="Suchen Sie nach einem Kabel (z.B. N2XY, NYM-J)..." - className="flex-1 bg-transparent border-none text-white px-4 py-3 placeholder:text-white/60 focus:outline-none text-lg" + placeholder="Projekt beschreiben oder Kabel suchen..." + 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" /> @@ -103,7 +106,7 @@ export default function Hero({ data }: { data?: any }) { href="/contact" variant="white" 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={() => trackEvent(AnalyticsEvents.BUTTON_CLICK, { label: data?.ctaLabel || t('cta'), diff --git a/components/search/AIOrb.tsx b/components/search/AIOrb.tsx new file mode 100644 index 00000000..b5708b79 --- /dev/null +++ b/components/search/AIOrb.tsx @@ -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(null); + const materialRef = useRef(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 ( + + + + + + ); +} + +export default function AIOrb({ isThinking = false }: AIOrbProps) { + return ( +
+ {/* Ambient glow effect behind the orb */} +
+ + + + + + + + +
+ ); +} diff --git a/components/search/AISearchResults.tsx b/components/search/AISearchResults.tsx index 023a2d86..cbb1680b 100644 --- a/components/search/AISearchResults.tsx +++ b/components/search/AISearchResults.tsx @@ -1,230 +1,323 @@ 'use client'; import { useState, useRef, useEffect, KeyboardEvent } from 'react'; -import { useTranslations } from 'next-intl'; -import { Search, Loader2, X, Sparkles, ChevronRight, MessageSquareWarning } from 'lucide-react'; -import { Button, cn } from '@/components/ui'; +import { Search, X, Sparkles, ChevronRight, MessageSquareWarning } from 'lucide-react'; import Link from 'next/link'; import { useAnalytics } from '../analytics/useAnalytics'; 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 { - id: string; - title: string; - sku: string; - slug: string; + id: string; + title: string; + sku: string; + slug: string; } -interface AIResponse { - answerText: string; - products: ProductMatch[]; +interface Message { + role: 'user' | 'assistant'; + content: string; + products?: ProductMatch[]; } - interface ComponentProps { - isOpen: boolean; - onClose: () => void; - initialQuery?: string; - triggerSearch?: boolean; // If true, immediately searches on mount with initialQuery + isOpen: boolean; + onClose: () => void; + initialQuery?: string; + triggerSearch?: boolean; // If true, immediately searches on mount with initialQuery } -export function AISearchResults({ isOpen, onClose, initialQuery = '', triggerSearch = false }: ComponentProps) { - const t = useTranslations('Search'); - const { trackEvent } = useAnalytics(); +export function AISearchResults({ + isOpen, + onClose, + initialQuery = '', + triggerSearch = false, +}: ComponentProps) { + const { trackEvent } = useAnalytics(); - const [query, setQuery] = useState(initialQuery); - const [honeypot, setHoneypot] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const [response, setResponse] = useState(null); - const [error, setError] = useState(null); - const inputRef = useRef(null); - const modalRef = useRef(null); + const [query, setQuery] = useState(''); + const [messages, setMessages] = useState([]); + const [honeypot, setHoneypot] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + const modalRef = useRef(null); + const messagesEndRef = useRef(null); - useEffect(() => { - if (isOpen) { - document.body.style.overflow = 'hidden'; - // Slight delay to allow animation to start before focus - setTimeout(() => inputRef.current?.focus(), 100); + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + setTimeout(() => inputRef.current?.focus(), 100); - if (triggerSearch && initialQuery && !response) { - handleSearch(initialQuery); - } - } else { - document.body.style.overflow = 'unset'; - } - return () => { document.body.style.overflow = 'unset'; }; - }, [isOpen, triggerSearch]); - - useEffect(() => { + if (triggerSearch && initialQuery && messages.length === 0) { setQuery(initialQuery); - }, [initialQuery]); - - const handleSearch = async (searchQuery: string = query) => { - if (!searchQuery.trim()) return; - - setIsLoading(true); - setError(null); - setResponse(null); - - trackEvent(AnalyticsEvents.FORM_SUBMIT, { - type: 'ai_search', - query: searchQuery - }); - - 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); - } + handleSearch(initialQuery); + } else if (!triggerSearch) { + setQuery(''); + } + } else { + document.body.style.overflow = 'unset'; + setQuery(''); + setMessages([]); + setError(null); + setIsLoading(false); + } + return () => { + document.body.style.overflow = 'unset'; }; + }, [isOpen, triggerSearch]); - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSearch(); - } - if (e.key === 'Escape') { - onClose(); - } - }; + useEffect(() => { + if (isOpen && initialQuery && messages.length === 0) { + setQuery(initialQuery); + } + }, [initialQuery, isOpen]); - if (!isOpen) return null; + useEffect(() => { + // Auto-scroll to bottom of chat + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, isLoading]); - return ( -
-