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.isStopPending = false;
this.pendingDeleteId = null;
// Promise for the currently in-flight runTurn, so reset() can await it.
this._currentRun = null;
this.currentController = null;
this.totalTokens = 0;
@@ -128,13 +130,26 @@ class Odidere {
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();
if (this.currentController) {
this.currentController.abort();
this.currentController = null;
}
this.setLoadingState(false);
// Wait for the current run to settle before clearing.
if (this._currentRun) {
try {
await this._currentRun;
} catch {}
}
this.pendingDeleteId = null;
this.conversation.clear();
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 {string} [options.text]
* @param {string} [options.detectedLanguage]
@@ -534,171 +556,192 @@ class Odidere {
this.renderer.setAutoScroll(true);
this.currentController = new AbortController();
let streamingBubbles = {
assistantId: null,
message: null,
reasoning: null,
text: null,
};
let done = false;
let pendingTools = null;
this._currentRun = (async () => {
let streamingBubbles = {
assistantId: null,
message: null,
reasoning: null,
text: null,
};
let pendingTools = null;
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 streamingMeta = (usage, timings) => {
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 (usage) meta.usage = usage;
if (timings) meta.timings = timings;
return meta;
};
const res = await fetch(STREAM_ENDPOINT, {
method: 'POST',
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}`);
}
try {
const hasNewInput =
text || this.attachments.length > 0 || this.locationData;
for await (const { eventType, event } of this._parseSSEStream(res)) {
if (event.error) {
throw new Error(event.error);
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,
);
}
if (
eventType === StreamEventDelta ||
eventType === StreamEventReasoningDelta
) {
if (!streamingBubbles.message) {
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 { provider, model } = this.settings.getModel();
const payload = { messages, provider, model };
const systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY);
if (systemMessage) {
payload.system_message = systemMessage;
}
const message = event.message;
if (!message) continue;
// Clear attachments and location after building payload.
this.clearAttachments();
this.clearLocation();
if (eventType === StreamEventDone) {
if (message.role !== 'assistant') continue;
// For text-only requests with new input, add to history and
// 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) {
this.totalTokens = event.usage.total_tokens;
this.updateContext();
const res = await fetch(STREAM_ENDPOINT, {
method: 'POST',
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);
let finalMessage = message;
if (
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) {
finalMessage.id = streamingBubbles.assistantId;
const appended = this.conversation.append([finalMessage], meta);
finalMessage = appended[0];
if (streamingBubbles.reasoning) {
this.renderer.finalizeStreamingReasoning(
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,
finalMessage,
meta,
event.reasoning_delta || '',
);
}
if (streamingBubbles.text && !finalMessage.tool_calls?.length) {
this.renderer.finalizeStreamingText(
} 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,
finalMessage,
meta,
event.delta || '',
);
}
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) {
const appended = this.conversation.append([finalMessage], meta);
finalMessage = appended[0];
this.renderer.renderMessages(appended, meta);
}
pendingTools = {
assistant: finalMessage,
results: [],
expected: finalMessage.tool_calls.length,
$el: streamingBubbles.text?.$el || null,
};
streamingBubbles = {
assistantId: null,
message: null,
@@ -715,104 +758,93 @@ class Odidere {
continue;
}
if (!streamingBubbles.message) {
const appended = this.conversation.append([finalMessage], meta);
finalMessage = appended[0];
this.renderer.renderMessages(appended, meta);
// Collect tool results.
if (
message.role === 'tool' &&
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.
if (message.role === 'tool' && pendingTools && pendingTools.assistant) {
const appended = this.conversation.append([message]);
pendingTools.results.push(appended[0]);
this.setLoadingState(false);
} catch (e) {
if (e.name !== 'AbortError') {
console.error('failed to send request:', e);
this.renderer.renderError(`Error: ${e.message}`);
}
this.setLoadingState(false);
if (pendingTools.results.length >= pendingTools.expected) {
if (pendingTools.$el) {
this.renderer.finalizeStreamingText(
pendingTools.$el,
pendingTools.assistant,
pendingTools.assistant.meta,
// Persist a streaming message interrupted by stop or error.
if (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,
);
}
this.renderer.renderMessages([
pendingTools.assistant,
...pendingTools.results,
]);
pendingTools = null;
if (streamingBubbles.text) {
this.renderer.finalizeStreamingText(
streamingBubbles.text.$el,
appended[0],
meta,
);
}
} else {
streamingBubbles.reasoning?.$el?.remove();
streamingBubbles.text?.$el?.remove();
}
}
}
done = true;
this.setLoadingState(false);
} catch (e) {
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) {
// Finalize pending tool calls interrupted by stop or error.
if (pendingTools) {
if (pendingTools.$el) {
this.renderer.finalizeStreamingText(
streamingBubbles.text.$el,
appended[0],
meta,
pendingTools.$el,
pendingTools.assistant,
pendingTools.assistant.meta ?? {},
);
}
} else {
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,
this.renderer.renderMessages([
pendingTools.assistant,
pendingTools.assistant.meta ?? {},
);
...pendingTools.results,
]);
pendingTools = null;
}
this.renderer.renderMessages([
pendingTools.assistant,
...pendingTools.results,
]);
pendingTools = null;
} finally {
this.currentController = 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.updateSendButtonState();
},
() => { },
() => {},
GeolocationOptions,
);
}
@@ -1226,6 +1258,7 @@ class Odidere {
if ($btn) $btn.classList.remove('pending');
}
this.pendingDeleteId = null;
this._currentRun = null;
}
_cancelAllPending() {