Optimized by using spans

This commit is contained in:
Thorsten Sommer 2026-03-21 19:31:15 +01:00
parent 419c5a9d68
commit af2642e0d8
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
2 changed files with 147 additions and 54 deletions

View File

@ -96,16 +96,17 @@
} }
else else
{ {
var renderSegments = GetMarkdownRenderSegments(textContent.Text); var renderPlan = this.GetMarkdownRenderPlan(textContent.Text);
foreach (var segment in renderSegments) foreach (var segment in renderPlan.Segments)
{ {
var segmentContent = segment.GetContent(renderPlan.Source);
if (segment.Type is MarkdownRenderSegmentType.MARKDOWN) if (segment.Type is MarkdownRenderSegmentType.MARKDOWN)
{ {
<MudMarkdown Value="@segment.Content" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE" /> <MudMarkdown Value="@segmentContent" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE" />
} }
else else
{ {
<MathJaxBlock Value="@segment.Content" Class="mb-5" /> <MathJaxBlock Value="@segmentContent" Class="mb-5" />
} }
} }
@if (textContent.Sources.Count > 0) @if (textContent.Sources.Count > 0)

View File

@ -2,7 +2,6 @@ using AIStudio.Components;
using AIStudio.Dialogs; using AIStudio.Dialogs;
using AIStudio.Tools.Services; using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using System.Text;
namespace AIStudio.Chat; namespace AIStudio.Chat;
@ -11,6 +10,14 @@ namespace AIStudio.Chat;
/// </summary> /// </summary>
public partial class ContentBlockComponent : MSGComponentBase public partial class ContentBlockComponent : MSGComponentBase
{ {
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 = "$$";
private const string HTML_CODE_FENCE_PREFIX = "```html";
private static readonly string[] HTML_TAG_MARKERS = private static readonly string[] HTML_TAG_MARKERS =
[ [
"<!doctype", "<!doctype",
@ -83,6 +90,8 @@ public partial class ContentBlockComponent : MSGComponentBase
private bool HideContent { get; set; } private bool HideContent { get; set; }
private bool hasRenderHash; private bool hasRenderHash;
private int lastRenderHash; private int lastRenderHash;
private string cachedMarkdownRenderPlanInput = string.Empty;
private MarkdownRenderPlan cachedMarkdownRenderPlan = MarkdownRenderPlan.EMPTY;
#region Overrides of ComponentBase #region Overrides of ComponentBase
@ -195,131 +204,214 @@ public partial class ContentBlockComponent : MSGComponentBase
CodeBlock = { Theme = this.CodeColorPalette }, CodeBlock = { Theme = this.CodeColorPalette },
}; };
private static IReadOnlyList<MarkdownRenderSegment> GetMarkdownRenderSegments(string text) 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 static MarkdownRenderPlan BuildMarkdownRenderPlan(string text)
{ {
var normalized = NormalizeMarkdownForRendering(text); var normalized = NormalizeMarkdownForRendering(text);
if (string.IsNullOrWhiteSpace(normalized)) if (string.IsNullOrWhiteSpace(normalized))
return []; return MarkdownRenderPlan.EMPTY;
var normalizedWithUnixLineEndings = normalized.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'); var normalizedSpan = normalized.AsSpan();
var lines = normalizedWithUnixLineEndings.Split('\n');
var markdownBuilder = new StringBuilder();
var mathBuilder = new StringBuilder();
var segments = new List<MarkdownRenderSegment>(); var segments = new List<MarkdownRenderSegment>();
string? activeCodeFenceMarker = null; var activeCodeFenceMarker = '\0';
var inMathBlock = false; var inMathBlock = false;
var markdownSegmentStart = 0;
var mathContentStart = 0;
foreach (var line in lines) for (var lineStart = 0; lineStart < normalizedSpan.Length;)
{ {
var trimmedLine = line.Trim(); 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 (!inMathBlock && TryUpdateCodeFenceState(trimmedLine, ref activeCodeFenceMarker)) if (!inMathBlock && TryUpdateCodeFenceState(trimmedLine, ref activeCodeFenceMarker))
{ {
markdownBuilder.AppendLine(line); lineStart = nextLineStart;
continue; continue;
} }
if (activeCodeFenceMarker is not null) if (activeCodeFenceMarker != '\0')
{ {
markdownBuilder.AppendLine(line); lineStart = nextLineStart;
continue; continue;
} }
if (trimmedLine == "$$") if (trimmedLine.SequenceEqual(MATH_BLOCK_MARKER.AsSpan()))
{ {
if (inMathBlock) if (inMathBlock)
{ {
segments.Add(new(MarkdownRenderSegmentType.MATH_BLOCK, mathBuilder.ToString().Trim('\r', '\n'))); var (start, end) = TrimLineBreaks(normalizedSpan, mathContentStart, lineStart);
mathBuilder.Clear(); segments.Add(new(MarkdownRenderSegmentType.MATH_BLOCK, start, end - start));
markdownSegmentStart = nextLineStart;
inMathBlock = false; inMathBlock = false;
} }
else else
{ {
FlushMarkdownSegment(); AddMarkdownSegment(markdownSegmentStart, lineStart);
mathContentStart = nextLineStart;
inMathBlock = true; inMathBlock = true;
} }
continue;
} }
if (inMathBlock) lineStart = nextLineStart;
mathBuilder.AppendLine(line);
else
markdownBuilder.AppendLine(line);
} }
if (inMathBlock) if (inMathBlock)
return [new(MarkdownRenderSegmentType.MARKDOWN, normalized)]; return new(normalized, [new(MarkdownRenderSegmentType.MARKDOWN, 0, normalized.Length)]);
FlushMarkdownSegment(); AddMarkdownSegment(markdownSegmentStart, normalized.Length);
return segments.Count > 0 ? segments : [new(MarkdownRenderSegmentType.MARKDOWN, normalized)]; if (segments.Count == 0)
segments.Add(new(MarkdownRenderSegmentType.MARKDOWN, 0, normalized.Length));
void FlushMarkdownSegment() return new(normalized, segments);
void AddMarkdownSegment(int start, int end)
{ {
if (markdownBuilder.Length == 0) if (end <= start)
return; return;
segments.Add(new(MarkdownRenderSegmentType.MARKDOWN, markdownBuilder.ToString())); segments.Add(new(MarkdownRenderSegmentType.MARKDOWN, start, end - start));
markdownBuilder.Clear();
} }
} }
private static string NormalizeMarkdownForRendering(string text) private static string NormalizeMarkdownForRendering(string text)
{ {
var cleaned = text.RemoveThinkTags().Trim(); var textWithoutThinkTags = text.RemoveThinkTags();
if (string.IsNullOrWhiteSpace(cleaned)) var trimmed = TrimWhitespace(textWithoutThinkTags.AsSpan());
if (trimmed.IsEmpty)
return string.Empty; return string.Empty;
if (cleaned.Contains("```", StringComparison.Ordinal)) var cleaned = trimmed.Length == textWithoutThinkTags.Length
? textWithoutThinkTags
: trimmed.ToString();
if (cleaned.Contains(CODE_FENCE_MARKER_BACKTICK, StringComparison.Ordinal))
return cleaned; return cleaned;
if (LooksLikeRawHtml(cleaned)) if (LooksLikeRawHtml(cleaned))
return $"```html{Environment.NewLine}{cleaned}{Environment.NewLine}```"; return $"{HTML_CODE_FENCE_PREFIX}{Environment.NewLine}{cleaned}{Environment.NewLine}{CODE_FENCE_MARKER_BACKTICK}";
return cleaned; return cleaned;
} }
private static bool LooksLikeRawHtml(string text) private static bool LooksLikeRawHtml(string text)
{ {
var content = text.TrimStart(); var content = text.AsSpan();
if (!content.StartsWith("<", StringComparison.Ordinal)) var start = 0;
while (start < content.Length && char.IsWhiteSpace(content[start]))
start++;
content = content[start..];
if (!content.StartsWith(HTML_START_TAG.AsSpan(), StringComparison.Ordinal))
return false; return false;
foreach (var marker in HTML_TAG_MARKERS) foreach (var marker in HTML_TAG_MARKERS)
if (content.Contains(marker, StringComparison.OrdinalIgnoreCase)) if (content.IndexOf(marker.AsSpan(), StringComparison.OrdinalIgnoreCase) >= 0)
return true; return true;
return content.Contains("</", StringComparison.Ordinal) || content.Contains("/>", StringComparison.Ordinal); return content.IndexOf(HTML_END_TAG.AsSpan(), StringComparison.Ordinal) >= 0
|| content.IndexOf(HTML_SELF_CLOSING_TAG.AsSpan(), StringComparison.Ordinal) >= 0;
} }
private static bool TryUpdateCodeFenceState(string trimmedLine, ref string? activeCodeFenceMarker) private static bool TryUpdateCodeFenceState(ReadOnlySpan<char> trimmedLine, ref char activeCodeFenceMarker)
{ {
string? fenceMarker = null; var fenceMarker = '\0';
if (trimmedLine.StartsWith("```", StringComparison.Ordinal)) if (trimmedLine.StartsWith(CODE_FENCE_MARKER_BACKTICK.AsSpan(), StringComparison.Ordinal))
fenceMarker = "```"; fenceMarker = '`';
else if (trimmedLine.StartsWith("~~~", StringComparison.Ordinal)) else if (trimmedLine.StartsWith(CODE_FENCE_MARKER_TILDE.AsSpan(), StringComparison.Ordinal))
fenceMarker = "~~~"; fenceMarker = '~';
if (fenceMarker is null) if (fenceMarker == '\0')
return false; return false;
activeCodeFenceMarker = activeCodeFenceMarker is null activeCodeFenceMarker = activeCodeFenceMarker == '\0'
? fenceMarker ? fenceMarker
: activeCodeFenceMarker == fenceMarker : activeCodeFenceMarker == fenceMarker
? null ? '\0'
: activeCodeFenceMarker; : activeCodeFenceMarker;
return true; 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 private enum MarkdownRenderSegmentType
{ {
MARKDOWN, MARKDOWN,
MATH_BLOCK, MATH_BLOCK,
} }
private sealed record MarkdownRenderSegment(MarkdownRenderSegmentType Type, string Content); 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 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;
}
}
private async Task RemoveBlock() private async Task RemoveBlock()
{ {
@ -393,4 +485,4 @@ public partial class ContentBlockComponent : MSGComponentBase
var result = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.Content.FileAttachments.ToHashSet()); var result = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.Content.FileAttachments.ToHashSet());
this.Content.FileAttachments = result.ToList(); this.Content.FileAttachments = result.ToList();
} }
} }