diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor index 17158e45..25eb3930 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor @@ -96,16 +96,17 @@ } else { - var renderSegments = GetMarkdownRenderSegments(textContent.Text); - foreach (var segment in renderSegments) + var renderPlan = this.GetMarkdownRenderPlan(textContent.Text); + foreach (var segment in renderPlan.Segments) { + var segmentContent = segment.GetContent(renderPlan.Source); if (segment.Type is MarkdownRenderSegmentType.MARKDOWN) { - + } else { - + } } @if (textContent.Sources.Count > 0) diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs index 6fb826e7..e2c608fe 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs @@ -2,7 +2,6 @@ using AIStudio.Components; using AIStudio.Dialogs; using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; -using System.Text; namespace AIStudio.Chat; @@ -11,6 +10,14 @@ namespace AIStudio.Chat; /// public partial class ContentBlockComponent : MSGComponentBase { + private const string HTML_START_TAG = "<"; + private const string HTML_END_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 = [ " 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); if (string.IsNullOrWhiteSpace(normalized)) - return []; + return MarkdownRenderPlan.EMPTY; - var normalizedWithUnixLineEndings = normalized.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'); - var lines = normalizedWithUnixLineEndings.Split('\n'); - var markdownBuilder = new StringBuilder(); - var mathBuilder = new StringBuilder(); - + var normalizedSpan = normalized.AsSpan(); var segments = new List(); - string? activeCodeFenceMarker = null; + var activeCodeFenceMarker = '\0'; 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)) { - markdownBuilder.AppendLine(line); + lineStart = nextLineStart; continue; } - if (activeCodeFenceMarker is not null) + if (activeCodeFenceMarker != '\0') { - markdownBuilder.AppendLine(line); + lineStart = nextLineStart; continue; } - if (trimmedLine == "$$") + if (trimmedLine.SequenceEqual(MATH_BLOCK_MARKER.AsSpan())) { if (inMathBlock) { - segments.Add(new(MarkdownRenderSegmentType.MATH_BLOCK, mathBuilder.ToString().Trim('\r', '\n'))); - mathBuilder.Clear(); + var (start, end) = TrimLineBreaks(normalizedSpan, mathContentStart, lineStart); + segments.Add(new(MarkdownRenderSegmentType.MATH_BLOCK, start, end - start)); + + markdownSegmentStart = nextLineStart; inMathBlock = false; } else { - FlushMarkdownSegment(); + AddMarkdownSegment(markdownSegmentStart, lineStart); + mathContentStart = nextLineStart; inMathBlock = true; } - - continue; } - if (inMathBlock) - mathBuilder.AppendLine(line); - else - markdownBuilder.AppendLine(line); + lineStart = nextLineStart; } if (inMathBlock) - return [new(MarkdownRenderSegmentType.MARKDOWN, normalized)]; + return new(normalized, [new(MarkdownRenderSegmentType.MARKDOWN, 0, normalized.Length)]); - FlushMarkdownSegment(); - return segments.Count > 0 ? segments : [new(MarkdownRenderSegmentType.MARKDOWN, normalized)]; + AddMarkdownSegment(markdownSegmentStart, normalized.Length); + 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; - segments.Add(new(MarkdownRenderSegmentType.MARKDOWN, markdownBuilder.ToString())); - markdownBuilder.Clear(); + segments.Add(new(MarkdownRenderSegmentType.MARKDOWN, start, end - start)); } } private static string NormalizeMarkdownForRendering(string text) { - var cleaned = text.RemoveThinkTags().Trim(); - if (string.IsNullOrWhiteSpace(cleaned)) + var textWithoutThinkTags = text.RemoveThinkTags(); + var trimmed = TrimWhitespace(textWithoutThinkTags.AsSpan()); + if (trimmed.IsEmpty) 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; 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; } private static bool LooksLikeRawHtml(string text) { - var content = text.TrimStart(); - if (!content.StartsWith("<", StringComparison.Ordinal)) + 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)) return false; foreach (var marker in HTML_TAG_MARKERS) - if (content.Contains(marker, StringComparison.OrdinalIgnoreCase)) + if (content.IndexOf(marker.AsSpan(), StringComparison.OrdinalIgnoreCase) >= 0) return true; - return 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 trimmedLine, ref char activeCodeFenceMarker) { - string? fenceMarker = null; - if (trimmedLine.StartsWith("```", StringComparison.Ordinal)) - fenceMarker = "```"; - else if (trimmedLine.StartsWith("~~~", StringComparison.Ordinal)) - fenceMarker = "~~~"; + 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 is null) + if (fenceMarker == '\0') return false; - activeCodeFenceMarker = activeCodeFenceMarker is null + activeCodeFenceMarker = activeCodeFenceMarker == '\0' ? fenceMarker : activeCodeFenceMarker == fenceMarker - ? null + ? '\0' : activeCodeFenceMarker; return true; } + private static ReadOnlySpan TrimWhitespace(ReadOnlySpan 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.Empty : text[start..(end + 1)]; + } + + private static (int Start, int End) TrimLineBreaks(ReadOnlySpan 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 sealed record MarkdownRenderSegment(MarkdownRenderSegmentType Type, string Content); + private sealed record MarkdownRenderPlan(string Source, IReadOnlyList 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() { @@ -393,4 +485,4 @@ public partial class ContentBlockComponent : MSGComponentBase var result = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.Content.FileAttachments.ToHashSet()); this.Content.FileAttachments = result.ToList(); } -} \ No newline at end of file +}