Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -222,7 +223,7 @@ test/setup/vitest.ts # 全局 mock:matchMedia、URL.createObjectURL
单一配置 `playwright.config.ts`(端口 4173,webServer 自动 build + preview,
不需要手动先 build;`E2E_PORT=<port>` 另起一套并隔离 `dist-e2e-<port>/` 与
`test-results-<port>/`,`E2E_BASE_URL=<站点>` 则不起本地服务、直接打线上)。
`test/e2e/` 现有 55 个 spec,下面先说三条主线,再给全量清单:
`test/e2e/` 现有 95 个 spec,下面先说三条主线,再给全量清单:

- `app-smoke.spec.ts` — 应用加载、PWA manifest 冒烟
- `embed-api.spec.ts` — embed postMessage 协议
Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -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)**:
引擎只对文字文档提供全文读取,表格和演示文稿返回空字符串。空字符串是有歧义的
Expand Down
80 changes: 80 additions & 0 deletions docs/explorations/2026-10-24-editor-startup-payload.md
Original file line number Diff line number Diff line change
@@ -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 `<pwa-install>` 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.
9 changes: 6 additions & 3 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
9 changes: 4 additions & 5 deletions lib/web-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 });
},
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
34 changes: 34 additions & 0 deletions test/e2e/editor-startup-payload.spec.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLScriptElement | HTMLLinkElement>(
'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);
});
Loading