// KI-Chat client: renders messages (markdown for assistant), sends messages, // streams assistant tokens over SSE, and handles "new chat" reset. // 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; // ----- helpers ----- 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; } // Minimal markdown → HTML. HTML is escaped first, so model output can never // inject markup; only the markdown tokens below are turned into tags. function inlineFmt(t) { return t .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/(^|[^*])\*([^*]+)\*/g, '$1$2') .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '$1'); } function renderMarkdown(src) { const esc = escapeHtml(src == null ? '' : String(src)).replace(/\r\n/g, '\n'); const lines = esc.split('\n'); let html = ''; let i = 0; let para = []; const flushPara = () => { if (para.length) html += '

' + inlineFmt(para.join(' ')) + '

'; para = []; }; while (i < lines.length) { const line = lines[i]; if (/^```/.test(line)) { // fenced code block flushPara(); const code = []; i++; while (i < lines.length && !/^```/.test(lines[i])) { code.push(lines[i]); i++; } i++; // closing fence html += '
' + code.join('\n') + '
'; continue; } const hm = line.match(/^(#{1,6})\s+(.*)$/); if (hm) { flushPara(); const lvl = Math.min(hm[1].length, 3); html += '' + inlineFmt(hm[2].trim()) + ''; i++; continue; } if (/^\s*[-*]\s+/.test(line)) { // bullet list flushPara(); const items = []; while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) { items.push('
  • ' + inlineFmt(lines[i].replace(/^\s*[-*]\s+/, '')) + '
  • '); i++; } html += ''; continue; } if (/^\s*\d+\.\s+/.test(line)) { // numbered list flushPara(); const items = []; while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) { items.push('
  • ' + inlineFmt(lines[i].replace(/^\s*\d+\.\s+/, '')) + '
  • '); i++; } html += '
      ' + items.join('') + '
    '; continue; } if (/^\s*$/.test(line)) { flushPara(); i++; continue; } para.push(line.trim()); i++; } flushPara(); return html; } function clearLogToEmpty(msg) { if (!log) return; log.innerHTML = '
    ' + escapeHtml(msg) + '
    '; } // Build a message row. Assistant content is markdown-rendered + left aligned; // user content is escaped plain text with preserved line breaks. function buildRow(role, contentHtml) { const wrap = document.createElement('div'); wrap.className = 'flex ' + (role === 'user' ? 'justify-end' : 'justify-start'); const bubble = document.createElement('div'); bubble.className = 'max-w-[85%] rounded-lg px-3 py-2 text-sm text-left ' + (role === 'user' ? 'bg-blue-600 text-white whitespace-pre-wrap break-words' : 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100 chat-md'); bubble.innerHTML = contentHtml; wrap.appendChild(bubble); return { wrap, bubble }; } function addMessage(role, content) { if (log && log.querySelector('.h-full')) log.innerHTML = ''; const html = role === 'user' ? escapeHtml(content) : renderMarkdown(content); const { wrap, bubble } = buildRow(role, html); 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.setAttribute('data-thread-li', id); li.innerHTML = '
    ' + '' + '
    ' + escapeHtml(titel || '(neuer Chat)') + '
    ' + '
    gerade
    ' + '
    ' + '' + '' + '
    '; threadList.insertBefore(li, threadList.firstChild); } // ----- "Neuer Chat": reset client-side to a fresh empty conversation ----- function resetToNewChat() { activeId = null; document.getElementById('activeThread').value = ''; clearLogToEmpty('Stelle eine Frage zu deinen Bewerbungen.'); if (input) { input.value = ''; input.disabled = false; input.style.height = 'auto'; input.focus(); } if (sendBtn) sendBtn.disabled = false; // deselect sidebar rows threadList && threadList.querySelectorAll('.bg-blue-50, .dark\\:bg-gray-700').forEach((el) => { el.classList.remove('bg-blue-50', 'dark:bg-gray-700'); }); history.replaceState(null, '', '/chat'); } 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); history.replaceState(null, '', '/chat?thread=' + activeId); return activeId; } // Parse the SSE response body stream; call onEvent with each parsed data object. 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; } } addMessage('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 === 'tool') { // Tool lookups run before the first text token; surface a muted hint // inside the typing indicator so the user sees activity. const typingEl = document.getElementById('typing'); if (typingEl && !assistantBubble) { const label = ev.label || ev.name || 'nachschlagen…'; typingEl.innerHTML = '
    ' + '🔍 ' + escapeHtml(label) + '
    '; } scrollBottom(); } else if (ev.type === 'token') { if (!assistantBubble) { if (typing) typing.remove(); assistantBubble = addMessage('assistant', ''); } assistantText += ev.content; assistantBubble.innerHTML = renderMarkdown(assistantText); scrollBottom(); } else if (ev.type === 'done') { if (assistantBubble && ev.content) { assistantBubble.innerHTML = renderMarkdown(ev.content); } else if (!assistantBubble) { if (typing) typing.remove(); addMessage('assistant', ev.content || '(keine Antwort)'); } // Live-update the auto-generated thread title in the sidebar. if (ev.titel && activeId) { const li = threadList && threadList.querySelector('li[data-thread-li="' + activeId + '"]'); const titleEl = li && li.querySelector('.threadLink .font-medium'); if (titleEl && ev.titel && ev.titel !== titleEl.textContent) titleEl.textContent = ev.titel; } scrollBottom(); } else if (ev.type === 'error') { if (typing) typing.remove(); if (!assistantBubble) addMessage('assistant', 'Fehler: ' + ev.message); else assistantBubble.innerHTML = renderMarkdown(assistantText + '\n\n_Fehler: ' + ev.message + '_'); } }); } catch (err) { if (typing) typing.remove(); addMessage('assistant', 'Fehler: ' + (err.message || 'unbekannt')); } finally { if (typing && typing.parentNode) typing.remove(); sending = false; sendBtn.disabled = false; input.focus(); } } // ----- wiring ----- 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', resetToNewChat); } // Rename a thread inline: replace the title with an input, save on // Enter/blur (PATCH), cancel on Escape. async function saveRename(input, link, id, original) { const val = input.value.trim(); if (!val) { link.querySelector('.font-medium').textContent = original; return; } try { const res = await fetch('/chat/api/threads/' + id, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ titel: val }), }); if (!res.ok) throw new Error('Speichern fehlgeschlagen'); link.querySelector('.font-medium').textContent = val; } catch (err) { link.querySelector('.font-medium').textContent = original; alert(err.message); } } function startRename(li, id) { const link = li.querySelector('.threadLink'); const titleEl = link.querySelector('.font-medium'); const original = titleEl.textContent; const input = document.createElement('input'); input.type = 'text'; input.value = original; input.className = 'font-medium w-full bg-transparent border border-blue-400 rounded px-1 py-0.5 text-sm text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500'; titleEl.replaceWith(input); input.focus(); input.select(); let done = false; const commit = () => { if (done) return; done = true; const title = document.createElement('div'); title.className = 'truncate font-medium'; title.textContent = input.value.trim() || original; input.replaceWith(title); if (input.value.trim() && input.value.trim() !== original) { saveRename(input, link, id, original); } else { title.textContent = original; } }; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } else if (e.key === 'Escape') { e.preventDefault(); done = true; const title = document.createElement('div'); title.className = 'truncate font-medium'; title.textContent = original; input.replaceWith(title); } }); input.addEventListener('blur', commit); } if (threadList) { threadList.addEventListener('click', (e) => { const renameBtn = e.target.closest('[data-rename-thread]'); if (renameBtn) { e.preventDefault(); e.stopPropagation(); const id = Number(renameBtn.getAttribute('data-rename-thread')); const li = threadList.querySelector('li[data-thread-li="' + id + '"]'); if (li) startRename(li, id); } }); } // Delete a thread from the sidebar. Event delegation: works for server-rendered // rows and ones added client-side (prependThread). if (threadList) { threadList.addEventListener('click', async (e) => { const btn = e.target.closest('[data-delete-thread]'); if (!btn) return; e.preventDefault(); e.stopPropagation(); const id = Number(btn.getAttribute('data-delete-thread')); if (!id || !confirm('Diesen Chat-Verlauf wirklich löschen?')) return; try { const res = await fetch('/chat/api/threads/' + id, { method: 'DELETE' }); if (!res.ok) throw new Error('Löschen fehlgeschlagen (' + res.status + ')'); } catch (err) { alert(err.message); return; } const li = threadList.querySelector('li[data-thread-li="' + id + '"]'); if (li) li.remove(); // If the deleted thread was active, start a fresh chat. if (activeId === id) resetToNewChat(); // If no threads remain, show the empty placeholder. if (!threadList.querySelector('li[data-thread-li]')) { const empty = document.createElement('li'); empty.className = 'px-3 py-4 text-sm text-gray-400 text-center'; empty.textContent = 'Noch keine Verläufe.'; threadList.appendChild(empty); } }); } // Render any pre-loaded messages (existing thread) with the same renderer. function renderInitial() { const blob = document.getElementById('initialMessages'); if (!blob) return; let msgs = []; try { msgs = JSON.parse(blob.textContent); } catch (e) { return; } if (!Array.isArray(msgs) || !msgs.length) return; if (log.querySelector('.h-full')) log.innerHTML = ''; msgs.forEach((m) => addMessage(m.role, m.content)); scrollBottom(); } renderInitial(); scrollBottom(); if (input && !input.disabled) input.focus(); })();