KI-Chat: interaktiver Bewerbungs-Assistent (Ollama-Streaming)

Eigenes Chat-Interface mit SSE-Streaming gegen das hinterlegte Ollama-Modell,
gegroundet in den Bewerbungs-/E-Mail-/Termindaten des Nutzers.

- lib/chat.js: streamChat (Ollama stream:true, NDJSON-Token) + buildContextPrompt
- chat_threads/chat_messages Tabellen (CASCADE, Index)
- Routen: GET /chat, Thread-CRUD, POST /messages (SSE, AbortController)
- views/chat.ejs + public/js/chat.js + Floating-Button im Footer
- hasApiKey-Gating (503 ohne OLLAMA_API_KEY)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-07 14:07:38 +00:00
co-authored by Claude
parent 17df46cb6c
commit ead70efa49
5 changed files with 697 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
// Conversational chat over Ollama Cloud.
//
// 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 grounds the conversation in
// the user's own application data via a context-rich system prompt.
//
// 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 OLLAMA_HOST = (process.env.OLLAMA_HOST || 'https://ollama.com').replace(/\/+$/, '');
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'gpt-oss:120b';
const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 300000);
function isConfigured() {
return Boolean(process.env.OLLAMA_API_KEY);
}
// Stream a chat completion from Ollama. `messages` = [{role, content}] in
// chronological order (system prompt is prepended separately). `onToken` is
// called with each incremental text chunk as it arrives. Returns the full
// assistant text once the stream finishes. Aborts cleanly via `signal`.
async function streamChat({ system, messages, onToken, temperature = 0.6, signal }) {
const apiKey = process.env.OLLAMA_API_KEY;
if (!apiKey) throw new Error('OLLAMA_API_KEY ist nicht gesetzt.');
const allMessages = [];
if (system) allMessages.push({ role: 'system', content: system });
for (const m of messages) allMessages.push({ role: m.role, content: m.content });
let res;
try {
res = await fetch(`${OLLAMA_HOST}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: OLLAMA_MODEL,
stream: true,
options: { temperature },
messages: allMessages,
}),
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 body = await res.text().catch(() => '');
throw new Error(`Ollama-API antwortete mit ${res.status}: ${body.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 text = ((data && data.message && data.message.content) || '').trim();
if (onToken && text) onToken(text);
return text;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let full = '';
// 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 delta = obj && obj.message && obj.message.content;
if (delta) {
full += delta;
if (onToken) onToken(delta);
}
};
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 full.trim();
}
// Build the German system prompt that grounds the assistant in the user's
// application data. `context` is gathered by the server from SQLite:
// { settings, applications, recentEmails, upcomingTermine }
// Each entry is already trimmed to the fields the prompt needs.
function buildContextPrompt(ctx) {
const name = (ctx && ctx.settings && ctx.settings.name) || 'der Bewerber';
const parts = [];
parts.push(
'Du bist ein hilfreicher, deutschsprachiger Bewerbungs-Assistent für ' + name + '. ' +
'Du beantwortest Fragen zu laufenden Bewerbungen, hilfst beim Formulieren von ' +
'Antworten, beim Vorbereiten auf Gespräche und beim Überblick über den Status. ' +
'Antworte natürlich, knapp und auf Deutsch im lateinischen Alphabet. ' +
'Erfinde KEINE Fakten (keine erfundenen Termine, Zahlen, Zusagen, Firmen). ' +
'Wenn eine Angabe nötig ist, die du nicht weißt, setze einen klar erkennbaren ' +
'Platzhalter in eckigen Klammern. Verwende nur den einfachen Bindestrich "-".'
);
const apps = (ctx && ctx.applications) || [];
if (apps.length) {
parts.push('# Bewerbungen (jüngste zuerst)');
for (const a of apps) {
const lines = [
`${a.firma || '?'} ${a.stelle || '?'} (Status: ${a.status || '—'}, Datum: ${a.datum || '—'})`,
];
if (a.notizen) lines.push(` Notizen: ${a.notizen}`);
parts.push(lines.join('\n'));
}
}
const emails = (ctx && ctx.recentEmails) || [];
if (emails.length) {
parts.push('# Letzte eingegangene E-Mails');
for (const e of emails) {
parts.push(
`${e.subject || '(kein Betreff)'} — von ${e.from || '?'} (${e.bewerbung || '?'})`
);
}
}
const termine = (ctx && ctx.upcomingTermine) || [];
if (termine.length) {
parts.push('# Kommende Termine');
for (const t of termine) {
parts.push(
`${t.titel}${t.start}${t.bewerbung ? ` (${t.bewerbung})` : ''}`
);
}
}
parts.push(
'# Aufgabe\n' +
'Beziehe dich bei Antworten auf den obigen Kontext, wenn relevant. ' +
'Wenn der Nutzer nach einer konkreten Bewerbung fragt, die im Kontext ' +
'nicht enthalten ist, weise darauf hin, dass du sie nicht findest.'
);
return parts.join('\n\n');
}
module.exports = { isConfigured, streamChat, buildContextPrompt, OLLAMA_MODEL };