From f51b5c100fca094c52ca22dc00c08e4a9f34ddce Mon Sep 17 00:00:00 2001 From: wunianxiao Date: Mon, 1 Jun 2026 21:08:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20manage=5Fskill=20+=20=E5=85=A8=E5=B1=80?= =?UTF-8?q?=20skills=20=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增: - manage_skill: LLM 可调用的 Skill 创建/删除/列出工具 (High) - 全局 skills 支持: ~/.ias/skills/ 自动加载 - SkillRegistry::register_skill() 动态注册 - SkillRegistry::load() 三元加载: system → global → user manage_skill 操作: create: 写 SKILL.md + 执行脚本到 skills// delete: 移除 skills// list: 列出所有自定义 Skill 目前一共 9 个系统 Skills --- resources/skills/manage_skill/SKILL.md | 49 +++++ .../skills/manage_skill/scripts/manage.sh | 106 +++++++++ src/context/types.rs | 18 +- src/main.rs | 206 +++++++++++++----- src/scheduler.rs | 24 +- src/tools/parser.rs | 3 +- src/tools/registry.rs | 17 +- 7 files changed, 342 insertions(+), 81 deletions(-) create mode 100644 resources/skills/manage_skill/SKILL.md create mode 100755 resources/skills/manage_skill/scripts/manage.sh diff --git a/resources/skills/manage_skill/SKILL.md b/resources/skills/manage_skill/SKILL.md new file mode 100644 index 0000000..d6b9347 --- /dev/null +++ b/resources/skills/manage_skill/SKILL.md @@ -0,0 +1,49 @@ +# manage_skill + +管理 Skill:创建新的 Skill,列出所有已加载的 Skill,删除 Skill。 + +创建的 Skill 会持久化到 `skills/` 目录,重启后自动加载。 + +## Risk Level +High + +## Parameters +```json +{ + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "操作类型", + "enum": ["create", "delete", "list"] + }, + "name": { + "type": "string", + "description": "Skill 名称(create/delete 时需要)" + }, + "description": { + "type": "string", + "description": "Skill 描述(create 时需要)" + }, + "command": { + "type": "string", + "description": "Skill 执行的命令(create 时需要)" + }, + "parameters": { + "type": "string", + "description": "参数 JSON Schema(create 时需要,JSON 字符串)" + }, + "risk_level": { + "type": "string", + "description": "风险等级 Low|High,默认 Low", + "enum": ["Low", "High"] + } + }, + "required": ["action"] +} +``` + +## Execute +```bash +scripts/manage.sh +``` diff --git a/resources/skills/manage_skill/scripts/manage.sh b/resources/skills/manage_skill/scripts/manage.sh new file mode 100755 index 0000000..7839559 --- /dev/null +++ b/resources/skills/manage_skill/scripts/manage.sh @@ -0,0 +1,106 @@ +#!/bin/bash +read -r INPUT +export MANAGE_INPUT="$INPUT" + +python3 - "skills" << 'PYEOF' +import sys, json, os, stat + +skills_dir = sys.argv[1] +input_raw = os.environ.get('MANAGE_INPUT', '{}') + +try: + req = json.loads(input_raw) if input_raw else json.loads(sys.stdin.read()) +except: + print("无效的 JSON 输入") + sys.exit(1) + +action = req.get('action', 'list') +name = req.get('name', '').strip() +desc = req.get('description', '') +command = req.get('command', '') +params = req.get('parameters', '{"type":"object","properties":{}}') +risk = req.get('risk_level', 'Low') + +os.makedirs(skills_dir, exist_ok=True) + +if action == 'list': + if not os.path.isdir(skills_dir): + print("skills 目录不存在") + sys.exit(0) + entries = [d for d in os.listdir(skills_dir) if os.path.isdir(os.path.join(skills_dir, d))] + if not entries: + print("暂无自定义 Skill") + else: + print("自定义 Skills:") + for d in sorted(entries): + md = os.path.join(skills_dir, d, "SKILL.md") + if os.path.exists(md): + first = open(md).readline().strip('# \n') + print(f" • {d} — {first}") + +elif action == 'create': + if not name: + print("请提供 name 参数") + sys.exit(1) + if not command: + print("请提供 command 参数") + sys.exit(1) + + skill_dir = os.path.join(skills_dir, name) + scripts_dir = os.path.join(skill_dir, "scripts") + os.makedirs(scripts_dir, exist_ok=True) + + # 解析参数 JSON(美化) + try: + params_obj = json.loads(params) if isinstance(params, str) else params + params_pretty = json.dumps(params_obj, indent=2, ensure_ascii=False) + except: + params_pretty = params + + # 写 SKILL.md + md_content = f"""# {name} + +{desc} + +## Risk Level +{risk} + +## Parameters +```json +{params_pretty} +``` + +## Execute +```bash +scripts/main.sh +``` +""" + with open(os.path.join(skill_dir, "SKILL.md"), "w") as f: + f.write(md_content.strip() + "\n") + + # 写执行脚本 + script = os.path.join(scripts_dir, "main.sh") + with open(script, "w") as f: + f.write(f"#!/bin/bash\n{command}\n") + os.chmod(script, 0o755) + + print(f"✅ 已创建 Skill: {name}") + print(f" 位置: {skill_dir}") + print(f" 风险: {risk}") + +elif action == 'delete': + if not name: + print("请提供 name 参数") + sys.exit(1) + skill_dir = os.path.join(skills_dir, name) + if not os.path.isdir(skill_dir): + print(f"Skill 不存在: {name}") + sys.exit(1) + import shutil + shutil.rmtree(skill_dir) + print(f"✅ 已删除 Skill: {name}") + +else: + print(f"未知操作: {action}(支持: create, delete, list)") + sys.exit(1) +PYEOF diff --git a/src/context/types.rs b/src/context/types.rs index 9a96a2e..fbb35fd 100644 --- a/src/context/types.rs +++ b/src/context/types.rs @@ -77,12 +77,14 @@ impl ChatSession { /// 记录一条带 tool_calls 的助手消息 pub fn add_assistant_tool_calls(&mut self, tool_calls: Vec) { - self.messages.push(Message::assistant_with_tool_calls(tool_calls)); + self.messages + .push(Message::assistant_with_tool_calls(tool_calls)); } /// 记录一条工具结果 pub fn add_tool_result(&mut self, tool_call_id: &str, name: &str, result: &str) { - self.messages.push(Message::tool_result(tool_call_id, name, result)); + self.messages + .push(Message::tool_result(tool_call_id, name, result)); } /// 是否因空闲超过阈值需要摘要 @@ -104,14 +106,12 @@ impl ChatSession { } } - /// 是否有 overflow 摘要可以注入 - pub fn has_overflow_summary(&self) -> bool { - self.summaries.iter().any(|s| s.reason == SummaryReason::Overflow) - } - /// 获取最新的 overflow 摘要 pub fn latest_overflow_summary(&self) -> Option<&SummaryEntry> { - self.summaries.iter().rev().find(|s| s.reason == SummaryReason::Overflow) + self.summaries + .iter() + .rev() + .find(|s| s.reason == SummaryReason::Overflow) } /// 从数据库加载最近的聊天记录到会话中 @@ -129,7 +129,7 @@ impl ChatSession { .await; if let Ok(rows) = rows { - let mut msgs: Vec = rows + let msgs: Vec = rows .into_iter() .rev() // 恢复时间顺序 .map(|(dir, _uid, text, _mid)| { diff --git a/src/main.rs b/src/main.rs index 78a253d..f4d52b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,16 +11,14 @@ mod wechat; use clap::Parser; use cli::{Cli, Commands}; -use context::MemoryStore; use config::AppConfig; -use context::types::ChatSession as _; +use context::MemoryStore; use db::Database; -use llm::{Conversation, ConversationConfig, ToolExecutor, Usage, DEFAULT_SYSTEM_PROMPT}; +use llm::{Conversation, ConversationConfig, DEFAULT_SYSTEM_PROMPT, ToolExecutor, Usage}; use std::sync::Arc; use tools::approval::ApprovalManager; use tools::registry::{ExecutionContext, SkillRegistry}; use tracing::{error, info}; -use tracing_subscriber::EnvFilter; use wechat::client::WeChatClient; #[tokio::main] @@ -46,7 +44,9 @@ async fn main() { }; // 长期记忆存储 - let memory_store = Arc::new(MemoryStore::new(database.as_ref().map(|db| Arc::new(db.pool().clone())))); + let memory_store = Arc::new(MemoryStore::new( + database.as_ref().map(|db| Arc::new(db.pool().clone())), + )); memory_store.load().await; // 文件状态管理器(降级方案) @@ -60,7 +60,15 @@ async fn main() { llm: enable_llm, echo, } => { - cmd_listen(enable_llm, echo, &database, &file_state, &config, &memory_store).await; + cmd_listen( + enable_llm, + echo, + &database, + &file_state, + &config, + &memory_store, + ) + .await; } Commands::Send { to, @@ -72,7 +80,11 @@ async fn main() { Commands::Whoami => { cmd_whoami(&database, &file_state).await; } - Commands::Usage { since, until, model } => { + Commands::Usage { + since, + until, + model, + } => { cmd_usage(&database, since, until, model).await; } Commands::Service => { @@ -117,7 +129,10 @@ async fn cmd_login( println!("\n✅ 登录成功!"); println!(" 账号: {}", result.account_id); - println!(" Token: {}...", &result.token[..std::cmp::min(16, result.token.len())]); + println!( + " Token: {}...", + &result.token[..std::cmp::min(16, result.token.len())] + ); println!(" API: {}", result.base_url); } Err(e) => error!("登录失败: {}", e), @@ -190,9 +205,9 @@ async fn cmd_listen( }; // 审批管理器 - let approval_manager = database.as_ref().map(|db| { - Arc::new(ApprovalManager::new(Some(Arc::new(db.pool().clone())))) - }); + let approval_manager = database + .as_ref() + .map(|db| Arc::new(ApprovalManager::new(Some(Arc::new(db.pool().clone()))))); // 共享的当前用户 ID(用于审批消息) let current_user_id = Arc::new(tokio::sync::Mutex::new(String::new())); @@ -260,7 +275,7 @@ async fn cmd_listen( // 创建 Conversation let mut conv = match Conversation::new(cfg) { - Ok(mut c) => { + Ok(c) => { // 设置 db_pool 并加载历史消息 if let Some(db) = database { let session = c.session(); @@ -291,7 +306,11 @@ async fn cmd_listen( let uid = user_id.to_string(); let msg = text.to_string(); Box::pin(async move { - client.send_text(&uid, &msg, None).await.map(|_| ()).map_err(|e| e) + client + .send_text(&uid, &msg, None) + .await + .map(|_| ()) + .map_err(|e| e) }) }) }; @@ -311,9 +330,12 @@ async fn cmd_listen( match n.as_str() { "read_memories" => return Ok(memory_store.read().await), "write_memory" => { - let params: serde_json::Value = serde_json::from_str(&args).unwrap_or_default(); + let params: serde_json::Value = + serde_json::from_str(&args).unwrap_or_default(); let content = params.get("content").and_then(|v| v.as_str()).unwrap_or(""); - if content.is_empty() { return Ok("请提供 content 参数".to_string()); } + if content.is_empty() { + return Ok("请提供 content 参数".to_string()); + } return Ok(memory_store.write(content).await); } "read_summaries" => return Ok(context::tools::read_summaries(&session).await), @@ -321,9 +343,12 @@ async fn cmd_listen( } let user_id = current_user.lock().await.clone(); if let Some(ref reg) = reg { - let params: serde_json::Value = serde_json::from_str(&args).unwrap_or(serde_json::json!({})); + let params: serde_json::Value = + serde_json::from_str(&args).unwrap_or(serde_json::json!({})); let mut ctx = ExecutionContext::new(&user_id); - if let Some(a) = approval { ctx = ctx.with_approval(a); } + if let Some(a) = approval { + ctx = ctx.with_approval(a); + } ctx = ctx.with_wechat(send); let result = reg.execute(&n, params, &ctx).await; Ok(result.output) @@ -347,17 +372,19 @@ async fn cmd_listen( let sched_client = client.clone(); tokio::spawn(async move { let scheduler = scheduler::Scheduler::new(Some(Arc::new(sched_db))); - scheduler.run(move |to: &str, _name: &str, msg: &str| { - let c = sched_client.clone(); - let uid = to.to_string(); - let text = msg.to_string(); - tokio::task::spawn(async move { - if let Err(e) = c.send_text(&uid, &text, None).await { - tracing::error!("发送调度结果失败: {}", e); - } - }); - Ok(()) - }).await; + scheduler + .run(move |to: &str, _name: &str, msg: &str| { + let c = sched_client.clone(); + let uid = to.to_string(); + let text = msg.to_string(); + tokio::task::spawn(async move { + if let Err(e) = c.send_text(&uid, &text, None).await { + tracing::error!("发送调度结果失败: {}", e); + } + }); + Ok(()) + }) + .await; }); info!("定时任务调度器已启动"); } @@ -483,14 +510,24 @@ async fn listen_loop( generate_reply(&conv, text_owned, &database).await }; let reply = reply_result.ok().unwrap_or_default(); - match client.send_text(&from_owned, &reply, ctx_owned.as_deref()).await { + match client + .send_text(&from_owned, &reply, ctx_owned.as_deref()) + .await + { Ok(sent_id) => { info!("LLM 回复成功 msg_id={}", sent_id); if let Some(db) = database { let _ = db::models::insert_chat_record( - db.pool(), "outbound", &from_owned, &aid, - &reply, "llm", ctx_owned.as_deref(), &sent_id, - ).await; + db.pool(), + "outbound", + &from_owned, + &aid, + &reply, + "llm", + ctx_owned.as_deref(), + &sent_id, + ) + .await; } } Err(e) => error!("发送 LLM 回复失败: {}", e), @@ -510,12 +547,21 @@ async fn listen_loop( } } -async fn generate_reply(conv: &Conversation, user_text: String, db: &Option>) -> Result { +async fn generate_reply( + conv: &Conversation, + user_text: String, + db: &Option>, +) -> Result { let handle = conv.chat(user_text).await?; if let Some(u) = handle.get_usage() { - info!("📊 Token: {} 总 / {} 提示 / {} 生成 | 缓存命中 {} | 缓存未命中 {}", - u.total_tokens, u.prompt_tokens, u.completion_tokens, - u.prompt_cache_hit_tokens, u.prompt_cache_miss_tokens); + info!( + "📊 Token: {} 总 / {} 提示 / {} 生成 | 缓存命中 {} | 缓存未命中 {}", + u.total_tokens, + u.prompt_tokens, + u.completion_tokens, + u.prompt_cache_hit_tokens, + u.prompt_cache_miss_tokens + ); store_usage(db, "", conv.model(), conv.provider_name(), u).await; } handle.consume().await @@ -528,28 +574,42 @@ async fn generate_reply_with_tools( ) -> Result { let (reply, _used_tools, usage) = conv.chat_with_tools(user_text).await?; if let Some(u) = &usage { - info!("📊 Token: {} 总 / {} 提示 / {} 生成 | 缓存命中 {} | 缓存未命中 {}", - u.total_tokens, u.prompt_tokens, u.completion_tokens, - u.prompt_cache_hit_tokens, u.prompt_cache_miss_tokens); + info!( + "📊 Token: {} 总 / {} 提示 / {} 生成 | 缓存命中 {} | 缓存未命中 {}", + u.total_tokens, + u.prompt_tokens, + u.completion_tokens, + u.prompt_cache_hit_tokens, + u.prompt_cache_miss_tokens + ); store_usage(db, "", conv.model(), conv.provider_name(), u).await; } Ok(reply) } -async fn store_usage(db: &Option>, user_id: &str, model: &str, provider: &str, u: &Usage) { +async fn store_usage( + db: &Option>, + user_id: &str, + model: &str, + provider: &str, + u: &Usage, +) { if let Some(db) = db { let _ = db::models::insert_llm_usage( - db.pool(), user_id, model, provider, - u.prompt_tokens, u.completion_tokens, - u.prompt_cache_hit_tokens, u.prompt_cache_miss_tokens, - ).await; + db.pool(), + user_id, + model, + provider, + u.prompt_tokens, + u.completion_tokens, + u.prompt_cache_hit_tokens, + u.prompt_cache_miss_tokens, + ) + .await; } } -async fn cmd_whoami( - database: &Option>, - file_state: &state::StateManager, -) { +async fn cmd_whoami(database: &Option>, file_state: &state::StateManager) { let auth: Option = if let Some(db) = database { db::models::load_auth(db.pool()).await } else { @@ -579,31 +639,61 @@ async fn cmd_usage( ) { let db = match database { Some(d) => d, - None => { println!("数据库未配置,无法查询用量"); return; } + None => { + println!("数据库未配置,无法查询用量"); + return; + } }; let since_dt = since.and_then(|s| { - chrono::DateTime::parse_from_rfc3339(&s).ok().map(|d| d.with_timezone(&chrono::Utc)) + chrono::DateTime::parse_from_rfc3339(&s) + .ok() + .map(|d| d.with_timezone(&chrono::Utc)) }); let until_dt = until.and_then(|s| { - chrono::DateTime::parse_from_rfc3339(&s).ok().map(|d| d.with_timezone(&chrono::Utc)) + chrono::DateTime::parse_from_rfc3339(&s) + .ok() + .map(|d| d.with_timezone(&chrono::Utc)) }); let model_ref = model.as_deref(); match db::models::query_llm_usage_stats(db.pool(), since_dt, until_dt, model_ref).await { Ok(stats) => { let hit_rate = if stats.total_cache_hit + stats.total_cache_miss > 0 { - stats.total_cache_hit as f64 / (stats.total_cache_hit + stats.total_cache_miss) as f64 * 100.0 - } else { 0.0 }; + stats.total_cache_hit as f64 + / (stats.total_cache_hit + stats.total_cache_miss) as f64 + * 100.0 + } else { + 0.0 + }; println!("📊 LLM Token 使用统计"); println!("{:=<40}", ""); println!("调用次数: {}", stats.total_calls); - println!("Prompt Tokens: {} ({:.1}K)", stats.total_prompt_tokens, stats.total_prompt_tokens as f64 / 1000.0); - println!("生成 Tokens: {} ({:.1}K)", stats.total_completion_tokens, stats.total_completion_tokens as f64 / 1000.0); - println!("总 Tokens: {} ({:.1}K)", stats.total_tokens, stats.total_tokens as f64 / 1000.0); - println!("缓存命中: {} ({:.1}%)", stats.total_cache_hit, hit_rate); - println!("缓存未命中: {} ({:.1}%)", stats.total_cache_miss, 100.0 - hit_rate); + println!( + "Prompt Tokens: {} ({:.1}K)", + stats.total_prompt_tokens, + stats.total_prompt_tokens as f64 / 1000.0 + ); + println!( + "生成 Tokens: {} ({:.1}K)", + stats.total_completion_tokens, + stats.total_completion_tokens as f64 / 1000.0 + ); + println!( + "总 Tokens: {} ({:.1}K)", + stats.total_tokens, + stats.total_tokens as f64 / 1000.0 + ); + println!( + "缓存命中: {} ({:.1}%)", + stats.total_cache_hit, hit_rate + ); + println!( + "缓存未命中: {} ({:.1}%)", + stats.total_cache_miss, + 100.0 - hit_rate + ); } Err(e) => println!("查询失败: {}", e), } diff --git a/src/scheduler.rs b/src/scheduler.rs index 80093ac..599acf5 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -1,4 +1,3 @@ -use chrono::Utc; use sqlx::PgPool; use std::sync::Arc; use std::time::Duration; @@ -16,10 +15,7 @@ impl Scheduler { /// 启动调度循环(在 listen_loop 中作为 tokio::spawn 调用) /// 每 5 秒检查一次是否有到期任务,执行后通过 callback 发送结果 - pub async fn run( - &self, - mut on_task: impl FnMut(&str, &str, &str) -> Result<(), String>, - ) { + pub async fn run(&self, mut on_task: impl FnMut(&str, &str, &str) -> Result<(), String>) { let pool = match self.pool.as_ref() { Some(p) => p.clone(), None => { @@ -52,11 +48,7 @@ impl Scheduler { let _ = update_task_status(&pool, &task.id, &result).await; // 通知用户 - let notify_msg = format!( - "⏰ 定时任务: {}\n结果: {}", - task.name, - result - ); + let notify_msg = format!("⏰ 定时任务: {}\n结果: {}", task.name, result); if let Err(e) = on_task(&task.user_id, &task.name, ¬ify_msg) { tracing::error!("发送定时任务通知失败: {}", e); } @@ -133,7 +125,11 @@ async fn execute_task(task: &DueTask) -> String { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); if output.status.success() { - if stdout.is_empty() { "执行成功(无输出)".to_string() } else { stdout } + if stdout.is_empty() { + "执行成功(无输出)".to_string() + } else { + stdout + } } else { format!( "退出码 {}: {}", @@ -146,7 +142,11 @@ async fn execute_task(task: &DueTask) -> String { } } -async fn update_task_status(pool: &PgPool, task_id: &uuid::Uuid, result: &str) -> Result<(), String> { +async fn update_task_status( + pool: &PgPool, + task_id: &uuid::Uuid, + result: &str, +) -> Result<(), String> { // 更新 last_run_at 和 next_run_at sqlx::query( r#" diff --git a/src/tools/parser.rs b/src/tools/parser.rs index b522bd9..4d83f9d 100644 --- a/src/tools/parser.rs +++ b/src/tools/parser.rs @@ -211,10 +211,11 @@ mod tests { )); match result { Ok(registry) => { - assert_eq!(registry.count(), 8, "应该有 8 个系统技能"); + assert_eq!(registry.count(), 9, "应该有 9 个系统技能"); let names: Vec<&str> = registry.list_specs().iter().map(|s| s.name.as_str()).collect(); println!("已加载技能: {:?}", names); assert!(names.contains(&"manage_memos")); + assert!(names.contains(&"manage_skill")); assert!(names.contains(&"get_current_datetime")); assert!(names.contains(&"query_weather")); assert!(names.contains(&"search_web")); diff --git a/src/tools/registry.rs b/src/tools/registry.rs index b6ddf19..54d44eb 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -34,7 +34,15 @@ impl SkillRegistry { registry.load_from_dir(system_dir, SkillSource::System, &mut errors); } - // 加载用户 skills + // 加载全局 skills (~/.ias/skills/) + if let Ok(home) = std::env::var("HOME") { + let global_dir = format!("{}/.ias/skills", home); + if Path::new(&global_dir).exists() { + registry.load_from_dir(&global_dir, SkillSource::User, &mut errors); + } + } + + // 加载用户 skills(项目目录) if Path::new(user_dir).exists() { registry.load_from_dir(user_dir, SkillSource::User, &mut errors); } @@ -110,6 +118,13 @@ impl SkillRegistry { self.skills.len() } + /// 动态注册一个技能(运行时添加,不持久化到文件) + pub fn register_skill(&mut self, spec: SkillSpec, dir: std::path::PathBuf, execute_command: String) { + let skill = Arc::new(Skill::new(spec, SkillSource::User, dir, execute_command)); + let name = skill.spec.name.clone(); + self.skills.insert(name, skill); + } + // ─── 执行 ─── /// 执行技能(含审批流程)