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

1554 lines
53 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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> {
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
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 插件插入段");
}
}