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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user