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
+49 -2
View File
@@ -19,6 +19,15 @@ pub async fn events(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<StreamSnapshotQuery>,
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, WebError> {
events_with_block_delta(state, context, query, None).await
}
async fn events_with_block_delta(
state: AppState,
context: RequestContext,
query: StreamSnapshotQuery,
block_delta_rx: Option<tokio::sync::broadcast::Receiver<Value>>,
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, WebError> {
let initial_payload = load_stream_snapshot(state.config(), &context, &query).await?;
let initial_cursor = read_stream_cursor_from_payload(&initial_payload);
@@ -36,6 +45,7 @@ pub async fn events(
polls: 0,
initial_payload,
initial_emitted: false,
block_delta_rx,
}),
move |state| async move {
let mut state = state?;
@@ -48,6 +58,23 @@ pub async fn events(
));
}
// Phase C:在每次 poll 前先检查是否有 block.delta 可发送
if let Some(ref mut rx) = state.block_delta_rx {
match rx.try_recv() {
Ok(payload) => {
return Some((
Ok(stream_event("block.delta", &payload)),
Some(state),
));
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
state.block_delta_rx = None;
}
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => {}
}
}
loop {
if let Some(max_polls) = max_polls {
if state.polls >= max_polls {
@@ -57,6 +84,23 @@ pub async fn events(
state.polls += 1;
sleep(Duration::from_millis(poll_ms)).await;
// 每次 poll 后也检查一下 delta
if let Some(ref mut rx) = state.block_delta_rx {
match rx.try_recv() {
Ok(payload) => {
return Some((
Ok(stream_event("block.delta", &payload)),
Some(state),
));
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
state.block_delta_rx = None;
}
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => {}
}
}
let poll_query = live_poll_query(&state.query);
let Ok((workspace_id, overview)) =
load_stream_overview(state.app_state.config(), &state.context, &poll_query)
@@ -140,11 +184,11 @@ pub async fn tree_events(
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-tree-stream-owner") {
headers.insert(name, HeaderValue::from_static("rust-web"));
}
let sse = events(State(state), Extension(context), Query(query)).await?;
let block_delta_rx = state.block_delta_tx.subscribe();
let sse = events_with_block_delta(state, context, query, Some(block_delta_rx)).await?;
Ok((headers, sse))
}
#[derive(Clone)]
struct StreamPollState {
app_state: AppState,
context: RequestContext,
@@ -153,6 +197,8 @@ struct StreamPollState {
polls: u32,
initial_payload: Value,
initial_emitted: bool,
#[allow(dead_code)]
block_delta_rx: Option<tokio::sync::broadcast::Receiver<Value>>,
}
fn live_poll_query(query: &StreamSnapshotQuery) -> StreamSnapshotQuery {
@@ -203,6 +249,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,