ui: fix stop and reasoning skip in single-model mode (#25084)

This commit is contained in:
Pascal 2026-06-28 21:06:43 +02:00 committed by GitHub
parent fa72bc6826
commit 7cb8576e7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 41 additions and 9 deletions

View file

@ -2450,6 +2450,8 @@ private:
server_slot * slot = get_slot_by_cmpl_id(task.params.control_cmpl_id);
if (slot == nullptr) {
SRV_WRN("control %s on unknown completion id=%s, no live slot\n",
task.params.control_action.c_str(), task.params.control_cmpl_id.c_str());
res->success = false;
res->message = "no active completion for this id";
queue_results.send(std::move(res));

View file

@ -1983,7 +1983,10 @@ void server_models_routes::init_routes() {
cli.set_read_timeout(0, STREAM_LOOKUP_TIMEOUT_MS * 1000);
cli.set_write_timeout(0, STREAM_LOOKUP_TIMEOUT_MS * 1000);
auto resp = cli.Delete(child_path.c_str());
(void) resp; // best effort, 404 and network errors are equivalent to no op
(void) resp; // the child logs its own miss when the session is unknown there
} else {
SRV_WRN("router stop for unknown conv_id=%s, no owning child in the conv map\n",
conv_id.c_str());
}
// drop the tracking entry, the session is being torn down
models.conv_models.forget(conv_id);

View file

@ -218,6 +218,13 @@ void stream_session_manager::evict_and_cancel(const std::string & conversation_i
std::unique_lock<std::shared_mutex> lock(map_mu);
auto it = sessions.find(conversation_id);
if (it == sessions.end()) {
std::string live;
for (const auto & kv : sessions) {
if (!live.empty()) live += ", ";
live += kv.first;
}
SRV_WRN("stop on unknown stream session, conv_id=%s matched nothing, %zu live: [%s]\n",
conversation_id.c_str(), sessions.size(), live.c_str());
return;
}
s = it->second;

View file

@ -154,7 +154,13 @@ class ChatStore {
});
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 = '';
}
@ -1055,11 +1061,14 @@ class ChatStore {
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) {
@ -1074,6 +1083,9 @@ class ChatStore {
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;
@ -1103,7 +1115,7 @@ class ChatStore {
};
const updateStreamingUI = () => {
this.setChatStreaming(convId, streamedContent, currentMessageId);
this.setChatStreaming(convId, streamedContent, currentMessageId, effectiveModel);
const idx = conversationsStore.findMessageIndex(currentMessageId);
conversationsStore.updateMessageAtIndex(idx, { content: streamedContent });
};
@ -1111,7 +1123,7 @@ class ChatStore {
const cleanupStreamingState = () => {
this.setStreamingActive(false);
this.setChatLoading(convId, false);
this.clearChatStreaming(convId);
this.clearChatStreaming(convId, currentMessageId);
this.setProcessingState(convId, null);
};
@ -1128,7 +1140,7 @@ class ChatStore {
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
@ -1405,7 +1417,7 @@ class ChatStore {
// 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);
@ -1846,6 +1858,14 @@ class ChatStore {
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;