mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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
This commit is contained in:
parent
818dc7a178
commit
7d2836643c
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
<MarkdownRenderer
|
||||
content={'Inline formula: $E = mc^2$\n\nBlock formula:\n\n$$\\int_0^1 x^2 \\, dx = \\frac{1}{3}$$'}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<MarkdownRenderer
|
||||
content={'$$\n\\begin{aligned}\na &= b + c \\\\\nd &= e + f\n\\end{aligned}\n$$'}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.md-math-display .katex')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders bracket-delimited inline and display formulas', () => {
|
||||
const { container } = render(
|
||||
<MarkdownRenderer
|
||||
content={'圆面积是 \\(A = \\pi r^2\\)。\n\n\\[\nE = mc^2\n\\]'}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<MarkdownRenderer
|
||||
content={[
|
||||
'矩阵和分段函数:',
|
||||
'',
|
||||
'$$',
|
||||
'\\begin{bmatrix}1 & 2 \\\\ 3 & 4\\end{bmatrix}',
|
||||
'\\begin{bmatrix}x \\\\ y\\end{bmatrix}',
|
||||
'=',
|
||||
'\\begin{cases}',
|
||||
'x + 2y, & x > 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(
|
||||
<MarkdownRenderer
|
||||
content={'Long display:\n\n$$\\sum_{i=1}^{n} \\left(y_i - \\hat{y}_i\\right)^2 + \\frac{\\alpha}{2}\\|w\\|_2^2 = \\mathcal{L}(w, b)$$'}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<MarkdownRenderer content={'Price is \\$5 and not math. Range is $5 to $10.'} />,
|
||||
)
|
||||
|
||||
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(
|
||||
<MarkdownRenderer
|
||||
content={'Keep `$E = mc^2$` as code.\n\n```text\n$$\\int_0^1 x^2 dx$$\n```'}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<MarkdownRenderer
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { useMemo, useCallback } from 'react'
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react'
|
||||
import DOMPurify from 'dompurify'
|
||||
import katex from 'katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import { marked, type Tokens } from 'marked'
|
||||
import { CodeViewer } from '../chat/CodeViewer'
|
||||
import { MermaidRenderer } from '../chat/MermaidRenderer'
|
||||
@ -19,9 +21,16 @@ type CodeBlock = {
|
||||
language: string | undefined
|
||||
}
|
||||
|
||||
type MathBlock = {
|
||||
id: string
|
||||
tex: string
|
||||
displayMode: boolean
|
||||
}
|
||||
|
||||
const MERMAID_LANGUAGE = 'mermaid'
|
||||
const PLAINTEXT_LANGUAGES = new Set(['', 'text', 'plaintext', 'plain'])
|
||||
const MERMAID_DIAGRAM_START = /^(graph|flowchart|sequenceDiagram|classDiagram|stateDiagram(?:-v2)?|erDiagram|journey|gantt|pie|gitGraph|mindmap|timeline|requirementDiagram|quadrantChart|xychart-beta|sankey-beta|block-beta|packet-beta|architecture|kanban)\b/i
|
||||
const CODE_FENCE_START = /^ {0,3}(`{3,}|~{3,})/
|
||||
|
||||
function normalizeCodeLanguage(language: string | undefined): string | undefined {
|
||||
const normalized = language?.trim().split(/\s+/)[0]?.toLowerCase()
|
||||
@ -71,7 +80,160 @@ marked.setOptions({
|
||||
})
|
||||
marked.use({ renderer })
|
||||
|
||||
function enhanceMarkdownHtml(html: string): string {
|
||||
function findUnescapedDelimiter(text: string, delimiter: string, fromIndex: number): number {
|
||||
let index = text.indexOf(delimiter, fromIndex)
|
||||
while (index !== -1) {
|
||||
let backslashCount = 0
|
||||
for (let i = index - 1; i >= 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}"></${tag}>${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<HTMLElement>('[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 (
|
||||
<div
|
||||
className={proseClasses}
|
||||
@ -239,7 +419,7 @@ export function MarkdownRenderer({ content, variant = 'default', className, onLi
|
||||
<div className={proseClasses} onClick={handleClick}>
|
||||
{parts.map((part, i) =>
|
||||
part.type === 'html' ? (
|
||||
<div key={i} dangerouslySetInnerHTML={{ __html: enhanceMarkdownHtml(part.content) }} />
|
||||
<div key={i} dangerouslySetInnerHTML={{ __html: enhanceMarkdownHtml(part.content, mathBlocks) }} />
|
||||
) : shouldRenderAsMermaid(part.block) ? (
|
||||
<MermaidRenderer key={part.block.id} code={part.block.code} />
|
||||
) : (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user