diff --git a/apps/web/app/(site)/contact/page.tsx b/apps/web/app/(site)/contact/page.tsx index 4d2d164..9c40023 100644 --- a/apps/web/app/(site)/contact/page.tsx +++ b/apps/web/app/(site)/contact/page.tsx @@ -1,5 +1,5 @@ import { Section } from "@/src/components/Section"; -import { ContactForm } from "@/src/components/ContactForm"; +import { AgentChat } from "@/src/components/agent/AgentChat"; import { AbstractCircuit } from "@/src/components/Effects"; export default function ContactPage() { @@ -12,9 +12,10 @@ export default function ContactPage() { effects={<>} className="pt-24 pb-12 md:pt-32 md:pb-20" > - {/* Full-width Form */} - + {/* AI Agent Chat */} + ); } + diff --git a/apps/web/app/api/agent-chat/route.ts b/apps/web/app/api/agent-chat/route.ts new file mode 100644 index 0000000..ed1ba10 --- /dev/null +++ b/apps/web/app/api/agent-chat/route.ts @@ -0,0 +1,381 @@ +import { NextResponse, NextRequest } from 'next/server'; +import redis from '../../../src/lib/redis'; +import * as Sentry from '@sentry/nextjs'; +import { + PRICING, + initialState, + PAGE_SAMPLES, + FEATURE_OPTIONS, + FUNCTION_OPTIONS, + API_OPTIONS, + ASSET_OPTIONS, + DESIGN_OPTIONS, + EMPLOYEE_OPTIONS, + DEADLINE_LABELS, +} from '../../../src/logic/pricing/constants'; + +// Rate limiting +const RATE_LIMIT_POINTS = 10; +const RATE_LIMIT_DURATION = 60; + +// Tool definitions for Mistral +const TOOLS = [ + { + type: 'function' as const, + function: { + name: 'update_company_info', + description: 'Aktualisiert Firmen-/Kontaktinformationen des Kunden. Nutze dieses Tool wenn der Nutzer seinen Namen, seine Firma oder Mitarbeiterzahl nennt.', + parameters: { + type: 'object', + properties: { + companyName: { type: 'string', description: 'Firmenname' }, + name: { type: 'string', description: 'Name des Ansprechpartners' }, + employeeCount: { + type: 'string', + enum: EMPLOYEE_OPTIONS.map((e) => e.id), + description: 'Mitarbeiterzahl', + }, + existingWebsite: { type: 'string', description: 'URL der bestehenden Website' }, + }, + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'update_project_type', + description: 'Setzt den Projekttyp. Nutze dieses Tool wenn klar wird ob es eine Website oder Web-App wird.', + parameters: { + type: 'object', + properties: { + projectType: { + type: 'string', + enum: ['website', 'web-app'], + description: 'Art des Projekts', + }, + }, + required: ['projectType'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'show_page_selector', + description: 'Zeigt dem Nutzer eine interaktive Auswahl der verfügbaren Seiten-Typen. Nutze dieses Tool wenn über die Struktur/Seiten der Website gesprochen wird.', + parameters: { + type: 'object', + properties: { + preselected: { + type: 'array', + items: { type: 'string' }, + description: 'Bereits ausgewählte Seiten-IDs basierend auf dem Gespräch', + }, + }, + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'show_feature_selector', + description: 'Zeigt dem Nutzer eine interaktive Auswahl der verfügbaren Features (Blog, Produkte, Jobs, Cases, Events). Nutze dieses Tool wenn über Inhalts-Bereiche gesprochen wird.', + parameters: { + type: 'object', + properties: { + preselected: { + type: 'array', + items: { type: 'string' }, + description: 'Vorausgewählte Feature-IDs', + }, + }, + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'show_function_selector', + description: 'Zeigt dem Nutzer eine interaktive Auswahl der technischen Funktionen (Suche, Filter, PDF, Formulare). Nutze dieses Tool wenn über technische Anforderungen gesprochen wird.', + parameters: { + type: 'object', + properties: { + preselected: { + type: 'array', + items: { type: 'string' }, + description: 'Vorausgewählte Funktions-IDs', + }, + }, + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'show_api_selector', + description: 'Zeigt dem Nutzer eine interaktive Auswahl der System-Integrationen (CRM, ERP, Payment, etc.). Nutze dieses Tool wenn über Drittanbieter-Anbindungen gesprochen wird.', + parameters: { + type: 'object', + properties: { + preselected: { + type: 'array', + items: { type: 'string' }, + description: 'Vorausgewählte API-IDs', + }, + }, + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'show_asset_selector', + description: 'Zeigt dem Nutzer eine Auswahl welche Assets bereits vorhanden sind (Logo, Styleguide, Bilder etc.). Nutze dieses Tool wenn über vorhandenes Material gesprochen wird.', + parameters: { + type: 'object', + properties: { + preselected: { + type: 'array', + items: { type: 'string' }, + description: 'Vorausgewählte Asset-IDs', + }, + }, + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'show_design_picker', + description: 'Zeigt dem Nutzer eine visuelle Design-Stil-Auswahl. Nutze dieses Tool wenn über das Design oder den visuellen Stil gesprochen wird.', + parameters: { + type: 'object', + properties: { + preselected: { type: 'string', description: 'Vorausgewählter Design-Stil' }, + }, + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'show_timeline_picker', + description: 'Zeigt dem Nutzer eine Timeline/Deadline-Auswahl. Nutze dieses Tool wenn über Zeitrahmen oder Deadlines gesprochen wird.', + parameters: { + type: 'object', + properties: { + preselected: { type: 'string', description: 'Vorausgewählte Deadline' }, + }, + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'show_contact_fields', + description: 'Zeigt dem Nutzer Eingabefelder für E-Mail-Adresse und optionale Nachricht. Nutze dieses Tool wenn es Zeit ist die Kontaktdaten zu sammeln, typischerweise gegen Ende des Gesprächs.', + parameters: { + type: 'object', + properties: {}, + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'request_file_upload', + description: 'Zeigt dem Nutzer einen Datei-Upload-Bereich. Nutze dieses Tool wenn der Nutzer Dateien teilen möchte (Briefing, Sitemap, Design-Referenzen etc.).', + parameters: { + type: 'object', + properties: { + label: { type: 'string', description: 'Beschriftung des Upload-Bereichs' }, + }, + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'show_estimate_preview', + description: 'Zeigt dem Nutzer eine Live-Kostenübersicht basierend auf dem aktuellen Konfigurationsstand. Nutze dieses Tool wenn genügend Informationen gesammelt wurden oder wenn der Nutzer nach Kosten fragt.', + parameters: { + type: 'object', + properties: {}, + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'generate_estimate_pdf', + description: 'Generiert ein PDF-Angebot basierend auf dem aktuellen Konfigurationsstand. Nutze dieses Tool wenn der Nutzer ein Angebot/PDF möchte oder das Gespräch abgeschlossen wird.', + parameters: { + type: 'object', + properties: {}, + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'submit_inquiry', + description: 'Sendet die Anfrage ab und benachrichtigt Marc Mintel. Nutze dieses Tool wenn der Nutzer explizit absenden möchte und mindestens Name + Email vorhanden sind.', + parameters: { + type: 'object', + properties: {}, + }, + }, + }, +]; + +// Available options for the system prompt +const availableOptions = ` +VERFÜGBARE SEITEN: ${PAGE_SAMPLES.map((p) => `${p.id} (${p.label})`).join(', ')} +VERFÜGBARE FEATURES: ${FEATURE_OPTIONS.map((f) => `${f.id} (${f.label})`).join(', ')} +VERFÜGBARE FUNKTIONEN: ${FUNCTION_OPTIONS.map((f) => `${f.id} (${f.label})`).join(', ')} +VERFÜGBARE API-INTEGRATIONEN: ${API_OPTIONS.map((a) => `${a.id} (${a.label})`).join(', ')} +VERFÜGBARE ASSETS: ${ASSET_OPTIONS.map((a) => `${a.id} (${a.label})`).join(', ')} +VERFÜGBARE DESIGN-STILE: ${DESIGN_OPTIONS.map((d) => `${d.id} (${d.label})`).join(', ')} +DEADLINES: ${Object.entries(DEADLINE_LABELS).map(([k, v]) => `${k} (${v})`).join(', ')} +MITARBEITER: ${EMPLOYEE_OPTIONS.map((e) => `${e.id} (${e.label})`).join(', ')} + +PREISE (netto): +- Basis Website: ${PRICING.BASE_WEBSITE}€ +- Pro Seite: ${PRICING.PAGE}€ +- Pro Feature: ${PRICING.FEATURE}€ +- Pro Funktion: ${PRICING.FUNCTION}€ +- API-Integration: ${PRICING.API_INTEGRATION}€ +- CMS Setup: ${PRICING.CMS_SETUP}€ +- Hosting monatlich: ${PRICING.HOSTING_MONTHLY}€ +`; + +const SYSTEM_PROMPT = `Du bist ein professioneller Projektberater der Digitalagentur "Mintel" – spezialisiert auf Next.js, Payload CMS und moderne Web-Infrastruktur. + +DEINE AUFGABE: +Du führst ein natürliches Beratungsgespräch, um alle Informationen für eine Website-/Web-App-Projektschätzung zu sammeln. Du bist freundlich, kompetent und effizient. + +GESPRÄCHSFÜHRUNG: +1. Begrüße den Nutzer und frage nach seinem Namen und Unternehmen. +2. Finde heraus, was für ein Projekt es wird (Website oder Web-App). +3. Sammle schrittweise die Anforderungen – NICHT alle auf einmal fragen! +4. Pro Nachricht maximal 1-2 Themen ansprechen. +5. Nutze die verfügbaren Tools um interaktive Auswahl-Widgets zu zeigen. +6. Wenn du genug Informationen hast, zeige eine Kostenübersicht. +7. Biete an, ein PDF-Angebot zu generieren. +8. Sammle am Ende Kontaktdaten und biete an die Anfrage abzusenden. + +WICHTIGE REGELN: +- ANTWORTE IN DER SPRACHE DES NUTZERS (Deutsch/Englisch). +- Halte Antworten kurz und natürlich (2-4 Sätze pro Nachricht). +- Zeige Widgets über Tool-Calls – nicht als Text-Listen. +- Wenn der Nutzer eine konkrete Auswahl trifft müssen wir das über die passenden UI-Tools machen, bestätige kurz und gehe zum nächsten Thema. +- Du darfst mehrere Tools gleichzeitig aufrufen wenn es sinnvoll ist. +- Sei proaktiv: Wenn der Nutzer sagt "ich brauche eine Website für mein Restaurant", sag nicht nur "ok", sondern schlage direkt passende Seiten vor (Home, About, Speisekarte, Kontakt, Impressum) und zeige den Seiten-Selektor. + +${availableOptions} + +AKTUELLER FORMSTATE (wird vom Frontend mitgeliefert): +Wird in jeder Nachricht als JSON übergeben.`; + +export async function POST(req: NextRequest) { + try { + const { messages, formState, visitorId, honeypot } = await req.json(); + + // 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; + + // Honeypot + if (honeypot && honeypot.length > 0) { + await new Promise((resolve) => setTimeout(resolve, 3000)); + return NextResponse.json({ + message: 'Vielen Dank für Ihre Anfrage.', + toolCalls: [], + }); + } + + // Rate Limiting + try { + if (visitorId) { + const requestCount = await redis.incr(`agent_chat_rate_limit:${visitorId}`); + if (requestCount === 1) { + await redis.expire(`agent_chat_rate_limit:${visitorId}`, RATE_LIMIT_DURATION); + } + if (requestCount > RATE_LIMIT_POINTS) { + return NextResponse.json( + { error: 'Rate limit exceeded. Please try again later.' }, + { status: 429 }, + ); + } + } + } catch (redisError) { + console.error('Redis Rate Limiting Error:', redisError); + Sentry.captureException(redisError, { tags: { context: 'agent-chat-rate-limit' } }); + } + + // Build messages for OpenRouter + const systemMessage = { + role: 'system', + content: `${SYSTEM_PROMPT}\n\nAKTUELLER FORMSTATE:\n${JSON.stringify(formState || initialState, null, 2)}`, + }; + + 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://mintel.me', + 'X-Title': 'Mintel.me Project Agent', + }, + body: JSON.stringify({ + model: 'mistralai/mistral-large-2407', + temperature: 0.4, + tools: TOOLS, + tool_choice: 'auto', + messages: [ + systemMessage, + ...messages.map((m: any) => ({ + role: m.role, + content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content), + ...(m.tool_calls ? { tool_calls: m.tool_calls } : {}), + ...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}), + })), + ], + }), + }); + + if (!fetchRes.ok) { + const errBody = await fetchRes.text(); + throw new Error(`OpenRouter API Error: ${errBody}`); + } + + const data = await fetchRes.json(); + const choice = data.choices[0]; + const responseMessage = choice.message; + + // Extract tool calls + const toolCalls = responseMessage.tool_calls?.map((tc: any) => ({ + id: tc.id, + name: tc.function.name, + arguments: JSON.parse(tc.function.arguments || '{}'), + })) || []; + + return NextResponse.json({ + message: responseMessage.content || '', + toolCalls, + rawToolCalls: responseMessage.tool_calls || [], + }); + } catch (error) { + console.error('Agent Chat API Error:', error); + Sentry.captureException(error, { tags: { context: 'agent-chat-api' } }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/ai-search/route.ts b/apps/web/app/api/ai-search/route.ts new file mode 100644 index 0000000..547540e --- /dev/null +++ b/apps/web/app/api/ai-search/route.ts @@ -0,0 +1,140 @@ +import { NextResponse, NextRequest } from 'next/server'; +import { searchPosts } from '../../../src/lib/qdrant'; +import redis from '../../../src/lib/redis'; +import * as Sentry from '@sentry/nextjs'; + +// Rate limiting constants +const RATE_LIMIT_POINTS = 5; // 5 requests +const RATE_LIMIT_DURATION = 60; // per 1 minute + +export async function POST(req: 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; + + 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'); + await new Promise((resolve) => setTimeout(resolve, 3000)); + return NextResponse.json({ + answerText: 'Vielen Dank für Ihre Anfrage.', + posts: [], + }); + } + + // 3. Rate Limiting via Redis + 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); + } + + if (requestCount > RATE_LIMIT_POINTS) { + return NextResponse.json( + { error: 'Rate limit exceeded. Please try again later.' }, + { status: 429 }, + ); + } + } + } catch (redisError) { + console.error('Redis Rate Limiting Error:', redisError); + Sentry.captureException(redisError, { tags: { context: 'ai-search-rate-limit' } }); + // Fail open if Redis is down + } + + // 4. Fetch Context from Qdrant + let contextStr = ''; + let foundPosts: any[] = []; + + try { + const searchResults = await searchPosts(latestMessage, 5); + + if (searchResults && searchResults.length > 0) { + const postDescriptions = searchResults + .map((p: any) => p.payload?.content) + .join('\n\n'); + + contextStr = `BLOG-POSTS & WISSEN:\n${postDescriptions}`; + + foundPosts = searchResults + .filter((p: any) => 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' } }); + } + + // 5. Generate AI Response via OpenRouter (Mistral) + const systemPrompt = `Du bist ein professioneller technischer Berater der Agentur "Mintel" – einer Full-Stack Digitalagentur spezialisiert auf Next.js, Payload CMS und moderne Web-Infrastruktur. +Deine Aufgabe ist es, Besuchern bei technischen Fragen zu helfen, basierend auf den Blog-Artikeln und dem Fachwissen der Agentur. + +WICHTIGE REGELN: +1. ANTWORTE IMMER IN DER SPRACHE DES BENUTZERS. Wenn der Benutzer Deutsch spricht, antworte auf Deutsch. Bei Englisch, antworte auf Englisch. +2. Nutze das bereitgestellte BLOG-WISSEN unten, um deine Antworten zu fundieren. Verweise auf relevante Blog-Posts. +3. Sei hilfreich, präzise und technisch versiert. Du kannst Code-Beispiele geben wenn sinnvoll. +4. Wenn du keine passende Information findest, gib das offen zu und schlage vor, über das Kontaktformular direkt Kontakt aufzunehmen. +5. Antworte in Markdown-Format (Überschriften, Listen, Code-Blöcke sind erlaubt). +6. Halte Antworten kompakt aber informativ – maximal 3-4 Absätze. +7. Oute dich als AI-Assistent von Mintel. + +VERFÜGBARER KONTEXT: +${contextStr ? contextStr : 'Keine spezifischen Blog-Daten für diese Anfrage gefunden.'} +`; + + 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://mintel.me', + 'X-Title': 'Mintel.me AI Search', + }, + 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 NextResponse.json({ + answerText: text, + posts: foundPosts, + }); + } 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/apps/web/package.json b/apps/web/package.json index b053b40..9fc6272 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "video:render:button": "remotion render video/index.ts ButtonShowcase out/button-showcase.mp4 --concurrency=1 --codec=h264 --crf=16 --pixel-format=yuv420p --overwrite", "video:render:all": "npm run video:render:contact && npm run video:render:button", "pagespeed:test": "npx tsx ./scripts/pagespeed-sitemap.ts", + "index:posts": "node --import tsx --experimental-loader ./ignore-css.mjs ./scripts/index-posts.ts", "typecheck": "tsc --noEmit", "check:og": "tsx scripts/check-og-images.ts", "check:forms": "tsx scripts/check-forms.ts", @@ -56,6 +57,7 @@ "@payloadcms/richtext-lexical": "^3.77.0", "@payloadcms/storage-s3": "^3.77.0", "@payloadcms/ui": "^3.77.0", + "@qdrant/js-client-rest": "^1.17.0", "@react-pdf/renderer": "^4.3.2", "@remotion/bundler": "^4.0.414", "@remotion/cli": "^4.0.414", @@ -92,9 +94,11 @@ "qrcode": "^1.5.4", "react": "^19.2.3", "react-dom": "^19.2.3", + "react-markdown": "^10.1.0", "react-social-media-embed": "^2.5.18", "react-tweet": "^3.3.0", "recharts": "^3.7.0", + "remark-gfm": "^4.0.1", "remotion": "^4.0.414", "replicate": "^1.4.0", "require-in-the-middle": "^8.0.1", @@ -142,4 +146,4 @@ "type": "git", "url": "git@git.infra.mintel.me:mmintel/mintel.me.git" } -} +} \ No newline at end of file diff --git a/apps/web/scripts/index-posts.ts b/apps/web/scripts/index-posts.ts new file mode 100644 index 0000000..ee0d488 --- /dev/null +++ b/apps/web/scripts/index-posts.ts @@ -0,0 +1,127 @@ +/** + * Index all published blog posts into Qdrant for AI search. + * + * Usage: pnpm --filter @mintel/web run index:posts + */ +import { getPayload } from 'payload'; +import configPromise from '../payload.config'; +import { upsertPostVector } from '../src/lib/qdrant'; + +function extractPlainText(node: any): string { + if (!node) return ''; + + // Handle text nodes + if (typeof node === 'string') return node; + if (node.text) return node.text; + + // Handle arrays + if (Array.isArray(node)) { + return node.map(extractPlainText).join(''); + } + + // Handle node with children + if (node.children) { + const childText = node.children.map(extractPlainText).join(''); + + // Add line breaks for block-level elements + if (['paragraph', 'heading', 'listitem', 'quote'].includes(node.type)) { + return childText + '\n'; + } + return childText; + } + + // Lexical root + if (node.root) { + return extractPlainText(node.root); + } + + return ''; +} + +async function run() { + console.log('🔍 Starting blog post indexing for AI search...'); + + let payload; + let retries = 5; + while (retries > 0) { + try { + console.log(`Connecting to database (URI: ${process.env.DATABASE_URI || 'default'})...`); + payload = await getPayload({ config: configPromise }); + break; + } catch (e: any) { + if ( + e.code === 'ECONNREFUSED' || + e.code === 'ENOTFOUND' || + e.message?.includes('ECONNREFUSED') || + e.message?.includes('cannot connect to Postgres') + ) { + console.log(`Database not ready, retrying in 3s... (${retries} retries left)`); + retries--; + await new Promise((res) => setTimeout(res, 3000)); + } else { + throw e; + } + } + } + + if (!payload) { + throw new Error('Failed to connect to database after multiple retries.'); + } + + // Fetch all published posts + const result = await payload.find({ + collection: 'posts', + limit: 1000, + where: { + _status: { equals: 'published' }, + }, + }); + + console.log(`Found ${result.docs.length} published posts to index.`); + + let indexed = 0; + for (const post of result.docs) { + const plainContent = extractPlainText(post.content); + + // Build searchable text: title + description + tags + content + const tags = (post.tags as any[])?.map((t: any) => t.tag).filter(Boolean).join(', ') || ''; + const searchableText = [ + `Titel: ${post.title}`, + `Beschreibung: ${post.description}`, + tags ? `Tags: ${tags}` : '', + `Inhalt: ${plainContent.substring(0, 2000)}`, // Limit content to avoid token overflow + ] + .filter(Boolean) + .join('\n\n'); + + // Upsert into Qdrant + await upsertPostVector( + post.id, + searchableText, + { + content: searchableText, + data: { + id: post.id, + title: post.title, + slug: post.slug, + description: post.description, + tags, + }, + }, + ); + + indexed++; + console.log(` ✅ [${indexed}/${result.docs.length}] ${post.title}`); + + // Small delay to avoid rate limiting on the embedding API + await new Promise((res) => setTimeout(res, 200)); + } + + console.log(`\n🎉 Successfully indexed ${indexed} posts into Qdrant.`); + process.exit(0); +} + +run().catch((e) => { + console.error('Indexing failed:', e); + process.exit(1); +}); diff --git a/apps/web/src/components/Header.tsx b/apps/web/src/components/Header.tsx index 37c9195..5e034c7 100644 --- a/apps/web/src/components/Header.tsx +++ b/apps/web/src/components/Header.tsx @@ -4,6 +4,7 @@ import Image from "next/image"; import Link from "next/link"; import { useSafePathname } from "./analytics/useSafePathname"; import * as React from "react"; +import { AISearchResults } from "./search/AISearchResults"; import IconWhite from "../assets/logo/Icon-White-Transparent.svg"; @@ -11,6 +12,19 @@ export const Header: React.FC = () => { const pathname = useSafePathname(); const [isScrolled, setIsScrolled] = React.useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false); + const [isAISearchOpen, setIsAISearchOpen] = React.useState(false); + + // Cmd+K to open AI search + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setIsAISearchOpen(true); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); React.useEffect(() => { const handleScroll = () => { @@ -50,8 +64,8 @@ export const Header: React.FC = () => { {/* Decoupled Background Layer - Prevents backdrop-filter parent context bugs */}
@@ -95,8 +109,8 @@ export const Header: React.FC = () => { key={link.href} href={link.href} className={`text-xs font-bold uppercase tracking-widest transition-colors duration-300 relative ${active - ? "text-slate-900" - : "text-slate-400 hover:text-slate-900" + ? "text-slate-900" + : "text-slate-400 hover:text-slate-900" }`} > {active && ( @@ -108,6 +122,17 @@ export const Header: React.FC = () => { ); })} + { href={item.href} onClick={() => setIsMobileMenuOpen(false)} className={`relative flex flex-col justify-center p-6 h-[110px] rounded-2xl border transition-all duration-200 ${active - ? "bg-slate-50 border-slate-200 ring-1 ring-slate-200" - : "bg-white border-slate-100 active:bg-slate-50" + ? "bg-slate-50 border-slate-200 ring-1 ring-slate-200" + : "bg-white border-slate-100 active:bg-slate-50" }`} >
@@ -307,6 +332,12 @@ export const Header: React.FC = () => { )} + + {/* AI Search Modal */} + setIsAISearchOpen(false)} + /> ); }; diff --git a/apps/web/src/components/agent/AgentChat.tsx b/apps/web/src/components/agent/AgentChat.tsx new file mode 100644 index 0000000..8bda193 --- /dev/null +++ b/apps/web/src/components/agent/AgentChat.tsx @@ -0,0 +1,554 @@ +'use client'; + +import { useState, useRef, useEffect, useCallback, KeyboardEvent } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { initialState, FormState } from '../../logic/pricing'; +import { + PAGE_SAMPLES, + FEATURE_OPTIONS, + FUNCTION_OPTIONS, + API_OPTIONS, + ASSET_OPTIONS, +} from '../../logic/pricing/constants'; +import { sendContactInquiry } from '../../actions/contact'; + +// Widgets +import { SelectionGrid } from './widgets/SelectionGrid'; +import { DesignPicker } from './widgets/DesignPicker'; +import { FileDropzone } from './widgets/FileDropzone'; +import { ContactFields } from './widgets/ContactFields'; +import { TimelinePicker } from './widgets/TimelinePicker'; +import { EstimatePreview } from './widgets/EstimatePreview'; + +// AI Orb +import AIOrb from '../search/AIOrb'; + +interface ToolCall { + id: string; + name: string; + arguments: Record; +} + +interface ChatMessage { + id: string; + role: 'user' | 'assistant' | 'tool'; + content: string; + toolCalls?: ToolCall[]; + rawToolCalls?: any[]; + tool_call_id?: string; +} + +export function AgentChat() { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [formState, setFormState] = useState({ ...initialState } as FormState); + const [honeypot, setHoneypot] = useState(''); + const [isSubmitted, setIsSubmitted] = useState(false); + const [pdfUrl, setPdfUrl] = useState(null); + const [error, setError] = useState(null); + + const inputRef = useRef(null); + const messagesEndRef = useRef(null); + + // Auto-scroll on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, isLoading]); + + // Auto-focus input + useEffect(() => { + inputRef.current?.focus(); + }, [isLoading]); + + // Track which widgets are locked (already interacted with) + const [lockedWidgets, setLockedWidgets] = useState>(new Set()); + + const lockWidget = (messageId: string) => { + setLockedWidgets((prev) => new Set([...prev, messageId])); + }; + + const updateFormState = useCallback((updates: Partial) => { + setFormState((prev) => ({ ...prev, ...updates })); + }, []); + + const genId = () => Math.random().toString(36).substring(2, 10); + + // Send message to agent API + const sendMessage = async (userMessage?: string) => { + const msgText = userMessage || input.trim(); + if (!msgText && messages.length > 0) return; + + setError(null); + setIsLoading(true); + + // Add user message + const userMsg: ChatMessage = { + id: genId(), + role: 'user', + content: msgText || 'Hallo!', + }; + + const newMessages = [...messages, userMsg]; + setMessages(newMessages); + setInput(''); + + try { + // Build API messages (exclude widget rendering details) + const apiMessages = newMessages.map((m) => ({ + role: m.role, + content: m.content, + ...(m.rawToolCalls ? { tool_calls: m.rawToolCalls } : {}), + ...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}), + })); + + const res = await fetch('/api/agent-chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: apiMessages, + formState, + honeypot, + }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || 'API request failed'); + } + + // Process tool calls to update FormState + const toolCalls: ToolCall[] = data.toolCalls || []; + for (const tc of toolCalls) { + processToolCall(tc); + } + + // Add assistant message + const assistantMsg: ChatMessage = { + id: genId(), + role: 'assistant', + content: data.message || '', + toolCalls, + rawToolCalls: data.rawToolCalls, + }; + + setMessages((prev) => [...prev, assistantMsg]); + + // If there are tool calls, we need to send tool results back + if (toolCalls.length > 0 && data.rawToolCalls?.length > 0) { + // Auto-acknowledge tool calls + const toolResultMessages = toolCalls.map((tc) => ({ + id: genId(), + role: 'tool' as const, + content: JSON.stringify({ status: 'ok', tool: tc.name }), + tool_call_id: tc.id, + })); + + setMessages((prev) => [...prev, ...toolResultMessages]); + } + } catch (err: any) { + console.error('Agent chat error:', err); + setError(err.message || 'Ein Fehler ist aufgetreten.'); + } finally { + setIsLoading(false); + } + }; + + // Process tool calls to update form state + const processToolCall = (tc: ToolCall) => { + switch (tc.name) { + case 'update_company_info': + updateFormState({ + ...(tc.arguments.companyName && { companyName: tc.arguments.companyName }), + ...(tc.arguments.name && { name: tc.arguments.name }), + ...(tc.arguments.employeeCount && { employeeCount: tc.arguments.employeeCount }), + ...(tc.arguments.existingWebsite && { existingWebsite: tc.arguments.existingWebsite }), + }); + break; + case 'update_project_type': + updateFormState({ projectType: tc.arguments.projectType }); + break; + case 'show_page_selector': + if (tc.arguments.preselected?.length) { + updateFormState({ selectedPages: tc.arguments.preselected }); + } + break; + case 'show_feature_selector': + if (tc.arguments.preselected?.length) { + updateFormState({ features: tc.arguments.preselected }); + } + break; + case 'show_function_selector': + if (tc.arguments.preselected?.length) { + updateFormState({ functions: tc.arguments.preselected }); + } + break; + case 'show_api_selector': + if (tc.arguments.preselected?.length) { + updateFormState({ apiSystems: tc.arguments.preselected }); + } + break; + case 'show_asset_selector': + if (tc.arguments.preselected?.length) { + updateFormState({ assets: tc.arguments.preselected }); + } + break; + case 'show_design_picker': + if (tc.arguments.preselected) { + updateFormState({ designVibe: tc.arguments.preselected }); + } + break; + case 'show_timeline_picker': + if (tc.arguments.preselected) { + updateFormState({ deadline: tc.arguments.preselected }); + } + break; + case 'submit_inquiry': + handleSubmitInquiry(); + break; + } + }; + + // Submit inquiry + const handleSubmitInquiry = async () => { + try { + const result = await sendContactInquiry({ + name: formState.name, + email: formState.email, + companyName: formState.companyName, + projectType: formState.projectType, + message: formState.message || 'Agent-gestützte Anfrage', + isFreeText: false, + config: formState, + }); + if (result.success) { + setIsSubmitted(true); + } + } catch (e) { + console.error('Submit error:', e); + } + }; + + // Render tool call as widget + const renderToolCallWidget = (tc: ToolCall, messageId: string) => { + const isLocked = lockedWidgets.has(`${messageId}-${tc.name}`); + const widgetKey = `${messageId}-${tc.name}`; + + switch (tc.name) { + case 'show_page_selector': + return ( + ({ id: p.id, label: p.label, desc: p.desc }))} + selected={formState.selectedPages} + onSelectionChange={(selected) => { + updateFormState({ selectedPages: selected }); + }} + locked={isLocked} + /> + ); + case 'show_feature_selector': + return ( + ({ id: f.id, label: f.label, desc: f.desc }))} + selected={formState.features} + onSelectionChange={(selected) => { + updateFormState({ features: selected }); + }} + locked={isLocked} + /> + ); + case 'show_function_selector': + return ( + ({ id: f.id, label: f.label, desc: f.desc }))} + selected={formState.functions} + onSelectionChange={(selected) => { + updateFormState({ functions: selected }); + }} + locked={isLocked} + /> + ); + case 'show_api_selector': + return ( + ({ id: a.id, label: a.label, desc: a.desc }))} + selected={formState.apiSystems} + onSelectionChange={(selected) => { + updateFormState({ apiSystems: selected }); + }} + locked={isLocked} + /> + ); + case 'show_asset_selector': + return ( + ({ id: a.id, label: a.label, desc: a.desc }))} + selected={formState.assets} + onSelectionChange={(selected) => { + updateFormState({ assets: selected }); + }} + locked={isLocked} + /> + ); + case 'show_design_picker': + return ( + updateFormState({ designVibe: id })} + locked={isLocked} + /> + ); + case 'show_timeline_picker': + return ( + updateFormState({ deadline: id })} + locked={isLocked} + /> + ); + case 'show_contact_fields': + return ( + updateFormState({ email: v })} + message={formState.message} + setMessage={(v) => updateFormState({ message: v })} + locked={isLocked} + /> + ); + case 'request_file_upload': + return ( + { + updateFormState({ + contactFiles: [...(formState.contactFiles || []), ...files], + }); + }} + locked={isLocked} + /> + ); + case 'show_estimate_preview': + return ; + case 'generate_estimate_pdf': + return ( +
+
+ + + + + + +
+

PDF-Angebot

+

+ Das Angebot wird nach Absenden der Anfrage erstellt und zugesendet. +

+
+
+
+ ); + default: + return null; + } + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }; + + // Start conversation automatically + useEffect(() => { + if (messages.length === 0) { + sendMessage('Hallo!'); + } + }, []); + + // Success view + if (isSubmitted) { + return ( +
+ + + + + +
+

Anfrage gesendet!

+

+ Marc wird sich in Kürze bei dir unter {formState.email} melden. +

+
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+

+ Projekt-Assistent +

+

+ AI-GESTÜTZTE BERATUNG +

+
+
+ + {/* Chat Messages */} +
+ + {messages + .filter((m) => m.role !== 'tool') + .map((msg) => ( + +
+ {/* Text content */} + {msg.content && ( +
+ {msg.role === 'user' && msg.content === 'Hallo!' + ? null + : msg.content} +
+ )} + + {/* Tool call widgets */} + {msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0 && ( +
+ {msg.toolCalls + .filter((tc) => !['update_company_info', 'update_project_type', 'submit_inquiry'].includes(tc.name)) + .map((tc) => renderToolCallWidget(tc, msg.id))} +
+ )} +
+
+ ))} +
+ + {/* Loading indicator */} + {isLoading && ( + +
+ + + denkt nach... + +
+
+ )} + + {/* Error */} + {error && ( +
+ + + + + + {error} +
+ )} + +
+
+ + {/* Input */} +
+
+ setInput(e.target.value)} + onKeyDown={onKeyDown} + placeholder="Beschreibe dein Projekt..." + disabled={isLoading} + className="flex-1 bg-transparent border-none text-sm p-4 focus:outline-none text-slate-900 placeholder:text-slate-300" + /> + setHoneypot(e.target.value)} + tabIndex={-1} + autoComplete="off" + aria-hidden="true" + /> + +
+

+ Enter zum Senden +

+
+
+ ); +} diff --git a/apps/web/src/components/agent/widgets/ContactFields.tsx b/apps/web/src/components/agent/widgets/ContactFields.tsx new file mode 100644 index 0000000..967acda --- /dev/null +++ b/apps/web/src/components/agent/widgets/ContactFields.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { cn } from '../../../utils/cn'; + +interface ContactFieldsProps { + email: string; + setEmail: (val: string) => void; + message: string; + setMessage: (val: string) => void; + locked?: boolean; +} + +export function ContactFields({ + email, + setEmail, + message, + setMessage, + locked = false, +}: ContactFieldsProps) { + return ( +
+

+ Kontaktdaten +

+
+ setEmail(e.target.value)} + placeholder="ihre@email.de" + disabled={locked} + className={cn( + 'w-full px-4 py-3 rounded-xl border text-sm font-medium transition-all focus:outline-none', + 'bg-white border-slate-200 text-slate-900 placeholder:text-slate-300', + 'focus:border-slate-900 focus:ring-1 focus:ring-slate-900', + locked && 'opacity-60', + )} + /> +