Files
mnote/rust/crates/mnote-web/src/hermes_tools/block.rs
T

1554 lines
53 KiB
Rust
Raw Normal View History

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)?;
ensure_allowed_target(context, input, &Value::Null, &block_id, "replace")?;
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,
&current_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)?;
ensure_allowed_target(
context,
input,
&Value::Null,
&anchor_block_id,
"insert_after",
)?;
let insert_values = insert_after_values(context, input)?;
let id_prefix = format!("ai_block_{}", context.trace.request_id.replace('-', "_"));
let inserted_block_ids = insert_values
.iter()
.enumerate()
.map(|(index, _)| {
if insert_values.len() == 1 {
id_prefix.clone()
} else {
format!("{id_prefix}_{index}")
}
})
.collect::<Vec<_>>();
let diff = insert_values
.iter()
.zip(inserted_block_ids.iter())
.map(|(content, block_id)| {
json!({
"op": "insert_after",
"anchorBlockId": anchor_block_id,
"after": anchor.get("text").cloned().unwrap_or(Value::Null),
"block": build_insert_block(block_id, content)
})
})
.collect::<Vec<_>>();
if input.dry_run.unwrap_or(false) {
let mut result =
dry_run_result(input, &aggregate, "block_insert_after", json!(diff), false);
result["insertedBlockIds"] = json!(inserted_block_ids);
return Ok(result);
}
let mut next_content = current_body_content(&aggregate);
let mut changed_blocks = Vec::with_capacity(insert_values.len());
let mut delta_opt = None;
let mut after_block_id = anchor_block_id.clone();
for (content, block_id) in insert_values.iter().zip(inserted_block_ids.iter()) {
let command = EditorCommand::InsertBlockAfter(EditorInsertBlockAfter {
after_block_id: after_block_id.clone(),
block: build_editor_block(block_id, content),
});
let (content_after_command, command_delta) = compute_next_content_via_actor(
state,
&aggregate,
input,
&next_content,
&command,
"block_insert_after",
)?;
next_content = content_after_command;
delta_opt = command_delta;
changed_blocks.push(json!({
"blockId": block_id,
"op": "insert_after",
"anchorBlockId": after_block_id
}));
after_block_id = block_id.clone();
}
let mut result =
execute_page_body_save(state, context, input, next_content, changed_blocks).await?;
result["insertedBlockIds"] = json!(inserted_block_ids);
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)?;
ensure_allowed_target(context, input, &Value::Null, &block_id, "delete")?;
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,
&current_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")?;
ensure_allowed_target(context, input, &Value::Null, &block_id, "move_after")?;
ensure_allowed_target(
context,
input,
&Value::Null,
&anchor_block_id,
"move_after_anchor",
)?;
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,
&current_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));
}
ensure_page_write_preconditions(context, input, &aggregate)?;
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)?;
ensure_operation_block_revision_ref(
context,
input,
operation,
&block,
"blockRevisionRef",
)?;
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)?;
ensure_operation_block_revision_ref(
context,
input,
operation,
&anchor,
"anchorRevisionRef",
)?;
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)?;
ensure_operation_block_revision_ref(
context,
input,
operation,
&block,
"blockRevisionRef",
)?;
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_operation_block_revision_ref(
context,
input,
operation,
&block,
"blockRevisionRef",
)?;
ensure_operation_block_revision_ref(
context,
input,
operation,
&anchor,
"anchorRevisionRef",
)?;
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
}
pub(crate) fn ensure_write_contract(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<(), WebError> {
2026-05-21 23:53:39 +08:00
crate::hermes_tools::ensure_write_authorized(context, input)
}
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(&current_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_operation_block_revision_ref(
context: &RequestContext,
input: &ToolCallInput,
operation: &Value,
block: &Value,
arg_name: &'static str,
) -> Result<(), WebError> {
if input.dry_run.unwrap_or(false) {
return Ok(());
}
let expected = operation
.get(arg_name)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.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 insert_after_values(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Vec<Value>, WebError> {
if let Some(blocks) = input.arg_value("blocks") {
let values = blocks.as_array().ok_or_else(|| {
WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.block.insert_after 的 blocks 必须是数组",
)
.with_context(context)
})?;
if values.is_empty() || values.len() > 20 {
return Err(WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.block.insert_after 的 blocks 数量必须在 1 到 20 之间",
)
.with_context(context));
}
return Ok(values.clone());
}
input
.arg_value("content")
.or_else(|| input.arg_value("block"))
.map(|value| vec![value])
.ok_or_else(|| {
WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.block.insert_after 缺少 content、block 或 blocks",
)
.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 为序列化后的 JSONPhase 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
2026-05-17 20:11:39 +08:00
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)?;
// 构建 BlockDeltaPhase 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
}))
}
pub(crate) 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
/// 将 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);
}
}
pub(crate) 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 ensure_write_contract_rejects_read_only_ai_scope() {
let context = RequestContext::from_http_parts(
&axum::http::Method::POST,
&"/api/hermes/tools".parse().expect("uri"),
&axum::http::HeaderMap::new(),
);
let input = ToolCallInput {
tool_name: "mnote.block.replace".into(),
workspace_id: Some("ws_1".into()),
document_id: Some("doc_1".into()),
source_kind: Some("local_folder".into()),
root_uri: Some("file:///tmp/mnote-readonly".into()),
actor_id: Some("user_1".into()),
profile: None,
session_id: Some("sess_1".into()),
run_id: Some("run_1".into()),
tool_call_id: Some("tool_1".into()),
trace_id: Some("trace_1".into()),
idempotency_key: Some("idem_1".into()),
dry_run: Some(false),
capability_scope: None,
args: Some(json!({
"aiAccessScope": {
"permissionLevel": "read_only",
"allowedRoots": ["file:///tmp/mnote-readonly"]
}
})),
};
let error = ensure_write_contract(&context, &input).expect_err("read only rejected");
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
assert_eq!(
error.message(),
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool"
);
}
#[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 插件插入段");
}
}