Files
mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs
T
lix-2026 b4c8bcb647 收口本地工作区清理与资源投影
清理历史 Electron、Graphify、沙箱和截图等仓库跟踪残留,补充 CodeGraph 与 Convex active deploy source 协作说明。

新增 tree-first 下一阶段设计稿和 2026-05-20 清理总结,记录本地工作区、路径身份和 Zed/Lapce/VSCode 参考收口方向。

扩展 Rust Web 本地文件夹、DocumentBuffer、mindmap 资源、tree runtime 和页面聚合链路,并补充 task455 local-folder mindmap clean smoke。

验证:git diff --check 通过;pnpm store status --store-dir .pnpm-store 通过;npm ls --depth=0 --json 通过;find -L node_modules 未发现断链。cargo test -p mnote-web 当前 418 passed / 35 failed。
2026-05-20 10:43:38 +08:00

1912 lines
68 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 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<Value, WebError> {
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::<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,
"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!("<!-- 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),
"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<Value, WebError> {
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<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),
"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<Value, WebError> {
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::<HashSet<_>>()
})
.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<String> {
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<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")
.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<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()
}
// ── 7-27: Markdown → Block Content 直接写回 ────────────────────────────
/// 解析后的单个块信息,用作 final_md → page.body.save 的中间表示
#[derive(Debug, Clone)]
#[allow(dead_code)]
struct ParsedBlockInfo {
/// 原始 block_idNone 表示新块(full_content 或无注释的新 paragraph
block_id: Option<String>,
block_type: String,
text: String,
block_revision_ref: Option<String>,
/// 标记为新建块——写入层分配新 ID
is_new: bool,
/// 从原始 block projection 中复制的完整块对象
original: Option<Value>,
/// 原始块的 props(如 heading level
props: Option<Value>,
/// 复杂块(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<ParsedBlockInfo> {
let originals_by_id: std::collections::HashMap<String, &Value> = 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;
}
// 尝试提取 <!-- block:id:type:rev[:level=N] --> 注释
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
}
/// 从行中提取 `<!-- block:id:type[:level=N] -->` 注释及其前的文本
fn extract_block_comment(line: &str) -> (Option<String>, Option<(String, String, Option<u64>)>) {
let comment_start = line.rfind("<!-- block:");
let comment_end = line.rfind("-->");
if let (Some(start), Some(end)) = (comment_start, comment_end) {
let before = line[..start].trim();
// 完整注释区间: "<!-- block:... -->" (3 chars for "-->")
let comment = &line[start..end + 3];
let inner = comment
.strip_prefix("<!--")
.and_then(|s| s.strip_suffix("-->"))
.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::<u64>().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)));
}
// 向后兼容旧格式: <!-- block:ID -->
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<String> {
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<Value> = original_content.as_array().cloned().unwrap_or_default();
let _original_index_by_id: std::collections::HashMap<String, usize> = original_blocks
.iter()
.enumerate()
.filter_map(|(i, block)| block_id_of(block).map(|id| (id, i)))
.collect();
let original_by_id: std::collections::HashMap<String, &Value> = 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::<Vec<_>>()
.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<Value> {
let original_by_id: std::collections::HashMap<String, &Value> = original_blocks
.iter()
.filter_map(|block| block_id_of(block).map(|id| (id, block)))
.collect();
let parsed_by_id: std::collections::HashMap<String, &ParsedBlockInfo> = 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<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 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}] <!-- block:{id}:{block_type} -->")
} else if block_type == "heading" {
let level = block
.pointer("/props/level")
.and_then(Value::as_u64)
.unwrap_or(2);
format!("{prefix}{text} <!-- block:{id}:{block_type}:level={level} -->")
} else {
format!("{prefix}{text} <!-- block:{id}:{block_type} -->")
}
} 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_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<Value> = blocks.as_array().unwrap().clone();
let md = blocks_to_markdown(&blocks, true);
assert!(md.contains("第一段 <!-- block:b1:paragraph -->"));
assert!(md.contains("第二段 <!-- block:b2:paragraph -->"));
}
#[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<Value> = blocks.as_array().unwrap().clone();
let md = blocks_to_markdown(&blocks, true);
assert!(md.contains("标题 <!-- block:h1:heading:level=2 -->"));
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<Value> = blocks.as_array().unwrap().clone();
let md = blocks_to_markdown(&blocks, true);
assert!(md.contains("[mnote-raw-block:rsc_1]"));
assert!(md.contains("<!-- block:rsc_1:resource -->"));
}
#[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<Value, WebError> {
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<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
}));
}
}
}
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::<Vec<_>>()
.join("\n")
} else {
String::from("无操作已应用")
};
// 5. 写回(本地文件直接 fs::writeConvex 文档通过 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<String, String> {
if let Some(replaced) = search_replace_exact_or_normalized(text, search, replace) {
return Ok(replaced);
}
// 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 search_replace_exact_or_normalized(text: &str, search: &str, replace: &str) -> Option<String> {
// 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<usize>) {
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<char> {
if ch.is_whitespace() || ch == '\u{3000}' {
return None;
}
Some(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),
_ => ch,
})
}
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
}
// 退役:7-27 改为 final_md → blocks → execute_page_body_save_from_aggregate 直接写回