Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec42c3dcdb | |||
| a49d8ac423 | |||
| 739f5e9c58 | |||
| af0620aa2d | |||
| 8dc232ebab | |||
| fec50b7be8 |
@@ -26,3 +26,5 @@ qweather/
|
||||
Thumbs.db
|
||||
|
||||
read.md
|
||||
.codegraph/
|
||||
.reasonix/
|
||||
|
||||
+445
@@ -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
|
||||
```
|
||||
@@ -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 ChatSession(checkpoint/messages/summaries)
|
||||
│ ├── builder.rs Token budget 估算 + 上下文构建 + 摘要触发
|
||||
│ └── tools.rs MemoryStore(HashMap<user_id, Vec<mem>>)
|
||||
├── db/ 数据库
|
||||
│ ├── mod.rs Database 连接池 + 迁移
|
||||
│ └── models.rs CRUD(auth/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 Conversation(chat + 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 CRUD(auth/chat/usage/summary)
|
||||
├── daemon.rs 守护进程(持有 WeChat + DB,spawn worker)
|
||||
├── worker.rs Worker 进程(无状态 LLM 执行)
|
||||
└── ipc.rs Unix Domain Socket 帧协议
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)]
|
||||
@@ -1,5 +0,0 @@
|
||||
pub mod builder;
|
||||
pub mod tools;
|
||||
pub mod types;
|
||||
|
||||
pub use tools::MemoryStore;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
"[{}] {} (原因: {})",
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod context;
|
||||
pub mod memory;
|
||||
pub mod pipeline;
|
||||
pub mod session;
|
||||
pub mod summary;
|
||||
|
||||
pub use memory::MemoryStore;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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); // 先取消 active,push_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,
|
||||
@@ -490,7 +515,6 @@ async fn handle_worker(
|
||||
error!("存储 LLM 用量失败: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
OutputFrame::Bye => break,
|
||||
},
|
||||
Err(e) => {
|
||||
@@ -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,
|
||||
@@ -526,7 +550,6 @@ async fn handle_worker(
|
||||
error!("保存聊天记录失败: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => error!("发送回复失败: {}", e),
|
||||
}
|
||||
} else if !chunk_buffer.is_empty() {
|
||||
@@ -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,
|
||||
@@ -554,7 +577,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
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
+19
-13
@@ -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,10 +218,13 @@ 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()))
|
||||
summary_builder::trigger_overflow_summary(
|
||||
&self.session,
|
||||
Some(&self.summarizer()),
|
||||
)
|
||||
.await;
|
||||
tracing::info!("📦 上下文超预算,已触发溢出摘要");
|
||||
}
|
||||
|
||||
+2
-3
@@ -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
@@ -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
@@ -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,24 +134,20 @@ 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
@@ -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
@@ -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,
|
||||
@@ -844,40 +492,10 @@ 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ pub struct RuntimeState {
|
||||
}
|
||||
|
||||
/// 状态管理器
|
||||
#[derive(Clone)]
|
||||
pub struct StateManager {
|
||||
state_dir: PathBuf,
|
||||
}
|
||||
|
||||
+28
-3
@@ -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 被 drop,rx 端收到 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,13 +201,12 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
+203
-366
@@ -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 → 路线
|
||||
// 步行路线: 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(¶ms)
|
||||
.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(¶ms).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(¶ms)
|
||||
.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)
|
||||
}
|
||||
"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)
|
||||
}
|
||||
Ok(data) => SkillResult::ok_with_data("✅ 驾车路线规划完成".to_string(), 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)
|
||||
}
|
||||
"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());
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -474,13 +474,11 @@ 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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,12 +17,11 @@ 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)
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user