Files
jobbi-bewerbung/lib/chat.js
T
thomasandClaude ead70efa49 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>
2026-07-07 14:07:38 +00:00

159 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };