- 归档 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 .
1071 lines
36 KiB
Rust
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(¤t).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,
|
|
}
|
|
}
|