Compare commits

...

6 Commits

Author SHA1 Message Date
wunianxiao ec42c3dcdb chore: 修复 clippy error(loop never loops → if-let)并应用自动修复 2026-06-10 10:27:27 +08:00
wunianxiao a49d8ac423 chore: 将 .codegraph/ 和 .reasonix/ 加入 .gitignore 2026-06-10 10:22:32 +08:00
wunianxiao 739f5e9c58 docs: 添加 ARCHITECTURE.md 代码调用图谱
包含 8 个 Mermaid 图表,覆盖:
1. 全局架构总览 — 所有模块及调用关系
2. 消息处理全链路 — Sequence Diagram 完整流程
3. 模块依赖关系 — 分层依赖图
4. 数据流(消息生命周期)
5. 关键函数调用关系(工具执行/审批/上下文构建)
6. 文件依赖矩阵
7. 启动流程
8. 消费者架构(三消费者)
2026-06-10 10:22:09 +08:00
wunianxiao af0620aa2d 重构:架构重组 + 6轮缺陷修复
Phase 1: 工具模块统一
- 新建 tools/executor.rs — 统一工具执行器,消除 main/worker/daemon 三处重复
- tools/builtin.rs → tools/registry.rs
- tools/mod.rs 新增 build_tools_list() / build_env_map(),daemon/main 共用

Phase 2: 主模块重组 context/ → core/
- core/pipeline.rs — 消息处理流水线 (从 main.rs 抽取 listen_loop)
- core/session.rs — ChatSession 会话管理
- core/context.rs — 上下文构建 + token 估算
- core/summary.rs — 摘要管理 (溢出/空闲触发)
- core/memory.rs — MemoryStore 长期记忆
- main.rs 精简至 ~200 行

Phase 3: listen 模式全局会话锁 + Pipeline 收尾
- PipelineContext 持有全局 conversation_lock,所有消息串行处理防串话

Phase 4: wechat/ → channel/ 重命名

缺陷修复:
- daemon send_frame 失败后消息重回 waiting 队列
- daemon 审批通过后按 code_hash 精确执行,不重跑 LLM
- listen 模式执行器从 session.current_user_id 动态获取用户
- listen 模式 LLM 用量统计传入实际 user_id
- send_text 响应校验改用 serde_json::Value 宽松解析
- pi_subagent 工具 (JSON 事件流模式, RiskLevel::High, timeout clamp 1-300)
- listen 审批超时 300s→60s,cancel_by_code 精确保护
- approval.rs 新增 cancel_by_code 方法同步 DB
2026-06-04 16:18:06 +08:00
wunianxiao 8dc232ebab refactor(amap): 简化输出格式,返回原始API数据
- 移除自定义文本格式化函数(haversine_distance, format_distance, parse_location 等)
- 移除 build_route_output 函数
- execute 函数改为返回简短确认消息 + 原始 API JSON 数据
- CLI 命令同时输出 r.output 和 r.data(JSON)
- 移除 driving/riding API 的 show_fields=cost 参数
- 清理已删除函数的冗余单元测试
2026-06-04 13:27:04 +08:00
wunianxiao fec50b7be8 refactor(amap): 升级全部路线API到v5,完善输出文本及JSON数据
主要变更:
- 路线规划全部升级到v5: walking/driving/riding/transit → v5端点
- 新增 electrobike (电动车骑行) 路线规划
- POI搜索参数 page/offset → v5标准的 page_num/page_size
- 周边搜索添加 sortrule=distance 按距离排序
- 驾车strategy默认值 10→32 (高德推荐)
- 公交 city→city1/city2 (citycode)
- 骑行响应解析 errcode→status, data→route (v4→v5)
- 所有路线添加 show_fields=cost 获取耗时数据

输出文本:
- POI搜索: 详细列出名称/地址/类型/坐标/距离(km)/电话/评分/人均
- Haversine 距离计算: 有参考坐标时自行计算并格式化(≥1km显示km)
- 地理编码: 格式化地址+省市+坐标
- 路径规划: 距离/时间/费用/红绿灯/限行状态

JSON数据:
- 所有接口通过 ok_with_data 返回结构化JSON
- 路径规划提取关键字段(距离km/时间min/费用等)

新增辅助函数:
- val_f64(): 兼容v5字符串和数字两种数值格式
- haversine_distance(): 球面距离计算
- format_distance(): 距离格式化(km/m)
- path_distance/duration/tolls/traffic_lights(): v5路径字段提取
2026-06-03 23:05:48 +08:00
34 changed files with 2164 additions and 1388 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
```
+27 -22
View File
@@ -64,15 +64,15 @@ ias service
│ main.rs → clap 子命令路由 │
├─────────────────────────────────────────────────┤
│ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ WeChat │ │ LLM │ │ Tools │ │
│ │ 长轮询 │ │ DeepSeek │ │ 内置工具 │ │
│ │ HTTP API │ │ 流式对话 │ │ 审批管理 │ │
│ │ Channel │ │ LLM │ │ Tools │ │
│ │ 微信通道 │ │ DeepSeek │ │ 统一执行器 │ │
│ │ 长轮询 │ │ 流式对话 │ │ 审批管理 │ │
│ └────┬─────┘ └────┬─────┘ └──────┬────────┘ │
│ │ │ │ │
│ ┌────┴──────────────┴───────────────┴────────┐ │
│ │ Context / Memory │ │
│ │ ChatSession · Token Budget · 摘要压缩 │ │
│ │ MemoryStore · 长期记忆 │ │
│ │ Core / 主模块 │ │
│ │ Pipeline · Session · Context · Summary │ │
│ │ Memory · 长期记忆 │ │
│ └──────────────────────┬──────────────────────┘ │
│ │ │
│ ┌──────────────────────┴──────────────────────┐ │
@@ -109,26 +109,31 @@ ias service
```
src/
├── main.rs CLI 入口 + 命令实现 + 监听循环 + 工具执行器
├── main.rs CLI 入口 + 命令实现 (~200行)
├── cli.rs clap 子命令定义(login/listen/send/tool 等)
├── logger.rs 日志初始化(终端 + 文件滚动)
├── state.rs 本地文件状态(auth.json / runtime.json,无DB时降级)
├── scheduler.rs 定时任务调度器
├── context/ 上下文管理
│ ├── types.rs ChatSessioncheckpoint/messages/summaries
│ ├── builder.rs Token budget 估算 + 上下文构建 + 摘要触发
── tools.rs MemoryStoreHashMap<user_id, Vec<mem>>
├── db/ 数据库
│ ├── mod.rs Database 连接池 + 迁移
│ └── models.rs CRUDauth/chat/usage/summary
├── llm/ 对话系统
│ ├── types.rs Message / Role / ToolCall / StreamChunk / Usage
├── core/ 主模块 — 消息处理流水线
│ ├── mod.rs 模块声明
│ ├── pipeline.rs 消息处理流水线(接收→上下文→LLM→持久化)
── session.rs 会话管理(ChatSession, checkpoint, token budget
│ ├── context.rs 上下文构建(build_context, token 估算)
│ ├── summary.rs 摘要管理(溢出触发, 空闲触发, LLM摘要生成)
│ └── memory.rs 长期记忆(MemoryStore, read_summaries
├── channel/ 微信通道
│ ├── mod.rs
│ ├── types.rs iLink API 协议类型
│ └── client.rs HTTP 客户端(login/poll/send/notify
├── llm/ LLM 对话系统
│ ├── types.rs Message / Role / ToolCall / StreamChunk
│ ├── provider.rs LlmProvider trait + SSE 解析
│ ├── deepseek.rs DeepSeek 流式客户端
│ └── conversation.rs Conversationchat + chat_with_tools 工具循环)
├── tools/ 工具系统
│ ├── types.rs RiskLevel / SkillSpec / SkillResult / ExecutionContext
│ ├── builtin.rs BuiltinRegistry 路由
├── tools/ 工具模块
│ ├── types.rs SkillSpec / SkillResult / RiskLevel
│ ├── registry.rs BuiltinRegistry 路由
│ ├── executor.rs 统一工具执行器(Listen/Worker 双模式)
│ ├── approval.rs ApprovalManager(确认码 + oneshot 通道)
│ └── builtins/ 内置工具实现
│ ├── datetime.rs 日期时间
@@ -137,9 +142,9 @@ src/
│ ├── fetch_page.rs 网页抓取
│ ├── memos.rs 备忘录
│ └── amap.rs 高德地图(POI/地理编码/路径/旅游规划)
├── wechat/ 微信通道
│ ├── types.rs iLink API 协议类型
│ └── client.rs HTTP 客户端(login/poll/send/notify
├── db/ 数据库
│ ├── mod.rs Database 连接池 + 迁移
│ └── models.rs CRUDauth/chat/usage/summary
├── daemon.rs 守护进程(持有 WeChat + DBspawn worker
├── worker.rs Worker 进程(无状态 LLM 执行)
└── ipc.rs Unix Domain Socket 帧协议
+14 -4
View File
@@ -1,4 +1,4 @@
use crate::wechat::types::*;
use crate::channel::types::*;
use base64::Engine;
use reqwest::Client as HttpClient;
use std::sync::Arc;
@@ -327,12 +327,22 @@ impl WeChatClient {
let url = format!("{}/ilink/bot/sendmessage", self.base_url);
let token = self.token.lock().await.clone();
let _resp_text = self
let resp: serde_json::Value = self
.post_json(&url, &body, Some(&token))
.await?
.text()
.json()
.await
.map_err(|e| format!("发送消息网络错误: {}", e))?;
.map_err(|e| format!("解析发送响应失败: {}", e))?;
// 校验业务返回码(存在 ret 字段且非 0 才算失败)
if let Some(ret) = resp.get("ret").and_then(|v| v.as_i64())
&& ret != 0 {
let errmsg = resp.get("errmsg").and_then(|v| v.as_str());
return Err(format!(
"发送消息业务失败 ret={} errmsg={:?}",
ret, errmsg
));
}
Ok(client_id)
}
+2 -37
View File
@@ -118,6 +118,7 @@ pub struct RefMessage {
/// 消息条目(支持 text/image/voice/file/video
#[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 +166,11 @@ 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,
}
}
}
// ─── 微信消息 ───
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Default)]
pub struct WeixinMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub seq: Option<i64>,
@@ -217,26 +202,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)]
-5
View File
@@ -1,5 +0,0 @@
pub mod builder;
pub mod tools;
pub mod types;
pub use tools::MemoryStore;
+112
View File
@@ -0,0 +1,112 @@
use crate::core::session::ChatSession;
use crate::llm::types::Message;
use std::sync::Arc;
use tokio::sync::Mutex;
/// 估算消息的 token 数
pub fn estimate_tokens(msg: &Message) -> u32 {
let text = msg.content.as_str();
estimate_text_tokens(text) + 8
}
/// 估算纯文本的 token 数
pub fn estimate_text_tokens(text: &str) -> u32 {
if text.is_empty() {
return 0;
}
let cjk_count = text.chars().filter(|c| is_cjk(*c)).count() as u32;
let other_count = text.chars().count() as u32 - cjk_count;
// 中文 ≈ 1.5 token/字,英文 ≈ 0.25 token/字符
cjk_count * 15 / 10 + other_count / 4
}
fn is_cjk(c: char) -> bool {
('\u{4e00}'..='\u{9fff}').contains(&c)
|| ('\u{3400}'..='\u{4dbf}').contains(&c)
|| ('\u{f900}'..='\u{faff}').contains(&c)
}
/// 构建给 LLM 的消息数组(带 token budget 管理)
///
/// 返回消息数组和是否需要摘要的信息
pub async fn build_context(
session: &Arc<Mutex<ChatSession>>,
system_prompt: &str,
) -> Vec<Message> {
let s = session.lock().await;
// ── 1. 空闲超时检查(消息到达前由调用方检查)──
// 这里只做构建,超时触发在上层
// ── 2. 构建消息 ──
let mut messages = Vec::new();
let mut used = 0u32;
// 系统提示词
let sys_tokens = estimate_text_tokens(system_prompt);
messages.push(Message::system(system_prompt));
used += sys_tokens + 8;
// overflow 摘要(如果有)
if let Some(summary) = s.latest_overflow_summary() {
let text = format!("【历史对话摘要】\n{}", summary.text);
let tokens = estimate_text_tokens(&text);
if used + tokens < s.token_budget {
messages.push(Message::system(text));
used += tokens + 8;
}
}
// 近期消息(从 checkpoint 开始,倒序添加直到接近 budget)
let recent = s.recent_messages().to_vec();
let mut included = Vec::new();
let mut tail_used = 0u32;
for msg in recent.iter().rev() {
let t = estimate_tokens(msg);
if used + tail_used + t > s.token_budget - 500 {
break;
}
tail_used += t;
included.push(msg.clone());
}
included.reverse();
// 保护:确保最近一条用户消息始终在上下文中
if let Some(last_user) = recent
.iter()
.rev()
.find(|m| m.role == crate::llm::types::Role::User)
{
let already_included = included
.iter()
.any(|m| m.role == last_user.role && m.content == last_user.content);
if !already_included {
let t = estimate_tokens(last_user);
if used + tail_used + t <= s.token_budget + 2000 {
included.push(last_user.clone());
}
}
}
messages.extend(included);
messages
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_estimate_cjk() {
let t = estimate_text_tokens("你好世界");
assert!(t >= 5 && t <= 10, "CJK estimate: {}", t);
}
#[test]
fn test_estimate_ascii() {
let t = estimate_text_tokens("hello world");
assert!(t >= 1 && t <= 6, "ASCII estimate: {}", t);
}
}
+3 -3
View File
@@ -63,15 +63,15 @@ impl MemoryStore {
/// read_summaries 工具:读取历史摘要(内存 + 数据库)
pub async fn read_summaries(
session: &Arc<Mutex<super::types::ChatSession>>,
session: &Arc<Mutex<super::session::ChatSession>>,
) -> String {
let s = session.lock().await;
let mut lines: Vec<String> = Vec::new();
for sum in &s.summaries {
let reason = match sum.reason {
super::types::SummaryReason::Overflow => "上下文压缩",
super::types::SummaryReason::Timeout => "空闲超时",
super::session::SummaryReason::Overflow => "上下文压缩",
super::session::SummaryReason::Timeout => "空闲超时",
};
lines.push(format!(
"[{}] {} (原因: {})",
+7
View File
@@ -0,0 +1,7 @@
pub mod context;
pub mod memory;
pub mod pipeline;
pub mod session;
pub mod summary;
pub use memory::MemoryStore;
+280
View File
@@ -0,0 +1,280 @@
//! 消息处理流水线
//!
//! 从 main.rs 抽取消息处理核心逻辑:
//! 1. 接收微信消息
//! 2. 用户切换 → 上下文加载
//! 3. LLM 对话 + 工具调用
//! 4. 结果持久化(chat_records, llm_usage
//!
//! 供 listen 模式和 daemon 模式复用。
use crate::core::memory::MemoryStore;
use crate::db::Database;
use crate::llm::{Conversation, Usage};
use crate::tools::approval::ApprovalManager;
use crate::channel::client::WeChatClient;
use crate::state;
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{error, info};
/// 消息处理上下文
pub struct PipelineContext {
pub client: WeChatClient,
pub database: Option<Arc<Database>>,
pub file_state: state::StateManager,
pub memory_store: Arc<MemoryStore>,
pub approval_manager: Arc<ApprovalManager>,
pub account_id: String,
/// 全局会话锁:所有消息共享同一 Conversation,必须串行处理防止跨用户串话
pub conversation_lock: Arc<Mutex<()>>,
}
impl PipelineContext {
/// 监听循环:长轮询微信消息,处理每条消息
pub async fn run_listen_loop(
&self,
enable_llm: bool,
echo: bool,
conversation: Option<Arc<Conversation>>,
) {
let last_user_id = Arc::new(tokio::sync::Mutex::new(String::new()));
loop {
match self.client.receive_messages().await {
Ok(resp) => {
// 持久化 runtime buf
if !resp.get_updates_buf.is_empty() {
self.file_state.save_runtime(&resp.get_updates_buf);
}
for msg in &resp.msgs {
if msg.msg_type != Some(crate::channel::types::WeixinMessage::TYPE_USER) {
continue;
}
let from = msg.from_user_id.as_deref().unwrap_or("unknown");
let text = msg.text_content().unwrap_or("(非文本消息)");
let ctx_token = msg.context_token.as_deref();
let msg_id = msg.message_id.map(|id| id.to_string()).unwrap_or_default();
info!("收到消息 from={}: {}", from, text);
// 入库:收到的消息
if let Some(db) = &self.database
&& let Err(e) = crate::db::models::insert_chat_record(
db.pool(),
"inbound",
from,
&self.account_id,
text,
"wechat",
ctx_token,
&msg_id,
)
.await
{
error!("保存聊天记录失败: {}", e);
}
// Echo 模式
if echo {
match self.client.send_text(from, text, ctx_token).await {
Ok(sent_id) => {
info!("回显成功 msg_id={}", sent_id);
if let Some(db) = &self.database
&& let Err(e) = crate::db::models::insert_chat_record(
db.pool(),
"outbound",
from,
&self.account_id,
text,
"echo",
ctx_token,
&sent_id,
)
.await
{
error!("保存聊天记录失败: {}", e);
}
}
Err(e) => error!("回显失败: {}", e),
}
}
// 审批回复检查
let is_approval_reply = self
.approval_manager
.handle_reply(from, text)
.await
.is_some();
if is_approval_reply {
info!("消息匹配审批确认码,不传给 LLM");
continue;
}
// 用户切换 + LLM 回复(全局会话锁保证串行)
if enable_llm
&& let Some(conv) = &conversation {
let conv = conv.clone();
let client = self.client.clone();
let database = self.database.clone();
let from_owned = from.to_string();
let text_owned = text.to_string();
let ctx_owned = ctx_token.map(String::from);
let aid = self.account_id.clone();
let memory_store = self.memory_store.clone();
let conv_lock = Arc::clone(&self.conversation_lock);
let last_user = Arc::clone(&last_user_id);
tokio::spawn(async move {
let _guard = conv_lock.lock().await;
// ── 用户切换 ──
{
let mut last = last_user.lock().await;
if *last != from_owned {
info!("用户切换: {} → {}", *last, from_owned);
let session = conv.session();
let mut s = session.lock().await;
s.messages.clear();
s.checkpoint = 0;
s.summaries.clear();
s.last_user_at = None;
s.current_user_id = from_owned.clone();
s.load_recent_messages(20).await;
drop(s);
memory_store.load(&from_owned).await;
}
*last = from_owned.clone();
}
// ── LLM 回复 ──
let reply_result = if conv.spec().tools.is_some() {
generate_reply_with_tools(
&conv,
text_owned,
&from_owned,
&database,
)
.await
} else {
generate_reply(
&conv,
text_owned,
&from_owned,
&database,
)
.await
};
let reply = match reply_result {
Ok(r) if !r.trim().is_empty() => r,
Ok(_) => "抱歉,生成回复为空,请重试。".to_string(),
Err(e) => format!("处理消息时出错:{:.200}", e),
};
match client
.send_text(&from_owned, &reply, ctx_owned.as_deref())
.await
{
Ok(sent_id) => {
info!("LLM 回复成功 msg_id={}", sent_id);
if let Some(db) = database
&& let Err(e) = crate::db::models::insert_chat_record(
db.pool(),
"outbound",
&from_owned,
&aid,
&reply,
"llm",
ctx_owned.as_deref(),
&sent_id,
)
.await
{
error!("保存聊天记录失败: {}", e);
}
}
Err(e) => error!("发送 LLM 回复失败: {}", e),
}
});
}
}
}
Err(e) => {
if !e.contains("超时") && !e.contains("timeout") {
error!("接收消息失败: {}", e);
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
}
}
}
}
}
async fn generate_reply(
conv: &Conversation,
user_text: String,
user_id: &str,
db: &Option<Arc<Database>>,
) -> Result<String, String> {
let mut handle = conv.chat(user_text).await?;
let reply = handle.consume().await?;
if let Some(u) = handle.get_usage() {
info!(
"📊 Token: {} 总 / {} 提示 / {} 生成 | 缓存命中 {} | 缓存未命中 {}",
u.total_tokens,
u.prompt_tokens,
u.completion_tokens,
u.prompt_cache_hit_tokens,
u.prompt_cache_miss_tokens
);
store_usage(db, user_id, conv.model(), conv.provider_name(), u).await;
}
Ok(reply)
}
async fn generate_reply_with_tools(
conv: &Conversation,
user_text: String,
user_id: &str,
db: &Option<Arc<Database>>,
) -> Result<String, String> {
let (reply, _used_tools, usage) = conv.chat_with_tools(user_text).await?;
if let Some(u) = &usage {
info!(
"📊 Token: {} 总 / {} 提示 / {} 生成 | 缓存命中 {} | 缓存未命中 {}",
u.total_tokens,
u.prompt_tokens,
u.completion_tokens,
u.prompt_cache_hit_tokens,
u.prompt_cache_miss_tokens
);
store_usage(db, user_id, conv.model(), conv.provider_name(), u).await;
}
Ok(reply)
}
async fn store_usage(
db: &Option<Arc<Database>>,
user_id: &str,
model: &str,
provider: &str,
u: &Usage,
) {
if let Some(db) = db
&& let Err(e) = crate::db::models::insert_llm_usage(
db.pool(),
user_id,
model,
provider,
u.prompt_tokens,
u.completion_tokens,
u.prompt_cache_hit_tokens,
u.prompt_cache_miss_tokens,
)
.await
{
tracing::error!("存储 LLM 用量失败: {}", e);
}
}
+11 -115
View File
@@ -1,97 +1,9 @@
use crate::context::types::ChatSession;
use crate::core::session::{ChatSession, SummaryEntry, SummaryReason};
use crate::llm::conversation::Summarizer;
use crate::llm::types::Message;
use std::sync::Arc;
use tokio::sync::Mutex;
/// 估算消息的 token 数
pub fn estimate_tokens(msg: &Message) -> u32 {
let text = msg.content.as_str();
estimate_text_tokens(text) + 8
}
/// 估算纯文本的 token 数
pub fn estimate_text_tokens(text: &str) -> u32 {
if text.is_empty() {
return 0;
}
let cjk_count = text.chars().filter(|c| is_cjk(*c)).count() as u32;
let other_count = text.chars().count() as u32 - cjk_count;
// 中文 ≈ 1.5 token/字,英文 ≈ 0.25 token/字符
cjk_count * 15 / 10 + other_count / 4
}
fn is_cjk(c: char) -> bool {
('\u{4e00}'..='\u{9fff}').contains(&c)
|| ('\u{3400}'..='\u{4dbf}').contains(&c)
|| ('\u{f900}'..='\u{faff}').contains(&c)
}
/// 构建给 LLM 的消息数组(带 token budget 管理)
///
/// 返回消息数组和是否需要摘要的信息
pub async fn build_context(session: &Arc<Mutex<ChatSession>>, system_prompt: &str) -> Vec<Message> {
let s = session.lock().await;
// ── 1. 空闲超时检查(消息到达前由调用方检查)──
// 这里只做构建,超时触发在上层
// ── 2. 构建消息 ──
let mut messages = Vec::new();
let mut used = 0u32;
// 系统提示词
let sys_tokens = estimate_text_tokens(system_prompt);
messages.push(Message::system(system_prompt));
used += sys_tokens + 8;
// overflow 摘要(如果有)
if let Some(summary) = s.latest_overflow_summary() {
let text = format!("【历史对话摘要】\n{}", summary.text);
let tokens = estimate_text_tokens(&text);
if used + tokens < s.token_budget {
messages.push(Message::system(text));
used += tokens + 8;
}
}
// 近期消息(从 checkpoint 开始,倒序添加直到接近 budget)
let recent = s.recent_messages().to_vec();
let mut included = Vec::new();
let mut tail_used = 0u32;
for msg in recent.iter().rev() {
let t = estimate_tokens(msg);
if used + tail_used + t > s.token_budget - 500 {
break;
}
tail_used += t;
included.push(msg.clone());
}
included.reverse();
// 保护:确保最近一条用户消息始终在上下文中
if let Some(last_user) = recent
.iter()
.rev()
.find(|m| m.role == crate::llm::types::Role::User)
{
let already_included = included
.iter()
.any(|m| m.role == last_user.role && m.content == last_user.content);
if !already_included {
let t = estimate_tokens(last_user);
if used + tail_used + t <= s.token_budget + 2000 {
included.push(last_user.clone());
}
}
}
messages.extend(included);
messages
}
/// 触发溢出摘要:压缩 checkpoint 到当前位置之间的新消息
pub async fn trigger_overflow_summary(
session: &Arc<Mutex<ChatSession>>,
@@ -123,10 +35,10 @@ pub async fn trigger_overflow_summary(
let summary_text = generate_summary(&to_summarize, summarizer).await;
s.summaries.push(super::types::SummaryEntry {
s.summaries.push(SummaryEntry {
checkpoint: prev_checkpoint,
text: summary_text.clone(),
reason: super::types::SummaryReason::Overflow,
reason: SummaryReason::Overflow,
created_at: chrono::Utc::now(),
});
@@ -166,10 +78,10 @@ pub async fn trigger_idle_summary(
let summary_text = generate_summary(&s.messages, summarizer).await;
let cp = s.messages.len();
s.summaries.push(super::types::SummaryEntry {
s.summaries.push(SummaryEntry {
checkpoint: cp,
text: summary_text.clone(),
reason: super::types::SummaryReason::Timeout,
reason: SummaryReason::Timeout,
created_at: chrono::Utc::now(),
});
@@ -194,8 +106,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 +117,6 @@ async fn generate_summary(messages: &[Message], summarizer: Option<&Summarizer>)
_ => tracing::warn!("LLM 摘要失败,回退到简单截断"),
}
}
}
summarize_messages(messages)
}
@@ -214,7 +125,8 @@ fn build_summary_prompt(messages: &[Message]) -> String {
let convo: String = messages
.iter()
.filter(|m| {
m.role == crate::llm::types::Role::User || m.role == crate::llm::types::Role::Assistant
m.role == crate::llm::types::Role::User
|| m.role == crate::llm::types::Role::Assistant
})
.map(|m| {
let role = if m.role == crate::llm::types::Role::User {
@@ -240,7 +152,8 @@ fn summarize_messages(messages: &[Message]) -> String {
let lines: Vec<String> = messages
.iter()
.filter(|m| {
m.role == crate::llm::types::Role::User || m.role == crate::llm::types::Role::Assistant
m.role == crate::llm::types::Role::User
|| m.role == crate::llm::types::Role::Assistant
})
.map(|m| {
let role = match m.role {
@@ -271,20 +184,3 @@ fn summarize_messages(messages: &[Message]) -> String {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_estimate_cjk() {
let t = estimate_text_tokens("你好世界");
assert!(t >= 5 && t <= 10, "CJK estimate: {}", t);
}
#[test]
fn test_estimate_ascii() {
let t = estimate_text_tokens("hello world");
assert!(t >= 1 && t <= 6, "ASCII estimate: {}", t);
}
}
+51 -191
View File
@@ -4,12 +4,12 @@
//! 通过 Unix Domain Socket 通信。Worker 代码更新后编译即可,下一条消息
//! 自动使用新版本。
use crate::context::MemoryStore;
use crate::core::MemoryStore;
use crate::db::Database;
use crate::ipc::{HistoryEntry, OutputFrame, TaskFrame, TaskMessage, recv_output, send_frame};
use crate::state::StateManager;
use crate::tools::approval::{ApprovalDecision, ApprovalManager};
use crate::wechat::client::WeChatClient;
use crate::channel::client::WeChatClient;
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::time::Instant;
@@ -28,6 +28,7 @@ struct MessageQueue {
waiting: VecDeque<String>,
}
#[derive(Clone)]
struct PendingMessage {
from: String,
text: String,
@@ -36,6 +37,7 @@ struct PendingMessage {
#[allow(dead_code)]
message_id: String,
approved_tool: Option<String>,
approved_tool_args: Option<String>,
}
impl MessageQueue {
@@ -65,7 +67,7 @@ impl MessageQueue {
continue;
}
// 检查是否有待处理消息
if self.pending.get(&uid).map_or(true, |q| q.is_empty()) {
if self.pending.get(&uid).is_none_or(|q| q.is_empty()) {
continue;
}
self.active.insert(uid.clone(), true);
@@ -78,6 +80,20 @@ impl MessageQueue {
self.pending.get_mut(user_id)?.pop_front()
}
/// 将消息放回队首(用于 send_frame 失败恢复),并确保用户回到 waiting
fn push_front(&mut self, user_id: &str, msg: PendingMessage) {
self.pending
.entry(user_id.to_string())
.or_default()
.push_front(msg);
// 如果用户未活跃且不在 waiting 中,重新加入 waiting
if !self.active.get(user_id).unwrap_or(&false)
&& !self.waiting.contains(&user_id.to_string())
{
self.waiting.push_back(user_id.to_string());
}
}
fn deactivate(&mut self, user_id: &str) {
self.active.insert(user_id.to_string(), false);
}
@@ -85,7 +101,7 @@ impl MessageQueue {
fn has_pending(&self, user_id: &str) -> bool {
self.pending
.get(user_id)
.map_or(false, |q| !q.is_empty())
.is_some_and(|q| !q.is_empty())
}
}
@@ -144,11 +160,11 @@ pub async fn run(
// 6. 消息队列
let queue = Arc::new(Mutex::new(MessageQueue::new()));
// 7. 构建工具列表(传给 worker
let tools_list = build_tools_list();
// 7. 构建工具列表(统一使用 tools 模块
let tools_list = crate::tools::build_tools_list();
// 8. 环境变量映射
let env_map = build_env_map();
// 8. 环境变量映射(统一使用 tools 模块)
let env_map = crate::tools::build_env_map();
// 9. 系统提示词
let system_prompt =
@@ -181,7 +197,7 @@ pub async fn run(
// 清理过期的审批上下文(超过 5 分钟)
let now = Instant::now();
shared_for_clean.approval_ctx.lock().await
.retain(|_, (_, _, _, created)| now.duration_since(*created).as_secs() < 300);
.retain(|_, (_, _, _, _, created)| now.duration_since(*created).as_secs() < 300);
}
});
@@ -222,7 +238,7 @@ pub async fn run(
}
for msg in &resp.msgs {
if msg.msg_type != Some(crate::wechat::types::WeixinMessage::TYPE_USER) {
if msg.msg_type != Some(crate::channel::types::WeixinMessage::TYPE_USER) {
continue;
}
let from = msg.from_user_id.as_deref().unwrap_or("unknown");
@@ -233,8 +249,8 @@ pub async fn run(
info!("收到消息 from={}: {}", from, text);
// 入库:收到的消息
if let Some(db) = &shared.db {
if let Err(e) = crate::db::models::insert_chat_record(
if let Some(db) = &shared.db
&& let Err(e) = crate::db::models::insert_chat_record(
db.pool(),
"inbound",
from,
@@ -248,7 +264,6 @@ pub async fn run(
{
error!("保存聊天记录失败: {}", e);
}
}
// 审批回复检查
if let Some((skill_name, decision)) = shared.approval.handle_reply(from, text).await {
@@ -256,7 +271,7 @@ pub async fn run(
ApprovalDecision::Approved => {
info!("审批通过: {}", skill_name);
let original = shared.approval_ctx.lock().await.remove(from);
if let Some((orig_text, orig_ctx, _tool, _ts)) = original {
if let Some((orig_text, orig_ctx, _tool, tool_args, _ts)) = original {
let mut q = queue.lock().await;
q.enqueue(from, PendingMessage {
from: from.to_string(),
@@ -264,7 +279,8 @@ pub async fn run(
account_id: shared.account_id.clone(),
context_token: orig_ctx,
message_id: String::new(),
approved_tool: Some(skill_name),
approved_tool: Some(skill_name.clone()),
approved_tool_args: if tool_args.is_empty() { None } else { Some(tool_args) },
});
drop(q);
spawn_worker_for_user(from, &queue, &shared).await;
@@ -292,6 +308,7 @@ pub async fn run(
context_token: ctx_token.map(String::from),
message_id: msg_id,
approved_tool: None,
approved_tool_args: None,
});
drop(q);
}
@@ -340,8 +357,8 @@ struct DaemonShared {
env_map: HashMap<String, String>,
system_prompt: String,
model: String,
/// 待审批消息: user_id → (原始消息文本, context_token, 审批工具名, 创建时间)
approval_ctx: Mutex<HashMap<String, (String, Option<String>, String, Instant)>>,
/// 待审批消息: user_id → (原始消息文本, context_token, 审批工具名, 工具参数, 创建时间)
approval_ctx: Mutex<HashMap<String, (String, Option<String>, String, String, Instant)>>,
}
/// 处理一个 Worker 连接
@@ -394,6 +411,8 @@ async fn handle_worker(
// 5. 构建 TaskFrame
let approved_tool_info = pending_msg.approved_tool.clone();
let approved_tool_args_info = pending_msg.approved_tool_args.clone();
let pending_clone = pending_msg.clone(); // 保留副本用于失败恢复
let task = TaskFrame {
user_id: user_id.clone(),
msg: TaskMessage {
@@ -406,6 +425,7 @@ async fn handle_worker(
memories,
summaries,
approved_tool: approved_tool_info.clone(),
approved_tool_args: approved_tool_args_info.clone(),
env: shared.env_map.clone(),
tools: shared.tools_list.clone(),
system_prompt: shared.system_prompt.clone(),
@@ -415,7 +435,12 @@ async fn handle_worker(
// 6. 发送 task 帧
if let Err(e) = send_frame(&mut stream, &task).await {
error!("发送 task 帧失败: {}", e);
queue.lock().await.deactivate(&user_id);
let mut q = queue.lock().await;
q.deactivate(&user_id); // 先取消 activepush_front 才能加入 waiting
q.push_front(&user_id, pending_clone);
drop(q);
// 尝试重新 spawn
spawn_worker_for_user(&user_id, &queue, &shared).await;
return;
}
@@ -433,7 +458,7 @@ async fn handle_worker(
OutputFrame::Reply { text } => {
reply_text = text;
}
OutputFrame::RequestApproval { tool, reason } => {
OutputFrame::RequestApproval { tool, reason, tool_args } => {
info!("Worker 请求审批: tool={} reason={}", tool, reason);
// 创建审批记录
match shared.approval.create(&user_id, &tool).await {
@@ -445,7 +470,7 @@ async fn handle_worker(
// 保存原始消息上下文以便审批后重新处理
shared.approval_ctx.lock().await.insert(
user_id.clone(),
(task.msg.text.clone(), ctx_token.clone(), tool.clone(), Instant::now()),
(task.msg.text.clone(), ctx_token.clone(), tool.clone(), tool_args.clone(), Instant::now()),
);
// 发送确认消息到微信
if let Err(e) = shared.client.send_text(&user_id, &msg, ctx_token.as_deref()).await {
@@ -474,8 +499,8 @@ async fn handle_worker(
cache_miss_tokens,
user_id: uid,
} => {
if let Some(db) = &shared.db {
if let Err(e) = crate::db::models::insert_llm_usage(
if let Some(db) = &shared.db
&& let Err(e) = crate::db::models::insert_llm_usage(
db.pool(),
&uid,
&model,
@@ -489,7 +514,6 @@ async fn handle_worker(
{
error!("存储 LLM 用量失败: {}", e);
}
}
}
OutputFrame::Bye => break,
},
@@ -510,8 +534,8 @@ async fn handle_worker(
Ok(sent_id) => {
info!("回复成功 user={} msg_id={}", user_id, sent_id);
// 入库:发送的回复
if let Some(db) = &shared.db {
if let Err(e) = crate::db::models::insert_chat_record(
if let Some(db) = &shared.db
&& let Err(e) = crate::db::models::insert_chat_record(
db.pool(),
"outbound",
&user_id,
@@ -525,7 +549,6 @@ async fn handle_worker(
{
error!("保存聊天记录失败: {}", e);
}
}
}
Err(e) => error!("发送回复失败: {}", e),
}
@@ -538,8 +561,8 @@ async fn handle_worker(
{
Ok(sent_id) => {
info!("回复成功 (chunk) user={} msg_id={}", user_id, sent_id);
if let Some(db) = &shared.db {
if let Err(e) = crate::db::models::insert_chat_record(
if let Some(db) = &shared.db
&& let Err(e) = crate::db::models::insert_chat_record(
db.pool(),
"outbound",
&user_id,
@@ -553,7 +576,6 @@ async fn handle_worker(
{
error!("保存聊天记录失败: {}", e);
}
}
}
Err(e) => error!("发送回复失败: {}", e),
}
@@ -763,165 +785,3 @@ async fn handle_db_write(
_ => warn!("未知 DbWrite table: {}", table),
}
}
fn build_tools_list() -> Vec<serde_json::Value> {
let mut list = Vec::new();
// query_capabilities
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "query_capabilities",
"description": "列出所有可用工具的详细说明、参数格式和调用示例。在需要了解有哪些工具可用时首先调用此工具。",
"parameters": { "type": "object", "properties": {} }
}
}));
// call_capability
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "call_capability",
"description": "调用一个已注册的工具。name 为工具名(来自 query_capabilities),工具所需的具体参数应直接放在 JSON 中。",
"parameters": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "工具名称" },
"prompt": { "type": "string", "description": "json格式参数" }
},
"required": ["name"]
}
}
}));
// 上下文工具
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "read_memories",
"description": "读取用户的长期记忆(偏好、个人信息、约定)",
"parameters": { "type": "object", "properties": {} }
}
}));
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "write_memory",
"description": "记录用户的重要信息或偏好",
"parameters": {
"type": "object",
"properties": {
"content": { "type": "string", "description": "记忆内容" }
},
"required": ["content"]
}
}
}));
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "read_summaries",
"description": "读取历史会话摘要",
"parameters": { "type": "object", "properties": {} }
}
}));
// 业务工具(与 BuiltinRegistry 保持一致)
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "get_current_datetime",
"description": "获取当前日期时间(北京时间 UTC+8)",
"parameters": { "type": "object", "properties": {"format": {"type": "string"}} }
}
}));
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "manage_memos",
"description": "管理备忘录:添加/列出/删除",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["add", "list", "delete"]},
"content": {"type": "string"},
"id": {"type": "integer"}
}
}
}
}));
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "query_weather",
"description": "查询指定城市的天气信息",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "城市名称"},
"days": {"type": "integer", "description": "查询天数(1-7"}
},
"required": ["location"]
}
}
}));
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "web_search",
"description": "联网搜索最新信息。通过 Tavily API 搜索互联网,获取实时、准确的结果。适用于需要最新资讯、事实查询的场景。",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "搜索关键词"},
"max_results": {"type": "integer", "description": "最大结果数(1-20,默认5"},
"include_answer": {"type": "boolean", "description": "是否包含 AI 生成的摘要回答"},
"search_depth": {"type": "string", "enum": ["basic", "advanced", "fast", "ultra-fast"], "description": "搜索深度"},
"topic": {"type": "string", "enum": ["general", "news", "finance"], "description": "搜索主题"},
"time_range": {"type": "string", "enum": ["day", "week", "month", "year"], "description": "按发布时间过滤"},
"start_date": {"type": "string", "description": "起始日期 YYYY-MM-DD"},
"end_date": {"type": "string", "description": "结束日期 YYYY-MM-DD"},
"include_domains": {"type": "array", "items": {"type": "string"}, "description": "限定域名列表"},
"exclude_domains": {"type": "array", "items": {"type": "string"}, "description": "排除域名列表"},
"country": {"type": "string", "description": "优先指定国家"}
},
"required": ["query"]
}
}
}));
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "fetch_page",
"description": "抓取网页并提取正文内容。传入 URL 即可阅读文章正文。可通过 ~/.ias/site_selectors.json 配置按站点用 CSS 选择器精确定位内容区域。",
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "网页 URL"}
},
"required": ["url"]
}
}
}));
list
}
fn build_env_map() -> HashMap<String, String> {
let mut map = HashMap::new();
for var in &[
"DEEPSEEK_API_KEY",
"DEEPSEEK_MODEL",
"DEEPSEEK_BASE_URL",
"QWEATHER_API_HOST",
"QWEATHER_JWT_KEY_ID",
"QWEATHER_JWT_PROJECT_ID",
"QWEATHER_JWT_PRIVATE_KEY_FILE",
"TAVILY_API_KEY",
] {
if let Ok(val) = std::env::var(var) {
map.insert(var.to_string(), val);
}
}
map
}
+6
View File
@@ -22,6 +22,9 @@ pub struct TaskFrame {
pub summaries: Vec<String>,
#[serde(default)]
pub approved_tool: Option<String>,
/// 审批通过后的工具调用参数
#[serde(default)]
pub approved_tool_args: Option<String>,
pub env: std::collections::HashMap<String, String>,
pub tools: Vec<serde_json::Value>,
pub system_prompt: String,
@@ -65,6 +68,9 @@ pub enum OutputFrame {
RequestApproval {
tool: String,
reason: String,
/// 工具调用参数(审批通过后直接执行)
#[serde(default)]
tool_args: String,
},
/// 数据库写入
#[serde(rename = "db_write")]
+20 -14
View File
@@ -1,5 +1,6 @@
use crate::context::builder;
use crate::context::types::ChatSession;
use crate::core::context as ctx_builder;
use crate::core::session::ChatSession;
use crate::core::summary as summary_builder;
use crate::llm::provider::{BoxedProvider, StreamReceiver, create_provider};
use crate::llm::types::{ConversationConfig, Message, StreamChunk, ToolCall, Usage};
use std::future::Future;
@@ -95,12 +96,13 @@ impl Conversation {
let s = self.session.lock().await;
if s.is_idle_timeout() {
drop(s);
builder::trigger_idle_summary(&self.session, Some(&self.summarizer())).await;
summary_builder::trigger_idle_summary(&self.session, Some(&self.summarizer()))
.await;
}
}
self.session.lock().await.add_user(user_message.clone());
let messages = builder::build_context(&self.session, &self.config.system_prompt).await;
let messages = ctx_builder::build_context(&self.session, &self.config.system_prompt).await;
let rx = self.provider.chat_stream(&self.config, &messages).await?;
Ok(ChatHandle {
@@ -122,7 +124,8 @@ impl Conversation {
let s = self.session.lock().await;
if s.is_idle_timeout() {
drop(s);
builder::trigger_idle_summary(&self.session, Some(&self.summarizer())).await;
summary_builder::trigger_idle_summary(&self.session, Some(&self.summarizer()))
.await;
tracing::info!("⏰ 检测到 12h 空闲,已生成摘要");
}
}
@@ -135,11 +138,12 @@ impl Conversation {
loop {
turn_count += 1;
if turn_count > 5 {
return Err("工具调用次数过多(最多5轮)".to_string());
if turn_count > 30 {
return Err("工具调用次数过多(最多30轮)".to_string());
}
let messages = builder::build_context(&self.session, &self.config.system_prompt).await;
let messages =
ctx_builder::build_context(&self.session, &self.config.system_prompt).await;
let rx = self.provider.chat_stream(&self.config, &messages).await?;
let mut full_text = String::new();
@@ -167,8 +171,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 请求 {} 个工具: {}",
@@ -204,7 +208,6 @@ impl Conversation {
}
continue;
}
}
// 无工具调用:记录回复,检查是否需要溢出摘要
if !full_text.is_empty() {
@@ -215,11 +218,14 @@ 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(ctx_builder::estimate_tokens).sum();
if estimated > s.token_budget {
drop(s);
builder::trigger_overflow_summary(&self.session, Some(&self.summarizer()))
.await;
summary_builder::trigger_overflow_summary(
&self.session,
Some(&self.summarizer()),
)
.await;
tracing::info!("📦 上下文超预算,已触发溢出摘要");
}
}
+2 -3
View File
@@ -167,8 +167,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 +195,6 @@ async fn stream_deepseek(
_ => {}
}
}
}
// 构建 tool_calls
let tool_calls: Option<Vec<ToolCall>> = if tool_call_builders.is_empty() {
+1 -1
View File
@@ -3,5 +3,5 @@ pub mod deepseek;
pub mod provider;
pub mod types;
pub use conversation::{Conversation, ToolExecutor, DEFAULT_SYSTEM_PROMPT};
pub use conversation::{Conversation, DEFAULT_SYSTEM_PROMPT};
pub use types::{ConversationConfig, Usage};
+12 -16
View File
@@ -110,16 +110,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 +134,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
+2 -3
View File
@@ -11,8 +11,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 +29,6 @@ pub fn init_logger(log_dir: Option<&str>, with_file: bool) {
.init();
return;
}
}
tracing_subscriber::registry()
.with(env_filter)
+94 -427
View File
@@ -1,5 +1,5 @@
mod cli;
mod context;
mod core;
mod daemon;
mod db;
mod ipc;
@@ -8,19 +8,21 @@ mod logger;
mod scheduler;
mod state;
mod tools;
mod wechat;
mod channel;
mod worker;
use clap::Parser;
use cli::{AmapAction, Cli, Commands, MemosAction, ToolCommand};
use context::MemoryStore;
use core::MemoryStore;
use core::pipeline::PipelineContext;
use db::Database;
use llm::{Conversation, ConversationConfig, DEFAULT_SYSTEM_PROMPT, ToolExecutor, Usage};
use llm::{Conversation, ConversationConfig, DEFAULT_SYSTEM_PROMPT};
use std::sync::Arc;
use tools::approval::{ApprovalDecision, ApprovalManager};
use tools::types::ExecutionContext;
use tokio::sync::Mutex;
use tools::approval::ApprovalManager;
use tools::executor::ToolExecutorBuilder;
use tracing::{error, info};
use wechat::client::WeChatClient;
use channel::client::WeChatClient;
#[tokio::main]
async fn main() {
@@ -240,66 +242,7 @@ async fn cmd_listen(
// 各项能力通过这两个元工具实现:查询能力 + 调用能力
// 具体工具描述通过 query_capabilities 返回
let mut tools_list: Vec<serde_json::Value> = Vec::new();
// query_capabilities:列出所有可用工具及其说明
tools_list.push(serde_json::json!({
"type": "function",
"function": {
"name": "query_capabilities",
"description": "列出所有可用工具的详细说明、参数格式和调用示例。在需要了解有哪些工具可用时首先调用此工具。",
"parameters": { "type": "object", "properties": {} }
}
}));
// call_capability:按名称调用任意工具
tools_list.push(serde_json::json!({
"type": "function",
"function": {
"name": "call_capability",
"description": "调用一个已注册的工具。name 为工具名(来自 query_capabilities),工具所需的具体参数(如 location、days)应直接放在 JSON 中。",
"parameters": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "工具名称" },
"prompt": { "type": "string", "description": "json格式参数,来自 query_capabilities 中的对应工具的描述。" }
},
"required": ["name"]
}
}
}));
// 上下文工具(仍然直接暴露给 LLM)
tools_list.push(serde_json::json!({
"type": "function",
"function": {
"name": "read_memories",
"description": "读取用户的长期记忆(偏好、个人信息、约定)",
"parameters": { "type": "object", "properties": {} }
}
}));
tools_list.push(serde_json::json!({
"type": "function",
"function": {
"name": "write_memory",
"description": "记录用户的重要信息或偏好",
"parameters": {
"type": "object",
"properties": {
"content": { "type": "string", "description": "记忆内容" }
},
"required": ["content"]
}
}
}));
tools_list.push(serde_json::json!({
"type": "function",
"function": {
"name": "read_summaries",
"description": "读取历史会话摘要",
"parameters": { "type": "object", "properties": {} }
}
}));
let tools_list = tools::build_tools_list();
cfg.tools = Some(tools_list);
@@ -321,10 +264,7 @@ async fn cmd_listen(
}
};
// 构建工具执行器
let session = conv.session();
let approval = approval_manager.clone();
let memory_store = memory_store.clone();
// 构建工具执行器(统一使用 ToolExecutorBuilder
let send_wechat: tools::types::WechatSender = {
let client = client.clone();
Arc::new(move |user_id: &str, text: &str| {
@@ -336,82 +276,16 @@ async fn cmd_listen(
.send_text(&uid, &msg, None)
.await
.map(|_| ())
.map_err(|e| e)
})
})
};
let executor: ToolExecutor = Arc::new(move |name: &str, args_json: &str| {
let approval = approval.clone();
let send = send_wechat.clone();
let session = session.clone();
let memory_store = memory_store.clone();
let n = name.to_string();
let args = args_json.to_string();
Box::pin(async move {
let user_id = session.lock().await.current_user_id.clone();
// 上下文工具直接处理
if n == "read_memories" {
return Ok(memory_store.read_for(&user_id).await);
}
if n == "write_memory" {
let params: serde_json::Value = serde_json::from_str(&args).unwrap_or_default();
let content = params.get("content").and_then(|v| v.as_str()).unwrap_or("");
if content.is_empty() {
return Ok("请提供 content 参数".to_string());
}
return Ok(memory_store.write_for(&user_id, content).await);
}
if n == "read_summaries" {
return Ok(context::tools::read_summaries(&session).await);
}
// query_capabilities: 列出所有内置工具
if n == "query_capabilities" {
let specs = tools::builtin::BuiltinRegistry::specs();
return Ok(tools::build_capability_guide(&specs));
}
// 确定目标工具名和参数
// call_capability: 从 {name, prompt} 中提取 name,从 prompt 中解包真实参数
let (target_name, target_args) = if n == "call_capability" {
let cp: serde_json::Value = serde_json::from_str(&args).unwrap_or_default();
let name = cp
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default();
let unpacked = tools::unpack_call_params(&cp);
(name, serde_json::to_string(&unpacked).unwrap_or_default())
} else {
(n.clone(), args.clone())
};
// 内置工具
if tools::builtin::BuiltinRegistry::is_builtin(&target_name) {
if tools::builtin::BuiltinRegistry::is_high_risk(&target_name) {
let mut ctx = ExecutionContext::new(&user_id);
ctx = ctx.with_approval(approval.clone());
ctx = ctx.with_wechat(send.clone());
let approved = builtin_approve(&ctx, &target_name).await;
match approved {
Ok(true) => {}
Ok(false) => return Ok(SkillResult::rejected(&target_name).output),
Err(e) => return Ok(e),
}
}
if let Some(result) =
tools::builtin::BuiltinRegistry::execute(&target_name, &target_args).await
{
return Ok(result.output);
}
}
Ok(format!("未知工具: {}", target_name))
})
});
let executor = ToolExecutorBuilder::new("")
.with_session(conv.session())
.with_approval(approval_manager.clone())
.with_wechat(send_wechat)
.with_memory(memory_store.clone())
.build();
conv.set_tool_executor(executor);
@@ -455,6 +329,17 @@ async fn cmd_listen(
info!("开始监听消息 (llm={}, echo={})...", enable_llm, echo);
println!("已开始监听消息,按 Ctrl+C 退出");
// 构建流水线
let pipeline = PipelineContext {
client: client.clone(),
database: database.clone(),
file_state: file_state.clone(),
memory_store: memory_store.clone(),
approval_manager: approval_manager.clone(),
account_id,
conversation_lock: Arc::new(Mutex::new(())),
};
let shutdown = async { tokio::signal::ctrl_c().await.expect("Ctrl+C 失败") };
tokio::select! {
@@ -464,244 +349,7 @@ async fn cmd_listen(
file_state.save_runtime(&client.get_updates_buf().await);
info!("已退出");
}
_ = listen_loop(&client, enable_llm, echo, &account_id, database, file_state, conversation, &approval_manager, memory_store) => {}
}
}
async fn listen_loop(
client: &WeChatClient,
enable_llm: bool,
echo: bool,
account_id: &str,
database: &Option<Arc<Database>>,
file_state: &state::StateManager,
conversation: Option<Arc<Conversation>>,
approval_manager: &ApprovalManager,
memory_store: &Arc<MemoryStore>,
) {
let last_user_id = Arc::new(tokio::sync::Mutex::new(String::new()));
loop {
match client.receive_messages().await {
Ok(resp) => {
// 持久化 runtime buf
if !resp.get_updates_buf.is_empty() {
file_state.save_runtime(&resp.get_updates_buf);
}
for msg in &resp.msgs {
if msg.msg_type != Some(wechat::types::WeixinMessage::TYPE_USER) {
continue;
}
let from = msg.from_user_id.as_deref().unwrap_or("unknown");
let text = msg.text_content().unwrap_or("(非文本消息)");
let ctx_token = msg.context_token.as_deref();
let msg_id = msg.message_id.map(|id| id.to_string()).unwrap_or_default();
info!("收到消息 from={}: {}", from, text);
// === 入库:收到的消息 ===
if let Some(db) = database {
if let Err(e) = db::models::insert_chat_record(
db.pool(),
"inbound",
from,
account_id,
text,
"wechat",
ctx_token,
&msg_id,
)
.await
{
error!("保存聊天记录失败: {}", e);
}
}
// Echo 模式
if echo {
match client.send_text(from, text, ctx_token).await {
Ok(sent_id) => {
info!("回显成功 msg_id={}", sent_id);
// === 入库:发送的回显 ===
if let Some(db) = database {
if let Err(e) = db::models::insert_chat_record(
db.pool(),
"outbound",
from,
account_id,
text,
"echo",
ctx_token,
&sent_id,
)
.await
{
error!("保存聊天记录失败: {}", e);
}
}
}
Err(e) => error!("回显失败: {}", e),
}
}
// 审批回复检查
let is_approval_reply =
approval_manager.handle_reply(from, text).await.is_some();
if is_approval_reply {
info!("消息匹配审批确认码,不传给 LLM");
continue;
}
// 用户切换:清空上下文,加载新用户历史
{
let mut last_user = last_user_id.lock().await;
if *last_user != *from {
info!("用户切换: {} → {}", *last_user, from);
if let Some(conv) = &conversation {
let session = conv.session();
let mut s = session.lock().await;
s.messages.clear();
s.checkpoint = 0;
s.summaries.clear();
s.last_user_at = None;
s.current_user_id = from.to_string();
// 加载该用户最近 20 条历史
s.load_recent_messages(20).await;
drop(s);
memory_store.load(from).await;
}
}
*last_user = from.to_string();
}
// LLM 回复(异步执行,避免审批阻塞主循环)
if enable_llm {
if let Some(conv) = &conversation {
let conv = conv.clone();
let client = client.clone();
let database = database.clone();
let from_owned = from.to_string();
let text_owned = text.to_string();
let ctx_owned = ctx_token.map(String::from);
let aid = account_id.to_string();
tokio::spawn(async move {
let reply_result = if conv.spec().tools.is_some() {
generate_reply_with_tools(&conv, text_owned, &database).await
} else {
generate_reply(&conv, text_owned, &database).await
};
let reply = match reply_result {
Ok(r) if !r.trim().is_empty() => r,
Ok(_) => "抱歉,生成回复为空,请重试。".to_string(),
Err(e) => format!("处理消息时出错:{:.200}", e),
};
match client
.send_text(&from_owned, &reply, ctx_owned.as_deref())
.await
{
Ok(sent_id) => {
info!("LLM 回复成功 msg_id={}", sent_id);
if let Some(db) = database {
if let Err(e) = db::models::insert_chat_record(
db.pool(),
"outbound",
&from_owned,
&aid,
&reply,
"llm",
ctx_owned.as_deref(),
&sent_id,
)
.await
{
error!("保存聊天记录失败: {}", e);
}
}
}
Err(e) => error!("发送 LLM 回复失败: {}", e),
}
});
}
}
}
}
Err(e) => {
if !e.contains("超时") && !e.contains("timeout") {
error!("接收消息失败: {}", e);
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
}
}
}
}
async fn generate_reply(
conv: &Conversation,
user_text: String,
db: &Option<Arc<Database>>,
) -> Result<String, String> {
let mut handle = conv.chat(user_text).await?;
// 先消费流(usage 在 Done chunk 中才可用)
let reply = handle.consume().await?;
if let Some(u) = handle.get_usage() {
info!(
"📊 Token: {} 总 / {} 提示 / {} 生成 | 缓存命中 {} | 缓存未命中 {}",
u.total_tokens,
u.prompt_tokens,
u.completion_tokens,
u.prompt_cache_hit_tokens,
u.prompt_cache_miss_tokens
);
store_usage(db, "", conv.model(), conv.provider_name(), u).await;
}
Ok(reply)
}
async fn generate_reply_with_tools(
conv: &Conversation,
user_text: String,
db: &Option<Arc<Database>>,
) -> Result<String, String> {
let (reply, _used_tools, usage) = conv.chat_with_tools(user_text).await?;
if let Some(u) = &usage {
info!(
"📊 Token: {} 总 / {} 提示 / {} 生成 | 缓存命中 {} | 缓存未命中 {}",
u.total_tokens,
u.prompt_tokens,
u.completion_tokens,
u.prompt_cache_hit_tokens,
u.prompt_cache_miss_tokens
);
store_usage(db, "", conv.model(), conv.provider_name(), u).await;
}
Ok(reply)
}
async fn store_usage(
db: &Option<Arc<Database>>,
user_id: &str,
model: &str,
provider: &str,
u: &Usage,
) {
if let Some(db) = db {
if let Err(e) = db::models::insert_llm_usage(
db.pool(),
user_id,
model,
provider,
u.prompt_tokens,
u.completion_tokens,
u.prompt_cache_hit_tokens,
u.prompt_cache_miss_tokens,
)
.await
{
tracing::error!("存储 LLM 用量失败: {}", e);
}
_ = pipeline.run_listen_loop(enable_llm, echo, conversation) => {}
}
}
@@ -828,8 +476,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,41 +491,11 @@ async fn cmd_send(
{
error!("保存聊天记录失败: {}", e);
}
}
}
Err(e) => error!("发送失败: {}", e),
}
}
use tools::types::SkillResult;
async fn builtin_approve(ctx: &ExecutionContext, name: &str) -> Result<bool, String> {
let manager = match ctx.approval_manager.as_ref() {
Some(m) => m.clone(),
None => return Ok(true),
};
let (code, rx) = manager.create(&ctx.user_id, name).await?;
let msg = format!(
"⚠️ 操作确认\n\n技能:{}\n\n确认码:{}\n\n回复确认码以继续\n回复 0 或「取消」以取消\n(5分钟内有效,最多3次尝试)",
name, code
);
if let Some(ref send) = ctx.send_wechat {
if let Err(e) = send(&ctx.user_id, &msg).await {
return Err(e);
}
}
match tokio::time::timeout(std::time::Duration::from_secs(300), rx).await {
Ok(Ok(ApprovalDecision::Approved)) => Ok(true),
_ => Ok(false),
}
}
// ─── 工具 CLI 命令 ───
async fn cmd_tool(cmd: ToolCommand) {
match cmd {
ToolCommand::Datetime => {
@@ -952,22 +570,48 @@ async fn cmd_amap(action: AmapAction) {
"page": page,
"offset": offset,
});
if let Some(c) = city { params["city"] = serde_json::json!(c); }
if let Some(l) = location { params["location"] = serde_json::json!(l); }
if let Some(r) = radius { params["radius"] = serde_json::json!(r); }
if let Some(c) = city {
params["city"] = serde_json::json!(c);
}
if let Some(l) = location {
params["location"] = serde_json::json!(l);
}
if let Some(r) = radius {
params["radius"] = serde_json::json!(r);
}
let result = tools::builtins::amap::execute("amap_poi_search", params).await;
if let Some(r) = result { println!("{}", r.output); }
if let Some(r) = result {
println!(
"{}\n{}",
r.output,
serde_json::to_string(&r.data).unwrap_or_default()
);
}
}
AmapAction::Geocode { address, city } => {
let mut params = serde_json::json!({"address": address});
if let Some(c) = city { params["city"] = serde_json::json!(c); }
if let Some(c) = city {
params["city"] = serde_json::json!(c);
}
let result = tools::builtins::amap::execute("amap_geocode", params).await;
if let Some(r) = result { println!("{}", r.output); }
if let Some(r) = result {
println!(
"{}\n{}",
r.output,
serde_json::to_string(&r.data).unwrap_or_default()
);
}
}
AmapAction::ReverseGeocode { location } => {
let params = serde_json::json!({"location": location});
let result = tools::builtins::amap::execute("amap_reverse_geocode", params).await;
if let Some(r) = result { println!("{}", r.output); }
if let Some(r) = result {
println!(
"{}\n{}",
r.output,
serde_json::to_string(&r.data).unwrap_or_default()
);
}
}
AmapAction::RoutePlan {
r#type,
@@ -982,11 +626,23 @@ async fn cmd_amap(action: AmapAction) {
"origin": origin,
"destination": destination,
});
if let Some(c) = city { params["city"] = serde_json::json!(c); }
if let Some(w) = waypoints { params["waypoints"] = serde_json::json!(w); }
if let Some(s) = strategy { params["strategy"] = serde_json::json!(s); }
if let Some(c) = city {
params["city"] = serde_json::json!(c);
}
if let Some(w) = waypoints {
params["waypoints"] = serde_json::json!(w);
}
if let Some(s) = strategy {
params["strategy"] = serde_json::json!(s);
}
let result = tools::builtins::amap::execute("amap_route_plan", params).await;
if let Some(r) = result { println!("{}", r.output); }
if let Some(r) = result {
println!(
"{}\n{}",
r.output,
serde_json::to_string(&r.data).unwrap_or_default()
);
}
}
AmapAction::TravelPlan {
city,
@@ -1003,15 +659,26 @@ async fn cmd_amap(action: AmapAction) {
"route_type": route_type,
});
let result = tools::builtins::amap::execute("amap_travel_plan", params).await;
if let Some(r) = result { println!("{}", r.output); }
if let Some(r) = result {
println!(
"{}\n{}",
r.output,
serde_json::to_string(&r.data).unwrap_or_default()
);
}
}
AmapAction::MapLink { data } => {
let map_data: serde_json::Value =
serde_json::from_str(&data).unwrap_or(serde_json::json!([]));
let params = serde_json::json!({"map_data": map_data});
let result = tools::builtins::amap::execute("amap_map_link", params).await;
if let Some(r) = result { println!("{}", r.output); }
if let Some(r) = result {
println!(
"{}\n{}",
r.output,
serde_json::to_string(&r.data).unwrap_or_default()
);
}
}
}
}
+1
View File
@@ -16,6 +16,7 @@ pub struct RuntimeState {
}
/// 状态管理器
#[derive(Clone)]
pub struct StateManager {
state_dir: PathBuf,
}
+28 -3
View File
@@ -147,6 +147,32 @@ impl ApprovalManager {
Some((name, result))
}
/// 按确认码哈希精确取消一条待审批记录(不触发决策通知)
/// 改为 cancelled 状态同步 DB,用于 listen 模式审批超时时清理
pub async fn cancel_by_code(&self, user_id: &str, code_hash: &str) {
let mut map = self.pending.lock().await;
if let Some(entries) = map.get_mut(user_id) {
if let Some(idx) = entries.iter().position(|e| e.code_hash == code_hash) {
let entry = entries.remove(idx);
// sender 被 droprx 端收到 Cancelled 错误
tracing::debug!("取消审批: user={} skill={}", user_id, entry.skill_name);
}
if entries.is_empty() {
map.remove(user_id);
}
}
// 同步 DB 状态
if let Some(ref pool) = self.pool {
let _ = sqlx::query(
"UPDATE pending_approvals SET status = 'cancelled', consumed_at = NOW() WHERE code_hash = $1 AND status = 'pending'",
)
.bind(code_hash)
.execute(pool.as_ref())
.await;
}
}
/// 清理过期审批(按时间判定,通知等待方 + 更新 DB)
pub async fn clean_expired(&self) {
let now = Instant::now();
@@ -175,12 +201,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());
}
+209 -372
View File
@@ -1,20 +1,22 @@
// 高德地图综合工具(Rust 原生实现)
// 高德地图综合工具(Rust 原生实现 — v5 API
//
// API:
// POI 搜索: GET /v5/place/text → poi 列表
// POI 周边搜索: GET /v5/place/around → poi 列表
// 地理编码: GET /v3/geocode/geo → 坐标
// 逆地理编码: GET /v3/geocode/regeo → 地址
// 步行路线: GET /v3/direction/walking → 路线
// 驾车路线: GET /v3/direction/driving → 路线
// 骑行路线: GET /v4/direction/bicycling → 路线
// 公交路线: GET /v3/direction/transit/integrated → 路线
// POI 搜索: GET /v5/place/text → poi 列表
// POI 周边搜索: GET /v5/place/around → poi 列表
// 地理编码: GET /v3/geocode/geo → 坐标
// 逆地理编码: GET /v3/geocode/regeo → 地址
// 步行路线: GET /v5/direction/walking → 路线
// 驾车路线: GET /v5/direction/driving → 路线
// 骑行路线: GET /v5/direction/bicycling → 路线
// 电动车骑行: GET /v5/direction/electrobike → 路线
// 公交路线: GET /v5/direction/transit/integrated → 路线
//
// 认证: AMAP_WEBSERVICE_KEY 或 AMAP_KEY 环境变量
// 所有请求必须带 appname=amap-lbs-skill
use reqwest::Client;
use std::time::Duration;
use tracing::info;
use crate::tools::types::{RiskLevel, SkillResult, SkillSpec};
@@ -46,30 +48,29 @@ impl AmapClient {
})
}
/// POI 文本搜索
/// POI 文本搜索 (v5)
async fn poi_text_search(
&self,
keywords: &str,
city: Option<&str>,
page: u32,
offset: u32,
page_num: u32,
page_size: u32,
) -> Result<serde_json::Value, String> {
let mut params = vec![
("key", self.key.as_str()),
("keywords", keywords),
("appname", "amap-lbs-skill"),
];
let region = city.unwrap_or("");
params.push(("region", region));
let page_str = page.to_string();
params.push(("page", &page_str));
let offset_str = offset.to_string();
params.push(("offset", &offset_str));
let pn = page_num.to_string();
let ps = page_size.to_string();
let resp: serde_json::Value = self
.http
.get("https://restapi.amap.com/v5/place/text")
.query(&params)
.query(&[
("key", self.key.as_str()),
("keywords", keywords),
("region", region),
("page_num", &pn),
("page_size", &ps),
("appname", "amap-lbs-skill"),
])
.send()
.await
.map_err(|e| format!("POI 搜索请求失败: {}", e))?
@@ -86,18 +87,19 @@ impl AmapClient {
Ok(resp)
}
/// POI 周边搜索
/// POI 周边搜索 (v5)
async fn poi_around(
&self,
keywords: &str,
location: &str,
radius: u32,
page: u32,
offset: u32,
page_num: u32,
page_size: u32,
sortrule: &str,
) -> Result<serde_json::Value, String> {
let page_str = page.to_string();
let offset_str = offset.to_string();
let radius_str = radius.to_string();
let pn = page_num.to_string();
let ps = page_size.to_string();
let r = radius.to_string();
let resp: serde_json::Value = self
.http
@@ -106,9 +108,10 @@ impl AmapClient {
("key", self.key.as_str()),
("keywords", keywords),
("location", location),
("radius", &radius_str),
("page", &page_str),
("offset", &offset_str),
("radius", &r),
("page_num", &pn),
("page_size", &ps),
("sortrule", sortrule),
("appname", "amap-lbs-skill"),
])
.send()
@@ -127,8 +130,12 @@ impl AmapClient {
Ok(resp)
}
/// 地理编码:地址 → 坐标
async fn geocode(&self, address: &str, city: Option<&str>) -> Result<serde_json::Value, String> {
/// 地理编码:地址 → 坐标 (v3)
async fn geocode(
&self,
address: &str,
city: Option<&str>,
) -> Result<serde_json::Value, String> {
let mut params = vec![
("key", self.key.as_str()),
("address", address),
@@ -161,7 +168,7 @@ impl AmapClient {
Ok(resp)
}
/// 逆地理编码:坐标 → 地址
/// 逆地理编码:坐标 → 地址 (v3)
async fn regeocode(&self, location: &str) -> Result<serde_json::Value, String> {
let resp: serde_json::Value = self
.http
@@ -188,7 +195,7 @@ impl AmapClient {
Ok(resp)
}
/// 步行路线规划
/// 步行路线规划 (v5)
async fn walking_route(
&self,
origin: &str,
@@ -196,11 +203,12 @@ impl AmapClient {
) -> Result<serde_json::Value, String> {
let resp: serde_json::Value = self
.http
.get("https://restapi.amap.com/v3/direction/walking")
.get("https://restapi.amap.com/v5/direction/walking")
.query(&[
("key", self.key.as_str()),
("origin", origin),
("destination", destination),
("show_fields", "cost"),
("appname", "amap-lbs-skill"),
])
.send()
@@ -219,7 +227,7 @@ impl AmapClient {
Ok(resp)
}
/// 驾车路线规划
/// 驾车路线规划 (v5)
async fn driving_route(
&self,
origin: &str,
@@ -227,22 +235,27 @@ impl AmapClient {
waypoints: Option<&str>,
strategy: u32,
) -> Result<serde_json::Value, String> {
let strategy_s = strategy.to_string();
let ss = strategy.to_string();
let mut params = vec![
("key", self.key.as_str()),
("origin", origin),
("destination", destination),
("strategy", &strategy_s),
("extensions", "base"),
("strategy", &ss),
// ("show_fields", "cost"),
("appname", "amap-lbs-skill"),
];
if let Some(wp) = waypoints {
params.push(("waypoints", wp));
}
info!(
"parms: {:?}",
serde_json::to_string(&params).unwrap_or_default()
);
let resp: serde_json::Value = self
.http
.get("https://restapi.amap.com/v3/direction/driving")
.get("https://restapi.amap.com/v5/direction/driving")
.query(&params)
.send()
.await
@@ -252,6 +265,10 @@ impl AmapClient {
.map_err(|e| format!("解析驾车路线响应失败: {}", e))?;
if resp["status"].as_str() != Some("1") {
info!(
"parms: {:?}",
serde_json::to_string(&resp).unwrap_or_default()
);
return Err(format!(
"驾车路线失败: {}",
resp["info"].as_str().unwrap_or("未知错误")
@@ -260,7 +277,7 @@ impl AmapClient {
Ok(resp)
}
/// 骑行路线规划
/// 骑行路线规划 (v5)
async fn riding_route(
&self,
origin: &str,
@@ -268,11 +285,12 @@ impl AmapClient {
) -> Result<serde_json::Value, String> {
let resp: serde_json::Value = self
.http
.get("https://restapi.amap.com/v4/direction/bicycling")
.get("https://restapi.amap.com/v5/direction/bicycling")
.query(&[
("key", self.key.as_str()),
("origin", origin),
("destination", destination),
// ("show_fields", "cost"),
("appname", "amap-lbs-skill"),
])
.send()
@@ -282,32 +300,67 @@ impl AmapClient {
.await
.map_err(|e| format!("解析骑行路线响应失败: {}", e))?;
if resp["errcode"].as_i64() != Some(0) {
if resp["status"].as_str() != Some("1") {
return Err(format!(
"骑行路线失败: {}",
resp["errmsg"].as_str().unwrap_or("未知错误")
resp["info"].as_str().unwrap_or("未知错误")
));
}
Ok(resp)
}
/// 公交路线规划
async fn transit_route(
/// 电动车骑行路线规划 (v5)
async fn electrobike_route(
&self,
origin: &str,
destination: &str,
city: &str,
strategy: u32,
) -> Result<serde_json::Value, String> {
let resp: serde_json::Value = self
.http
.get("https://restapi.amap.com/v3/direction/transit/integrated")
.get("https://restapi.amap.com/v5/direction/electrobike")
.query(&[
("key", self.key.as_str()),
("origin", origin),
("destination", destination),
("city", city),
("show_fields", "cost"),
("appname", "amap-lbs-skill"),
])
.send()
.await
.map_err(|e| format!("电动车骑行路线请求失败: {}", e))?
.json()
.await
.map_err(|e| format!("解析电动车骑行路线响应失败: {}", e))?;
if resp["status"].as_str() != Some("1") {
return Err(format!(
"电动车骑行路线失败: {}",
resp["info"].as_str().unwrap_or("未知错误")
));
}
Ok(resp)
}
/// 公交路线规划 (v5)
async fn transit_route(
&self,
origin: &str,
destination: &str,
city1: &str,
city2: &str,
strategy: u32,
) -> Result<serde_json::Value, String> {
let resp: serde_json::Value = self
.http
.get("https://restapi.amap.com/v5/direction/transit/integrated")
.query(&[
("key", self.key.as_str()),
("origin", origin),
("destination", destination),
("city1", city1),
("city2", city2),
("strategy", &strategy.to_string()),
("show_fields", "cost"),
("appname", "amap-lbs-skill"),
])
.send()
@@ -327,6 +380,10 @@ impl AmapClient {
}
}
// ═══════════════════════════════════════════════
// 辅助:URL 编码
// ═══════════════════════════════════════════════
// ═══════════════════════════════════════════════
// 1. amap_poi_search — POI 搜索
// ═══════════════════════════════════════════════
@@ -356,12 +413,7 @@ pub fn poi_search_spec() -> SkillSpec {
}
pub async fn poi_search_execute(params: serde_json::Value) -> SkillResult {
let keywords = params["keywords"]
.as_str()
.unwrap_or_else(|| {
tracing::warn!("amap_poi_search: 缺少 keywords");
""
});
let keywords = params["keywords"].as_str().unwrap_or("");
if keywords.is_empty() {
return SkillResult::error("参数错误:缺少 keywords(搜索关键词)");
}
@@ -369,37 +421,34 @@ pub async fn poi_search_execute(params: serde_json::Value) -> SkillResult {
let city = params["city"].as_str();
let location = params["location"].as_str();
let radius = params["radius"].as_u64().unwrap_or(1000) as u32;
let page = params["page"].as_u64().unwrap_or(1) as u32;
let offset = params["offset"].as_u64().unwrap_or(10).min(25) as u32;
// CLI 参数兼容:page/offset → v5 page_num/page_size
let page_num = params["page"].as_u64().unwrap_or(1) as u32;
let page_size = params["offset"].as_u64().unwrap_or(10).min(25) as u32;
let client = match AmapClient::new() {
Ok(c) => c,
Err(e) => return SkillResult::error(e),
};
// 有 location → 周边搜索,否则 → 文本搜索
let result = if let Some(loc) = location {
tracing::info!("📍 周边搜索: {} @ {} (radius={}m)", keywords, loc, radius);
tracing::info!(
"📍 周边搜索: {} @ {} (radius={}m, sort=distance)",
keywords,
loc,
radius
);
client
.poi_around(keywords, loc, radius, page, offset)
.poi_around(keywords, loc, radius, page_num, page_size, "distance")
.await
} else {
tracing::info!("🔍 POI 搜索: {} city={:?}", keywords, city);
client
.poi_text_search(keywords, city, page, offset)
.poi_text_search(keywords, city, page_num, page_size)
.await
};
match result {
Ok(data) => {
let count = data["count"].as_str().unwrap_or("?");
let pois = &data["pois"];
let poi_count = pois.as_array().map(|a| a.len()).unwrap_or(0);
let text = format!(
"✅ 搜索「{keywords}」共 {count} 条结果,当前页显示 {poi_count}",
);
SkillResult::ok_with_data(text, data)
}
Ok(data) => SkillResult::ok_with_data("✅ POI 搜索完成".to_string(), data),
Err(e) => SkillResult::error(e),
}
}
@@ -442,25 +491,7 @@ pub async fn geocode_execute(params: serde_json::Value) -> SkillResult {
tracing::info!("📍 地理编码: {} city={:?}", address, city);
match client.geocode(address, city).await {
Ok(data) => {
let geocodes = &data["geocodes"];
if let Some(arr) = geocodes.as_array() {
if arr.is_empty() {
return SkillResult::error(format!("未找到地址: {address}"));
}
let loc = arr[0]["location"].as_str().unwrap_or("未知");
let formatted = arr[0]["formatted_address"].as_str().unwrap_or(address);
let text = format!("📍 {formatted} 坐标: {loc}");
let out = serde_json::json!({
"address": formatted,
"location": loc,
"top_result": arr[0]
});
SkillResult::ok_with_data(text, out)
} else {
SkillResult::error(format!("未找到地址: {address}"))
}
}
Ok(data) => SkillResult::ok_with_data("✅ 地理编码完成".to_string(), data),
Err(e) => SkillResult::error(e),
}
}
@@ -501,14 +532,7 @@ pub async fn reverse_geocode_execute(params: serde_json::Value) -> SkillResult {
tracing::info!("📍 逆地理编码: {}", location);
match client.regeocode(location).await {
Ok(data) => {
let regeocode = &data["regeocode"];
let addr = regeocode["formatted_address"]
.as_str()
.unwrap_or("未知地址");
let text = format!("📍 坐标 {location}{addr}");
SkillResult::ok_with_data(text, data)
}
Ok(data) => SkillResult::ok_with_data("✅ 逆地理编码完成".to_string(), data),
Err(e) => SkillResult::error(e),
}
}
@@ -520,20 +544,20 @@ pub async fn reverse_geocode_execute(params: serde_json::Value) -> SkillResult {
pub fn route_plan_spec() -> SkillSpec {
SkillSpec {
name: "amap_route_plan".into(),
description: "高德地图路径规划,支持步行(walking)、驾车(driving)、骑行(riding)、公交(transit)四种出行方式\
参数 origin 为起点坐标'经度,纬度'destination 为终点坐标'经度,纬度'\
type 为出行方式,city 为城市名(公交必填),waypoints 为途经点(驾车可选,多个用;分隔)"
description: "高德地图路径规划v5,支持步行(walking)、驾车(driving)、骑行(riding)、电动车(electrobike)、公交(transit)。\
参数 origin/destination 为终点坐标'经度,纬度'type 为出行方式,\
city 为城市 citycode(公交必填,用于 city1/city2),waypoints 为途经点(驾车可选,多个用;分隔)"
.into(),
risk_level: RiskLevel::Low,
parameters: serde_json::json!({
"type": "object",
"properties": {
"type": {"type": "string", "description": "出行方式walking/driving/riding/transit", "enum": ["walking", "driving", "riding", "transit"]},
"type": {"type": "string", "description": "出行方式", "enum": ["walking", "driving", "riding", "electrobike", "transit"]},
"origin": {"type": "string", "description": "起点坐标,格式:经度,纬度"},
"destination": {"type": "string", "description": "终点坐标,格式:经度,纬度"},
"city": {"type": "string", "description": "城市名称(公交方式必填"},
"city": {"type": "string", "description": "城市 citycode(公交方式必填,同时用作 city1 和 city2"},
"waypoints": {"type": "string", "description": "途经点坐标,多个用;分隔(仅驾车方式)"},
"strategy": {"type": "integer", "description": "策略:驾车默认10(躲避拥堵),公交默认0最快捷"}
"strategy": {"type": "integer", "description": "策略:驾车默认32(高德推荐),公交默认0推荐模式"}
},
"required": ["type", "origin", "destination"]
}),
@@ -547,10 +571,7 @@ pub async fn route_plan_execute(params: serde_json::Value) -> SkillResult {
let destination = params["destination"].as_str().unwrap_or("");
if route_type.is_empty() || origin.is_empty() || destination.is_empty() {
return SkillResult::error(
"参数错误:缺少 type/origin/destination。格式示例:\
{\"type\":\"walking\",\"origin\":\"116.397428,39.90923\",\"destination\":\"116.427281,39.903719\"}",
);
return SkillResult::error("参数错误:缺少 type/origin/destination");
}
let client = match AmapClient::new() {
@@ -566,167 +587,51 @@ pub async fn route_plan_execute(params: serde_json::Value) -> SkillResult {
);
match route_type {
"walking" => {
match client.walking_route(origin, destination).await {
Ok(data) => {
let summary = route_summary(&data, "walking");
SkillResult::ok_with_data(summary, data)
}
Err(e) => SkillResult::error(e),
}
}
"walking" => match client.walking_route(origin, destination).await {
Ok(data) => SkillResult::ok_with_data("✅ 步行路线规划完成".to_string(), data),
Err(e) => SkillResult::error(e),
},
"driving" => {
let waypoints = params["waypoints"].as_str();
let strategy = params["strategy"].as_u64().unwrap_or(10) as u32;
let strategy = params["strategy"].as_u64().unwrap_or(32) as u32;
match client
.driving_route(origin, destination, waypoints, strategy)
.await
{
Ok(data) => {
let summary = route_summary(&data, "driving");
SkillResult::ok_with_data(summary, data)
}
Err(e) => SkillResult::error(e),
}
}
"riding" => {
match client.riding_route(origin, destination).await {
Ok(data) => {
let summary = route_summary(&data, "riding");
SkillResult::ok_with_data(summary, data)
}
Ok(data) => SkillResult::ok_with_data("✅ 驾车路线规划完成".to_string(), data),
Err(e) => SkillResult::error(e),
}
}
"riding" => match client.riding_route(origin, destination).await {
Ok(data) => SkillResult::ok_with_data("✅ 骑行路线规划完成".to_string(), data),
Err(e) => SkillResult::error(e),
},
"electrobike" => match client.electrobike_route(origin, destination).await {
Ok(data) => SkillResult::ok_with_data("✅ 电动车路线规划完成".to_string(), data),
Err(e) => SkillResult::error(e),
},
"transit" => {
let city = params["city"].as_str().unwrap_or("");
if city.is_empty() {
return SkillResult::error("公交路线需要提供 city 参数(城市名称)");
let city_code = params["city"].as_str().unwrap_or("");
if city_code.is_empty() {
return SkillResult::error(
"公交路线需要提供 city 参数(城市 citycode,如 010 表示北京)",
);
}
let strategy = params["strategy"].as_u64().unwrap_or(0) as u32;
match client
.transit_route(origin, destination, city, strategy)
.transit_route(origin, destination, city_code, city_code, strategy)
.await
{
Ok(data) => {
let summary = route_summary(&data, "transit");
SkillResult::ok_with_data(summary, data)
}
Ok(data) => SkillResult::ok_with_data("✅ 公交路线规划完成".to_string(), data),
Err(e) => SkillResult::error(e),
}
}
other => SkillResult::error(format!(
"不支持的出行方式: {other},支持 walking/driving/riding/transit"
"不支持的出行方式: {other},支持 walking/driving/riding/electrobike/transit"
)),
}
}
/// 提取路线摘要信息
fn route_summary(data: &serde_json::Value, route_type: &str) -> String {
match route_type {
"walking" => {
if let Some(path) = data["route"]["paths"]
.as_array()
.and_then(|a| a.first())
{
let dist = path["distance"]
.as_str()
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let dur = path["duration"]
.as_str()
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
format!(
"🚶 步行路线:距离 {:.2} 公里,预计 {} 分钟",
dist / 1000.0,
(dur / 60.0) as u32
)
} else {
"🚶 步行路线规划完成".into()
}
}
"driving" => {
if let Some(path) = data["route"]["paths"]
.as_array()
.and_then(|a| a.first())
{
let dist = path["distance"]
.as_str()
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let dur = path["duration"]
.as_str()
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let tolls = path["tolls"]
.as_str()
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
format!(
"🚗 驾车路线:距离 {:.2} 公里,预计 {} 分钟,过路费 {}",
dist / 1000.0,
(dur / 60.0) as u32,
tolls
)
} else {
"🚗 驾车路线规划完成".into()
}
}
"riding" => {
if let Some(path) = data["data"]["paths"]
.as_array()
.and_then(|a| a.first())
{
let dist = path["distance"].as_f64().unwrap_or(0.0);
let dur = path["duration"].as_f64().unwrap_or(0.0);
format!(
"🚴 骑行路线:距离 {:.2} 公里,预计 {} 分钟",
dist / 1000.0,
(dur / 60.0) as u32
)
} else {
"🚴 骑行路线规划完成".into()
}
}
"transit" => {
let count = data["route"]["transits"]
.as_array()
.map(|a| a.len())
.unwrap_or(0);
if let Some(t) = data["route"]["transits"]
.as_array()
.and_then(|a| a.first())
{
let dur = t["duration"]
.as_str()
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let cost = t["cost"].as_str().and_then(|s| s.parse::<f64>().ok());
let wdist = t["walking_distance"]
.as_str()
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let mut text = format!(
"🚌 公交路线:共 {count} 个方案,推荐方案预计 {} 分钟",
(dur / 60.0) as u32
);
if let Some(c) = cost {
text.push_str(&format!(",费用 {c}"));
}
text.push_str(&format!(
",步行 {:.0}",
wdist
));
text
} else {
format!("🚌 公交路线规划完成,共 {count} 个方案")
}
}
_ => "路线规划完成".into(),
}
}
// ═══════════════════════════════════════════════
// 5. amap_travel_plan — 旅游规划
// ═══════════════════════════════════════════════
@@ -736,7 +641,7 @@ pub fn travel_plan_spec() -> SkillSpec {
name: "amap_travel_plan".into(),
description: "高德地图智能旅游规划:自动搜索城市内的兴趣点并规划游览路线。\
参数 city 为城市名(必填),interests 为兴趣点关键词数组如['景点','美食']\
route_type 为路线类型 walking/driving/riding/transit(默认walking"
route_type 为路线类型 walking/driving/riding/electrobike/transit(默认walking"
.into(),
risk_level: RiskLevel::Low,
parameters: serde_json::json!({
@@ -744,7 +649,7 @@ pub fn travel_plan_spec() -> SkillSpec {
"properties": {
"city": {"type": "string", "description": "城市名称,如 北京、杭州、上海"},
"interests": {"type": "array", "items": {"type": "string"}, "description": "兴趣点关键词,如 ['景点','美食','酒店']"},
"route_type": {"type": "string", "description": "路线类型walking/driving/riding/transit", "enum": ["walking", "driving", "riding", "transit"]}
"route_type": {"type": "string", "description": "路线类型", "enum": ["walking", "driving", "riding", "electrobike", "transit"]}
},
"required": ["city"]
}),
@@ -760,18 +665,14 @@ pub async fn travel_plan_execute(params: serde_json::Value) -> SkillResult {
let interests: Vec<&str> = params["interests"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<&str>>()
})
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_else(|| vec!["景点", "美食"]);
let route_type = params["route_type"].as_str().unwrap_or("walking");
let valid_types = ["walking", "driving", "riding", "transit"];
let valid_types = ["walking", "driving", "riding", "electrobike", "transit"];
if !valid_types.contains(&route_type) {
return SkillResult::error(format!(
"无效的路线类型: {route_type},支持 walking/driving/riding/transit"
"无效的路线类型: {route_type},支持 walking/driving/riding/electrobike/transit"
));
}
@@ -793,26 +694,17 @@ pub async fn travel_plan_execute(params: serde_json::Value) -> SkillResult {
// 第一步:搜索各类兴趣点
for interest in &interests {
tracing::info!("📍 搜索 {}...", interest);
match client
.poi_text_search(interest, Some(city), 1, 5)
.await
{
match client.poi_text_search(interest, Some(city), 1, 5).await {
Ok(data) => {
if let Some(pois) = data["pois"].as_array() {
for poi in pois {
let name = poi["name"].as_str().unwrap_or("未知");
let address = poi["address"].as_str().unwrap_or("");
let loc_str = poi["location"].as_str().unwrap_or("0,0");
let parts: Vec<&str> = loc_str.split(',').collect();
let lng = parts
.first()
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let lat = parts
.get(1)
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let parts: Vec<f64> =
loc_str.split(',').filter_map(|s| s.parse().ok()).collect();
let lng = parts.first().copied().unwrap_or(0.0);
let lat = parts.get(1).copied().unwrap_or(0.0);
map_tasks.push(serde_json::json!({
"type": "poi",
@@ -826,9 +718,7 @@ pub async fn travel_plan_execute(params: serde_json::Value) -> SkillResult {
tracing::info!(" 找到 {} 个 {}", pois.len(), interest);
}
}
Err(e) => {
tracing::warn!(" 搜索 {} 失败: {}", interest, e);
}
Err(e) => tracing::warn!(" 搜索 {} 失败: {}", interest, e),
}
}
@@ -848,10 +738,7 @@ pub async fn travel_plan_execute(params: serde_json::Value) -> SkillResult {
.split(',')
.filter_map(|s| s.parse().ok())
.collect();
let end_parts: Vec<f64> = end_loc
.split(',')
.filter_map(|s| s.parse().ok())
.collect();
let end_parts: Vec<f64> = end_loc.split(',').filter_map(|s| s.parse().ok()).collect();
let start_name = start["name"].as_str().unwrap_or("?");
let end_name = end["name"].as_str().unwrap_or("?");
@@ -865,43 +752,41 @@ pub async fn travel_plan_execute(params: serde_json::Value) -> SkillResult {
});
if route_type == "transit" {
route_task["city"] = serde_json::json!(city);
route_task["city1"] = serde_json::json!(city);
route_task["city2"] = serde_json::json!(city);
}
map_tasks.push(route_task);
}
}
// 第三步:生成地图可视化链接
let map_link = generate_map_link(&map_tasks);
let route_type_cn = match route_type {
"walking" => "步行",
"driving" => "驾车",
"riding" => "骑行",
"electrobike" => "电动车骑行",
"transit" => "公交",
_ => route_type,
};
let poi_names: Vec<&str> = all_pois
.iter()
.filter_map(|p| p["name"].as_str())
.collect();
let poi_names: Vec<&str> = all_pois.iter().filter_map(|p| p["name"].as_str()).collect();
let text = format!(
"🗺️ {city} 旅游规划完成({route_type_cn}\n\
📍 推荐地点({} 个):{} \n\
🔗 地图链接:{map_link}",
"🗺️ {city} 旅游规划完成({route_type_cn}\n📍 推荐地点({} 个):{}\n🔗 地图链接:{map_link}",
all_pois.len(),
poi_names.join("")
);
let output = serde_json::json!({
"city": city,
"route_type": route_type,
"poi_count": all_pois.len(),
"pois": all_pois,
"route_count": map_tasks.iter().filter(|t| t["type"] == "route").count(),
"map_tasks": map_tasks,
"map_link": map_link,
"route_type": route_type,
});
SkillResult::ok_with_data(text, output)
@@ -921,7 +806,7 @@ pub fn map_link_spec() -> SkillSpec {
parameters: serde_json::json!({
"type": "object",
"properties": {
"map_data": {"type": "array", "description": "地图数据数组,每项格式:{\"type\":\"poi\",\"lnglat\":[lng,lat],\"sort\":\"分类\",\"text\":\"名称\",\"remark\":\"备注\"} 或 {\"type\":\"route\",\"routeType\":\"walking\",\"start\":[lng,lat],\"end\":[lng,lat],\"remark\":\"描述\"}"}
"map_data": {"type": "array", "description": "地图数据数组"}
},
"required": ["map_data"]
}),
@@ -949,52 +834,51 @@ pub async fn map_link_execute(params: serde_json::Value) -> SkillResult {
SkillResult::ok_with_data(text, output)
}
/// 生成高德地图可视化链接
fn generate_map_link(map_task_data: &[serde_json::Value]) -> String {
let base_url = "https://a.amap.com/jsapi_demo_show/static/openclaw/travel_plan.html";
let data_str = serde_json::to_string(map_task_data).unwrap_or_default();
let encoded = urlencoding(&data_str); // 手动 URL 编码(只编码特殊字符)
let encoded = urlencoding(&data_str);
format!("{base_url}?data={encoded}")
}
/// 简易 URL 编码(仅编码必要的特殊字符)
fn urlencoding(s: &str) -> String {
s.chars()
.map(|c| match c {
' ' => "%20".to_string(),
':' => "%3A".to_string(),
'/' => "%2F".to_string(),
'?' => "%3F".to_string(),
'#' => "%23".to_string(),
'[' => "%5B".to_string(),
']' => "%5D".to_string(),
'@' => "%40".to_string(),
'!' => "%21".to_string(),
'$' => "%24".to_string(),
'&' => "%26".to_string(),
'\'' => "%27".to_string(),
'(' => "%28".to_string(),
')' => "%29".to_string(),
'*' => "%2A".to_string(),
'+' => "%2B".to_string(),
',' => "%2C".to_string(),
';' => "%3B".to_string(),
'=' => "%3D".to_string(),
'%' => "%25".to_string(),
'{' => "%7B".to_string(),
'}' => "%7D".to_string(),
'"' => "%22".to_string(),
'\\' => "%5C".to_string(),
other => other.to_string(),
})
.collect()
let mut out = String::with_capacity(s.len() * 3);
for c in s.chars() {
match c {
' ' => out.push_str("%20"),
':' => out.push_str("%3A"),
'/' => out.push_str("%2F"),
'?' => out.push_str("%3F"),
'#' => out.push_str("%23"),
'[' => out.push_str("%5B"),
']' => out.push_str("%5D"),
'@' => out.push_str("%40"),
'!' => out.push_str("%21"),
'$' => out.push_str("%24"),
'&' => out.push_str("%26"),
'\'' => out.push_str("%27"),
'(' => out.push_str("%28"),
')' => out.push_str("%29"),
'*' => out.push_str("%2A"),
'+' => out.push_str("%2B"),
',' => out.push_str("%2C"),
';' => out.push_str("%3B"),
'=' => out.push_str("%3D"),
'%' => out.push_str("%25"),
'{' => out.push_str("%7B"),
'}' => out.push_str("%7D"),
'"' => out.push_str("%22"),
'\\' => out.push_str("%5C"),
other => out.push(other),
}
}
out
}
// ═══════════════════════════════════════════════
// 统一分发入口
// ═══════════════════════════════════════════════
/// 所有 amap 工具的 spec 列表
pub fn specs() -> Vec<SkillSpec> {
vec![
poi_search_spec(),
@@ -1006,7 +890,6 @@ pub fn specs() -> Vec<SkillSpec> {
]
}
/// 根据名称分发执行
pub async fn execute(name: &str, params: serde_json::Value) -> Option<SkillResult> {
match name {
"amap_poi_search" => Some(poi_search_execute(params).await),
@@ -1030,67 +913,24 @@ mod tests {
#[test]
fn test_url_encoding() {
let encoded = urlencoding("https://example.com/path?q=hello world");
// 不应该有冒号、斜杠等特殊字符被漏掉
assert!(!encoded.contains("://"));
assert!(encoded.contains("%3A%2F%2F"));
assert!(encoded.contains("%20"));
}
#[test]
fn test_generate_map_link() {
let data = vec![
serde_json::json!({
"type": "poi",
"lnglat": [116.397428, 39.90923],
"sort": "风景名胜",
"text": "故宫博物院",
"remark": "明清皇家宫殿"
}),
serde_json::json!({
"type": "route",
"routeType": "walking",
"start": [116.397428, 39.90923],
"end": [116.427281, 39.903719],
"remark": "步行路线"
}),
];
let link = generate_map_link(&data);
assert!(link.starts_with("https://a.amap.com/jsapi_demo_show/static/openclaw/travel_plan.html?data="));
assert!(link.contains("walking"));
}
#[test]
fn test_route_summary_walking() {
let data = serde_json::json!({
"route": {
"paths": [{
"distance": "1500",
"duration": "1200"
}]
}
});
let summary = route_summary(&data, "walking");
assert!(summary.contains("1.50"));
assert!(summary.contains("20"));
}
#[test]
fn test_specs_count() {
let s = specs();
assert_eq!(s.len(), 6);
// 验证每个都有非空 name 和 description
for spec in &s {
assert!(!spec.name.is_empty());
assert!(!spec.description.is_empty());
}
}
/// 端到端测试(需要 AMAP_WEBSERVICE_KEY 环境变量)
#[tokio::test]
async fn test_poi_search_integration() {
if std::env::var("AMAP_WEBSERVICE_KEY").is_err()
&& std::env::var("AMAP_KEY").is_err()
{
if std::env::var("AMAP_WEBSERVICE_KEY").is_err() && std::env::var("AMAP_KEY").is_err() {
eprintln!("跳过集成测试: 未设置 AMAP_WEBSERVICE_KEY");
return;
}
@@ -1101,14 +941,11 @@ mod tests {
#[tokio::test]
async fn test_geocode_integration() {
if std::env::var("AMAP_WEBSERVICE_KEY").is_err()
&& std::env::var("AMAP_KEY").is_err()
{
if std::env::var("AMAP_WEBSERVICE_KEY").is_err() && std::env::var("AMAP_KEY").is_err() {
eprintln!("跳过集成测试: 未设置 AMAP_WEBSERVICE_KEY");
return;
}
let result =
geocode_execute(serde_json::json!({"address": "天安门"})).await;
let result = geocode_execute(serde_json::json!({"address": "天安门"})).await;
assert!(result.success, "地理编码失败: {}", result.output);
let data = result.data.unwrap();
assert!(data["location"].as_str().is_some());
+9 -13
View File
@@ -136,20 +136,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 +237,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 +278,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 +308,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()
}
+1
View File
@@ -2,5 +2,6 @@ pub mod amap;
pub mod datetime;
pub mod fetch_page;
pub mod memos;
pub mod subagent;
pub mod weather;
pub mod web_search;
+228
View File
@@ -0,0 +1,228 @@
//! Pi 子代理工具 — 调用独立 pi 实例执行任务
//!
//! 通过 JSON 事件流模式启动一个独立的 pi 进程,传入 prompt 并收集流式回复。
//! 用于将复杂子任务委派给独立 agent,隔离上下文。
//!
//! 协议:`pi --mode json --no-session "prompt"` → stdout JSON 事件流
use super::super::types::{RiskLevel, SkillResult, SkillSpec};
use serde::Deserialize;
use std::time::Duration;
/// 默认超时(秒)
const DEFAULT_TIMEOUT: u64 = 120;
/// 最大回复长度
const MAX_REPLY_CHARS: usize = 8000;
// ─── Spec ───
pub fn spec() -> SkillSpec {
SkillSpec {
name: "pi_subagent".into(),
description: "启动独立的 pi agent 子进程处理复杂子任务。传入 prompt,返回 agent 完整回复。适合将复杂分析、代码生成等任务委派给独立上下文执行。".into(),
risk_level: RiskLevel::High,
parameters: serde_json::json!({
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "发送给子 agent 的完整 prompt"
},
"timeout_secs": {
"type": "integer",
"description": "超时秒数(1-300,默认 120"
}
},
"required": ["prompt"]
}),
timeout_secs: DEFAULT_TIMEOUT,
}
}
// ─── JSON 事件流类型 ───
/// 事件顶层结构:根据 type 字段区分
#[derive(Deserialize, Debug)]
#[allow(dead_code)]
struct JsonEvent {
#[serde(rename = "type")]
event_type: String,
#[serde(default)]
message: Option<EventMessage>,
#[serde(default)]
#[serde(rename = "assistantMessageEvent")]
assistant_message_event: Option<AssistantMessageEvent>,
}
#[derive(Deserialize, Debug)]
#[allow(dead_code)]
struct EventMessage {
#[serde(default)]
content: Option<serde_json::Value>,
}
/// message_update 中的嵌套增量事件
#[derive(Deserialize, Debug)]
struct AssistantMessageEvent {
#[serde(rename = "type")]
delta_type: Option<String>,
#[serde(default)]
delta: Option<String>,
#[serde(default)]
content: Option<String>,
}
// ─── 执行 ───
pub async fn execute(params: serde_json::Value) -> SkillResult {
let prompt = params
.get("prompt")
.and_then(|v| v.as_str())
.unwrap_or("");
let timeout_secs = params
.get("timeout_secs")
.and_then(|v| v.as_u64())
.unwrap_or(DEFAULT_TIMEOUT)
.clamp(1, 300); // 限制在 1~300 秒
if prompt.is_empty() {
return SkillResult::error("缺少 prompt 参数");
}
match run_subagent(prompt, timeout_secs).await {
Ok(text) => SkillResult::ok(text),
Err(e) => SkillResult::error(format!("pi 子代理执行失败: {}", e)),
}
}
async fn run_subagent(prompt: &str, timeout_secs: u64) -> Result<String, String> {
let pi_exe = find_pi_exe()?;
let mut cmd = tokio::process::Command::new(&pi_exe);
cmd.args(["--mode", "json", "--no-session", prompt])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null()) // 不读 stderr,避免管道满阻塞
.kill_on_drop(true);
let mut child = cmd
.spawn()
.map_err(|e| format!("启动 pi 进程失败: {}", e))?;
let stdout = child
.stdout
.take()
.ok_or("无法获取 pi stdout")?;
use tokio::io::{AsyncBufReadExt, BufReader};
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
let mut reply = String::new();
let mut done = false;
let timeout = Duration::from_secs(timeout_secs);
let start = std::time::Instant::now();
while !done {
// 检查超时
if start.elapsed() > timeout {
child.kill().await.ok();
return Err("子代理执行超时".to_string());
}
let line = match tokio::time::timeout(Duration::from_secs(10), lines.next_line()).await {
Ok(Ok(Some(l))) => l,
Ok(Ok(None)) => break, // EOF
Ok(Err(e)) => return Err(format!("读取 pi 输出失败: {}", e)),
Err(_) => continue, // 读超时,继续循环
};
if line.trim().is_empty() {
continue;
}
let event: JsonEvent = match serde_json::from_str(&line) {
Ok(e) => e,
Err(_) => continue, // 跳过无法解析的行
};
match event.event_type.as_str() {
"session" => {
// 首行:会话头部,跳过
}
"message_update" => {
if let Some(delta_ev) = event.assistant_message_event {
match delta_ev.delta_type.as_deref() {
Some("text_delta") => {
if let Some(ref delta) = delta_ev.delta {
reply.push_str(delta);
}
}
Some("text_end") => {
if let Some(ref content) = delta_ev.content {
reply = content.clone(); // 完整内容覆盖增量
}
}
_ => {}
}
}
}
"agent_end" => {
done = true;
}
_ => {}
}
}
// 确保子进程退出
let _ = child.wait().await;
if reply.trim().is_empty() {
return Err("pi 子代理返回空回复".to_string());
}
// 截断过长回复
if reply.chars().count() > MAX_REPLY_CHARS {
reply = format!(
"{}…(已截断,完整输出 {} 字符)",
reply.chars().take(MAX_REPLY_CHARS).collect::<String>(),
reply.chars().count()
);
}
Ok(reply)
}
/// 查找 pi 可执行文件路径
fn find_pi_exe() -> Result<String, String> {
// 1. 检查环境变量 PI_EXE
if let Ok(path) = std::env::var("PI_EXE")
&& std::path::Path::new(&path).exists() {
return Ok(path);
}
// 2. 在 PATH 中搜索
if let Ok(path_var) = std::env::var("PATH") {
for dir in path_var.split(':') {
let candidate = std::path::PathBuf::from(dir).join("pi");
if candidate.exists() {
return Ok(candidate.to_string_lossy().to_string());
}
}
}
// 3. 检查常见全局路径
let candidates = [
"/home/xiao/.npm/bin/pi",
"/usr/local/bin/pi",
"/usr/bin/pi",
];
for c in &candidates {
if std::path::Path::new(c).exists() {
return Ok(c.to_string());
}
}
Err("未找到 pi 可执行文件。请设置 PI_EXE 环境变量或确保 pi 在 PATH 中。".to_string())
}
+2 -4
View File
@@ -474,11 +474,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 +599,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()));
}
}
}
+2 -3
View File
@@ -305,11 +305,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() {
+333
View File
@@ -0,0 +1,333 @@
//! 统一工具执行器构建器
//!
//! 消除 main.rs / worker.rs / daemon.rs 中三处重复的 ToolExecutor 构建逻辑。
//! 支持两种模式:
//! - Listen 模式:有 ApprovalManager + WechatSender + DB
//! - Worker 模式:无 DB 连接,使用本地缓存(memories/summaries
use crate::core::memory::MemoryStore;
use crate::core::session::ChatSession;
use crate::llm::conversation::ToolExecutor;
use crate::tools::approval::ApprovalManager;
use crate::tools::types::{SkillResult, WechatSender};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
/// 执行器构建器
pub struct ToolExecutorBuilder {
/// 审批管理器(Listen 模式)
approval: Option<Arc<ApprovalManager>>,
/// 微信发送器(Listen 模式)
send_wechat: Option<WechatSender>,
/// 会话引用
session: Option<Arc<Mutex<ChatSession>>>,
/// 记忆管理器
memory_store: Option<Arc<MemoryStore>>,
/// Worker 模式:已批准的工具名
approved_tool: Option<String>,
/// Worker 模式:预载记忆
memories_cache: HashMap<String, Vec<String>>,
/// Worker 模式:预载摘要
summaries_cache: Vec<String>,
/// Worker 模式:待发送的审批请求
pending_approval: Option<Arc<Mutex<Option<(String, String, String)>>>>,
/// Worker 模式:新写入的记忆
new_memories: Option<Arc<Mutex<Vec<String>>>>,
/// 当前用户 ID
user_id: String,
}
impl ToolExecutorBuilder {
pub fn new(user_id: impl Into<String>) -> Self {
Self {
approval: None,
send_wechat: None,
session: None,
memory_store: None,
approved_tool: None,
memories_cache: HashMap::new(),
summaries_cache: Vec::new(),
pending_approval: None,
new_memories: None,
user_id: user_id.into(),
}
}
pub fn with_approval(mut self, mgr: Arc<ApprovalManager>) -> Self {
self.approval = Some(mgr);
self
}
pub fn with_wechat(mut self, sender: WechatSender) -> Self {
self.send_wechat = Some(sender);
self
}
pub fn with_session(mut self, session: Arc<Mutex<ChatSession>>) -> Self {
self.session = Some(session);
self
}
pub fn with_memory(mut self, store: Arc<MemoryStore>) -> Self {
self.memory_store = Some(store);
self
}
/// Worker 模式:注入预载上下文
pub fn with_worker_context(
mut self,
approved_tool: Option<String>,
memories: HashMap<String, Vec<String>>,
summaries: Vec<String>,
pending_approval: Arc<Mutex<Option<(String, String, String)>>>,
new_memories: Arc<Mutex<Vec<String>>>,
) -> Self {
self.approved_tool = approved_tool;
self.memories_cache = memories;
self.summaries_cache = summaries;
self.pending_approval = Some(pending_approval);
self.new_memories = Some(new_memories);
self
}
/// 构建最终 ToolExecutor
pub fn build(self) -> ToolExecutor {
let approval = self.approval;
let send_wechat = self.send_wechat;
let session = self.session;
let memory_store = self.memory_store;
let approved_tool = self.approved_tool;
let memories_cache = self.memories_cache;
let summaries_cache = self.summaries_cache;
let pending_approval = self.pending_approval;
let new_memories = self.new_memories;
let user_id = self.user_id;
let is_worker = pending_approval.is_some();
Arc::new(move |name: &str, args_json: &str| {
let approval = approval.clone();
let send_wechat = send_wechat.clone();
let session = session.clone();
let memory_store = memory_store.clone();
let approved_tool = approved_tool.clone();
let memories_cache = memories_cache.clone();
let summaries_cache = summaries_cache.clone();
let pending_approval = pending_approval.clone();
let new_memories = new_memories.clone();
let user_id = user_id.clone();
let is_worker_flag = is_worker;
let n = name.to_string();
let args = args_json.to_string();
Box::pin(async move {
// listen 模式下从 session 动态获取当前用户
let effective_user = if is_worker_flag {
user_id.clone()
} else if let Some(ref s) = session {
s.lock().await.current_user_id.clone()
} else {
user_id.clone()
};
// ── 上下文工具:read_memories ──
if n == "read_memories" {
return if is_worker_flag {
read_worker_memories(&effective_user, &memories_cache).await
} else if let Some(store) = &memory_store {
Ok(store.read_for(&effective_user).await)
} else {
Ok("暂无长期记忆".to_string())
};
}
// ── 上下文工具:write_memory ──
if n == "write_memory" {
let params: serde_json::Value =
serde_json::from_str(&args).unwrap_or_default();
let content =
params.get("content").and_then(|v| v.as_str()).unwrap_or("");
if content.is_empty() {
return Ok("请提供 content 参数".to_string());
}
if let Some(ref new_mems) = new_memories {
new_mems.lock().await.push(content.to_string());
}
if let Some(store) = &memory_store {
return Ok(store.write_for(&effective_user, content).await);
}
return Ok(format!("已添加长期记忆: {}", content));
}
// ── 上下文工具:read_summaries ──
if n == "read_summaries" {
if is_worker_flag {
return read_worker_summaries(&summaries_cache).await;
}
if let Some(ref s) = session {
return Ok(crate::core::memory::read_summaries(s).await);
}
return Ok("暂无历史摘要".to_string());
}
// ── query_capabilities ──
if n == "query_capabilities" {
let specs = crate::tools::registry::BuiltinRegistry::specs();
return Ok(crate::tools::build_capability_guide(&specs));
}
// ── 解析目标工具名和参数 ──
let (target_name, target_args) = if n == "call_capability" {
let cp: serde_json::Value =
serde_json::from_str(&args).unwrap_or_default();
let name = cp
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let unpacked = crate::tools::unpack_call_params(&cp);
(name, serde_json::to_string(&unpacked).unwrap_or_default())
} else {
(n.clone(), args.clone())
};
if target_name.is_empty() {
return Ok(format!("未知工具: {}", n));
}
// ── 高风险工具审批 ──
if crate::tools::registry::BuiltinRegistry::is_high_risk(&target_name) {
if is_worker_flag {
// Worker 模式:已批准则放行一次,否则请求审批
let is_approved = approved_tool.as_deref() == Some(&target_name);
if !is_approved {
if let Some(ref pa) = pending_approval {
let reason = format!(
"LLM 请求执行高风险工具: {}",
target_name
);
*pa.lock().await =
Some((target_name.clone(), target_args.clone(), reason));
}
return Ok(SkillResult::rejected(&target_name).output);
}
} else if let Some(ref mgr) = approval {
// Listen 模式:通过 ApprovalManager 处理
let approved = listen_approve(
mgr,
&effective_user,
&target_name,
send_wechat.as_ref(),
)
.await;
match approved {
Ok(true) => {}
Ok(false) => {
return Ok(SkillResult::rejected(&target_name).output)
}
Err(e) => return Ok(e),
}
}
}
// ── 执行内置工具 ──
if let Some(result) =
crate::tools::registry::BuiltinRegistry::execute(
&target_name,
&target_args,
)
.await
{
// manage_memos add 同步到记忆缓存
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")
&& let Some(content) =
cp.get("content").and_then(|v| v.as_str())
{
if let Some(ref new_mems) = new_memories {
new_mems.lock().await.push(content.to_string());
}
if let Some(store) = &memory_store {
store.write_for(&effective_user, content).await;
}
}
}
return Ok(result.output);
}
Ok(format!("未知工具: {}", target_name))
})
})
}
}
/// Worker 模式:读取记忆缓存
async fn read_worker_memories(
user_id: &str,
cache: &HashMap<String, Vec<String>>,
) -> Result<String, String> {
match cache.get(user_id) {
Some(m) if !m.is_empty() => {
let lines: Vec<String> = m
.iter()
.enumerate()
.map(|(i, v)| format!("{}. {}", i + 1, v))
.collect();
Ok(lines.join("\n"))
}
_ => Ok("暂无长期记忆".to_string()),
}
}
/// Worker 模式:读取摘要缓存
async fn read_worker_summaries(summaries: &[String]) -> Result<String, String> {
if summaries.is_empty() {
Ok("暂无历史摘要".to_string())
} else {
Ok(summaries
.iter()
.enumerate()
.map(|(i, s)| format!("{}. {}", i + 1, s))
.collect::<Vec<_>>()
.join("\n---\n"))
}
}
/// Listen 模式:通过 ApprovalManager 处理高风险工具审批
///
/// 注意:listen 模式共享全局会话锁,审批期间所有用户消息都会排队。
/// 生产环境建议使用 daemon 模式(`ias daemon`),审批不阻塞其他用户。
async fn listen_approve(
mgr: &Arc<ApprovalManager>,
user_id: &str,
tool_name: &str,
send_wechat: Option<&WechatSender>,
) -> Result<bool, String> {
use crate::tools::approval::ApprovalDecision;
use sha2::{Digest, Sha256};
let (code, rx) = mgr.create(user_id, tool_name).await?;
let code_hash = format!("{:x}", Sha256::digest(code.as_bytes()));
let msg = format!(
"⚠️ 操作确认\n\n技能:{}\n\n确认码:{}\n\n回复确认码以继续\n回复 0 或「取消」以取消\n(60秒内有效,最多3次尝试)",
tool_name, code
);
if let Some(send) = send_wechat {
send(user_id, &msg).await?
}
// listen 模式使用较短超时(60s),避免长时间持有全局会话锁
match tokio::time::timeout(std::time::Duration::from_secs(60), rx).await {
Ok(Ok(ApprovalDecision::Approved)) => Ok(true),
Ok(_) => Ok(false), // Rejected 或 sender dropped
Err(_elapsed) => {
// 超时:精确取消该条审批,防止迟到确认码被误吞 + 同步 DB
mgr.cancel_by_code(user_id, &code_hash).await;
Ok(false)
}
}
}
+189 -5
View File
@@ -1,6 +1,7 @@
pub mod approval;
pub mod builtin;
pub mod builtins;
pub mod executor;
pub mod registry;
pub mod types;
/// 从 call_capability 的 `{name, prompt}` JSON 中提取目标工具的真实参数。
@@ -16,11 +17,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());
@@ -75,6 +75,7 @@ mod tests {
}
}
use std::collections::HashMap;
use types::SkillSpec;
/// 构建工具调用指南(含优先级说明)
@@ -124,6 +125,189 @@ pub fn build_capability_guide(specs: &[SkillSpec]) -> String {
lines.join("\n")
}
/// 构建所有 LLM 可见工具的 JSON 定义列表
/// 供 main.rs cmd_listen 和 daemon.rs 共用
pub fn build_tools_list() -> Vec<serde_json::Value> {
let mut list = Vec::new();
// query_capabilities
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "query_capabilities",
"description": "列出所有可用工具的详细说明、参数格式和调用示例。在需要了解有哪些工具可用时首先调用此工具。",
"parameters": { "type": "object", "properties": {} }
}
}));
// call_capability
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "call_capability",
"description": "调用一个已注册的工具。name 为工具名(来自 query_capabilities),工具所需的具体参数应直接放在 JSON 中。",
"parameters": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "工具名称" },
"prompt": { "type": "string", "description": "json格式参数" }
},
"required": ["name"]
}
}
}));
// 上下文工具
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "read_memories",
"description": "读取用户的长期记忆(偏好、个人信息、约定)",
"parameters": { "type": "object", "properties": {} }
}
}));
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "write_memory",
"description": "记录用户的重要信息或偏好",
"parameters": {
"type": "object",
"properties": {
"content": { "type": "string", "description": "记忆内容" }
},
"required": ["content"]
}
}
}));
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "read_summaries",
"description": "读取历史会话摘要",
"parameters": { "type": "object", "properties": {} }
}
}));
// 业务工具
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "get_current_datetime",
"description": "获取当前日期时间(北京时间 UTC+8)",
"parameters": { "type": "object", "properties": {"format": {"type": "string"}} }
}
}));
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "manage_memos",
"description": "管理备忘录:添加/列出/删除",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["add", "list", "delete"]},
"content": {"type": "string"},
"id": {"type": "integer"}
}
}
}
}));
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "query_weather",
"description": "查询指定城市的天气信息",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "城市名称"},
"days": {"type": "integer", "description": "查询天数(1-7"}
},
"required": ["location"]
}
}
}));
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "web_search",
"description": "联网搜索最新信息。通过 Tavily API 搜索互联网,获取实时、准确的结果。适用于需要最新资讯、事实查询的场景。",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "搜索关键词"},
"max_results": {"type": "integer", "description": "最大结果数(1-20,默认5"},
"include_answer": {"type": "boolean", "description": "是否包含 AI 生成的摘要回答"},
"search_depth": {"type": "string", "enum": ["basic", "advanced", "fast", "ultra-fast"], "description": "搜索深度"},
"topic": {"type": "string", "enum": ["general", "news", "finance"], "description": "搜索主题"},
"time_range": {"type": "string", "enum": ["day", "week", "month", "year"], "description": "按发布时间过滤"},
"start_date": {"type": "string", "description": "起始日期 YYYY-MM-DD"},
"end_date": {"type": "string", "description": "结束日期 YYYY-MM-DD"},
"include_domains": {"type": "array", "items": {"type": "string"}, "description": "限定域名列表"},
"exclude_domains": {"type": "array", "items": {"type": "string"}, "description": "排除域名列表"},
"country": {"type": "string", "description": "优先指定国家"}
},
"required": ["query"]
}
}
}));
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "fetch_page",
"description": "抓取网页并提取正文内容。传入 URL 即可阅读文章正文。可通过 ~/.ias/site_selectors.json 配置按站点用 CSS 选择器精确定位内容区域。",
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "网页 URL"}
},
"required": ["url"]
}
}
}));
// pi 子代理
list.push(serde_json::json!({
"type": "function",
"function": {
"name": "pi_subagent",
"description": "启动独立的 pi agent 子进程处理复杂子任务。传入 prompt 让子 agent 独立执行,返回完整回复。适合将复杂分析、代码生成、多步推理等任务委派给独立 agent。",
"parameters": {
"type": "object",
"properties": {
"prompt": {"type": "string", "description": "发送给子 agent 的完整 prompt"},
"timeout_secs": {"type": "integer", "description": "超时秒数(1-300,默认 120"}
},
"required": ["prompt"]
}
}
}));
list
}
/// 构建传递给 Worker 的环境变量映射
/// 供 daemon.rs 使用
pub fn build_env_map() -> HashMap<String, String> {
let mut map = HashMap::new();
for var in &[
"DEEPSEEK_API_KEY",
"DEEPSEEK_MODEL",
"DEEPSEEK_BASE_URL",
"QWEATHER_API_HOST",
"QWEATHER_JWT_KEY_ID",
"QWEATHER_JWT_PROJECT_ID",
"QWEATHER_JWT_PRIVATE_KEY_FILE",
"TAVILY_API_KEY",
] {
if let Ok(val) = std::env::var(var) {
map.insert(var.to_string(), val);
}
}
map
}
fn format_spec(lines: &mut Vec<String>, spec: &SkillSpec) {
let risk = if spec.risk_level == crate::tools::types::RiskLevel::High {
" ⚠️需确认"
@@ -138,7 +322,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!(
" 参数: {}",
@@ -23,6 +23,11 @@ impl BuiltinRegistry {
serde_json::from_str(args_json).unwrap_or_default();
Some(super::builtins::fetch_page::execute(params).await)
}
"pi_subagent" => {
let params: serde_json::Value =
serde_json::from_str(args_json).unwrap_or_default();
Some(super::builtins::subagent::execute(params).await)
}
n if n.starts_with("amap_") => {
let params: serde_json::Value =
serde_json::from_str(args_json).unwrap_or_default();
@@ -40,6 +45,7 @@ impl BuiltinRegistry {
super::builtins::weather::spec(),
super::builtins::web_search::spec(),
super::builtins::fetch_page::spec(),
super::builtins::subagent::spec(),
];
specs.extend(super::builtins::amap::specs());
specs
@@ -51,6 +57,7 @@ impl BuiltinRegistry {
.any(|s| s.name == name && s.risk_level == RiskLevel::High)
}
#[allow(dead_code)]
pub fn is_builtin(name: &str) -> bool {
Self::specs().iter().any(|s| s.name == name)
}
+2
View File
@@ -95,12 +95,14 @@ pub type WechatSender = Arc<
dyn Fn(&str, &str) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>> + Send + Sync,
>;
#[allow(dead_code)]
pub struct ExecutionContext {
pub user_id: String,
pub approval_manager: Option<Arc<crate::tools::approval::ApprovalManager>>,
pub send_wechat: Option<WechatSender>,
}
#[allow(dead_code)]
impl ExecutionContext {
pub fn new(user_id: impl Into<String>) -> Self {
Self {
+62 -147
View File
@@ -9,7 +9,7 @@
use crate::ipc::{OutputFrame, TaskFrame, recv_task, send_frame};
use crate::llm::{Conversation, ConversationConfig, DEFAULT_SYSTEM_PROMPT};
use crate::tools::types::SkillResult;
use crate::tools::executor::ToolExecutorBuilder;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::net::UnixStream;
@@ -17,18 +17,10 @@ use tracing::info;
/// Worker 与工具执行器之间的共享状态
struct WorkerShared {
/// 待发送的审批请求 (tool, reason)
pending_approval: tokio::sync::Mutex<Option<(String, String)>>,
/// 本次对话中已由用户批准的工具名(避免重复审批)
approved_tool: tokio::sync::Mutex<Option<String>>,
/// 本地记忆缓存
memories: tokio::sync::Mutex<HashMap<String, Vec<String>>>,
/// 待发送的审批请求 (tool, args, reason)
pending_approval: Arc<tokio::sync::Mutex<Option<(String, String, String)>>>,
/// 本次写入的新记忆(退出前发送 DbWrite 帧同步到 daemon
new_memories: tokio::sync::Mutex<Vec<String>>,
/// 预载的摘要
summaries: Vec<String>,
/// 用户 ID
user_id: String,
new_memories: Arc<tokio::sync::Mutex<Vec<String>>>,
}
/// Worker 入口: 连接 daemon,处理一条消息后退出
@@ -87,21 +79,58 @@ pub async fn run(sock_path: &str) -> Result<(), String> {
// 5. 共享状态
let approved_tool = task.approved_tool.clone().filter(|t| !t.is_empty());
let shared = Arc::new(WorkerShared {
pending_approval: tokio::sync::Mutex::new(None),
approved_tool: tokio::sync::Mutex::new(approved_tool),
memories: tokio::sync::Mutex::new(HashMap::from([(
task.user_id.clone(),
task.memories.clone(),
)])),
new_memories: tokio::sync::Mutex::new(Vec::new()),
summaries: task.summaries.clone(),
user_id: task.user_id.clone(),
pending_approval: Arc::new(tokio::sync::Mutex::new(None)),
new_memories: Arc::new(tokio::sync::Mutex::new(Vec::new())),
});
// 6. 构建工具执行器
let executor = build_worker_executor(conv.session(), shared.clone());
// 6. 构建工具执行器(统一使用 ToolExecutorBuilder
// 注意:若审批已通过,approved_tool 不传给 executor(已在 6b 直接执行)
let pending_approval = Arc::clone(&shared.pending_approval);
let new_memories = Arc::clone(&shared.new_memories);
let has_approved = task.approved_tool.as_deref().filter(|t| !t.is_empty()).is_some()
&& task.approved_tool_args.as_deref().filter(|a| !a.is_empty()).is_some();
let executor = ToolExecutorBuilder::new(task.user_id.clone())
.with_worker_context(
if has_approved { None } else { approved_tool },
HashMap::from([(task.user_id.clone(), task.memories.clone())]),
task.summaries.clone(),
pending_approval,
new_memories,
)
.build();
conv.set_tool_executor(executor);
// 6b. 若审批已通过且带有参数,直接执行工具并注入结果
if let (Some(tool_name), Some(tool_args)) =
(&task.approved_tool, &task.approved_tool_args)
&& !tool_name.is_empty() && !tool_args.is_empty() {
info!("审批通过,直接执行工具: {} args={:.80}", tool_name, tool_args);
let tool_id = format!("approved_{}", uuid::Uuid::new_v4());
// 手动执行工具
let result_text: String = match crate::tools::registry::BuiltinRegistry::execute(
tool_name, tool_args,
)
.await
{
Some(r) => r.output,
None => format!("未知工具: {}", tool_name),
};
// 注入到 session:先加 assistant tool_call,再加 tool_result
{
let session = conv.session();
let mut s = session.lock().await;
s.add_assistant_tool_calls(vec![crate::llm::types::ToolCall {
id: tool_id.clone(),
call_type: "function".to_string(),
function: crate::llm::types::ToolFunctionCall {
name: tool_name.clone(),
arguments: tool_args.clone(),
},
}]);
s.add_tool_result(&tool_id, tool_name, &result_text);
}
}
// 7. 执行 LLM 对话(带审批检测)
let (reply, _used_tools, usage) = conv
.chat_with_tools(task.msg.text.clone())
@@ -110,9 +139,17 @@ pub async fn run(sock_path: &str) -> Result<(), String> {
// 8. 检查是否有审批请求(由工具执行器设置)
let approval_req = shared.pending_approval.lock().await.take();
if let Some((tool, reason)) = approval_req {
if let Some((tool, tool_args, reason)) = approval_req {
info!("Worker 发送审批请求: tool={}", tool);
let _ = send_frame(&mut stream, &OutputFrame::RequestApproval { tool, reason }).await;
let _ = send_frame(
&mut stream,
&OutputFrame::RequestApproval {
tool,
reason,
tool_args,
},
)
.await;
let _ = send_frame(&mut stream, &OutputFrame::Bye).await;
return Ok(());
}
@@ -167,125 +204,3 @@ pub async fn run(sock_path: &str) -> Result<(), String> {
Ok(())
}
/// 构建 Worker 侧的工具执行器(无 DB 连接)
fn build_worker_executor(
_session: Arc<tokio::sync::Mutex<crate::context::types::ChatSession>>,
shared: Arc<WorkerShared>,
) -> crate::llm::conversation::ToolExecutor {
Arc::new(move |name: &str, args_json: &str| {
let shared = shared.clone();
let n = name.to_string();
let args = args_json.to_string();
Box::pin(async move {
match n.as_str() {
"read_memories" => {
let cache = shared.memories.lock().await;
let mems = cache.get(&shared.user_id);
match mems {
Some(m) if !m.is_empty() => {
let lines: Vec<String> = m
.iter()
.enumerate()
.map(|(i, v)| format!("{}. {}", i + 1, v))
.collect();
Ok(lines.join("\n"))
}
_ => Ok("暂无长期记忆".to_string()),
}
}
"write_memory" => {
let params: serde_json::Value = serde_json::from_str(&args).unwrap_or_default();
let content = params.get("content").and_then(|v| v.as_str()).unwrap_or("");
if content.is_empty() {
return Ok("请提供 content 参数".to_string());
}
shared
.memories
.lock()
.await
.entry(shared.user_id.clone())
.or_default()
.push(content.to_string());
shared.new_memories.lock().await.push(content.to_string());
Ok(format!("已添加长期记忆: {}", content))
}
"read_summaries" => {
if shared.summaries.is_empty() {
Ok("暂无历史摘要".to_string())
} else {
Ok(shared
.summaries
.iter()
.enumerate()
.map(|(i, s)| format!("{}. {}", i + 1, s))
.collect::<Vec<_>>()
.join("\n---\n"))
}
}
"query_capabilities" => {
let specs = crate::tools::builtin::BuiltinRegistry::specs();
Ok(crate::tools::build_capability_guide(&specs))
}
// call_capability 或直接调用
_ => {
let (target_name, target_args) = if n == "call_capability" {
let cp: serde_json::Value = serde_json::from_str(&args).unwrap_or_default();
let name = cp
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let unpacked = crate::tools::unpack_call_params(&cp);
(name, serde_json::to_string(&unpacked).unwrap_or_default())
} else {
(n.clone(), args.clone())
};
if target_name.is_empty() {
return Ok(format!("未知工具: {}", n));
}
// 高风险工具 → 请求审批(除非已被用户批准)
if crate::tools::builtin::BuiltinRegistry::is_high_risk(&target_name) {
let is_approved = shared.approved_tool.lock().await.as_deref() == Some(&target_name);
if !is_approved {
let reason = format!("LLM 请求执行高风险工具: {}", target_name);
*shared.pending_approval.lock().await = Some((target_name.clone(), reason));
return Ok(SkillResult::rejected(&target_name).output);
}
// 批准过 → 执行一次后清除(同一对话内同一工具不会再被拦截)
*shared.approved_tool.lock().await = None;
}
// 执行内置工具
if let Some(result) =
crate::tools::builtin::BuiltinRegistry::execute(&target_name, &target_args)
.await
{
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()) {
shared
.memories
.lock()
.await
.entry(shared.user_id.clone())
.or_default()
.push(content.to_string());
shared.new_memories.lock().await.push(content.to_string());
}
}
}
return Ok(result.output);
}
Ok(format!("未知工具: {}", target_name))
}
}
})
})
}