Add per-answer thinking disclosure

This commit is contained in:
Peer Hogeterp 2026-09-10 15:37:43 +02:00
parent c7b42bee96
commit bbbcdddc91
29 changed files with 360 additions and 37 deletions

View File

@ -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"

View File

@ -31,6 +31,23 @@
</MudButton>
</MudTooltip>
}
@if (this.HasThinking)
{
<MudTooltip Text="@this.GetThinkingTooltip()" Placement="Placement.Bottom">
<MudButton Variant="Variant.Outlined"
Color="Color.Default"
Size="Size.Small"
Class="px-2 py-1 rounded-pill"
Style="min-width:auto; border-width:1px; text-transform:none;"
OnClick="@this.ToggleThinking">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.Psychology" Color="Color.Default" Size="Size.Small" />
<MudText Typo="Typo.body2">@T("Thinking")</MudText>
<MudIcon Icon="@(this.showThinking ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" Size="Size.Small" />
</MudStack>
</MudButton>
</MudTooltip>
}
</MudStack>
</CardHeaderContent>
<CardHeaderActions>
@ -193,6 +210,14 @@
</MudPaper>
}
@if (this.HasThinking && this.showThinking)
{
<MudPaper Class="pa-3 mb-3 border rounded-lg" Style="border-width:1px;">
<MudText Typo="Typo.subtitle2" Class="mb-2">@T("Thinking")</MudText>
<MudText Typo="Typo.body2" Style="white-space: pre-wrap; overflow-wrap: anywhere;">@textContent.Thinking</MudText>
</MudPaper>
}
if (textContent.InitialRemoteWait)
{
<MudSkeleton Width="30%" Height="42px;"/>

View File

@ -127,6 +127,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private bool hasActiveMathContainer;
private bool isDisposed;
private bool showToolTrace;
private bool showThinking;
private readonly HashSet<int> expandedToolInvocations = [];
/// <summary>
@ -139,6 +140,10 @@ public partial class ContentBlockComponent : MSGComponentBase
/// </remarks>
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);
/// <summary>
/// The tables this block holds so that the export menu can offer each of them.
/// </summary>
@ -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();
}
}
}

View File

@ -16,6 +16,9 @@ namespace AIStudio.Chat;
/// </summary>
public sealed class ContentText : IContent
{
private const string OPEN_THINK_TAG = "<think>";
private const string CLOSE_THINK_TAG = "</think>";
private static readonly ILogger<ContentText> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ContentText>();
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ContentText).Namespace, nameof(ContentText));
@ -26,6 +29,12 @@ public sealed class ContentText : IContent
/// </summary>
private static readonly TimeSpan MIN_TIME = TimeSpan.FromSeconds(3);
[JsonIgnore]
private ThinkTagStreamState thinkTagStreamState;
[JsonIgnore]
private readonly StringBuilder thinkTagBuffer = new();
#region Implementation of IContent
/// <inheritdoc />
@ -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.
/// </summary>
public string Text { get; set; } = string.Empty;
}
/// <summary>
/// Human-readable thinking content exposed by the provider.
/// </summary>
public string Thinking { get; set; } = string.Empty;
/// <summary>
/// Applies one provider stream chunk to this content.
/// </summary>
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);
}
/// <summary>
/// Completes parsing of provider content and normalizes the displayed values.
/// </summary>
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,
}
}

View File

@ -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"

View File

@ -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"

View File

@ -36,6 +36,13 @@ public sealed record AnthropicResponse
.Where(x => ReadString(x, "type").Equals("text", StringComparison.Ordinal))
.Select(x => ReadString(x, "text")));
/// <summary>
/// The human-readable thinking the model returned, with redacted blocks omitted.
/// </summary>
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;
}
}
}

View File

@ -55,6 +55,7 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageB
this.lastResponse = response;
return new ToolCallingRound(
response.GetTextOutput(),
response.GetThinkingOutput(),
response.GetToolUses()
.Select(toolUse => new ToolCallingRequestedCall(
toolUse.Id,
@ -85,4 +86,4 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageB
Content = content,
IsError = isError,
});
}
}

View File

@ -6,4 +6,5 @@ namespace AIStudio.Provider.Anthropic;
/// </summary>
/// <param name="Type">The type of the delta.</param>
/// <param name="Text">The text of the delta.</param>
public readonly record struct Delta(string Type, string Text);
/// <param name="Thinking">The human-readable thinking delta.</param>
public readonly record struct Delta(string Type, string Text, string Thinking);

View File

@ -10,10 +10,15 @@ namespace AIStudio.Provider.Anthropic;
public readonly record struct ResponseStreamLine(string Type, int Index, Delta Delta) : IResponseStreamLine
{
/// <inheritdoc />
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));
/// <inheritdoc />
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<ISource> GetSources() => [];
#endregion
}
}

View File

@ -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;
}
}
}

View File

@ -4,13 +4,18 @@ 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, IList<ISource> Sources)
public sealed record ContentStreamChunk(string Content, string Thinking, IList<ISource> Sources)
{
public ContentStreamChunk(string content, IList<ISource> sources) : this(content, string.Empty, sources)
{
}
/// <summary>
/// Implicit conversion to string.
/// </summary>
/// <param name="chunk">The content stream chunk.</param>
/// <returns>The text content of the chunk.</returns>
public static implicit operator string(ContentStreamChunk chunk) => chunk.Content;
}
}

View File

@ -1,7 +1,17 @@
using System.Text.Json;
using AIStudio.Provider.OpenAI;
namespace AIStudio.Provider.Fireworks;
/// <summary>
/// The delta text of a choice.
/// </summary>
/// <param name="Content">The content of the delta text.</param>
public readonly record struct Delta(string Content);
/// <param name="ReasoningContent">OpenAI-compatible reasoning content.</param>
/// <param name="Reasoning">OpenRouter-compatible reasoning content.</param>
/// <param name="ReasoningDetails">Structured reasoning details.</param>
public readonly record struct Delta(string Content, string? ReasoningContent, string? Reasoning, IList<JsonElement>? ReasoningDetails)
{
public string Thinking => ThinkingContent.Get(this.ReasoningContent, this.Reasoning, this.ReasoningDetails);
}

View File

@ -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, []);
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<ISource> GetSources() => [];
#endregion
}
}

View File

@ -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<JsonElement>? ReasoningDetails { get; init; }
public IList<ChatCompletionToolCall> ToolCalls { get; init; } = [];
}

View File

@ -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<JsonElement>? ReasoningDetails { get; init; }
[JsonIgnore]
public string Thinking => ThinkingContent.Get(this.ReasoningContent, this.Reasoning, this.ReasoningDetails);
}

View File

@ -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, []);
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<ISource> GetSources() => [];
#endregion
}
}

View File

@ -15,5 +15,11 @@ public sealed record ChatCompletionResponseMessage
public string? ReasoningContent { get; init; }
public string? Reasoning { get; init; }
public IList<JsonElement>? ReasoningDetails { get; init; }
public IList<ChatCompletionToolCall?>? ToolCalls { get; init; }
public string GetThinkingOutput() => ThinkingContent.Get(this.ReasoningContent, this.Reasoning, this.ReasoningDetails);
}

View File

@ -72,6 +72,7 @@ public sealed class ChatCompletionToolCallingAdapter<TRequest>(
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<TRequest>(
{
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<TRequest>(
}
private readonly record struct PreparedChatCompletionToolCall(ChatCompletionToolCall ToolCall, bool IsValid);
}
}

View File

@ -15,7 +15,11 @@ public record ResponsesDeltaStreamLine(
public bool ContainsContent() => this.Delta is not null;
/// <inheritdoc />
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<ISource> GetSources() => [];
#endregion
}
}

View File

@ -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<Source> GetSources() => this.Output
.Where(x => ReadString(x, "type").Equals("message", StringComparison.Ordinal))
.SelectMany(ReadContentItems)

View File

@ -57,6 +57,7 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> 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<object> b
.Concat(runnableTools.Select(x => (object)ProviderToolAdapters.ToResponsesTool(x.Definition)))
.ToList();
}
}
}

View File

@ -0,0 +1,46 @@
using System.Text.Json;
namespace AIStudio.Provider.OpenAI;
/// <summary>
/// Reads human-readable thinking from OpenAI-compatible response fields.
/// </summary>
internal static class ThinkingContent
{
public static string Get(string? reasoningContent, string? reasoning, IEnumerable<JsonElement>? 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;
}
}

View File

@ -1,7 +1,17 @@
using System.Text.Json;
using AIStudio.Provider.OpenAI;
namespace AIStudio.Provider.Perplexity;
/// <summary>
/// The delta text of a choice.
/// </summary>
/// <param name="Content">The content of the delta text.</param>
public readonly record struct Delta(string Content);
/// <param name="ReasoningContent">OpenAI-compatible reasoning content.</param>
/// <param name="Reasoning">OpenRouter-compatible reasoning content.</param>
/// <param name="ReasoningDetails">Structured reasoning details.</param>
public readonly record struct Delta(string Content, string? ReasoningContent, string? Reasoning, IList<JsonElement>? ReasoningDetails)
{
public string Thinking => ThinkingContent.Get(this.ReasoningContent, this.Reasoning, this.ReasoningDetails);
}

View File

@ -15,11 +15,11 @@ 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.GetSources());
public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, this.Choices[0].Delta.Thinking, this.GetSources());
/// <inheritdoc />
public bool ContainsSources() => this != default && this.SearchResults.Count > 0;
/// <inheritdoc />
public IList<ISource> GetSources() => this.SearchResults.Cast<ISource>().ToList();
}
}

View File

@ -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;
}
}
}

View File

@ -28,6 +28,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
var toolCallCount = 0;
var toolResultCharacterCount = 0L;
var toolSources = new List<Source>();
var hasThinkingOutput = false;
while (true)
{
@ -47,6 +48,13 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> 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<ToolCallingLoop> 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;
}
}

View File

@ -5,6 +5,7 @@ namespace AIStudio.Tools.ToolCallingSystem.Harness;
/// longer depends on the provider API it came from.
/// </summary>
/// <param name="TextOutput">The text the model produced, empty when it only requested tool calls.</param>
/// <param name="ThinkingOutput">The human-readable thinking the provider exposed.</param>
/// <param name="Calls">The tool calls the model requested, empty when it answered instead.</param>
/// <param name="Sources">Sources the provider itself attached, such as those of a provider-native web search.</param>
public sealed record ToolCallingRound(string TextOutput, IReadOnlyList<ToolCallingRequestedCall> Calls, IReadOnlyList<ISource> Sources);
public sealed record ToolCallingRound(string TextOutput, string ThinkingOutput, IReadOnlyList<ToolCallingRequestedCall> Calls, IReadOnlyList<ISource> Sources);

View File

@ -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.