收口 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:
lix-2026
2026-06-01 09:29:12 +08:00
parent 49a0545148
commit 1882db7681
143 changed files with 29810 additions and 3228 deletions
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(&current).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"));
}
}