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 { 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::(), 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!("\n"), "outline" => "\n".to_string(), "keyword" => { let query = input.arg_string("query").unwrap_or_default(); format!("\n", query) } "block" => "\n".to_string(), "selection" => "\n".to_string(), _ => String::new(), }; if !marker.is_empty() { content = format!("{}{}\n", 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 { 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 { 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 { 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 { aggregate .pointer("/body/blockDocument/blocks") .and_then(Value::as_array) .cloned() .unwrap_or_default() } pub(crate) fn block_id_of(block: &Value) -> Option { 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 { 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 { 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, query: &str) -> Vec { 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 { 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::>() .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_id_of(block).unwrap_or_default() ) } else { format!("{prefix}{text}") } }) .collect::>() .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!( "", 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 {}", escape_xml(&block_text(block)))); } output.push_str("\n"); 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('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } #[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 = blocks.as_array().unwrap().clone(); let md = blocks_to_markdown(&blocks, true); assert!(md.contains("第一段 ")); assert!(md.contains("第二段 ")); } #[test] fn test_blocks_to_markdown_heading() { let blocks = json!([ {"blockId": "h1", "text": "标题", "type": "heading"}, {"blockId": "p1", "text": "正文", "type": "paragraph"} ]); let blocks: Vec = 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 { 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 = 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::>() .join("\n") } else { String::from("无操作已应用") }; // 5. 写回(本地文件直接 fs::write,Convex 文档通过 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 { // 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 { 'A'..='Z' => ((ch as u32).saturating_sub('A' as u32) + 'A' as u32) .try_into() .unwrap_or(ch), 'a'..='z' => ((ch as u32).saturating_sub('a' as u32) + 'a' as u32) .try_into() .unwrap_or(ch), '0'..='9' => ((ch as u32).saturating_sub('0' 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: 按段落 fuzzy(30% 字符差异容限) 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 = text.chars().collect(); let pat_chars: Vec = 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 { 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 }