mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-03-22 22:11:36 +00:00
Added math rendering (#705)
Some checks are pending
Build and Release / Read metadata (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage deb updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage deb updater) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Some checks are pending
Build and Release / Read metadata (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage deb updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage deb updater) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
This commit is contained in:
parent
a2bd67eda3
commit
cf6226546e
@ -193,6 +193,7 @@ Multi-level confidence scheme allows users to control which providers see which
|
|||||||
- **MudBlazor** - Component library requires DI setup in Program.cs
|
- **MudBlazor** - Component library requires DI setup in Program.cs
|
||||||
- **Encryption** - Initialized before Rust service is marked ready
|
- **Encryption** - Initialized before Rust service is marked ready
|
||||||
- **Message Bus** - Singleton event bus for cross-component communication inside the .NET app
|
- **Message Bus** - Singleton event bus for cross-component communication inside the .NET app
|
||||||
|
- **Naming conventions** - Constants, enum members, and `static readonly` fields use `UPPER_SNAKE_CASE` such as `MY_CONSTANT`.
|
||||||
- **Empty lines** - Avoid adding extra empty lines at the end of files.
|
- **Empty lines** - Avoid adding extra empty lines at the end of files.
|
||||||
|
|
||||||
## Changelogs
|
## Changelogs
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
<script src="system/MudBlazor.Markdown/MudBlazor.Markdown.min.js"></script>
|
<script src="system/MudBlazor.Markdown/MudBlazor.Markdown.min.js"></script>
|
||||||
<script src="system/CodeBeam.MudBlazor.Extensions/MudExtensions.min.js"></script>
|
<script src="system/CodeBeam.MudBlazor.Extensions/MudExtensions.min.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
<script src="chat-math.js"></script>
|
||||||
<script src="audio.js"></script>
|
<script src="audio.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@ -96,11 +96,25 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudMarkdown Value="@NormalizeMarkdownForRendering(textContent.Text)" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE" />
|
var renderPlan = this.GetMarkdownRenderPlan(textContent.Text);
|
||||||
@if (textContent.Sources.Count > 0)
|
<div @ref="this.mathContentContainer" class="chat-math-container">
|
||||||
{
|
@foreach (var segment in renderPlan.Segments)
|
||||||
<MudMarkdown Value="@textContent.Sources.ToMarkdown()" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE" />
|
{
|
||||||
}
|
var segmentContent = segment.GetContent(renderPlan.Source);
|
||||||
|
if (segment.Type is MarkdownRenderSegmentType.MARKDOWN)
|
||||||
|
{
|
||||||
|
<MudMarkdown @key="@segment.RenderKey" Value="@segmentContent" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MathJaxBlock @key="@segment.RenderKey" Value="@segmentContent" Class="mb-5" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (textContent.Sources.Count > 0)
|
||||||
|
{
|
||||||
|
<MudMarkdown Value="@textContent.Sources.ToMarkdown()" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,8 +8,20 @@ namespace AIStudio.Chat;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The UI component for a chat content block, i.e., for any IContent.
|
/// The UI component for a chat content block, i.e., for any IContent.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class ContentBlockComponent : MSGComponentBase
|
public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
||||||
{
|
{
|
||||||
|
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";
|
||||||
|
|
||||||
private static readonly string[] HTML_TAG_MARKERS =
|
private static readonly string[] HTML_TAG_MARKERS =
|
||||||
[
|
[
|
||||||
"<!doctype",
|
"<!doctype",
|
||||||
@ -79,9 +91,18 @@ public partial class ContentBlockComponent : MSGComponentBase
|
|||||||
[Inject]
|
[Inject]
|
||||||
private RustService RustService { get; init; } = null!;
|
private RustService RustService { get; init; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private IJSRuntime JsRuntime { get; init; } = null!;
|
||||||
|
|
||||||
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;
|
||||||
|
private ElementReference mathContentContainer;
|
||||||
|
private string lastMathRenderSignature = string.Empty;
|
||||||
|
private bool hasActiveMathContainer;
|
||||||
|
private bool isDisposed;
|
||||||
|
|
||||||
#region Overrides of ComponentBase
|
#region Overrides of ComponentBase
|
||||||
|
|
||||||
@ -97,6 +118,12 @@ public partial class ContentBlockComponent : MSGComponentBase
|
|||||||
return base.OnParametersSetAsync();
|
return base.OnParametersSetAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
await this.SyncMathRenderIfNeededAsync();
|
||||||
|
await base.OnAfterRenderAsync(firstRender);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override bool ShouldRender()
|
protected override bool ShouldRender()
|
||||||
{
|
{
|
||||||
@ -194,32 +221,320 @@ public partial class ContentBlockComponent : MSGComponentBase
|
|||||||
CodeBlock = { Theme = this.CodeColorPalette },
|
CodeBlock = { Theme = this.CodeColorPalette },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
await this.JsRuntime.InvokeVoidAsync(CHAT_MATH_SYNC_FUNCTION, this.mathContentContainer, mathRenderSignature);
|
||||||
|
this.lastMathRenderSignature = mathRenderSignature;
|
||||||
|
this.hasActiveMathContainer = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DisposeMathContainerIfNeededAsync()
|
||||||
|
{
|
||||||
|
if (!this.hasActiveMathContainer)
|
||||||
|
{
|
||||||
|
this.lastMathRenderSignature = string.Empty;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await this.JsRuntime.InvokeVoidAsync(CHAT_MATH_DISPOSE_FUNCTION, this.mathContentContainer);
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
lineStart = nextLineStart;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
lineStart = nextLineStart;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
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(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()
|
private async Task RemoveBlock()
|
||||||
@ -294,4 +609,14 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (this.isDisposed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.isDisposed = true;
|
||||||
|
await this.DisposeMathContainerIfNeededAsync();
|
||||||
|
this.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
5
app/MindWork AI Studio/Chat/MathJaxBlock.razor
Normal file
5
app/MindWork AI Studio/Chat/MathJaxBlock.razor
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
@namespace AIStudio.Chat
|
||||||
|
|
||||||
|
<div class="@this.RootClass" data-chat-math-block="true" style="white-space: pre-wrap;">
|
||||||
|
@this.MathText
|
||||||
|
</div>
|
||||||
18
app/MindWork AI Studio/Chat/MathJaxBlock.razor.cs
Normal file
18
app/MindWork AI Studio/Chat/MathJaxBlock.razor.cs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace AIStudio.Chat;
|
||||||
|
|
||||||
|
public partial class MathJaxBlock
|
||||||
|
{
|
||||||
|
[Parameter]
|
||||||
|
public string Value { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string Class { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
private string RootClass => string.IsNullOrWhiteSpace(this.Class)
|
||||||
|
? "chat-mathjax-block"
|
||||||
|
: $"chat-mathjax-block {this.Class}";
|
||||||
|
|
||||||
|
private string MathText => $"$${Environment.NewLine}{this.Value}{Environment.NewLine}$$";
|
||||||
|
}
|
||||||
@ -151,3 +151,18 @@
|
|||||||
top: 0em !important;
|
top: 0em !important;
|
||||||
left: 2.2em !important;
|
left: 2.2em !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-mathjax-block {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-mathjax-block mjx-container[display="true"] {
|
||||||
|
text-align: left !important;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-mathjax-block mjx-container[display="true"] mjx-math {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
- Added the ability to format your user prompt in the chat using icons instead of typing Markdown directly.
|
- Added the ability to format your user prompt in the chat using icons instead of typing Markdown directly.
|
||||||
- Added the ability to load a system prompt from a file when creating or editing chat templates.
|
- Added the ability to load a system prompt from a file when creating or editing chat templates.
|
||||||
- Added a start-page setting, so AI Studio can now open directly on your preferred page when the app starts. Configuration plugins can also provide and optionally lock this default for organizations.
|
- Added a start-page setting, so AI Studio can now open directly on your preferred page when the app starts. Configuration plugins can also provide and optionally lock this default for organizations.
|
||||||
|
- Added math rendering in chats for LaTeX display formulas, including block formats such as `$$ ... $$` and `\[ ... \]`.
|
||||||
- Released the document analysis assistant after an intense testing phase.
|
- Released the document analysis assistant after an intense testing phase.
|
||||||
- Improved the profile selection for assistants and the chat. You can now explicitly choose between the app default profile, no profile, or a specific profile.
|
- Improved the profile selection for assistants and the chat. You can now explicitly choose between the app default profile, no profile, or a specific profile.
|
||||||
- Improved the performance by caching the OS language detection and requesting the user language only once per app start.
|
- Improved the performance by caching the OS language detection and requesting the user language only once per app start.
|
||||||
|
|||||||
287
app/MindWork AI Studio/wwwroot/chat-math.js
Normal file
287
app/MindWork AI Studio/wwwroot/chat-math.js
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
const MATH_JAX_SCRIPT_ID = 'mudblazor-markdown-mathjax'
|
||||||
|
const MATH_JAX_SCRIPT_SRC = '_content/MudBlazor.Markdown/MudBlazor.Markdown.MathJax.min.js'
|
||||||
|
const INTERSECTION_ROOT_MARGIN = '240px 0px 240px 0px'
|
||||||
|
const MAX_TYPES_PER_BATCH = 4
|
||||||
|
const containerStates = new Map()
|
||||||
|
const pendingMathElements = new Set()
|
||||||
|
|
||||||
|
let mathJaxReadyPromise = null
|
||||||
|
let batchScheduled = false
|
||||||
|
let typesetInProgress = false
|
||||||
|
|
||||||
|
function applyMathJaxConfiguration() {
|
||||||
|
window.MathJax = window.MathJax ?? {}
|
||||||
|
window.MathJax.options = window.MathJax.options ?? {}
|
||||||
|
window.MathJax.options.enableMenu = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMathJaxReady() {
|
||||||
|
return typeof window.MathJax?.typesetPromise === 'function' || typeof window.MathJax?.typeset === 'function'
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForMathJaxReady(attempt = 0) {
|
||||||
|
if (isMathJaxReady())
|
||||||
|
return Promise.resolve()
|
||||||
|
|
||||||
|
if (attempt >= 80)
|
||||||
|
return Promise.reject(new Error('MathJax did not finish loading in time.'))
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
waitForMathJaxReady(attempt + 1).then(resolve).catch(reject)
|
||||||
|
}, 50)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureMathJaxLoaded() {
|
||||||
|
if (isMathJaxReady())
|
||||||
|
return Promise.resolve()
|
||||||
|
|
||||||
|
if (mathJaxReadyPromise)
|
||||||
|
return mathJaxReadyPromise
|
||||||
|
|
||||||
|
mathJaxReadyPromise = new Promise((resolve, reject) => {
|
||||||
|
applyMathJaxConfiguration()
|
||||||
|
let script = document.getElementById(MATH_JAX_SCRIPT_ID)
|
||||||
|
|
||||||
|
const onLoad = () => {
|
||||||
|
waitForMathJaxReady().then(resolve).catch(reject)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onError = () => reject(new Error('Failed to load the MathJax script.'))
|
||||||
|
|
||||||
|
if (!script) {
|
||||||
|
script = document.createElement('script')
|
||||||
|
script.id = MATH_JAX_SCRIPT_ID
|
||||||
|
script.type = 'text/javascript'
|
||||||
|
script.src = MATH_JAX_SCRIPT_SRC
|
||||||
|
script.addEventListener('load', onLoad, { once: true })
|
||||||
|
script.addEventListener('error', onError, { once: true })
|
||||||
|
document.head.appendChild(script)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
script.addEventListener('load', onLoad, { once: true })
|
||||||
|
script.addEventListener('error', onError, { once: true })
|
||||||
|
void waitForMathJaxReady().then(resolve).catch(() => {})
|
||||||
|
}).catch(error => {
|
||||||
|
mathJaxReadyPromise = null
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
|
||||||
|
return mathJaxReadyPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function createContainerState() {
|
||||||
|
return {
|
||||||
|
signature: '',
|
||||||
|
observer: null,
|
||||||
|
observedElements: new Set()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectContainerState(state) {
|
||||||
|
if (state.observer) {
|
||||||
|
state.observer.disconnect()
|
||||||
|
state.observer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const element of state.observedElements)
|
||||||
|
pendingMathElements.delete(element)
|
||||||
|
|
||||||
|
state.observedElements.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNearViewport(element) {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
return rect.bottom >= -240 && rect.top <= window.innerHeight + 240
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueElementForTypeset(element, signature) {
|
||||||
|
if (!element || !element.isConnected)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (element.dataset.chatMathProcessedSignature === signature)
|
||||||
|
return
|
||||||
|
|
||||||
|
element.dataset.chatMathTargetSignature = signature
|
||||||
|
element.dataset.chatMathPending = 'true'
|
||||||
|
pendingMathElements.add(element)
|
||||||
|
schedulePendingTypeset(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePendingTypeset(useIdleCallback) {
|
||||||
|
if (batchScheduled)
|
||||||
|
return
|
||||||
|
|
||||||
|
batchScheduled = true
|
||||||
|
const flush = () => {
|
||||||
|
batchScheduled = false
|
||||||
|
void flushPendingTypeset()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useIdleCallback && typeof window.requestIdleCallback === 'function') {
|
||||||
|
window.requestIdleCallback(flush, { timeout: 120 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.requestAnimationFrame(flush)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushPendingTypeset() {
|
||||||
|
if (typesetInProgress || pendingMathElements.size === 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
typesetInProgress = true
|
||||||
|
const elementsToTypeset = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureMathJaxLoaded()
|
||||||
|
|
||||||
|
for (const element of pendingMathElements) {
|
||||||
|
if (elementsToTypeset.length >= MAX_TYPES_PER_BATCH)
|
||||||
|
break
|
||||||
|
|
||||||
|
if (!element.isConnected) {
|
||||||
|
pendingMathElements.delete(element)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetSignature = element.dataset.chatMathTargetSignature ?? ''
|
||||||
|
if (element.dataset.chatMathProcessedSignature === targetSignature) {
|
||||||
|
pendingMathElements.delete(element)
|
||||||
|
element.dataset.chatMathPending = 'false'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
elementsToTypeset.push(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elementsToTypeset.length === 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
for (const element of elementsToTypeset)
|
||||||
|
pendingMathElements.delete(element)
|
||||||
|
|
||||||
|
if (typeof window.MathJax?.typesetClear === 'function') {
|
||||||
|
try {
|
||||||
|
window.MathJax.typesetClear(elementsToTypeset)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('chatMath: failed to clear previous MathJax state.', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.MathJax?.typesetPromise === 'function')
|
||||||
|
await window.MathJax.typesetPromise(elementsToTypeset)
|
||||||
|
else if (typeof window.MathJax?.typeset === 'function')
|
||||||
|
window.MathJax.typeset(elementsToTypeset)
|
||||||
|
|
||||||
|
for (const element of elementsToTypeset) {
|
||||||
|
element.dataset.chatMathProcessedSignature = element.dataset.chatMathTargetSignature ?? ''
|
||||||
|
element.dataset.chatMathPending = 'false'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('chatMath: failed to typeset math content.', error)
|
||||||
|
|
||||||
|
for (const element of elementsToTypeset)
|
||||||
|
if (element.isConnected)
|
||||||
|
pendingMathElements.add(element)
|
||||||
|
} finally {
|
||||||
|
typesetInProgress = false
|
||||||
|
|
||||||
|
if (pendingMathElements.size > 0)
|
||||||
|
schedulePendingTypeset(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIntersectionObserver(state, signature) {
|
||||||
|
return new IntersectionObserver(entries => {
|
||||||
|
let queuedVisibleElement = false
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isIntersecting)
|
||||||
|
continue
|
||||||
|
|
||||||
|
const element = entry.target
|
||||||
|
state.observer?.unobserve(element)
|
||||||
|
state.observedElements.delete(element)
|
||||||
|
queueElementForTypeset(element, signature)
|
||||||
|
queuedVisibleElement = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queuedVisibleElement)
|
||||||
|
schedulePendingTypeset(true)
|
||||||
|
}, {
|
||||||
|
root: null,
|
||||||
|
rootMargin: INTERSECTION_ROOT_MARGIN,
|
||||||
|
threshold: 0.01
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMathElements(container) {
|
||||||
|
return Array.from(container.querySelectorAll('.chat-mathjax-block'))
|
||||||
|
}
|
||||||
|
|
||||||
|
window.chatMath = {
|
||||||
|
syncContainer: async function(container, signature) {
|
||||||
|
if (!container)
|
||||||
|
return
|
||||||
|
|
||||||
|
let state = containerStates.get(container)
|
||||||
|
if (!state) {
|
||||||
|
state = createContainerState()
|
||||||
|
containerStates.set(container, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.signature === signature)
|
||||||
|
return
|
||||||
|
|
||||||
|
disconnectContainerState(state)
|
||||||
|
state.signature = signature
|
||||||
|
|
||||||
|
const mathElements = getMathElements(container)
|
||||||
|
if (mathElements.length === 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
await ensureMathJaxLoaded()
|
||||||
|
|
||||||
|
state.observer = createIntersectionObserver(state, signature)
|
||||||
|
|
||||||
|
for (const element of mathElements) {
|
||||||
|
if (isNearViewport(element)) {
|
||||||
|
queueElementForTypeset(element, signature)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
element.dataset.chatMathTargetSignature = signature
|
||||||
|
state.observer.observe(element)
|
||||||
|
state.observedElements.add(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
schedulePendingTypeset(false)
|
||||||
|
},
|
||||||
|
|
||||||
|
disposeContainer: function(container) {
|
||||||
|
if (!container)
|
||||||
|
return
|
||||||
|
|
||||||
|
const state = containerStates.get(container)
|
||||||
|
if (!state)
|
||||||
|
return
|
||||||
|
|
||||||
|
disconnectContainerState(state)
|
||||||
|
containerStates.delete(container)
|
||||||
|
|
||||||
|
const mathElements = getMathElements(container)
|
||||||
|
for (const element of mathElements)
|
||||||
|
pendingMathElements.delete(element)
|
||||||
|
|
||||||
|
if (typeof window.MathJax?.typesetClear === 'function' && mathElements.length > 0) {
|
||||||
|
try {
|
||||||
|
window.MathJax.typesetClear(mathElements)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('chatMath: failed to clear container MathJax state during dispose.', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user