2026-05-29 21:57:29 +08:00
|
|
|
use crate::context::RequestContext;
|
|
|
|
|
use crate::error::WebError;
|
|
|
|
|
use crate::hermes_tools::ToolCallInput;
|
|
|
|
|
use axum::http::StatusCode;
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub struct MnoteSkill {
|
|
|
|
|
pub id: &'static str,
|
|
|
|
|
pub title: &'static str,
|
|
|
|
|
pub description: &'static str,
|
|
|
|
|
pub agent_ids: &'static [&'static str],
|
|
|
|
|
pub read_only: bool,
|
|
|
|
|
pub requires_context_refs: &'static [&'static str],
|
|
|
|
|
pub tool_names: &'static [&'static str],
|
|
|
|
|
pub content: &'static str,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SKILLS: &[MnoteSkill] = &[
|
|
|
|
|
MnoteSkill {
|
|
|
|
|
id: "mnote-current-page",
|
|
|
|
|
title: "MNote current page",
|
|
|
|
|
description: "Read the current MNote Markdown page only when the task needs page content.",
|
|
|
|
|
agent_ids: &["hermes", "reasonix"],
|
|
|
|
|
read_only: true,
|
|
|
|
|
requires_context_refs: &["current_page"],
|
|
|
|
|
tool_names: &[
|
|
|
|
|
"mnote.context.snapshot",
|
|
|
|
|
"mnote.context.resolve_target",
|
|
|
|
|
"mnote.context.read_current_page",
|
|
|
|
|
],
|
|
|
|
|
content: include_str!("../../../../../skills/mnote-current-page/SKILL.md"),
|
|
|
|
|
},
|
|
|
|
|
MnoteSkill {
|
|
|
|
|
id: "mnote-local-file",
|
|
|
|
|
title: "MNote local file editing",
|
|
|
|
|
description: "Read and patch local Markdown files inside MNote allowed roots.",
|
|
|
|
|
agent_ids: &["hermes", "reasonix"],
|
|
|
|
|
read_only: false,
|
|
|
|
|
requires_context_refs: &["current_page", "file", "folder"],
|
|
|
|
|
tool_names: &["mnote.context.snapshot", "mnote.context.resolve_target"],
|
|
|
|
|
content: include_str!("../../../../../skills/mnote-local-file/SKILL.md"),
|
|
|
|
|
},
|
2026-06-01 09:29:12 +08:00
|
|
|
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"),
|
|
|
|
|
},
|
2026-05-29 21:57:29 +08:00
|
|
|
MnoteSkill {
|
|
|
|
|
id: "mnote-chat-only",
|
|
|
|
|
title: "MNote chat only",
|
|
|
|
|
description: "Reply conversationally without reading or writing MNote page/file context.",
|
|
|
|
|
agent_ids: &["chat_only", "hermes", "reasonix"],
|
|
|
|
|
read_only: true,
|
|
|
|
|
requires_context_refs: &[],
|
|
|
|
|
tool_names: &[],
|
|
|
|
|
content: include_str!("../../../../../skills/mnote-chat-only/SKILL.md"),
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
pub fn skill_summaries_for_agent(agent_id: Option<&str>) -> Vec<Value> {
|
|
|
|
|
SKILLS
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|skill| skill_matches_agent(skill, agent_id))
|
|
|
|
|
.map(skill_summary)
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn find_skill(skill_id: &str, agent_id: Option<&str>) -> Option<&'static MnoteSkill> {
|
|
|
|
|
let requested = skill_id.trim();
|
|
|
|
|
SKILLS
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|skill| skill.id == requested && skill_matches_agent(skill, agent_id))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn skill_read(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
input: &ToolCallInput,
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
let skill_id = input
|
|
|
|
|
.arg_string("skillId")
|
|
|
|
|
.or_else(|| input.arg_string("skill_id"))
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code("mnote_skill_id_required", "缺少 skillId")
|
|
|
|
|
.with_context(context)
|
|
|
|
|
})?;
|
|
|
|
|
let agent_id = input
|
|
|
|
|
.arg_string("agentId")
|
|
|
|
|
.or_else(|| input.arg_string("agent_id"));
|
|
|
|
|
let skill = find_skill(&skill_id, agent_id.as_deref()).ok_or_else(|| {
|
|
|
|
|
WebError::new(
|
|
|
|
|
StatusCode::NOT_FOUND,
|
|
|
|
|
"mnote_skill_not_found",
|
|
|
|
|
"未知或当前 agent 不可用的 MNote skill",
|
|
|
|
|
)
|
|
|
|
|
.with_context(context)
|
|
|
|
|
})?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"schema": "mnote.skill.v1",
|
|
|
|
|
"skill": skill_summary(skill),
|
|
|
|
|
"content": skill.content,
|
|
|
|
|
"tools": skill.tool_names,
|
|
|
|
|
"constraints": {
|
|
|
|
|
"allowedRootsRequired": skill.requires_context_refs.contains(&"folder"),
|
|
|
|
|
"mustReadBackAfterWrite": !skill.read_only
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn skill_matches_agent(skill: &MnoteSkill, agent_id: Option<&str>) -> bool {
|
|
|
|
|
let Some(agent_id) = agent_id.map(str::trim).filter(|value| !value.is_empty()) else {
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
skill
|
|
|
|
|
.agent_ids
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|candidate| *candidate == agent_id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn skill_summary(skill: &MnoteSkill) -> Value {
|
|
|
|
|
json!({
|
|
|
|
|
"id": skill.id,
|
|
|
|
|
"title": skill.title,
|
|
|
|
|
"description": skill.description,
|
|
|
|
|
"agentIds": skill.agent_ids,
|
|
|
|
|
"readOnly": skill.read_only,
|
|
|
|
|
"requiresContextRefs": skill.requires_context_refs,
|
|
|
|
|
"toolNames": skill.tool_names
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn skill_registry_filters_by_agent() {
|
|
|
|
|
let chat_skills = skill_summaries_for_agent(Some("chat_only"));
|
|
|
|
|
assert!(chat_skills
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|skill| skill["id"] == "mnote-chat-only"));
|
|
|
|
|
assert!(!chat_skills
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|skill| skill["id"] == "mnote-local-file"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn skill_lookup_rejects_unknown_or_agent_mismatch() {
|
|
|
|
|
assert!(find_skill("mnote-current-page", Some("reasonix")).is_some());
|
|
|
|
|
assert!(find_skill("mnote-local-file", Some("chat_only")).is_none());
|
|
|
|
|
assert!(find_skill("missing", Some("reasonix")).is_none());
|
|
|
|
|
}
|
2026-06-01 09:29:12 +08:00
|
|
|
|
|
|
|
|
#[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"));
|
|
|
|
|
}
|
2026-05-29 21:57:29 +08:00
|
|
|
}
|