2026-05-16 22:03:14 +08:00
|
|
|
|
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)?;
|
2026-05-18 17:01:35 +08:00
|
|
|
|
ensure_allowed_target(context, input, &Value::Null, &block_id, "replace")?;
|
2026-05-16 22:03:14 +08:00
|
|
|
|
let replacement_text = content_to_text(&content);
|
|
|
|
|
|
let diff = json!([{
|
|
|
|
|
|
"op": "replace",
|
|
|
|
|
|
"targetBlockId": block_id,
|
|
|
|
|
|
"before": block.get("text").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"after": replacement_text
|
|
|
|
|
|
}]);
|
|
|
|
|
|
if input.dry_run.unwrap_or(false) {
|
|
|
|
|
|
return Ok(dry_run_result(
|
|
|
|
|
|
input,
|
|
|
|
|
|
&aggregate,
|
|
|
|
|
|
"block_replace",
|
|
|
|
|
|
diff,
|
|
|
|
|
|
false,
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
let current_content = current_body_content(&aggregate);
|
|
|
|
|
|
let command = EditorCommand::ReplaceBlock(EditorReplaceBlock {
|
|
|
|
|
|
block_id: block_id.clone(),
|
|
|
|
|
|
block_type: replacement_block_type(&content),
|
|
|
|
|
|
props: replacement_block_props(&content),
|
|
|
|
|
|
content_nodes: Some(build_content_nodes(&replacement_text)),
|
|
|
|
|
|
});
|
|
|
|
|
|
let (next_content, delta_opt) = compute_next_content_via_actor(
|
|
|
|
|
|
state,
|
|
|
|
|
|
&aggregate,
|
|
|
|
|
|
input,
|
|
|
|
|
|
¤t_content,
|
|
|
|
|
|
&command,
|
|
|
|
|
|
"block_replace",
|
|
|
|
|
|
)?;
|
|
|
|
|
|
let mut result = execute_page_body_save(
|
|
|
|
|
|
state,
|
|
|
|
|
|
context,
|
|
|
|
|
|
input,
|
|
|
|
|
|
next_content,
|
|
|
|
|
|
vec![json!({
|
|
|
|
|
|
"blockId": block_id,
|
|
|
|
|
|
"op": "replace"
|
|
|
|
|
|
})],
|
|
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
merge_block_delta(&mut result, delta_opt);
|
|
|
|
|
|
Ok(result)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub async fn block_insert_after(
|
|
|
|
|
|
state: &AppState,
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
input: &ToolCallInput,
|
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
|
ensure_write_contract(context, input)?;
|
|
|
|
|
|
let aggregate = aggregate_value(state, context, input).await?;
|
|
|
|
|
|
let anchor_block_id = input
|
|
|
|
|
|
.arg_string("anchorBlockId")
|
|
|
|
|
|
.or_else(|| input.arg_string("afterBlockId"))
|
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
|
"mnote_tool_bad_request",
|
|
|
|
|
|
"mnote.block.insert_after 缺少 anchorBlockId",
|
|
|
|
|
|
)
|
|
|
|
|
|
.with_context(context)
|
|
|
|
|
|
})?;
|
|
|
|
|
|
let blocks = block_projection_blocks(&aggregate);
|
|
|
|
|
|
let anchor = find_block(&blocks, &anchor_block_id).ok_or_else(|| block_not_found(context))?;
|
|
|
|
|
|
ensure_page_write_preconditions(context, input, &aggregate)?;
|
|
|
|
|
|
ensure_block_revision_ref(context, input, &anchor, "anchorRevisionRef")?;
|
|
|
|
|
|
ensure_block_editable(context, &anchor)?;
|
2026-05-18 17:01:35 +08:00
|
|
|
|
ensure_allowed_target(
|
2026-05-16 22:03:14 +08:00
|
|
|
|
context,
|
|
|
|
|
|
input,
|
2026-05-18 17:01:35 +08:00
|
|
|
|
&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,
|
2026-05-16 22:03:14 +08:00
|
|
|
|
"op": "insert_after",
|
2026-05-18 17:01:35 +08:00
|
|
|
|
"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);
|
2026-05-16 22:03:14 +08:00
|
|
|
|
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)?;
|
2026-05-18 17:01:35 +08:00
|
|
|
|
ensure_allowed_target(context, input, &Value::Null, &block_id, "delete")?;
|
2026-05-16 22:03:14 +08:00
|
|
|
|
let leaf = block
|
|
|
|
|
|
.get("children")
|
|
|
|
|
|
.and_then(Value::as_array)
|
|
|
|
|
|
.map(|children| children.is_empty())
|
|
|
|
|
|
.unwrap_or(true);
|
|
|
|
|
|
let blocked = !leaf;
|
|
|
|
|
|
let diff = json!([{
|
|
|
|
|
|
"op": "delete",
|
|
|
|
|
|
"blockId": block_id,
|
|
|
|
|
|
"before": block.get("text").cloned().unwrap_or(Value::Null)
|
|
|
|
|
|
}]);
|
|
|
|
|
|
if input.dry_run.unwrap_or(false) || blocked {
|
|
|
|
|
|
return Ok(json!({
|
|
|
|
|
|
"ok": true,
|
|
|
|
|
|
"dryRun": input.dry_run.unwrap_or(false),
|
|
|
|
|
|
"command": "block_delete",
|
|
|
|
|
|
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"blocked": blocked,
|
|
|
|
|
|
"risk": if blocked { "medium" } else { "low" },
|
|
|
|
|
|
"diff": diff,
|
|
|
|
|
|
"warnings": if blocked {
|
|
|
|
|
|
json!([{
|
|
|
|
|
|
"code": "block_delete_blocked",
|
|
|
|
|
|
"message": "第一阶段仅开放无子块的普通块删除"
|
|
|
|
|
|
}])
|
|
|
|
|
|
} else {
|
|
|
|
|
|
json!([])
|
|
|
|
|
|
}
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
let current_content = current_body_content(&aggregate);
|
|
|
|
|
|
let command = EditorCommand::DeleteBlock(EditorDeleteBlock {
|
|
|
|
|
|
block_id: block_id.clone(),
|
|
|
|
|
|
preserve_children: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
let (next_content, delta_opt) = compute_next_content_via_actor(
|
|
|
|
|
|
state,
|
|
|
|
|
|
&aggregate,
|
|
|
|
|
|
input,
|
|
|
|
|
|
¤t_content,
|
|
|
|
|
|
&command,
|
|
|
|
|
|
"block_delete",
|
|
|
|
|
|
)?;
|
|
|
|
|
|
let mut result = execute_page_body_save(
|
|
|
|
|
|
state,
|
|
|
|
|
|
context,
|
|
|
|
|
|
input,
|
|
|
|
|
|
next_content,
|
|
|
|
|
|
vec![json!({
|
|
|
|
|
|
"blockId": block_id,
|
|
|
|
|
|
"op": "delete"
|
|
|
|
|
|
})],
|
|
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
merge_block_delta(&mut result, delta_opt);
|
|
|
|
|
|
Ok(result)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub async fn block_move_after(
|
|
|
|
|
|
state: &AppState,
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
input: &ToolCallInput,
|
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
|
ensure_write_contract(context, input)?;
|
|
|
|
|
|
let aggregate = aggregate_value(state, context, input).await?;
|
|
|
|
|
|
let block_id = required_arg(input, context, "blockId")?;
|
|
|
|
|
|
let anchor_block_id = required_arg(input, context, "anchorBlockId")?;
|
|
|
|
|
|
let blocks = block_projection_blocks(&aggregate);
|
|
|
|
|
|
let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?;
|
|
|
|
|
|
let anchor = find_block(&blocks, &anchor_block_id).ok_or_else(|| block_not_found(context))?;
|
|
|
|
|
|
ensure_page_write_preconditions(context, input, &aggregate)?;
|
|
|
|
|
|
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
|
|
|
|
|
|
ensure_block_revision_ref(context, input, &anchor, "anchorRevisionRef")?;
|
2026-05-18 17:01:35 +08:00
|
|
|
|
ensure_allowed_target(context, input, &Value::Null, &block_id, "move_after")?;
|
|
|
|
|
|
ensure_allowed_target(
|
|
|
|
|
|
context,
|
|
|
|
|
|
input,
|
|
|
|
|
|
&Value::Null,
|
|
|
|
|
|
&anchor_block_id,
|
|
|
|
|
|
"move_after_anchor",
|
|
|
|
|
|
)?;
|
2026-05-16 22:03:14 +08:00
|
|
|
|
let same_parent = block.get("parentBlockId") == anchor.get("parentBlockId");
|
|
|
|
|
|
let leaf = block
|
|
|
|
|
|
.get("children")
|
|
|
|
|
|
.and_then(Value::as_array)
|
|
|
|
|
|
.map(|children| children.is_empty())
|
|
|
|
|
|
.unwrap_or(true);
|
|
|
|
|
|
let movable_type = block
|
|
|
|
|
|
.get("type")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(|block_type| matches!(block_type, "paragraph" | "heading" | "todo" | "task"))
|
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
|
let editable = block
|
|
|
|
|
|
.get("editable")
|
|
|
|
|
|
.and_then(Value::as_bool)
|
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
|
let blocked =
|
|
|
|
|
|
!same_parent || !leaf || !movable_type || !editable || block_id == anchor_block_id;
|
|
|
|
|
|
let diff = json!([{
|
|
|
|
|
|
"op": "move_after",
|
|
|
|
|
|
"blockId": block_id,
|
|
|
|
|
|
"anchorBlockId": anchor_block_id,
|
|
|
|
|
|
"from": {
|
|
|
|
|
|
"parentBlockId": block.get("parentBlockId").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"order": block.get("order").cloned().unwrap_or(Value::Null)
|
|
|
|
|
|
},
|
|
|
|
|
|
"to": {
|
|
|
|
|
|
"parentBlockId": anchor.get("parentBlockId").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"afterOrder": anchor.get("order").cloned().unwrap_or(Value::Null)
|
|
|
|
|
|
}
|
|
|
|
|
|
}]);
|
|
|
|
|
|
if input.dry_run.unwrap_or(false) || blocked {
|
|
|
|
|
|
return Ok(json!({
|
|
|
|
|
|
"ok": true,
|
|
|
|
|
|
"dryRun": input.dry_run.unwrap_or(false),
|
|
|
|
|
|
"command": "block_move_after",
|
|
|
|
|
|
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"blocked": blocked,
|
|
|
|
|
|
"risk": "medium",
|
|
|
|
|
|
"diff": diff,
|
|
|
|
|
|
"warnings": if blocked {
|
|
|
|
|
|
json!([{
|
|
|
|
|
|
"code": "block_move_after_blocked",
|
|
|
|
|
|
"message": "第一阶段仅开放同父级普通叶子块移动,且不能移动到自身之后"
|
|
|
|
|
|
}])
|
|
|
|
|
|
} else {
|
|
|
|
|
|
json!([])
|
|
|
|
|
|
}
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
let current_content = current_body_content(&aggregate);
|
|
|
|
|
|
let parent_block_id = anchor
|
|
|
|
|
|
.get("parentBlockId")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(str::to_owned);
|
|
|
|
|
|
let command = EditorCommand::MoveBlock(EditorMoveBlock {
|
|
|
|
|
|
block_id: block_id.clone(),
|
|
|
|
|
|
parent_block_id,
|
|
|
|
|
|
after_block_id: Some(anchor_block_id.clone()),
|
|
|
|
|
|
});
|
|
|
|
|
|
let (next_content, delta_opt) = compute_next_content_via_actor(
|
|
|
|
|
|
state,
|
|
|
|
|
|
&aggregate,
|
|
|
|
|
|
input,
|
|
|
|
|
|
¤t_content,
|
|
|
|
|
|
&command,
|
|
|
|
|
|
"block_move_after",
|
|
|
|
|
|
)?;
|
|
|
|
|
|
let mut result = execute_page_body_save(
|
|
|
|
|
|
state,
|
|
|
|
|
|
context,
|
|
|
|
|
|
input,
|
|
|
|
|
|
next_content,
|
|
|
|
|
|
vec![json!({
|
|
|
|
|
|
"blockId": block_id,
|
|
|
|
|
|
"op": "move_after",
|
|
|
|
|
|
"anchorBlockId": anchor_block_id
|
|
|
|
|
|
})],
|
|
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
merge_block_delta(&mut result, delta_opt);
|
|
|
|
|
|
Ok(result)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub async fn doc_apply_block_ops(
|
|
|
|
|
|
state: &AppState,
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
input: &ToolCallInput,
|
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
|
ensure_write_contract(context, input)?;
|
|
|
|
|
|
let aggregate = aggregate_value(state, context, input).await?;
|
|
|
|
|
|
let operations = input
|
|
|
|
|
|
.arg_value("operations")
|
|
|
|
|
|
.and_then(|value| value.as_array().cloned())
|
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
|
"mnote_tool_bad_request",
|
|
|
|
|
|
"mnote.doc.apply_block_ops 缺少 operations",
|
|
|
|
|
|
)
|
|
|
|
|
|
.with_context(context)
|
|
|
|
|
|
})?;
|
|
|
|
|
|
if operations.is_empty() {
|
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
|
"mnote_tool_bad_request",
|
|
|
|
|
|
"mnote.doc.apply_block_ops operations 不能为空",
|
|
|
|
|
|
)
|
|
|
|
|
|
.with_context(context));
|
|
|
|
|
|
}
|
|
|
|
|
|
if operations.len() > 12 {
|
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
|
"mnote_tool_bad_request",
|
|
|
|
|
|
"mnote.doc.apply_block_ops 一次最多允许 12 个块操作",
|
|
|
|
|
|
)
|
|
|
|
|
|
.with_context(context));
|
|
|
|
|
|
}
|
2026-05-18 17:01:35 +08:00
|
|
|
|
ensure_page_write_preconditions(context, input, &aggregate)?;
|
2026-05-16 22:03:14 +08:00
|
|
|
|
|
|
|
|
|
|
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)?;
|
2026-05-18 17:01:35 +08:00
|
|
|
|
ensure_operation_block_revision_ref(
|
|
|
|
|
|
context,
|
|
|
|
|
|
input,
|
|
|
|
|
|
operation,
|
|
|
|
|
|
&block,
|
|
|
|
|
|
"blockRevisionRef",
|
|
|
|
|
|
)?;
|
2026-05-16 22:03:14 +08:00
|
|
|
|
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)?;
|
2026-05-18 17:01:35 +08:00
|
|
|
|
ensure_operation_block_revision_ref(
|
|
|
|
|
|
context,
|
|
|
|
|
|
input,
|
|
|
|
|
|
operation,
|
|
|
|
|
|
&anchor,
|
|
|
|
|
|
"anchorRevisionRef",
|
|
|
|
|
|
)?;
|
2026-05-16 22:03:14 +08:00
|
|
|
|
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)?;
|
2026-05-18 17:01:35 +08:00
|
|
|
|
ensure_operation_block_revision_ref(
|
|
|
|
|
|
context,
|
|
|
|
|
|
input,
|
|
|
|
|
|
operation,
|
|
|
|
|
|
&block,
|
|
|
|
|
|
"blockRevisionRef",
|
|
|
|
|
|
)?;
|
2026-05-16 22:03:14 +08:00
|
|
|
|
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)?;
|
2026-05-18 17:01:35 +08:00
|
|
|
|
ensure_operation_block_revision_ref(
|
|
|
|
|
|
context,
|
|
|
|
|
|
input,
|
|
|
|
|
|
operation,
|
|
|
|
|
|
&block,
|
|
|
|
|
|
"blockRevisionRef",
|
|
|
|
|
|
)?;
|
|
|
|
|
|
ensure_operation_block_revision_ref(
|
|
|
|
|
|
context,
|
|
|
|
|
|
input,
|
|
|
|
|
|
operation,
|
|
|
|
|
|
&anchor,
|
|
|
|
|
|
"anchorRevisionRef",
|
|
|
|
|
|
)?;
|
2026-05-16 22:03:14 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 17:01:35 +08:00
|
|
|
|
pub(crate) fn ensure_write_contract(
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
input: &ToolCallInput,
|
|
|
|
|
|
) -> Result<(), WebError> {
|
2026-05-16 22:03:14 +08:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
2026-05-19 08:07:17 +08:00
|
|
|
|
if input.ai_access_scope_is_read_only() {
|
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
|
axum::http::StatusCode::FORBIDDEN,
|
|
|
|
|
|
"mnote_tool_ai_scope_write_forbidden",
|
|
|
|
|
|
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool",
|
|
|
|
|
|
)
|
|
|
|
|
|
.with_context(context));
|
|
|
|
|
|
}
|
2026-05-16 22:03:14 +08:00
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn ensure_leaf_block(
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
block: &Value,
|
|
|
|
|
|
operation: &'static str,
|
|
|
|
|
|
) -> Result<(), WebError> {
|
|
|
|
|
|
let leaf = block
|
|
|
|
|
|
.get("children")
|
|
|
|
|
|
.and_then(Value::as_array)
|
|
|
|
|
|
.map(|children| children.is_empty())
|
|
|
|
|
|
.unwrap_or(true);
|
|
|
|
|
|
if leaf {
|
|
|
|
|
|
return Ok(());
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(WebError::bad_request_code(
|
|
|
|
|
|
"mnote_block_unsupported",
|
|
|
|
|
|
format!("{operation} 第一阶段仅支持无子块普通块"),
|
|
|
|
|
|
)
|
|
|
|
|
|
.with_context(context))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn resolve_target_block(
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
blocks: &[Value],
|
|
|
|
|
|
operation: &Value,
|
|
|
|
|
|
allow_anchor_alias: bool,
|
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
|
let block_id = operation
|
|
|
|
|
|
.get("blockId")
|
|
|
|
|
|
.or_else(|| operation.get("block_id"))
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
|
.map(ToOwned::to_owned);
|
|
|
|
|
|
if let Some(block_id) = block_id {
|
|
|
|
|
|
return find_block(blocks, &block_id).ok_or_else(|| block_not_found(context));
|
|
|
|
|
|
}
|
|
|
|
|
|
let text = operation
|
|
|
|
|
|
.get("matchText")
|
|
|
|
|
|
.or_else(|| operation.get("match_text"))
|
|
|
|
|
|
.or_else(|| operation.get("text"))
|
|
|
|
|
|
.or_else(|| {
|
|
|
|
|
|
if allow_anchor_alias {
|
|
|
|
|
|
operation
|
|
|
|
|
|
.get("anchorText")
|
|
|
|
|
|
.or_else(|| operation.get("afterText"))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
|
"mnote_tool_bad_request",
|
|
|
|
|
|
"块操作必须提供 blockId 或 matchText",
|
|
|
|
|
|
)
|
|
|
|
|
|
.with_context(context)
|
|
|
|
|
|
})?;
|
|
|
|
|
|
resolve_unique_block_by_text(context, blocks, text)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn resolve_anchor_block(
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
blocks: &[Value],
|
|
|
|
|
|
operation: &Value,
|
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
|
if let Some(anchor_id) = operation
|
|
|
|
|
|
.get("anchorBlockId")
|
|
|
|
|
|
.or_else(|| operation.get("anchor_block_id"))
|
|
|
|
|
|
.or_else(|| operation.get("afterBlockId"))
|
|
|
|
|
|
.or_else(|| operation.get("after_block_id"))
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
|
{
|
|
|
|
|
|
return find_block(blocks, anchor_id).ok_or_else(|| block_not_found(context));
|
|
|
|
|
|
}
|
|
|
|
|
|
resolve_target_block(context, blocks, operation, true)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn resolve_unique_block_by_text(
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
blocks: &[Value],
|
|
|
|
|
|
text: &str,
|
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
|
let exact = blocks
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|block| block.get("text").and_then(Value::as_str) == Some(text))
|
|
|
|
|
|
.cloned()
|
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
|
if exact.len() == 1 {
|
|
|
|
|
|
return Ok(exact[0].clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
if exact.len() > 1 {
|
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
|
"mnote_block_ambiguous",
|
|
|
|
|
|
"匹配到多个同文本块,请改用 blockId",
|
|
|
|
|
|
)
|
|
|
|
|
|
.with_context(context));
|
|
|
|
|
|
}
|
|
|
|
|
|
let contains = blocks
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|block| {
|
|
|
|
|
|
block
|
|
|
|
|
|
.get("text")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(|block_text| block_text.contains(text))
|
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
|
})
|
|
|
|
|
|
.cloned()
|
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
|
if contains.len() == 1 {
|
|
|
|
|
|
return Ok(contains[0].clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
if contains.len() > 1 {
|
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
|
"mnote_block_ambiguous",
|
|
|
|
|
|
"包含匹配命中多个块,请改用 blockId",
|
|
|
|
|
|
)
|
|
|
|
|
|
.with_context(context));
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(block_not_found(context))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn ensure_page_write_preconditions(
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
input: &ToolCallInput,
|
|
|
|
|
|
aggregate: &Value,
|
|
|
|
|
|
) -> Result<(), WebError> {
|
|
|
|
|
|
if input.dry_run.unwrap_or(false) {
|
|
|
|
|
|
return Ok(());
|
|
|
|
|
|
}
|
|
|
|
|
|
let expected_revision = input
|
|
|
|
|
|
.arg_value("revision")
|
|
|
|
|
|
.ok_or_else(|| missing_write_precondition(context, "revision"))?;
|
|
|
|
|
|
let expected_conflict_key = input
|
|
|
|
|
|
.arg_string("conflictDetectionKey")
|
|
|
|
|
|
.ok_or_else(|| missing_write_precondition(context, "conflictDetectionKey"))?;
|
|
|
|
|
|
let current_revision = aggregate
|
|
|
|
|
|
.pointer("/body/revision")
|
|
|
|
|
|
.cloned()
|
|
|
|
|
|
.unwrap_or(Value::Null);
|
|
|
|
|
|
let current_conflict_key = aggregate
|
|
|
|
|
|
.pointer("/body/conflictDetectionKey")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
|
.unwrap_or("");
|
|
|
|
|
|
if value_label(&expected_revision).as_deref() != value_label(¤t_revision).as_deref()
|
|
|
|
|
|
|| expected_conflict_key != current_conflict_key
|
|
|
|
|
|
{
|
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
|
"mnote_tool_conflict",
|
|
|
|
|
|
"页面正文 revision 或 conflictDetectionKey 已变化,请重新读取后再写入",
|
|
|
|
|
|
)
|
|
|
|
|
|
.with_context(context));
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn ensure_block_revision_ref(
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
input: &ToolCallInput,
|
|
|
|
|
|
block: &Value,
|
|
|
|
|
|
arg_name: &'static str,
|
|
|
|
|
|
) -> Result<(), WebError> {
|
|
|
|
|
|
if input.dry_run.unwrap_or(false) {
|
|
|
|
|
|
return Ok(());
|
|
|
|
|
|
}
|
|
|
|
|
|
let expected = input
|
|
|
|
|
|
.arg_string(arg_name)
|
|
|
|
|
|
.ok_or_else(|| missing_write_precondition(context, arg_name))?;
|
|
|
|
|
|
let current = block
|
|
|
|
|
|
.get("revisionRef")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
|
.unwrap_or("");
|
|
|
|
|
|
if expected != current {
|
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
|
"mnote_tool_conflict",
|
|
|
|
|
|
format!("{arg_name} 已过期,请重新读取 block projection 后再写入"),
|
|
|
|
|
|
)
|
|
|
|
|
|
.with_context(context));
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 17:01:35 +08:00
|
|
|
|
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(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 22:03:14 +08:00
|
|
|
|
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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 17:01:35 +08:00
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 22:03:14 +08:00
|
|
|
|
fn missing_write_precondition(context: &RequestContext, name: &'static str) -> WebError {
|
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
|
"mnote_tool_write_precondition_required",
|
|
|
|
|
|
format!("真实写入必须携带 {name};缺少时只能 dryRun=true"),
|
|
|
|
|
|
)
|
|
|
|
|
|
.with_context(context)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn value_label(value: &Value) -> Option<String> {
|
|
|
|
|
|
if let Some(number) = value.as_i64() {
|
|
|
|
|
|
return Some(number.to_string());
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(number) = value.as_u64() {
|
|
|
|
|
|
return Some(number.to_string());
|
|
|
|
|
|
}
|
|
|
|
|
|
value
|
|
|
|
|
|
.as_str()
|
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn revision_number_value(value: Value) -> Option<Value> {
|
|
|
|
|
|
if let Some(number) = value.as_u64() {
|
|
|
|
|
|
return Some(json!(number));
|
|
|
|
|
|
}
|
|
|
|
|
|
value
|
|
|
|
|
|
.as_str()
|
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
|
.and_then(|value| value.parse::<u64>().ok())
|
|
|
|
|
|
.map(|number| json!(number))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn same_parent_blocks(blocks: &[Value], block: &Value) -> Vec<Value> {
|
|
|
|
|
|
let parent = block.get("parentBlockId").cloned().unwrap_or(Value::Null);
|
|
|
|
|
|
blocks
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|candidate| {
|
|
|
|
|
|
candidate
|
|
|
|
|
|
.get("parentBlockId")
|
|
|
|
|
|
.cloned()
|
|
|
|
|
|
.unwrap_or(Value::Null)
|
|
|
|
|
|
== parent
|
|
|
|
|
|
})
|
|
|
|
|
|
.cloned()
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn dry_run_result(
|
|
|
|
|
|
input: &ToolCallInput,
|
|
|
|
|
|
aggregate: &Value,
|
|
|
|
|
|
command: &str,
|
|
|
|
|
|
diff: Value,
|
|
|
|
|
|
blocked: bool,
|
|
|
|
|
|
) -> Value {
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"ok": true,
|
|
|
|
|
|
"dryRun": true,
|
|
|
|
|
|
"command": command,
|
|
|
|
|
|
"documentId": input.effective_document_id(),
|
|
|
|
|
|
"workspaceId": input.effective_workspace_id(),
|
|
|
|
|
|
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
|
|
|
|
|
"diff": diff,
|
|
|
|
|
|
"warnings": [],
|
|
|
|
|
|
"blocked": blocked,
|
|
|
|
|
|
"risk": "low"
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 如果 editor_actor 已启用,通过它 apply 并获取 legacy content;否则用旧路径计算。
|
|
|
|
|
|
/// 返回 `(content, block_delta_opt)`,其中 block_delta_opt 为序列化后的 JSON(Phase B)。
|
|
|
|
|
|
fn compute_next_content_via_actor(
|
|
|
|
|
|
state: &AppState,
|
|
|
|
|
|
aggregate: &Value,
|
|
|
|
|
|
input: &ToolCallInput,
|
|
|
|
|
|
current_content: &Value,
|
|
|
|
|
|
command: &EditorCommand,
|
|
|
|
|
|
command_name: &str,
|
|
|
|
|
|
) -> Result<(Value, Option<Value>), WebError> {
|
|
|
|
|
|
if !state.config().enable_editor_actor {
|
|
|
|
|
|
let content = apply_editor_command_to_legacy_content(
|
|
|
|
|
|
input.effective_document_id().as_deref().unwrap_or_default(),
|
|
|
|
|
|
current_content,
|
|
|
|
|
|
command.clone(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.map_err(|error| {
|
|
|
|
|
|
WebError::bad_request_code("mnote_block_command_failed", format!("{error:?}"))
|
|
|
|
|
|
})?;
|
|
|
|
|
|
return Ok((content, None));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let document_id = input.effective_document_id().ok_or_else(|| {
|
|
|
|
|
|
WebError::bad_request_code("mnote_tool_bad_request", "actor 路径缺少 documentId")
|
|
|
|
|
|
})?;
|
|
|
|
|
|
let workspace_id = input.effective_workspace_id();
|
|
|
|
|
|
|
|
|
|
|
|
// 确保 actor 已加载此文档
|
|
|
|
|
|
if !state.editor_actor.is_loaded(&document_id) {
|
|
|
|
|
|
state
|
|
|
|
|
|
.editor_actor
|
|
|
|
|
|
.load_or_init(&document_id, workspace_id.as_deref(), aggregate)?;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 在内存中 apply
|
2026-05-17 20:11:39 +08:00
|
|
|
|
let _apply_result =
|
|
|
|
|
|
state
|
|
|
|
|
|
.editor_actor
|
|
|
|
|
|
.apply_command(&document_id, command.clone(), command_name)?;
|
2026-05-16 22:03:14 +08:00
|
|
|
|
|
|
|
|
|
|
// 从 actor 获取 legacy content(用于 Convex save 的 payload)
|
|
|
|
|
|
let content = state.editor_actor.legacy_content_for_save(&document_id)?;
|
|
|
|
|
|
|
|
|
|
|
|
// 构建 BlockDelta(Phase B)并序列化为 JSON
|
|
|
|
|
|
let delta = state
|
|
|
|
|
|
.editor_actor
|
|
|
|
|
|
.build_block_delta(&document_id, command)
|
|
|
|
|
|
.ok()
|
|
|
|
|
|
.and_then(|bd| serde_json::to_value(bd).ok());
|
|
|
|
|
|
|
|
|
|
|
|
// Phase C:将 block.delta 推送到 broadcast 广播(SSE 事件 stream)
|
|
|
|
|
|
if let Some(ref delta_json) = delta {
|
|
|
|
|
|
state.editor_actor.try_push_block_delta(delta_json);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok((content, delta))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn execute_page_body_save(
|
|
|
|
|
|
state: &AppState,
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
input: &ToolCallInput,
|
|
|
|
|
|
content: Value,
|
|
|
|
|
|
changed_blocks: Vec<Value>,
|
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
|
let document_id = input.effective_document_id().ok_or_else(|| {
|
|
|
|
|
|
WebError::bad_request_code("mnote_tool_bad_request", "块写工具缺少 documentId")
|
|
|
|
|
|
.with_context(context)
|
|
|
|
|
|
})?;
|
|
|
|
|
|
let workspace_id = input.effective_workspace_id();
|
|
|
|
|
|
let command_id = format!("page_body_save_{}", context.trace.request_id);
|
|
|
|
|
|
let payload = json!({
|
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
|
"content": content,
|
|
|
|
|
|
"mode": "replace",
|
|
|
|
|
|
"revision": input.arg_value("revision").and_then(revision_number_value),
|
|
|
|
|
|
"conflictDetectionKey": input.arg_value("conflictDetectionKey")
|
|
|
|
|
|
});
|
|
|
|
|
|
let command = RuntimeCommandEnvelopeWire {
|
|
|
|
|
|
name: "page.body.save".into(),
|
|
|
|
|
|
command_id: command_id.clone(),
|
|
|
|
|
|
idempotency_key: Some(input.idempotency_key_or_default(&command_id)),
|
|
|
|
|
|
actor: RuntimeActorWire {
|
|
|
|
|
|
actor_type: context.auth.actor_type.clone(),
|
|
|
|
|
|
actor_id: context.auth.actor_id.clone(),
|
|
|
|
|
|
session_id: input
|
|
|
|
|
|
.session_id
|
|
|
|
|
|
.clone()
|
|
|
|
|
|
.or_else(|| context.auth.session_id.clone()),
|
|
|
|
|
|
},
|
|
|
|
|
|
source: RuntimeSourceWire {
|
|
|
|
|
|
channel: "hermes".into(),
|
|
|
|
|
|
client: "mnote-hermes-plugin".into(),
|
|
|
|
|
|
source_kind: None,
|
|
|
|
|
|
root_uri: None,
|
|
|
|
|
|
workspace_id: workspace_id.clone(),
|
|
|
|
|
|
capabilities: input.capability_scope.clone().unwrap_or_default(),
|
|
|
|
|
|
},
|
|
|
|
|
|
target: Some(RuntimeTargetWire {
|
|
|
|
|
|
workspace_id: workspace_id.clone(),
|
|
|
|
|
|
page_id: Some(document_id.clone()),
|
|
|
|
|
|
block_id: None,
|
|
|
|
|
|
}),
|
|
|
|
|
|
payload,
|
|
|
|
|
|
preflight_data: None,
|
|
|
|
|
|
reason: Some("Hermes block tool page.body.save".into()),
|
|
|
|
|
|
refs: vec!["page.body.save".into(), "hermes-block-tool-call".into()],
|
|
|
|
|
|
dry_run: false,
|
|
|
|
|
|
validate_only: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
let execution = execute_runtime_command_via_convex_with_artifacts(
|
2026-05-17 12:58:27 +08:00
|
|
|
|
&state,
|
2026-05-16 22:03:14 +08:00
|
|
|
|
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
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 17:01:35 +08:00
|
|
|
|
pub(crate) async fn execute_page_body_save_from_aggregate(
|
2026-05-16 22:03:14 +08:00
|
|
|
|
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(
|
2026-05-17 12:58:27 +08:00
|
|
|
|
&state,
|
2026-05-16 22:03:14 +08:00
|
|
|
|
context,
|
|
|
|
|
|
workspace_id.as_deref(),
|
|
|
|
|
|
command,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
Ok(json!({
|
|
|
|
|
|
"commandName": "page.body.save",
|
|
|
|
|
|
"commandId": command_id,
|
|
|
|
|
|
"changedBlocks": changed_blocks,
|
|
|
|
|
|
"result": execution.result,
|
|
|
|
|
|
"artifacts": execution.artifacts,
|
|
|
|
|
|
"artifactError": execution.artifact_error
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn content_to_text(value: &Value) -> String {
|
|
|
|
|
|
if let Some(text) = value.as_str() {
|
|
|
|
|
|
return text.to_string();
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(text) = value.get("text").and_then(Value::as_str) {
|
|
|
|
|
|
return text.to_string();
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(payload) = value.get("payload") {
|
|
|
|
|
|
return content_to_text(payload);
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(content_nodes) = value.get("contentNodes") {
|
|
|
|
|
|
return content_to_text(content_nodes);
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(content) = value.get("content") {
|
|
|
|
|
|
return content_to_text(content);
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(items) = value.as_array() {
|
|
|
|
|
|
return items
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(content_to_text)
|
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
|
.join("");
|
|
|
|
|
|
}
|
|
|
|
|
|
String::new()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn ensure_allowed_target(
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
input: &ToolCallInput,
|
|
|
|
|
|
operation: &Value,
|
|
|
|
|
|
block_id: &str,
|
|
|
|
|
|
op: &str,
|
|
|
|
|
|
) -> Result<(), WebError> {
|
|
|
|
|
|
let allowed = allowed_target_block_ids(input, operation);
|
|
|
|
|
|
if allowed.is_empty() || allowed.contains(block_id) {
|
|
|
|
|
|
return Ok(());
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(WebError::bad_request_code(
|
|
|
|
|
|
"mnote_block_target_out_of_scope",
|
|
|
|
|
|
format!("{op} 目标块不在当前 AI selection 允许范围内"),
|
|
|
|
|
|
)
|
|
|
|
|
|
.with_context(context))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn allowed_target_block_ids(input: &ToolCallInput, operation: &Value) -> HashSet<String> {
|
|
|
|
|
|
let mut allowed = HashSet::new();
|
|
|
|
|
|
if let Some(Value::Array(values)) = input.arg_value("allowedTargetBlockIds") {
|
|
|
|
|
|
for value in values {
|
|
|
|
|
|
if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) {
|
|
|
|
|
|
allowed.insert(id.to_string());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(values) = operation
|
|
|
|
|
|
.get("allowedTargetBlockIds")
|
|
|
|
|
|
.and_then(Value::as_array)
|
|
|
|
|
|
{
|
|
|
|
|
|
for value in values {
|
|
|
|
|
|
if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) {
|
|
|
|
|
|
allowed.insert(id.to_string());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
allowed
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn block_to_ai_content(
|
|
|
|
|
|
format: &str,
|
|
|
|
|
|
block: &Value,
|
|
|
|
|
|
aggregate: &Value,
|
|
|
|
|
|
document_id: &str,
|
|
|
|
|
|
) -> String {
|
|
|
|
|
|
let text = block
|
|
|
|
|
|
.get("text")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
match format {
|
|
|
|
|
|
"page_xml" | "xml" => {
|
|
|
|
|
|
let revision = aggregate
|
|
|
|
|
|
.pointer("/body/revision")
|
|
|
|
|
|
.and_then(Value::as_u64)
|
|
|
|
|
|
.map(|value| value.to_string())
|
|
|
|
|
|
.unwrap_or_else(|| {
|
|
|
|
|
|
aggregate
|
|
|
|
|
|
.pointer("/body/revision")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
|
.to_string()
|
|
|
|
|
|
});
|
|
|
|
|
|
let block_id = block_id_of(block).unwrap_or_default();
|
|
|
|
|
|
let block_type = block
|
|
|
|
|
|
.get("type")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.unwrap_or("paragraph");
|
|
|
|
|
|
let revision_ref = block
|
|
|
|
|
|
.get("revisionRef")
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
format!(
|
|
|
|
|
|
"<page id=\"{}\" revision=\"{}\">\n <block id=\"{}\" type=\"{}\" revisionRef=\"{}\">{}</block>\n</page>",
|
|
|
|
|
|
escape_xml(document_id),
|
|
|
|
|
|
escape_xml(&revision),
|
|
|
|
|
|
escape_xml(&block_id),
|
|
|
|
|
|
escape_xml(block_type),
|
|
|
|
|
|
escape_xml(revision_ref),
|
|
|
|
|
|
escape_xml(text)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
"text" | "plain" => format!("[{}] {text}", block_id_of(block).unwrap_or_default()),
|
|
|
|
|
|
_ => format!(
|
|
|
|
|
|
"{text} <!-- block:{} -->",
|
|
|
|
|
|
block_id_of(block).unwrap_or_default()
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn escape_xml(value: &str) -> String {
|
|
|
|
|
|
value
|
|
|
|
|
|
.replace('&', "&")
|
|
|
|
|
|
.replace('<', "<")
|
|
|
|
|
|
.replace('>', ">")
|
|
|
|
|
|
.replace('"', """)
|
|
|
|
|
|
.replace('\'', "'")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 将 BlockDelta JSON 合并到工具响应中(Phase B)
|
|
|
|
|
|
fn merge_block_delta(response: &mut Value, delta_opt: Option<Value>) {
|
|
|
|
|
|
if let (Some(delta), Some(obj)) = (delta_opt, response.as_object_mut()) {
|
|
|
|
|
|
obj.insert("blockDelta".into(), delta);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 17:01:35 +08:00
|
|
|
|
pub(crate) fn current_body_content(aggregate: &Value) -> Value {
|
2026-05-16 22:03:14 +08:00
|
|
|
|
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]
|
2026-05-19 08:07:17 +08:00
|
|
|
|
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]
|
2026-05-16 22:03:14 +08:00
|
|
|
|
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 插件插入段");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|