optimize sse

This commit is contained in:
team 1
2026-04-19 12:57:58 +02:00
parent 13d31c72f9
commit a71426c300
2 changed files with 240 additions and 79 deletions

View File

@@ -4,8 +4,15 @@ document.addEventListener('DOMContentLoaded', () => {
const sendBtn = document.getElementById('send'); const sendBtn = document.getElementById('send');
const abortBtn = document.getElementById('abort'); const abortBtn = document.getElementById('abort');
const clearBtn = document.getElementById('clear'); 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 }); marked.setOptions({ breaks: true });
@@ -13,6 +20,14 @@ document.addEventListener('DOMContentLoaded', () => {
return DOMPurify.sanitize(marked.parse(text)); return DOMPurify.sanitize(marked.parse(text));
} }
function scrollChatToBottom() {
if (!chatEl) {
return;
}
chatEl.scrollTop = chatEl.scrollHeight;
}
function enhanceChatLinks(container = chatEl) { function enhanceChatLinks(container = chatEl) {
if (!container) { if (!container) {
return; return;
@@ -36,8 +51,7 @@ document.addEventListener('DOMContentLoaded', () => {
chatEl.appendChild(msg); chatEl.appendChild(msg);
enhanceChatLinks(bubble); enhanceChatLinks(bubble);
scrollChatToBottom();
chatEl.scrollTop = chatEl.scrollHeight;
return bubble; return bubble;
} }
@@ -46,6 +60,12 @@ document.addEventListener('DOMContentLoaded', () => {
return addMessage('assistant', 'AI is thinking…', 'loader'); 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) { function cleanupEmptyBlocks(container) {
container.querySelectorAll('p, div, li, blockquote').forEach((el) => { container.querySelectorAll('p, div, li, blockquote').forEach((el) => {
const html = el.innerHTML const html = el.innerHTML
@@ -55,7 +75,12 @@ document.addEventListener('DOMContentLoaded', () => {
const text = (el.textContent || '').trim(); const text = (el.textContent || '').trim();
if (html === '' || text === '') { if (html === '' && text === '') {
el.remove();
return;
}
if (text === '' && !hasMeaningfulChildContent(el)) {
el.remove(); el.remove();
} }
}); });
@@ -91,9 +116,11 @@ document.addEventListener('DOMContentLoaded', () => {
while (node) { while (node) {
const next = node.nextSibling; const next = node.nextSibling;
const isLastThink = node === lastThinkInBlock;
node.remove(); node.remove();
if (node === lastThinkInBlock) { if (isLastThink) {
break; break;
} }
@@ -126,9 +153,7 @@ document.addEventListener('DOMContentLoaded', () => {
return true; return true;
} }
return clone.querySelector( return hasMeaningfulChildContent(clone);
'img, table, pre, code, ul, ol, h1, h2, h3, h4, h5, h6, a'
) !== null;
} }
function cleanupThinkSpans(container) { function cleanupThinkSpans(container) {
@@ -197,31 +222,132 @@ document.addEventListener('DOMContentLoaded', () => {
bubble.innerHTML = renderMarkdown(raw); bubble.innerHTML = renderMarkdown(raw);
cleanupThinkSpans(bubble); cleanupThinkSpans(bubble);
enhanceChatLinks(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() { async function loadHistory() {
try { try {
const res = await fetch('/history'); 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(); 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); enhanceChatLinks(chatEl);
} catch {} } catch (err) {
console.error('History load failed:', err);
}
} }
loadHistory(); loadHistory();
promptEl.addEventListener('keydown', (e) => { promptEl.addEventListener('keydown', (event) => {
if (e.key === 'Enter' && !e.shiftKey) { if (event.key === 'Enter' && !event.shiftKey) {
e.preventDefault(); event.preventDefault();
sendBtn.click(); sendBtn.click();
} }
}); });
sendBtn.addEventListener('click', async () => { sendBtn.addEventListener('click', async () => {
const prompt = promptEl.value.trim(); const prompt = promptEl.value.trim();
if (!prompt) return;
if (!prompt || state.isStreaming) {
return;
}
addMessage('user', renderMarkdown(prompt)); addMessage('user', renderMarkdown(prompt));
promptEl.value = ''; promptEl.value = '';
@@ -229,100 +355,136 @@ document.addEventListener('DOMContentLoaded', () => {
const bubble = addLoader(); const bubble = addLoader();
let raw = ''; let raw = '';
let firstChunk = true; let firstChunk = true;
let renderTimer = null; let sseBuffer = '';
function scheduleRender() { state.abortRequested = false;
if (renderTimer) return; state.abortController = new AbortController();
renderTimer = setTimeout(() => { setBusyUi(true);
renderBubbleContent(bubble, raw);
renderTimer = null;
}, 100);
}
abort = false;
sendBtn.disabled = true;
abortBtn.disabled = false;
clearBtn.disabled = true;
try { try {
document.getElementById('ai-cloud')?.classList.remove('d-none');
const res = await fetch('/ask-sse', { const res = await fetch('/ask-sse', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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(); const decoder = new TextDecoder();
let sseBuffer = '';
while (!abort) { while (!state.abortRequested) {
const { value, done } = await reader.read(); const { value, done } = await state.reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true }); if (done) {
sseBuffer += chunk; break;
}
const events = sseBuffer.split('\n\n'); sseBuffer += decoder.decode(value, { stream: true });
sseBuffer = events.pop() || '';
events.forEach(event => { const parsed = parseSseEvents(sseBuffer);
if (!event.trim()) return; sseBuffer = parsed.rest;
const dataLines = event for (const rawEvent of parsed.events) {
.split('\n') if (!rawEvent.trim()) {
.filter(line => line.startsWith('data: ')) continue;
.map(line => line.slice(6)); }
if (dataLines.length === 0) return; const { eventName, data } = readSseEvent(rawEvent);
const text = dataLines.join('\n'); if (!data && eventName !== 'done') {
continue;
}
if (text === '[DONE]') { if (eventName === 'done' || data === '[DONE]') {
if (renderTimer) { finalizeStream(bubble, raw);
clearTimeout(renderTimer); state.abortRequested = true;
renderTimer = null; break;
}
renderBubbleContent(bubble, raw);
abort = true;
return;
} }
if (firstChunk) { if (firstChunk) {
bubble.classList.remove('loader'); bubble.classList.remove('loader');
bubble.innerHTML = ''; bubble.innerHTML = '';
firstChunk = false; firstChunk = false;
document.getElementById('ai-cloud')?.classList.add('d-none');
} }
raw += text; raw += data;
scheduleRender(); scheduleRender(bubble, () => raw);
}); }
}
} catch {
bubble.innerHTML += '<br><em>Error occurred.</em>';
enhanceChatLinks(bubble);
} finally {
if (renderTimer) {
clearTimeout(renderTimer);
} }
sendBtn.disabled = false; if (!state.abortRequested && sseBuffer.trim() !== '') {
abortBtn.disabled = true; const trailingEvent = readSseEvent(sseBuffer);
clearBtn.disabled = false;
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\n<em>Stream error: ${String(err.message || err)}</em>`;
renderBubbleContent(bubble, raw);
} else {
bubble.innerHTML = `<em>Stream error: ${String(err.message || err)}</em>`;
enhanceChatLinks(bubble);
scrollChatToBottom();
}
}
} finally {
clearScheduledRender();
await releaseStreamResources();
setBusyUi(false);
} }
}); });
abortBtn.addEventListener('click', () => { abortBtn.addEventListener('click', async () => {
abort = true; if (!state.isStreaming) {
return;
}
state.abortRequested = true;
state.abortController?.abort();
await releaseStreamResources();
setBusyUi(false);
addMessage('assistant', '<em>[aborted]</em>'); addMessage('assistant', '<em>[aborted]</em>');
}); });
clearBtn.addEventListener('click', async () => { 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 = ''; chatEl.innerHTML = '';
addMessage('assistant', '<em>History cleared.</em>'); addMessage('assistant', '<em>History cleared.</em>');
}); });

View File

@@ -65,10 +65,9 @@ final readonly class AskSseController
$this->sendData($chunk); $this->sendData($chunk);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->sendData( $this->sendEvent(
'<span class="text-danger">❌ Stream abgebrochen: ' 'error',
. htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') '❌ Stream abgebrochen: ' . $e->getMessage()
. '</span>'
); );
} }