342 lines
15 KiB
JavaScript
342 lines
15 KiB
JavaScript
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)
|
||
- <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
|
||
- 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 };
|