Files
mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs
T
lix-2026 bb2f190f50 feat: stabilize page AI ACP runtimes
实现并稳定页面 AI 的 ACP Hermes / ACP Reasonix 运行路径。

主要内容:

- 分离 profile 与 acpRuntime,ACP Hermes 按所选 Hermes profile 启动并注入 provider key。

- 修复 Reasonix ACP wrapper 的 API key 读取、ToolRegistry 注册、LoopEvent role 映射和 reasoning/final 分流。

- 修复 ACP agent_thought_chunk 被 untagged enum 误解析为 message.delta 的问题,补充 thought 相关单测。

- 补充页面 AI 浏览器验证 skill 证据到 7-15 设计稿,并记录严格验收标准。

- 同步提交当前仓库中已存在的 rust-web / Hermes tools / SSE / bug 文档相关改动。

验证:

- node --check scripts/reasonix-acp-wrapper.mjs

- cargo test -p mnote-web acp -- --nocapture

- 页面 AI ACP 浏览器验证:tmp/page-ai-acp-browser-UAYwyM/
2026-05-17 20:11:39 +08:00

1010 lines
35 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::ToolCallInput;
use crate::routes::web_shell::build_page_aggregate_snapshot;
use serde_json::{json, Value};
use std::collections::HashSet;
pub async fn doc_fetch(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let document_id = input.effective_document_id().unwrap_or_default();
let workspace_id = input.effective_workspace_id();
// 本地文件路径检测:直接读取 .md 文件,不经过 Convex
let is_local_file = document_id.starts_with('/') || document_id.starts_with("./");
if is_local_file {
use std::fs;
let path = &document_id;
let content = fs::read_to_string(path).map_err(|error| {
WebError::bad_request_code(
"mnote_tool_bad_request",
format!("无法读取本地文件 {path}: {error}"),
)
.with_context(context)
})?;
let char_count = content.chars().count();
let max_chars = input
.arg_value("maxChars")
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let (result_content, truncated) = if max_chars > 0 && char_count > max_chars {
(content.chars().take(max_chars).collect::<String>(), true)
} else {
(content, false)
};
return Ok(json!({
"ok": true,
"schema": "mnote.page_ai_context.v1",
"source": "local_fs",
"documentId": document_id,
"workspaceId": workspace_id,
"format": "markdown",
"detail": "simple",
"scope": "full",
"content": result_content,
"truncated": truncated,
"blocks": json!([]),
"warnings": json!([])
}));
}
let aggregate = aggregate_value(state, context, input).await?;
let scope = input
.arg_string("scope")
.unwrap_or_else(|| "full".into())
.to_ascii_lowercase();
let detail = input
.arg_string("detail")
.unwrap_or_else(|| "with_ids".into())
.to_ascii_lowercase();
let max_blocks = input
.arg_value("maxBlocks")
.and_then(|value| value.as_u64())
.unwrap_or(120)
.clamp(1, 240) as usize;
let mut blocks = block_projection_blocks(&aggregate);
blocks = match scope.as_str() {
"outline" => blocks
.into_iter()
.filter(|block| block.get("type").and_then(Value::as_str) == Some("heading"))
.collect(),
"block" => {
let block_id = input.arg_string("blockId").ok_or_else(|| {
WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.doc.fetch scope=block 缺少 blockId",
)
.with_context(context)
})?;
blocks
.into_iter()
.filter(|block| block_id_of(block).as_deref() == Some(block_id.as_str()))
.collect()
}
"section" => {
// 提取第一个标题到下一个同级/高级标题之间的节
let heading_idx = blocks.iter().position(|block| {
block
.get("type")
.and_then(Value::as_str)
.map(|t| t == "heading")
.unwrap_or(false)
});
match heading_idx {
Some(start) => {
let level = blocks[start]
.pointer("/attrs/level")
.and_then(Value::as_u64)
.unwrap_or(2);
let mut end = blocks.len();
for (idx, block) in blocks.iter().enumerate().skip(start + 1) {
if block
.get("type")
.and_then(Value::as_str)
.map(|t| t == "heading")
.unwrap_or(false)
{
if let Some(hl) = block.pointer("/attrs/level").and_then(Value::as_u64)
{
if hl <= level {
end = idx;
break;
}
}
}
}
blocks.drain(start..end).collect()
}
None => blocks, // 没有标题时返回全文
}
}
"keyword" => {
let query = input.arg_string("query").ok_or_else(|| {
WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.doc.fetch scope=keyword 缺少 query",
)
.with_context(context)
})?;
filter_blocks_by_query(blocks, &query)
}
"selection" => {
let selected_ids = selected_block_ids(input);
if selected_ids.is_empty() {
return Err(WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.doc.fetch scope=selection 缺少 selectedBlockIds",
)
.with_context(context));
}
blocks
.into_iter()
.filter(|block| {
block_id_of(block)
.map(|block_id| selected_ids.contains(&block_id))
.unwrap_or(false)
})
.collect()
}
_ => blocks,
};
let max_chars = input
.arg_value("maxChars")
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let truncated = blocks.len() > max_blocks;
blocks.truncate(max_blocks);
let format = input
.arg_string("format")
.unwrap_or_else(|| "json".into())
.to_ascii_lowercase();
let include_ids = detail == "with_ids" || detail == "full";
let raw_content = blocks_to_content(&format, &blocks, include_ids, &document_id, &aggregate);
// maxChars 截断 + 片段包装
let mut content = raw_content;
let mut char_truncated = false;
if max_chars > 0 && content.chars().count() > max_chars {
content = content.chars().take(max_chars).collect();
char_truncated = true;
}
// 片段包装:非 full scope 时包裹注释标记
let is_partial = scope != "full";
if is_partial {
let marker = match scope.as_str() {
"section" => format!("<!-- fragment: section -->\n"),
"outline" => "<!-- fragment: outline -->\n".to_string(),
"keyword" => {
let query = input.arg_string("query").unwrap_or_default();
format!("<!-- fragment: keyword \"{}\" -->\n", query)
}
"block" => "<!-- fragment: block -->\n".to_string(),
"selection" => "<!-- fragment: selection -->\n".to_string(),
_ => String::new(),
};
if !marker.is_empty() {
content = format!("{}{}\n<!-- /fragment -->", marker, content);
}
}
let truncated_final = truncated || char_truncated;
let mut warnings_list = Vec::new();
if truncated {
warnings_list.push(json!({
"code": "mnote_doc_fetch_truncated",
"message": "结果已按 maxBlocks 裁剪",
"maxBlocks": max_blocks
}));
}
if char_truncated {
warnings_list.push(json!({
"code": "mnote_doc_fetch_char_truncated",
"message": "结果已按 maxChars 裁剪",
"maxChars": max_chars
}));
}
let source = if document_id.starts_with('/')
|| document_id.starts_with("./")
|| document_id.contains('/')
{
"local_fs"
} else {
"convex"
};
Ok(json!({
"ok": true,
"schema": "mnote.page_ai_context.v1",
"documentId": document_id,
"workspaceId": workspace_id,
"source": source,
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
"format": format,
"detail": detail,
"scope": scope,
"content": content,
"blocks": blocks,
"allowedTargetBlockIds": selected_block_ids(input),
"truncated": truncated_final,
"continuation": if truncated_final { json!({"maxBlocks": max_blocks, "maxChars": max_chars}) } else { Value::Null },
"warnings": warnings_list
}))
}
pub async fn doc_find(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let aggregate = aggregate_value(state, context, input).await?;
let query = input.arg_string("query").ok_or_else(|| {
WebError::bad_request_code("mnote_tool_bad_request", "mnote.doc.find 缺少 query")
.with_context(context)
})?;
let match_kind = input
.arg_string("match")
.unwrap_or_else(|| "text".into())
.to_ascii_lowercase();
let limit = input
.arg_value("limit")
.and_then(|value| value.as_u64())
.unwrap_or(20)
.clamp(1, 50) as usize;
let mut matches = Vec::new();
for block in block_projection_blocks(&aggregate) {
let matched = match match_kind.as_str() {
"type" => block
.get("type")
.and_then(Value::as_str)
.map(|value| value.eq_ignore_ascii_case(&query))
.unwrap_or(false),
"block_id" | "blockid" => block_id_of(&block).as_deref() == Some(query.as_str()),
_ => block
.get("text")
.and_then(Value::as_str)
.map(|text| text.contains(&query))
.unwrap_or(false),
};
if matched {
matches.push(json!({
"blockId": block.get("blockId").cloned().unwrap_or(Value::Null),
"type": block.get("type").cloned().unwrap_or(Value::Null),
"text": block.get("text").cloned().unwrap_or(Value::Null),
"path": block.get("path").cloned().unwrap_or(Value::Null),
"revisionRef": block.get("revisionRef").cloned().unwrap_or(Value::Null),
"score": 1.0
}));
if matches.len() >= limit {
break;
}
}
}
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),
"matches": matches
}))
}
pub async fn plan_update(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
"写入计划型 mnote Hermes tool 必须携带 idempotencyKey",
)
.with_context(context));
}
if input.dry_run != Some(true) {
return Err(WebError::bad_request_code(
"mnote_tool_dry_run_required",
"mnote.doc.plan_update 第一阶段只允许 dryRun=true",
)
.with_context(context));
}
let aggregate = aggregate_value(state, context, input).await?;
let command = input
.arg_string("command")
.unwrap_or_else(|| "block_replace".into())
.to_ascii_lowercase();
let blocks = block_projection_blocks(&aggregate);
let diff = match command.as_str() {
"block_replace" => {
let block_id = required_arg(input, context, "blockId")?;
let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?;
vec![json!({
"op": "replace",
"targetBlockId": block_id,
"before": block.get("text").cloned().unwrap_or(Value::Null),
"after": input.arg_value("content").unwrap_or(Value::Null)
})]
}
"block_insert_after" => {
let anchor = input
.arg_string("anchorBlockId")
.or_else(|| input.arg_string("afterBlockId"))
.ok_or_else(|| {
WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.doc.plan_update block_insert_after 缺少 anchorBlockId",
)
.with_context(context)
})?;
let block = find_block(&blocks, &anchor).ok_or_else(|| block_not_found(context))?;
vec![json!({
"op": "insert_after",
"anchorBlockId": anchor,
"after": block.get("text").cloned().unwrap_or(Value::Null),
"content": input.arg_value("content").unwrap_or(Value::Null)
})]
}
"block_move_after" => {
let block_id = required_arg(input, context, "blockId")?;
let anchor = required_arg(input, context, "anchorBlockId")?;
let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?;
let anchor_block =
find_block(&blocks, &anchor).ok_or_else(|| block_not_found(context))?;
let blocked = block_move_after_blocked(&block, &anchor_block, &block_id, &anchor);
vec![json!({
"op": "move_after",
"blockId": block_id,
"anchorBlockId": anchor,
"supportedForWrite": !blocked,
"blocked": blocked
})]
}
"block_delete" => {
let block_id = required_arg(input, context, "blockId")?;
let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?;
let blocked = block
.get("children")
.and_then(Value::as_array)
.map(|children| !children.is_empty())
.unwrap_or(false);
vec![json!({
"op": "delete",
"blockId": block_id,
"before": block.get("text").cloned().unwrap_or(Value::Null),
"supportedForWrite": !blocked,
"blocked": blocked
})]
}
"str_replace" => vec![json!({
"op": "str_replace",
"query": input.arg_value("query").unwrap_or(Value::Null),
"replacement": input.arg_value("content").unwrap_or(Value::Null)
})],
other => {
return Err(WebError::bad_request_code(
"mnote_tool_bad_request",
format!("mnote.doc.plan_update 不支持 command={other}"),
)
.with_context(context));
}
};
let plan_blocked = matches!(command.as_str(), "block_move_after" | "block_delete")
&& diff
.first()
.and_then(|item| item.get("blocked"))
.and_then(Value::as_bool)
.unwrap_or(false);
Ok(json!({
"ok": true,
"dryRun": true,
"planId": format!("plan_{}", context.trace.request_id),
"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),
"command": command,
"diff": diff,
"warnings": if plan_blocked {
json!([{
"code": "block_move_after_blocked",
"message": "第一阶段仅开放同父级普通叶子块移动,且不能移动到自身之后"
}])
} else {
json!([])
},
"risk": if command == "block_move_after" { "medium" } else { "low" },
"blocked": plan_blocked
}))
}
fn block_move_after_blocked(
block: &Value,
anchor: &Value,
block_id: &str,
anchor_id: &str,
) -> bool {
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);
!same_parent || !leaf || !movable_type || !editable || block_id == anchor_id
}
pub(crate) async fn aggregate_value(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> 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 aggregate = build_page_aggregate_snapshot(
state,
context,
&document_id,
workspace_id.as_deref(),
None,
None,
)
.await?;
serde_json::to_value(&aggregate).map_err(|error| WebError::internal(error.to_string()))
}
pub(crate) fn block_projection_blocks(aggregate: &Value) -> Vec<Value> {
aggregate
.pointer("/body/blockDocument/blocks")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
}
pub(crate) fn block_id_of(block: &Value) -> Option<String> {
block
.get("blockId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
pub(crate) fn find_block(blocks: &[Value], block_id: &str) -> Option<Value> {
blocks
.iter()
.find(|block| block_id_of(block).as_deref() == Some(block_id))
.cloned()
}
pub(crate) fn required_arg(
input: &ToolCallInput,
context: &RequestContext,
key: &'static str,
) -> Result<String, WebError> {
input.arg_string(key).ok_or_else(|| {
WebError::bad_request_code("mnote_tool_bad_request", format!("工具调用缺少 {key}"))
.with_context(context)
})
}
pub(crate) fn block_not_found(context: &RequestContext) -> WebError {
WebError::bad_request_code("mnote_block_not_found", "块不存在").with_context(context)
}
fn filter_blocks_by_query(blocks: Vec<Value>, query: &str) -> Vec<Value> {
blocks
.into_iter()
.filter(|block| {
block
.get("text")
.and_then(Value::as_str)
.map(|text| text.contains(query))
.unwrap_or(false)
})
.collect()
}
fn selected_block_ids(input: &ToolCallInput) -> Vec<String> {
let mut seen = HashSet::new();
let mut ids = Vec::new();
for key in ["selectedBlockIds", "allowedTargetBlockIds"] {
if let Some(Value::Array(values)) = input.arg_value(key) {
for value in values {
if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) {
if seen.insert(id.to_string()) {
ids.push(id.to_string());
}
}
}
}
}
for key in ["selectedBlockId", "blockId"] {
if let Some(id) = input.arg_string(key) {
if seen.insert(id.clone()) {
ids.push(id);
}
}
}
ids
}
fn blocks_to_content(
format: &str,
blocks: &[Value],
include_ids: bool,
document_id: &str,
aggregate: &Value,
) -> String {
match format {
"page_xml" | "xml" => blocks_to_page_xml(blocks, document_id, aggregate),
"text" | "plain" => blocks_to_text(blocks, include_ids),
"markdown" | "md" => blocks_to_markdown(blocks, include_ids),
_ => blocks_to_markdown(blocks, include_ids),
}
}
fn blocks_to_text(blocks: &[Value], include_ids: bool) -> String {
blocks
.iter()
.map(|block| {
let text = block_text(block);
if include_ids {
format!("[{}] {text}", block_id_of(block).unwrap_or_default())
} else {
text
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn blocks_to_markdown(blocks: &[Value], include_ids: bool) -> String {
blocks
.iter()
.map(|block| {
let text = block_text(block);
let prefix = match block.get("type").and_then(Value::as_str) {
Some("heading") => "## ",
Some("todo") | Some("task") => "- [ ] ",
_ => "",
};
if include_ids {
format!(
"{prefix}{text} <!-- block:{} -->",
block_id_of(block).unwrap_or_default()
)
} else {
format!("{prefix}{text}")
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn blocks_to_page_xml(blocks: &[Value], document_id: &str, aggregate: &Value) -> String {
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 mut output = format!(
"<page id=\"{}\" revision=\"{}\">",
escape_xml(document_id),
escape_xml(&revision)
);
for block in blocks {
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();
output.push_str(&format!(
"\n <block id=\"{}\" type=\"{}\" revisionRef=\"{}\"",
escape_xml(&block_id),
escape_xml(block_type),
escape_xml(revision_ref)
));
if let Some(level) = block
.pointer("/attrs/level")
.or_else(|| block.pointer("/props/level"))
{
if let Some(level) = level.as_u64() {
output.push_str(&format!(" level=\"{}\"", level));
}
}
output.push_str(&format!(">{}</block>", escape_xml(&block_text(block))));
}
output.push_str("\n</page>");
output
}
fn block_text(block: &Value) -> String {
block
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
}
fn escape_xml(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_search_replace_exact() {
assert_eq!(
search_replace("第一段内容。\n第二段内容。", "第一段内容", "替换后").unwrap(),
"替换后。\n第二段内容。"
);
}
#[test]
fn test_search_replace_not_found() {
let result = search_replace("第一段内容。", "不存在的文本", "替换");
assert!(result.is_err());
assert!(result.unwrap_err().contains("无法匹配"));
}
#[test]
fn test_search_replace_full_content() {
// 全文替换:search 等于全文
let result = search_replace("全文内容", "全文内容", "新全文").unwrap();
assert_eq!(result, "新全文");
}
#[test]
fn test_blocks_to_markdown_with_ids() {
let blocks = json!([
{"blockId": "b1", "text": "第一段", "type": "paragraph"},
{"blockId": "b2", "text": "第二段", "type": "paragraph"}
]);
let blocks: Vec<Value> = blocks.as_array().unwrap().clone();
let md = blocks_to_markdown(&blocks, true);
assert!(md.contains("第一段 <!-- block:b1 -->"));
assert!(md.contains("第二段 <!-- block:b2 -->"));
}
#[test]
fn test_blocks_to_markdown_heading() {
let blocks = json!([
{"blockId": "h1", "text": "标题", "type": "heading"},
{"blockId": "p1", "text": "正文", "type": "paragraph"}
]);
let blocks: Vec<Value> = blocks.as_array().unwrap().clone();
let md = blocks_to_markdown(&blocks, false);
assert!(md.contains("## 标题"));
assert!(md.contains("正文"));
}
}
// ── mnote.doc.markdown_edit ──────────────────────────────────────────
pub async fn doc_markdown_edit(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let document_id = input.effective_document_id().unwrap_or_default();
let workspace_id = input.effective_workspace_id();
let is_local_file = document_id.starts_with('/') || document_id.starts_with("./");
// 1. 读取当前文档内容(markdown 形式)
let (current_md, source) = if is_local_file {
use std::fs;
// full_content 模式时允许文件不存在(创建新文件)
let has_full = input.arg_value("full_content").is_some();
let content = match fs::read_to_string(&document_id) {
Ok(c) => c,
Err(_) if has_full => String::new(), // 创建模式:空内容
Err(error) => {
return Err(WebError::bad_request_code(
"mnote_tool_bad_request",
format!("无法读取本地文件 {document_id}: {error}"),
)
.with_context(context));
}
};
(content, "local_fs")
} else {
let aggregate = aggregate_value(state, context, input).await?;
let blocks = block_projection_blocks(&aggregate);
(blocks_to_markdown(&blocks, true), "convex")
};
// 2. 解析 operations
let use_full_content = input.arg_string("full_content");
let operations: Vec<Value> = if let Some(full) = &use_full_content {
// 全文替换模式
vec![json!({"search": current_md.trim(), "replace": full.trim()})]
} else {
input
.arg_value("operations")
.and_then(|v| v.as_array().cloned())
.ok_or_else(|| {
WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.doc.markdown_edit 缺少 operations 或 full_content",
)
.with_context(context)
})?
};
if operations.is_empty() {
return Err(WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.doc.markdown_edit operations 不能为空",
)
.with_context(context));
}
if operations.len() > 20 {
return Err(WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.doc.markdown_edit 一次最多允许 20 个操作",
)
.with_context(context));
}
// 3. 逐条执行 search_replace
let mut applied = 0usize;
let mut failed = Vec::new();
let mut md = current_md.clone();
for (idx, op) in operations.iter().enumerate() {
let search = op
.get("search")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_default();
let replace = op
.get("replace")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_default();
if search.is_empty() {
// full_content 模式且当前内容为空:直接使用替换文本
if use_full_content.is_some() && current_md.trim().is_empty() {
md = replace.clone();
applied += 1;
} else {
failed.push(json!({
"index": idx,
"reason": "search 不能为空",
"search": search
}));
}
continue;
}
match search_replace(&md, &search, &replace) {
Ok(new_md) => {
md = new_md;
applied += 1;
}
Err(reason) => {
failed.push(json!({
"index": idx,
"reason": reason,
"search": search
}));
}
}
}
// 4. 构建 changedText 摘要
let changed_text = if applied > 0 {
operations
.iter()
.take(applied)
.map(|op| {
let s = op.get("search").and_then(Value::as_str).unwrap_or("");
let r = op.get("replace").and_then(Value::as_str).unwrap_or("");
format!("「{}」→「{}」", s, r)
})
.collect::<Vec<_>>()
.join("\n")
} else {
String::from("无操作已应用")
};
// 5. 写回(本地文件直接 fs::writeConvex 文档通过 block ops apply
let apply_result = if is_local_file {
use std::fs;
fs::write(&document_id, &md).map_err(|error| {
WebError::bad_request_code(
"mnote_tool_bad_request",
format!("无法写入本地文件 {document_id}: {error}"),
)
.with_context(context)
})?;
json!({"written": true, "path": document_id.clone()})
} else {
// 构建 block ops:将修改后的 markdown 重新注入
let aggregate = aggregate_value(state, context, input).await?;
let blocks = block_projection_blocks(&aggregate);
let block_ops = build_block_ops_from_markdown_edit(&blocks, &operations, applied);
let apply_input = ToolCallInput {
tool_name: "mnote.doc.apply_block_ops".into(),
workspace_id: workspace_id.clone(),
document_id: Some(document_id.clone()),
actor_id: input.actor_id.clone(),
profile: input.profile.clone(),
session_id: input.session_id.clone(),
run_id: input.run_id.clone(),
tool_call_id: Some(format!("md_edit_{}", context.trace.request_id)),
trace_id: input.trace_id.clone(),
idempotency_key: Some(format!("md_edit_{}", context.trace.request_id)),
dry_run: input.dry_run,
capability_scope: Some(vec!["block.write".into(), "page.write".into()]),
args: Some(json!({
"operations": block_ops,
"allowedTargetBlockIds": json!([])
})),
};
crate::hermes_tools::block::doc_apply_block_ops(state, context, &apply_input).await?
};
Ok(json!({
"ok": true,
"schema": "mnote.doc.markdown_edit.v1",
"source": source,
"documentId": document_id,
"workspaceId": workspace_id,
"operationsApplied": applied,
"operationsFailed": failed.len(),
"failedOperations": failed,
"changedText": changed_text,
"applyResult": apply_result
}))
}
/// 四级搜索替换:精确 → 忽略空白 → 段落 fuzzy → 失败
fn search_replace(text: &str, search: &str, replace: &str) -> Result<String, String> {
// Level 1: 精确匹配
if text.contains(search) {
return Ok(text.replacen(search, replace, 1));
}
// Level 2: 忽略首尾空白和全角/半角差异
let normalized = |s: &str| -> String {
s.trim()
.chars()
.map(|ch| match ch {
''..='' => ((ch as u32).saturating_sub('' as u32) + 'A' as u32)
.try_into()
.unwrap_or(ch),
''..='' => ((ch as u32).saturating_sub('' as u32) + 'a' as u32)
.try_into()
.unwrap_or(ch),
''..='' => ((ch as u32).saturating_sub('' as u32) + '0' as u32)
.try_into()
.unwrap_or(ch),
'\u{3000}' => ' ',
_ => ch,
})
.collect()
};
let norm_search = normalized(search);
for line in text.lines() {
let norm_line = normalized(line);
if norm_line.contains(&norm_search) {
let start = norm_line.find(&norm_search).unwrap();
let end = start + norm_search.len();
let replaced = format!(
"{}{}{}",
&line[..line.char_indices().nth(start).map(|(i, _)| i).unwrap_or(0)],
replace,
&line[line
.char_indices()
.nth(end)
.map(|(i, _)| i)
.unwrap_or(line.len())..]
);
return Ok(text.replacen(line, &replaced, 1));
}
}
// Level 3: 按段落 fuzzy30% 字符差异容限)
for para in text.split("\n\n") {
if fuzzy_match(para, search, 0.3) {
let idx = text.find(para).unwrap();
let replaced = format!("{}{}{}", &text[..idx], replace, &text[idx + para.len()..]);
return Ok(replaced);
}
}
// Level 4: 失败
Err(format!(
"无法匹配 \"{}\"",
if search.len() > 60 {
format!("{}...", &search[..60])
} else {
search.to_string()
}
))
}
fn fuzzy_match(text: &str, pattern: &str, max_diff_ratio: f64) -> bool {
let text_chars: Vec<char> = text.chars().collect();
let pat_chars: Vec<char> = pattern.chars().collect();
let max_dist = (pat_chars.len() as f64 * max_diff_ratio).ceil() as usize;
// 简单的滑动窗口匹配
for window in text_chars.windows(pat_chars.len().min(text_chars.len())) {
let dist = window
.iter()
.zip(pat_chars.iter())
.filter(|(a, b)| a != b)
.count();
if dist <= max_dist {
return true;
}
}
false
}
/// 将 markdown_edit 的 operations 转换为 block ops 供 doc_apply_block_ops 执行
fn build_block_ops_from_markdown_edit(
blocks: &[Value],
operations: &[Value],
applied_count: usize,
) -> Vec<Value> {
let mut block_ops = Vec::new();
for op in operations.iter().take(applied_count) {
let search = op.get("search").and_then(Value::as_str).unwrap_or("");
let replace = op.get("replace").and_then(Value::as_str).unwrap_or("");
let _content_val = op.get("content").cloned().unwrap_or_else(|| json!(replace));
// 在 blocks 中查找匹配文本
let matched_block = blocks.iter().find(|block| {
block
.get("text")
.and_then(Value::as_str)
.map(|t| t.contains(search))
.unwrap_or(false)
});
if let Some(block) = matched_block {
let block_id = block_id_of(block).unwrap_or_default();
let block_text = block.get("text").and_then(Value::as_str).unwrap_or("");
let new_text = block_text.replacen(search, replace, 1);
block_ops.push(json!({
"op": "replace",
"blockId": block_id,
"content": new_text
}));
}
}
block_ops
}