411 lines
14 KiB
Rust
411 lines
14 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 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,
|
||
|
|
}
|
||
|
|
}
|