Compare commits

...

10 Commits

Author SHA1 Message Date
wunianxiao f7d0781090 chore: 修复 clippy 错误(loop never loops → if-let)并应用自动修复 2026-06-10 10:17:25 +08:00
wunianxiao 19dc54f842 docs: 添加 ARCHITECTURE.md 代码调用图谱
包含 8 个 Mermaid 图表,覆盖:
1. 全局架构总览 — 所有模块及调用关系
2. 消息处理全链路 — Sequence Diagram 完整流程
3. 模块依赖关系 — 分层依赖图
4. 数据流(消息生命周期)
5. 关键函数调用关系(工具执行/审批/上下文构建)
6. 文件依赖矩阵
7. 启动流程
8. 消费者架构(三消费者)
2026-06-10 09:54:07 +08:00
wunianxiao a04447c7bc docs: 为全部 38 个源文件补充详细模块级注释
为 iAs 项目的所有 Rust 源文件添加了系统化的模块级文档注释(//!),
覆盖全部 src/ 下的文件。每个文件包含模块职责、架构设计、数据流
和关键设计决策的说明。

主要变更:
- 入口/CLI: main.rs, cli.rs — 系统架构概览、子命令说明
- Daemon/Worker: daemon.rs, worker.rs — 三消费者架构、旧架构说明
- 微信通道: mod.rs, client.rs, types.rs — API 端点、登录流程
- LLM 系统: mod.rs, types.rs, provider.rs, deepseek.rs, conversation.rs
  — 架构分层、流式处理、工具循环、摘要机制
- 上下文管理: mod.rs, types.rs, builder.rs, tools.rs
  — Checkpoint 机制、Token Budget、双重摘要
- 工具系统: mod.rs, types.rs, builtin.rs, approval.rs
  — 两层元工具架构、审批流程
- 内置工具: mod.rs + 6 个工具 — API 端点、认证方式、参数说明
- 消息队列: mod.rs, message_queue.rs, runner.rs
  — 公平轮转算法、三消费者路由
- 数据库/状态/调度/日志: db/mod.rs, state.rs, scheduler.rs, logger.rs
  — 表结构、回退策略、定时任务、日志初始化
2026-06-10 09:46:42 +08:00
wunianxiao 74bb35bd26 chore: 将 .codegraph/ 和 .reasonix/ 加入 .gitignore 2026-06-09 22:11:01 +08:00
wunianxiao 713cbb7cc3 fix: 修复 daemon 架构中 3 个 blocking 正确性 bug
1. call_capability 元工具不可用
   - execute_tool() 中缺失 call_capability 处理分支
   - 添加解包 {name, prompt} → 提取目标工具并派发的逻辑

2. tool_call_id 传空字符串
   - Tool Consumer 中 tool_call_id 被 _ 忽略,ToolResult 传空串
   - 改为使用 LLM 原始 tool_call_id 传递

3. 高风险工具审批无限循环
   - Tool Consumer 无条件触发审批,approved_tool 从未被读取
   - ToolCall 新增 approved 字段,已批准的工具跳过审批
   - ToolExecutor 根据 approved_tool 设置标记

4. Session TTL 时间戳不更新(should-fix)
   - ToolResult 回喂时更新 last_access,防止长对话被误清理

5. 其他清理
   - load_memories 魔数字符串比较改为 starts_with
   - _sock_path 添加文档说明保留用于兼容
   - MessageKind::ToolCall 新增 approved: bool 字段
2026-06-09 22:10:39 +08:00
wunianxiao 8ed608f01b chore: 删除死代码 subprocess.rs 和 definitions.rs
- subprocess.rs(JSON 工具子进程执行器)未被任何代码引用
- definitions.rs(ToolDef 定义)仅被 subprocess.rs 引用
- 删除后消除全部 3 个 dead_code warning
- 更新 tools/mod.rs 注释和模块声明
2026-06-09 21:49:17 +08:00
wunianxiao ee5c9b733f chore: 消除全部编译 warning
在 src/tools/subprocess.rs 文件顶部添加 #![allow(dead_code)],
因为该模块作为可选功能(JSON 工具定义)未被 daemon 主线直接调用。
移除冗余的逐个 #[allow(dead_code)] 标注。
2026-06-09 21:11:53 +08:00
wunianxiao 079be45615 feat: 添加 WEIXIN_BASE_URL 环境变量支持
- WeChatClient::new() 中增加 WEIXIN_BASE_URL 环境变量检查
- 优先级: 显式参数 > WEIXIN_BASE_URL > 默认值
- FIXED_BASE_URL 改为函数 fixed_base_url(),同样支持环境变量覆盖
- 与 DEEPSEEK_BASE_URL 的环境变量模式保持一致
2026-06-09 20:59:46 +08:00
wunianxiao 23c7fbac62 feat: 为全部 39 个 Rust 源文件添加系统性中文注释
为整个项目添加了详尽的中文文档注释,覆盖所有模块:

入口与架构:
- main.rs — 架构图、数据流、4 个关键设计决策
- cli.rs — 所有 CLI 子命令中文化
- Cargo.toml — 每个依赖的作用说明
- channel/mod.rs — 渠道标识设计意图

核心守护进程与消息队列:
- daemon.rs — 三消费者架构图、审批流
- queue/* — 公平轮转算法、路由调度架构

LLM 层:
- types.rs — Role/Message/ToolCall 等每个字段含义
- provider.rs — Provider trait 设计 + SSE 解析
- conversation.rs — 工具循环流程图、摘要机制
- deepseek.rs — API 请求格式

上下文管理:
- context/types.rs — ChatSession 消息生命周期
- context/builder.rs — Token 预算管理策略
- context/tools.rs — MemoryStore 双写策略

工具系统:
- tools/mod.rs — 两层元工具架构图
- tools/approval.rs — 审批流程
- tools/definitions.rs — 声明式工具三要素
- tools/subprocess.rs — 执行流程 + 缓存策略

数据库与状态:
- db/* — 5 张表用途、回退策略
- state.rs — 文件存储回退方案
- scheduler.rs — SKIP LOCKED 防重复

已废弃模块:
- ipc.rs — 旧 UDS 协议
- worker.rs — 旧 vs 新架构对比
2026-06-09 20:52:24 +08:00
wunianxiao 937556917c refactor: worker进程 → tokio task 架构 + 多渠道消息队列 + JSON工具系统
核心变更:
- daemon: 废弃 worker 子进程模型,替换为 LLM/Tool/Send 三消费者 tokio task
- queue: 新增公平轮转 MessageQueue + QueueRunner 路由,严格 ChannelId 渠道隔离
- channel: ChannelId(platform + user_id) 统一渠道标识,替代散列 user_id+platform
- tools: 全部工具统一经由 execute_tool() → Tool Consumer 单一执行路径
- llm: chat_with_tools 返回 ChatResult,支持异步工具调用 → 保留/恢复 session
- tools: 新增 JSON 工具定义系统 (ToolDef) 及 SubprocessRunner (HTTP 模板引擎)
- bin: ias-auth 独立 JWT 生成器,ias-fetch headless Chrome 网页抓取
- 所有出站消息通过 Send Consumer 队列转发,无直接 send_text 调用
- Session TTL 清理 (5分钟),QueueRunner 通道容错
- cargo check 零警告,cargo test 23/23 通过
2026-06-09 16:32:49 +08:00
43 changed files with 3428 additions and 986 deletions
+2
View File
@@ -26,3 +26,5 @@ qweather/
Thumbs.db
read.md
.codegraph/
.reasonix/
+445
View File
@@ -0,0 +1,445 @@
# iAs 代码调用图谱
> 微信 AI 智能助手 —— 完整架构与调用关系
---
## 1. 全局架构总览
```mermaid
flowchart TB
subgraph CLI["CLI 入口 (main.rs)"]
A1["clap::Parser Cli 解析"]
A2["cmd_login"]
A3["cmd_listen"]
A4["cmd_send"]
A5["cmd_whoami"]
A6["cmd_usage"]
A7["cmd_tool"]
A8["cmd_daemon"]
end
subgraph DAEMON["守护进程 (daemon.rs)"]
B0["run()"]
B1["spawn_consumers()"]
B2["llm_consumer_loop"]
B3["tool_consumer_loop"]
B4["send_consumer_loop"]
B5["run_llm_round / resume_llm_round"]
B6["handle_chat_result"]
B7["execute_tool"]
end
subgraph QUEUE["消息队列 (queue/)"]
C1["MessageQueue 公平轮转"]
C2["QueueRunner 路由调度"]
C3["EnqueueHandle"]
C4["ConsumerChannels"]
end
subgraph LLM["LLM 对话系统 (llm/)"]
D1["Conversation 对话管理器"]
D2["chat_with_tools() / run_tool_loop()"]
D3["DeepSeekProvider"]
D4["LlmProvider trait"]
D5["StreamReceiver"]
end
subgraph CONTEXT["上下文管理 (context/)"]
E1["ChatSession 消息历史+摘要"]
E2["builder Token估算+上下文构建"]
E3["MemoryStore 长期记忆"]
E4["read_summaries"]
end
subgraph TOOLS["工具系统 (tools/)"]
F1["BuiltinRegistry 工具注册表"]
F2["ApprovalManager 审批管理"]
F3["build_capability_guide"]
F4["unpack_call_params"]
subgraph BUILTINS["内置工具 (builtins/)"]
G1["datetime"]
G2["weather"]
G3["web_search"]
G4["fetch_page"]
G5["memos"]
G6["amap"]
end
end
subgraph WECHAT["微信通道 (wechat/)"]
H1["WeChatClient"]
H2["receive_messages()"]
H3["send_text()"]
H4["login()"]
H5["notify_start/stop"]
end
subgraph DB["数据库 (db/)"]
I1["Database::connect()"]
I2["models CRUD 操作"]
end
subgraph OTHER["其他模块"]
J1["state.rs 文件状态回退"]
J2["scheduler.rs 定时任务"]
J3["logger.rs 日志初始化"]
J4["channel/mod.rs ChannelId"]
J5["ipc.rs UDS 协议(已废弃)"]
J6["worker.rs 旧架构(已废弃)"]
end
A1 --> A2 & A3 & A4 & A5 & A6 & A7 & A8
A3 --> B0
A8 --> B0
B0 --> B1
B1 --> B2 & B3 & B4
B2 --> B5 & B6
B3 --> B7
B0 --> C1 & C2 & C3
C2 --> C4
C4 --> B2 & B3 & B4
B2 --> D1
D1 --> D2
D2 --> D3
D3 --> D4
D4 --> D5
D1 --> E1
D2 --> E2
B2 --> E3 & E4
D2 --> F1
B3 --> F1 & F2
B7 --> F1 & F3 & F4
F1 --> G1 & G2 & G3 & G4 & G5 & G6
B4 --> H1
H1 --> H2 & H3
B0 --> I1
B2 --> I2
B4 --> I2
A2 --> I2 & J1
A3 --> J1
B0 --> J1 & J2
A1 --> J3
```
---
## 2. 消息处理全链路
```mermaid
sequenceDiagram
participant WX as 微信用户
participant WC as WeChatClient
participant MQ as MessageQueue
participant QR as QueueRunner
participant LC as LLM Consumer
participant TC as Tool Consumer
participant SC as Send Consumer
participant LLM as DeepSeek API
participant BT as BuiltinRegistry
WX->>WC: 发送消息
WC->>MQ: enqueue(UserMessage)
MQ->>QR: dequeue (公平轮转)
QR->>LC: llm_tx.send(UserMessage)
LC->>LC: 加载历史/记忆/摘要
LC->>LLM: chat_stream(上下文)
LLM-->>LC: 流式回复 + tool_calls
alt 有工具调用
LC->>MQ: enqueue(ToolCall)
MQ->>QR: dequeue
QR->>TC: tool_tx.send(ToolCall)
alt 高风险工具
TC->>TC: ApprovalManager.create()
TC->>MQ: enqueue(LLMReply: 确认码)
MQ->>QR: dequeue
QR->>SC: send_tx.send(LLMReply)
SC->>WC: send_text(确认码)
WX-->>WC: 回复确认码
WC->>MQ: enqueue(UserMessage: 确认码)
MQ->>QR: dequeue
QR->>LC: llm_tx.send(确认码)
LC->>TC: ApprovalManager.handle_reply()
TC-->>LC: Approved
LC->>MQ: enqueue(ToolCall + approved=true)
MQ->>QR: dequeue
QR->>TC: tool_tx.send(ToolCall)
end
TC->>BT: execute(工具名, 参数)
BT-->>TC: SkillResult
TC->>MQ: enqueue(ToolResult)
MQ->>QR: dequeue
QR->>LC: llm_tx.send(ToolResult)
LC->>LLM: resume_tool_loop()
LLM-->>LC: 最终回复
end
LC->>MQ: enqueue(LLMReply)
MQ->>QR: dequeue
QR->>SC: send_tx.send(LLMReply)
SC->>WC: send_text(回复)
WC->>WX: 显示回复
```
---
## 3. 模块依赖关系
```mermaid
flowchart LR
subgraph CORE["核心层"]
MAIN["main.rs"]
DAEMON["daemon.rs"]
CLI["cli.rs"]
end
subgraph PIPELINE["消息管线"]
QUEUE["queue/"]
CHANNEL["channel/"]
end
subgraph AI["AI 层"]
LLM["llm/"]
CTX["context/"]
end
subgraph TOOL["工具层"]
TOOLS["tools/"]
BUILTINS["tools/builtins/"]
APPROVAL["tools/approval.rs"]
end
subgraph IO["IO 层"]
WECHAT["wechat/"]
DB["db/"]
STATE["state.rs"]
SCHED["scheduler.rs"]
end
subgraph UTIL["工具层"]
LOG["logger.rs"]
IPC["ipc.rs"]
WORKER["worker.rs"]
end
MAIN --> CLI & DAEMON & LOG & STATE & DB & CTX & LLM & TOOLS & WECHAT & SCHED
DAEMON --> QUEUE & CHANNEL & WECHAT & DB & STATE & LLM & CTX & TOOLS & SCHED
QUEUE --> CHANNEL
LLM --> CTX & TOOLS
TOOLS --> BUILTINS & APPROVAL
CTX --> DB
TOOLS --> DB
APPROVAL --> DB
DB --> STATE
WORKER --> IPC & LLM & TOOLS
```
---
## 4. 数据流(消息生命周期)
```mermaid
flowchart TB
START(["📱 用户发消息"]) --> A["WeChatClient receive_messages()"]
A --> B["入库: chat_records (inbound)"]
B --> C["PipelineMessage kind: UserMessage"]
C --> D["MessageQueue 公平轮转"]
D --> E["加载历史消息 (最近20条)"]
E --> F["加载长期记忆 (MemoryStore)"]
F --> G["加载摘要 (read_summaries)"]
G --> H["构建上下文 (builder)"]
H --> I["Conversation chat_with_tools()"]
I --> J{"LLM 调用了工具?"}
J -->|"是"| K["Tool Consumer 执行工具"]
K --> L{"高风险?→ 审批流程"}
L --> M["结果回喂 LLM"]
M --> I
J -->|"否"| N["LLM 生成最终回复"]
N --> O["Send Consumer send_text()"]
O --> P["入库: chat_records (outbound)"]
P --> ENDD(["📱 用户收到回复"])
```
---
## 5. 关键函数调用关系
### 5.1 工具执行链路
```mermaid
flowchart TB
subgraph LLM_SIDE["LLM 侧"]
A["LLM 调用 call_capability()"]
B["ToolExecutor (闭包)"]
C["返回 ASYNC_MARKER + 工具名"]
end
subgraph QUEUE_SIDE["队列侧"]
D["PipelineMessage::tool_call()"]
E["MessageQueue.enqueue()"]
F["QueueRunner → Tool Consumer"]
end
subgraph TOOL_SIDE["工具侧"]
G["tool_consumer_loop()"]
H{"高风险?"}
I["ApprovalManager 审批流程"]
J["execute_tool()"]
K{"工具类型?"}
L["BuiltinRegistry.execute()"]
M["上下文工具 直接处理"]
N["unpack_call_params()"]
end
subgraph RESULT["结果回喂"]
O["PipelineMessage::tool_result()"]
P["LLM Consumer resume_llm_round()"]
Q["Conversation resume_tool_loop()"]
end
A --> B --> C --> D --> E --> F --> G
G --> H
H -->|"是"| I --> J
H -->|"否"| J
J --> K
K -->|"内置工具"| L
K -->|"上下文工具"| M
K -->|"call_capability"| N --> K
J --> O --> P --> Q
```
### 5.2 审批流程
```mermaid
flowchart TB
A["LLM 请求高风险工具"] --> B["ApprovalManager.create(user_id, tool)"]
B --> C["生成 6 位确认码 SHA-256 哈希存储"]
B --> D["创建 oneshot channel"]
C --> E["发送消息给用户 ⚠️ 操作确认"]
D --> F["等待用户回复 超时 5 分钟"]
F --> G{"用户回复"}
G -->|"匹配确认码"| H["ApprovalDecision::Approved"]
G -->|"输入 0/取消"| I["ApprovalDecision::Rejected"]
G -->|"不匹配(最多3次)"| J["减少尝试次数"]
G -->|"超时"| K["ApprovalDecision::Expired"]
J --> F
H --> L["重新入队 UserMessage + approved_tool"]
I --> M["回复用户: 已取消"]
K --> N["回复用户: 已超时"]
```
### 5.3 上下文构建流程
```mermaid
flowchart TB
A["ChatSession (消息历史)"] --> B["estimate_tokens()"]
B --> C{"Token 超 budget? (默认 28000)"}
C -->|"是"| D{"checkpoint 之前有未摘要消息?"}
D -->|"是"| E["trigger_overflow_summary() → 调用 LLM 生成摘要"]
D -->|"否"| F["裁剪最早的消息 直到符合 budget"]
C -->|"否"| G{"空闲超时? (12h 无消息)"}
G -->|"是"| H["trigger_idle_summary() → 清空消息, 保留摘要"]
G -->|"否"| I["build_context() → 组装最终上下文"]
E --> I
F --> I
H --> I
I --> J["发送给 LLM"]
```
---
## 6. 文件依赖矩阵
| 文件 | 依赖的关键模块 |
|------|---------------|
| `main.rs` | cli, daemon, db, llm, context, tools, wechat, state, scheduler, logger |
| `daemon.rs` | queue, channel, wechat, db, state, llm, context, tools, scheduler |
| `cli.rs` | clap (独立) |
| `llm/conversation.rs` | context/builder, context/types, llm/provider, llm/types |
| `llm/deepseek.rs` | llm/provider, llm/types |
| `llm/provider.rs` | llm/types |
| `llm/types.rs` | serde (独立) |
| `context/types.rs` | llm/types |
| `context/builder.rs` | context/types, llm/types |
| `context/tools.rs` | db/models |
| `queue/message_queue.rs` | channel/mod |
| `queue/runner.rs` | queue/message_queue |
| `tools/builtin.rs` | tools/types, tools/builtins/* |
| `tools/approval.rs` | db/models |
| `tools/mod.rs` | tools/types |
| `wechat/client.rs` | wechat/types |
| `wechat/types.rs` | serde (独立) |
| `db/mod.rs` | db/models |
| `db/models.rs` | sqlx (独立) |
| `state.rs` | serde (独立) |
| `scheduler.rs` | db/models |
| `worker.rs` | ipc, llm, tools (已废弃) |
| `ipc.rs` | serde (已废弃) |
---
## 7. 启动流程
```mermaid
flowchart TB
START(["ias listen --llm"]) --> A["main()"]
A --> B["clap::Parser::parse()"]
B --> C{"命令类型"}
C -->|"Tool"| D["cmd_tool() 直接执行工具"]
C -->|"Login"| E["cmd_login() 扫码登录"]
C -->|"Listen/Service"| F["cmd_listen()"]
F --> G["加载 .env"]
F --> H["Database::connect()"]
H --> I{"数据库可用?"}
I -->|"是"| J["创建 MemoryStore (DB 模式)"]
I -->|"否"| K["创建 MemoryStore (文件模式)"]
F --> L["加载认证信息"]
L --> M{"已登录?"}
M -->|"否"| N["报错退出"]
M -->|"是"| O["WeChatClient.set_auth()"]
O --> P["WeChatClient.notify_start()"]
P --> Q["创建 ApprovalManager"]
Q --> R["创建 Conversation + ToolExecutor"]
R --> S["启动 Scheduler (定时任务)"]
S --> T["启动 listen_loop()"]
T --> U["WeChatClient.receive_messages()"]
U --> V{"收到消息?"}
V -->|"是"| W["enqueue PipelineMessage"]
W --> X["LLM/Tool/Send 消费者处理"]
V -->|"否/超时"| U
```
---
## 8. 消费者架构(三消费者)
```mermaid
flowchart TB
subgraph QUEUE2["消息队列"]
Q["MessageQueue 公平轮转"]
R["QueueRunner 路由"]
end
subgraph LLM_C["LLM Consumer"]
L1["接收: UserMessage / ToolResult / ScheduledTask"]
L2["加载用户历史 + 记忆 + 摘要"]
L3["创建/恢复 Conversation"]
L4["chat_with_tools() / resume_tool_loop()"]
L5{"有异步工具等待?"}
L6["发送 LLMReply"]
L7["保留 session"]
L8["清理 session"]
end
subgraph TOOL_C["Tool Consumer"]
T1["接收: ToolCall / ApprovalRequest"]
T2{"高风险 + 未审批?"}
T3["ApprovalManager.create()"]
T4["发送确认码"]
T5["execute_tool()"]
T6["发送 ToolResult"]
end
subgraph SEND_C["Send Consumer"]
S1["接收: LLMReply"]
S2["WeChatClient.send_text()"]
S3["入库: chat_records (outbound)"]
end
Q --> R
R -->|"UserMessage / ToolResult"| L1
R -->|"ToolCall"| T1
R -->|"LLMReply"| S1
L1 --> L2 --> L3 --> L4
L4 --> L5
L5 -->|"是"| L7
L5 -->|"否"| L6
```
+51 -40
View File
@@ -2,45 +2,56 @@
name = "iAs"
version = "0.1.0"
edition = "2024"
description = "微信 AI 智能助手 — 通过 DeepSeek LLM 实现 AI 自动回复"
[dependencies]
# 异步运行时
tokio = { version = "1.0", features = ["full"] }
# HTTP 客户端
reqwest = { version = "0.12", features = ["json", "stream", "gzip"] }
# 序列化
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# 环境变量
dotenvy = "0.15"
# CLI 参数解析
clap = { version = "4", features = ["derive"] }
# 终端输入
rustyline = "14.0"
# UUID
uuid = { version = "1", features = ["v4", "serde"] }
# 时间
chrono = { version = "0.4", features = ["serde"] }
# 加密
base64 = "0.22"
sha2 = "0.10"
hex = "0.4"
rand = "0.9"
# 日志
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# 二维码终端显示
qrcode = "0.14"
image = "0.25"
# 异步 trait
async-trait = "0.1"
# 流式处理
futures-util = "0.3"
sqlx = { version = "0.8.6", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono", "derive", "migrate"] }
tracing-appender = "0.2.5"
dialoguer = "0.12.0"
console = "0.16.3"
dirs = "6.0.0"
# Ed25519 JWT 签名(天气 API
ed25519-dalek = { version = "2", features = ["pem"] }
scraper = "0.27.0"
# ── 异步运行时 ──
tokio = { version = "1.0", features = ["full"] } # 异步运行时核心
# ── HTTP 客户端 ──
reqwest = { version = "0.12", features = ["json", "stream", "gzip"] } # HTTP 请求(微信 API + DeepSeek API + 工具调用)
# ── 序列化 ──
serde = { version = "1.0", features = ["derive"] } # 序列化/反序列化
serde_json = "1.0" # JSON 处理
# ── 环境变量加载 ──
dotenvy = "0.15" # .env 文件加载
# ── CLI 参数解析 ──
clap = { version = "4", features = ["derive"] } # 命令行参数解析
# ── 终端交互 ──
rustyline = "14.0" # REPL 行编辑
dialoguer = "0.12.0" # 终端交互对话框
console = "0.16.3" # 终端控制
# ── UUID ──
uuid = { version = "1", features = ["v4", "serde"] } # 唯一标识符
# ── 时间处理 ──
chrono = { version = "0.4", features = ["serde"] } # 日期时间
# ── 加密与签名 ──
base64 = "0.22" # Base64 编码
sha2 = "0.10" # SHA-256 哈希(审批码)
hex = "0.4" # 十六进制
rand = "0.9" # 随机数
ed25519-dalek = { version = "2", features = ["pem"] } # Ed25519 JWT 签名(和风天气 API
# ── 日志 ──
tracing = "0.1" # 结构化日志
tracing-subscriber = { version = "0.3", features = ["env-filter"] } # 日志输出
tracing-appender = "0.2.5" # 文件日志(日滚)
# ── 二维码 ──
qrcode = "0.14" # 二维码生成
image = "0.25" # 图片处理
# ── 异步 trait ──
async-trait = "0.1" # 异步 trait 支持
# ── 流式处理 ──
futures-util = "0.3" # 流式处理工具
# ── 数据库 ──
sqlx = { version = "0.8.6", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono", "derive", "migrate"] } # PostgreSQL
# ── 文件系统 ──
dirs = "6.0.0" # 系统目录路径
# ── HTML 解析 ──
scraper = "0.27.0" # HTML 解析(fetch_page 工具)
[[bin]]
name = "ias"
path = "src/main.rs"
[[bin]]
name = "ias-auth"
path = "src/bin/ias-auth.rs"
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "ias-fetch"
version = "0.1.0"
edition = "2024"
[dependencies]
chromiumoxide = { version = "0.7", default-features = false, features = ["tokio-runtime", "bytes"] }
futures = "0.3"
serde_json = "1.0"
tokio = { version = "1.0", features = ["full"] }
+143
View File
@@ -0,0 +1,143 @@
//! ias-fetch — 通过 headless Chrome 提取网页正文
//!
//! 启动无头 Chrome 渲染页面 JS,取渲染后的纯文本输出到 stdout。
//! 专供 JSON 工具 `${cmd:ias-fetch {{url}}}` 调用。
//!
//! 用法:
//! ias-fetch <url> # 默认:取 body.innerText
//! ias-fetch --raw <url> # 返回完整 HTML
//! ias-fetch --timeout 15 <url> # 自定义超时(默认 15s)
use futures::StreamExt;
use std::time::Duration;
use chromiumoxide::{Browser, BrowserConfig, Page};
const DEFAULT_TIMEOUT: u64 = 15;
const MAX_OUTPUT_CHARS: usize = 10_000;
#[tokio::main]
async fn main() {
let args: Vec<String> = std::env::args().collect();
let mut raw_mode = false;
let mut timeout_secs = DEFAULT_TIMEOUT;
let mut url: Option<String> = None;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--raw" => raw_mode = true,
"--timeout" => {
i += 1;
timeout_secs = args.get(i).and_then(|s| s.parse().ok()).unwrap_or(DEFAULT_TIMEOUT);
}
s if s.starts_with("http://") || s.starts_with("https://") => {
url = Some(s.to_string());
}
s if !s.starts_with('-') => url = Some(s.to_string()),
_ => {}
}
i += 1;
}
let url = match url {
Some(u) => {
if u.starts_with("http://") || u.starts_with("https://") {
u
} else {
format!("https://{}", u)
}
}
None => {
eprintln!("用法: ias-fetch [--raw] [--timeout N] <url>");
std::process::exit(1);
}
};
let timeout = Duration::from_secs(timeout_secs);
match tokio::time::timeout(timeout, fetch_page(&url, raw_mode)).await {
Ok(Ok(output)) => {
if output.is_empty() {
eprintln!("页面无内容");
std::process::exit(1);
}
println!("{}", output);
}
Ok(Err(e)) => {
eprintln!("{}", e);
std::process::exit(1);
}
Err(_) => {
eprintln!("超时 ({}s): {}", timeout_secs, url);
std::process::exit(1);
}
}
}
async fn fetch_page(url: &str, raw_mode: bool) -> Result<String, String> {
let (mut browser, mut handler) = Browser::launch(
BrowserConfig::builder()
.build()
.map_err(|e| format!("Chrome 配置失败: {}", e))?,
)
.await
.map_err(|e| format!("启动 Chrome 失败: {}\n请确保已安装 chromium 或 google-chrome", e))?;
let handle = tokio::spawn(async move {
while let Some(_event) = handler.next().await {
// 持续消费 handler 事件
}
});
// new_page 接受 URL,自动导航并等待加载
let page = browser
.new_page(url)
.await
.map_err(|e| format!("导航到 {} 失败: {}", url, e))?;
// 额外等待 JS 渲染
tokio::time::sleep(Duration::from_secs(2)).await;
// 提取内容
let content = if raw_mode {
page.content().await.map_err(|e| format!("获取 HTML 失败: {}", e))?
} else {
get_inner_text(&page).await?
};
// 清理
browser.close().await.ok();
handle.await.ok();
// 截断
if content.chars().count() > MAX_OUTPUT_CHARS {
Ok(format!(
"{}…(已截断,完整内容 {} 字符)",
content.chars().take(MAX_OUTPUT_CHARS).collect::<String>(),
content.chars().count()
))
} else {
Ok(content)
}
}
/// 通过 evaluate JS 获取 body.innerText
async fn get_inner_text(page: &Page) -> Result<String, String> {
let result = page
.evaluate("document.body?.innerText || document.documentElement?.innerText || ''")
.await
.map_err(|e| format!("提取正文失败: {}", e))?;
let value: serde_json::Value = result
.into_value()
.map_err(|e| format!("解析 evaluate 结果失败: {}", e))?;
match value {
serde_json::Value::String(s) => {
let cleaned: Vec<&str> = s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
Ok(cleaned.join("\n"))
}
_ => Ok(String::new()),
}
}
+112
View File
@@ -0,0 +1,112 @@
/// ## ias-auth —— 独立 Token 生成器(辅助二进制)
///
/// 这是一个独立的二进制文件,不依赖主程序的任何模块。
/// 专供 JSON 工具定义通过 `${cmd:ias-auth gen-qweather-jwt}` 调用,
/// 用于生成和风天气 API 的 Ed25519 JWT Token。
///
/// ### 设计原因
/// 独立编译有两个好处:
/// 1. 编译速度快(没有主程序的庞大依赖链)
/// 2. 在 JSON 工具定义中通过 shell 命令调用的开销很小
///
/// ### 用法
/// ```bash
/// ias-auth gen-qweather-jwt
/// ```
use std::process::exit;
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
eprintln!("用法: ias-auth <command>");
eprintln!("命令:");
eprintln!(" gen-qweather-jwt 生成和风天气 Ed25519 JWT Token");
exit(1);
}
match args[1].as_str() {
"gen-qweather-jwt" => gen_qweather_jwt(),
_ => {
eprintln!("未知命令: {}", args[1]);
eprintln!("支持的命令: gen-qweather-jwt");
exit(1);
}
}
}
fn gen_qweather_jwt() {
match generate_jwt() {
Ok(token) => println!("{}", token),
Err(e) => {
eprintln!("{}", e);
exit(1);
}
}
}
// ─── 和风天气 Ed25519 JWT 生成 ───
// 从 iAs 的 tools::builtins::weather 提取,保持独立
use base64::Engine;
use chrono::Utc;
use ed25519_dalek::pkcs8::DecodePrivateKey;
use ed25519_dalek::{Signer, SigningKey};
fn base64url(data: &[u8]) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
}
fn generate_jwt() -> Result<String, String> {
let key_file = std::env::var("QWEATHER_JWT_PRIVATE_KEY_FILE")
.unwrap_or_else(|_| "qweather/ed25519-private.pem".into());
let pem = std::fs::read_to_string(&key_file)
.map_err(|e| format!("读取密钥文件 {} 失败: {}", key_file, e))?;
// 手动解析 PEM: 去掉头尾,base64 解码得到 DER
let der = pem
.lines()
.filter(|l| !l.starts_with("-----"))
.collect::<Vec<_>>()
.join("");
let der_bytes = base64::engine::general_purpose::STANDARD
.decode(&der)
.map_err(|e| format!("Base64 解码密钥失败: {e}"))?;
let signing_key = SigningKey::from_pkcs8_der(&der_bytes)
.map_err(|e| format!("解析 Ed25519 密钥失败: {e}"))?;
let key_id = std::env::var("QWEATHER_JWT_KEY_ID").unwrap_or_default();
let project_id = std::env::var("QWEATHER_JWT_PROJECT_ID").unwrap_or_default();
let ttl: i64 = std::env::var("QWEATHER_JWT_TTL_SECONDS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(3600);
let now = Utc::now().timestamp();
// Header
let header = serde_json::json!({"alg": "EdDSA", "kid": key_id});
let header_b64 = base64url(header.to_string().as_bytes());
// Payload
let payload = serde_json::json!({
"sub": project_id,
"iat": now - 30,
"exp": now + ttl,
});
let payload_b64 = base64url(payload.to_string().as_bytes());
// Sign
let message = format!("{}.{}", header_b64, payload_b64);
let signature = signing_key
.try_sign(message.as_bytes())
.map_err(|e| format!("JWT 签名失败: {e}"))?;
Ok(format!(
"{}.{}.{}",
header_b64,
payload_b64,
base64url(&signature.to_bytes())
))
}
+68
View File
@@ -0,0 +1,68 @@
//! ## 渠道/通道基础类型
//!
//! 定义消息的来源和目标渠道标识,以及从外部轮询收到的原始消息。
//!
//! ### 设计意图
//!
//! `ChannelId` 是本项目中所有消息路由的核心标识符。
//! 它由 `platform`(平台名,如 "wechat")和 `user_id`(用户 ID)两部分组成,
//! 确保不同平台上的用户可以被唯一区分。
//!
//! 未来如果要接入 Telegram、Discord 等新平台,只需要新增 `ChannelId` 的构造方法,
//! 消息队列的路由逻辑无需改动。
use serde::{Deserialize, Serialize};
use std::fmt;
/// ## 渠道标识 —— 区分消息来自哪个平台和哪个用户
///
/// 这是消息队列和 daemon 中所有消息路由的基础。
/// 每个 `PipelineMessage` 都携带一个 `ChannelId`,用来追踪消息的来龙去脉。
///
/// ### 字段
/// * `platform` — 平台名称,如 `"wechat"`、`"telegram"`、`"console"`
/// * `user_id` — 在该平台上的用户标识
///
/// ### 示例
/// ```
/// let ch = ChannelId::wechat("wxid_123");
/// assert_eq!(ch.to_string(), "wechat:wxid_123");
/// ```
#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)]
pub struct ChannelId {
/// 平台名称,如 "wechat"、"telegram"、"console"
pub platform: String,
/// 在该平台上的用户 ID
pub user_id: String,
}
impl ChannelId {
/// 快捷构造微信渠道
pub fn wechat(user_id: impl Into<String>) -> Self {
Self { platform: "wechat".into(), user_id: user_id.into() }
}
}
impl fmt::Display for ChannelId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.platform, self.user_id)
}
}
/// ## 从外部轮询收到的原始消息
///
/// 在 daemon 的主循环中,从 WeChat API 获取到的消息会被转换为这个结构,
/// 然后封装成 `PipelineMessage` 投递到消息队列。
///
/// * `channel` — 来源渠道(平台 + 用户)
/// * `text` — 消息文本
/// * `message_id` — 消息 ID
/// * `context_token` — 微信的上下文令牌(用于多轮对话的上下文恢复)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct IncomingMessage {
pub channel: ChannelId,
pub text: String,
pub message_id: String,
pub context_token: Option<String>,
}
+24 -7
View File
@@ -1,15 +1,32 @@
//! ## CLI 子命令定义 —— clap 参数解析
//!
//! 定义了 iAs 的所有命令行子命令和参数:
//!
//! | 命令 | 说明 |
//! |------|------|
//! | `ias login` | 扫码登录微信 |
//! | `ias listen --llm` | 启动 AI 自动回复 |
//! | `ias send --to <id> --text <msg>` | 手动发送消息 |
//! | `ias whoami` | 查看登录状态 |
//! | `ias usage` | Token 用量统计 |
//! | `ias service` | 后台服务模式 |
//! | `ias daemon` | 守护进程模式 |
//! | `ias tool <tool>` | 调用内置工具 |
use clap::{Parser, Subcommand};
/// iAs — 微信 AI 智能助手
/// ## iAs — 微信 AI 智能助手
///
/// 支持扫码登录、长轮询收发消息、AI 自动回复(DeepSeek)、
/// 内置工具(天气/搜索/备忘录/日期时间等)、Token 用量统计。
///
/// 快速开始:
/// ias login 扫码登录微信
/// ias listen --llm 启动 AI 自动回复
/// ias tool weather 北京 查询天气
/// ias tool search "最新新闻" 联网搜索
/// ### 快速开始
/// ```bash
/// ias login # 扫码登录微信
/// ias listen --llm # 启动 AI 自动回复
/// ias tool weather 北京 # 查询天气
/// ias tool search "最新新闻" # 联网搜索
/// ```
#[derive(Parser, Debug)]
#[command(name = "ias", version, about, long_about = None)]
pub struct Cli {
@@ -115,7 +132,7 @@ pub enum Commands {
sock: String,
},
/// Worker 进程( daemon 自动 spawn,也可手动测试
/// [已废弃] Worker 进程(新架构使用 daemon 统一 tokio task
///
/// 示例:
/// ias worker --sock /tmp/ias_daemon.sock
+57 -9
View File
@@ -1,16 +1,40 @@
//! ## 上下文构建器 —— Token 估算 + 上下文组装 + 摘要触发
//!
//! 这是 LLM 调用前的关键步骤:
//!
//! 1. **Token 估算** — 中/英文分别估算 token 消耗
//! 2. **上下文构建** — 从 ChatSession 中提取合适的消息,在 token budget 内保留最多上下文
//! 3. **摘要触发** — 溢出摘要(token 超 budget)和空闲超时摘要(12h 无消息)
//!
//! ### Token 估算公式
//! - 中文字符 ≈ 1.5 tokens/字
//! - 英文字符 ≈ 0.25 tokens/字符
//! - 每条消息 + 8 tokens 格式开销
use crate::context::types::ChatSession;
use crate::llm::conversation::Summarizer;
use crate::llm::types::Message;
use std::sync::Arc;
use tokio::sync::Mutex;
/// 估算消息的 token 数
/// ## 估算消息的 token 数
///
/// 粗略估算一条消息的 token 消耗,用于 token budget 管理。
/// 公式:`文本 token + 8`(8 是消息格式的开销)。
///
/// 注意这只是估算,不是精确值,但在实践中足够做预算判断。
pub fn estimate_tokens(msg: &Message) -> u32 {
let text = msg.content.as_str();
estimate_text_tokens(text) + 8
}
/// 估算纯文本的 token 数
/// ## 估算纯文本的 token 数
///
/// 中文和英文的 token 消耗不同:
/// * 中文字符 ≈ 1.5 tokens/字(CJK 字符更密集)
/// * 英文字符 ≈ 0.25 tokens/字符
///
/// 这是一个粗略估算,用于在调用 API 之前判断上下文是否超 budget。
pub fn estimate_text_tokens(text: &str) -> u32 {
if text.is_empty() {
return 0;
@@ -27,9 +51,17 @@ fn is_cjk(c: char) -> bool {
|| ('\u{f900}'..='\u{faff}').contains(&c)
}
/// 构建给 LLM 的消息数组(带 token budget 管理)
/// ## 构建给 LLM 的消息数组(带 token budget 管理)
///
/// 返回消息数组和是否需要摘要的信息
/// 这是 LLM 调用前的关键步骤 —— 从 `ChatSession` 中提取合适的消息,
/// 在 token 预算内尽可能保留更多上下文。
///
/// ### 构建策略
/// 1. **系统提示词** — 始终包含
/// 2. **溢出摘要** — 如果有最新的 overflow 摘要,加入(仍在 budget 内时)
/// 3. **近期消息** — 从 checkpoint 开始,从最新消息倒序添加,直到接近 budget
/// 4. **保护最近用户消息** — 确保最后一条用户消息始终在上下文中
/// (即使超过 budget,也在 +2000 范围内强制包含)
pub async fn build_context(session: &Arc<Mutex<ChatSession>>, system_prompt: &str) -> Vec<Message> {
let s = session.lock().await;
@@ -92,7 +124,18 @@ pub async fn build_context(session: &Arc<Mutex<ChatSession>>, system_prompt: &st
messages
}
/// 触发溢出摘要压缩 checkpoint 到当前位置之间的新消息
/// ## 触发溢出摘要 —— 压缩 checkpoint 到当前位置之间的新消息
///
/// 当上下文 token 超 budget 时调用。把 checkpoint 之后的消息交给 LLM 生成摘要,
/// 然后推进 checkpoint,让旧消息可以被释放(或截断)。
///
/// ### 流程
/// 1. 获取 checkpoint 到当前末尾的消息(最多 40 条)
/// 2. 用 LLM summarizer 生成摘要(不可用时回退到简单截断)
/// 3. 将摘要存入 `summaries` 列表
/// 4. 持久化到数据库
/// 5. 推进 checkpoint 到当前末尾
/// 6. 如果 checkpoint 超过 200,截断最早的消息(保留最近 100 条用于调试)
pub async fn trigger_overflow_summary(
session: &Arc<Mutex<ChatSession>>,
summarizer: Option<&Summarizer>,
@@ -153,7 +196,13 @@ pub async fn trigger_overflow_summary(
true
}
/// 触发空闲摘要(不注入,保存到 summaries 供 LLM 工具查询
/// ## 触发空闲超时摘要(不注入上下文,只存档
///
/// 当距上条用户消息超过 12 小时时触发。
/// 与溢出摘要不同,空闲摘要**不会**作为 system prompt 注入到下一次对话中,
/// 而是只保存到数据库,供 `read_summaries` 工具查询。
///
/// 生成摘要后会清空所有消息,checkpoint 重置为 0。
pub async fn trigger_idle_summary(
session: &Arc<Mutex<ChatSession>>,
summarizer: Option<&Summarizer>,
@@ -194,8 +243,8 @@ pub async fn trigger_idle_summary(
/// 生成摘要:优先使用 LLM,不可用时回退到简单截断
async fn generate_summary(messages: &[Message], summarizer: Option<&Summarizer>) -> String {
if let Some(summarizer) = summarizer {
if messages.len() >= 3 {
if let Some(summarizer) = summarizer
&& messages.len() >= 3 {
let prompt = build_summary_prompt(messages);
match summarizer(prompt).await {
Ok(text) if !text.is_empty() => {
@@ -205,7 +254,6 @@ async fn generate_summary(messages: &[Message], summarizer: Option<&Summarizer>)
_ => tracing::warn!("LLM 摘要失败,回退到简单截断"),
}
}
}
summarize_messages(messages)
}
+13
View File
@@ -1,3 +1,16 @@
//! ## 上下文管理 —— 对话状态 + Token 预算 + 长期记忆
//!
//! 管理 LLM 对话的上下文状态,包括:
//!
//! - `types` — `ChatSession` 核心数据结构(消息历史、摘要、checkpoint)
//! - `builder` — Token 预算估算、上下文构建、摘要触发
//! - `tools` — `MemoryStore` 长期记忆管理器
//!
//! ### 关键设计
//! - **Checkpoint 机制**checkpoint 之前的消息被压缩成摘要,之后的消息保持完整
//! - **Token Budget**:默认 28000 tokens,留 4000 给回复
//! - **双重摘要**:溢出摘要(token 超预算)和空闲超时摘要(12h 无消息)
pub mod builder;
pub mod tools;
pub mod types;
+32 -2
View File
@@ -1,9 +1,36 @@
//! ## 长期记忆管理器 + 摘要读取工具
//!
//! 提供两个核心功能:
//!
//! 1. **MemoryStore** — 按用户隔离的长期记忆存储(内存缓存 + PostgreSQL 双写)
//! 2. **read_summaries** — 读取历史会话摘要(内存 + 数据库合并去重)
//!
//! ### 数据流
//! ```text
//! 启动时 load(user_id) → 从数据库加载到缓存
//! 对话中 read_for() → 从缓存读取,返回 "1. ...\n2. ..." 格式
//! 对话中 write_for() → 写入缓存 + 写入数据库
//! ```
use sqlx::PgPool;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
/// 长期记忆管理器(内存 + PostgreSQL 双写,按用户隔离
/// ## 长期记忆管理器(内存 + PostgreSQL 双写)
///
/// 为每个用户维护独立的长期记忆列表,支持读写操作。
///
/// ### 存储策略
/// * **内存缓存** — `HashMap<String, Vec<String>>`,按 user_id 隔离
/// * **数据库持久化** — 可选的 `PgPool`,写入时同时写到 PostgreSQL
///
/// ### 数据流
/// ```text
/// 启动时 load(user_id) → 从数据库加载到缓存
/// 对话中 read_for() → 从缓存读取,返回 "1. ...\n2. ..." 格式
/// 对话中 write_for() → 写入缓存 + 写入数据库
/// ```
pub struct MemoryStore {
pool: Option<Arc<PgPool>>,
cache: Arc<Mutex<HashMap<String, Vec<String>>>>,
@@ -61,7 +88,10 @@ impl MemoryStore {
}
}
/// read_summaries 工具读取历史摘要(内存 + 数据库)
/// ## `read_summaries` 工具 —— 读取历史摘要(内存 + 数据库)
///
/// 返回当前 session 中的摘要列表 + 数据库中存档的摘要。
/// 去重:数据库中的摘要如果与 session 中的摘要前 30 个字符相同,则跳过。
pub async fn read_summaries(
session: &Arc<Mutex<super::types::ChatSession>>,
) -> String {
+54 -3
View File
@@ -1,9 +1,31 @@
//! ## 聊天会话状态 —— 核心数据结构
//!
//! 这是整个项目中最重要的数据结构之一 —— 它维护了一次 LLM 对话的完整状态。
//!
//! ### 关键设计
//! - **消息历史** (`messages`) — 从 checkpoint 之后的消息保持完整
//! - **摘要机制** (`summaries` + `checkpoint`) — checkpoint 之前的消息被压缩成摘要
//! - **Token 预算** (`token_budget`) — 默认 28000,留 4000 给回复
//! - **空闲超时** (`idle_timeout_secs`) — 默认 12 小时,超时后生成空闲摘要
//! - **会话 ID** (`session_id`) — 每次新对话分配一个 Uuid,用于追踪工具调用往返
//!
//! ### 消息生命周期
//! 1. 用户消息到达 → `add_user()` → 存入 `messages`
//! 2. AI 回复 → `add_assistant()` → 存入 `messages`
//! 3. Token 超 budget → `trigger_overflow_summary()` → 压缩 checkpoint 前的消息
//! 4. 12h 空闲 → `trigger_idle_summary()` → 生成摘要,清空消息
use crate::llm::types::Message;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
/// 摘要原因
/// ## 摘要原因
///
/// 区分两种触发摘要的场景:
/// * `Overflow` — 上下文 token 总数超过预算(token budget),触发压缩
/// * `Timeout` — 距上条用户消息超过 12 小时空闲,触发会话总结
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum SummaryReason {
/// 上下文 token 超 budget 触发
@@ -12,7 +34,12 @@ pub enum SummaryReason {
Timeout,
}
/// 一条摘要记录
/// ## 一条摘要记录
///
/// * `checkpoint` — 摘要对应的消息位置,此位置之前的消息已被压缩成摘要
/// * `text` — 摘要文本
/// * `reason` — 生成原因(Overflow / Timeout
/// * `created_at` — 生成时间
#[derive(Debug, Clone)]
pub struct SummaryEntry {
/// 摘要对应的消息位置(checkpoint)
@@ -25,7 +52,23 @@ pub struct SummaryEntry {
pub created_at: DateTime<Utc>,
}
/// 聊天会话状态
/// ## 聊天会话状态(核心数据结构)
///
/// 这是整个项目中最重要的数据结构之一 —— 它维护了一次 LLM 对话的完整状态。
///
/// ### 关键设计
/// * **消息历史** (`messages`) — 从 checkpoint 之后的消息保持完整
/// * **摘要机制** (`summaries` + `checkpoint`) — checkpoint 之前的消息被压缩成摘要,
/// 用 token budget 确保上下文不会超出 LLM 的限制
/// * **Token 预算** (`token_budget`) — 默认 28000,留 4000 给回复
/// * **空闲超时** (`idle_timeout_secs`) — 默认 12 小时,超时后生成空闲摘要
/// * **会话 ID** (`session_id`) — 每次新对话分配一个 Uuid,用于追踪工具调用往返
///
/// ### 消息生命周期
/// 1. 用户消息到达 → `add_user()` → 存入 `messages`
/// 2. AI 回复 → `add_assistant()` → 存入 `messages`
/// 3. Token 超 budget → `trigger_overflow_summary()` → 压缩 checkpoint 前的消息
/// 4. 12h 空闲 → `trigger_idle_summary()` → 生成摘要,清空消息
#[derive(Debug, Clone)]
pub struct ChatSession {
pub user_id: String,
@@ -44,6 +87,8 @@ pub struct ChatSession {
pub token_budget: u32,
/// 12 小时空闲阈值(秒)
pub idle_timeout_secs: i64,
/// 会话 ID(用于追踪工具调用往返)
pub session_id: Uuid,
}
impl Default for ChatSession {
@@ -58,6 +103,7 @@ impl Default for ChatSession {
last_user_at: None,
token_budget: 28000,
idle_timeout_secs: 12 * 3600,
session_id: Uuid::new_v4(),
}
}
}
@@ -90,6 +136,11 @@ impl ChatSession {
.push(Message::tool_result(tool_call_id, name, result));
}
/// 添加一条系统说明消息(如已批准的工具)
pub fn add_system_note(&mut self, note: &str) {
self.messages.push(Message::system(note));
}
/// 是否因空闲超过阈值需要摘要
pub fn is_idle_timeout(&self) -> bool {
if let Some(last) = self.last_user_at {
+596 -717
View File
File diff suppressed because it is too large Load Diff
+36 -2
View File
@@ -1,10 +1,44 @@
//! ## 数据库管理
//!
//! ### 职责
//! 1. 从 `DATABASE_URL` 环境变量连接 PostgreSQL
//! 2. 自动运行 `./migrations/` 下的 SQL 迁移
//! 3. 提供连接池引用给各模块使用
//!
//! ### 回退策略
//! 数据库是可选的。如果数据库不可用,系统会优雅地回退到文件存储
//! `StateManager`),核心功能仍然可用。
//! ## 数据库管理 —— PostgreSQL 连接 + 迁移
//!
//! ### 职责
//! 1. 从 `DATABASE_URL` 环境变量连接 PostgreSQL
//! 2. 自动运行 `./migrations/` 下的 SQL 迁移
//! 3. 提供连接池引用给各模块使用
//!
//! ### 回退策略
//! 数据库是可选的。如果数据库不可用,系统会优雅地回退到文件存储
//! `StateManager`),核心功能仍然可用。
//!
//! ### 表结构
//! - `app_state` — 认证状态 KV 存储
//! - `chat_records` — 收发消息记录
//! - `llm_usage` — Token 用量统计
//! - `user_memories` — 用户长期记忆
//! - `pending_approvals` — 待审批记录
//! - `session_summaries` — 会话摘要
//! - `scheduled_tasks` — 定时任务
pub mod models;
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use std::time::Duration;
/// 数据库管理器
/// ## 数据库管理器
///
/// 封装了 PostgreSQL 连接池的创建和迁移管理。
/// 通过 `Database::connect()` 创建,所有数据库操作都在 `models` 模块中。
pub struct Database {
pool: PgPool,
}
+12
View File
@@ -1,3 +1,15 @@
//! ## 数据库查询(models
//!
//! 所有数据库操作集中在此模块,包括:
//!
//! ### 表操作
//! * `app_state` — 认证状态的持久化(key-value 存储)
//! * `chat_records` — 聊天记录(inbound/outbound
//! * `llm_usage` — Token 用量记录和统计
//! * `session_summaries` — 会话摘要存档
//! * `user_memories` — 用户长期记忆
//! * `pending_approvals` — 待审批记录
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
+15 -5
View File
@@ -1,10 +1,19 @@
//! IPC 帧格式 — Unix Domain Socket 长度前缀帧
//! ## IPC 帧格式 — Unix Domain Socket 长度前缀帧(已废弃)
//!
//! 帧格式: [4 字节 u32 BE: payload_len][N 字节: JSON payload]
//! 这是旧架构(daemon + worker 分离模式)的进程间通信协议。
//! 新架构已改为统一的单进程 tokio task,此模块保留仅为编译参考。
//!
//! 消息类型定义:
//! Daemon → Worker: Task (携带用户消息、历史、记忆、摘要、环境变量)
//! Worker → Daemon: Chunk / Reply / NeedApproval / DbWrite / UsageReport / Bye
//! ### 帧格式
//! ```text
//! [4 字节 u32 大端: payload_len][N 字节: JSON payload]
//! ```
//!
//! ### 消息类型
//! * Daemon → Worker: `TaskFrame`(用户消息 + 上下文 + 环境变量)
//! * Worker → Daemon: `OutputFrame`Chunk / Reply / NeedApproval / DbWrite / UsageReport / Bye
//!
//! ### 最大帧大小
//! 10MB(防止恶意超大帧导致 OOM)
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -149,6 +158,7 @@ pub async fn recv_task(stream: &mut UnixStream) -> Result<TaskFrame, String> {
}
/// 接收并解析为 OutputFrame
#[allow(dead_code)]
pub async fn recv_output(stream: &mut UnixStream) -> Result<OutputFrame, String> {
let v = recv_frame(stream).await?;
serde_json::from_value(v).map_err(|e| format!("解析 OutputFrame 失败: {}", e))
+160 -13
View File
@@ -1,31 +1,116 @@
//! ## Conversation —— LLM 对话管理器(核心类)
//!
//! 这是整个项目中"与 LLM 对话"的核心抽象,负责:
//!
//! ### 职责
//! 1. **管理对话状态** — 持有 `ChatSession`(消息历史、摘要、token 预算)
//! 2. **流式对话** — 调用 provider 发起流式请求,逐 chunk 消费
//! 3. **工具循环** — 自动处理 LLM 发起的工具调用,循环直到无工具或达到轮次上限
//! 4. **异步工具** — 支持工具通过消息队列异步执行,结果回来后恢复对话
//! 5. **上下文管理** — token 预算检查、溢出摘要、空闲超时摘要
//!
//! ### 工具循环流程
//! ```text
//! chat_with_tools(user_msg)
//! → run_tool_loop()
//! → 1. build_context() 构建上下文(含摘要、system prompt
//! → 2. provider.chat_stream() 发起流式请求
//! → 3. 消费流,收集文本 + tool_calls
//! → 4. 有 tool_calls?→ 执行 → 回到步骤 1(最多 5 轮)
//! → 5. 无 tool_calls?→ 返回最终回复
//! ```
use crate::context::builder;
use crate::context::types::ChatSession;
use crate::llm::provider::{BoxedProvider, StreamReceiver, create_provider};
use crate::llm::types::{ConversationConfig, Message, StreamChunk, ToolCall, Usage};
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::Mutex;
/// ## 工具执行器 —— LLM 调用的工具回调
///
/// 这是一个函数别名:`Fn(工具名, JSON参数) → Future<Result<String>>`。
///
/// 当 LLM 在对话中发起工具调用时,Conversation 会调用这个 executor 来执行工具。
///
/// ### 两种执行模式
/// 1. **同步模式** — executor 直接执行并返回结果字符串
/// 2. **异步模式** — executor 将工具调用入队到消息队列,返回 `ASYNC_MARKER + 工具名`
/// 表示"工具已异步入队,结果稍后通过 ToolResult 消息回喂"
pub type ToolExecutor = Arc<
dyn Fn(&str, &str) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>>
+ Send
+ Sync,
>;
/// 摘要生成器:接收要摘要的消息文本 → 返回 LLM 生成的摘要
/// ## 摘要生成器 —— 用于生成对话摘要的回调
///
/// Conversation 在两种情况下会触发摘要:
/// 1. **溢出摘要** — 消息 token 总数接近 budget 时,压缩早期消息
/// 2. **空闲超时摘要** — 距上条用户消息超过 12 小时时,总结本次会话
pub type Summarizer = Arc<
dyn Fn(String) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> + Send + Sync,
>;
/// ## `chat_with_tools` 的返回结果
///
/// * `reply` — LLM 最终的文本回复
/// * `used_tools` — 本轮对话中是否调用了工具
/// * `usage` — Token 用量统计(可选)
/// * `has_pending_async` — 是否有异步工具调用仍在等待结果
/// 如果为 true,调用方应该保留 Conversation 实例,等待 ToolResult 回来后再调用
/// `resume_tool_loop()` 恢复对话
#[derive(Debug)]
pub struct ChatResult {
pub reply: String,
pub used_tools: bool,
pub usage: Option<Usage>,
/// 是否有异步工具调用仍在等待结果
pub has_pending_async: bool,
}
/// 异步工具标记 —— 工具执行器返回此前缀表示工具已异步入队
pub const ASYNC_MARKER: &str = "__ASYNC_PENDING__";
/// ## Conversation —— LLM 对话管理器(核心类)
///
/// 这是整个项目中"与 LLM 对话"的核心抽象,负责:
///
/// ### 职责
/// 1. **管理对话状态** — 持有 `ChatSession`(消息历史、摘要、token 预算)
/// 2. **流式对话** — 调用 provider 发起流式请求,逐 chunk 消费
/// 3. **工具循环** — 自动处理 LLM 发起的工具调用,循环直到无工具或达到轮次上限
/// 4. **异步工具** — 支持工具通过消息队列异步执行,结果回来后恢复对话
/// 5. **上下文管理** — token 预算检查、溢出摘要、空闲超时摘要
///
/// ### 工具循环流程
/// ```text
/// chat_with_tools(user_msg)
/// → run_tool_loop()
/// → 1. build_context() 构建上下文(含摘要、system prompt
/// → 2. provider.chat_stream() 发起流式请求
/// → 3. 消费流,收集文本 + tool_calls
/// → 4. 有 tool_calls?→ 执行 → 回到步骤 1(最多 5 轮)
/// → 5. 无 tool_calls?→ 返回最终回复
/// ```
pub struct Conversation {
config: ConversationConfig,
provider: BoxedProvider,
session: Arc<Mutex<ChatSession>>,
tool_executor: Option<ToolExecutor>,
/// 是否有异步工具调用等待结果返回
pending_async_tools: Arc<AtomicBool>,
}
impl Conversation {
/// ## 创建 Conversation
///
/// 1. 通过 `create_provider()` 工厂从配置创建 LLM 提供商
/// 2. 初始化空的 `ChatSession`(消息历史在收到第一条用户消息后按需加载)
/// 3. tool_executor 初始为 None,需要调用 `set_tool_executor` 设置
pub fn new(config: ConversationConfig) -> Result<Self, String> {
let provider = create_provider(&config)?;
Ok(Self {
@@ -33,12 +118,18 @@ impl Conversation {
provider,
session: Arc::new(Mutex::new(ChatSession::new())),
tool_executor: None,
pending_async_tools: Arc::new(AtomicBool::new(false)),
})
}
pub fn set_tool_executor(&mut self, executor: ToolExecutor) {
self.tool_executor = Some(executor);
}
/// 是否有异步工具调用正在等待结果
#[allow(dead_code)]
pub fn has_pending_async(&self) -> bool {
self.pending_async_tools.load(Ordering::SeqCst)
}
pub fn session(&self) -> Arc<Mutex<ChatSession>> {
Arc::clone(&self.session)
}
@@ -55,7 +146,12 @@ impl Conversation {
self.create_summarizer()
}
/// 创建 LLM 摘要生成器(用当前 provider 做非流式调用)
/// ## 创建 LLM 摘要生成器
///
/// 用当前 LLM provider 创建一个非流式调用的摘要器。
/// 把消息列表拼成 prompt 发给 LLM,返回生成的摘要文本。
///
/// 摘要 prompt 为:"你是一个对话摘要助手,用中文输出简洁摘要。"
pub fn create_summarizer(&self) -> Summarizer {
let provider = self.provider.clone();
Arc::new(move |prompt: String| {
@@ -88,7 +184,17 @@ impl Conversation {
})
}
/// 单次对话(无工具)
/// ## 单次对话(无工具调用
///
/// 简单的流式对话:发送用户消息 → 获取 AI 回复。
/// 不会触发工具循环,适用于不需要 function calling 的场景。
///
/// ### 流程
/// 1. 检查是否空闲超时(距上条消息 > 12h),如果是则触发摘要
/// 2. 将用户消息添加到 session
/// 3. 构建上下文(system prompt + 摘要 + 近期消息)
/// 4. 发起流式请求
/// 5. 返回 `ChatHandle`(可逐 chunk 消费)
pub async fn chat(&self, user_message: String) -> Result<ChatHandle, String> {
// 空闲超时检查
{
@@ -112,11 +218,19 @@ impl Conversation {
})
}
/// 对话 + 工具循环(带上下文管理
pub async fn chat_with_tools(
&self,
user_message: String,
) -> Result<(String, bool, Option<Usage>), String> {
/// ## 对话 + 工具循环(主入口
///
/// 这是最常用的方法:发送用户消息,AI 可以选择回复文本或调用工具。
/// 如果调用了工具,工具执行结果会被自动回喂给 AI,AI 再决定下一步操作。
///
/// ### 完整流程
/// 1. 空闲超时检查 → 触发摘要
/// 2. 添加用户消息到 session
/// 3. 进入 `run_tool_loop()`
/// - 构建上下文 → 调 LLM → 解析流
/// - 有 tool_calls?→ 执行工具 → 继续循环(最多 5 轮)
/// - 无 tool_calls?→ 返回最终回复
pub async fn chat_with_tools(&self, user_message: String) -> Result<ChatResult, String> {
// ── 空闲超时检查 ──
{
let s = self.session.lock().await;
@@ -128,7 +242,17 @@ impl Conversation {
}
self.session.lock().await.add_user(user_message.clone());
self.run_tool_loop().await
}
/// 在异步工具结果返回后恢复工具循环(不追加用户消息)
pub async fn resume_tool_loop(&self) -> Result<ChatResult, String> {
self.pending_async_tools.store(false, Ordering::SeqCst);
self.run_tool_loop().await
}
/// 工具循环核心:调 LLM → 处理工具调用 → 循环直到无工具或需要异步等待
async fn run_tool_loop(&self) -> Result<ChatResult, String> {
let mut used_tools = false;
let mut last_usage = None;
let mut turn_count = 0u32;
@@ -167,8 +291,8 @@ impl Conversation {
}
}
if let Some(calls) = tool_calls {
if !calls.is_empty() {
if let Some(calls) = tool_calls
&& !calls.is_empty() {
used_tools = true;
tracing::info!(
"🔧 LLM 请求 {} 个工具: {}",
@@ -185,6 +309,7 @@ impl Conversation {
.await
.add_assistant_tool_calls(calls.clone());
let mut has_async = false;
for tc in &calls {
let result = match &self.tool_executor {
Some(exec) => {
@@ -195,6 +320,14 @@ impl Conversation {
}
None => "工具执行器未配置".to_string(),
};
if result.starts_with(ASYNC_MARKER) {
has_async = true;
tracing::info!("📦 {} → 异步入队", tc.function.name);
// 不添加到 session,等待 ToolResult 异步回喂
continue;
}
tracing::info!("📦 {} → {:.150}", tc.function.name, result);
self.session.lock().await.add_tool_result(
&tc.id,
@@ -202,9 +335,18 @@ impl Conversation {
&result,
);
}
if has_async {
self.pending_async_tools.store(true, Ordering::SeqCst);
return Ok(ChatResult {
reply: full_text,
used_tools,
usage: last_usage,
has_pending_async: true,
});
}
continue;
}
}
// 无工具调用:记录回复,检查是否需要溢出摘要
if !full_text.is_empty() {
@@ -215,7 +357,7 @@ impl Conversation {
{
let s = self.session.lock().await;
let recent = s.recent_messages();
let estimated: u32 = recent.iter().map(|m| builder::estimate_tokens(m)).sum();
let estimated: u32 = recent.iter().map(builder::estimate_tokens).sum();
if estimated > s.token_budget {
drop(s);
builder::trigger_overflow_summary(&self.session, Some(&self.summarizer()))
@@ -224,7 +366,12 @@ impl Conversation {
}
}
return Ok((full_text, used_tools, last_usage));
return Ok(ChatResult {
reply: full_text,
used_tools,
usage: last_usage,
has_pending_async: false,
});
}
}
}
+50 -3
View File
@@ -1,3 +1,30 @@
//! ## DeepSeek LLM 提供商实现
//!
//! 实现了 `LlmProvider` trait,通过 DeepSeek API 提供流式对话能力。
//!
//! ### 配置
//! 从环境变量读取:
//! - `DEEPSEEK_API_KEY` — API 密钥(必需)
//! - `DEEPSEEK_BASE_URL` — API 地址(可选,默认 `https://api.deepseek.com/v1`
//! - `DEEPSEEK_MODEL` — 模型名(可选,默认 `deepseek-v4-flash`
//!
//! ### 请求格式
//! 发送 POST 到 `{base_url}/chat/completions`,标准 OpenAI-compatible 格式:
//! ```json
//! {
//! "model": "deepseek-v4-flash",
//! "messages": [...],
//! "stream": true,
//! "tools": [...],
//! "thinking": {...}
//! }
//! ```
//!
//! ### 流式处理
//! 使用 `reqwest` 的 `bytes_stream()` 逐 chunk 读取 SSE 流,
//! 通过 `parse_chat_chunk()` 解析每个 delta,通过 mpsc channel 发送给调用方。
//! 工具调用的 delta 跨多个 chunk 拼接,在 Done 信号中一次性返回。
use crate::llm::provider::{
LlmProvider, ParsedChunk, StreamReceiver, StreamSender, parse_chat_chunk,
};
@@ -7,6 +34,27 @@ use reqwest::Client as HttpClient;
use std::collections::BTreeMap;
use tokio::sync::mpsc;
/// ## DeepSeek LLM 提供商
///
/// 实现了 `LlmProvider` trait,通过 DeepSeek API 提供流式对话能力。
///
/// ### 配置
/// 从环境变量读取:
/// * `DEEPSEEK_API_KEY` — API 密钥(必需)
/// * `DEEPSEEK_BASE_URL` — API 地址(可选,默认 `https://api.deepseek.com/v1`
/// * `DEEPSEEK_MODEL` — 模型名(可选,默认 `deepseek-v4-flash`
///
/// ### 请求格式
/// 发送 POST 到 `{base_url}/chat/completions`,标准 OpenAI-compatible 格式:
/// ```json
/// {
/// "model": "deepseek-v4-flash",
/// "messages": [...],
/// "stream": true,
/// "tools": [...], // 可选,function calling
/// "thinking": {...} // 可选,DeepSeek 推理模式
/// }
/// ```
pub struct DeepSeekProvider {
http: HttpClient,
api_key: String,
@@ -167,8 +215,8 @@ async fn stream_deepseek(
}
// 检查最后的 buffer
if !buf.trim().is_empty() {
if let Some(parsed) = parse_chat_chunk(buf.trim()) {
if !buf.trim().is_empty()
&& let Some(parsed) = parse_chat_chunk(buf.trim()) {
match parsed {
ParsedChunk::Text(t) => full_text.push_str(&t),
ParsedChunk::ToolCallDelta {
@@ -195,7 +243,6 @@ async fn stream_deepseek(
_ => {}
}
}
}
// 构建 tool_calls
let tool_calls: Option<Vec<ToolCall>> = if tool_call_builders.is_empty() {
+19 -1
View File
@@ -1,7 +1,25 @@
//! ## LLM 对话系统 —— DeepSeek API 集成
//!
//! 封装与 DeepSeek 大语言模型的交互:
//!
//! - `types` — 消息/角色/工具调用/用量等核心类型
//! - `provider` — `LlmProvider` trait 抽象 + SSE 流式解析
//! - `deepseek` — DeepSeek API 流式客户端实现
//! - `conversation` — `Conversation` 对话管理器(工具循环、摘要、上下文管理)
//!
//! ### 架构
//! ```text
//! Conversation (对话管理器)
//! ├── ChatSession (消息历史 + 摘要 + checkpoint)
//! ├── LlmProvider (trait: chat_stream → StreamReceiver)
//! │ └── DeepSeekProvider (实现: SSE 流式请求)
//! └── ToolExecutor (回调: 执行 LLM 调用的工具)
//! ```
pub mod conversation;
pub mod deepseek;
pub mod provider;
pub mod types;
pub use conversation::{Conversation, ToolExecutor, DEFAULT_SYSTEM_PROMPT};
pub use conversation::{ChatResult, Conversation, ToolExecutor, ASYNC_MARKER, DEFAULT_SYSTEM_PROMPT};
pub use types::{ConversationConfig, Usage};
+65 -20
View File
@@ -1,3 +1,21 @@
//! ## LLM 提供商抽象(trait + SSE 流式解析
//!
//! 定义了 LLM 层的核心抽象接口 `LlmProvider`,以及 SSE 流式响应的解析工具。
//!
//! ### 设计模式
//! - `LlmProvider` trait — 所有 LLM 提供商需实现的接口
//! - `create_provider()` — 工厂函数,根据配置创建对应的提供商实例
//! - `parse_chat_chunk()` — SSE 流式 delta 解析器
//!
//! ### 流式架构
//! ```text
//! Provider.chat_stream() → mpsc::Receiver<StreamChunk>
//! ├── StreamChunk::Text (delta 文本)
//! ├── StreamChunk::Reasoning (思考内容)
//! ├── StreamChunk::Done (完成信号 + 工具调用 + 用量)
//! └── StreamChunk::Error (错误)
//! ```
use crate::llm::types::{ConversationConfig, Message, StreamChunk};
use async_trait::async_trait;
use serde::Deserialize;
@@ -8,7 +26,16 @@ use tokio::sync::mpsc;
pub type StreamReceiver = mpsc::Receiver<StreamChunk>;
pub type StreamSender = mpsc::Sender<StreamChunk>;
/// LLM 提供商抽象
/// ## LLM 提供商抽象trait
///
/// 这是 LLM 层的核心抽象接口,定义了"一个 LLM 提供商需要提供什么能力"。
/// 目前唯一的实现是 `DeepSeekProvider`,但通过这个 trait 可以轻松添加
/// 其他提供商(如 OpenAI、Claude 等)。
///
/// ### 关键设计
/// * 使用 `async_trait` 宏支持异步 trait 方法
/// * 返回 `StreamReceiver`mpsc::Receiver),调用方可以按 chunk 消费流式结果
/// * `Send + Sync` 保证可以在 tokio task 间安全共享
#[async_trait]
pub trait LlmProvider: Send + Sync {
/// 提供商名称
@@ -26,14 +53,23 @@ pub trait LlmProvider: Send + Sync {
pub type BoxedProvider = Arc<dyn LlmProvider>;
/// 从配置创建恰当的提供商
/// ## `create_provider` — 工厂函数:从配置创建恰当的 LLM 提供商
///
/// 目前只返回 DeepSeek,后续可以在这里根据配置 switch 到不同提供商。
pub fn create_provider(_config: &ConversationConfig) -> Result<BoxedProvider, String> {
Ok(Arc::new(super::deepseek::DeepSeekProvider::new()?))
}
// ─── 内部:SSE 解析工具 ───
/// 流式块解析结果
/// ## 流式块解析结果(内部枚举)
///
/// 用于从 SSE 的 JSON delta 中解析出不同种类的块。
/// * `Text` — 普通文本 delta
/// * `Reasoning` — 思考内容(DeepSeek reasoning_content
/// * `ToolCallDelta` — 工具调用的增量信息(可能需要跨多个 chunk 拼接)
/// * `FinishReason` — 结束原因(stop / tool_calls 等)
/// * `Usage` — 用量统计(通常在最后一个 chunk 中)
#[allow(dead_code)]
pub(crate) enum ParsedChunk {
Text(String),
@@ -48,7 +84,20 @@ pub(crate) enum ParsedChunk {
Usage(super::types::Usage),
}
/// 从 JSON body 中解析 DeepSeek/OpenAI 流式 delta
/// ## 从 JSON body 中解析 DeepSeek/OpenAI 流式 delta
///
/// ### 输入
/// 一行 SSE 数据,格式为 `data: {json}`。
///
/// ### 处理逻辑
/// 1. 跳过非 `data: ` 前缀的行和 `[DONE]` 行
/// 2. 解析 JSON 为 `ChunkResponse`(内嵌多个 `choices`
/// 3. 提取 usage(可能在任意一个 chunk 中)
/// 4. 对每个 choice,按优先级提取:
/// - tool_calls delta → `ToolCallDelta`
/// - reasoning_content → `Reasoning`
/// - content → `Text`
/// - finish_reason → `FinishReason`
pub(crate) fn parse_chat_chunk(line: &str) -> Option<ParsedChunk> {
if !line.starts_with("data: ") {
return None;
@@ -110,16 +159,16 @@ pub(crate) fn parse_chat_chunk(line: &str) -> Option<ParsedChunk> {
};
// 提取 usage(可能在最后一个 chunk
if let Some(ref usage) = parsed.usage {
if usage.total_tokens > 0 {
if let Some(ref usage) = parsed.usage
&& usage.total_tokens > 0 {
return Some(ParsedChunk::Usage(usage.clone()));
}
}
for choice in parsed.choices {
// SSE 流式响应中每个 chunk 只包含一个 choice 的 delta
if let Some(choice) = parsed.choices.into_iter().next() {
// 工具调用 delta
if let Some(tool_calls) = &choice.delta.tool_calls {
for tc in tool_calls {
if let Some(tool_calls) = &choice.delta.tool_calls
&& let Some(tc) = tool_calls.first() {
let idx = tc.index.unwrap_or(0);
let args = tc
.function
@@ -134,23 +183,19 @@ pub(crate) fn parse_chat_chunk(line: &str) -> Option<ParsedChunk> {
arguments: args,
});
}
}
if let Some(reasoning) = &choice.delta.reasoning_content {
if !reasoning.is_empty() {
if let Some(reasoning) = &choice.delta.reasoning_content
&& !reasoning.is_empty() {
return Some(ParsedChunk::Reasoning(reasoning.clone()));
}
}
if let Some(content) = &choice.delta.content {
if !content.is_empty() {
if let Some(content) = &choice.delta.content
&& !content.is_empty() {
return Some(ParsedChunk::Text(content.clone()));
}
}
if let Some(reason) = &choice.finish_reason {
if !reason.is_empty() {
if let Some(reason) = &choice.finish_reason
&& !reason.is_empty() {
return Some(ParsedChunk::FinishReason(reason.clone()));
}
}
}
None
+61 -12
View File
@@ -1,7 +1,25 @@
//! ## LLM 对话核心类型定义
//!
//! 定义了与 LLM API 交互的全部核心类型:
//!
//! - `Role` — 对话角色(System / User / Assistant / Tool
//! - `Message` — 单条消息(支持文本、工具调用、工具结果)
//! - `ToolCall` — LLM 发起的工具调用请求
//! - `StreamChunk` — 流式响应块(Text / Reasoning / Done / Error
//! - `Usage` — Token 用量统计
//! - `ConversationConfig` — 对话配置参数
//!
//! 这些类型符合 OpenAI/DeepSeek 的 Chat Completion API 标准格式。
use serde::{Deserialize, Serialize};
// ─── 角色 ───
/// ## 对话角色(System / User / Assistant / Tool
///
/// 符合 OpenAI/DeepSeek 的 Chat Completion API 标准角色定义。
/// * `System` — 系统提示词,设定 AI 的行为和限制
/// * `User` — 用户消息
/// * `Assistant` — AI 回复(可以是纯文本或带 tool_calls
/// * `Tool` — 工具执行结果的回馈
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
@@ -11,8 +29,17 @@ pub enum Role {
Tool,
}
// ─── 消息 ───
/// ## LLM 消息 —— 符合 OpenAI/DeepSeek Chat Completion API 格式
///
/// 这是整个 LLM 交互的核心类型。`Vec<Message>` 构成一次对话的全部上下文,
/// 被序列化为 JSON 发送给 DeepSeek API。
///
/// ### 字段
/// * `role` — 消息角色(system/user/assistant/tool
/// * `content` — 消息内容(文本)
/// * `tool_call_id` — 如果是 tool 角色的结果消息,关联到原始 tool_call 的 ID
/// * `name` — 工具名(tool 角色时使用)
/// * `tool_calls` — assistant 发起工具调用时携带的调用列表
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub role: Role,
@@ -48,8 +75,12 @@ impl Message {
}
}
// ─── 工具调用 ───
/// ## 工具调用 —— LLM 发起的一个工具调用请求
///
/// 当 LLM 认为需要调用工具时,会在回复中携带这个结构。
/// * `id` — 唯一标识这个调用(用于 tool result 回馈时匹配)
/// * `call_type` — 固定为 "function"
/// * `function` — 函数名 + JSON 格式的参数
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
@@ -64,8 +95,13 @@ pub struct ToolFunctionCall {
pub arguments: String, // JSON string
}
// ─── 流式响应块 ───
/// ## 流式响应块 —— 从 DeepSeek API 的 SSE 流中解析出的每个 delta
///
/// ### 变体
/// * `Text` — 文本片段 delta(逐步拼接得到完整回复)
/// * `Reasoning` — 思考/推理内容(DeepSeek 的 thinking 机制)
/// * `Done` — 完成信号,携带完整文本 + 可选的工具调用 + 用量统计
/// * `Error` — 错误
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum StreamChunk {
@@ -85,8 +121,14 @@ pub enum StreamChunk {
Error(String),
}
// ─── Token 用量 ───
/// ## Token 用量统计
///
/// DeepSeek API 在流式响应的最后一个 chunk 中会附带用量信息。
/// * `prompt_tokens` — 提示词消耗的 token 数
/// * `completion_tokens` — 生成文本消耗的 token 数
/// * `total_tokens` = prompt + completion
/// * `prompt_cache_hit_tokens` — 命中上下文的缓存 token 数(省钱)
/// * `prompt_cache_miss_tokens` — 未命中缓存的 token 数
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Usage {
pub prompt_tokens: u32,
@@ -98,8 +140,15 @@ pub struct Usage {
pub prompt_cache_miss_tokens: u32,
}
// ─── 对话配置 ───
/// ## 对话配置 —— 创建 Conversation 时的参数
///
/// ### 关键字段
/// * `system_prompt` — 系统提示词,决定 AI 的行为方式
/// * `model` — 模型名称(如 `deepseek-v4-flash`
/// * `temperature` — 生成随机性(0.0-1.0,越高越有创意)
/// * `max_tokens` — 最大生成 token 数
/// * `thinking` — 是否启用 DeepSeek 的 thinking 模式
/// * `tools` — OpenAI 格式的工具定义列表(function calling 用)
#[derive(Debug, Clone)]
pub struct ConversationConfig {
pub system_prompt: String,
+19 -3
View File
@@ -1,8 +1,25 @@
//! ## 日志初始化
//!
//! ### 两种模式
//! - **终端模式** — 彩色输出到 stderr,适合开发调试
//! - **文件模式** (`with_file=true`) — 同时输出到终端和日滚文件
//! `~/.ias/logs/ias.log.YYYY-MM-DD`
//!
//! 日志级别通过 `RUST_LOG` 环境变量控制(默认 `info`)。
use std::path::PathBuf;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
/// ## 日志初始化
///
/// ### 两种模式
/// * **终端模式** — 彩色输出到 stderr,适合开发调试
/// * **文件模式** (`with_file=true`) — 同时输出到终端和日滚文件
/// `~/.ias/logs/ias.log.YYYY-MM-DD`
///
/// 日志级别通过 `RUST_LOG` 环境变量控制(默认 `info`)。
pub fn init_logger(log_dir: Option<&str>, with_file: bool) {
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info"));
@@ -11,8 +28,8 @@ pub fn init_logger(log_dir: Option<&str>, with_file: bool) {
.with_target(false)
.with_ansi(true);
if with_file {
if let Some(dir) = log_dir {
if with_file
&& let Some(dir) = log_dir {
let dir_path = PathBuf::from(dir);
std::fs::create_dir_all(&dir_path).ok();
@@ -29,7 +46,6 @@ pub fn init_logger(log_dir: Option<&str>, with_file: bool) {
.init();
return;
}
}
tracing_subscriber::registry()
.with(env_filter)
+51 -25
View File
@@ -1,3 +1,35 @@
//! ## iAs —— 微信 AI 智能助手(入口)
//!
//! ### 系统架构概览
//!
//! ```text
//! CLI (main.rs → clap)
//! ├── ias login → 扫码登录,保存 token 到 DB 或文件
//! ├── ias listen --llm → Daemon 模式(主运行模式)
//! │ └── daemon.rs: 长轮询 → MessageQueue → 3 个 tokio 消费者
//! │ ├── LLM Consumer → Conversation.chat_with_tools() → DeepSeek API
//! │ ├── Tool Consumer → BuiltinRegistry.execute() / ApprovalManager
//! │ └── Send Consumer → WeChatClient.send_message()
//! ├── ias service → 等同 listen --llm 但启用文件日志
//! ├── ias daemon → 守护进程入口(等同 listen --llm
//! ├── ias tool <tool> → 直接调用内置工具(无需登录)
//! └── ias usage → Token 消耗统计查询
//! ```
//!
//! ### 数据流
//! ```text
//! 微信消息 → 存 chat_records → 加载历史 + 记忆 → 构建 LLM 上下文
//! → Conversation.chat_with_tools() → LLM 回复或调工具
//! → 高风险工具走审批 → 工具结果回喂 LLM → 最终回复发送
//! ```
//!
//! ### 关键设计决策
//! 1. **单进程架构** — 旧版 daemon+worker 分离模式已废弃,统一用 tokio task
//! 2. **公平队列** — MessageQueue 按用户轮转,防止一个用户刷屏饿死其他人
//! 3. **可选数据库** — PostgreSQL 不可用时回退到文件存储
//! 4. **两层元工具** — LLM 通过 query_capabilities / call_capability 间接调用工具
mod channel;
mod cli;
mod context;
mod daemon;
@@ -5,6 +37,7 @@ mod db;
mod ipc;
mod llm;
mod logger;
mod queue;
mod scheduler;
mod state;
mod tools;
@@ -15,11 +48,11 @@ use clap::Parser;
use cli::{AmapAction, Cli, Commands, MemosAction, ToolCommand};
use context::MemoryStore;
use db::Database;
use llm::{Conversation, ConversationConfig, DEFAULT_SYSTEM_PROMPT, ToolExecutor, Usage};
use llm::{ChatResult, Conversation, ConversationConfig, DEFAULT_SYSTEM_PROMPT, ToolExecutor, Usage};
use std::sync::Arc;
use tools::approval::{ApprovalDecision, ApprovalManager};
use tools::types::ExecutionContext;
use tracing::{error, info};
use tracing::{error, info, warn};
use wechat::client::WeChatClient;
#[tokio::main]
@@ -105,6 +138,8 @@ async fn main() {
cmd_daemon(sock, &database, &file_state, &memory_store).await;
}
Commands::Worker { sock } => {
warn!("Worker 模式已废弃,请使用 daemon 模式");
#[allow(deprecated)]
if let Err(e) = worker::run(&sock).await {
error!("Worker 失败: {}", e);
}
@@ -336,7 +371,6 @@ async fn cmd_listen(
.send_text(&uid, &msg, None)
.await
.map(|_| ())
.map_err(|e| e)
})
})
};
@@ -501,8 +535,8 @@ async fn listen_loop(
info!("收到消息 from={}: {}", from, text);
// === 入库:收到的消息 ===
if let Some(db) = database {
if let Err(e) = db::models::insert_chat_record(
if let Some(db) = database
&& let Err(e) = db::models::insert_chat_record(
db.pool(),
"inbound",
from,
@@ -516,7 +550,6 @@ async fn listen_loop(
{
error!("保存聊天记录失败: {}", e);
}
}
// Echo 模式
if echo {
@@ -524,8 +557,8 @@ async fn listen_loop(
Ok(sent_id) => {
info!("回显成功 msg_id={}", sent_id);
// === 入库:发送的回显 ===
if let Some(db) = database {
if let Err(e) = db::models::insert_chat_record(
if let Some(db) = database
&& let Err(e) = db::models::insert_chat_record(
db.pool(),
"outbound",
from,
@@ -539,7 +572,6 @@ async fn listen_loop(
{
error!("保存聊天记录失败: {}", e);
}
}
}
Err(e) => error!("回显失败: {}", e),
}
@@ -577,8 +609,8 @@ async fn listen_loop(
}
// LLM 回复(异步执行,避免审批阻塞主循环)
if enable_llm {
if let Some(conv) = &conversation {
if enable_llm
&& let Some(conv) = &conversation {
let conv = conv.clone();
let client = client.clone();
let database = database.clone();
@@ -604,8 +636,8 @@ async fn listen_loop(
{
Ok(sent_id) => {
info!("LLM 回复成功 msg_id={}", sent_id);
if let Some(db) = database {
if let Err(e) = db::models::insert_chat_record(
if let Some(db) = database
&& let Err(e) = db::models::insert_chat_record(
db.pool(),
"outbound",
&from_owned,
@@ -619,13 +651,11 @@ async fn listen_loop(
{
error!("保存聊天记录失败: {}", e);
}
}
}
Err(e) => error!("发送 LLM 回复失败: {}", e),
}
});
}
}
}
}
Err(e) => {
@@ -665,7 +695,7 @@ async fn generate_reply_with_tools(
user_text: String,
db: &Option<Arc<Database>>,
) -> Result<String, String> {
let (reply, _used_tools, usage) = conv.chat_with_tools(user_text).await?;
let ChatResult { reply, used_tools: _used_tools, usage, .. } = conv.chat_with_tools(user_text).await?;
if let Some(u) = &usage {
info!(
"📊 Token: {} 总 / {} 提示 / {} 生成 | 缓存命中 {} | 缓存未命中 {}",
@@ -687,8 +717,8 @@ async fn store_usage(
provider: &str,
u: &Usage,
) {
if let Some(db) = db {
if let Err(e) = db::models::insert_llm_usage(
if let Some(db) = db
&& let Err(e) = db::models::insert_llm_usage(
db.pool(),
user_id,
model,
@@ -702,7 +732,6 @@ async fn store_usage(
{
tracing::error!("存储 LLM 用量失败: {}", e);
}
}
}
async fn cmd_whoami(database: &Option<Arc<Database>>, file_state: &state::StateManager) {
@@ -828,8 +857,8 @@ async fn cmd_send(
println!("✅ 消息已发送, msg_id={}", msg_id);
// 入库:发送的消息
if let Some(db) = database {
if let Err(e) = db::models::insert_chat_record(
if let Some(db) = database
&& let Err(e) = db::models::insert_chat_record(
db.pool(),
"outbound",
to,
@@ -843,7 +872,6 @@ async fn cmd_send(
{
error!("保存聊天记录失败: {}", e);
}
}
}
Err(e) => error!("发送失败: {}", e),
}
@@ -863,9 +891,7 @@ async fn builtin_approve(ctx: &ExecutionContext, name: &str) -> Result<bool, Str
name, code
);
if let Some(ref send) = ctx.send_wechat {
if let Err(e) = send(&ctx.user_id, &msg).await {
return Err(e);
}
send(&ctx.user_id, &msg).await?
}
match tokio::time::timeout(std::time::Duration::from_secs(300), rx).await {
+582
View File
@@ -0,0 +1,582 @@
//! ## 多渠道公平轮转消息队列 (MessageQueue)
//!
//! ### 设计目标
//!
//! 多用户场景下保证**公平性**:如果一个用户连续发了很多消息,不会让其他用户饿死。
//! 出队顺序是**轮转(round-robin**的:
//! - A 发了 3 条、B 发了 1 条 → 处理顺序 A1, B1, A2, A3
//!
//! ### 消息关联追踪
//!
//! 每条消息有一个 `correlation_id`Uuid),用来追踪一次用户请求的完整生命周期:
//! 用户消息 → LLM 处理 → 工具调用 → 工具结果回喂 → 最终回复发送。
//! 这样即使多用户并发,回复也不会串。
//!
//! ### 架构位置
//!
//! ```text
//! receive_messages → enqueue PipelineMessage
//! ┌─────────────────────────────┐
//! │ MessageQueue │
//! │ [User A] [User B] │ ← 每用户独立 VecDeque
//! │ pending: [A, B, A] │ ← 公平轮转索引
//! └─────────────────────────────┘
//! QueueRunner::run() → dequeue → 按 kind 路由到 mpsc 通道
//! ├─ llm_tx (UserMessage, ToolResult)
//! ├─ tool_tx (ToolCall, ApprovalRequest)
//! └─ send_tx (LLMReply)
//! ```
use crate::channel::ChannelId;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Notify;
use uuid::Uuid;
// ─── 管线消息类型 ───
/// 消息种类 —— 区分不同消费路由
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MessageKind {
/// 从外部收到的用户消息 → 交给 LLM Consumer
UserMessage {
text: String,
message_id: String,
context_token: Option<String>,
/// 审批通过后携带的已批准工具名
approved_tool: Option<String>,
},
/// LLM 生成的回复文本 → 发给 Send Consumer
LLMReply {
text: String,
context_token: Option<String>,
},
/// LLM 请求的工具调用 → 发给 Tool Consumer
ToolCall {
session_id: Uuid,
tool_call_id: String,
tool_name: String,
arguments: String,
context_token: Option<String>,
/// 该工具调用是否已被用户批准(跳过审批流程)
approved: bool,
},
/// 工具执行结果 → 回喂 LLM Consumer
ToolResult {
session_id: Uuid,
tool_call_id: String,
tool_name: String,
result: String,
context_token: Option<String>,
},
/// 高风险工具 → 需用户审批
ApprovalRequest {
session_id: Uuid,
tool_name: String,
tool_args: String,
/// 审批后要追加到用户消息中的文本
approval_prompt: String,
},
/// 定时任务到期 → 交给 LLM Consumer
ScheduledTask {
text: String,
task_name: String,
},
}
/// 管线消息 —— 携带路由元数据的统一消息类型
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineMessage {
/// 来源/目标渠道(平台 + 用户),全程不变
pub channel: ChannelId,
/// 关联 ID:追踪一次用户请求的完整链路(用户消息→LLM→工具→回喂→发送)
pub correlation_id: Uuid,
/// 消息种类
pub kind: MessageKind,
}
impl PipelineMessage {
/// 便捷:渠道中的 user_id
pub fn user_id(&self) -> &str { &self.channel.user_id }
/// 便捷:渠道中的 platform
#[allow(dead_code)]
pub fn platform(&self) -> &str { &self.channel.platform }
/// 快速构造一条用户消息
#[allow(dead_code)]
pub fn user_message(
user_id: impl Into<String>,
text: impl Into<String>,
message_id: impl Into<String>,
context_token: Option<String>,
) -> Self {
Self {
channel: ChannelId::wechat(user_id),
correlation_id: Uuid::new_v4(),
kind: MessageKind::UserMessage {
text: text.into(),
message_id: message_id.into(),
context_token,
approved_tool: None,
},
}
}
/// 快速构造一条 LLM 回复
pub fn llm_reply(
channel: ChannelId,
correlation_id: Uuid,
text: impl Into<String>,
context_token: Option<String>,
) -> Self {
Self {
channel,
correlation_id,
kind: MessageKind::LLMReply {
text: text.into(),
context_token,
},
}
}
/// 快速构造一条工具调用
pub fn tool_call(
channel: ChannelId,
correlation_id: Uuid,
session_id: Uuid,
tool_call_id: impl Into<String>,
tool_name: impl Into<String>,
arguments: impl Into<String>,
context_token: Option<String>,
) -> Self {
Self {
channel,
correlation_id,
kind: MessageKind::ToolCall {
session_id,
tool_call_id: tool_call_id.into(),
tool_name: tool_name.into(),
arguments: arguments.into(),
context_token,
approved: false,
},
}
}
/// 快速构造一条已批准的工具调用(跳过审批)
pub fn approved_tool_call(
channel: ChannelId,
correlation_id: Uuid,
session_id: Uuid,
tool_call_id: impl Into<String>,
tool_name: impl Into<String>,
arguments: impl Into<String>,
context_token: Option<String>,
) -> Self {
Self {
channel,
correlation_id,
kind: MessageKind::ToolCall {
session_id,
tool_call_id: tool_call_id.into(),
tool_name: tool_name.into(),
arguments: arguments.into(),
context_token,
approved: true,
},
}
}
/// 快速构造一条工具结果
pub fn tool_result(
channel: ChannelId,
correlation_id: Uuid,
session_id: Uuid,
tool_call_id: impl Into<String>,
tool_name: impl Into<String>,
result: impl Into<String>,
context_token: Option<String>,
) -> Self {
Self {
channel,
correlation_id,
kind: MessageKind::ToolResult {
session_id,
tool_call_id: tool_call_id.into(),
tool_name: tool_name.into(),
result: result.into(),
context_token,
},
}
}
}
// ─── 公平轮转队列 ───
/// 多渠道公平轮转消息队列
///
/// 按 ChannelIdplatform + user_id)分队列,轮转出队保证公平。
/// 出队后由 QueueRunner 按 MessageKind 路由到对应 mpsc 通道。
pub struct MessageQueue {
/// 每渠道独立队列
queues: HashMap<ChannelId, VecDeque<PipelineMessage>>,
/// 有待处理消息的渠道(轮转顺序)
pending: VecDeque<ChannelId>,
/// 空→非空时触发 Notify 唤醒消费者
signal: Arc<Notify>,
/// 定时唤醒间隔(兜底用)
poll_interval: Duration,
}
impl MessageQueue {
pub fn new() -> Self {
Self {
queues: HashMap::new(),
pending: VecDeque::new(),
signal: Arc::new(Notify::new()),
poll_interval: Duration::from_millis(200),
}
}
#[allow(dead_code)]
pub fn with_poll_interval(mut self, d: Duration) -> Self {
self.poll_interval = d;
self
}
/// 入队。如果该渠道之前为空 → 加入 pending 尾部 + 通知
pub fn enqueue(&mut self, item: PipelineMessage) {
let ch = item.channel.clone();
let q = self.queues.entry(ch.clone()).or_default();
let was_empty = q.is_empty();
q.push_back(item);
if was_empty {
self.pending.push_back(ch);
self.signal.notify_one();
}
}
/// 公平轮转出队
///
/// 每次调用切换到下一个有消息的渠道:
/// - 从 pending 头部取出一个渠道
/// - pop 其一条消息
/// - 该渠道还有消息 → 放回 pending 尾部
pub fn dequeue(&mut self) -> Option<PipelineMessage> {
while let Some(ch) = self.pending.pop_front() {
if let Some(q) = self.queues.get_mut(&ch)
&& let Some(item) = q.pop_front() {
if !q.is_empty() {
self.pending.push_back(ch);
} else {
self.queues.remove(&ch);
}
return Some(item);
}
self.queues.remove(&ch);
}
None
}
pub fn signal(&self) -> Arc<Notify> {
Arc::clone(&self.signal)
}
#[allow(dead_code)]
pub fn poll_interval(&self) -> Duration {
self.poll_interval
}
#[allow(dead_code)]
pub fn has_pending(&self, ch: &ChannelId) -> bool {
self.queues.get(ch).is_some_and(|q| !q.is_empty())
}
#[allow(dead_code)]
pub fn pending_count(&self) -> usize {
self.pending.len()
}
/// 清空指定渠道的全部待处理消息
#[allow(dead_code)]
pub fn drain_channel(&mut self, ch: &ChannelId) -> Vec<PipelineMessage> {
let drained = self
.queues
.remove(ch)
.map(|mut q| q.drain(..).collect())
.unwrap_or_default();
self.pending.retain(|c| c != ch);
drained
}
}
// ─── 消费者通道标识 ───
/// 消息路由目标
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConsumerTarget {
/// LLM 消费者(处理 UserMessage, ToolResult
LLM,
/// 工具消费者(处理 ToolCall, ApprovalRequest
Tool,
/// 发送消费者(处理 LLMReply
Send,
}
impl PipelineMessage {
/// 判断这条消息应该路由到哪个消费者
pub fn target(&self) -> ConsumerTarget {
match self.kind {
MessageKind::UserMessage { .. }
| MessageKind::ToolResult { .. }
| MessageKind::ScheduledTask { .. } => ConsumerTarget::LLM,
MessageKind::ToolCall { .. } | MessageKind::ApprovalRequest { .. } => {
ConsumerTarget::Tool
}
MessageKind::LLMReply { .. } => ConsumerTarget::Send,
}
}
}
// ─── 测试 ───
#[cfg(test)]
mod tests {
use super::*;
use crate::queue::QueueRunner;
fn make_user_msg(user: &str, text: &str) -> PipelineMessage {
PipelineMessage::user_message(user, text, "", None)
}
#[test]
fn test_target_routing() {
let msg = PipelineMessage::user_message("u1", "hello", "m1", None);
assert_eq!(msg.target(), ConsumerTarget::LLM);
let msg = PipelineMessage::llm_reply(ChannelId::wechat("u1"), Uuid::new_v4(), "hi", None);
assert_eq!(msg.target(), ConsumerTarget::Send);
let msg = PipelineMessage::tool_call(
ChannelId::wechat("u1"),
Uuid::new_v4(),
Uuid::new_v4(),
"tc1",
"query_weather",
r#"{"location":"北京"}"#,
None,
);
assert_eq!(msg.target(), ConsumerTarget::Tool);
}
#[test]
fn test_fair_round_robin() {
let mut q = MessageQueue::new();
q.enqueue(make_user_msg("A", "a1"));
q.enqueue(make_user_msg("A", "a2"));
q.enqueue(make_user_msg("A", "a3"));
q.enqueue(make_user_msg("B", "b1"));
assert_eq!(q.dequeue().unwrap().channel.user_id, "A");
assert_eq!(q.dequeue().unwrap().channel.user_id, "B");
assert_eq!(q.dequeue().unwrap().channel.user_id, "A");
assert_eq!(q.dequeue().unwrap().channel.user_id, "A");
assert!(q.dequeue().is_none());
}
#[test]
fn test_single_user() {
let mut q = MessageQueue::new();
q.enqueue(make_user_msg("X", "hi"));
q.enqueue(make_user_msg("X", "hello"));
assert_eq!(q.dequeue().unwrap().channel.user_id, "X");
assert_eq!(q.dequeue().unwrap().channel.user_id, "X");
assert!(q.dequeue().is_none());
}
#[test]
fn test_empty_queue() {
let mut q = MessageQueue::new();
assert!(q.dequeue().is_none());
assert_eq!(q.pending_count(), 0);
}
#[test]
fn test_user_id_preserved() {
let msg = PipelineMessage::user_message("wx_user_1", "天气?", "m1", None);
assert_eq!(msg.channel.user_id, "wx_user_1");
let reply = PipelineMessage::llm_reply(ChannelId::wechat("wx_user_1"), msg.correlation_id, "晴天", None);
assert_eq!(reply.channel.user_id, "wx_user_1");
assert_eq!(reply.correlation_id, msg.correlation_id);
}
#[test]
fn test_mixed_item_types() {
let mut q = MessageQueue::new();
q.enqueue(PipelineMessage::user_message("U", "user msg", "1", None));
q.enqueue(PipelineMessage {
channel: ChannelId::wechat("U"),
correlation_id: Uuid::new_v4(),
kind: MessageKind::ScheduledTask {
text: "task result".into(),
task_name: "reminder".into(),
},
});
assert!(matches!(
q.dequeue().unwrap().kind,
MessageKind::UserMessage { .. }
));
assert!(matches!(
q.dequeue().unwrap().kind,
MessageKind::ScheduledTask { .. }
));
}
// ─── 端到端关联测试 ───
/// 验证 user_id 从用户消息到 LLM 回复全程不变
#[test]
fn test_user_id_pipeline_preserved() {
let user_id = "wx_user_1";
// 用户消息入队
let user_msg = PipelineMessage::user_message(user_id, "天气怎么样?", "msg1", None);
assert_eq!(user_msg.channel.user_id, user_id);
let correlation_id = user_msg.correlation_id;
// LLM 回复(模拟 LLM Consumer 生成)
let reply = PipelineMessage::llm_reply(ChannelId::wechat(user_id), correlation_id, "今天晴天", None);
assert_eq!(reply.channel.user_id, user_id);
assert_eq!(reply.correlation_id, correlation_id);
// 工具调用(模拟 LLM Consumer 生成)
let tool_call = PipelineMessage::tool_call(
ChannelId::wechat(user_id),
correlation_id,
Uuid::new_v4(),
"tc1",
"query_weather",
r#"{"location":"北京"}"#,
None,
);
assert_eq!(tool_call.channel.user_id, user_id);
assert_eq!(tool_call.correlation_id, correlation_id);
// 工具结果回喂(模拟 Tool Consumer 生成)
let tool_result = PipelineMessage::tool_result(
ChannelId::wechat(user_id),
correlation_id,
Uuid::new_v4(),
"tc1",
"query_weather",
"25°C",
None,
);
assert_eq!(tool_result.channel.user_id, user_id);
assert_eq!(tool_result.correlation_id, correlation_id);
}
/// 验证多个用户的消息不会串
#[test]
fn test_multi_user_isolation() {
let mut q = MessageQueue::new();
q.enqueue(PipelineMessage::user_message("u1", "你好", "1", None));
q.enqueue(PipelineMessage::user_message("u2", "hello", "2", None));
q.enqueue(PipelineMessage::user_message("u1", "天气?", "3", None));
// 按公平轮转出队,验证 user_id 正确
let msg1 = q.dequeue().unwrap();
assert_eq!(msg1.channel.user_id, "u1");
let msg2 = q.dequeue().unwrap();
assert_eq!(msg2.channel.user_id, "u2");
let msg3 = q.dequeue().unwrap();
assert_eq!(msg3.channel.user_id, "u1");
}
/// 验证路由目标正确
#[test]
fn test_consumer_target_routing() {
// UserMessage → LLM
let m = PipelineMessage::user_message("u1", "hi", "1", None);
assert_eq!(m.target(), ConsumerTarget::LLM);
// ToolResult → LLM
let m = PipelineMessage::tool_result(ChannelId::wechat("u1"), Uuid::new_v4(), Uuid::new_v4(), "tc1", "test", "ok", None);
assert_eq!(m.target(), ConsumerTarget::LLM);
// ScheduledTask → LLM
let m = PipelineMessage {
channel: ChannelId::wechat("u1"),
correlation_id: Uuid::new_v4(),
kind: MessageKind::ScheduledTask { text: "task".into(), task_name: "t1".into() },
};
assert_eq!(m.target(), ConsumerTarget::LLM);
// ToolCall → Tool
let m = PipelineMessage::tool_call(ChannelId::wechat("u1"), Uuid::new_v4(), Uuid::new_v4(), "tc1", "weather", "{}", None);
assert_eq!(m.target(), ConsumerTarget::Tool);
// ApprovalRequest → Tool
let m = PipelineMessage {
channel: ChannelId::wechat("u1"),
correlation_id: Uuid::new_v4(),
kind: MessageKind::ApprovalRequest {
session_id: Uuid::new_v4(),
tool_name: "high_risk_tool".into(),
tool_args: "{}".into(),
approval_prompt: "".into(),
},
};
assert_eq!(m.target(), ConsumerTarget::Tool);
// LLMReply → Send
let m = PipelineMessage::llm_reply(ChannelId::wechat("u1"), Uuid::new_v4(), "reply", None);
assert_eq!(m.target(), ConsumerTarget::Send);
}
/// 验证 QueueRunner 的路由逻辑:入队 → 路由到正确通道
#[tokio::test]
async fn test_queue_runner_routing() {
let (runner, mut channels) = QueueRunner::new(16);
let enqueue = runner.enqueue_handle();
// 启动 runner(在后台运行)
let handle = tokio::spawn(async move {
runner.run().await;
});
// 入队一个 UserMessage → 应到 llm_rx
let user_msg = PipelineMessage::user_message("u1", "hello", "m1", None);
enqueue.enqueue(user_msg).await;
// 入队一个 LLMReply → 应到 send_rx
let reply_msg = PipelineMessage::llm_reply(ChannelId::wechat("u1"), Uuid::new_v4(), "reply", None);
enqueue.enqueue(reply_msg).await;
// 验证 llm_rx 收到 UserMessage
let llm_msg = tokio::time::timeout(
std::time::Duration::from_secs(2),
channels.llm_rx.recv(),
)
.await
.expect("llm_rx 超时")
.expect("llm_rx 通道关闭");
assert_eq!(llm_msg.channel.user_id, "u1");
assert!(matches!(llm_msg.kind, MessageKind::UserMessage { .. }));
// 验证 send_rx 收到 LLMReply
let send_msg = tokio::time::timeout(
std::time::Duration::from_secs(2),
channels.send_rx.recv(),
)
.await
.expect("send_rx 超时")
.expect("send_rx 通道关闭");
assert_eq!(send_msg.channel.user_id, "u1");
assert!(matches!(send_msg.kind, MessageKind::LLMReply { .. }));
// 关闭 runner
handle.abort();
}
}
+19
View File
@@ -0,0 +1,19 @@
//! ## 消息队列 —— 多渠道公平轮转 + 三消费者路由
//!
//! 这是 daemon 架构的消息管线核心:
//!
//! - `message_queue` — 公平轮转队列(按用户轮转出队)
//! - `runner` — 队列运行时(出队 → 按 MessageKind 路由到对应 mpsc 通道)
//!
//! ### 消费者路由
//! ```text
//! MessageKind::UserMessage / ToolResult / ScheduledTask → LLM Consumer
//! MessageKind::ToolCall / ApprovalRequest → Tool Consumer
//! MessageKind::LLMReply → Send Consumer
//! ```
pub mod message_queue;
pub mod runner;
pub use message_queue::{ConsumerTarget, MessageKind, MessageQueue, PipelineMessage};
pub use runner::{ConsumerChannels, EnqueueHandle, QueueRunner};
+219
View File
@@ -0,0 +1,219 @@
//! ## QueueRunner —— 消息队列运行时(路由调度器)
//!
//! 从公平轮转 `MessageQueue` 中出队消息,然后按 `MessageKind` 路由到对应的
//! tokio mpsc 通道,由注册的消费者 task 处理。
//!
//! ### 角色定位
//!
//! QueueRunner 是整个消息管线的**调度核心**:
//! 1. 持有 `MessageQueue`(公平轮转)+ 3 个 mpsc 通道
//! 2. 在 `run()` 循环中不断出队 → 按类型路由 → 投递到正确的消费者
//! 3. 支持优雅关闭(AtomicBool 信号)
//!
//! ### 架构
//!
//! ```text
//! ┌──────────────┐
//! enqueue ──────→ │ MessageQueue │ ← 公平轮转
//! └──────┬───────┘
//! │ dequeue
//! ▼
//! ┌──────────────┐
//! │ QueueRunner │ ← 路由循环
//! └──┬───┬───┬──┘
//! ┌─┘ │ └─┐
//! ▼ ▼ ▼
//! llm_tx tool_tx send_tx
//! │ │ │
//! ┌────┘ ┌─┘ ┌──┘
//! ▼ ▼ ▼
//! LLMCons. ToolCons. SendCons.
//! ```
use crate::queue::{ConsumerTarget, MessageQueue, PipelineMessage};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex, Notify};
use tracing::{error, info, warn};
/// 消费者通道集 —— 创建 QueueRunner 时获得接收端
pub struct ConsumerChannels {
pub llm_rx: mpsc::Receiver<PipelineMessage>,
pub tool_rx: mpsc::Receiver<PipelineMessage>,
pub send_rx: mpsc::Receiver<PipelineMessage>,
}
/// 队列运行时 —— 负责从公平轮转队列出队并按类型路由
pub struct QueueRunner {
/// 公平轮转队列
queue: Arc<Mutex<MessageQueue>>,
/// LLM 消费者通道
llm_tx: mpsc::Sender<PipelineMessage>,
/// 工具消费者通道
tool_tx: mpsc::Sender<PipelineMessage>,
/// 发送消费者通道
send_tx: mpsc::Sender<PipelineMessage>,
/// 关闭信号
shutdown: Arc<AtomicBool>,
/// 队列信号(空→非空通知)
signal: Arc<Notify>,
/// 轮询间隔
poll_interval: std::time::Duration,
}
impl QueueRunner {
/// 创建 QueueRunner 和对应的消费者通道
///
/// `channel_size`: 每个 mpsc 通道的缓冲区大小
pub fn new(channel_size: usize) -> (Self, ConsumerChannels) {
let (llm_tx, llm_rx) = mpsc::channel(channel_size);
let (tool_tx, tool_rx) = mpsc::channel(channel_size);
let (send_tx, send_rx) = mpsc::channel(channel_size);
let queue = MessageQueue::new();
let signal = queue.signal();
let queue = Arc::new(Mutex::new(queue));
let runner = Self {
queue,
llm_tx,
tool_tx,
send_tx,
shutdown: Arc::new(AtomicBool::new(false)),
signal,
poll_interval: std::time::Duration::from_millis(200),
};
let channels = ConsumerChannels {
llm_rx,
tool_rx,
send_rx,
};
(runner, channels)
}
/// 获取队列的写句柄(用于外部入队)
pub fn enqueue_handle(&self) -> EnqueueHandle {
EnqueueHandle {
queue: self.queue.clone(),
shutdown: self.shutdown.clone(),
}
}
/// 获取关闭信号句柄
pub fn shutdown_handle(&self) -> ShutdownHandle {
ShutdownHandle {
shutdown: self.shutdown.clone(),
}
}
/// 启动路由主循环(阻塞,应在 tokio::spawn 中运行)
pub async fn run(&self) {
info!("QueueRunner 已启动");
loop {
// 检查关闭信号
if self.shutdown.load(Ordering::Relaxed) {
info!("QueueRunner 收到关闭信号,停止路由");
break;
}
// 出队
let item = {
let mut q = self.queue.lock().await;
q.dequeue()
};
if let Some(msg) = item {
// 预提取字段(避免 borrow + move 冲突)
let target = msg.target();
let user_id = msg.channel.user_id.clone();
let correlation_id = msg.correlation_id;
let result = match target {
ConsumerTarget::LLM => self.llm_tx.send(msg).await,
ConsumerTarget::Tool => self.tool_tx.send(msg).await,
ConsumerTarget::Send => self.send_tx.send(msg).await,
};
if let Err(_e) = result {
warn!(
"路由消息失败 (target={:?}, user={}, corr={}): 通道已关闭,跳过",
target, user_id, correlation_id
);
// 不中断整个路由循环,跳过该消息继续处理
}
} else {
// 队列为空,等待通知或超时
tokio::select! {
_ = self.signal.notified() => {
// 有新的消息入队,继续循环
}
_ = tokio::time::sleep(self.poll_interval) => {
// 超时兜底,继续检查
}
}
}
}
info!("QueueRunner 已停止");
}
}
/// 入队句柄 —— 外部模块通过它向队列投放消息
#[derive(Clone)]
pub struct EnqueueHandle {
queue: Arc<Mutex<MessageQueue>>,
shutdown: Arc<AtomicBool>,
}
impl EnqueueHandle {
/// 向队列投放一条消息
pub async fn enqueue(&self, msg: PipelineMessage) {
if self.shutdown.load(Ordering::Relaxed) {
warn!(
"队列已关闭,丢弃消息: user={}",
msg.user_id()
);
return;
}
self.queue.lock().await.enqueue(msg);
}
}
/// 关闭句柄
#[derive(Clone)]
pub struct ShutdownHandle {
shutdown: Arc<AtomicBool>,
}
impl ShutdownHandle {
/// 发送关闭信号
pub fn shutdown(&self) {
self.shutdown.store(true, Ordering::Relaxed);
}
}
/// 等所有消费者处理完并关闭后,等待队列排空
#[allow(dead_code)]
pub async fn drain_and_shutdown(
_enqueue: &EnqueueHandle,
runner_shutdown: &ShutdownHandle,
consumer_handles: Vec<tokio::task::JoinHandle<()>>,
) {
info!("开始关闭队列系统...");
// 1. 阻止新消息入队(设置关闭标志)
runner_shutdown.shutdown();
// 2. 等待排空(给一个合理的时间)
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// 3. 等待消费者 task 完成
for handle in consumer_handles {
if let Err(e) = handle.await {
error!("消费者 task 结束异常: {:?}", e);
}
}
info!("队列系统已完全关闭");
}
+31 -1
View File
@@ -1,9 +1,39 @@
//! ## 定时任务调度器
//!
//! 轮询 PostgreSQL 中到期的 `scheduled_tasks` 表,执行任务后将结果通知用户。
//!
//! ### 调度流程
//! 1. 每 5 秒检查一次是否有到期任务(`next_run_at <= NOW()`
//! 2. 使用 `FOR UPDATE SKIP LOCKED` 防止多个实例重复执行
//! 3. 执行任务 shell 命令(120 秒超时)
//! 4. 更新 `next_run_at`(当前时间 + interval_seconds
//! 5. 通过回调函数通知用户结果
//!
//! ### 安全保障
//! - `SKIP LOCKED` — 多实例部署时不会抢同一任务
//! - `kill_on_drop` — 子进程在超时时会被杀掉
//! - 120 秒超时 — 防止 shell 命令无限执行
use sqlx::PgPool;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::interval;
/// 定时任务调度器 —— 轮询 PostgreSQL 中到期的 scheduled_tasks
/// ## 定时任务调度器
///
/// 轮询 PostgreSQL 中到期的 `scheduled_tasks` 表,执行任务后将结果通知用户。
///
/// ### 调度流程
/// 1. 每 5 秒检查一次是否有到期任务(`next_run_at <= NOW()`
/// 2. 使用 `FOR UPDATE SKIP LOCKED` 防止多个实例重复执行
/// 3. 执行任务 shell 命令(120 秒超时)
/// 4. 更新 `next_run_at`(当前时间 + interval_seconds
/// 5. 通过回调函数通知用户结果
///
/// ### 安全保障
/// * `SKIP LOCKED` — 多实例部署时不会抢同一任务
/// * `kill_on_drop` — 子进程在超时时会被杀掉
/// * 120 秒超时 — 防止 shell 命令无限执行
pub struct Scheduler {
pool: Option<Arc<PgPool>>,
}
+21 -1
View File
@@ -1,3 +1,14 @@
//! ## 文件状态管理(PostgreSQL 不可用时的回退方案)
//!
//! ### 设计意图
//! 在数据库不可用时,使用本地 JSON 文件持久化认证和运行时状态。
//! 当数据库可用时,数据优先存数据库,并从文件迁移到数据库后清理文件。
//!
//! ### 存储位置
//! 默认 `~/.ias/data/weixin-ilink/`
//! * `auth.json` — 认证信息(token, account_id, base_url
//! * `runtime.json` — 运行时状态(get_updates_buf
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
@@ -15,7 +26,16 @@ pub struct RuntimeState {
pub get_updates_buf: String,
}
/// 状态管理器
/// ## 状态管理器 —— 基于文件的状态持久化
///
/// 在数据库不可用时代替 DB 存储认证和运行时状态。
///
/// ### 文件格式
/// * `auth.json` — `AuthState { token, account_id, base_url }`
/// * `runtime.json` — `RuntimeState { get_updates_buf }`
///
/// ### 安全
/// Unix 系统上 auth.json 的权限被设置为 600(仅所有者可读写)
pub struct StateManager {
state_dir: PathBuf,
}
+44 -5
View File
@@ -1,3 +1,23 @@
//! ## 审批管理器 —— 高风险工具的用户确认流程
//!
//! 当 LLM 调用高风险工具时,需要用户通过微信输入确认码来批准。
//!
//! ### 完整流程
//! 1. `create(user_id, skill_name)` → 生成 6 位随机确认码,SHA-256 哈希后存储
//! 2. 向用户发送 "⚠️ 操作确认\n\n技能:{name}\n确认码:{code}"
//! 3. 用户在微信聊天中输入确认码
//! 4. `handle_reply(user_id, reply)` → 验证确认码哈希
//! - 匹配 → `Approved`
//! - 不匹配 → 减少尝试次数(最多 3 次)
//! - 输入 0 或"取消" → `Rejected`
//! - 超时 → `Expired`(每 60 秒清理一次)
//!
//! ### 安全设计
//! - 确认码用 SHA-256 哈希后存储,原始码不持久化
//! - 每次生成随机 6 位数字(100000-999999
//! - 最多 3 次尝试,超时 5 分钟
//! - 同时支持内存和数据库双写
use rand::Rng;
use sha2::{Digest, Sha256};
use sqlx::PgPool;
@@ -6,7 +26,11 @@ use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{Mutex, oneshot};
/// 审批决策
/// ## 审批决策
///
/// * `Approved` — 用户输入了正确的确认码
/// * `Rejected` — 用户拒绝了(输入 0 或"取消"
/// * `Expired` — 超时未确认(5 分钟有效)
#[derive(Debug, Clone, PartialEq)]
pub enum ApprovalDecision {
Approved,
@@ -24,7 +48,23 @@ struct PendingEntry {
sender: oneshot::Sender<ApprovalDecision>,
}
/// 审批管理器
/// ## 审批管理器
///
/// 管理高风险工具的用户确认流程。
///
/// ### 流程
/// 1. `create(user_id, skill_name)` → 生成 6 位随机确认码,哈希后存储
/// 2. 向用户发送"⚠️ 操作确认\n\n技能:{name}\n确认码:{code}"
/// 3. 用户在微信聊天中输入确认码
/// 4. `handle_reply(user_id, reply)` → 验证确认码哈希
/// - 匹配 → Approved
/// - 不匹配 → 减少尝试次数(最多 3 次)
/// - 输入 0 或"取消" → Rejected
/// - 超时 → Expired(每 60 秒清理一次)
///
/// ### 存储
/// * 内存 — `HashMap<user_id, Vec<PendingEntry>>`(快速访问)
/// * 数据库 — `pending_approvals` 表(持久化审计)
pub struct ApprovalManager {
pool: Option<Arc<PgPool>>,
/// user_id → 该用户的待审批队列
@@ -175,12 +215,11 @@ impl ApprovalManager {
let mut map = self.pending.lock().await;
for (uid, i, _) in expired.iter().rev() {
if let Some(entries) = map.get_mut(uid) {
if *i < entries.len() {
if let Some(entries) = map.get_mut(uid)
&& *i < entries.len() {
let entry = entries.remove(*i);
let _ = entry.sender.send(ApprovalDecision::Expired);
}
}
}
map.retain(|_, v| !v.is_empty());
}
+27 -1
View File
@@ -1,6 +1,32 @@
//! ## 内置工具注册表 —— 工具路由中心
//!
//! 连接 LLM 的元工具调用和实际 Rust 实现的工具函数。
//! 所有内置工具在此统一注册,通过名称路由到对应实现。
//!
//! ### 职责
//! 1. `execute(name, args)` — 按名称路由到对应的 builtin 工具执行
//! 2. `specs()` — 返回所有内置工具的 SkillSpec(给元工具 `query_capabilities` 使用)
//! 3. `is_high_risk(name)` — 判断工具是否需要审批
//! 4. `is_builtin(name)` — 判断名称是否对应一个已注册的内置工具
use crate::tools::types::{RiskLevel, SkillResult};
/// 内置工具注册表:名称 → 执行、spec 列表、风险判断
/// ## 内置工具注册表
///
/// 连接 LLM 的元工具调用和实际 Rust 实现的工具函数。
///
/// ### 职责
/// 1. `execute(name, args)` — 按名称路由到对应的 builtin 工具执行
/// 2. `specs()` — 返回所有内置工具的 SkillSpec(给元工具 `query_capabilities` 使用)
/// 3. `is_high_risk(name)` — 判断工具是否需要审批
///
/// ### 当前内置工具
/// * `get_current_datetime` — 获取当前北京时间
/// * `manage_memos` — 管理备忘录(CRUD
/// * `query_weather` — 查询天气(和风天气 API)
/// * `web_search` — 联网搜索(Tavily API
/// * `fetch_page` — 抓取网页内容
/// * `amap_*` — 高德地图系列(POI搜索、地理编码、路径规划等)
pub struct BuiltinRegistry;
impl BuiltinRegistry {
+25
View File
@@ -1,3 +1,28 @@
//! ## 高德地图综合工具(Rust 原生实现 — v5 API
//!
//! 提供 6 个高德地图相关的工具:
//!
//! | 工具名 | 功能 | API 版本 |
//! |--------|------|---------|
//! | `amap_poi_search` | POI(地点)搜索 + 周边搜索 | v5 |
//! | `amap_geocode` | 地理编码:地址 → 坐标 | v3 |
//! | `amap_reverse_geocode` | 逆地理编码:坐标 → 地址 | v3 |
//! | `amap_route_plan` | 路径规划(步行/驾车/骑行/公交) | v5 |
//! | `amap_travel_plan` | 智能旅游规划(搜索兴趣点 + 规划路线) | v5 |
//! | `amap_map_link` | 生成地图可视化链接 | - |
//!
//! ### 认证
//! 环境变量 `AMAP_WEBSERVICE_KEY` 或 `AMAP_KEY`
//! 获取 Key: https://lbs.amap.com/api/webservice/create-project-and-key
//!
//! ### API 端点
//! - POI 文本搜索: `GET /v5/place/text`
//! - POI 周边搜索: `GET /v5/place/around`
//! - 地理编码: `GET /v3/geocode/geo`
//! - 逆地理编码: `GET /v3/geocode/regeo`
//! - 步行/驾车/骑行/公交路线: `GET /v5/direction/{type}`
//! - 所有请求必须带 `appname=amap-lbs-skill`
// 高德地图综合工具(Rust 原生实现 — v5 API
//
// API:
+18 -1
View File
@@ -1,8 +1,21 @@
//! 日期时间工具
//! ## 日期时间工具 —— 获取当前北京时间
//!
//! 最简单的内置工具,无网络依赖,纯粹返回本地时间。
//! 用于 LLM 需要知道当前时间时调用。
//!
//! ### 风险等级
//! `Low` — 无需用户确认,直接执行
//!
//! ### 输出格式
//! ```json
//! {"datetime": "2026-06-10 14:30:00"}
//! ```
use chrono::Local;
use super::super::types::{RiskLevel, SkillResult, SkillSpec};
/// 返回该工具的元数据描述(供 query_capabilities 元工具使用)
pub fn spec() -> SkillSpec {
SkillSpec {
name: "get_current_datetime".into(),
@@ -13,6 +26,10 @@ pub fn spec() -> SkillSpec {
}
}
/// 执行日期时间查询
///
/// 使用 chrono::Local 获取系统当前时间,格式化为 "YYYY-MM-DD HH:MM:SS"。
/// 注意:系统时区需设置为 Asia/Shanghai(北京时间 UTC+8)。
pub fn execute() -> SkillResult {
let now = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
SkillResult::ok(serde_json::json!({"datetime": now}).to_string())
+33 -22
View File
@@ -1,9 +1,23 @@
//! 网页内容提取工具
//! ## 网页内容提取工具 —— 可读性算法提取正文
//!
//! 使用可读性算法自动识别正文区域清除广告/导航/页脚噪声,
//! 按文本密度评分,取最高分区域。同时支持站点自定义 CSS 选择器
//! 自动识别网页正文区域清除广告导航页脚噪声,
//! 按文本密度评分,取最高分区域作为正文
//!
//! 站点选择器配置: `~/.ias/site_selectors.json`
//! ### 提取策略(三层降级)
//! 1. **自定义选择器** — 从 `~/.ias/site_selectors.json` 读取按站点的 CSS 选择器
//! 2. **可读性算法** — 清除噪声 → 对块级元素评分 → 取最高分
//! 3. **回退** — 取 `<body>` 的全部文本
//!
//! ### 评分因子
//! - 文本长度(基础分)
//! - 逗号/句号数(句子结构丰富度)
//! - `<p>` 标签数(段落丰富度)
//! - 链接密度惩罚(链接太多 → 导航/索引)
//! - 代码块惩罚(代码多 → 降分)
//!
//! ### 配置
//! 站点选择器配置文件: `~/.ias/site_selectors.json`
//! 格式: `{"example.com": ["article.main", "#content"]}`
use reqwest::Client as HttpClient;
use scraper::{ElementRef, Html, Selector};
@@ -11,19 +25,20 @@ use std::collections::HashMap;
use super::super::types::{RiskLevel, SkillResult, SkillSpec};
/// 站点选择器配置文件路径
/// 站点选择器配置文件路径(相对于 home 目录)
const CONFIG_PATH: &str = ".ias/site_selectors.json";
/// 最大输出字符数
/// 最大输出字符数(超过则截断)
const MAX_OUTPUT_CHARS: usize = 6000;
/// 需移除的 HTML 标签
/// 需移除的 HTML 标签列表(这些标签内的内容不可能是正文)
const STRIP_TAGS: &[&str] = &[
"script", "style", "noscript", "iframe", "svg",
"nav", "header", "footer", "aside", "form",
];
/// 需移除的 class/id 关键词
/// 需移除的 class/id 关键词列表
/// 匹配元素的 class 或 id 属性,包含这些关键词的视为噪声元素
const NOISE_PATTERNS: &[&str] = &[
"ad", "ads", "advert", "banner",
"sidebar", "side-bar", "widget",
@@ -42,7 +57,7 @@ const NOISE_PATTERNS: &[&str] = &[
"edit-section", "noprint", "thumb",
];
/// 内容评分阈值:低于此分不取
/// 内容评分阈值:低于此分的元素不视为正文候选
const MIN_SCORE_THRESHOLD: f64 = 50.0;
// ─── 工具 spec ───
@@ -136,20 +151,18 @@ fn extract_content(document: &Html, domain: &str) -> String {
let config = load_config();
if let Some(selectors) = config.get(domain) {
for sel_str in selectors {
if let Some(text) = try_selector(document, sel_str) {
if text.len() > 50 {
if let Some(text) = try_selector(document, sel_str)
&& text.len() > 50 {
return text;
}
}
}
}
// 2. 先用可读性算法
if let Some(text) = readability_extract(document) {
if text.len() > 50 {
if let Some(text) = readability_extract(document)
&& text.len() > 50 {
return text;
}
}
// 3. 回退到 body
if let Some(text) = try_selector(document, "body") {
@@ -239,7 +252,7 @@ fn score_element(el: &ElementRef) -> f64 {
score += commas * 3.0;
// 句号/问号/感叹号 → 完整句子
let sentences = text.matches(|c| c == '.' || c == '。' || c == '?' || c == '' || c == '!' || c == '').count() as f64;
let sentences = text.matches(['.', '。', '?', '', '!', '']).count() as f64;
score += sentences * 2.0;
// 段落数(<p> 标签)
@@ -280,11 +293,10 @@ fn extract_clean_text(el: &ElementRef) -> String {
for child in el.descendants() {
// 跳过噪声元素
if let Some(child_el) = ElementRef::wrap(child) {
if is_noise(&child_el) {
if let Some(child_el) = ElementRef::wrap(child)
&& is_noise(&child_el) {
continue;
}
}
if let Some(t) = child.value().as_text() {
let t = t.trim();
@@ -311,12 +323,11 @@ fn extract_clean_text(el: &ElementRef) -> String {
// ─── 辅助 ───
fn extract_title(document: &Html) -> String {
if let Ok(sel) = Selector::parse("title") {
if let Some(el) = document.select(&sel).next() {
if let Ok(sel) = Selector::parse("title")
&& let Some(el) = document.select(&sel).next() {
let t: String = el.text().collect::<Vec<_>>().join(" ").trim().to_string();
if !t.is_empty() { return t; }
}
}
"无标题".to_string()
}
+22 -1
View File
@@ -1,8 +1,25 @@
//! 备忘录工具 — 文件存储,支持 add/list/delete
//! ## 备忘录工具 — 本地文件存储的简易备忘录
//!
//! 支持三种操作:
//! - `add` — 添加一条备忘录(自动分配自增 ID)
//! - `list` — 列出所有备忘录
//! - `delete` — 按 ID 删除备忘录
//!
//! ### 存储
//! 数据存储在 `.data/memos.json` 文件中,JSON 数组格式。
//! 每条记录包含:`id`、`content`、`created_at`。
//!
//! ### 智能推断
//! 当通过 `call_capability` 调用时,可以从 prompt 文本中推断操作类型:
//! - 包含"添加"/"新增"/"add" → 自动转为 add 操作
//! - 包含"删除"/"移除"/"delete" → 自动转为 delete 操作
//! - 其他 → 默认 list 操作
use chrono::Local;
use super::super::types::{RiskLevel, SkillResult, SkillSpec};
/// 返回该工具的元数据描述(供 query_capabilities 元工具使用)
pub fn spec() -> SkillSpec {
SkillSpec {
name: "manage_memos".into(),
@@ -20,6 +37,10 @@ pub fn spec() -> SkillSpec {
}
}
/// 执行备忘录操作
///
/// 从 JSON 参数中解析 action/content/id,执行对应操作。
/// 数据文件路径为 `.data/memos.json`,自动创建目录。
pub async fn execute(args_json: &str) -> SkillResult {
let mut params: serde_json::Value = serde_json::from_str(args_json).unwrap_or_default();
// call_capability 透传完整参数,action/content 在顶层
+16
View File
@@ -1,3 +1,19 @@
//! ## 内置工具实现集合
//!
//! 每个子模块对应一个具体的工具实现:
//!
//! | 模块 | 工具名 | 功能 |
//! |------|--------|------|
//! | `datetime` | `get_current_datetime` | 获取当前北京时间 |
//! | `weather` | `query_weather` | 和风天气查询(实时/预报/逐时) |
//! | `web_search` | `web_search` | Tavily 联网搜索 |
//! | `fetch_page` | `fetch_page` | 网页正文提取(可读性算法) |
//! | `memos` | `manage_memos` | 备忘录增删查 |
//! | `amap` | `amap_*` | 高德地图系列(POI/地理编码/路径规划/旅游规划) |
//!
//! 每个工具都提供 `spec()` 返回元数据,和 `execute()` 执行入口。
//! 通过 `BuiltinRegistry` 统一注册和路由。
pub mod amap;
pub mod datetime;
pub mod fetch_page;
+24 -14
View File
@@ -1,13 +1,25 @@
// 和风天气查询工具(Rust 原生实现,替代 bash 脚本
//
// API:
// GeoAPI: GET /geo/v2/city/lookup?location=城市名 → city_id
// Now: GET /v7/weather/now?location=city_id → 实时天气
// Daily: GET /v7/weather/{3,7,10,15,30}d?location=id → 每日预报
// Hourly: GET /v7/weather/{24,72,168}h?location=id → 逐时预报
//
// 认证: EdDSA JWT (Ed25519)
// 缓存: city_id 内存缓存 1h TTL
//! ## 和风天气查询工具(Rust 原生实现)
//!
//! 通过和风天气 API 查询实时天气、每日预报和逐小时预报。
//!
//! ### API 端点
//! - 城市查询: `GET /geo/v2/city/lookup?location=城市名` → 获取 city_id
//! - 实时天气: `GET /v7/weather/now?location=city_id`
//! - 每日预报: `GET /v7/weather/{3,7,10,15,30}d?location=id`
//! - 逐时预报: `GET /v7/weather/{24,72,168}h?location=id`
//!
//! ### 认证方式
//! 使用 EdDSA (Ed25519) JWT 进行 API 认证。
//! 密钥对存放在 `qweather/` 目录下。
//!
//! ### 环境变量
//! - `QWEATHER_API_HOST` — API 地址(默认 `api.qweather.com`
//! - `QWEATHER_JWT_KEY_ID` — JWT Key ID
//! - `QWEATHER_JWT_PROJECT_ID` — 项目 ID
//! - `QWEATHER_JWT_PRIVATE_KEY_FILE` — Ed25519 私钥路径
//!
//! ### 缓存策略
//! city_id 在内存中缓存 1 小时(TTL),减少重复查询。
use base64::{Engine as _, engine::general_purpose};
use chrono::Utc;
@@ -474,11 +486,9 @@ fn extract_days_from_prompt(prompt: &str) -> u32 {
.rev()
.collect::<String>()
.into()
{
if let Ok(n) = num_str.parse::<u32>() {
&& let Ok(n) = num_str.parse::<u32>() {
return n.min(30).max(1);
}
}
}
}
3 // 默认
@@ -601,7 +611,7 @@ pub async fn execute(params: serde_json::Value) -> SkillResult {
if let Some(arr) = obj.get_mut(*key).and_then(|v| v.as_array_mut()) {
for item in arr.iter_mut() {
if let Some(m) = item.as_object_mut() {
m.retain(|_, v| !v.is_null() && v.as_str().map_or(true, |s| !s.is_empty()));
m.retain(|_, v| !v.is_null() && v.as_str().is_none_or(|s| !s.is_empty()));
}
}
}
+30 -11
View File
@@ -1,15 +1,29 @@
//! Web 搜索工具 — Tavily Search API
//! ## Web 搜索工具 — Tavily Search API
//!
//! API: POST https://api.tavily.com/search
//! 认证: Authorization: Bearer <TAVILY_API_KEY>
//! 文档: https://docs.tavily.com/documentation/api-reference/endpoint/search
//! 通过 Tavily 搜索引擎获取互联网实时信息。
//! Tavily 是一个专为 AI Agent 设计的搜索引擎,返回结构化搜索结果。
//!
//! ### API
//! - 端点: `POST https://api.tavily.com/search`
//! - 认证: `Authorization: Bearer <TAVILY_API_KEY>`
//! - 文档: https://docs.tavily.com/documentation/api-reference/endpoint/search
//!
//! ### 功能特性
//! - 支持 AI 摘要(include_answer
//! - 支持多种搜索深度(basic / advanced / fast / ultra-fast
//! - 支持按主题过滤(general / news / finance
//! - 支持按时间范围过滤
//! - 支持限定/排除域名
//! - 支持按国家优先搜索结果
use reqwest::Client as HttpClient;
use serde::{Deserialize, Serialize};
use super::super::types::{RiskLevel, SkillResult, SkillSpec};
// ─── 请求 ───
// ═══════════════════════════════════════════════
// 请求结构体
// ═══════════════════════════════════════════════
#[derive(Debug, Serialize)]
struct SearchRequest {
@@ -73,7 +87,9 @@ struct SearchRequest {
safe_search: Option<bool>, // Enterprise only
}
// ─── 响应 ───
// ═══════════════════════════════════════════════
// 响应结构体
// ═══════════════════════════════════════════════
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
@@ -126,7 +142,9 @@ struct UsageInfo {
credits: Option<u32>,
}
// ─── 工具 spec ───
// ═══════════════════════════════════════════════
// 工具元数据
// ═══════════════════════════════════════════════
pub fn spec() -> SkillSpec {
SkillSpec {
@@ -161,7 +179,9 @@ pub fn spec() -> SkillSpec {
}
}
// ─── 执行 ───
// ═══════════════════════════════════════════════
// 执行入口
// ═══════════════════════════════════════════════
pub async fn execute(params: serde_json::Value) -> SkillResult {
let api_key = match std::env::var("TAVILY_API_KEY") {
@@ -305,11 +325,10 @@ pub async fn execute(params: serde_json::Value) -> SkillResult {
let mut output = String::new();
// AI 摘要
if let Some(ref answer) = data.answer {
if !answer.is_empty() {
if let Some(ref answer) = data.answer
&& !answer.is_empty() {
output.push_str(&format!("📝 {}\n\n", answer));
}
}
// 搜索结果
if data.results.is_empty() {
+62 -9
View File
@@ -1,14 +1,68 @@
//! ## 工具系统 —— 元工具 + 分离式内置工具
//!
//! 本模块是 iAs 的"工具系统",让 LLM 能够调用外部功能。
//!
//! ### 架构设计(两层元工具)
//!
//! LLM 不直接感知每个具体工具,而是通过两个**元工具**间接调用:
//!
//! 1. **`query_capabilities`** — 列出所有可用工具的名称、描述、参数格式
//! 2. **`call_capability`** — 按名称调用工具,参数通过 JSON 传递
//!
//! 这样做的优势:新增工具时只需要在服务端注册,不需要修改 LLM 的 tool definitions。
//!
//! ### 工具类型
//! * **内置工具** (builtins/) — Rust 代码实现的工具(天气、搜索、高德地图等)
//! * **上下文工具** — read_memories, write_memory, read_summaries(直接在 daemon 中处理)
//!
//! ### 工具执行流程
//! ```text
//! LLM 请求 call_capability("query_weather", {"location":"北京"})
//! → daemon 解析出 target_name="query_weather",提取参数
//! → 高风险?→ 走 ApprovalManager 审批流
//! → 低风险?→ BuiltinRegistry::execute() 或 SubprocessRunner::execute()
//! → 结果返回 LLM 继续对话
//! ## 工具系统 —— 元工具 + 内置工具 + 审批管理
//!
//! 本模块是 iAs 的"工具系统",让 LLM 能够调用外部功能。
//!
//! ### 架构设计(两层元工具)
//!
//! LLM 不直接感知每个具体工具,而是通过两个**元工具**间接调用:
//!
//! 1. **`query_capabilities`** — 列出所有可用工具的名称、描述、参数格式
//! 2. **`call_capability`** — 按名称调用工具,参数通过 JSON 传递
//!
//! 这样做的优势:新增工具时只需要在服务端注册,不需要修改 LLM 的 tool definitions。
//!
//! ### 工具类型
//! - **内置工具** (builtins/) — Rust 代码实现的工具(天气、搜索、高德地图等)
//! - **上下文工具** — read_memories, write_memory, read_summaries(直接在 daemon 中处理)
//!
//! ### 工具执行流程
//! ```text
//! LLM 请求 call_capability("query_weather", {"location":"北京"})
//! → daemon 解析出 target_name="query_weather",提取参数
//! → 高风险?→ 走 ApprovalManager 审批流
//! → 低风险?→ BuiltinRegistry::execute()
//! → 结果返回 LLM 继续对话
//! ```
pub mod approval;
pub mod builtin;
pub mod builtins;
pub mod types;
/// 从 call_capability 的 `{name, prompt}` JSON 中提取目标工具的真实参数
/// ##`call_capability` 的 `{name, prompt}` JSON 中提取目标工具的真实参数
///
/// 逻辑:
/// - 如果 prompt 是 JSON 字符串 → parse 后合并
/// - 如果 prompt 是 JSON 对象 → 直接合并
/// - 顶层字段(非 name/prompt)也合并进去
/// `call_capability` 的参数格式为 `{name: "工具名", prompt: "参数JSON字符串"}`。
/// 这个函数负责从这种格式中提取出工具真正的参数。
///
/// ### 提取逻辑
/// 1. 如果 `prompt` 是 JSON 字符串 → parse 后合并
/// 2. 如果 `prompt` 是 JSON 对象 → 直接合并
/// 3. 顶层字段(非 name/prompt)也合并进去(支持直接传参)
pub fn unpack_call_params(cp: &serde_json::Value) -> serde_json::Value {
let mut params = serde_json::Map::new();
@@ -16,11 +70,10 @@ pub fn unpack_call_params(cp: &serde_json::Value) -> serde_json::Value {
if let Some(prompt) = cp.get("prompt") {
match prompt {
serde_json::Value::String(s) => {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(s) {
if let Some(obj) = v.as_object() {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(s)
&& let Some(obj) = v.as_object() {
params.extend(obj.clone());
}
}
}
serde_json::Value::Object(obj) => {
params.extend(obj.clone());
@@ -138,7 +191,7 @@ fn format_spec(lines: &mut Vec<String>, spec: &SkillSpec) {
.parameters
.get("properties")
.and_then(|v| v.as_object())
.map_or(true, |o| o.is_empty())
.is_none_or(|o| o.is_empty())
{
lines.push(format!(
" 参数: {}",
+33 -4
View File
@@ -1,9 +1,22 @@
//! ## 工具系统类型定义
//!
//! 定义了工具系统的核心类型:
//!
//! - `RiskLevel` — 工具的安全级别(Low/High)
//! - `SkillSpec` — 工具的元数据描述(名称、参数、风险等级)
//! - `SkillResult` — 工具执行结果
//! - `ExecutionContext` — 工具执行时的上下文环境
//! - `WechatSender` — 微信发送回调类型别名
use serde::{Deserialize, Serialize};
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
// ─── 风险等级 ───
/// ## 风险等级 —— 工具的安全级别
///
/// * `Low` — 低风险,直接执行(如天气查询、日期时间)
/// * `High` — 高风险,需要用户输入确认码审批(如写备忘录、发消息)
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "lowercase")]
@@ -14,7 +27,14 @@ pub enum RiskLevel {
impl RiskLevel {}
// ─── 工具元数据 ───
/// ## 工具元数据(SkillSpec)—— 工具的自描述信息
///
/// 用于给 LLM 展示"有什么工具可用、怎么用"。
/// * `name` — 工具名称
/// * `description` — 工具描述
/// * `risk_level` — 风险等级(Low/High
/// * `parameters` — JSON Schema 格式的参数定义
/// * `timeout_secs` — 执行超时时间
#[derive(Debug, Clone, Deserialize)]
pub struct SkillSpec {
@@ -36,7 +56,11 @@ fn default_timeout() -> u64 {
30
}
// ─── 工具执行结果 ───
/// ## 工具执行结果
///
/// * `success` — 执行是否成功
/// * `output` — 可读的输出文本(将返回给 LLM)
/// * `data` — 结构化数据(可选),用于后续处理
#[derive(Debug, Clone, Serialize)]
pub struct SkillResult {
@@ -89,7 +113,12 @@ impl SkillResult {
}
}
// ─── 执行上下文 ───
/// ## 执行上下文 —— 工具执行时需要的环境信息
///
/// 当工具在 daemon 上下文中执行时,携带以下信息:
/// * `user_id` — 触发工具的用户
/// * `approval_manager` — 审批管理器(高风险工具需要)
/// * `send_wechat` — 微信发送回调(用于向用户发送审批提示)
pub type WechatSender = Arc<
dyn Fn(&str, &str) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>> + Send + Sync,
+66 -9
View File
@@ -1,3 +1,19 @@
//! ## 微信 iLink Bot API 客户端
//!
//! 封装了与微信 iLink Bot API 的所有通信:
//!
//! ### 核心功能
//! - **扫码登录** — `get_qrcode()` → `poll_qr_status()` → `login()`
//! - **消息接收** — `receive_messages()`(长轮询)
//! - **消息发送** — `send_text()`
//! - **生命周期** — `notify_start()` / `notify_stop()`
//!
//! ### 线程安全
//! `#[derive(Clone)]` + 内部 `Arc<Mutex<>>` 保证所有方法可以在 tokio task 间共享。
//!
//! ### 环境变量
//! - `WEIXIN_BASE_URL` — API 地址(默认 `https://ilinkai.weixin.qq.com`
use crate::wechat::types::*;
use base64::Engine;
use reqwest::Client as HttpClient;
@@ -6,7 +22,24 @@ use std::time::Duration;
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
/// 微信 iLink Bot API 客户端
/// ## 微信 iLink Bot API 客户端
///
/// ### 职责
/// 封装了与微信 iLink Bot API 的所有通信,包括:
/// * 扫码登录流程(获取二维码、轮询状态)
/// * 消息接收(长轮询 getUpdates
/// * 消息发送(sendMessage
/// * 生命周期管理(notifyStart/notifyStop
///
/// ### 线程安全
/// `#[derive(Clone)]` + 内部 `Arc<Mutex<>>` 保证所有方法可以在 tokio task 间共享。
///
/// ### API 端点
/// * 二维码: `{base_url}/ilink/bot/get_bot_qrcode`
/// * 登录状态: `{base_url}/ilink/bot/get_qrcode_status`
/// * 收消息: `{base_url}/ilink/bot/getupdates`
/// * 发消息: `{base_url}/ilink/bot/sendmessage`
/// * 注册监听: `{base_url}/ilink/bot/msg/notifystart`
#[derive(Clone)]
pub struct WeChatClient {
http: HttpClient,
@@ -22,15 +55,21 @@ impl WeChatClient {
const ILINK_APP_ID: &'static str = "";
/// 默认 bot_type
const DEFAULT_BOT_TYPE: &'static str = "3";
/// 默认 API 地址
/// 默认 API 地址(可通过 WEIXIN_BASE_URL 环境变量覆盖)
const DEFAULT_BASE_URL: &'static str = "https://ilinkai.weixin.qq.com";
/// 二维码固定 API 地址
const FIXED_BASE_URL: &'static str = "https://ilinkai.weixin.qq.com";
/// 二维码固定 API 地址(可通过 WEIXIN_BASE_URL 环境变量覆盖)
fn fixed_base_url() -> String {
std::env::var("WEIXIN_BASE_URL")
.unwrap_or_else(|_| "https://ilinkai.weixin.qq.com".to_string())
}
/// 版本号编码:0x00MMNNPP
const CLIENT_VERSION: u32 = 0x000100_00; // 1.0.0
pub fn new(base_url: Option<String>) -> Self {
let base = base_url.unwrap_or_else(|| Self::DEFAULT_BASE_URL.to_string());
// 优先级: 显式参数 > WEIXIN_BASE_URL 环境变量 > 默认值
let base = base_url
.or_else(|| std::env::var("WEIXIN_BASE_URL").ok())
.unwrap_or_else(|| Self::DEFAULT_BASE_URL.to_string());
Self {
http: HttpClient::builder()
@@ -46,11 +85,14 @@ impl WeChatClient {
// ─── 公共方法 ───
/// 获取登录二维码链接
/// ## 获取登录二维码链接
///
/// 调用 iLink API `/ilink/bot/get_bot_qrcode` 获取二维码。
/// 二维码内容是一个 URL,浏览器打开后显示二维码图片。
pub async fn get_qrcode(&self) -> Result<(String, String), String> {
let url = format!(
"{}/ilink/bot/get_bot_qrcode?bot_type={}",
Self::FIXED_BASE_URL,
&Self::fixed_base_url(),
Self::DEFAULT_BOT_TYPE
);
@@ -68,7 +110,16 @@ impl WeChatClient {
Ok((resp.qrcode, resp.qrcode_img_content))
}
/// 轮询扫码状态
/// ## 轮询扫码状态(带 35 秒超时的长轮询)
///
/// 调用 iLink API 检查用户是否已扫码。
/// * `wait` — 继续等待
/// * `scaned` — 已扫码,等待确认
/// * `scaned_but_redirect` — IDC 重定向(切换到 redirect_host
/// * `confirmed` — 登录成功
/// * `expired` — 二维码过期
///
/// 网络错误不会导致退出,而是返回 `wait` 让调用者继续轮询。
pub async fn poll_qr_status(
&self,
qrcode: &str,
@@ -147,7 +198,7 @@ impl WeChatClient {
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
let mut scanned = false;
let mut current_base = Self::FIXED_BASE_URL.to_string();
let mut current_base = Self::fixed_base_url();
while tokio::time::Instant::now() < deadline {
let status = self.poll_qr_status(&qrcode, &current_base).await?;
@@ -358,6 +409,12 @@ impl WeChatClient {
// ─── 内部方法 ───
/// ## 公共请求头
///
/// 每个请求都携带以下头信息:
/// * `Content-Type: application/json`
/// * `iLink-App-ClientVersion` — 客户端版本号编码
/// * `X-WECHAT-UIN` — 随机 uint32 → base64 编码的会话标识
fn common_headers() -> reqwest::header::HeaderMap {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
+14
View File
@@ -1,2 +1,16 @@
//! ## 微信 iLink Bot 通道
//!
//! 封装与微信 iLink Bot API 的所有通信:
//!
//! - `client` — HTTP 客户端(登录、长轮询收消息、发消息、生命周期管理)
//! - `types` — API 协议类型定义(消息结构、请求/响应体)
//!
//! ### 核心流程
//! 1. `login()` — 扫码登录,获取 token + account_id
//! 2. `notify_start()` — 注册监听器
//! 3. `receive_messages()` — 长轮询接收消息
//! 4. `send_text()` — 发送文本回复
//! 5. `notify_stop()` — 注销监听器
pub mod client;
pub mod types;
+32 -39
View File
@@ -1,3 +1,15 @@
//! ## 微信 iLink Bot API 协议类型
//!
//! 定义了与微信 iLink Bot API 通信的全部数据结构:
//!
//! - `BaseInfo` — 公共请求元数据
//! - `WeixinMessage` — 微信消息结构(支持 text/image/voice/file/video
//! - `MessageItem` — 消息条目(5 种类型)
//! - `GetUpdatesReq/Resp` — 长轮询收消息的请求/响应
//! - `SendMessageReq` — 发送消息请求
//! - `QRCodeResponse` / `StatusResponse` — 扫码登录相关
//! - `LoginResult` / `MessageEvent` — 业务层结果类型
use serde::{Deserialize, Serialize};
// ─── 基础类型 ───
@@ -116,8 +128,14 @@ pub struct RefMessage {
pub title: Option<String>,
}
/// 消息条目(支持 text/image/voice/file/video
/// ## 消息条目 —— 消息的具体内容
///
/// 支持 5 种消息类型:text(1), image(2), voice(3), file(4), video(5)。
/// 每种类型有对应的 `*_item` 字段,其他字段保持 None。
///
/// `text()` 便捷方法用于快速构造文本消息(发送消息时使用)。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Default)]
pub struct MessageItem {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub item_type: Option<i32>,
@@ -165,27 +183,22 @@ impl MessageItem {
}
}
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,
}
}
}
// ─── 微信消息 ───
/// ## 微信消息结构
///
/// 对应 iLink API 返回的单个消息。所有字段都是 Option 的,
/// 因为不同消息类型携带不同字段。
///
/// ### 关键字段
/// * `msg_type` — 消息类型:1=用户消息(TYPE_USER), 2=机器人消息(TYPE_BOT)
/// * `from_user_id` — 发送者微信 ID
/// * `to_user_id` — 接收者微信 ID
/// * `item_list` — 消息内容列表(支持 text/image/voice/file/video
/// * `context_token` — 微信上下文令牌(用于多轮对话的上下文恢复)
/// * `group_id` — 群聊 ID(群消息时非空)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Default)]
pub struct WeixinMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub seq: Option<i64>,
@@ -217,26 +230,6 @@ pub struct WeixinMessage {
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 {
#[allow(dead_code)]
+15 -6
View File
@@ -1,6 +1,15 @@
//! Worker — 无状态执行进程
//! ## Worker — 无状态执行进程(已废弃)
//!
//! 每条消息 spawn 一个 worker:
//! ### 旧架构说明
//! 在旧架构中,每条消息会 spawn 一个独立的 Worker 进程,
//! Worker 通过 UDS 连接到 daemon,处理完一条消息后退出。
//!
//! 优势:Worker 代码热更新(编译后下一条消息自动用新版本)
//! 劣势:进程启动开销、IPC 序列化开销、状态管理复杂
//!
//! ### 新架构
//! 现在改为 daemon 内的 LLM/Tool/Send 三个消费者 tokio task。
//! 此模块保留供编译参考,不再被 daemon 使用。
//! 1. 连接 daemon 的 Unix Domain Socket
//! 2. 读取 TaskFrame (用户消息 + 上下文 + 环境变量)
//! 3. 注入环境变量,构建 Conversation,执行 LLM 对话 + 工具调用
@@ -103,7 +112,8 @@ pub async fn run(sock_path: &str) -> Result<(), String> {
conv.set_tool_executor(executor);
// 7. 执行 LLM 对话(带审批检测)
let (reply, _used_tools, usage) = conv
use crate::llm::ChatResult;
let ChatResult { reply, used_tools: _used_tools, usage, .. } = conv
.chat_with_tools(task.msg.text.clone())
.await
.map_err(|e| format!("LLM 对话失败: {}", e))?;
@@ -266,8 +276,8 @@ fn build_worker_executor(
if target_name == "manage_memos" {
let cp: serde_json::Value =
serde_json::from_str(&target_args).unwrap_or_default();
if cp.get("action").and_then(|v| v.as_str()) == Some("add") {
if let Some(content) = cp.get("content").and_then(|v| v.as_str()) {
if cp.get("action").and_then(|v| v.as_str()) == Some("add")
&& let Some(content) = cp.get("content").and_then(|v| v.as_str()) {
shared
.memories
.lock()
@@ -277,7 +287,6 @@ fn build_worker_executor(
.push(content.to_string());
shared.new_memories.lock().await.push(content.to_string());
}
}
}
return Ok(result.output);
}