Files
AI-Studio/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
T

800 lines
32 KiB
C#
Raw Normal View History

2025-04-27 09:06:05 +02:00
using AIStudio.Components;
using AIStudio.Dialogs;
using AIStudio.Tools.Services;
2026-09-04 15:48:07 +02:00
using AIStudio.Tools.ToolCallingSystem;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Chat;
2024-05-04 11:11:09 +02:00
/// <summary>
/// The UI component for a chat content block, i.e., for any IContent.
/// </summary>
public partial class ContentBlockComponent : MSGComponentBase
{
2026-03-21 20:34:11 +01:00
private const string CHAT_MATH_SYNC_FUNCTION = "chatMath.syncContainer";
private const string CHAT_MATH_DISPOSE_FUNCTION = "chatMath.disposeContainer";
/// <summary>
/// How many files of an answer the export menu lists one by one; beyond that, it groups them by kind.
/// </summary>
private const int MAX_FILES_WITHOUT_SUBMENUS = 5;
2024-05-04 11:11:09 +02:00
/// <summary>
/// The role of the chat content block.
/// </summary>
[Parameter]
public ChatRole Role { get; init; } = ChatRole.NONE;
/// <summary>
/// The content.
/// </summary>
[Parameter]
public IContent Content { get; init; } = new ContentText();
/// <summary>
/// The content type.
/// </summary>
[Parameter]
public ContentType Type { get; init; } = ContentType.NONE;
/// <summary>
/// When was the content created?
/// </summary>
[Parameter]
public DateTimeOffset Time { get; init; }
2024-07-14 21:46:17 +02:00
/// <summary>
/// Optional CSS classes.
/// </summary>
[Parameter]
public string Class { get; set; } = string.Empty;
[Parameter]
2025-03-16 21:17:06 +01:00
public bool IsLastContentBlock { get; set; }
[Parameter]
2025-03-16 21:17:06 +01:00
public bool IsSecondToLastBlock { get; set; }
2025-01-03 18:01:22 +01:00
[Parameter]
public Func<IContent, Task>? RemoveBlockFunc { get; set; }
2024-07-14 21:46:17 +02:00
[Parameter]
public Func<IContent, Task>? RegenerateFunc { get; set; }
[Parameter]
public Func<IContent, Task>? RollbackFunc { get; set; }
[Parameter]
public Func<IContent, Task>? EditLastBlockFunc { get; set; }
[Parameter]
public Func<IContent, Task>? EditLastUserBlockFunc { get; set; }
[Parameter]
public Func<bool> RegenerateEnabled { get; set; } = () => false;
2026-08-31 08:40:50 +02:00
/// <summary>
/// Whether the user may change the messages of the chat right now, i.e., remove, edit, regenerate, or roll back.
/// </summary>
/// <remarks>
/// The chat answers false while an answer is being written for it, since every change to a message
/// would collide with the answer. Whoever passes none of these actions needs no answer here.
/// </remarks>
[Parameter]
public Func<bool> BlockActionsEnabled { get; set; } = () => false;
2026-08-31 08:40:50 +02:00
/// <summary>
/// What the export offers, used both as the label of the export button and as the title of
/// the save dialog.
/// </summary>
/// <remarks>
/// Only AI blocks offer the export menu, so this always names something the AI produced. In the
/// chat that is its response, whereas in an assistant it is the result, and there the user sees
/// no chat at all. Whoever renders this block knows which of the two it is. Null falls back to
/// the chat wording. A code block of a message of the user is saved with the label of its
/// button as the title instead.
2026-08-31 08:40:50 +02:00
/// </remarks>
[Parameter]
public string? ExportTitle { get; set; }
2026-09-23 15:02:33 +02:00
/// <summary>
/// What an export of this block is named after, which the save dialog suggests as file name.
/// </summary>
/// <remarks>
/// In the chat that is the name of the chat, in an assistant whatever the assistant says its
/// result is about. Whoever renders this block knows which of the two it is. A table or a code
/// block with a heading above it is named after that heading instead. Null falls back to a
/// generic name.
/// </remarks>
[Parameter]
public string? ExportFileName { get; set; }
/// <summary>
/// Whether copying or exporting an AI answer of this block ends it with the notice that AI
/// generated it, when that is switched on.
/// </summary>
/// <remarks>
/// An assistant whose answer is meant for another model rather than for a reader, such as the
/// prompt optimizer, turns this off, the same way as for its "Copy result" button.
/// </remarks>
[Parameter]
public bool AddsAIGeneratedContentDisclosure { get; set; } = true;
/// <summary>
/// Sends the text of this block to the given assistant, or into a new chat.
/// </summary>
/// <remarks>
/// Null hides the menu for it. An assistant shows its result with this block as well, and it offers
/// sending the result through its own footer instead.
/// </remarks>
[Parameter]
public Func<IContent, Tools.Components, Task>? SendToFunc { get; set; }
/// <summary>
/// The assistants and the chat the menu offers, in its order, see AssistantLaunchService.GetDestinationsFor.
/// </summary>
/// <remarks>
/// Whoever passes SendToFunc passes them as well, so that a chat asks for them once for all of its blocks.
/// </remarks>
[Parameter]
public IReadOnlyList<Tools.Components> SendToDestinations { get; set; } = [];
2025-01-03 18:01:22 +01:00
[Inject]
private IDialogService DialogService { get; init; } = null!;
2024-05-04 11:11:09 +02:00
[Inject]
private RustService RustService { get; init; } = null!;
[Inject]
private AIGeneratedContentDisclosureService DisclosureService { get; init; } = null!;
2026-03-21 20:34:11 +01:00
[Inject]
private IJSRuntime JsRuntime { get; init; } = null!;
2026-08-31 08:40:50 +02:00
[Inject]
private ILogger<ContentBlockComponent> Logger { get; init; } = null!;
[Inject]
private PandocAvailabilityService PandocAvailability { get; init; } = null!;
2024-05-04 11:11:09 +02:00
private bool HideContent { get; set; }
2026-02-26 08:51:22 +01:00
private bool hasRenderHash;
private int lastRenderHash;
2026-03-21 20:34:11 +01:00
private string cachedMarkdownRenderPlanInput = string.Empty;
private MarkdownRenderPlan cachedMarkdownRenderPlan = MarkdownRenderPlan.EMPTY;
2026-09-23 15:02:33 +02:00
private string cachedMessageFilesInput = string.Empty;
private IReadOnlyList<MessageFile> cachedMessageFiles = [];
2026-08-31 08:40:50 +02:00
private char csvSeparator = ',';
2026-03-21 20:34:11 +01:00
private ElementReference mathContentContainer;
private SourcesList? sourcesList;
2026-03-21 20:34:11 +01:00
private string lastMathRenderSignature = string.Empty;
private bool hasActiveMathContainer;
private bool isDisposed;
2026-09-04 15:48:07 +02:00
private bool showToolTrace;
private readonly HashSet<int> expandedToolInvocations = [];
2024-05-04 11:11:09 +02:00
2026-08-31 08:40:50 +02:00
/// <summary>
/// Whether this block can be exported.
/// </summary>
/// <remarks>
/// We wait for the stream to finish: half an answer is nothing anybody wants in a document,
/// and waiting keeps us from searching for a text which still grows with every token. Only text
/// can be completely exported; an image, for example, has no representation our formats could write.
/// </remarks>
private bool CanExport => this.Content is { InitialRemoteWait: false, IsStreaming: false } && this.Content.TryGetMarkdownText(out _);
/// <summary>
/// Whether this block offers its answer for export through the export menu: entirely, and file by file.
/// </summary>
private bool CanExportAnswer => this.Role is ChatRole.AI && this.CanExport;
/// <summary>
/// Whether each code block of this block can be saved on its own, through a button at the block.
/// </summary>
/// <remarks>
/// Besides an answer, this holds for a message of the user: code the user sent weeks ago can be
/// got back that way after many changes to it, without copying it out of the chat.
/// </remarks>
private bool CanSaveCodeBlocks => this.Role is ChatRole.AI or ChatRole.USER && this.CanExport;
/// <summary>
/// Whether this block offers its text to an assistant or a new chat.
/// </summary>
private bool CanSendTo => this.SendToFunc is not null && this.SendToDestinations.Count > 0 && this.Role is ChatRole.AI or ChatRole.USER && this.Content is ContentText;
/// <summary>
/// Whether the text of this block can be sent right now.
/// </summary>
/// <remarks>
/// Like the export, the menu waits for the stream to finish: half an answer is nothing anybody wants
/// to continue with. Sending changes nothing in this chat, so an answer written meanwhile for another
/// block does not stand in the way.
/// </remarks>
private bool IsSendToEnabled => this.Content is ContentText { InitialRemoteWait: false, IsStreaming: false } text && !string.IsNullOrWhiteSpace(text.Text);
/// <summary>
/// Whether this block leaves AI Studio with the notice that AI generated it, when that is switched on.
/// </summary>
private bool GetsAIGeneratedContentDisclosure => this.Role is ChatRole.AI && this.AddsAIGeneratedContentDisclosure;
2026-08-31 08:40:50 +02:00
/// <summary>
/// The files this block holds, tables, code blocks, and formulas, so that the export menu can offer each of them.
2026-08-31 08:40:50 +02:00
/// </summary>
/// <remarks>
2026-09-23 15:02:33 +02:00
/// Cached the same way the Markdown render plan is: reading the files means parsing the whole
2026-08-31 08:40:50 +02:00
/// message, and a block re-renders for reasons which have nothing to do with its text, such as
/// switching the theme, which would parse every message of a long chat again.
///
/// The files are read from the text the render plan shows, not from the message as stored. A
/// code block the chat renders finds its file by where it stands in that text, and an answer of
/// raw HTML only becomes a code block there.
2026-08-31 08:40:50 +02:00
/// </remarks>
2026-09-23 15:02:33 +02:00
private IReadOnlyList<MessageFile> MessageFiles
2026-08-31 08:40:50 +02:00
{
get
{
if (!this.Content.TryGetMarkdownText(out var markdown))
return [];
2026-09-23 15:02:33 +02:00
if (ReferenceEquals(this.cachedMessageFilesInput, markdown) || string.Equals(this.cachedMessageFilesInput, markdown, StringComparison.Ordinal))
return this.cachedMessageFiles;
2026-08-31 08:40:50 +02:00
2026-09-23 15:02:33 +02:00
this.cachedMessageFilesInput = markdown;
this.cachedMessageFiles = PlainFileExport.ExtractFiles(this.GetMarkdownRenderPlan(markdown), this.csvSeparator);
2026-09-23 15:02:33 +02:00
return this.cachedMessageFiles;
2026-08-31 08:40:50 +02:00
}
}
/// <summary>
/// The files whose code blocks get a button to save them: all files of this block, as long as its
/// code blocks can be saved.
/// </summary>
/// <remarks>
/// In a long answer, the user would otherwise have to scroll from the code block up to the export
/// menu. The button saves the very file the menu offers for the block, under the same name.
/// </remarks>
private IReadOnlyList<MessageFile> FilesWithSaveButton => this.CanSaveCodeBlocks ? this.MessageFiles : [];
/// <summary>
/// Names the button which saves a file at its code block, e.g., "Save as Python (.py)".
/// </summary>
private string SaveAsLabel(MessageFile file) => string.Format(T("Save as {0}"), file.ToName());
2026-08-31 08:40:50 +02:00
/// <summary>
2026-09-23 15:02:33 +02:00
/// Names one file in the export menu.
2026-08-31 08:40:50 +02:00
/// </summary>
/// <remarks>
2026-09-23 15:02:33 +02:00
/// Tables and code blocks are named apart, just as they are counted apart. With a single file of
/// its kind its type alone says everything. As soon as an answer holds more than one, the user
2026-09-23 15:02:33 +02:00
/// has to be able to tell them apart: the heading above a file does that, unless it is missing
/// or two files of the kind share one. Then the type still does, as long as no other file of
/// the kind shares it, such as one block of Python next to one of Rust; otherwise we count them.
/// A code block always says that it is one, because the menu offers the entire answer as a web
/// page or a LaTeX document right below, and the two entries must not read alike.
2026-08-31 08:40:50 +02:00
/// </remarks>
2026-09-23 15:02:33 +02:00
private string ExportLabel(MessageFile file)
2026-08-31 08:40:50 +02:00
{
2026-09-23 15:02:33 +02:00
var isTable = file.Format.IsTabular();
var filesOfItsKind = this.MessageFiles.Where(entry => entry.Format.IsTabular() == isTable).ToList();
var extensionOrName = file.ToFileExtensionOrName();
var typeName = file.ToName();
2026-08-31 08:40:50 +02:00
//
// The caption is the heading the model wrote, so it already carries the language of the
// answer and needs no translation of ours. Only the fallback, where we have to count the
2026-09-23 15:02:33 +02:00
// files ourselves, is our own wording.
2026-08-31 08:40:50 +02:00
//
2026-09-23 15:02:33 +02:00
string name;
if (filesOfItsKind.Count < 2)
name = typeName;
2026-09-23 15:02:33 +02:00
else if (!string.IsNullOrWhiteSpace(file.Caption) && filesOfItsKind.Count(entry => string.Equals(entry.Caption, file.Caption, StringComparison.Ordinal)) is 1)
name = $"{file.Caption} ({extensionOrName})";
else if (filesOfItsKind.Count(entry => string.Equals(entry.ToName(), typeName, StringComparison.Ordinal)) is 1)
name = typeName;
2026-09-23 15:02:33 +02:00
else
return isTable
? string.Format(T("Table {0} ({1})"), file.Ordinal, extensionOrName)
: string.Format(T("Code block {0} ({1})"), file.Ordinal, extensionOrName);
2026-09-23 15:02:33 +02:00
return isTable ? name : string.Format(T("Code block: {0}"), name);
2026-08-31 08:40:50 +02:00
}
/// <summary>
/// The files this block holds by kind, tables first, for an answer with more files than the
/// export menu lists one by one.
/// </summary>
/// <remarks>
/// An answer with many code blocks, a tutorial for example, would otherwise push the formats
/// for the entire answer out of sight. Each submenu keeps the order of the answer, so "Code
/// block 3" is the third entry of its submenu as well.
/// </remarks>
private IEnumerable<IGrouping<bool, MessageFile>> MessageFilesByKind => this.MessageFiles.GroupBy(file => file.Format.IsTabular()).OrderByDescending(isTable => isTable.Key);
/// <summary>
/// Names the submenu of one kind of files in the export menu, with the number of its files.
/// </summary>
private string ExportGroupLabel(IGrouping<bool, MessageFile> filesOfAKind)
{
var count = filesOfAKind.Count().CompactCount();
return filesOfAKind.Key ? string.Format(T("Tables ({0})"), count) : string.Format(T("Code blocks ({0})"), count);
}
/// <summary>
/// Returns the icon of the submenu of one kind of files in the export menu.
/// </summary>
private static string ExportGroupIcon(IGrouping<bool, MessageFile> filesOfAKind) => filesOfAKind.Key ? FileExportFormat.CSV.ToIcon() : FileExportFormat.SOURCE_CODE.ToIcon();
2026-08-31 08:40:50 +02:00
/// <summary>
/// What the export offers, falling back to the chat wording when nobody named it.
/// </summary>
private string EffectiveExportTitle => this.ExportTitle ?? this.T("Export AI response");
2024-05-04 11:11:09 +02:00
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
2026-02-26 08:51:22 +01:00
this.RegisterStreamingEvents();
2024-05-04 11:11:09 +02:00
await base.OnInitializedAsync();
2026-08-31 08:40:50 +02:00
//
// Which separator a CSV needs depends on the language, and asking for the language means
// waiting for the settings. The first render therefore uses the comma we start with; once
// we know better, we ask for another render. Nobody can have opened the export menu in
// between, so no file is ever written with the wrong separator.
//
var languagePlugin = await this.SettingsManager.GetActiveLanguagePlugin();
var separator = CsvWriter.SeparatorFor(languagePlugin.IETFTag);
if (separator == this.csvSeparator)
return;
this.csvSeparator = separator;
2026-09-23 15:02:33 +02:00
this.cachedMessageFilesInput = string.Empty;
this.cachedMessageFiles = [];
2026-08-31 08:40:50 +02:00
await this.InvokeAsync(this.StateHasChanged);
2024-05-04 11:11:09 +02:00
}
2026-02-26 08:51:22 +01:00
protected override Task OnParametersSetAsync()
{
this.RegisterStreamingEvents();
return base.OnParametersSetAsync();
}
2026-03-21 20:34:11 +01:00
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await this.SyncMathRenderIfNeededAsync();
await base.OnAfterRenderAsync(firstRender);
}
2026-02-26 08:51:22 +01:00
/// <inheritdoc />
protected override bool ShouldRender()
{
var currentRenderHash = this.CreateRenderHash();
if (!this.hasRenderHash || currentRenderHash != this.lastRenderHash)
{
this.lastRenderHash = currentRenderHash;
this.hasRenderHash = true;
return true;
}
return false;
}
2024-05-04 11:11:09 +02:00
/// <summary>
/// Gets called when the content stream ended.
/// </summary>
private async Task AfterStreaming()
{
// Might be called from a different thread, so we need to invoke the UI thread:
2024-10-28 15:41:00 +01:00
await this.InvokeAsync(async () =>
2024-05-04 11:11:09 +02:00
{
//
// Issue we try to solve: When the content changes during streaming,
// Blazor might fail to see all changes made to the render tree.
// This happens mostly when Markdown code blocks are streamed.
//
// Hide the content for a short time:
this.HideContent = true;
// Let Blazor update the UI, i.e., to see the render tree diff:
this.StateHasChanged();
// Show the content again:
this.HideContent = false;
// Let Blazor update the UI, i.e., to see the render tree diff:
this.StateHasChanged();
2024-10-28 15:41:00 +01:00
// Inform the chat that the streaming is done:
await MessageBus.INSTANCE.SendMessage<bool>(this, Event.CHAT_STREAMING_DONE);
2024-05-04 11:11:09 +02:00
});
}
2026-02-26 08:51:22 +01:00
private void RegisterStreamingEvents()
{
this.Content.StreamingDone = this.AfterStreaming;
this.Content.StreamingEvent = () => this.InvokeAsync(this.StateHasChanged);
}
private int CreateRenderHash()
{
var hash = new HashCode();
hash.Add(this.Role);
hash.Add(this.Type);
hash.Add(this.Time);
hash.Add(this.Class);
hash.Add(this.IsLastContentBlock);
hash.Add(this.IsSecondToLastBlock);
hash.Add(this.HideContent);
hash.Add(this.SettingsManager.IsDarkMode);
hash.Add(this.RegenerateEnabled());
hash.Add(this.BlockActionsEnabled());
2026-02-26 08:51:22 +01:00
hash.Add(this.Content.InitialRemoteWait);
hash.Add(this.Content.IsStreaming);
hash.Add(this.Content.FileAttachments.Count);
hash.Add(this.Content.Sources.Count);
hash.Add(this.SendToFunc is not null);
hash.Add(this.SendToDestinations.Count);
foreach (var destination in this.SendToDestinations)
hash.Add(destination);
2026-02-26 08:51:22 +01:00
switch (this.Content)
{
case ContentText text:
var textValue = text.Text;
hash.Add(textValue.Length);
hash.Add(textValue.GetHashCode(StringComparison.Ordinal));
hash.Add(text.Sources.Count);
hash.Add(text.SourcePassages.Count);
2026-09-04 15:48:07 +02:00
hash.Add(text.ToolInvocations.Count);
2026-10-28 08:51:30 +01:00
hash.Add(text.Presentation?.Kind);
hash.Add(text.Presentation?.GetDataHashCode() ?? 0);
2026-09-04 15:48:07 +02:00
hash.Add(text.ToolRuntimeStatus.IsRunning);
hash.Add(text.ToolRuntimeStatus.Message);
hash.Add(this.showToolTrace);
hash.Add(this.expandedToolInvocations.Count);
foreach (var expandedInvocation in this.expandedToolInvocations.Order())
hash.Add(expandedInvocation);
foreach (var invocation in text.ToolInvocations)
{
hash.Add(invocation.Order);
hash.Add(invocation.ToolId);
hash.Add(invocation.Status);
hash.Add(invocation.StatusMessage);
hash.Add(invocation.Result);
hash.Add(invocation.JsonResult is not null);
hash.Add(invocation.Arguments.Count);
foreach (var argument in invocation.Arguments)
{
hash.Add(argument.Key);
hash.Add(argument.Value);
}
}
2026-02-26 08:51:22 +01:00
break;
case ContentImage image:
hash.Add(image.SourceType);
hash.Add(image.Source);
break;
}
return hash.ToHashCode();
}
2024-05-04 11:11:09 +02:00
#endregion
2024-07-14 21:46:17 +02:00
private string CardClasses => $"my-2 rounded-lg {this.Class}";
2025-01-02 15:24:15 +01:00
2026-09-04 15:48:07 +02:00
private bool HasToolTrace => this.Role is ChatRole.AI && this.GetToolInvocations().Count > 0;
private static Color GetTraceColor(ToolInvocationTraceStatus status) => status switch
{
ToolInvocationTraceStatus.SUCCESS => Color.Success,
ToolInvocationTraceStatus.ERROR => Color.Error,
ToolInvocationTraceStatus.BLOCKED => Color.Warning,
_ => Color.Default,
};
private string GetTraceStatusText(ToolInvocationTrace trace) => trace.Status switch
{
ToolInvocationTraceStatus.SUCCESS => this.T("Executed"),
ToolInvocationTraceStatus.ERROR => this.T("Failed"),
ToolInvocationTraceStatus.BLOCKED => this.T("Blocked"),
_ => this.T("Unknown"),
};
private IReadOnlyList<ToolInvocationTrace> GetToolInvocations() => this.Content is ContentText textContent
? textContent.ToolInvocations.OrderBy(x => x.Order).ToList()
: [];
private string GetToolTraceTooltip()
{
var invocations = this.GetToolInvocations();
return invocations.Count switch
{
0 => this.T("No tool calls"),
1 => string.Format(this.T("Show tool call for {0}"), invocations[0].ToolName),
_ => string.Format(this.T("Show {0} tool calls"), invocations.Count),
};
}
private void ToggleToolTrace() => this.showToolTrace = !this.showToolTrace;
private bool IsToolInvocationExpanded(int order) => this.expandedToolInvocations.Contains(order);
private void ToggleToolInvocation(int order)
{
if (!this.expandedToolInvocations.Add(order))
this.expandedToolInvocations.Remove(order);
}
private string GetToolInvocationResult(ToolInvocationTrace invocation) => string.IsNullOrWhiteSpace(invocation.Result)
? this.T("No result")
: invocation.Result;
2026-03-21 20:34:11 +01:00
private MarkdownRenderPlan GetMarkdownRenderPlan(string text)
{
if (ReferenceEquals(this.cachedMarkdownRenderPlanInput, text) || string.Equals(this.cachedMarkdownRenderPlanInput, text, StringComparison.Ordinal))
return this.cachedMarkdownRenderPlan;
this.cachedMarkdownRenderPlanInput = text;
this.cachedMarkdownRenderPlan = MarkdownRenderPlanner.Build(text);
2026-03-21 20:34:11 +01:00
return this.cachedMarkdownRenderPlan;
}
private async Task SyncMathRenderIfNeededAsync()
{
if (this.isDisposed)
return;
if (!this.TryGetCompletedMathRenderState(out var mathRenderSignature))
{
await this.DisposeMathContainerIfNeededAsync();
return;
}
if (string.Equals(this.lastMathRenderSignature, mathRenderSignature, StringComparison.Ordinal))
return;
//
// Remember what the browser shows only when it really got the call: otherwise, a call which was
// lost while the connection was down would make us skip the math rendering after the reconnect.
//
if (!await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, CHAT_MATH_SYNC_FUNCTION, this.mathContentContainer, mathRenderSignature))
return;
2026-03-21 20:34:11 +01:00
this.lastMathRenderSignature = mathRenderSignature;
this.hasActiveMathContainer = true;
}
private async Task DisposeMathContainerIfNeededAsync()
{
if (!this.hasActiveMathContainer)
{
this.lastMathRenderSignature = string.Empty;
return;
}
await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, CHAT_MATH_DISPOSE_FUNCTION, this.mathContentContainer);
2026-03-21 20:34:11 +01:00
this.hasActiveMathContainer = false;
this.lastMathRenderSignature = string.Empty;
}
private bool TryGetCompletedMathRenderState(out string mathRenderSignature)
{
mathRenderSignature = string.Empty;
if (this.HideContent || this.Type is not ContentType.TEXT || this.Content.IsStreaming || this.Content is not ContentText textContent || textContent.InitialRemoteWait)
return false;
var renderPlan = this.GetMarkdownRenderPlan(textContent.Text);
mathRenderSignature = CreateMathRenderSignature(renderPlan);
return !string.IsNullOrEmpty(mathRenderSignature);
}
private static string CreateMathRenderSignature(MarkdownRenderPlan renderPlan)
{
var hash = new HashCode();
var mathSegmentCount = 0;
foreach (var segment in renderPlan.Segments)
{
if (segment.Type is not MarkdownRenderSegmentType.MATH_BLOCK)
continue;
mathSegmentCount++;
hash.Add(segment.Start);
hash.Add(segment.Length);
hash.Add(segment.GetContent(renderPlan.Source).GetHashCode(StringComparison.Ordinal));
}
return mathSegmentCount == 0
? string.Empty
: $"{mathSegmentCount}:{hash.ToHashCode()}";
}
2025-01-03 18:01:22 +01:00
private async Task RemoveBlock()
{
if (this.RemoveBlockFunc is null || !this.BlockActionsEnabled())
2025-01-03 18:01:22 +01:00
return;
2026-10-31 10:48:20 +01:00
var remove = await this.DialogService.ShowMessageBoxAsync(
2025-04-27 09:06:05 +02:00
T("Remove Message"),
T("Do you really want to remove this message?"),
T("Yes, remove it"),
T("No, keep it"));
2025-01-03 18:01:22 +01:00
if (remove.HasValue && remove.Value)
await this.RemoveBlockFunc(this.Content);
}
private async Task SendTo(Tools.Components destination)
{
if (this.SendToFunc is null || !this.IsSendToEnabled)
return;
await this.SendToFunc(this.Content, destination);
}
2026-08-31 08:40:50 +02:00
/// <summary>
/// Exports the entire message, ending with the notice that AI generated it when that is switched on.
2026-08-31 08:40:50 +02:00
/// </summary>
private async Task ExportDocument(FileExportFormat format)
{
try
{
//
// The format itself knows who writes it, so we do not have to keep a list of formats
// here which would fall out of sync with the one in FileExportFormatExtensions.
//
var disclosureService = this.GetsAIGeneratedContentDisclosure ? this.DisclosureService : null;
2026-08-31 08:40:50 +02:00
if (format.UsesPandoc())
await PandocExport.ToDocument(this.RustService, this.PandocAvailability, disclosureService, this.EffectiveExportTitle, format, this.Content, this.ExportFileName);
else if (this.Content.TryGetExportMarkdown(out var markdown))
await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, format, disclosureService?.AddDisclosureToMarkdown(markdown) ?? markdown, this.ExportFileName);
2026-08-31 08:40:50 +02:00
}
catch (ArgumentOutOfRangeException e)
{
await this.ReportUnknownExportFormat(e, format);
}
}
/// <summary>
2026-09-23 15:02:33 +02:00
/// Exports one file out of the message, along with the sources the answer rests on wherever its
/// format has room for them.
2026-08-31 08:40:50 +02:00
/// </summary>
/// <remarks>
/// Unlike the entire message, the file never gets the notice that AI generated it. It is meant for
/// a program as often as for a reader, a table or a web page for instance, and one sentence more
/// would break most of them.
/// </remarks>
2026-09-23 15:02:33 +02:00
private async Task ExportFile(MessageFile file)
2026-08-31 08:40:50 +02:00
{
try
{
//
// A message of the user is no AI response, so the save dialog takes the label of the
// button at the code block as its title instead:
//
var dialogTitle = this.Role is ChatRole.AI ? this.EffectiveExportTitle : this.SaveAsLabel(file);
2026-09-23 15:02:33 +02:00
var fileName = string.IsNullOrWhiteSpace(file.Caption) ? this.ExportFileName : file.Caption;
await PlainFileExport.ToFile(this.RustService, dialogTitle, file, this.Content.ToExportContent(file), fileName);
2026-08-31 08:40:50 +02:00
}
catch (ArgumentOutOfRangeException e)
{
2026-09-23 15:02:33 +02:00
await this.ReportUnknownExportFormat(e, file.Format);
2026-08-31 08:40:50 +02:00
}
}
private async Task ReportUnknownExportFormat(ArgumentOutOfRangeException exception, FileExportFormat format)
{
2026-08-31 08:40:50 +02:00
await this.MessageBus.SendError(new(Icons.Material.Filled.Error, string.Format(this.T("Failed to export this message, because the file format '{0}' is unknown."), format)));
this.Logger.LogError(exception, "Failed to export the content, because no exporter writes the format {ExportFormat}.", format);
}
private async Task RegenerateBlock()
{
if (this.RegenerateFunc is null || !this.BlockActionsEnabled())
return;
if(this.Role is not ChatRole.AI)
return;
2026-10-31 10:48:20 +01:00
var regenerate = await this.DialogService.ShowMessageBoxAsync(
2025-04-27 09:06:05 +02:00
T("Regenerate Message"),
T("Do you really want to regenerate this message?"),
T("Yes, regenerate it"),
T("No, keep it"));
if (regenerate.HasValue && regenerate.Value)
await this.RegenerateFunc(this.Content);
}
private async Task RollbackBlock()
{
if (this.RollbackFunc is null || this.Role is not ChatRole.AI || !this.BlockActionsEnabled())
return;
2026-10-31 10:48:20 +01:00
var rollback = await this.DialogService.ShowMessageBoxAsync(
T("Roll Back Chat"),
T("Do you really want to roll back this chat to this AI response? All later messages and their attachments will be permanently removed."),
T("Yes, roll back the chat"),
T("No, keep it"));
if (rollback.HasValue && rollback.Value)
await this.RollbackFunc(this.Content);
}
private async Task EditLastBlock()
{
if (this.EditLastBlockFunc is null || !this.BlockActionsEnabled())
return;
if(this.Role is not ChatRole.USER)
return;
await this.EditLastBlockFunc(this.Content);
}
private async Task EditLastUserBlock()
{
if (this.EditLastUserBlockFunc is null || !this.BlockActionsEnabled())
return;
if(this.Role is not ChatRole.USER)
return;
2026-10-31 10:48:20 +01:00
var edit = await this.DialogService.ShowMessageBoxAsync(
2025-04-27 09:06:05 +02:00
T("Edit Message"),
T("Do you really want to edit this message? In order to edit this message, the AI response will be deleted."),
T("Yes, remove the AI response and edit it"),
T("No, keep it"));
if (edit.HasValue && edit.Value)
await this.EditLastUserBlockFunc(this.Content);
}
private async Task OpenAttachmentsDialog()
{
var result = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.Content.FileAttachments.ToHashSet());
this.Content.FileAttachments = [.. result];
}
2026-03-21 20:34:11 +01:00
/// <summary>
/// Whether the sources of this block stand below the answer, where the counter can take the reader.
/// </summary>
/// <remarks>
/// The same condition the block itself renders the list under. While an answer is still coming
/// in, its sources may already be known, but there is nothing on the page yet to scroll to --
/// so the counter says it cannot do anything rather than doing nothing when clicked.
/// </remarks>
private bool HasSourcesToShow => this.Content is { InitialRemoteWait: false, IsStreaming: false, Sources.Count: > 0 };
/// <summary>
/// Takes the reader from the source counter down to the sources themselves.
/// </summary>
private async Task ShowSources()
{
if (this.sourcesList is not null)
await this.sourcesList.ScrollIntoViewAsync();
}
protected override async ValueTask DisposeResourcesAsync()
2026-03-21 20:34:11 +01:00
{
if (this.isDisposed)
return;
this.isDisposed = true;
//
// Our handlers close over this component, while the content belongs to the chat thread and
// outlives us. We only detach what is still ours, though: when this content is streaming
// again, another component has registered its own handlers in the meantime.
//
if (this.Content.StreamingDone == this.AfterStreaming)
this.Content.ResetStreamingHandlers();
2026-03-21 20:34:11 +01:00
await this.DisposeMathContainerIfNeededAsync();
}
2026-08-31 08:40:50 +02:00
}