推进资源工具与本地优先兼容链收口

This commit is contained in:
lix-2026
2026-05-19 12:03:43 +08:00
parent 94be1c927b
commit 6fcdf78603
13 changed files with 1192 additions and 30 deletions
@@ -27,6 +27,10 @@ pub fn manifest() -> Value {
page_save_tool(),
available_tool("mnote.page.update_title", "更新当前页面标题", ["page.write"]),
available_tool("mnote.page.update_options", "更新当前页面设置", ["page.write"]),
mindmap_fetch_tool(),
mindmap_apply_ops_tool(),
office_fetch_summary_tool(),
office_propose_changes_tool(),
available_tool("mnote.artifact.create_summary", "为当前页面创建或更新 AI Summary", ["artifact.write"]),
available_tool("mnote.artifact.create_ai_note", "基于当前页面创建新的 AI Note", ["artifact.write"])
]
@@ -479,6 +483,120 @@ fn doc_markdown_edit_tool() -> Value {
tool
}
fn resource_identity_properties(resource_id_name: &str) -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("rootUri".into(), json!({ "type": "string" }));
map.insert("sourceKind".into(), json!({ "type": "string" }));
map.insert(resource_id_name.into(), json!({ "type": "string" }));
map.insert("resourcePath".into(), json!({ "type": "string" }));
map.insert(
"aiAccessScope".into(),
json!({
"type": "object",
"properties": {
"permissionLevel": { "type": "string" },
"allowedResourceIds": { "type": "array", "items": { "type": "string" } },
"allowedRoots": { "type": "array" }
}
}),
);
}
properties
}
fn mindmap_fetch_tool() -> Value {
let mut properties = resource_identity_properties("mindmapId");
if let Value::Object(map) = &mut properties {
map.insert(
"scope".into(),
json!({ "type": "string", "enum": ["tree", "subtree", "markdown_summary"], "default": "tree" }),
);
map.insert("nodeId".into(), json!({ "type": "string" }));
}
json!({
"name": "mnote.mindmap.fetch",
"description": "读取授权 root 内的 mindmap resource 结构或 markdown summary",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["resource.read", "mindmap.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "documentId", "mindmapId", "sessionId", "runId", "toolCallId", "traceId", "actorId"],
"properties": properties
}
})
}
fn mindmap_apply_ops_tool() -> Value {
write_tool(
"mnote.mindmap.apply_ops",
"对授权 mindmap resource 生成或执行结构操作;本地 workspace 默认建议 agent 直接编辑授权文件,必要时才走该工具",
["resource.write", "mindmap.write"],
json!({
"mindmapId": { "type": "string" },
"rootUri": { "type": "string" },
"sourceKind": { "type": "string" },
"resourcePath": { "type": "string" },
"expectedRevision": { "type": ["number", "string"] },
"ops": {
"type": "array",
"items": { "type": "object" }
},
"aiAccessScope": { "type": "object" }
}),
["mindmapId", "ops"],
)
}
fn office_fetch_summary_tool() -> Value {
let mut properties = resource_identity_properties("assetId");
if let Value::Object(map) = &mut properties {
map.insert(
"extractMode".into(),
json!({ "type": "string", "enum": ["text", "outline", "metadata"], "default": "metadata" }),
);
}
json!({
"name": "mnote.office.fetch_summary",
"description": "读取授权 Office resource 的元数据和轻量文本摘要;不直接写二进制文件",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["resource.read", "office.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "documentId", "assetId", "sessionId", "runId", "toolCallId", "traceId", "actorId"],
"properties": properties
}
})
}
fn office_propose_changes_tool() -> Value {
let mut properties = resource_identity_properties("assetId");
if let Value::Object(map) = &mut properties {
map.insert("instructions".into(), json!({ "type": "string" }));
map.insert(
"basisRevision".into(),
json!({ "type": ["number", "string"] }),
);
}
json!({
"name": "mnote.office.propose_changes",
"description": "基于授权 Office resource 生成修改建议;真实写入仍由 OnlyOffice / officecli 完成",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["resource.read", "office.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "documentId", "assetId", "sessionId", "runId", "toolCallId", "traceId", "actorId", "instructions"],
"properties": properties
}
})
}
fn tool_annotations(
readonly: bool,
destructive: bool,
@@ -3,6 +3,7 @@ pub mod block;
pub mod doc;
pub mod manifest;
pub mod page;
pub mod resource;
use serde::Deserialize;
use serde_json::Value;
@@ -0,0 +1,410 @@
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 nodes = collect_mindmap_nodes(&data);
let scope = input
.arg_string("scope")
.unwrap_or_else(|| "tree".into())
.to_ascii_lowercase();
Ok(json!({
"objectIdentity": target.object_identity,
"resourceKind": "mindmap",
"documentId": target.document_id,
"mindmapId": target.resource_id,
"resourcePath": target.resource_path,
"scope": scope,
"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}]
}));
}
Err(WebError::bad_request_code(
"mnote_resource_native_patch_required",
format!(
"本地 mindmap resource 写入请使用 agent 原生 patch 编辑授权文件;已校验可写资源路径 {}",
path.display()
),
)
.with_context(context))
}
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 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> {
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
"写入型 mnote resource tool 必须携带 idempotencyKey",
)
.with_context(context));
}
if input.dry_run.is_none() {
return Err(WebError::bad_request_code(
"mnote_tool_dry_run_required",
"写入型 mnote resource tool 必须显式携带 dryRun",
)
.with_context(context));
}
if input.ai_access_scope_is_read_only() {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_write_forbidden",
"当前 AI scope 是只读权限,禁止执行写入型 mnote resource tool",
)
.with_context(context));
}
Ok(())
}
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 collect_mindmap_nodes(value: &Value) -> Vec<Value> {
let mut nodes = Vec::new();
collect_mindmap_nodes_inner(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,
}
}
+13 -9
View File
@@ -726,8 +726,8 @@ pub async fn empty_trash(
);
}
let command = RuntimeCommandEnvelopeWire {
name: "documents.emptyTrashByWorkspace".into(),
command_id: format!("documents_empty_trash_{}", context.trace.request_id),
name: "tree.trash.emptyWorkspace".into(),
command_id: format!("tree_trash_empty_workspace_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
@@ -751,8 +751,11 @@ pub async fn empty_trash(
"workspaceId": workspace_id,
}),
preflight_data: None,
reason: Some("mnote-web documents empty trash".into()),
refs: vec!["mnote-web-documents-trash".into()],
reason: Some("mnote-web tree trash empty workspace".into()),
refs: vec![
"mnote-web-documents-trash-compat".into(),
"tree.trash.emptyWorkspace".into(),
],
dry_run: false,
validate_only: false,
};
@@ -769,7 +772,7 @@ pub async fn empty_trash(
if let Value::Object(map) = &mut result {
map.insert(
"canonicalCommand".into(),
json!("documents.emptyTrashByWorkspace"),
json!("tree.trash.emptyWorkspace"),
);
map.insert("compatRoute".into(), json!("/api/documents/empty-trash"));
}
@@ -784,8 +787,9 @@ pub async fn empty_trash(
"traceId": context.trace.trace_id,
"owner": "mnote-web",
"meta": {
"commandName": "documents.emptyTrashByWorkspace",
"canonicalCommand": "documents.emptyTrashByWorkspace",
"commandName": "tree.trash.emptyWorkspace",
"canonicalCommand": "tree.trash.emptyWorkspace",
"compatCommandName": "documents.emptyTrashByWorkspace",
"artifacts": artifacts,
"artifactError": artifact_error,
},
@@ -1242,11 +1246,11 @@ mod tests {
assert_eq!(payload["result"]["deletedCount"], 2);
assert_eq!(
payload["result"]["canonicalCommand"],
"documents.emptyTrashByWorkspace"
"tree.trash.emptyWorkspace"
);
assert_eq!(
payload["meta"]["artifacts"]["commandLog"]["commandName"],
"documents.emptyTrashByWorkspace"
"tree.trash.emptyWorkspace"
);
assert_eq!(
payload["meta"]["artifacts"]["domainEvent"]["eventType"],
@@ -2,7 +2,7 @@ use super::hermes_client;
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{artifact, block, doc, manifest, page, ToolCallInput};
use crate::hermes_tools::{artifact, block, doc, manifest, page, resource, ToolCallInput};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
@@ -344,6 +344,10 @@ pub(crate) async fn execute_mnote_tool_call(
"mnote.page.save" => page::page_save(&state, &context, &input).await,
"mnote.page.update_title" => page::update_title(&state, &context, &input).await,
"mnote.page.update_options" => page::update_options(&state, &context, &input).await,
"mnote.mindmap.fetch" => resource::mindmap_fetch(&context, &input).await,
"mnote.mindmap.apply_ops" => resource::mindmap_apply_ops(&context, &input).await,
"mnote.office.fetch_summary" => resource::office_fetch_summary(&context, &input).await,
"mnote.office.propose_changes" => resource::office_propose_changes(&context, &input).await,
"mnote.artifact.create_summary" => artifact::create_summary(&state, &context, &input).await,
"mnote.artifact.create_ai_note" => artifact::create_ai_note(&state, &context, &input).await,
_ => Err(
@@ -448,7 +452,13 @@ pub(crate) async fn execute_mnote_tool_call(
fn is_read_tool(tool_name: &str) -> bool {
matches!(
tool_name,
"mnote.page.get" | "mnote.doc.fetch" | "mnote.doc.find" | "mnote.block.fetch"
"mnote.page.get"
| "mnote.doc.fetch"
| "mnote.doc.find"
| "mnote.block.fetch"
| "mnote.mindmap.fetch"
| "mnote.office.fetch_summary"
| "mnote.office.propose_changes"
)
}
@@ -995,6 +1005,18 @@ mod tests {
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.doc.apply_block_ops"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.mindmap.fetch"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.mindmap.apply_ops"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.office.fetch_summary"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.office.propose_changes"));
let page_save = tools
.iter()
.find(|tool| tool["name"] == "mnote.page.save")
@@ -1148,6 +1170,324 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn hermes_tools_resource_tools_require_allowed_resource_scope() {
let root =
std::env::temp_dir().join(format!("mnote-resource-scope-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("maps")).expect("maps");
fs::write(
root.join("maps").join("idea.mindmap.json"),
r#"{"data":{"text":"中心主题","uid":"root"},"children":[]}"#,
)
.expect("mindmap");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_1", &root_uri,
)
.expect("workspace");
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"toolName": "mnote.mindmap.fetch",
"workspaceId": "local-ws-resource",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"actorId": "user_1",
"sessionId": "sess_resource_scope",
"runId": "run_resource_scope",
"toolCallId": "call_resource_scope",
"traceId": "trace_resource_scope",
"args": {
"mindmapId": "mind_allowed",
"resourcePath": "maps/idea.mindmap.json",
"aiAccessScope": {
"permissionLevel": "shared_read",
"allowedResourceIds": ["mind_other"]
}
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("mnote_resource_ai_scope_forbidden")
);
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn hermes_tools_mindmap_fetch_reads_authorized_local_resource() {
let root = std::env::temp_dir().join(format!(
"mnote-resource-mindmap-fetch-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("maps")).expect("maps");
fs::write(
root.join("maps").join("idea.mindmap.json"),
r#"{"data":{"text":"中心主题","uid":"root"},"children":[{"data":{"text":"分支一","uid":"child_1"},"children":[]}]}"#,
)
.expect("mindmap");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_1", &root_uri,
)
.expect("workspace");
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"toolName": "mnote.mindmap.fetch",
"workspaceId": "local-ws-resource",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"actorId": "user_1",
"sessionId": "sess_mindmap_fetch",
"runId": "run_mindmap_fetch",
"toolCallId": "call_mindmap_fetch",
"traceId": "trace_mindmap_fetch",
"args": {
"mindmapId": "mind_allowed",
"resourcePath": "maps/idea.mindmap.json",
"aiAccessScope": {
"permissionLevel": "shared_read",
"allowedResourceIds": ["mind_allowed"]
}
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["toolName"], "mnote.mindmap.fetch");
assert_eq!(payload["audit"]["effect"], "read");
assert_eq!(payload["result"]["resourceKind"], "mindmap");
assert_eq!(
payload["result"]["objectIdentity"],
"resource:mindmap:local-md:README.md:mind_allowed"
);
assert!(payload["result"]["markdownSummary"]
.as_str()
.unwrap_or_default()
.contains("中心主题"));
assert_eq!(payload["result"]["nodes"][1]["text"], "分支一");
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn hermes_tools_mindmap_apply_ops_shared_read_is_forbidden() {
let root = std::env::temp_dir().join(format!(
"mnote-resource-mindmap-write-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("maps")).expect("maps");
fs::write(
root.join("maps").join("idea.mindmap.json"),
r#"{"data":{"text":"中心主题","uid":"root"},"children":[]}"#,
)
.expect("mindmap");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_1", &root_uri,
)
.expect("workspace");
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"toolName": "mnote.mindmap.apply_ops",
"workspaceId": "local-ws-resource",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"actorId": "user_1",
"sessionId": "sess_mindmap_write",
"runId": "run_mindmap_write",
"toolCallId": "call_mindmap_write",
"traceId": "trace_mindmap_write",
"idempotencyKey": "idem_mindmap_write",
"dryRun": false,
"args": {
"mindmapId": "mind_allowed",
"resourcePath": "maps/idea.mindmap.json",
"ops": [{"op": "update_node", "nodeId": "root", "text": "改名"}],
"aiAccessScope": {
"permissionLevel": "shared_read",
"allowedResourceIds": ["mind_allowed"]
}
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("mnote_tool_shared_read_write_forbidden")
);
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn hermes_tools_office_fetch_and_propose_changes_do_not_write_file() {
let root = std::env::temp_dir().join(format!(
"mnote-resource-office-fetch-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("office")).expect("office");
fs::write(root.join("office").join("report.docx"), "Office text").expect("office file");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_1", &root_uri,
)
.expect("workspace");
let fetch = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"toolName": "mnote.office.fetch_summary",
"workspaceId": "local-ws-resource",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"actorId": "user_1",
"sessionId": "sess_office_fetch",
"runId": "run_office_fetch",
"toolCallId": "call_office_fetch",
"traceId": "trace_office_fetch",
"args": {
"assetId": "asset_report",
"resourcePath": "office/report.docx",
"aiAccessScope": {
"permissionLevel": "shared_read",
"allowedResourceIds": ["resource:onlyoffice:local-md:README.md:asset_report"]
}
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(fetch.status(), StatusCode::OK);
let fetch_body = to_bytes(fetch.into_body(), usize::MAX)
.await
.expect("fetch body");
let fetch_payload: Value = serde_json::from_slice(&fetch_body).expect("fetch json");
assert_eq!(fetch_payload["audit"]["effect"], "read");
assert_eq!(fetch_payload["result"]["resourceKind"], "only_office");
assert_eq!(fetch_payload["result"]["fileName"], "report.docx");
let before = fs::read(root.join("office").join("report.docx")).expect("before");
let propose = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"toolName": "mnote.office.propose_changes",
"workspaceId": "local-ws-resource",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"actorId": "user_1",
"sessionId": "sess_office_propose",
"runId": "run_office_propose",
"toolCallId": "call_office_propose",
"traceId": "trace_office_propose",
"args": {
"assetId": "asset_report",
"resourcePath": "office/report.docx",
"instructions": "补充结论",
"aiAccessScope": {
"permissionLevel": "shared_read",
"allowedResourceIds": ["asset_report"]
}
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(propose.status(), StatusCode::OK);
let propose_body = to_bytes(propose.into_body(), usize::MAX)
.await
.expect("propose body");
let propose_payload: Value = serde_json::from_slice(&propose_body).expect("propose json");
assert_eq!(propose_payload["audit"]["effect"], "read");
assert_eq!(propose_payload["result"]["writesBinary"], false);
assert_eq!(
fs::read(root.join("office").join("report.docx")).expect("after"),
before
);
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn hermes_tools_page_get_accepts_delegated_actor_from_hermes_payload() {
let response = app()
@@ -30,6 +30,7 @@ pub struct MediaBatchRequest {
#[serde(default)]
pub asset_ids: Vec<String>,
pub new_name: Option<String>,
pub target_document_id: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -510,10 +511,10 @@ pub async fn media_batch(
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await;
let action = body.action.trim();
if action != "restore" && action != "delete" && action != "rename" {
if action != "restore" && action != "delete" && action != "rename" && action != "move" {
return Err(WebError::bad_request_code(
"media_batch_action_unsupported",
"Rust resource trash 当前仅支持附件 delete / restore / rename",
"Rust resource trash 当前仅支持附件 delete / restore / rename / move",
)
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
@@ -553,6 +554,21 @@ pub async fn media_batch(
"newName": new_name,
}),
)
} else if action == "move" {
let target_document_id = require_id(
&context,
body.target_document_id.as_deref().unwrap_or_default(),
"targetDocumentId",
)?;
(
"tree.resource.move",
json!({
"resourceKind": "file",
"assetId": asset_id,
"fromDocumentId": document_id,
"targetDocumentId": target_document_id,
}),
)
} else {
(
"tree.resource.archive",
@@ -595,6 +611,29 @@ pub async fn media_batch(
)
.await?;
}
if action == "move" {
let target_document_id = require_id(
&context,
body.target_document_id.as_deref().unwrap_or_default(),
"targetDocumentId",
)?;
execute_convex_mutation_by_name(
state.config(),
&context,
"mediaAssets:patchById",
json!({
"userId": user_id,
"id": asset_id,
"patch": {
"document_id": target_document_id,
},
}),
workspace_id.as_deref(),
None,
"media_batch_move_patch",
)
.await?;
}
let command_result = execute_runtime_command_via_convex_with_artifacts(
&state,
&context,
@@ -602,12 +641,13 @@ pub async fn media_batch(
command,
)
.await;
if action == "rename" {
if action == "rename" || action == "move" {
if let Err(error) = command_result {
tracing::warn!(
error = %error.message(),
asset_id = %asset_id,
"tree.resource.rename artifact command 失败,已保留兼容 patch 结果"
action = %action,
"tree.resource artifact command 失败,已保留兼容 patch 结果"
);
}
} else {
@@ -624,10 +664,13 @@ pub async fn media_batch(
"restored": if action == "restore" { updated } else { 0 },
"deleted": if action == "delete" { updated } else { 0 },
"renamed": if action == "rename" { updated } else { 0 },
"moved": if action == "move" { updated } else { 0 },
"canonicalCommand": if action == "restore" {
"tree.resource.restore"
} else if action == "rename" {
"tree.resource.rename"
} else if action == "move" {
"tree.resource.move"
} else {
"tree.resource.archive"
},
@@ -1195,6 +1238,19 @@ mod tests {
assert_ne!(renamed["result"]["canonicalCommand"], "tree.node.rename");
}
#[tokio::test]
async fn resource_move_uses_resource_command_not_document_command() {
let moved = post_json(
"/api/media/batch",
json!({"action": "move", "assetIds": ["asset_1"], "targetDocumentId": "doc_2"}),
)
.await;
assert_eq!(moved["result"]["moved"], 1);
assert_eq!(moved["result"]["canonicalCommand"], "tree.resource.move");
assert_ne!(moved["result"]["canonicalCommand"], "documents.move");
}
#[tokio::test]
async fn mindmap_trash_routes_delete_restore_purge_and_empty() {
let deleted = delete_json("/api/mindmap/doc_1/mind_1").await;
+14 -4
View File
@@ -1023,6 +1023,12 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
return textToTiptapDocument(fallbackText);
};
const pageBodyTiptapDocument = (body, fallbackText = '') => {
const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null;
if (blockDocument) return toTiptapDocument(blockDocument, fallbackText);
return toTiptapDocument(body?.content, fallbackText);
};
const inlineTextNodes = (node) => {
if (!node || typeof node !== 'object') return [];
if (Array.isArray(node.content)) {
@@ -1568,7 +1574,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const aggregatePlainText = (aggregate) => {
const body = aggregate?.body || {};
return flattenText(toTiptapDocument(body.content)).replace(/\n{3,}/g, '\n\n').trim();
return flattenText(pageBodyTiptapDocument(body)).replace(/\n{3,}/g, '\n\n').trim();
};
const conflictEnvelopeFromResponse = (payload) => (
@@ -1604,7 +1610,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const nextBody = nextAggregate?.body || {};
const nextPermissions = nextAggregate?.head?.permissions || {};
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
const nextTiptapDocument = toTiptapDocument(nextBody.content);
const nextTiptapDocument = pageBodyTiptapDocument(nextBody);
const nextSerialized = JSON.stringify(nextTiptapDocument);
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
session.latestAggregate = nextAggregate;
@@ -1971,7 +1977,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const nextBody = nextAggregate?.body || {};
const nextPermissions = nextAggregate?.head?.permissions || {};
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
const nextTiptapDocument = toTiptapDocument(nextBody.content);
const nextTiptapDocument = pageBodyTiptapDocument(nextBody);
const nextSerialized = JSON.stringify(nextTiptapDocument);
const contentChanged = nextSerialized !== session.currentSerialized;
session.externalChangePending = false;
@@ -2250,7 +2256,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const conflictDetectionKey = conflictDetectionKeyFromBody(pageBody);
const pageBodyRevision = Number.isInteger(pageBody.revision) ? pageBody.revision : null;
const keyRevision = revisionFromConflictKey(conflictDetectionKey);
const tiptapDocument = toTiptapDocument(pageBody.content);
const tiptapDocument = pageBodyTiptapDocument(pageBody);
const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap);
const session = {
key: buildDocumentSessionKey(runtimeDescriptor.bootstrap),
@@ -3492,6 +3498,10 @@ mod tests {
assert!(html.contains("/api/tree/events"));
assert!(html.contains("data-mnote-tree-live-transport"));
assert!(html.contains("syncPageAggregateScript(session, nextAggregate);"));
assert!(html.contains("const pageBodyTiptapDocument = (body, fallbackText = '') => {"));
assert!(html.contains("body?.blockDocument || body?.block_document"));
assert!(html.contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody);"));
assert!(html.contains("const tiptapDocument = pageBodyTiptapDocument(pageBody);"));
assert!(!html.contains("mnote-web-document-shell"));
}
+13 -2
View File
@@ -8072,6 +8072,15 @@ const TREE_LIVE_CONTROLLER_JS: &str = r##"
}
}
function liveDeltaNeedsResync(payload) {
var data = payload && (payload.data || payload.delta || payload);
var op = data && typeof data.op === 'string' ? String(data.op).trim() : '';
if (!op || op === 'noop') return false;
if (op === 'resync_required') return true;
if (data && data.requiresResync === true) return true;
return false;
}
function startWithSse(bootstrap, workspaceId, url) {
var failures = 0;
var source = new EventSource(url.toString());
@@ -8144,8 +8153,7 @@ const TREE_LIVE_CONTROLLER_JS: &str = r##"
} else if (kind === 'delta') {
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision));
dispatchTreeEvent('tree:delta', { payload: payload, revision: revision, bootstrap: bootstrap });
// Delta indicates something changed; request fresh resync from server
if (ws.readyState === WebSocket.OPEN) {
if (liveDeltaNeedsResync(payload) && ws.readyState === WebSocket.OPEN) {
ws.send('resync');
}
} else if (kind === 'resync') {
@@ -8738,7 +8746,10 @@ mod tests {
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:snapshot"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:delta"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:resync"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("liveDeltaNeedsResync(payload)"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("op === 'resync_required'"));
assert!(!TREE_LIVE_CONTROLLER_JS.contains("resyncEndpoint"));
assert!(!TREE_LIVE_CONTROLLER_JS.contains("tree:resync-requested"));
assert!(!TREE_LIVE_CONTROLLER_JS.contains("Delta indicates something changed"));
}
}
@@ -439,6 +439,7 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
"tree.resource.archive"
| "tree.resource.restore"
| "tree.resource.rename"
| "tree.resource.move"
| "tree.resource.purge"
) {
args = convex_resource_lifecycle_args_for_plan(plan, &args);
@@ -537,6 +538,17 @@ fn convex_resource_lifecycle_args_for_plan(
"file_name": args.get("newName").and_then(Value::as_str).unwrap_or_default(),
},
}),
"move" => json!({
"userId": user_id,
"id": id,
"patch": {
"document_id": args
.get("targetDocumentId")
.or_else(|| args.get("target_document_id"))
.and_then(Value::as_str)
.unwrap_or_default(),
},
}),
"purge" => json!({
"userId": user_id,
"id": id,