From bbbcdddc910fb8db401da72cd474ae5de9ac341b Mon Sep 17 00:00:00 2001 From: Peer Hogeterp <20603780+peerschuett@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:37:43 +0200 Subject: [PATCH] Add per-answer thinking disclosure --- .../Assistants/I18N/allTexts.lua | 9 ++ .../Chat/ContentBlockComponent.razor | 25 ++++ .../Chat/ContentBlockComponent.razor.cs | 14 +- app/MindWork AI Studio/Chat/ContentText.cs | 140 +++++++++++++++++- .../plugin.lua | 9 ++ .../plugin.lua | 9 ++ .../Provider/Anthropic/AnthropicResponse.cs | 9 +- .../Anthropic/AnthropicToolCallingAdapter.cs | 3 +- .../Provider/Anthropic/Delta.cs | 3 +- .../Provider/Anthropic/ResponseStreamLine.cs | 11 +- .../Provider/BaseProvider.cs | 8 +- .../Provider/ContentStreamChunk.cs | 9 +- .../Provider/Fireworks/Delta.cs | 12 +- .../Provider/Fireworks/ResponseStreamLine.cs | 4 +- .../OpenAI/AssistantToolCallMessage.cs | 6 + .../Provider/OpenAI/ChatCompletionDelta.cs | 9 ++ .../OpenAI/ChatCompletionDeltaStreamLine.cs | 4 +- .../OpenAI/ChatCompletionResponseMessage.cs | 6 + .../ChatCompletionToolCallingAdapter.cs | 5 +- .../OpenAI/ResponsesDeltaStreamLine.cs | 8 +- .../Provider/OpenAI/ResponsesResponse.cs | 5 + .../OpenAI/ResponsesToolCallingAdapter.cs | 3 +- .../Provider/OpenAI/ThinkingContent.cs | 46 ++++++ .../Provider/Perplexity/Delta.cs | 12 +- .../Provider/Perplexity/ResponseStreamLine.cs | 4 +- .../Tools/AIJobs/AIJobService.cs | 10 +- .../Harness/ToolCallingLoop.cs | 10 +- .../Harness/ToolCallingRound.cs | 3 +- .../wwwroot/changelog/v26.9.1.md | 1 + 29 files changed, 360 insertions(+), 37 deletions(-) create mode 100644 app/MindWork AI Studio/Provider/OpenAI/ThinkingContent.cs diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 82e9864f..499994b7 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 edab5ac6..95b450b7 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) { diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs index b2ec2bab..84fdce11 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 @@ public partial class ContentBlockComponent : MSGComponentBase 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 @@ public partial class ContentBlockComponent : MSGComponentBase 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 @@ public partial class ContentBlockComponent : MSGComponentBase 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 0640b658..c4f37315 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 /// @@ -122,11 +131,8 @@ public sealed class ContentText : IContent this.InitialRemoteWait = false; this.IsStreaming = true; - // Add the response to the text: - this.Text += contentStreamChunk; - - // Merge the sources: - this.Sources.MergeSources(contentStreamChunk.Sources); + // Add the response to the content: + this.ApplyStreamChunk(contentStreamChunk); // Notify the UI that the content has changed, // depending on the energy saving mode: @@ -160,7 +166,7 @@ public sealed class ContentText : IContent } finally { - this.Text = this.Text.RemoveThinkTags().Trim(); + this.FinalizeStreamContent(); // Inform the UI that the streaming is done: await this.StreamingDone(); @@ -253,6 +259,7 @@ public sealed class ContentText : IContent public IContent DeepClone() => new ContentText { Text = this.Text, + Thinking = this.Thinking, InitialRemoteWait = this.InitialRemoteWait, IsStreaming = this.IsStreaming, Sources = [..this.Sources], @@ -407,4 +414,123 @@ public sealed class ContentText : IContent /// 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 60ed776d..04ec8982 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 13998081..b7297e90 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 b94d23cb..c320c050 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs @@ -36,6 +36,13 @@ public sealed record AnthropicResponse .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. + /// + public string GetThinkingOutput() => string.Concat(this.Content + .Where(x => ReadString(x, "type").Equals("thinking", StringComparison.Ordinal)) + .Select(x => ReadString(x, "thinking"))); + private static string ReadString(JsonElement item, string propertyName) { if (item.ValueKind is not JsonValueKind.Object || @@ -45,4 +52,4 @@ public sealed record AnthropicResponse 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 9843b362..c0f6ce5b 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 sealed class AnthropicToolCallingAdapter(Model chatModel, IList /// 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 195f164c..ef47ccbe 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, string.Empty, []), + }; #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 3ff4de26..445228a4 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -1048,6 +1048,12 @@ public abstract class BaseProvider : IProvider, ISecretId // if (jsonData.StartsWith(""" {"type":"response.output_text.delta" + """, StringComparison.InvariantCulture) || + jsonData.StartsWith(""" + {"type":"response.reasoning_summary_text.delta" + """, StringComparison.InvariantCulture) || + jsonData.StartsWith(""" + {"type":"response.reasoning_text.delta" """, StringComparison.InvariantCulture)) { TDelta? providerResponse; @@ -1606,4 +1612,4 @@ public abstract class BaseProvider : IProvider, ISecretId 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 c6b2e205..a6c7347e 100644 --- a/app/MindWork AI Studio/Provider/ContentStreamChunk.cs +++ b/app/MindWork AI Studio/Provider/ContentStreamChunk.cs @@ -4,13 +4,18 @@ namespace AIStudio.Provider; /// A chunk of content from a content stream, along with its associated sources. /// /// The text content of the chunk. +/// The provider-exposed thinking content of the chunk. /// The list of sources associated with the chunk. -public sealed record ContentStreamChunk(string Content, IList Sources) +public sealed record ContentStreamChunk(string Content, string Thinking, IList Sources) { + public ContentStreamChunk(string content, IList sources) : this(content, string.Empty, sources) + { + } + /// /// Implicit conversion to string. /// /// 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 3c53684b..1b4d0741 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 25e35f82..a0869f3e 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/OpenAI/AssistantToolCallMessage.cs b/app/MindWork AI Studio/Provider/OpenAI/AssistantToolCallMessage.cs index cd404878..45bbae2a 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 9f6f6cfc..517b8119 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 1db13ba9..7b686ac3 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs @@ -19,7 +19,7 @@ public record ChatCompletionDeltaStreamLine(string Id, string Object, uint Creat 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 record ChatCompletionDeltaStreamLine(string Id, string Object, uint Creat 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 40b20e78..4ec59e9e 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 594efc43..c525eff2 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 sealed class ChatCompletionToolCallingAdapter( { Content = this.lastResponseMessage?.RawContent, ReasoningContent = this.lastResponseMessage?.ReasoningContent, + Reasoning = this.lastResponseMessage?.Reasoning, + ReasoningDetails = this.lastResponseMessage?.ReasoningDetails, ToolCalls = this.lastToolCalls, }); @@ -168,4 +171,4 @@ public sealed class ChatCompletionToolCallingAdapter( } private readonly record struct PreparedChatCompletionToolCall(ChatCompletionToolCall ToolCall, bool IsValid); -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs index 0c1767d0..2a7f4777 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs @@ -15,7 +15,11 @@ public record ResponsesDeltaStreamLine( public bool ContainsContent() => this.Delta is not null; /// - public ContentStreamChunk GetContent() => new(this.Delta ?? string.Empty, this.GetSources()); + public ContentStreamChunk GetContent() => this.Type switch + { + "response.reasoning_summary_text.delta" or "response.reasoning_text.delta" => new(string.Empty, this.Delta ?? string.Empty, this.GetSources()), + _ => new(this.Delta ?? string.Empty, string.Empty, this.GetSources()), + }; // // Please note that there are multiple options where LLM providers might stream sources: @@ -36,4 +40,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 285bca00..f0266ea2 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs @@ -42,6 +42,11 @@ public sealed record ResponsesResponse })); } + public string GetThinkingOutput() => string.Concat(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"))); + 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 01486557..5d30a623 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs @@ -57,6 +57,7 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList b this.lastResponse = response; return new ToolCallingRound( response.GetTextOutput(), + response.GetThinkingOutput(), response.GetFunctionCalls() .Select(call => new ToolCallingRequestedCall( call.CallId ?? string.Empty, @@ -102,4 +103,4 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList b .Concat(runnableTools.Select(x => (object)ProviderToolAdapters.ToResponsesTool(x.Definition))) .ToList(); } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/OpenAI/ThinkingContent.cs b/app/MindWork AI Studio/Provider/OpenAI/ThinkingContent.cs new file mode 100644 index 00000000..114258cd --- /dev/null +++ b/app/MindWork AI Studio/Provider/OpenAI/ThinkingContent.cs @@ -0,0 +1,46 @@ +using System.Text.Json; + +namespace AIStudio.Provider.OpenAI; + +/// +/// Reads human-readable thinking from OpenAI-compatible response fields. +/// +internal static class ThinkingContent +{ + public static string Get(string? reasoningContent, string? reasoning, IEnumerable? reasoningDetails) + { + if (!string.IsNullOrWhiteSpace(reasoningContent)) + return reasoningContent; + + if (!string.IsNullOrWhiteSpace(reasoning)) + return reasoning; + + if (reasoningDetails is null) + return string.Empty; + + return string.Concat(reasoningDetails.Select(GetVisibleDetailText)); + } + + private static string GetVisibleDetailText(JsonElement detail) + { + if (detail.ValueKind is not JsonValueKind.Object || + !detail.TryGetProperty("type", out var typeProperty) || + typeProperty.ValueKind is not JsonValueKind.String) + return string.Empty; + + return typeProperty.GetString() switch + { + "reasoning.text" => ReadString(detail, "text"), + "reasoning.summary" => ReadString(detail, "summary"), + _ => string.Empty, + }; + } + + private static string ReadString(JsonElement item, string propertyName) + { + if (!item.TryGetProperty(propertyName, out var property) || property.ValueKind is not JsonValueKind.String) + return string.Empty; + + return property.GetString() ?? string.Empty; + } +} diff --git a/app/MindWork AI Studio/Provider/Perplexity/Delta.cs b/app/MindWork AI Studio/Provider/Perplexity/Delta.cs index a4f1da61..82d34b41 100644 --- a/app/MindWork AI Studio/Provider/Perplexity/Delta.cs +++ b/app/MindWork AI Studio/Provider/Perplexity/Delta.cs @@ -1,7 +1,17 @@ +using System.Text.Json; + +using AIStudio.Provider.OpenAI; + namespace AIStudio.Provider.Perplexity; /// /// 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/Perplexity/ResponseStreamLine.cs b/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs index 5ef74083..318b9df3 100644 --- a/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs +++ b/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs @@ -15,11 +15,11 @@ 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, this.GetSources()); + public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, this.Choices[0].Delta.Thinking, this.GetSources()); /// public bool ContainsSources() => this != default && this.SearchResults.Count > 0; /// public IList GetSources() => this.SearchResults.Cast().ToList(); -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs index 76c76454..a3155ae1 100644 --- a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs @@ -273,7 +273,6 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes catch (ProviderRequestException e) { logger.LogError(e, "The provider request failed for chat generation job '{JobId}'. Status={StatusCode}, Reason='{ReasonPhrase}', Body='{ResponseBody}'", state.Snapshot.JobId, e.StatusCode, e.ReasonPhrase, e.ResponseBody); - RemoveEmptyAIResponse(state); await this.CompleteChatGenerationAsync(state, AIJobStatus.FAILED, e.UserMessage); await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, e.UserMessage)); } @@ -302,7 +301,7 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes var aiText = request.AIText; aiText.InitialRemoteWait = false; aiText.IsStreaming = false; - aiText.Text = aiText.Text.RemoveThinkTags().Trim(); + aiText.FinalizeStreamContent(); RemoveEmptyAIResponse(state); @@ -367,7 +366,7 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes return; var aiText = request.AIText; - if (!string.IsNullOrWhiteSpace(aiText.Text)) + if (!string.IsNullOrWhiteSpace(aiText.Text) || !string.IsNullOrWhiteSpace(aiText.Thinking)) return; var aiBlock = request.ChatThread.Blocks @@ -399,8 +398,7 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes var aiText = state.ChatGenerationRequest.AIText; aiText.InitialRemoteWait = false; aiText.IsStreaming = true; - aiText.Text += contentStreamChunk; - aiText.Sources.MergeSources(contentStreamChunk.Sources); + aiText.ApplyStreamChunk(contentStreamChunk); if (state.Snapshot.Status is not AIJobStatus.RUNNING) { @@ -519,4 +517,4 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes logger.LogWarning("Skipping AI request because model '{ModelId}' is not available from '{ProviderInstanceName}' (provider={ProviderType}).", chatModel.Id, provider.InstanceName, provider.Provider); return false; } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs index e9c897eb..61add4be 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs @@ -28,6 +28,7 @@ public sealed class ToolCallingLoop(ILogger logger) : IToolCall var toolCallCount = 0; var toolResultCharacterCount = 0L; var toolSources = new List(); + var hasThinkingOutput = false; while (true) { @@ -47,6 +48,13 @@ public sealed class ToolCallingLoop(ILogger logger) : IToolCall toolSources.MergeSources(round.Sources); + if (!string.IsNullOrWhiteSpace(round.ThinkingOutput)) + { + var separator = hasThinkingOutput ? $"{Environment.NewLine}{Environment.NewLine}" : string.Empty; + yield return new ContentStreamChunk(string.Empty, $"{separator}{round.ThinkingOutput}", []); + hasThinkingOutput = true; + } + // // A call without an ID cannot be answered: the provider correlates the result by that // ID, and inventing one would have the next request rejected. Nothing can be salvaged @@ -168,4 +176,4 @@ public sealed class ToolCallingLoop(ILogger logger) : IToolCall private static string GetDisplayName(ToolCallingLoopContext context, string toolName) => context.RunnableTools .FirstOrDefault(tool => tool.Definition.Function.Name.Equals(toolName, StringComparison.Ordinal)) .Implementation?.GetDisplayName() ?? toolName; -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingRound.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingRound.cs index 7ec85734..7a1c961b 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingRound.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingRound.cs @@ -5,6 +5,7 @@ namespace AIStudio.Tools.ToolCallingSystem.Harness; /// longer depends on the provider API it came from. /// /// The text the model produced, empty when it only requested tool calls. +/// The human-readable thinking the provider exposed. /// The tool calls the model requested, empty when it answered instead. /// Sources the provider itself attached, such as those of a provider-native web search. -public sealed record ToolCallingRound(string TextOutput, IReadOnlyList Calls, IReadOnlyList Sources); \ No newline at end of file +public sealed record ToolCallingRound(string TextOutput, string ThinkingOutput, IReadOnlyList Calls, IReadOnlyList Sources); diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md index 192554b0..b8cdfbe4 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md @@ -8,6 +8,7 @@ - Added tools to the Assistant Builder. For a direct-chat launcher you pick them yourself, alongside the workspace, provider, and data sources. For an assistant, the AI chooses from the tools installed here and says so in the draft, so you see the decision before the assistant is written. - Added organization-wide management for tools. Among other options, IT departments can switch tools off entirely, disable individual ones, or define the provider trust a tool requires. You do not have to write any of it by hand: set a tool up in the app, then export its configuration as ready-made Lua code for your plugin, with encrypted API keys if you want them. - Added tool calling to the abilities you can state yourself in the expert provider settings. When you use a model AI Studio does not recognize as tool-capable, you can now declare that it is, the same way you already could for image input or reasoning. +- Added a Thinking section to AI answers when a model shares details about how it reached its result. The section stays collapsed until you choose to open it, updates while the model is working, and remains available when you reopen a saved chat. - Added local RAG as a beta feature, so the AI can answer from your own documents. You point AI Studio at a folder or at a single file, and it prepares those documents in the background so their contents can be found again later. Ask a question with such a data source selected, and AI Studio looks for the passages that fit your question and hands only those to the model, along with where each one came from. We will keep developing it together with the people who use it: to try it, open the app settings, allow preview features down to beta, and then enable the RAG feature. Many thanks to Paul Koudelka (`PaulKoudelka`) for around ten months of work on the concept and the implementation. - Added the setup for local data sources. You pick an embedding provider, and AI Studio asks for your confirmation before any document goes to a cloud service. It keeps up with your files as they change, shows the progress on a page of its own, and checks every document for hidden instructions before indexing it. Documents without readable text, such as scanned pages, are remembered as such, so AI Studio does not work through them again after every start — it comes back to them once they change. - Improved loading web content in the assistants: it now uses the same reader as the Read Web Page tool, which extracts the main content of a page more reliably and skips navigation and boilerplate. Pages from your own network, including local servers, keep working as before. When a page cannot be read, AI Studio now says why instead of leaving the field empty.