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) { %>
+
+ <% }); %>
+ <% } %>
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+ KI-Chat
+