Files
mintel.me/apps/web/app/api/agent-chat/route.ts
Marc Mintel 85d2d2c069
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Failing after 18m2s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
feat(ai): Implement AI agent contact form and fix local Qdrant network configs
2026-03-06 11:56:12 +01:00

382 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
}
}