Files
mnote/rust/crates/mnote-web/src/hermes_tools/resource.rs
T

411 lines
14 KiB
Rust
Raw Normal View History

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,
}
}