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 };
|
||||
@@ -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 =
|
||||
'<div class="h-full flex items-center justify-center text-gray-400 text-sm text-center px-6">' +
|
||||
escapeHtml(msg) + '</div>';
|
||||
}
|
||||
|
||||
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 = '<div class="rounded-lg px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-500 flex gap-1">' +
|
||||
'<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span></div>';
|
||||
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 = '<a href="/chat?thread=' + encodeURIComponent(id) + '" data-thread="' + id +
|
||||
'" class="block px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700/40 bg-blue-50 dark:bg-gray-700">' +
|
||||
'<div class="truncate font-medium">' + escapeHtml(titel || '(neuer Chat)') + '</div>' +
|
||||
'<div class="text-[11px] text-gray-400">gerade</div></a>';
|
||||
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();
|
||||
})();
|
||||
@@ -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.
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<%- include('partials/head') %>
|
||||
<style>
|
||||
.chat-msg { white-space: pre-wrap; word-break: break-word; }
|
||||
#chatLog::-webkit-scrollbar { width: 8px; }
|
||||
#chatLog::-webkit-scrollbar-thumb { background: rgba(120,120,120,.4); border-radius: 4px; }
|
||||
.typing-dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; display: inline-block; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||
<%- include('partials/header', { hideSettings: true }) %>
|
||||
|
||||
<main class="container mx-auto px-4 py-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-2xl font-bold text-gray-800 dark:text-white">KI-Bewerbungs-Assistent</h2>
|
||||
<button id="newThreadBtn"
|
||||
class="px-3 py-1.5 rounded-md bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium">
|
||||
+ Neuer Chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-[260px_1fr] gap-4 h-[calc(100vh-220px)] min-h-[420px]">
|
||||
<!-- Thread sidebar -->
|
||||
<aside class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden flex flex-col">
|
||||
<div class="px-3 py-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">Verläufe</div>
|
||||
<ul id="threadList" class="overflow-y-auto flex-1 divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<% threads.forEach(function(t) { %>
|
||||
<li>
|
||||
<a href="/chat?thread=<%= t.id %>"
|
||||
data-thread="<%= t.id %>"
|
||||
class="threadLink block px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700/40 <%= (t.id === activeId) ? 'bg-blue-50 dark:bg-gray-700' : '' %>">
|
||||
<div class="truncate font-medium"><%= t.titel || '(ohne Titel)' %></div>
|
||||
<div class="text-[11px] text-gray-400"><%= new Date(t.updated_at).toLocaleDateString('de-DE') %></div>
|
||||
</a>
|
||||
</li>
|
||||
<% }); %>
|
||||
<% if (!threads.length) { %>
|
||||
<li class="px-3 py-4 text-sm text-gray-400 text-center">Noch keine Verläufe.</li>
|
||||
<% } %>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- Message area -->
|
||||
<section class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 flex flex-col overflow-hidden">
|
||||
<div id="chatLog" class="flex-1 overflow-y-auto p-4 space-y-3 text-gray-800 dark:text-gray-100">
|
||||
<% if (!activeId) { %>
|
||||
<div class="h-full flex items-center justify-center text-gray-400 text-sm text-center px-6">
|
||||
Starte einen neuen Chat, um deinen Bewerbungs-Assistenten zu fragen.
|
||||
</div>
|
||||
<% } else if (!messages.length) { %>
|
||||
<div class="h-full flex items-center justify-center text-gray-400 text-sm text-center px-6">
|
||||
Stelle die erste Frage zu deinen Bewerbungen.
|
||||
</div>
|
||||
<% } else { %>
|
||||
<% messages.forEach(function(m) { %>
|
||||
<div class="flex <%= m.role === 'user' ? 'justify-end' : 'justify-start' %>">
|
||||
<div class="max-w-[80%] rounded-lg px-3 py-2 text-sm chat-msg
|
||||
<%= m.role === 'user'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100' %>">
|
||||
<%= m.content %>
|
||||
</div>
|
||||
</div>
|
||||
<% }); %>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<form id="chatForm" class="border-t border-gray-200 dark:border-gray-700 p-3 flex gap-2"
|
||||
<%= activeId ? '' : 'data-needs-thread="1"' %>>
|
||||
<textarea id="chatInput" rows="1" autocomplete="off"
|
||||
class="flex-1 resize-none rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Frage stellen … (Enter zum Senden, Shift+Enter = Zeilenumbruch)"
|
||||
<%= activeId ? '' : 'disabled' %>></textarea>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium disabled:opacity-50"
|
||||
id="sendBtn" <%= activeId ? '' : 'disabled' %>>Senden</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<input type="hidden" id="activeThread" value="<%= activeId || '' %>">
|
||||
|
||||
<script>
|
||||
// Minimal dark-mode toggle: main.js is not loaded on the chat page, so
|
||||
// wire the header's toggle + sync the sun/moon icon visibility here.
|
||||
(function () {
|
||||
var sun = document.getElementById('sunIcon');
|
||||
var moon = document.getElementById('moonIcon');
|
||||
var isDark = document.documentElement.classList.contains('dark');
|
||||
function sync() {
|
||||
sun && sun.classList.toggle('hidden', isDark);
|
||||
moon && moon.classList.toggle('hidden', !isDark);
|
||||
}
|
||||
sync();
|
||||
var btn = document.getElementById('darkModeToggle');
|
||||
if (btn) btn.addEventListener('click', function () {
|
||||
isDark = !document.documentElement.classList.toggle('dark');
|
||||
// toggle returns whether class is present AFTER toggle; recompute.
|
||||
isDark = document.documentElement.classList.contains('dark');
|
||||
localStorage.setItem('darkMode', isDark ? 'enabled' : 'disabled');
|
||||
sync();
|
||||
});
|
||||
})();
|
||||
window.__CHAT_BOOT__ = {
|
||||
activeId: <%= activeId ? JSON.stringify(activeId) : 'null' %>,
|
||||
needsThread: <%= activeId ? 'false' : 'true' %>
|
||||
};
|
||||
</script>
|
||||
<script src="/js/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,3 +6,13 @@
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Floating button: open the KI chat assistant -->
|
||||
<a href="/chat"
|
||||
class="fixed bottom-5 right-5 z-40 flex items-center gap-2 rounded-full bg-blue-600 hover:bg-blue-700 text-white shadow-lg px-4 py-3 text-sm font-medium transition-colors"
|
||||
title="KI-Bewerbungs-Assistent öffnen">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h8M8 14h5m4 7a8 8 0 100-16 8 8 0 000 16z"></path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">KI-Chat</span>
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user