Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/MindWork AI Studio/Assistants/I18N/allTexts.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"

Expand Down
28 changes: 27 additions & 1 deletion app/MindWork AI Studio/Chat/ContentBlockComponent.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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;"/>
Expand All @@ -201,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
Expand Down
14 changes: 13 additions & 1 deletion app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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>
Expand Down Expand Up @@ -305,11 +310,14 @@ private int CreateRenderHash()
var textValue = text.Text;
hash.Add(textValue.Length);
hash.Add(textValue.GetHashCode(StringComparison.Ordinal));
hash.Add(text.Thinking.Length);
hash.Add(text.Thinking.GetHashCode(StringComparison.Ordinal));
hash.Add(text.Sources.Count);
hash.Add(text.ToolInvocations.Count);
hash.Add(text.ToolRuntimeStatus.IsRunning);
hash.Add(text.ToolRuntimeStatus.Message);
hash.Add(this.showToolTrace);
hash.Add(this.showThinking);
hash.Add(this.expandedToolInvocations.Count);
foreach (var expandedInvocation in this.expandedToolInvocations.Order())
hash.Add(expandedInvocation);
Expand Down Expand Up @@ -380,6 +388,10 @@ private string GetToolTraceTooltip()

private void ToggleToolTrace() => this.showToolTrace = !this.showToolTrace;

private void ToggleThinking() => this.showThinking = !this.showThinking;

private string GetThinkingTooltip() => this.showThinking ? this.T("Hide thinking") : this.T("Show thinking");

private bool IsToolInvocationExpanded(int order) => this.expandedToolInvocations.Contains(order);

private void ToggleToolInvocation(int order)
Expand Down Expand Up @@ -832,4 +844,4 @@ protected override async ValueTask DisposeResourcesAsync()

await this.DisposeMathContainerIfNeededAsync();
}
}
}
148 changes: 140 additions & 8 deletions app/MindWork AI Studio/Chat/ContentText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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 />
Expand Down Expand Up @@ -118,15 +127,18 @@ await Task.Run(async () =>
if (token.IsCancellationRequested)
break;

// Stop the waiting animation:
this.InitialRemoteWait = false;
this.IsStreaming = true;

// Add the response to the text:
this.Text += contentStreamChunk;
// Add the response to the content:
this.ApplyStreamChunk(contentStreamChunk);

// Merge the sources:
this.Sources.MergeSources(contentStreamChunk.Sources);
//
// Stop the waiting animation once the answer itself starts. A model which
// is still reasoning has not written anything to read yet, and an empty
// bubble would look like a finished, empty answer. The thinking section
// is available next to the animation the whole time.
//
this.InitialRemoteWait = this.Text.Length is 0;

// Notify the UI that the content has changed,
// depending on the energy saving mode:
Expand Down Expand Up @@ -160,7 +172,7 @@ await Task.Run(async () =>
}
finally
{
this.Text = this.Text.RemoveThinkTags().Trim();
this.FinalizeStreamContent();

// Inform the UI that the streaming is done:
await this.StreamingDone();
Expand Down Expand Up @@ -253,6 +265,7 @@ private async Task<bool> CheckSelectedModelAvailability(IProvider provider, Mode
public IContent DeepClone() => new ContentText
{
Text = this.Text,
Thinking = this.Thinking,
InitialRemoteWait = this.InitialRemoteWait,
IsStreaming = this.IsStreaming,
Sources = [..this.Sources],
Expand Down Expand Up @@ -407,4 +420,123 @@ public async Task<string> PrepareTextContentForAI()
/// 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,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"

Expand Down
16 changes: 15 additions & 1 deletion app/MindWork AI Studio/Provider/Anthropic/AnthropicResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ public string GetTextOutput() => string.Concat(this.Content
.Where(x => ReadString(x, "type").Equals("text", StringComparison.Ordinal))
.Select(x => ReadString(x, "text")));

/// <summary>
/// The human-readable thinking the model returned, with redacted blocks omitted.
/// </summary>
/// <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"))
.Where(x => !string.IsNullOrWhiteSpace(x)));

private static string ReadString(JsonElement item, string propertyName)
{
if (item.ValueKind is not JsonValueKind.Object ||
Expand All @@ -45,4 +59,4 @@ private static string ReadString(JsonElement item, string propertyName)

return property.GetString() ?? string.Empty;
}
}
}
Loading