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 axum::http::StatusCode; use serde_json::{json, Value}; use std::collections::HashSet; fn file_version_from_aggregate(aggregate: &Value) -> Value { [ "/body/fileVersion", "/body/file_version", "/body/conflictDetectionKey", "/body/conflict_detection_key", ] .iter() .find_map(|pointer| { aggregate .pointer(pointer) .filter(|value| !value.is_null()) .cloned() }) .unwrap_or(Value::Null) } fn conflict_detection_key_from_aggregate(aggregate: &Value) -> Option<&str> { [ "/body/conflictDetectionKey", "/body/conflict_detection_key", "/body/fileVersion", "/body/file_version", ] .iter() .find_map(|pointer| aggregate.pointer(pointer).and_then(Value::as_str)) } 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(); ensure_ai_scope_resource_allowed(context, input, &document_id)?; // 本地文件路径检测:直接读取授权 root 内的 .md 文件,不经过 Convex。 let is_local_file = document_id.starts_with('/') || document_id.starts_with("./") || document_id.starts_with("file://"); if is_local_file { use std::fs; let root_uri = local_root_uri_for_tool(input).ok_or_else(|| { WebError::new( StatusCode::FORBIDDEN, "ai_scope_root_uri_required", "本地文件读取需要授权 rootUri", ) .with_context(context) })?; let path = crate::routes::ensure_local_path_read_access(context, &root_uri, &document_id) .map_err(|error| error.with_context(context))?; let content = fs::read_to_string(path).map_err(|error| { WebError::bad_request_code( "mnote_tool_bad_request", format!("无法读取本地文件: {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, "rootUri": root_uri, "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), "fileVersion": file_version_from_aggregate(&aggregate), "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 { if let Some(document_id) = input.effective_document_id() { ensure_ai_scope_resource_allowed(context, input, &document_id)?; } 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), "fileVersion": file_version_from_aggregate(&aggregate), "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), "fileVersion": file_version_from_aggregate(&aggregate), "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) })?; ensure_ai_scope_resource_allowed(context, input, &document_id)?; let workspace_id = input.effective_workspace_id(); let source_kind = input.effective_source_kind(); let root_uri = input.effective_root_uri(); let aggregate = build_page_aggregate_snapshot( state, context, &document_id, workspace_id.as_deref(), source_kind.as_deref(), root_uri.as_deref(), ) .await?; serde_json::to_value(&aggregate).map_err(|error| WebError::internal(error.to_string())) } pub(crate) fn ensure_ai_scope_resource_allowed( context: &RequestContext, input: &ToolCallInput, document_id: &str, ) -> Result<(), WebError> { let Some(scope) = input.arg_value("aiAccessScope") else { return Ok(()); }; let allowed = scope .get("allowedResourceIds") .or_else(|| scope.get("allowed_resource_ids")) .and_then(Value::as_array) .map(|values| { values .iter() .filter_map(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .collect::>() }) .unwrap_or_default(); if allowed.is_empty() || allowed.contains(document_id) { return Ok(()); } Err(WebError::new( StatusCode::FORBIDDEN, "mnote_tool_ai_scope_read_forbidden", "当前 AI scope 不允许读取该资源", ) .with_context(context)) } fn local_root_uri_for_tool(input: &ToolCallInput) -> Option { input.effective_root_uri().or_else(|| { input .arg_value("aiAccessScope") .and_then(|scope| { scope .get("allowedRoots") .or_else(|| scope.get("allowed_roots")) .cloned() }) .and_then(|allowed_roots| { allowed_roots.as_array().and_then(|roots| { roots .iter() .filter_map(|root| { root.get("rootUri") .or_else(|| root.get("root_uri")) .and_then(Value::as_str) }) .map(str::trim) .find(|root_uri| !root_uri.is_empty()) .map(ToOwned::to_owned) }) }) }) } 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") .or_else(|| block.get("id")) .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() } // ── 7-27: Markdown → Block Content 直接写回 ──────────────────────────── /// 解析后的单个块信息,用作 final_md → page.body.save 的中间表示 #[derive(Debug, Clone)] #[allow(dead_code)] struct ParsedBlockInfo { /// 原始 block_id;None 表示新块(full_content 或无注释的新 paragraph) block_id: Option, block_type: String, text: String, block_revision_ref: Option, /// 标记为新建块——写入层分配新 ID is_new: bool, /// 从原始 block projection 中复制的完整块对象 original: Option, /// 原始块的 props(如 heading level) props: Option, /// 复杂块(resource/mindmap/table)的原始内容——只保留,不改动 is_complex: bool, } /// 将搜索替换后的最终 markdown 解析为块列表,保留原始块的元数据 /// /// * `final_md` — `search_replace` 应用后的最终 markdown /// * `originals` — 从 `aggregate.body.blockDocument.blocks` 读取的原始块 fn parse_final_markdown_to_blocks(final_md: &str, originals: &[Value]) -> Vec { let originals_by_id: std::collections::HashMap = originals .iter() .filter_map(|block| block_id_of(block).map(|id| (id, block))) .collect(); let mut blocks = Vec::new(); for line in final_md.lines() { let line = line.trim(); if line.is_empty() { continue; } // 尝试提取 注释 let (text_part, meta) = extract_block_comment(line); // 检测复杂块标记 [mnote-raw-block:id] if let Some(raw_id) = extract_raw_block_marker(line) { if let Some(original) = originals_by_id.get(&raw_id) { blocks.push(ParsedBlockInfo { block_id: Some(raw_id.clone()), block_type: original .get("type") .and_then(Value::as_str) .unwrap_or("paragraph") .to_string(), text: block_text(original), block_revision_ref: original .get("revisionRef") .and_then(Value::as_str) .map(str::to_string), is_new: false, original: Some((*original).clone()), props: original.get("props").cloned(), is_complex: true, }); } continue; } if let Some((block_id, block_type, _level)) = meta { // 匹配到注释的块——从 originals 继承元数据 if let Some(original) = originals_by_id.get(&block_id) { let raw_text = text_part.unwrap_or_else(|| block_text(original)); blocks.push(ParsedBlockInfo { block_id: Some(block_id.clone()), block_type, text: strip_prefix(&raw_text), block_revision_ref: original .get("revisionRef") .and_then(Value::as_str) .map(str::to_string), is_new: false, original: Some((*original).clone()), props: original.get("props").cloned(), is_complex: false, }); } else { // 注释中的 block_id 不在 originals 中(不应该发生) let raw_text = text_part.unwrap_or_default(); blocks.push(ParsedBlockInfo { block_id: Some(block_id), block_type, text: strip_prefix(&raw_text), block_revision_ref: None, is_new: true, original: None, props: None, is_complex: false, }); } } else { // 无注释的纯文本行 → 新 paragraph 块 let block_type = detect_block_type_from_prefix(line); let text = strip_prefix(line); blocks.push(ParsedBlockInfo { block_id: None, block_type, text, block_revision_ref: None, is_new: true, original: None, props: None, is_complex: false, }); } } blocks } /// 从行中提取 `` 注释及其前的文本 fn extract_block_comment(line: &str) -> (Option, Option<(String, String, Option)>) { let comment_start = line.rfind(""); if let (Some(start), Some(end)) = (comment_start, comment_end) { let before = line[..start].trim(); // 完整注释区间: "" (3 chars for "-->") let comment = &line[start..end + 3]; let inner = comment .strip_prefix("")) .map(str::trim) .unwrap_or(""); // 期望格式: block:ID:TYPE 或 block:ID:TYPE:level=N // TYPE 中不含冒号(paragraph/heading/todo/resource/mindmap/table/image) let parts: Vec<&str> = inner.splitn(3, ':').collect(); if parts.len() >= 2 && parts[0] == "block" { let id = parts[1].to_string(); let rest = if parts.len() >= 3 { parts[2] } else { "" }; // 尝试从 rest 中分割 type 和 level let (typ, level) = if let Some(level_idx) = rest.find(":level=") { let typ = rest[..level_idx].to_string(); let level_str = &rest[level_idx + 7..]; // ":level=" is 7 chars let level = level_str.parse::().ok(); (typ, level) } else { (rest.to_string(), None) }; let typ = if typ.is_empty() { "paragraph".to_string() } else { typ }; let text = if before.is_empty() { None } else { Some(before.to_string()) }; return (text, Some((id, typ, level))); } // 向后兼容旧格式: let parts_old: Vec<&str> = inner.splitn(2, ':').collect(); if parts_old.len() == 2 && parts_old[0] == "block" { let id = parts_old[1].to_string(); let text = if before.is_empty() { None } else { Some(before.to_string()) }; return (text, Some((id, "paragraph".to_string(), None))); } } (None, None) } /// 提取 `[mnote-raw-block:ID]` 标记 fn extract_raw_block_marker(line: &str) -> Option { let start = line.find("[mnote-raw-block:"); let end = line.find(']'); if let (Some(start), Some(end)) = (start, end) { let id = &line[start + 18..end]; return Some(id.to_string()); } None } /// 从行前缀推断块类型 fn detect_block_type_from_prefix(line: &str) -> String { let trimmed = line.trim(); if trimmed.starts_with("## ") { "heading".to_string() } else if trimmed.starts_with("- [ ] ") || trimmed.starts_with("- [x] ") || trimmed.starts_with("- [X] ") { "todo".to_string() } else { "paragraph".to_string() } } /// 去掉 markdown 前缀(`## ` / `- [ ] `),返回纯文本 fn strip_prefix(line: &str) -> String { let trimmed = line.trim(); if let Some(rest) = trimmed.strip_prefix("## ") { rest.to_string() } else if let Some(rest) = trimmed.strip_prefix("- [ ] ") { rest.to_string() } else if let Some(rest) = trimmed.strip_prefix("- [x] ") { rest.to_string() } else if let Some(rest) = trimmed.strip_prefix("- [X] ") { rest.to_string() } else { trimmed.to_string() } } /// 将解析后的块列表与原始 `body/content` 合并,生成最终写回 blocks 数组 fn build_page_content(original_content: &Value, parsed: &[ParsedBlockInfo]) -> Value { // 为 original_content 建立 block_id → index + full_block 映射 let original_blocks: Vec = original_content.as_array().cloned().unwrap_or_default(); let _original_index_by_id: std::collections::HashMap = original_blocks .iter() .enumerate() .filter_map(|(i, block)| block_id_of(block).map(|id| (id, i))) .collect(); let original_by_id: std::collections::HashMap = original_blocks .iter() .filter_map(|block| block_id_of(block).map(|id| (id, block))) .collect(); let mut result = Vec::new(); let mut processed_ids = std::collections::HashSet::new(); let parsed_references_original = parsed.iter().any(|parsed_block| { parsed_block .block_id .as_ref() .map(|block_id| original_by_id.contains_key(block_id)) .unwrap_or(false) }); for parsed_block in parsed { if let Some(ref block_id) = parsed_block.block_id { processed_ids.insert(block_id.clone()); if parsed_block.is_complex { // 复杂块:原样保留 if let Some(original) = original_by_id.get(block_id) { result.push((*original).clone()); } } else if let Some(original) = original_by_id.get(block_id) { let updated = update_legacy_block_text_for_markdown_edit(original, block_id, parsed_block); result.push(updated); } } else if parsed_block.is_new { // 新块:生成 ID 并构造 legacy 格式的 paragraph 结构 use std::sync::atomic::{AtomicU64, Ordering}; static COUNTER: AtomicU64 = AtomicU64::new(0); let next = COUNTER.fetch_add(1, Ordering::Relaxed); let new_id = format!("ai_block_md_{}", next); result.push(json!({ "id": new_id, "type": parsed_block.block_type, "content": parsed_block.text, })); } } // 追加未被处理的原始块(保留顺序) for original in &original_blocks { if let Some(id) = block_id_of(original) { if !processed_ids.contains(&id) && (parsed_references_original || is_complex_markdown_original_block(original)) { result.push(original.clone()); } } } Value::Array(result) } fn update_legacy_block_text_for_markdown_edit( original: &Value, block_id: &str, parsed_block: &ParsedBlockInfo, ) -> Value { let original_text = legacy_block_text(original); if original_text == parsed_block.text { return original.clone(); } let mut updated = original.clone(); if let Some(obj) = updated.as_object_mut() { obj.remove("blockId"); if !obj.contains_key("id") { obj.insert("id".into(), json!(block_id)); } if let Some(content) = obj.get_mut("content") { if replace_single_inline_text_value(content, &parsed_block.text) { obj.remove("text"); return updated; } } if let Some(content_nodes) = obj.get_mut("contentNodes") { if replace_single_inline_text_value(content_nodes, &parsed_block.text) { obj.remove("text"); return updated; } } obj.insert("content".into(), json!(parsed_block.text)); obj.remove("contentNodes"); obj.remove("text"); } updated } fn replace_single_inline_text_value(value: &mut Value, text: &str) -> bool { if value.is_string() { *value = json!(text); return true; } if let Some(items) = value.as_array_mut() { if items.len() != 1 { return false; } return replace_single_inline_text_value(&mut items[0], text); } if let Some(object) = value.as_object_mut() { if object.get("text").and_then(Value::as_str).is_some() { object.insert("text".into(), json!(text)); return true; } if let Some(payload) = object.get_mut("payload") { return replace_single_inline_text_value(payload, text); } } false } fn legacy_block_text(block: &Value) -> String { if let Some(text) = block.get("text").and_then(Value::as_str) { return text.to_string(); } if let Some(content) = block.get("content") { return inline_text_value(content); } if let Some(content_nodes) = block.get("contentNodes") { return inline_text_value(content_nodes); } String::new() } fn inline_text_value(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 inline_text_value(payload); } if let Some(content) = value.get("content") { return inline_text_value(content); } if let Some(items) = value.as_array() { return items .iter() .map(inline_text_value) .collect::>() .join(""); } String::new() } fn is_complex_markdown_original_block(block: &Value) -> bool { let block_type = block .get("type") .and_then(Value::as_str) .unwrap_or("paragraph") .to_ascii_lowercase(); matches!( block_type.as_str(), "resource" | "mindmap" | "table" | "image" | "embed" | "attachment" ) || block .get("editable") .and_then(Value::as_bool) .map(|editable| !editable) .unwrap_or(false) || block.get("unsupportedReason").is_some() || block .get("children") .and_then(Value::as_array) .map(|children| !children.is_empty()) .unwrap_or(false) } /// 生成 changedBlocks 摘要(格式与 `doc_apply_block_ops` 输出兼容) fn build_changed_blocks_summary( original_blocks: &[Value], parsed: &[ParsedBlockInfo], ) -> Vec { let original_by_id: std::collections::HashMap = original_blocks .iter() .filter_map(|block| block_id_of(block).map(|id| (id, block))) .collect(); let parsed_by_id: std::collections::HashMap = parsed .iter() .filter_map(|p| p.block_id.as_ref().map(|id| (id.clone(), p))) .collect(); let mut changed = Vec::new(); for (id, _original) in &original_by_id { if let Some(parsed_block) = parsed_by_id.get(id) { if !parsed_block.is_complex { let original_text = original_by_id .get(id) .map(|block| block_text(block)) .unwrap_or_default(); if original_text != parsed_block.text { changed.push(json!({ "op": "replace", "blockId": id, "blockRevisionRef": parsed_block.block_revision_ref, "content": parsed_block.text })); } } } } // 新块标记为 insert(无原始 block_id) for parsed_block in parsed { if parsed_block.is_new { changed.push(json!({ "op": "insert", "content": parsed_block.text, "type": parsed_block.block_type })); } } changed } fn ensure_markdown_changed_blocks_within_allowed( context: &RequestContext, changed_blocks: &[Value], allowed_block_ids: &[String], ) -> Result<(), WebError> { if allowed_block_ids.is_empty() { return Ok(()); } let allowed: HashSet<&str> = allowed_block_ids.iter().map(String::as_str).collect(); for change in changed_blocks { let Some(block_id) = change.get("blockId").and_then(Value::as_str) else { return Err(WebError::bad_request_code( "mnote_markdown_edit_target_out_of_scope", "selection 范围内的 markdown_edit 不允许写入无法归属到 blockId 的变更", ) .with_context(context)); }; if !allowed.contains(block_id) { return Err(WebError::bad_request_code( "mnote_markdown_edit_target_out_of_scope", format!("markdown_edit 目标块 {block_id} 不在当前 AI selection 允许范围内"), ) .with_context(context)); } } Ok(()) } 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 block_type = block .get("type") .and_then(Value::as_str) .unwrap_or("paragraph"); let prefix = match block_type { "heading" => "## ", "todo" | "task" => "- [ ] ", _ => "", }; if include_ids { let id = block_id_of(block).unwrap_or_default(); let editable = block .get("editable") .and_then(Value::as_bool) .unwrap_or(true); let unsupported = block.get("unsupportedReason").and_then(Value::as_str); if !editable || unsupported.is_some() { // 复杂块(resource/mindmap/table/image):特殊标记供解析器原样保留 format!("[mnote-raw-block:{id}] ") } else if block_type == "heading" { let level = block .pointer("/props/level") .and_then(Value::as_u64) .unwrap_or(2); format!("{prefix}{text} ") } else { format!("{prefix}{text} ") } } 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_normalized_chinese_byte_boundaries() { assert_eq!( search_replace("前缀:第一段内容", "一 段", "二段").unwrap(), "前缀:第二段内容" ); } #[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", "props": {"level": 2}}, {"blockId": "p1", "text": "正文", "type": "paragraph"} ]); let blocks: Vec = blocks.as_array().unwrap().clone(); let md = blocks_to_markdown(&blocks, true); assert!(md.contains("标题 ")); let md2 = blocks_to_markdown(&blocks, false); assert!(md2.contains("## 标题")); assert!(md2.contains("正文")); } #[test] fn test_blocks_to_markdown_complex_block_uses_raw_marker() { let blocks = json!([ {"blockId": "rsc_1", "text": "资源", "type": "resource", "editable": false, "unsupportedReason": "复杂块暂不开放 AI 精确写入"} ]); let blocks: Vec = blocks.as_array().unwrap().clone(); let md = blocks_to_markdown(&blocks, true); assert!(md.contains("[mnote-raw-block:rsc_1]")); assert!(md.contains("")); } #[test] fn test_build_page_content_full_content_replaces_old_text_blocks_but_keeps_complex_blocks() { let original_content = json!([ {"id": "heading_1", "type": "heading", "content": "章节一"}, {"id": "p_1", "type": "paragraph", "content": "第一段"}, {"id": "resource_1", "type": "resource", "content": "资源块", "editable": false} ]); let parsed = vec![ ParsedBlockInfo { block_id: None, block_type: "paragraph".into(), text: "新章节".into(), block_revision_ref: None, is_new: true, original: None, props: None, is_complex: false, }, ParsedBlockInfo { block_id: None, block_type: "paragraph".into(), text: "新第一段".into(), block_revision_ref: None, is_new: true, original: None, props: None, is_complex: false, }, ]; let next = build_page_content(&original_content, &parsed); let next_blocks = next.as_array().expect("array"); assert_eq!(next_blocks.len(), 3); assert_eq!(next_blocks[0]["content"], "新章节"); assert_eq!(next_blocks[1]["content"], "新第一段"); assert_eq!(next_blocks[2]["id"], "resource_1"); } #[test] fn test_build_page_content_preserves_missing_original_text_block_when_some_ids_remain() { let original_content = json!([ {"id": "heading_1", "type": "heading", "content": "章节一"}, {"id": "p_1", "type": "paragraph", "content": "第一段"}, {"id": "p_2", "type": "paragraph", "content": "第二段"} ]); let parsed = vec![ ParsedBlockInfo { block_id: Some("heading_1".into()), block_type: "heading".into(), text: "新章节".into(), block_revision_ref: None, is_new: false, original: None, props: None, is_complex: false, }, ParsedBlockInfo { block_id: Some("p_1".into()), block_type: "paragraph".into(), text: "新第一段".into(), block_revision_ref: None, is_new: false, original: None, props: None, is_complex: false, }, ]; let next = build_page_content(&original_content, &parsed); let next_blocks = next.as_array().expect("array"); assert!(next_blocks.iter().any(|block| block["id"] == "p_2")); assert!(next_blocks .iter() .any(|block| block["id"] == "heading_1" && block["content"] == "新章节")); assert_eq!(next_blocks.len(), 3); } #[test] fn test_build_page_content_preserves_unchanged_inline_content_nodes() { let original_content = json!([ { "id": "p_1", "type": "paragraph", "content": [ {"type": "text", "text": "第一段", "marks": [{"type": "bold"}]} ] }, {"id": "p_2", "type": "paragraph", "content": "第二段"} ]); let parsed = vec![ ParsedBlockInfo { block_id: Some("p_1".into()), block_type: "paragraph".into(), text: "第一段".into(), block_revision_ref: None, is_new: false, original: None, props: None, is_complex: false, }, ParsedBlockInfo { block_id: Some("p_2".into()), block_type: "paragraph".into(), text: "第二段已改".into(), block_revision_ref: None, is_new: false, original: None, props: None, is_complex: false, }, ]; let next = build_page_content(&original_content, &parsed); let p1 = &next.as_array().expect("array")[0]; assert_eq!(p1["content"][0]["text"], "第一段"); assert_eq!(p1["content"][0]["marks"][0]["type"], "bold"); } #[test] fn test_build_page_content_preserves_single_inline_node_marks_when_text_changes() { let original_content = json!([ { "id": "p_1", "type": "paragraph", "content": [ {"type": "text", "text": "第一段", "marks": [{"type": "bold"}]} ] } ]); let parsed = vec![ParsedBlockInfo { block_id: Some("p_1".into()), block_type: "paragraph".into(), text: "第一段已改".into(), block_revision_ref: None, is_new: false, original: None, props: None, is_complex: false, }]; let next = build_page_content(&original_content, &parsed); let p1 = &next.as_array().expect("array")[0]; assert_eq!(p1["content"][0]["text"], "第一段已改"); assert_eq!(p1["content"][0]["marks"][0]["type"], "bold"); } } // ── 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 source_kind = input.effective_source_kind(); let root_uri = input.effective_root_uri(); let is_local_file = document_id.starts_with('/') || document_id.starts_with("./"); let is_local_workspace = source_kind.as_deref() == Some("local_folder") && root_uri.as_deref().is_some(); crate::hermes_tools::block::ensure_write_contract(context, input)?; // 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), if is_local_workspace { "local_folder" } else { "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 })); } } } if applied == 0 { return Err(WebError::bad_request_code( "mnote_markdown_edit_no_operations_applied", "markdown_edit 没有任何 search/replace 操作命中,未执行写入", ) .with_context(context)); } // 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 { if input.dry_run.unwrap_or(false) { json!({"written": false, "dryRun": true, "path": document_id.clone()}) } else { 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 { // 7-27: 在线写回以最终 markdown 为真源,直接生成 block content // 与 /api/documents/save 共用同一个 RuntimeCommandEnvelopeWire 路径 let (aggregate, blocks, original_content) = match aggregate_value(state, context, input).await { Ok(agg) => { let blocks = block_projection_blocks(&agg); let original_content = crate::hermes_tools::block::current_body_content(&agg); (agg, blocks, original_content) } Err(_) if use_full_content.is_some() => { // 空文档 + full_content:跳过读取 (Value::Null, vec![], json!([])) } Err(e) => return Err(e), }; let parsed = parse_final_markdown_to_blocks(&md, &blocks); let next_content = build_page_content(&original_content, &parsed); let changed_blocks = build_changed_blocks_summary(&blocks, &parsed); ensure_markdown_changed_blocks_within_allowed( context, &changed_blocks, &selected_block_ids(input), )?; if input.dry_run == Some(true) { // dryRun:返回 diff 预览,不真实写入 json!({ "written": false, "dryRun": true, "documentId": document_id, "diff": changed_blocks }) } else { // 直接构造 RuntimeCommandEnvelopeWire(与 /api/documents/save 相同) let command_id = format!("markdown_edit_{}", context.trace.request_id); let file_version = file_version_from_aggregate(&aggregate); let conflict_detection_key = conflict_detection_key_from_aggregate(&aggregate); if is_local_workspace { let root_uri = root_uri.as_deref().ok_or_else(|| { WebError::bad_request_code( "local_folder_root_required", "缺少本地文件夹 rootUri", ) .with_context(context) })?; crate::routes::ensure_local_workspace_access(context, root_uri) .map_err(|error| error.with_context(context))?; let expected_file_version = file_version .as_str() .or(conflict_detection_key) .map(|value| value.to_string()); let result = crate::routes::write_local_markdown_page_body( &core_protocol::PageBodyWriteRequest { document_id: document_id.clone(), workspace_id: workspace_id.clone().unwrap_or_default(), source_kind: core_protocol::WorkspaceSourceKind::LocalFolder, root_uri: root_uri.to_string(), expected_file_version, base_content_hash: None, content_format: "editorBlocks".into(), content: next_content, editor_source: Some("mnote.doc.markdown_edit".into()), }, Some(&state.buffer_store), )?; return Ok(json!({ "ok": true, "schema": "mnote.doc.markdown_edit.v1", "source": "local_folder", "documentId": document_id, "workspaceId": workspace_id, "operationsApplied": applied, "operationsFailed": failed.len(), "failedOperations": failed, "changedText": changed_text, "fileVersion": file_version, "applyResult": { "commandName": "page.body.write", "commandId": command_id, "changedBlocks": changed_blocks, "result": result } })); } let payload = json!({ "documentId": document_id, "workspaceId": workspace_id, "content": next_content, "mode": "replace", "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null), "conflictDetectionKey": conflict_detection_key.map(Value::from).unwrap_or(Value::Null), "fileVersion": file_version, }); use bridge_runtime::{ RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, }; 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: "mnote-hermes".into(), client: "mnote-hermes-plugin".into(), source_kind, root_uri, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: workspace_id.clone(), page_id: Some(document_id.clone()), block_id: None, }), payload, preflight_data: None, reason: Some("mnote.doc.markdown_edit (7-27)".into()), refs: vec!["page.body.save".into(), "mnote-hermes-tool-call".into()], dry_run: false, validate_only: false, }; let execution = crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts( state, context, workspace_id.as_deref(), command, ) .await?; json!({ "commandName": "page.body.save", "commandId": command_id, "changedBlocks": changed_blocks, "result": execution.result, "artifacts": execution.artifacts, "artifactError": execution.artifact_error }) } }; 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 { if let Some(replaced) = search_replace_exact_or_normalized(text, search, replace) { return Ok(replaced); } // 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 search_replace_exact_or_normalized(text: &str, search: &str, replace: &str) -> Option { // Level 1: 精确匹配 if text.contains(search) { return Some(text.replacen(search, replace, 1)); } // Level 2: 忽略空白和全角/半角差异,同时保留原文 byte 边界。 let norm_search = normalize_for_search(search); if norm_search.is_empty() { return None; } for line in text.lines() { let (norm_line, byte_map) = normalize_line_with_byte_map(line); let Some(start_byte_in_norm) = norm_line.find(&norm_search) else { continue; }; let start = norm_line[..start_byte_in_norm].chars().count(); let end = start + norm_search.chars().count(); let start_byte = byte_map.get(start).copied().unwrap_or(0); let end_byte = byte_map.get(end).copied().unwrap_or(line.len()); let replaced = format!("{}{}{}", &line[..start_byte], replace, &line[end_byte..]); return Some(text.replacen(line, &replaced, 1)); } None } fn normalize_for_search(value: &str) -> String { value.chars().filter_map(normalize_search_char).collect() } fn normalize_line_with_byte_map(value: &str) -> (String, Vec) { let mut normalized = String::new(); let mut byte_map = Vec::new(); for (byte_index, ch) in value.char_indices() { if let Some(next) = normalize_search_char(ch) { normalized.push(next); byte_map.push(byte_index); } } (normalized, byte_map) } fn normalize_search_char(ch: char) -> Option { if ch.is_whitespace() || ch == '\u{3000}' { return None; } Some(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), _ => ch, }) } 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 } // 退役:7-27 改为 final_md → blocks → execute_page_body_save_from_aggregate 直接写回