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:
1 parent
0b51bb6142
commit
8a2bf46b09
8 files changed
+213
-15
No files matched your search
+4
-2
@@ -78,7 +78,8 @@ const MODELS = [
|
|||||||
label: 'DeepSeek V4 Flash',
|
label: 'DeepSeek V4 Flash',
|
||||||
provider: 'deepseek',
|
provider: 'deepseek',
|
||||||
vision: false,
|
vision: false,
|
||||||
maxTokens: 64000,
|
// Max. Output laut Doku: 384K – großzügiges Limit gegen abgeschnittene Dashboards
|
||||||
|
maxTokens: 131072,
|
||||||
reasoningEffort: () => envOr('DEEPSEEK_REASONING_EFFORT', 'max'),
|
reasoningEffort: () => envOr('DEEPSEEK_REASONING_EFFORT', 'max'),
|
||||||
prices: { inputCacheHit: 0.014, inputCacheMiss: 0.44, output: 1.32 },
|
prices: { inputCacheHit: 0.014, inputCacheMiss: 0.44, output: 1.32 },
|
||||||
},
|
},
|
||||||
@@ -87,7 +88,8 @@ const MODELS = [
|
|||||||
label: 'GLM 5.3 Flash (OpenRouter)',
|
label: 'GLM 5.3 Flash (OpenRouter)',
|
||||||
provider: 'openrouter',
|
provider: 'openrouter',
|
||||||
vision: true,
|
vision: true,
|
||||||
maxTokens: 64000,
|
// Max. Completion laut OpenRouter: 131072
|
||||||
|
maxTokens: 131072,
|
||||||
reasoningEffort: () => envOr('GLM_REASONING_EFFORT', 'max'),
|
reasoningEffort: () => envOr('GLM_REASONING_EFFORT', 'max'),
|
||||||
prices: { inputCacheHit: 0.015, inputCacheMiss: 0.075, output: 0.25 },
|
prices: { inputCacheHit: 0.015, inputCacheMiss: 0.075, output: 0.25 },
|
||||||
costNote: 'Schätzung (OpenRouter, Rabatt bis Sep 2026)',
|
costNote: 'Schätzung (OpenRouter, Rabatt bis Sep 2026)',
|
||||||
|
|||||||
@@ -254,7 +254,10 @@ module.exports = {
|
|||||||
id
|
id
|
||||||
),
|
),
|
||||||
computeCost,
|
computeCost,
|
||||||
deleteSession: (id) => stmts.deleteSession.run(id),
|
deleteSession: (id) => {
|
||||||
|
stmts.deleteMessages.run(id);
|
||||||
|
stmts.deleteSession.run(id);
|
||||||
|
},
|
||||||
getSessionMessages,
|
getSessionMessages,
|
||||||
toApiMessages,
|
toApiMessages,
|
||||||
upsertPushSub(userName, endpoint, keys) {
|
upsertPushSub(userName, endpoint, keys) {
|
||||||
|
|||||||
+23
-1
@@ -6,6 +6,28 @@ events.setMaxListeners(100);
|
|||||||
let queue = [];
|
let queue = [];
|
||||||
let running = false;
|
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. */
|
/** Einen Job anmelden. Läuft sofort, wenn nichts anderes läuft, sonst wartet er. */
|
||||||
function enqueue(job) {
|
function enqueue(job) {
|
||||||
queue.push(job);
|
queue.push(job);
|
||||||
@@ -45,4 +67,4 @@ function state() {
|
|||||||
return { running, queued: queue.map((j) => j.sessionId) };
|
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 };
|
||||||
+67
-5
@@ -228,6 +228,12 @@ function summarizeTool(toolName, output) {
|
|||||||
|
|
||||||
const MAX_ITERATIONS = 40;
|
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. */
|
/** Baut den System-Prompt inkl. aktuellem Dashboard-Stand und Vision-Hinweisen. */
|
||||||
function buildSystemPrompt(session, model) {
|
function buildSystemPrompt(session, model) {
|
||||||
let prompt = SYSTEM_PROMPT;
|
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 = {
|
const body = {
|
||||||
model: model.id,
|
model: model.id,
|
||||||
messages: [{ role: 'system', content: systemPrompt }, ...messages],
|
messages: [{ role: 'system', content: systemPrompt }, ...messages],
|
||||||
@@ -293,11 +299,23 @@ async function chatCompletion(config, messages, systemPrompt, model, tools) {
|
|||||||
Authorization: `Bearer ${config.apiKey}`,
|
Authorization: `Bearer ${config.apiKey}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const resp = await fetch(`${baseUrl}/chat/completions`, {
|
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',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(body),
|
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) {
|
if (!resp.ok) {
|
||||||
const err = await resp.text().catch(() => '');
|
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;
|
if (choice.finish_reason) delta.finishReason = choice.finish_reason;
|
||||||
|
else if (d.finish_reason) delta.finishReason = d.finish_reason;
|
||||||
};
|
};
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@@ -358,6 +377,31 @@ async function chatCompletion(config, messages, systemPrompt, model, tools) {
|
|||||||
}
|
}
|
||||||
if (buffer.trim()) feed(buffer);
|
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 {
|
return {
|
||||||
content: delta.content,
|
content: delta.content,
|
||||||
reasoning: delta.reasoning,
|
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} emit Event-Helfer: { token(t), thinking(t), progress(icon, text), step(msg), done(obj), error(msg) }
|
||||||
* @param {object} db Datenbankmodul
|
* @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 dashboardUrl = session.dashboard || null;
|
||||||
let dashboardSaved = false;
|
let dashboardSaved = false;
|
||||||
|
|
||||||
const model = getModel(session.model || config.model);
|
const model = getModel(session.model || config.model);
|
||||||
const toolDefs = model.vision ? [...TOOL_DEFINITIONS, SCREENSHOT_TOOL] : TOOL_DEFINITIONS;
|
const toolDefs = model.vision ? [...TOOL_DEFINITIONS, SCREENSHOT_TOOL] : TOOL_DEFINITIONS;
|
||||||
const prices = model.prices;
|
const prices = model.prices;
|
||||||
|
const isAborted = () => signal?.aborted === true;
|
||||||
|
|
||||||
const handlers = {
|
const handlers = {
|
||||||
async search({ query }) {
|
async search({ query }) {
|
||||||
@@ -450,9 +495,25 @@ async function runResearchJob(config, session, userMessageText, emit, db) {
|
|||||||
return { text: finalText, dashboardUrl, usage, costEstimate, model: model.id };
|
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++) {
|
for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
|
||||||
|
if (isAborted()) return finishAborted();
|
||||||
emit.progress('🤖', iter === 0 ? 'Analysiere Anfrage und plane Suche…' : 'Weitere Analyse…');
|
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);
|
trackUsage(result.usage);
|
||||||
|
|
||||||
if (result.reasoning) emit.thinking(result.reasoning);
|
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) });
|
apiMessages.push({ role: 'tool', tool_call_id: tc.id, content: outStr.slice(0, 8000) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isAborted()) return finishAborted();
|
||||||
continue;
|
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)) {
|
if (!dashboardSaved && dashboardUrl && isChangeRequest(userMessageText)) {
|
||||||
emit.progress(
|
emit.progress(
|
||||||
'⚠️',
|
'⚠️',
|
||||||
|
|||||||
+18
-1
@@ -144,6 +144,23 @@ function saveDashboard(sessionId, filename, html, log) {
|
|||||||
return { file, url: `/dashboards/${file}` };
|
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. */
|
/** Screenshot einer Seite (JPEG, als Data-URL) – für Vision-Modelle. */
|
||||||
async function screenshot(url, fullPage = false, log) {
|
async function screenshot(url, fullPage = false, log) {
|
||||||
return withPage(url, async (page) => {
|
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 };
|
||||||
+59
-2
@@ -62,18 +62,52 @@ function renderSessions() {
|
|||||||
: s.status === 'done'
|
: s.status === 'done'
|
||||||
? '<span class="w-2 h-2 rounded-full bg-emerald-400 inline-block mr-2"></span>'
|
? '<span class="w-2 h-2 rounded-full bg-emerald-400 inline-block mr-2"></span>'
|
||||||
: '<span class="w-2 h-2 rounded-full bg-slate-500 inline-block mr-2"></span>';
|
: '<span class="w-2 h-2 rounded-full bg-slate-500 inline-block mr-2"></span>';
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'group relative';
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = `w-full text-left px-3 py-2 rounded-lg text-sm flex items-start gap-1 ${
|
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'
|
active ? 'bg-blue-600/20 text-blue-300' : 'hover:bg-slate-800'
|
||||||
}`;
|
}`;
|
||||||
btn.innerHTML = `${dot}<span class="truncate">${escapeHtml(s.title)}</span>`;
|
btn.innerHTML = `${dot}<span class="truncate flex-1">${escapeHtml(s.title)}</span>`;
|
||||||
btn.onclick = () => openSession(s.id);
|
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');
|
const qs = state.sessions.find((s) => s.job === 'running' || s.job === 'queued');
|
||||||
$('queueStatus').classList.toggle('hidden', !qs);
|
$('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
|
// Chat-Rendering
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -187,6 +221,8 @@ function showActivity() {
|
|||||||
$('activityLog').classList.add('hidden');
|
$('activityLog').classList.add('hidden');
|
||||||
$('toggleLog').textContent = 'Details zeigen';
|
$('toggleLog').textContent = 'Details zeigen';
|
||||||
state.activityShown = true;
|
state.activityShown = true;
|
||||||
|
$('abortBtn').classList.remove('hidden');
|
||||||
|
$('abortBtn').disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActivity(text, icon = '') {
|
function setActivity(text, icon = '') {
|
||||||
@@ -202,15 +238,29 @@ function logActivity(text) {
|
|||||||
|
|
||||||
function hideActivity() {
|
function hideActivity() {
|
||||||
$('activityBar').classList.add('hidden');
|
$('activityBar').classList.add('hidden');
|
||||||
|
$('abortBtn').classList.add('hidden');
|
||||||
state.activityShown = false;
|
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 = () => {
|
$('toggleLog').onclick = () => {
|
||||||
const log = $('activityLog');
|
const log = $('activityLog');
|
||||||
const hidden = log.classList.toggle('hidden');
|
const hidden = log.classList.toggle('hidden');
|
||||||
$('toggleLog').textContent = hidden ? 'Details zeigen' : 'Details verbergen';
|
$('toggleLog').textContent = hidden ? 'Details zeigen' : 'Details verbergen';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$('abortBtn').onclick = abortResearch;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// SSE-Stream
|
// SSE-Stream
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -286,6 +336,13 @@ function connectStream(sessionId) {
|
|||||||
$('sendBtn').disabled = false;
|
$('sendBtn').disabled = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
es.addEventListener('aborted', () => {
|
||||||
|
addToolLine('⛔', 'Recherche abgebrochen.');
|
||||||
|
hideActivity();
|
||||||
|
loadSessions();
|
||||||
|
$('sendBtn').disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
es.addEventListener('error', (e) => {
|
es.addEventListener('error', (e) => {
|
||||||
if (!e.data) return;
|
if (!e.data) return;
|
||||||
const d = JSON.parse(e.data);
|
const d = JSON.parse(e.data);
|
||||||
|
|||||||
@@ -57,6 +57,8 @@
|
|||||||
<div id="activityLine" class="flex items-center gap-2">
|
<div id="activityLine" class="flex items-center gap-2">
|
||||||
<span class="inline-block w-3 h-3 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></span>
|
<span class="inline-block w-3 h-3 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></span>
|
||||||
<span id="activityText">Starte…</span>
|
<span id="activityText">Starte…</span>
|
||||||
|
<button id="abortBtn" type="button" title="Recherche abbrechen"
|
||||||
|
class="ml-auto hidden text-xs px-2 py-1 rounded-lg bg-red-600/15 text-red-400 hover:bg-red-600/30">⛔ Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="activityLog" class="hidden mt-1 max-h-32 overflow-y-auto text-xs text-slate-400 space-y-0.5"></div>
|
<div id="activityLog" class="hidden mt-1 max-h-32 overflow-y-auto text-xs text-slate-400 space-y-0.5"></div>
|
||||||
<button id="toggleLog" class="text-xs text-blue-400 mt-1">Details zeigen</button>
|
<button id="toggleLog" class="text-xs text-blue-400 mt-1">Details zeigen</button>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const { config, getModel, getModels } = require('./lib/config');
|
|||||||
const db = require('./lib/db');
|
const db = require('./lib/db');
|
||||||
const queue = require('./lib/queue');
|
const queue = require('./lib/queue');
|
||||||
const { runResearchJob } = require('./lib/research');
|
const { runResearchJob } = require('./lib/research');
|
||||||
const { closeBrowser } = require('./lib/tools');
|
const { closeBrowser, deleteSessionFiles } = require('./lib/tools');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
@@ -206,9 +206,31 @@ app.get('/api/sessions/:id/share', requireAuth, (req, res) => {
|
|||||||
|
|
||||||
app.delete('/api/sessions/:id', requireAuth, (req, res) => {
|
app.delete('/api/sessions/:id', requireAuth, (req, res) => {
|
||||||
const session = db.getSession(req.params.id);
|
const session = db.getSession(req.params.id);
|
||||||
if (!session) return res.status(404).json({ error: 'Session nicht gefunden' });
|
if (!session || session.owner_name !== req.user.n) {
|
||||||
|
return res.status(404).json({ error: 'Session nicht gefunden' });
|
||||||
|
}
|
||||||
queue.cancelQueued(session.id);
|
queue.cancelQueued(session.id);
|
||||||
|
queue.abortRunning(session.id);
|
||||||
db.deleteSession(session.id);
|
db.deleteSession(session.id);
|
||||||
|
deleteSessionFiles(session.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/sessions/:id/abort', requireAuth, (req, res) => {
|
||||||
|
const session = db.getSession(req.params.id);
|
||||||
|
if (!session || session.owner_name !== req.user.n) {
|
||||||
|
return res.status(404).json({ error: 'Session nicht gefunden' });
|
||||||
|
}
|
||||||
|
if (queue.queuedBefore(session.id) >= 0) {
|
||||||
|
// Job wartet noch in der Queue → direkt entfernen
|
||||||
|
queue.cancelQueued(session.id);
|
||||||
|
db.setSessionStatus(session.id, 'done');
|
||||||
|
emitToSession(session.id, 'aborted', {});
|
||||||
|
emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'aborted' });
|
||||||
|
} else {
|
||||||
|
// Laufender Job → Controller auslösen, der Job räumt selbst auf
|
||||||
|
queue.abortRunning(session.id);
|
||||||
|
}
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -259,9 +281,12 @@ app.post('/api/sessions/:id/messages', requireAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
db.setSessionStatus(session.id, 'queued');
|
db.setSessionStatus(session.id, 'queued');
|
||||||
|
|
||||||
|
const controller = queue.registerController(session.id);
|
||||||
|
|
||||||
const job = {
|
const job = {
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
async run() {
|
async run() {
|
||||||
|
try {
|
||||||
db.setSessionStatus(session.id, 'running');
|
db.setSessionStatus(session.id, 'running');
|
||||||
emitToSession(session.id, 'status', { sessionStatus: 'running', jobStatus: 'running' });
|
emitToSession(session.id, 'status', { sessionStatus: 'running', jobStatus: 'running' });
|
||||||
|
|
||||||
@@ -275,6 +300,10 @@ app.post('/api/sessions/:id/messages', requireAuth, (req, res) => {
|
|||||||
emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'done' });
|
emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'done' });
|
||||||
sendPush(session, result);
|
sendPush(session, result);
|
||||||
},
|
},
|
||||||
|
aborted: () => {
|
||||||
|
emitToSession(session.id, 'aborted', {});
|
||||||
|
emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'aborted' });
|
||||||
|
},
|
||||||
error: (msg) => emitToSession(session.id, 'error', { text: msg }),
|
error: (msg) => emitToSession(session.id, 'error', { text: msg }),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -283,8 +312,12 @@ app.post('/api/sessions/:id/messages', requireAuth, (req, res) => {
|
|||||||
db.getSession(session.id),
|
db.getSession(session.id),
|
||||||
text,
|
text,
|
||||||
emit,
|
emit,
|
||||||
db
|
db,
|
||||||
|
controller.signal
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
queue.clearController(session.id);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async onError(e) {
|
async onError(e) {
|
||||||
console.error('Job-Fehler:', e);
|
console.error('Job-Fehler:', e);
|
||||||
|
|||||||
Reference in new issue
Block a user