mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Reduce formula-heavy chat render cost
Formula-heavy conversations were still expensive after removing the fixed-height virtual chat window because every render repeated KaTeX expansion and HTML post-processing. Keep readable math, but reduce the DOM and CPU cost by rendering KaTeX without the hidden MathML copy, caching repeated formula output, and memoizing enhanced HTML parts outside the JSX render path. Constraint: H5 streaming scroll follow behavior must remain unchanged Rejected: Restore fixed-height virtualization | it caused blank gaps for variable-height math and code messages Rejected: Disable LaTeX rendering | formula readability is a user-facing feature Confidence: medium Scope-risk: narrow Directive: Do not reintroduce estimated-height chat virtualization without measured row heights for math-heavy messages Tested: cd desktop && bun run test -- MarkdownRenderer.test.tsx MessageList.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: git diff --check Not-tested: Manual desktop scroll trace on the user's formula-heavy production conversation
This commit is contained in:
parent
5e899bb1a1
commit
2d7d11d822
@ -122,6 +122,8 @@ describe('MarkdownRenderer', () => {
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.katex')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('.katex-html')).toHaveLength(2)
|
||||
expect(container.querySelector('.katex-mathml')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.md-math-inline')).toBeInTheDocument()
|
||||
expect(container.querySelector('.md-math-display')).toBeInTheDocument()
|
||||
expect(container.textContent).not.toContain('$E = mc^2$')
|
||||
|
||||
@ -27,10 +27,16 @@ type MathBlock = {
|
||||
displayMode: boolean
|
||||
}
|
||||
|
||||
type HtmlPart = { type: 'html'; content: string }
|
||||
type CodePart = { type: 'code'; block: CodeBlock }
|
||||
type MarkdownPart = HtmlPart | CodePart
|
||||
|
||||
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,})/
|
||||
const MATH_RENDER_CACHE_LIMIT = 200
|
||||
const mathRenderCache = new Map<string, string>()
|
||||
|
||||
function normalizeCodeLanguage(language: string | undefined): string | undefined {
|
||||
const normalized = language?.trim().split(/\s+/)[0]?.toLowerCase()
|
||||
@ -221,13 +227,24 @@ function extractMath(content: string): { markdown: string; mathBlocks: MathBlock
|
||||
}
|
||||
|
||||
function renderMath(block: MathBlock): string {
|
||||
const cacheKey = `${block.displayMode ? 'block' : 'inline'}\0${block.tex}`
|
||||
const cached = mathRenderCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
try {
|
||||
return katex.renderToString(block.tex, {
|
||||
const rendered = katex.renderToString(block.tex, {
|
||||
displayMode: block.displayMode,
|
||||
output: 'html',
|
||||
throwOnError: false,
|
||||
strict: false,
|
||||
trust: false,
|
||||
})
|
||||
mathRenderCache.set(cacheKey, rendered)
|
||||
if (mathRenderCache.size > MATH_RENDER_CACHE_LIMIT) {
|
||||
const firstKey = mathRenderCache.keys().next().value
|
||||
if (firstKey) mathRenderCache.delete(firstKey)
|
||||
}
|
||||
return rendered
|
||||
} catch {
|
||||
return DOMPurify.sanitize(block.tex)
|
||||
}
|
||||
@ -239,6 +256,11 @@ function enhanceMarkdownHtml(html: string, mathBlocks: MathBlock[]): string {
|
||||
ADD_ATTR: ['xlink:href'],
|
||||
})
|
||||
|
||||
const needsDomEnhancement = mathBlocks.length > 0 || /<(?:a|table)\b/i.test(cleanHtml)
|
||||
if (!needsDomEnhancement) {
|
||||
return cleanHtml
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return cleanHtml
|
||||
}
|
||||
@ -350,10 +372,10 @@ export function MarkdownRenderer({ content, variant = 'default', className, onLi
|
||||
|
||||
const parts = useMemo(() => {
|
||||
if (codeBlocks.length === 0) {
|
||||
return [{ type: 'html' as const, content: html }]
|
||||
return [{ type: 'html' as const, content: enhanceMarkdownHtml(html, mathBlocks) }]
|
||||
}
|
||||
|
||||
const result: Array<{ type: 'html'; content: string } | { type: 'code'; block: CodeBlock }> = []
|
||||
const result: MarkdownPart[] = []
|
||||
let remaining = html
|
||||
|
||||
for (const block of codeBlocks) {
|
||||
@ -363,18 +385,18 @@ export function MarkdownRenderer({ content, variant = 'default', className, onLi
|
||||
|
||||
const before = remaining.slice(0, idx)
|
||||
if (before) {
|
||||
result.push({ type: 'html', content: before })
|
||||
result.push({ type: 'html', content: enhanceMarkdownHtml(before, mathBlocks) })
|
||||
}
|
||||
result.push({ type: 'code', block })
|
||||
remaining = remaining.slice(idx + marker.length)
|
||||
}
|
||||
|
||||
if (remaining) {
|
||||
result.push({ type: 'html', content: remaining })
|
||||
result.push({ type: 'html', content: enhanceMarkdownHtml(remaining, mathBlocks) })
|
||||
}
|
||||
|
||||
return result
|
||||
}, [html, codeBlocks])
|
||||
}, [html, codeBlocks, mathBlocks])
|
||||
|
||||
const handleClick = useCallback(async (event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
const target = event.target as HTMLElement | null
|
||||
@ -405,11 +427,10 @@ export function MarkdownRenderer({ content, variant = 'default', className, onLi
|
||||
}, [onLinkClick])
|
||||
|
||||
if (codeBlocks.length === 0) {
|
||||
const cleanHtml = enhanceMarkdownHtml(html, mathBlocks)
|
||||
return (
|
||||
<div
|
||||
className={proseClasses}
|
||||
dangerouslySetInnerHTML={{ __html: cleanHtml }}
|
||||
dangerouslySetInnerHTML={{ __html: parts[0]?.type === 'html' ? parts[0].content : '' }}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
)
|
||||
@ -419,7 +440,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, mathBlocks) }} />
|
||||
<div key={i} dangerouslySetInnerHTML={{ __html: part.content }} />
|
||||
) : shouldRenderAsMermaid(part.block) ? (
|
||||
<MermaidRenderer key={part.block.id} code={part.block.code} />
|
||||
) : (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user