Stale-Job-Reset, Ownership-Angleichung, robuste Dashboard-Erzeugung
- Server-Neustart setzt hängengebliebene Jobs (running/queued) zurück (markStaleJobs) mit Hinweis-Nachricht; /abort räumt auch verwaiste Sessions auf - Löschen/Abbrechen/Modellwechsel ohne Owner-Beschränkung (konsistent zur offenen Sichtbarkeit im Ein-Nutzer-System) - Dashboard-HTML wird nicht mehr per Tool-Aufruf übertragen, sondern in der finalen Antwort als ```html```-Codeblock gestreamt (GLM scheiterte reproduzierbar am riesigen Tool-Call); Extraktion mit klaren Fehlern bei fehlendem/unvollständigem Codeblock - Reasoning-Effort pro Runde: volle Tiefe in Research-Runden, minimales Denken in der Dashboard-Liefer-Runde (DeepSeek: thinking aus, GLM: low) – Budget gehört dem HTML - GLM-Reasoning-Default high (max verbrennt das Completion-Budget), DeepSeek max_tokens auf 300k
This commit is contained in:
1 parent
8a2bf46b09
commit
ab4f8e8570
6 files changed
+178
-36
No files matched your search
+114
-21
@@ -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 (/^<!DOCTYPE html/i.test(t) || /^<html[\s>]/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(
|
||||
|
||||
Reference in new issue
Block a user