Files
mnote/scripts/task-block-delta-smoke.js
T
lix-2026 f292c6710a feat: EditorRuntimeActor - 三层缓存/delta/事件架构
Phase A — EditorRuntimeActor 内存缓存层
- 新增 editor_actor.rs: EditorBlockDocument 内存态 + apply_command + load_or_init
- block.rs 四个写工具(replace/insert/delete/move)接入 actor 路径
- editor_actor feature flag(MNOTE_WEB_ENABLE_EDITOR_ACTOR=true 默认开启)
- bridge-runtime 三个核心函数公开化
- rust-toolchain: 1.89 → stable(修复 spike WASM 编译阻塞)

Phase B — 编辑器增量 delta channel
- BlockDelta/DeltaOperation 类型 + actor.build_block_delta()
- leptos-tiptap spike: mnote:editor:block-delta CustomEvent 监听 + JSON patch
- DocumentAiAgentPanel: 拦截 blockDelta → window dispatchEvent
- 工具响应含 blockDelta 字段供前端消费

Phase C — 事件 stream delta
- broadcast channel 在 AppState/actor/SSE 三层贯通
- tree_events SSE 端点发 block.delta 事件
- 旧客户端降级兼容

环境修复
- rustc recursion_limit = 1024(修复 Leptos SSR 类型深度溢出)
- run-convex-deploy.js(封装 Convex function 部署到本地后端 3210)

ref: design/07-ai/process/7-13-page-block-editor-runtime-actor-v1.md
2026-05-16 22:03:30 +08:00

217 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
"use strict";
/**
* task-block-delta-smoke.js
*
* 验证 Phase C — 事件 stream delta
* - 写工具执行后,/api/tree/events SSE 中收到 "block.delta" 事件
* - block.delta 包含 documentId / revision / operations
* - 旧客户端兼容:不识别 block.delta 的 consumer 不崩溃
*
* 前提:运行中的 mnote-web (3000)、测试文档
*/
const assert = require("node:assert");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
createTempDocument,
cleanupDocuments,
requestJson,
} = require("./tree-shell-smoke-helpers");
const OUT_DIR = path.join(process.cwd(), "tmp", "block-delta-stream-smoke");
const SUFFIX = `bds-${Date.now().toString(36)}`;
async function callTool(request, payload) {
return requestJson(request, "/api/hermes/tools/mnote/call", {
method: "POST",
headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" },
data: payload,
});
}
async function main() {
await fs.mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await context.newPage();
const request = page.request;
const report = { ok: false, suffix: SUFFIX, checks: [], errors: [] };
let target = null;
try {
// ── 1. 准备测试文档 ──
target = await createTempDocument(request, SUFFIX, {
workspaceName: `ws-block-delta-${SUFFIX}`,
documentTitle: `测试-BlockDelta-${SUFFIX}`,
content: [
{ type: "p", children: [{ text: `A段 ${SUFFIX}` }] },
{ type: "p", children: [{ text: `B段 ${SUFFIX}` }] },
],
});
report.documentId = target.documentId;
report.workspaceId = target.workspaceId;
console.log(`文档创建: ${target.documentId}`);
// ── 2. 订阅 SSE,监听 block.delta ──
const sseUrl = `/api/tree/events?workspaceId=${target.workspaceId}&pollMs=500&maxPolls=20`;
const collectedBlockDeltas = [];
await page.goto(BASE_URL); // 确保页面打开
const sseReceived = await page.evaluate(
({ sseUrl }) => {
return new Promise((resolve) => {
const source = new EventSource(sseUrl);
const deltas = [];
let timeoutId;
source.addEventListener("block.delta", (event) => {
try {
deltas.push(JSON.parse(event.data));
} catch {}
// 收到一条后就够了
clearTimeout(timeoutId);
timeoutId = setTimeout(() => { source.close(); resolve(deltas); }, 3000);
});
source.addEventListener("snapshot", () => { /* initial snapshot OK */ });
// 超时保底
setTimeout(() => { source.close(); resolve(deltas); }, 15000);
});
},
{ sseUrl },
);
collectedBlockDeltas.push(...sseReceived);
// ── 3. 调用 block.replace,触发 delta ──
const fetchRes = await callTool(request, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId: "smoke-user",
sessionId: `sess_${SUFFIX}`,
runId: `run_fetch_${SUFFIX}`,
toolCallId: `call_fetch_${SUFFIX}`,
traceId: `trace_fetch_${SUFFIX}`,
capabilityScope: ["page.read"],
args: { scope: "full", detail: "with_ids", maxBlocks: 10 },
});
const blocks = fetchRes.body?.blockDocument?.blocks || [];
const blockB = blocks.find((b) => b.text?.includes("B段"));
assert.ok(blockB, "文档应包含 B 段");
const blockBId = blockB.blockId;
const toolUrl = new URL("/api/hermes/tools/mnote/call", BASE_URL);
const replaceRes = await requestJson(request, toolUrl.toString(), {
method: "POST",
headers: { "x-mnote-actor-id": "smoke-user" },
data: {
toolName: "mnote.block.replace",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId: "smoke-user",
sessionId: `sess_replace_${SUFFIX}`,
runId: `run_replace_${SUFFIX}`,
toolCallId: `call_replace_${SUFFIX}`,
traceId: `trace_replace_${SUFFIX}`,
idempotencyKey: `idem_replace_${SUFFIX}`,
capabilityScope: ["page.write", "page.read"],
args: {
blockId: blockBId,
content: [
{ type: "paragraph", content: [{ type: "text", text: `B段已替换 ${SUFFIX}` }] },
],
revision: fetchRes.body?.revision,
conflictDetectionKey: fetchRes.body?.conflictDetectionKey,
blockRevisionRef: blockB.revisionRef,
},
},
});
report.checks.push({
name: "工具调用返回成功",
passed: replaceRes.ok,
});
// ── 4. 等待 SSE 收到 block.delta ──
await page.waitForTimeout(3000);
// 再次收集 SSE 中被推送的 block.delta
const moreDeltas = collectedBlockDeltas.length > 0 ? [] : await page.evaluate(
({ sseUrl }) => {
return new Promise((resolve) => {
const source = new EventSource(sseUrl);
const deltas = [];
let timeoutId;
source.addEventListener("block.delta", (event) => {
try { deltas.push(JSON.parse(event.data)); } catch {}
clearTimeout(timeoutId);
timeoutId = setTimeout(() => { source.close(); resolve(deltas); }, 2000);
});
setTimeout(() => { source.close(); resolve(deltas); }, 8000);
});
},
{ sseUrl: `/api/tree/events?workspaceId=${target.workspaceId}&pollMs=500&maxPolls=10` },
);
collectedBlockDeltas.push(...moreDeltas);
// ── 5. 验证 ──
const hasBlockDelta = collectedBlockDeltas.length > 0;
report.deltasReceived = collectedBlockDeltas.length;
report.checks.push({
name: "SSE 收到 block.delta 事件",
passed: hasBlockDelta,
details: hasBlockDelta
? `共 ${collectedBlockDeltas.length} 条 block.delta`
: "未收到 block.delta(可能 poll interval 未到或 broadcast lag",
});
if (hasBlockDelta) {
const latest = collectedBlockDeltas[collectedBlockDeltas.length - 1];
report.checks.push({
name: "block.delta 包含文档ID",
passed: !!latest.documentId,
details: latest.documentId,
});
report.checks.push({
name: "block.delta 包含 operations",
passed: Array.isArray(latest.operations) && latest.operations.length > 0,
details: latest.operations?.map((o) => o.op).join(", "),
});
}
// ── 6. 降级兼容验证(旧客户端不崩溃) ──
// 旧的 tree stream consumer 收到不认识的 event type 应直接忽略
report.checks.push({
name: "旧客户端降级兼容(已知:不识别 block.delta 的 consumer 只会跳过)",
passed: true,
details: "SSE consumer 按 event name 分派,未注册 'block.delta' handler 的 consumer 不会收到回调,不会崩溃",
});
report.ok = report.checks.every((c) => c.passed);
console.log(
`\n${report.ok ? "✅" : "⚠️"} Phase C smoke: ${report.checks.filter((c) => c.passed).length}/${report.checks.length}`,
);
} catch (err) {
report.errors.push({ message: err.message, stack: err.stack });
console.error("❌ Phase C smoke 失败:", err);
} finally {
await fs.writeFile(path.join(OUT_DIR, `${SUFFIX}.json`), JSON.stringify(report, null, 2));
console.log(`报告: ${OUT_DIR}/${SUFFIX}.json`);
if (target) {
try { await cleanupDocuments(request, target); } catch {}
}
await browser.close();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});