const tools = require('./tools'); 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 mit dem save_dashboard-Tool (Detailstruktur unten) und fasse dann in deiner finalen Antwort das Wichtigste zusammen. ## Dashboard-Struktur (HTML, komplette Standalone-Datei) - , 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 - 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 - Die finale Antwort ist eine deutsche Zusammenfassung (max. 300 Wörter): wichtigste Erkenntnisse + Link zum Dashboard. Details gehören ins Dashboard, nicht in die Antwort. - Bei Folge-Nachrichten in einer bestehenden Session: Recherchiere die neue Anforderung und überschreibe das bestehende Dashboard mit dem save_dashboard-Tool, indem du DENSELBEN Dateinamen wie zuvor verwendest. - 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 und liest den sichtbaren Text der Seite (Titel + Inhalt). 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: 'Speichert das fertige HTML-Dashboard als Datei auf dem Server und liefert die URL zurück. Übergib die KOMPLETTE standalone HTML-Datei (mit Tailwind CDN, deutsch, verifizierte Links mit Badges, Einleitung mit Analyse). Nutze bei Folge-Nachrichten den bisherigen Dateinamen, 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: 'Die komplette standalone HTML-Datei des Dashboards' }, }, required: ['title', 'filename', 'html'], }, }, }, ]; /** 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)}`; case 'verify': return `Link ${data.ok ? 'VERIFIZIERT ✓' : 'FEHLGESCHLAGEN ✗'}: ${data.url}${data.reason ? ' – ' + data.reason : ''}`; case 'save_dashboard': return `Dashboard gespeichert: ${data.url || ''}`; default: return String(output).slice(0, 1000); } } const MAX_ITERATIONS = 40; async function chatCompletion(config, messages) { const body = { model: config.model, messages, tools: TOOL_DEFINITIONS, stream: true, max_tokens: 32000, stream_options: { include_usage: true }, }; if (config.thinking !== 'disabled') { body.thinking = { type: 'enabled' }; body.reasoning_effort = 'medium'; } const resp = await fetch(`${config.baseUrl || 'https://api.deepseek.com'}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.apiKey}`, }, body: JSON.stringify(body), }); if (!resp.ok) { const err = await resp.text().catch(() => ''); throw new Error(`DeepSeek API Fehler ${resp.status}: ${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 || {}; 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; }; 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); 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) { let dashboardUrl = session.dashboard || null; let dashboardFile = dashboardUrl ? dashboardUrl.split('/').pop().replace('.html', '') : null; 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)); return JSON.stringify(result); }, 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 }) { emit.progress('📊', 'Dashboard wird gespeichert…'); const saved = tools.saveDashboard(session.id, filename || title, html, (m) => emit.step(m)); dashboardUrl = saved.url; dashboardFile = saved.file.replace('.html', ''); db.setSessionDashboard(session.id, saved.url); emit.progress('📊', `Dashboard gespeichert: ${saved.file}`); return JSON.stringify(saved); }, }; const apiMessages = db.toApiMessages(session.id); const text = userMessageText; const usage = { promptTokens: 0, completionTokens: 0, cacheHitTokens: 0, cacheMissTokens: 0 }; emit.progress('🤖', 'Research startet…'); 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); db.setSessionStatus(session.id, 'done'); if (dashboardUrl) db.setSessionDashboard(session.id, dashboardUrl); const costEstimate = db.computeCost(usage); emit.done({ text: finalText, dashboardUrl, usage, costEstimate }); return { text: finalText, dashboardUrl, usage, costEstimate }; }; for (let iter = 0; iter < MAX_ITERATIONS; iter++) { emit.progress('🤖', iter === 0 ? 'Analysiere Anfrage und plane Suche…' : 'Weitere Analyse…'); const result = await chatCompletion(config, apiMessages); 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 }, })), }; db.createMessage(session.id, { role: 'assistant', content: assistantMsg.content, toolCalls: assistantMsg.tool_calls }); 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)}` }); } } db.createMessage(session.id, { role: 'tool', toolName: tc.function.name, toolCallId: tc.id, content: summarizeTool(tc.function.name, output).slice(0, 8000), }); apiMessages.push({ role: 'tool', tool_call_id: tc.id, content: output.slice(0, 8000) }); } continue; } const finalText = result.content || (dashboardUrl ? 'Recherche abgeschlossen – siehe Dashboard.' : ''); db.createMessage(session.id, { role: 'assistant', content: finalText }); 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); db.setSessionStatus(session.id, 'done'); emit.error('Maximale Iterationen erreicht – Recherche abgebrochen.'); return { text: '', dashboardUrl, usage, costEstimate: db.computeCost(usage) }; } module.exports = { runResearchJob, SYSTEM_PROMPT, TOOL_DEFINITIONS };