From 7d2836643c5b7b66a0216483b54116c8c06f16b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 16 May 2026 13:29:08 +0800 Subject: [PATCH] Render model math as readable desktop markdown Desktop assistant responses previously showed LaTeX source for common model outputs. The markdown renderer now extracts math outside code spans and fences, renders it through KaTeX, and applies chat-safe layout rules for inline, block, multiline, matrix, and long-form formulas. Constraint: Desktop chat already forces long markdown text to wrap, so KaTeX internals must opt out of that wrapping while display blocks handle overflow locally Rejected: Render all dollar-delimited text blindly | currency and escaped dollar text would become false-positive formulas Confidence: high Scope-risk: narrow Directive: Keep math extraction before marked parsing and preserve code fence protection when extending markdown rendering Tested: bun run test --run src/components/markdown/MarkdownRenderer.test.tsx Tested: bun run lint Tested: bun run build Tested: WebUI complex formula screenshot and 390px viewport overflow check Not-tested: Full desktop gate still has unrelated /goal command hint expectation drift --- desktop/bun.lock | 1 + desktop/package.json | 1 + .../markdown/MarkdownRenderer.test.tsx | 104 ++++++++++ .../components/markdown/MarkdownRenderer.tsx | 196 +++++++++++++++++- 4 files changed, 294 insertions(+), 8 deletions(-) diff --git a/desktop/bun.lock b/desktop/bun.lock index ba1833f1..bc30800c 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -10,6 +10,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "dompurify": "^3.3.3", + "katex": "^0.16.45", "lucide-react": "^0.469.0", "marked": "^15.0.7", "mermaid": "^11.14.0", diff --git a/desktop/package.json b/desktop/package.json index 079a7459..9ce7d338 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -21,6 +21,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "dompurify": "^3.3.3", + "katex": "^0.16.45", "lucide-react": "^0.469.0", "marked": "^15.0.7", "mermaid": "^11.14.0", diff --git a/desktop/src/components/markdown/MarkdownRenderer.test.tsx b/desktop/src/components/markdown/MarkdownRenderer.test.tsx index 832e188a..5daca8a1 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.test.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.test.tsx @@ -18,6 +18,12 @@ vi.mock('../chat/MermaidRenderer', () => ({ import { MarkdownRenderer } from './MarkdownRenderer' +function visibleMathText(container: HTMLElement): string { + const clone = container.cloneNode(true) as HTMLElement + clone.querySelectorAll('annotation').forEach((node) => node.remove()) + return clone.textContent ?? '' +} + describe('MarkdownRenderer', () => { it('applies document prose classes and custom width classes', () => { const { container } = render( @@ -108,6 +114,104 @@ describe('MarkdownRenderer', () => { expect(screen.queryByTestId('mermaid-renderer')).not.toBeInTheDocument() }) + it('renders inline and block LaTeX formulas with KaTeX', () => { + const { container } = render( + , + ) + + expect(container.querySelectorAll('.katex')).toHaveLength(2) + expect(container.querySelector('.md-math-inline')).toBeInTheDocument() + expect(container.querySelector('.md-math-display')).toBeInTheDocument() + expect(container.textContent).not.toContain('$E = mc^2$') + expect(container.textContent).not.toContain('$$') + }) + + it('renders multi-line display LaTeX formulas', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.md-math-display .katex')).toBeInTheDocument() + }) + + it('renders bracket-delimited inline and display formulas', () => { + const { container } = render( + , + ) + + expect(container.querySelectorAll('.katex')).toHaveLength(2) + expect(container.querySelector('.md-math-inline .katex')).toBeInTheDocument() + expect(container.querySelector('.md-math-display .katex-display')).toBeInTheDocument() + expect(container.textContent).not.toContain('\\(A = \\pi r^2\\)') + expect(container.textContent).not.toContain('\\[') + }) + + it('renders complex display formulas without exposing TeX source', () => { + const { container } = render( + 0 \\\\', + '3x + 4y, & x \\le 0', + '\\end{cases}', + '$$', + ].join('\n')} + />, + ) + + expect(container.querySelector('.md-math-display .katex')).toBeInTheDocument() + expect(visibleMathText(container)).not.toContain('\\begin{bmatrix}') + expect(visibleMathText(container)).not.toContain('\\begin{cases}') + }) + + it('keeps math layout protected from markdown forced wrapping rules', () => { + const { container } = render( + , + ) + + const root = container.firstChild as HTMLDivElement + expect(root.className).toContain('[&_.katex]:[white-space:nowrap]') + expect(root.className).toContain('[&_.katex]:[overflow-wrap:normal]') + expect(root.className).toContain('[&_.md-math-display]:justify-center') + expect(container.querySelector('.md-math-display .katex')).toBeInTheDocument() + }) + + it('does not treat escaped dollars or currency text as formulas', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.katex')).not.toBeInTheDocument() + expect(container.textContent).toContain('$5') + expect(container.textContent).toContain('$10') + }) + + it('does not render LaTeX inside inline code or code fences', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.katex')).not.toBeInTheDocument() + expect(screen.getByText('$E = mc^2$')).toBeInTheDocument() + expect(screen.getByTestId('code-viewer')).toHaveTextContent('$$\\int_0^1 x^2 dx$$') + }) + it('wraps markdown tables for horizontal overflow handling', () => { const { container } = render( = 0 && text[i] === '\\'; i--) { + backslashCount += 1 + } + if (backslashCount % 2 === 0) return index + index = text.indexOf(delimiter, index + delimiter.length) + } + return -1 +} + +function consumeMath( + content: string, + mathBlocks: MathBlock[], + start: number, + open: string, + close: string, + displayMode: boolean, +): { replacement: string; end: number } | null { + const contentStart = start + open.length + const end = findUnescapedDelimiter(content, close, contentStart) + if (end === -1) return null + + const tex = content.slice(contentStart, end) + if (!tex.trim()) return null + if (!displayMode && /[\n\r]/.test(tex)) return null + if (open === '$' && (/\s/.test(content[contentStart] ?? '') || /\s/.test(content[end - 1] ?? ''))) { + return null + } + + const id = `math-${mathBlocks.length}` + mathBlocks.push({ id, tex, displayMode }) + const tag = displayMode ? 'div' : 'span' + const spacing = displayMode ? '\n\n' : '' + return { + replacement: `${spacing}<${tag} data-math-id="${id}">${spacing}`, + end: end + close.length, + } +} + +function extractMathFromSegment(segment: string, mathBlocks: MathBlock[]): string { + let output = '' + let index = 0 + + while (index < segment.length) { + if (segment[index] === '`') { + const match = /^`+/.exec(segment.slice(index)) + const ticks = match?.[0] ?? '`' + const end = segment.indexOf(ticks, index + ticks.length) + if (end !== -1) { + output += segment.slice(index, end + ticks.length) + index = end + ticks.length + continue + } + } + + const displayDollar = segment.startsWith('$$', index) + ? consumeMath(segment, mathBlocks, index, '$$', '$$', true) + : null + if (displayDollar) { + output += displayDollar.replacement + index = displayDollar.end + continue + } + + const displayBracket = segment.startsWith('\\[', index) + ? consumeMath(segment, mathBlocks, index, '\\[', '\\]', true) + : null + if (displayBracket) { + output += displayBracket.replacement + index = displayBracket.end + continue + } + + const inlineParen = segment.startsWith('\\(', index) + ? consumeMath(segment, mathBlocks, index, '\\(', '\\)', false) + : null + if (inlineParen) { + output += inlineParen.replacement + index = inlineParen.end + continue + } + + const inlineDollar = segment[index] === '$' && segment[index + 1] !== '$' + ? consumeMath(segment, mathBlocks, index, '$', '$', false) + : null + if (inlineDollar) { + output += inlineDollar.replacement + index = inlineDollar.end + continue + } + + output += segment[index] + index += 1 + } + + return output +} + +function extractMath(content: string): { markdown: string; mathBlocks: MathBlock[] } { + const mathBlocks: MathBlock[] = [] + const lines = content.match(/[^\n]*\n|[^\n]+/g) ?? [''] + let output = '' + let pendingMarkdown = '' + let inFence: string | null = null + + const flushPendingMarkdown = () => { + if (!pendingMarkdown) return + output += extractMathFromSegment(pendingMarkdown, mathBlocks) + pendingMarkdown = '' + } + + for (const line of lines) { + const fenceMatch = CODE_FENCE_START.exec(line) + if (fenceMatch) { + const marker = fenceMatch[1]!.charAt(0) + if (!inFence) { + flushPendingMarkdown() + inFence = marker + } else if (inFence === marker) { + inFence = null + } + output += line + continue + } + + if (inFence) { + output += line + } else { + pendingMarkdown += line + } + } + + flushPendingMarkdown() + + return { markdown: output, mathBlocks } +} + +function renderMath(block: MathBlock): string { + try { + return katex.renderToString(block.tex, { + displayMode: block.displayMode, + throwOnError: false, + strict: false, + trust: false, + }) + } catch { + return DOMPurify.sanitize(block.tex) + } +} + +function enhanceMarkdownHtml(html: string, mathBlocks: MathBlock[]): string { const cleanHtml = DOMPurify.sanitize(html, { ADD_TAGS: ['use'], ADD_ATTR: ['xlink:href'], @@ -83,6 +245,17 @@ function enhanceMarkdownHtml(html: string): string { const container = document.createElement('div') container.innerHTML = cleanHtml + const mathById = new Map(mathBlocks.map((block) => [block.id, block])) + + container.querySelectorAll('[data-math-id]').forEach((placeholder) => { + const block = mathById.get(placeholder.dataset.mathId ?? '') + if (!block) return + + const rendered = document.createElement(block.displayMode ? 'div' : 'span') + rendered.className = block.displayMode ? 'md-math-display' : 'md-math-inline' + rendered.innerHTML = renderMath(block) + placeholder.replaceWith(rendered) + }) container.querySelectorAll('table').forEach((table) => { if (table.parentElement?.classList.contains('md-table-wrap')) return @@ -100,12 +273,13 @@ function enhanceMarkdownHtml(html: string): string { return container.innerHTML } -function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[] } { +function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[]; mathBlocks: MathBlock[] } { pendingCodeBlocks = [] - const html = marked.parse(content) as string + const { markdown, mathBlocks } = extractMath(content) + const html = marked.parse(markdown) as string const codeBlocks = [...pendingCodeBlocks] pendingCodeBlocks = [] - return { html, codeBlocks } + return { html, codeBlocks, mathBlocks } } const BASE_PROSE_CLASSES = `markdown-prose prose prose-sm min-w-0 max-w-none break-words [overflow-wrap:anywhere] text-[var(--color-text-primary)] @@ -121,6 +295,10 @@ const BASE_PROSE_CLASSES = `markdown-prose prose prose-sm min-w-0 max-w-none bre prose-table:my-0 prose-table:w-full prose-table:table-auto prose-table:text-sm prose-th:bg-[var(--color-surface-info)] prose-th:px-3 prose-th:py-2 prose-th:text-left prose-th:whitespace-normal prose-th:break-words prose-th:align-top prose-th:border-b prose-th:border-[var(--color-border)] prose-td:px-3 prose-td:py-2 prose-td:border-b prose-td:border-[var(--color-border)] prose-td:whitespace-normal prose-td:break-words prose-td:align-top prose-td:bg-[var(--color-surface)] + [&_.katex]:[white-space:nowrap] [&_.katex]:[overflow-wrap:normal] [&_.katex]:[word-break:normal] + [&_.md-math-inline]:inline-flex [&_.md-math-inline]:max-w-full [&_.md-math-inline]:overflow-x-auto [&_.md-math-inline]:[vertical-align:-0.08em] [&_.md-math-inline_.katex]:text-[1.02em] + [&_.md-math-display]:my-5 [&_.md-math-display]:flex [&_.md-math-display]:max-w-full [&_.md-math-display]:justify-center [&_.md-math-display]:overflow-x-auto [&_.md-math-display]:px-1 [&_.md-math-display]:py-2 [&_.md-math-display]:[scrollbar-width:thin] + [&_.md-math-display_.katex-display]:m-0 [&_.md-math-display_.katex]:text-[1.14em] [&_.md-math-display_.katex-html]:min-w-max [&_.md-table-wrap]:my-5 [&_.md-table-wrap]:overflow-x-auto [&_.md-table-wrap]:rounded-xl [&_.md-table-wrap]:border [&_.md-table-wrap]:border-[var(--color-border)] [&_.md-table-wrap]:bg-[var(--color-surface-container-lowest)]` const DOCUMENT_PROSE_CLASSES = ` @@ -137,7 +315,8 @@ const DOCUMENT_PROSE_CLASSES = ` prose-ul:pl-5 prose-ul:[&>li]:marker:text-[var(--color-text-tertiary)] prose-ol:pl-5 prose-ol:[&>li]:marker:text-[var(--color-text-tertiary)] prose-li:my-1.5 - prose-table:my-0` + prose-table:my-0 + [&_.md-math-display]:my-6 [&_.md-math-display_.katex]:text-[1.18em]` const COMPACT_PROSE_CLASSES = ` prose-p:my-1 prose-p:text-xs prose-p:leading-5 prose-p:text-[var(--color-text-secondary)] @@ -148,6 +327,7 @@ const COMPACT_PROSE_CLASSES = ` prose-ul:my-1 prose-ol:my-1 prose-ul:pl-4 prose-ol:pl-4 prose-li:my-0.5 prose-li:text-xs prose-li:leading-5 prose-li:text-[var(--color-text-secondary)] prose-table:text-xs + [&_.md-math-display]:my-2 [&_.md-math-display]:py-1 [&_.md-math-display_.katex]:text-[1.04em] [&_.md-table-wrap]:my-2` function getProseClasses(variant: 'default' | 'document' | 'compact', className?: string) { @@ -162,7 +342,7 @@ function getProseClasses(variant: 'default' | 'document' | 'compact', className? } export function MarkdownRenderer({ content, variant = 'default', className, onLinkClick }: Props) { - const { html, codeBlocks } = useMemo(() => parseMarkdown(content), [content]) + const { html, codeBlocks, mathBlocks } = useMemo(() => parseMarkdown(content), [content]) const proseClasses = useMemo( () => getProseClasses(variant, className), [variant, className], @@ -225,7 +405,7 @@ export function MarkdownRenderer({ content, variant = 'default', className, onLi }, [onLinkClick]) if (codeBlocks.length === 0) { - const cleanHtml = enhanceMarkdownHtml(html) + const cleanHtml = enhanceMarkdownHtml(html, mathBlocks) return (
{parts.map((part, i) => part.type === 'html' ? ( -
+
) : shouldRenderAsMermaid(part.block) ? ( ) : (