Files
mnote/rust/crates/mnote-web/src/hermes_tools/resource.rs
T
lix-2026 1882db7681 收口 MNote P0 P1 P2 审查尾项
- 归档 OnlyOffice live bridge、Page AI、mindmap、design governance 与相关 bug 条目
- 补齐 MinerU OCR 后端 runtime 合同与 smoke/test 基线
- 收口 ChatOnly/Doubao、ObjectIdentity、Page Aggregate compat 与 runtime owner 文档口径

验证:
- cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1
- cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1
- git diff --check
- git diff --cached --check
- codegraph index . --force && codegraph status .
- codegraph sync . && codegraph status .
2026-06-01 09:29:12 +08:00

1071 lines
36 KiB
Rust

use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use axum::http::StatusCode;
use serde_json::{json, Value};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
pub async fn mindmap_fetch(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let target = ResourceToolTarget::from_input(context, input, "mindmap", "mindmapId")?;
ensure_resource_scope_allowed(context, input, &target)?;
let path = target.resolve_read_path(context, input)?;
let content = fs::read_to_string(&path).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_read_failed",
format!("无法读取 mindmap resource: {error}"),
)
.with_context(context)
})?;
let data =
serde_json::from_str::<Value>(&content).unwrap_or_else(|_| json!({ "raw": content }));
let scope = input
.arg_string("scope")
.unwrap_or_else(|| "tree".into())
.to_ascii_lowercase();
let root = mindmap_root_value(&data).clone();
let nodes = collect_mindmap_nodes(&root);
let envelope = if scope == "full_envelope" {
data
} else {
Value::Null
};
Ok(json!({
"objectIdentity": target.object_identity,
"resourceKind": "mindmap",
"documentId": target.document_id,
"mindmapId": target.resource_id,
"resourcePath": target.resource_path,
"scope": scope,
"root": root,
"envelope": envelope,
"nodes": nodes,
"edges": [],
"markdownSummary": mindmap_markdown_summary(&nodes),
"revision": file_revision(&path),
"source": "local_folder"
}))
}
pub async fn mindmap_apply_ops(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
ensure_resource_write_contract(context, input)?;
let target = ResourceToolTarget::from_input(context, input, "mindmap", "mindmapId")?;
ensure_resource_scope_allowed(context, input, &target)?;
let path = target.resolve_write_path(context, input)?;
let ops = input.arg_value("ops").ok_or_else(|| {
WebError::bad_request_code("mnote_resource_ops_required", "mindmap 写工具缺少 ops")
.with_context(context)
})?;
if input.dry_run.unwrap_or(false) {
return Ok(json!({
"dryRun": true,
"commandName": "mnote.mindmap.apply_ops",
"objectIdentity": target.object_identity,
"resourceKind": "mindmap",
"documentId": target.document_id,
"mindmapId": target.resource_id,
"resourcePath": target.resource_path,
"diff": [{"op": "mindmap.apply_ops", "ops": ops}]
}));
}
ensure_mindmap_revision_precondition(context, input, &path)?;
let content = fs::read_to_string(&path).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_read_failed",
format!("无法读取 mindmap resource: {error}"),
)
.with_context(context)
})?;
let mut envelope = serde_json::from_str::<Value>(&content).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_json_invalid",
format!("mindmap resource 不是有效 JSON: {error}"),
)
.with_context(context)
})?;
apply_mindmap_ops(context, &mut envelope, &ops)?;
let serialized = serde_json::to_string_pretty(&envelope).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_serialize_failed",
format!("无法序列化 mindmap JSON: {error}"),
)
.with_context(context)
})?;
fs::write(&path, serialized).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_write_failed",
format!("无法写入 mindmap resource: {error}"),
)
.with_context(context)
})?;
let root = mindmap_root_value(&envelope).clone();
let nodes = collect_mindmap_nodes(&root);
Ok(json!({
"dryRun": false,
"commandName": "mnote.mindmap.apply_ops",
"objectIdentity": target.object_identity,
"resourceKind": "mindmap",
"documentId": target.document_id,
"mindmapId": target.resource_id,
"resourcePath": target.resource_path,
"root": root,
"nodes": nodes,
"edges": [],
"markdownSummary": mindmap_markdown_summary(&nodes),
"revision": file_revision(&path),
"changedFiles": [target.relative_path()],
"source": "local_folder"
}))
}
pub async fn mindmap_create_from_outline(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
ensure_resource_write_contract(context, input)?;
let target = ResourceToolTarget::from_input(context, input, "mindmap", "mindmapId")?;
ensure_resource_scope_allowed(context, input, &target)?;
let path = target.resolve_create_path(context, input)?;
let title = input
.arg_string("title")
.unwrap_or_else(|| "KMIND".to_string());
let outline = input.arg_value("outline").ok_or_else(|| {
WebError::bad_request_code("mnote_mindmap_outline_required", "创建思维导图缺少 outline")
.with_context(context)
})?;
let source_refs = input.arg_value("sourceRefs").unwrap_or_else(|| json!([]));
let envelope = mindmap_envelope_from_outline(&title, &outline, source_refs, context)?;
let root = mindmap_root_value(&envelope).clone();
let nodes = collect_mindmap_nodes(&root);
let resource_relative_path = target.relative_path();
let mut changed_files = if input.dry_run.unwrap_or(false) {
Vec::new()
} else {
vec![resource_relative_path.clone()]
};
let mut embed_result = Value::Null;
if !input.dry_run.unwrap_or(false) {
let parent = path.parent().ok_or_else(|| {
WebError::bad_request_code("mnote_resource_bad_path", "无法解析 mindmap 父目录")
.with_context(context)
})?;
fs::create_dir_all(parent).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_create_dir_failed",
format!("无法创建 mindmap 目录: {error}"),
)
.with_context(context)
})?;
let content = serde_json::to_string_pretty(&envelope).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_serialize_failed",
format!("无法序列化 mindmap JSON: {error}"),
)
.with_context(context)
})?;
fs::write(&path, content).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_write_failed",
format!("无法写入 mindmap resource: {error}"),
)
.with_context(context)
})?;
if input
.arg_value("embedIntoPage")
.and_then(|value| value.as_bool())
.unwrap_or(true)
{
embed_result = embed_mindmap_into_local_markdown_page(
context,
input,
&target.document_id,
&resource_relative_path,
&title,
)?;
if let Some(changed_file) = embed_result
.get("changedFile")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
changed_files.push(changed_file.to_string());
}
}
}
Ok(json!({
"dryRun": input.dry_run.unwrap_or(false),
"commandName": "mnote.mindmap.create_from_outline",
"objectIdentity": target.object_identity,
"resourceKind": "mindmap",
"documentId": target.document_id,
"mindmapId": target.resource_id,
"resourcePath": target.resource_path,
"envelope": envelope,
"root": root,
"nodes": nodes,
"edges": [],
"markdownSummary": mindmap_markdown_summary(&nodes),
"revision": if input.dry_run.unwrap_or(false) { Value::Null } else { file_revision(&path) },
"changedFiles": changed_files,
"embedResult": embed_result,
"source": "local_folder"
}))
}
pub async fn office_fetch_summary(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let target = ResourceToolTarget::from_input(context, input, "onlyoffice", "assetId")?;
ensure_resource_scope_allowed(context, input, &target)?;
let path = target.resolve_read_path(context, input)?;
let metadata = fs::metadata(&path).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_metadata_failed",
format!("无法读取 Office resource 元数据: {error}"),
)
.with_context(context)
})?;
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(target.resource_id.as_str());
Ok(json!({
"objectIdentity": target.object_identity,
"resourceKind": "only_office",
"documentId": target.document_id,
"assetId": target.resource_id,
"resourcePath": target.resource_path,
"fileName": file_name,
"mimeType": office_mime_type(&path),
"textSummary": office_text_preview(&path),
"outline": [],
"revision": file_revision(&path),
"size": metadata.len(),
"source": "local_folder"
}))
}
pub async fn office_propose_changes(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let target = ResourceToolTarget::from_input(context, input, "onlyoffice", "assetId")?;
ensure_resource_scope_allowed(context, input, &target)?;
let path = target.resolve_write_path(context, input)?;
let instructions = input.arg_string("instructions").ok_or_else(|| {
WebError::bad_request_code(
"mnote_resource_instructions_required",
"office 建议工具缺少 instructions",
)
.with_context(context)
})?;
Ok(json!({
"dryRun": input.dry_run.unwrap_or(false),
"commandName": "mnote.office.propose_changes",
"objectIdentity": target.object_identity,
"resourceKind": "only_office",
"documentId": target.document_id,
"assetId": target.resource_id,
"resourcePath": target.resource_path,
"basisRevision": input.arg_value("basisRevision"),
"revision": file_revision(&path),
"changeSummary": instructions,
"suggestedEdits": [{
"kind": "instruction",
"text": instructions
}],
"requiresOnlyOffice": true,
"requiresOfficeCli": true,
"writesBinary": false
}))
}
struct ResourceToolTarget {
document_id: String,
resource_id: String,
object_identity: String,
resource_path: Option<String>,
}
impl ResourceToolTarget {
fn from_input(
context: &RequestContext,
input: &ToolCallInput,
resource_kind: &str,
resource_id_arg: &str,
) -> Result<Self, WebError> {
let document_id = input.effective_document_id().ok_or_else(|| {
WebError::bad_request_code(
"mnote_resource_document_required",
"资源工具缺少 documentId",
)
.with_context(context)
})?;
let resource_id = input.arg_string(resource_id_arg).ok_or_else(|| {
WebError::bad_request_code(
"mnote_resource_id_required",
format!("资源工具缺少 {resource_id_arg}"),
)
.with_context(context)
})?;
let object_identity = format!("resource:{resource_kind}:{document_id}:{resource_id}");
Ok(Self {
document_id,
resource_id,
object_identity,
resource_path: input.arg_string("resourcePath"),
})
}
fn resolve_read_path(
&self,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<PathBuf, WebError> {
let root_uri = local_root_uri_for_resource(input).ok_or_else(|| {
WebError::new(
StatusCode::FORBIDDEN,
"mnote_resource_root_uri_required",
"资源工具需要授权 rootUri",
)
.with_context(context)
})?;
let relative = self.relative_path();
crate::routes::ensure_local_path_read_access(context, &root_uri, &relative)
.map_err(|error| error.with_context(context))
}
fn resolve_write_path(
&self,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<PathBuf, WebError> {
let root_uri = local_root_uri_for_resource(input).ok_or_else(|| {
WebError::new(
StatusCode::FORBIDDEN,
"mnote_resource_root_uri_required",
"资源工具需要授权 rootUri",
)
.with_context(context)
})?;
let root = crate::routes::ensure_local_workspace_access(context, &root_uri)
.map_err(|error| error.with_context(context))?;
let relative = self.relative_path();
let path = root.join(relative);
let canonical = path.canonicalize().map_err(|error| {
WebError::bad_request_code(
"mnote_resource_unavailable",
format!("无法访问资源文件: {error}"),
)
.with_context(context)
})?;
if !canonical.starts_with(root) {
return Err(WebError::bad_request_code(
"mnote_resource_root_escape",
"资源工具不能越过授权目录",
)
.with_context(context));
}
Ok(canonical)
}
fn resolve_create_path(
&self,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<PathBuf, WebError> {
let root_uri = local_root_uri_for_resource(input).ok_or_else(|| {
WebError::new(
StatusCode::FORBIDDEN,
"mnote_resource_root_uri_required",
"资源工具需要授权 rootUri",
)
.with_context(context)
})?;
let root = crate::routes::ensure_local_workspace_access(context, &root_uri)
.map_err(|error| error.with_context(context))?;
let relative = self.relative_path();
let relative_path = Path::new(&relative);
if relative_path.is_absolute()
|| relative_path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err(WebError::bad_request_code(
"mnote_resource_root_escape",
"资源工具不能越过授权目录",
)
.with_context(context));
}
let path = root.join(relative_path);
if !path.starts_with(&root) {
return Err(WebError::bad_request_code(
"mnote_resource_root_escape",
"资源工具不能越过授权目录",
)
.with_context(context));
}
Ok(path)
}
fn relative_path(&self) -> String {
self.resource_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| self.resource_id.clone())
}
}
fn ensure_resource_scope_allowed(
context: &RequestContext,
input: &ToolCallInput,
target: &ResourceToolTarget,
) -> 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())
.map(ToOwned::to_owned)
.collect::<HashSet<_>>()
})
.unwrap_or_default();
if allowed.is_empty()
|| allowed.contains(&target.resource_id)
|| allowed.contains(&target.object_identity)
{
return Ok(());
}
Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_resource_ai_scope_forbidden",
"当前 AI scope 不允许访问该资源",
)
.with_context(context))
}
fn ensure_resource_write_contract(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<(), WebError> {
crate::hermes_tools::ensure_write_authorized(context, input)
}
fn local_root_uri_for_resource(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)
})
})
})
}
fn embed_mindmap_into_local_markdown_page(
context: &RequestContext,
input: &ToolCallInput,
document_id: &str,
resource_relative_path: &str,
title: &str,
) -> Result<Value, WebError> {
let root_uri = local_root_uri_for_resource(input).ok_or_else(|| {
WebError::new(
StatusCode::FORBIDDEN,
"mnote_resource_root_uri_required",
"资源工具需要授权 rootUri",
)
.with_context(context)
})?;
let root = crate::routes::ensure_local_workspace_access(context, &root_uri)
.map_err(|error| error.with_context(context))?;
let page_relative_path = local_markdown_relative_path_from_document_id(context, document_id)?;
let page_path = root.join(&page_relative_path);
if !page_path.starts_with(&root) {
return Err(WebError::bad_request_code(
"mnote_resource_root_escape",
"资源工具不能越过授权目录",
)
.with_context(context));
}
if !page_path.is_file() {
return Err(WebError::bad_request_code(
"mnote_resource_page_not_found",
"找不到要绑定 mindmap 的本地 Markdown 页面",
)
.with_context(context));
}
let href = markdown_href_from_page(&root, &page_path, resource_relative_path);
let mut markdown = fs::read_to_string(&page_path).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_page_read_failed",
format!("无法读取本地 Markdown 页面: {error}"),
)
.with_context(context)
})?;
let link = format!("[{}]({href})", markdown_link_label(title));
if markdown.contains(&link) || markdown.contains(&format!("]({href})")) {
return Ok(json!({
"status": "already_present",
"documentId": document_id,
"changedFile": page_relative_path,
"href": href
}));
}
if !markdown.ends_with('\n') {
markdown.push('\n');
}
if !markdown.ends_with("\n\n") {
markdown.push('\n');
}
markdown.push_str(&link);
markdown.push('\n');
fs::write(&page_path, markdown).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_page_write_failed",
format!("无法写入本地 Markdown 页面: {error}"),
)
.with_context(context)
})?;
Ok(json!({
"status": "embedded",
"documentId": document_id,
"changedFile": page_relative_path,
"href": href
}))
}
fn local_markdown_relative_path_from_document_id(
context: &RequestContext,
document_id: &str,
) -> Result<String, WebError> {
let encoded = document_id
.trim()
.strip_prefix("local-md:")
.ok_or_else(|| {
WebError::bad_request_code(
"mnote_resource_local_markdown_required",
"绑定 mindmap 需要 local-md 页面",
)
.with_context(context)
})?;
let relative = crate::routes::decode_local_id_segment(encoded)
.map_err(|error| error.with_context(context))?;
let path = Path::new(&relative);
if path.is_absolute()
|| path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err(WebError::bad_request_code(
"mnote_resource_root_escape",
"资源工具不能越过授权目录",
)
.with_context(context));
}
Ok(relative)
}
fn markdown_href_from_page(root: &Path, page_path: &Path, resource_relative_path: &str) -> String {
let page_dir = page_path.parent().unwrap_or(root);
let target = root.join(resource_relative_path);
if let Ok(relative) = target.strip_prefix(page_dir) {
return path_to_markdown_href(relative);
}
let page_dir_relative = page_dir.strip_prefix(root).unwrap_or(Path::new(""));
let depth = page_dir_relative
.components()
.filter(|component| matches!(component, std::path::Component::Normal(_)))
.count();
let mut href = String::new();
for _ in 0..depth {
href.push_str("../");
}
href.push_str(&resource_relative_path.replace('\\', "/"));
href
}
fn path_to_markdown_href(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn markdown_link_label(value: &str) -> String {
value
.trim()
.replace('\\', "\\\\")
.replace('[', "\\[")
.replace(']', "\\]")
.replace('\n', " ")
}
fn default_mindmap_view() -> Value {
json!({
"state": {
"scale": 1,
"sx": 0,
"sy": 0,
"x": -44.99991989135742_f64,
"y": -15.500006675720217_f64
},
"transform": {
"a": 1,
"b": 0,
"c": 0,
"d": 1,
"e": -44.99991989135742_f64,
"f": -15.500006675720217_f64,
"originX": 0,
"originY": 0,
"rotate": 0,
"scaleX": 1,
"scaleY": 1,
"shear": 0,
"translateX": -44.99991989135742_f64,
"translateY": -15.500006675720217_f64
}
})
}
fn mindmap_envelope_from_outline(
title: &str,
outline: &Value,
source_refs: Value,
context: &RequestContext,
) -> Result<Value, WebError> {
let outline_items = outline.as_array().ok_or_else(|| {
WebError::bad_request_code(
"mnote_mindmap_outline_invalid",
"mindmap outline 必须是数组",
)
.with_context(context)
})?;
let title = title.trim();
let root_title = if title.is_empty() { "KMIND" } else { title };
Ok(json!({
"data": {
"children": mindmap_outline_items_to_children(outline_items, "node"),
"data": {
"expand": true,
"isActive": false,
"text": root_title,
"uid": "root"
}
},
"view": default_mindmap_view(),
"metadata": {
"sourceRefs": source_refs
}
}))
}
fn mindmap_outline_items_to_children(items: &[Value], prefix: &str) -> Vec<Value> {
items
.iter()
.enumerate()
.map(|(index, item)| {
let ordinal = index + 1;
let uid = format!("{prefix}_{ordinal}");
let text = item
.get("text")
.or_else(|| item.get("title"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("未命名节点");
let children = item
.get("children")
.and_then(Value::as_array)
.map(|children| mindmap_outline_items_to_children(children, &uid))
.unwrap_or_default();
json!({
"data": {
"expand": true,
"isActive": false,
"text": text,
"uid": uid
},
"children": children
})
})
.collect()
}
fn mindmap_root_value(value: &Value) -> &Value {
value
.get("data")
.filter(|data| data.get("data").is_some() || data.get("children").is_some())
.unwrap_or(value)
}
fn mindmap_root_value_mut(value: &mut Value) -> Option<&mut Value> {
let has_envelope_root = value
.get("data")
.map(|data| data.get("data").is_some() || data.get("children").is_some())
.unwrap_or(false);
if has_envelope_root {
return value.get_mut("data");
}
Some(value)
}
fn apply_mindmap_ops(
context: &RequestContext,
envelope: &mut Value,
ops: &Value,
) -> Result<(), WebError> {
let ops = ops.as_array().ok_or_else(|| {
WebError::bad_request_code("mnote_resource_ops_invalid", "mindmap ops 必须是数组")
.with_context(context)
})?;
let root = mindmap_root_value_mut(envelope).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_json_invalid", "mindmap 缺少 root")
.with_context(context)
})?;
for op in ops {
apply_mindmap_op(context, root, op)?;
}
Ok(())
}
fn apply_mindmap_op(
context: &RequestContext,
root: &mut Value,
op: &Value,
) -> Result<(), WebError> {
let op_name = op
.get("op")
.or_else(|| op.get("type"))
.or_else(|| op.get("action"))
.and_then(Value::as_str)
.map(normalize_mindmap_op_name)
.ok_or_else(|| {
WebError::bad_request_code("mnote_resource_op_required", "mindmap op 缺少 op")
.with_context(context)
})?;
match op_name.as_str() {
"updatetext" | "updatenode" => {
let node_id = mindmap_op_string(op, &["nodeId", "node_id", "id"]).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_node_required", "更新节点缺少 nodeId")
.with_context(context)
})?;
let text = mindmap_op_string(op, &["text", "title"]).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_text_required", "更新节点缺少 text")
.with_context(context)
})?;
let node = find_mindmap_node_mut(root, &node_id).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_node_not_found", "找不到要更新的节点")
.with_context(context)
})?;
set_mindmap_node_text(node, &text);
}
"insertchild" | "addchild" => {
let parent_id = mindmap_op_string(op, &["parentId", "parent_id", "nodeId"])
.ok_or_else(|| {
WebError::bad_request_code(
"mnote_resource_parent_required",
"插入子节点缺少 parentId",
)
.with_context(context)
})?;
let parent = find_mindmap_node_mut(root, &parent_id).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_node_not_found", "找不到父节点")
.with_context(context)
})?;
let child_input = op.get("node").unwrap_or(op);
let child = mindmap_node_from_input(context, child_input)?;
ensure_mindmap_children_array(context, parent)?.push(child);
}
"deletenode" => {
let node_id = mindmap_op_string(op, &["nodeId", "node_id", "id"]).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_node_required", "删除节点缺少 nodeId")
.with_context(context)
})?;
if mindmap_node_matches(root, &node_id) {
return Err(WebError::bad_request_code(
"mnote_resource_root_delete_forbidden",
"不能删除 mindmap 根节点",
)
.with_context(context));
}
remove_mindmap_node(root, &node_id).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_node_not_found", "找不到要删除的节点")
.with_context(context)
})?;
}
_ => {
return Err(WebError::bad_request_code(
"mnote_resource_op_unsupported",
format!("暂不支持 mindmap op: {op_name}"),
)
.with_context(context));
}
}
Ok(())
}
fn normalize_mindmap_op_name(value: &str) -> String {
value
.chars()
.filter(|ch| *ch != '_' && *ch != '-' && !ch.is_whitespace())
.flat_map(char::to_lowercase)
.collect()
}
fn mindmap_op_string(op: &Value, keys: &[&str]) -> Option<String> {
keys.iter().find_map(|key| {
op.get(*key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn find_mindmap_node_mut<'a>(node: &'a mut Value, node_id: &str) -> Option<&'a mut Value> {
if mindmap_node_matches(node, node_id) {
return Some(node);
}
let children = node.get_mut("children").and_then(Value::as_array_mut)?;
for child in children {
if let Some(found) = find_mindmap_node_mut(child, node_id) {
return Some(found);
}
}
None
}
fn mindmap_node_matches(node: &Value, node_id: &str) -> bool {
mindmap_node_id(node)
.as_deref()
.map(|value| value == node_id)
.unwrap_or(false)
}
fn mindmap_node_id(node: &Value) -> Option<String> {
node.pointer("/data/uid")
.or_else(|| node.pointer("/data/id"))
.or_else(|| node.get("uid"))
.or_else(|| node.get("id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn set_mindmap_node_text(node: &mut Value, text: &str) {
if let Some(data) = node.get_mut("data").and_then(Value::as_object_mut) {
data.insert("text".into(), json!(text));
return;
}
if let Some(object) = node.as_object_mut() {
object.insert("text".into(), json!(text));
}
}
fn ensure_mindmap_children_array<'a>(
context: &RequestContext,
node: &'a mut Value,
) -> Result<&'a mut Vec<Value>, WebError> {
let object = node.as_object_mut().ok_or_else(|| {
WebError::bad_request_code("mnote_resource_node_invalid", "mindmap 节点必须是对象")
.with_context(context)
})?;
let children = object
.entry("children")
.or_insert_with(|| Value::Array(Vec::new()));
if !children.is_array() {
*children = Value::Array(Vec::new());
}
Ok(children.as_array_mut().expect("children 已归一为数组"))
}
fn mindmap_node_from_input(context: &RequestContext, input: &Value) -> Result<Value, WebError> {
if input.get("data").is_some() || input.get("children").is_some() {
let mut node = input.clone();
ensure_mindmap_children_array(context, &mut node)?;
return Ok(node);
}
let text = input
.get("text")
.or_else(|| input.get("title"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("未命名节点");
let uid = input
.get("uid")
.or_else(|| input.get("id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(text);
let mut node = json!({
"data": {
"expand": true,
"isActive": false,
"text": text,
"uid": uid
},
"children": []
});
if let Some(object) = node.as_object_mut() {
for key in ["metadata", "sourceRefs", "refs", "note", "hyperlink"] {
if let Some(value) = input.get(key) {
object.insert(key.into(), value.clone());
}
}
}
Ok(node)
}
fn remove_mindmap_node(parent: &mut Value, node_id: &str) -> Option<Value> {
let children = parent.get_mut("children").and_then(Value::as_array_mut)?;
if let Some(index) = children
.iter()
.position(|child| mindmap_node_matches(child, node_id))
{
return Some(children.remove(index));
}
for child in children {
if let Some(removed) = remove_mindmap_node(child, node_id) {
return Some(removed);
}
}
None
}
fn ensure_mindmap_revision_precondition(
context: &RequestContext,
input: &ToolCallInput,
path: &Path,
) -> Result<(), WebError> {
let Some(expected) = input.arg_value("expectedRevision") else {
return Ok(());
};
let current = file_revision(path);
if revision_label(&expected).as_deref() != revision_label(&current).as_deref() {
return Err(WebError::bad_request_code(
"mnote_resource_revision_conflict",
"mindmap resource revision 已变化,请重新读取后再写入",
)
.with_context(context));
}
Ok(())
}
fn revision_label(value: &Value) -> Option<String> {
match value {
Value::String(value) => Some(value.trim().to_string()).filter(|value| !value.is_empty()),
Value::Number(value) => Some(value.to_string()),
_ => None,
}
}
fn collect_mindmap_nodes(value: &Value) -> Vec<Value> {
let mut nodes = Vec::new();
collect_mindmap_nodes_inner(mindmap_root_value(value), &mut nodes);
nodes
}
fn collect_mindmap_nodes_inner(value: &Value, nodes: &mut Vec<Value>) {
let text = value
.pointer("/data/text")
.or_else(|| value.get("text"))
.and_then(Value::as_str)
.unwrap_or("");
let id = value
.pointer("/data/uid")
.or_else(|| value.pointer("/data/id"))
.or_else(|| value.get("uid"))
.or_else(|| value.get("id"))
.and_then(Value::as_str)
.unwrap_or("root");
if !text.is_empty() {
nodes.push(json!({ "id": id, "text": text }));
}
if let Some(children) = value.get("children").and_then(Value::as_array) {
for child in children {
collect_mindmap_nodes_inner(child, nodes);
}
}
}
fn mindmap_markdown_summary(nodes: &[Value]) -> String {
nodes
.iter()
.filter_map(|node| node.get("text").and_then(Value::as_str))
.take(20)
.map(|text| format!("- {text}"))
.collect::<Vec<_>>()
.join("\n")
}
fn file_revision(path: &Path) -> Value {
let Ok(metadata) = fs::metadata(path) else {
return Value::Null;
};
let modified_ms = metadata
.modified()
.ok()
.and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
.map(|duration| duration.as_millis())
.unwrap_or_default();
json!(format!("{}:{}", metadata.len(), modified_ms))
}
fn office_mime_type(path: &Path) -> &'static str {
match path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
{
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
_ => "application/octet-stream",
}
}
fn office_text_preview(path: &Path) -> Value {
match fs::read_to_string(path) {
Ok(text) => json!(text.chars().take(4000).collect::<String>()),
Err(_) => Value::Null,
}
}