From c88074a19e9318b6563f803069479a57614ecb53 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sat, 10 Jan 2026 19:06:12 +0100 Subject: [PATCH] feat(api): support 'reasoning' api field (#7959) This PR adds support to support the 'reasoning' API field of the OpenAI spec. LocalAI now will extract automatically thinking tags in both SSE and non-SSE mode. The changes are adapted as well to the Chat UI now that will use the reasoning field to extract the thinking process and display it in the chat. This fixes https://github.com/mudler/LocalAI/issues/7944 Signed-off-by: Ettore Di Giacinto --- core/http/endpoints/openai/chat.go | 111 +++++++++-- core/http/static/chat.js | 244 +++++++++++++++++++---- core/http/views/chat.html | 309 ++++++++++++++++++++++++----- core/schema/message.go | 7 +- pkg/functions/reasoning.go | 114 +++++++++++ pkg/functions/reasoning_test.go | 261 ++++++++++++++++++++++++ 6 files changed, 941 insertions(+), 105 deletions(-) create mode 100644 pkg/functions/reasoning.go create mode 100644 pkg/functions/reasoning_test.go diff --git a/core/http/endpoints/openai/chat.go b/core/http/endpoints/openai/chat.go index 59f84e524..4ece68d5c 100644 --- a/core/http/endpoints/openai/chat.go +++ b/core/http/endpoints/openai/chat.go @@ -3,6 +3,7 @@ package openai import ( "encoding/json" "fmt" + "strings" "time" "github.com/google/uuid" @@ -34,11 +35,54 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator Created: created, Model: req.Model, // we have to return what the user sent here, due to OpenAI spec. Choices: []schema.Choice{{Delta: &schema.Message{Role: "assistant"}, Index: 0, FinishReason: nil}}, - Object: "chat.completion.chunk", } responses <- initialMessage + // Track accumulated content for reasoning extraction + accumulatedContent := "" + lastEmittedReasoning := "" + lastEmittedCleanedContent := "" + _, _, err := ComputeChoices(req, s, config, cl, startupOptions, loader, func(s string, c *[]schema.Choice) {}, func(s string, tokenUsage backend.TokenUsage) bool { + accumulatedContent += s + // Extract reasoning from accumulated content + currentReasoning, cleanedContent := functions.ExtractReasoning(accumulatedContent) + + // Calculate new reasoning delta (what we haven't emitted yet) + var reasoningDelta *string + if currentReasoning != lastEmittedReasoning { + // Extract only the new part + if len(currentReasoning) > len(lastEmittedReasoning) && strings.HasPrefix(currentReasoning, lastEmittedReasoning) { + newReasoning := currentReasoning[len(lastEmittedReasoning):] + reasoningDelta = &newReasoning + lastEmittedReasoning = currentReasoning + } else if currentReasoning != "" { + // If reasoning changed in a non-append way, emit the full current reasoning + reasoningDelta = ¤tReasoning + lastEmittedReasoning = currentReasoning + } + } + + // Calculate content delta from cleaned content + var deltaContent string + if len(cleanedContent) > len(lastEmittedCleanedContent) && strings.HasPrefix(cleanedContent, lastEmittedCleanedContent) { + deltaContent = cleanedContent[len(lastEmittedCleanedContent):] + lastEmittedCleanedContent = cleanedContent + } else if cleanedContent != lastEmittedCleanedContent { + // If cleaned content changed but not in a simple append, extract delta from cleaned content + // This handles cases where thinking tags are removed mid-stream + if lastEmittedCleanedContent == "" { + deltaContent = cleanedContent + lastEmittedCleanedContent = cleanedContent + } else { + // Content changed in non-append way, use the new cleaned content + deltaContent = cleanedContent + lastEmittedCleanedContent = cleanedContent + } + } + // Only emit content if there's actual content (not just thinking tags) + // If deltaContent is empty, we still emit the response but with empty content + usage := schema.OpenAIUsage{ PromptTokens: tokenUsage.Prompt, CompletionTokens: tokenUsage.Completion, @@ -49,11 +93,20 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator usage.TimingPromptProcessing = tokenUsage.TimingPromptProcessing } + delta := &schema.Message{} + // Only include content if there's actual content (not just thinking tags) + if deltaContent != "" { + delta.Content = &deltaContent + } + if reasoningDelta != nil && *reasoningDelta != "" { + delta.Reasoning = reasoningDelta + } + resp := schema.OpenAIResponse{ ID: id, Created: created, Model: req.Model, // we have to return what the user sent here, due to OpenAI spec. - Choices: []schema.Choice{{Delta: &schema.Message{Content: &s}, Index: 0, FinishReason: nil}}, + Choices: []schema.Choice{{Delta: delta, Index: 0, FinishReason: nil}}, Object: "chat.completion.chunk", Usage: usage, } @@ -176,6 +229,10 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator if err != nil { return err } + // Extract reasoning before processing tool calls + reasoning, cleanedResult := functions.ExtractReasoning(result) + result = cleanedResult + textContentToReturn = functions.ParseTextContent(result, config.FunctionsConfig) result = functions.CleanupLLMResult(result, config.FunctionsConfig) functionResults := functions.ParseFunctionCall(result, config.FunctionsConfig) @@ -208,11 +265,20 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator usage.TimingPromptProcessing = tokenUsage.TimingPromptProcessing } + var deltaReasoning *string + if reasoning != "" { + deltaReasoning = &reasoning + } + delta := &schema.Message{Content: &result} + if deltaReasoning != nil { + delta.Reasoning = deltaReasoning + } + resp := schema.OpenAIResponse{ ID: id, Created: created, Model: req.Model, // we have to return what the user sent here, due to OpenAI spec. - Choices: []schema.Choice{{Delta: &schema.Message{Content: &result}, Index: 0, FinishReason: nil}}, + Choices: []schema.Choice{{Delta: delta, Index: 0, FinishReason: nil}}, Object: "chat.completion.chunk", Usage: usage, } @@ -553,10 +619,18 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator default: tokenCallback := func(s string, c *[]schema.Choice) { + // Extract reasoning from the response + reasoning, cleanedS := functions.ExtractReasoning(s) + s = cleanedS + if !shouldUseFn { // no function is called, just reply and use stop as finish reason stopReason := FinishReasonStop - *c = append(*c, schema.Choice{FinishReason: &stopReason, Index: 0, Message: &schema.Message{Role: "assistant", Content: &s}}) + message := &schema.Message{Role: "assistant", Content: &s} + if reasoning != "" { + message.Reasoning = &reasoning + } + *c = append(*c, schema.Choice{FinishReason: &stopReason, Index: 0, Message: message}) return } @@ -575,9 +649,13 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator } stopReason := FinishReasonStop + message := &schema.Message{Role: "assistant", Content: &result} + if reasoning != "" { + message.Reasoning = &reasoning + } *c = append(*c, schema.Choice{ FinishReason: &stopReason, - Message: &schema.Message{Role: "assistant", Content: &result}}) + Message: message}) default: toolCallsReason := FinishReasonToolCalls toolChoice := schema.Choice{ @@ -586,6 +664,9 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator Role: "assistant", }, } + if reasoning != "" { + toolChoice.Message.Reasoning = &reasoning + } for _, ss := range results { name, args := ss.Name, ss.Arguments @@ -606,16 +687,20 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator } else { // otherwise we return more choices directly (deprecated) functionCallReason := FinishReasonFunctionCall + message := &schema.Message{ + Role: "assistant", + Content: &textContentToReturn, + FunctionCall: map[string]interface{}{ + "name": name, + "arguments": args, + }, + } + if reasoning != "" { + message.Reasoning = &reasoning + } *c = append(*c, schema.Choice{ FinishReason: &functionCallReason, - Message: &schema.Message{ - Role: "assistant", - Content: &textContentToReturn, - FunctionCall: map[string]interface{}{ - "name": name, - "arguments": args, - }, - }, + Message: message, }) } } diff --git a/core/http/static/chat.js b/core/http/static/chat.js index 6c74a9c1c..6314f4f43 100644 --- a/core/http/static/chat.js +++ b/core/http/static/chat.js @@ -1368,6 +1368,7 @@ async function promptGPT(systemPrompt, input) { let lastAssistantMessageIndex = -1; let lastThinkingMessageIndex = -1; let lastThinkingScrollTime = 0; + let hasReasoningFromAPI = false; // Track if we're receiving reasoning from API (skip tag-based detection) const THINKING_SCROLL_THROTTLE = 200; // Throttle scrolling to every 200ms try { @@ -1401,19 +1402,24 @@ async function promptGPT(systemPrompt, input) { // Handle different event types switch (eventData.type) { case "reasoning": + hasReasoningFromAPI = true; // Mark that we're receiving reasoning from API if (eventData.content) { - // Insert reasoning before assistant message if it exists + const currentChat = chatStore.getChat(chatId); + if (!currentChat) break; // Chat was deleted + const isMCPMode = currentChat.mcpMode || false; + const shouldExpand = !isMCPMode; // Expanded in non-MCP mode, collapsed in MCP mode + // Insert thinking before assistant message if it exists (always use "thinking" role) if (lastAssistantMessageIndex >= 0 && targetHistory[lastAssistantMessageIndex]?.role === "assistant") { targetHistory.splice(lastAssistantMessageIndex, 0, { - role: "reasoning", + role: "thinking", content: eventData.content, html: DOMPurify.sanitize(marked.parse(eventData.content)), image: [], audio: [], - expanded: false // Reasoning is always collapsed + expanded: shouldExpand }); lastAssistantMessageIndex++; // Adjust index since we inserted - // Scroll smoothly after adding reasoning + // Scroll smoothly after adding thinking setTimeout(() => { const chatContainer = document.getElementById('chat'); if (chatContainer) { @@ -1425,7 +1431,7 @@ async function promptGPT(systemPrompt, input) { }, 100); } else { // No assistant message yet, just add normally - chatStore.add("reasoning", eventData.content, null, null, chatId); + chatStore.add("thinking", eventData.content, null, null, chatId); } } break; @@ -1491,14 +1497,17 @@ async function promptGPT(systemPrompt, input) { // Only update display if this is the active chat (interval will handle it) // Don't call updateTokensPerSecond here to avoid unnecessary updates - // Check for thinking tags in the chunk (incremental detection) - if (contentChunk.includes("") || contentChunk.includes("")) { - isThinking = true; - thinkingContent = ""; - lastThinkingMessageIndex = -1; - } - - if (contentChunk.includes("") || contentChunk.includes("")) { + // Only check for thinking tags if we're NOT receiving reasoning from API + // This prevents duplicate thinking/reasoning messages + if (!hasReasoningFromAPI) { + // Check for thinking tags in the chunk (incremental detection) + if (contentChunk.includes("") || contentChunk.includes("")) { + isThinking = true; + thinkingContent = ""; + lastThinkingMessageIndex = -1; + } + + if (contentChunk.includes("") || contentChunk.includes("")) { isThinking = false; // When closing tag is detected, process the accumulated thinking content if (thinkingContent.trim()) { @@ -1552,10 +1561,11 @@ async function promptGPT(systemPrompt, input) { } thinkingContent = ""; } + } } - // Handle content based on thinking state - if (isThinking) { + // Handle content based on thinking state (only if not receiving reasoning from API) + if (!hasReasoningFromAPI && isThinking) { thinkingContent += contentChunk; const currentChat = chatStore.getChat(chatId); if (!currentChat) break; // Chat was deleted @@ -1637,7 +1647,10 @@ async function promptGPT(systemPrompt, input) { // Process any thinking tags that might be in the accumulated content // This handles cases where tags are split across chunks - const { regularContent: processedRegular, thinkingContent: processedThinking } = processThinkingTags(regularContent); + // Only process if we're NOT receiving reasoning from API (to avoid duplicates) + const { regularContent: processedRegular, thinkingContent: processedThinking } = hasReasoningFromAPI + ? { regularContent: regularContent, thinkingContent: "" } + : processThinkingTags(regularContent); // Update or create assistant message with processed regular content const currentChat = chatStore.getChat(chatId); @@ -1645,10 +1658,10 @@ async function promptGPT(systemPrompt, input) { const request = activeRequests.get(chatId); const requestModel = request?.model || null; if (lastAssistantMessageIndex === -1) { - if (processedRegular && processedRegular.trim()) { - chatStore.add("assistant", processedRegular, null, null, chatId, requestModel); - lastAssistantMessageIndex = targetHistory.length - 1; - } + // Create assistant message if we have any content (even if empty string after processing) + // This ensures the message is created and can be updated with more content later + chatStore.add("assistant", processedRegular || "", null, null, chatId, requestModel); + lastAssistantMessageIndex = targetHistory.length - 1; } else { const lastMessage = targetHistory[lastAssistantMessageIndex]; if (lastMessage && lastMessage.role === "assistant") { @@ -1686,7 +1699,10 @@ async function promptGPT(systemPrompt, input) { if (assistantContentBuffer.length > 0) { const regularContent = assistantContentBuffer.join(""); // Process any remaining thinking tags that might be in the buffer - const { regularContent: processedRegular, thinkingContent: processedThinking } = processThinkingTags(regularContent); + // Only process if we're NOT receiving reasoning from API (to avoid duplicates) + const { regularContent: processedRegular, thinkingContent: processedThinking } = hasReasoningFromAPI + ? { regularContent: regularContent, thinkingContent: "" } + : processThinkingTags(regularContent); const currentChat = chatStore.getChat(chatId); if (!currentChat) { @@ -1719,23 +1735,26 @@ async function promptGPT(systemPrompt, input) { } // Then update or create assistant message + // Always create/update assistant message if we have any content if (lastAssistantMessageIndex !== -1) { const lastMessage = targetHistory[lastAssistantMessageIndex]; if (lastMessage && lastMessage.role === "assistant") { lastMessage.content = (lastMessage.content || "") + (processedRegular || ""); lastMessage.html = DOMPurify.sanitize(marked.parse(lastMessage.content)); } - } else if (processedRegular && processedRegular.trim()) { + } else { + // Create assistant message (even if empty, so it can be updated with more content) const request = activeRequests.get(chatId); const requestModel = request?.model || null; - chatStore.add("assistant", processedRegular, null, null, chatId, requestModel); + chatStore.add("assistant", processedRegular || "", null, null, chatId, requestModel); lastAssistantMessageIndex = targetHistory.length - 1; } } // Final thinking content flush if any data remains (from incremental detection) + // Only process if we're NOT receiving reasoning from API (to avoid duplicates) const finalChat = chatStore.getChat(chatId); - if (finalChat && thinkingContent.trim() && lastThinkingMessageIndex === -1) { + if (finalChat && !hasReasoningFromAPI && thinkingContent.trim() && lastThinkingMessageIndex === -1) { const finalHistory = finalChat.history; // Extract thinking content if tags are present const thinkingMatch = thinkingContent.match(/<(?:thinking|redacted_reasoning)>(.*?)<\/(?:thinking|redacted_reasoning)>/s); @@ -1891,9 +1910,13 @@ async function promptGPT(systemPrompt, input) { let buffer = ""; let contentBuffer = []; let thinkingContent = ""; + let reasoningContent = ""; // Track reasoning from API reasoning field let isThinking = false; let lastThinkingMessageIndex = -1; + let lastReasoningMessageIndex = -1; // Track reasoning message separately + let lastAssistantMessageIndex = -1; // Track assistant message for reasoning placement let lastThinkingScrollTime = 0; + let hasReasoningFromAPI = false; // Track if we're receiving reasoning from API (skip tag-based detection) const THINKING_SCROLL_THROTTLE = 200; // Throttle scrolling to every 200ms try { @@ -1929,30 +1952,100 @@ async function promptGPT(systemPrompt, input) { chatStore.updateTokenUsage(jsonData.usage, chatId); } - const token = jsonData.choices[0].delta.content; + const token = jsonData.choices?.[0]?.delta?.content; + const reasoningDelta = jsonData.choices?.[0]?.delta?.reasoning; - if (token) { - // Check for thinking tags - if (token.includes("") || token.includes("")) { - isThinking = true; - thinkingContent = ""; - lastThinkingMessageIndex = -1; + // Handle reasoning from API reasoning field - always use "thinking" role + if (reasoningDelta && reasoningDelta.trim() !== "") { + hasReasoningFromAPI = true; // Mark that we're receiving reasoning from API + reasoningContent += reasoningDelta; + const currentChat = chatStore.getChat(chatId); + if (!currentChat) { + // Chat was deleted, skip this line return; } - if (token.includes("") || token.includes("")) { - isThinking = false; - if (thinkingContent.trim()) { - // Only add the final thinking message if we don't already have one - if (lastThinkingMessageIndex === -1) { - chatStore.add("thinking", thinkingContent, null, null, chatId); + const isMCPMode = currentChat.mcpMode || false; + const shouldExpand = !isMCPMode; // Expanded in non-MCP mode, collapsed in MCP mode + + // Only create/update thinking message if we have actual content + if (reasoningContent.trim() !== "") { + // Update or create thinking message (always use "thinking" role, not "reasoning") + if (lastReasoningMessageIndex === -1) { + // Find the last assistant message index to insert thinking before it + const targetHistory = currentChat.history; + const assistantIndex = targetHistory.length - 1; + if (assistantIndex >= 0 && targetHistory[assistantIndex]?.role === "assistant") { + // Insert thinking before assistant message + targetHistory.splice(assistantIndex, 0, { + role: "thinking", + content: reasoningContent, + html: DOMPurify.sanitize(marked.parse(reasoningContent)), + image: [], + audio: [], + expanded: shouldExpand + }); + lastReasoningMessageIndex = assistantIndex; + lastAssistantMessageIndex = assistantIndex + 1; // Adjust for inserted thinking + } else { + // No assistant message yet, just add normally + chatStore.add("thinking", reasoningContent, null, null, chatId); + lastReasoningMessageIndex = currentChat.history.length - 1; + } + } else { + // Update existing thinking message + const targetHistory = currentChat.history; + if (lastReasoningMessageIndex >= 0 && lastReasoningMessageIndex < targetHistory.length) { + const thinkingMessage = targetHistory[lastReasoningMessageIndex]; + if (thinkingMessage && thinkingMessage.role === "thinking") { + thinkingMessage.content = reasoningContent; + thinkingMessage.html = DOMPurify.sanitize(marked.parse(reasoningContent)); + } } } - return; } + + // Scroll when reasoning is updated (throttled) + const now = Date.now(); + if (now - lastThinkingScrollTime > THINKING_SCROLL_THROTTLE) { + lastThinkingScrollTime = now; + setTimeout(() => { + const chatContainer = document.getElementById('chat'); + if (chatContainer) { + chatContainer.scrollTo({ + top: chatContainer.scrollHeight, + behavior: 'smooth' + }); + } + scrollThinkingBoxToBottom(); + }, 100); + } + } - // Handle content based on thinking state - if (isThinking) { - thinkingContent += token; + if (token && token.trim() !== "") { + // Only check for thinking tags if we're NOT receiving reasoning from API + // This prevents duplicate thinking/reasoning messages + if (!hasReasoningFromAPI) { + // Check for thinking tags (legacy support - models that output tags directly) + if (token.includes("") || token.includes("")) { + isThinking = true; + thinkingContent = ""; + lastThinkingMessageIndex = -1; + return; + } + if (token.includes("") || token.includes("")) { + isThinking = false; + if (thinkingContent.trim()) { + // Only add the final thinking message if we don't already have one + if (lastThinkingMessageIndex === -1) { + chatStore.add("thinking", thinkingContent, null, null, chatId); + } + } + return; + } + + // Handle content based on thinking state + if (isThinking) { + thinkingContent += token; // Count tokens for rate calculation (per chat) const request = activeRequests.get(chatId); if (request) { @@ -1995,7 +2088,42 @@ async function promptGPT(systemPrompt, input) { }, 100); } } else { + // Not in thinking state, add to content buffer contentBuffer.push(token); + // Track assistant message index for reasoning placement + if (lastAssistantMessageIndex === -1) { + const currentChat = chatStore.getChat(chatId); + if (currentChat) { + const targetHistory = currentChat.history; + // Find or create assistant message index + for (let i = targetHistory.length - 1; i >= 0; i--) { + if (targetHistory[i].role === "assistant") { + lastAssistantMessageIndex = i; + break; + } + } + // If no assistant message yet, it will be created when we flush contentBuffer + } + } + } + } else { + // Receiving reasoning from API, just add token to content buffer + contentBuffer.push(token); + // Track assistant message index for reasoning placement + if (lastAssistantMessageIndex === -1) { + const currentChat = chatStore.getChat(chatId); + if (currentChat) { + const targetHistory = currentChat.history; + // Find or create assistant message index + for (let i = targetHistory.length - 1; i >= 0; i--) { + if (targetHistory[i].role === "assistant") { + lastAssistantMessageIndex = i; + break; + } + } + // If no assistant message yet, it will be created when we flush contentBuffer + } + } } } } catch (error) { @@ -2007,6 +2135,17 @@ async function promptGPT(systemPrompt, input) { // Efficiently update the chat in batch if (contentBuffer.length > 0) { addToChat(contentBuffer.join("")); + // Update assistant message index after adding content + const currentChat = chatStore.getChat(chatId); + if (currentChat) { + const targetHistory = currentChat.history; + for (let i = targetHistory.length - 1; i >= 0; i--) { + if (targetHistory[i].role === "assistant") { + lastAssistantMessageIndex = i; + break; + } + } + } contentBuffer = []; // Scroll when assistant content is updated (this will also show thinking messages above) setTimeout(() => { @@ -2025,7 +2164,30 @@ async function promptGPT(systemPrompt, input) { if (contentBuffer.length > 0) { addToChat(contentBuffer.join("")); } + + // Final reasoning flush if any data remains - always use "thinking" role const finalChat = chatStore.getChat(chatId); + if (finalChat && reasoningContent.trim() && lastReasoningMessageIndex === -1) { + const isMCPMode = finalChat.mcpMode || false; + const shouldExpand = !isMCPMode; + const targetHistory = finalChat.history; + // Find assistant message to insert before + const assistantIndex = targetHistory.length - 1; + if (assistantIndex >= 0 && targetHistory[assistantIndex]?.role === "assistant") { + targetHistory.splice(assistantIndex, 0, { + role: "thinking", + content: reasoningContent, + html: DOMPurify.sanitize(marked.parse(reasoningContent)), + image: [], + audio: [], + expanded: shouldExpand + }); + } else { + chatStore.add("thinking", reasoningContent, null, null, chatId); + } + } + + // Final thinking content flush (legacy tag-based thinking) if (finalChat && thinkingContent.trim() && lastThinkingMessageIndex === -1) { chatStore.add("thinking", thinkingContent, null, null, chatId); } diff --git a/core/http/views/chat.html b/core/http/views/chat.html index dbb4dc862..6c064977c 100644 --- a/core/http/views/chat.html +++ b/core/http/views/chat.html @@ -41,7 +41,7 @@ SOFTWARE. __chatContextSize = {{ .ContextSize }}; {{ end }} - // Store gallery configs for header icon display + // Store gallery configs for header icon display and model info modal window.__galleryConfigs = {}; {{ $allGalleryConfigs:=.GalleryConfig }} {{ range $modelName, $galleryConfig := $allGalleryConfigs }} @@ -49,6 +49,16 @@ SOFTWARE. {{ if $galleryConfig.Icon }} window.__galleryConfigs["{{$modelName}}"].Icon = "{{$galleryConfig.Icon}}"; {{ end }} + {{ if $galleryConfig.Description }} + window.__galleryConfigs["{{$modelName}}"].Description = {{ printf "%q" $galleryConfig.Description }}; + {{ end }} + {{ if $galleryConfig.URLs }} + window.__galleryConfigs["{{$modelName}}"].URLs = [ + {{ range $idx, $url := $galleryConfig.URLs }} + {{ if $idx }},{{ end }}{{ printf "%q" $url }} + {{ end }} + ]; + {{ end }} {{ end }} // Function to initialize store @@ -326,10 +336,10 @@ SOFTWARE. c += DOMPurify.sanitize(marked.parse(line)); }); } - // Set expanded state: thinking is expanded by default in non-MCP mode, collapsed in MCP mode - // Reasoning, tool_call, and tool_result are always collapsed by default + // Set expanded state: thinking and reasoning are expanded by default in non-MCP mode, collapsed in MCP mode + // tool_call and tool_result are always collapsed by default const isMCPMode = chat.mcpMode || false; - const shouldExpand = (role === "thinking" && !isMCPMode) || false; + const shouldExpand = ((role === "thinking" || role === "reasoning") && !isMCPMode) || false; chat.history.push({ role, content, html: c, image, audio, expanded: shouldExpand, model: messageModel }); // Auto-name chat from first user message @@ -497,6 +507,11 @@ SOFTWARE. activeChat.model = modelName; activeChat.updatedAt = Date.now(); + // Update model info modal with new model + if (window.updateModelInfoModal) { + window.updateModelInfoModal(modelName); + } + // Get context size from data attribute let contextSize = null; if (selectedOption.dataset.contextSize) { @@ -536,18 +551,23 @@ SOFTWARE. } // Update model selector to reflect the change (ensure it stays in sync) + // Note: We don't dispatch a change event here to avoid infinite loop + // The selector is already updated via user interaction or programmatic change const modelSelector = document.getElementById('modelSelector'); if (modelSelector) { // Find and select the option matching the model const optionValue = 'chat/' + modelName; for (let i = 0; i < modelSelector.options.length; i++) { if (modelSelector.options[i].value === optionValue) { - modelSelector.selectedIndex = i; + // Only update if it's different to avoid unnecessary updates + if (modelSelector.selectedIndex !== i) { + modelSelector.selectedIndex = i; + } break; } } - // Trigger Alpine reactivity by dispatching change event - modelSelector.dispatchEvent(new Event('change', { bubbles: true })); + // Don't dispatch change event here - it would cause infinite recursion + // The selector is already in sync with the model } // Trigger MCP availability check in Alpine component @@ -603,27 +623,52 @@ SOFTWARE.
- {{ if $model }} - {{ $galleryConfig:= index $allGalleryConfigs $model}} - {{ if $galleryConfig }} - - {{ end }} - {{ end }} - {{ if $model }} - - - - {{ end }} + + + + + + + +