mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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
437 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|