mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 01:53:36 +00:00
Polish per-answer thinking disclosure
This commit is contained in:
parent
b28f2bcf6c
commit
fc30b33daa
@ -226,8 +226,9 @@
|
||||
}
|
||||
else if (this.Content.IsStreaming)
|
||||
{
|
||||
@* The think tags never reach the text: ContentText splits them off while streaming. *@
|
||||
<MudText Typo="Typo.body1" Style="white-space: pre-wrap;">
|
||||
@textContent.Text.RemoveThinkTags()
|
||||
@textContent.Text
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -39,9 +39,16 @@ public sealed record AnthropicResponse
|
||||
/// <summary>
|
||||
/// The human-readable thinking the model returned, with redacted blocks omitted.
|
||||
/// </summary>
|
||||
public string GetThinkingOutput() => string.Concat(this.Content
|
||||
/// <remarks>
|
||||
/// Each thinking block reads as its own paragraph, so they are joined as paragraphs
|
||||
/// rather than run into one another.
|
||||
/// </remarks>
|
||||
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")));
|
||||
.Select(x => ReadString(x, "thinking"))
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
|
||||
private static string ReadString(JsonElement item, string propertyName)
|
||||
{
|
||||
|
||||
@ -16,8 +16,8 @@ public readonly record struct ResponseStreamLine(string Type, int Index, Delta D
|
||||
/// <inheritdoc />
|
||||
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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -4,14 +4,10 @@ namespace AIStudio.Provider;
|
||||
/// A chunk of content from a content stream, along with its associated sources.
|
||||
/// </summary>
|
||||
/// <param name="Content">The text content of the chunk.</param>
|
||||
/// <param name="Thinking">The provider-exposed thinking content of the chunk.</param>
|
||||
/// <param name="Sources">The list of sources associated with the chunk.</param>
|
||||
public sealed record ContentStreamChunk(string Content, string Thinking, IList<ISource> Sources)
|
||||
/// <param name="Thinking">The provider-exposed thinking content of the chunk.</param>
|
||||
public sealed record ContentStreamChunk(string Content, IList<ISource> Sources, string Thinking = "")
|
||||
{
|
||||
public ContentStreamChunk(string content, IList<ISource> sources) : this(content, string.Empty, sources)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implicit conversion to string.
|
||||
/// </summary>
|
||||
|
||||
@ -14,7 +14,7 @@ public readonly record struct ResponseStreamLine(string Id, string Object, uint
|
||||
public bool ContainsContent() => this != default && this.Choices.Count > 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
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
|
||||
|
||||
|
||||
@ -25,6 +25,13 @@ public readonly record struct Model(string Id, string? DisplayName)
|
||||
/// <summary>
|
||||
/// The provider-reported default reasoning behavior, when the model catalog supplies it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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. <see cref="ModelReasoningBehavior.UNKNOWN"/>
|
||||
/// falls back to the model-name heuristics, which is also what every older settings file yields.
|
||||
/// </remarks>
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ModelReasoningBehavior ReasoningBehavior { get; init; }
|
||||
|
||||
@ -80,14 +87,3 @@ public readonly record struct Model(string Id, string? DisplayName)
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes the default reasoning behavior reported by a provider's model catalog.
|
||||
/// </summary>
|
||||
public enum ModelReasoningBehavior
|
||||
{
|
||||
UNKNOWN,
|
||||
OPTIONAL,
|
||||
DEFAULT_ON,
|
||||
ALWAYS_ON,
|
||||
}
|
||||
|
||||
32
app/MindWork AI Studio/Provider/ModelReasoningBehavior.cs
Normal file
32
app/MindWork AI Studio/Provider/ModelReasoningBehavior.cs
Normal file
@ -0,0 +1,32 @@
|
||||
namespace AIStudio.Provider;
|
||||
|
||||
/// <summary>
|
||||
/// Describes the default reasoning behavior reported by a provider's model catalog.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A catalog which reports this is more reliable than our model-name heuristics, so
|
||||
/// <see cref="Model.ReasoningBehavior"/> takes precedence over them. Not every provider
|
||||
/// reports it, which is what <see cref="UNKNOWN"/> stands for.
|
||||
/// </remarks>
|
||||
public enum ModelReasoningBehavior
|
||||
{
|
||||
/// <summary>
|
||||
/// The catalog said nothing about reasoning. The model-name heuristics decide.
|
||||
/// </summary>
|
||||
UNKNOWN,
|
||||
|
||||
/// <summary>
|
||||
/// The model can reason, but does not unless the request asks for it.
|
||||
/// </summary>
|
||||
OPTIONAL,
|
||||
|
||||
/// <summary>
|
||||
/// The model reasons unless the request switches it off.
|
||||
/// </summary>
|
||||
DEFAULT_ON,
|
||||
|
||||
/// <summary>
|
||||
/// The model always reasons and it cannot be switched off.
|
||||
/// </summary>
|
||||
ALWAYS_ON,
|
||||
}
|
||||
@ -19,7 +19,7 @@ public record ChatCompletionDeltaStreamLine(string Id, string Object, uint Creat
|
||||
public bool ContainsContent() => this.Choices.Count > 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
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
|
||||
|
||||
|
||||
@ -97,6 +97,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,
|
||||
|
||||
@ -5,20 +5,37 @@ namespace AIStudio.Provider.OpenAI;
|
||||
/// </summary>
|
||||
/// <param name="Type">The type of the response.</param>
|
||||
/// <param name="Delta">The delta content of the response.</param>
|
||||
/// <param name="SummaryIndex">The reasoning summary part this line belongs to.</param>
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// Whether this line starts a reasoning summary part which follows an earlier one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private bool IsFollowUpSummaryPart => this.Type is SUMMARY_PART_ADDED && this.SummaryIndex > 0;
|
||||
|
||||
#region Implementation of IResponseStreamLine
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ContainsContent() => this.Delta is not null;
|
||||
public bool ContainsContent() => this.Delta is not null || this.IsFollowUpSummaryPart;
|
||||
|
||||
/// <inheritdoc />
|
||||
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()),
|
||||
};
|
||||
|
||||
//
|
||||
|
||||
@ -42,11 +42,23 @@ public sealed record ResponsesResponse
|
||||
}));
|
||||
}
|
||||
|
||||
public string GetThinkingOutput() => string.Concat(this.Output
|
||||
/// <summary>
|
||||
/// The human-readable thinking the model returned.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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<string> parts) => string.Join(
|
||||
$"{Environment.NewLine}{Environment.NewLine}",
|
||||
parts.Where(part => !string.IsNullOrWhiteSpace(part)));
|
||||
|
||||
public IReadOnlyList<Source> GetSources() => this.Output
|
||||
.Where(x => ReadString(x, "type").Equals("message", StringComparison.Ordinal))
|
||||
.SelectMany(ReadContentItems)
|
||||
|
||||
@ -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.
|
||||
/// </remarks>
|
||||
public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> baseInput, IDictionary<string, object> apiParameters, IList<object> providerTools,
|
||||
public sealed class ResponsesToolCallingAdapter(Model chatModel, bool isReasoningModel, IList<object> baseInput, IDictionary<string, object> apiParameters, IList<object> providerTools,
|
||||
IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools,
|
||||
Func<ResponsesAPIRequest, CancellationToken, Task<ResponsesResponse?>> executeRequestAsync) : IToolCallingProviderAdapter
|
||||
{
|
||||
@ -50,7 +50,7 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> 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<object> b
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.<br/><br/>
|
||||
/// Only a reasoning model gets this, because the include is meaningless for every other one.
|
||||
/// </remarks>
|
||||
private static IDictionary<string, object> IncludeEncryptedReasoning(IDictionary<string, object> apiParameters)
|
||||
{
|
||||
@ -111,11 +112,15 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> 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<object> values => values.ToList(),
|
||||
string value => new List<object> { value },
|
||||
_ => [],
|
||||
var value => [value],
|
||||
};
|
||||
|
||||
if (!includedOutput.Any(value => string.Equals(value as string, ENCRYPTED_REASONING_INCLUDE, StringComparison.Ordinal)))
|
||||
|
||||
@ -8,6 +8,9 @@ namespace AIStudio.Provider.OpenRouter;
|
||||
/// <param name="Reasoning">The model's provider-reported reasoning behavior.</param>
|
||||
public readonly record struct OpenRouterModel(string Id, string? Name, OpenRouterReasoning? Reasoning)
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts the catalog entry into the model the rest of the app works with.
|
||||
/// </summary>
|
||||
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
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reasoning defaults returned for a model by OpenRouter's model catalog.
|
||||
/// </summary>
|
||||
/// <param name="DefaultEnabled">Whether reasoning is enabled when the request does not configure it.</param>
|
||||
/// <param name="Mandatory">Whether reasoning cannot be disabled for the model.</param>
|
||||
public readonly record struct OpenRouterReasoning(bool DefaultEnabled, bool Mandatory);
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
namespace AIStudio.Provider.OpenRouter;
|
||||
|
||||
/// <summary>
|
||||
/// The reasoning defaults returned for a model by OpenRouter's model catalog.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="DefaultEnabled">Whether reasoning is enabled when the request does not configure it.</param>
|
||||
/// <param name="Mandatory">Whether reasoning cannot be disabled for the model.</param>
|
||||
public readonly record struct OpenRouterReasoning(bool DefaultEnabled, bool Mandatory);
|
||||
@ -15,7 +15,7 @@ public readonly record struct ResponseStreamLine(string Id, string Object, uint
|
||||
public bool ContainsContent() => this != default && this.Choices.Count > 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ContainsSources() => this != default && this.SearchResults.Count > 0;
|
||||
|
||||
@ -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
|
||||
/// <returns>The detected reasoning configuration state.</returns>
|
||||
/// <remarks>
|
||||
/// OpenAI-compatible providers commonly use a nested <c>reasoning</c> object and/or
|
||||
/// a top-level <c>reasoning_effort</c> parameter.
|
||||
/// a top-level <c>reasoning_effort</c> parameter. Both are covered here, so a caller
|
||||
/// does not merge <see cref="GetReasoningEffortState"/> on top of this one again.
|
||||
/// </remarks>
|
||||
private static ReasoningConfigurationState GetOpenAICompatibleReasoningState(IDictionary<string, object> parameters)
|
||||
{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -51,7 +51,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> 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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user