perf: keep Markdown segmentation for single-line display math and ship KaTeX fonts as files
This commit is contained in:
@@ -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 / 待开始
|
||||
|
||||
@@ -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()]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 |', '| - |', '***', '---', '===', '<div>', '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(<div><MarkdownRenderer>{content}</MarkdownRenderer></div>)
|
||||
const reference = render(<div><UnsegmentedMarkdownRenderer>{content}</UnsegmentedMarkdownRenderer></div>)
|
||||
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(<MarkdownRenderer>{base}</MarkdownRenderer>)
|
||||
|
||||
@@ -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
|
||||
// CommonMark: a backtick fence's info string cannot contain backticks.
|
||||
const segmentFenceOpening = /^ {0,3}(?:(`{3,})(?!.*`)|(~{3,}))/u
|
||||
const segmentMathFence = /^ {0,3}\$\$\s*$/u
|
||||
// micromark-extension-math: a run of two or more `$` opens a math block only
|
||||
// when the rest of the line (the optional meta) has no `$`. Otherwise, as in
|
||||
// `$$x^2$$`, the line is an ordinary paragraph containing inline math.
|
||||
const segmentMathFenceOpening = /^ {0,3}(\${2,})(.*)$/u
|
||||
// A block after a blank line belongs to the previous top-level block when it
|
||||
// is indented (list item or indented code continuation), a list marker (the
|
||||
// list may continue and its looseness may change), or a block quote marker.
|
||||
@@ -277,12 +281,12 @@ const continuationLine = /^(?:\s|[-*+](?:\s|$)|\d{1,9}[.)](?:\s|$)|>)/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
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user