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
382 lines
15 KiB
TypeScript
382 lines
15 KiB
TypeScript
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 });
|
||
}
|
||
}
|