]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: fix stop and reasoning skip in single-model mode (#25084)
authorPascal <redacted>
Sun, 28 Jun 2026 19:06:43 +0000 (21:06 +0200)
committerGitHub <redacted>
Sun, 28 Jun 2026 19:06:43 +0000 (21:06 +0200)
tools/server/server-context.cpp
tools/server/server-models.cpp
tools/server/server-stream.cpp
tools/ui/src/lib/stores/chat.svelte.ts

index bb1c236cbf77857da23d045cd3e8f33c41630a7e..06f236b4cd0f9b4203994f0c742ea1a5b5478dc1 100644 (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));
index 0380f98a3615dc6a2a28eb0676151e21c2826f78..81da00c0e02221118913cb1626f5b4568eff8559 100644 (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);
index 785c28b3a57fc705d8f93360b23d63837fc039dd..c2bba8ec4661260125801f509198ccd91f74fd93 100644 (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;
index faaaa9755e6b45dc2b5e93e2a78a5af9eaab975c..43f9e72c7dc463ac10880bed00caf4c98ba13170 100644 (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;