Recherche abbrechen + Chats löschen (mit Aufräumen) + Truncation-Fix

- Manueller Abbruch: POST /api/sessions/:id/abort (nur Owner), AbortController
  für laufende Jobs (auch mitten im API-Stream), cancelQueued für wartende,
  ⛔-Button in der Activity-Bar, 'abgebrochen'-Event + History-Eintrag
- Chats löschen: 🗑-Button in der Sidebar mit Bestätigung, Ownership-Check
  (vorher konnte jeder fremde Chats löschen), löscht jetzt auch Messages
  und Dashboard-Dateien (HTML/GPX/tour.json) mit
- Truncation-Fix: finish_reason 'length' wird erkannt (sichtbarer Fehler
  statt leerer Abschluss), max_tokens auf 131072 (GLM-Max), 10-Min-Timeout
  gegen hängende Streams
This commit is contained in:
nico committed 2026-08-28 07:17:06 +02:00
1 parent 0b51bb6142
commit 8a2bf46b09
8 files changed
+237 -39

No files matched your search

+4 -2
View File
@@ -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)',
+4 -1
View File
@@ -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) {
+23 -1
View File
@@ -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 };
+71 -9
View File
@@ -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(
'⚠️',
+18 -1
View File
@@ -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 };