diff --git a/internal/service/static/main.js b/internal/service/static/main.js index ae0a605..465632c 100644 --- a/internal/service/static/main.js +++ b/internal/service/static/main.js @@ -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() {