Added formulas from LaTeX code blocks and buttons to copy and save formulas (#1154)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-04 08:41:19 +01:00
1 parent f4a86c2fb8
commit acc7d09fa2
18 files changed
+943 -314

No files matched your search

@@ -239,23 +239,23 @@
{
<div @ref="this.mathContentContainer" class="chat-math-container">
<ContentPresentationView Presentation="@textContent.Presentation">
@{ var renderPlan = this.GetMarkdownRenderPlan(textContent.Text); }
@{
var renderPlan = this.GetMarkdownRenderPlan(textContent.Text);
RenderFragment<MessageFile> saveButton = file =>
@<MudTooltip Text="@this.SaveAsLabel(file)" Placement="Placement.Top">
<MudIconButton Icon="@Icons.Material.Filled.Save" Color="Color.Primary" Variant="Variant.Filled" Class="ma-2" OnClick="@(() => this.ExportFile(file))"/>
</MudTooltip>;
}
@foreach (var segment in renderPlan.Segments)
{
var segmentContent = segment.GetContent(renderPlan.Source);
if (segment.Type is MarkdownRenderSegmentType.MARKDOWN)
{
<ExportableMarkdown @key="@segment.RenderKey" Value="@segmentContent" Props="Markdown.DefaultConfig" MarkdownPipeline="Markdown.CHAT_MARKDOWN_PIPELINE" Files="@this.FilesWithSaveButton" ValueStart="@segment.Start">
<CodeBlockActions Context="file">
<MudTooltip Text="@this.SaveAsLabel(file)" Placement="Placement.Top">
<MudIconButton Icon="@Icons.Material.Filled.Save" Color="Color.Primary" Variant="Variant.Filled" Class="ma-2" OnClick="@(() => this.ExportFile(file))"/>
</MudTooltip>
</CodeBlockActions>
</ExportableMarkdown>
<ExportableMarkdown @key="@segment.RenderKey" Value="@segmentContent" Props="Markdown.DefaultConfig" MarkdownPipeline="Markdown.CHAT_MARKDOWN_PIPELINE" Files="@this.FilesWithSaveButton" ValueStart="@segment.Start" CodeBlockActions="@saveButton"/>
}
else
{
<MathJaxBlock @key="@segment.RenderKey" Value="@segmentContent" Class="mb-5" />
<MathJaxBlock @key="@segment.RenderKey" Value="@segmentContent" Class="mb-5" File="@ExportableMarkdown.FileAt(this.FilesWithSaveButton, segment.FileStart)" Actions="@saveButton"/>
}
}
</ContentPresentationView>
@@ -13,33 +13,12 @@ public partial class ContentBlockComponent : MSGComponentBase
{
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";
/// <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;
private static readonly string[] HTML_TAG_MARKERS =
[
"<!doctype",
"<html",
"<head",
"<body",
"<style",
"<script",
"<iframe",
"<svg",
];
/// <summary>
/// The role of the chat content block.
/// </summary>
@@ -239,7 +218,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private bool GetsAIGeneratedContentDisclosure => this.Role is ChatRole.AI && this.AddsAIGeneratedContentDisclosure;
/// <summary>
/// The files this block holds, tables and code blocks, so that the export menu can offer each of them.
/// The files this block holds, tables, code blocks, and formulas, so that the export menu can offer each of them.
/// </summary>
/// <remarks>
/// Cached the same way the Markdown render plan is: reading the files means parsing the whole
@@ -261,7 +240,7 @@ public partial class ContentBlockComponent : MSGComponentBase
return this.cachedMessageFiles;
this.cachedMessageFilesInput = markdown;
this.cachedMessageFiles = PlainFileExport.ExtractFiles(this.GetMarkdownRenderPlan(markdown).Source, this.csvSeparator);
this.cachedMessageFiles = PlainFileExport.ExtractFiles(this.GetMarkdownRenderPlan(markdown), this.csvSeparator);
return this.cachedMessageFiles;
}
}
@@ -559,7 +538,7 @@ public partial class ContentBlockComponent : MSGComponentBase
return this.cachedMarkdownRenderPlan;
this.cachedMarkdownRenderPlanInput = text;
this.cachedMarkdownRenderPlan = BuildMarkdownRenderPlan(text);
this.cachedMarkdownRenderPlan = MarkdownRenderPlanner.Build(text);
return this.cachedMarkdownRenderPlan;
}
@@ -635,231 +614,6 @@ public partial class ContentBlockComponent : MSGComponentBase
: $"{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));
}
}
private static string NormalizeMarkdownForRendering(string text)
{
var textWithoutThinkTags = text.RemoveThinkTags();
var trimmed = TrimWhitespace(textWithoutThinkTags.AsSpan());
if (trimmed.IsEmpty)
return string.Empty;
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_CODE_FENCE_PREFIX}{Environment.NewLine}{cleaned}{Environment.NewLine}{CODE_FENCE_MARKER_BACKTICK}";
return cleaned;
}
private static bool LooksLikeRawHtml(string text)
{
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.IndexOf(marker.AsSpan(), StringComparison.OrdinalIgnoreCase) >= 0)
return true;
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;
}
}
private async Task RemoveBlock()
{
if (this.RemoveBlockFunc is null || !this.BlockActionsEnabled())
+227
View File
@@ -0,0 +1,227 @@
using System.Collections.Frozen;
namespace AIStudio.Chat;
/// <summary>
/// Finds the formula in a code block which a model marked as latex, tex, or math.
/// </summary>
/// <remarks>
/// Models often put a single formula into such a block instead of between $$. The chat shows it as a
/// formula then, as it shows one between $$. A block of LaTeX which holds more than a formula, such as
/// a document or text with formulas in it, stays code: MathJax would typeset its text as one formula,
/// without spaces and in italics. When in doubt, a block stays code, since code shows what the model
/// wrote, and a broken formula does not.
/// </remarks>
internal static class LatexFormula
{
private const string LATEX_LANGUAGE = "latex";
private const string TEX_LANGUAGE = "tex";
/// <summary>
/// The language behind the fence of a block which holds nothing but a formula, as GitHub reads it.
/// </summary>
private const string MATH_LANGUAGE = "math";
private const string BEGIN_ENVIRONMENT = @"\begin{";
/// <summary>
/// How many letters a word of text has at least: shorter ones, such as mc in E = mc^2, are
/// variables of a formula.
/// </summary>
private const int MIN_LETTERS_OF_A_WORD = 3;
/// <summary>
/// From how many words on LaTeX is text: a formula names its functions with commands, such as
/// \sin, and writes words inside braces, such as \text{if}.
/// </summary>
private const int MIN_WORDS_OF_TEXT = 2;
/// <summary>
/// Commands which belong to a document or to its text, never to a formula.
/// </summary>
private static readonly string[] TEXT_COMMANDS =
[
@"\documentclass",
@"\usepackage",
@"\chapter",
@"\section",
@"\subsection",
@"\subsubsection",
@"\paragraph",
@"\title",
@"\author",
@"\maketitle",
@"\input",
@"\include",
@"\bibliography",
@"\item",
@"\caption",
];
/// <summary>
/// The environments of formulas which MathJax typesets. Any other environment, such as document,
/// itemize, or tabular, belongs to a document.
/// </summary>
private static readonly FrozenSet<string> MATH_ENVIRONMENTS = FrozenSet.Create(StringComparer.Ordinal,
"align", "align*", "alignat", "alignat*", "aligned", "alignedat", "array", "Bmatrix", "bmatrix", "cases", "CD",
"eqnarray", "eqnarray*", "equation", "equation*", "flalign", "flalign*", "gather", "gather*", "gathered", "matrix",
"multline", "multline*", "pmatrix", "smallmatrix", "split", "subarray", "Vmatrix", "vmatrix");
/// <summary>
/// The markers a model sometimes puts around the formula inside the block, too.
/// </summary>
private static readonly Delimiters[] MATH_DELIMITERS =
[
new("$$", "$$"),
new(@"\[", @"\]"),
new(@"\(", @"\)"),
new("$", "$"),
];
/// <summary>
/// Finds the formula in a code block.
/// </summary>
/// <param name="fenceLanguage">The language behind the opening fence, for example latex.</param>
/// <param name="code">The content of the block.</param>
/// <param name="formula">Where the formula stands in the content, without the markers around it.</param>
/// <returns>True when the block holds nothing but a formula.</returns>
public static bool TryFind(ReadOnlySpan<char> fenceLanguage, ReadOnlySpan<char> code, out Range formula)
{
formula = default;
var isMath = fenceLanguage.Equals(MATH_LANGUAGE, StringComparison.OrdinalIgnoreCase);
if (!isMath && !fenceLanguage.Equals(LATEX_LANGUAGE, StringComparison.OrdinalIgnoreCase) && !fenceLanguage.Equals(TEX_LANGUAGE, StringComparison.OrdinalIgnoreCase))
return false;
var start = 0;
var end = code.Length;
Trim(code, ref start, ref end);
foreach (var delimiters in MATH_DELIMITERS)
{
var text = code[start..end];
if (text.Length < delimiters.Open.Length + delimiters.Close.Length || !text.StartsWith(delimiters.Open, StringComparison.Ordinal) || !text.EndsWith(delimiters.Close, StringComparison.Ordinal))
continue;
start += delimiters.Open.Length;
end -= delimiters.Close.Length;
Trim(code, ref start, ref end);
break;
}
if (start >= end || (!isMath && !IsFormulaOnly(code[start..end])))
return false;
formula = start..end;
return true;
}
/// <summary>
/// Checks whether LaTeX is a formula and nothing else.
/// </summary>
/// <param name="latex">The LaTeX, without the markers around a formula.</param>
/// <returns>True when MathJax can typeset it as one formula.</returns>
internal static bool IsFormulaOnly(ReadOnlySpan<char> latex)
{
//
// Markers inside mean text with formulas in it, and so does an empty line, which ends a paragraph:
//
if (latex.Contains('$') || latex.Contains(@"\(", StringComparison.Ordinal) || HasEmptyLine(latex) || HasWordsOfText(latex))
return false;
foreach (var command in TEXT_COMMANDS)
if (latex.Contains(command, StringComparison.Ordinal))
return false;
var environments = MATH_ENVIRONMENTS.GetAlternateLookup<ReadOnlySpan<char>>();
var rest = latex;
while (true)
{
var begin = rest.IndexOf(BEGIN_ENVIRONMENT, StringComparison.Ordinal);
if (begin < 0)
return true;
rest = rest[(begin + BEGIN_ENVIRONMENT.Length)..];
var nameEnd = rest.IndexOf('}');
if (nameEnd < 0 || !environments.Contains(rest[..nameEnd]))
return false;
rest = rest[(nameEnd + 1)..];
}
}
/// <summary>
/// Checks whether LaTeX holds words of text outside of braces, such as a sentence.
/// </summary>
private static bool HasWordsOfText(ReadOnlySpan<char> latex)
{
var depth = 0;
var words = 0;
for (var position = 0; position < latex.Length;)
{
var character = latex[position];
if (character is '\\')
{
//
// A command, such as \frac, or a character after a backslash, such as \{, is no word:
//
position++;
if (position < latex.Length && char.IsLetter(latex[position]))
position = EndOfWord(latex, position);
else
position++;
continue;
}
if (char.IsLetter(character))
{
var end = EndOfWord(latex, position);
if (depth is 0 && end - position >= MIN_LETTERS_OF_A_WORD && ++words >= MIN_WORDS_OF_TEXT)
return true;
position = end;
continue;
}
if (character is '{')
depth++;
else if (character is '}' && depth > 0)
depth--;
position++;
}
return false;
}
private static int EndOfWord(ReadOnlySpan<char> latex, int start)
{
var end = start;
while (end < latex.Length && char.IsLetter(latex[end]))
end++;
return end;
}
private static bool HasEmptyLine(ReadOnlySpan<char> latex)
{
foreach (var line in latex.EnumerateLines())
if (line.IsWhiteSpace())
return true;
return false;
}
private static void Trim(ReadOnlySpan<char> text, ref int start, ref int end)
{
while (start < end && char.IsWhiteSpace(text[start]))
start++;
while (end > start && char.IsWhiteSpace(text[end - 1]))
end--;
}
/// <summary>
/// The markers before and after a formula.
/// </summary>
private sealed record Delimiters(string Open, string Close);
}
@@ -0,0 +1,12 @@
namespace AIStudio.Chat;
/// <summary>
/// How the chat renders the text of a message: in parts, Markdown and the formulas between them, see
/// MarkdownRenderPlanner.
/// </summary>
/// <param name="Source">The text as the chat renders it. The parts refer to it.</param>
/// <param name="Segments">The parts, in the order of the text.</param>
internal sealed record MarkdownRenderPlan(string Source, IReadOnlyList<MarkdownRenderSegment> Segments)
{
public static readonly MarkdownRenderPlan EMPTY = new(string.Empty, []);
}
@@ -0,0 +1,265 @@
namespace AIStudio.Chat;
/// <summary>
/// Cuts the text of a message into the parts the chat renders one by one: Markdown, and the formulas
/// between $$ or \[ and \] on lines of their own, or in a code block of latex, tex, or math which holds
/// nothing but a formula, see LatexFormula.
/// </summary>
/// <remarks>
/// MudMarkdown shows a formula as it is, and MathJax typesets only what the chat hands it as a
/// formula block. A formula therefore has to leave the Markdown around it. Inside a code block, $$ is
/// code, not a formula, and a formula block the model never closed leaves the whole text as Markdown.
/// </remarks>
internal static class MarkdownRenderPlanner
{
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";
private static readonly string[] HTML_TAG_MARKERS =
[
"<!doctype",
"<html",
"<head",
"<body",
"<style",
"<script",
"<iframe",
"<svg",
];
/// <summary>
/// Cuts a text into the parts the chat renders.
/// </summary>
/// <param name="text">The text of a message, as stored.</param>
/// <returns>The plan, or MarkdownRenderPlan.EMPTY when the text shows nothing.</returns>
public static MarkdownRenderPlan Build(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;
var mathBlockStart = 0;
var codeFenceStart = 0;
var codeContentStart = 0;
var codeFenceLanguage = ReadOnlySpan<char>.Empty;
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]);
var wasInCodeBlock = activeCodeFenceMarker != '\0';
if (activeMathBlockFenceType is MathBlockFenceType.NONE && TryUpdateCodeFenceState(trimmedLine, ref activeCodeFenceMarker))
{
if (!wasInCodeBlock)
{
codeFenceStart = lineStart;
codeContentStart = nextLineStart;
codeFenceLanguage = FenceLanguageOf(trimmedLine);
}
else if (activeCodeFenceMarker == '\0' && LatexFormula.TryFind(codeFenceLanguage, normalizedSpan[codeContentStart..lineStart], out var formula))
{
var (formulaStart, formulaLength) = formula.GetOffsetAndLength(lineStart - codeContentStart);
AddMarkdownSegment(markdownSegmentStart, codeFenceStart);
segments.Add(new(MarkdownRenderSegmentType.MATH_BLOCK, codeContentStart + formulaStart, formulaLength, codeFenceStart));
markdownSegmentStart = nextLineStart;
}
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);
mathBlockStart = lineStart;
mathContentStart = nextLineStart;
activeMathBlockFenceType = MathBlockFenceType.DOLLAR;
lineStart = nextLineStart;
continue;
}
if (trimmedLine.SequenceEqual(MATH_BLOCK_MARKER_BRACKET_OPEN.AsSpan()))
{
AddMarkdownSegment(markdownSegmentStart, lineStart);
mathBlockStart = lineStart;
mathContentStart = nextLineStart;
activeMathBlockFenceType = MathBlockFenceType.BRACKET;
}
}
else if (activeMathBlockFenceType is MathBlockFenceType.DOLLAR && trimmedLine.SequenceEqual(MATH_BLOCK_MARKER_DOLLAR.AsSpan()))
{
segments.Add(MathSegment(normalizedSpan, mathContentStart, lineStart, mathBlockStart));
markdownSegmentStart = nextLineStart;
activeMathBlockFenceType = MathBlockFenceType.NONE;
}
else if (activeMathBlockFenceType is MathBlockFenceType.BRACKET && trimmedLine.SequenceEqual(MATH_BLOCK_MARKER_BRACKET_CLOSE.AsSpan()))
{
segments.Add(MathSegment(normalizedSpan, mathContentStart, lineStart, mathBlockStart));
markdownSegmentStart = nextLineStart;
activeMathBlockFenceType = MathBlockFenceType.NONE;
}
lineStart = nextLineStart;
}
if (activeMathBlockFenceType is not MathBlockFenceType.NONE)
return new(normalized, [new(MarkdownRenderSegmentType.MARKDOWN, 0, normalized.Length, 0)]);
AddMarkdownSegment(markdownSegmentStart, normalized.Length);
if (segments.Count == 0)
segments.Add(new(MarkdownRenderSegmentType.MARKDOWN, 0, normalized.Length, 0));
return new(normalized, segments);
void AddMarkdownSegment(int start, int end)
{
if (end <= start)
return;
segments.Add(new(MarkdownRenderSegmentType.MARKDOWN, start, end - start, start));
}
}
private static string NormalizeMarkdownForRendering(string text)
{
var textWithoutThinkTags = text.RemoveThinkTags();
var trimmed = TrimWhitespace(textWithoutThinkTags.AsSpan());
if (trimmed.IsEmpty)
return string.Empty;
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_CODE_FENCE_PREFIX}{Environment.NewLine}{cleaned}{Environment.NewLine}{CODE_FENCE_MARKER_BACKTICK}";
return cleaned;
}
private static bool LooksLikeRawHtml(string text)
{
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.IndexOf(marker.AsSpan(), StringComparison.OrdinalIgnoreCase) >= 0)
return true;
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)];
}
/// <summary>
/// Reads the language behind an opening fence, such as latex in ```latex.
/// </summary>
private static ReadOnlySpan<char> FenceLanguageOf(ReadOnlySpan<char> fence)
{
var info = TrimWhitespace(fence.TrimStart(fence[0]));
var end = 0;
while (end < info.Length && !char.IsWhiteSpace(info[end]))
end++;
return info[..end];
}
/// <summary>
/// The part of a formula block between its markers, without the line breaks around it.
/// </summary>
private static MarkdownRenderSegment MathSegment(ReadOnlySpan<char> text, int start, int end, int blockStart)
{
while (start < end && text[start] is '\r' or '\n')
start++;
while (end > start && text[end - 1] is '\r' or '\n')
end--;
return new(MarkdownRenderSegmentType.MATH_BLOCK, start, end - start, blockStart);
}
private enum MathBlockFenceType
{
NONE,
DOLLAR,
BRACKET,
}
}
@@ -0,0 +1,36 @@
namespace AIStudio.Chat;
/// <summary>
/// A part of a message which the chat renders on its own, see MarkdownRenderPlan.
/// </summary>
/// <param name="type">What the part is.</param>
/// <param name="start">Where the part starts in the source of its plan.</param>
/// <param name="length">How long the part is.</param>
/// <param name="fileStart">Where the block of the part starts in the source, its markers included,
/// such as the $$ before a formula. A formula finds its file by it, see PlainFileExport.ExtractFiles.</param>
internal sealed class MarkdownRenderSegment(MarkdownRenderSegmentType type, int start, int length, int fileStart)
{
private string? cachedContent;
public MarkdownRenderSegmentType Type { get; } = type;
public int Start { get; } = start;
public int Length { get; } = length;
public int FileStart { get; } = fileStart;
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;
}
}
@@ -0,0 +1,17 @@
namespace AIStudio.Chat;
/// <summary>
/// What a part of a message is, see MarkdownRenderPlan.
/// </summary>
internal enum MarkdownRenderSegmentType
{
/// <summary>
/// Markdown, which the chat renders with ExportableMarkdown.
/// </summary>
MARKDOWN,
/// <summary>
/// A formula of its own, which the chat renders with MathJaxBlock.
/// </summary>
MATH_BLOCK,
}
+12 -2
View File
@@ -1,5 +1,15 @@
@namespace AIStudio.Chat
@using AIStudio.Components
<div class="@this.RootClass" data-chat-math-block="true" style="white-space: pre-wrap;">
@this.MathText
<div class="@this.RootClass">
<div class="chat-mathjax-block" data-chat-math-block="true" style="white-space: pre-wrap;">
@this.MathText
</div>
<div class="formula-block-actions">
@if (this.File is not null && this.Actions is not null)
{
@this.Actions(this.File)
}
<CodeBlockCopyButton Text="@this.Value"/>
</div>
</div>
@@ -2,17 +2,41 @@ using Microsoft.AspNetCore.Components;
namespace AIStudio.Chat;
/// <summary>
/// A formula of its own in the chat, which MathJax typesets, with a button to copy it and the actions
/// of its file, such as a button to save it.
/// </summary>
/// <remarks>
/// The buttons stand next to the element MathJax typesets, not inside it: MathJax replaces the content
/// of that element, see chat-math.js.
/// </remarks>
public partial class MathJaxBlock
{
/// <summary>
/// The formula, without the markers around it. The copy button copies it like this.
/// </summary>
[Parameter]
public string Value { get; init; } = string.Empty;
[Parameter]
public string Class { get; init; } = string.Empty;
/// <summary>
/// The file of the formula, as PlainFileExport.ExtractFiles read it, or null when the formula
/// cannot be saved, for example in a message whose code blocks cannot be saved either.
/// </summary>
[Parameter]
public MessageFile? File { get; init; }
/// <summary>
/// What to show next to the copy button for the file, for example a button to save it.
/// </summary>
[Parameter]
public RenderFragment<MessageFile>? Actions { get; init; }
private string RootClass => string.IsNullOrWhiteSpace(this.Class)
? "chat-mathjax-block"
: $"chat-mathjax-block {this.Class}";
? "formula-block"
: $"formula-block {this.Class}";
private string MathText => $"$${Environment.NewLine}{this.Value}{Environment.NewLine}$$";
}