});
if (convId === conversationsStore.activeConversation?.id) this.currentResponse = response;
}
- private clearChatStreaming(convId: string): void {
+ private clearChatStreaming(convId: string, messageId?: string): void {
+ // session aware: a stale generation must not wipe a newer one's streaming state on the
+ // same conversation, that would drop the frozen stop identity and stop the wrong session
+ if (messageId !== undefined) {
+ const cur = this.chatStreamingStates.get(convId);
+ if (cur && cur.messageId !== messageId) return;
+ }
this.chatStreamingStates.delete(convId);
if (convId === conversationsStore.activeConversation?.id) this.currentResponse = '';
}
modelOverride?: string | null,
firstUserMessageContent?: string
): Promise<void> {
- let effectiveModel = modelOverride;
+ // the ::model suffix in the stream identity is only for router mode, where it routes to the
+ // owning child. in single-model mode the identity stays the bare conv id so that attach, stop
+ // and reattach all agree, regardless of fresh send vs regenerate passing a resolved model
+ let effectiveModel: string | null | undefined = undefined;
- if (isRouterMode() && !effectiveModel) {
+ if (isRouterMode()) {
const conversationModel = this.getConversationModel(allMessages);
- effectiveModel = selectedModelName() || conversationModel;
+ effectiveModel = modelOverride || selectedModelName() || conversationModel;
}
if (isRouterMode() && effectiveModel) {
let resolvedModel: string | null = null;
let modelPersisted = false;
const convId = assistantMessage.convId;
+ // freeze the POST identity from t0 so a stop cancels with the exact session key,
+ // never a stale or empty model resolved later
+ this.setChatStreaming(convId, streamedContent, currentMessageId, effectiveModel);
const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => {
if (!modelName) return;
};
const updateStreamingUI = () => {
- this.setChatStreaming(convId, streamedContent, currentMessageId);
+ this.setChatStreaming(convId, streamedContent, currentMessageId, effectiveModel);
const idx = conversationsStore.findMessageIndex(currentMessageId);
conversationsStore.updateMessageAtIndex(idx, { content: streamedContent });
};
const cleanupStreamingState = () => {
this.setStreamingActive(false);
this.setChatLoading(convId, false);
- this.clearChatStreaming(convId);
+ this.clearChatStreaming(convId, currentMessageId);
this.setProcessingState(convId, null);
};
onReasoningChunk: (chunk: string) => {
streamedReasoningContent += chunk;
// mark streaming state so a stop mid-thinking can persist the partial reasoning
- this.setChatStreaming(convId, streamedContent, currentMessageId);
+ this.setChatStreaming(convId, streamedContent, currentMessageId, effectiveModel);
const idx = conversationsStore.findMessageIndex(currentMessageId);
conversationsStore.updateMessageAtIndex(idx, {
reasoningContent: streamedReasoningContent
// detached drain keeps producing tokens until eos or max_tokens. use the frozen identity
// captured when the session started, not the live dropdown
const streamStateForStop = this.chatStreamingStates.get(convId);
- const modelForStop = streamStateForStop?.model ?? selectedModelName();
+ const modelForStop = streamStateForStop?.model;
void ChatService.cancelServerStream(convId, modelForStop);
this.abortRequest(convId);
this.setChatLoading(convId, false);
updateStreamingContent(originalContent + appendedContent);
this.setChatReasoning(msg.convId, false);
},
+ onCompletionId: (id: string) => {
+ if (!id) return;
+ // refresh the message id so a later skip targets the live slot after a continue
+ conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
+ completionId: id
+ });
+ DatabaseService.updateMessage(msg.id, { completionId: id }).catch(() => {});
+ },
onReasoningChunk: (chunk: string) => {
appendedReasoning += chunk;
hasReceivedContent = true;