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 <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-01-10 19:06:12 +01:00
committed by GitHub
parent 5ca8f0aea0
commit c88074a19e
6 changed files with 941 additions and 105 deletions
+98 -13
View File
@@ -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 = &currentReasoning
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,
})
}
}
+203 -41
View File
@@ -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("<thinking>") || contentChunk.includes("<think>")) {
isThinking = true;
thinkingContent = "";
lastThinkingMessageIndex = -1;
}
if (contentChunk.includes("</thinking>") || contentChunk.includes("</think>")) {
// 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("<thinking>") || contentChunk.includes("<think>")) {
isThinking = true;
thinkingContent = "";
lastThinkingMessageIndex = -1;
}
if (contentChunk.includes("</thinking>") || contentChunk.includes("</think>")) {
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("<thinking>") || token.includes("<think>")) {
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("</thinking>") || token.includes("</think>")) {
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("<thinking>") || token.includes("<think>")) {
isThinking = true;
thinkingContent = "";
lastThinkingMessageIndex = -1;
return;
}
if (token.includes("</thinking>") || token.includes("</think>")) {
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);
}
+260 -49
View File
@@ -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.
<div class="flex items-center justify-between gap-2">
<label class="text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide flex-shrink-0">Model</label>
<div class="flex items-center gap-1 flex-shrink-0">
{{ if $model }}
{{ $galleryConfig:= index $allGalleryConfigs $model}}
{{ if $galleryConfig }}
<button
data-twe-ripple-init
data-twe-ripple-color="light"
class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]"
data-modal-target="model-info-modal"
data-modal-toggle="model-info-modal"
title="Model Information">
<i class="fas fa-info-circle"></i>
</button>
{{ end }}
{{ end }}
{{ if $model }}
<a href="/models/edit/{{$model}}"
class="text-[var(--color-text-secondary)] hover:text-[var(--color-warning)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]"
title="Edit Model Configuration">
<i class="fas fa-edit"></i>
</a>
{{ end }}
<!-- Info button - reactive to active chat model -->
<template x-if="$store.chat.activeChat() && $store.chat.activeChat().model && window.__galleryConfigs && window.__galleryConfigs[$store.chat.activeChat().model]">
<button
data-twe-ripple-init
data-twe-ripple-color="light"
class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]"
data-modal-target="model-info-modal"
data-modal-toggle="model-info-modal"
:data-model-name="$store.chat.activeChat().model"
@click="if (window.updateModelInfoModal) { window.updateModelInfoModal($store.chat.activeChat().model, true); }"
title="Model Information">
<i class="fas fa-info-circle"></i>
</button>
</template>
<!-- Fallback info button for initial model from server -->
<template x-if="(!$store.chat.activeChat() || !$store.chat.activeChat().model) && window.__galleryConfigs && window.__galleryConfigs['{{$model}}']">
<button
data-twe-ripple-init
data-twe-ripple-color="light"
class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]"
data-modal-target="model-info-modal"
data-modal-toggle="model-info-modal"
data-model-name="{{$model}}"
@click="if (window.updateModelInfoModal) { window.updateModelInfoModal('{{$model}}', true); }"
title="Model Information">
<i class="fas fa-info-circle"></i>
</button>
</template>
<!-- Edit button - reactive to active chat model -->
<template x-if="$store.chat.activeChat() && $store.chat.activeChat().model">
<a :href="'/models/edit/' + $store.chat.activeChat().model"
class="text-[var(--color-text-secondary)] hover:text-[var(--color-warning)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]"
title="Edit Model Configuration">
<i class="fas fa-edit"></i>
</a>
</template>
<!-- Fallback edit button for initial model from server -->
<template x-if="!$store.chat.activeChat() || !$store.chat.activeChat().model">
{{ if $model }}
<a href="/models/edit/{{$model}}"
class="text-[var(--color-text-secondary)] hover:text-[var(--color-warning)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]"
title="Edit Model Configuration">
<i class="fas fa-edit"></i>
</a>
{{ end }}
</template>
</div>
</div>
<select
@@ -1488,17 +1533,14 @@ SOFTWARE.
</div>
</div>
<!-- Modal moved outside of sidebar to appear in center of page -->
{{ if $model }}
{{ $galleryConfig:= index $allGalleryConfigs $model}}
{{ if $galleryConfig }}
<div id="model-info-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<!-- Modal moved outside of sidebar to appear in center of page - Always available, content updated dynamically -->
<div id="model-info-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full h-full md:inset-0 max-h-full" style="padding: 1rem;">
<div class="relative p-4 w-full max-w-2xl max-h-full">
<div class="relative p-4 w-full max-w-2xl max-h-full bg-white rounded-lg shadow dark:bg-gray-700">
<!-- Header -->
<div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">{{ $model }}</h3>
<button class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-hide="model-info-modal">
<h3 id="model-info-modal-title" class="text-xl font-semibold text-gray-900 dark:text-white">{{ if $model }}{{ $model }}{{ end }}</h3>
<button class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-hide="model-info-modal" @click="if (window.closeModelInfoModal) { window.closeModelInfoModal(); }">
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
@@ -1509,29 +1551,24 @@ SOFTWARE.
<!-- Body -->
<div class="p-4 md:p-5 space-y-4">
<div class="flex justify-center items-center">
{{ if $galleryConfig.Icon }}<img class="lazy rounded-t-lg max-h-48 max-w-96 object-cover mt-3 entered loaded" src="{{$galleryConfig.Icon}}" loading="lazy"/>{{end}}
<img id="model-info-modal-icon" class="lazy rounded-t-lg max-h-48 max-w-96 object-cover mt-3 entered loaded" style="display: none;" loading="lazy"/>
</div>
<div id="model-info-description" class="text-base leading-relaxed text-gray-500 dark:text-gray-400 break-words max-w-full">{{ $galleryConfig.Description }}</div>
<div id="model-info-description" class="text-base leading-relaxed text-gray-500 dark:text-gray-400 break-words max-w-full"></div>
<hr>
<p class="text-sm font-semibold text-gray-900 dark:text-white">Links</p>
<ul>
{{range $galleryConfig.URLs}}
<li><a href="{{ . }}" target="_blank">{{ . }}</a></li>
{{end}}
<ul id="model-info-links">
</ul>
</div>
<!-- Footer -->
<div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600">
<button data-modal-hide="model-info-modal" class="py-2.5 px-5 ms-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
<button data-modal-hide="model-info-modal" class="py-2.5 px-5 ms-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700" @click="if (window.closeModelInfoModal) { window.closeModelInfoModal(); }">
Close
</button>
</div>
</div>
</div>
</div>
{{ end }}
{{ end }}
<!-- Alpine store initialization and utilities -->
<script>
@@ -1742,10 +1779,20 @@ SOFTWARE.
});
// Also listen for click events on modal toggle buttons
document.querySelectorAll('[data-modal-toggle="model-info-modal"]').forEach(button => {
button.addEventListener('click', () => {
// Use event delegation to handle dynamically created buttons
document.addEventListener('click', (e) => {
const button = e.target.closest('[data-modal-toggle="model-info-modal"]');
if (button) {
// Update modal with current model before showing
if (window.Alpine && window.Alpine.store("chat")) {
const activeChat = window.Alpine.store("chat").activeChat();
const modelName = activeChat ? activeChat.model : (button.dataset.modelName || (document.getElementById("chat-model") ? document.getElementById("chat-model").value : null));
if (modelName && window.updateModelInfoModal) {
window.updateModelInfoModal(modelName, true);
}
}
setTimeout(processMarkdown, 300);
});
}
});
// Process on initial load if libraries are ready
@@ -1786,12 +1833,176 @@ SOFTWARE.
syncModelSelectorOnLoad();
}
// Function to update model info modal with current model
// Set openModal to true to actually open the modal, false to just update content
window.updateModelInfoModal = function(modelName, openModal = false) {
if (!modelName) {
return;
}
if (!window.__galleryConfigs) {
return;
}
const galleryConfig = window.__galleryConfigs[modelName];
// Check if galleryConfig exists and has at least one property
if (!galleryConfig || Object.keys(galleryConfig).length === 0) {
// Still update the modal title even if no config, so user can see which model they clicked
const titleEl = document.getElementById('model-info-modal-title');
if (titleEl) {
titleEl.textContent = modelName;
}
// Show message that no info is available
const descEl = document.getElementById('model-info-description');
if (descEl) {
descEl.textContent = 'No additional information available for this model.';
}
const linksEl = document.getElementById('model-info-links');
if (linksEl) {
linksEl.innerHTML = '';
}
const iconEl = document.getElementById('model-info-modal-icon');
if (iconEl) {
iconEl.style.display = 'none';
}
// Only open the modal if explicitly requested
if (openModal) {
const modalElement = document.getElementById('model-info-modal');
if (modalElement) {
modalElement.classList.remove('hidden');
modalElement.setAttribute('aria-hidden', 'false');
// Add backdrop
let backdrop = document.querySelector('.modal-backdrop');
if (!backdrop) {
backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop fixed inset-0 bg-gray-900 bg-opacity-50 dark:bg-opacity-80 z-40';
document.body.appendChild(backdrop);
backdrop.addEventListener('click', () => {
closeModelInfoModal();
});
}
}
}
return;
}
// Update modal title
const titleEl = document.getElementById('model-info-modal-title');
if (titleEl) {
titleEl.textContent = modelName;
}
// Update icon
const iconEl = document.getElementById('model-info-modal-icon');
if (iconEl) {
if (galleryConfig.Icon) {
iconEl.src = galleryConfig.Icon;
iconEl.style.display = 'block';
} else {
iconEl.style.display = 'none';
}
}
// Update description
const descEl = document.getElementById('model-info-description');
if (descEl) {
descEl.textContent = galleryConfig.Description || 'No description available.';
}
// Update links
const linksEl = document.getElementById('model-info-links');
if (linksEl && galleryConfig.URLs && Array.isArray(galleryConfig.URLs) && galleryConfig.URLs.length > 0) {
linksEl.innerHTML = '';
galleryConfig.URLs.forEach(url => {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.textContent = url;
li.appendChild(a);
linksEl.appendChild(li);
});
} else if (linksEl) {
linksEl.innerHTML = '<li>No links available</li>';
}
// Only open the modal if explicitly requested
if (openModal) {
const modalElement = document.getElementById('model-info-modal');
if (modalElement) {
// Ensure positioning classes are present (they might have been removed)
if (!modalElement.classList.contains('flex')) {
modalElement.classList.add('flex');
}
if (!modalElement.classList.contains('justify-center')) {
modalElement.classList.add('justify-center');
}
if (!modalElement.classList.contains('items-center')) {
modalElement.classList.add('items-center');
}
// Ensure fixed positioning
if (!modalElement.classList.contains('fixed')) {
modalElement.classList.add('fixed');
}
// Ensure full width and height
if (!modalElement.classList.contains('w-full')) {
modalElement.classList.add('w-full');
}
if (!modalElement.classList.contains('h-full')) {
modalElement.classList.add('h-full');
}
// Ensure padding is set
if (!modalElement.style.padding) {
modalElement.style.padding = '1rem';
}
// Remove hidden class if present
modalElement.classList.remove('hidden');
// Set aria-hidden to false
modalElement.setAttribute('aria-hidden', 'false');
// Add backdrop if needed
let backdrop = document.querySelector('.modal-backdrop');
if (!backdrop) {
backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop fixed inset-0 bg-gray-900 bg-opacity-50 dark:bg-opacity-80 z-40';
document.body.appendChild(backdrop);
backdrop.addEventListener('click', () => {
window.closeModelInfoModal();
});
}
}
}
};
// Function to close the model info modal
window.closeModelInfoModal = function() {
const modalElement = document.getElementById('model-info-modal');
if (modalElement) {
modalElement.classList.add('hidden');
modalElement.setAttribute('aria-hidden', 'true');
}
const backdrop = document.querySelector('.modal-backdrop');
if (backdrop) {
backdrop.remove();
}
};
// Also sync after Alpine initializes (in case it runs after DOMContentLoaded)
function initializeModelInfo() {
syncModelSelectorOnLoad();
// Initialize model info modal content with current model (but don't open it)
if (window.updateModelInfoModal && window.Alpine && window.Alpine.store("chat")) {
const activeChat = window.Alpine.store("chat").activeChat();
const modelName = activeChat ? activeChat.model : (document.getElementById("chat-model") ? document.getElementById("chat-model").value : null);
if (modelName) {
window.updateModelInfoModal(modelName, false); // false = don't open, just update content
}
}
}
if (window.Alpine) {
Alpine.nextTick(syncModelSelectorOnLoad);
Alpine.nextTick(initializeModelInfo);
} else {
document.addEventListener('alpine:init', () => {
Alpine.nextTick(syncModelSelectorOnLoad);
Alpine.nextTick(initializeModelInfo);
});
}
</script>
+5 -2
View File
@@ -27,6 +27,9 @@ type Message struct {
FunctionCall interface{} `json:"function_call,omitempty" yaml:"function_call,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty" yaml:"tool_call,omitempty"`
// Reasoning content extracted from <thinking>...</thinking> tags
Reasoning *string `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
}
type ToolCall struct {
@@ -78,8 +81,8 @@ func (messages Messages) ToProto() []*proto.Message {
}
}
// Note: tool_call_id and reasoning_content are not in schema.Message yet
// They may need to be added to schema.Message if needed in the future
// Note: tool_call_id is not in schema.Message yet
// Reasoning field is now available in schema.Message but not yet in proto.Message
}
return protoMessages
}
+114
View File
@@ -0,0 +1,114 @@
package functions
import (
"strings"
)
// ExtractReasoning extracts reasoning content from thinking tags and returns
// both the extracted reasoning and the cleaned content (with tags removed).
// It handles <thinking>...</thinking> and <think>...</think> tags.
// Multiple reasoning blocks are concatenated with newlines.
func ExtractReasoning(content string) (reasoning string, cleanedContent string) {
if content == "" {
return "", content
}
var reasoningParts []string
var cleanedParts []string
remaining := content
// Define tag pairs to look for
tagPairs := []struct {
start string
end string
}{
{"<thinking>", "</thinking>"},
{"<think>", "</think>"},
}
// Track the last position we've processed
lastPos := 0
for {
// Find the earliest tag start
earliestStart := -1
earliestEnd := -1
isUnclosed := false
var matchedTag struct {
start string
end string
}
for _, tagPair := range tagPairs {
startIdx := strings.Index(remaining[lastPos:], tagPair.start)
if startIdx == -1 {
continue
}
startIdx += lastPos
// Find the corresponding end tag
endIdx := strings.Index(remaining[startIdx+len(tagPair.start):], tagPair.end)
if endIdx == -1 {
// Unclosed tag - extract what we have
if earliestStart == -1 || startIdx < earliestStart {
earliestStart = startIdx
earliestEnd = len(remaining)
isUnclosed = true
matchedTag = tagPair
}
continue
}
endIdx += startIdx + len(tagPair.start)
// Found a complete tag pair
if earliestStart == -1 || startIdx < earliestStart {
earliestStart = startIdx
earliestEnd = endIdx + len(tagPair.end)
isUnclosed = false
matchedTag = tagPair
}
}
if earliestStart == -1 {
// No more tags found, add remaining content
if lastPos < len(remaining) {
cleanedParts = append(cleanedParts, remaining[lastPos:])
}
break
}
// Add content before the tag
if earliestStart > lastPos {
cleanedParts = append(cleanedParts, remaining[lastPos:earliestStart])
}
// Extract reasoning content
reasoningStart := earliestStart + len(matchedTag.start)
// For unclosed tags, earliestEnd is already at the end of the string
// For closed tags, earliestEnd points to after the closing tag, so we subtract the end tag length
var reasoningEnd int
if isUnclosed {
// Unclosed tag - extract everything to the end
reasoningEnd = len(remaining)
} else {
// Closed tag - exclude the end tag
reasoningEnd = earliestEnd - len(matchedTag.end)
}
if reasoningEnd > reasoningStart {
reasoningContent := strings.TrimSpace(remaining[reasoningStart:reasoningEnd])
if reasoningContent != "" {
reasoningParts = append(reasoningParts, reasoningContent)
}
}
// Move past this tag
lastPos = earliestEnd
}
// Combine reasoning parts
reasoning = strings.Join(reasoningParts, "\n\n")
// Combine cleaned content parts
cleanedContent = strings.Join(cleanedParts, "")
return reasoning, cleanedContent
}
+261
View File
@@ -0,0 +1,261 @@
package functions_test
import (
"strings"
. "github.com/mudler/LocalAI/pkg/functions"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ExtractReasoning", func() {
Context("when content has no reasoning tags", func() {
It("should return empty reasoning and original content", func() {
content := "This is regular content without any tags."
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(Equal(content))
})
It("should handle empty string", func() {
content := ""
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(BeEmpty())
})
It("should handle content with only whitespace", func() {
content := " \n\t "
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(Equal(content))
})
})
Context("when content has <thinking> tags", func() {
It("should extract reasoning from single thinking block", func() {
content := "Some text <thinking>This is my reasoning</thinking> More text"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("This is my reasoning"))
Expect(cleaned).To(Equal("Some text More text"))
})
It("should extract reasoning and preserve surrounding content", func() {
content := "Before <thinking>Reasoning here</thinking> After"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Reasoning here"))
Expect(cleaned).To(Equal("Before After"))
})
It("should handle thinking block at the start", func() {
content := "<thinking>Start reasoning</thinking> Regular content"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Start reasoning"))
Expect(cleaned).To(Equal(" Regular content"))
})
It("should handle thinking block at the end", func() {
content := "Regular content <thinking>End reasoning</thinking>"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("End reasoning"))
Expect(cleaned).To(Equal("Regular content "))
})
It("should handle only thinking block", func() {
content := "<thinking>Only reasoning</thinking>"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Only reasoning"))
Expect(cleaned).To(BeEmpty())
})
It("should trim whitespace from reasoning content", func() {
content := "Text <thinking> \n Reasoning with spaces \n </thinking> More"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Reasoning with spaces"))
Expect(cleaned).To(Equal("Text More"))
})
})
Context("when content has <think> tags", func() {
It("should extract reasoning from redacted_reasoning block", func() {
content := "Text <think>Redacted reasoning</think> More"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Redacted reasoning"))
Expect(cleaned).To(Equal("Text More"))
})
It("should handle redacted_reasoning with multiline content", func() {
content := "Before <think>Line 1\nLine 2\nLine 3</think> After"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Line 1\nLine 2\nLine 3"))
Expect(cleaned).To(Equal("Before After"))
})
It("should handle redacted_reasoning with complex content", func() {
content := "Start <think>Complex reasoning\nwith\nmultiple\nlines</think> End"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Complex reasoning\nwith\nmultiple\nlines"))
Expect(cleaned).To(Equal("Start End"))
})
})
Context("when content has multiple reasoning blocks", func() {
It("should concatenate multiple thinking blocks with newlines", func() {
content := "Text <thinking>First</thinking> Middle <thinking>Second</thinking> End"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("First\n\nSecond"))
Expect(cleaned).To(Equal("Text Middle End"))
})
It("should handle multiple different tag types", func() {
content := "A <thinking>One</thinking> B <think>Two</think> C <think>Three</think> D"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(ContainSubstring("One"))
Expect(reasoning).To(ContainSubstring("Two"))
Expect(reasoning).To(ContainSubstring("Three"))
Expect(cleaned).To(Equal("A B C D"))
})
It("should handle nested tags correctly (extracts first match)", func() {
content := "Text <thinking>Outer <think>Inner</think></thinking> More"
reasoning, cleaned := ExtractReasoning(content)
// Should extract the outer thinking block
Expect(reasoning).To(ContainSubstring("Outer"))
Expect(reasoning).To(ContainSubstring("Inner"))
Expect(cleaned).To(Equal("Text More"))
})
})
Context("when content has unclosed reasoning tags", func() {
It("should extract unclosed thinking block", func() {
content := "Text <thinking>Unclosed reasoning"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Unclosed reasoning"))
Expect(cleaned).To(Equal("Text "))
})
It("should extract unclosed think block", func() {
content := "Before <think>Incomplete"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Incomplete"))
Expect(cleaned).To(Equal("Before "))
})
It("should extract unclosed redacted_reasoning block", func() {
content := "Start <think>Partial reasoning content"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Partial reasoning content"))
Expect(cleaned).To(Equal("Start "))
})
It("should handle unclosed tag at the end", func() {
content := "Regular content <thinking>Unclosed at end"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Unclosed at end"))
Expect(cleaned).To(Equal("Regular content "))
})
})
Context("when content has empty reasoning blocks", func() {
It("should ignore empty thinking block", func() {
content := "Text <thinking></thinking> More"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(Equal("Text More"))
})
It("should ignore thinking block with only whitespace", func() {
content := "Text <thinking> \n\t </thinking> More"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(Equal("Text More"))
})
})
Context("when content has reasoning tags with special characters", func() {
It("should handle reasoning with newlines", func() {
content := "Before <thinking>Line 1\nLine 2\nLine 3</thinking> After"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Line 1\nLine 2\nLine 3"))
Expect(cleaned).To(Equal("Before After"))
})
It("should handle reasoning with code blocks", func() {
content := "Text <thinking>Reasoning with ```code``` blocks</thinking> More"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Reasoning with ```code``` blocks"))
Expect(cleaned).To(Equal("Text More"))
})
It("should handle reasoning with JSON", func() {
content := "Before <think>{\"key\": \"value\"}</think> After"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("{\"key\": \"value\"}"))
Expect(cleaned).To(Equal("Before After"))
})
It("should handle reasoning with HTML-like content", func() {
content := "Text <thinking>Reasoning with <tags> inside</thinking> More"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Reasoning with <tags> inside"))
Expect(cleaned).To(Equal("Text More"))
})
})
Context("when content has reasoning mixed with regular content", func() {
It("should preserve content order correctly", func() {
content := "Start <thinking>Reasoning</thinking> Middle <think>More reasoning</think> End"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(ContainSubstring("Reasoning"))
Expect(reasoning).To(ContainSubstring("More reasoning"))
Expect(cleaned).To(Equal("Start Middle End"))
})
It("should handle reasoning in the middle of a sentence", func() {
content := "This is a <thinking>reasoning</thinking> sentence."
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("reasoning"))
Expect(cleaned).To(Equal("This is a sentence."))
})
})
Context("edge cases", func() {
It("should handle content with only opening tag", func() {
content := "<thinking>"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(Equal(""))
})
It("should handle content with only closing tag", func() {
content := "</thinking>"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(Equal("</thinking>"))
})
It("should handle mismatched tags", func() {
content := "<thinking>Content</think>"
reasoning, cleaned := ExtractReasoning(content)
// Should extract unclosed thinking block
Expect(reasoning).To(ContainSubstring("Content"))
Expect(cleaned).To(Equal(""))
})
It("should handle very long reasoning content", func() {
longReasoning := strings.Repeat("This is reasoning content. ", 100)
content := "Text <thinking>" + longReasoning + "</thinking> More"
reasoning, cleaned := ExtractReasoning(content)
// TrimSpace is applied, so we need to account for that
Expect(reasoning).To(Equal(strings.TrimSpace(longReasoning)))
Expect(cleaned).To(Equal("Text More"))
})
It("should handle reasoning with unicode characters", func() {
content := "Text <thinking>Reasoning with 中文 and emoji 🧠</thinking> More"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Reasoning with 中文 and emoji 🧠"))
Expect(cleaned).To(Equal("Text More"))
})
})
})