cc-haha/desktop/src/components/markdown/MarkdownRenderer.tsx
程序员阿江(Relakkes) 7d2836643c 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
2026-05-16 13:29:08 +08:00

437 lines
15 KiB
TypeScript

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'
import { copyTextToClipboard } from '../chat/clipboard'
type Props = {
content: string
variant?: 'default' | 'document' | 'compact'
className?: string
onLinkClick?: (href: string, event: ReactMouseEvent<HTMLDivElement>) => boolean | void
}
type CodeBlock = {
id: string
code: string
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()
return normalized || undefined
}
function looksLikeMermaid(code: string): boolean {
const firstMeaningfulLine = code
.split('\n')
.map((line) => line.trim())
.find(Boolean)
return firstMeaningfulLine ? MERMAID_DIAGRAM_START.test(firstMeaningfulLine) : false
}
function shouldRenderAsMermaid(block: CodeBlock): boolean {
const normalizedLanguage = normalizeCodeLanguage(block.language)
if (normalizedLanguage === MERMAID_LANGUAGE) {
return true
}
if (!PLAINTEXT_LANGUAGES.has(normalizedLanguage ?? '')) {
return false
}
return looksLikeMermaid(block.code)
}
const renderer = new marked.Renderer()
let pendingCodeBlocks: CodeBlock[] = []
renderer.code = function ({ text, lang }: Tokens.Code) {
const id = `cb-${pendingCodeBlocks.length}`
pendingCodeBlocks.push({
id,
code: text,
language: normalizeCodeLanguage(lang || undefined),
})
return `<div data-codeblock-id="${id}"></div>`
}
marked.setOptions({
breaks: true,
gfm: true,
})
marked.use({ renderer })
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'],
})
if (typeof document === 'undefined') {
return cleanHtml
}
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
const wrapper = document.createElement('div')
wrapper.className = 'md-table-wrap'
table.parentNode?.insertBefore(wrapper, table)
wrapper.appendChild(table)
})
container.querySelectorAll('a[href]').forEach((link) => {
link.setAttribute('target', '_blank')
link.setAttribute('rel', 'noreferrer noopener')
})
return container.innerHTML
}
function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[]; mathBlocks: MathBlock[] } {
pendingCodeBlocks = []
const { markdown, mathBlocks } = extractMath(content)
const html = marked.parse(markdown) as string
const codeBlocks = [...pendingCodeBlocks]
pendingCodeBlocks = []
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)]
prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold
prose-p:my-2 prose-p:leading-relaxed
prose-p:break-words prose-p:[overflow-wrap:anywhere]
prose-code:text-[13px] prose-code:text-[var(--color-code-fg)] prose-code:font-[var(--font-mono)] prose-code:bg-[var(--color-code-bg)] prose-code:border prose-code:border-[var(--color-border)] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:before:hidden prose-code:after:hidden
prose-pre:!bg-transparent prose-pre:!p-0 prose-pre:!shadow-none
prose-a:text-[var(--color-text-accent)] prose-a:no-underline prose-a:[overflow-wrap:anywhere] hover:prose-a:underline
prose-strong:text-[var(--color-text-primary)]
prose-ul:my-2 prose-ol:my-2
prose-li:my-0.5
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 = `
prose-p:text-[15px] prose-p:leading-7
prose-headings:scroll-mt-6 prose-headings:tracking-[-0.01em]
prose-h1:mb-4 prose-h1:text-2xl prose-h1:font-semibold prose-h1:leading-tight
prose-h2:mt-8 prose-h2:mb-3 prose-h2:border-b prose-h2:border-[var(--color-border)] prose-h2:pb-2 prose-h2:text-xl prose-h2:font-semibold
prose-h3:mt-6 prose-h3:mb-2 prose-h3:text-base prose-h3:font-semibold
prose-h4:mt-5 prose-h4:mb-2 prose-h4:text-sm prose-h4:font-semibold
prose-blockquote:my-4 prose-blockquote:rounded-r-lg prose-blockquote:border-l-4 prose-blockquote:border-[var(--color-outline-variant)] prose-blockquote:bg-[var(--color-surface-container-low)] prose-blockquote:px-4 prose-blockquote:py-2 prose-blockquote:italic
prose-hr:my-6 prose-hr:border-[var(--color-border)]
prose-img:rounded-lg prose-img:border prose-img:border-[var(--color-border)]
prose-kbd:rounded prose-kbd:border prose-kbd:border-[var(--color-border)] prose-kbd:bg-[var(--color-surface-container-lowest)] prose-kbd:px-1.5 prose-kbd:py-0.5 prose-kbd:font-[var(--font-mono)] prose-kbd:text-[12px] prose-kbd:font-normal prose-kbd:text-[var(--color-text-secondary)] prose-kbd:shadow-none
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
[&_.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)]
prose-headings:mt-2 prose-headings:mb-1 prose-headings:leading-snug
prose-h1:text-base prose-h2:text-sm prose-h3:text-xs prose-h4:text-xs
prose-blockquote:my-2 prose-blockquote:border-l-2 prose-blockquote:border-[var(--color-outline-variant)] prose-blockquote:pl-3 prose-blockquote:text-[var(--color-text-secondary)]
prose-code:text-[12px]
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) {
return [
BASE_PROSE_CLASSES,
variant === 'document' ? DOCUMENT_PROSE_CLASSES : '',
variant === 'compact' ? COMPACT_PROSE_CLASSES : '',
className ?? '',
]
.filter(Boolean)
.join(' ')
}
export function MarkdownRenderer({ content, variant = 'default', className, onLinkClick }: Props) {
const { html, codeBlocks, mathBlocks } = useMemo(() => parseMarkdown(content), [content])
const proseClasses = useMemo(
() => getProseClasses(variant, className),
[variant, className],
)
const parts = useMemo(() => {
if (codeBlocks.length === 0) {
return [{ type: 'html' as const, content: html }]
}
const result: Array<{ type: 'html'; content: string } | { type: 'code'; block: CodeBlock }> = []
let remaining = html
for (const block of codeBlocks) {
const marker = `<div data-codeblock-id="${block.id}"></div>`
const idx = remaining.indexOf(marker)
if (idx === -1) continue
const before = remaining.slice(0, idx)
if (before) {
result.push({ type: 'html', content: before })
}
result.push({ type: 'code', block })
remaining = remaining.slice(idx + marker.length)
}
if (remaining) {
result.push({ type: 'html', content: remaining })
}
return result
}, [html, codeBlocks])
const handleClick = useCallback(async (event: ReactMouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement | null
const button = target?.closest<HTMLButtonElement>('[data-copy-code]')
if (!button) {
const link = target?.closest<HTMLAnchorElement>('a[href]')
if (!link || !onLinkClick) return
const handled = onLinkClick(link.getAttribute('href') ?? '', event)
if (handled) {
event.preventDefault()
event.stopPropagation()
}
return
}
const text = button.getAttribute('data-copy-code')
if (!text) return
const copied = await copyTextToClipboard(text)
if (!copied) return
const original = button.textContent
button.textContent = 'Copied'
window.setTimeout(() => {
button.textContent = original
}, 1500)
}, [onLinkClick])
if (codeBlocks.length === 0) {
const cleanHtml = enhanceMarkdownHtml(html, mathBlocks)
return (
<div
className={proseClasses}
dangerouslySetInnerHTML={{ __html: cleanHtml }}
onClick={handleClick}
/>
)
}
return (
<div className={proseClasses} onClick={handleClick}>
{parts.map((part, i) =>
part.type === 'html' ? (
<div key={i} dangerouslySetInnerHTML={{ __html: enhanceMarkdownHtml(part.content, mathBlocks) }} />
) : shouldRenderAsMermaid(part.block) ? (
<MermaidRenderer key={part.block.id} code={part.block.code} />
) : (
<div key={part.block.id} className="my-4">
<CodeViewer
code={part.block.code}
language={part.block.language}
/>
</div>
)
)}
</div>
)
}