diff --git a/.env.example b/.env.example index 82c7266..d9bc307 100644 --- a/.env.example +++ b/.env.example @@ -18,10 +18,13 @@ PORT=3000 MODEL=deepseek-v4-flash # Thinking-Mode des Modells: enabled | disabled (gilt für DeepSeek) THINKING=enabled -# Reasoning-Tiefe je Modell: low | high | max (GLM: Pflicht, max Default) +# Reasoning-Tiefe je Modell: low | high | max +# GLM-Default 'high': effort 'max' verbrennt fast das ganze Token-Budget mit +# Denken – bei langen Recherchen bleibt dann nichts fürs Dashboard-HTML übrig. DEEPSEEK_REASONING_EFFORT=max -GLM_REASONING_EFFORT=max -# Maximale Antwort-Tokens pro API-Aufruf (Dashboards sind große HTML-Dateien) +GLM_REASONING_EFFORT=high +# Maximale Antwort-Tokens: nur relevant für eigene Modelle aus der Env (MODEL). +# Die eingebauten Modelle (DeepSeek, GLM) nutzen fest 131072 aus der Registry. MAX_TOKENS=64000 # VAPID-Schlüsselpaar für Web Push (wird automatisch generiert) VAPID_PUBLIC_KEY= diff --git a/lib/config.js b/lib/config.js index b0b5f73..3856d7a 100644 --- a/lib/config.js +++ b/lib/config.js @@ -78,9 +78,11 @@ const MODELS = [ label: 'DeepSeek V4 Flash', provider: 'deepseek', vision: false, - // Max. Output laut Doku: 384K – großzügiges Limit gegen abgeschnittene Dashboards - maxTokens: 131072, + // Max. Output laut Doku: 384K – DeepSeek hat 3x so viel Headroom wie GLM + maxTokens: 300000, reasoningEffort: () => envOr('DEEPSEEK_REASONING_EFFORT', 'max'), + // Liefer-Runde des Dashboards: Denken komplett aus, Budget gehört dem HTML + deliveryEffort: 'none', prices: { inputCacheHit: 0.014, inputCacheMiss: 0.44, output: 1.32 }, }, { @@ -90,7 +92,11 @@ const MODELS = [ vision: true, // Max. Completion laut OpenRouter: 131072 maxTokens: 131072, - reasoningEffort: () => envOr('GLM_REASONING_EFFORT', 'max'), + // Research-Runden: 'high' (effort 'max' verbrennt das ganze Budget mit + // Denken; für lange Recherchen bleibt dann nichts fürs große HTML übrig) + reasoningEffort: () => envOr('GLM_REASONING_EFFORT', 'high'), + // Liefer-Runde des Dashboards: minimales Denken (GLM: Reasoning Pflicht) + deliveryEffort: 'low', prices: { inputCacheHit: 0.015, inputCacheMiss: 0.075, output: 0.25 }, costNote: 'Schätzung (OpenRouter, Rabatt bis Sep 2026)', }, diff --git a/lib/db.js b/lib/db.js index 343cfa5..c624ac9 100644 --- a/lib/db.js +++ b/lib/db.js @@ -115,6 +115,7 @@ const stmts = { VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ), listMessages: db.prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY id ASC`), + listStaleSessions: db.prepare(`SELECT id FROM sessions WHERE status IN ('running', 'queued')`), 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 = ?`), @@ -258,6 +259,29 @@ module.exports = { stmts.deleteMessages.run(id); stmts.deleteSession.run(id); }, + /** + * Setzt nach einem Server-Neustart alle Sessions zurück, die im alten + * Prozess noch als 'running'/'queued' festhingen (Jobs existieren im + * Speicher nicht mehr). Gibt die Anzahl zurück. + */ + markStaleJobs() { + const stale = stmts.listStaleSessions.all(); + for (const s of stale) { + stmts.setSessionStatus.run('done', now(), s.id); + stmts.insertMessage.run( + s.id, + 'assistant', + '⛔ Recherche wurde durch einen Server-Neustart abgebrochen.', + null, + null, + null, + null, + null, + now() + ); + } + return stale.length; + }, getSessionMessages, toApiMessages, upsertPushSub(userName, endpoint, keys) { diff --git a/lib/queue.js b/lib/queue.js index 14bba49..06259db 100644 --- a/lib/queue.js +++ b/lib/queue.js @@ -23,6 +23,11 @@ function abortRunning(sessionId) { controllers.delete(sessionId); } +/** Läuft für diese Session gerade ein Job im Speicher? */ +function hasController(sessionId) { + return controllers.has(sessionId); +} + /** Controller nach Job-Ende entfernen. */ function clearController(sessionId) { controllers.delete(sessionId); @@ -67,4 +72,4 @@ function state() { return { running, queued: queue.map((j) => j.sessionId) }; } -module.exports = { events, enqueue, queuedBefore, isRunning, cancelQueued, state, registerController, abortRunning, clearController }; +module.exports = { events, enqueue, queuedBefore, isRunning, cancelQueued, state, registerController, abortRunning, hasController, clearController }; diff --git a/lib/research.js b/lib/research.js index 01a55ec..4d4d16d 100644 --- a/lib/research.js +++ b/lib/research.js @@ -17,7 +17,7 @@ const SYSTEM_PROMPT = `Du bist ein professioneller Web-Researcher und Wissensber 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. +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: @@ -55,8 +55,8 @@ Erkennst du den Wunsch nach einer Route/Tour/Städtebesichtigung (z.B. "plane ei - 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 -- 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. +- 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 = [ @@ -110,15 +110,15 @@ const TOOL_DEFINITIONS = [ 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.', + '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: 'Die komplette standalone HTML-Datei des Dashboards' }, + 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', 'html'], + required: ['title', 'filename'], }, }, }, @@ -208,7 +208,7 @@ function summarizeTool(toolName, output) { case 'verify': return `Link ${data.ok ? 'VERIFIZIERT ✓' : 'FEHLGESCHLAGEN ✗'}: ${data.url}${data.reason ? ' – ' + data.reason : ''}`; case 'save_dashboard': - return `Dashboard gespeichert: ${data.url || ''}`; + return data.note ? `Dashboard vorbereitet – HTML folgt in der finalen Antwort` : `Dashboard gespeichert: ${data.url || ''}`; case 'geocode': return data.error ? `Geocoding FEHLGESCHLAGEN: ${data.error}` @@ -231,6 +231,9 @@ const MAX_ITERATIONS = 40; // Abbruch, wenn der API-Stream länger hängt (z.B. OpenRouter 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 {} @@ -243,8 +246,8 @@ function buildSystemPrompt(session, model) { ## 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 aufrufen und das KOMPLETTE aktualisierte HTML mit EXAKT diesem Dateinamen übergeben: "${file}" (ohne .html). -Eine bloße Erklärung oder Beschreibung der Änderungen im Chat ist KEINE Umsetzung – erst wenn save_dashboard erfolgreich gelaufen ist, fasst du die Änderungen im Chat zusammen.`; +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 += ` @@ -264,7 +267,22 @@ function isChangeRequest(text) { ); } -async function chatCompletion(config, messages, systemPrompt, model, tools, signal) { +/** 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 (/^]/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], @@ -281,10 +299,13 @@ async function chatCompletion(config, messages, systemPrompt, model, tools, sign if (isOpenRouter) { // GLM: Reasoning ist Pflicht; Effort über OpenRouter-Param "reasoning" - body.reasoning = { effort: model.reasoningEffort?.() || 'max' }; - } else if (config.thinking !== 'disabled') { + body.reasoning = { effort: effortOverride || model.reasoningEffort?.() || 'high' }; + } else 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 = model.reasoningEffort?.() || 'high'; + body.reasoning_effort = effortOverride || model.reasoningEffort?.() || 'max'; } const headers = isOpenRouter @@ -377,6 +398,11 @@ async function chatCompletion(config, messages, systemPrompt, model, tools, sign } 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. @@ -422,6 +448,10 @@ async function chatCompletion(config, messages, systemPrompt, model, tools, sign 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; @@ -450,13 +480,27 @@ async function runResearchJob(config, session, userMessageText, emit, db, signal 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; - dashboardSaved = true; - db.setSessionDashboard(session.id, saved.url); - emit.progress('📊', `Dashboard gespeichert: ${saved.file}`); - return JSON.stringify(saved); + 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 || (model.provider === 'openrouter' ? 'low' : '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}`); @@ -473,6 +517,7 @@ async function runResearchJob(config, session, userMessageText, emit, db, signal 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) => { @@ -509,7 +554,7 @@ async function runResearchJob(config, session, userMessageText, emit, db, signal 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); + result = await chatCompletion(config, apiMessages, buildSystemPrompt(session, model), model, toolDefs, signal, deliveryEffort); } catch (e) { if (isAborted() || e instanceof JobAbortedError) return finishAborted(); throw e; @@ -576,6 +621,54 @@ async function runResearchJob(config, session, userMessageText, emit, db, signal 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( diff --git a/server.js b/server.js index 84943ea..17a4653 100644 --- a/server.js +++ b/server.js @@ -165,7 +165,7 @@ app.post('/api/sessions', requireAuth, (req, res) => { app.patch('/api/sessions/:id', requireAuth, (req, res) => { const session = db.getSession(req.params.id); - if (!session || session.owner_name !== req.user.n) { + if (!session) { return res.status(404).json({ error: 'Session nicht gefunden' }); } const modelId = String(req.body?.model || ''); @@ -206,7 +206,7 @@ app.get('/api/sessions/:id/share', requireAuth, (req, res) => { app.delete('/api/sessions/:id', requireAuth, (req, res) => { const session = db.getSession(req.params.id); - if (!session || session.owner_name !== req.user.n) { + if (!session) { return res.status(404).json({ error: 'Session nicht gefunden' }); } queue.cancelQueued(session.id); @@ -218,19 +218,23 @@ app.delete('/api/sessions/:id', requireAuth, (req, res) => { app.post('/api/sessions/:id/abort', requireAuth, (req, res) => { const session = db.getSession(req.params.id); - if (!session || session.owner_name !== req.user.n) { + if (!session) { return res.status(404).json({ error: 'Session nicht gefunden' }); } if (queue.queuedBefore(session.id) >= 0) { - // Job wartet noch in der Queue → direkt entfernen + // Job wartet noch in der Queue → direkt entfernen und aufräumen queue.cancelQueued(session.id); - db.setSessionStatus(session.id, 'done'); - emitToSession(session.id, 'aborted', {}); - emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'aborted' }); - } else { + } else if (queue.hasController(session.id)) { // Laufender Job → Controller auslösen, der Job räumt selbst auf queue.abortRunning(session.id); + return res.json({ ok: true }); } + // Wartender Job oder veralteter Job (z.B. nach Server-Neustart, ohne + // laufenden Prozess): Zustand und UI direkt zurücksetzen + db.createMessage(session.id, { role: 'assistant', content: '⛔ Recherche abgebrochen.' }); + db.setSessionStatus(session.id, 'done'); + emitToSession(session.id, 'aborted', {}); + emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'aborted' }); res.json({ ok: true }); }); @@ -445,6 +449,13 @@ app.get('/', (req, res) => { // --------------------------------------------------------------------------- +// Nach einem Server-Neustart hängengebliebene Jobs (running/queued in der DB) +// zurücksetzen – die Jobs existieren im Speicher nicht mehr. +const staleCount = db.markStaleJobs(); +if (staleCount > 0) { + console.log(`INFO: ${staleCount} verwaiste Recherche(n) nach Neustart zurückgesetzt.`); +} + if (!config.apiKey) { console.error('WARNUNG: DEEPSEEK_API_KEY fehlt in .env – DeepSeek-Modelle werden nicht funktionieren.'); }