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