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:
+159
@@ -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 };
|
||||
Reference in New Issue
Block a user