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
This commit is contained in:
lix-2026
2026-05-16 22:03:30 +08:00
parent d8bfaea306
commit f292c6710a
101 changed files with 13618 additions and 2416 deletions
@@ -2936,6 +2936,7 @@ mod tests {
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -2969,6 +2970,29 @@ mod tests {
}))
}
fn app_with_unreachable_convex_without_fixture() -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: None,
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: Some("http://127.0.0.1:9".into()),
convex_admin_key: Some("test-admin-key".into()),
allow_dev_fixtures: false,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
#[tokio::test]
async fn document_shell_returns_page_aggregate_snapshot() {
let response = app()
@@ -3100,6 +3124,100 @@ mod tests {
assert_eq!(payload["result"]["body"]["revision"], 7);
}
#[tokio::test]
async fn page_aggregate_endpoint_errors_without_convex_or_fixture() {
let response = app_with_unreachable_convex_without_fixture()
.oneshot(
Request::builder()
.uri("/api/page-aggregate/doc_1?workspaceId=ws_demo")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
if response.status() != StatusCode::SERVICE_UNAVAILABLE {
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
panic!(
"expected SERVICE_UNAVAILABLE, got {status}: {}",
String::from_utf8_lossy(&body)
);
}
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("convex_unavailable")
);
assert_eq!(
response
.headers()
.get("x-error-phase")
.and_then(|value| value.to_str().ok()),
Some("query_send")
);
assert_eq!(
response
.headers()
.get("x-upstream-service")
.and_then(|value| value.to_str().ok()),
Some("convex")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "convex_unavailable");
assert!(payload.get("schema").is_none());
assert!(payload.get("result").is_none());
}
#[tokio::test]
async fn document_shell_errors_without_convex_or_fixture() {
let response = app_with_unreachable_convex_without_fixture()
.oneshot(
Request::builder()
.uri("/documents/doc_1?workspaceId=ws_demo")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
if response.status() != StatusCode::SERVICE_UNAVAILABLE {
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
panic!(
"expected SERVICE_UNAVAILABLE, got {status}: {}",
String::from_utf8_lossy(&body)
);
}
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("convex_unavailable")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
let payload: Value = serde_json::from_str(&text).expect("json");
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "convex_unavailable");
assert!(!text.contains("mnote.page_aggregate.v1"));
assert!(!text.contains("data-mnote-dev-fixture"));
assert!(!text.contains("data-page-aggregate-snapshot"));
}
#[tokio::test]
async fn page_aggregate_endpoint_returns_local_markdown_readonly_snapshot() {
let root =
@@ -3231,6 +3349,7 @@ mod tests {
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,