feat: iPet → iAs 完整迁移,Rust 版微信 AI 助手
Phase 1 ✅ CLI 框架 + 配置系统 - clap 子命令: login / listen / send / whoami / usage - config.json + env var 替换 - tracing 日志系统 - state 持久化(auth/runtime 文件存 + PostgreSQL) Phase 2 ✅ 微信通道 - wechat::client — 完整 iLink Bot HTTP API 实现 - 扫码登录(终端二维码 + 轮询状态) - 长轮询 getupdates / 消息收发 / 监听注册 Phase 3 ✅ AI 对话(纯文本 + function calling) - LlmProvider trait: DeepSeek + LM Studio 实现 - SSE 流式解析(text / reasoning / tool_calls delta / usage) - Conversation: 消息历史 + chat / chat_with_tools 工具循环 Phase 4 ✅ PostgreSQL 集成 - app_state(认证 KV 存储) - chat_records(消息收发记录) - llm_usage(Token 用量统计缓存命中率) - user_memories(长期记忆持久化) - pending_approvals(审批确认码) - scheduled_tasks(定时任务表) Phase 5 ✅ 一切皆 Skill(工具系统) - SkillRegistry: 系统 + 用户 skills 双目录合并 - SKILL.md 解析器 + 子进程执行器(stdin JSON → stdout) - 9 个系统 Skills: datetime / weather / search / email / shell / schedule / memos / read_memories / read_summaries - ApprovalManager: High 风险技能 → 确认码审批(透明模式) - High 风险技能:确认码审批(透明模式) Phase 6 ✅ 定时任务调度器 上下文管理 - ChatSession: checkpoint + token budget (28K) + summaries - Token 估算器(中英文自适应) - 12h 空闲 → trigger_idle_summary(不入会话) - Budget 溢出 → trigger_overflow_summary(入会话 + drain 旧消息) - Summarizer: LLM 生成自然语言摘要(fallback 简单截断) - 长期记忆 / 摘要 通过 read_memories / read_summaries 工具按需读取 工具调用日志 + Token 统计 - INFO: 工具名 + 参数 + 结果摘要 - DEBUG: 子进程 exit/stdout/stderr - ias usage --since --until --model 查看用量和缓存命中率
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
# iAs 工具系统设计
|
||||
|
||||
## 核心理念
|
||||
|
||||
**一切皆 Skill。**
|
||||
|
||||
没有"内置工具"和"外部技能"的区别——所有工具都是 SKILL.md,区别仅在来源:
|
||||
- **系统 skills** — `resources/skills/` 目录,随 iAs 分发,只读
|
||||
- **用户 skills** — `skills/` 项目目录,用户自己添加
|
||||
|
||||
加载时自动合并,同名时用户 skills 覆盖系统 skills。
|
||||
|
||||
---
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ LLM │
|
||||
│ generate_reply() ←→ tool_choice → tool_execute(result) │
|
||||
└─────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ SkillRegistry │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌────────────────────────────┐ │
|
||||
│ │ 系统 Skills │ │ 用户 Skills │ │
|
||||
│ │ resources/skills/ │ │ skills/ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ • datetime │ │ • query_qweather/ │ │
|
||||
│ │ • weather │ │ • search_tavily/ │ │
|
||||
│ │ • search_web │ │ • start_pc/ │ │
|
||||
│ │ • email │ │ • ... │ │
|
||||
│ │ • shell_exec │ │ │ │
|
||||
│ │ • schedule │ │ │ │
|
||||
│ └────────┬──────────┘ └────────────┬──────────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ Approval Gate │ │
|
||||
│ │ risk == High → 确认码审批 / Low → 自动执行 │ │
|
||||
│ │ 审批过程对 LLM 完全透明 │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心类型
|
||||
|
||||
```rust
|
||||
/// 风险等级(仅两级)
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RiskLevel {
|
||||
Low, // 只读/安全,自动执行
|
||||
High, // 写操作/有风险,需微信确认码审批
|
||||
}
|
||||
|
||||
/// 技能来源
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SkillSource {
|
||||
System, // resources/skills/
|
||||
User, // skills/
|
||||
}
|
||||
|
||||
/// 技能元数据(从 SKILL.md 解析)
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SkillSpec {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub risk_level: RiskLevel,
|
||||
pub parameters: serde_json::Value, // JSON Schema
|
||||
pub timeout_secs: u64,
|
||||
}
|
||||
|
||||
/// 技能(加载到内存后)
|
||||
pub struct Skill {
|
||||
pub spec: SkillSpec,
|
||||
pub source: SkillSource,
|
||||
pub dir: PathBuf,
|
||||
pub execute_command: String,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SKILL.md 格式
|
||||
|
||||
每个技能是一个目录,包含 `SKILL.md` 文件:
|
||||
|
||||
```markdown
|
||||
# Get Current Datetime
|
||||
|
||||
获取当前日期时间(北京时间 UTC+8)。
|
||||
|
||||
## Risk Level
|
||||
Low
|
||||
|
||||
## Parameters
|
||||
\`\`\`json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"format": {
|
||||
"type": "string",
|
||||
"description": "时间格式: full | date | time",
|
||||
"enum": ["full", "date", "time"],
|
||||
"default": "full"
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Execute
|
||||
\`\`\`bash
|
||||
#!/usr/bin/env bash
|
||||
scripts/datetime.sh
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 内置 Skills 一览
|
||||
|
||||
随 iAs 分发的系统 skills:
|
||||
|
||||
| Skill | 风险 | 说明 |
|
||||
|-------|------|------|
|
||||
| `get_current_datetime` | Low | 获取当前时间(UTC+8) |
|
||||
| `query_weather` | Low | 和风天气查询 |
|
||||
| `search_web` | Low | Tavily 网络搜索 |
|
||||
| `email` | High | 收发邮件 |
|
||||
| `execute_shell` | High | 执行 shell 命令 |
|
||||
| `list_scheduled_tasks` | Low | 列出定时任务 |
|
||||
| `manage_scheduled_task` | High | 增删改定时任务 |
|
||||
|
||||
---
|
||||
|
||||
## SkillRegistry
|
||||
|
||||
```rust
|
||||
pub struct SkillRegistry {
|
||||
skills: HashMap<String, Arc<Skill>>,
|
||||
}
|
||||
|
||||
impl SkillRegistry {
|
||||
pub async fn load(system_dir: &str, user_dir: &str) -> Result<Self, LoadError>;
|
||||
|
||||
pub fn list_specs(&self) -> Vec<&SkillSpec>;
|
||||
pub fn list_specs_by_risk(&self, level: RiskLevel) -> Vec<&SkillSpec>;
|
||||
pub fn get(&self, name: &str) -> Option<&Skill>;
|
||||
|
||||
/// 执行技能(含审批流程)
|
||||
pub async fn execute(
|
||||
&self,
|
||||
name: &str,
|
||||
params: serde_json::Value,
|
||||
ctx: &ExecutionContext,
|
||||
) -> Result<SkillResult, SkillError>;
|
||||
}
|
||||
|
||||
pub struct ExecutionContext {
|
||||
pub user_id: String,
|
||||
pub db: Option<Arc<Database>>,
|
||||
/// 发送微信消息的回调
|
||||
pub send_wechat: Option<Box<dyn Fn(&str, &str) -> Result<(), String> + Send + Sync>>,
|
||||
/// 等待用户回复的回调(返回用户消息文本)
|
||||
pub wait_for_reply: Option<Box<dyn Fn(&str, Duration) -> Result<Option<String>, String> + Send + Sync>>,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 审批流程(透明模式)
|
||||
|
||||
**核心原则:审批过程对 LLM 完全不可见。** LLM 只看到最终结果。
|
||||
|
||||
```
|
||||
LLM 调用 High 风险技能
|
||||
│
|
||||
▼
|
||||
SkillRegistry::execute()
|
||||
│
|
||||
├── 查找 Skill
|
||||
├── risk_level == High?
|
||||
│
|
||||
└── YES ──────────────────────────────────────────┐
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────────────────────────────┐ │
|
||||
│ 等待阶段(LLM 不感知) │ │
|
||||
│ │ │
|
||||
│ ① 生成 6 位确认码(如 482731) │ │
|
||||
│ ② SHA-256 哈希存入 pending_approvals │ │
|
||||
│ ③ 通过微信发送确认消息给用户: │ │
|
||||
│ │ │
|
||||
│ ┌─────────────────────────────┐ │ │
|
||||
│ │ ⚠️ 操作确认 │ │ │
|
||||
│ │ │ │ │
|
||||
│ │ 技能:send_email │ │ │
|
||||
│ │ 参数:收件人 xxx@xxx.com │ │ │
|
||||
│ │ │ │ │
|
||||
│ │ 确认码:482731 │ │ │
|
||||
│ │ 回复确认码以继续 │ │ │
|
||||
│ │ 回复 0 或「取消」以取消 │ │ │
|
||||
│ │ (5分钟内有效,最多3次尝试) │ │ │
|
||||
│ └─────────────────────────────┘ │ │
|
||||
│ │ │
|
||||
│ ④ 等待用户回复 │ │
|
||||
│ │ │ │
|
||||
│ ├── 回复正确确认码 ──┐ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌──────────────┐ │ │
|
||||
│ │ │ ⑤ 执行技能 │ │ │
|
||||
│ │ │ ⑥ LLM 看到 │ │ │
|
||||
│ │ │ 执行结果 │ │ │
|
||||
│ │ └──────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ ├── 回复错误确认码 │ │
|
||||
│ │ → "确认码错误,还剩 N 次" │ │
|
||||
│ │ → 重试(最多 3 次) │ │
|
||||
│ │ → 3 次都错 │ │
|
||||
│ │ → LLM 看到: │ │
|
||||
│ │ "用户拒绝了你的调用: │ │
|
||||
│ │ send_email" │ │
|
||||
│ │ │ │
|
||||
│ ├── 回复 0 / 「取消」 │ │
|
||||
│ │ → LLM 看到: │ │
|
||||
│ │ "用户拒绝了你的调用: │ │
|
||||
│ │ send_email" │ │
|
||||
│ │ │ │
|
||||
│ └── 5 分钟超时 │ │
|
||||
│ → LLM 看到: │ │
|
||||
│ "用户没有确认操作: │ │
|
||||
│ send_email" │ │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### LLM 视角(两种可能)
|
||||
|
||||
```
|
||||
✅ 通过:
|
||||
LLM: 调用 send_email({to: "xxx", subject: "..."})
|
||||
系统: { success: true, message: "邮件已发送" }
|
||||
|
||||
❌ 拒绝:
|
||||
LLM: 调用 send_email({to: "xxx", subject: "..."})
|
||||
系统: { success: false, error: "用户拒绝了你的调用:send_email" }
|
||||
```
|
||||
|
||||
LLM 不知道确认码的存在,不知道审批过程。它只知道自己调用了工具,然后得到了结果或拒绝。
|
||||
|
||||
---
|
||||
|
||||
## 数据库表
|
||||
|
||||
```sql
|
||||
-- 待审批的工具调用
|
||||
CREATE TABLE pending_approvals (
|
||||
id UUID PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL, -- 5分钟后过期
|
||||
user_id TEXT NOT NULL,
|
||||
skill_name TEXT NOT NULL,
|
||||
params JSONB NOT NULL,
|
||||
code_hash TEXT NOT NULL, -- 确认码 SHA-256(不存明文)
|
||||
attempts_left INTEGER NOT NULL DEFAULT 3, -- 剩余尝试次数
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | rejected | expired
|
||||
result JSONB,
|
||||
consumed_at TIMESTAMPTZ
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Skills 目录结构
|
||||
|
||||
```
|
||||
resources/skills/ # 系统内置 Skills(随 iAs 分发)
|
||||
├── get_current_datetime/
|
||||
│ ├── SKILL.md
|
||||
│ └── scripts/datetime.sh
|
||||
├── query_weather/
|
||||
│ ├── SKILL.md
|
||||
│ └── scripts/weather.sh
|
||||
├── search_web/
|
||||
│ ├── SKILL.md
|
||||
│ └── scripts/search.sh
|
||||
├── email/
|
||||
│ ├── SKILL.md
|
||||
│ └── scripts/email.sh
|
||||
├── execute_shell/
|
||||
│ ├── SKILL.md
|
||||
│ └── scripts/shell.sh
|
||||
├── list_scheduled_tasks/
|
||||
│ ├── SKILL.md
|
||||
│ └── scripts/list.sh
|
||||
└── manage_scheduled_task/
|
||||
├── SKILL.md
|
||||
└── scripts/manage.sh
|
||||
|
||||
skills/ # 用户 Skills(用户自行添加)
|
||||
├── query_qweather/
|
||||
│ ├── SKILL.md
|
||||
│ └── scripts/query.sh
|
||||
├── search_tavily/
|
||||
│ ├── SKILL.md
|
||||
│ └── scripts/search.sh
|
||||
└── start_pc/
|
||||
├── SKILL.md
|
||||
└── scripts/wake.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实施步骤
|
||||
|
||||
| 步骤 | 内容 | 预估 |
|
||||
|------|------|------|
|
||||
| Step 1 | SkillSpec/Skill/SkillRegistry 核心类型 + 注册表 | 半天 |
|
||||
| Step 2 | SKILL.md 解析器 + 目录扫描(system + user 合并) | 半天 |
|
||||
| Step 3 | 技能执行器:子进程管理 + 参数 JSON 传递 + 结果解析 | 半天 |
|
||||
| Step 4 | 系统 Skills 脚本实现(7 个 bash 脚本) | 1 天 |
|
||||
| Step 5 | 审批系统:pending_approvals 表 + 确认码生成/验证 + 微信透明通知 | 1 天 |
|
||||
| Step 6 | LLM function calling 集成 + 审计日志 | 1 天 |
|
||||
@@ -0,0 +1,154 @@
|
||||
use rand::Rng;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, oneshot};
|
||||
|
||||
/// 审批决策
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ApprovalDecision {
|
||||
Approved,
|
||||
Rejected,
|
||||
Expired,
|
||||
}
|
||||
|
||||
/// 内存中待审批的条目
|
||||
struct PendingEntry {
|
||||
code_hash: String,
|
||||
skill_name: String,
|
||||
attempts_left: i32,
|
||||
/// 通知等待方
|
||||
sender: oneshot::Sender<ApprovalDecision>,
|
||||
}
|
||||
|
||||
/// 审批管理器
|
||||
pub struct ApprovalManager {
|
||||
pool: Option<Arc<PgPool>>,
|
||||
/// user_id → 该用户的待审批队列
|
||||
pending: Arc<Mutex<HashMap<String, Vec<PendingEntry>>>>,
|
||||
}
|
||||
|
||||
impl ApprovalManager {
|
||||
pub fn new(pool: Option<Arc<PgPool>>) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建审批请求
|
||||
/// 返回 (确认码明文, 接收器) — 调用方 await 接收器等待用户确认
|
||||
pub async fn create(
|
||||
&self,
|
||||
user_id: &str,
|
||||
skill_name: &str,
|
||||
) -> Result<(String, oneshot::Receiver<ApprovalDecision>), String> {
|
||||
let code: u32 = rand::thread_rng().gen_range(100000..999999);
|
||||
let code_str = code.to_string();
|
||||
let code_hash = format!("{:x}", Sha256::digest(code_str.as_bytes()));
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
// 入库
|
||||
if let Some(ref pool) = self.pool {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(5);
|
||||
let params = serde_json::json!({"name": skill_name});
|
||||
|
||||
let _ = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO pending_approvals (id, expires_at, user_id, skill_name, params, code_hash, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'pending')
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(expires_at)
|
||||
.bind(user_id)
|
||||
.bind(skill_name)
|
||||
.bind(¶ms)
|
||||
.bind(&code_hash)
|
||||
.execute(pool.as_ref())
|
||||
.await;
|
||||
}
|
||||
|
||||
// 注册到内存
|
||||
let mut map = self.pending.lock().await;
|
||||
map.entry(user_id.to_string())
|
||||
.or_default()
|
||||
.push(PendingEntry {
|
||||
code_hash,
|
||||
skill_name: skill_name.to_string(),
|
||||
attempts_left: 3,
|
||||
sender: tx,
|
||||
});
|
||||
|
||||
Ok((code_str, rx))
|
||||
}
|
||||
|
||||
/// 处理用户回复(由消息循环调用)
|
||||
/// 匹配成功时返回 Some(技能名),匹配失败返回 None
|
||||
pub async fn handle_reply(&self, user_id: &str, reply: &str) -> Option<String> {
|
||||
let mut map = self.pending.lock().await;
|
||||
let entries = map.get_mut(user_id)?;
|
||||
if entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let reply_hash = format!("{:x}", Sha256::digest(reply.as_bytes()));
|
||||
let reply_trimmed = reply.trim();
|
||||
|
||||
// 检查取消
|
||||
if reply_trimmed == "0" || reply_trimmed == "取消" {
|
||||
let entry = entries.remove(0);
|
||||
let name = entry.skill_name.clone();
|
||||
let _ = entry.sender.send(ApprovalDecision::Rejected);
|
||||
return Some(name);
|
||||
}
|
||||
|
||||
// 检查确认码
|
||||
let pos = entries.iter().position(|e| e.code_hash == reply_hash);
|
||||
if let Some(idx) = pos {
|
||||
let entry = entries.remove(idx);
|
||||
let name = entry.skill_name.clone();
|
||||
let _ = entry.sender.send(ApprovalDecision::Approved);
|
||||
return Some(name);
|
||||
}
|
||||
|
||||
// 确认码错误
|
||||
if let Some(entry) = entries.first_mut() {
|
||||
entry.attempts_left -= 1;
|
||||
if entry.attempts_left <= 0 {
|
||||
let entry = entries.remove(0);
|
||||
let name = entry.skill_name.clone();
|
||||
let _ = entry.sender.send(ApprovalDecision::Rejected);
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// 清理过期(由定时任务调用)
|
||||
pub async fn clean_expired(&self) {
|
||||
let mut map = self.pending.lock().await;
|
||||
for entries in map.values_mut() {
|
||||
entries.retain(|e| !e.sender.is_closed());
|
||||
}
|
||||
map.retain(|_, v| !v.is_empty());
|
||||
|
||||
if let Some(ref pool) = self.pool {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE pending_approvals SET status = 'expired' WHERE status = 'pending' AND expires_at < NOW()",
|
||||
)
|
||||
.execute(pool.as_ref())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取某个用户的待审批条数
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod approval;
|
||||
pub mod parser;
|
||||
pub mod registry;
|
||||
pub mod skill;
|
||||
@@ -0,0 +1,279 @@
|
||||
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 in_desc = false;
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// 标题行 → 技能名
|
||||
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_desc = false;
|
||||
in_params = false;
|
||||
in_execute = false;
|
||||
}
|
||||
"parameters" | "param" => {
|
||||
in_desc = false;
|
||||
in_params = true;
|
||||
in_execute = false;
|
||||
}
|
||||
"execute" | "execution" | "command" | "run" => {
|
||||
in_desc = false;
|
||||
in_params = false;
|
||||
in_execute = true;
|
||||
params_fence = 0;
|
||||
execute_fence = 0;
|
||||
}
|
||||
_ => {
|
||||
in_desc = false;
|
||||
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(¶ms_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 result = rt.block_on(crate::tools::registry::SkillRegistry::load(
|
||||
"resources/skills",
|
||||
"/nonexistent",
|
||||
));
|
||||
match result {
|
||||
Ok(registry) => {
|
||||
assert_eq!(registry.count(), 8, "应该有 8 个系统技能");
|
||||
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(&"get_current_datetime"));
|
||||
assert!(names.contains(&"query_weather"));
|
||||
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"));
|
||||
}
|
||||
Err(errors) => {
|
||||
for e in &errors {
|
||||
eprintln!("加载失败: {}", e);
|
||||
}
|
||||
panic!("技能加载失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
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 加载所有技能
|
||||
/// system_dir: resources/skills/(随 iAs 分发)
|
||||
/// user_dir: skills/(用户自行添加)
|
||||
/// 同名时 user 覆盖 system
|
||||
pub async fn load(system_dir: &str, user_dir: &str) -> Result<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
|
||||
if Path::new(user_dir).exists() {
|
||||
registry.load_from_dir(user_dir, SkillSource::User, &mut errors);
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(registry)
|
||||
} else {
|
||||
Err(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 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(¶ms).unwrap_or_default();
|
||||
cmd.stdin(std::process::Stdio::piped());
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
// ─── 风险等级 ───
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RiskLevel {
|
||||
Low, // 只读/安全,自动执行
|
||||
High, // 写操作/有风险,需微信确认码审批
|
||||
}
|
||||
|
||||
impl RiskLevel {
|
||||
pub fn is_high(&self) -> bool {
|
||||
matches!(self, RiskLevel::High)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 技能来源 ───
|
||||
|
||||
#[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 {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(default = "default_risk_level")]
|
||||
pub risk_level: RiskLevel,
|
||||
#[serde(default)]
|
||||
pub parameters: serde_json::Value,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout_secs: u64,
|
||||
}
|
||||
|
||||
fn default_risk_level() -> RiskLevel {
|
||||
RiskLevel::Low
|
||||
}
|
||||
|
||||
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,
|
||||
pub output: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl SkillResult {
|
||||
pub fn ok(output: impl Into<String>) -> Self {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rejected(skill_name: &str) -> Self {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
output: msg.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 错误 ───
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SkillError {
|
||||
NotFound(String),
|
||||
Execution(String),
|
||||
Timeout(String),
|
||||
InvalidParams(String),
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user