KI-Chat: Markdown-Rendering, Left-Alignment, kompaktere UI, "Neuer Chat" fix

- Markdown-Renderer (Headings/Listen/**bold*/*italic*/`code`/Links) für
  Assistenten-Antworten; HTML wird vorher escaped (keine Injektion).
- Antworten explizit linksbündig, Inhalt getrimmt (keine Leerzeilen oben/unten).
- Initiale Nachrichten als JSON-Blob, einheitlich via chat.js gerendert.
- Chat-Container kompakter (Höhe/Spacing reduziert).
- "Neuer Chat" setzt clientseitig auf leeren Zustand zurück (vorher lud der
  Button nur den letzten Thread neu).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-07 14:28:54 +00:00
co-authored by Claude
parent c389d3d5ec
commit cc397e8f71
2 changed files with 149 additions and 50 deletions
+124 -27
View File
@@ -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, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
}
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 += '<p>' + inlineFmt(para.join(' ')) + '</p>';
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 += '<pre><code>' + code.join('\n') + '</code></pre>';
continue;
}
const hm = line.match(/^(#{1,6})\s+(.*)$/);
if (hm) {
flushPara();
const lvl = Math.min(hm[1].length, 3);
html += '<h' + lvl + '>' + inlineFmt(hm[2].trim()) + '</h' + lvl + '>';
i++; continue;
}
if (/^\s*[-*]\s+/.test(line)) { // bullet list
flushPara();
const items = [];
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
items.push('<li>' + inlineFmt(lines[i].replace(/^\s*[-*]\s+/, '')) + '</li>');
i++;
}
html += '<ul>' + items.join('') + '</ul>';
continue;
}
if (/^\s*\d+\.\s+/.test(line)) { // numbered list
flushPara();
const items = [];
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
items.push('<li>' + inlineFmt(lines[i].replace(/^\s*\d+\.\s+/, '')) + '</li>');
i++;
}
html += '<ol>' + items.join('') + '</ol>';
continue;
}
if (/^\s*$/.test(line)) { flushPara(); i++; continue; }
para.push(line.trim());
i++;
}
flushPara();
return html;
}
function clearLogToEmpty(msg) {
if (!log) return;
log.innerHTML =
'<div class="h-full flex items-center justify-center text-gray-400 text-sm text-center px-6">' +
'<div class="h-full flex items-center justify-center text-gray-400 text-sm px-6">' +
escapeHtml(msg) + '</div>';
}
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 = '<a href="/chat?thread=' + encodeURIComponent(id) + '" data-thread="' + id +
'" 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 bg-blue-50 dark:bg-gray-700">' +
'" 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">' +
'<div class="truncate font-medium">' + escapeHtml(titel || '(neuer Chat)') + '</div>' +
'<div class="text-[11px] text-gray-400">gerade</div></a>';
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();
})();