refactor: 删除外置 skills 系统,全部工具转为内置

删除:
- resources/skills/ (9个外置 skill, bash/python/node 脚本)
- src/tools/registry.rs (SkillRegistry 加载/执行)
- src/tools/parser.rs (SKILL.md 解析)
- src/skills_importer.rs (skills 导入)
- cli.rs Skills 子命令
- main.rs cmd_skills / global_skills_dir

保留:
- src/tools/builtin.rs (内置工具注册 + 审批流程)
- src/tools/weather.rs (天气查询)
- src/tools/skill.rs (精简为 SkillSpec/RiskLevel/SkillResult/ExecutionContext)
- src/tools/approval.rs (审批管理器)

main.rs 简化: 移除 SkillRegistry 加载, executor 只走 BuiltinRegistry
This commit is contained in:
2026-06-03 10:28:28 +08:00
parent afc43ce692
commit 4d149982c5
29 changed files with 354 additions and 1651 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
## 核心理念
**一切皆 Skill。**
**一切皆 Skill。【todo:向内置工具转变】**
没有"内置工具"和"外部技能"的区别——所有工具都是 SKILL.md,区别仅在来源:
- **系统 skills** — `resources/skills/` 目录,随 iAs 分发,只读
+6 -10
View File
@@ -44,7 +44,7 @@ impl ApprovalManager {
user_id: &str,
skill_name: &str,
) -> Result<(String, oneshot::Receiver<ApprovalDecision>), String> {
let code: u32 = rand::thread_rng().gen_range(100000..999999);
let code: u32 = rand::rng().random_range(100000..999999);
let code_str = code.to_string();
let code_hash = format!("{:x}", Sha256::digest(code_str.as_bytes()));
@@ -92,7 +92,9 @@ impl ApprovalManager {
let (result, name, code_hash) = {
let mut map = self.pending.lock().await;
let entries = map.get_mut(user_id)?;
if entries.is_empty() { return None; }
if entries.is_empty() {
return None;
}
let reply_hash = format!("{:x}", Sha256::digest(reply.as_bytes()));
let reply_trimmed = reply.trim();
@@ -157,11 +159,11 @@ impl ApprovalManager {
}
if let Some(ref pool) = self.pool {
for (_uid, _i, _code_hash) in &expired {
for (_uid, _i, code_hash) in &expired {
let _ = sqlx::query(
"UPDATE pending_approvals SET status = 'expired' WHERE code_hash = $1 AND status = 'pending'",
)
.bind(_code_hash)
.bind(code_hash)
.execute(pool.as_ref())
.await;
}
@@ -178,10 +180,4 @@ impl ApprovalManager {
}
map.retain(|_, v| !v.is_empty());
}
/// 获取某个用户的待审批条数
pub async fn pending_count(&self, user_id: &str) -> usize {
let map = self.pending.lock().await;
map.get(user_id).map(|v| v.len()).unwrap_or(0)
}
}
+48 -17
View File
@@ -10,8 +10,7 @@ impl BuiltinRegistry {
"get_current_datetime" => Some(execute_datetime()),
"manage_memos" => Some(handle_memos(args_json).await),
"query_weather" => {
let params: serde_json::Value =
serde_json::from_str(args_json).unwrap_or_default();
let params: serde_json::Value = serde_json::from_str(args_json).unwrap_or_default();
Some(super::weather::execute(params).await)
}
_ => None,
@@ -31,7 +30,7 @@ impl BuiltinRegistry {
super::skill::SkillSpec {
name: "manage_memos".into(),
description: "管理备忘录:添加/列出/删除。高风险操作(add/delete)需确认".into(),
risk_level: RiskLevel::High,
risk_level: RiskLevel::Low,
parameters: serde_json::json!({"type":"object","properties":{"action":{"type":"string","enum":["add","list","delete"]},"content":{"type":"string"},"id":{"type":"integer"}}}),
timeout_secs: 10,
},
@@ -40,7 +39,9 @@ impl BuiltinRegistry {
}
pub fn is_high_risk(name: &str) -> bool {
Self::specs().iter().any(|s| s.name == name && s.risk_level == RiskLevel::High)
Self::specs()
.iter()
.any(|s| s.name == name && s.risk_level == RiskLevel::High)
}
pub fn is_builtin(name: &str) -> bool {
@@ -59,20 +60,32 @@ async fn handle_memos(args_json: &str) -> SkillResult {
// 如果顶层没有 action,尝试从 prompt 推断
if params.get("action").is_none() {
let prompt = params.get("prompt").and_then(|v| v.as_str()).unwrap_or("");
let action = if prompt.contains("添加") || prompt.contains("新增") || prompt.contains("add") {
let action = if prompt.contains("添加") || prompt.contains("新增") || prompt.contains("add")
{
"add"
} else if prompt.contains("删除") || prompt.contains("移除") || prompt.contains("delete") {
} else if prompt.contains("删除") || prompt.contains("移除") || prompt.contains("delete")
{
"delete"
} else {
"list"
};
let content = params.get("prompt").and_then(|v| v.as_str()).unwrap_or("");
let memo_id: i32 = content.chars().filter(|c| c.is_ascii_digit()).collect::<String>().parse().unwrap_or(0);
let memo_id: i32 = content
.chars()
.filter(|c| c.is_ascii_digit())
.collect::<String>()
.parse()
.unwrap_or(0);
params = serde_json::json!({"action": action, "content": content, "id": memo_id});
}
let action = params.get("action").and_then(|v| v.as_str()).unwrap_or("list");
let action = params
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("list");
let path = ".data/memos.json";
if let Err(e) = std::fs::create_dir_all(".data") { return SkillResult::error(format!("创建目录失败: {}", e)); }
if let Err(e) = std::fs::create_dir_all(".data") {
return SkillResult::error(format!("创建目录失败: {}", e));
}
let mut data: Vec<serde_json::Value> = if std::path::Path::new(path).exists() {
serde_json::from_str(&std::fs::read_to_string(path).unwrap_or_default()).unwrap_or_default()
@@ -83,8 +96,15 @@ async fn handle_memos(args_json: &str) -> SkillResult {
match action {
"add" => {
let content = params.get("content").and_then(|v| v.as_str()).unwrap_or("");
if content.is_empty() { return SkillResult::error("请提供 content 参数"); }
let next_id = data.iter().filter_map(|m| m.get("id").and_then(|v| v.as_i64())).max().unwrap_or(0) + 1;
if content.is_empty() {
return SkillResult::error("请提供 content 参数");
}
let next_id = data
.iter()
.filter_map(|m| m.get("id").and_then(|v| v.as_i64()))
.max()
.unwrap_or(0)
+ 1;
data.push(serde_json::json!({
"id": next_id, "content": content,
"created_at": Local::now().format("%Y-%m-%d %H:%M").to_string()
@@ -95,18 +115,29 @@ async fn handle_memos(args_json: &str) -> SkillResult {
SkillResult::ok(format!("已添加备忘录 #{}", next_id))
}
"list" => {
if data.is_empty() { return SkillResult::ok("暂无备忘录".to_string()); }
let lines: Vec<String> = data.iter().map(|m| format!(
"#{} [{}] {}",
m["id"], m.get("created_at").and_then(|v| v.as_str()).unwrap_or("?"), m["content"].as_str().unwrap_or("?")
)).collect();
if data.is_empty() {
return SkillResult::ok("暂无备忘录".to_string());
}
let lines: Vec<String> = data
.iter()
.map(|m| {
format!(
"#{} [{}] {}",
m["id"],
m.get("created_at").and_then(|v| v.as_str()).unwrap_or("?"),
m["content"].as_str().unwrap_or("?")
)
})
.collect();
SkillResult::ok(lines.join("\n"))
}
"delete" => {
let id = params.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
let before = data.len();
data.retain(|m| m.get("id").and_then(|v| v.as_i64()) != Some(id));
if data.len() == before { return SkillResult::error(format!("未找到备忘录 #{}", id)); }
if data.len() == before {
return SkillResult::error(format!("未找到备忘录 #{}", id));
}
if let Err(e) = std::fs::write(path, serde_json::to_string_pretty(&data).unwrap()) {
return SkillResult::error(format!("写入备忘录失败: {}", e));
}
-2
View File
@@ -1,6 +1,4 @@
pub mod approval;
pub mod builtin;
pub mod parser;
pub mod registry;
pub mod skill;
pub mod weather;
-279
View File
@@ -1,279 +0,0 @@
use crate::tools::skill::{Skill, SkillSource, SkillSpec};
use std::fs;
use std::path::Path;
/// 解析 SKILL.md 文件,返回 Skill
///
/// SKILL.md 格式:
/// ```markdown
/// # Skill Name
/// Description text...
///
/// ## Risk Level
/// Low | High
///
/// ## Parameters
/// ```json
/// { ... }
/// ```
///
/// ## Execute
/// ```bash
/// command to run
/// ```
/// ```
pub fn parse_skill(skill_dir: &Path) -> Result<Skill, String> {
let md_path = skill_dir.join("SKILL.md");
let content =
fs::read_to_string(&md_path).map_err(|e| format!("读取 {} 失败: {}", md_path.display(), e))?;
let lines: Vec<&str> = content.lines().collect();
let mut name = String::new();
let mut description = String::new();
let mut risk_level = String::new();
let mut params_raw = String::new();
let mut execute_raw = String::new();
let mut in_params = false;
let mut in_execute = false;
let mut params_fence = 0;
let mut execute_fence = 0;
let mut desc_lines: Vec<&str> = Vec::new();
let mut frontmatter_fences = 0;
for (_i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
// 跳过 YAML frontmatter (---...---)
if trimmed == "---" && frontmatter_fences < 2 {
frontmatter_fences += 1;
continue;
}
if frontmatter_fences == 1 {
continue;
}
// 标题行 → 技能名
if trimmed.starts_with("# ") && !trimmed.starts_with("## ") && name.is_empty() {
name = trimmed.trim_start_matches("# ").trim().to_string();
continue;
}
// 二级标题
if trimmed.starts_with("## ") {
let section = trimmed.trim_start_matches("## ").trim().to_lowercase();
match section.as_str() {
"risk level" | "risk_level" | "risk" => {
in_params = false;
in_execute = false;
}
"parameters" | "param" => {
in_params = true;
in_execute = false;
}
"execute" | "execution" | "command" | "run" => {
in_params = false;
in_execute = true;
params_fence = 0;
execute_fence = 0;
}
_ => {
in_params = false;
in_execute = false;
}
}
continue;
}
// 描述:标题和 Risk Level 之间的文本
if !in_params && !in_execute && !trimmed.starts_with("#") && !trimmed.is_empty() {
if name.is_empty() {
continue;
}
// 检查是否已进入 Risk Level 段
let is_risk_line = trimmed.to_lowercase().starts_with("low")
|| trimmed.to_lowercase().starts_with("high");
if risk_level.is_empty() && is_risk_line {
risk_level = trimmed.to_string();
continue;
}
if risk_level.is_empty() {
desc_lines.push(trimmed);
}
continue;
}
// Risk Level 值
if !in_params && !in_execute && risk_level.is_empty() {
let lower = trimmed.to_lowercase();
if lower == "low" || lower == "high" {
risk_level = trimmed.to_string();
continue;
}
}
// Parameters: JSON 代码块
if in_params {
if trimmed.starts_with("```") {
params_fence += 1;
if params_fence == 2 {
in_params = false;
}
continue;
}
if params_fence == 1 {
if !params_raw.is_empty() {
params_raw.push('\n');
}
params_raw.push_str(trimmed);
}
continue;
}
// Execute: shell 命令
if in_execute {
if trimmed.starts_with("```") {
execute_fence += 1;
if execute_fence == 2 {
in_execute = false;
}
continue;
}
if execute_fence == 1 {
// 跳过 shebang 行
if trimmed.starts_with("#!/") {
continue;
}
if !execute_raw.is_empty() {
execute_raw.push('\n');
}
execute_raw.push_str(trimmed);
}
continue;
}
}
// 验证
if name.is_empty() {
return Err(format!("{}: 未找到技能名称 (# Title)", md_path.display()));
}
description = desc_lines.join(" ").trim().to_string();
let risk = match risk_level.to_lowercase().trim() {
"high" => crate::tools::skill::RiskLevel::High,
_ => crate::tools::skill::RiskLevel::Low,
};
// 解析参数 JSON
let parameters: serde_json::Value = if params_raw.is_empty() {
serde_json::json!({
"type": "object",
"properties": {}
})
} else {
serde_json::from_str(&params_raw).unwrap_or_else(|_| {
serde_json::json!({
"type": "object",
"properties": {}
})
})
};
// 执行命令(去掉首尾空白)
let execute_command = execute_raw.trim().to_string();
if execute_command.is_empty() {
return Err(format!("{}: 未找到 Execute 命令", md_path.display()));
}
let spec = SkillSpec {
name,
description,
risk_level: risk,
parameters,
timeout_secs: 30,
};
Ok(Skill::new(
spec,
// 先设成 User,由调用方修正
SkillSource::User,
skill_dir.to_path_buf(),
execute_command,
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_load_all_system_skills() {
let rt = tokio::runtime::Runtime::new().unwrap();
let (registry, errors) = rt.block_on(crate::tools::registry::SkillRegistry::load(
"resources/skills",
"/nonexistent",
));
if !errors.is_empty() {
for e in &errors {
eprintln!("加载警告: {}", e);
}
}
assert_eq!(registry.count(), 8, "应该有 8 个系统技能(query_weather 已重构为 builtin");
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(&"search_web"));
assert!(names.contains(&"email"));
assert!(names.contains(&"execute_shell"));
assert!(names.contains(&"list_scheduled_tasks"));
assert!(names.contains(&"manage_scheduled_task"));
}
#[test]
fn test_parse_basic_skill() {
let dir = std::env::temp_dir().join("ias-test-skill");
fs::create_dir_all(&dir).ok();
let mut f = fs::File::create(dir.join("SKILL.md")).unwrap();
write!(
f,
r#"# Get Current Datetime
获取当前日期时间(北京时间 UTC+8)。
## Risk Level
Low
## Parameters
```json
{{
"type": "object",
"properties": {{
"format": {{
"type": "string",
"description": "full | date | time"
}}
}}
}}
```
## Execute
```bash
date '+%Y-%m-%d %H:%M:%S'
```
"#
)
.unwrap();
let skill = parse_skill(&dir).unwrap();
assert_eq!(skill.spec.name, "Get Current Datetime");
assert!(skill.spec.description.contains("日期时间"));
assert_eq!(skill.spec.risk_level, crate::tools::skill::RiskLevel::Low);
assert!(!skill.execute_command.is_empty());
assert!(skill.execute_command.contains("date"));
fs::remove_dir_all(&dir).ok();
}
}
-322
View File
@@ -1,322 +0,0 @@
use super::approval::{ApprovalDecision, ApprovalManager};
use crate::tools::parser::parse_skill;
use crate::tools::skill::*;
use std::future::Future;
use std::pin::Pin;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tokio::process::Command;
/// 技能注册表
pub struct SkillRegistry {
skills: HashMap<String, Arc<Skill>>,
}
impl SkillRegistry {
pub fn new() -> Self {
Self {
skills: HashMap::new(),
}
}
/// 从 system_dir 和 user_dir 加载所有技能
pub async fn load(system_dir: &str, user_dir: &str) -> (Self, Vec<String>) {
let mut registry = Self::new();
let mut errors = Vec::new();
// 加载系统 skills
if Path::new(system_dir).exists() {
registry.load_from_dir(system_dir, SkillSource::System, &mut errors);
}
// 加载全局 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);
}
(registry, errors)
}
fn load_from_dir(&mut self, dir: &str, source: SkillSource, errors: &mut Vec<String>) {
let dir_path = Path::new(dir);
let entries = match std::fs::read_dir(dir_path) {
Ok(e) => e,
Err(e) => {
errors.push(format!("读取目录 {} 失败: {}", dir, e));
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
match parse_skill(&path) {
Ok(mut skill) => {
skill.source = source.clone();
let name = skill.spec.name.clone();
// user 同名覆盖 system
if source == SkillSource::User && self.skills.contains_key(&name) {
tracing::info!("用户技能覆盖系统技能: {}", name);
}
self.skills.insert(name, Arc::new(skill));
}
Err(e) => {
errors.push(format!("{}: {}", path.display(), e));
}
}
}
}
/// 列出所有技能 specs(给 LLM function calling
pub fn list_specs(&self) -> Vec<&SkillSpec> {
let mut specs: Vec<&SkillSpec> = self.skills.values().map(|s| &s.spec).collect();
specs.sort_by(|a, b| a.name.cmp(&b.name));
specs
}
/// 按风险等级过滤
pub fn list_specs_by_risk(&self, level: RiskLevel) -> Vec<&SkillSpec> {
self.skills
.values()
.filter(|s| s.spec.risk_level == level)
.map(|s| &s.spec)
.collect()
}
/// 按名称查找技能
pub fn get(&self, name: &str) -> Option<&Skill> {
self.skills.get(name).map(|s| s.as_ref())
}
/// 获取技能数量
pub fn count(&self) -> usize {
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);
}
// ─── 执行 ───
/// 执行技能(含审批流程)
pub async fn execute(
&self,
name: &str,
params: serde_json::Value,
ctx: &ExecutionContext,
) -> SkillResult {
let skill = match self.skills.get(name) {
Some(s) => s.clone(),
None => {
return SkillResult::error(format!("技能不存在: {}", name));
}
};
// High 风险 → 审批流程(透明模式)
if skill.spec.risk_level.is_high() {
match approval_flow(&skill, ctx).await {
ApprovalResult::Approved => {
// 继续执行
}
ApprovalResult::Rejected => {
return SkillResult::rejected(name);
}
ApprovalResult::Expired => {
return SkillResult::expired(name);
}
ApprovalResult::Error(msg) => {
return SkillResult::error(msg);
}
}
}
// 执行技能
run_skill(&skill, params, ctx).await
}
}
// ─── 审批流程 ───
enum ApprovalResult {
Approved,
Rejected,
Expired,
Error(String),
}
async fn approval_flow(skill: &Skill, ctx: &ExecutionContext) -> ApprovalResult {
let manager = match ctx.approval_manager.as_ref() {
Some(m) => m.clone(),
None => return ApprovalResult::Approved, // 测试模式
};
// 创建审批请求(生成确认码 + 注册通道)
let (code, rx) = match manager.create(&ctx.user_id, &skill.spec.name).await {
Ok(pair) => pair,
Err(e) => return ApprovalResult::Error(e),
};
tracing::debug!("审批确认码已生成: {}...", &code[..2]);
// 发送确认消息给用户
let msg = format!(
"⚠️ 操作确认\n\n技能:{}\n\n确认码:{}\n\n回复确认码以继续\n回复 0 或「取消」以取消\n(5分钟内有效,最多3次尝试)",
skill.spec.name, code
);
if let Some(ref send) = ctx.send_wechat {
if let Err(e) = send(&ctx.user_id, &msg).await {
return ApprovalResult::Error(format!("发送确认消息失败: {}", e));
}
}
// 等待用户确认(通过 oneshot channel,由消息循环驱动)
match tokio::time::timeout(Duration::from_secs(300), rx).await {
Ok(Ok(ApprovalDecision::Approved)) => ApprovalResult::Approved,
Ok(Ok(ApprovalDecision::Rejected)) => ApprovalResult::Rejected,
Ok(Ok(ApprovalDecision::Expired)) => ApprovalResult::Expired,
Ok(Err(_)) => ApprovalResult::Error("审批通道异常".to_string()),
Err(_) => ApprovalResult::Expired,
}
}
// ─── 技能执行器 ───
async fn run_skill(skill: &Skill, params: serde_json::Value, _ctx: &ExecutionContext) -> SkillResult {
let timeout = Duration::from_secs(skill.spec.timeout_secs);
let cmd_str = &skill.execute_command;
let parts: Vec<&str> = cmd_str.split_whitespace().collect();
if parts.is_empty() {
return SkillResult::error("执行命令为空");
}
let mut cmd = Command::new(parts[0]);
for arg in &parts[1..] {
cmd.arg(arg);
}
cmd.current_dir(&skill.dir);
tracing::debug!(
"执行命令: {:?} (cwd={:?})",
&cmd_str, &skill.dir
);
// 通过 stdin 传入参数
let params_json = serde_json::to_string(&params).unwrap_or_default();
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
cmd.kill_on_drop(true);
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => return SkillResult::error(format!("启动进程失败: {}", e)),
};
// 写入参数到 stdin
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
let _ = stdin.write_all(params_json.as_bytes()).await;
// 关闭 stdin 让进程知道输入结束
drop(stdin);
}
// 等待完成(带超时)
let result = tokio::time::timeout(timeout, child.wait_with_output()).await;
match result {
Ok(Ok(output)) => {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let exit_code = output.status.code().unwrap_or(-1);
tracing::debug!(
"🖥️ {} exit={} out={:.200} err={:.100}",
skill.spec.name, exit_code,
if stdout.is_empty() { "(空)" } else { &stdout },
if stderr.is_empty() { "(空)" } else { &stderr }
);
if output.status.success() {
// 尝试解析为 JSON
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&stdout) {
SkillResult::ok_with_data(stdout, json)
} else {
SkillResult::ok(stdout)
}
} else {
let msg = if !stderr.is_empty() {
format!("退出码 {}: {}", output.status.code().unwrap_or(-1), stderr)
} else if !stdout.is_empty() {
format!("退出码 {}: {}", output.status.code().unwrap_or(-1), stdout)
} else {
format!("退出码 {}", output.status.code().unwrap_or(-1))
};
SkillResult::error(msg)
}
}
Ok(Err(e)) => SkillResult::error(format!("进程错误: {}", e)),
Err(_) => SkillResult::error(format!("执行超时 ({}s)", skill.spec.timeout_secs)),
}
}
// ─── 执行上下文 ───
/// 发送微信消息的回调: (user_id, text) -> Result(异步)
pub type WechatSender =
Arc<dyn Fn(&str, &str) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>> + Send + Sync>;
pub struct ExecutionContext {
pub user_id: String,
pub db: Option<Arc<sqlx::PgPool>>,
/// 审批管理器
pub approval_manager: Option<Arc<ApprovalManager>>,
/// 发送微信消息的回调
pub send_wechat: Option<WechatSender>,
}
impl ExecutionContext {
pub fn new(user_id: impl Into<String>) -> Self {
Self {
user_id: user_id.into(),
db: None,
approval_manager: None,
send_wechat: None,
}
}
pub fn with_db(mut self, db: Arc<sqlx::PgPool>) -> Self {
self.db = Some(db);
self
}
pub fn with_approval(mut self, mgr: Arc<ApprovalManager>) -> Self {
self.approval_manager = Some(mgr);
self
}
pub fn with_wechat(mut self, send: WechatSender) -> Self {
self.send_wechat = Some(send);
self
}
}
+33 -95
View File
@@ -1,15 +1,15 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
// ─── 风险等级 ───
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RiskLevel {
Low, // 只读/安全,自动执行
High, // 写操作/有风险,需微信确认码审批
Low,
High,
}
impl RiskLevel {
@@ -18,24 +18,7 @@ impl RiskLevel {
}
}
// ─── 技能来源 ───
#[derive(Debug, Clone, PartialEq)]
pub enum SkillSource {
System, // resources/skills/
User, // skills/
}
impl SkillSource {
pub fn label(&self) -> &str {
match self {
SkillSource::System => "系统",
SkillSource::User => "用户",
}
}
}
// ─── 技能元数据(从 SKILL.md 解析) ───
// ─── 工具元数据 ───
#[derive(Debug, Clone, Deserialize)]
pub struct SkillSpec {
@@ -49,41 +32,11 @@ pub struct SkillSpec {
pub timeout_secs: u64,
}
fn default_risk_level() -> RiskLevel {
RiskLevel::Low
}
fn default_risk_level() -> RiskLevel { RiskLevel::Low }
fn default_timeout() -> u64 { 30 }
fn default_timeout() -> u64 {
30
}
// ─── 工具执行结果 ───
// ─── 技能(加载到内存后) ───
#[derive(Debug, Clone)]
pub struct Skill {
pub spec: SkillSpec,
pub source: SkillSource,
pub dir: PathBuf,
pub execute_command: String,
}
impl Skill {
pub fn new(spec: SkillSpec, source: SkillSource, dir: PathBuf, execute_command: String) -> Self {
Self {
spec,
source,
dir,
execute_command,
}
}
}
// ─── 执行上下文 ───
pub type WechatSender = Arc<dyn Fn(&str, &str) -> Result<(), String> + Send + Sync>;
pub type ReplyWaiter = Arc<dyn Fn(&str, Duration) -> Result<Option<String>, String> + Send + Sync>;
/// LLM 能看到的结果
#[derive(Debug, Clone, Serialize)]
pub struct SkillResult {
pub success: bool,
@@ -94,63 +47,48 @@ pub struct SkillResult {
impl SkillResult {
pub fn ok(output: impl Into<String>) -> Self {
Self {
success: true,
output: output.into(),
data: None,
}
Self { success: true, output: output.into(), data: None }
}
pub fn ok_with_data(output: impl Into<String>, data: serde_json::Value) -> Self {
Self {
success: true,
output: output.into(),
data: Some(data),
}
Self { success: true, output: output.into(), data: Some(data) }
}
pub fn rejected(skill_name: &str) -> Self {
Self {
success: false,
output: format!("用户拒绝了你的调用:{}", skill_name),
data: None,
}
Self { success: false, output: format!("用户拒绝了你的调用:{}", skill_name), data: None }
}
pub fn expired(skill_name: &str) -> Self {
Self {
success: false,
output: format!("用户没有确认操作:{}", skill_name),
data: None,
}
Self { success: false, output: format!("用户没有确认操作:{}", skill_name), data: None }
}
pub fn error(msg: impl Into<String>) -> Self {
Self {
success: false,
output: msg.into(),
data: None,
}
Self { success: false, output: msg.into(), data: None }
}
}
// ─── 错误 ───
// ─── 执行上下文 ───
#[derive(Debug)]
pub enum SkillError {
NotFound(String),
Execution(String),
Timeout(String),
InvalidParams(String),
pub type WechatSender = Arc<dyn Fn(&str, &str) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>> + Send + Sync>;
pub struct ExecutionContext {
pub user_id: String,
pub approval_manager: Option<Arc<crate::tools::approval::ApprovalManager>>,
pub send_wechat: Option<WechatSender>,
}
impl std::fmt::Display for SkillError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SkillError::NotFound(name) => write!(f, "技能不存在: {}", name),
SkillError::Execution(msg) => write!(f, "执行失败: {}", msg),
SkillError::Timeout(name) => write!(f, "执行超时: {}", name),
SkillError::InvalidParams(msg) => write!(f, "参数无效: {}", msg),
}
impl ExecutionContext {
pub fn new(user_id: impl Into<String>) -> Self {
Self { user_id: user_id.into(), approval_manager: None, send_wechat: None }
}
pub fn with_approval(mut self, mgr: Arc<crate::tools::approval::ApprovalManager>) -> Self {
self.approval_manager = Some(mgr);
self
}
pub fn with_wechat(mut self, send: WechatSender) -> Self {
self.send_wechat = Some(send);
self
}
}
+107 -31
View File
@@ -90,6 +90,7 @@ struct CityLookupResponse {
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct CityLocation {
id: String,
name: String,
@@ -246,13 +247,10 @@ impl QWeatherClient {
return Ok(id);
}
let url = format!(
"https://geoapi.qweather.com/v2/city/lookup?location={}&number=1",
location
);
let resp: CityLookupResponse = self
.http
.get(&url)
.get("https://geoapi.qweather.com/v2/city/lookup")
.query(&[("location", location), ("number", "1")])
.header("Authorization", format!("Bearer {}", self.jwt))
.send()
.await
@@ -279,13 +277,10 @@ impl QWeatherClient {
/// 实时天气
async fn weather_now(&self, city_id: &str) -> Result<serde_json::Value, String> {
let url = format!(
"https://{}/v7/weather/now?location={}&lang=zh&unit=m",
self.api_host, city_id
);
let resp: WeatherNowResponse = self
.http
.get(&url)
.get(format!("https://{}/v7/weather/now", self.api_host))
.query(&[("location", city_id), ("lang", "zh"), ("unit", "m")])
.header("Authorization", format!("Bearer {}", self.jwt))
.send()
.await
@@ -328,13 +323,10 @@ impl QWeatherClient {
_ => "30d",
};
let url = format!(
"https://{}/v7/weather/{}?location={}&lang=zh&unit=m",
self.api_host, d, city_id
);
let resp: DailyForecastResponse = self
.http
.get(&url)
.get(format!("https://{}/v7/weather/{}", self.api_host, d))
.query(&[("location", city_id), ("lang", "zh"), ("unit", "m")])
.header("Authorization", format!("Bearer {}", self.jwt))
.send()
.await
@@ -381,13 +373,10 @@ impl QWeatherClient {
_ => "168h",
};
let url = format!(
"https://{}/v7/weather/{}?location={}&lang=zh&unit=m",
self.api_host, h, city_id
);
let resp: HourlyForecastResponse = self
.http
.get(&url)
.get(format!("https://{}/v7/weather/{}", self.api_host, h))
.query(&[("location", city_id), ("lang", "zh"), ("unit", "m")])
.header("Authorization", format!("Bearer {}", self.jwt))
.send()
.await
@@ -448,12 +437,6 @@ fn extract_params(params: &serde_json::Value) -> (&str, u32, u32) {
let location = params
.get("location")
.and_then(|v| v.as_str())
.or_else(|| params.get("prompt").and_then(|v| v.as_str()).and_then(|s| {
// call_capability 可能用 prompt 透传,尝试从中提取城市名
// 简单匹配:"北京"、"上海" 等中文名或拼音
s.split(|c: char| c == '"' || c == '\'' || c == ',' || c == '')
.find(|w| w.chars().any(|c| c as u32 > 127))
}))
.unwrap_or("北京");
let days = params
@@ -471,8 +454,75 @@ fn extract_params(params: &serde_json::Value) -> (&str, u32, u32) {
(location, days.max(1), hours)
}
/// 执行天气查询
#[allow(dead_code)]
fn extract_days_from_prompt(prompt: &str) -> u32 {
// 匹配模式: "7天" "3日" "未来7" "7d" "7D"
for pat in &["", "", "d", "D"] {
if let Some(pos) = prompt.find(pat) {
// 向前找数字
let before = &prompt[..pos];
if let Some(num_str) = before
.chars()
.rev()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.chars()
.rev()
.collect::<String>()
.into()
{
if let Ok(n) = num_str.parse::<u32>() {
return n.min(30).max(1);
}
}
}
}
3 // 默认
}
#[allow(dead_code)]
fn extract_city_from_prompt(prompt: &str) -> &str {
// 去掉常见前缀,取第一个2-3字中文词。GeoAPI 支持模糊搜索,
// 即使多取一两个字也能正确匹配到城市
let chars: Vec<char> = prompt.chars().collect();
let byte_pos = |idx: usize| -> usize { chars[..idx].iter().map(|c| c.len_utf8()).sum() };
// 跳过前缀
let mut i = 0;
for prefix in &["帮我查一下", "帮我看看", "帮我查", "帮我", "查一下", "查询", "看看"] {
if prompt.starts_with(prefix) {
i = prefix.chars().count();
while i < chars.len() && chars[i] as u32 <= 0x4e00 { i += 1; }
break;
}
}
// 跳过非中文
while i < chars.len() && chars[i] as u32 <= 0x4e00 { i += 1; }
// 取至多4个连续中文
let start = i;
while i < chars.len() && chars[i] as u32 > 0x4e00 && i - start < 4 { i += 1; }
if i - start >= 2 {
&prompt[byte_pos(start)..byte_pos(i)]
} else {
"北京"
}
}
pub async fn execute(params: serde_json::Value) -> SkillResult {
// call_capability 透传但没有 structured 参数 → 告知正确格式
if params.get("location").is_none()
&& params.get("name").and_then(|v| v.as_str()) == Some("query_weather")
{
let spec = spec();
return SkillResult::error(format!(
"参数格式错误。请将 query_weather 所需的参数直接放在 call_capability 的 prompt 字段中,格式:\
prompt: {{\"location\": \"城市名\", \"days\": 3, \"hours\": 0}}\
工具说明:{}。参数 schema{}",
spec.description,
serde_json::to_string(&spec.parameters).unwrap_or_default()
));
}
let (location, days, hours) = extract_params(&params);
tracing::info!("🌤️ 查询天气: location={}, days={}, hours={}", location, days, hours);
@@ -574,9 +624,35 @@ mod tests {
}
#[test]
fn test_spec() {
let s = spec();
assert_eq!(s.name, "query_weather");
assert_eq!(s.risk_level, RiskLevel::Low);
fn test_extract_city_from_prompt() {
// 取前缀后的连续中文给 GeoAPI 模糊搜索(多取不影响结果)
assert_eq!(extract_city_from_prompt("查询合肥未来7天的天气预报"), "合肥未来");
assert_eq!(extract_city_from_prompt("北京今天天气"), "北京今天");
assert_eq!(extract_city_from_prompt("上海"), "上海");
assert_eq!(extract_city_from_prompt("帮我查一下广州"), "广州");
}
#[test]
fn test_extract_days() {
assert_eq!(extract_days_from_prompt("查询合肥未来7天的天气预报"), 7);
assert_eq!(extract_days_from_prompt("3天预报"), 3);
assert_eq!(extract_days_from_prompt("北京天气"), 3);
}
/// 端到端测试:需要有效的 QWeather 环境变量
#[tokio::test]
async fn test_execute_weather_integration() {
// 跳过无环境变量的情况
if std::env::var("QWEATHER_JWT_KEY_ID").is_err() {
eprintln!("跳过集成测试: 未设置 QWeather 环境变量");
return;
}
let result = super::execute(serde_json::json!({"location": "合肥", "days": 3})).await;
assert!(result.success, "查询失败: {}", result.output);
let data = result.data.expect("应有 data 字段");
assert_eq!(data["location"], "合肥");
assert!(data["now"].is_object(), "应有实时天气");
assert!(data["forecast"].is_array(), "应有预报");
}
}