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 { 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::>(); 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { let exact = blocks .iter() .filter(|block| block.get("text").and_then(Value::as_str) == Some(text)) .cloned() .collect::>(); 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::>(); 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 { 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 { if let Some(number) = value.as_u64() { return Some(json!(number)); } value .as_str() .map(str::trim) .and_then(|value| value.parse::().ok()) .map(|number| json!(number)) } fn same_parent_blocks(blocks: &[Value], block: &Value) -> Vec { 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), 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, ) -> Result { 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, ) -> Result { 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::>() .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 { 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!( "\n {}\n", 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_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) { 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 { 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 { value .get("type") .map(|_| editor_block_type_from_value(value)) } fn replacement_block_props(value: &Value) -> Option { 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 插件插入段"); } }