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