diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
index 82e9864fa..499994b75 100644
--- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
+++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
@@ -3220,6 +3220,12 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T3768991250"] = "User"
-- AI
UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T601166687"] = "AI"
+-- Show thinking
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1121977572"] = "Show thinking"
+
+-- Thinking
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1182941917"] = "Thinking"
+
-- Edit Message
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1183581066"] = "Edit Message"
@@ -3301,6 +3307,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blocked
-- Do you really want to regenerate this message?
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3878878761"] = "Do you really want to regenerate this message?"
+-- Hide thinking
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3979442719"] = "Hide thinking"
+
-- Remove Message
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4070211974"] = "Remove Message"
diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor
index edab5ac68..2649e17e0 100644
--- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor
+++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor
@@ -31,6 +31,23 @@
}
+ @if (this.HasThinking)
+ {
+
+
+
+
+ @T("Thinking")
+
+
+
+
+ }
@@ -193,6 +210,14 @@
}
+ @if (this.HasThinking && this.showThinking)
+ {
+
+ @T("Thinking")
+ @textContent.Thinking
+
+ }
+
if (textContent.InitialRemoteWait)
{
@@ -201,8 +226,9 @@
}
else if (this.Content.IsStreaming)
{
+ @* The think tags never reach the text: ContentText splits them off while streaming. *@
- @textContent.Text.RemoveThinkTags()
+ @textContent.Text
}
else
diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
index b2ec2babb..84fdce113 100644
--- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
+++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
@@ -127,6 +127,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private bool hasActiveMathContainer;
private bool isDisposed;
private bool showToolTrace;
+ private bool showThinking;
private readonly HashSet expandedToolInvocations = [];
///
@@ -139,6 +140,10 @@ public partial class ContentBlockComponent : MSGComponentBase
///
private bool CanExport => this.Content is { InitialRemoteWait: false, IsStreaming: false } && this.Content.TryGetMarkdownText(out _);
+ private bool HasThinking => this.Role is ChatRole.AI &&
+ this.Content is ContentText { Thinking: var thinking } &&
+ !string.IsNullOrWhiteSpace(thinking);
+
///
/// The tables this block holds so that the export menu can offer each of them.
///
@@ -305,11 +310,14 @@ private int CreateRenderHash()
var textValue = text.Text;
hash.Add(textValue.Length);
hash.Add(textValue.GetHashCode(StringComparison.Ordinal));
+ hash.Add(text.Thinking.Length);
+ hash.Add(text.Thinking.GetHashCode(StringComparison.Ordinal));
hash.Add(text.Sources.Count);
hash.Add(text.ToolInvocations.Count);
hash.Add(text.ToolRuntimeStatus.IsRunning);
hash.Add(text.ToolRuntimeStatus.Message);
hash.Add(this.showToolTrace);
+ hash.Add(this.showThinking);
hash.Add(this.expandedToolInvocations.Count);
foreach (var expandedInvocation in this.expandedToolInvocations.Order())
hash.Add(expandedInvocation);
@@ -380,6 +388,10 @@ private string GetToolTraceTooltip()
private void ToggleToolTrace() => this.showToolTrace = !this.showToolTrace;
+ private void ToggleThinking() => this.showThinking = !this.showThinking;
+
+ private string GetThinkingTooltip() => this.showThinking ? this.T("Hide thinking") : this.T("Show thinking");
+
private bool IsToolInvocationExpanded(int order) => this.expandedToolInvocations.Contains(order);
private void ToggleToolInvocation(int order)
@@ -832,4 +844,4 @@ protected override async ValueTask DisposeResourcesAsync()
await this.DisposeMathContainerIfNeededAsync();
}
-}
\ No newline at end of file
+}
diff --git a/app/MindWork AI Studio/Chat/ContentText.cs b/app/MindWork AI Studio/Chat/ContentText.cs
index 0640b6582..814f217ae 100644
--- a/app/MindWork AI Studio/Chat/ContentText.cs
+++ b/app/MindWork AI Studio/Chat/ContentText.cs
@@ -16,6 +16,9 @@ namespace AIStudio.Chat;
///
public sealed class ContentText : IContent
{
+ private const string OPEN_THINK_TAG = "";
+ private const string CLOSE_THINK_TAG = "";
+
private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger();
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ContentText).Namespace, nameof(ContentText));
@@ -26,6 +29,12 @@ public sealed class ContentText : IContent
///
private static readonly TimeSpan MIN_TIME = TimeSpan.FromSeconds(3);
+ [JsonIgnore]
+ private ThinkTagStreamState thinkTagStreamState;
+
+ [JsonIgnore]
+ private readonly StringBuilder thinkTagBuffer = new();
+
#region Implementation of IContent
///
@@ -118,15 +127,18 @@ await Task.Run(async () =>
if (token.IsCancellationRequested)
break;
- // Stop the waiting animation:
- this.InitialRemoteWait = false;
this.IsStreaming = true;
- // Add the response to the text:
- this.Text += contentStreamChunk;
+ // Add the response to the content:
+ this.ApplyStreamChunk(contentStreamChunk);
- // Merge the sources:
- this.Sources.MergeSources(contentStreamChunk.Sources);
+ //
+ // Stop the waiting animation once the answer itself starts. A model which
+ // is still reasoning has not written anything to read yet, and an empty
+ // bubble would look like a finished, empty answer. The thinking section
+ // is available next to the animation the whole time.
+ //
+ this.InitialRemoteWait = this.Text.Length is 0;
// Notify the UI that the content has changed,
// depending on the energy saving mode:
@@ -160,7 +172,7 @@ await Task.Run(async () =>
}
finally
{
- this.Text = this.Text.RemoveThinkTags().Trim();
+ this.FinalizeStreamContent();
// Inform the UI that the streaming is done:
await this.StreamingDone();
@@ -253,6 +265,7 @@ private async Task CheckSelectedModelAvailability(IProvider provider, Mode
public IContent DeepClone() => new ContentText
{
Text = this.Text,
+ Thinking = this.Thinking,
InitialRemoteWait = this.InitialRemoteWait,
IsStreaming = this.IsStreaming,
Sources = [..this.Sources],
@@ -407,4 +420,123 @@ public async Task PrepareTextContentForAI()
/// The text content.
///
public string Text { get; set; } = string.Empty;
-}
\ No newline at end of file
+
+ ///
+ /// Human-readable thinking content exposed by the provider.
+ ///
+ public string Thinking { get; set; } = string.Empty;
+
+ ///
+ /// Applies one provider stream chunk to this content.
+ ///
+ public void ApplyStreamChunk(ContentStreamChunk chunk)
+ {
+ if (!string.IsNullOrEmpty(chunk.Thinking))
+ this.Thinking += chunk.Thinking;
+
+ if (!string.IsNullOrEmpty(chunk.Content))
+ this.ApplyAnswerChunk(chunk.Content);
+
+ this.Sources.MergeSources(chunk.Sources);
+ }
+
+ ///
+ /// Completes parsing of provider content and normalizes the displayed values.
+ ///
+ public void FinalizeStreamContent()
+ {
+ switch (this.thinkTagStreamState)
+ {
+ case ThinkTagStreamState.UNDECIDED:
+ this.Text += this.thinkTagBuffer;
+ break;
+
+ case ThinkTagStreamState.THINKING:
+ this.Thinking += this.thinkTagBuffer;
+ break;
+ }
+
+ this.thinkTagBuffer.Clear();
+ this.thinkTagStreamState = ThinkTagStreamState.ANSWER;
+ this.Text = this.Text.Trim();
+ this.Thinking = this.Thinking.Trim();
+ }
+
+ private void ApplyAnswerChunk(string content)
+ {
+ if (this.thinkTagStreamState is ThinkTagStreamState.UNDECIDED && this.Text.Length > 0)
+ this.thinkTagStreamState = ThinkTagStreamState.ANSWER;
+
+ switch (this.thinkTagStreamState)
+ {
+ case ThinkTagStreamState.UNDECIDED:
+ this.thinkTagBuffer.Append(content);
+ var undecidedContent = this.thinkTagBuffer.ToString();
+ if (undecidedContent.Length < OPEN_THINK_TAG.Length &&
+ OPEN_THINK_TAG.StartsWith(undecidedContent, StringComparison.Ordinal))
+ return;
+
+ if (!undecidedContent.StartsWith(OPEN_THINK_TAG, StringComparison.Ordinal))
+ {
+ this.Text += undecidedContent;
+ this.thinkTagBuffer.Clear();
+ this.thinkTagStreamState = ThinkTagStreamState.ANSWER;
+ return;
+ }
+
+ this.thinkTagBuffer.Clear();
+ this.thinkTagStreamState = ThinkTagStreamState.THINKING;
+ this.ApplyThinkingTagContent(undecidedContent[OPEN_THINK_TAG.Length..]);
+ return;
+
+ case ThinkTagStreamState.THINKING:
+ this.ApplyThinkingTagContent(content);
+ return;
+
+ case ThinkTagStreamState.ANSWER:
+ this.Text += content;
+ return;
+ }
+ }
+
+ private void ApplyThinkingTagContent(string content)
+ {
+ this.thinkTagBuffer.Append(content);
+ var thinkingContent = this.thinkTagBuffer.ToString();
+ var closeTagIndex = thinkingContent.IndexOf(CLOSE_THINK_TAG, StringComparison.Ordinal);
+ if (closeTagIndex >= 0)
+ {
+ this.Thinking += thinkingContent[..closeTagIndex];
+ this.Text += thinkingContent[(closeTagIndex + CLOSE_THINK_TAG.Length)..];
+ this.thinkTagBuffer.Clear();
+ this.thinkTagStreamState = ThinkTagStreamState.ANSWER;
+ return;
+ }
+
+ var pendingLength = GetMarkerPrefixSuffixLength(thinkingContent, CLOSE_THINK_TAG);
+ var completedLength = thinkingContent.Length - pendingLength;
+ if (completedLength <= 0)
+ return;
+
+ this.Thinking += thinkingContent[..completedLength];
+ this.thinkTagBuffer.Clear();
+ this.thinkTagBuffer.Append(thinkingContent.AsSpan(completedLength));
+ }
+
+ private static int GetMarkerPrefixSuffixLength(string content, string marker)
+ {
+ var maximumLength = Math.Min(content.Length, marker.Length - 1);
+ for (var length = maximumLength; length > 0; length--)
+ if (content.AsSpan(content.Length - length).SequenceEqual(marker.AsSpan(0, length)))
+ return length;
+
+ return 0;
+ }
+
+ private enum ThinkTagStreamState
+ {
+ UNDECIDED,
+ THINKING,
+ ANSWER,
+ }
+}
diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua
index 60ed776d8..04ec89821 100644
--- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua
+++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua
@@ -3222,6 +3222,12 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T3768991250"] = "Benutzer"
-- AI
UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T601166687"] = "KI"
+-- Show thinking
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1121977572"] = "Denkprozess anzeigen"
+
+-- Thinking
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1182941917"] = "Denkprozess"
+
-- Edit Message
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1183581066"] = "Nachricht bearbeiten"
@@ -3303,6 +3309,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blockie
-- Do you really want to regenerate this message?
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3878878761"] = "Möchten Sie diese Nachricht wirklich neu generieren?"
+-- Hide thinking
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3979442719"] = "Denkprozess ausblenden"
+
-- Remove Message
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4070211974"] = "Nachricht entfernen"
diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua
index 13998081a..b7297e90b 100644
--- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua
+++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua
@@ -3222,6 +3222,12 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T3768991250"] = "User"
-- AI
UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T601166687"] = "AI"
+-- Show thinking
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1121977572"] = "Show thinking"
+
+-- Thinking
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1182941917"] = "Thinking"
+
-- Edit Message
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1183581066"] = "Edit Message"
@@ -3303,6 +3309,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blocked
-- Do you really want to regenerate this message?
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3878878761"] = "Do you really want to regenerate this message?"
+-- Hide thinking
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3979442719"] = "Hide thinking"
+
-- Remove Message
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4070211974"] = "Remove Message"
diff --git a/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs b/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs
index b94d23cb7..9c65b65a8 100644
--- a/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs
+++ b/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs
@@ -36,6 +36,20 @@ public string GetTextOutput() => string.Concat(this.Content
.Where(x => ReadString(x, "type").Equals("text", StringComparison.Ordinal))
.Select(x => ReadString(x, "text")));
+ ///
+ /// The human-readable thinking the model returned, with redacted blocks omitted.
+ ///
+ ///
+ /// Each thinking block reads as its own paragraph, so they are joined as paragraphs
+ /// rather than run into one another.
+ ///
+ public string GetThinkingOutput() => string.Join(
+ $"{Environment.NewLine}{Environment.NewLine}",
+ this.Content
+ .Where(x => ReadString(x, "type").Equals("thinking", StringComparison.Ordinal))
+ .Select(x => ReadString(x, "thinking"))
+ .Where(x => !string.IsNullOrWhiteSpace(x)));
+
private static string ReadString(JsonElement item, string propertyName)
{
if (item.ValueKind is not JsonValueKind.Object ||
@@ -45,4 +59,4 @@ private static string ReadString(JsonElement item, string propertyName)
return property.GetString() ?? string.Empty;
}
-}
\ No newline at end of file
+}
diff --git a/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolCallingAdapter.cs b/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolCallingAdapter.cs
index 9843b3629..c0f6ce5b1 100644
--- a/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolCallingAdapter.cs
+++ b/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolCallingAdapter.cs
@@ -55,6 +55,7 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList new ToolCallingRequestedCall(
toolUse.Id,
@@ -85,4 +86,4 @@ public void RecordToolResult(string callId, string content, bool isError = false
Content = content,
IsError = isError,
});
-}
\ No newline at end of file
+}
diff --git a/app/MindWork AI Studio/Provider/Anthropic/Delta.cs b/app/MindWork AI Studio/Provider/Anthropic/Delta.cs
index 84fb06bea..05f02d7db 100644
--- a/app/MindWork AI Studio/Provider/Anthropic/Delta.cs
+++ b/app/MindWork AI Studio/Provider/Anthropic/Delta.cs
@@ -6,4 +6,5 @@ namespace AIStudio.Provider.Anthropic;
///
/// The type of the delta.
/// The text of the delta.
-public readonly record struct Delta(string Type, string Text);
\ No newline at end of file
+/// The human-readable thinking delta.
+public readonly record struct Delta(string Type, string Text, string Thinking);
diff --git a/app/MindWork AI Studio/Provider/Anthropic/ResponseStreamLine.cs b/app/MindWork AI Studio/Provider/Anthropic/ResponseStreamLine.cs
index 195f164c0..5f831353e 100644
--- a/app/MindWork AI Studio/Provider/Anthropic/ResponseStreamLine.cs
+++ b/app/MindWork AI Studio/Provider/Anthropic/ResponseStreamLine.cs
@@ -10,10 +10,15 @@ namespace AIStudio.Provider.Anthropic;
public readonly record struct ResponseStreamLine(string Type, int Index, Delta Delta) : IResponseStreamLine
{
///
- public bool ContainsContent() => this != default && !string.IsNullOrWhiteSpace(this.Delta.Text);
+ public bool ContainsContent() => this != default &&
+ (!string.IsNullOrEmpty(this.Delta.Text) || !string.IsNullOrEmpty(this.Delta.Thinking));
///
- public ContentStreamChunk GetContent() => new(this.Delta.Text, []);
+ public ContentStreamChunk GetContent() => this.Delta.Type switch
+ {
+ "thinking_delta" => new(string.Empty, [], this.Delta.Thinking),
+ _ => new(this.Delta.Text, []),
+ };
#region Implementation of IAnnotationStreamLine
@@ -29,4 +34,4 @@ public readonly record struct ResponseStreamLine(string Type, int Index, Delta D
public IList GetSources() => [];
#endregion
-}
\ No newline at end of file
+}
diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs
index 3ff4de26f..07f4507b5 100644
--- a/app/MindWork AI Studio/Provider/BaseProvider.cs
+++ b/app/MindWork AI Studio/Provider/BaseProvider.cs
@@ -1048,6 +1048,21 @@ protected async IAsyncEnumerable StreamResponsesInternal parameters, stri
return true;
}
-}
\ No newline at end of file
+}
diff --git a/app/MindWork AI Studio/Provider/ContentStreamChunk.cs b/app/MindWork AI Studio/Provider/ContentStreamChunk.cs
index c6b2e205c..60cbebb72 100644
--- a/app/MindWork AI Studio/Provider/ContentStreamChunk.cs
+++ b/app/MindWork AI Studio/Provider/ContentStreamChunk.cs
@@ -5,7 +5,8 @@ namespace AIStudio.Provider;
///
/// The text content of the chunk.
/// The list of sources associated with the chunk.
-public sealed record ContentStreamChunk(string Content, IList Sources)
+/// The provider-exposed thinking content of the chunk.
+public sealed record ContentStreamChunk(string Content, IList Sources, string Thinking = "")
{
///
/// Implicit conversion to string.
@@ -13,4 +14,4 @@ public sealed record ContentStreamChunk(string Content, IList Sources)
/// The content stream chunk.
/// The text content of the chunk.
public static implicit operator string(ContentStreamChunk chunk) => chunk.Content;
-}
\ No newline at end of file
+}
diff --git a/app/MindWork AI Studio/Provider/Fireworks/Delta.cs b/app/MindWork AI Studio/Provider/Fireworks/Delta.cs
index 3c53684bc..1b4d0741d 100644
--- a/app/MindWork AI Studio/Provider/Fireworks/Delta.cs
+++ b/app/MindWork AI Studio/Provider/Fireworks/Delta.cs
@@ -1,7 +1,17 @@
+using System.Text.Json;
+
+using AIStudio.Provider.OpenAI;
+
namespace AIStudio.Provider.Fireworks;
///
/// The delta text of a choice.
///
/// The content of the delta text.
-public readonly record struct Delta(string Content);
\ No newline at end of file
+/// OpenAI-compatible reasoning content.
+/// OpenRouter-compatible reasoning content.
+/// Structured reasoning details.
+public readonly record struct Delta(string Content, string? ReasoningContent, string? Reasoning, IList? ReasoningDetails)
+{
+ public string Thinking => ThinkingContent.Get(this.ReasoningContent, this.Reasoning, this.ReasoningDetails);
+}
diff --git a/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs b/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs
index 25e35f829..625308c63 100644
--- a/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs
+++ b/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs
@@ -14,7 +14,7 @@ public readonly record struct ResponseStreamLine(string Id, string Object, uint
public bool ContainsContent() => this != default && this.Choices.Count > 0;
///
- public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, []);
+ public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, [], this.Choices[0].Delta.Thinking);
#region Implementation of IAnnotationStreamLine
@@ -29,4 +29,4 @@ public readonly record struct ResponseStreamLine(string Id, string Object, uint
public IList GetSources() => [];
#endregion
-}
\ No newline at end of file
+}
diff --git a/app/MindWork AI Studio/Provider/Model.cs b/app/MindWork AI Studio/Provider/Model.cs
index 97ca3bbfc..548ee5745 100644
--- a/app/MindWork AI Studio/Provider/Model.cs
+++ b/app/MindWork AI Studio/Provider/Model.cs
@@ -1,3 +1,5 @@
+using System.Text.Json.Serialization;
+
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Provider;
@@ -20,6 +22,19 @@ public readonly record struct Model(string Id, string? DisplayName)
///
public static readonly Model SYSTEM_MODEL = new(SYSTEM_MODEL_ID, null);
+ ///
+ /// The provider-reported default reasoning behavior, when the model catalog supplies it.
+ ///
+ ///
+ /// This travels with the selected model into the user's settings, so it is a copy of what the
+ /// catalog said when the user picked the model, not a live value. It is refreshed when the user
+ /// picks a model again. That is deliberate: the capability lookup works from the stored model
+ /// and must not depend on the provider being reachable.
+ /// falls back to the model-name heuristics, which is also what every older settings file yields.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public ModelReasoningBehavior ReasoningBehavior { get; init; }
+
///
/// Checks if this model is the system-configured placeholder.
///
@@ -71,4 +86,4 @@ public override string ToString()
public override int GetHashCode() => this.Id?.GetHashCode(StringComparison.Ordinal) ?? 0;
#endregion
-}
\ No newline at end of file
+}
diff --git a/app/MindWork AI Studio/Provider/ModelReasoningBehavior.cs b/app/MindWork AI Studio/Provider/ModelReasoningBehavior.cs
new file mode 100644
index 000000000..535edcf56
--- /dev/null
+++ b/app/MindWork AI Studio/Provider/ModelReasoningBehavior.cs
@@ -0,0 +1,32 @@
+namespace AIStudio.Provider;
+
+///
+/// Describes the default reasoning behavior reported by a provider's model catalog.
+///
+///
+/// A catalog which reports this is more reliable than our model-name heuristics, so
+/// takes precedence over them. Not every provider
+/// reports it, which is what stands for.
+///
+public enum ModelReasoningBehavior
+{
+ ///
+ /// The catalog said nothing about reasoning. The model-name heuristics decide.
+ ///
+ UNKNOWN,
+
+ ///
+ /// The model can reason, but does not unless the request asks for it.
+ ///
+ OPTIONAL,
+
+ ///
+ /// The model reasons unless the request switches it off.
+ ///
+ DEFAULT_ON,
+
+ ///
+ /// The model always reasons and it cannot be switched off.
+ ///
+ ALWAYS_ON,
+}
diff --git a/app/MindWork AI Studio/Provider/OpenAI/AssistantToolCallMessage.cs b/app/MindWork AI Studio/Provider/OpenAI/AssistantToolCallMessage.cs
index cd4048789..45bbae2a2 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/AssistantToolCallMessage.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/AssistantToolCallMessage.cs
@@ -12,5 +12,11 @@ public sealed record AssistantToolCallMessage : IMessageBase
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ReasoningContent { get; init; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Reasoning { get; init; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public IList? ReasoningDetails { get; init; }
+
public IList ToolCalls { get; init; } = [];
}
diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDelta.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDelta.cs
index 9f6f6cfcc..517b81191 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDelta.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDelta.cs
@@ -13,4 +13,13 @@ public sealed record ChatCompletionDelta
[JsonIgnore]
public string Content => ChatCompletionContent.GetText(this.RawContent) ?? string.Empty;
+
+ public string? ReasoningContent { get; init; }
+
+ public string? Reasoning { get; init; }
+
+ public IList? ReasoningDetails { get; init; }
+
+ [JsonIgnore]
+ public string Thinking => ThinkingContent.Get(this.ReasoningContent, this.Reasoning, this.ReasoningDetails);
}
diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs
index 1db13ba9d..32489bc78 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs
@@ -19,7 +19,7 @@ public ChatCompletionDeltaStreamLine() : this(string.Empty, string.Empty, 0, str
public bool ContainsContent() => this.Choices.Count > 0;
///
- public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, []);
+ public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, [], this.Choices[0].Delta.Thinking);
#region Implementation of IAnnotationStreamLine
@@ -42,4 +42,4 @@ public ChatCompletionDeltaStreamLine() : this(string.Empty, string.Empty, 0, str
public IList GetSources() => [];
#endregion
-}
\ No newline at end of file
+}
diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionResponseMessage.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionResponseMessage.cs
index 40b20e788..4ec59e9e3 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionResponseMessage.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionResponseMessage.cs
@@ -15,5 +15,11 @@ public sealed record ChatCompletionResponseMessage
public string? ReasoningContent { get; init; }
+ public string? Reasoning { get; init; }
+
+ public IList? ReasoningDetails { get; init; }
+
public IList? ToolCalls { get; init; }
+
+ public string GetThinkingOutput() => ThinkingContent.Get(this.ReasoningContent, this.Reasoning, this.ReasoningDetails);
}
diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs
index 594efc43f..c525eff26 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs
@@ -72,6 +72,7 @@ public sealed class ChatCompletionToolCallingAdapter(
return new ToolCallingRound(
responseChoice.Message.Content ?? string.Empty,
+ responseChoice.Message.GetThinkingOutput(),
preparedCalls
.Select(x => new ToolCallingRequestedCall(x.ToolCall.Id!, x.ToolCall.Function!.Name!, x.ToolCall.Function!.Arguments!, x.IsValid))
.ToList(),
@@ -83,6 +84,8 @@ public void RecordAssistantTurn() => this.internalMessages.Add(new AssistantTool
{
Content = this.lastResponseMessage?.RawContent,
ReasoningContent = this.lastResponseMessage?.ReasoningContent,
+ Reasoning = this.lastResponseMessage?.Reasoning,
+ ReasoningDetails = this.lastResponseMessage?.ReasoningDetails,
ToolCalls = this.lastToolCalls,
});
@@ -168,4 +171,4 @@ private List PrepareToolCalls(IEnumerable StreamChatCompletion(
// Check if we are using the Responses API or the Chat Completion API:
var usingResponsesAPI = modelCapabilities.Contains(Capability.RESPONSES_API);
+
+ // Whether the model reasons at all, no matter whether it may be switched off:
+ var isReasoningModel = modelCapabilities.Contains(Capability.ALWAYS_REASONING) ||
+ modelCapabilities.Contains(Capability.REASONING_BY_DEFAULT) ||
+ modelCapabilities.Contains(Capability.OPTIONAL_REASONING);
// Prepare the request path based on the API we are using:
var requestPath = usingResponsesAPI ? "responses" : "chat/completions";
@@ -225,6 +230,7 @@ public override async IAsyncEnumerable StreamChatCompletion(
{
var adapter = new ResponsesToolCallingAdapter(
chatModel,
+ isReasoningModel,
baseInput,
additionalApiParameters,
providerTools,
diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs
index 0c1767d0f..b38778a5a 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs
@@ -5,17 +5,38 @@ namespace AIStudio.Provider.OpenAI;
///
/// The type of the response.
/// The delta content of the response.
+/// The reasoning summary part this line belongs to.
public record ResponsesDeltaStreamLine(
string Type,
- string? Delta) : IResponseStreamLine
+ string? Delta,
+ int SummaryIndex) : IResponseStreamLine
{
+ private const string SUMMARY_PART_ADDED = "response.reasoning_summary_part.added";
+ private const string SUMMARY_TEXT_DELTA = "response.reasoning_summary_text.delta";
+ private const string REASONING_TEXT_DELTA = "response.reasoning_text.delta";
+
+ ///
+ /// Whether this line starts a reasoning summary part which follows an earlier one.
+ ///
+ ///
+ /// A reasoning summary arrives as several parts, and each one reads as its own paragraph.
+ /// The stream carries no separator between them, so the boundary has to come from the
+ /// event which announces the next part.
+ ///
+ private bool IsFollowUpSummaryPart => this.Type is SUMMARY_PART_ADDED && this.SummaryIndex > 0;
+
#region Implementation of IResponseStreamLine
///
- public bool ContainsContent() => this.Delta is not null;
+ public bool ContainsContent() => this.Delta is not null || this.IsFollowUpSummaryPart;
///
- public ContentStreamChunk GetContent() => new(this.Delta ?? string.Empty, this.GetSources());
+ public ContentStreamChunk GetContent() => this.Type switch
+ {
+ SUMMARY_PART_ADDED => new(string.Empty, this.GetSources(), this.IsFollowUpSummaryPart ? $"{Environment.NewLine}{Environment.NewLine}" : string.Empty),
+ SUMMARY_TEXT_DELTA or REASONING_TEXT_DELTA => new(string.Empty, this.GetSources(), this.Delta ?? string.Empty),
+ _ => new(this.Delta ?? string.Empty, this.GetSources()),
+ };
//
// Please note that there are multiple options where LLM providers might stream sources:
@@ -36,4 +57,4 @@ public record ResponsesDeltaStreamLine(
public IList GetSources() => [];
#endregion
-}
\ No newline at end of file
+}
diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs
index 285bca00e..b2be4fb33 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs
@@ -42,6 +42,23 @@ public string GetTextOutput()
}));
}
+ ///
+ /// The human-readable thinking the model returned.
+ ///
+ ///
+ /// A reasoning item carries its summary and its raw text as arrays of parts, and each part
+ /// reads as its own paragraph. The API states no separator between them, so we join them
+ /// as paragraphs instead of running them into one another.
+ ///
+ public string GetThinkingOutput() => JoinParagraphs(this.Output
+ .Where(x => ReadString(x, "type").Equals("reasoning", StringComparison.Ordinal))
+ .SelectMany(x => ReadArrayItems(x, "summary").Concat(ReadArrayItems(x, "content")))
+ .Select(x => ReadString(x, "text")));
+
+ private static string JoinParagraphs(IEnumerable parts) => string.Join(
+ $"{Environment.NewLine}{Environment.NewLine}",
+ parts.Where(part => !string.IsNullOrWhiteSpace(part)));
+
public IReadOnlyList GetSources() => this.Output
.Where(x => ReadString(x, "type").Equals("message", StringComparison.Ordinal))
.SelectMany(ReadContentItems)
diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs
index 014865579..6c0ca70aa 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs
@@ -11,10 +11,12 @@ namespace AIStudio.Provider.OpenAI;
/// correlated by call ID. Unlike Chat Completions, the whole output of a round has to be sent
/// back for the next one, reasoning items included, or the API refuses to continue.
///
-public sealed class ResponsesToolCallingAdapter(Model chatModel, IList