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>
276 lines
11 KiB
JavaScript
276 lines
11 KiB
JavaScript
// Conversational chat over Ollama Cloud with tool calling.
|
|
//
|
|
// The rest of the app uses the LLM as a one-shot generator (cover letters,
|
|
// e-mail replies). This module adds an interactive chat: it streams assistant
|
|
// tokens from Ollama (`stream: true`, NDJSON) and lets the assistant look up
|
|
// the user's application data on demand via tool calls instead of having every
|
|
// application baked into the system prompt (which would be slow and grow with
|
|
// the data).
|
|
//
|
|
// Unlike lib/documents.js, no JSON output schema is enforced — the model just
|
|
// replies as text, token by token, so the UI can render progressively.
|
|
|
|
const promptStore = require('./prompts');
|
|
const config = require('./config');
|
|
|
|
// Firmenrecherche braucht mehr Runden als eine reine Datenbankfrage: Bewerbung
|
|
// holen -> web_suche -> ein, zwei Seiten lesen -> antworten. Mit 4 Runden lief
|
|
// der Assistent dabei ins Limit, statt zu antworten.
|
|
const MAX_TOOL_ROUNDS = 8;
|
|
|
|
function isConfigured() {
|
|
return Boolean(config.ollama().apiKey);
|
|
}
|
|
|
|
// Stream a single chat completion from Ollama. `messages` = [{role, content}]
|
|
// in chronological order (system prompt is prepended separately). `tools` is an
|
|
// optional Ollama tool-definition array; when the model calls a tool the
|
|
// returned `toolCalls` is non-empty and `content` is usually empty. `onToken`
|
|
// fires with each incremental text chunk. Returns { content, toolCalls }.
|
|
// Aborts cleanly via `signal`.
|
|
async function streamChat({ system, messages, tools, onToken, temperature = 0.6, signal }) {
|
|
const { host: ollamaHost, model: ollamaModel, apiKey } = config.ollama();
|
|
if (!apiKey) throw new Error('OLLAMA_API_KEY ist nicht gesetzt (unter „Einstellungen“ konfigurieren).');
|
|
|
|
const allMessages = [];
|
|
if (system) allMessages.push({ role: 'system', content: system });
|
|
for (const m of messages) {
|
|
// Preserve tool_calls (assistant) and tool_name (tool result) so Ollama can
|
|
// match tool calls to their results across rounds.
|
|
const msg = { role: m.role, content: m.content };
|
|
if (m.tool_calls) msg.tool_calls = m.tool_calls;
|
|
if (m.tool_name) msg.tool_name = m.tool_name;
|
|
allMessages.push(msg);
|
|
}
|
|
|
|
const body = {
|
|
model: ollamaModel,
|
|
stream: true,
|
|
options: { temperature },
|
|
messages: allMessages,
|
|
};
|
|
if (tools && tools.length) body.tools = tools;
|
|
|
|
let res;
|
|
try {
|
|
res = await fetch(`${ollamaHost}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
body: JSON.stringify(body),
|
|
signal,
|
|
});
|
|
} catch (err) {
|
|
if (err.name === 'AbortError') throw new Error('Abgebrochen.');
|
|
throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`);
|
|
}
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
throw new Error(`Ollama-API antwortete mit ${res.status}: ${text.slice(0, 300)}`);
|
|
}
|
|
if (!res.body || !res.body.getReader) {
|
|
// Node without streaming body support: fall back to buffered response.
|
|
const data = await res.json();
|
|
const msg = (data && data.message) || {};
|
|
const text = (msg.content || '').trim();
|
|
if (onToken && text) onToken(text);
|
|
return { content: text, toolCalls: normalizeToolCalls(msg.tool_calls) };
|
|
}
|
|
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
let full = '';
|
|
const toolCalls = [];
|
|
|
|
// Ollama streams one JSON object per line (NDJSON). Accumulate partial
|
|
// lines across chunks, then parse each complete line.
|
|
const handleLine = (line) => {
|
|
line = line.trim();
|
|
if (!line) return;
|
|
let obj;
|
|
try { obj = JSON.parse(line); } catch (e) { return; } // ignore keepalives
|
|
const msg = obj && obj.message;
|
|
if (!msg) return;
|
|
const delta = msg.content;
|
|
if (delta) {
|
|
full += delta;
|
|
if (onToken) onToken(delta);
|
|
}
|
|
if (msg.tool_calls && msg.tool_calls.length) {
|
|
// Ollama emits tool_calls (complete) on the final chunk; capture them.
|
|
toolCalls.length = 0;
|
|
toolCalls.push(...normalizeToolCalls(msg.tool_calls));
|
|
}
|
|
};
|
|
|
|
while (true) {
|
|
let chunk;
|
|
try {
|
|
chunk = await reader.read();
|
|
} catch (err) {
|
|
if (err.name === 'AbortError') throw new Error('Abgebrochen.');
|
|
throw err;
|
|
}
|
|
if (chunk.done) break;
|
|
buffer += decoder.decode(chunk.value, { stream: true });
|
|
let nl;
|
|
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
const line = buffer.slice(0, nl);
|
|
buffer = buffer.slice(nl + 1);
|
|
handleLine(line);
|
|
}
|
|
}
|
|
handleLine(buffer); // flush trailing line
|
|
return { content: full.trim(), toolCalls };
|
|
}
|
|
|
|
// Coerce Ollama's tool_calls into a stable shape: [{function:{name, arguments}}]
|
|
// where arguments is always a plain object (Ollama sometimes sends a string).
|
|
function normalizeToolCalls(raw) {
|
|
if (!Array.isArray(raw)) return [];
|
|
const out = [];
|
|
for (const tc of raw) {
|
|
const fn = (tc && tc.function) || {};
|
|
let args = fn.arguments;
|
|
if (typeof args === 'string') {
|
|
try { args = JSON.parse(args); } catch (e) { args = {}; }
|
|
}
|
|
out.push({ function: { name: fn.name, arguments: args || {} } });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Run a full agentic turn: stream the model's reply; if it calls tools,
|
|
// execute them (via `executeTool(name, args) -> any JSON-serialisable value`),
|
|
// feed the results back, and loop until the model answers in plain text (or
|
|
// MAX_TOOL_ROUNDS is hit). `onToken` streams the final text answer; `onToolCall`
|
|
// fires for each tool invocation so the UI can show what's being looked up.
|
|
async function runChat({ system, messages, tools, executeTool, onToken, onToolCall, signal, temperature = 0.6 }) {
|
|
const convo = messages.map((m) => ({ role: m.role, content: m.content }));
|
|
|
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
const { content, toolCalls } = await streamChat({
|
|
system, messages: convo, tools, onToken, temperature, signal,
|
|
});
|
|
if (!toolCalls || !toolCalls.length) return content;
|
|
|
|
// Append the assistant's tool-call message verbatim so the model sees its
|
|
// own call in the next round, then one tool-result message per call.
|
|
convo.push({
|
|
role: 'assistant',
|
|
content: content || '',
|
|
tool_calls: toolCalls,
|
|
});
|
|
for (const tc of toolCalls) {
|
|
const name = tc.function.name;
|
|
if (onToolCall) onToolCall(name, tc.function.arguments);
|
|
let result;
|
|
try {
|
|
result = await executeTool(name, tc.function.arguments);
|
|
} catch (err) {
|
|
result = { error: err.message || String(err) };
|
|
}
|
|
convo.push({
|
|
role: 'tool',
|
|
tool_name: name,
|
|
content: JSON.stringify(result),
|
|
});
|
|
}
|
|
}
|
|
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.
|
|
function buildContextPrompt(ctx) {
|
|
const name = (ctx && ctx.settings && ctx.settings.name) || 'der Bewerber';
|
|
const heute = (ctx && ctx.heute) || '';
|
|
const parts = [];
|
|
// The assistant's role/tone is editable (Vorlagen page); the sections below
|
|
// describe the runtime wiring (tools, date, profile) and stay in code.
|
|
parts.push(promptStore.get(ctx && ctx.prompts, 'chat', { name }));
|
|
parts.push(
|
|
'# Werkzeuge\n' +
|
|
'Du hast Werkzeuge, um Bewerbungsdaten nachzuschlagen. Nutze sie, sobald der ' +
|
|
'Nutzer nach einer konkreten Bewerbung, Firma, Status, Stellenbeschreibung, ' +
|
|
'Korrespondenz oder einem Termin fragt — rufe niemals Daten aus dem Gedächtnis ' +
|
|
'ab, die du per Werkzeug holen kannst. Lege mehrere benötigte Werkzeuge in ' +
|
|
'einer Runde parallel an. Wenn eine Suche keine Treffer liefert, weise den ' +
|
|
'Nutzer darauf hin und frage nach Details (z. B. Link/Stellenbeschreibung).\n\n' +
|
|
'Zusätzlich kannst du mit `web_suche` und `web_seite_lesen` im Internet ' +
|
|
'recherchieren. Nutze das für alles, was nicht in den Bewerbungsdaten steht: ' +
|
|
'Hintergrund zu einer Firma (Produkte, Größe, Standorte, Kultur, aktuelle ' +
|
|
'Nachrichten), Gehaltsspannen, Technologien, Vorbereitung auf ein Gespräch. ' +
|
|
'Typischer Ablauf bei „Was muss ich über Firma X wissen?": erst die Bewerbung ' +
|
|
'per Werkzeug holen (Stelle, Ort, Stellenbeschreibung, quelle_url), dann ' +
|
|
'gezielt im Web nachrecherchieren und beides zusammenführen. Recherchiere ' +
|
|
'nicht ungefragt bei jeder Frage — nur, wenn aktuelles oder externes Wissen ' +
|
|
'die Antwort wirklich besser macht.\n\n' +
|
|
'Bei Web-Rechercheergebnissen: Gib nur wieder, was in den Treffern steht, und ' +
|
|
'nenne die Quelle als URL. Wenn du etwas nicht belegen kannst, sage es — rate ' +
|
|
'nicht. Achte darauf, dass Treffer wirklich die gemeinte Firma betreffen ' +
|
|
'(Namensdopplungen, falscher Standort) und weise auf Zweifel hin.'
|
|
);
|
|
if (heute) {
|
|
parts.push('# Heute\n' + 'Heutiges Datum: ' + heute + '. ' +
|
|
'Terminangaben aus Werkzeugen sind UTC-Zeitstempel (ISO, endet auf Z). ' +
|
|
'Rechne in UTC-Zeiten und leite daraus ab, was "heute", "morgen" usw. ist. ' +
|
|
'Zeige dem Nutzer Terminzeiten in seiner lokalen Zeit (Europe/Berlin = UTC+1, ' +
|
|
'bzw. UTC+2 während der Sommerzeit).');
|
|
}
|
|
|
|
if (ctx && ctx.profil) {
|
|
parts.push('# Dein Profil (Lebenslauf)\n' + ctx.profil +
|
|
'\nNutze dies, um Antworten auf den Bewerber zuzuschneiden: Wenn der Nutzer ' +
|
|
'nach einer Firma fragt, hebe hervor, welche Anforderungen der Stelle zu ' +
|
|
'seinem Profil passen und wo Lücken bestehen.');
|
|
}
|
|
|
|
return parts.join('\n\n');
|
|
}
|
|
|
|
module.exports = {
|
|
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; },
|
|
}; |