feat(ai): Implement AI agent contact form and fix local Qdrant network configs
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

This commit is contained in:
2026-03-06 11:56:12 +01:00
parent 6a6fbb6f19
commit 85d2d2c069
21 changed files with 9857 additions and 13830 deletions

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}