From 3d4e9577b56f98aa6f6d2fd46028cf411f21eaae Mon Sep 17 00:00:00 2001 From: mesalogo Date: Fri, 2 Oct 2026 14:41:56 +0800 Subject: [PATCH] perf: keep Markdown segmentation for single-line display math and ship KaTeX fonts as files --- ...performance-experience-improvement-plan.md | 32 ++++++++++- electron.vite.config.ts | 12 ++++ src/renderer/src/MarkdownRenderer.test.tsx | 53 +++++++++++++++++- src/renderer/src/MarkdownRenderer.tsx | 55 +++++++++++++------ tests/electron-vite-config.test.ts | 8 +++ 5 files changed, 139 insertions(+), 21 deletions(-) diff --git a/docs/roadmap/product-performance-experience-improvement-plan.md b/docs/roadmap/product-performance-experience-improvement-plan.md index 6673b77f..adf81f1e 100644 --- a/docs/roadmap/product-performance-experience-improvement-plan.md +++ b/docs/roadmap/product-performance-experience-improvement-plan.md @@ -474,7 +474,8 @@ frame rate, heap measurements and 500KB/1MB scenarios remain unmeasured. - 有历史时侧栏一次渲染全部 302 行,DOM 约 4,400 节点;访问过若干会话后约 1.6 万;两次 流式后约 4 万,JS heap 最高约 210 MB(keep-alive 缓存持续增长)。 - 长会话只渲染最近 80 条,打开耗时主要是一次约 100 ms 的 Long Task。 -- KaTeX 的 `data:` 字体被 CSP 拒绝(缺少 `font-src`),每轮 8 条控制台错误,字体回退。 +- KaTeX 的 `data:` 字体被 CSP 拒绝(缺少 `font-src`),每轮 8 条控制台错误,字体回退 + (已在 `PERF-12` 第一步修复)。 - `app.getAppMetrics()` 的 CPU 百分比在 Windows 上按全部逻辑核归一,只作相对比较。 **归因(剖析运行):** 流式期间 Renderer 自身 CPU 约 74% 在应用 bundle 中,热点是 @@ -499,13 +500,40 @@ p95 从约 36 ms 降到 15.5 ms。 ### PERF-12 流式与输入快速止血 -- **优先级 / 状态:** P1 / 待开始 +- **优先级 / 状态:** P1 / 进行中(Markdown 分段已完成) - **范围:** 流式 delta 在 Renderer 按 `requestAnimationFrame` 合并后提交,并以 transition 降低优先级;缓存 Markdown 预处理结果;`RightAssistantSidebar`、`ProjectSwitcher` 和 keep-alive 路由使用 memo 并稳定回调;滚动跟随只在需要时执行。 - **验收:** `PERF-11` 流式与输入场景的 Long Tasks 和输入延迟下降;事件顺序、停止、重试、 工具与 Subagent 块不回退。该项吸收 `PERF-04` 的范围。 +#### 2026-10-02 第一步:Markdown 分段与 KaTeX 字体 + +- **分段(`MarkdownRenderer.tsx`):** `splitMarkdownSegments` 按 micromark-extension-math + 的实际规则识别块级公式:`$$` 等两个以上 `$` 开头、其后不含 `$` 的行才开启公式块,并需要 + 不短于开启长度的 `$` 行关闭;`$$x^2$$` 这类单行公式按普通段落处理,不再使整篇退回单段。 + 链接定义和脚注改为逐行检查,位于顶格代码围栏内的行视为字面内容。原始 HTML 行、`\r`、 + 缩进围栏内的定义仍整篇退回。 +- **字体(`electron.vite.config.ts`):** Renderer 构建不再把字体内联为 `data:` URL。CSP + 没有 `font-src`,原先 4 个小于 4 KB 的 KaTeX 字体被拒绝并回退;现在 60 个 KaTeX 字体 + 全部作为同源文件输出,CSP 不变。 +- **等价性验证:** 现有逐前缀对照语料新增单行公式、长 `$` 围栏、代码围栏内的定义等用例; + 新增 1,200 份确定性随机文档(半数取随机前缀)对照,要求分段渲染与整篇渲染的 DOM 完全 + 一致。开发期另跑了约 4.8 万份随机文档与前缀,其中约 6,300 份走分段路径,全部一致。 +- **`npm run perf:app` 对比(同机,各 3 次,`80579ee` + 本改动):** + +| 场景 | 改动前 | 改动后 | +| --- | --- | --- | +| 流式 40 KB:Long Tasks | 126–137 次 / 8.6–9.3 s | 1 次 / 0.16 s | +| 流式 40 KB:帧间隔 p95 / 掉帧 | 83 ms / 45–48% | 8.6–8.9 ms / 3–5% | +| 流式期间输入:按键→帧 p50 / p95 | 12.5–13.5 / 35–36 ms | 11.7–11.9 / 19–21 ms | +| 流式期间输入:掉帧 | 50–52% | 7.7–8.5% | +| KaTeX 字体 CSP 错误 | 每轮 8 条 | 0 | + + 输入、切换、长会话和滚动场景与基线持平。流式期间输入仍有约 8% 掉帧、按键 p95 约 20 ms, + 剩余开销属于 App 级重渲染,留给 `PERF-12` 后续项和 `PERF-13`。 +- **剩余:** 流式 delta 按帧合并、大面板 memo、滚动跟随优化仍待实施。 + ### PERF-13 Renderer 领域 Store 迁移 - **优先级 / 状态:** P1 / 待开始 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index f6c4fad6..f9c77955 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -73,6 +73,15 @@ export function stableMainEntryFileName(chunk: { : '[name].js' } +// The renderer CSP has no font-src, so fonts fall back to default-src 'self'. +// Small fonts (e.g. KaTeX) must stay files rather than data: URLs, or Chromium +// refuses them. Other assets keep Vite's default inline threshold. +export function rendererAssetInlineLimit( + filePath: string +): false | undefined { + return /\.(?:woff2?|ttf|otf)(?:$|\?)/iu.test(filePath) ? false : undefined +} + export default defineConfig({ main: { plugins: [ @@ -192,6 +201,9 @@ export default defineConfig({ worker: { format: 'es' }, + build: { + assetsInlineLimit: rendererAssetInlineLimit + }, plugins: [react()] } }) diff --git a/src/renderer/src/MarkdownRenderer.test.tsx b/src/renderer/src/MarkdownRenderer.test.tsx index b9bd9fbc..ab74d2a8 100644 --- a/src/renderer/src/MarkdownRenderer.test.tsx +++ b/src/renderer/src/MarkdownRenderer.test.tsx @@ -757,7 +757,16 @@ describe('segmented Markdown rendering', () => { ' indented by three\n\n\tafter tab\n\n - nested\n\ntrailing', '```a`b\n\ntext\n\n```\n\ncode?\n\n```\n\nend', '- item\n\n ```\n x\n\n y\n ```\n\nafter\n\n> ```\n\nq', - '1. one\n\n $$\n a\n\n $$\n\nb\n\n10. ten\n\nc' + '1. one\n\n $$\n a\n\n $$\n\nb\n\n10. ten\n\nc', + // Single-line display math is a paragraph with inline math, not a block. + 'Display math:\n\n$$\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}$$\n\nafter\n\n$$x^2$$ trailing text\n\n$$a$ odd\n\nend', + '$$$\na\n\n$$\nstill math\n$$$\n\nafter\n\n$$ meta\nx\n\n$$$$\n\nnext\n\n$$\nunclosed\n\nstill unclosed', + '- item with $$x$$\n\n $$\n y\n $$\n\n> $$\n> q\n\n$$z$$\n\nend', + // Document-scoped syntax only inside unindented code fences. + '# Notes\n\n```md\n[ref]: https://example.com\nsee [^1]\n\n[^1]: body\n```\n\nafter [ref]\n\n~~~\n]:\n~~~\n\nend', + '- list\n\n ```\n [ref]: https://example.com\n\n ```\n\n[ref]\n\nend', + 'text `a]: b` inline\n\nmore\n\n```\ncode\n```\n\n[x]\n\n[x]: https://example.com', + '````\n```\n[r]: https://example.com\n```\n````\n\n[r]\n\nend' ] function canonicalHtml(element: HTMLElement): string { @@ -796,6 +805,37 @@ describe('segmented Markdown rendering', () => { expect(multiSegment).toBeGreaterThan(500) }, 15000) + it('renders random block combinations and their prefixes identically to whole-document parsing', () => { + // Deterministic generator over lines that stress segment boundaries. + const lines = [ + 'para', 'para $x$', '# h', '- a', '1. one', ' indented', ' code4', '> q', + '$$', '$$x$$', '$$$', ' $$', '$$ meta', '$$x$$ tail', '```', ' ```', '~~~', '````', '```md', + '[r]: https://e.com', ' [r]: https://e.com', 'see [r]', '[^1]', '[^1]: fn', + '| a |', '| - |', '***', '---', '===', '
', 'x' + ] + let seed = 20261002 + const random = () => { + seed = (seed * 1103515245 + 12345) % 2147483648 + return seed / 2147483648 + } + let multiSegment = 0 + for (let index = 0; index < 1200; index++) { + const full = Array.from( + { length: 3 + Math.floor(random() * 14) }, + () => random() < 0.4 ? '' : lines[Math.floor(random() * lines.length)]! + ).join('\n') + const content = index % 2 ? full.slice(0, Math.floor(random() * (full.length + 1))) : full + const segmented = render(
{content}
) + const reference = render(
{content}
) + expect(canonicalHtml(segmented.container), JSON.stringify(content)) + .toBe(canonicalHtml(reference.container)) + segmented.unmount() + reference.unmount() + if (splitMarkdownSegments(normalizeLatexDelimiters(content)).length > 1) multiSegment++ + } + expect(multiSegment).toBeGreaterThan(100) + }, 60000) + it('keeps completed segments intact and falls back for document-scoped syntax', () => { // List markers never start a segment: the list may continue or turn loose. expect(splitMarkdownSegments('a\n\nb\n\n- c\n\n- d\n\ne')).toEqual(['a\n\n', 'b\n\n- c\n\n- d\n\n', 'e']) @@ -805,6 +845,17 @@ describe('segmented Markdown rendering', () => { expect(splitMarkdownSegments('a\r\n\r\nb')).toHaveLength(1) }) + it('splits around single-line display math and literal code fences', () => { + expect(splitMarkdownSegments('a\n\n$$x^2$$\n\nb')).toEqual(['a\n\n', '$$x^2$$\n\n', 'b']) + expect(splitMarkdownSegments('$$\nx\n\ny\n$$\n\nb')).toEqual(['$$\nx\n\ny\n$$\n\n', 'b']) + // A longer opening run needs an equally long closing run. + expect(splitMarkdownSegments('$$$\nx\n$$\n\ny\n$$$\n\nb')).toEqual(['$$$\nx\n$$\n\ny\n$$$\n\n', 'b']) + expect(splitMarkdownSegments('```md\n[r]: https://example.com\n```\n\nb')).toEqual(['```md\n[r]: https://example.com\n```\n\n', 'b']) + // Indented fences may belong to a container that ends earlier. + expect(splitMarkdownSegments('- a\n\n ```\n [r]: https://example.com\n ```\n\nb')).toHaveLength(1) + expect(splitMarkdownSegments('a `[r]: x`\n\nb')).toHaveLength(1) + }) + it('re-parses only the growing tail when completed segments are unchanged', () => { const base = Array.from({ length: 30 }, (_, i) => `段落 ${i} **粗体** $x_${i}$`).join('\n\n') const { rerender, container } = render({base}) diff --git a/src/renderer/src/MarkdownRenderer.tsx b/src/renderer/src/MarkdownRenderer.tsx index 7f897776..83fd3772 100644 --- a/src/renderer/src/MarkdownRenderer.tsx +++ b/src/renderer/src/MarkdownRenderer.tsx @@ -257,13 +257,17 @@ function standaloneHtmlSource(content: string): string | undefined { // Link reference and footnote definitions resolve across the whole document, // even from inside lists or block quotes, so their presence disables splitting. +// Lines inside an unindented code fence are literal and are not checked. const documentScopedMarkdown = /\]:|\[\^/u -// Constructs that may legally span blank lines in ways a line scanner cannot -// track: raw HTML blocks and `$$` lines carrying other text. -const unsafeSegmentLine = /^ {0,3}(?:<|\$\$.*\S)/u +// Raw HTML blocks may legally span blank lines in ways a line scanner cannot +// track. +const unsafeSegmentLine = /^ {0,3})/u * could make that unsafe. */ export function splitMarkdownSegments(content: string): string[] { - if (documentScopedMarkdown.test(content) || content.includes('\r')) { + if (content.includes('\r')) { return [content] } const segments: string[] = [] - let fence: { character: '`' | '~'; length: number } | undefined - let inMath = false + let fence: { closing: RegExp; literal: boolean } | undefined + let math: { closing: RegExp } | undefined let segmentStart = 0 let segmentHasBlock = false let previousBlank = false @@ -293,18 +297,21 @@ export function splitMarkdownSegments(content: string): string[] { const lineEnd = newline === -1 ? content.length : newline const line = content.slice(lineStart, lineEnd) + // An unindented fence line always opens a top-level code block, so its + // content is literal until the matching close. Indented fences may sit in + // a container that ends earlier, so their lines are still checked. + if (!fence?.literal && documentScopedMarkdown.test(line)) { + return [content] + } + if (fence) { - const closing = new RegExp( - `^ {0,3}${fence.character}{${fence.length},}\\s*$`, - 'u' - ) - if (closing.test(line)) { + if (fence.closing.test(line)) { fence = undefined } previousBlank = false - } else if (inMath) { - if (segmentMathFence.test(line)) { - inMath = false + } else if (math) { + if (math.closing.test(line)) { + math = undefined } previousBlank = false } else if (line.trim() === '') { @@ -325,15 +332,27 @@ export function splitMarkdownSegments(content: string): string[] { } segmentHasBlock = true const opening = segmentFenceOpening.exec(line) + const mathOpening = segmentMathFenceOpening.exec(line) if (opening) { const marker = (opening[1] ?? opening[2])! - fence = { character: marker[0] as '`' | '~', length: marker.length } + fence = { + closing: new RegExp( + `^ {0,3}${marker[0]}{${marker.length},}[ \\t]*$`, + 'u' + ), + literal: line.startsWith(marker) + } } else if (/^ {0,3}`{3,}/u.test(line)) { // Backtick run with a backtick in its info string: inline code that // the line scanner cannot model, so keep the document whole. return [content] - } else if (segmentMathFence.test(line)) { - inMath = true + } else if (mathOpening && !mathOpening[2]!.includes('$')) { + math = { + closing: new RegExp( + `^ {0,3}\\\${${mathOpening[1]!.length},}[ \\t]*$`, + 'u' + ) + } } previousBlank = false } diff --git a/tests/electron-vite-config.test.ts b/tests/electron-vite-config.test.ts index 6b8ea14a..c7801e5d 100644 --- a/tests/electron-vite-config.test.ts +++ b/tests/electron-vite-config.test.ts @@ -2,11 +2,19 @@ import { describe, expect, it } from 'vitest' import { + rendererAssetInlineLimit, stableMainEntryFileName, serializeDeepSeekHarnessBundleManifest } from '../electron.vite.config' describe('Electron Vite configuration', () => { + it('never inlines renderer fonts as data URLs blocked by the CSP', () => { + expect(rendererAssetInlineLimit('/n/katex/dist/fonts/KaTeX_Size4-Regular.woff2')).toBe(false) + expect(rendererAssetInlineLimit('/n/katex/dist/fonts/KaTeX_Size4-Regular.woff')).toBe(false) + expect(rendererAssetInlineLimit('/n/katex/dist/fonts/KaTeX_Size4-Regular.ttf')).toBe(false) + expect(rendererAssetInlineLimit('/n/inter.woff2?url')).toBe(false) + expect(rendererAssetInlineLimit('/src/renderer/src/assets/logo.png')).toBeUndefined() + }) it('keeps Utility Process bootstrap entry names stable', () => { expect( stableMainEntryFileName({