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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user