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 <noreply@anthropic.com>
This commit is contained in:
+40
-1
@@ -180,6 +180,45 @@ async function runChat({ system, messages, tools, executeTool, onToken, onToolCa
|
|||||||
throw new Error('Maximale Anzahl Tool-Aufrufe erreicht.');
|
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
|
// Build the German system prompt that grounds the assistant. `context` is the
|
||||||
// lightweight core gathered by the server: { heute, settings, profil }.
|
// lightweight core gathered by the server: { heute, settings, profil }.
|
||||||
// Application data is NOT baked in — the assistant fetches it via tools.
|
// Application data is NOT baked in — the assistant fetches it via tools.
|
||||||
@@ -231,7 +270,7 @@ function buildContextPrompt(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
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.
|
// Dynamic so an edit on /einstellungen is reflected without a restart.
|
||||||
get OLLAMA_MODEL() { return config.ollama().model; },
|
get OLLAMA_MODEL() { return config.ollama().model; },
|
||||||
};
|
};
|
||||||
+14
-5
@@ -213,6 +213,15 @@
|
|||||||
return wrap;
|
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) {
|
function prependThread(id, titel) {
|
||||||
if (!threadList) return;
|
if (!threadList) return;
|
||||||
const empty = threadList.querySelector('li.text-center');
|
const empty = threadList.querySelector('li.text-center');
|
||||||
@@ -350,12 +359,12 @@
|
|||||||
addMessage('assistant', ev.content || '(keine Antwort)');
|
addMessage('assistant', ev.content || '(keine Antwort)');
|
||||||
}
|
}
|
||||||
// Live-update the auto-generated thread title in the sidebar.
|
// Live-update the auto-generated thread title in the sidebar.
|
||||||
if (ev.titel && activeId) {
|
setThreadTitle(ev.titel);
|
||||||
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;
|
|
||||||
}
|
|
||||||
scrollBottom();
|
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') {
|
} else if (ev.type === 'error') {
|
||||||
if (typing) typing.remove();
|
if (typing) typing.remove();
|
||||||
if (!assistantBubble) addMessage('assistant', 'Fehler: ' + ev.message);
|
if (!assistantBubble) addMessage('assistant', 'Fehler: ' + ev.message);
|
||||||
|
|||||||
@@ -4154,7 +4154,9 @@ initializeDatabase().then(async () => {
|
|||||||
await dbRun('INSERT INTO chat_messages (user_id, thread_id, role, content) VALUES (?, ?, ?, ?)',
|
await dbRun('INSERT INTO chat_messages (user_id, thread_id, role, content) VALUES (?, ?, ?, ?)',
|
||||||
[uid(), threadId, 'user', userText]);
|
[uid(), threadId, 'user', userText]);
|
||||||
await dbRun('UPDATE chat_threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', [threadId, uid()]);
|
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) {
|
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()]);
|
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) {
|
if (first) {
|
||||||
@@ -4232,6 +4234,33 @@ initializeDatabase().then(async () => {
|
|||||||
send({ type: 'done', messageId: lastID, content: saved, titel: threadRow && threadRow.titel });
|
send({ type: 'done', messageId: lastID, content: saved, titel: threadRow && threadRow.titel });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
send({ type: 'error', message: 'Antwort konnte nicht gespeichert werden.' });
|
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();
|
res.end();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user