fix(stage-ui): define deterministic chat math syntax (#2328)

This commit is contained in:
leafyy
2026-08-20 16:30:01 +08:00
committed by GitHub
parent f6969d07c5
commit 7756d921a7
9 changed files with 543 additions and 33 deletions
+1
View File
@@ -200,6 +200,7 @@
"@types/culori": "catalog:",
"@types/d3": "catalog:",
"@types/hast": "catalog:",
"@types/mdast": "catalog:",
"@types/splitpanes": "catalog:",
"@types/unist": "catalog:",
"@types/ws": "catalog:",
@@ -4,16 +4,16 @@ import { defineComponent, onMounted, ref, useTemplateRef } from 'vue'
import MarkdownRenderer from './markdown-renderer.vue'
const markdown = `### Roof Leak in Server Room
*Anime style virtual AI girl waking up in a server room and noticing water from the ceiling.*
> **Sharing**: Sending a quick sketch to our chat history...`
const MarkdownMountProbe = defineComponent({
components: {
MarkdownRenderer,
},
props: {
markdown: {
type: String,
required: true,
},
},
emits: {
mountedHtml: (html: string) => typeof html === 'string',
},
@@ -25,7 +25,6 @@ const MarkdownMountProbe = defineComponent({
})
return {
markdown,
root,
}
},
@@ -36,22 +35,26 @@ const MarkdownMountProbe = defineComponent({
`,
})
const MarkdownHarness = defineComponent({
components: {
MarkdownMountProbe,
},
setup() {
const mountedHtml = ref('')
function createMarkdownHarness(markdown: string, label: string) {
return defineComponent({
components: {
MarkdownMountProbe,
},
setup() {
const mountedHtml = ref('')
return {
mountedHtml,
}
},
template: `
<MarkdownMountProbe @mounted-html="mountedHtml = $event" />
<output aria-label="initial-markdown-html">{{ mountedHtml }}</output>
`,
})
return {
label,
markdown,
mountedHtml,
}
},
template: `
<MarkdownMountProbe :markdown="markdown" @mounted-html="mountedHtml = $event" />
<output :aria-label="label">{{ mountedHtml }}</output>
`,
})
}
describe('markdown renderer initial content', () => {
it('renders basic Markdown before mounted layout code reads the element', async () => {
@@ -62,8 +65,31 @@ describe('markdown renderer initial content', () => {
// The content jumped to its real height when the animation released its fixed height.
//
// We fixed this by rendering basic Markdown synchronously before optional rich processing.
const screen = await render(MarkdownHarness)
const markdown = `### Roof Leak in Server Room
*Anime style virtual AI girl waking up in a server room and noticing water from the ceiling.*
> **Sharing**: Sending a quick sketch to our chat history...`
const screen = await render(createMarkdownHarness(markdown, 'initial-markdown-html'))
await expect.element(screen.getByLabelText('initial-markdown-html')).toHaveTextContent('<h3>Roof Leak in Server Room</h3>')
})
// https://github.com/moeru-ai/airi/discussions/2239
it('renders double-dollar math without consuming currency for Issue #2239', async () => {
// ROOT CAUSE:
//
// Single-dollar math can pair two currency signs. The rendered component
// then displays normal prose as a formula.
//
// We fixed this by keeping single dollars as text and using double dollars
// for inline math.
const markdown = 'Price is $5 and cost is $10.\n\nThe result is $$x^2$$.'
const screen = await render(createMarkdownHarness(markdown, 'chat-math-html'))
const html = screen.getByLabelText('chat-math-html')
await expect.element(html).toHaveTextContent('Price is $5 and cost is $10.')
await expect.element(html).toHaveTextContent('<math')
await expect.element(html).not.toHaveTextContent('katex-error')
})
})
@@ -0,0 +1,71 @@
import type { Root, RootContent } from 'mdast'
import type { Plugin, Preset } from 'unified'
import remarkMath from 'remark-math'
import { SKIP, visit } from 'unist-util-visit'
const chatMathFenceLanguages = new Set(['latex', 'math', 'tex'])
/**
* Checks whether a code-fence language belongs to the AIRI chat math syntax.
*
* These fences are consumed before syntax highlighting and must not be loaded
* as Shiki languages.
*/
export function isChatMathFenceLanguage(language: string | undefined): boolean {
return chatMathFenceLanguages.has(language?.toLowerCase() ?? '')
}
const remarkChatMath: Plugin<[], Root> = () => (tree) => {
visit(tree, 'code', (node, index, parent) => {
const language = node.lang?.toLowerCase()
if (index === undefined || !parent || !isChatMathFenceLanguage(language))
return
// `math` is the canonical remark-math fence. It must be filtered from
// Shiki, but only `latex` and `tex` fences need conversion here.
if (language === 'math')
return
const rows = node.value
.split(/\r?\n/)
.map(row => row.trim())
.filter(Boolean)
if (rows.length === 0) {
// Streaming can expose a chat math fence before its first formula row.
// Consume the marker instead of rendering an empty code block.
parent.children.splice(index, 1)
return [SKIP, index]
}
const meta = node.meta?.toLowerCase().split(/\s+/).filter(Boolean) ?? []
const values = meta.includes('block') ? [node.value.trim()] : rows
const mathNodes: RootContent[] = values.map(value => ({
type: 'code',
lang: 'math',
meta: null,
value,
}))
parent.children.splice(index, 1, ...mathNodes)
// Tell the unist visitor to skip the newly inserted nodes and resume at
// the index immediately after them, so each row is visited only once.
return [SKIP, index + mathNodes.length]
})
}
/**
* Defines the math syntax for AIRI chat Markdown.
*
* A single dollar sign stays text, and `$$...$$` defines inline math. A
* `latex` or `tex` fence contains one formula per non-empty row. The `block`
* meta value keeps the fence intact.
*/
export const chatMathPreset = {
plugins: [
[remarkMath, { singleDollarTextMath: false }],
remarkChatMath,
],
} satisfies Preset
@@ -0,0 +1,378 @@
import { describe, expect, it } from 'vitest'
import { useMarkdown } from './markdown'
function mathNodeCount(html: string): number {
return html.match(/<math/g)?.length ?? 0
}
describe('useMarkdown', () => {
// https://github.com/moeru-ai/airi/discussions/2239
it('renders each LaTeX fence line as display math for Issue #2239', async () => {
// ROOT CAUSE:
//
// Markdown treats a LaTeX fence as source code. The old fix then tried to
// infer formula boundaries from relations and operators in each line.
//
// We define a chat syntax instead. Each non-empty line in a `latex` or
// `tex` fence is one display formula.
const markdown = [
'```latex',
String.raw`\frac{d}{dx}(c)=0`,
String.raw`\frac{d}{dx}(x^n)=n x^{n-1}`,
String.raw`\frac{d}{dx}(e^x)=e^x`,
String.raw`\int c\,dx = cx + C`,
String.raw`\int x^n\,dx = \frac{x^{n+1}}{n+1}+C`,
String.raw`\int \sin x\,dx = -\cos x + C`,
'```',
].join('\n')
const { process, processSync } = useMarkdown()
const initialHtml = processSync(markdown)
const html = await process(markdown)
expect(mathNodeCount(initialHtml)).toBe(6)
expect(html).not.toContain('class="shiki')
expect(mathNodeCount(html)).toBe(6)
expect(html).toContain('<annotation encoding="application/x-tex">\\frac{d}{dx}(c)=0')
expect(html).toContain('<annotation encoding="application/x-tex">\\frac{d}{dx}(x^n)=n x^{n-1}')
expect(html).toContain('<annotation encoding="application/x-tex">\\frac{d}{dx}(e^x)=e^x')
expect(html).toContain('<annotation encoding="application/x-tex">\\int c\\,dx = cx + C')
expect(html).toContain('<annotation encoding="application/x-tex">\\int x^n\\,dx = \\frac{x^{n+1}}{n+1}+C')
})
// https://github.com/moeru-ai/airi/pull/2328#discussion_r3818349198
it('renders formula-list rows without a relation allowlist', () => {
// ROOT CAUSE:
//
// The old splitter required a known relation on every line. Commands such
// as `\in`, `\notin`, and `\subseteq` form an open set. Expressions can
// also be independent formulas without any relation.
//
// We fixed this by making physical rows part of the `latex` fence syntax.
const markdown = [
'```latex',
String.raw`x \in A`,
String.raw`y \notin B`,
String.raw`A \subseteq C`,
String.raw`\sin x`,
'```',
].join('\n')
const html = useMarkdown().processSync(markdown)
expect(mathNodeCount(html)).toBe(4)
})
// https://github.com/moeru-ai/airi/discussions/2239
it('supports the explicit tex rows alias with blank rows and CRLF for Issue #2239', () => {
const markdown = [
'```tex rows',
String.raw`\int c\,dx = cx + C`,
'',
String.raw`\int e^x\,dx = e^x + C`,
'```',
].join('\r\n')
const html = useMarkdown().processSync(markdown)
expect(mathNodeCount(html)).toBe(2)
})
// https://github.com/moeru-ai/airi/pull/2328
it('keeps a latex block as one formula without inspecting its rows', () => {
// ROOT CAUSE:
//
// Content heuristics split any rows that looked like complete equations.
// This ignored the fence mode and changed macro scope and layout.
//
// We fixed this by making `block` the only boundary decision.
const markdown = [
'```latex block',
'x = 1',
'y = 2',
'```',
].join('\n')
const html = useMarkdown().processSync(markdown)
expect(mathNodeCount(html)).toBe(1)
expect(html).toContain('x = 1\ny = 2')
})
// https://github.com/moeru-ai/airi/pull/2328#discussion_r3819441402
it('keeps syntax highlighting when a math fence has metadata', async () => {
// ROOT CAUSE:
//
// The rich pipeline loaded the complete fence info string as a Shiki
// language. For example, it tried to load `latex block`. Shiki rejected
// that name, and the fallback removed highlighting from unrelated code.
//
// The language loader must read only the first info-string token.
const markdown = [
'```latex block',
'x = 1',
'y = 2',
'```',
'',
'```typescript',
'const answer = 42',
'```',
].join('\n')
const html = await useMarkdown().process(markdown)
expect(mathNodeCount(html)).toBe(1)
expect(html).toContain('class="shiki')
expect(html).toContain('>const</span>')
expect(html).toContain('> answer</span>')
})
// https://github.com/moeru-ai/airi/pull/2328#discussion_r3819513359
it('keeps syntax highlighting when a message has a math fence', async () => {
// ROOT CAUSE:
//
// A `math` fence is a remark-math marker, not a Shiki language. Loading
// it in the rich pipeline made Shiki reject the processor and removed
// highlighting from every code block in the message.
//
// Chat math fence languages must not enter the Shiki language list.
const markdown = [
'```math',
String.raw`\begin{aligned}`,
String.raw`x &= 1 \\`,
String.raw`y &= 2`,
String.raw`\end{aligned}`,
'```',
'',
'```typescript',
'const answer = 42',
'```',
].join('\n')
const html = await useMarkdown().process(markdown)
expect(mathNodeCount(html)).toBe(1)
expect(html).toContain('class="shiki')
expect(html).toContain('>const</span>')
expect(html).toContain('> answer</span>')
})
// https://github.com/moeru-ai/airi/pull/2328#discussion_r3819733711
it('consumes empty chat math fences without disabling syntax highlighting for PR #2328', async () => {
// ROOT CAUSE:
//
// Empty `latex` and `tex` fences were excluded from Shiki language
// loading but remained code nodes, so streaming displayed blank code
// blocks before the first formula row arrived.
//
// Chat math fences must be consumed even before streaming adds a formula.
const markdown = [
'```latex',
'```',
'',
'```tex',
' ',
'```',
'',
'```typescript',
'const answer = 42',
'```',
].join('\n')
const html = await useMarkdown().process(markdown)
expect(mathNodeCount(html)).toBe(0)
expect(html).not.toContain('language-latex')
expect(html).not.toContain('language-tex')
expect(html).toContain('class="shiki')
expect(html).toContain('>const</span>')
expect(html).toContain('> answer</span>')
})
// https://github.com/moeru-ai/airi/pull/2328#discussion_r3819568323
it('loads a code language from a fence nested in a blockquote', async () => {
// ROOT CAUSE:
//
// A source-text regex only found top-level fences. Remark still parsed a
// fence inside a blockquote, but Shiki did not preload its language and
// made the complete rich pipeline fall back.
//
// Language discovery must use the same Markdown AST as rendering.
const markdown = [
'> ```typescript',
'> const answer = 42',
'> ```',
].join('\n')
const html = await useMarkdown().process(markdown)
expect(html).toContain('<blockquote>')
expect(html).toContain('class="shiki')
expect(html).toContain('>const</span>')
expect(html).toContain('> answer</span>')
})
// https://github.com/moeru-ai/airi/pull/2328#discussion_r3812513778
it('keeps command arguments together in a latex block', () => {
const markdown = [
'```latex block',
String.raw`\frac{a=b}`,
String.raw`{c=d}`,
'```',
].join('\n')
const html = useMarkdown().processSync(markdown)
expect(mathNodeCount(html)).toBe(1)
expect(html).not.toContain('<merror')
expect(html).toContain('\\frac{a=b}\n{c=d}')
})
// https://github.com/moeru-ai/airi/pull/2328#discussion_r3818140362
it('keeps paired delimiters together in a tex block', () => {
const markdown = [
'```tex block',
String.raw`\left(x=1`,
String.raw`\right)=y`,
'```',
].join('\n')
const html = useMarkdown().processSync(markdown)
expect(mathNodeCount(html)).toBe(1)
expect(html).not.toContain('katex-error')
expect(html).toContain('\\left(x=1\n\\right)=y')
})
// https://github.com/moeru-ai/airi/pull/2328#discussion_r3818173297
it('keeps optional command arguments together in a latex block', () => {
const markdown = [
'```latex block',
String.raw`y = \sqrt`,
'[3]{x = z}',
'```',
].join('\n')
const html = useMarkdown().processSync(markdown)
expect(mathNodeCount(html)).toBe(1)
expect(html).not.toContain('katex-error')
expect(html).toContain('y = \\sqrt\n[3]{x = z}')
})
// https://github.com/moeru-ai/airi/pull/2328#discussion_r3818250572
it('keeps scripts together in a latex block', () => {
const markdown = [
'```latex block',
String.raw`S = \sum`,
String.raw`_{i=1}^{n} i = n(n+1)/2`,
'```',
].join('\n')
const html = useMarkdown().processSync(markdown)
expect(mathNodeCount(html)).toBe(1)
expect(html).not.toContain('katex-error')
expect(html).toContain('S = \\sum\n_{i=1}^{n} i = n(n+1)/2')
})
it('keeps a multiline math fence as one formula', () => {
const markdown = [
'```math',
String.raw`\begin{aligned}`,
String.raw`f(x) &= x^2 \\`,
String.raw`f'(x) &= 2x`,
String.raw`\end{aligned}`,
'```',
].join('\n')
const html = useMarkdown().processSync(markdown)
expect(mathNodeCount(html)).toBe(1)
expect(html).not.toContain('<merror')
expect(html).toContain('\\begin{aligned}')
expect(html).toContain('\\end{aligned}')
})
it('keeps macros and their uses in one tex block', () => {
const markdown = [
'```tex block',
String.raw`\newcommand{\foo}{x=1}`,
String.raw`\foo=2`,
'```',
].join('\n')
const html = useMarkdown().processSync(markdown)
expect(mathNodeCount(html)).toBe(1)
expect(html).not.toContain('<merror')
expect(html).toContain('\\newcommand{\\foo}{x=1}\n\\foo=2')
})
// https://github.com/moeru-ai/airi/discussions/2239
it('keeps single dollar signs as text for Issue #2239', () => {
// ROOT CAUSE:
//
// Single-dollar math pairs normal currency signs before Markdown can know
// whether the content is prose or a formula. Word-based recovery rules
// then conflict with valid variables and units.
//
// We fixed this by disabling single-dollar math for the chat syntax.
const markdown = [
'Price is $5 and cost is $10.',
'Prices are $5 and $10.',
'Tickets cost $5-$10.',
'Tickets cost $5 to $10.',
'The old formula syntax is $5 + x$.',
'Values are $5$ and $10$.',
].join('\n\n')
const html = useMarkdown().processSync(markdown)
expect(mathNodeCount(html)).toBe(0)
expect(html).toContain('Price is $5 and cost is $10.')
expect(html).toContain('Prices are $5 and $10.')
expect(html).toContain('Tickets cost $5-$10.')
expect(html).toContain('Tickets cost $5 to $10.')
expect(html).toContain('The old formula syntax is $5 + x$.')
expect(html).toContain('Values are $5$ and $10$.')
})
it('renders double-dollar inline math', () => {
const html = useMarkdown().processSync('The result is $$5 + x$$.')
expect(mathNodeCount(html)).toBe(1)
expect(html).toContain('<annotation encoding="application/x-tex">5 + x</annotation>')
})
it('renders each display-math block as one formula', () => {
const markdown = [
'$$',
'x = 1',
'$$',
'',
'$$',
String.raw`y \in A`,
'$$',
].join('\n')
const html = useMarkdown().processSync(markdown)
expect(mathNodeCount(html)).toBe(2)
})
it('keeps invalid formula source visible', () => {
const html = useMarkdown().processSync('$$\\notacommand{x}$$')
expect(html).toContain('\\notacommand{x}')
expect(html).not.toContain('<a href=')
})
it('does not create a link for an untrusted KaTeX URL', () => {
const html = useMarkdown().processSync(String.raw`$$\href{javascript:alert(1)}{x}$$`)
expect(html).toContain(String.raw`\href{javascript:alert(1)}{x}`)
expect(html).not.toContain('<a href=')
})
})
+15 -9
View File
@@ -5,27 +5,33 @@ import type { Processor } from 'unified'
import rehypeShiki from '@shikijs/rehype'
import rehypeKatex from 'rehype-katex'
import RehypeStringify from 'rehype-stringify'
import remarkMath from 'remark-math'
import RemarkParse from 'remark-parse'
import RemarkRehype from 'remark-rehype'
import { defaultPerfTracer } from '@proj-airi/stage-shared'
import { unified } from 'unified'
import { visit } from 'unist-util-visit'
import { chatMathPreset, isChatMathFenceLanguage } from './chat-math'
// Define a specific, compatible type for our processor to ensure type safety.
type MarkdownProcessor = Processor<any, any, any, any, string>
const processorCache = new Map<string, Promise<MarkdownProcessor>>()
const langRegex = /```(.{2,})\s/g
// The rich path parses once for language discovery so info strings and fences
// inside blockquotes or nested lists follow Remark rules instead of a regex.
const languageParser = unified()
.use(RemarkParse)
.use(chatMathPreset)
function extractLangs(markdown: string): BundledLanguage[] {
const matches = markdown.matchAll(langRegex)
const tree = languageParser.parse(markdown)
const langs = new Set<BundledLanguage>()
langs.add('python')
for (const match of matches) {
if (match[1])
langs.add(match[1] as BundledLanguage)
}
visit(tree, 'code', (node) => {
if (node.lang && !isChatMathFenceLanguage(node.lang))
langs.add(node.lang as BundledLanguage)
})
return [...langs]
}
@@ -61,7 +67,7 @@ async function createProcessor(langs: BundledLanguage[]): Promise<MarkdownProces
return unified()
.use(RemarkParse)
.use(remarkMath)
.use(chatMathPreset)
.use(RemarkRehype)
.use(measuredKatex, { output: 'mathml' })
.use(rehypeShiki, options)
@@ -83,7 +89,7 @@ function getProcessor(langs: BundledLanguage[]): Promise<MarkdownProcessor> {
export function useMarkdown() {
const fallbackProcessor = unified()
.use(RemarkParse)
.use(remarkMath)
.use(chatMathPreset)
.use(RemarkRehype)
.use(measuredKatex, { output: 'mathml' })
.use(RehypeStringify)
@@ -606,6 +606,21 @@ describe('chat-session-store · cloud deletion', () => {
})
describe('chat-session-store · active card prompt edits', () => {
// https://github.com/moeru-ai/airi/discussions/2239
it('adds the AIRI chat math syntax to the system message for Issue #2239', async () => {
const store = useChatSessionStore()
await store.initialize()
const content = store.messages[0]?.content
expect(content).toContain('Use $$...$$ for inline math.')
expect(content).toContain('Use a separate multiline $$ block for each display equation.')
expect(content).toContain('Use a latex fence for a list of independent one-line equations.')
expect(content).toContain('Use a math fence for one multiline equation or LaTeX environment.')
expect(content).toContain('Do not use single dollar signs as math delimiters.')
expect(content).not.toContain('eg: $ x^3 $')
})
// ROOT CAUSE:
//
// Editing the active card updates `systemPrompt`, but the session store only
@@ -124,7 +124,13 @@ export const useChatSessionStore = defineStore('chat-session', () => {
// I know this nu uh, better than loading all language on rehypeShiki
const codeBlockSystemPrompt = '- For any programming code block, always specify the programming language that supported on @shikijs/rehype on the rendered markdown, eg. ```python ... ```\n'
const mathSyntaxSystemPrompt = '- For any math equation, use LaTeX format, eg: $ x^3 $, always escape dollar sign outside math equation\n'
const mathSyntaxSystemPrompt = `${[
'- Use $$...$$ for inline math.',
'- Use a separate multiline $$ block for each display equation.',
'- Use a latex fence for a list of independent one-line equations.',
'- Use a math fence for one multiline equation or LaTeX environment.',
'- Do not use single dollar signs as math delimiters.',
].join('\n')}\n`
function getCurrentUserId() {
return userId.value || 'local'