diff --git a/lib/config.js b/lib/config.js index 4292ed2..b0b5f73 100644 --- a/lib/config.js +++ b/lib/config.js @@ -78,7 +78,8 @@ const MODELS = [ label: 'DeepSeek V4 Flash', provider: 'deepseek', vision: false, - maxTokens: 64000, + // Max. Output laut Doku: 384K – großzügiges Limit gegen abgeschnittene Dashboards + maxTokens: 131072, reasoningEffort: () => envOr('DEEPSEEK_REASONING_EFFORT', 'max'), prices: { inputCacheHit: 0.014, inputCacheMiss: 0.44, output: 1.32 }, }, @@ -87,7 +88,8 @@ const MODELS = [ label: 'GLM 5.3 Flash (OpenRouter)', provider: 'openrouter', vision: true, - maxTokens: 64000, + // Max. Completion laut OpenRouter: 131072 + maxTokens: 131072, reasoningEffort: () => envOr('GLM_REASONING_EFFORT', 'max'), 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 f1925d4..343cfa5 100644 --- a/lib/db.js +++ b/lib/db.js @@ -254,7 +254,10 @@ module.exports = { id ), computeCost, - deleteSession: (id) => stmts.deleteSession.run(id), + deleteSession: (id) => { + stmts.deleteMessages.run(id); + stmts.deleteSession.run(id); + }, getSessionMessages, toApiMessages, upsertPushSub(userName, endpoint, keys) { diff --git a/lib/queue.js b/lib/queue.js index 9d9f311..14bba49 100644 --- a/lib/queue.js +++ b/lib/queue.js @@ -6,6 +6,28 @@ events.setMaxListeners(100); let queue = []; let running = false; +// Abort-Controller für laufende Jobs (für manuellen Abbruch) +const controllers = new Map(); + +/** Abort-Controller für eine Session registrieren (einmal pro Job). */ +function registerController(sessionId) { + const controller = new AbortController(); + controllers.set(sessionId, controller); + return controller; +} + +/** Laufenden Job einer Session abbrechen. */ +function abortRunning(sessionId) { + const controller = controllers.get(sessionId); + if (controller) controller.abort(); + controllers.delete(sessionId); +} + +/** Controller nach Job-Ende entfernen. */ +function clearController(sessionId) { + controllers.delete(sessionId); +} + /** Einen Job anmelden. Läuft sofort, wenn nichts anderes läuft, sonst wartet er. */ function enqueue(job) { queue.push(job); @@ -45,4 +67,4 @@ function state() { return { running, queued: queue.map((j) => j.sessionId) }; } -module.exports = { events, enqueue, queuedBefore, isRunning, cancelQueued, state }; +module.exports = { events, enqueue, queuedBefore, isRunning, cancelQueued, state, registerController, abortRunning, clearController }; diff --git a/lib/research.js b/lib/research.js index 16e88c2..01a55ec 100644 --- a/lib/research.js +++ b/lib/research.js @@ -228,6 +228,12 @@ function summarizeTool(toolName, output) { 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; + +/** Wird geworfen, wenn der Nutzer die Recherche manuell abbricht. */ +class JobAbortedError extends Error {} + /** Baut den System-Prompt inkl. aktuellem Dashboard-Stand und Vision-Hinweisen. */ function buildSystemPrompt(session, model) { let prompt = SYSTEM_PROMPT; @@ -258,7 +264,7 @@ function isChangeRequest(text) { ); } -async function chatCompletion(config, messages, systemPrompt, model, tools) { +async function chatCompletion(config, messages, systemPrompt, model, tools, signal) { const body = { model: model.id, messages: [{ role: 'system', content: systemPrompt }, ...messages], @@ -293,11 +299,23 @@ async function chatCompletion(config, messages, systemPrompt, model, tools) { Authorization: `Bearer ${config.apiKey}`, }; - const resp = await fetch(`${baseUrl}/chat/completions`, { - method: 'POST', - headers, - body: JSON.stringify(body), - }); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const fetchSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal; + let resp; + try { + resp = await fetch(`${baseUrl}/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: fetchSignal, + }); + } catch (e) { + clearTimeout(timer); + if (signal?.aborted) throw new JobAbortedError(); + throw new Error(`Modell-API nicht erreichbar (${model.id}): ${e.message.slice(0, 200)}`); + } + clearTimeout(timer); if (!resp.ok) { const err = await resp.text().catch(() => ''); @@ -344,6 +362,7 @@ async function chatCompletion(config, messages, systemPrompt, model, tools) { } } if (choice.finish_reason) delta.finishReason = choice.finish_reason; + else if (d.finish_reason) delta.finishReason = d.finish_reason; }; for (;;) { @@ -358,6 +377,31 @@ async function chatCompletion(config, messages, systemPrompt, model, tools) { } if (buffer.trim()) feed(buffer); + // 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. + if (delta.finishReason === 'length') { + const hasCalls = Array.isArray(delta.toolCalls) && delta.toolCalls.length > 0; + const callsComplete = !hasCalls || delta.toolCalls.every((tc) => { + try { + JSON.parse(tc.function.arguments || '{}'); + return true; + } catch { + return false; + } + }); + if (!hasCalls && !delta.content) { + throw new Error( + `Modell-Antwort abgeschnitten (Token-Limit bei ${model.id} erreicht) – Ergebnis unvollständig. Bitte erneut versuchen oder ein anderes Modell wählen.` + ); + } + if (hasCalls && !callsComplete) { + throw new Error( + `Tool-Aufruf abgeschnitten (Token-Limit bei ${model.id} erreicht) – Recherche wird abgebrochen. Bitte erneut versuchen.` + ); + } + } + return { content: delta.content, reasoning: delta.reasoning, @@ -375,13 +419,14 @@ async function chatCompletion(config, messages, systemPrompt, model, tools) { * @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) { +async function runResearchJob(config, session, userMessageText, emit, db, signal) { let dashboardUrl = session.dashboard || null; let dashboardSaved = false; const model = getModel(session.model || config.model); const toolDefs = model.vision ? [...TOOL_DEFINITIONS, SCREENSHOT_TOOL] : TOOL_DEFINITIONS; const prices = model.prices; + const isAborted = () => signal?.aborted === true; const handlers = { async search({ query }) { @@ -450,9 +495,25 @@ async function runResearchJob(config, session, userMessageText, emit, db) { return { text: finalText, dashboardUrl, usage, costEstimate, model: model.id }; }; + const finishAborted = () => { + db.createMessage(session.id, { role: 'assistant', content: '⛔ Recherche abgebrochen.' }); + db.setSessionUsage(session.id, usage, prices); + db.setSessionStatus(session.id, 'done'); + const costEstimate = db.computeCost(usage, prices); + emit.aborted?.(); + return { text: '⛔ Recherche abgebrochen.', dashboardUrl, usage, costEstimate, model: model.id, aborted: true }; + }; + for (let iter = 0; iter < MAX_ITERATIONS; iter++) { + if (isAborted()) return finishAborted(); emit.progress('🤖', iter === 0 ? 'Analysiere Anfrage und plane Suche…' : 'Weitere Analyse…'); - const result = await chatCompletion(config, apiMessages, buildSystemPrompt(session, model), model, toolDefs); + let result; + try { + result = await chatCompletion(config, apiMessages, buildSystemPrompt(session, model), model, toolDefs, signal); + } catch (e) { + if (isAborted() || e instanceof JobAbortedError) return finishAborted(); + throw e; + } trackUsage(result.usage); if (result.reasoning) emit.thinking(result.reasoning); @@ -511,10 +572,11 @@ async function runResearchJob(config, session, userMessageText, emit, db) { apiMessages.push({ role: 'tool', tool_call_id: tc.id, content: outStr.slice(0, 8000) }); } } + if (isAborted()) return finishAborted(); continue; } - const finalText = result.content || (dashboardUrl ? 'Recherche abgeschlossen – siehe Dashboard.' : ''); + const finalText = result.content || (dashboardUrl ? 'Recherche abgeschlossen – siehe Dashboard.' : 'Recherche abgeschlossen.'); if (!dashboardSaved && dashboardUrl && isChangeRequest(userMessageText)) { emit.progress( '⚠️', diff --git a/lib/tools.js b/lib/tools.js index b3ffb14..c0f17f1 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -144,6 +144,23 @@ function saveDashboard(sessionId, filename, html, log) { return { file, url: `/dashboards/${file}` }; } +/** Alle Dashboard-Dateien einer Session (HTML/GPX/tour.json) löschen. */ +function deleteSessionFiles(sessionId) { + let entries; + try { + entries = fs.readdirSync(DASHBOARD_DIR); + } catch { + return; + } + for (const f of entries) { + if (f.startsWith(`${sessionId}-`)) { + try { + fs.unlinkSync(path.join(DASHBOARD_DIR, f)); + } catch {} + } + } +} + /** Screenshot einer Seite (JPEG, als Data-URL) – für Vision-Modelle. */ async function screenshot(url, fullPage = false, log) { return withPage(url, async (page) => { @@ -374,4 +391,4 @@ async function closeBrowser() { } } -module.exports = { search, browse, verify, saveDashboard, screenshot, geocode, saveTour, closeBrowser }; +module.exports = { search, browse, verify, saveDashboard, screenshot, geocode, saveTour, deleteSessionFiles, closeBrowser }; diff --git a/public/app.js b/public/app.js index beeb4ea..6f52238 100644 --- a/public/app.js +++ b/public/app.js @@ -62,18 +62,52 @@ function renderSessions() { : s.status === 'done' ? '' : ''; + const row = document.createElement('div'); + row.className = 'group relative'; const btn = document.createElement('button'); btn.className = `w-full text-left px-3 py-2 rounded-lg text-sm flex items-start gap-1 ${ active ? 'bg-blue-600/20 text-blue-300' : 'hover:bg-slate-800' }`; - btn.innerHTML = `${dot}${escapeHtml(s.title)}`; + btn.innerHTML = `${dot}${escapeHtml(s.title)}`; btn.onclick = () => openSession(s.id); - list.appendChild(btn); + const del = document.createElement('button'); + del.className = + 'absolute right-1 top-1/2 -translate-y-1/2 w-6 h-6 rounded text-xs text-slate-500 hover:text-red-400 hover:bg-slate-700 opacity-0 group-hover:opacity-100'; + del.textContent = '🗑'; + del.title = 'Chat löschen'; + del.onclick = (e) => { + e.stopPropagation(); + deleteSession(s.id); + }; + row.append(btn, del); + list.appendChild(row); } const qs = state.sessions.find((s) => s.job === 'running' || s.job === 'queued'); $('queueStatus').classList.toggle('hidden', !qs); } +async function deleteSession(id) { + const s = state.sessions.find((x) => x.id === id); + if (!confirm(`Chat „${s?.title || '…'}“ wirklich löschen? Das zugehörige Dashboard wird mit gelöscht.`)) return; + const res = await fetch(`/api/sessions/${id}`, { method: 'DELETE' }); + if (!res.ok) { + addToolLine('⚠️', 'Löschen fehlgeschlagen'); + return; + } + if (state.current?.id === id) { + state.stream?.close(); + state.stream = null; + state.streamingEl = null; + state.current = null; + hideActivity(); + const next = state.sessions.find((x) => x.id !== id); + if (next) await openSession(next.id); + else await newSession(); + } else { + await loadSessions(); + } +} + // --------------------------------------------------------------------------- // Chat-Rendering // --------------------------------------------------------------------------- @@ -187,6 +221,8 @@ function showActivity() { $('activityLog').classList.add('hidden'); $('toggleLog').textContent = 'Details zeigen'; state.activityShown = true; + $('abortBtn').classList.remove('hidden'); + $('abortBtn').disabled = false; } function setActivity(text, icon = '') { @@ -202,15 +238,29 @@ function logActivity(text) { function hideActivity() { $('activityBar').classList.add('hidden'); + $('abortBtn').classList.add('hidden'); state.activityShown = false; } +async function abortResearch() { + if (!state.current) return; + $('abortBtn').disabled = true; + setActivity('Breche Recherche ab…', '⛔'); + try { + await fetch(`/api/sessions/${state.current.id}/abort`, { method: 'POST' }); + } catch { + $('abortBtn').disabled = false; + } +} + $('toggleLog').onclick = () => { const log = $('activityLog'); const hidden = log.classList.toggle('hidden'); $('toggleLog').textContent = hidden ? 'Details zeigen' : 'Details verbergen'; }; +$('abortBtn').onclick = abortResearch; + // --------------------------------------------------------------------------- // SSE-Stream // --------------------------------------------------------------------------- @@ -286,6 +336,13 @@ function connectStream(sessionId) { $('sendBtn').disabled = false; }); + es.addEventListener('aborted', () => { + addToolLine('⛔', 'Recherche abgebrochen.'); + hideActivity(); + loadSessions(); + $('sendBtn').disabled = false; + }); + es.addEventListener('error', (e) => { if (!e.data) return; const d = JSON.parse(e.data); diff --git a/public/index.html b/public/index.html index 983ae58..c0914b2 100644 --- a/public/index.html +++ b/public/index.html @@ -57,6 +57,8 @@