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
+209
View File
@@ -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.