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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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。
|
||||
|
||||
红线关闭(不是实现):
|
||||
|
||||
|
||||
@@ -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 文档编制人兼任方案
|
||||
|
||||
|
||||
@@ -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<MessageBubbleProps> = ({
|
||||
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 () => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<BlockRendererProps> = ({
|
||||
ast,
|
||||
currentRole,
|
||||
@@ -54,6 +64,20 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
|
||||
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<BlockRendererProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
data-math-tick={mathTick}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { DocumentBlock } from '../../../types/block';
|
||||
import katex from 'katex';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import { Calculator } from 'lucide-react';
|
||||
|
||||
interface MathBlockViewProps {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import katex from 'katex';
|
||||
|
||||
/** 仅在出现公式时由 renderMarkdown 动态加载,避免进入登录入口包。 */
|
||||
export function renderKatexHtml(source: string, displayMode: boolean): string {
|
||||
return katex.renderToString(source, {
|
||||
displayMode,
|
||||
throwOnError: false,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { renderInlineMarkdown } from './renderMarkdown';
|
||||
import { contentHasMath, ensureMathRenderer, renderInlineMarkdown } from './renderMarkdown';
|
||||
import { sanitizeMermaidCode } from '../components/doc/blocks/MermaidBlockView';
|
||||
|
||||
test('renderInlineMarkdown: renders bold, italic, strikethrough', () => {
|
||||
@@ -71,6 +71,16 @@ test('renderInlineMarkdown: renders headings lists and tables for chat replies',
|
||||
assert.ok(html.includes('<strong>结论</strong>'));
|
||||
});
|
||||
|
||||
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双路色彩配准与超低延迟融合引擎]
|
||||
|
||||
@@ -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<void> | null = null;
|
||||
|
||||
export function contentHasMath(content: string): boolean {
|
||||
return /\$\$[\s\S]*?\$\$|\$[^$\n]+?\$/.test(content);
|
||||
}
|
||||
|
||||
/** 首次遇到公式才加载 KaTeX 及其样式。没有公式的会话不请求这个包。 */
|
||||
export function ensureMathRenderer(): Promise<void> {
|
||||
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 `<span class="math-pending">$$${escaped}$$</span>`;
|
||||
}
|
||||
return `<span class="math-pending">$${escaped}$</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 健壮的行内 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(
|
||||
`<span style="color:var(--danger)">$$${escapeHtml(math)}$$</span>`,
|
||||
);
|
||||
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`
|
||||
|
||||
@@ -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<CopyResult> {
|
||||
const ir = parseReplyMarkdown(markdown, title);
|
||||
const { renderClipboardHtml } = await import('../../engine/replyDocument/renderClipboardHtml');
|
||||
const html = renderClipboardHtml(ir);
|
||||
const plain = renderPlainText(ir);
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user