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:
@@ -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
|
||||
```
|
||||
+106
@@ -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
|
||||
@@ -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
@@ -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
@@ -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#"
|
||||
|
||||
+2
-1
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
// ─── 执行 ───
|
||||
|
||||
/// 执行技能(含审批流程)
|
||||
|
||||
Reference in New Issue
Block a user