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 }} + + + + + + + +