From 0f5d18dac7ed87ca902249e05dcf41dc359bfd4e Mon Sep 17 00:00:00 2001 From: Timo Uttenweiler Date: Wed, 8 Apr 2026 14:00:54 +0200 Subject: [PATCH] Add KI-Suche via OpenRouter GPT-4o-mini - /api/ai-search: sends user description to GPT-4o-mini, returns 2-4 structured query/region pairs as JSON - AiSearchModal: textarea, generates previews, user selects queries to run - KI-Suche button in hero section of /suche page Co-Authored-By: Claude Sonnet 4.6 --- app/api/ai-search/route.ts | 90 +++++++++++ app/suche/page.tsx | 70 ++++++++- components/search/AiSearchModal.tsx | 223 ++++++++++++++++++++++++++++ 3 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 app/api/ai-search/route.ts create mode 100644 components/search/AiSearchModal.tsx diff --git a/app/api/ai-search/route.ts b/app/api/ai-search/route.ts new file mode 100644 index 0000000..1d08d56 --- /dev/null +++ b/app/api/ai-search/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; + +const SYSTEM_PROMPT = `Du bist ein Experte für B2B-Lead-Generierung im deutschsprachigen Raum. + +Deine Aufgabe: Wandle die Beschreibung des Nutzers in 2–4 konkrete Google-Suchanfragen um, die lokale Unternehmen und Dienstleister finden. + +Regeln: +- Jede Query besteht aus einem kurzen Suchbegriff (Branche/Tätigkeit) und einer Region (Bundesland, Stadt oder Gebiet) +- Suchbegriffe sind konkret, auf Deutsch, wie ein Mensch bei Google suchen würde +- Keine Firmennamen, keine Websites, keine Social-Media-Begriffe +- Wenn der Nutzer keine Region nennt, verteile auf sinnvolle deutsche Regionen (z.B. Bayern, NRW, Baden-Württemberg) +- Wenn der Nutzer eine spezifische Region nennt, halte dich daran — teile ggf. in Städte auf für mehr Abdeckung +- count immer 50 außer der Nutzer nennt explizit eine Zahl (dann zwischen 25 und 100) +- Maximal 4 Queries zurückgeben +- Keine Erklärungen, nur JSON + +Antworte ausschließlich mit einem JSON-Array, kein Markdown, kein Text drumherum: +[ + { "query": "Dachdecker", "region": "Bayern", "count": 50 }, + { "query": "Dachdecker", "region": "NRW", "count": 50 } +]`; + +export async function POST(req: NextRequest) { + try { + const { description } = await req.json() as { description: string }; + + if (!description?.trim()) { + return NextResponse.json({ error: "Beschreibung fehlt" }, { status: 400 }); + } + + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + return NextResponse.json({ error: "OpenRouter API Key nicht konfiguriert" }, { status: 500 }); + } + + const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${apiKey}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://onvyaleads.app", + "X-Title": "OnyvaLeads", + }, + body: JSON.stringify({ + model: "openai/gpt-4o-mini", + temperature: 0.4, + max_tokens: 512, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: description.trim() }, + ], + }), + }); + + if (!res.ok) { + const err = await res.text(); + console.error("[ai-search] OpenRouter error:", err); + return NextResponse.json({ error: "KI-Anfrage fehlgeschlagen" }, { status: 500 }); + } + + const data = await res.json() as { + choices: Array<{ message: { content: string } }>; + }; + + const raw = data.choices[0]?.message?.content?.trim() ?? ""; + + let queries: Array<{ query: string; region: string; count: number }>; + try { + queries = JSON.parse(raw); + } catch { + const match = raw.match(/\[[\s\S]*\]/); + if (!match) throw new Error("Kein JSON in Antwort"); + queries = JSON.parse(match[0]); + } + + queries = queries + .filter(q => typeof q.query === "string" && q.query.trim()) + .slice(0, 4) + .map(q => ({ + query: q.query.trim(), + region: (q.region ?? "").trim(), + count: Math.min(Math.max(Number(q.count) || 50, 25), 100), + })); + + return NextResponse.json({ queries }); + } catch (err) { + console.error("[ai-search] error:", err); + return NextResponse.json({ error: "Fehler bei der KI-Anfrage" }, { status: 500 }); + } +} diff --git a/app/suche/page.tsx b/app/suche/page.tsx index e5b63a2..448a19f 100644 --- a/app/suche/page.tsx +++ b/app/suche/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { toast } from "sonner"; import { SearchCard } from "@/components/search/SearchCard"; import { LoadingCard, type LeadResult } from "@/components/search/LoadingCard"; +import { AiSearchModal } from "@/components/search/AiSearchModal"; export default function SuchePage() { const [query, setQuery] = useState(""); @@ -18,6 +19,7 @@ export default function SuchePage() { const [deleting, setDeleting] = useState(false); const [onlyNew, setOnlyNew] = useState(false); const [saveOnlyNew, setSaveOnlyNew] = useState(false); + const [aiOpen, setAiOpen] = useState(false); function handleChange(field: "query" | "region" | "count", value: string | number) { if (field === "query") setQuery(value as string); @@ -130,12 +132,33 @@ export default function SuchePage() { Lead-Suche -

- Leads finden -

-

- Suchbegriff eingeben — wir finden passende Unternehmen mit Kontaktdaten. -

+
+
+

+ Leads finden +

+

+ Suchbegriff eingeben — wir finden passende Unternehmen mit Kontaktdaten. +

+
+ +
@@ -162,6 +185,41 @@ export default function SuchePage() { /> )} + {/* AI Modal */} + {aiOpen && ( + { + setAiOpen(false); + if (!queries.length) return; + // Fill first query into the search fields and submit + const first = queries[0]; + setQuery(first.query); + setRegion(first.region); + setCount(first.count); + setLoading(true); + setJobId(null); + setLeads([]); + setSearchDone(false); + setSelected(new Set()); + fetch("/api/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: first.query, region: first.region, count: first.count }), + }) + .then(r => r.json()) + .then((d: { jobId?: string; error?: string }) => { + if (d.jobId) setJobId(d.jobId); + else throw new Error(d.error); + }) + .catch(err => { + toast.error(err instanceof Error ? err.message : "Fehler"); + setLoading(false); + }); + }} + onClose={() => setAiOpen(false)} + /> + )} + {/* Results */} {searchDone && leads.length > 0 && (() => { const newCount = leads.filter(l => l.isNew).length; diff --git a/components/search/AiSearchModal.tsx b/components/search/AiSearchModal.tsx new file mode 100644 index 0000000..32076a7 --- /dev/null +++ b/components/search/AiSearchModal.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useState } from "react"; + +interface Query { + query: string; + region: string; + count: number; +} + +interface AiSearchModalProps { + onStart: (queries: Query[]) => void; + onClose: () => void; +} + +export function AiSearchModal({ onStart, onClose }: AiSearchModalProps) { + const [description, setDescription] = useState(""); + const [loading, setLoading] = useState(false); + const [queries, setQueries] = useState([]); + const [selected, setSelected] = useState>(new Set()); + const [error, setError] = useState(""); + + async function generate() { + if (!description.trim() || loading) return; + setLoading(true); + setError(""); + setQueries([]); + try { + const res = await fetch("/api/ai-search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ description }), + }); + const data = await res.json() as { queries?: Query[]; error?: string }; + if (!res.ok || !data.queries) throw new Error(data.error || "Fehler"); + setQueries(data.queries); + setSelected(new Set(data.queries.map((_, i) => i))); + } catch (e) { + setError(e instanceof Error ? e.message : "Unbekannter Fehler"); + } finally { + setLoading(false); + } + } + + function toggle(i: number) { + setSelected(prev => { + const n = new Set(prev); + if (n.has(i)) n.delete(i); else n.add(i); + return n; + }); + } + + function handleStart() { + const chosen = queries.filter((_, i) => selected.has(i)); + if (!chosen.length) return; + onStart(chosen); + } + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+ +

+ KI-gestützte Suche +

+
+

+ Beschreibe deine Zielgruppe — die KI generiert passende Suchanfragen. +

+
+ +
+ + {/* Textarea */} +
+