2025-04-27 09:06:05 +02:00
using AIStudio.Components ;
2025-12-18 15:00:27 +01:00
using AIStudio.Dialogs ;
2025-11-11 15:30:17 +01:00
using AIStudio.Tools.Services ;
2026-09-04 15:48:07 +02:00
using AIStudio.Tools.ToolCallingSystem ;
2024-04-22 18:56:18 +02:00
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>
2026-08-23 21:15:37 +02:00
public partial class ContentBlockComponent : MSGComponentBase
2024-04-22 18:56:18 +02:00
{
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" ;
2026-10-27 15:23:42 +01:00
/// <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 ;
2025-01-03 21:18:27 +01:00
[Parameter]
2025-03-16 21:17:06 +01:00
public bool IsLastContentBlock { get ; set ; }
2025-01-03 21:58:31 +01:00
[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
2025-01-03 21:18:27 +01:00
[Parameter]
public Func < IContent , Task >? RegenerateFunc { get ; set ; }
2026-09-23 10:44:56 +02:00
[Parameter]
public Func < IContent , Task >? RollbackFunc { get ; set ; }
2025-01-03 21:18:27 +01:00
2025-01-03 21:58:31 +01:00
[Parameter]
public Func < IContent , Task >? EditLastBlockFunc { get ; set ; }
[Parameter]
public Func < IContent , Task >? EditLastUserBlockFunc { get ; set ; }
2025-01-03 21:18:27 +01:00
[Parameter]
public Func < bool > RegenerateEnabled { get ; set ; } = () => false ;
2026-08-31 08:40:50 +02:00
2026-10-13 18:50:56 +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>
2026-09-23 10:44:56 +02:00
[Parameter]
2026-10-13 18:50:56 +02:00
public Func < bool > BlockActionsEnabled { get ; set ; } = () => false ;
2026-09-23 10:44:56 +02:00
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>
2026-10-27 15:23:42 +01:00
/// 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 ; }
2026-10-16 20:42:04 +02:00
/// <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 ;
2026-10-28 13:28:30 +01:00
/// <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
2025-11-11 15:30:17 +01:00
[Inject]
private RustService RustService { get ; init ; } = null !;
2026-10-16 20:42:04 +02:00
[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 ;
2026-09-15 19:11:39 +02:00
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 _ );
2026-10-27 15:23:42 +01:00
/// <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 ;
2026-10-28 13:28:30 +01:00
/// <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 );
2026-10-16 20:42:04 +02:00
/// <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>
2026-11-04 08:41:19 +01:00
/// 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.
2026-10-27 15:23:42 +01:00
///
/// 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 ;
2026-11-04 08:41:19 +01:00
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
}
}
2026-10-27 15:23:42 +01: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
2026-10-27 15:23:42 +01:00
/// 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
2026-10-27 15:23:42 +01:00
/// 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 ();
2026-10-27 15:23:42 +01:00
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 )
2026-10-27 15:23:42 +01:00
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 )
2026-10-27 15:23:42 +01:00
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
2026-10-27 15:23:42 +01:00
? 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
}
2026-10-27 15:23:42 +01: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 ());
2026-10-13 18:50:56 +02:00
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 );
2026-10-28 13:28:30 +01:00
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 );
2026-10-26 10:00:01 +01:00
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 ;
2026-11-04 08:41:19 +01:00
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 ;
2026-08-25 18:53:09 +02:00
//
// 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 ;
}
2026-08-25 18:53:09 +02:00
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 ()
{
2026-10-13 18:50:56 +02:00
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 );
}
2026-10-28 13:28:30 +01:00
private async Task SendTo ( Tools . Components destination )
{
if ( this . SendToFunc is null || ! this . IsSendToEnabled )
return ;
await this . SendToFunc ( this . Content , destination );
}
2025-01-03 21:18:27 +01:00
2026-08-31 08:40:50 +02:00
/// <summary>
2026-10-16 20:42:04 +02:00
/// 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.
//
2026-10-16 20:42:04 +02:00
var disclosureService = this . GetsAIGeneratedContentDisclosure ? this . DisclosureService : null ;
2026-08-31 08:40:50 +02:00
if ( format . UsesPandoc ())
2026-10-16 20:42:04 +02:00
await PandocExport . ToDocument ( this . RustService , this . PandocAvailability , disclosureService , this . EffectiveExportTitle , format , this . Content , this . ExportFileName );
2026-09-13 19:37:15 +02:00
else if ( this . Content . TryGetExportMarkdown ( out var markdown ))
2026-10-16 20:42:04 +02:00
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>
2026-10-16 20:42:04 +02:00
/// <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
{
2026-10-27 15:23:42 +01:00
//
// 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 ;
2026-10-27 15:23:42 +01:00
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 )
2025-11-11 15:30:17 +01:00
{
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 );
2025-11-11 15:30:17 +01:00
}
2025-01-03 21:18:27 +01:00
private async Task RegenerateBlock ()
{
2026-10-13 18:50:56 +02:00
if ( this . RegenerateFunc is null || ! this . BlockActionsEnabled ())
2025-01-03 21:18:27 +01:00
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" ));
2025-01-03 21:18:27 +01:00
if ( regenerate . HasValue && regenerate . Value )
await this . RegenerateFunc ( this . Content );
}
2026-09-23 10:44:56 +02:00
private async Task RollbackBlock ()
{
2026-10-13 18:50:56 +02:00
if ( this . RollbackFunc is null || this . Role is not ChatRole . AI || ! this . BlockActionsEnabled ())
2026-09-23 10:44:56 +02:00
return ;
2026-10-31 10:48:20 +01:00
var rollback = await this . DialogService . ShowMessageBoxAsync (
2026-09-23 10:44:56 +02:00
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 );
}
2025-01-03 21:58:31 +01:00
private async Task EditLastBlock ()
{
2026-10-13 18:50:56 +02:00
if ( this . EditLastBlockFunc is null || ! this . BlockActionsEnabled ())
2025-01-03 21:58:31 +01:00
return ;
if ( this . Role is not ChatRole . USER )
return ;
await this . EditLastBlockFunc ( this . Content );
}
private async Task EditLastUserBlock ()
{
2026-10-13 18:50:56 +02:00
if ( this . EditLastUserBlockFunc is null || ! this . BlockActionsEnabled ())
2025-01-03 21:58:31 +01:00
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" ));
2025-01-03 21:58:31 +01:00
if ( edit . HasValue && edit . Value )
await this . EditLastUserBlockFunc ( this . Content );
}
2025-11-11 15:30:17 +01:00
2025-12-18 15:00:27 +01:00
private async Task OpenAttachmentsDialog ()
{
var result = await ReviewAttachmentsDialog . OpenDialogAsync ( this . DialogService , this . Content . FileAttachments . ToHashSet ());
2026-08-23 21:15:37 +02:00
this . Content . FileAttachments = [.. result ];
2025-12-18 15:00:27 +01:00
}
2026-03-21 20:34:11 +01:00
2026-09-15 19:11:39 +02: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 ();
}
2026-08-23 21:15:37 +02:00
protected override async ValueTask DisposeResourcesAsync ()
2026-03-21 20:34:11 +01:00
{
if ( this . isDisposed )
return ;
this . isDisposed = true ;
2026-08-23 21:15:37 +02:00
//
// 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
}