mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-10 20:27:21 +00:00
Added formulas from LaTeX code blocks and buttons to copy and save formulas (#1154)
This commit is contained in:
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())
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}$$";
|
||||
}
|
||||
Reference in new issue
Block a user