optimize stream sse handling
This commit is contained in:
@@ -12,6 +12,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
renderTimer: null,
|
||||
abortController: null,
|
||||
reader: null,
|
||||
eventSource: null,
|
||||
completeStream: null,
|
||||
failStream: null,
|
||||
};
|
||||
|
||||
marked.setOptions({breaks: true});
|
||||
@@ -256,6 +259,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
async function releaseStreamResources() {
|
||||
if (state.eventSource) {
|
||||
state.eventSource.close();
|
||||
state.eventSource = null;
|
||||
}
|
||||
|
||||
try {
|
||||
await state.reader?.cancel();
|
||||
} catch (err) {
|
||||
@@ -264,6 +272,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
state.reader = null;
|
||||
state.abortController = null;
|
||||
state.completeStream = null;
|
||||
state.failStream = null;
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
@@ -310,15 +320,47 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const bubble = addLoader();
|
||||
let raw = '';
|
||||
let firstChunk = true;
|
||||
let sseBuffer = '';
|
||||
|
||||
state.abortRequested = false;
|
||||
state.abortController = new AbortController();
|
||||
|
||||
setBusyUi(true);
|
||||
|
||||
const appendChunk = (chunk) => {
|
||||
if (firstChunk) {
|
||||
bubble.classList.remove('loader');
|
||||
bubble.innerHTML = '';
|
||||
firstChunk = false;
|
||||
}
|
||||
|
||||
raw += chunk;
|
||||
scheduleRender(bubble, () => raw);
|
||||
};
|
||||
|
||||
const appendError = (message) => {
|
||||
const safeMessage = String(message || '').trim();
|
||||
|
||||
if (!safeMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstChunk) {
|
||||
bubble.classList.remove('loader');
|
||||
bubble.innerHTML = '';
|
||||
firstChunk = false;
|
||||
}
|
||||
|
||||
raw += `\n\n<em>${safeMessage}</em>`;
|
||||
finalizeStream(bubble, raw);
|
||||
};
|
||||
|
||||
const finishEventStream = () => {
|
||||
state.eventSource?.close();
|
||||
state.eventSource = null;
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/ask-sse', {
|
||||
const jobRes = await fetch('/ask-jobs', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
@@ -328,87 +370,81 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
signal: state.abortController.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
||||
if (!jobRes.ok) {
|
||||
throw new Error(`HTTP ${jobRes.status} ${jobRes.statusText}`);
|
||||
}
|
||||
|
||||
if (!res.body) {
|
||||
throw new Error('SSE response has no body');
|
||||
const jobPayload = await jobRes.json();
|
||||
const jobId = String(jobPayload.jobId || '');
|
||||
|
||||
if (!/^[a-f0-9]{48}$/.test(jobId)) {
|
||||
throw new Error('Invalid stream job response');
|
||||
}
|
||||
|
||||
state.reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
await new Promise((resolve, reject) => {
|
||||
let finished = false;
|
||||
const source = new EventSource(`/ask-sse/${encodeURIComponent(jobId)}`);
|
||||
state.eventSource = source;
|
||||
|
||||
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 complete = () => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {eventName, data} = readSseEvent(rawEvent);
|
||||
finished = true;
|
||||
finishEventStream();
|
||||
resolve();
|
||||
};
|
||||
|
||||
if (!data && eventName !== 'done') {
|
||||
continue;
|
||||
const fail = (err) => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName === 'done' || data === '[DONE]') {
|
||||
finished = true;
|
||||
finishEventStream();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
state.completeStream = complete;
|
||||
state.failStream = fail;
|
||||
|
||||
source.onmessage = (event) => {
|
||||
if (state.abortRequested || finished) {
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data === undefined || event.data === null || event.data === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
appendChunk(event.data);
|
||||
};
|
||||
|
||||
source.addEventListener('done', () => {
|
||||
if (!state.abortRequested) {
|
||||
finalizeStream(bubble, raw);
|
||||
state.abortRequested = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (eventName === 'error') {
|
||||
if (firstChunk) {
|
||||
bubble.classList.remove('loader');
|
||||
bubble.innerHTML = '';
|
||||
firstChunk = false;
|
||||
}
|
||||
complete();
|
||||
});
|
||||
|
||||
raw += `\n\n<em>${data}</em>`;
|
||||
finalizeStream(bubble, raw);
|
||||
state.abortRequested = true;
|
||||
break;
|
||||
source.addEventListener('error', (event) => {
|
||||
if (state.abortRequested || finished) {
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstChunk) {
|
||||
bubble.classList.remove('loader');
|
||||
bubble.innerHTML = '';
|
||||
firstChunk = false;
|
||||
if (event instanceof MessageEvent && typeof event.data === 'string') {
|
||||
appendError(event.data);
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
fail(new Error('EventSource connection error'));
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError' || state.abortRequested) {
|
||||
console.info('SSE request aborted by user');
|
||||
@@ -442,6 +478,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
state.abortRequested = true;
|
||||
state.abortController?.abort();
|
||||
state.completeStream?.();
|
||||
await releaseStreamResources();
|
||||
setBusyUi(false);
|
||||
addMessage('assistant', '<em>[aborted]</em>');
|
||||
|
||||
Reference in New Issue
Block a user