From ead70efa49e6acddade73e650559c72abad55d6e Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Tue, 7 Jul 2026 14:07:38 +0000 Subject: [PATCH] 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 --- lib/chat.js | 159 +++++++++++++++++++++++++++++ public/js/chat.js | 205 +++++++++++++++++++++++++++++++++++++ server.js | 209 ++++++++++++++++++++++++++++++++++++++ views/chat.ejs | 114 +++++++++++++++++++++ views/partials/footer.ejs | 10 ++ 5 files changed, 697 insertions(+) create mode 100644 lib/chat.js create mode 100644 public/js/chat.js create mode 100644 views/chat.ejs diff --git a/lib/chat.js b/lib/chat.js new file mode 100644 index 0000000..e5a08e6 --- /dev/null +++ b/lib/chat.js @@ -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 }; \ No newline at end of file diff --git a/public/js/chat.js b/public/js/chat.js new file mode 100644 index 0000000..690eb1a --- /dev/null +++ b/public/js/chat.js @@ -0,0 +1,205 @@ +// KI-Chat client: sends messages, streams assistant tokens over SSE. +// Tailwind CDN is loaded via the head partial; this file only handles logic. +(function () { + const boot = window.__CHAT_BOOT__ || {}; + let activeId = boot.activeId || null; + const log = document.getElementById('chatLog'); + const form = document.getElementById('chatForm'); + const input = document.getElementById('chatInput'); + const sendBtn = document.getElementById('sendBtn'); + const newThreadBtn = document.getElementById('newThreadBtn'); + const threadList = document.getElementById('threadList'); + let sending = false; + + function escapeHtml(s) { + const d = document.createElement('div'); + d.textContent = s == null ? '' : String(s); + return d.innerHTML; + } + + function scrollBottom() { + if (log) log.scrollTop = log.scrollHeight; + } + + function emptyState(msg) { + log.innerHTML = + '
' + + escapeHtml(msg) + '
'; + } + + function addBubble(role, content) { + if (log && log.querySelector('.h-full')) log.innerHTML = ''; + const wrap = document.createElement('div'); + wrap.className = 'flex ' + (role === 'user' ? 'justify-end' : 'justify-start'); + const bubble = document.createElement('div'); + bubble.className = 'max-w-[80%] rounded-lg px-3 py-2 text-sm chat-msg ' + + (role === 'user' ? 'bg-blue-600 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100'); + bubble.textContent = content; + wrap.appendChild(bubble); + log.appendChild(wrap); + scrollBottom(); + return bubble; + } + + function typingIndicator() { + if (log && log.querySelector('.h-full')) log.innerHTML = ''; + const wrap = document.createElement('div'); + wrap.className = 'flex justify-start'; + wrap.id = 'typing'; + wrap.innerHTML = '
' + + '
'; + log.appendChild(wrap); + scrollBottom(); + return wrap; + } + + function prependThread(id, titel) { + if (!threadList) return; + const empty = threadList.querySelector('li.text-center'); + if (empty) empty.remove(); + const li = document.createElement('li'); + li.innerHTML = '' + + '
' + escapeHtml(titel || '(neuer Chat)') + '
' + + '
gerade
'; + threadList.insertBefore(li, threadList.firstChild); + } + + function enableInput() { + input.disabled = false; + sendBtn.disabled = false; + } + + async function createThread() { + const res = await fetch('/chat/api/threads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ titel: '' }), + }); + if (!res.ok) throw new Error('Thread konnte nicht angelegt werden.'); + const data = await res.json(); + activeId = data.id; + document.getElementById('activeThread').value = activeId; + prependThread(activeId, data.titel); + enableInput(); + history.replaceState(null, '', '/chat?thread=' + activeId); + return activeId; + } + + // Parse the SSE response body stream. `onEvent` is called with each parsed + // data object. Returns when the stream closes or errors. + async function consumeSSE(res, onEvent) { + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let sep; + while ((sep = buffer.indexOf('\n\n')) >= 0) { + const block = buffer.slice(0, sep); + buffer = buffer.slice(sep + 2); + for (const line of block.split('\n')) { + if (!line.startsWith('data:')) continue; + const payload = line.slice(5).trim(); + if (!payload) continue; + try { onEvent(JSON.parse(payload)); } catch (e) { /* ignore */ } + } + } + } + } + + async function sendMessage(text) { + if (sending || !text.trim()) return; + sending = true; + sendBtn.disabled = true; + + if (!activeId) { + try { await createThread(); } + catch (e) { sending = false; sendBtn.disabled = false; alert(e.message); return; } + } + + addBubble('user', text); + input.value = ''; + input.style.height = 'auto'; + const typing = typingIndicator(); + let assistantBubble = null; + let assistantText = ''; + + try { + const res = await fetch('/chat/api/threads/' + activeId + '/messages', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: text }), + }); + if (!res.ok || !res.body) { + let msg = 'Senden fehlgeschlagen (' + res.status + ')'; + try { const j = await res.json(); msg = j.error || msg; } catch (e) {} + throw new Error(msg); + } + + await consumeSSE(res, (ev) => { + if (ev.type === 'token') { + if (!assistantBubble) { + if (typing) typing.remove(); + assistantBubble = addBubble('assistant', ''); + } + assistantText += ev.content; + assistantBubble.textContent = assistantText; + scrollBottom(); + } else if (ev.type === 'done') { + if (assistantBubble && ev.content) { + assistantBubble.textContent = ev.content; + } else if (!assistantBubble) { + if (typing) typing.remove(); + addBubble('assistant', ev.content || '(keine Antwort)'); + } + scrollBottom(); + } else if (ev.type === 'error') { + if (typing) typing.remove(); + if (!assistantBubble) addBubble('assistant', 'Fehler: ' + ev.message); + else assistantBubble.textContent += '\n[Fehler: ' + ev.message + ']'; + } + }); + } catch (err) { + if (typing) typing.remove(); + addBubble('assistant', 'Fehler: ' + (err.message || 'unbekannt')); + } finally { + if (typing && typing.parentNode) typing.remove(); + sending = false; + sendBtn.disabled = false; + input.focus(); + } + } + + // Auto-grow the textarea; Enter sends, Shift+Enter inserts a newline. + if (input) { + input.addEventListener('input', () => { + input.style.height = 'auto'; + input.style.height = Math.min(input.scrollHeight, 160) + 'px'; + }); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(input.value); + } + }); + } + + if (form) { + form.addEventListener('submit', (e) => { + e.preventDefault(); + sendMessage(input.value); + }); + } + + if (newThreadBtn) { + newThreadBtn.addEventListener('click', () => { + window.location.href = '/chat'; + }); + } + + scrollBottom(); + if (input && !input.disabled) input.focus(); +})(); \ No newline at end of file diff --git a/server.js b/server.js index 5aa7805..6cb3b1a 100644 --- a/server.js +++ b/server.js @@ -28,6 +28,7 @@ const multer = require('multer'); })(); const { generateApplicationDocuments, generateEmailReply } = require('./lib/documents'); +const chat = require('./lib/chat'); const mailer = require('./lib/mailer'); const { createExternalApi } = require('./lib/api'); const { buildOpenApiSpec } = require('./lib/openapi'); @@ -883,6 +884,28 @@ function initializeDatabase() { ) `, () => {}); + // Conversational KI-Chat: threads and their messages. The assistant + // answer is streamed from Ollama (see lib/chat.js) and persisted here. + db.run(` + CREATE TABLE IF NOT EXISTS chat_threads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + titel TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + db.run(` + CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (thread_id) REFERENCES chat_threads(id) ON DELETE CASCADE + ) + `, () => {}); + db.run('CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id)', () => {}); + db.run(` CREATE TABLE IF NOT EXISTS settings ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -2297,6 +2320,192 @@ initializeDatabase().then(() => { } }); + // ----- Conversational KI-Chat (Ollama, streaming) ----- + // Gated behind OLLAMA_API_KEY. Threads + messages persist in SQLite; the + // assistant answer is streamed back via Server-Sent Events. + async function gatherChatContext() { + const [settings, applications, recentEmails, upcoming] = await Promise.all([ + dbGet('SELECT name FROM settings WHERE id = 1'), + dbAll(`SELECT firma, stelle, status, datum, notizen FROM bewerbungen + ORDER BY datum DESC, created_at DESC LIMIT 25`), + dbAll(`SELECT e.subject, e.from_addr AS from, b.firma AS bewerbung + FROM emails e LEFT JOIN bewerbungen b ON b.id = e.bewerbung_id + WHERE e.direction = 'received' + ORDER BY e.email_date DESC, e.created_at DESC LIMIT 12`), + upcomingTermine(8), + ]); + return { + settings: settings || {}, + applications: applications.map((a) => ({ + firma: a.firma, stelle: a.stelle, status: a.status, + datum: a.datum, notizen: (a.notizen || '').slice(0, 240), + })), + recentEmails: recentEmails.map((e) => ({ + subject: e.subject, from: e.from_addr, bewerbung: e.bewerbung, + })), + upcomingTermine: upcoming.map((t) => ({ + titel: t.titel, start: t.start, bewerbung: t.bewerbung_firma, + })), + }; + } + + // Chat page: list threads + render the active thread (or a fresh empty one). + app.get('/chat', async (req, res) => { + if (!chat.isConfigured()) return res.status(503).send('KI-Chat deaktiviert – OLLAMA_API_KEY fehlt.'); + try { + const threads = await dbAll( + 'SELECT id, titel, updated_at FROM chat_threads ORDER BY updated_at DESC' + ); + const activeId = req.query.thread ? Number(req.query.thread) : (threads[0] && threads[0].id); + let messages = []; + if (activeId) { + messages = await dbAll( + 'SELECT id, role, content, created_at FROM chat_messages WHERE thread_id = ? ORDER BY id ASC', + [activeId] + ); + } + res.render('chat', { + threads, activeId, messages, + hasApiKey: true, hideSettings: false, + }); + } catch (error) { + console.error('Chat page error:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Create a new thread. Optional `titel` in the body. + app.post('/chat/api/threads', async (req, res) => { + try { + const titel = sanitizeInput((req.body.titel || '').trim()).slice(0, 120) || null; + const { lastID } = await dbRun('INSERT INTO chat_threads (titel) VALUES (?)', [titel]); + res.json({ id: lastID, titel }); + } catch (error) { + console.error('Create thread error:', error); + res.status(500).json({ error: 'Serverfehler' }); + } + }); + + // Delete a thread (cascades to its messages). + app.delete('/chat/api/threads/:id', async (req, res) => { + try { + await dbRun('DELETE FROM chat_threads WHERE id = ?', [Number(req.params.id)]); + res.json({ ok: true }); + } catch (error) { + console.error('Delete thread error:', error); + res.status(500).json({ error: 'Serverfehler' }); + } + }); + + // Rename a thread (e.g. auto-title from first message). + app.patch('/chat/api/threads/:id', async (req, res) => { + try { + const titel = sanitizeInput((req.body.titel || '').trim()).slice(0, 120); + await dbRun('UPDATE chat_threads SET titel = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [titel, Number(req.params.id)]); + res.json({ ok: true }); + } catch (error) { + console.error('Rename thread error:', error); + res.status(500).json({ error: 'Serverfehler' }); + } + }); + + // Send a user message and stream the assistant reply via SSE. + app.post('/chat/api/threads/:id/messages', async (req, res) => { + if (!chat.isConfigured()) return res.status(503).json({ error: 'KI-Chat deaktiviert.' }); + const threadId = Number(req.params.id); + const userText = sanitizeInput((req.body.content || '').trim()); + if (!userText) return res.status(400).json({ error: 'Leere Nachricht.' }); + + let thread; + try { + thread = await dbGet('SELECT id, titel FROM chat_threads WHERE id = ?', [threadId]); + } catch (e) { /* fall through */ } + if (!thread) return res.status(404).json({ error: 'Thread nicht gefunden.' }); + + // Persist the user message, then load the full prior history for context. + try { + await dbRun('INSERT INTO chat_messages (thread_id, role, content) VALUES (?, ?, ?)', + [threadId, 'user', userText]); + await dbRun('UPDATE chat_threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [threadId]); + // Auto-title the thread from the first user message, if untitled. + if (!thread.titel) { + const first = await dbGet('SELECT content FROM chat_messages WHERE thread_id = ? ORDER BY id ASC LIMIT 1', [threadId]); + if (first) { + const t = first.content.slice(0, 60).replace(/\s+/g, ' ').trim(); + if (t) await dbRun('UPDATE chat_threads SET titel = ? WHERE id = ? AND (titel IS NULL OR titel = "")', [t, threadId]); + } + } + } catch (error) { + console.error('Persist user message error:', error); + return res.status(500).json({ error: 'Serverfehler' }); + } + + let history; + try { + history = await dbAll( + 'SELECT role, content FROM chat_messages WHERE thread_id = ? ORDER BY id ASC', + [threadId] + ); + } catch (error) { + return res.status(500).json({ error: 'Serverfehler' }); + } + + // SSE setup. Keep the connection alive; flush headers immediately. + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders && res.flushHeaders(); + + const send = (obj) => { + res.write(`data: ${JSON.stringify(obj)}\n\n`); + }; + + // AbortController so a closed client stops the upstream Ollama stream. + const controller = new AbortController(); + let aborted = false; + req.on('close', () => { aborted = true; controller.abort(); }); + + // Trim very old history to bound token cost (keep the last 20 turns). + const trimmed = history.slice(-40); + const messages = trimmed.map((m) => ({ role: m.role, content: m.content })); + + let context; + try { context = await gatherChatContext(); } + catch (e) { context = {}; } + const system = chat.buildContextPrompt(context); + + let assistantText = ''; + try { + assistantText = await chat.streamChat({ + system, + messages, + signal: controller.signal, + onToken: (delta) => send({ type: 'token', content: delta }), + }); + } catch (err) { + if (aborted) { res.end(); return; } + send({ type: 'error', message: err.message || 'KI-Fehler' }); + res.end(); + return; + } + + // Persist the (possibly empty) assistant reply. + const saved = assistantText || '(keine Antwort)'; + try { + const { lastID } = await dbRun( + 'INSERT INTO chat_messages (thread_id, role, content) VALUES (?, ?, ?)', + [threadId, 'assistant', saved] + ); + await dbRun('UPDATE chat_threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [threadId]); + send({ type: 'done', messageId: lastID, content: saved }); + } catch (error) { + send({ type: 'error', message: 'Antwort konnte nicht gespeichert werden.' }); + } + res.end(); + }); + // ----- Third-party REST API (/api/v1) + OpenAPI/Swagger ----- // API key for third-party software. When unset, the API responds 503 on // every endpoint except /health — it never silently exposes data. diff --git a/views/chat.ejs b/views/chat.ejs new file mode 100644 index 0000000..3c59050 --- /dev/null +++ b/views/chat.ejs @@ -0,0 +1,114 @@ + + + + <%- include('partials/head') %> + + + + <%- include('partials/header', { hideSettings: true }) %> + +
+
+

KI-Bewerbungs-Assistent

+ +
+ +
+ + + + +
+
+ <% if (!activeId) { %> +
+ Starte einen neuen Chat, um deinen Bewerbungs-Assistenten zu fragen. +
+ <% } else if (!messages.length) { %> +
+ Stelle die erste Frage zu deinen Bewerbungen. +
+ <% } else { %> + <% messages.forEach(function(m) { %> +
+
+ <%= m.content %> +
+
+ <% }); %> + <% } %> +
+ +
> + + +
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/views/partials/footer.ejs b/views/partials/footer.ejs index 42e792d..ff3187c 100644 --- a/views/partials/footer.ejs +++ b/views/partials/footer.ejs @@ -6,3 +6,13 @@

+ + + + + + + +