feat(ai-search): optimize dev server, add qdrant boot sync, fix orb overflow
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m0s
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

This commit is contained in:
2026-03-06 22:35:48 +01:00
parent 81ce3a4588
commit 4dcdb717f0
16 changed files with 1981 additions and 380 deletions

View File

@@ -52,7 +52,7 @@ SENTRY_DSN=
# AI Agent (Payload CMS Agent via OpenRouter)
# ────────────────────────────────────────────────────────────────────────────
# Required for the Payload CMS AI Chat Agent
OPENROUTER_API_KEY=
MISTRAL_API_KEY=
# ────────────────────────────────────────────────────────────────────────────
# Payload Infrastructure (Dockerized)

1
.npmrc
View File

@@ -1,2 +1 @@
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${NPM_TOKEN}

View File

@@ -1,7 +1,7 @@
FROM node:20-alpine
# Install essential build tools if needed (e.g., for node-gyp)
RUN apk add --no-cache libc6-compat python3 make g++
RUN apk add --no-cache libc6-compat python3 make g++ curl
WORKDIR /app

View File

@@ -1,57 +1,84 @@
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { default as default_9ed509b5e5f7d08a16335393f27586cc } from '../../../src/payload/components/Icon'
import { default as default_5470ea90f7a8fd882c2fe59ff2b1c5b9 } from '../../../src/payload/components/Logo'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { default as default_9ed509b5e5f7d08a16335393f27586cc } from '../../../src/payload/components/Icon';
import { default as default_5470ea90f7a8fd882c2fe59ff2b1c5b9 } from '../../../src/payload/components/Logo';
import { ChatWindowProvider as ChatWindowProvider_d32a660df96f186e48bfc5b31626ccf5 } from '@mintel/payload-ai/components/ChatWindow';
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc';
export const importMap = {
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"/src/payload/components/Icon#default": default_9ed509b5e5f7d08a16335393f27586cc,
"/src/payload/components/Logo#default": default_5470ea90f7a8fd882c2fe59ff2b1c5b9,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell':
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalField':
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
'@payloadcms/richtext-lexical/rsc#LexicalDiffComponent':
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
'@payloadcms/richtext-lexical/client#BlocksFeatureClient':
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient':
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#UploadFeatureClient':
UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#BlockquoteFeatureClient':
BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#RelationshipFeatureClient':
RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#LinkFeatureClient':
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#ChecklistFeatureClient':
ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#OrderedListFeatureClient':
OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#UnorderedListFeatureClient':
UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#IndentFeatureClient':
IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#AlignFeatureClient':
AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#HeadingFeatureClient':
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#ParagraphFeatureClient':
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#InlineCodeFeatureClient':
InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#SuperscriptFeatureClient':
SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#SubscriptFeatureClient':
SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#StrikethroughFeatureClient':
StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#UnderlineFeatureClient':
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#BoldFeatureClient':
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#ItalicFeatureClient':
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'/src/payload/components/Icon#default': default_9ed509b5e5f7d08a16335393f27586cc,
'/src/payload/components/Logo#default': default_5470ea90f7a8fd882c2fe59ff2b1c5b9,
'@mintel/payload-ai/components/ChatWindow#ChatWindowProvider':
ChatWindowProvider_d32a660df96f186e48bfc5b31626ccf5,
'@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
};

View File

@@ -3,16 +3,33 @@ import { searchProducts } from '../../../src/lib/qdrant';
import redis from '../../../src/lib/redis';
import { z } from 'zod';
import * as Sentry from '@sentry/nextjs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60; // Max allowed duration (Vercel)
// Config and constants
const RATE_LIMIT_POINTS = 5; // 5 requests
const RATE_LIMIT_DURATION = 60 * 1; // per 1 minute
const RATE_LIMIT_POINTS = 20; // 20 requests per minute
const RATE_LIMIT_DURATION = 60; // 1 minute window
const DAILY_BUDGET_LIMIT = 200; // max 200 requests per IP per day
const DAILY_BUDGET_DURATION = 60 * 60 * 24; // 24h
const MAX_CONVERSATION_MESSAGES = 20; // max messages in context
const MAX_RESPONSE_TOKENS = 300; // cap AI response length — keeps it chat-like
// Removed requestSchema as it's replaced by direct parsing
export async function POST(req: NextRequest) {
// Changed req type to NextRequest
try {
const { messages, visitorId, honeypot } = await req.json();
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const { messages, honeypot } = body;
// Get client IP for rate limiting
const forwarded = req.headers.get('x-forwarded-for');
const clientIp = forwarded?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || 'unknown';
// 1. Basic Validation
if (!messages || !Array.isArray(messages) || messages.length === 0) {
@@ -38,35 +55,50 @@ export async function POST(req: NextRequest) {
});
}
// 3. Rate Limiting via Redis
// 3. Rate Limiting via Redis (IP-based)
try {
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
}
// Per-minute burst limit
const minuteKey = `ai_rate:${clientIp}:min`;
const minuteCount = await redis.incr(minuteKey);
if (minuteCount === 1) await redis.expire(minuteKey, RATE_LIMIT_DURATION);
if (requestCount > RATE_LIMIT_POINTS) {
// Use constant
return NextResponse.json(
{
error: 'Rate limit exceeded. Please try again later.',
},
{ status: 429 },
);
}
if (minuteCount > RATE_LIMIT_POINTS) {
return NextResponse.json(
{ error: 'Zu viele Anfragen. Bitte warte einen Moment.' },
{ status: 429 },
);
}
// Daily budget limit
const dayKey = `ai_rate:${clientIp}:day`;
const dayCount = await redis.incr(dayKey);
if (dayCount === 1) await redis.expire(dayKey, DAILY_BUDGET_DURATION);
if (dayCount > DAILY_BUDGET_LIMIT) {
return NextResponse.json(
{ error: 'Tägliches Limit erreicht. Bitte versuche es morgen erneut.' },
{ status: 429 },
);
}
} catch (redisError) {
// Renamed variable for clarity
console.error('Redis Rate Limiting Error:', redisError); // Changed to error for consistency
console.error('Redis Rate Limiting Error:', redisError);
Sentry.captureException(redisError, { tags: { context: 'ai-search-rate-limit' } });
// Fail open if Redis is down
}
// 4. Cap conversation length to limit token usage
const cappedMessages = messages.slice(-MAX_CONVERSATION_MESSAGES);
// 4. Fetch Context from Qdrant based on the latest message
let contextStr = '';
let foundProducts: any[] = [];
// Team context — hardcoded from translation data (no Payload collection for team)
const teamContextStr = `
Das ECHTE KLZ Team:
- Michael Bodemer (Geschäftsführer) — Der Macher, packt an wenn es kompliziert wird, kennt Kabelnetze in- und auswendig
- Klaus Mintel (Geschäftsführer) — Der Fels in der Brandung, jahrzehntelange Erfahrung, stabiles Netzwerk`;
try {
const searchResults = await searchProducts(latestMessage, 5);
@@ -85,50 +117,69 @@ export async function POST(req: NextRequest) {
foundProducts = searchResults
.filter((p) => (p.payload?.type === 'product' || !p.payload?.type) && p.payload?.data)
.map((p: any) => p.payload?.data);
.map((p: any) => ({
id: p.id as string,
title: p.payload?.data?.title as string,
sku: p.payload?.data?.sku as string,
slug: p.payload?.data?.slug as string,
}));
}
} catch (e) {
console.error('Qdrant Search Error:', e);
Sentry.captureException(e, { tags: { context: 'ai-search-qdrant' } });
} catch (searchError) {
console.error('Qdrant Search Error:', searchError);
Sentry.captureException(searchError, { tags: { context: 'ai-search-qdrant' } });
// We can still proceed without context if Qdrant fails
}
// 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.
const systemPrompt = `Du bist "Ohm" — der digitale KI-Berater von KLZ Cables. Dein Name ist eine Anspielung auf die Einheit des elektrischen Widerstands.
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.
STIL & PERSÖNLICHKEIT:
- Antworte KURZ, KNAPP und PROFESSIONELL (maximal 2-3 Sätze).
- Schreibe wie in einem lockeren, aber kompetenten B2B-Chat (Du-Form ist okay, aber fachlich top).
- Kein Markdown, nur Fließtext.
- NIEMALS Platzhalter wie [Ihr Name], [Name], [Firma] verwenden.
VERFÜGBARER KONTEXT:
${contextStr ? contextStr : 'Keine spezifischen Katalogdaten für diese Anfrage gefunden.'}
DEINE HAUPTAUFGABE: BERATEN, NICHT AUSFRAGEN!
- Wenn der Kunde ein Projekt nennt (z.B. "Windpark 30kV"), dann lies im KONTEXT nach, welche Kabel passen, und EMPFIEHL SIE DIREKT! (z.B. "Für 30kV Windparks nehmen wir meistens NA2XS(F)2Y.").
- Stelle NIEMALS mehr als EINE Rückfrage pro Nachricht.
- FRAGE NICHT nach abstrakten Dingen wie "Welchen Kabeltyp brauchst du?" -> DAS IST DEIN JOB, IHM DAS ZU SAGEN!
- FRAGE NICHT nach Längen oder genauen Trassen, es sei denn, der Kunde hat schon ganz klar gesagt, was er kaufen will.
- Biete aktiv Hilfe an: "Ich kann dir die passenden Querschnitte raussuchen, wenn du willst."
VORGEHEN:
1. Prüfe den KONTEXT auf passende Kabel für das Kundenprojekt.
2. Nenne direkt 1-2 passende Produktserien aus dem Kontext, die für diesen Fall Sinn machen.
3. Biete eine konkrete Hilfestellung an (z.B. Leitungsberechnung, Verfügbarkeitsprüfung) ODER stelle EINE einzige fachliche Rückfrage, um das Kabel weiter einzugrenzen (z.B. Alu oder Kupfer?).
4. Wenn das Projekt klar ist und die Kabeltypen besprochen sind, frag nach, ob ein Kollege (z.B. Micha) ein konkretes Angebot machen soll.
GRENZEN:
- PRIVAT-ANFRAGEN: B2B only. Private Hausinstallationen lehnen wir freundlich ab.
- Keine Preise oder genauen Lieferzeiten versprechen. Immer auf die menschlichen Kollegen verweisen für finale Angebote.
KONTEXT KABEL & TEAM:
${contextStr || 'Kein Katalogkontext verfügbar.'}
${teamContextStr}
`;
const openRouterKey = process.env.OPENROUTER_API_KEY;
if (!openRouterKey) {
throw new Error('OPENROUTER_API_KEY is not set');
const mistralKey = process.env.MISTRAL_API_KEY;
if (!mistralKey) {
throw new Error('MISTRAL_API_KEY is not set');
}
const fetchRes = await fetch('https://openrouter.ai/api/v1/chat/completions', {
// DSGVO: Mistral AI API direkt (EU/Frankreich) statt OpenRouter (US)
const fetchRes = await fetch('https://api.mistral.ai/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${openRouterKey}`,
Authorization: `Bearer ${mistralKey}`,
'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',
model: 'ministral-8b-latest',
temperature: 0.3,
max_tokens: MAX_RESPONSE_TOKENS,
messages: [
{ role: 'system', content: systemPrompt },
...messages.map((m: any) => ({
...cappedMessages.map((m: any) => ({
role: m.role,
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
})),
@@ -138,7 +189,19 @@ ${contextStr ? contextStr : 'Keine spezifischen Katalogdaten für diese Anfrage
if (!fetchRes.ok) {
const errBody = await fetchRes.text();
throw new Error(`OpenRouter API Error: ${errBody}`);
console.error('Mistral API Error:', errBody);
Sentry.captureException(new Error(`Mistral ${fetchRes.status}: ${errBody}`), {
tags: { context: 'ai-search-mistral' },
});
// Return user-friendly error based on status
const userMsg =
fetchRes.status === 429
? 'Der KI-Service ist gerade überlastet. Bitte versuche es in ein paar Sekunden erneut.'
: fetchRes.status >= 500
? 'Der KI-Service ist vorübergehend nicht erreichbar. Bitte versuche es gleich nochmal.'
: 'Es gab ein Problem mit der KI-Anfrage. Bitte versuche es erneut.';
return NextResponse.json({ error: userMsg }, { status: 502 });
}
const data = await fetchRes.json();
@@ -152,6 +215,9 @@ ${contextStr ? contextStr : 'Keine spezifischen Katalogdaten für diese Anfrage
} 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 });
return NextResponse.json(
{ error: 'Ein interner Fehler ist aufgetreten. Bitte versuche es erneut.' },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,126 @@
import { NextResponse } from 'next/server';
import { getPayload } from 'payload';
import configPromise from '../../../payload.config';
import { upsertProductVector } from '../../../src/lib/qdrant';
export const dynamic = 'force-dynamic';
export const maxDuration = 120;
/**
* Internal endpoint called by the warmup script on every dev boot.
* Syncs posts, pages, and products from Payload CMS into Qdrant.
* NOT for form entries, media, or users.
*/
export async function GET() {
const results = { products: 0, posts: 0, pages: 0, errors: [] as string[] };
try {
const payload = await getPayload({ config: configPromise });
// ── Products ──
const { docs: products } = await payload.find({
collection: 'products',
limit: 1000,
depth: 0,
where: { _status: { equals: 'published' } },
});
for (const product of products) {
try {
const contentText = `${product.title} - SKU: ${product.sku}\n${product.description || ''}`;
await upsertProductVector(String(product.id), contentText, {
type: 'product',
data: {
title: product.title,
sku: product.sku,
slug: product.slug,
description: product.description,
},
});
results.products++;
} catch (e: any) {
results.errors.push(`product:${product.sku}: ${e.message}`);
}
}
// ── Posts ──
const { docs: posts } = await payload.find({
collection: 'posts',
limit: 1000,
depth: 0,
where: { _status: { equals: 'published' } },
});
for (const post of posts) {
try {
const contentText = [
`Blog-Artikel: ${post.title}`,
post.excerpt ? `Zusammenfassung: ${post.excerpt}` : '',
post.category ? `Kategorie: ${post.category}` : '',
]
.filter(Boolean)
.join('\n');
await upsertProductVector(`post_${post.id}`, contentText, {
type: 'knowledge',
content: contentText,
data: {
title: post.title,
slug: post.slug,
},
});
results.posts++;
} catch (e: any) {
results.errors.push(`post:${post.slug}: ${e.message}`);
}
}
// ── Pages ──
const { docs: pages } = await payload.find({
collection: 'pages',
limit: 1000,
depth: 0,
where: { _status: { equals: 'published' } },
});
for (const page of pages) {
try {
const contentText = [
`Seite: ${page.title}`,
page.excerpt ? `Beschreibung: ${page.excerpt}` : '',
]
.filter(Boolean)
.join('\n');
await upsertProductVector(`page_${page.id}`, contentText, {
type: 'knowledge',
content: contentText,
data: {
title: page.title,
slug: page.slug,
},
});
results.pages++;
} catch (e: any) {
results.errors.push(`page:${page.slug}: ${e.message}`);
}
}
console.log(
`[Qdrant Sync] ✅ ${results.products} products, ${results.posts} posts, ${results.pages} pages synced`,
);
return NextResponse.json({
success: true,
synced: {
products: results.products,
posts: results.posts,
pages: results.pages,
},
errors: results.errors.length > 0 ? results.errors : undefined,
});
} catch (error: any) {
console.error('[Qdrant Sync] ❌ Fatal error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -6,11 +6,11 @@ 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 { useState, useEffect, useRef, useCallback } from 'react';
import { ChevronRight } from 'lucide-react';
import { AISearchResults } from '../search/AISearchResults';
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
const AIOrb = dynamic(() => import('../search/AIOrb'), { ssr: false });
export default function Hero({ data }: { data?: any }) {
const t = useTranslations('Home.hero');
@@ -19,6 +19,62 @@ export default function Hero({ data }: { data?: any }) {
const [searchQuery, setSearchQuery] = useState('');
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [heroPlaceholder, setHeroPlaceholder] = useState(
'Projekt beschreiben oder Kabel suchen...',
);
const typingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const HERO_PLACEHOLDERS = [
'Querschnittsberechnung für 110kV Trasse', // Hochspannung
'Wie schwer ist NAYY 4x150?',
'Ich plane einen Solarpark, was brauche ich?', // Projekt Solar
'Unterschied zwischen N2XSY und NAY2XSY?', // Fach
'Mittelspannungskabel für Windkraftanlage', // Windpark
'Welches Aluminiumkabel für 20kV?', // Mittelspannung
];
// Typing animation for the hero search placeholder
useEffect(() => {
if (searchQuery) {
setHeroPlaceholder('Projekt beschreiben oder Kabel suchen...');
return;
}
let textIdx = 0;
let charIdx = 0;
let deleting = false;
const tick = () => {
const fullText = HERO_PLACEHOLDERS[textIdx];
if (deleting) {
charIdx--;
setHeroPlaceholder(fullText.substring(0, charIdx));
} else {
charIdx++;
setHeroPlaceholder(fullText.substring(0, charIdx));
}
let delay = deleting ? 30 : 70;
if (!deleting && charIdx === fullText.length) {
delay = 2500;
deleting = true;
} else if (deleting && charIdx === 0) {
deleting = false;
textIdx = (textIdx + 1) % HERO_PLACEHOLDERS.length;
delay = 400;
}
typingRef.current = setTimeout(tick, delay);
};
typingRef.current = setTimeout(tick, 1500);
return () => {
if (typingRef.current) clearTimeout(typingRef.current);
};
}, [searchQuery]);
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -79,15 +135,16 @@ export default function Hero({ data }: { data?: any }) {
onSubmit={handleSearchSubmit}
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"
>
<div className="absolute left-2 w-12 h-12 flex items-center justify-center opacity-80 pointer-events-none">
<div className="absolute left-1 w-20 h-20 flex items-center justify-center z-10 overflow-visible">
<AIOrb isThinking={false} />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
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"
placeholder={heroPlaceholder}
className="flex-1 bg-transparent border-none text-white pl-20 pr-2 py-4 placeholder:text-white/50 focus:outline-none text-lg lg:text-xl"
autoFocus
/>
<Button
type="submit"

View File

@@ -74,11 +74,14 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
suppressHydrationWarning
className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md"
>
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
{new Date(post.frontmatter.date).toLocaleDateString(
locale?.length === 2 ? locale : 'de',
{
year: 'numeric',
month: 'short',
day: 'numeric',
},
)}
</time>
{(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && (

View File

@@ -1,88 +1,371 @@
/* 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';
import React, { useRef, useEffect, useCallback } from 'react';
interface AIOrbProps {
isThinking: boolean;
hasError?: 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;
}
});
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
// Simple noise function for organic movement
function noise(x: number, y: number, t: number): number {
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>
Math.sin(x * 1.3 + t * 0.7) * Math.cos(y * 0.9 + t * 0.5) * 0.5 +
Math.sin(x * 2.7 + y * 1.1 + t * 1.3) * 0.25 +
Math.cos(x * 0.8 - y * 2.3 + t * 0.9) * 0.25
);
}
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'}`}
/>
// ── Particle ───────────────────────────────────────────────────
interface Particle {
// Sphere position (target shape)
theta: number;
phi: number;
// Current position
x: number;
y: number;
z: number;
// Velocity
vx: number;
vy: number;
vz: number;
// Properties
size: number;
baseSize: number;
hue: number; // 0=blue, 1=green
brightness: number;
phase: number;
orbitSpeed: number;
noiseScale: number;
}
<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>
function createParticles(count: number): Particle[] {
const particles: Particle[] = [];
for (let i = 0; i < count; i++) {
// Fibonacci sphere distribution for even spacing
const golden = Math.PI * (3 - Math.sqrt(5));
const y = 1 - (i / (count - 1)) * 2;
const radiusAtY = Math.sqrt(1 - y * y);
const theta = golden * i;
const phi = Math.acos(y);
particles.push({
theta,
phi,
x: Math.cos(theta) * radiusAtY,
y,
z: Math.sin(theta) * radiusAtY,
vx: 0,
vy: 0,
vz: 0,
size: 0.4 + Math.random() * 0.8,
baseSize: 0.4 + Math.random() * 0.8,
hue: Math.random() > 0.45 ? 0 : 1,
brightness: 0.5 + Math.random() * 0.5,
phase: Math.random() * Math.PI * 2,
orbitSpeed: (0.1 + Math.random() * 0.4) * (Math.random() > 0.5 ? 1 : -1),
noiseScale: 0.5 + Math.random() * 1.5,
});
}
return particles;
}
export default function AIOrb({ isThinking = false, hasError = false }: AIOrbProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const animRef = useRef<number>(0);
const particlesRef = useRef<Particle[]>([]);
const mouse = useRef({ x: 0.5, y: 0.5, hover: false });
const state = useRef({
pulse: 0,
hover: 0,
error: 0,
mouseX: 0.5,
mouseY: 0.5,
rotY: 0,
rotX: 0,
breathe: 0,
scatter: 0,
shake: 0,
});
const onMove = useCallback((e: React.PointerEvent) => {
const r = wrapRef.current?.getBoundingClientRect();
if (!r) return;
mouse.current.x = (e.clientX - r.left) / r.width;
mouse.current.y = (e.clientY - r.top) / r.height;
}, []);
const onEnter = useCallback(() => {
mouse.current.hover = true;
}, []);
const onLeave = useCallback(() => {
mouse.current.hover = false;
mouse.current.x = 0.5;
mouse.current.y = 0.5;
}, []);
const draw = useCallback(
function drawStep() {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const w = rect.width * dpr;
const h = rect.height * dpr;
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
const cx = w / 2;
const cy = h / 2;
const minDim = Math.min(w, h);
// Reduced further to give maximum breathing room for glow + movement
const sphereR = minDim * 0.16;
const time = performance.now() / 1000;
const s = state.current;
const m = mouse.current;
// ── Interpolate state ──
s.pulse = lerp(s.pulse, isThinking ? 1 : 0, 0.03);
s.hover = lerp(s.hover, m.hover ? 1 : 0, 0.12);
s.error = lerp(s.error, hasError ? 1 : 0, 0.05);
s.mouseX = lerp(s.mouseX, m.x, 0.12);
s.mouseY = lerp(s.mouseY, m.y, 0.12);
s.scatter = lerp(s.scatter, m.hover ? 0.8 : hasError ? 0.5 : 0, 0.06);
s.shake += 0.15 * s.error;
// Global rotation — ALWAYS rotating + ALWAYS facing cursor
s.rotY += lerp(0.008, 0.04, Math.max(s.pulse, s.hover));
const mouseRotY = (s.mouseX - 0.5) * 1.2; // always face cursor
const mouseRotX = (s.mouseY - 0.5) * 0.8;
s.breathe += lerp(1.2, 3.0, s.pulse) / 60;
const breathe = Math.sin(s.breathe) * 0.5 + 0.5;
// ── Clear ──
ctx.clearRect(0, 0, w, h);
// ── Subtle core glow ──
const shakeX = Math.sin(s.shake * 17) * s.error * minDim * 0.02;
const glowCX = cx + shakeX;
const glowCY = cy;
// Clamp glow radius so it never exceeds ~48% of canvas (leaves padding for movement)
const glowR = Math.min(
sphereR * lerp(2.2, 4.0, Math.max(s.pulse, s.hover * 0.8)),
minDim * 0.48,
);
const glowA = lerp(0.1, 0.4, Math.max(s.pulse, s.hover * 0.7, s.error * 0.8));
const glow = ctx.createRadialGradient(glowCX, glowCY, 0, glowCX, glowCY, glowR);
// Glow color: blue normally, red on error
const glowR1 = Math.round(lerp(20, 255, s.error));
const glowG1 = Math.round(lerp(60, 40, s.error));
const glowB1 = Math.round(lerp(255, 40, s.error));
glow.addColorStop(0, `rgba(${glowR1}, ${glowG1}, ${glowB1}, ${glowA * 2})`);
glow.addColorStop(
0.25,
`rgba(${Math.round(lerp(80, 200, s.error))}, ${Math.round(lerp(140, 50, s.error))}, ${Math.round(lerp(255, 50, s.error))}, ${glowA * 1.2})`,
);
glow.addColorStop(0.6, `rgba(${glowR1}, ${glowG1}, ${glowB1}, ${glowA * 0.4})`);
glow.addColorStop(1, `rgba(${glowR1}, ${glowG1}, ${glowB1}, 0)`);
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(glowCX, glowCY, glowR, 0, Math.PI * 2);
ctx.fill();
// ── Create particles if empty ──
if (particlesRef.current.length === 0) {
particlesRef.current = createParticles(350);
}
// ── Update & draw particles ──
const cosRY = Math.cos(s.rotY + mouseRotY);
const sinRY = Math.sin(s.rotY + mouseRotY);
const cosRX = Math.cos(mouseRotX);
const sinRX = Math.sin(mouseRotX);
// Sort by z for correct layering
type ParticleWithScreen = { p: Particle; sx: number; sy: number; sz: number; depth: number };
const projected: ParticleWithScreen[] = [];
for (const p of particlesRef.current) {
// Target position: sphere surface + noise displacement
const n = noise(p.theta * p.noiseScale, p.phi * p.noiseScale, time * 0.5 + p.phase);
const displacement = 1 + n * lerp(0.12, 0.3, s.pulse);
// Orbit: rotate theta — always moving, faster idle
const activeTheta = p.theta + time * p.orbitSpeed * lerp(0.35, 0.8, s.pulse);
// Sphere coordinates to cartesian
const sinPhi = Math.sin(p.phi);
const tgtX = Math.cos(activeTheta) * sinPhi * displacement;
// Excitement from hover + pulse + error
const targetExcite = Math.max(s.hover * 0.9, s.pulse, s.error * 0.8);
const tgtY = Math.cos(p.phi) * displacement;
const tgtZ = Math.sin(activeTheta) * sinPhi * displacement;
// Scatter on hover: push particles outward
const scatterMul = 1 + s.scatter * (0.5 + n * 0.5);
// Spring physics toward target
const tx = tgtX * scatterMul;
const ty = tgtY * scatterMul;
const tz = tgtZ * scatterMul;
p.vx += (tx - p.x) * 0.08;
p.vy += (ty - p.y) * 0.08;
p.vz += (tz - p.z) * 0.08;
p.vx *= 0.88;
p.vy *= 0.88;
p.vz *= 0.88;
p.x += p.vx;
p.y += p.vy;
p.z += p.vz;
// 3D rotation (Y then X)
const rx = p.x * cosRY - p.z * sinRY;
const rz = p.x * sinRY + p.z * cosRY;
const ry = p.y * cosRX - rz * sinRX;
const finalZ = p.y * sinRX + rz * cosRX;
// Project to screen
const perspective = 3;
const scale = perspective / (perspective + finalZ);
const sx = cx + rx * sphereR * scale;
const sy = cy + ry * sphereR * scale;
projected.push({ p, sx, sy, sz: finalZ, depth: scale });
}
// Sort back-to-front
projected.sort((a, b) => a.sz - b.sz);
for (const { p, sx, sy, sz, depth } of projected) {
// Depth-based alpha and size
const depthAlpha = 0.25 + (sz + 1) * 0.375; // 0.25 (back) → 1.0 (front)
const twinkle = 0.75 + 0.25 * Math.sin(time * 3.5 + p.phase);
const alpha =
depthAlpha * twinkle * p.brightness * lerp(0.8, 1.3, Math.max(s.pulse, s.hover * 0.8));
const drawSize =
p.baseSize * depth * dpr * lerp(1.0, 2.0, Math.max(s.pulse, s.hover * 0.7));
// Color — shift to red on error
let r: number, g: number, b: number;
if (s.error > 0.1) {
// Error: red family
if (p.hue === 0) {
r = Math.round(lerp(40 + sz * 30, 255, s.error));
g = Math.round(lerp(80 + sz * 40, 40 + sz * 20, s.error));
b = Math.round(lerp(255, 40, s.error));
} else {
r = Math.round(lerp(100 + sz * 30, 230, s.error));
g = Math.round(lerp(220 + sz * 17, 60, s.error));
b = Math.round(lerp(20, 20, s.error));
}
} else if (p.hue === 0) {
r = 60 + Math.round(sz * 40);
g = 100 + Math.round(sz * 50);
b = 255;
} else {
r = 120 + Math.round(sz * 30);
g = 237 + Math.round(sz * 10);
b = 30;
}
// Thinking: shift toward brighter, more saturated
if (s.pulse > 0.1) {
r = Math.round(lerp(r, p.hue === 0 ? 100 : 130, s.pulse * 0.3));
g = Math.round(lerp(g, p.hue === 0 ? 140 : 237, s.pulse * 0.3));
b = Math.round(lerp(b, p.hue === 0 ? 255 : 32, s.pulse * 0.3));
}
// Micro glow — always visible, stronger on front
if (depthAlpha > 0.25) {
const gSize = drawSize * lerp(4, 7, s.hover);
const pg = ctx.createRadialGradient(sx, sy, 0, sx, sy, gSize);
pg.addColorStop(0, `rgba(${r},${g},${b},${alpha * 0.5})`);
pg.addColorStop(1, `rgba(${r},${g},${b},0)`);
ctx.fillStyle = pg;
ctx.beginPath();
ctx.arc(sx, sy, gSize, 0, Math.PI * 2);
ctx.fill();
}
// Core dot — bright
ctx.fillStyle = `rgba(${Math.min(r + 40, 255)},${Math.min(g + 30, 255)},${b},${Math.min(alpha * 1.6, 1)})`;
ctx.beginPath();
ctx.arc(sx, sy, Math.max(drawSize * 0.5, 0.3 * dpr), 0, Math.PI * 2);
ctx.fill();
}
// ── Loading rings (thinking) ──
if (s.pulse > 0.02) {
ctx.save();
ctx.translate(cx, cy);
// Spinning arc
const spinAngle = time * 2;
const arcLen = Math.PI * lerp(0.3, 1.0, (Math.sin(time * 1.5) + 1) / 2);
ctx.rotate(spinAngle);
ctx.strokeStyle = `rgba(130, 237, 32, ${s.pulse * 0.4})`;
ctx.lineWidth = 1.2 * dpr;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(0, 0, sphereR * 1.25, 0, arcLen);
ctx.stroke();
// Counter-spinning arc
ctx.rotate(-spinAngle * 2);
ctx.strokeStyle = `rgba(1, 29, 255, ${s.pulse * 0.3})`;
ctx.lineWidth = 0.8 * dpr;
ctx.beginPath();
ctx.arc(0, 0, sphereR * 1.35, 0, arcLen * 0.6);
ctx.stroke();
ctx.restore();
// Expanding pulse
const pulsePhase = (time * 0.8) % 1;
const pulseR = sphereR * (1 + pulsePhase * 1.5);
const pulseA = s.pulse * (1 - pulsePhase) * 0.15;
ctx.strokeStyle = `rgba(130, 237, 32, ${pulseA})`;
ctx.lineWidth = 1 * dpr;
ctx.beginPath();
ctx.arc(cx, cy, pulseR, 0, Math.PI * 2);
ctx.stroke();
}
animRef.current = requestAnimationFrame(drawStep);
},
[isThinking, hasError],
);
useEffect(() => {
animRef.current = requestAnimationFrame(draw);
return () => cancelAnimationFrame(animRef.current);
}, [draw]);
return (
<div
ref={wrapRef}
className="w-full h-full relative overflow-visible"
onPointerMove={onMove}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
style={{ cursor: 'pointer' }}
>
<canvas ref={canvasRef} className="w-full h-full block" style={{ imageRendering: 'auto' }} />
</div>
);
}

View File

@@ -1,13 +1,20 @@
'use client';
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
import { Search, X, Sparkles, ChevronRight, MessageSquareWarning } from 'lucide-react';
import { ArrowUp, X, Sparkles, ChevronRight, RotateCcw, Copy, Check } from 'lucide-react';
import Link from 'next/link';
import { useAnalytics } from '../analytics/useAnalytics';
import { AnalyticsEvents } from '../analytics/analytics-events';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import AIOrb from './AIOrb';
import dynamic from 'next/dynamic';
const AIOrb = dynamic(() => import('./AIOrb'), { ssr: false });
const LOADING_TEXTS = [
'Durchsuche das Kabelhandbuch... 📖',
'Frage den Senior-Ingenieur... 👴🔧',
'Frage ChatGPTs Cousin 2. Grades... 🤖',
];
interface ProductMatch {
id: string;
@@ -20,12 +27,13 @@ interface Message {
role: 'user' | 'assistant';
content: string;
products?: ProductMatch[];
timestamp: number;
}
interface ComponentProps {
isOpen: boolean;
onClose: () => void;
initialQuery?: string;
triggerSearch?: boolean; // If true, immediately searches on mount with initialQuery
triggerSearch?: boolean;
}
export function AISearchResults({
@@ -41,20 +49,41 @@ export function AISearchResults({
const [honeypot, setHoneypot] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [copiedAll, setCopiedAll] = useState(false);
const [loadingText, setLoadingText] = useState(LOADING_TEXTS[0]);
const inputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const loadingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const hasTriggeredRef = useRef(false);
// Dedicated focus effect — polls until the input actually has focus
useEffect(() => {
if (!isOpen) return;
let attempts = 0;
const focusTimer = setInterval(() => {
const el = inputRef.current;
if (el && document.activeElement !== el) {
el.focus({ preventScroll: true });
}
attempts++;
if (attempts >= 15 || document.activeElement === el) {
clearInterval(focusTimer);
}
}, 100);
return () => clearInterval(focusTimer);
}, [isOpen]);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
setTimeout(() => inputRef.current?.focus(), 100);
if (triggerSearch && initialQuery && messages.length === 0) {
setQuery(initialQuery);
// Trigger initial search only once
if (triggerSearch && initialQuery && !hasTriggeredRef.current) {
hasTriggeredRef.current = true;
handleSearch(initialQuery);
} else if (!triggerSearch) {
setQuery('');
}
} else {
document.body.style.overflow = 'unset';
@@ -62,6 +91,7 @@ export function AISearchResults({
setMessages([]);
setError(null);
setIsLoading(false);
hasTriggeredRef.current = false;
}
return () => {
document.body.style.overflow = 'unset';
@@ -69,46 +99,81 @@ export function AISearchResults({
}, [isOpen, triggerSearch]);
useEffect(() => {
if (isOpen && initialQuery && messages.length === 0) {
setQuery(initialQuery);
}
}, [initialQuery, isOpen]);
useEffect(() => {
// Auto-scroll to bottom of chat
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isLoading]);
// Global ESC handler
useEffect(() => {
if (!isOpen) return;
const handleEsc = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape') {
const activeElement = document.activeElement;
const isInputFocused = activeElement === inputRef.current;
if (query.trim()) {
// If there's text, clear it but keep chat open
setQuery('');
inputRef.current?.focus();
} else if (!isInputFocused) {
// If no text and input is not focused, focus it
inputRef.current?.focus();
} else {
// If no text and input IS focused, close the chat
onClose();
}
}
};
document.addEventListener('keydown', handleEsc);
return () => document.removeEventListener('keydown', handleEsc);
}, [isOpen, onClose, query]);
const handleSearch = async (searchQuery: string = query) => {
if (!searchQuery.trim() || isLoading) return;
const newUserMessage: Message = { role: 'user', content: searchQuery };
const newUserMessage: Message = { role: 'user', content: searchQuery, timestamp: Date.now() };
const newMessagesContext = [...messages, newUserMessage];
setMessages(newMessagesContext);
setQuery('');
setIsLoading(true);
setQuery(''); // Always clear input after send
setError(null);
// Give the user message animation 400ms to arrive before showing "thinking"
setTimeout(() => {
setIsLoading(true);
// Start rotating loading texts
let textIdx = Math.floor(Math.random() * LOADING_TEXTS.length);
setLoadingText(LOADING_TEXTS[textIdx]);
loadingIntervalRef.current = setInterval(() => {
textIdx = (textIdx + 1) % LOADING_TEXTS.length;
setLoadingText(LOADING_TEXTS[textIdx]);
}, 2500);
}, 400);
trackEvent(AnalyticsEvents.FORM_SUBMIT, {
type: 'ai_search',
type: 'ai_chat',
query: searchQuery,
});
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 60000);
const res = await fetch('/api/ai-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
messages: newMessagesContext,
_honeypot: honeypot,
}),
});
const data = await res.json();
clearTimeout(timeout);
if (!res.ok) {
throw new Error(data.error || 'Failed to fetch search results');
const data = await res.json().catch(() => null);
if (!res.ok || !data) {
throw new Error(data?.error || `Server antwortete mit Status ${res.status}`);
}
setMessages((prev) => [
@@ -117,21 +182,41 @@ export function AISearchResults({
role: 'assistant',
content: data.answerText,
products: data.products,
timestamp: Date.now(),
},
]);
// Re-focus input after response so user can continue typing easily
setTimeout(() => inputRef.current?.focus(), 100);
} catch (err: any) {
console.error(err);
setError(err.message || 'An error occurred while chatting. Please try again.');
const msg =
err.name === 'AbortError'
? 'Anfrage hat zu lange gedauert. Bitte versuche es erneut.'
: err.message || 'Ein Fehler ist aufgetreten.';
// Show error as a system message in the chat instead of a separate error banner
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: `⚠️ ${msg}`,
timestamp: Date.now(),
},
]);
trackEvent(AnalyticsEvents.ERROR, {
location: 'ai_search_results',
location: 'ai_chat',
message: err.message,
query: searchQuery,
});
} finally {
setIsLoading(false);
if (loadingIntervalRef.current) {
clearInterval(loadingIntervalRef.current);
loadingIntervalRef.current = null;
}
// Always re-focus the input
setTimeout(() => inputRef.current?.focus(), 50);
}
};
@@ -140,14 +225,36 @@ export function AISearchResults({
e.preventDefault();
handleSearch();
}
if (e.key === 'Escape') {
onClose();
if (e.key === 'ArrowUp' && !query) {
// Find the last user message and put it into the input
const lastUserNav = [...messages].reverse().find((m) => m.role === 'user');
if (lastUserNav) {
e.preventDefault();
setQuery(lastUserNav.content);
}
}
};
const handleCopy = (content: string, index?: number) => {
navigator.clipboard.writeText(content);
if (index !== undefined) {
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
} else {
setCopiedAll(true);
setTimeout(() => setCopiedAll(false), 2000);
}
};
const handleCopyChat = () => {
const fullChat = messages
.map((m) => `${m.role === 'user' ? 'Du' : 'Ohm'}:\n${m.content}`)
.join('\n\n');
handleCopy(fullChat);
};
if (!isOpen) return null;
// Handle clicking outside to close
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
@@ -156,168 +263,384 @@ export function AISearchResults({
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"
className="fixed inset-0 z-[100] flex items-start justify-center pt-6 md:pt-12 px-4"
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
style={{ animation: 'chatBackdropIn 0.4s ease-out forwards' }}
>
{/* Animated backdrop */}
<div
className="absolute inset-0 bg-[#000a18]/90 backdrop-blur-2xl"
style={{ animation: 'chatFadeIn 0.3s ease-out' }}
/>
<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"
className="relative w-full max-w-3xl flex flex-col"
style={{
height: 'min(90vh, 900px)',
animation: 'chatSlideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards',
}}
>
{/* 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>
{/* 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>
{/* ── Glassmorphism container ── */}
<div className="flex flex-col h-full rounded-3xl overflow-hidden border border-white/[0.08] bg-gradient-to-b from-white/[0.06] to-white/[0.02] shadow-[0_32px_64px_-12px_rgba(0,0,0,0.6)]">
{/* ── Header ── */}
<div className="flex items-center justify-between px-5 py-4 border-b border-white/[0.06]">
<div className="flex items-center gap-3">
<div className="w-8 h-8 overflow-hidden rounded-full">
<AIOrb isThinking={isLoading} hasError={!!error} />
</div>
<div>
<h2 className="text-white font-bold text-sm tracking-wide">Ohm</h2>
<p className="text-[10px] text-white/30 font-medium tracking-wider uppercase">
{isLoading ? 'Denkt nach...' : error ? 'Fehler aufgetreten' : 'Online'}
</p>
</div>
</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>
<div className="flex items-center gap-2">
{messages.length > 0 && (
<button
onClick={handleCopyChat}
className="flex items-center gap-1.5 text-[10px] font-bold text-white/40 hover:text-white/80 transition-all duration-200 hover:bg-white/5 rounded-full px-3 py-1.5 cursor-pointer uppercase tracking-wider"
title="gesamten Chat kopieren"
>
{copiedAll ? (
<Check className="w-3.5 h-3.5 text-accent" />
) : (
<p className="whitespace-pre-wrap">{msg.content}</p>
<Copy className="w-3.5 h-3.5" />
)}
<span>{copiedAll ? 'Kopiert' : 'Chat kopieren'}</span>
</button>
)}
<button
onClick={onClose}
className="text-white/30 hover:text-white/80 transition-all duration-200 hover:bg-white/5 rounded-xl p-2 cursor-pointer"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* ── Chat Area ── */}
<div className="flex-1 overflow-y-auto px-5 py-6 space-y-5 scroll-smooth chat-scrollbar">
{/* Empty state */}
{messages.length === 0 && !isLoading && !error && (
<div
className="flex flex-col items-center justify-center h-full text-center space-y-5"
style={{ animation: 'chatFadeIn 0.6s ease-out 0.3s both' }}
>
<div className="w-24 h-24 mb-2">
<AIOrb isThinking={false} hasError={false} />
</div>
<div>
<p className="text-xl md:text-2xl font-bold text-white/80">
Wie kann ich helfen?
</p>
<p className="text-sm text-white/30 mt-2 max-w-md">
Beschreibe dein Projekt, frag nach bestimmten Kabeln, oder nenne mir deine
Anforderungen.
</p>
</div>
{/* Quick prompts */}
<div className="flex flex-wrap justify-center gap-2 mt-4">
{['Windpark 33kV Verkabelung', 'NYCWY 4x185', 'Erdkabel für Solarpark'].map(
(prompt) => (
<button
key={prompt}
onClick={() => handleSearch(prompt)}
className="text-xs text-white/40 hover:text-white/80 border border-white/10 hover:border-white/20 hover:bg-white/5 rounded-full px-4 py-2 transition-all duration-200 cursor-pointer"
>
{prompt}
</button>
),
)}
</div>
</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',
});
{/* Messages */}
{messages.map((msg, index) => (
<div
key={index}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
style={{
animation: `chatMessageIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) ${index * 0.05}s both`,
}}
>
<div
className={`relative group max-w-[85%] rounded-2xl px-5 py-4 ${
msg.role === 'user'
? 'bg-accent text-primary font-semibold rounded-br-lg'
: 'bg-white/[0.05] border border-white/[0.06] text-white/90 rounded-bl-lg'
}`}
>
{/* Copy Button */}
<button
onClick={() => handleCopy(msg.content, index)}
className={`absolute opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-lg cursor-pointer ${
msg.role === 'user'
? 'top-2 right-2 bg-primary/10 hover:bg-primary/20 text-primary/60 hover:text-primary'
: 'top-2 right-2 bg-white/5 hover:bg-white/10 text-white/40 hover:text-white'
}`}
title="Nachricht kopieren"
>
{copiedIndex === index ? (
<Check className="w-3.5 h-3.5" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
{msg.role === 'assistant' && (
<div className="flex items-center gap-2 mb-2">
<Sparkles className="w-3 h-3 text-accent/60" />
<span className="text-[10px] font-bold tracking-widest uppercase text-accent/50">
Ohm
</span>
</div>
)}
<div
className={`text-sm md:text-[15px] leading-relaxed ${
msg.role === 'assistant'
? 'prose prose-invert prose-sm prose-p:leading-relaxed prose-a:text-accent prose-strong:text-accent/90 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>
{/* Timestamp */}
{!msg.products?.length && (
<p
className={`text-[9px] mt-2 font-medium tracking-wide ${msg.role === 'user' ? 'text-primary/40' : 'text-white/20'}`}
>
{new Date(msg.timestamp).toLocaleTimeString('de', {
hour: '2-digit',
minute: '2-digit',
})}
</p>
)}
{/* Product cards */}
{msg.role === 'assistant' && msg.products && msg.products.length > 0 && (
<div className="mt-4 space-y-2 border-t border-white/[0.06] pt-4">
<h4 className="text-[10px] font-bold tracking-widest uppercase text-white/30 mb-2">
Empfohlene Produkte
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{msg.products.map((product, idx) => (
<Link
key={idx}
href={`/produkte/${product.slug}`}
onClick={() => {
onClose();
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
target: product.slug,
location: 'ai_chat',
});
}}
className="group flex items-center justify-between bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.06] hover:border-accent/30 rounded-xl px-4 py-3 transition-all duration-300"
style={{ animation: `chatFadeIn 0.3s ease-out ${idx * 0.1}s both` }}
>
<div className="min-w-0">
<p className="text-[9px] font-bold text-white/25 tracking-wider">
{product.sku}
</p>
<h5 className="text-xs font-bold text-white/70 group-hover:text-accent truncate transition-colors">
{product.title}
</h5>
</div>
<ChevronRight className="w-3.5 h-3.5 text-white/20 group-hover:text-accent shrink-0 ml-3 group-hover:translate-x-0.5 transition-all" />
</Link>
))}
</div>
</div>
)}
</div>
</div>
))}
{/* Loading indicator */}
{isLoading && (
<div
className="flex justify-start"
style={{ animation: 'chatMessageIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards' }}
>
<div className="flex items-center gap-4 bg-white/[0.03] border border-white/[0.06] rounded-2xl rounded-bl-lg px-5 py-4">
<div className="w-10 h-10 shrink-0">
<AIOrb isThinking={true} hasError={false} />
</div>
<div>
<p
className="text-sm text-white/50 font-medium"
style={{ animation: 'chatTextSwap 0.4s ease-out' }}
key={loadingText}
>
{loadingText}
</p>
<div className="flex gap-1 mt-2">
{[0, 1, 2].map((i) => (
<div
key={i}
className="w-1.5 h-1.5 rounded-full bg-accent/40"
style={{
animation: 'chatDotBounce 1.2s ease-in-out infinite',
animationDelay: `${i * 0.15}s`,
}}
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>
</div>
))}
)}
{isLoading && (
<div className="flex justify-start">
<div className="bg-transparent rounded-2xl p-2 w-24 flex justify-center">
<AIOrb isThinking={true} />
{/* Error */}
{error && (
<div className="flex justify-start" style={{ animation: 'chatShake 0.5s ease-out' }}>
<div className="flex items-center gap-4 bg-red-500/[0.06] border border-red-500/20 rounded-2xl rounded-bl-lg px-5 py-4">
<div className="w-10 h-10 shrink-0">
<AIOrb isThinking={false} hasError={true} />
</div>
<div>
<h3 className="text-sm font-bold text-red-300">Da ist was schiefgelaufen 😬</h3>
<p className="text-xs text-red-300/60 mt-1">{error}</p>
<button
onClick={() => {
setError(null);
inputRef.current?.focus();
}}
className="flex items-center gap-1.5 text-[10px] font-bold text-red-300/50 hover:text-red-300 mt-2 transition-colors cursor-pointer"
>
<RotateCcw className="w-3 h-3" />
Nochmal versuchen
</button>
</div>
</div>
</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 ref={messagesEndRef} />
</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>
{/* ── Input Area ── */}
<div className="px-5 pb-5 pt-3 border-t border-white/[0.04]">
<div
className={`relative flex items-center rounded-2xl transition-all duration-300 ${
query.trim()
? 'bg-white/[0.08] border border-accent/30 shadow-[0_0_20px_-4px_rgba(130,237,32,0.1)]'
: 'bg-white/[0.04] border border-white/[0.06]'
}`}
>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Nachricht eingeben..."
className="flex-1 bg-transparent border-none text-white text-sm md:text-base px-5 py-4 focus:outline-none placeholder:text-white/20"
disabled={isLoading}
tabIndex={1}
autoFocus
/>
<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={`mr-2 w-9 h-9 rounded-xl flex items-center justify-center shrink-0 transition-all duration-300 cursor-pointer ${
query.trim()
? 'bg-accent text-primary shadow-lg shadow-accent/20 hover:shadow-accent/40 hover:scale-105 active:scale-95'
: 'bg-white/5 text-white/20'
}`}
aria-label="Nachricht senden"
>
<ArrowUp className="w-4 h-4" strokeWidth={2.5} />
</button>
</div>
<div className="flex items-center justify-center gap-3 mt-2.5">
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-white/15">
Enter zum Senden · Esc zum Schließen
</span>
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-white/15">
·
</span>
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-accent/40 flex items-center gap-1">
🛡 DSGVO-konform · EU-Server
</span>
</div>
</div>
</div>
</div>
{/* ── Keyframe animations ── */}
<style>{`
@keyframes chatBackdropIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes chatFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes chatSlideUp {
from { opacity: 0; transform: translateY(40px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes chatMessageIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes chatDotBounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.3; }
40% { transform: scale(1); opacity: 1; }
}
@keyframes chatTextSwap {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes chatShake {
0%, 100% { transform: translateX(0); }
15% { transform: translateX(-6px); }
30% { transform: translateX(5px); }
45% { transform: translateX(-4px); }
60% { transform: translateX(3px); }
75% { transform: translateX(-1px); }
}
/* Custom scrollbar */
.chat-scrollbar::-webkit-scrollbar {
width: 4px;
}
.chat-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.chat-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: 4px;
}
.chat-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.16);
}
.chat-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
}
`}</style>
</div>
);
}

View File

@@ -8,7 +8,7 @@ services:
- infra
labels:
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
- "caddy.reverse_proxy=host.docker.internal:3100"
- "caddy.reverse_proxy=http://klz-app:3000"
# Full Docker dev (use with `pnpm run dev:docker`)
klz-app:
@@ -26,13 +26,20 @@ services:
- ${ENV_FILE:-.env}
environment:
NODE_ENV: development
# Force Garbage Collection before Docker kills the container (OOM)
NODE_OPTIONS: "--max-old-space-size=6144"
NEXT_TELEMETRY_DISABLED: "1"
POSTGRES_URI: postgres://${PAYLOAD_DB_USER:-payload}:${PAYLOAD_DB_PASSWORD:-120in09oenaoinsd9iaidon}@klz-db:5432/${PAYLOAD_DB_NAME:-payload}
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-dev}
NODE_OPTIONS: "--max-old-space-size=8192"
UV_THREADPOOL_SIZE: "4"
UV_THREADPOOL_SIZE: "1"
RAYON_NUM_THREADS: "1"
NEXT_PRIVATE_WORKER_THREADS: "false"
NPM_TOKEN: ${NPM_TOKEN:-}
CI: "true"
QDRANT_URL: "http://klz-qdrant:6333"
REDIS_URL: "redis://klz-redis:6379"
MISTRAL_API_KEY: ${MISTRAL_API_KEY:-}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
volumes:
- .:/app
- klz_node_modules:/app/node_modules
@@ -42,19 +49,34 @@ services:
- /app/.git
- /app/reference
- /app/data
deploy:
resources:
limits:
cpus: '4'
memory: 8G
command: >
sh -c "pnpm install --no-frozen-lockfile && pnpm next dev --webpack --hostname 0.0.0.0"
sh -c "pnpm install --no-frozen-lockfile &&
while true; do
(
echo '[warmup] Waiting for Next.js to be reachable...'
until curl -sf http://localhost:3000 > /dev/null; do sleep 2; done
echo '[warmup] Server is up! Pre-compiling routes...'
curl -sf http://localhost:3000/de > /dev/null 2>&1 && echo '[warmup] /de ready'
curl -sf http://localhost:3000/api/health/cms > /dev/null 2>&1 && echo '[warmup] /api/health/cms ready'
curl -sf -X POST -H 'Content-Type: application/json' -d '{\"messages\":[{\"role\":\"user\",\"content\":\"warmup\"}]}' http://localhost:3000/api/ai-search > /dev/null 2>&1 && echo '[warmup] /api/ai-search ready'
echo '[warmup] Syncing CMS data to Qdrant...'
SYNC_RESULT=$(curl -sf http://localhost:3000/api/sync-qdrant 2>&1)
echo \"[warmup] Qdrant sync: $SYNC_RESULT\"
echo '[warmup] All routes pre-compiled + Qdrant synced ✓'
) &
pnpm next dev --webpack --hostname 0.0.0.0;
echo '[klz-app] next dev exited, restarting in 2s...';
sleep 2;
done"
labels:
- "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
- "traefik.docker.network=infra"
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
- "caddy.reverse_proxy={{upstreams 3000}}"
klz-db:
image: postgres:15-alpine
@@ -81,7 +103,7 @@ services:
networks:
- default
ports:
- "6379:6379"
- "16379:6379"
klz-qdrant:
image: qdrant/qdrant:v1.13.2
@@ -91,7 +113,7 @@ services:
networks:
- default
ports:
- "6333:6333"
- "16333:6333"
networks:
default:

View File

@@ -7,18 +7,28 @@ import { withPayload } from '@payloadcms/next/withPayload';
const isProd = process.env.NODE_ENV === 'production';
/** @type {import('next').NextConfig} */
const nextConfig = {
serverExternalPackages: ['@mintel/payload-ai'],
transpilePackages: ['react-image-crop', '@react-three/fiber'],
onDemandEntries: {
// Make sure entries are not disposed too quickly
maxInactiveAge: 60 * 1000,
// Keep compiled pages/routes in memory for 5 minutes (reduced from 25m to prevent OOM)
maxInactiveAge: 5 * 60 * 1000,
// Keep up to 2 pages in the dev buffer (reduced from 10 to prevent OOM)
pagesBufferLength: 2,
},
experimental: {
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
cpus: 3,
optimizePackageImports: [
'lucide-react',
'framer-motion',
'@/components/ui',
'@sentry/nextjs',
'@payloadcms/richtext-lexical',
'react-hook-form',
'zod',
'date-fns',
],
workerThreads: false,
memoryBasedWorkersCount: true,
},
reactStrictMode: false,
swcMinify: true,
productionBrowserSourceMaps: false,
logging: {
fetches: {
@@ -26,6 +36,21 @@ const nextConfig = {
},
},
...(isProd ? { output: 'standalone' } : {}),
// Prevent webpack from restarting when .env files are touched via Docker volume mount
webpack: (config, { dev }) => {
if (dev) {
config.watchOptions = {
...config.watchOptions,
ignored: /node_modules|\.env/,
// Reduce poll frequency to lower CPU churn from VirtioFS
poll: 1000,
aggregateTimeout: 300,
};
// Reduce source map quality in dev for faster rebuilds
config.devtool = 'eval';
}
return config;
},
async headers() {
const isProd = process.env.NODE_ENV === 'production';
const umamiDomain = new URL(process.env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me').origin;
@@ -48,7 +73,7 @@ const nextConfig = {
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: blob: ${extraImgDomains};
connect-src 'self' ${umamiDomain} ${glitchtipDomain};
connect-src 'self' ${umamiDomain} ${glitchtipDomain} https://raw.githack.com https://raw.githubusercontent.com;
frame-src 'self';
object-src 'none';
base-uri 'self';
@@ -394,6 +419,7 @@ const nextConfig = {
];
},
images: {
qualities: [25, 50, 75, 100],
formats: ['image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
remotePatterns: [

View File

@@ -105,8 +105,8 @@
"vitest": "^4.0.16"
},
"scripts": {
"dev": "bash -c '[ -f .env ] || (cp .env.example .env && sed -i.bak \"s/TRAEFIK_HOST=klz-cables.com/TRAEFIK_HOST=klz.localhost/\" .env && rm -f .env.bak && echo \"✅ Created .env from .env.example\"); trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up klz-app klz-db klz-proxy --remove-orphans'",
"dev:local": "bash -c 'trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up -d klz-db klz-proxy && POSTGRES_URI=NODE_ENV=development next dev --webpack --port 3100 --hostname 0.0.0.0'",
"dev": "bash -c '[ -f .env ] || (cp .env.example .env && sed -i.bak \"s/TRAEFIK_HOST=klz-cables.com/TRAEFIK_HOST=klz.localhost/\" .env && rm -f .env.bak && echo \"✅ Created .env from .env.example\"); trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up klz-app klz-db klz-proxy klz-qdrant klz-redis --remove-orphans'",
"dev:local": "bash -c 'trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up -d klz-db klz-proxy klz-qdrant klz-redis && POSTGRES_URI=NODE_ENV=development next dev --webpack --port 3100 --hostname 0.0.0.0'",
"dev:infra": "COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up -d klz-db klz-proxy",
"build": "next build",
"start": "next start",

View File

@@ -22,7 +22,13 @@ import { FormSubmissions } from './src/payload/collections/FormSubmissions';
import { Products } from './src/payload/collections/Products';
import { Pages } from './src/payload/collections/Pages';
import { seedDatabase } from './src/payload/seed';
import { payloadChatPlugin } from '@mintel/payload-ai';
const isMigrate = process.argv.includes('migrate');
let chatPlugin: any = null;
if (!isMigrate) {
const mod = await import('@mintel/payload-ai');
chatPlugin = mod.payloadChatPlugin;
}
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
@@ -100,9 +106,13 @@ export default buildConfig({
: undefined,
sharp,
plugins: [
payloadChatPlugin({
enabled: true,
mcpServers: [],
}),
...(chatPlugin
? [
chatPlugin({
enabled: true,
mcpServers: [{ name: 'klz-qdrant-mcp' }],
}),
]
: []),
],
});

585
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import { QdrantClient } from '@qdrant/js-client-rest';
import redis from './redis';
const isDockerContainer =
process.env.IS_DOCKER === 'true' || process.env.HOSTNAME?.includes('klz-app');
@@ -10,15 +11,26 @@ const qdrantApiKey = process.env.QDRANT_API_KEY || '';
export const qdrant = new QdrantClient({
url: qdrantUrl,
apiKey: qdrantApiKey || undefined,
// Disable qdrant client's own version check to avoid the warning spam
checkCompatibility: false,
});
export const COLLECTION_NAME = 'klz_products';
export const VECTOR_SIZE = 1536; // OpenAI text-embedding-3-small
// Cache TTLs
const EMBEDDING_CACHE_TTL = 60 * 60 * 24; // 24h — embeddings are deterministic
const SEARCH_CACHE_TTL = 60 * 30; // 30 min — product data could change
// Track collection existence in-memory (don't re-check every request)
let collectionVerified = false;
/**
* Ensure the collection exists in Qdrant.
* Ensure the collection exists in Qdrant (only checks once per process lifetime).
*/
export async function ensureCollection() {
if (collectionVerified) return;
try {
const collections = await qdrant.getCollections();
const exists = collections.collections.some((c) => c.name === COLLECTION_NAME);
@@ -31,15 +43,47 @@ export async function ensureCollection() {
});
console.log(`Successfully created Qdrant collection: ${COLLECTION_NAME}`);
}
collectionVerified = true;
} catch (error) {
console.error('Error ensuring Qdrant collection:', error);
}
}
/**
* Generate an embedding for a given text using OpenRouter (OpenAI embedding proxy)
* Simple hash for cache keys
*/
function hashKey(text: string): string {
let hash = 0;
for (let i = 0; i < text.length; i++) {
const chr = text.charCodeAt(i);
hash = (hash << 5) - hash + chr;
hash |= 0;
}
return hash.toString(36);
}
/**
* Generate an embedding for a given text using OpenRouter (OpenAI embedding proxy).
* Results are cached in Redis for 24h since embeddings are deterministic.
*
* NOTE: We keep OpenRouter for embeddings because the Qdrant collection uses 1536-dim
* vectors (OpenAI text-embedding-3-small). Switching to Mistral embed (1024-dim) would
* require re-indexing the entire product catalog.
* User-facing chat uses Mistral AI directly for DSGVO compliance.
*/
export async function generateEmbedding(text: string): Promise<number[]> {
const cacheKey = `emb:${hashKey(text.toLowerCase().trim())}`;
// Try Redis cache first
try {
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
} catch {
// Redis down — proceed without cache
}
const openRouterKey = process.env.OPENROUTER_API_KEY;
if (!openRouterKey) {
throw new Error('OPENROUTER_API_KEY is not set');
@@ -67,7 +111,16 @@ export async function generateEmbedding(text: string): Promise<number[]> {
}
const data = await response.json();
return data.data[0].embedding;
const embedding = data.data[0].embedding;
// Cache the embedding in Redis
try {
await redis.set(cacheKey, JSON.stringify(embedding), 'EX', EMBEDDING_CACHE_TTL);
} catch {
// Redis down — proceed without caching
}
return embedding;
}
/**
@@ -113,9 +166,23 @@ export async function deleteProductVector(id: string | number) {
}
/**
* Search products in Qdrant
* Search products in Qdrant.
* Results are cached in Redis for 30 minutes keyed by query text.
*/
export async function searchProducts(query: string, limit = 5) {
const cacheKey = `search:${hashKey(query.toLowerCase().trim())}:${limit}`;
// Try Redis cache first
try {
const cached = await redis.get(cacheKey);
if (cached) {
console.log(`[Qdrant] Cache HIT for query: "${query.substring(0, 50)}"`);
return JSON.parse(cached);
}
} catch {
// Redis down — proceed without cache
}
try {
await ensureCollection();
const vector = await generateEmbedding(query);
@@ -126,6 +193,13 @@ export async function searchProducts(query: string, limit = 5) {
with_payload: true,
});
// Cache results in Redis
try {
await redis.set(cacheKey, JSON.stringify(results), 'EX', SEARCH_CACHE_TTL);
} catch {
// Redis down — proceed without caching
}
return results;
} catch (error) {
console.error('Error searching in Qdrant:', error);