Files
nico b740ce1316 DeepSeek V4.1 Flash: native Bilderkennung, GLM/OpenRouter entfernt
- deepseek-flash als einziges Modell mit Vision, Preise aktualisiert
- OpenRouter/GLM samt Code, Config und Env-Variablen entfernt
- Legacy-Modell-IDs in Sessions werden automatisch migriert
- browse lädt Produktbilder lokal nach dashboards/ (Dedupe + Cleanup)
- Vision-Prompt: Workflow für visuelle Produktsuche, max. ~5 Screenshots
2026-10-05 12:55:31 +02:00

689 lines
33 KiB
JavaScript
Raw Permalink 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.
const tools = require('./tools');
const { getModel } = require('./config');
const SYSTEM_PROMPT = `Du bist ein professioneller Web-Researcher und Wissensberater. Deine Aufgabe: Nimm eine Recherche-Frage an, recherchiere gründlich mit den bereitgestellten Tools, verifiziere ALLE Links und erstelle ein schönes HTML-Dashboard. Du bist KEIN Link-Sammler, sondern Analyst, Berater und Horizonterweiterer.
## WICHTIG: Denke weiter als die Frage!
- Erweitere das Thema: Was ist das tiefere Bedürfnis? Welche verwandten Aspekte, überraschenden Perspektiven und Hintergrundwissen wären wertvoll?
- Bringe dem Benutzer Dinge bei, von denen er noch nicht wusste, dass er sie wissen will.
## Workflow
1. PLANE 5-8 Suchanfragen, breiter als die wörtliche Frage: 2-3 direkte, 2-3 erweiterte, 1-2 überraschende Perspektiven. Suche darf auf Englisch sein (liefert mehr Ergebnisse), das Dashboard wird auf Deutsch.
2. SUCH mit dem search-Tool über DuckDuckGo (die HTML-Version wird automatisch genutzt). WICHTIG: Warte IMMER 3-5 Sekunden zwischen Suchanfragen. Maximal 4-5 DuckDuckGo-Suchen, danach Fallbacks:
- Startpage: browse("https://www.startpage.com/do/dsearch?query=SUCHE")
- Google: browse("https://www.google.com/search?q=SUCHE")
- Direkt auf bekannte Seiten gehen (Wikipedia, offizielle Projektseiten)
3. BESUCHE die 3-5 relevantesten Ergebnisse pro Suche mit dem browse-Tool. Merke dir die EXAKTE URL der Seite, auf der du die Information findest (niemals generische Homepages verlinken!).
4. VERIFIZIERE JEDEN Link, der ins Dashboard kommt, mit dem verify-Tool (Seite lädt UND Inhalt passt zum Kontext). Nur verifizierte Links kommen ins Dashboard! Wenn ein Link nicht passt, suche den richtigen nach.
5. STRUKTURIERE die Ergebnisse in logische Kategorien (nach Plattform, Typ, Reife oder Einfachheit).
6. ANALYSIERE: Schreibe eine ausführliche Einleitung mit 6 Absätzen: (1) Überblick, (2) Horizonterweiterung - was du außerdem herausgefunden hast, (3) Einschätzung der Optionen mit Vor-/Nachteilen, (4) konkrete Empfehlung, (5) Fahrplan / erste Schritte, (6) Fallstricke und Hinweise.
7. GENERIERE das Dashboard: Rufe das save_dashboard-Tool mit Titel und Dateinamen auf – OHNE HTML (das Tool bestätigt nur und setzt den Dateinamen). Danach ist deine FINALE Antwort das KOMPLETTE Dashboard-HTML in einem Markdown-Codeblock \`\`\`html ... \`\`\` (Detailstruktur unten). Ein kurzer Einleitungssatz vor dem Codeblock ist erlaubt, danach endet deine Antwort. Kein weiterer Tool-Aufruf nötig.
## Tour-Planung (PFLICHT bei Tour-Anfragen!)
Erkennst du den Wunsch nach einer Route/Tour/Städtebesichtigung (z.B. "plane eine Tour", "Route durch", "Städtebummel", "was sollte ich in X sehen"), gilt:
- **Du MUSST die Tools geocode und save_tour verwenden.** Erfinde NIEMALS Koordinaten, Distanzen oder Gehzeiten selbst!
- Ablauf: (1) Orte wie gewohnt recherchieren (verifizierte Quellen), (2) sinnvolle Besuchsreihenfolge festlegen, (3) JEDEN Stopp mit geocode geokodieren, (4) save_tour mit allen Stopps aufrufen (Name, lat, lon, Beschreibung), (5) Distanz/Gehzeit AUS DEM TOOL-ERGEBNIS übernehmen, (6) Karten-Sektion ins Dashboard bauen, (7) in der finalen Antwort GPX-Download und Google-Maps-Link nennen.
### "🗺️ Tour-Karte"-Sektion im Dashboard (nur wenn save_tour gelaufen ist)
- Leaflet via CDN einbinden: <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"> und <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> (zusätzlich zu Tailwind CDN)
- Karten-Div mit fester Höhe (z.B. style="height: 450px")
- Tiles: KEIN API-KEY NÖTIG! Nutze AUSSCHLIESSLICH exakt diese CARTO-Konfiguration (kostenlos, ohne Key, kein Referer-Zwang):
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
subdomains: 'abcd',
maxZoom: 20,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>'
}).addTo(map);
- VERBOTEN sind Anbieter mit API-Key (Mapbox, MapTiler, Thunderforest, Google Maps, Here) und tile.openstreetmap.org – CARTO ist die einzige erlaubte Kartenquelle. Falls du irgendwo einen "API key" sehen solltest, ist es falsch!
- Die Stopps (mit lat/lon) INLINE als JavaScript-Objekt ins HTML schreiben
- Die Routen-Geometrie per fetch() aus der tour.json laden (URL hast du vom save_tour-Ergebnis). Fallback, wenn fetch fehlschlägt: Polylinie direkt durch die Stopps zeichnen
- Nummerierte Marker (CircleMarker mit Zahl), Polylinie (blau), map.fitBounds(...) über alle Punkte
- Buttons über der Karte: "📍 Route in Google Maps öffnen" (googleMapsUrl aus dem save_tour-Ergebnis, target=_blank) und "🗺️ GPX für OsmAnd herunterladen" (gpxUrl, mit download-Attribut)
- Unter der Karte die Stopp-Liste: Nummer, Name, Beschreibung, "Hier hin navigieren"-Link (navUrl pro Stopp)
- Header-Zeile mit Distanz (km) und Gehzeit (min), wenn vorhanden
- Hinweis: "GPX in OsmAnd importieren: Datei herunterladen → Menü → Meine Orte → Importieren"
## Dashboard-Struktur (HTML, komplette Standalone-Datei)
- <html lang="de">, <script src="https://cdn.tailwindcss.com"></script> im Head, responsives Design mit Dark-Mode-Unterstützung (dark: Klassen)
- Header: Titel + Datum + Anzahl verifizierter Links
- Einleitung & Analyse (3-5 Absätze, visuell abgesetzt mit eigenem Styling/Badge "Analyse & Empfehlung")
- Kategorien mit Karten: Titel mit Link (target="_blank" rel="noopener noreferrer"), grüner "Verifiziert"-Badge, Beschreibung (2-3 Sätze), Feature-Liste, "Besuchen"-Button
- Footer: verwendete Suchbegriffe, geprüfte vs. verifizierte Quellen, Zeitstempel
- ALLES auf Deutsch!
## Regeln
- JEDER Link im Dashboard MUSS mit verify() verifiziert sein - füge bei den Karten einen "✓ Verifiziert"-Badge hinzu
- Erfinde NIEMALS Koordinaten, Distanzen oder Gehzeiten – bei Touren kommen die ausschließlich aus geocode() und save_tour()
- Ziele auf 8-15 verifizierte Ressourcen pro Thema
- Bei Login-Walls: old.reddit.com statt reddit.com, nitter.net statt twitter.com; wenn eine Seite blockiert, notiere das und nimm einen anderen Link
- Deine finale Antwort nach dem save_dashboard-Tool-Aufruf ist das komplette Dashboard-HTML in einem \`\`\`html\`\`\`-Codeblock (eine Text-Zusammenfassung ist dann NICHT nötig – Details gehören ins Dashboard).
- Bei Folge-Nachrichten in einer bestehenden Session: Recherchiere die neue Anforderung und überschreibe das bestehende Dashboard, indem du save_dashboard mit DENSELBEN Dateinamen wie zuvor aufrufst (OHNE HTML) und das komplette aktualisierte HTML in deiner finalen Antwort im \`\`\`html\`\`\`-Codeblock sendest.
- Du darfst KEINE anderen URLs erfinden. Nutze ausschließlich Ergebnisse aus search/browse/verify.`;
const TOOL_DEFINITIONS = [
{
type: 'function',
function: {
name: 'search',
description:
'Durchsucht das Web über DuckDuckGo (HTML-Version). Liefert die Top-10 Ergebnisse mit Titel, URL und Kurzbeschreibung. Warte zwischen Suchen 3-5 Sekunden und nutze maximal 4-5 DuckDuckGo-Suchen pro Session, danach Fallback-Suchmaschinen über browse().',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Suchanfrage, z.B. "fediverse instagram alternative pixelfed"' },
},
required: ['query'],
},
},
},
{
type: 'function',
function: {
name: 'browse',
description:
'Besucht eine URL, liest den sichtbaren Text und lädt Produktbilder der Seite lokal herunter. Das Feld `images` enthält die fertigen Bild-URLs (z.B. /dashboards/...) fürs Dashboard. Nutze das zum Lesen von Artikeln, Projektseiten und Fallback-Suchmaschinen (Startpage: https://www.startpage.com/do/dsearch?query=..., Google: https://www.google.com/search?q=...).',
parameters: {
type: 'object',
properties: {
url: { type: 'string', description: 'Die exakte URL, die besucht werden soll' },
},
required: ['url'],
},
},
},
{
type: 'function',
function: {
name: 'verify',
description:
'Verifiziert einen Link: Prüft, ob die Seite erfolgreich lädt und echten Inhalt hat (keine 404-/Fehlerseite). JEDER Link, der ins Dashboard kommt, MUSS mit diesem Tool verifiziert werden.',
parameters: {
type: 'object',
properties: {
url: { type: 'string', description: 'Die exakte URL, die verifiziert werden soll' },
},
required: ['url'],
},
},
},
{
type: 'function',
function: {
name: 'save_dashboard',
description:
'Bereitet das Speichern des HTML-Dashboards vor. Übergib NUR Titel und Dateinamen (OHNE HTML). Danach sendest du das KOMPLETTE HTML in deiner finalen Antwort in einem ```html```-Codeblock. Bei Folge-Nachrichten denselben Dateinamen wie zuvor verwenden, um das Dashboard zu aktualisieren.',
parameters: {
type: 'object',
properties: {
title: { type: 'string', description: 'Titel des Dashboards, z.B. "Fediverse-Alternativen zu Instagram"' },
filename: { type: 'string', description: 'Dateiname ohne Endung, z.B. "fediverse-alternativen". Für Updates denselben Namen wie vorher verwenden.' },
html: { type: 'string', description: 'Optional: komplettes HTML direkt übergeben – nur für kleine Dashboards; bevorzugt ist der Weg über die finale Antwort (```html```-Codeblock).' },
},
required: ['title', 'filename'],
},
},
},
{
type: 'function',
function: {
name: 'geocode',
description:
'Wandelt einen Ortsnamen in Koordinaten um (Nominatim/OpenStreetMap, kostenlos). Nutze das für jeden Stopp einer Tour, z.B. "Brandenburger Tor Berlin". Liefert lat/lon. Maximal 1 Request pro Sekunde.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Ortsangabe, z.B. "Brandenburger Tor Berlin" oder bereits Koordinaten "52.5163, 13.3777"' },
},
required: ['query'],
},
},
},
{
type: 'function',
function: {
name: 'save_tour',
description:
'Erstellt aus einer Liste von Stopps (Name + Koordinaten + Beschreibung) eine komplette Tour: GPX-Datei für OsmAnd, tour.json für die Karte im Dashboard, Google-Maps-Routen-URL mit allen Stopps und Fuß-Navigation pro Stopp. Die Route wird über OSRM als echte Geh-Route berechnet (Fallback: Luftlinie). IMMER vorher jeden Stopp mit geocode() geokodieren!',
parameters: {
type: 'object',
properties: {
title: { type: 'string', description: 'Titel der Tour, z.B. "Tagestour Prag"' },
filename: { type: 'string', description: 'Dateiname ohne Endung, z.B. "prag-tagestour"' },
stops: {
type: 'array',
description: 'Stopps in Besuchsreihenfolge (Start bis Ende)',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Name des Ortes' },
lat: { type: 'number', description: 'Breitengrad' },
lon: { type: 'number', description: 'Längengrad' },
description: { type: 'string', description: 'Kurze Beschreibung (2-3 Sätze)' },
},
required: ['name', 'lat', 'lon'],
},
},
},
required: ['title', 'filename', 'stops'],
},
},
},
];
/** Nur für Vision-Modelle: Seite fotografieren und visuell beurteilen. */
const SCREENSHOT_TOOL = {
type: 'function',
function: {
name: 'screenshot',
description:
'Macht einen Screenshot einer Seite (JPEG) und liefert das Bild in den Kontext. Nutze das bei bildlastigen Seiten, wenn der Text nicht ausreicht (z.B. Anzeigen-Listen, Produktfotos, Infografiken) oder um Produkte visuell zu vergleichen (Farbe, Form). Maximal ~5 Screenshots pro Session.',
parameters: {
type: 'object',
properties: {
url: { type: 'string', description: 'Die exakte URL der Seite' },
full_page: {
type: 'boolean',
description: 'Volle Seitenlänge erfassen (Standard: nur sichtbarer Bereich). Bei langen Seiten zu groß – dort besser weglassen.',
},
},
required: ['url'],
},
},
};
/** Menschlich lesbare Zusammenfassung eines Tool-Ergebnisses für den Chat-Verlauf. */
function summarizeTool(toolName, output) {
let data;
try {
data = JSON.parse(output);
} catch {
return output;
}
switch (toolName) {
case 'search':
return `Suche ergab ${data.results?.length ?? 0} Ergebnisse: ${
(data.results || []).slice(0, 5).map((r) => `${r.title} (${r.url})`).join(', ') || (data.note || '')
}`;
case 'browse':
return `Seite: ${data.title || data.url || ''} – ${String(data.text || '').slice(0, 500)}${
data.images?.length ? ` [${data.images.length} Bilder extrahiert]` : ''
}`;
case 'verify':
return `Link ${data.ok ? 'VERIFIZIERT ✓' : 'FEHLGESCHLAGEN ✗'}: ${data.url}${data.reason ? ' – ' + data.reason : ''}`;
case 'save_dashboard':
return data.note ? `Dashboard vorbereitet – HTML folgt in der finalen Antwort` : `Dashboard gespeichert: ${data.url || ''}`;
case 'geocode':
return data.error
? `Geocoding FEHLGESCHLAGEN: ${data.error}`
: `Geocodiert: ${data.name} -> ${data.lat}, ${data.lon}`;
case 'save_tour':
return data.error
? `Tour-Fehler: ${data.error}`
: `Tour gespeichert: ${data.title || ''} (${data.stops?.length || 0} Stopps, ${data.distanceKm} km, ${data.durationMin} min). GPX: ${data.gpxUrl || ''}`;
case 'screenshot':
return data.error
? `Screenshot FEHLGESCHLAGEN: ${data.error}`
: `Screenshot erstellt: ${data.title || data.url || ''} (Bild liegt im Kontext des Modells, nicht in der History)`;
default:
return String(output).slice(0, 1000);
}
}
const MAX_ITERATIONS = 40;
// Abbruch, wenn der API-Stream länger hängt (z.B. Upstream ohne Keep-Alive)
const REQUEST_TIMEOUT_MS = 10 * 60 * 1000;
// Leere Modell-Antworten (kein Inhalt, keine Tool-Calls) einmal wiederholen
const MAX_EMPTY_RETRIES = 1;
/** Wird geworfen, wenn der Nutzer die Recherche manuell abbricht. */
class JobAbortedError extends Error {}
/** Baut den System-Prompt inkl. aktuellem Dashboard-Stand und Vision-Hinweisen. */
function buildSystemPrompt(session, model) {
let prompt = SYSTEM_PROMPT;
if (session.dashboard) {
const file = session.dashboard.split('/').pop().replace(/\.html$/, '');
prompt += `
## Aktueller Stand dieser Session
Für diese Session existiert bereits ein Dashboard: ${session.dashboard}
Wenn der Nutzer Änderungen, Ergänzungen oder Erweiterungen wünscht, MUSST du das save_dashboard-Tool mit EXAKT diesem Dateinamen aufrufen: "${file}" (ohne .html) – OHNE HTML im Aufruf. Das KOMPLETTE aktualisierte HTML sendest du danach in deiner finalen Antwort in einem \`\`\`html\`\`\`-Codeblock.
Eine bloße Erklärung oder Beschreibung der Änderungen im Chat ist KEINE Umsetzung – erst wenn der Codeblock gespeichert wurde, ist die Aufgabe erfüllt.`;
}
if (model.vision) {
prompt += `
## Bilderkennung & visuelle Produktsuche (Vision)
- Hat der Nutzer ein Bild hochgeladen (z.B. einen Schuh): Analysiere es SORGFÄLTIG – Gattung/Modelltyp (z.B. Sneaker, Loafer, Stiefelette), Farben (Haupt-, Neben- und Sohlenfarbe), Material (Leder, Mesh, Wildleder), Form/Details (Sohle, Schnürung, Logo, Muster), Marke und Zustand.
- Leite daraus 4-8 Suchbegriffe ab, Deutsch UND Englisch (z.B. "weißer minimalistischer leder-sneaker" / "white minimalist leather sneaker"), und suche gezielt nach ähnlichen Produkten und Angeboten. Priorität: Farbe, Form und Modelltyp.
- Das browse-Tool lädt Produktbilder der Seite automatisch lokal herunter und liefert im Feld \`images\` fertige Dashboard-URLs (z.B. /dashboards/session-img-abc123.jpg). Bette sie direkt ein: <img src="..." alt="..." loading="lazy">. Nur diese URLs verwenden – niemals erfinden.
- Baue eine Sektion "Visuelle Treffer" ins Dashboard: pro Treffer Produktbild, Name, ggf. Preis, verifizierter Link und eine kurze Begründung, warum er passt (Farbe/Form/Material).
- Nutze das screenshot-Tool, um bildlastige Seiten (Anzeigen-Listen, Produktseiten) visuell zu vergleichen, wenn der Text nicht ausreicht – achte auf Farbton, Silhouette und Sohlenform. Maximal ~5 Screenshots pro Session; priorisiere die aussichtsreichsten Treffer.
- Das hochgeladene Bild liegt dir als Data-URL im Verlauf vor – du kannst es als Referenz oben im Dashboard einbetten und beschreiben, worauf du beim Vergleich geachtet hast.`;
}
return prompt;
}
/** Erkennt Änderungswünsche am Dashboard in der Nutzernachricht. */
function isChangeRequest(text) {
return /dashboard|anpassen|anpass|ändern|änder|ergänz|erweiter|aktualisier|update|umbau|hinzufüg|hinzufueg|modifizier|vervollständig|vervollstaendig/i.test(
text
);
}
/** HTML aus der finalen Antwort extrahieren (```html```-Codeblock oder direktes HTML). */
function extractHtmlBlock(content) {
const s = String(content || '');
const m = s.match(/```html\s*([\s\S]*?)```/i);
if (m && m[1].trim()) return m[1].trim();
const t = s.trim();
if (/^<!DOCTYPE html/i.test(t) || /^<html[\s>]/i.test(t)) return t;
return null;
}
/** Codeblöcke aus der finalen Antwort entfernen (für die Chat-Anzeige). */
function stripHtmlBlock(content) {
return String(content || '').replace(/```html\s*[\s\S]*?```/gi, '');
}
async function chatCompletion(config, messages, systemPrompt, model, tools, signal, effortOverride) {
const body = {
model: model.id,
messages: [{ role: 'system', content: systemPrompt }, ...messages],
tools,
stream: true,
max_tokens: model.maxTokens || config.maxTokens || 64000,
stream_options: { include_usage: true },
};
const baseUrl = config.baseUrl || 'https://api.deepseek.com';
if (effortOverride === 'none' || config.thinking === 'disabled') {
// DeepSeek: Denken pro Request abschaltbar (Liefer-Runde des Dashboards)
body.thinking = { type: 'disabled' };
} else {
body.thinking = { type: 'enabled' };
body.reasoning_effort = effortOverride || model.reasoningEffort?.() || 'max';
}
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const fetchSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
let resp;
try {
resp = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: fetchSignal,
});
} catch (e) {
clearTimeout(timer);
if (signal?.aborted) throw new JobAbortedError();
throw new Error(`Modell-API nicht erreichbar (${model.id}): ${e.message.slice(0, 200)}`);
}
clearTimeout(timer);
if (!resp.ok) {
const err = await resp.text().catch(() => '');
throw new Error(`Modell-API Fehler ${resp.status} (${model.id}): ${err.slice(0, 500)}`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const delta = { content: '', reasoning: '', toolCalls: null, finishReason: null, usage: null };
let toolCallIndex = -1;
const feed = (line) => {
if (!line.startsWith('data:')) return;
const payload = line.slice(5).trim();
if (payload === '[DONE]') return;
let json;
try {
json = JSON.parse(payload);
} catch {
return;
}
if (json.usage) delta.usage = json.usage;
const choice = json.choices?.[0];
if (!choice) return;
const d = choice.delta || {};
// DeepSeek liefert den Denk-Text als reasoning_content
if (d.reasoning_content) delta.reasoning += d.reasoning_content;
if (d.content) delta.content += d.content;
if (d.tool_calls) {
for (const tc of d.tool_calls) {
if (tc.index != null) {
if (tc.index > toolCallIndex) {
toolCallIndex = tc.index;
if (!delta.toolCalls) delta.toolCalls = [];
delta.toolCalls.push({ id: '', type: 'function', function: { name: '', arguments: '' } });
}
const cur = delta.toolCalls[tc.index];
if (tc.id) cur.id = tc.id;
if (tc.function?.name) cur.function.name += tc.function.name;
if (tc.function?.arguments) cur.function.arguments += tc.function.arguments;
}
}
}
if (choice.finish_reason) delta.finishReason = choice.finish_reason;
else if (d.finish_reason) delta.finishReason = d.finish_reason;
};
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (line.trim()) feed(line);
}
}
if (buffer.trim()) feed(buffer);
// Stream ohne Abschlussstatus: unvollständig beendet (Upstream-Fehler)
if (!delta.finishReason && !delta.content && !delta.toolCalls) {
throw new Error(`Modell ${model.id}: Stream unvollständig beendet (keine Antwort erhalten). Bitte erneut versuchen.`);
}
// Antwort abgeschnitten (max_tokens erreicht): Wenn weder Inhalt noch
// vollständige Tool-Calls vorhanden sind, endet die Recherche sonst
// stillschweigend ohne Ergebnis. Dann lieber sichtbar abbrechen.
if (delta.finishReason === 'length') {
const hasCalls = Array.isArray(delta.toolCalls) && delta.toolCalls.length > 0;
const callsComplete = !hasCalls || delta.toolCalls.every((tc) => {
try {
JSON.parse(tc.function.arguments || '{}');
return true;
} catch {
return false;
}
});
if (!hasCalls && !delta.content) {
throw new Error(
`Modell-Antwort abgeschnitten (Token-Limit bei ${model.id} erreicht) – Ergebnis unvollständig. Bitte erneut versuchen oder ein anderes Modell wählen.`
);
}
if (hasCalls && !callsComplete) {
throw new Error(
`Tool-Aufruf abgeschnitten (Token-Limit bei ${model.id} erreicht) – Recherche wird abgebrochen. Bitte erneut versuchen.`
);
}
}
return {
content: delta.content,
reasoning: delta.reasoning,
toolCalls: delta.toolCalls,
finishReason: delta.finishReason,
usage: delta.usage,
};
}
/**
* Führt den Research-Job für eine Session aus.
* @param {object} config {apiKey, model, thinking}
* @param {object} session Session aus DB
* @param {string} userMessageText Text der neuen Benutzernachricht
* @param {object} emit Event-Helfer: { token(t), thinking(t), progress(icon, text), step(msg), done(obj), error(msg) }
* @param {object} db Datenbankmodul
*/
async function runResearchJob(config, session, userMessageText, emit, db, signal) {
let dashboardUrl = session.dashboard || null;
let dashboardSaved = false;
let pendingDashboard = null;
// Sobald das Dashboard vorbereitet ist, läuft die nächste Runde mit minimalem
// Denken – das Budget gehört dann dem kompletten HTML (siehe deliveryEffort).
let deliveryEffort = null;
const model = getModel(session.model || config.model);
const toolDefs = model.vision ? [...TOOL_DEFINITIONS, SCREENSHOT_TOOL] : TOOL_DEFINITIONS;
const prices = model.prices;
const isAborted = () => signal?.aborted === true;
const handlers = {
async search({ query }) {
emit.progress('🔍', `Suche: ${query}`);
const result = await tools.search(query, (m) => emit.step(m));
if (result.blocked) emit.progress('⚠️', 'DuckDuckGo blockiert – Fallback-Strategie nutzen');
return JSON.stringify(result);
},
async browse({ url }) {
emit.progress('🌐', `Besuche: ${url}`);
const result = await tools.browse(url, (m) => emit.step(m), { sessionId: session.id });
return JSON.stringify(result);
},
async screenshot({ url, full_page }) {
emit.progress('📷', `Screenshot: ${url}`);
return tools.screenshot(url, !!full_page, (m) => emit.step(m));
},
async verify({ url }) {
emit.progress('✅', `Verifiziere: ${url}`);
const result = await tools.verify(url, (m) => emit.step(m));
return JSON.stringify(result);
},
async save_dashboard({ title, filename, html }) {
if (html && String(html).trim()) {
// Alter Weg: HTML direkt im Tool-Aufruf (nur kleine Dashboards)
emit.progress('📊', 'Dashboard wird gespeichert…');
const saved = tools.saveDashboard(session.id, filename || title, html, (m) => emit.step(m));
dashboardUrl = saved.url;
dashboardSaved = true;
db.setSessionDashboard(session.id, saved.url);
emit.progress('📊', `Dashboard gespeichert: ${saved.file}`);
return JSON.stringify(saved);
}
// Neuer Weg: nur vorbereiten, HTML kommt in der finalen Antwort
pendingDashboard = { title: title || 'Dashboard', filename: filename || null };
deliveryEffort = model.deliveryEffort || 'none';
emit.progress(
'📊',
`Dashboard vorbereitet (${(filename || title || 'dashboard').toLowerCase().replace(/[^a-z0-9äöü-]+/g, '-')}.html) – sende jetzt das komplette HTML in deiner finalen Antwort als \`\`\`html\`\`\`-Codeblock…`
);
return JSON.stringify({
ok: true,
note: 'Dashboard vorbereitet. Sende jetzt das komplette HTML in deiner finalen Antwort in einem ```html```-Codeblock. Kein weiterer Tool-Aufruf nötig.',
});
},
async geocode({ query }) {
emit.progress('📍', `Geocode: ${query}`);
const result = await tools.geocode(query, config, (m) => emit.step(m));
return JSON.stringify(result);
},
async save_tour({ title, filename, stops }) {
emit.progress('🗺️', `Tour wird erstellt: ${title || ''}`);
const result = await tools.saveTour({ stops, title, filename, sessionId: session.id }, config, (m) => emit.step(m));
return JSON.stringify(result);
},
};
const apiMessages = db.toApiMessages(session.id, { provider: model.provider, vision: model.vision });
const text = userMessageText;
const usage = { promptTokens: 0, completionTokens: 0, cacheHitTokens: 0, cacheMissTokens: 0 };
let emptyRetries = 0;
emit.progress('🤖', `Research startet mit ${model.label}…`);
const trackUsage = (u) => {
if (!u) return;
usage.promptTokens += u.prompt_tokens || 0;
usage.completionTokens += u.completion_tokens || 0;
usage.cacheHitTokens += u.prompt_cache_hit_tokens || 0;
usage.cacheMissTokens += u.prompt_cache_miss_tokens || 0;
if (!usage.cacheHitTokens && !usage.cacheMissTokens) {
usage.cacheMissTokens = u.prompt_tokens || 0;
}
};
const finishJob = (finalText, dashboardUrl) => {
db.setSessionUsage(session.id, usage, prices);
db.setSessionStatus(session.id, 'done');
if (dashboardUrl) db.setSessionDashboard(session.id, dashboardUrl);
const costEstimate = db.computeCost(usage, prices);
emit.done({ text: finalText, dashboardUrl, usage, costEstimate, model: model.id });
return { text: finalText, dashboardUrl, usage, costEstimate, model: model.id };
};
const finishAborted = () => {
db.createMessage(session.id, { role: 'assistant', content: '⛔ Recherche abgebrochen.' });
db.setSessionUsage(session.id, usage, prices);
db.setSessionStatus(session.id, 'done');
const costEstimate = db.computeCost(usage, prices);
emit.aborted?.();
return { text: '⛔ Recherche abgebrochen.', dashboardUrl, usage, costEstimate, model: model.id, aborted: true };
};
for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
if (isAborted()) return finishAborted();
emit.progress('🤖', iter === 0 ? 'Analysiere Anfrage und plane Suche…' : 'Weitere Analyse…');
let result;
try {
result = await chatCompletion(config, apiMessages, buildSystemPrompt(session, model), model, toolDefs, signal, deliveryEffort);
} catch (e) {
if (isAborted() || e instanceof JobAbortedError) return finishAborted();
throw e;
}
trackUsage(result.usage);
if (result.reasoning) emit.thinking(result.reasoning);
if (result.toolCalls && result.toolCalls.length > 0) {
const assistantMsg = {
role: 'assistant',
content: result.content || null,
tool_calls: result.toolCalls.map((tc) => ({
id: tc.id,
type: 'function',
function: { name: tc.function.name, arguments: tc.function.arguments },
})),
};
// DeepSeek: Reasoning-Text muss bei tools-Requests mit zurückgegeben werden
if (model.provider === 'deepseek' && result.reasoning) assistantMsg.reasoning_content = result.reasoning;
db.createMessage(session.id, {
role: 'assistant',
content: assistantMsg.content,
toolCalls: assistantMsg.tool_calls,
reasoning: result.reasoning || null,
});
apiMessages.push(assistantMsg);
for (const tc of result.toolCalls) {
const fn = handlers[tc.function.name];
let output;
if (!fn) {
output = JSON.stringify({ error: `Unbekanntes Tool: ${tc.function.name}` });
} else {
try {
const args = JSON.parse(tc.function.arguments || '{}');
output = await fn(args);
} catch (e) {
output = JSON.stringify({ error: `Tool-Fehler: ${e.message.slice(0, 300)}` });
}
}
const outStr = typeof output === 'string' ? output : JSON.stringify(output);
db.createMessage(session.id, {
role: 'tool',
toolName: tc.function.name,
toolCallId: tc.id,
content: summarizeTool(tc.function.name, outStr).slice(0, 8000),
});
if (output && typeof output === 'object' && output.image) {
// Screenshot: Bild nur im laufenden Verlauf, DB bekommt die Zusammenfassung
apiMessages.push({
role: 'tool',
tool_call_id: tc.id,
content: [
{ type: 'text', text: (output.text || output.title || '').slice(0, 2000) },
{ type: 'image_url', image_url: { url: output.image } },
],
});
} else {
apiMessages.push({ role: 'tool', tool_call_id: tc.id, content: outStr.slice(0, 8000) });
}
}
if (isAborted()) return finishAborted();
continue;
}
// Leere Antwort: Modell-Glitch oder abgebrochener Stream
if (!result.content) {
if (!pendingDashboard && !dashboardUrl && emptyRetries < MAX_EMPTY_RETRIES) {
emptyRetries++;
emit.progress('🔄', 'Leere Antwort erhalten – versuche es erneut…');
continue;
}
if (pendingDashboard) {
throw new Error(
`Modell ${model.id} hat das Dashboard-HTML nicht geliefert (leere Antwort). Bitte erneut versuchen.`
);
}
if (!dashboardUrl) {
const hint = result.reasoning
? ` Letzter Gedanke: „${String(result.reasoning).slice(-200)}“`
: '';
throw new Error(
`Modell ${model.id} hat keine Antwort geliefert.${hint} Bitte erneut versuchen oder ein anderes Modell wählen.`
);
}
}
// Dashboard vorbereitet → HTML aus der finalen Antwort extrahieren und speichern
if (pendingDashboard && result.content) {
const html = extractHtmlBlock(result.content);
if (!html) {
if (/```html/i.test(result.content)) {
throw new Error(
`Modell ${model.id}: Dashboard-HTML unvollständig (Codeblock nicht geschlossen) – möglicherweise wurde das Token-Limit erreicht. Bitte erneut versuchen oder ein kleineres Dashboard anfragen.`
);
}
throw new Error(
`Modell ${model.id} hat das Dashboard-HTML nicht geliefert (kein \`\`\`html\`\`\`-Codeblock in der Antwort). Bitte erneut versuchen.`
);
}
const saved = tools.saveDashboard(
session.id,
pendingDashboard.filename || pendingDashboard.title,
html,
(m) => emit.step(m)
);
dashboardUrl = saved.url;
dashboardSaved = true;
db.setSessionDashboard(session.id, saved.url);
emit.progress('📊', `Dashboard gespeichert: ${saved.file}`);
result.content = stripHtmlBlock(result.content).trim() || `Fertig! Dein Dashboard ist online: ${saved.url}`;
}
const finalText = result.content || (dashboardUrl ? 'Recherche abgeschlossen – siehe Dashboard.' : 'Recherche abgeschlossen.');
if (!dashboardSaved && dashboardUrl && isChangeRequest(userMessageText)) {
emit.progress(
'⚠️',
'Achtung: Das Dashboard wurde nicht aktualisiert – der Agent hat save_dashboard nicht aufgerufen.'
);
}
db.createMessage(session.id, {
role: 'assistant',
content: finalText,
reasoning: result.reasoning || null,
});
return finishJob(finalText, dashboardUrl);
}
db.createMessage(session.id, {
role: 'assistant',
content: 'Maximale Iterationen erreicht – Recherche abgebrochen. Bitte neue Session starten.',
});
db.setSessionUsage(session.id, usage, prices);
db.setSessionStatus(session.id, 'done');
emit.error('Maximale Iterationen erreicht – Recherche abgebrochen.');
return { text: '', dashboardUrl, usage, costEstimate: db.computeCost(usage, prices), model: model.id };
}
module.exports = { runResearchJob, SYSTEM_PROMPT, TOOL_DEFINITIONS };