Fix conversation reset during streaming

This commit is contained in:
dwrz
2026-07-05 14:14:04 +00:00
parent b1b708dd45
commit c4730eb8ce

View File

@@ -77,6 +77,8 @@ class Odidere {
this.isScrollPending = false; this.isScrollPending = false;
this.isStopPending = false; this.isStopPending = false;
this.pendingDeleteId = null; this.pendingDeleteId = null;
// Promise for the currently in-flight runTurn, so reset() can await it.
this._currentRun = null;
this.currentController = null; this.currentController = null;
this.totalTokens = 0; this.totalTokens = 0;
@@ -128,13 +130,26 @@ class Odidere {
this.wakeLock.release(); this.wakeLock.release();
} }
reset() { /**
* Reset stops any in-flight turn, waits for it to settle, then
* clears the conversation, DOM, attachments, location, and input.
* The abort and await pattern ensures message streaming in runTurn completes
* before conversation.clear().
*/
async reset() {
this.tts.stop(); this.tts.stop();
if (this.currentController) { if (this.currentController) {
this.currentController.abort(); this.currentController.abort();
this.currentController = null; this.currentController = null;
} }
this.setLoadingState(false); this.setLoadingState(false);
// Wait for the current run to settle before clearing.
if (this._currentRun) {
try {
await this._currentRun;
} catch {}
}
this.pendingDeleteId = null; this.pendingDeleteId = null;
this.conversation.clear(); this.conversation.clear();
this.renderer.setAutoScroll(true); this.renderer.setAutoScroll(true);
@@ -523,7 +538,14 @@ class Odidere {
} }
/** /**
* runTurn sends a chat request to the streaming SSE endpoint. * Build and send a chat request, then consume the streaming SSE response.
*
* The body executes inside an async IIFE stored as this._currentRun so
* reset() can abort the stream and await the IIFE before clearing state.
* On interruption (stop or reset), the catch block persists any partially-
* received assistant message or pending tool calls; on reset, clear() runs
* afterward, so the persisted data is immediately discarded.
*
* @param {Object} options * @param {Object} options
* @param {string} [options.text] * @param {string} [options.text]
* @param {string} [options.detectedLanguage] * @param {string} [options.detectedLanguage]
@@ -534,171 +556,192 @@ class Odidere {
this.renderer.setAutoScroll(true); this.renderer.setAutoScroll(true);
this.currentController = new AbortController(); this.currentController = new AbortController();
let streamingBubbles = { this._currentRun = (async () => {
assistantId: null, let streamingBubbles = {
message: null, assistantId: null,
reasoning: null, message: null,
text: null, reasoning: null,
}; text: null,
let done = false; };
let pendingTools = null; let pendingTools = null;
const streamingMeta = (usage, timings) => { const streamingMeta = (usage, timings) => {
const meta = {};
if (voice) meta.voice = voice;
if (usage) meta.usage = usage;
if (timings) meta.timings = timings;
return meta;
};
try {
const hasNewInput =
text || this.attachments.length > 0 || this.locationData;
const activePath = this.conversation.getActivePath();
let messages;
let userMessage;
if (hasNewInput) {
userMessage = await this._buildUserMessage(text, this.attachments);
messages = [
...activePath.map(({ parent, children, id, meta, ...msg }) => msg),
userMessage,
];
} else {
messages = activePath.map(
({ parent, children, id, meta, ...msg }) => msg,
);
}
const { provider, model } = this.settings.getModel();
const payload = { messages, provider, model };
const systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY);
if (systemMessage) {
payload.system_message = systemMessage;
}
// Clear attachments and location after building payload.
this.clearAttachments();
this.clearLocation();
// For text-only requests with new input, add to history and
// render immediately.
if (hasNewInput) {
const meta = {}; const meta = {};
if (detectedLanguage) meta.language = detectedLanguage;
if (voice) meta.voice = voice; if (voice) meta.voice = voice;
const appended = this.conversation.append([userMessage], meta); if (usage) meta.usage = usage;
this.renderer.renderMessages(appended, meta); if (timings) meta.timings = timings;
} return meta;
};
const res = await fetch(STREAM_ENDPOINT, { try {
method: 'POST', const hasNewInput =
headers: { 'Content-Type': 'application/json' }, text || this.attachments.length > 0 || this.locationData;
body: JSON.stringify(payload),
signal: this.currentController.signal,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`server error ${res.status}: ${err}`);
}
for await (const { eventType, event } of this._parseSSEStream(res)) { const activePath = this.conversation.getActivePath();
if (event.error) { let messages;
throw new Error(event.error); let userMessage;
if (hasNewInput) {
userMessage = await this._buildUserMessage(text, this.attachments);
messages = [
...activePath.map(({ parent, children, id, meta, ...msg }) => msg),
userMessage,
];
} else {
messages = activePath.map(
({ parent, children, id, meta, ...msg }) => msg,
);
} }
if ( const { provider, model } = this.settings.getModel();
eventType === StreamEventDelta || const payload = { messages, provider, model };
eventType === StreamEventReasoningDelta const systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY);
) { if (systemMessage) {
if (!streamingBubbles.message) { payload.system_message = systemMessage;
streamingBubbles.assistantId = crypto.randomUUID();
streamingBubbles.message = {
id: streamingBubbles.assistantId,
role: 'assistant',
content: [{ type: ContentTypeText, text: '' }],
};
}
if (eventType === StreamEventReasoningDelta) {
if (!streamingBubbles.reasoning) {
const $el = this.renderer.createStreamingReasoning(
streamingBubbles.assistantId,
streamingMeta(event.usage, event.timings),
);
streamingBubbles.reasoning = { $el };
streamingBubbles.message.reasoning_content = '';
}
streamingBubbles.message.reasoning_content +=
event.reasoning_delta || '';
this.renderer.appendReasoningDelta(
streamingBubbles.reasoning.$el,
event.reasoning_delta || '',
);
} else {
if (!streamingBubbles.text) {
const $el = this.renderer.createStreamingText(
streamingBubbles.message,
streamingMeta(event.usage, event.timings),
);
streamingBubbles.text = { $el };
}
streamingBubbles.message.content[0].text += event.delta || '';
this.renderer.appendDelta(
streamingBubbles.text.$el,
event.delta || '',
);
}
continue;
} }
const message = event.message; // Clear attachments and location after building payload.
if (!message) continue; this.clearAttachments();
this.clearLocation();
if (eventType === StreamEventDone) { // For text-only requests with new input, add to history and
if (message.role !== 'assistant') continue; // render immediately.
if (hasNewInput) {
const meta = {};
if (detectedLanguage) meta.language = detectedLanguage;
if (voice) meta.voice = voice;
const appended = this.conversation.append([userMessage], meta);
this.renderer.renderMessages(appended, meta);
}
if (event.usage?.total_tokens) { const res = await fetch(STREAM_ENDPOINT, {
this.totalTokens = event.usage.total_tokens; method: 'POST',
this.updateContext(); headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: this.currentController.signal,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`server error ${res.status}: ${err}`);
}
for await (const { eventType, event } of this._parseSSEStream(res)) {
if (event.error) {
throw new Error(event.error);
} }
const meta = streamingMeta(event.usage, event.timings); if (
let finalMessage = message; eventType === StreamEventDelta ||
eventType === StreamEventReasoningDelta
) {
if (!streamingBubbles.message) {
streamingBubbles.assistantId = crypto.randomUUID();
streamingBubbles.message = {
id: streamingBubbles.assistantId,
role: 'assistant',
content: [{ type: ContentTypeText, text: '' }],
};
}
if (streamingBubbles.message) { if (eventType === StreamEventReasoningDelta) {
finalMessage.id = streamingBubbles.assistantId; if (!streamingBubbles.reasoning) {
const appended = this.conversation.append([finalMessage], meta); const $el = this.renderer.createStreamingReasoning(
finalMessage = appended[0]; streamingBubbles.assistantId,
streamingMeta(event.usage, event.timings),
if (streamingBubbles.reasoning) { );
this.renderer.finalizeStreamingReasoning( streamingBubbles.reasoning = { $el };
streamingBubbles.message.reasoning_content = '';
}
streamingBubbles.message.reasoning_content +=
event.reasoning_delta || '';
this.renderer.appendReasoningDelta(
streamingBubbles.reasoning.$el, streamingBubbles.reasoning.$el,
finalMessage, event.reasoning_delta || '',
meta,
); );
} } else {
if (streamingBubbles.text && !finalMessage.tool_calls?.length) { if (!streamingBubbles.text) {
this.renderer.finalizeStreamingText( const $el = this.renderer.createStreamingText(
streamingBubbles.message,
streamingMeta(event.usage, event.timings),
);
streamingBubbles.text = { $el };
}
streamingBubbles.message.content[0].text += event.delta || '';
this.renderer.appendDelta(
streamingBubbles.text.$el, streamingBubbles.text.$el,
finalMessage, event.delta || '',
meta,
); );
} }
continue;
} }
if (finalMessage.tool_calls?.length > 0) { const message = event.message;
if (!message) continue;
if (eventType === StreamEventDone) {
if (message.role !== 'assistant') continue;
if (event.usage?.total_tokens) {
this.totalTokens = event.usage.total_tokens;
this.updateContext();
}
const meta = streamingMeta(event.usage, event.timings);
let finalMessage = message;
if (streamingBubbles.message) {
finalMessage.id = streamingBubbles.assistantId;
const appended = this.conversation.append([finalMessage], meta);
finalMessage = appended[0];
if (streamingBubbles.reasoning) {
this.renderer.finalizeStreamingReasoning(
streamingBubbles.reasoning.$el,
finalMessage,
meta,
);
}
if (streamingBubbles.text && !finalMessage.tool_calls?.length) {
this.renderer.finalizeStreamingText(
streamingBubbles.text.$el,
finalMessage,
meta,
);
}
}
if (finalMessage.tool_calls?.length > 0) {
if (!streamingBubbles.message) {
const appended = this.conversation.append([finalMessage], meta);
finalMessage = appended[0];
}
pendingTools = {
assistant: finalMessage,
results: [],
expected: finalMessage.tool_calls.length,
$el: streamingBubbles.text?.$el || null,
};
streamingBubbles = {
assistantId: null,
message: null,
reasoning: null,
text: null,
};
if (extractText(finalMessage.content)) {
this.tts.enqueue(
extractText(finalMessage.content),
voice || '',
finalMessage.id,
);
}
continue;
}
if (!streamingBubbles.message) { if (!streamingBubbles.message) {
const appended = this.conversation.append([finalMessage], meta); const appended = this.conversation.append([finalMessage], meta);
finalMessage = appended[0]; finalMessage = appended[0];
this.renderer.renderMessages(appended, meta);
} }
pendingTools = {
assistant: finalMessage,
results: [],
expected: finalMessage.tool_calls.length,
$el: streamingBubbles.text?.$el || null,
};
streamingBubbles = { streamingBubbles = {
assistantId: null, assistantId: null,
message: null, message: null,
@@ -715,104 +758,93 @@ class Odidere {
continue; continue;
} }
if (!streamingBubbles.message) { // Collect tool results.
const appended = this.conversation.append([finalMessage], meta); if (
finalMessage = appended[0]; message.role === 'tool' &&
this.renderer.renderMessages(appended, meta); pendingTools &&
pendingTools.assistant
) {
const appended = this.conversation.append([message]);
pendingTools.results.push(appended[0]);
if (pendingTools.results.length >= pendingTools.expected) {
if (pendingTools.$el) {
this.renderer.finalizeStreamingText(
pendingTools.$el,
pendingTools.assistant,
pendingTools.assistant.meta,
);
}
this.renderer.renderMessages([
pendingTools.assistant,
...pendingTools.results,
]);
pendingTools = null;
}
} }
streamingBubbles = {
assistantId: null,
message: null,
reasoning: null,
text: null,
};
if (extractText(finalMessage.content)) {
this.tts.enqueue(
extractText(finalMessage.content),
voice || '',
finalMessage.id,
);
}
continue;
} }
// Collect tool results. this.setLoadingState(false);
if (message.role === 'tool' && pendingTools && pendingTools.assistant) { } catch (e) {
const appended = this.conversation.append([message]); if (e.name !== 'AbortError') {
pendingTools.results.push(appended[0]); console.error('failed to send request:', e);
this.renderer.renderError(`Error: ${e.message}`);
}
this.setLoadingState(false);
if (pendingTools.results.length >= pendingTools.expected) { // Persist a streaming message interrupted by stop or error.
if (pendingTools.$el) { if (streamingBubbles.message) {
this.renderer.finalizeStreamingText( const meta = streamingMeta(null, null);
pendingTools.$el, const finalMessage = streamingBubbles.message;
pendingTools.assistant, const hasContent = !!extractText(finalMessage.content);
pendingTools.assistant.meta, const hasToolCalls = finalMessage.tool_calls?.length > 0;
if (hasContent || hasToolCalls) {
const appended = this.conversation.append([finalMessage], meta);
if (streamingBubbles.reasoning) {
this.renderer.finalizeStreamingReasoning(
streamingBubbles.reasoning.$el,
appended[0],
meta,
); );
} }
this.renderer.renderMessages([ if (streamingBubbles.text) {
pendingTools.assistant, this.renderer.finalizeStreamingText(
...pendingTools.results, streamingBubbles.text.$el,
]); appended[0],
pendingTools = null; meta,
);
}
} else {
streamingBubbles.reasoning?.$el?.remove();
streamingBubbles.text?.$el?.remove();
} }
} }
}
done = true; // Finalize pending tool calls interrupted by stop or error.
this.setLoadingState(false); if (pendingTools) {
} catch (e) { if (pendingTools.$el) {
if (e.name !== 'AbortError') {
console.error('failed to send request:', e);
this.renderer.renderError(`Error: ${e.message}`);
}
this.setLoadingState(false);
} finally {
this.currentController = null;
// Persist a streaming message interrupted by stop or error.
if (!done && streamingBubbles.message) {
const meta = streamingMeta(null, null);
const finalMessage = streamingBubbles.message;
const hasContent = !!extractText(finalMessage.content);
const hasToolCalls = finalMessage.tool_calls?.length > 0;
if (hasContent || hasToolCalls) {
const appended = this.conversation.append([finalMessage], meta);
if (streamingBubbles.reasoning) {
this.renderer.finalizeStreamingReasoning(
streamingBubbles.reasoning.$el,
appended[0],
meta,
);
}
if (streamingBubbles.text) {
this.renderer.finalizeStreamingText( this.renderer.finalizeStreamingText(
streamingBubbles.text.$el, pendingTools.$el,
appended[0], pendingTools.assistant,
meta, pendingTools.assistant.meta ?? {},
); );
} }
} else { this.renderer.renderMessages([
streamingBubbles.reasoning?.$el?.remove();
streamingBubbles.text?.$el?.remove();
}
}
// Finalize pending tool calls interrupted by stop or error.
if (!done && pendingTools) {
if (pendingTools.$el) {
this.renderer.finalizeStreamingText(
pendingTools.$el,
pendingTools.assistant, pendingTools.assistant,
pendingTools.assistant.meta ?? {}, ...pendingTools.results,
); ]);
pendingTools = null;
} }
this.renderer.renderMessages([ } finally {
pendingTools.assistant, this.currentController = null;
...pendingTools.results,
]);
pendingTools = null;
} }
})();
try {
await this._currentRun;
} finally {
this._currentRun = null;
} }
} }
@@ -1058,7 +1090,7 @@ class Odidere {
this.$location.classList.add('compose__action-btn--location', 'active'); this.$location.classList.add('compose__action-btn--location', 'active');
this.updateSendButtonState(); this.updateSendButtonState();
}, },
() => { }, () => {},
GeolocationOptions, GeolocationOptions,
); );
} }
@@ -1226,6 +1258,7 @@ class Odidere {
if ($btn) $btn.classList.remove('pending'); if ($btn) $btn.classList.remove('pending');
} }
this.pendingDeleteId = null; this.pendingDeleteId = null;
this._currentRun = null;
} }
_cancelAllPending() { _cancelAllPending() {