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,333 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ─── 基础类型 ───
|
||||
|
||||
/// 公共请求元数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct BaseInfo {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub channel_version: Option<String>,
|
||||
}
|
||||
|
||||
impl BaseInfo {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
channel_version: Some(env!("CARGO_PKG_VERSION").to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 消息类型 ───
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TextItem {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CDNMedia {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub encrypt_query_param: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aes_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub encrypt_type: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub full_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageItem {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub media: Option<CDNMedia>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thumb_media: Option<CDNMedia>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aeskey: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mid_size: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thumb_size: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thumb_height: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thumb_width: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hd_size: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VoiceItem {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub media: Option<CDNMedia>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub encode_type: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bits_per_sample: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sample_rate: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub playtime: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileItem {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub media: Option<CDNMedia>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub md5: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub len: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VideoItem {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub media: Option<CDNMedia>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub video_size: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub play_length: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub video_md5: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thumb_media: Option<CDNMedia>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thumb_size: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thumb_height: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thumb_width: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RefMessage {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message_item: Option<Box<MessageItem>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
/// 消息条目(支持 text/image/voice/file/video)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageItem {
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub item_type: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub create_time_ms: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub update_time_ms: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_completed: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub msg_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ref_msg: Option<Box<RefMessage>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_item: Option<TextItem>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_item: Option<ImageItem>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub voice_item: Option<VoiceItem>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_item: Option<FileItem>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub video_item: Option<VideoItem>,
|
||||
}
|
||||
|
||||
impl MessageItem {
|
||||
pub const TYPE_TEXT: i32 = 1;
|
||||
pub const TYPE_IMAGE: i32 = 2;
|
||||
pub const TYPE_VOICE: i32 = 3;
|
||||
pub const TYPE_FILE: i32 = 4;
|
||||
pub const TYPE_VIDEO: i32 = 5;
|
||||
|
||||
pub fn text(text: &str) -> Self {
|
||||
Self {
|
||||
item_type: Some(Self::TYPE_TEXT),
|
||||
text_item: Some(TextItem {
|
||||
text: Some(text.to_string()),
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MessageItem {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
item_type: None,
|
||||
create_time_ms: None,
|
||||
update_time_ms: None,
|
||||
is_completed: None,
|
||||
msg_id: None,
|
||||
ref_msg: None,
|
||||
text_item: None,
|
||||
image_item: None,
|
||||
voice_item: None,
|
||||
file_item: None,
|
||||
video_item: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 微信消息 ───
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WeixinMessage {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub seq: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub from_user_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub to_user_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub client_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub create_time_ms: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub update_time_ms: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delete_time_ms: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub group_id: Option<String>,
|
||||
#[serde(rename = "message_type", skip_serializing_if = "Option::is_none")]
|
||||
pub msg_type: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message_state: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub item_list: Option<Vec<MessageItem>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context_token: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for WeixinMessage {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
seq: None,
|
||||
message_id: None,
|
||||
from_user_id: None,
|
||||
to_user_id: None,
|
||||
client_id: None,
|
||||
create_time_ms: None,
|
||||
update_time_ms: None,
|
||||
delete_time_ms: None,
|
||||
session_id: None,
|
||||
group_id: None,
|
||||
msg_type: None,
|
||||
message_state: None,
|
||||
item_list: None,
|
||||
context_token: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WeixinMessage {
|
||||
pub const TYPE_NONE: i32 = 0;
|
||||
pub const TYPE_USER: i32 = 1;
|
||||
pub const TYPE_BOT: i32 = 2;
|
||||
|
||||
/// 获取文本内容(如果有纯文本 item)
|
||||
pub fn text_content(&self) -> Option<&str> {
|
||||
self.item_list
|
||||
.as_ref()?
|
||||
.iter()
|
||||
.find(|item| item.item_type == Some(MessageItem::TYPE_TEXT))
|
||||
.and_then(|item| item.text_item.as_ref())
|
||||
.and_then(|t| t.text.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── API 请求/响应 ───
|
||||
|
||||
/// 获取二维码响应
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct QRCodeResponse {
|
||||
pub qrcode: String,
|
||||
pub qrcode_img_content: String,
|
||||
}
|
||||
|
||||
/// 扫码状态响应
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct StatusResponse {
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bot_token: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ilink_bot_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub baseurl: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ilink_user_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub redirect_host: Option<String>,
|
||||
}
|
||||
|
||||
/// GetUpdates 请求
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GetUpdatesReq {
|
||||
pub get_updates_buf: String,
|
||||
pub base_info: BaseInfo,
|
||||
}
|
||||
|
||||
/// GetUpdates 响应
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetUpdatesResp {
|
||||
#[serde(default)]
|
||||
pub ret: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub errcode: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub errmsg: Option<String>,
|
||||
#[serde(default)]
|
||||
pub msgs: Vec<WeixinMessage>,
|
||||
#[serde(default)]
|
||||
pub get_updates_buf: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub longpolling_timeout_ms: Option<i64>,
|
||||
}
|
||||
|
||||
/// SendMessage 请求
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SendMessageReq {
|
||||
pub msg: WeixinMessage,
|
||||
}
|
||||
|
||||
/// NotifyStart/NotifyStop 响应
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NotifyResp {
|
||||
#[serde(default)]
|
||||
pub ret: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub errmsg: Option<String>,
|
||||
}
|
||||
|
||||
/// 登录结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoginResult {
|
||||
pub token: String,
|
||||
pub account_id: String,
|
||||
pub base_url: String,
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
/// 收到的消息事件
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageEvent {
|
||||
pub from_user_id: String,
|
||||
pub text: String,
|
||||
pub context_token: Option<String>,
|
||||
pub message_id: i64,
|
||||
}
|
||||
Reference in New Issue
Block a user