// 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 =
'
' +
'' + escapeHtml(titel || '(neuer Chat)') + '
' +
'gerade
';
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();
})();