From 67b8e5735fc45a300ef1babb6d2b41afaef28fbb Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Tue, 14 Jul 2026 12:19:27 +0200 Subject: [PATCH] KI-Chat: Thread-Titel vom Modell formulieren lassen Der Titel in der Seitenleiste waren bisher die ersten 60 Zeichen der ersten Nachricht - abgeschnitten mitten im Wort ("Ich habe mich bei der Hetzner Online GmbH bewor..."). Jetzt formuliert das Modell daraus einen kurzen Titel, wie man es aus ChatGPT kennt. - Der Aufruf laeuft NACH dem done-Event: die Antwort wartet nicht auf die Titelgenerierung (1-3 s), der Titel kommt als eigenes Event nach und die Seitenleiste zieht live nach. - Der abgeschnittene Titel bleibt als Platzhalter, solange die Antwort streamt - die Seitenleiste ist also nie leer, und faellt die Titelgenerierung aus, bleibt es schlicht dabei. - Nur beim ersten Schlagabtausch und nur, wenn der Thread noch keinen Titel hatte: ein selbst vergebener Name wird nicht ueberschrieben. - Eigener Mini-Systemprompt statt des Chat-Prompts, der will beraten, nicht betiteln. Anfuehrungszeichen/"Titel:"-Praefixe werden abgeraeumt. Co-Authored-By: Claude Opus 4.8 --- lib/chat.js | 41 ++++++++++++++++++++++++++++++++++++++++- public/js/chat.js | 19 ++++++++++++++----- server.js | 31 ++++++++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/lib/chat.js b/lib/chat.js index fd9d985..81de40f 100644 --- a/lib/chat.js +++ b/lib/chat.js @@ -180,6 +180,45 @@ async function runChat({ system, messages, tools, executeTool, onToken, onToolCa throw new Error('Maximale Anzahl Tool-Aufrufe erreicht.'); } +// Kurzen Thread-Titel aus dem ersten Schlagabtausch formulieren lassen — wie in +// ChatGPT, statt die ersten 60 Zeichen der Frage abzuschneiden ("Ich habe mich +// bei der Hetzner Online GmbH bewor…"). Ein eigener, winziger Aufruf ohne +// Werkzeuge: Der Chat-Systemprompt würde hier nur stören (er will beraten, nicht +// betiteln). Wirft nicht — schlägt die Titelgenerierung fehl, bleibt der Aufrufer +// einfach beim Fallback-Titel. +async function generateTitle({ userText, assistantText, signal }) { + const system = + 'Du formulierst kurze Titel für Chat-Verläufe eines Bewerbungs-Assistenten. ' + + 'Antworte mit NICHTS außer dem Titel: 2 bis 5 Wörter, Deutsch, keine ' + + 'Anführungszeichen, kein Punkt am Ende, keine Einleitung. Benenne das Thema ' + + 'konkret (Firma, Stelle oder Anliegen), nicht generisch wie "Frage zur Bewerbung".'; + const inhalt = + 'Nachricht des Nutzers:\n' + String(userText || '').slice(0, 600) + + '\n\nAntwort des Assistenten:\n' + String(assistantText || '').slice(0, 600) + + '\n\nTitel:'; + + try { + const { content } = await streamChat({ + system, + messages: [{ role: 'user', content: inhalt }], + temperature: 0.2, + signal, + }); + // Modelle packen den Titel gern in Anführungszeichen, Sternchen oder eine + // "Titel:"-Zeile — das wieder abräumen. + let titel = String(content || '') + .split('\n').map((z) => z.trim()).filter(Boolean).pop() || ''; + titel = titel + .replace(/^\s*(titel|title)\s*:\s*/i, '') + .replace(/^["“„'*#\s]+|["“”'*.\s]+$/g, '') + .trim(); + if (titel.length > 60) titel = titel.slice(0, 60).trim(); + return titel; + } catch (err) { + return ''; + } +} + // Build the German system prompt that grounds the assistant. `context` is the // lightweight core gathered by the server: { heute, settings, profil }. // Application data is NOT baked in — the assistant fetches it via tools. @@ -231,7 +270,7 @@ function buildContextPrompt(ctx) { } module.exports = { - isConfigured, streamChat, runChat, buildContextPrompt, MAX_TOOL_ROUNDS, + isConfigured, streamChat, runChat, buildContextPrompt, generateTitle, MAX_TOOL_ROUNDS, // Dynamic so an edit on /einstellungen is reflected without a restart. get OLLAMA_MODEL() { return config.ollama().model; }, }; \ No newline at end of file diff --git a/public/js/chat.js b/public/js/chat.js index 28f0df1..38ce661 100644 --- a/public/js/chat.js +++ b/public/js/chat.js @@ -213,6 +213,15 @@ return wrap; } + // Titel des aktiven Threads in der Seitenleiste setzen (kein innerHTML: der + // Titel kommt aus einer Modellantwort). + function setThreadTitle(titel) { + if (!titel || !activeId) return; + const li = threadList && threadList.querySelector('li[data-thread-li="' + activeId + '"]'); + const titleEl = li && li.querySelector('.threadLink .font-medium'); + if (titleEl && titel !== titleEl.textContent) titleEl.textContent = titel; + } + function prependThread(id, titel) { if (!threadList) return; const empty = threadList.querySelector('li.text-center'); @@ -350,12 +359,12 @@ addMessage('assistant', ev.content || '(keine Antwort)'); } // Live-update the auto-generated thread title in the sidebar. - if (ev.titel && activeId) { - const li = threadList && threadList.querySelector('li[data-thread-li="' + activeId + '"]'); - const titleEl = li && li.querySelector('.threadLink .font-medium'); - if (titleEl && ev.titel && ev.titel !== titleEl.textContent) titleEl.textContent = ev.titel; - } + setThreadTitle(ev.titel); scrollBottom(); + } else if (ev.type === 'titel') { + // Kommt nach 'done' nach: der vom Modell formulierte Titel löst den + // vorläufigen (abgeschnittene erste Nachricht) in der Seitenleiste ab. + setThreadTitle(ev.titel); } else if (ev.type === 'error') { if (typing) typing.remove(); if (!assistantBubble) addMessage('assistant', 'Fehler: ' + ev.message); diff --git a/server.js b/server.js index cdfc752..bdf11c1 100644 --- a/server.js +++ b/server.js @@ -4154,7 +4154,9 @@ initializeDatabase().then(async () => { await dbRun('INSERT INTO chat_messages (user_id, thread_id, role, content) VALUES (?, ?, ?, ?)', [uid(), threadId, 'user', userText]); await dbRun('UPDATE chat_threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', [threadId, uid()]); - // Auto-title the thread from the first user message, if untitled. + // Vorläufiger Titel aus der ersten Nachricht, damit in der Seitenleiste + // sofort etwas steht. Sobald die Antwort durch ist, formuliert das Modell + // daraus einen richtigen Titel (siehe unten). if (!thread.titel) { const first = await dbGet('SELECT content FROM chat_messages WHERE thread_id = ? AND user_id = ? ORDER BY id ASC LIMIT 1', [threadId, uid()]); if (first) { @@ -4232,6 +4234,33 @@ initializeDatabase().then(async () => { send({ type: 'done', messageId: lastID, content: saved, titel: threadRow && threadRow.titel }); } catch (error) { send({ type: 'error', message: 'Antwort konnte nicht gespeichert werden.' }); + res.end(); + return; + } + + // Titel vom Modell formulieren lassen — erst NACH dem 'done', damit die + // Antwort nicht auf den Zusatzaufruf warten muss; der Titel kommt als + // eigenes Event nach und die Seitenleiste zieht nach. + // + // Nur beim ersten Schlagabtausch (history = genau die eine Nutzernachricht) + // und nur, wenn der Thread vorher keinen Titel hatte — sonst würde ein + // selbst vergebener Name wieder überschrieben. + const ersterSchlagabtausch = history.length === 1; + if (!aborted && !thread.titel && ersterSchlagabtausch && assistantText) { + try { + const titel = await chat.generateTitle({ + userText, assistantText, signal: controller.signal, + }); + if (titel && !aborted) { + await dbRun( + 'UPDATE chat_threads SET titel = ? WHERE id = ? AND user_id = ?', + [sanitizeInput(titel).slice(0, 120), threadId, uid()] + ); + send({ type: 'titel', titel }); + } + } catch (e) { + // Der Fallback-Titel aus der ersten Nachricht steht bereits — nichts tun. + } } res.end(); });