-
Notifications
You must be signed in to change notification settings - Fork 2.8k
feat(chat): render agent-bubble LaTeX with KaTeX and safe math detection #2697
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
senamakel
merged 11 commits into
tinyhumansai:main
from
YellowSnnowmann:feat/agent-bubble-latex-rendering
May 28, 2026
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
d512d39
feat(latex): add normalization functions for LaTeX math delimiters an…
YellowSnnowmann 972054f
feat(latex): integrate KaTeX support for rendering LaTeX math in Agen…
YellowSnnowmann 532643b
feat(dependencies): add KaTeX and related packages for LaTeX math ren…
YellowSnnowmann e943096
test(latex): add unit tests for LaTeX normalization and detection fun…
YellowSnnowmann fdc0132
refactor(latex): streamline KaTeX integration and improve test readab…
YellowSnnowmann 1312e34
fix(tests): reduce timeout for loopback OAuth listener from 300 to 60…
YellowSnnowmann a0463fe
fix(tests): increase timeout for loopback OAuth listener from 60 to 3…
YellowSnnowmann fa590fe
feat(latex): enhance LaTeX normalization by masking code blocks and i…
YellowSnnowmann 16d09c4
test(latex): enhance AgentMessageBubble tests for math rendering and …
YellowSnnowmann 1fd21e0
fix(latex): update placeholder handling in LaTeX normalization to use…
YellowSnnowmann 2f09c2b
test(latex): add test case to ensure math bodies with digits are pres…
YellowSnnowmann File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| import { hasLatexContent, normalizeLatexDelimiters } from '../latex'; | ||
|
|
||
| describe('normalizeLatexDelimiters', () => { | ||
| it('converts \\[ ... \\] to $$ ... $$', () => { | ||
| expect(normalizeLatexDelimiters('\\[ x^2 + y^2 = z^2 \\]')).toContain('$$ x^2 + y^2 = z^2 $$'); | ||
| }); | ||
|
|
||
| it('converts \\( ... \\) to $ ... $', () => { | ||
| expect(normalizeLatexDelimiters('inline \\(a+b\\) here')).toBe('inline $a+b$ here'); | ||
| }); | ||
|
|
||
| it('converts bare bracketed LaTeX-only block to $$', () => { | ||
| const input = | ||
| '直接套公式:\n\n[ V_3 = (x_2 - x_1)(x_3 - x_1)(x_3 - x_2) = 1 \\times 3 \\times 2 = 6 ]'; | ||
| const out = normalizeLatexDelimiters(input); | ||
| expect(out).toContain('$$'); | ||
| expect(out).toContain('\\times'); | ||
| expect(out).not.toMatch(/\[ V_3/); | ||
| }); | ||
|
|
||
| it('preserves markdown link syntax', () => { | ||
| const input = 'see [link](https://example.com) and more'; | ||
| expect(normalizeLatexDelimiters(input)).toBe(input); | ||
| }); | ||
|
|
||
| it('returns input unchanged when no LaTeX present', () => { | ||
| const input = 'plain text with no math'; | ||
| expect(normalizeLatexDelimiters(input)).toBe(input); | ||
| }); | ||
|
|
||
| it('handles vmatrix blocks', () => { | ||
| const input = '[ \\begin{vmatrix} 1 & 2 \\\\ 3 & 4 \\end{vmatrix} = -2 ]'; | ||
| const out = normalizeLatexDelimiters(input); | ||
| expect(out).toContain('$$'); | ||
| expect(out).toContain('\\begin{vmatrix}'); | ||
| }); | ||
|
|
||
| it('preserves \\[...\\] inside inline code spans', () => { | ||
| const input = 'use `\\[x^2\\]` for display math and \\[a+b\\] renders'; | ||
| const out = normalizeLatexDelimiters(input); | ||
| expect(out).toContain('`\\[x^2\\]`'); | ||
| expect(out).toContain('$$a+b$$'); | ||
| }); | ||
|
|
||
| it('preserves \\(...\\) inside inline code spans', () => { | ||
| const input = 'use `\\(x\\)` for inline math and \\(a+b\\) renders'; | ||
| const out = normalizeLatexDelimiters(input); | ||
| expect(out).toContain('`\\(x\\)`'); | ||
| expect(out).toContain('$a+b$'); | ||
| }); | ||
|
|
||
| it('preserves LaTeX delimiters inside fenced code blocks', () => { | ||
| const input = '```\n\\[x^2\\]\n\\(y\\)\n```\n\nthen \\(a+b\\) here'; | ||
| const out = normalizeLatexDelimiters(input); | ||
| expect(out).toContain('```\n\\[x^2\\]\n\\(y\\)\n```'); | ||
| expect(out).toContain('$a+b$'); | ||
| }); | ||
|
|
||
| it('does not corrupt math bodies containing digits when code blocks present', () => { | ||
| const input = 'use `x` and \\[a^2 + 7\\] and `y` and \\(b_3\\)'; | ||
| const out = normalizeLatexDelimiters(input); | ||
| expect(out).toContain('`x`'); | ||
| expect(out).toContain('`y`'); | ||
| expect(out).toContain('$$a^2 + 7$$'); | ||
| expect(out).toContain('$b_3$'); | ||
| expect(out).not.toContain('undefined'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('hasLatexContent', () => { | ||
| it('detects backslash math commands', () => { | ||
| expect(hasLatexContent('use \\frac{1}{2} here')).toBe(true); | ||
| expect(hasLatexContent('\\begin{vmatrix} 1 \\end{vmatrix}')).toBe(true); | ||
| expect(hasLatexContent('\\[ a \\]')).toBe(true); | ||
| expect(hasLatexContent('\\(a\\)')).toBe(true); | ||
| expect(hasLatexContent('$$x$$')).toBe(true); | ||
| }); | ||
|
|
||
| it('rejects currency mentions', () => { | ||
| expect(hasLatexContent('total is $10 and $20')).toBe(false); | ||
| expect(hasLatexContent('$100')).toBe(false); | ||
| }); | ||
|
|
||
| it('rejects plain text and markdown', () => { | ||
| expect(hasLatexContent('see [link](https://example.com)')).toBe(false); | ||
| expect(hasLatexContent('hello world')).toBe(false); | ||
| expect(hasLatexContent('')).toBe(false); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| /** | ||
| * Normalize LaTeX math delimiters emitted by upstream LLMs into the | ||
| * `$...$` / `$$...$$` form that `remark-math` understands. | ||
| * | ||
| * Models frequently emit `\[ ... \]` (display) and `\( ... \)` (inline) | ||
| * or even bare `[ ... ]` blocks containing `\begin{vmatrix}`, `\cdot`, | ||
| * `x_1`, etc. Without this normalization those land in chat as raw | ||
| * source instead of rendered math. | ||
| */ | ||
|
|
||
| const DISPLAY_BACKSLASH = /\\\[([\s\S]+?)\\\]/g; | ||
| const INLINE_BACKSLASH = /\\\(([\s\S]+?)\\\)/g; | ||
|
|
||
| // Bare `[ ... ]` block that contains a LaTeX-only signal (\begin, \cdot, | ||
| // \times, etc.) and lives on its own line. Conservative: avoids matching | ||
| // markdown link/image syntax (`[text](url)`, ``). | ||
| const DISPLAY_BARE_BRACKETS = | ||
| /(^|\n)[ \t]*\[[ \t]*((?:[^[\]\n]|\n(?!\n))*?\\(?:begin|end|frac|sqrt|cdot|times|sum|int|prod|lim|left|right|vmatrix|pmatrix|bmatrix|matrix|mathrm|mathbf|mathbb|alpha|beta|gamma|delta|theta|pi|sigma|infty)[^[\]]*?)[ \t]*\][ \t]*(?=\n|$)/g; | ||
|
|
||
| // Match fenced code blocks (```...```) and inline code spans (`...`) so | ||
| // they can be masked out before delimiter normalization. Without this, | ||
| // content like "use `\[x^2\]` for display math" would get its inline | ||
| // code corrupted into `$$x^2$$`. | ||
| const CODE_BLOCKS = /```[\s\S]*?```|`[^`\n]+`/g; | ||
|
|
||
| // Sentinel uses Unicode Private Use Area (U+E000 / U+E001): | ||
| // - non-control (ESLint `no-control-regex` does not fire) | ||
| // - reserved by Unicode for private use, will not appear in real chat text | ||
| // - wrapped on both sides so the restore regex matches *only* placeholders | ||
| // and never stray digits inside math bodies (e.g. `$$a^2$$`). | ||
| const PLACEHOLDER_OPEN = ''; | ||
| const PLACEHOLDER_CLOSE = ''; | ||
| const PLACEHOLDER = /(\d+)/g; | ||
|
|
||
| export function normalizeLatexDelimiters(input: string): string { | ||
| if (!input || (!input.includes('\\') && !input.includes('['))) return input; | ||
|
|
||
| const codeSegments: string[] = []; | ||
| let out = input.replace(CODE_BLOCKS, match => { | ||
| codeSegments.push(match); | ||
| return `${PLACEHOLDER_OPEN}${codeSegments.length - 1}${PLACEHOLDER_CLOSE}`; | ||
| }); | ||
|
|
||
| out = out.replace(DISPLAY_BACKSLASH, (_m, body) => `\n\n$$${body}$$\n\n`); | ||
| out = out.replace(INLINE_BACKSLASH, (_m, body) => `$${body}$`); | ||
| out = out.replace(DISPLAY_BARE_BRACKETS, (_m, lead, body) => `${lead}\n$$${body}$$\n`); | ||
|
|
||
| if (codeSegments.length > 0) { | ||
| out = out.replace(PLACEHOLDER, (_m, i) => codeSegments[Number(i)] ?? ''); | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
| /** | ||
| * Heuristic: does this string likely contain LaTeX math? | ||
| * | ||
| * We use this to gate `remark-math` + `rehype-katex` so plain chat | ||
| * messages (e.g. "$10 vs $20", "[link](url)") are never reinterpreted as | ||
| * math. Only content the LLM clearly intended as math turns the plugins | ||
| * on. | ||
| */ | ||
| const LATEX_SIGNATURE = | ||
| /\\(?:begin|end|frac|sqrt|cdot|times|sum|int|prod|lim|left|right|vmatrix|pmatrix|bmatrix|matrix|mathrm|mathbf|mathbb|alpha|beta|gamma|delta|theta|pi|sigma|infty)\b|\\\[|\\\(|\$\$/; | ||
|
|
||
| export function hasLatexContent(input: string): boolean { | ||
| if (!input) return false; | ||
| return LATEX_SIGNATURE.test(input); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.