From 34401ab378ef9a8911fc3d5736abd466798ebc3c Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 24 Oct 2026 13:04:21 +0800 Subject: [PATCH 1/5] perf(webmcp): load the agent tool layer on the first text read Co-Authored-By: Claude Opus 5.5 --- lib/web-mcp.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/web-mcp.ts b/lib/web-mcp.ts index c561d4deb..186c56a3d 100644 --- a/lib/web-mcp.ts +++ b/lib/web-mcp.ts @@ -22,11 +22,6 @@ import { getDocmentObj } from '@ranuts/shared/store'; import { DOCUMENT_TYPE_MAP, getDocumentType } from '@ranuts/shared/document-utils'; import { getReadonlyMode, requestSaveDocument, setReadonlyMode } from './onlyoffice-editor'; import { onCreateNew, openDocumentFromUrl, openLocalFile } from './document'; -// The tool layer is transport-agnostic by design (see @ranuts/agent-core types): -// the same definitions back the in-page agent panel. Reused rather than -// reimplemented -- editor-bridge has no imports of its own, so this costs -// nothing in the bundle and cannot drift from the panel's behaviour. -import { getDocumentTextTool } from './agent-plugin/tools'; /** Minimal shape of the WebMCP surface this adapter uses. */ export interface ModelContextLike { @@ -240,6 +235,10 @@ export function buildTools(): WebMcpTool[] { note: `Full text is only available for word-processing documents; this is a ${kind || 'non-word'} document. Use save_document (targetExt TXT, CSV or PDF) and read the exported file instead.`, }); } + // Reuse the panel's transport-agnostic tool so the two cannot drift, but + // load it here: tools.ts pulls the whole agent tool layer (and marked), + // which a static import put on every editor load for one optional call. + const { getDocumentTextTool } = await import('./agent-plugin/tools'); const { text, truncated } = await getDocumentTextTool.execute(maxChars ? { maxChars } : {}); return ok({ ok: true, text, truncated, supported: true }); }, From 4ea50b36d2f520a54a5f519210a37ab6be30012e Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 24 Oct 2026 13:04:21 +0800 Subject: [PATCH 2/5] perf(editor): load the PWA install element with its delayed mount Co-Authored-By: Claude Opus 5.5 --- index.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index 4f2771c6a..68ff6862f 100644 --- a/index.ts +++ b/index.ts @@ -25,7 +25,6 @@ import { initWebMcp } from './lib/web-mcp'; import { createAgentEntry } from './lib/agent-entry'; import { hasUnsavedChanges, installUnsavedChangesGuard } from './lib/unsaved-guard'; import { initDocumentHistory } from './lib/history'; -import '@khmyznikov/pwa-install'; import './styles/base.css'; declare global { @@ -338,5 +337,9 @@ const initPwaInstall = () => { document.body.appendChild(builder.build()); }; -// Start PWA initialization after short delay to ensure everything is settled -setTimeout(initPwaInstall, 1000); +// Start PWA initialization after short delay to ensure everything is settled. +// The element is optional and mounted late, so its ~110 KB definition stays +// off the editor's startup chunk. +setTimeout(() => { + import('@khmyznikov/pwa-install').then(initPwaInstall, () => {}); +}, 1000); From d1b8f3157c9e80b6a095ec515dba892b6b1fbb56 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 24 Oct 2026 13:04:21 +0800 Subject: [PATCH 3/5] test(e2e): keep optional modules out of the editor startup scripts Co-Authored-By: Claude Opus 5.5 --- test/e2e/editor-startup-payload.spec.ts | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 test/e2e/editor-startup-payload.spec.ts diff --git a/test/e2e/editor-startup-payload.spec.ts b/test/e2e/editor-startup-payload.spec.ts new file mode 100644 index 000000000..f31dba49d --- /dev/null +++ b/test/e2e/editor-startup-payload.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from './lib/l0'; + +// Code that only serves an optional, later interaction must not ride on the +// scripts every editor load fetches before it can run. Each probe is a string +// found only inside the module in question: an error message thrown in +// lib/agent-plugin/tools.ts, and an element the PWA library defines for itself. +const DEFERRED = [ + { owner: 'agent tool layer (lib/agent-plugin/tools.ts)', probe: 'replace_selection requires a nonempty string' }, + { owner: '@khmyznikov/pwa-install', probe: 'pwa-bottom-sheet' }, +]; + +test('the editor startup scripts leave the agent tools and the PWA prompt out', async ({ page }) => { + await page.goto('/editor?new=docx'); + // Vite writes the entry and its whole static import graph into the HTML as + // the module script plus modulepreload links: that is the startup payload. + const urls = await page.evaluate(() => + [ + ...document.querySelectorAll( + 'script[type="module"][src], link[rel="modulepreload"]', + ), + ].map((node) => (node instanceof HTMLScriptElement ? node.src : node.href)), + ); + expect(urls.length).toBeGreaterThan(0); + for (const url of urls) { + const body = await (await page.request.get(url)).text(); + for (const { owner, probe } of DEFERRED) expect(body.includes(probe), `${owner} is in ${url}`).toBe(false); + } +}); + +test('the PWA install element is still defined and mounted after startup', async ({ page }) => { + await page.goto('/editor?new=docx'); + await expect(page.locator('pwa-install#pwa-install')).toBeAttached({ timeout: 15_000 }); + await expect.poll(() => page.evaluate(() => Boolean(customElements.get('pwa-install')))).toBe(true); +}); From 748b07b203a00aef5371004ed18eae5fe00eb03c Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 24 Oct 2026 13:04:22 +0800 Subject: [PATCH 4/5] chore(e2e): add a clean script for isolated E2E outputs Co-Authored-By: Claude Opus 5.5 --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index b3332a1fc..5dd5c26da 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test:e2e": "playwright test --grep-invert @serial", "test:e2e:serial": "playwright test --grep @serial --workers=1", "test:e2e:ui": "playwright test --ui", + "clean:e2e": "rm -rf dist-e2e-* test-results-* playwright-report-*", "docker:build": "docker build -t document:e2e .", "test:e2e:docker": "sh ./bin/test-e2e-docker.sh", "format": "node bin/archive-quality.mjs format", From 7c5e48959208a1a6c9ee986c3d66134ba2a67f9f Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 24 Oct 2026 13:04:22 +0800 Subject: [PATCH 5/5] docs(perf): record the editor startup payload audit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + CLAUDE.md | 9 ++- .../2026-10-24-editor-startup-payload.md | 80 +++++++++++++++++++ 3 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 docs/explorations/2026-10-24-editor-startup-payload.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 564d2a81b..82d95f81b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -186,6 +186,9 @@ notes. Entries describe what users experience, not internal refactors. ### Changed +- The editor page fetches about a fifth less JavaScript before it can start + (281 KB instead of 350 KB compressed). Browser-agent text reading and the + install prompt now load when they are first needed. - Voice controls stay in place while recording or finishing. Explanations appear without moving the draft, and cancelling preserves the text typed before dictation. - **Configured assistants reopen in chat.** Enable a downloaded model or reconnect diff --git a/CLAUDE.md b/CLAUDE.md index cc2889c41..7c397fae6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,7 @@ pnpm run test:coverage # 带覆盖率的单元测试 pnpm run test:e2e # E2E 测试(Playwright,自动 build+preview)——不含 @serial pnpm run test:e2e:serial # 时序预算用例(@serial),单 worker 独占跑;本地要跑全须两条都跑 pnpm run test:e2e:docker # 同套 E2E 跑在生产 Docker 镜像上(bin/test-e2e-docker.sh) +pnpm run clean:e2e # 删掉 E2E_PORT 隔离跑留下的 dist-e2e-* / test-results-* / playwright-report-* CORPUS_DIR=<本地语料目录> pnpm run test:e2e:corpus # 真实文档回归矩阵(本地/夜间,语料不入库) pnpm run lint # lint:ts + lint:docker ``` @@ -222,7 +223,7 @@ test/setup/vitest.ts # 全局 mock:matchMedia、URL.createObjectURL 单一配置 `playwright.config.ts`(端口 4173,webServer 自动 build + preview, 不需要手动先 build;`E2E_PORT=` 另起一套并隔离 `dist-e2e-/` 与 `test-results-/`,`E2E_BASE_URL=<站点>` 则不起本地服务、直接打线上)。 -`test/e2e/` 现有 55 个 spec,下面先说三条主线,再给全量清单: +`test/e2e/` 现有 95 个 spec,下面先说三条主线,再给全量清单: - `app-smoke.spec.ts` — 应用加载、PWA manifest 冒烟 - `embed-api.spec.ts` — embed postMessage 协议 @@ -260,7 +261,7 @@ test/setup/vitest.ts # 全局 mock:matchMedia、URL.createObjectURL | 面向 | spec | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 站点 / 入口 | `app-smoke`、`main-site`(hero 打开 + Ctrl+S 下载)、`entry-paths`(`?file=` / `document:open-url` / `?open=local`)、`sw-warm`(SW 已控制页面)、`font-cache`(第二次打开字体全走缓存) | +| 站点 / 入口 | `app-smoke`、`main-site`(hero 打开 + Ctrl+S 下载)、`entry-paths`(`?file=` / `document:open-url` / `?open=local`)、`sw-warm`(SW 已控制页面)、`font-cache`(第二次打开字体全走缓存)、`editor-startup-payload`(启动脚本不含 agent 工具层与 PWA 安装组件) | | embed 协议 | `embed-api`、`embed-regression`(真实编辑器主回归)、`embed-save-default`(裸 save 用文档自身格式)、`embed-dirty-state`(未保存标志:保存后的编辑要再报,docx/xlsx/pptx) | | 格式与内容 | `filename-matrix`、`format-parity`(docx/pptx 导出 PDF + 只读 + 运行时切换)、`resave-idempotence`、`xlsx-features`(合并/公式/2 万行)、`xlsx-panes`(冻结窗格/筛选)、`docx-features`(修订/页眉页脚)、`docx-ruby`(注音底文)、`comments`、`image-insert`、`csv-encoding`(GBK)、`html-as-xls`、`pdf-route`、`pdf-roundtrip`(打开/注释/存回/只读) | | 失败与守卫 | `open-failure`(-82 可见 + 保存快速拒绝,兼作 L0 自检)、`comment-bulk-actions`(守卫 8)、`wasm-memory`(守卫 14:x2t 跑在 worker 里、流式实例化、闲置后连堆一起回收)、`offline-seam`(vendor 的进程内服务端应答器 + x2t 的唯一接缝 + 跨 realm 安全)、`plugin-availability`(插件框架经 `editorConfig.plugins` 可达)、`font-picker-scroll`(字体列表滑到底不炸,#218)、`open-url-failure`(`?src=` 取不到文件时给的是本地化 toast 而不是 alert)、`bad-image-url-locale`(守卫 13) | @@ -732,7 +733,9 @@ WebMCP 让页面向浏览器内的 AI Agent 注册结构化工具,Agent 直接 5. **工具层是共用的**。`get_document_text` 直接复用 `lib/agent-plugin/tools.ts` 的实现(`@ranuts/agent-core` 的类型注释里写明 工具是 transport-agnostic 的),不要在 web-mcp 里另写一份。 - `editor-bridge.ts` 零 import,所以复用不带来 bundle 成本。 + 但 `tools.ts` 自己会拉进整层 agent 工具和 `marked`,所以 web-mcp 只在 handler 里 + `import()` 它——改回顶层 import 会让每次打开编辑器多下 ~107 KB, + `editor-startup-payload.spec.ts` 会红。 **已知缺口(实测 v9,见 docs/explorations/2026-08-21-webmcp-completion.md)**: 引擎只对文字文档提供全文读取,表格和演示文稿返回空字符串。空字符串是有歧义的 diff --git a/docs/explorations/2026-10-24-editor-startup-payload.md b/docs/explorations/2026-10-24-editor-startup-payload.md new file mode 100644 index 000000000..c2d747b60 --- /dev/null +++ b/docs/explorations/2026-10-24-editor-startup-payload.md @@ -0,0 +1,80 @@ +# Editor startup payload: two optional modules off the entry chunk + +A project-wide audit measured what `/editor` loads before its entry script can +run: the entry chunk plus its whole static import graph (the module script and +the `modulepreload` links Vite writes into `editor.html`). Two optional +modules accounted for about a fifth of it. + +## Findings + +### WebMCP pulled the whole agent tool layer into every editor load + +`lib/web-mcp.ts` imported `getDocumentTextTool` from `lib/agent-plugin/tools.ts` +at the top of the file. Its comment said this "costs nothing in the bundle" +because `editor-bridge` has no imports. That was true of `editor-bridge`, but +`tools.ts` itself imports about twenty tool modules, +`@ranuts/agent-core/runtime` and `marked`. `initWebMcp()` runs on every editor +load, while the tool is only called inside one handler, and only when the +browser exposes `modelContext` (Chrome origin trial). + +The import now happens inside that handler. WebMCP and the panel still share +one tool implementation. + +### The PWA install element shipped with the entry chunk + +`index.ts` imported `@khmyznikov/pwa-install` at the top, yet only mounted the +element from a one-second timer, in manual mode. The import now runs inside +that timer, before the mount. + +## Measurements + +Measured with `vite build` on main at `37ecc5c5`. The figures are the static +editor graph, summed per chunk from `.vite/manifest.json`. + +| Build | Entry chunk | Static editor graph (raw) | Static editor graph (gzip) | +| ------------------------ | ----------- | ------------------------- | -------------------------- | +| Before | 654 KB | 1102 KB | 350 KB | +| Without the tools import | 547 KB | 995 KB | 317 KB | +| Without both imports | 418 KB | 866 KB | 281 KB | + +Both modules are still precached for offline use. `bin/offline-assets.mjs` +follows dynamic imports from app entries into the core list, so the first +offline open behaves as before. + +## Regression test and reverse validation + +`test/e2e/editor-startup-payload.spec.ts` reads the module script and +modulepreload URLs from the served `editor.html`. It fetches each one and checks +that none contains a string unique to a deferred module: + +- `replace_selection requires a nonempty string`, an error message only `tools.ts` throws (the bare tool name also appears in UI modules); +- `pwa-bottom-sheet`, an element the PWA library defines for itself. + +A second case checks that `` is still defined and mounted. +`webmcp.spec.ts` covers `get_document_text` end to end, through the real +editor. + +Reverse validation: + +- With both source changes reverted, the payload case failed on the agent tool + probe. +- With only the `index.ts` change reverted, it failed on the PWA probe in the + entry chunk. +- The mount case passed both times, since it checks behavior that is meant to + stay the same. + +## Not changed + +The audit found several other candidates that were left out of this change: + +- **Building once for all CI shards.** The 9 preview and Pages shards each + build the same output. Sharing one build would put a serial build step in + front of every shard, so it needs a measurement first. +- **Splitting `lib/agent-plugin/ui/panel.ts`** (3159 lines). This is a + refactor and needs its own request. +- **Sidebar MutationObserver.** It watches the whole editor DOM while the + assistant is enabled. The cost is inferred, not measured. + +`pnpm run clean:e2e` was added for the `dist-e2e-*` and `test-results-*` +directories that `E2E_PORT` runs leave behind. Nothing cleaned them before; +the audited checkout had 40 of them.