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:
+124
-27
@@ -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();
|
||||
})();
|
||||
+25
-23
@@ -3,25 +3,41 @@
|
||||
<head>
|
||||
<%- include('partials/head') %>
|
||||
<style>
|
||||
.chat-msg { white-space: pre-wrap; word-break: break-word; }
|
||||
#chatLog::-webkit-scrollbar { width: 8px; }
|
||||
#chatLog::-webkit-scrollbar-thumb { background: rgba(120,120,120,.4); border-radius: 4px; }
|
||||
.typing-dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; display: inline-block; }
|
||||
/* Markdown rendering inside assistant bubbles */
|
||||
.chat-md { line-height: 1.5; }
|
||||
.chat-md > *:first-child { margin-top: 0; }
|
||||
.chat-md > *:last-child { margin-bottom: 0; }
|
||||
.chat-md p { margin: 0 0 0.5rem 0; }
|
||||
.chat-md h1, .chat-md h2, .chat-md h3 { font-weight: 600; margin: 0.6rem 0 0.3rem 0; line-height: 1.25; }
|
||||
.chat-md h1 { font-size: 1.05rem; }
|
||||
.chat-md h2 { font-size: 1rem; }
|
||||
.chat-md h3 { font-size: 0.95rem; }
|
||||
.chat-md ul, .chat-md ol { margin: 0 0 0.5rem 0; padding-left: 1.25rem; }
|
||||
.chat-md li { margin: 0.15rem 0; }
|
||||
.chat-md code { background: rgba(0,0,0,0.08); padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.85em; }
|
||||
.dark .chat-md code { background: rgba(255,255,255,0.12); }
|
||||
.chat-md pre { background: rgba(0,0,0,0.08); padding: 0.6rem; border-radius: 6px; overflow-x: auto; margin: 0 0 0.5rem 0; }
|
||||
.dark .chat-md pre { background: rgba(0,0,0,0.35); }
|
||||
.chat-md pre code { background: none; padding: 0; }
|
||||
.chat-md a { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||
<%- include('partials/header', { hideSettings: true }) %>
|
||||
|
||||
<main class="container mx-auto px-4 py-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-2xl font-bold text-gray-800 dark:text-white">KI-Bewerbungs-Assistent</h2>
|
||||
<main class="container mx-auto px-4 py-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-xl font-bold text-gray-800 dark:text-white">KI-Bewerbungs-Assistent</h2>
|
||||
<button id="newThreadBtn"
|
||||
class="px-3 py-1.5 rounded-md bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium">
|
||||
+ Neuer Chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-[260px_1fr] gap-4 h-[calc(100vh-220px)] min-h-[420px]">
|
||||
<div class="grid grid-cols-1 md:grid-cols-[240px_1fr] gap-3 h-[calc(100vh-180px)] min-h-[360px]">
|
||||
<!-- Thread sidebar -->
|
||||
<aside class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden flex flex-col">
|
||||
<div class="px-3 py-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">Verläufe</div>
|
||||
@@ -45,25 +61,10 @@
|
||||
<!-- Message area -->
|
||||
<section class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 flex flex-col overflow-hidden">
|
||||
<div id="chatLog" class="flex-1 overflow-y-auto p-4 space-y-3 text-gray-800 dark:text-gray-100">
|
||||
<% if (!activeId) { %>
|
||||
<div class="h-full flex items-center justify-center text-gray-400 text-sm text-center px-6">
|
||||
Starte einen neuen Chat, um deinen Bewerbungs-Assistenten zu fragen.
|
||||
<% if (!messages.length) { %>
|
||||
<div class="h-full flex items-center justify-center text-gray-400 text-sm px-6">
|
||||
Stelle eine Frage zu deinen Bewerbungen.
|
||||
</div>
|
||||
<% } else if (!messages.length) { %>
|
||||
<div class="h-full flex items-center justify-center text-gray-400 text-sm text-center px-6">
|
||||
Stelle die erste Frage zu deinen Bewerbungen.
|
||||
</div>
|
||||
<% } else { %>
|
||||
<% messages.forEach(function(m) { %>
|
||||
<div class="flex <%= m.role === 'user' ? 'justify-end' : 'justify-start' %>">
|
||||
<div class="max-w-[80%] rounded-lg px-3 py-2 text-sm chat-msg
|
||||
<%= m.role === 'user'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100' %>">
|
||||
<%= m.content %>
|
||||
</div>
|
||||
</div>
|
||||
<% }); %>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
@@ -79,6 +80,7 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script id="initialMessages" type="application/json"><%= JSON.stringify(messages.map(function(m){ return { role: m.role, content: m.content }; })) %></script>
|
||||
<input type="hidden" id="activeThread" value="<%= activeId || '' %>">
|
||||
|
||||
<script>
|
||||
|
||||
Reference in New Issue
Block a user