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
+159
View File
@@ -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 };
+205
View File
@@ -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();
})();
+209
View File
@@ -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
View File
@@ -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>
+10
View File
@@ -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>