推进资源工具与本地优先兼容链收口
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user