optimize sse
This commit is contained in:
@@ -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>');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>'
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user