document.addEventListener('DOMContentLoaded', () => { const chatEl = document.getElementById('chat'); const promptEl = document.getElementById('prompt'); const sendBtn = document.getElementById('send'); const abortBtn = document.getElementById('abort'); const clearBtn = document.getElementById('clear'); const aiCloudEl = document.getElementById('ai-cloud'); const state = { abortRequested: false, isStreaming: false, renderTimer: null, abortController: null, reader: null, eventSource: null, completeStream: null, failStream: null, }; marked.setOptions({breaks: true}); function renderMarkdown(text) { return DOMPurify.sanitize(marked.parse(text)); } function scrollChatToBottom() { if (!chatEl) { return; } chatEl.scrollTop = chatEl.scrollHeight; } function enhanceChatLinks(container = chatEl) { if (!container) { return; } container.querySelectorAll('a[href]').forEach((link) => { link.setAttribute('target', '_blank'); link.setAttribute('rel', 'noopener noreferrer'); }); } function addMessage(role, html = '', extra = '') { const msg = document.createElement('div'); msg.className = 'message ' + role; const bubble = document.createElement('div'); bubble.className = 'bubble ' + extra; bubble.innerHTML = html; msg.appendChild(bubble); chatEl.appendChild(msg); enhanceChatLinks(bubble); scrollChatToBottom(); return bubble; } function addLoader() { return addMessage('assistant', 'AI is thinking…', 'loader'); } function hasMeaningfulChildContent(element) { return element.querySelector( 'img, table, pre, code, ul, ol, h1, h2, h3, h4, h5, h6, a, hr, .badge' ) !== null; } function cleanupEmptyBlocks(container) { container.querySelectorAll('p, div, li, blockquote').forEach((el) => { const html = el.innerHTML .replace(//gi, '') .replace(/ /gi, '') .trim(); const text = (el.textContent || '').trim(); if (html === '' && text === '') { el.remove(); return; } if (text === '' && !hasMeaningfulChildContent(el)) { el.remove(); } }); } function removeThinkSpansOnly(container) { container.querySelectorAll('.think').forEach((span) => { span.remove(); }); cleanupEmptyBlocks(container); } function cloneWithoutThinkContent(container) { const clone = container.cloneNode(true); clone.querySelectorAll('.think').forEach((span) => span.remove()); cleanupEmptyBlocks(clone); return clone; } function hasNonThinkContent(container) { const clone = cloneWithoutThinkContent(container); if ((clone.textContent || '').trim() !== '') { return true; } return hasMeaningfulChildContent(clone); } function keepOnlyLastThink(container) { const thinkSpans = Array.from(container.querySelectorAll('.think')); if (thinkSpans.length <= 1) { cleanupEmptyBlocks(container); return; } const lastThink = thinkSpans[thinkSpans.length - 1]; thinkSpans.slice(0, -1).forEach((span) => { span.remove(); }); const blockSelector = 'p, div, li, blockquote'; const lastBlock = lastThink.closest(blockSelector) || lastThink.parentElement; if (lastBlock && lastThink.parentElement === lastBlock) { Array.from(lastBlock.childNodes).forEach((node) => { if (node === lastThink) { return; } if ( node.nodeType === Node.TEXT_NODE && node.textContent.trim() === '' ) { node.remove(); return; } if ( node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR' ) { node.remove(); } }); } cleanupEmptyBlocks(container); } function cleanupThinkSpans(container) { if (!container) { return; } const thinkSpans = Array.from(container.querySelectorAll('.think')); if (thinkSpans.length === 0) { return; } if (hasNonThinkContent(container)) { removeThinkSpansOnly(container); return; } keepOnlyLastThink(container); } function renderBubbleContent(bubble, raw) { bubble.innerHTML = renderMarkdown(raw); cleanupThinkSpans(bubble); enhanceChatLinks(bubble); scrollChatToBottom(); } function setBusyUi(isBusy) { state.isStreaming = isBusy; sendBtn.disabled = isBusy; abortBtn.disabled = !isBusy; clearBtn.disabled = isBusy; if (isBusy) { aiCloudEl?.classList.remove('d-none'); return; } aiCloudEl?.classList.add('d-none'); } function clearScheduledRender() { if (!state.renderTimer) { return; } clearTimeout(state.renderTimer); state.renderTimer = null; } function scheduleRender(bubble, getRaw) { if (state.renderTimer) { return; } state.renderTimer = setTimeout(() => { try { renderBubbleContent(bubble, getRaw()); } finally { state.renderTimer = null; } }, 100); } function parseSseEvents(buffer) { const normalizedBuffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const chunks = normalizedBuffer.split('\n\n'); return { events: chunks.slice(0, -1), rest: chunks[chunks.length - 1] || '', }; } function readSseEvent(rawEvent) { const lines = rawEvent.split('\n'); let eventName = 'message'; const dataLines = []; lines.forEach((line) => { if (line.startsWith('event:')) { eventName = line.slice(6).trim() || 'message'; return; } if (line.startsWith('data:')) { dataLines.push(line.slice(5).trimStart()); } }); return { eventName, data: dataLines.join('\n'), }; } function finalizeStream(bubble, raw) { clearScheduledRender(); renderBubbleContent(bubble, raw); } async function releaseStreamResources() { if (state.eventSource) { state.eventSource.close(); state.eventSource = null; } try { await state.reader?.cancel(); } catch (err) { console.debug('Reader cancel failed:', err); } state.reader = null; state.abortController = null; state.completeStream = null; state.failStream = null; } async function loadHistory() { try { const res = await fetch('/history'); if (!res.ok) { console.error('History request failed with status:', res.status); return; } const messages = await res.json(); messages.forEach((message) => { const bubble = addMessage(message.role); renderBubbleContent(bubble, message.text); }); enhanceChatLinks(chatEl); } catch (err) { console.error('History load failed:', err); } } loadHistory(); promptEl.addEventListener('keydown', (event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendBtn.click(); } }); sendBtn.addEventListener('click', async () => { const prompt = promptEl.value.trim(); if (!prompt || state.isStreaming) { return; } addMessage('user', renderMarkdown(prompt)); promptEl.value = ''; const bubble = addLoader(); let raw = ''; let firstChunk = true; state.abortRequested = false; state.abortController = new AbortController(); setBusyUi(true); const appendChunk = (chunk) => { if (firstChunk) { bubble.classList.remove('loader'); bubble.innerHTML = ''; firstChunk = false; } raw += chunk; scheduleRender(bubble, () => raw); }; const appendError = (message) => { const safeMessage = String(message || '').trim(); if (!safeMessage) { return; } if (firstChunk) { bubble.classList.remove('loader'); bubble.innerHTML = ''; firstChunk = false; } raw += `\n\n${safeMessage}`; finalizeStream(bubble, raw); }; const finishEventStream = () => { state.eventSource?.close(); state.eventSource = null; }; try { const jobRes = await fetch('/ask-jobs', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ prompt, fullContext: false, }), signal: state.abortController.signal, }); if (!jobRes.ok) { throw new Error(`HTTP ${jobRes.status} ${jobRes.statusText}`); } const jobPayload = await jobRes.json(); const jobId = String(jobPayload.jobId || ''); if (!/^[a-f0-9]{48}$/.test(jobId)) { throw new Error('Invalid stream job response'); } await new Promise((resolve, reject) => { let finished = false; const source = new EventSource(`/ask-sse/${encodeURIComponent(jobId)}`); state.eventSource = source; const complete = () => { if (finished) { return; } finished = true; finishEventStream(); resolve(); }; const fail = (err) => { if (finished) { return; } finished = true; finishEventStream(); reject(err); }; state.completeStream = complete; state.failStream = fail; source.onmessage = (event) => { if (state.abortRequested || finished) { complete(); return; } if (event.data === undefined || event.data === null || event.data === '') { return; } appendChunk(event.data); }; source.addEventListener('done', () => { if (!state.abortRequested) { finalizeStream(bubble, raw); } complete(); }); source.addEventListener('error', (event) => { if (state.abortRequested || finished) { complete(); return; } if (event instanceof MessageEvent && typeof event.data === 'string') { appendError(event.data); complete(); return; } fail(new Error('EventSource connection error')); }); }); } catch (err) { if (err?.name === 'AbortError' || state.abortRequested) { console.info('SSE request aborted by user'); } else { console.error('SSE stream failed:', err); bubble.classList.remove('loader'); const userMessage = 'Die Verbindung zum Antwort-Stream wurde unterbrochen. Bitte sende die Anfrage erneut, falls die Antwort unvollständig ist.'; if (raw.trim() !== '') { raw += `\n\n${userMessage}`; renderBubbleContent(bubble, raw); } else { bubble.innerHTML = `${userMessage}`; enhanceChatLinks(bubble); scrollChatToBottom(); } } } finally { clearScheduledRender(); await releaseStreamResources(); setBusyUi(false); } }); abortBtn.addEventListener('click', async () => { if (!state.isStreaming) { return; } state.abortRequested = true; state.abortController?.abort(); state.completeStream?.(); await releaseStreamResources(); setBusyUi(false); addMessage('assistant', '[aborted]'); }); clearBtn.addEventListener('click', async () => { if (state.isStreaming) { return; } try { await fetch('/history/delete', {method: 'POST'}); } catch (err) { console.error('History delete failed:', err); } chatEl.innerHTML = ''; addMessage('assistant', 'History cleared.'); }); });