收口 MNote P0 P1 P2 审查尾项
- 归档 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 .
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ pub mod block;
|
||||
pub mod context_tools;
|
||||
pub mod doc;
|
||||
pub mod manifest;
|
||||
pub mod onlyoffice_live;
|
||||
pub mod page;
|
||||
pub mod resource;
|
||||
pub mod skill;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,11 +23,17 @@ pub async fn mindmap_fetch(
|
||||
})?;
|
||||
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();
|
||||
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",
|
||||
@@ -35,6 +41,8 @@ pub async fn mindmap_fetch(
|
||||
"mindmapId": target.resource_id,
|
||||
"resourcePath": target.resource_path,
|
||||
"scope": scope,
|
||||
"root": root,
|
||||
"envelope": envelope,
|
||||
"nodes": nodes,
|
||||
"edges": [],
|
||||
"markdownSummary": mindmap_markdown_summary(&nodes),
|
||||
@@ -67,14 +75,150 @@ pub async fn mindmap_apply_ops(
|
||||
"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))
|
||||
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(
|
||||
@@ -235,6 +379,45 @@ impl ResourceToolTarget {
|
||||
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()
|
||||
@@ -315,9 +498,508 @@ fn local_root_uri_for_resource(input: &ToolCallInput) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
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(value, &mut nodes);
|
||||
collect_mindmap_nodes_inner(mindmap_root_value(value), &mut nodes);
|
||||
nodes
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,68 @@ const SKILLS: &[MnoteSkill] = &[
|
||||
tool_names: &["mnote.context.snapshot", "mnote.context.resolve_target"],
|
||||
content: include_str!("../../../../../skills/mnote-local-file/SKILL.md"),
|
||||
},
|
||||
MnoteSkill {
|
||||
id: "mnote-onlyoffice-live",
|
||||
title: "MNote ONLYOFFICE live bridge",
|
||||
description: "Operate the currently open ONLYOFFICE editor session for Word, Excel, and PPT.",
|
||||
agent_ids: &["hermes", "reasonix"],
|
||||
read_only: false,
|
||||
requires_context_refs: &["onlyoffice"],
|
||||
tool_names: &[
|
||||
"mnote.onlyoffice.session.current",
|
||||
"mnote.onlyoffice.capabilities",
|
||||
"mnote.onlyoffice.selection.get",
|
||||
"mnote.onlyoffice.document.insert_text",
|
||||
"mnote.onlyoffice.document.replace_selection",
|
||||
"mnote.onlyoffice.document.insert_html",
|
||||
"mnote.onlyoffice.document.export",
|
||||
"mnote.onlyoffice.document.search_replace",
|
||||
"mnote.onlyoffice.document.insert_table",
|
||||
"mnote.onlyoffice.document.get_comments",
|
||||
"mnote.onlyoffice.document.add_comment",
|
||||
"mnote.onlyoffice.sheet.get_sheets",
|
||||
"mnote.onlyoffice.sheet.add_sheet",
|
||||
"mnote.onlyoffice.sheet.rename_sheet",
|
||||
"mnote.onlyoffice.sheet.get_range",
|
||||
"mnote.onlyoffice.sheet.get_range_values",
|
||||
"mnote.onlyoffice.sheet.get_values",
|
||||
"mnote.onlyoffice.sheet.set_value",
|
||||
"mnote.onlyoffice.sheet.set_formula",
|
||||
"mnote.onlyoffice.sheet.batch_set_values",
|
||||
"mnote.onlyoffice.sheet.set_range_values",
|
||||
"mnote.onlyoffice.sheet.format_range",
|
||||
"mnote.onlyoffice.sheet.set_dimensions",
|
||||
"mnote.onlyoffice.sheet.sort_range",
|
||||
"mnote.onlyoffice.sheet.add_chart",
|
||||
"mnote.onlyoffice.presentation.get_slides",
|
||||
"mnote.onlyoffice.presentation.get_slide_texts",
|
||||
"mnote.onlyoffice.presentation.get_shapes",
|
||||
"mnote.onlyoffice.presentation.add_text_slide",
|
||||
"mnote.onlyoffice.presentation.replace_text",
|
||||
"mnote.onlyoffice.presentation.set_shape_text",
|
||||
"mnote.onlyoffice.presentation.delete_slide",
|
||||
"mnote.onlyoffice.presentation.add_table",
|
||||
"mnote.onlyoffice.presentation.clear_slide",
|
||||
"mnote.onlyoffice.presentation.add_shape",
|
||||
],
|
||||
content: include_str!("../../../../../skills/mnote-onlyoffice-live/SKILL.md"),
|
||||
},
|
||||
MnoteSkill {
|
||||
id: "mnote-mindmap",
|
||||
title: "MNote mindmap editing",
|
||||
description: "Read, update, summarize, or create MNote mindmap resources, including generating a new mindmap from PDF or document outlines.",
|
||||
agent_ids: &["hermes", "reasonix"],
|
||||
read_only: false,
|
||||
requires_context_refs: &["current_page", "file", "folder", "resource"],
|
||||
tool_names: &[
|
||||
"mnote.context.snapshot",
|
||||
"mnote.context.resolve_target",
|
||||
"mnote.mindmap.fetch",
|
||||
"mnote.mindmap.apply_ops",
|
||||
"mnote.mindmap.create_from_outline",
|
||||
],
|
||||
content: include_str!("../../../../../skills/mnote-mindmap/SKILL.md"),
|
||||
},
|
||||
MnoteSkill {
|
||||
id: "mnote-chat-only",
|
||||
title: "MNote chat only",
|
||||
@@ -146,4 +208,108 @@ mod tests {
|
||||
assert!(find_skill("mnote-local-file", Some("chat_only")).is_none());
|
||||
assert!(find_skill("missing", Some("reasonix")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_registry_exposes_onlyoffice_live_skill_to_agents() {
|
||||
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
|
||||
let skill = reasonix_skills
|
||||
.iter()
|
||||
.find(|skill| skill["id"] == "mnote-onlyoffice-live")
|
||||
.expect("reasonix should see live ONLYOFFICE skill");
|
||||
assert_eq!(skill["readOnly"], false);
|
||||
assert_eq!(
|
||||
skill["toolNames"]
|
||||
.as_array()
|
||||
.expect("tool names")
|
||||
.iter()
|
||||
.any(|name| name == "mnote.onlyoffice.session.current"),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
skill["toolNames"]
|
||||
.as_array()
|
||||
.expect("tool names")
|
||||
.iter()
|
||||
.any(|name| name == "mnote.onlyoffice.sheet.batch_set_values"),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
skill["toolNames"]
|
||||
.as_array()
|
||||
.expect("tool names")
|
||||
.iter()
|
||||
.any(|name| name == "mnote.onlyoffice.sheet.add_chart"),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
skill["toolNames"]
|
||||
.as_array()
|
||||
.expect("tool names")
|
||||
.iter()
|
||||
.any(|name| name == "mnote.onlyoffice.presentation.add_shape"),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
skill["toolNames"]
|
||||
.as_array()
|
||||
.expect("tool names")
|
||||
.iter()
|
||||
.any(|name| name == "mnote.onlyoffice.presentation.replace_text"),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_registry_exposes_mindmap_skill_to_agents() {
|
||||
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
|
||||
let skill = reasonix_skills
|
||||
.iter()
|
||||
.find(|skill| skill["id"] == "mnote-mindmap")
|
||||
.expect("reasonix should see mindmap skill");
|
||||
assert_eq!(skill["readOnly"], false);
|
||||
assert!(skill["requiresContextRefs"]
|
||||
.as_array()
|
||||
.expect("context refs")
|
||||
.iter()
|
||||
.any(|value| value == "resource"));
|
||||
assert!(skill["toolNames"]
|
||||
.as_array()
|
||||
.expect("tool names")
|
||||
.iter()
|
||||
.any(|name| name == "mnote.mindmap.create_from_outline"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_read_returns_mindmap_skill_content() {
|
||||
let context = RequestContext::from_http_parts(
|
||||
&axum::http::Method::POST,
|
||||
&"/api/hermes/tools/execute".parse().expect("uri"),
|
||||
&axum::http::HeaderMap::new(),
|
||||
);
|
||||
let input: ToolCallInput = serde_json::from_value(json!({
|
||||
"toolName": "mnote.skill.read",
|
||||
"args": {
|
||||
"skillId": "mnote-mindmap",
|
||||
"agentId": "reasonix"
|
||||
}
|
||||
}))
|
||||
.expect("input");
|
||||
|
||||
let payload = skill_read(&context, &input).await.expect("skill read");
|
||||
|
||||
assert_eq!(payload["skill"]["id"], "mnote-mindmap");
|
||||
assert!(payload["content"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("mnote.mindmap.create_from_outline"));
|
||||
let content = payload["content"].as_str().unwrap_or_default();
|
||||
assert!(content.contains("Mindmap outline format"));
|
||||
assert!(content.contains("Use concise keywords or short phrases"));
|
||||
assert!(content.contains("Avoid long paragraphs"));
|
||||
assert!(payload["tools"]
|
||||
.as_array()
|
||||
.expect("tools")
|
||||
.iter()
|
||||
.any(|tool| tool == "mnote.mindmap.create_from_outline"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user