Files
mnote/rust/crates/mnote-web/src/hermes_tools/skill.rs
T

426 lines
15 KiB
Rust

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 MnoteCapabilityPack {
pub id: &'static str,
pub title: &'static str,
pub description: &'static str,
pub category: &'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,
}
pub type MnoteSkill = MnoteCapabilityPack;
const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
MnoteCapabilityPack {
id: "mnote-current-page",
title: "当前页读取",
description: "仅在任务需要当前 MNote Markdown 页面内容时读取当前页。",
category: "mnote",
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"),
},
MnoteCapabilityPack {
id: "mnote-knowledge-rag",
title: "资料库问答",
description:
"通过 LightRAG provider 检索多本书、论文、PDF 和附件资料库,并返回可回跳来源。",
category: "knowledge",
agent_ids: &["hermes", "reasonix"],
read_only: true,
requires_context_refs: &["folder"],
tool_names: &[
"mnote.context.snapshot",
"mnote.context.resolve_target",
"mnote.knowledge_rag.status",
"mnote.knowledge_rag.query",
"mnote.knowledge_rag.open_reference",
],
content: include_str!("../../../../../skills/mnote-knowledge-rag/SKILL.md"),
},
MnoteCapabilityPack {
id: "mnote-local-file",
title: "本地文件编辑",
description: "在 MNote 授权目录内读取和修改本地 Markdown 文件。",
category: "file",
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"),
},
MnoteCapabilityPack {
id: "mnote-onlyoffice-live",
title: "ONLYOFFICE 实时编辑",
description: "操作当前已打开的 ONLYOFFICE Word、Excel、PPT 编辑会话。",
category: "office",
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"),
},
MnoteCapabilityPack {
id: "mnote-mindmap",
title: "思维导图",
description: "读取、更新、总结或创建 MNote 思维导图资源。",
category: "resource",
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"),
},
MnoteCapabilityPack {
id: "mnote-chat-only",
title: "纯聊天",
description: "只进行对话回复,不读取或写入 MNote 页面、文件上下文。",
category: "chat",
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> {
capability_summaries_for_agent(agent_id)
}
pub fn capability_summaries_for_agent(agent_id: Option<&str>) -> Vec<Value> {
CAPABILITY_PACKS
.iter()
.filter(|skill| skill_matches_agent(skill, agent_id))
.map(skill_summary)
.collect()
}
pub fn public_capability_packs() -> &'static [MnoteCapabilityPack] {
CAPABILITY_PACKS
}
pub fn find_skill(skill_id: &str, agent_id: Option<&str>) -> Option<&'static MnoteSkill> {
find_capability_pack(skill_id, agent_id)
}
pub fn find_capability_pack(
capability_id: &str,
agent_id: Option<&str>,
) -> Option<&'static MnoteCapabilityPack> {
let requested = canonical_skill_id(capability_id.trim());
CAPABILITY_PACKS
.iter()
.find(|skill| skill.id == requested && skill_matches_agent(skill, agent_id))
}
pub fn capability_ids_for_tool(tool_name: &str) -> Vec<&'static str> {
let name = tool_name.trim();
if name.is_empty() {
return Vec::new();
}
CAPABILITY_PACKS
.iter()
.filter(|pack| pack.tool_names.iter().any(|tool| *tool == name))
.map(|pack| pack.id)
.collect()
}
pub fn manifest_capabilities() -> Vec<Value> {
CAPABILITY_PACKS
.iter()
.map(|pack| {
json!({
"id": pack.id,
"title": pack.title,
"description": pack.description,
"category": pack.category,
"agentIds": pack.agent_ids,
"readOnly": pack.read_only,
"requiresContextRefs": pack.requires_context_refs,
"skillId": pack.id,
"toolNames": pack.tool_names,
"uiKind": if pack.category == "chat" { "chat" } else { "ai_capability" },
"public": true
})
})
.collect()
}
fn canonical_skill_id(skill_id: &str) -> &str {
match skill_id {
"mnote-document-evidence" | "mnote-local-index" => "mnote-knowledge-rag",
other => other,
}
}
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,
"category": skill.category,
"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());
}
#[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"));
}
#[test]
fn skill_registry_retired_local_index_in_favor_of_lightrag() {
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
assert!(!hermes_skills
.iter()
.any(|skill| skill["id"] == "mnote-local-index"));
let skill = hermes_skills
.iter()
.find(|skill| skill["id"] == "mnote-knowledge-rag")
.expect("hermes should see LightRAG skill");
assert_eq!(skill["readOnly"], true);
assert!(skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.knowledge_rag.query"));
assert!(!hermes_skills
.iter()
.any(|skill| skill["id"] == "mnote-document-evidence"));
}
#[test]
fn skill_read_maps_document_evidence_alias_to_lightrag() {
let skill = find_skill("mnote-document-evidence", Some("reasonix"))
.expect("compat alias should resolve");
assert_eq!(skill.id, "mnote-knowledge-rag");
}
#[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"));
}
}