## Problem
Convex backend RSS grew to 7.7G due to ~17 HTTP POST /api/query/min
(32K+ in 32h) from the SSE polling loop in /api/tree/events?pollMs=1000.
Each poll triggered a Convex query even when nothing changed.
## Root Cause
The tree live EventSource client polled every 1s via SSE, calling
load_stream_overview() → execute_runtime_query_via_convex() → Convex
POST /api/query on every cycle, regardless of workspace state.
## Solution
Replace polling with push: add a `stream_delta_tx` broadcast channel
that publishes after every Convex mutation, consumed by WebSocket and
SSE endpoints for push-only delivery.
### Server-side
- **app.rs**: Add `stream_delta_tx: broadcast::Sender<Value>` to AppState
- **command_support.rs**: `execute_runtime_command_via_convex_with_artifacts`
now takes `&AppState` (was `&AppConfig`) and pushes `{"kind":"command_committed",...}`
to `stream_delta_tx` after every successful mutation
- **ws.rs**: Rewrite `handle_socket` with `tokio::select!` subscribing to
`stream_delta_tx`; pushes delta events to WS clients on mutation, handles
client `resync` requests for fresh snapshots
- **sse.rs**: `tree_events` endpoint now subscribes to both `block_delta_tx`
and `stream_delta_tx`; when broadcast channels are available, runs in
push-only mode (250ms heartbeat, no Convex query). Polling degrades to
60s safety net. Keeps backward compatibility for non-WS clients.
### Client-side
- **layout.rs**: Bootstrap JSON now defaults to `transport: "convex-command-log-ws"`
with `wsEndpoint: "/api/realtime/ws"`. TREE_LIVE_CONTROLLER_JS extended
with `startWithWebSocket()` supporting snapshot/delta/resync/lagged-hint
events; auto-fallback to SSE on WS failure after 2s.
### Caller updates (17 call sites)
- documents.rs, mindmap_api.rs, resource_trash.rs, tree.rs
- hermes_tools/{artifact,block,page}.rs
All updated from `state.config()` to `&state` for the new signature.
## Verification
- `cargo build` + `cargo test`: 295/298 passed (3 pre-existing failures)
- Browser smoke: page loaded → transport=convex-command-log-ws, status=connected
- Convex logs: 0 POST /api/query in 2min with page idle (vs ~17/min before)
- Initial burst: 8 queries on page load (normal), then silence
1400 lines
47 KiB
Rust
1400 lines
47 KiB
Rust
use crate::app::AppState;
|
||
use crate::context::RequestContext;
|
||
use crate::error::WebError;
|
||
use crate::hermes_tools::doc::{
|
||
aggregate_value, block_id_of, block_not_found, block_projection_blocks, find_block,
|
||
required_arg,
|
||
};
|
||
use crate::hermes_tools::ToolCallInput;
|
||
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
|
||
use bridge_runtime::{
|
||
apply_editor_command_to_legacy_content, RuntimeActorWire, RuntimeCommandEnvelopeWire,
|
||
RuntimeSourceWire, RuntimeTargetWire,
|
||
};
|
||
use core_protocol::{
|
||
BlockProps, ContentNode, ContentNodePayload, EditorBlock, EditorBlockType, EditorCommand,
|
||
EditorDeleteBlock, EditorInsertBlockAfter, EditorMoveBlock, EditorReplaceBlock,
|
||
};
|
||
use serde_json::{json, Value};
|
||
use std::collections::{BTreeMap, HashSet};
|
||
|
||
pub async fn block_fetch(
|
||
state: &AppState,
|
||
context: &RequestContext,
|
||
input: &ToolCallInput,
|
||
) -> Result<Value, WebError> {
|
||
let aggregate = aggregate_value(state, context, input).await?;
|
||
let block_id = required_arg(input, context, "blockId")?;
|
||
let blocks = block_projection_blocks(&aggregate);
|
||
let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?;
|
||
let context_before = input
|
||
.arg_value("contextBefore")
|
||
.and_then(|value| value.as_u64())
|
||
.unwrap_or(1) as usize;
|
||
let context_after = input
|
||
.arg_value("contextAfter")
|
||
.and_then(|value| value.as_u64())
|
||
.unwrap_or(1) as usize;
|
||
let siblings = same_parent_blocks(&blocks, &block);
|
||
let index = siblings
|
||
.iter()
|
||
.position(|candidate| block_id_of(candidate).as_deref() == Some(block_id.as_str()))
|
||
.unwrap_or(0);
|
||
let before_start = index.saturating_sub(context_before);
|
||
let before = siblings[before_start..index].to_vec();
|
||
let after = siblings
|
||
.iter()
|
||
.skip(index + 1)
|
||
.take(context_after)
|
||
.cloned()
|
||
.collect::<Vec<_>>();
|
||
let format = input
|
||
.arg_string("format")
|
||
.unwrap_or_else(|| "json".into())
|
||
.to_ascii_lowercase();
|
||
|
||
Ok(json!({
|
||
"ok": true,
|
||
"documentId": input.effective_document_id(),
|
||
"workspaceId": input.effective_workspace_id(),
|
||
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
|
||
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
||
"format": format,
|
||
"content": block_to_ai_content(&format, &block, &aggregate, input.effective_document_id().as_deref().unwrap_or_default()),
|
||
"block": block,
|
||
"context": {
|
||
"before": before,
|
||
"after": after
|
||
},
|
||
"warnings": []
|
||
}))
|
||
}
|
||
|
||
pub async fn block_replace(
|
||
state: &AppState,
|
||
context: &RequestContext,
|
||
input: &ToolCallInput,
|
||
) -> Result<Value, WebError> {
|
||
ensure_write_contract(context, input)?;
|
||
let aggregate = aggregate_value(state, context, input).await?;
|
||
let block_id = required_arg(input, context, "blockId")?;
|
||
let content = input.arg_value("content").ok_or_else(|| {
|
||
WebError::bad_request_code("mnote_tool_bad_request", "mnote.block.replace 缺少 content")
|
||
.with_context(context)
|
||
})?;
|
||
let blocks = block_projection_blocks(&aggregate);
|
||
let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?;
|
||
ensure_page_write_preconditions(context, input, &aggregate)?;
|
||
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
|
||
ensure_block_editable(context, &block)?;
|
||
let replacement_text = content_to_text(&content);
|
||
let diff = json!([{
|
||
"op": "replace",
|
||
"targetBlockId": block_id,
|
||
"before": block.get("text").cloned().unwrap_or(Value::Null),
|
||
"after": replacement_text
|
||
}]);
|
||
if input.dry_run.unwrap_or(false) {
|
||
return Ok(dry_run_result(
|
||
input,
|
||
&aggregate,
|
||
"block_replace",
|
||
diff,
|
||
false,
|
||
));
|
||
}
|
||
let current_content = current_body_content(&aggregate);
|
||
let command = EditorCommand::ReplaceBlock(EditorReplaceBlock {
|
||
block_id: block_id.clone(),
|
||
block_type: replacement_block_type(&content),
|
||
props: replacement_block_props(&content),
|
||
content_nodes: Some(build_content_nodes(&replacement_text)),
|
||
});
|
||
let (next_content, delta_opt) = compute_next_content_via_actor(
|
||
state,
|
||
&aggregate,
|
||
input,
|
||
¤t_content,
|
||
&command,
|
||
"block_replace",
|
||
)?;
|
||
let mut result = execute_page_body_save(
|
||
state,
|
||
context,
|
||
input,
|
||
next_content,
|
||
vec![json!({
|
||
"blockId": block_id,
|
||
"op": "replace"
|
||
})],
|
||
)
|
||
.await?;
|
||
merge_block_delta(&mut result, delta_opt);
|
||
Ok(result)
|
||
}
|
||
|
||
pub async fn block_insert_after(
|
||
state: &AppState,
|
||
context: &RequestContext,
|
||
input: &ToolCallInput,
|
||
) -> Result<Value, WebError> {
|
||
ensure_write_contract(context, input)?;
|
||
let aggregate = aggregate_value(state, context, input).await?;
|
||
let anchor_block_id = input
|
||
.arg_string("anchorBlockId")
|
||
.or_else(|| input.arg_string("afterBlockId"))
|
||
.ok_or_else(|| {
|
||
WebError::bad_request_code(
|
||
"mnote_tool_bad_request",
|
||
"mnote.block.insert_after 缺少 anchorBlockId",
|
||
)
|
||
.with_context(context)
|
||
})?;
|
||
let blocks = block_projection_blocks(&aggregate);
|
||
let anchor = find_block(&blocks, &anchor_block_id).ok_or_else(|| block_not_found(context))?;
|
||
ensure_page_write_preconditions(context, input, &aggregate)?;
|
||
ensure_block_revision_ref(context, input, &anchor, "anchorRevisionRef")?;
|
||
ensure_block_editable(context, &anchor)?;
|
||
let content = input
|
||
.arg_value("content")
|
||
.or_else(|| input.arg_value("block"))
|
||
.ok_or_else(|| {
|
||
WebError::bad_request_code(
|
||
"mnote_tool_bad_request",
|
||
"mnote.block.insert_after 缺少 content",
|
||
)
|
||
.with_context(context)
|
||
})?;
|
||
let new_block_id = format!("ai_block_{}", context.trace.request_id.replace('-', "_"));
|
||
let new_block = build_insert_block(&new_block_id, &content);
|
||
let diff = json!([{
|
||
"op": "insert_after",
|
||
"anchorBlockId": anchor_block_id,
|
||
"after": anchor.get("text").cloned().unwrap_or(Value::Null),
|
||
"block": new_block
|
||
}]);
|
||
if input.dry_run.unwrap_or(false) {
|
||
return Ok(dry_run_result(
|
||
input,
|
||
&aggregate,
|
||
"block_insert_after",
|
||
diff,
|
||
false,
|
||
));
|
||
}
|
||
let current_content = current_body_content(&aggregate);
|
||
let command = EditorCommand::InsertBlockAfter(EditorInsertBlockAfter {
|
||
after_block_id: anchor_block_id.clone(),
|
||
block: build_editor_block(&new_block_id, &content),
|
||
});
|
||
let (next_content, delta_opt) = compute_next_content_via_actor(
|
||
state,
|
||
&aggregate,
|
||
input,
|
||
¤t_content,
|
||
&command,
|
||
"block_insert_after",
|
||
)?;
|
||
let mut result = execute_page_body_save(
|
||
state,
|
||
context,
|
||
input,
|
||
next_content,
|
||
vec![json!({
|
||
"blockId": new_block_id,
|
||
"op": "insert_after",
|
||
"anchorBlockId": anchor_block_id
|
||
})],
|
||
)
|
||
.await?;
|
||
merge_block_delta(&mut result, delta_opt);
|
||
Ok(result)
|
||
}
|
||
|
||
pub async fn block_delete(
|
||
state: &AppState,
|
||
context: &RequestContext,
|
||
input: &ToolCallInput,
|
||
) -> Result<Value, WebError> {
|
||
ensure_write_contract(context, input)?;
|
||
let aggregate = aggregate_value(state, context, input).await?;
|
||
let block_id = required_arg(input, context, "blockId")?;
|
||
let blocks = block_projection_blocks(&aggregate);
|
||
let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?;
|
||
ensure_page_write_preconditions(context, input, &aggregate)?;
|
||
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
|
||
ensure_block_editable(context, &block)?;
|
||
let leaf = block
|
||
.get("children")
|
||
.and_then(Value::as_array)
|
||
.map(|children| children.is_empty())
|
||
.unwrap_or(true);
|
||
let blocked = !leaf;
|
||
let diff = json!([{
|
||
"op": "delete",
|
||
"blockId": block_id,
|
||
"before": block.get("text").cloned().unwrap_or(Value::Null)
|
||
}]);
|
||
if input.dry_run.unwrap_or(false) || blocked {
|
||
return Ok(json!({
|
||
"ok": true,
|
||
"dryRun": input.dry_run.unwrap_or(false),
|
||
"command": "block_delete",
|
||
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
|
||
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
||
"blocked": blocked,
|
||
"risk": if blocked { "medium" } else { "low" },
|
||
"diff": diff,
|
||
"warnings": if blocked {
|
||
json!([{
|
||
"code": "block_delete_blocked",
|
||
"message": "第一阶段仅开放无子块的普通块删除"
|
||
}])
|
||
} else {
|
||
json!([])
|
||
}
|
||
}));
|
||
}
|
||
let current_content = current_body_content(&aggregate);
|
||
let command = EditorCommand::DeleteBlock(EditorDeleteBlock {
|
||
block_id: block_id.clone(),
|
||
preserve_children: false,
|
||
});
|
||
let (next_content, delta_opt) = compute_next_content_via_actor(
|
||
state,
|
||
&aggregate,
|
||
input,
|
||
¤t_content,
|
||
&command,
|
||
"block_delete",
|
||
)?;
|
||
let mut result = execute_page_body_save(
|
||
state,
|
||
context,
|
||
input,
|
||
next_content,
|
||
vec![json!({
|
||
"blockId": block_id,
|
||
"op": "delete"
|
||
})],
|
||
)
|
||
.await?;
|
||
merge_block_delta(&mut result, delta_opt);
|
||
Ok(result)
|
||
}
|
||
|
||
pub async fn block_move_after(
|
||
state: &AppState,
|
||
context: &RequestContext,
|
||
input: &ToolCallInput,
|
||
) -> Result<Value, WebError> {
|
||
ensure_write_contract(context, input)?;
|
||
let aggregate = aggregate_value(state, context, input).await?;
|
||
let block_id = required_arg(input, context, "blockId")?;
|
||
let anchor_block_id = required_arg(input, context, "anchorBlockId")?;
|
||
let blocks = block_projection_blocks(&aggregate);
|
||
let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?;
|
||
let anchor = find_block(&blocks, &anchor_block_id).ok_or_else(|| block_not_found(context))?;
|
||
ensure_page_write_preconditions(context, input, &aggregate)?;
|
||
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
|
||
ensure_block_revision_ref(context, input, &anchor, "anchorRevisionRef")?;
|
||
let same_parent = block.get("parentBlockId") == anchor.get("parentBlockId");
|
||
let leaf = block
|
||
.get("children")
|
||
.and_then(Value::as_array)
|
||
.map(|children| children.is_empty())
|
||
.unwrap_or(true);
|
||
let movable_type = block
|
||
.get("type")
|
||
.and_then(Value::as_str)
|
||
.map(|block_type| matches!(block_type, "paragraph" | "heading" | "todo" | "task"))
|
||
.unwrap_or(false);
|
||
let editable = block
|
||
.get("editable")
|
||
.and_then(Value::as_bool)
|
||
.unwrap_or(false);
|
||
let blocked =
|
||
!same_parent || !leaf || !movable_type || !editable || block_id == anchor_block_id;
|
||
let diff = json!([{
|
||
"op": "move_after",
|
||
"blockId": block_id,
|
||
"anchorBlockId": anchor_block_id,
|
||
"from": {
|
||
"parentBlockId": block.get("parentBlockId").cloned().unwrap_or(Value::Null),
|
||
"order": block.get("order").cloned().unwrap_or(Value::Null)
|
||
},
|
||
"to": {
|
||
"parentBlockId": anchor.get("parentBlockId").cloned().unwrap_or(Value::Null),
|
||
"afterOrder": anchor.get("order").cloned().unwrap_or(Value::Null)
|
||
}
|
||
}]);
|
||
if input.dry_run.unwrap_or(false) || blocked {
|
||
return Ok(json!({
|
||
"ok": true,
|
||
"dryRun": input.dry_run.unwrap_or(false),
|
||
"command": "block_move_after",
|
||
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
|
||
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
||
"blocked": blocked,
|
||
"risk": "medium",
|
||
"diff": diff,
|
||
"warnings": if blocked {
|
||
json!([{
|
||
"code": "block_move_after_blocked",
|
||
"message": "第一阶段仅开放同父级普通叶子块移动,且不能移动到自身之后"
|
||
}])
|
||
} else {
|
||
json!([])
|
||
}
|
||
}));
|
||
}
|
||
let current_content = current_body_content(&aggregate);
|
||
let parent_block_id = anchor
|
||
.get("parentBlockId")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_owned);
|
||
let command = EditorCommand::MoveBlock(EditorMoveBlock {
|
||
block_id: block_id.clone(),
|
||
parent_block_id,
|
||
after_block_id: Some(anchor_block_id.clone()),
|
||
});
|
||
let (next_content, delta_opt) = compute_next_content_via_actor(
|
||
state,
|
||
&aggregate,
|
||
input,
|
||
¤t_content,
|
||
&command,
|
||
"block_move_after",
|
||
)?;
|
||
let mut result = execute_page_body_save(
|
||
state,
|
||
context,
|
||
input,
|
||
next_content,
|
||
vec![json!({
|
||
"blockId": block_id,
|
||
"op": "move_after",
|
||
"anchorBlockId": anchor_block_id
|
||
})],
|
||
)
|
||
.await?;
|
||
merge_block_delta(&mut result, delta_opt);
|
||
Ok(result)
|
||
}
|
||
|
||
pub async fn doc_apply_block_ops(
|
||
state: &AppState,
|
||
context: &RequestContext,
|
||
input: &ToolCallInput,
|
||
) -> Result<Value, WebError> {
|
||
ensure_write_contract(context, input)?;
|
||
let aggregate = aggregate_value(state, context, input).await?;
|
||
let operations = input
|
||
.arg_value("operations")
|
||
.and_then(|value| value.as_array().cloned())
|
||
.ok_or_else(|| {
|
||
WebError::bad_request_code(
|
||
"mnote_tool_bad_request",
|
||
"mnote.doc.apply_block_ops 缺少 operations",
|
||
)
|
||
.with_context(context)
|
||
})?;
|
||
if operations.is_empty() {
|
||
return Err(WebError::bad_request_code(
|
||
"mnote_tool_bad_request",
|
||
"mnote.doc.apply_block_ops operations 不能为空",
|
||
)
|
||
.with_context(context));
|
||
}
|
||
if operations.len() > 12 {
|
||
return Err(WebError::bad_request_code(
|
||
"mnote_tool_bad_request",
|
||
"mnote.doc.apply_block_ops 一次最多允许 12 个块操作",
|
||
)
|
||
.with_context(context));
|
||
}
|
||
|
||
let blocks = block_projection_blocks(&aggregate);
|
||
let mut next_content = current_body_content(&aggregate);
|
||
let mut changed_blocks = Vec::new();
|
||
let mut diff = Vec::new();
|
||
let document_id = input.effective_document_id().unwrap_or_default();
|
||
|
||
for (index, operation) in operations.iter().enumerate() {
|
||
let op = operation
|
||
.get("op")
|
||
.or_else(|| operation.get("operation"))
|
||
.and_then(Value::as_str)
|
||
.map(|value| value.trim().replace('-', "_").to_ascii_lowercase())
|
||
.unwrap_or_default();
|
||
match op.as_str() {
|
||
"replace" | "block_replace" => {
|
||
let block = resolve_target_block(context, &blocks, operation, false)?;
|
||
ensure_block_editable(context, &block)?;
|
||
let block_id = block_id_of(&block).unwrap_or_default();
|
||
let content = operation.get("content").cloned().ok_or_else(|| {
|
||
WebError::bad_request_code("mnote_tool_bad_request", "replace 操作缺少 content")
|
||
.with_context(context)
|
||
})?;
|
||
let replacement_text = content_to_text(&content);
|
||
ensure_allowed_target(context, input, operation, &block_id, "replace")?;
|
||
diff.push(json!({
|
||
"op": "replace",
|
||
"blockId": block_id,
|
||
"before": block.get("text").cloned().unwrap_or(Value::Null),
|
||
"after": replacement_text
|
||
}));
|
||
if input.dry_run == Some(true) {
|
||
continue;
|
||
}
|
||
next_content = apply_editor_command_to_legacy_content(
|
||
&document_id,
|
||
&next_content,
|
||
EditorCommand::ReplaceBlock(EditorReplaceBlock {
|
||
block_id: block_id.clone(),
|
||
block_type: replacement_block_type(&content),
|
||
props: replacement_block_props(&content),
|
||
content_nodes: Some(build_content_nodes(&replacement_text)),
|
||
}),
|
||
)
|
||
.map_err(|error| {
|
||
WebError::bad_request_code("mnote_block_command_failed", format!("{error:?}"))
|
||
.with_context(context)
|
||
})?;
|
||
changed_blocks.push(json!({"blockId": block_id, "op": "replace"}));
|
||
}
|
||
"insert_after" | "block_insert_after" => {
|
||
let anchor = resolve_anchor_block(context, &blocks, operation)?;
|
||
ensure_block_editable(context, &anchor)?;
|
||
let anchor_block_id = block_id_of(&anchor).unwrap_or_default();
|
||
ensure_allowed_target(context, input, operation, &anchor_block_id, "insert_after")?;
|
||
let content = operation.get("content").cloned().ok_or_else(|| {
|
||
WebError::bad_request_code(
|
||
"mnote_tool_bad_request",
|
||
"insert_after 操作缺少 content",
|
||
)
|
||
.with_context(context)
|
||
})?;
|
||
let new_block_id = format!(
|
||
"ai_block_{}_{}",
|
||
context.trace.request_id.replace('-', "_"),
|
||
index
|
||
);
|
||
diff.push(json!({
|
||
"op": "insert_after",
|
||
"anchorBlockId": anchor_block_id,
|
||
"after": anchor.get("text").cloned().unwrap_or(Value::Null),
|
||
"blockId": new_block_id,
|
||
"content": content
|
||
}));
|
||
if input.dry_run == Some(true) {
|
||
continue;
|
||
}
|
||
next_content = apply_editor_command_to_legacy_content(
|
||
&document_id,
|
||
&next_content,
|
||
EditorCommand::InsertBlockAfter(EditorInsertBlockAfter {
|
||
after_block_id: anchor_block_id.clone(),
|
||
block: build_editor_block(&new_block_id, &content),
|
||
}),
|
||
)
|
||
.map_err(|error| {
|
||
WebError::bad_request_code("mnote_block_command_failed", format!("{error:?}"))
|
||
.with_context(context)
|
||
})?;
|
||
changed_blocks.push(json!({
|
||
"blockId": new_block_id,
|
||
"op": "insert_after",
|
||
"anchorBlockId": anchor_block_id
|
||
}));
|
||
}
|
||
"delete" | "block_delete" => {
|
||
let block = resolve_target_block(context, &blocks, operation, false)?;
|
||
ensure_block_editable(context, &block)?;
|
||
let block_id = block_id_of(&block).unwrap_or_default();
|
||
ensure_allowed_target(context, input, operation, &block_id, "delete")?;
|
||
ensure_leaf_block(context, &block, "delete")?;
|
||
diff.push(json!({
|
||
"op": "delete",
|
||
"blockId": block_id,
|
||
"before": block.get("text").cloned().unwrap_or(Value::Null)
|
||
}));
|
||
if input.dry_run == Some(true) {
|
||
continue;
|
||
}
|
||
next_content = apply_editor_command_to_legacy_content(
|
||
&document_id,
|
||
&next_content,
|
||
EditorCommand::DeleteBlock(EditorDeleteBlock {
|
||
block_id: block_id.clone(),
|
||
preserve_children: false,
|
||
}),
|
||
)
|
||
.map_err(|error| {
|
||
WebError::bad_request_code("mnote_block_command_failed", format!("{error:?}"))
|
||
.with_context(context)
|
||
})?;
|
||
changed_blocks.push(json!({"blockId": block_id, "op": "delete"}));
|
||
}
|
||
"move_after" | "block_move_after" => {
|
||
let block = resolve_target_block(context, &blocks, operation, false)?;
|
||
let anchor = resolve_anchor_block(context, &blocks, operation)?;
|
||
ensure_block_editable(context, &block)?;
|
||
ensure_leaf_block(context, &block, "move_after")?;
|
||
let block_id = block_id_of(&block).unwrap_or_default();
|
||
let anchor_block_id = block_id_of(&anchor).unwrap_or_default();
|
||
ensure_allowed_target(context, input, operation, &block_id, "move_after")?;
|
||
ensure_allowed_target(
|
||
context,
|
||
input,
|
||
operation,
|
||
&anchor_block_id,
|
||
"move_after_anchor",
|
||
)?;
|
||
if block.get("parentBlockId") != anchor.get("parentBlockId")
|
||
|| block_id == anchor_block_id
|
||
{
|
||
return Err(WebError::bad_request_code(
|
||
"mnote_block_unsupported",
|
||
"move_after 批量快路径第一阶段仅支持同父级普通叶子块",
|
||
)
|
||
.with_context(context));
|
||
}
|
||
diff.push(json!({
|
||
"op": "move_after",
|
||
"blockId": block_id,
|
||
"anchorBlockId": anchor_block_id
|
||
}));
|
||
if input.dry_run == Some(true) {
|
||
continue;
|
||
}
|
||
let parent_block_id = anchor
|
||
.get("parentBlockId")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_owned);
|
||
next_content = apply_editor_command_to_legacy_content(
|
||
&document_id,
|
||
&next_content,
|
||
EditorCommand::MoveBlock(EditorMoveBlock {
|
||
block_id: block_id.clone(),
|
||
parent_block_id,
|
||
after_block_id: Some(anchor_block_id.clone()),
|
||
}),
|
||
)
|
||
.map_err(|error| {
|
||
WebError::bad_request_code("mnote_block_command_failed", format!("{error:?}"))
|
||
.with_context(context)
|
||
})?;
|
||
changed_blocks.push(json!({
|
||
"blockId": block_id,
|
||
"op": "move_after",
|
||
"anchorBlockId": anchor_block_id
|
||
}));
|
||
}
|
||
_ => {
|
||
return Err(WebError::bad_request_code(
|
||
"mnote_tool_bad_request",
|
||
format!("mnote.doc.apply_block_ops 不支持 op={op}"),
|
||
)
|
||
.with_context(context));
|
||
}
|
||
}
|
||
}
|
||
|
||
if input.dry_run == Some(true) {
|
||
return Ok(json!({
|
||
"ok": true,
|
||
"dryRun": true,
|
||
"command": "doc_apply_block_ops",
|
||
"documentId": input.effective_document_id(),
|
||
"workspaceId": input.effective_workspace_id(),
|
||
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
|
||
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
||
"diff": diff,
|
||
"warnings": [],
|
||
"blocked": false,
|
||
"risk": if operations.len() > 4 { "medium" } else { "low" }
|
||
}));
|
||
}
|
||
|
||
execute_page_body_save_from_aggregate(
|
||
state,
|
||
context,
|
||
input,
|
||
&aggregate,
|
||
next_content,
|
||
changed_blocks,
|
||
)
|
||
.await
|
||
}
|
||
|
||
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
|
||
if !input.has_idempotency_key() {
|
||
return Err(WebError::bad_request_code(
|
||
"mnote_tool_idempotency_required",
|
||
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
|
||
)
|
||
.with_context(context));
|
||
}
|
||
if input.dry_run.is_none() {
|
||
return Err(WebError::bad_request_code(
|
||
"mnote_tool_dry_run_required",
|
||
"写入型 mnote Hermes tool 必须显式携带 dryRun",
|
||
)
|
||
.with_context(context));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn ensure_leaf_block(
|
||
context: &RequestContext,
|
||
block: &Value,
|
||
operation: &'static str,
|
||
) -> Result<(), WebError> {
|
||
let leaf = block
|
||
.get("children")
|
||
.and_then(Value::as_array)
|
||
.map(|children| children.is_empty())
|
||
.unwrap_or(true);
|
||
if leaf {
|
||
return Ok(());
|
||
}
|
||
Err(WebError::bad_request_code(
|
||
"mnote_block_unsupported",
|
||
format!("{operation} 第一阶段仅支持无子块普通块"),
|
||
)
|
||
.with_context(context))
|
||
}
|
||
|
||
fn resolve_target_block(
|
||
context: &RequestContext,
|
||
blocks: &[Value],
|
||
operation: &Value,
|
||
allow_anchor_alias: bool,
|
||
) -> Result<Value, WebError> {
|
||
let block_id = operation
|
||
.get("blockId")
|
||
.or_else(|| operation.get("block_id"))
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned);
|
||
if let Some(block_id) = block_id {
|
||
return find_block(blocks, &block_id).ok_or_else(|| block_not_found(context));
|
||
}
|
||
let text = operation
|
||
.get("matchText")
|
||
.or_else(|| operation.get("match_text"))
|
||
.or_else(|| operation.get("text"))
|
||
.or_else(|| {
|
||
if allow_anchor_alias {
|
||
operation
|
||
.get("anchorText")
|
||
.or_else(|| operation.get("afterText"))
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.ok_or_else(|| {
|
||
WebError::bad_request_code(
|
||
"mnote_tool_bad_request",
|
||
"块操作必须提供 blockId 或 matchText",
|
||
)
|
||
.with_context(context)
|
||
})?;
|
||
resolve_unique_block_by_text(context, blocks, text)
|
||
}
|
||
|
||
fn resolve_anchor_block(
|
||
context: &RequestContext,
|
||
blocks: &[Value],
|
||
operation: &Value,
|
||
) -> Result<Value, WebError> {
|
||
if let Some(anchor_id) = operation
|
||
.get("anchorBlockId")
|
||
.or_else(|| operation.get("anchor_block_id"))
|
||
.or_else(|| operation.get("afterBlockId"))
|
||
.or_else(|| operation.get("after_block_id"))
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
{
|
||
return find_block(blocks, anchor_id).ok_or_else(|| block_not_found(context));
|
||
}
|
||
resolve_target_block(context, blocks, operation, true)
|
||
}
|
||
|
||
fn resolve_unique_block_by_text(
|
||
context: &RequestContext,
|
||
blocks: &[Value],
|
||
text: &str,
|
||
) -> Result<Value, WebError> {
|
||
let exact = blocks
|
||
.iter()
|
||
.filter(|block| block.get("text").and_then(Value::as_str) == Some(text))
|
||
.cloned()
|
||
.collect::<Vec<_>>();
|
||
if exact.len() == 1 {
|
||
return Ok(exact[0].clone());
|
||
}
|
||
if exact.len() > 1 {
|
||
return Err(WebError::bad_request_code(
|
||
"mnote_block_ambiguous",
|
||
"匹配到多个同文本块,请改用 blockId",
|
||
)
|
||
.with_context(context));
|
||
}
|
||
let contains = blocks
|
||
.iter()
|
||
.filter(|block| {
|
||
block
|
||
.get("text")
|
||
.and_then(Value::as_str)
|
||
.map(|block_text| block_text.contains(text))
|
||
.unwrap_or(false)
|
||
})
|
||
.cloned()
|
||
.collect::<Vec<_>>();
|
||
if contains.len() == 1 {
|
||
return Ok(contains[0].clone());
|
||
}
|
||
if contains.len() > 1 {
|
||
return Err(WebError::bad_request_code(
|
||
"mnote_block_ambiguous",
|
||
"包含匹配命中多个块,请改用 blockId",
|
||
)
|
||
.with_context(context));
|
||
}
|
||
Err(block_not_found(context))
|
||
}
|
||
|
||
fn ensure_page_write_preconditions(
|
||
context: &RequestContext,
|
||
input: &ToolCallInput,
|
||
aggregate: &Value,
|
||
) -> Result<(), WebError> {
|
||
if input.dry_run.unwrap_or(false) {
|
||
return Ok(());
|
||
}
|
||
let expected_revision = input
|
||
.arg_value("revision")
|
||
.ok_or_else(|| missing_write_precondition(context, "revision"))?;
|
||
let expected_conflict_key = input
|
||
.arg_string("conflictDetectionKey")
|
||
.ok_or_else(|| missing_write_precondition(context, "conflictDetectionKey"))?;
|
||
let current_revision = aggregate
|
||
.pointer("/body/revision")
|
||
.cloned()
|
||
.unwrap_or(Value::Null);
|
||
let current_conflict_key = aggregate
|
||
.pointer("/body/conflictDetectionKey")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.unwrap_or("");
|
||
if value_label(&expected_revision).as_deref() != value_label(¤t_revision).as_deref()
|
||
|| expected_conflict_key != current_conflict_key
|
||
{
|
||
return Err(WebError::bad_request_code(
|
||
"mnote_tool_conflict",
|
||
"页面正文 revision 或 conflictDetectionKey 已变化,请重新读取后再写入",
|
||
)
|
||
.with_context(context));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn ensure_block_revision_ref(
|
||
context: &RequestContext,
|
||
input: &ToolCallInput,
|
||
block: &Value,
|
||
arg_name: &'static str,
|
||
) -> Result<(), WebError> {
|
||
if input.dry_run.unwrap_or(false) {
|
||
return Ok(());
|
||
}
|
||
let expected = input
|
||
.arg_string(arg_name)
|
||
.ok_or_else(|| missing_write_precondition(context, arg_name))?;
|
||
let current = block
|
||
.get("revisionRef")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.unwrap_or("");
|
||
if expected != current {
|
||
return Err(WebError::bad_request_code(
|
||
"mnote_tool_conflict",
|
||
format!("{arg_name} 已过期,请重新读取 block projection 后再写入"),
|
||
)
|
||
.with_context(context));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn ensure_block_editable(context: &RequestContext, block: &Value) -> Result<(), WebError> {
|
||
if block
|
||
.get("editable")
|
||
.and_then(Value::as_bool)
|
||
.unwrap_or(false)
|
||
{
|
||
return Ok(());
|
||
}
|
||
Err(WebError::bad_request_code(
|
||
"mnote_block_unsupported",
|
||
block
|
||
.get("unsupportedReason")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("目标块暂不支持 AI 精确写入"),
|
||
)
|
||
.with_context(context))
|
||
}
|
||
|
||
fn missing_write_precondition(context: &RequestContext, name: &'static str) -> WebError {
|
||
WebError::bad_request_code(
|
||
"mnote_tool_write_precondition_required",
|
||
format!("真实写入必须携带 {name};缺少时只能 dryRun=true"),
|
||
)
|
||
.with_context(context)
|
||
}
|
||
|
||
fn value_label(value: &Value) -> Option<String> {
|
||
if let Some(number) = value.as_i64() {
|
||
return Some(number.to_string());
|
||
}
|
||
if let Some(number) = value.as_u64() {
|
||
return Some(number.to_string());
|
||
}
|
||
value
|
||
.as_str()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
}
|
||
|
||
fn revision_number_value(value: Value) -> Option<Value> {
|
||
if let Some(number) = value.as_u64() {
|
||
return Some(json!(number));
|
||
}
|
||
value
|
||
.as_str()
|
||
.map(str::trim)
|
||
.and_then(|value| value.parse::<u64>().ok())
|
||
.map(|number| json!(number))
|
||
}
|
||
|
||
fn same_parent_blocks(blocks: &[Value], block: &Value) -> Vec<Value> {
|
||
let parent = block.get("parentBlockId").cloned().unwrap_or(Value::Null);
|
||
blocks
|
||
.iter()
|
||
.filter(|candidate| {
|
||
candidate
|
||
.get("parentBlockId")
|
||
.cloned()
|
||
.unwrap_or(Value::Null)
|
||
== parent
|
||
})
|
||
.cloned()
|
||
.collect()
|
||
}
|
||
|
||
fn dry_run_result(
|
||
input: &ToolCallInput,
|
||
aggregate: &Value,
|
||
command: &str,
|
||
diff: Value,
|
||
blocked: bool,
|
||
) -> Value {
|
||
json!({
|
||
"ok": true,
|
||
"dryRun": true,
|
||
"command": command,
|
||
"documentId": input.effective_document_id(),
|
||
"workspaceId": input.effective_workspace_id(),
|
||
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
|
||
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
||
"diff": diff,
|
||
"warnings": [],
|
||
"blocked": blocked,
|
||
"risk": "low"
|
||
})
|
||
}
|
||
|
||
/// 如果 editor_actor 已启用,通过它 apply 并获取 legacy content;否则用旧路径计算。
|
||
/// 返回 `(content, block_delta_opt)`,其中 block_delta_opt 为序列化后的 JSON(Phase B)。
|
||
fn compute_next_content_via_actor(
|
||
state: &AppState,
|
||
aggregate: &Value,
|
||
input: &ToolCallInput,
|
||
current_content: &Value,
|
||
command: &EditorCommand,
|
||
command_name: &str,
|
||
) -> Result<(Value, Option<Value>), WebError> {
|
||
if !state.config().enable_editor_actor {
|
||
let content = apply_editor_command_to_legacy_content(
|
||
input.effective_document_id().as_deref().unwrap_or_default(),
|
||
current_content,
|
||
command.clone(),
|
||
)
|
||
.map_err(|error| {
|
||
WebError::bad_request_code("mnote_block_command_failed", format!("{error:?}"))
|
||
})?;
|
||
return Ok((content, None));
|
||
}
|
||
|
||
let document_id = input.effective_document_id().ok_or_else(|| {
|
||
WebError::bad_request_code("mnote_tool_bad_request", "actor 路径缺少 documentId")
|
||
})?;
|
||
let workspace_id = input.effective_workspace_id();
|
||
|
||
// 确保 actor 已加载此文档
|
||
if !state.editor_actor.is_loaded(&document_id) {
|
||
state
|
||
.editor_actor
|
||
.load_or_init(&document_id, workspace_id.as_deref(), aggregate)?;
|
||
}
|
||
|
||
// 在内存中 apply
|
||
let _apply_result = state
|
||
.editor_actor
|
||
.apply_command(&document_id, command.clone(), command_name)?;
|
||
|
||
// 从 actor 获取 legacy content(用于 Convex save 的 payload)
|
||
let content = state.editor_actor.legacy_content_for_save(&document_id)?;
|
||
|
||
// 构建 BlockDelta(Phase B)并序列化为 JSON
|
||
let delta = state
|
||
.editor_actor
|
||
.build_block_delta(&document_id, command)
|
||
.ok()
|
||
.and_then(|bd| serde_json::to_value(bd).ok());
|
||
|
||
// Phase C:将 block.delta 推送到 broadcast 广播(SSE 事件 stream)
|
||
if let Some(ref delta_json) = delta {
|
||
state.editor_actor.try_push_block_delta(delta_json);
|
||
}
|
||
|
||
Ok((content, delta))
|
||
}
|
||
|
||
async fn execute_page_body_save(
|
||
state: &AppState,
|
||
context: &RequestContext,
|
||
input: &ToolCallInput,
|
||
content: Value,
|
||
changed_blocks: Vec<Value>,
|
||
) -> Result<Value, WebError> {
|
||
let document_id = input.effective_document_id().ok_or_else(|| {
|
||
WebError::bad_request_code("mnote_tool_bad_request", "块写工具缺少 documentId")
|
||
.with_context(context)
|
||
})?;
|
||
let workspace_id = input.effective_workspace_id();
|
||
let command_id = format!("page_body_save_{}", context.trace.request_id);
|
||
let payload = json!({
|
||
"documentId": document_id,
|
||
"workspaceId": workspace_id,
|
||
"content": content,
|
||
"mode": "replace",
|
||
"revision": input.arg_value("revision").and_then(revision_number_value),
|
||
"conflictDetectionKey": input.arg_value("conflictDetectionKey")
|
||
});
|
||
let command = RuntimeCommandEnvelopeWire {
|
||
name: "page.body.save".into(),
|
||
command_id: command_id.clone(),
|
||
idempotency_key: Some(input.idempotency_key_or_default(&command_id)),
|
||
actor: RuntimeActorWire {
|
||
actor_type: context.auth.actor_type.clone(),
|
||
actor_id: context.auth.actor_id.clone(),
|
||
session_id: input
|
||
.session_id
|
||
.clone()
|
||
.or_else(|| context.auth.session_id.clone()),
|
||
},
|
||
source: RuntimeSourceWire {
|
||
channel: "hermes".into(),
|
||
client: "mnote-hermes-plugin".into(),
|
||
source_kind: None,
|
||
root_uri: None,
|
||
workspace_id: workspace_id.clone(),
|
||
capabilities: input.capability_scope.clone().unwrap_or_default(),
|
||
},
|
||
target: Some(RuntimeTargetWire {
|
||
workspace_id: workspace_id.clone(),
|
||
page_id: Some(document_id.clone()),
|
||
block_id: None,
|
||
}),
|
||
payload,
|
||
preflight_data: None,
|
||
reason: Some("Hermes block tool page.body.save".into()),
|
||
refs: vec!["page.body.save".into(), "hermes-block-tool-call".into()],
|
||
dry_run: false,
|
||
validate_only: false,
|
||
};
|
||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||
&state,
|
||
context,
|
||
workspace_id.as_deref(),
|
||
command,
|
||
)
|
||
.await?;
|
||
Ok(json!({
|
||
"commandName": "page.body.save",
|
||
"commandId": command_id,
|
||
"changedBlocks": changed_blocks,
|
||
"result": execution.result,
|
||
"artifacts": execution.artifacts,
|
||
"artifactError": execution.artifact_error
|
||
}))
|
||
}
|
||
|
||
async fn execute_page_body_save_from_aggregate(
|
||
state: &AppState,
|
||
context: &RequestContext,
|
||
input: &ToolCallInput,
|
||
aggregate: &Value,
|
||
content: Value,
|
||
changed_blocks: Vec<Value>,
|
||
) -> Result<Value, WebError> {
|
||
let document_id = input.effective_document_id().ok_or_else(|| {
|
||
WebError::bad_request_code("mnote_tool_bad_request", "块写工具缺少 documentId")
|
||
.with_context(context)
|
||
})?;
|
||
let workspace_id = input.effective_workspace_id();
|
||
let command_id = format!("page_body_save_{}", context.trace.request_id);
|
||
let payload = json!({
|
||
"documentId": document_id,
|
||
"workspaceId": workspace_id,
|
||
"content": content,
|
||
"mode": "replace",
|
||
"revision": aggregate.pointer("/body/revision").cloned().and_then(revision_number_value),
|
||
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned()
|
||
});
|
||
let command = RuntimeCommandEnvelopeWire {
|
||
name: "page.body.save".into(),
|
||
command_id: command_id.clone(),
|
||
idempotency_key: Some(input.idempotency_key_or_default(&command_id)),
|
||
actor: RuntimeActorWire {
|
||
actor_type: context.auth.actor_type.clone(),
|
||
actor_id: context.auth.actor_id.clone(),
|
||
session_id: input
|
||
.session_id
|
||
.clone()
|
||
.or_else(|| context.auth.session_id.clone()),
|
||
},
|
||
source: RuntimeSourceWire {
|
||
channel: "hermes".into(),
|
||
client: "mnote-hermes-plugin".into(),
|
||
source_kind: None,
|
||
root_uri: None,
|
||
workspace_id: workspace_id.clone(),
|
||
capabilities: input.capability_scope.clone().unwrap_or_default(),
|
||
},
|
||
target: Some(RuntimeTargetWire {
|
||
workspace_id: workspace_id.clone(),
|
||
page_id: Some(document_id.clone()),
|
||
block_id: None,
|
||
}),
|
||
payload,
|
||
preflight_data: None,
|
||
reason: Some("Hermes batch block tool page.body.save".into()),
|
||
refs: vec![
|
||
"page.body.save".into(),
|
||
"hermes-batch-block-tool-call".into(),
|
||
],
|
||
dry_run: false,
|
||
validate_only: false,
|
||
};
|
||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||
&state,
|
||
context,
|
||
workspace_id.as_deref(),
|
||
command,
|
||
)
|
||
.await?;
|
||
Ok(json!({
|
||
"commandName": "page.body.save",
|
||
"commandId": command_id,
|
||
"changedBlocks": changed_blocks,
|
||
"result": execution.result,
|
||
"artifacts": execution.artifacts,
|
||
"artifactError": execution.artifact_error
|
||
}))
|
||
}
|
||
|
||
fn content_to_text(value: &Value) -> String {
|
||
if let Some(text) = value.as_str() {
|
||
return text.to_string();
|
||
}
|
||
if let Some(text) = value.get("text").and_then(Value::as_str) {
|
||
return text.to_string();
|
||
}
|
||
if let Some(payload) = value.get("payload") {
|
||
return content_to_text(payload);
|
||
}
|
||
if let Some(content_nodes) = value.get("contentNodes") {
|
||
return content_to_text(content_nodes);
|
||
}
|
||
if let Some(content) = value.get("content") {
|
||
return content_to_text(content);
|
||
}
|
||
if let Some(items) = value.as_array() {
|
||
return items
|
||
.iter()
|
||
.map(content_to_text)
|
||
.collect::<Vec<_>>()
|
||
.join("");
|
||
}
|
||
String::new()
|
||
}
|
||
|
||
fn ensure_allowed_target(
|
||
context: &RequestContext,
|
||
input: &ToolCallInput,
|
||
operation: &Value,
|
||
block_id: &str,
|
||
op: &str,
|
||
) -> Result<(), WebError> {
|
||
let allowed = allowed_target_block_ids(input, operation);
|
||
if allowed.is_empty() || allowed.contains(block_id) {
|
||
return Ok(());
|
||
}
|
||
Err(WebError::bad_request_code(
|
||
"mnote_block_target_out_of_scope",
|
||
format!("{op} 目标块不在当前 AI selection 允许范围内"),
|
||
)
|
||
.with_context(context))
|
||
}
|
||
|
||
fn allowed_target_block_ids(input: &ToolCallInput, operation: &Value) -> HashSet<String> {
|
||
let mut allowed = HashSet::new();
|
||
if let Some(Value::Array(values)) = input.arg_value("allowedTargetBlockIds") {
|
||
for value in values {
|
||
if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) {
|
||
allowed.insert(id.to_string());
|
||
}
|
||
}
|
||
}
|
||
if let Some(values) = operation
|
||
.get("allowedTargetBlockIds")
|
||
.and_then(Value::as_array)
|
||
{
|
||
for value in values {
|
||
if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) {
|
||
allowed.insert(id.to_string());
|
||
}
|
||
}
|
||
}
|
||
allowed
|
||
}
|
||
|
||
fn block_to_ai_content(
|
||
format: &str,
|
||
block: &Value,
|
||
aggregate: &Value,
|
||
document_id: &str,
|
||
) -> String {
|
||
let text = block
|
||
.get("text")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or_default();
|
||
match format {
|
||
"page_xml" | "xml" => {
|
||
let revision = aggregate
|
||
.pointer("/body/revision")
|
||
.and_then(Value::as_u64)
|
||
.map(|value| value.to_string())
|
||
.unwrap_or_else(|| {
|
||
aggregate
|
||
.pointer("/body/revision")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or_default()
|
||
.to_string()
|
||
});
|
||
let block_id = block_id_of(block).unwrap_or_default();
|
||
let block_type = block
|
||
.get("type")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("paragraph");
|
||
let revision_ref = block
|
||
.get("revisionRef")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or_default();
|
||
format!(
|
||
"<page id=\"{}\" revision=\"{}\">\n <block id=\"{}\" type=\"{}\" revisionRef=\"{}\">{}</block>\n</page>",
|
||
escape_xml(document_id),
|
||
escape_xml(&revision),
|
||
escape_xml(&block_id),
|
||
escape_xml(block_type),
|
||
escape_xml(revision_ref),
|
||
escape_xml(text)
|
||
)
|
||
}
|
||
"text" | "plain" => format!("[{}] {text}", block_id_of(block).unwrap_or_default()),
|
||
_ => format!(
|
||
"{text} <!-- block:{} -->",
|
||
block_id_of(block).unwrap_or_default()
|
||
),
|
||
}
|
||
}
|
||
|
||
fn escape_xml(value: &str) -> String {
|
||
value
|
||
.replace('&', "&")
|
||
.replace('<', "<")
|
||
.replace('>', ">")
|
||
.replace('"', """)
|
||
.replace('\'', "'")
|
||
}
|
||
|
||
/// 将 BlockDelta JSON 合并到工具响应中(Phase B)
|
||
fn merge_block_delta(response: &mut Value, delta_opt: Option<Value>) {
|
||
if let (Some(delta), Some(obj)) = (delta_opt, response.as_object_mut()) {
|
||
obj.insert("blockDelta".into(), delta);
|
||
}
|
||
}
|
||
|
||
fn current_body_content(aggregate: &Value) -> Value {
|
||
aggregate
|
||
.pointer("/body/content")
|
||
.cloned()
|
||
.unwrap_or_else(|| json!([]))
|
||
}
|
||
|
||
fn build_content_nodes(text: &str) -> Vec<ContentNode> {
|
||
if text.trim().is_empty() {
|
||
return vec![];
|
||
}
|
||
vec![ContentNode {
|
||
payload: ContentNodePayload::Text {
|
||
text: text.to_string(),
|
||
marks: vec![],
|
||
},
|
||
attrs: BTreeMap::new(),
|
||
}]
|
||
}
|
||
|
||
fn editor_block_type_from_value(value: &Value) -> EditorBlockType {
|
||
match value
|
||
.get("type")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("paragraph")
|
||
.to_ascii_lowercase()
|
||
.as_str()
|
||
{
|
||
"heading" => EditorBlockType::Heading,
|
||
"todo" | "task" => EditorBlockType::Todo,
|
||
"quote" | "blockquote" => EditorBlockType::Quote,
|
||
"code" | "code_block" | "code-block" => EditorBlockType::CodeBlock,
|
||
_ => EditorBlockType::Paragraph,
|
||
}
|
||
}
|
||
|
||
fn block_props_from_value(value: &Value) -> BlockProps {
|
||
let mut props = BlockProps::default();
|
||
let raw_props = value.get("props").and_then(Value::as_object);
|
||
if let Some(level) = raw_props
|
||
.and_then(|map| map.get("level").or_else(|| map.get("headingLevel")))
|
||
.and_then(Value::as_u64)
|
||
.and_then(|value| u8::try_from(value).ok())
|
||
{
|
||
props.heading_level = Some(level.clamp(1, 6));
|
||
}
|
||
if let Some(checked) = raw_props
|
||
.and_then(|map| map.get("checked"))
|
||
.and_then(Value::as_bool)
|
||
{
|
||
props.checked = Some(checked);
|
||
}
|
||
if let Some(language) = raw_props
|
||
.and_then(|map| map.get("language"))
|
||
.and_then(Value::as_str)
|
||
.map(str::to_owned)
|
||
{
|
||
props.language = Some(language);
|
||
}
|
||
props
|
||
}
|
||
|
||
fn replacement_block_type(value: &Value) -> Option<EditorBlockType> {
|
||
value
|
||
.get("type")
|
||
.map(|_| editor_block_type_from_value(value))
|
||
}
|
||
|
||
fn replacement_block_props(value: &Value) -> Option<BlockProps> {
|
||
value.get("props").map(|_| block_props_from_value(value))
|
||
}
|
||
|
||
fn build_editor_block(block_id: &str, value: &Value) -> EditorBlock {
|
||
EditorBlock {
|
||
block_id: block_id.to_string(),
|
||
block_type: editor_block_type_from_value(value),
|
||
props: block_props_from_value(value),
|
||
content_nodes: build_content_nodes(&content_to_text(value)),
|
||
child_block_ids: vec![],
|
||
}
|
||
}
|
||
|
||
fn content_to_legacy(value: &Value) -> Value {
|
||
if value.is_object() && value.get("type").is_some() {
|
||
return value.clone();
|
||
}
|
||
json!({
|
||
"type": "paragraph",
|
||
"content": content_to_text(value)
|
||
})
|
||
}
|
||
|
||
fn build_insert_block(block_id: &str, value: &Value) -> Value {
|
||
let mut block = content_to_legacy(value);
|
||
if let Value::Object(map) = &mut block {
|
||
map.insert("id".into(), json!(block_id));
|
||
map.entry("type").or_insert_with(|| json!("paragraph"));
|
||
map.entry("children").or_insert_with(|| json!([]));
|
||
}
|
||
block
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn content_to_text_reads_projection_content_nodes() {
|
||
let value = json!([
|
||
{
|
||
"attrs": {},
|
||
"payload": {
|
||
"marks": [],
|
||
"text": "Hermes 插件替换第二段",
|
||
"type": "text"
|
||
}
|
||
}
|
||
]);
|
||
|
||
assert_eq!(content_to_text(&value), "Hermes 插件替换第二段");
|
||
}
|
||
|
||
#[test]
|
||
fn content_to_text_reads_block_with_content_nodes() {
|
||
let value = json!([
|
||
{
|
||
"type": "paragraph",
|
||
"contentNodes": [
|
||
{
|
||
"attrs": {},
|
||
"payload": {
|
||
"marks": [],
|
||
"text": "Hermes 插件插入段",
|
||
"type": "text"
|
||
}
|
||
}
|
||
]
|
||
}
|
||
]);
|
||
|
||
assert_eq!(content_to_text(&value), "Hermes 插件插入段");
|
||
}
|
||
}
|