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 { generateApplicationDocuments, generateEmailReply } = require('./lib/documents');
|
||||||
|
const chat = require('./lib/chat');
|
||||||
const mailer = require('./lib/mailer');
|
const mailer = require('./lib/mailer');
|
||||||
const { createExternalApi } = require('./lib/api');
|
const { createExternalApi } = require('./lib/api');
|
||||||
const { buildOpenApiSpec } = require('./lib/openapi');
|
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(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
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 -----
|
// ----- Third-party REST API (/api/v1) + OpenAPI/Swagger -----
|
||||||
// API key for third-party software. When unset, the API responds 503 on
|
// API key for third-party software. When unset, the API responds 503 on
|
||||||
// every endpoint except /health — it never silently exposes data.
|
// 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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