Research Chat: Web-Chatoberfläche für den Research-Agent (DeepSeek v4 Flash + Playwright, Docker/Podman)
This commit is contained in:
commit
a0243d494c
23 files changed
+2253
No files matched your search
@@ -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 };
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
Reference in new issue
Block a user