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, }; 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' ) !== 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 stripAllThinkContent(container) { const blockSelector = 'p, div, li, blockquote'; const thinkSpans = Array.from(container.querySelectorAll('.think')); if (thinkSpans.length === 0) { return; } const handledBlocks = new Set(); thinkSpans.forEach((span) => { const block = span.closest(blockSelector) || span.parentElement; if (!block || handledBlocks.has(block)) { return; } handledBlocks.add(block); const thinksInBlock = Array.from(block.querySelectorAll('.think')); const lastThinkInBlock = thinksInBlock[thinksInBlock.length - 1]; if (!lastThinkInBlock) { return; } let node = block.firstChild; while (node) { const next = node.nextSibling; const isLastThink = node === lastThinkInBlock; node.remove(); if (isLastThink) { break; } node = next; } while ( block.firstChild && ( (block.firstChild.nodeType === Node.TEXT_NODE && block.firstChild.textContent.trim() === '') || (block.firstChild.nodeType === Node.ELEMENT_NODE && block.firstChild.tagName === 'BR') ) ) { block.firstChild.remove(); } }); cleanupEmptyBlocks(container); } function hasNonThinkContent(container) { const clone = container.cloneNode(true); stripAllThinkContent(clone); cleanupEmptyBlocks(clone); if ((clone.textContent || '').trim() !== '') { return true; } return hasMeaningfulChildContent(clone); } function cleanupThinkSpans(container) { if (!container) { return; } const thinkSpans = Array.from(container.querySelectorAll('.think')); if (thinkSpans.length === 0) { return; } if (hasNonThinkContent(container)) { stripAllThinkContent(container); return; } if (thinkSpans.length <= 1) { return; } const blockSelector = 'p, div, li, blockquote'; const lastThink = thinkSpans[thinkSpans.length - 1]; const lastBlock = lastThink.closest(blockSelector) || lastThink.parentElement; thinkSpans.slice(0, -1).forEach((span) => { const block = span.closest(blockSelector) || span.parentElement; if (block && block !== lastBlock) { block.remove(); return; } if (block === lastBlock) { span.remove(); } }); if (lastBlock && lastBlock.contains(lastThink)) { let node = lastBlock.firstChild; while (node && node !== lastThink) { const next = node.nextSibling; node.remove(); node = next; } while ( lastThink.nextSibling && ( (lastThink.nextSibling.nodeType === Node.TEXT_NODE && lastThink.nextSibling.textContent.trim() === '') || (lastThink.nextSibling.nodeType === Node.ELEMENT_NODE && lastThink.nextSibling.tagName === 'BR') ) ) { lastThink.nextSibling.remove(); } } cleanupEmptyBlocks(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() { try { await state.reader?.cancel(); } catch (err) { console.debug('Reader cancel failed:', err); } state.reader = null; state.abortController = 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; let sseBuffer = ''; state.abortRequested = false; state.abortController = new AbortController(); setBusyUi(true); try { const res = await fetch('/ask-sse', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt }), signal: state.abortController.signal, }); if (!res.ok) { throw new Error(`HTTP ${res.status} ${res.statusText}`); } if (!res.body) { throw new Error('SSE response has no body'); } state.reader = res.body.getReader(); const decoder = new TextDecoder(); while (!state.abortRequested) { const { value, done } = await state.reader.read(); if (done) { break; } sseBuffer += decoder.decode(value, { stream: true }); const parsed = parseSseEvents(sseBuffer); sseBuffer = parsed.rest; for (const rawEvent of parsed.events) { if (!rawEvent.trim()) { continue; } const { eventName, data } = readSseEvent(rawEvent); if (!data && eventName !== 'done') { continue; } if (eventName === 'done' || data === '[DONE]') { finalizeStream(bubble, raw); state.abortRequested = true; break; } if (firstChunk) { bubble.classList.remove('loader'); bubble.innerHTML = ''; firstChunk = false; } raw += data; scheduleRender(bubble, () => raw); } } if (!state.abortRequested && sseBuffer.trim() !== '') { const trailingEvent = readSseEvent(sseBuffer); if (trailingEvent.data && trailingEvent.data !== '[DONE]') { if (firstChunk) { bubble.classList.remove('loader'); bubble.innerHTML = ''; firstChunk = false; } raw += trailingEvent.data; } } if (!state.abortRequested) { finalizeStream(bubble, raw); } } 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'); if (raw.trim() !== '') { raw += `\n\nStream error: ${String(err.message || err)}`; renderBubbleContent(bubble, raw); } else { bubble.innerHTML = `Stream error: ${String(err.message || err)}`; enhanceChatLinks(bubble); scrollChatToBottom(); } } } finally { clearScheduledRender(); await releaseStreamResources(); setBusyUi(false); } }); abortBtn.addEventListener('click', async () => { if (!state.isStreaming) { return; } state.abortRequested = true; state.abortController?.abort(); 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.'); }); });