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 += '
' +
+ '
' +
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
-
+