diff --git a/public/assets/js/base.js b/public/assets/js/base.js index c3e9384..0b0bcd4 100644 --- a/public/assets/js/base.js +++ b/public/assets/js/base.js @@ -4,8 +4,15 @@ document.addEventListener('DOMContentLoaded', () => { const sendBtn = document.getElementById('send'); const abortBtn = document.getElementById('abort'); const clearBtn = document.getElementById('clear'); + const aiCloudEl = document.getElementById('ai-cloud'); - let abort = false; + const state = { + abortRequested: false, + isStreaming: false, + renderTimer: null, + abortController: null, + reader: null, + }; marked.setOptions({ breaks: true }); @@ -13,6 +20,14 @@ document.addEventListener('DOMContentLoaded', () => { return DOMPurify.sanitize(marked.parse(text)); } + function scrollChatToBottom() { + if (!chatEl) { + return; + } + + chatEl.scrollTop = chatEl.scrollHeight; + } + function enhanceChatLinks(container = chatEl) { if (!container) { return; @@ -36,8 +51,7 @@ document.addEventListener('DOMContentLoaded', () => { chatEl.appendChild(msg); enhanceChatLinks(bubble); - - chatEl.scrollTop = chatEl.scrollHeight; + scrollChatToBottom(); return bubble; } @@ -46,6 +60,12 @@ document.addEventListener('DOMContentLoaded', () => { 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 @@ -55,7 +75,12 @@ document.addEventListener('DOMContentLoaded', () => { const text = (el.textContent || '').trim(); - if (html === '' || text === '') { + if (html === '' && text === '') { + el.remove(); + return; + } + + if (text === '' && !hasMeaningfulChildContent(el)) { el.remove(); } }); @@ -91,9 +116,11 @@ document.addEventListener('DOMContentLoaded', () => { while (node) { const next = node.nextSibling; + const isLastThink = node === lastThinkInBlock; + node.remove(); - if (node === lastThinkInBlock) { + if (isLastThink) { break; } @@ -126,9 +153,7 @@ document.addEventListener('DOMContentLoaded', () => { return true; } - return clone.querySelector( - 'img, table, pre, code, ul, ol, h1, h2, h3, h4, h5, h6, a' - ) !== null; + return hasMeaningfulChildContent(clone); } function cleanupThinkSpans(container) { @@ -197,31 +222,132 @@ document.addEventListener('DOMContentLoaded', () => { bubble.innerHTML = renderMarkdown(raw); cleanupThinkSpans(bubble); enhanceChatLinks(bubble); - chatEl.scrollTop = chatEl.scrollHeight; + 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) return; + + if (!res.ok) { + console.error('History request failed with status:', res.status); + return; + } + const messages = await res.json(); - messages.forEach(m => addMessage(m.role, renderMarkdown(m.text))); + + messages.forEach((message) => { + const bubble = addMessage(message.role); + renderBubbleContent(bubble, message.text); + }); + enhanceChatLinks(chatEl); - } catch {} + } catch (err) { + console.error('History load failed:', err); + } } loadHistory(); - promptEl.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); + 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) return; + + if (!prompt || state.isStreaming) { + return; + } addMessage('user', renderMarkdown(prompt)); promptEl.value = ''; @@ -229,100 +355,136 @@ document.addEventListener('DOMContentLoaded', () => { const bubble = addLoader(); let raw = ''; let firstChunk = true; - let renderTimer = null; + let sseBuffer = ''; - function scheduleRender() { - if (renderTimer) return; + state.abortRequested = false; + state.abortController = new AbortController(); - renderTimer = setTimeout(() => { - renderBubbleContent(bubble, raw); - renderTimer = null; - }, 100); - } - - abort = false; - sendBtn.disabled = true; - abortBtn.disabled = false; - clearBtn.disabled = true; + setBusyUi(true); try { - document.getElementById('ai-cloud')?.classList.remove('d-none'); - const res = await fetch('/ask-sse', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt }) + body: JSON.stringify({ prompt }), + signal: state.abortController.signal, }); - const reader = res.body.getReader(); + 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(); - let sseBuffer = ''; - while (!abort) { - const { value, done } = await reader.read(); - if (done) break; + while (!state.abortRequested) { + const { value, done } = await state.reader.read(); - const chunk = decoder.decode(value, { stream: true }); - sseBuffer += chunk; + if (done) { + break; + } - const events = sseBuffer.split('\n\n'); - sseBuffer = events.pop() || ''; + sseBuffer += decoder.decode(value, { stream: true }); - events.forEach(event => { - if (!event.trim()) return; + const parsed = parseSseEvents(sseBuffer); + sseBuffer = parsed.rest; - const dataLines = event - .split('\n') - .filter(line => line.startsWith('data: ')) - .map(line => line.slice(6)); + for (const rawEvent of parsed.events) { + if (!rawEvent.trim()) { + continue; + } - if (dataLines.length === 0) return; + const { eventName, data } = readSseEvent(rawEvent); - const text = dataLines.join('\n'); + if (!data && eventName !== 'done') { + continue; + } - if (text === '[DONE]') { - if (renderTimer) { - clearTimeout(renderTimer); - renderTimer = null; - } - - renderBubbleContent(bubble, raw); - abort = true; - return; + if (eventName === 'done' || data === '[DONE]') { + finalizeStream(bubble, raw); + state.abortRequested = true; + break; } if (firstChunk) { bubble.classList.remove('loader'); bubble.innerHTML = ''; firstChunk = false; - document.getElementById('ai-cloud')?.classList.add('d-none'); } - raw += text; - scheduleRender(); - }); - } - } catch { - bubble.innerHTML += '
Error occurred.'; - enhanceChatLinks(bubble); - } finally { - if (renderTimer) { - clearTimeout(renderTimer); + raw += data; + scheduleRender(bubble, () => raw); + } } - sendBtn.disabled = false; - abortBtn.disabled = true; - clearBtn.disabled = false; + 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', () => { - abort = true; + 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 () => { - await fetch('/history/delete', { method: 'POST' }); + 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.'); }); diff --git a/src/Controller/AskSseController.php b/src/Controller/AskSseController.php index 866f799..a5848a9 100644 --- a/src/Controller/AskSseController.php +++ b/src/Controller/AskSseController.php @@ -65,10 +65,9 @@ final readonly class AskSseController $this->sendData($chunk); } } catch (\Throwable $e) { - $this->sendData( - '❌ Stream abgebrochen: ' - . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') - . '' + $this->sendEvent( + 'error', + '❌ Stream abgebrochen: ' . $e->getMessage() ); }