From fc30b33daa8267ac6e1c303bc3373518eeace027 Mon Sep 17 00:00:00 2001 From: Peer Hogeterp <20603780+peerschuett@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:32:38 +0200 Subject: [PATCH] Polish per-answer thinking disclosure --- .../Chat/ContentBlockComponent.razor | 3 +- app/MindWork AI Studio/Chat/ContentText.cs | 10 ++++-- .../Provider/Anthropic/AnthropicResponse.cs | 13 ++++++-- .../Provider/Anthropic/ResponseStreamLine.cs | 4 +-- .../Provider/BaseProvider.cs | 9 ++++++ .../Provider/ContentStreamChunk.cs | 8 ++--- .../Provider/Fireworks/ResponseStreamLine.cs | 2 +- app/MindWork AI Studio/Provider/Model.cs | 18 ++++------- .../Provider/ModelReasoningBehavior.cs | 32 +++++++++++++++++++ .../OpenAI/ChatCompletionDeltaStreamLine.cs | 2 +- .../Provider/OpenAI/ProviderOpenAI.cs | 6 ++++ .../OpenAI/ResponsesDeltaStreamLine.cs | 25 ++++++++++++--- .../Provider/OpenAI/ResponsesResponse.cs | 14 +++++++- .../OpenAI/ResponsesToolCallingAdapter.cs | 15 ++++++--- .../Provider/OpenRouter/OpenRouterModel.cs | 10 ++---- .../OpenRouter/OpenRouterReasoning.cs | 12 +++++++ .../Provider/Perplexity/ResponseStreamLine.cs | 2 +- .../Settings/ProviderExtensions.Reasoning.cs | 10 ++---- .../Tools/AIJobs/AIJobService.cs | 4 ++- .../Harness/ToolCallingLoop.cs | 2 +- 20 files changed, 147 insertions(+), 54 deletions(-) create mode 100644 app/MindWork AI Studio/Provider/ModelReasoningBehavior.cs create mode 100644 app/MindWork AI Studio/Provider/OpenRouter/OpenRouterReasoning.cs diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor index 95b450b7..2649e17e 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor @@ -226,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/ContentText.cs b/app/MindWork AI Studio/Chat/ContentText.cs index c4f37315..814f217a 100644 --- a/app/MindWork AI Studio/Chat/ContentText.cs +++ b/app/MindWork AI Studio/Chat/ContentText.cs @@ -127,13 +127,19 @@ public sealed class ContentText : IContent if (token.IsCancellationRequested) break; - // Stop the waiting animation: - this.InitialRemoteWait = false; this.IsStreaming = true; // Add the response to the content: this.ApplyStreamChunk(contentStreamChunk); + // + // 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: var now = DateTimeOffset.Now; diff --git a/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs b/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs index c320c050..9c65b65a 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs @@ -39,9 +39,16 @@ public sealed record AnthropicResponse /// /// 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"))); + /// + /// 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) { diff --git a/app/MindWork AI Studio/Provider/Anthropic/ResponseStreamLine.cs b/app/MindWork AI Studio/Provider/Anthropic/ResponseStreamLine.cs index ef47ccbe..5f831353 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/ResponseStreamLine.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/ResponseStreamLine.cs @@ -16,8 +16,8 @@ public readonly record struct ResponseStreamLine(string Type, int Index, Delta D /// public ContentStreamChunk GetContent() => this.Delta.Type switch { - "thinking_delta" => new(string.Empty, this.Delta.Thinking, []), - _ => new(this.Delta.Text, string.Empty, []), + "thinking_delta" => new(string.Empty, [], this.Delta.Thinking), + _ => new(this.Delta.Text, []), }; #region Implementation of IAnnotationStreamLine diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 445228a4..07f4507b 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -1054,6 +1054,15 @@ public abstract class BaseProvider : IProvider, ISecretId """, StringComparison.InvariantCulture) || jsonData.StartsWith(""" {"type":"response.reasoning_text.delta" + """, StringComparison.InvariantCulture) || + + // + // Not a delta of its own: it announces the next part of a reasoning summary. + // The stream carries no separator between those parts, so the stream line + // turns this event into the paragraph break between them. + // + jsonData.StartsWith(""" + {"type":"response.reasoning_summary_part.added" """, StringComparison.InvariantCulture)) { TDelta? providerResponse; diff --git a/app/MindWork AI Studio/Provider/ContentStreamChunk.cs b/app/MindWork AI Studio/Provider/ContentStreamChunk.cs index a6c7347e..60cbebb7 100644 --- a/app/MindWork AI Studio/Provider/ContentStreamChunk.cs +++ b/app/MindWork AI Studio/Provider/ContentStreamChunk.cs @@ -4,14 +4,10 @@ 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, string Thinking, IList Sources) +/// The provider-exposed thinking content of the chunk. +public sealed record ContentStreamChunk(string Content, IList Sources, string Thinking = "") { - public ContentStreamChunk(string content, IList sources) : this(content, string.Empty, sources) - { - } - /// /// Implicit conversion to string. /// diff --git a/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs b/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs index a0869f3e..625308c6 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, this.Choices[0].Delta.Thinking, []); + public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, [], this.Choices[0].Delta.Thinking); #region Implementation of IAnnotationStreamLine diff --git a/app/MindWork AI Studio/Provider/Model.cs b/app/MindWork AI Studio/Provider/Model.cs index 5961a2a6..548ee574 100644 --- a/app/MindWork AI Studio/Provider/Model.cs +++ b/app/MindWork AI Studio/Provider/Model.cs @@ -25,6 +25,13 @@ public readonly record struct Model(string Id, string? DisplayName) /// /// 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; } @@ -80,14 +87,3 @@ public readonly record struct Model(string Id, string? DisplayName) #endregion } - -/// -/// Describes the default reasoning behavior reported by a provider's model catalog. -/// -public enum ModelReasoningBehavior -{ - UNKNOWN, - OPTIONAL, - DEFAULT_ON, - ALWAYS_ON, -} diff --git a/app/MindWork AI Studio/Provider/ModelReasoningBehavior.cs b/app/MindWork AI Studio/Provider/ModelReasoningBehavior.cs new file mode 100644 index 00000000..535edcf5 --- /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/ChatCompletionDeltaStreamLine.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs index 7b686ac3..32489bc7 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, this.Choices[0].Delta.Thinking, []); + public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, [], this.Choices[0].Delta.Thinking); #region Implementation of IAnnotationStreamLine diff --git a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs index 07ece606..7082ca6a 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs @@ -96,6 +96,11 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur // 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 sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur { 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 2a7f4777..b38778a5 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs @@ -5,20 +5,37 @@ 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() => 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()), + 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()), }; // diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs index f0266ea2..b2be4fb3 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs @@ -42,11 +42,23 @@ public sealed record ResponsesResponse })); } - public string GetThinkingOutput() => string.Concat(this.Output + /// + /// 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 f039a14b..6c0ca70a 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs @@ -11,7 +11,7 @@ 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 baseInput, IDictionary apiParameters, IList providerTools, +public sealed class ResponsesToolCallingAdapter(Model chatModel, bool isReasoningModel, IList baseInput, IDictionary apiParameters, IList providerTools, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools, Func> executeRequestAsync) : IToolCallingProviderAdapter { @@ -50,7 +50,7 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList b Stream = false, Store = false, Tools = includeTools ? this.effectiveProviderTools : [], - AdditionalApiParameters = IncludeEncryptedReasoning(apiParameters), + AdditionalApiParameters = isReasoningModel ? IncludeEncryptedReasoning(apiParameters) : apiParameters, }, token); if (response is null) @@ -99,7 +99,8 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList b /// /// /// Tool rounds use stateless Responses requests. OpenAI requires encrypted reasoning items in that mode - /// so that the complete output can be passed back with the tool result on the next round. + /// so that the complete output can be passed back with the tool result on the next round.

+ /// Only a reasoning model gets this, because the include is meaningless for every other one. ///
private static IDictionary IncludeEncryptedReasoning(IDictionary apiParameters) { @@ -111,11 +112,15 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList b return result; } + // + // Whatever the user put there stays. A value we cannot read as a list is kept as the + // single entry it appears to be: the API rejecting the user's own value is the honest + // outcome, while dropping it here would hide the mistake behind a request that works. + // var includedOutput = result[includeKey] switch { IEnumerable values => values.ToList(), - string value => new List { value }, - _ => [], + var value => [value], }; if (!includedOutput.Any(value => string.Equals(value as string, ENCRYPTED_REASONING_INCLUDE, StringComparison.Ordinal))) diff --git a/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs b/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs index c05d2475..012975fa 100644 --- a/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs +++ b/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs @@ -8,6 +8,9 @@ namespace AIStudio.Provider.OpenRouter; /// The model's provider-reported reasoning behavior. public readonly record struct OpenRouterModel(string Id, string? Name, OpenRouterReasoning? Reasoning) { + /// + /// Converts the catalog entry into the model the rest of the app works with. + /// public Model ToModel() => new(this.Id, this.Name) { ReasoningBehavior = this.Reasoning switch @@ -19,10 +22,3 @@ public readonly record struct OpenRouterModel(string Id, string? Name, OpenRoute }, }; } - -/// -/// The reasoning defaults returned for a model by OpenRouter's model catalog. -/// -/// Whether reasoning is enabled when the request does not configure it. -/// Whether reasoning cannot be disabled for the model. -public readonly record struct OpenRouterReasoning(bool DefaultEnabled, bool Mandatory); diff --git a/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterReasoning.cs b/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterReasoning.cs new file mode 100644 index 00000000..dcba875a --- /dev/null +++ b/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterReasoning.cs @@ -0,0 +1,12 @@ +namespace AIStudio.Provider.OpenRouter; + +/// +/// The reasoning defaults returned for a model by OpenRouter's model catalog. +/// +/// +/// The catalog reports more than this, such as the supported efforts and the default one. +/// We read only what decides the model's reasoning capability today. +/// +/// Whether reasoning is enabled when the request does not configure it. +/// Whether reasoning cannot be disabled for the model. +public readonly record struct OpenRouterReasoning(bool DefaultEnabled, bool Mandatory); diff --git a/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs b/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs index 318b9df3..68b4d2ec 100644 --- a/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs +++ b/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs @@ -15,7 +15,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, this.Choices[0].Delta.Thinking, this.GetSources()); + public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, this.GetSources(), this.Choices[0].Delta.Thinking); /// public bool ContainsSources() => this != default && this.SearchResults.Count > 0; diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs index c239f257..7ebfb89b 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs @@ -73,9 +73,7 @@ public static partial class ProviderExtensions return provider.UsedLLMProvider switch { - LLMProviders.OPEN_AI => MergeReasoningStates( - GetOpenAICompatibleReasoningState(parameters), - GetReasoningEffortState(parameters)), + LLMProviders.OPEN_AI => GetOpenAICompatibleReasoningState(parameters), LLMProviders.ANTHROPIC => GetAnthropicReasoningState(parameters), @@ -101,7 +99,6 @@ public static partial class ProviderExtensions LLMProviders.HELMHOLTZ or LLMProviders.GWDG => MergeReasoningStates( GetOpenAICompatibleReasoningState(parameters), - GetReasoningEffortState(parameters), GetQwenReasoningState(parameters), GetGoogleReasoningState(parameters)), @@ -119,14 +116,12 @@ public static partial class ProviderExtensions Host.VLLM => MergeReasoningStates( GetOpenAICompatibleReasoningState(parameters), - GetReasoningEffortState(parameters), GetVllmReasoningState(parameters), GetQwenReasoningState(parameters), GetGoogleReasoningState(parameters)), _ => MergeReasoningStates( GetOpenAICompatibleReasoningState(parameters), - GetReasoningEffortState(parameters), GetQwenReasoningState(parameters), GetGoogleReasoningState(parameters)), }, @@ -142,7 +137,8 @@ public static partial class ProviderExtensions /// The detected reasoning configuration state. /// /// OpenAI-compatible providers commonly use a nested reasoning object and/or - /// a top-level reasoning_effort parameter. + /// a top-level reasoning_effort parameter. Both are covered here, so a caller + /// does not merge on top of this one again. /// private static ReasoningConfigurationState GetOpenAICompatibleReasoningState(IDictionary parameters) { diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs index a3155ae1..5fee6065 100644 --- a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs @@ -396,10 +396,12 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes return false; var aiText = state.ChatGenerationRequest.AIText; - aiText.InitialRemoteWait = false; aiText.IsStreaming = true; aiText.ApplyStreamChunk(contentStreamChunk); + // The waiting animation stays until the answer itself starts, cf. ContentText: + aiText.InitialRemoteWait = aiText.Text.Length is 0; + if (state.Snapshot.Status is not AIJobStatus.RUNNING) { state.Snapshot = state.Snapshot with diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs index 61add4be..619e806b 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs @@ -51,7 +51,7 @@ public sealed class ToolCallingLoop(ILogger logger) : IToolCall if (!string.IsNullOrWhiteSpace(round.ThinkingOutput)) { var separator = hasThinkingOutput ? $"{Environment.NewLine}{Environment.NewLine}" : string.Empty; - yield return new ContentStreamChunk(string.Empty, $"{separator}{round.ThinkingOutput}", []); + yield return new ContentStreamChunk(string.Empty, [], $"{separator}{round.ThinkingOutput}"); hasThinkingOutput = true; }