diff --git a/AGENTS.md b/AGENTS.md index f960b509..6bf4eb5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 - **Encryption** - Initialized before Rust service is marked ready - **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. ## Changelogs diff --git a/app/MindWork AI Studio/App.razor b/app/MindWork AI Studio/App.razor index b314b033..7df24793 100644 --- a/app/MindWork AI Studio/App.razor +++ b/app/MindWork AI Studio/App.razor @@ -27,6 +27,7 @@ + diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor index 579e8bf2..8d0689da 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor @@ -96,11 +96,25 @@ } else { - - @if (textContent.Sources.Count > 0) - { - - } + var renderPlan = this.GetMarkdownRenderPlan(textContent.Text); +
+ @foreach (var segment in renderPlan.Segments) + { + var segmentContent = segment.GetContent(renderPlan.Source); + if (segment.Type is MarkdownRenderSegmentType.MARKDOWN) + { + + } + else + { + + } + } + @if (textContent.Sources.Count > 0) + { + + } +
} } } @@ -135,4 +149,4 @@ } } - \ No newline at end of file + diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs index 29e70487..e0b035ce 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs @@ -8,8 +8,20 @@ namespace AIStudio.Chat; /// /// The UI component for a chat content block, i.e., for any IContent. /// -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 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 = [ " protected override bool ShouldRender() { @@ -194,32 +221,320 @@ public partial class ContentBlockComponent : MSGComponentBase 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(); + 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) { - var cleaned = text.RemoveThinkTags().Trim(); - if (string.IsNullOrWhiteSpace(cleaned)) + var textWithoutThinkTags = text.RemoveThinkTags(); + var trimmed = TrimWhitespace(textWithoutThinkTags.AsSpan()); + if (trimmed.IsEmpty) return string.Empty; - if (cleaned.Contains("```", StringComparison.Ordinal)) + var cleaned = trimmed.Length == textWithoutThinkTags.Length + ? textWithoutThinkTags + : trimmed.ToString(); + + if (cleaned.Contains(CODE_FENCE_MARKER_BACKTICK, StringComparison.Ordinal)) return cleaned; if (LooksLikeRawHtml(cleaned)) - return $"```html{Environment.NewLine}{cleaned}{Environment.NewLine}```"; + return $"{HTML_CODE_FENCE_PREFIX}{Environment.NewLine}{cleaned}{Environment.NewLine}{CODE_FENCE_MARKER_BACKTICK}"; return cleaned; } private static bool LooksLikeRawHtml(string text) { - var content = text.TrimStart(); - if (!content.StartsWith("<", StringComparison.Ordinal)) + var content = text.AsSpan(); + var start = 0; + while (start < content.Length && char.IsWhiteSpace(content[start])) + start++; + + content = content[start..]; + if (!content.StartsWith(HTML_START_TAG.AsSpan(), StringComparison.Ordinal)) return false; foreach (var marker in HTML_TAG_MARKERS) - if (content.Contains(marker, StringComparison.OrdinalIgnoreCase)) + if (content.IndexOf(marker.AsSpan(), StringComparison.OrdinalIgnoreCase) >= 0) return true; - return content.Contains("", StringComparison.Ordinal); + return content.IndexOf(HTML_END_TAG.AsSpan(), StringComparison.Ordinal) >= 0 + || content.IndexOf(HTML_SELF_CLOSING_TAG.AsSpan(), StringComparison.Ordinal) >= 0; + } + + private static bool TryUpdateCodeFenceState(ReadOnlySpan 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 TrimWhitespace(ReadOnlySpan text) + { + var start = 0; + var end = text.Length - 1; + + while (start < text.Length && char.IsWhiteSpace(text[start])) + start++; + + while (end >= start && char.IsWhiteSpace(text[end])) + end--; + + return start > end ? ReadOnlySpan.Empty : text[start..(end + 1)]; + } + + private static (int Start, int End) TrimLineBreaks(ReadOnlySpan text, int start, int end) + { + while (start < end && text[start] is '\r' or '\n') + start++; + + while (end > start && text[end - 1] is '\r' or '\n') + end--; + + return (start, end); + } + + private enum MarkdownRenderSegmentType + { + MARKDOWN, + MATH_BLOCK, + } + + private enum MathBlockFenceType + { + NONE, + DOLLAR, + BRACKET, + } + + private sealed record MarkdownRenderPlan(string Source, IReadOnlyList Segments) + { + public static readonly MarkdownRenderPlan EMPTY = new(string.Empty, []); + } + + private sealed class MarkdownRenderSegment(MarkdownRenderSegmentType type, int start, int length) + { + private string? cachedContent; + + public MarkdownRenderSegmentType Type { get; } = type; + + public int Start { get; } = start; + + public int Length { get; } = length; + + public 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() @@ -294,4 +609,14 @@ public partial class ContentBlockComponent : MSGComponentBase var result = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.Content.FileAttachments.ToHashSet()); this.Content.FileAttachments = result.ToList(); } -} \ No newline at end of file + + public async ValueTask DisposeAsync() + { + if (this.isDisposed) + return; + + this.isDisposed = true; + await this.DisposeMathContainerIfNeededAsync(); + this.Dispose(); + } +} diff --git a/app/MindWork AI Studio/Chat/MathJaxBlock.razor b/app/MindWork AI Studio/Chat/MathJaxBlock.razor new file mode 100644 index 00000000..6b203a4f --- /dev/null +++ b/app/MindWork AI Studio/Chat/MathJaxBlock.razor @@ -0,0 +1,5 @@ +@namespace AIStudio.Chat + +
+ @this.MathText +
\ No newline at end of file diff --git a/app/MindWork AI Studio/Chat/MathJaxBlock.razor.cs b/app/MindWork AI Studio/Chat/MathJaxBlock.razor.cs new file mode 100644 index 00000000..c78db2c9 --- /dev/null +++ b/app/MindWork AI Studio/Chat/MathJaxBlock.razor.cs @@ -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}$$"; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/app.css b/app/MindWork AI Studio/wwwroot/app.css index cd80c5a9..909d350d 100644 --- a/app/MindWork AI Studio/wwwroot/app.css +++ b/app/MindWork AI Studio/wwwroot/app.css @@ -150,4 +150,19 @@ .sources-card-header { top: 0em !important; left: 2.2em !important; -} \ No newline at end of file +} + +.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; +} diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md index ba8f5c42..cd5b5899 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md @@ -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 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 math rendering in chats for LaTeX display formulas, including block formats such as `$$ ... $$` and `\[ ... \]`. - 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 performance by caching the OS language detection and requesting the user language only once per app start. diff --git a/app/MindWork AI Studio/wwwroot/chat-math.js b/app/MindWork AI Studio/wwwroot/chat-math.js new file mode 100644 index 00000000..1c1bf0f6 --- /dev/null +++ b/app/MindWork AI Studio/wwwroot/chat-math.js @@ -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) + } + } + } +} \ No newline at end of file