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()
);
}