Research Chat: Web-Chatoberfläche für den Research-Agent (DeepSeek v4 Flash + Playwright, Docker/Podman)

This commit is contained in:
nico committed 2026-08-26 16:34:03 +02:00
commit a0243d494c
23 files changed
+2253

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const webpush = require('web-push');
const ENV_PATH = path.join(__dirname, '..', '.env');
function loadEnv() {
const env = {};
if (fs.existsSync(ENV_PATH)) {
for (const line of fs.readFileSync(ENV_PATH, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (m && !line.trim().startsWith('#')) env[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
}
return env;
}
function saveEnv(env) {
const lines = [];
for (const [k, v] of Object.entries(env)) lines.push(`${k}=${v}`);
fs.writeFileSync(ENV_PATH, lines.join('\n') + '\n', 'utf8');
}
function ensure(key, generator) {
if (process.env[key] || env[key]) return process.env[key] || env[key];
const value = generator();
env[key] = value;
saveEnv(env);
return value;
}
const env = loadEnv();
function ensureVapidPair() {
if ((process.env.VAPID_PUBLIC_KEY || env.VAPID_PUBLIC_KEY) && (process.env.VAPID_PRIVATE_KEY || env.VAPID_PRIVATE_KEY)) {
return {
publicKey: process.env.VAPID_PUBLIC_KEY || env.VAPID_PUBLIC_KEY,
privateKey: process.env.VAPID_PRIVATE_KEY || env.VAPID_PRIVATE_KEY,
};
}
const pair = webpush.generateVAPIDKeys();
env.VAPID_PUBLIC_KEY = pair.publicKey;
env.VAPID_PRIVATE_KEY = pair.privateKey;
saveEnv(env);
return pair;
}
const vapid = ensureVapidPair();
const config = {
port: Number(process.env.PORT || env.PORT || 3000),
apiKey: process.env.DEEPSEEK_API_KEY || env.DEEPSEEK_API_KEY || '',
appPassword: process.env.APP_PASSWORD || env.APP_PASSWORD || '',
model: process.env.MODEL || env.MODEL || 'deepseek-v4-flash',
thinking: process.env.THINKING || env.THINKING || 'enabled',
baseUrl: process.env.DEEPSEEK_BASE_URL || env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com',
sessionSecret: ensure('SESSION_SECRET', () => crypto.randomBytes(32).toString('hex')),
vapidPublicKey: vapid.publicKey,
vapidPrivateKey: vapid.privateKey,
};
module.exports = { config };
+194
View File
@@ -0,0 +1,194 @@
const path = require('path');
const { DatabaseSync } = require('node:sqlite');
const crypto = require('crypto');
const DATA_DIR = path.join(__dirname, '..', 'data');
const DB_PATH = path.join(DATA_DIR, 'research.db');
const fs = require('fs');
fs.mkdirSync(DATA_DIR, { recursive: true });
const db = new DatabaseSync(DB_PATH);
db.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT DEFAULT 'Neue Recherche',
owner_name TEXT NOT NULL,
share_token TEXT UNIQUE,
status TEXT DEFAULT 'idle',
dashboard TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
tool_name TEXT,
tool_call_id TEXT,
tool_calls TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_name TEXT NOT NULL,
endpoint TEXT UNIQUE NOT NULL,
keys TEXT NOT NULL,
created_at TEXT NOT NULL
);
`);
const now = () => new Date().toISOString();
const uid = () => crypto.randomBytes(12).toString('hex');
const shareToken = () => crypto.randomBytes(16).toString('hex');
// DeepSeek v4 Flash Preise (USD pro 1M Tokens, Peak) – obere Schätzung
const PRICES = { inputCacheHit: 0.014, inputCacheMiss: 0.44, output: 1.32 };
function computeCost(u) {
return (
((u.cacheHitTokens || 0) * PRICES.inputCacheHit +
(u.cacheMissTokens || 0) * PRICES.inputCacheMiss +
(u.completionTokens || 0) * PRICES.output) /
1e6
);
}
// Migration: Usage-Spalten ergänzen (falls Tabelle älter ist)
{
const cols = db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name);
const add = (name, def) => {
if (!cols.includes(name)) db.exec(`ALTER TABLE sessions ADD COLUMN ${name} ${def}`);
};
add('prompt_tokens', 'INTEGER DEFAULT 0');
add('completion_tokens', 'INTEGER DEFAULT 0');
add('cache_hit_tokens', 'INTEGER DEFAULT 0');
add('cache_miss_tokens', 'INTEGER DEFAULT 0');
add('cost_estimate', 'REAL DEFAULT 0');
}
const stmts = {
createSession: db.prepare(
`INSERT INTO sessions (id, title, owner_name, share_token, status, created_at, updated_at)
VALUES (?, ?, ?, ?, 'idle', ?, ?)`
),
getSession: db.prepare(`SELECT * FROM sessions WHERE id = ?`),
getSessionByShare: db.prepare(`SELECT * FROM sessions WHERE share_token = ?`),
listSessions: db.prepare(`SELECT * FROM sessions ORDER BY updated_at DESC`),
updateSession: db.prepare(
`UPDATE sessions SET title = ?, status = ?, dashboard = ?, updated_at = ? WHERE id = ?`
),
setSessionStatus: db.prepare(
`UPDATE sessions SET status = ?, updated_at = ? WHERE id = ?`
),
setSessionTitle: db.prepare(`UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?`),
setSessionDashboard: db.prepare(`UPDATE sessions SET dashboard = ?, updated_at = ? WHERE id = ?`),
addUsage: db.prepare(
`UPDATE sessions SET prompt_tokens = prompt_tokens + ?, completion_tokens = completion_tokens + ?,
cache_hit_tokens = cache_hit_tokens + ?, cache_miss_tokens = cache_miss_tokens + ?,
cost_estimate = cost_estimate + ? WHERE id = ?`
),
deleteSession: db.prepare(`DELETE FROM sessions WHERE id = ?`),
insertMessage: db.prepare(
`INSERT INTO messages (session_id, role, content, tool_name, tool_call_id, tool_calls, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
),
listMessages: db.prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY id ASC`),
lastMessage: db.prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT 1`),
updateMessageContent: db.prepare(`UPDATE messages SET content = ? WHERE id = ?`),
deleteMessages: db.prepare(`DELETE FROM messages WHERE session_id = ?`),
insertPushSub: db.prepare(
`INSERT INTO push_subscriptions (user_name, endpoint, keys, created_at) VALUES (?, ?, ?, ?)`
),
deletePushSub: db.prepare(`DELETE FROM push_subscriptions WHERE endpoint = ?`),
listPushSubs: db.prepare(`SELECT * FROM push_subscriptions`),
};
function createSession(ownerName, title = 'Neue Recherche') {
const id = uid();
stmts.createSession.run(id, title, ownerName, shareToken(), now(), now());
return stmts.getSession.get(id);
}
function createMessage(sessionId, { role, content, toolName, toolCallId, toolCalls }) {
stmts.insertMessage.run(
sessionId,
role,
content ?? null,
toolName ?? null,
toolCallId ?? null,
toolCalls ? JSON.stringify(toolCalls) : null,
now()
);
return stmts.lastMessage.get(sessionId);
}
function getSessionMessages(sessionId) {
return stmts.listMessages.all(sessionId).map((m) => ({
...m,
tool_calls: m.tool_calls ? JSON.parse(m.tool_calls) : null,
}));
}
/** Baut aus dem Chat-Verlauf das Messages-Array für die DeepSeek-API. */
function toApiMessages(sessionId) {
const msgs = getSessionMessages(sessionId);
const out = [];
for (const m of msgs) {
if (m.role === 'user') out.push({ role: 'user', content: m.content });
else if (m.role === 'assistant') {
const msg = { role: 'assistant' };
if (m.tool_calls) msg.tool_calls = m.tool_calls;
if (m.content) msg.content = m.content;
else if (!m.tool_calls) msg.content = '';
out.push(msg);
} else if (m.role === 'tool') {
out.push({ role: 'tool', tool_call_id: m.tool_call_id, content: m.content ?? '' });
}
}
return out.slice(-60);
}
module.exports = {
db,
createSession,
createMessage,
getSession: stmts.getSession.get.bind(stmts.getSession),
getSessionByShare: stmts.getSessionByShare.get.bind(stmts.getSessionByShare),
listSessions: stmts.listSessions.all.bind(stmts.listSessions),
setSessionStatus: (id, status) => stmts.setSessionStatus.run(status, now(), id),
setSessionTitle: (id, title) => stmts.setSessionTitle.run(title, now(), id),
setSessionDashboard: (id, dashboard) => stmts.setSessionDashboard.run(dashboard, now(), id),
setSessionUsage: (id, u) =>
stmts.addUsage.run(
u.promptTokens || 0,
u.completionTokens || 0,
u.cacheHitTokens || 0,
u.cacheMissTokens || 0,
computeCost(u),
id
),
computeCost,
deleteSession: (id) => stmts.deleteSession.run(id),
getSessionMessages,
toApiMessages,
upsertPushSub(userName, endpoint, keys) {
try {
stmts.insertPushSub.run(userName, endpoint, JSON.stringify(keys), now());
} catch {
stmts.deletePushSub.run(endpoint);
stmts.insertPushSub.run(userName, endpoint, JSON.stringify(keys), now());
}
},
removePushSub: stmts.deletePushSub.run.bind(stmts.deletePushSub),
listPushSubs: stmts.listPushSubs.all.bind(stmts.listPushSubs),
};
+48
View File
@@ -0,0 +1,48 @@
const { EventEmitter } = require('node:events');
const events = new EventEmitter();
events.setMaxListeners(100);
let queue = [];
let running = false;
/** Einen Job anmelden. Läuft sofort, wenn nichts anderes läuft, sonst wartet er. */
function enqueue(job) {
queue.push(job);
events.emit('job-queued', job.sessionId);
processNext();
}
/** Job aus der Warteschlange holen und ausführen. */
async function processNext() {
if (running || queue.length === 0) return;
const job = queue.shift();
running = true;
try {
await job.run();
} catch (e) {
job.onError?.(e);
} finally {
running = false;
events.emit('job-finished', job.sessionId);
processNext();
}
}
function queuedBefore(sessionId) {
return queue.findIndex((j) => j.sessionId === sessionId);
}
function isRunning(sessionId) {
return running;
}
function cancelQueued(sessionId) {
queue = queue.filter((j) => j.sessionId !== sessionId);
}
function state() {
return { running, queued: queue.map((j) => j.sessionId) };
}
module.exports = { events, enqueue, queuedBefore, isRunning, cancelQueued, state };
+341
View File
@@ -0,0 +1,341 @@
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 };
+154
View File
@@ -0,0 +1,154 @@
const path = require('path');
const fs = require('fs');
const { chromium } = require('playwright');
const DASHBOARD_DIR = path.join(__dirname, '..', 'dashboards');
let browser = null;
const USER_AGENT =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36';
/** DDG-Redirect-URLs (duckduckgo.com/l/?uddg=...) auf die echte URL auflösen. */
function realUrl(href) {
try {
const u = new URL(href);
if (u.hostname === 'duckduckgo.com' && u.pathname.startsWith('/l/') && u.searchParams.get('uddg')) {
return u.searchParams.get('uddg');
}
} catch {}
return href;
}
async function getBrowser() {
if (!browser || !browser.isConnected()) {
browser = await chromium.launch({
channel: 'chromium',
headless: true,
args: ['--disable-blink-features=AutomationControlled', '--no-sandbox'],
});
}
return browser;
}
async function withPage(url, fn) {
const b = await getBrowser();
const page = await b.newPage({ userAgent: USER_AGENT, viewport: { width: 1280, height: 900 } });
try {
return await fn(page);
} finally {
await page.close();
}
}
async function goto(page, url, timeout = 30000) {
let resp;
try {
resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout });
} catch (e) {
return { ok: false, error: `Navigation fehlgeschlagen: ${e.message.slice(0, 200)}` };
}
await page.waitForTimeout(1200);
return {
ok: resp ? resp.status() < 400 : true,
status: resp ? resp.status() : 0,
finalUrl: page.url(),
};
}
/** DuckDuckGo HTML-Suche: Ergebnisse parsen. */
async function search(query, log) {
const url = `https://duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
const results = await withPage(url, async (page) => {
const nav = await goto(page, url);
if (!nav.ok) return { nav, results: [], note: 'Suchseite nicht erreichbar' };
await page.waitForTimeout(2500);
const links = await page
.locator('a.result__a')
.evaluateAll((els) =>
els.slice(0, 10).map((a) => ({
title: (a.textContent || '').trim(),
url: a.href,
}))
);
for (const l of links) l.url = realUrl(l.url);
const snippets = await page
.locator('.result__snippet')
.evaluateAll((els) => els.slice(0, 10).map((e) => (e.textContent || '').trim()));
const blocked =
(await page.title().catch(() => '')) === 'CAPTCHA' ||
(await page.locator('text=CAPTCHA').count().catch(() => 0)) > 0;
return {
nav,
blocked,
results: links.map((l, i) => ({ ...l, snippet: snippets[i] || '' })),
};
});
log?.(`Suche "${query}" -> ${results.results.length} Ergebnisse`);
return results;
}
/** Seite besuchen und lesbaren Text extrahieren. */
async function browse(url, log) {
return withPage(url, async (page) => {
const nav = await goto(page, url);
if (!nav.ok) return { nav, error: 'Seite konnte nicht geladen werden' };
const data = await page.evaluate(() => {
const title = document.title || '';
const text = (document.body ? document.body.innerText : '').replace(/\s+\n/g, '\n');
return { title, text: text.slice(0, 15000) };
});
log?.(`Besuche ${url} -> ${data.title}`);
return { nav, title: data.title, text: data.text };
});
}
/** Link prüfen: lädt die Seite erfolgreich und hat sie echten Inhalt? */
async function verify(url, log) {
return withPage(url, async (page) => {
const nav = await goto(page, url, 25000);
if (!nav.ok) {
log?.(`Verifizierung FEHLGESCHLAGEN (${nav.status || 'Timeout'}): ${url}`);
return { url, ok: false, reason: `Seite lädt nicht (${nav.status || 'Timeout'})` };
}
const title = await page.title().catch(() => '');
const bodyLen = await page.evaluate(() => document.body?.innerText?.length || 0).catch(() => 0);
const badTitle = /404|not found|seite nicht gefunden|page not found/i.test(title);
const ok = !badTitle && bodyLen > 200;
log?.(`${ok ? 'VERIFIZIERT' : 'FEHLGESCHLAGEN'}: ${url}`);
return {
url,
ok,
reason: ok ? '' : badTitle ? 'Fehlerseite' : 'Kein nennenswerter Inhalt',
title,
};
});
}
/** Dashboard als HTML-Datei speichern. Gibt den URL-Pfad zurück. */
function saveDashboard(sessionId, filename, html, log) {
fs.mkdirSync(DASHBOARD_DIR, { recursive: true });
const safeName = (filename || 'dashboard')
.toLowerCase()
.replace(/[^a-z0-9äöü-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'dashboard';
const file = `${sessionId}-${safeName}.html`;
fs.writeFileSync(path.join(DASHBOARD_DIR, file), html, 'utf8');
log?.(`Dashboard gespeichert: ${file}`);
return { file, url: `/dashboards/${file}` };
}
async function closeBrowser() {
if (browser) {
await browser.close().catch(() => {});
browser = null;
}
}
module.exports = { search, browse, verify, saveDashboard, closeBrowser };