// 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(msg) + '
'; } 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 = '
' + '
'; 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 = '' + '
' + 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(); })();