Skip to content

Commit ef38865

Browse files
authored
perf(web): share document parsers and skip covered snapshot reads (#920)
1 parent ffc9c62 commit ef38865

24 files changed

Lines changed: 422 additions & 363 deletions

‎docs/development/OPENPI_WEB_DEVELOPMENT.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,15 @@ bun run check
134134
bun run test
135135
```
136136

137-
修改 `web/ui/` 或 Web 构建配置后,需要同时提交重新生成的 `web/dist/`。CI 会重新构建并检查该目录;如果源码和提交的产物不一致,门禁会失败。不要手工编辑 `web/dist/`。
137+
修改 `web/ui/` 或 Web 构建配置后,需要同时提交重新生成的 `web/dist/`。CI 会重新构建并检查该目录;如果源码和提交的产物不一致,门禁会失败。如果合并或 rebase 时 `web/dist/` 有冲突,先解决源码和锁文件的冲突,再从仓库根目录运行:
138+
139+
```bash
140+
bun install --frozen-lockfile
141+
bun run build:web
142+
git add -A -- web/dist
143+
```
144+
145+
Vite 会清空旧的输出目录。最后一步会同时暂存新产物和已失效的 chunk 删除记录。
138146

139147
CI 在 Node `22.19.0` 上运行一次 Chromium E2E,覆盖项目声明的最低 Node 版本;普通 `check` 和 `test` 继续同时覆盖 Node `22.19.0` 与 Node `24`。
140148

‎tests/web/file-attachments.spec.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ describe("composer file staging boundary", () => {
3030
await vi.waitFor(() => expect(StagingWorker.instances).toHaveLength(1));
3131
const worker = StagingWorker.instances[0]!;
3232
expect(worker.postMessage.mock.calls[0]?.[0]).toMatchObject({
33+
operation: "extract",
3334
kind: "text",
3435
});
3536
worker.onmessage?.({

‎tests/web/file-text-worker.spec.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ async function extract(bytes: Uint8Array, kind: string) {
1313
postMessage: vi.fn(),
1414
};
1515
vi.stubGlobal("self", worker);
16-
await import("../../web/ui/src/features/composer/file-text-worker.ts");
16+
await import("../../web/ui/src/features/files/document-worker.ts");
1717
worker.onmessage?.({
18-
data: { data: Uint8Array.from(bytes).buffer, kind },
18+
data: { operation: "extract", data: Uint8Array.from(bytes).buffer, kind },
1919
} as MessageEvent);
2020
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalledOnce());
2121
return worker.postMessage.mock.calls[0]![0].result;

‎tests/web/office-preview-worker.spec.ts‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,19 @@ type WorkerReply = { result?: OfficePreview; error?: string };
66
let receive: ((reply: WorkerReply) => void) | undefined;
77
const worker = {
88
onmessage: null as
9-
| ((event: MessageEvent<{ data: ArrayBuffer; extension: string }>) => void)
9+
| ((
10+
event: MessageEvent<{
11+
operation: "preview";
12+
data: ArrayBuffer;
13+
extension: string;
14+
}>,
15+
) => void)
1016
| null,
1117
postMessage: (reply: WorkerReply) => receive?.(reply),
1218
};
1319
beforeAll(async () => {
1420
vi.stubGlobal("self", worker);
15-
await import("../../web/ui/src/features/files/office-worker.ts");
21+
await import("../../web/ui/src/features/files/document-worker.ts");
1622
});
1723
afterAll(() => vi.unstubAllGlobals());
1824

@@ -25,7 +31,9 @@ async function preview(workbook: ExcelJS.Workbook) {
2531
});
2632
expect(worker.onmessage).toBeTypeOf("function");
2733
worker.onmessage?.(
28-
new MessageEvent("message", { data: { data, extension: "xlsx" } }),
34+
new MessageEvent("message", {
35+
data: { operation: "preview", data, extension: "xlsx" },
36+
}),
2937
);
3038
const reply = await response;
3139
expect(reply.error).toBeUndefined();
@@ -68,3 +76,21 @@ it("marks a budget cut within the last worksheet row, but not an exactly complet
6876
expect(clipped.sheets[1].rows[1][19]).toBe("2,20");
6977
expect(clipped.sheets[1].truncated).toBe(true);
7078
});
79+
80+
it("reports invalid preview documents through the preview error response", async () => {
81+
const response = new Promise<WorkerReply>((resolve) => {
82+
receive = resolve;
83+
});
84+
worker.onmessage?.(
85+
new MessageEvent("message", {
86+
data: {
87+
operation: "preview",
88+
data: new TextEncoder().encode("not an Office archive").buffer,
89+
extension: "xlsx",
90+
},
91+
}),
92+
);
93+
const reply = await response;
94+
expect(reply.result).toBeUndefined();
95+
expect(reply.error).toBeTypeOf("string");
96+
});

‎tests/web/web-store.spec.ts‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3014,6 +3014,121 @@ describe("OpenPI Web store", () => {
30143014
store.getState().actions.stop();
30153015
});
30163016

3017+
it.each([
3018+
{ snapshotCursor: 6, expectedReads: 2 },
3019+
{ snapshotCursor: 5, expectedReads: 3 },
3020+
])(
3021+
"keeps only uncovered event refreshes after an in-flight snapshot at cursor $snapshotCursor",
3022+
async ({ snapshotCursor, expectedReads }) => {
3023+
vi.useFakeTimers();
3024+
const client = new FakeClient();
3025+
const reading = deferred<WebSnapshot>();
3026+
const canonical = { ...snapshot("Canonical"), cursor: snapshotCursor };
3027+
client.snapshots.push(
3028+
Promise.resolve(snapshot()),
3029+
reading.promise,
3030+
Promise.resolve({ ...canonical, cursor: 6 }),
3031+
);
3032+
const stream = eventStreamHarness();
3033+
const store = createWebStore(client, {
3034+
consumeEvents: stream.consumeEvents,
3035+
});
3036+
await store.getState().actions.refreshSnapshot();
3037+
store.getState().actions.start();
3038+
try {
3039+
stream.emit(runtimeEvent(5, "agent_start", { sessionId: "session-1" }));
3040+
await vi.advanceTimersByTimeAsync(160);
3041+
expect(client.snapshotPaths).toHaveLength(2);
3042+
stream.emit(
3043+
runtimeEvent(6, "message_end", {
3044+
messageKey: "answer",
3045+
message: { role: "assistant", content: "Finished answer" },
3046+
}),
3047+
);
3048+
// Live projection remains immediate while canonical history is read.
3049+
expect(store.getState().liveMessages[0]?.message.content).toBe(
3050+
"Finished answer",
3051+
);
3052+
reading.resolve(canonical);
3053+
await vi.advanceTimersByTimeAsync(320);
3054+
expect(client.snapshotPaths).toHaveLength(expectedReads);
3055+
expect(store.getState().snapshot?.cursor).toBe(6);
3056+
expect(store.getState().snapshot?.sessions[0]?.name).toBe("Canonical");
3057+
} finally {
3058+
store.getState().actions.stop();
3059+
}
3060+
},
3061+
);
3062+
3063+
it("does not let a superseded snapshot suppress refreshes for newer events", async () => {
3064+
vi.useFakeTimers();
3065+
const client = new FakeClient();
3066+
const older = deferred<WebSnapshot>();
3067+
client.snapshots.push(
3068+
Promise.resolve(snapshot()),
3069+
older.promise,
3070+
Promise.resolve({ ...snapshot(), cursor: 5 }),
3071+
Promise.resolve({ ...snapshot("Newer event confirmed"), cursor: 6 }),
3072+
);
3073+
const stream = eventStreamHarness();
3074+
const store = createWebStore(client, {
3075+
consumeEvents: stream.consumeEvents,
3076+
});
3077+
await store.getState().actions.refreshSnapshot();
3078+
store.getState().actions.start();
3079+
try {
3080+
stream.emit(runtimeEvent(5, "agent_start", { sessionId: "session-1" }));
3081+
await vi.advanceTimersByTimeAsync(160);
3082+
stream.emit(
3083+
runtimeEvent(6, "session_renamed", { sessionId: "session-1" }),
3084+
);
3085+
await store.getState().actions.refreshSnapshot();
3086+
// This cursor would cover the event, but the newer request superseded it.
3087+
older.resolve({ ...snapshot("Superseded"), cursor: 6 });
3088+
await vi.advanceTimersByTimeAsync(320);
3089+
expect(client.snapshotPaths).toHaveLength(4);
3090+
expect(store.getState().snapshot?.sessions[0]?.name).toBe(
3091+
"Newer event confirmed",
3092+
);
3093+
} finally {
3094+
store.getState().actions.stop();
3095+
}
3096+
});
3097+
3098+
it("retains heartbeat reconciliation even when a snapshot covers pending events", async () => {
3099+
vi.useFakeTimers();
3100+
const client = new FakeClient();
3101+
client.snapshots.push(
3102+
Promise.resolve(snapshot()),
3103+
Promise.resolve({ ...snapshot(), cursor: 5 }),
3104+
Promise.resolve({ ...snapshot("Externally updated"), cursor: 5 }),
3105+
);
3106+
let heartbeat: (() => void) | undefined;
3107+
const stream = eventStreamHarness();
3108+
const store = createWebStore(client, {
3109+
consumeEvents: (options) => {
3110+
heartbeat = options.onHeartbeat;
3111+
return stream.consumeEvents(options);
3112+
},
3113+
});
3114+
await store.getState().actions.refreshSnapshot();
3115+
store.getState().actions.start();
3116+
try {
3117+
stream.emit(
3118+
runtimeEvent(5, "session_renamed", { sessionId: "session-1" }),
3119+
);
3120+
await store.getState().actions.refreshSnapshot();
3121+
heartbeat?.();
3122+
await vi.advanceTimersByTimeAsync(160);
3123+
expect(client.snapshotPaths).toHaveLength(3);
3124+
expect(store.getState().snapshot?.sessions[0]?.name).toBe(
3125+
"Externally updated",
3126+
);
3127+
} finally {
3128+
store.getState().actions.stop();
3129+
}
3130+
});
3131+
30173132
it("refreshes Session metadata events emitted by another browser tab", async () => {
30183133
vi.useFakeTimers();
30193134
const client = new FakeClient();

‎web/dist/app-document-worker-LgsVRVal.js‎

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎web/dist/app-exceljs.min-tRzj5o_b.js‎

Lines changed: 0 additions & 71 deletions
This file was deleted.

‎web/dist/app-file-text-worker-DegOHoqE.js‎

Lines changed: 0 additions & 14 deletions
This file was deleted.
Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)