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" ;
private const string HTML_START_TAG = "<" ;
private const string HTML_END_TAG = "</" ;
private const string HTML_SELF_CLOSING_TAG = "/>" ;
private const string CODE_FENCE_MARKER_BACKTICK = "```" ;
private const string CODE_FENCE_MARKER_TILDE = "~~~" ;
private const string MATH_BLOCK_MARKER_DOLLAR = "$$" ;
private const string MATH_BLOCK_MARKER_BRACKET_OPEN = """\[""" ;
private const string MATH_BLOCK_MARKER_BRACKET_CLOSE = """\]""" ;
private const string HTML_CODE_FENCE_PREFIX = "```html" ;
2026-02-26 08:51:22 +01:00
private static readonly string [] HTML_TAG_MARKERS =
[
"<!doctype",
"<html",
"<head",
"<body",
"<style",
"<script",
"<iframe",
"<svg",
] ;
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 ; }
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
/// <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 can be exported, 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.
/// </remarks>
[Parameter]
public string? ExportTitle { get ; set ; }
2025-01-03 21:18:27 +01:00
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-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-08-31 08:40:50 +02:00
private string cachedMessageTablesInput = string . Empty ;
private IReadOnlyList < MessageTable > cachedMessageTables = [];
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 _ );
/// <summary>
/// The tables this block holds so that the export menu can offer each of them.
/// </summary>
/// <remarks>
/// Cached the same way the Markdown render plan is: reading the tables means parsing the whole
/// 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.
/// </remarks>
private IReadOnlyList < MessageTable > MessageTables
{
get
{
if (! this . Content . TryGetMarkdownText ( out var markdown ))
return [];
if ( ReferenceEquals ( this . cachedMessageTablesInput , markdown ) || string . Equals ( this . cachedMessageTablesInput , markdown , StringComparison . Ordinal ))
return this . cachedMessageTables ;
this . cachedMessageTablesInput = markdown ;
this . cachedMessageTables = PlainFileExport . ExtractTables ( markdown , this . csvSeparator );
return this . cachedMessageTables ;
}
}
/// <summary>
/// Names one table in the export menu.
/// </summary>
/// <remarks>
/// With a single table the format alone says everything. As soon as an answer holds more than
/// one, the user has to be able to tell them apart: the heading above a table does that, unless
/// it is missing or two tables share one, and then we count them.
/// </remarks>
private string ExportLabel ( MessageTable table )
{
var tables = this . MessageTables ;
if ( tables . Count < 2 )
return table . Format . ToName ();
var captionIsTelling = ! string . IsNullOrWhiteSpace ( table . Caption )
&& tables . Where ( entry => entry . Ordinal != table . Ordinal ). All ( entry => ! string . Equals ( entry . Caption , table . Caption , StringComparison . Ordinal ));
//
// 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
// tables ourselves, is our own wording.
//
return captionIsTelling
? $"{table.Caption} ({table.Format.ToFileExtension()})"
: string . Format ( this . T ( "Table {0} ({1})" ), table . Ordinal , table . Format . ToFileExtension ());
}
/// <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 ;
this . cachedMessageTablesInput = string . Empty ;
this . cachedMessageTables = [];
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 . Content . InitialRemoteWait );
hash . Add ( this . Content . IsStreaming );
hash . Add ( this . Content . FileAttachments . Count );
hash . Add ( this . Content . Sources . Count );
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-09-04 15:48:07 +02:00
hash . Add ( text . ToolInvocations . Count );
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 ;
2025-01-02 15:24:15 +01:00
private CodeBlockTheme CodeColorPalette => this . SettingsManager . IsDarkMode ? CodeBlockTheme . Dark : CodeBlockTheme . Default ;
2025-06-27 10:31:36 +02:00
2026-09-04 15:48:07 +02:00
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 ;
2025-06-27 10:31:36 +02:00
private MudMarkdownStyling MarkdownStyling => new ()
{
CodeBlock = { Theme = this . CodeColorPalette },
};
2026-02-26 08:51:22 +01:00
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 = BuildMarkdownRenderPlan ( text );
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()}" ;
}
private static MarkdownRenderPlan BuildMarkdownRenderPlan ( string text )
{
var normalized = NormalizeMarkdownForRendering ( text );
if ( string . IsNullOrWhiteSpace ( normalized ))
return MarkdownRenderPlan . EMPTY ;
var normalizedSpan = normalized . AsSpan ();
var segments = new List < MarkdownRenderSegment >();
var activeCodeFenceMarker = '\0' ;
var activeMathBlockFenceType = MathBlockFenceType . NONE ;
var markdownSegmentStart = 0 ;
var mathContentStart = 0 ;
for ( var lineStart = 0 ; lineStart < normalizedSpan . Length ;)
{
var lineEnd = lineStart ;
while ( lineEnd < normalizedSpan . Length && normalizedSpan [ lineEnd ] is not '\r' and not '\n' )
lineEnd ++;
var nextLineStart = lineEnd ;
if ( nextLineStart < normalizedSpan . Length )
{
if ( normalizedSpan [ nextLineStart ] == '\r' )
nextLineStart ++;
if ( nextLineStart < normalizedSpan . Length && normalizedSpan [ nextLineStart ] == '\n' )
nextLineStart ++;
}
var trimmedLine = TrimWhitespace ( normalizedSpan [ lineStart .. lineEnd ]);
if ( activeMathBlockFenceType is MathBlockFenceType . NONE && TryUpdateCodeFenceState ( trimmedLine , ref activeCodeFenceMarker ))
{
lineStart = nextLineStart ;
continue ;
}
if ( activeCodeFenceMarker != '\0' )
{
lineStart = nextLineStart ;
continue ;
}
if ( activeMathBlockFenceType is MathBlockFenceType . NONE )
{
if ( trimmedLine . SequenceEqual ( MATH_BLOCK_MARKER_DOLLAR . AsSpan ()))
{
AddMarkdownSegment ( markdownSegmentStart , lineStart );
mathContentStart = nextLineStart ;
activeMathBlockFenceType = MathBlockFenceType . DOLLAR ;
lineStart = nextLineStart ;
continue ;
}
if ( trimmedLine . SequenceEqual ( MATH_BLOCK_MARKER_BRACKET_OPEN . AsSpan ()))
{
AddMarkdownSegment ( markdownSegmentStart , lineStart );
mathContentStart = nextLineStart ;
activeMathBlockFenceType = MathBlockFenceType . BRACKET ;
}
}
else if ( activeMathBlockFenceType is MathBlockFenceType . DOLLAR && trimmedLine . SequenceEqual ( MATH_BLOCK_MARKER_DOLLAR . AsSpan ()))
{
var ( start , end ) = TrimLineBreaks ( normalizedSpan , mathContentStart , lineStart );
segments . Add ( new ( MarkdownRenderSegmentType . MATH_BLOCK , start , end - start ));
markdownSegmentStart = nextLineStart ;
activeMathBlockFenceType = MathBlockFenceType . NONE ;
}
else if ( activeMathBlockFenceType is MathBlockFenceType . BRACKET && trimmedLine . SequenceEqual ( MATH_BLOCK_MARKER_BRACKET_CLOSE . AsSpan ()))
{
var ( start , end ) = TrimLineBreaks ( normalizedSpan , mathContentStart , lineStart );
segments . Add ( new ( MarkdownRenderSegmentType . MATH_BLOCK , start , end - start ));
markdownSegmentStart = nextLineStart ;
activeMathBlockFenceType = MathBlockFenceType . NONE ;
}
lineStart = nextLineStart ;
}
if ( activeMathBlockFenceType is not MathBlockFenceType . NONE )
return new ( normalized , [ new ( MarkdownRenderSegmentType . MARKDOWN , 0 , normalized . Length )]);
AddMarkdownSegment ( markdownSegmentStart , normalized . Length );
if ( segments . Count == 0 )
segments . Add ( new ( MarkdownRenderSegmentType . MARKDOWN , 0 , normalized . Length ));
return new ( normalized , segments );
void AddMarkdownSegment ( int start , int end )
{
if ( end <= start )
return ;
segments . Add ( new ( MarkdownRenderSegmentType . MARKDOWN , start , end - start ));
}
}
2026-02-26 08:51:22 +01:00
private static string NormalizeMarkdownForRendering ( string text )
{
2026-03-21 20:34:11 +01:00
var textWithoutThinkTags = text . RemoveThinkTags ();
var trimmed = TrimWhitespace ( textWithoutThinkTags . AsSpan ());
if ( trimmed . IsEmpty )
2026-02-26 08:51:22 +01:00
return string . Empty ;
2026-03-21 20:34:11 +01:00
var cleaned = trimmed . Length == textWithoutThinkTags . Length
? textWithoutThinkTags
: trimmed . ToString ();
if ( cleaned . Contains ( CODE_FENCE_MARKER_BACKTICK , StringComparison . Ordinal ))
2026-02-26 08:51:22 +01:00
return cleaned ;
if ( LooksLikeRawHtml ( cleaned ))
2026-03-21 20:34:11 +01:00
return $"{HTML_CODE_FENCE_PREFIX}{Environment.NewLine}{cleaned}{Environment.NewLine}{CODE_FENCE_MARKER_BACKTICK}" ;
2026-02-26 08:51:22 +01:00
return cleaned ;
}
private static bool LooksLikeRawHtml ( string text )
{
2026-03-21 20:34:11 +01:00
var content = text . AsSpan ();
var start = 0 ;
while ( start < content . Length && char . IsWhiteSpace ( content [ start ]))
start ++;
content = content [ start ..];
if (! content . StartsWith ( HTML_START_TAG . AsSpan (), StringComparison . Ordinal ))
2026-02-26 08:51:22 +01:00
return false ;
foreach ( var marker in HTML_TAG_MARKERS )
2026-03-21 20:34:11 +01:00
if ( content . IndexOf ( marker . AsSpan (), StringComparison . OrdinalIgnoreCase ) >= 0 )
2026-02-26 08:51:22 +01:00
return true ;
2026-03-21 20:34:11 +01:00
return content . IndexOf ( HTML_END_TAG . AsSpan (), StringComparison . Ordinal ) >= 0
|| content . IndexOf ( HTML_SELF_CLOSING_TAG . AsSpan (), StringComparison . Ordinal ) >= 0 ;
}
private static bool TryUpdateCodeFenceState ( ReadOnlySpan < char > trimmedLine , ref char activeCodeFenceMarker )
{
var fenceMarker = '\0' ;
if ( trimmedLine . StartsWith ( CODE_FENCE_MARKER_BACKTICK . AsSpan (), StringComparison . Ordinal ))
fenceMarker = '`' ;
else if ( trimmedLine . StartsWith ( CODE_FENCE_MARKER_TILDE . AsSpan (), StringComparison . Ordinal ))
fenceMarker = '~' ;
if ( fenceMarker == '\0' )
return false ;
activeCodeFenceMarker = activeCodeFenceMarker == '\0'
? fenceMarker
: activeCodeFenceMarker == fenceMarker
? '\0'
: activeCodeFenceMarker ;
return true ;
}
private static ReadOnlySpan < char > TrimWhitespace ( ReadOnlySpan < char > text )
{
var start = 0 ;
var end = text . Length - 1 ;
while ( start < text . Length && char . IsWhiteSpace ( text [ start ]))
start ++;
while ( end >= start && char . IsWhiteSpace ( text [ end ]))
end --;
return start > end ? ReadOnlySpan < char >. Empty : text [ start ..( end + 1 )];
}
private static ( int Start , int End ) TrimLineBreaks ( ReadOnlySpan < char > text , int start , int end )
{
while ( start < end && text [ start ] is '\r' or '\n' )
start ++;
while ( end > start && text [ end - 1 ] is '\r' or '\n' )
end --;
return ( start , end );
}
private enum MarkdownRenderSegmentType
{
MARKDOWN ,
MATH_BLOCK ,
}
private enum MathBlockFenceType
{
NONE ,
DOLLAR ,
BRACKET ,
}
private sealed record MarkdownRenderPlan ( string Source , IReadOnlyList < MarkdownRenderSegment > Segments )
{
public static readonly MarkdownRenderPlan EMPTY = new ( string . Empty , []);
}
private sealed class MarkdownRenderSegment ( MarkdownRenderSegmentType type , int start , int length )
{
private string? cachedContent ;
public MarkdownRenderSegmentType Type { get ; } = type ;
public int Start { get ; } = start ;
public int Length { get ; } = length ;
public int RenderKey { get ; } = HashCode . Combine ( type , start , length );
public string GetContent ( string source )
{
if ( this . cachedContent is not null )
return this . cachedContent ;
this . cachedContent = this . Start == 0 && this . Length == source . Length
? source
: source . Substring ( this . Start , this . Length );
return this . cachedContent ;
}
2026-02-26 08:51:22 +01:00
}
2025-01-03 18:01:22 +01:00
private async Task RemoveBlock ()
{
if ( this . RemoveBlockFunc is null )
return ;
var remove = await this . DialogService . ShowMessageBox (
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 );
}
2025-01-03 21:18:27 +01:00
2026-08-31 08:40:50 +02:00
/// <summary>
/// Exports the entire message.
/// </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.
//
if ( format . UsesPandoc ())
await PandocExport . ToDocument ( this . RustService , this . PandocAvailability , this . EffectiveExportTitle , format , this . Content );
2026-09-13 19:37:15 +02:00
else if ( this . Content . TryGetExportMarkdown ( out var markdown ))
2026-08-31 08:40:50 +02:00
await PlainFileExport . ToFile ( this . RustService , this . EffectiveExportTitle , format , markdown );
}
catch ( ArgumentOutOfRangeException e )
{
await this . ReportUnknownExportFormat ( e , format );
}
}
/// <summary>
/// Exports one table out of the message, exactly as the menu offered it.
/// </summary>
private async Task ExportTable ( MessageTable table )
{
try
{
await PlainFileExport . ToFile ( this . RustService , this . EffectiveExportTitle , table . Format , table . Content , table . Caption );
}
catch ( ArgumentOutOfRangeException e )
{
await this . ReportUnknownExportFormat ( e , table . Format );
}
}
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 ()
{
if ( this . RegenerateFunc is null )
return ;
if ( this . Role is not ChatRole . AI )
return ;
var regenerate = await this . DialogService . ShowMessageBox (
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 );
}
2025-01-03 21:58:31 +01:00
private async Task EditLastBlock ()
{
if ( this . EditLastBlockFunc is null )
return ;
if ( this . Role is not ChatRole . USER )
return ;
await this . EditLastBlockFunc ( this . Content );
}
private async Task EditLastUserBlock ()
{
if ( this . EditLastUserBlockFunc is null )
return ;
if ( this . Role is not ChatRole . USER )
return ;
var edit = await this . DialogService . ShowMessageBox (
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
}