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>
205 lines
7.0 KiB
JavaScript
205 lines
7.0 KiB
JavaScript
// 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();
|
|
})(); |