Fix conversation reset during streaming
This commit is contained in:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user