From 63b0f72081bdfdc30a3edb69ab6c0537a7faea6b Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 19 Sep 2026 10:57:49 +0000 Subject: [PATCH] feat(web): load the formula engine only when math appears The login entry no longer ships KaTeX. Chat bubbles and document text render formulas after a dynamic import, and rich-text copy does the same. Co-authored-by: Cursor --- .../long-term/2026-09-19-audit-remainder.md | 2 +- .doc/status/FEATURE-MATRIX.md | 2 +- web/src/components/chat/MessageBubble.tsx | 18 ++++- web/src/components/doc/BlockEditorModal.tsx | 1 + web/src/components/doc/BlockRenderer.tsx | 25 ++++++ .../components/doc/blocks/MathBlockView.tsx | 1 + web/src/engine/katexRender.ts | 9 +++ web/src/engine/renderMarkdown.test.ts | 12 ++- web/src/engine/renderMarkdown.ts | 76 +++++++++++-------- web/src/features/replyExport/clipboard.ts | 2 +- web/src/loginShellImports.test.ts | 47 +++++++++++- web/src/main.tsx | 1 - 12 files changed, 157 insertions(+), 39 deletions(-) create mode 100644 web/src/engine/katexRender.ts diff --git a/.doc/ops/long-term/2026-09-19-audit-remainder.md b/.doc/ops/long-term/2026-09-19-audit-remainder.md index 362fffe1..99772f7c 100644 --- a/.doc/ops/long-term/2026-09-19-audit-remainder.md +++ b/.doc/ops/long-term/2026-09-19-audit-remainder.md @@ -49,7 +49,7 @@ updated: 2026-09-19 | FE App.tsx | 部分。登录壳不再静态引用导出引擎、文件预览、管理台、块编辑器和图表。入口包 2.83 MB → 1.00 MB(br 626 KB → 235 KB)。`loginShellImports.test.ts`。工作台主体仍在 `App.tsx`,未按 hook 拆文件 | | H1-del | 关闭理由:`allowLegacyTestPrincipal` 仍被 `AllowInMemory && ContextDbHost==""` 的 dev/test 内存测试使用。删除会把这批测试变成未登录 401。保留告警,删除另开、先改测试夹具 | -仍开放:rdev P3 三样本真 PI、首次加载 U1/U6/U7、dev 容器重建与治理库 PG。P4 键名空间化已在本分支落地:新清单用带前缀的任务键,旧键与旧 doc_id 仍可读,`TestPublishedTaskKeysAreNamespacedAndDisjoint`。意图短语仍输出旧键,执行路径按别名接上。P2 规划界面已折叠长阶段、可批量纳入人工审批,并在选中 rdev 时提示 CDO 前置。U4 登录扇出收口仍有效,浏览器请求次数证据还没有。2026-09-19 在 `fix/audit-remainder-closeout` 跑过 `cd web && npm test && npm run build`:node:test 414 pass / 0 fail,vitest 32 pass;入口 `index-BFf6n8IA.js` 1,005.01 kB,br 235,285 B。CURRENT 文首 schema 52 与三套容器一致,00053 要等重建后才升。H2 批注/审计的项目成员检查在 Postgres 上走 `RequireProjectAccess`;内存授权器在无库时把任意正用户 id 视为成员,这是 `TestAuthorizeDocRevision_ReviewerAndNonMemberCannotWrite` 的隔离前提,不能改成默认拒绝。H2 的 HTTP 负例因此要在 `TEST_DATABASE_URL` 上补,本机未设置该变量。M7 的投影意图已随账本提交(goose `00053`);容器 schema 仍是 52,要等占槽重建后才升到 53。 +仍开放:rdev P3 三样本真 PI、首次加载 U1/U6/U7、dev 容器重建与治理库 PG。公式引擎已离开入口包(`index-j0BIrjIR.js` 737.38 kB,br 170,451 B,包内无 katex),U3 仍缺浏览器瀑布,不关。P4 键名空间化已在本分支落地:新清单用带前缀的任务键,旧键与旧 doc_id 仍可读,`TestPublishedTaskKeysAreNamespacedAndDisjoint`。意图短语仍输出旧键,执行路径按别名接上。P2 规划界面已折叠长阶段、可批量纳入人工审批,并在选中 rdev 时提示 CDO 前置。U4 登录扇出收口仍有效,浏览器请求次数证据还没有。2026-09-19 在 `fix/audit-remainder-closeout` 跑过 `cd web && npm test && npm run build`:node:test 414 pass / 0 fail,vitest 32 pass;入口 `index-BFf6n8IA.js` 1,005.01 kB,br 235,285 B。CURRENT 文首 schema 52 与三套容器一致,00053 要等重建后才升。H2 批注/审计的项目成员检查在 Postgres 上走 `RequireProjectAccess`;内存授权器在无库时把任意正用户 id 视为成员,这是 `TestAuthorizeDocRevision_ReviewerAndNonMemberCannotWrite` 的隔离前提,不能改成默认拒绝。H2 的 HTTP 负例因此要在 `TEST_DATABASE_URL` 上补,本机未设置该变量。M7 的投影意图已随账本提交(goose `00053`);容器 schema 仍是 52,要等占槽重建后才升到 53。 红线关闭(不是实现): diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index f9a9f6f0..fb2229b4 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -96,7 +96,7 @@ ## 2026-09-17 首次加载性能专项 -首次加载与重启首访性能优化:**partial**(U2 静态交付已验收;U3 只做了登录壳拆包,未关闭)。[审计](../audit/2026-09-17-initial-load-performance-audit.md) · [方案](../remediation/2026-09-17-initial-load-performance-plan.md) · [U2 报告](../test-reports/2026-09-17-initial-load-perf-u2-test-report.md)。U2:自托管字体 + br/gzip。U3 进展(`fix/audit-remainder-closeout`):导出、预览、管理台、块编辑器、Mermaid/公式改为点击或出现时再加载。入口包 `index-*.js` 由 2.83 MB / br 626 KB 降到 1.00 MB / br 235 KB。消息气泡里的公式引擎仍在入口图里。U5:`/auth/me` 复用本请求已核过的用户,不再第二次解析会话;会话与用户状态合成一次 JOIN。没有锁等待证据,所以不节流 `last_seen`。`TestAuthMe_LogoutNextRequestIs401`。U1、U6、U7 未做。U4 进展:登录不再按项目数拉成员名册,只同步当前项目;其他项目先种入当前用户以保持会话可见,完整名册在打开成员面板时取。会话列表不再因项目数组变化而重拉,迟到响应用代次丢弃,同一项目的在途成员请求合并。`src/data/initFanout.test.ts`。尚未用浏览器记录 1/30/100 项目的真实请求次数。mutex `web + static-delivery`(U2 done)/ `loading-performance-audit`。 +首次加载与重启首访性能优化:**partial**(U2 静态交付已验收;U3 只做了登录壳拆包,未关闭)。[审计](../audit/2026-09-17-initial-load-performance-audit.md) · [方案](../remediation/2026-09-17-initial-load-performance-plan.md) · [U2 报告](../test-reports/2026-09-17-initial-load-perf-u2-test-report.md)。U2:自托管字体 + br/gzip。U3 进展(`fix/audit-remainder-closeout`):导出、预览、管理台、块编辑器、Mermaid/公式改为点击或出现时再加载。入口包 `index-*.js` 由 2.83 MB / br 626 KB 降到 737 kB / br 170 KB(`index-j0BIrjIR.js`,包内无 katex)。公式在气泡或正文里出现时才加载,富文本复制同样延后。U3 的浏览器资源瀑布还没测,本行不关。U5:`/auth/me` 复用本请求已核过的用户,不再第二次解析会话;会话与用户状态合成一次 JOIN。没有锁等待证据,所以不节流 `last_seen`。`TestAuthMe_LogoutNextRequestIs401`。U1、U6、U7 未做。U4 进展:登录不再按项目数拉成员名册,只同步当前项目;其他项目先种入当前用户以保持会话可见,完整名册在打开成员面板时取。会话列表不再因项目数组变化而重拉,迟到响应用代次丢弃,同一项目的在途成员请求合并。`src/data/initFanout.test.ts`。尚未用浏览器记录 1/30/100 项目的真实请求次数。mutex `web + static-delivery`(U2 done)/ `loading-performance-audit`。 ## 2026-09-17 文档编制人兼任方案 diff --git a/web/src/components/chat/MessageBubble.tsx b/web/src/components/chat/MessageBubble.tsx index 6a3961b4..cdd0cdee 100644 --- a/web/src/components/chat/MessageBubble.tsx +++ b/web/src/components/chat/MessageBubble.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { ChatErrorInfo, ChatMessage } from '../../types/workbench'; import { assistantTaskStatusLabel } from '../../api/chatWindow'; import { isBlockedGovernanceBubble } from '../../lib/blockedGovernanceCopy'; @@ -7,7 +7,7 @@ import { RecommendedCaseCard } from './RecommendedCaseCard'; import { InteractiveCard } from './InteractiveCard'; import { ReplyActionBar } from './ReplyActionBar'; import { FileText, Bot, User, Loader2, AlertTriangle, Copy, Check, Quote as QuoteIcon, ExternalLink } from 'lucide-react'; -import { renderInlineMarkdown } from '../../engine/renderMarkdown'; +import { contentHasMath, ensureMathRenderer, renderInlineMarkdown } from '../../engine/renderMarkdown'; import { copyPlainText } from '../../features/replyExport/clipboard'; import { QuoteCard } from './QuoteCard'; @@ -71,10 +71,22 @@ const MessageBubbleInner: React.FC = ({ const [diagCopied, setDiagCopied] = useState(false); const [diagCopyFailed, setDiagCopyFailed] = useState(false); const [isHovered, setIsHovered] = useState(false); + const [mathTick, setMathTick] = useState(0); + + useEffect(() => { + if (!contentHasMath(message.content || '')) return; + let cancelled = false; + ensureMathRenderer().then(() => { + if (!cancelled) setMathTick((tick) => tick + 1); + }); + return () => { + cancelled = true; + }; + }, [message.content]); const renderedHtml = useMemo( () => renderInlineMarkdown(message.content || '', { preserveNewlines: true }), - [message.content] + [message.content, mathTick] ); const handleCopyDiag = async () => { diff --git a/web/src/components/doc/BlockEditorModal.tsx b/web/src/components/doc/BlockEditorModal.tsx index 0d9c4ec3..f2249d12 100644 --- a/web/src/components/doc/BlockEditorModal.tsx +++ b/web/src/components/doc/BlockEditorModal.tsx @@ -22,6 +22,7 @@ import { ListOrdered, } from 'lucide-react'; import katex from 'katex'; +import 'katex/dist/katex.min.css'; import { renderInlineMarkdown } from '../../engine/renderMarkdown'; import { BLOCK_EDITOR_DIALOG_LABEL, BLOCK_EDITOR_SAVE_NAME } from '../../lib/blockEditorDialog'; diff --git a/web/src/components/doc/BlockRenderer.tsx b/web/src/components/doc/BlockRenderer.tsx index be35613b..abac2e06 100644 --- a/web/src/components/doc/BlockRenderer.tsx +++ b/web/src/components/doc/BlockRenderer.tsx @@ -9,6 +9,7 @@ import { TableBlockView } from './blocks/TableBlockView'; import { CalloutBlockView } from './blocks/CalloutBlockView'; import { ListBlockView } from './blocks/ListBlockView'; import { OnDemand } from '../lazy/OnDemand'; +import { contentHasMath, ensureMathRenderer } from '../../engine/renderMarkdown'; import { BlockContextMenu } from './BlockContextMenu'; import { canAddBlockComment } from '../../lib/commentPolicy'; import { documentBlockDomAttrs } from '../../lib/documentBlockDom'; @@ -33,6 +34,15 @@ export interface BlockRendererProps { overlayHunks?: DiffHunk[]; } +function subtreeHasInlineMath(block: DocumentBlock): boolean { + if (block.type !== 'math' && contentHasMath(block.content || '')) return true; + const cells = block.properties?.tableData; + if (cells?.some((row) => row.some((cell) => contentHasMath(cell)))) return true; + const items = block.properties?.listItems; + if (items?.some((item) => contentHasMath(item))) return true; + return (block.children || []).some(subtreeHasInlineMath); +} + export const BlockRenderer: React.FC = ({ ast, currentRole, @@ -54,6 +64,20 @@ export const BlockRenderer: React.FC = ({ index: number; } | null>(null); + const [mathTick, setMathTick] = useState(0); + + useEffect(() => { + const hasMath = subtreeHasInlineMath(ast.rootBlock); + if (!hasMath) return; + let cancelled = false; + ensureMathRenderer().then(() => { + if (!cancelled) setMathTick((tick) => tick + 1); + }); + return () => { + cancelled = true; + }; + }, [ast]); + useEffect(() => { if (highlightedBlockId) { const el = document.getElementById(`block-${highlightedBlockId}`); @@ -106,6 +130,7 @@ export const BlockRenderer: React.FC = ({ return (
{ @@ -71,6 +71,16 @@ test('renderInlineMarkdown: renders headings lists and tables for chat replies', assert.ok(html.includes('结论')); }); +test('renderInlineMarkdown loads katex only after a formula is seen', async () => { + assert.equal(contentHasMath('报价 12 元'), false); + const pending = renderInlineMarkdown('能量 $E=mc^2$'); + assert.match(pending, /math-pending/); + assert.equal(pending.includes('class="katex"'), false); + await ensureMathRenderer(); + const html = renderInlineMarkdown('能量 $E=mc^2$'); + assert.match(html, /class="katex"/); +}); + test('sanitizeMermaidCode: auto wraps unquoted labels containing brackets or comparison operators', () => { const rawMermaid = `graph LR LaserSource[NIR近红外与白光LED复用光源] --> FPGA[FPGA双路色彩配准与超低延迟融合引擎] diff --git a/web/src/engine/renderMarkdown.ts b/web/src/engine/renderMarkdown.ts index ab21a58d..9b9a2a2d 100644 --- a/web/src/engine/renderMarkdown.ts +++ b/web/src/engine/renderMarkdown.ts @@ -1,9 +1,47 @@ -import katex from 'katex'; - export interface RenderMarkdownOptions { preserveNewlines?: boolean; } +type MathRenderer = (source: string, displayMode: boolean) => string; + +let mathRenderer: MathRenderer | null = null; +let mathRendererLoading: Promise | null = null; + +export function contentHasMath(content: string): boolean { + return /\$\$[\s\S]*?\$\$|\$[^$\n]+?\$/.test(content); +} + +/** 首次遇到公式才加载 KaTeX 及其样式。没有公式的会话不请求这个包。 */ +export function ensureMathRenderer(): Promise { + if (mathRenderer) return Promise.resolve(); + if (!mathRendererLoading) { + mathRendererLoading = (async () => { + if (typeof document !== 'undefined') { + await import('katex/dist/katex.min.css'); + } + const mod = await import('./katexRender'); + mathRenderer = mod.renderKatexHtml; + })(); + } + return mathRendererLoading; +} + +function renderMathSource(math: string, displayMode: boolean): string { + const trimmed = math.trim(); + if (mathRenderer) { + try { + return mathRenderer(trimmed, displayMode); + } catch { + // 落到转义文本,避免把未渲染的源码再送进 Markdown 规则。 + } + } + const escaped = escapeHtml(trimmed); + if (displayMode) { + return `$$${escaped}$$`; + } + return `$${escaped}$`; +} + /** * 健壮的行内 Markdown、LaTeX 公式与有序/无序列表渲染引擎 * 支持: @@ -34,38 +72,16 @@ export function renderInlineMarkdown(content: string, options: RenderMarkdownOpt // 1.1 块级独立公式 $$ ... $$ text = text.replace(/\$\$([\s\S]*?)\$\$/g, (_, math) => { - try { - const rendered = katex.renderToString(math.trim(), { - displayMode: true, - throwOnError: false, - }); - const idx = mathPlaceholders.length; - mathPlaceholders.push(rendered); - return `${placeholderPrefix}${idx}XENDX`; - } catch { - const idx = mathPlaceholders.length; - mathPlaceholders.push( - `$$${escapeHtml(math)}$$`, - ); - return `${placeholderPrefix}${idx}XENDX`; - } + const idx = mathPlaceholders.length; + mathPlaceholders.push(renderMathSource(math, true)); + return `${placeholderPrefix}${idx}XENDX`; }); // 1.2 行内公式 $ ... $ text = text.replace(/\$([^\$\n]+?)\$/g, (_, math) => { - try { - const rendered = katex.renderToString(math.trim(), { - displayMode: false, - throwOnError: false, - }); - const idx = mathPlaceholders.length; - mathPlaceholders.push(rendered); - return `${placeholderPrefix}${idx}XENDX`; - } catch { - const idx = mathPlaceholders.length; - mathPlaceholders.push(`$${escapeHtml(math)}$`); - return `${placeholderPrefix}${idx}XENDX`; - } + const idx = mathPlaceholders.length; + mathPlaceholders.push(renderMathSource(math, false)); + return `${placeholderPrefix}${idx}XENDX`; }); // 2. 行内代码保护 `code` diff --git a/web/src/features/replyExport/clipboard.ts b/web/src/features/replyExport/clipboard.ts index e6b0c878..8884fdd0 100644 --- a/web/src/features/replyExport/clipboard.ts +++ b/web/src/features/replyExport/clipboard.ts @@ -1,5 +1,4 @@ import { parseReplyMarkdown } from '../../engine/replyDocument/parseReplyMarkdown'; -import { renderClipboardHtml } from '../../engine/replyDocument/renderClipboardHtml'; import { renderPlainText } from '../../engine/replyDocument/renderPlainText'; export interface CopyResult { @@ -14,6 +13,7 @@ export interface CopyResult { */ export async function copyRichReplyToClipboard(markdown: string, title?: string): Promise { const ir = parseReplyMarkdown(markdown, title); + const { renderClipboardHtml } = await import('../../engine/replyDocument/renderClipboardHtml'); const html = renderClipboardHtml(ir); const plain = renderPlainText(ir); diff --git a/web/src/loginShellImports.test.ts b/web/src/loginShellImports.test.ts index c1d60ca9..ab0fdd75 100644 --- a/web/src/loginShellImports.test.ts +++ b/web/src/loginShellImports.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; function staticImports(rel: string): string[] { const text = readFileSync(new URL(rel, import.meta.url), 'utf8'); @@ -36,4 +38,47 @@ test('login shell does not statically import export, preview, admin, or chart en const markdownDownload = readFileSync(new URL('./features/replyExport/download.ts', import.meta.url), 'utf8'); assert.equal(markdownDownload.includes("from 'docx'"), false); + + const reached = entryFiles(); + for (const file of reached) { + const text = readFileSync(file, 'utf8').replace(/import\s*\(/g, 'dynamic('); + assert.equal(/from\s+['"]katex['"]/.test(text), false, `${file} statically imports katex`); + assert.equal(/import\s+['"]katex\//.test(text), false, `${file} statically imports katex css`); + } }); + +function entryFiles(): string[] { + const start = fileURLToPath(new URL('./main.tsx', import.meta.url)); + const seen = new Set(); + const queue = [start]; + while (queue.length > 0) { + const current = queue.pop(); + if (!current || seen.has(current)) continue; + seen.add(current); + const text = readFileSync(current, 'utf8').replace(/import\s*\(/g, 'dynamic('); + const specifiers = [ + ...text.matchAll(/from\s+['"](\.[^'"]+)['"]/g), + ...text.matchAll(/^\s*import\s+['"](\.[^'"]+)['"]/gm), + ].map((match) => match[1]); + for (const spec of specifiers) { + const resolved = resolveSource(current, spec); + if (resolved) queue.push(resolved); + } + } + return [...seen]; +} + +function resolveSource(fromFile: string, spec: string): string | null { + const target = path.resolve(path.dirname(fromFile), spec); + const candidates = [target, `${target}.ts`, `${target}.tsx`, path.join(target, 'index.ts'), path.join(target, 'index.tsx')]; + for (const candidate of candidates) { + if (!existsSync(candidate)) continue; + try { + readFileSync(candidate); + return candidate; + } catch { + // directory or unreadable + } + } + return null; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 35c809cc..d973d091 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,7 +1,6 @@ import ReactDOM from 'react-dom/client'; import App from './App'; import { ErrorBoundary } from './components/ErrorBoundary'; -import 'katex/dist/katex.min.css'; import './styles/index.css'; ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(