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 { capability_summaries_for_agent(agent_id) } pub fn capability_summaries_for_agent(agent_id: Option<&str>) -> Vec { 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 { 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 { 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")); } }