diff --git a/public/js/chat.js b/public/js/chat.js index 690eb1a..1a8acce 100644 --- a/public/js/chat.js +++ b/public/js/chat.js @@ -1,4 +1,5 @@ -// KI-Chat client: sends messages, streams assistant tokens over SSE. +// 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__ || {}; @@ -11,6 +12,7 @@ const threadList = document.getElementById('threadList'); let sending = false; + // ----- helpers ----- function escapeHtml(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); @@ -21,21 +23,98 @@ if (log) log.scrollTop = log.scrollHeight; } - function emptyState(msg) { + // 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) + '
    '; } - function addBubble(role, content) { - if (log && log.querySelector('.h-full')) log.innerHTML = ''; + // 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-[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; + 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; @@ -59,15 +138,24 @@ if (empty) empty.remove(); const li = document.createElement('li'); li.innerHTML = '' + + '" 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">' + '
    ' + escapeHtml(titel || '(neuer Chat)') + '
    ' + '
    gerade
    '; threadList.insertBefore(li, threadList.firstChild); } - function enableInput() { - input.disabled = false; - sendBtn.disabled = false; + // ----- "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() { @@ -81,13 +169,11 @@ 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. + // 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(); @@ -120,7 +206,7 @@ catch (e) { sending = false; sendBtn.disabled = false; alert(e.message); return; } } - addBubble('user', text); + addMessage('user', text); input.value = ''; input.style.height = 'auto'; const typing = typingIndicator(); @@ -143,28 +229,28 @@ if (ev.type === 'token') { if (!assistantBubble) { if (typing) typing.remove(); - assistantBubble = addBubble('assistant', ''); + assistantBubble = addMessage('assistant', ''); } assistantText += ev.content; - assistantBubble.textContent = assistantText; + assistantBubble.innerHTML = renderMarkdown(assistantText); scrollBottom(); } else if (ev.type === 'done') { if (assistantBubble && ev.content) { - assistantBubble.textContent = ev.content; + assistantBubble.innerHTML = renderMarkdown(ev.content); } else if (!assistantBubble) { if (typing) typing.remove(); - addBubble('assistant', ev.content || '(keine Antwort)'); + addMessage('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 + ']'; + if (!assistantBubble) addMessage('assistant', 'Fehler: ' + ev.message); + else assistantBubble.innerHTML = renderMarkdown(assistantText + '\n\n_Fehler: ' + ev.message + '_'); } }); } catch (err) { if (typing) typing.remove(); - addBubble('assistant', 'Fehler: ' + (err.message || 'unbekannt')); + addMessage('assistant', 'Fehler: ' + (err.message || 'unbekannt')); } finally { if (typing && typing.parentNode) typing.remove(); sending = false; @@ -173,7 +259,7 @@ } } - // Auto-grow the textarea; Enter sends, Shift+Enter inserts a newline. + // ----- wiring ----- if (input) { input.addEventListener('input', () => { input.style.height = 'auto'; @@ -195,11 +281,22 @@ } if (newThreadBtn) { - newThreadBtn.addEventListener('click', () => { - window.location.href = '/chat'; - }); + 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(); })(); \ No newline at end of file diff --git a/views/chat.ejs b/views/chat.ejs index 8865483..8345d2d 100644 --- a/views/chat.ejs +++ b/views/chat.ejs @@ -3,25 +3,41 @@ <%- include('partials/head') %> <%- include('partials/header', { hideSettings: true }) %> -
    -
    -

    KI-Bewerbungs-Assistent

    +
    +
    +

    KI-Bewerbungs-Assistent

    -
    +
    +