feat: manage_skill + 全局 skills 目录

新增:
- manage_skill: LLM 可调用的 Skill 创建/删除/列出工具 (High)
- 全局 skills 支持: ~/.ias/skills/ 自动加载
- SkillRegistry::register_skill() 动态注册
- SkillRegistry::load() 三元加载: system → global → user

manage_skill 操作:
  create: 写 SKILL.md + 执行脚本到 skills/<name>/
  delete: 移除 skills/<name>/
  list: 列出所有自定义 Skill

目前一共 9 个系统 Skills
This commit is contained in:
2026-06-01 21:08:38 +08:00
parent a31afaba80
commit f51b5c100f
7 changed files with 342 additions and 81 deletions
+49
View File
@@ -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 Schemacreate 时需要,JSON 字符串)"
},
"risk_level": {
"type": "string",
"description": "风险等级 Low|High,默认 Low",
"enum": ["Low", "High"]
}
},
"required": ["action"]
}
```
## Execute
```bash
scripts/manage.sh
```
+106
View File
@@ -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
+9 -9
View File
@@ -77,12 +77,14 @@ impl ChatSession {
/// 记录一条带 tool_calls 的助手消息
pub fn add_assistant_tool_calls(&mut self, tool_calls: Vec<crate::llm::types::ToolCall>) {
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<Message> = rows
let msgs: Vec<Message> = rows
.into_iter()
.rev() // 恢复时间顺序
.map(|(dir, _uid, text, _mid)| {
+148 -58
View File
@@ -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<Arc<Database>>) -> Result<String, String> {
async fn generate_reply(
conv: &Conversation,
user_text: String,
db: &Option<Arc<Database>>,
) -> Result<String, String> {
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<String, String> {
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<Arc<Database>>, user_id: &str, model: &str, provider: &str, u: &Usage) {
async fn store_usage(
db: &Option<Arc<Database>>,
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<Arc<Database>>,
file_state: &state::StateManager,
) {
async fn cmd_whoami(database: &Option<Arc<Database>>, file_state: &state::StateManager) {
let auth: Option<state::AuthState> = 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),
}
+12 -12
View File
@@ -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, &notify_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#"
+2 -1
View File
@@ -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"));
+16 -1
View File
@@ -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);
}
// ─── 执行 ───
/// 执行技能(含审批流程)