diff --git a/.gitignore b/.gitignore index ae839a2..e697f2b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,35 +1,5 @@ -# Rust 构建产物 /target -tools/*/target/ -tools/bin/ -dist/ - -# 运行时数据(状态文件、缓存、聊天记录本地副本等) -.data/ -.pi -# tool_manager 审计日志 -tools/.tool_audit.log - -# 环境配置(含密钥,不入库) +*/target .env -.env.local -.env.*.local - -# 密钥文件 -*.pem -*.key -qweather/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo - -# 操作系统 -.DS_Store -Thumbs.db - -read.md -.codegraph/ -.reasonix/ +.vscode +.agents \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index e5d5527..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,472 +0,0 @@ -# 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"] -``` - -### 5.3.1 `/clear` 清空上下文流程 - -用户发送 `/clear` 指令时,主动截断上下文并把本次会话归档为摘要(按会话过期处理): - -```mermaid -flowchart TB - U(["📱 用户发送 /clear"]) --> K{"is_clear_command() 匹配?"} - K -->|"否"| N(["走正常 LLM 对话流程"]) - K -->|"是"| L["加载历史(过滤掉 /clear 本身)"] - L --> M{"历史非空?"} - M -->|"否"| R2["回复: 上下文已是空的"] - M -->|"是"| S["一次性 Conversation + summarizer"] - S --> T["trigger_clear_summary() → LLM 生成摘要"] - T --> DB[("session_summaries\nreason=clear")] - DB --> D["丢弃该用户在途会话 (sessions.retain)"] - D --> R1["回复确认 + 摘要预览"] - R2 --> END(["📱 用户收到回复"]) - R1 --> END -``` - -- **会话边界**:摘要的 `created_at` 作为边界,之后加载历史时通过 - `latest_session_boundary()` 过滤掉此前的消息(`/clear` 与 12h 空闲超时 - 共用同一套边界机制) -- **注入与否**:`/clear` 摘要**不会**注入下一次对话的 system prompt, - 只存档供 `read_summaries` 工具查询(与空闲超时摘要一致) -- **在途会话**:`/clear` 会丢弃该用户尚未完成的工具往返,避免用旧上下文继续 - ---- - -## 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 -``` \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index d3c40c5..df5845d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "ai" +version = "0.1.0" +dependencies = [ + "chrono", + "common", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "tokio", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -26,73 +39,12 @@ dependencies = [ "libc", ] -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - [[package]] name = "anyhow" version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "atoi" version = "2.0.0" @@ -114,18 +66,64 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bitflags" version = "2.13.0" @@ -144,6 +142,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -158,15 +165,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "cc" -version = "1.2.64" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "shlex", @@ -184,6 +191,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -193,57 +211,24 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", - "serde", "wasm-bindgen", "windows-link", ] [[package]] -name = "clap" -version = "4.6.1" +name = "cmov" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "common" +version = "0.1.0" dependencies = [ - "clap_builder", - "clap_derive", + "serde", + "serde_json", ] -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -255,9 +240,9 @@ dependencies = [ [[package]] name = "const-oid" -version = "0.9.6" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] name = "core-foundation-sys" @@ -274,6 +259,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -324,64 +318,21 @@ dependencies = [ ] [[package]] -name = "cssparser" -version = "0.37.0" +name = "crypto-common" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "phf", - "smallvec", + "hybrid-array", ] [[package]] -name = "cssparser-macros" -version = "0.7.0" +name = "ctutils" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", + "cmov", ] [[package]] @@ -390,58 +341,26 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "syn", -] - [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", "const-oid", - "crypto-common", - "subtle", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -461,51 +380,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "dtoa" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" - -[[package]] -name = "dtoa-short" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" -dependencies = [ - "dtoa", -] - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "serde", - "sha2", - "subtle", - "zeroize", -] - -[[package]] -name = "ego-tree" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" - [[package]] name = "either" version = "1.16.0" @@ -533,13 +407,12 @@ dependencies = [ [[package]] name = "etcetera" -version = "0.8.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "home", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -553,18 +426,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -573,9 +434,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flume" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", @@ -584,9 +445,9 @@ dependencies = [ [[package]] name = "foldhash" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "form_urlencoded" @@ -690,15 +551,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "getopts" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" -dependencies = [ - "unicode-width", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -728,22 +580,21 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", + "rand_core 0.10.1", ] [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", @@ -758,11 +609,11 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -779,39 +630,20 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hkdf" -version = "0.12.4" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ "hmac", ] [[package]] name = "hmac" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest", -] - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "html5ever" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" -dependencies = [ - "log", - "markup5ever", + "digest 0.11.3", ] [[package]] @@ -853,6 +685,21 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.10.1" @@ -866,6 +713,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -886,7 +734,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.7", + "webpki-roots", ] [[package]] @@ -912,35 +760,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "iAs" -version = "0.1.0" -dependencies = [ - "async-trait", - "base64", - "chrono", - "clap", - "dirs", - "dotenvy", - "ed25519-dalek", - "futures-util", - "qrcode", - "rand 0.9.4", - "regex", - "reqwest", - "scraper", - "serde", - "serde_json", - "serde_yaml", - "sha2", - "sqlx", - "tokio", - "tracing", - "tracing-appender", - "tracing-subscriber", - "uuid", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1047,12 +866,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "idna" version = "1.1.0" @@ -1082,8 +895,6 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", - "serde", - "serde_core", ] [[package]] @@ -1092,12 +903,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - [[package]] name = "itoa" version = "1.0.18" @@ -1120,15 +925,6 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" @@ -1136,24 +932,6 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libredox" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" -dependencies = [ - "bitflags", - "libc", - "plain", - "redox_syscall 0.8.1", -] - [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -1181,9 +959,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru-slab" @@ -1191,17 +969,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "markup5ever" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" -dependencies = [ - "log", - "tendril", - "web_atoms", -] - [[package]] name = "matchers" version = "0.2.0" @@ -1212,13 +979,19 @@ dependencies = [ ] [[package]] -name = "md-5" -version = "0.10.6" +name = "matchit" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest", + "digest 0.11.3", ] [[package]] @@ -1227,6 +1000,12 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "mio" version = "1.2.1" @@ -1238,12 +1017,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1253,48 +1026,12 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.6", - "smallvec", - "zeroize", -] - [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -1302,7 +1039,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -1311,18 +1047,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "parking" version = "2.2.1" @@ -1347,118 +1071,29 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "phf" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" -dependencies = [ - "phf_macros", - "phf_shared", - "serde", -] - -[[package]] -name = "phf_codegen" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" -dependencies = [ - "fastrand", - "phf_shared", -] - -[[package]] -name = "phf_macros" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "phf_shared" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" -dependencies = [ - "siphasher", -] - [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "potential_utf" version = "0.1.5" @@ -1483,22 +1118,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -1516,9 +1135,9 @@ checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -1536,9 +1155,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "bytes", "getrandom 0.3.4", @@ -1571,9 +1190,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -1590,35 +1209,25 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha 0.9.0", + "rand_chacha", "rand_core 0.9.5", ] [[package]] -name = "rand_chacha" -version = "0.3.1" +name = "rand" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -1631,15 +1240,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - [[package]] name = "rand_core" version = "0.9.5" @@ -1649,6 +1249,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1658,38 +1264,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_syscall" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" -dependencies = [ - "bitflags", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror", -] - -[[package]] -name = "regex" -version = "1.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - [[package]] name = "regex-automata" version = "0.4.14" @@ -1745,7 +1319,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.7", + "webpki-roots", ] [[package]] @@ -1762,46 +1336,17 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - [[package]] name = "rustc-hash" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "once_cell", "ring", @@ -1850,46 +1395,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "scraper" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" -dependencies = [ - "cssparser", - "ego-tree", - "getopts", - "html5ever", - "precomputed-hash", - "selectors", - "tendril", -] - -[[package]] -name = "selectors" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" -dependencies = [ - "bitflags", - "cssparser", - "derive_more", - "log", - "new_debug_unreachable", - "phf", - "phf_codegen", - "precomputed-hash", - "rustc-hash", - "servo_arc", - "smallvec", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "serde" version = "1.0.228" @@ -1933,6 +1438,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1959,23 +1475,33 @@ dependencies = [ ] [[package]] -name = "servo_arc" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +name = "service" +version = "0.1.0" dependencies = [ - "stable_deref_trait", + "ai", + "anyhow", + "axum", + "common", + "dotenvy", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "sqlx", + "thiserror", + "tokio", + "wechat", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -1985,8 +1511,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -2014,22 +1551,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - -[[package]] -name = "siphasher" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" - [[package]] name = "slab" version = "0.4.12" @@ -2064,21 +1585,11 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "sqlx" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" dependencies = [ "sqlx-core", "sqlx-macros", @@ -2089,12 +1600,13 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ "base64", "bytes", + "cfg-if", "chrono", "crc", "crossbeam-queue", @@ -2104,32 +1616,30 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "hashlink", "indexmap", "log", "memchr", - "once_cell", "percent-encoding", "rustls", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror", "tokio", "tokio-stream", "tracing", "url", - "uuid", - "webpki-roots 0.26.11", + "webpki-roots", ] [[package]] name = "sqlx-macros" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" dependencies = [ "proc-macro2", "quote", @@ -2140,78 +1650,62 @@ dependencies = [ [[package]] name = "sqlx-macros-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" dependencies = [ + "cfg-if", "dotenvy", "either", "heck", "hex", - "once_cell", "proc-macro2", "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", "syn", + "thiserror", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "atoi", - "base64", "bitflags", "byteorder", "bytes", "chrono", "crc", - "digest", + "digest 0.11.3", "dotenvy", "either", - "futures-channel", "futures-core", - "futures-io", "futures-util", "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", "log", - "md-5", - "memchr", - "once_cell", "percent-encoding", - "rand 0.8.6", - "rsa", "serde", "sha1", - "sha2", - "smallvec", + "sha2 0.11.0", "sqlx-core", - "stringprep", "thiserror", "tracing", - "uuid", - "whoami", ] [[package]] name = "sqlx-postgres" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64", @@ -2227,34 +1721,32 @@ dependencies = [ "hex", "hkdf", "hmac", - "home", "itoa", "log", "md-5", "memchr", - "once_cell", - "rand 0.8.6", + "rand 0.10.1", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "smallvec", "sqlx-core", "stringprep", "thiserror", "tracing", - "uuid", "whoami", ] [[package]] name = "sqlx-sqlite" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ "atoi", "chrono", "flume", + "form_urlencoded", "futures-channel", "futures-core", "futures-executor", @@ -2264,12 +1756,10 @@ dependencies = [ "log", "percent-encoding", "serde", - "serde_urlencoded", "sqlx-core", "thiserror", "tracing", "url", - "uuid", ] [[package]] @@ -2278,30 +1768,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "string_cache" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared", - "precomputed-hash", -] - -[[package]] -name = "string_cache_codegen" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", -] - [[package]] name = "stringprep" version = "0.1.5" @@ -2313,12 +1779,6 @@ dependencies = [ "unicode-properties", ] -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - [[package]] name = "subtle" version = "2.6.1" @@ -2362,16 +1822,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tendril" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" -dependencies = [ - "new_debug_unreachable", - "utf-8", -] - [[package]] name = "thiserror" version = "2.0.18" @@ -2403,9 +1853,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.49" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" dependencies = [ "deranged", "num-conv", @@ -2423,9 +1873,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.29" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" dependencies = [ "num-conv", "time-core", @@ -2530,6 +1980,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2676,18 +2127,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -2712,31 +2151,19 @@ dependencies = [ "serde", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "uuid" -version = "1.23.3" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -2781,24 +2208,9 @@ version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", + "wit-bindgen", ] -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - [[package]] name = "wasm-bindgen" version = "0.2.125" @@ -2854,28 +2266,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.4.2" @@ -2889,18 +2279,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" version = "0.3.102" @@ -2921,46 +2299,40 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web_atoms" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" -dependencies = [ - "phf", - "phf_codegen", - "string_cache", - "string_cache_codegen", -] - [[package]] name = "webpki-roots" -version = "0.26.11" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.7", -] - -[[package]] -name = "webpki-roots" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] [[package]] -name = "whoami" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +name = "wechat" +version = "0.1.0" dependencies = [ - "libredox", - "wasite", + "base64", + "qrcode", + "rand 0.10.1", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "sha2 0.11.0", + "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber", + "uuid", ] +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" + [[package]] name = "windows-core" version = "0.62.2" @@ -3020,15 +2392,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -3056,21 +2419,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -3104,12 +2452,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3122,12 +2464,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3140,12 +2476,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3170,12 +2500,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3188,12 +2512,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3206,12 +2524,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3224,12 +2536,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3242,100 +2548,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 8ad320c..23a670e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,51 +1,3 @@ -[package] -name = "iAs" -version = "0.1.0" -edition = "2024" -description = "AI 智能助手" - -[dependencies] -# ── 异步运行时 ── -tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "sync", "time", "process", "signal", "io-util"] } # 异步运行时核心 -# ── HTTP 客户端 ── -reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } # HTTP 请求(微信 API + DeepSeek API + 工具调用) -# ── 序列化 ── -serde = { version = "1.0", features = ["derive"] } # 序列化/反序列化 -serde_json = "1.0" # JSON 处理 -serde_yaml = "0.9" # YAML 解析(工具规范文件) -# ── 环境变量加载 ── -dotenvy = "0.15" # .env 文件加载 -# ── CLI 参数解析 ── -clap = { version = "4", features = ["derive"] } # 命令行参数解析 - -# ── UUID ── -uuid = { version = "1", features = ["v4", "serde"] } # 唯一标识符 -# ── 时间处理 ── -chrono = { version = "0.4", features = ["serde"] } # 日期时间 -# ── 加密与签名 ── -base64 = "0.22" # Base64 编码 -sha2 = "0.10" # SHA-256 哈希(审批码) -rand = "0.9" # 随机数 -ed25519-dalek = { version = "2", features = ["pem"] } # Ed25519 JWT 签名(和风天气 API) -# ── 日志 ── -tracing = "0.1" # 结构化日志 -tracing-subscriber = { version = "0.3", features = ["env-filter"] } # 日志输出 -tracing-appender = "0.2.5" # 文件日志(日滚) -# ── 二维码 ── -qrcode = { version = "0.14", default-features = false } # 二维码生成(仅 Unicode 终端渲染) -# ── 异步 trait ── -async-trait = "0.1" # 异步 trait 支持 -# ── 流式处理 ── -futures-util = "0.3" # 流式处理工具 -# ── 数据库 ── -sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "derive", "migrate"] } # PostgreSQL -# ── 文件系统 ── -dirs = "6.0.0" # 系统目录路径 -# ── HTML 解析 ── -scraper = "0.27.0" # HTML 解析(fetch_page 工具) -# ── 正则表达式 ── -regex = "1.0" # 代码块解析 - -[[bin]] -name = "ias" -path = "src/main.rs" +[workspace] +members = ["service", "ai", "common", "wechat"] +resolver = "2" diff --git a/README.md b/README.md deleted file mode 100644 index 4e8f977..0000000 --- a/README.md +++ /dev/null @@ -1,298 +0,0 @@ -# iAs — 微信 AI 智能助手 - -Rust 实现的微信 iLink Bot,支持 DeepSeek AI 自动回复、内置工具调用、审批确认、定时任务和长期记忆。 - -## 快速开始 - -```bash -# 1. 配置环境变量(或创建 .env 文件) -export DEEPSEEK_API_KEY="sk-xxx" -export DATABASE_URL="postgresql://..." # PostgreSQL 可选 - -# 2. 扫码登录微信 -ias login - -# 3. 启动守护进程(AI 自动回复) -ias daemon - -# 4. 后台运行(配合 systemd) -ias daemon --log-file -``` - -## 命令一览 - -| 命令 | 说明 | -|------|------| -| `ias login` | 扫码登录微信,认证信息存入 PostgreSQL | -| `ias daemon` | 守护进程模式(主运行模式,长轮询 + 三消费者架构) | -| `ias daemon --log-file` | 守护进程 + 文件日志(配合 systemd) | -| `ias send --to --text ` | 手动发送消息 | -| `ias whoami` | 查看登录状态 | -| `ias usage` | Token 用量统计(默认 7 天) | -| `ias task list` | 列出所有定时任务 | -| `ias task add --name ...` | 添加定时任务 | -| `ias task update ` | 更新定时任务 | -| `ias task delete ` | 删除定时任务 | -| `ias task toggle ` | 启用/禁用定时任务 | -| `ias tools` | 列出所有可用的 LLM 工具 | -| `ias tool datetime` | 获取当前时间 | -| `ias tool weather <城市>` | 查询天气 | -| `ias tool search <关键词>` | 联网搜索 | -| `ias tool fetch ` | 提取网页正文 | -| `ias tool memos list/add/delete` | 管理备忘录 | -| `ias tool amap poi-search/geocode/route-plan/travel-plan` | 高德地图服务 | - -## 配置(环境变量) - -| 变量 | 必须 | 说明 | -|------|------|------| -| `DEEPSEEK_API_KEY` | ✅ | DeepSeek API 密钥 | -| `DEEPSEEK_MODEL` | — | 模型名,默认 `deepseek-v4-flash` | -| `DEEPSEEK_BASE_URL` | — | API 地址,默认 `https://api.deepseek.com/v1` | -| `DATABASE_URL` | ✅ | PostgreSQL 连接串 | -| `WEIXIN_LLM_SYSTEM_PROMPT` | — | 自定义系统提示词 | -| `TAVILY_API_KEY` | — | Tavily 搜索 API Key | -| `QWEATHER_API_HOST` | — | 和风天气 API 地址 | -| `QWEATHER_JWT_KEY_ID` | — | 和风天气 JWT Key ID | -| `QWEATHER_JWT_PROJECT_ID` | — | 和风天气项目 ID | -| `QWEATHER_JWT_PRIVATE_KEY_FILE` | — | 和风天气 Ed25519 私钥路径 | -| `AMAP_WEBSERVICE_KEY` | — | 高德地图 Web 服务 Key(或 `AMAP_KEY`) | - -加载顺序:`./.env` → `~/.ias/.env` → 环境变量 - -## 架构 - -``` -┌──────────────────────────────────────────────────────────────┐ -│ iAs CLI (main.rs) │ -│ clap 子命令路由 → login / daemon / send / tool / task / ... │ -├──────────────────────────────────────────────────────────────┤ -│ Daemon (daemon.rs) │ -│ 持有 WeChat 长连接 + 数据库 + 审批管理器 + 长期记忆 │ -├──────────────────────────────────────────────────────────────┤ -│ MessageQueue (公平轮转队列) │ -│ 按用户轮转出队,防止一个用户刷屏饿死其他人 │ -├────────────────┬────────────────┬───────────────────────────┤ -│ LLM Consumer │ Tool Consumer │ Send Consumer │ -│ Conversation │ execute_tool │ WeChatClient.send_text() │ -│ DeepSeek 流式 │ ApprovalMgr │ 入库 chat_records │ -│ 上下文管理 │ 内置工具执行 │ │ -├────────────────┴────────────────┴───────────────────────────┤ -│ Context / Memory │ -│ ChatSession · Token Budget · 摘要压缩 · MemoryStore │ -├──────────────────────────────────────────────────────────────┤ -│ Database (PostgreSQL) │ -│ app_state · chat_records · llm_usage · user_memories │ -│ pending_approvals · session_summaries · scheduled_tasks │ -└──────────────────────────────────────────────────────────────┘ -``` - -## 核心数据流 - -``` -微信消息到达 - → 存 inbound chat_records - → enqueue PipelineMessage → MessageQueue 公平轮转 - → LLM Consumer 消费 - → 加载用户历史 + 记忆 + 摘要 - → 构建 LLM 上下文(token budget + 摘要注入) - → Conversation.chat_with_tools() - ├── LLM 流式回复 - ├── 工具调用 → enqueue ToolCall → Tool Consumer - │ ├── 高风险工具 → ApprovalManager 审批流程 - │ └── 执行工具 → 结果回喂 LLM - └── 最终回复 → enqueue LLMReply → Send Consumer - → Send Consumer 发送微信消息 + 入库 outbound chat_records -``` - -## 源码结构 - -``` -src/ -├── main.rs CLI 入口 + 命令实现 -├── cli.rs clap 子命令定义(login/daemon/send/tool/task/tools) -├── logger.rs 日志初始化(终端 + 文件日滚) -├── daemon.rs 守护进程核心(长轮询 + 三消费者 + 调度器) -├── scheduler.rs 定时任务调度器(每 5s 轮询到期任务) -├── channel/ -│ └── mod.rs ChannelId(平台+用户标识)、IncomingMessage -├── queue/ -│ ├── message_queue.rs 公平轮转队列(按用户轮转出队) -│ └── runner.rs QueueRunner(出队 → 按类型路由到消费者) -├── context/ -│ ├── types.rs ChatSession(消息历史、摘要、checkpoint、token budget) -│ ├── builder.rs Token 估算、上下文构建、摘要触发 -│ └── tools.rs MemoryStore(长期记忆管理器) -├── db/ -│ ├── mod.rs Database 连接池 + 迁移 -│ └── models.rs CRUD(auth/chat/usage/summary/memory/task) -├── llm/ -│ ├── types.rs Message / Role / ToolCall / StreamChunk / Usage -│ ├── provider.rs LlmProvider trait + SSE 解析 + 工厂函数 -│ ├── deepseek.rs DeepSeek 流式客户端实现 -│ └── conversation.rs Conversation(chat + chat_with_tools 工具循环) -├── tools/ -│ ├── types.rs RiskLevel / SkillSpec / SkillResult / ExecutionContext -│ ├── tool.rs Tool trait + ToolRegistry(统一注册表) -│ ├── api_tool.rs ApiTool(Rust 闭包实现的工具) -│ ├── shell_tool.rs ShellTool(shell 命令执行的工具) -│ ├── stdio_tool.rs StdioTool(子进程 stdin/stdout 通信的工具) -│ ├── builtin.rs BuiltinRegistry(旧接口兼容层) -│ ├── approval.rs ApprovalManager(确认码 + oneshot 通道) -│ ├── mod.rs 全局注册表 + unpack_call_params + build_capability_guide -│ └── builtins/ 内置工具实现 -│ ├── datetime.rs 日期时间 -│ ├── weather.rs 和风天气(实时/每日预报/逐时预报) -│ ├── web_search.rs Tavily 搜索 -│ ├── fetch_page.rs 网页抓取(可读性算法) -│ ├── memos.rs 备忘录(文件存储,增删查) -│ ├── amap.rs 高德地图(POI/地理编码/路径规划/旅游规划/地图链接) -│ └── scheduled_task.rs 定时任务管理(增删改查切换) -└── wechat/ - ├── types.rs iLink API 协议类型 - └── client.rs HTTP 客户端(login/poll/send/notify) -``` - -## 工具系统 - -### 架构设计(两层元工具) - -LLM 不直接感知每个具体工具,而是通过两个**元工具**间接调用: - -1. **`query_capabilities`** — 列出所有可用工具的名称、描述、参数格式 -2. **`call_capability`** — 按名称调用工具,参数通过 JSON 传递 - -### 工具抽象 - -所有工具分为三种类型: - -| 类型 | 说明 | 示例 | -|------|------|------| -| `ApiTool` | Rust 闭包实现的工具(HTTP API / 本地计算 / 文件 I/O) | 天气、搜索、高德地图 | -| `ShellTool` | 通过 shell 命令执行的工具 | 定时任务、系统命令 | -| `StdioTool` | 通过子进程 stdin/stdout 通信的工具 | 外部脚本、可执行程序 | - -### 内置工具 - -| 工具名 | 风险 | 说明 | -|--------|------|------| -| `get_current_datetime` | Low | 获取北京时间 | -| `weather_now` | Low | 实时天气(和风天气) | -| `weather_forecast` | Low | 每日天气预报 | -| `weather_hourly` | Low | 逐时天气预报 | -| `web_search` | Low | Tavily 联网搜索 | -| `fetch_page` | Low | 网页正文提取(可读性算法) | -| `memos_list` | Low | 列出备忘录 | -| `memos_add` | Low | 添加备忘录 | -| `memos_get` | Low | 查看指定备忘录 | -| `memos_delete` | Low | 删除备忘录 | -| `amap_poi_search` | Low | POI 搜索 + 周边搜索 | -| `amap_geocode` | Low | 地理编码(地址→坐标) | -| `amap_reverse_geocode` | Low | 逆地理编码(坐标→地址) | -| `amap_route_plan` | Low | 路径规划(步行/驾车/骑行/电动车/公交) | -| `amap_travel_plan` | Low | 智能旅游规划 | -| `amap_map_link` | Low | 生成地图可视化链接 | -| `scheduled_task_list` | Low | 列出定时任务 | -| `scheduled_task_add` | Low | 添加定时任务 | -| `scheduled_task_update` | Low | 更新定时任务 | -| `scheduled_task_delete` | Low | 删除定时任务 | -| `scheduled_task_toggle` | Low | 启用/禁用定时任务 | - -### 审批流程 - -High 风险工具需要用户微信确认: - -``` -LLM 调用 High 工具 - → 生成 6 位确认码 → SHA-256 入库 - → 微信发送确认消息(含确认码) - → 等待用户回复(最多 300s,3 次重试) - → 匹配成功 → 执行工具 → 返回结果 - → 匹配失败 / 超时 → 返回拒绝信息 -``` - -LLM 不感知确认码和审批过程,只看到最终的执行结果。 - -## 上下文管理 - -- **Token Budget**:28K tokens,中/英文字符估算 -- **Checkpoint**:摘要压缩点,之前的消息已压缩,之后完整保留 -- **摘要触发**:token 溢出(overflow)或 12h 空闲(timeout) -- **长期记忆**:LLM 按需通过 `read_memories` / `write_memory` 读写 -- **用户隔离**:切换用户时清空 session,按需加载新用户历史 - -## 消息队列(三消费者架构) - -``` - ┌──────────────┐ - enqueue ──────→ │ MessageQueue │ ← 公平轮转(按用户) - └──────┬───────┘ - │ dequeue - ▼ - ┌──────────────┐ - │ QueueRunner │ ← 路由循环 - └──┬───┬───┬──┘ - ┌─┘ │ └─┐ - ▼ ▼ ▼ - LLM Tool Send - Consumer Consumer Consumer -``` - -消息路由规则: -- `UserMessage` / `ToolResult` / `ScheduledTask` → **LLM Consumer** -- `ToolCall` / `ApprovalRequest` → **Tool Consumer** -- `LLMReply` → **Send Consumer** - -## 数据库表 - -| 表 | 用途 | -|----|------| -| `app_state` | 认证信息 KV 存储 | -| `chat_records` | 收发消息记录(message_id 去重) | -| `llm_usage` | Token 用量统计(含缓存命中率) | -| `user_memories` | 长期记忆(按 user_id) | -| `pending_approvals` | 待审批记录(SHA-256 确认码) | -| `session_summaries` | 会话摘要 | -| `scheduled_tasks` | 定时任务(支持 model/system 类型) | - -## 部署 - -### systemd 服务 - -```bash -sudo cp systemd/ias.service /etc/systemd/system/ -# 编辑 .env 文件确保 API Key 等环境变量已配置 -sudo systemctl enable --now ias -``` - -### 天气 API 密钥 - -将和风天气 Ed25519 密钥对放入 `~/.ias/qweather/` 目录: - -``` -~/.ias/qweather/ -├── ed25519-private.pem -└── ed25519-public.pem -``` - -## 依赖 - -| 依赖 | 用途 | -|------|------| -| tokio | 异步运行时 | -| clap | CLI 参数解析 | -| reqwest | HTTP 客户端 | -| sqlx | PostgreSQL 驱动 + 迁移 | -| serde / serde_json | 序列化 | -| chrono | 时间处理 | -| tracing | 结构化日志 | -| async-trait | 异步 trait | -| ed25519-dalek | 天气 API JWT 签名 | -| scraper | 网页正文提取(可读性算法) | -| sha2 / hex / rand | 审批确认码加密 | -| uuid | 会话/任务唯一标识 | -| base64 | Base64 编码 | -| qrcode / image | 二维码生成 | -| rustyline / dialoguer / console | 终端交互 | -| futures-util | 流式处理工具 | -| dirs | 系统目录路径 | diff --git a/ai/Cargo.toml b/ai/Cargo.toml new file mode 100644 index 0000000..38a7ca6 --- /dev/null +++ b/ai/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ai" +version = "0.1.0" +edition = "2024" + +[dependencies] +common = {path = "../common"} +# ── 异步运行时 ── +tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "sync", "time", "process", "signal", "io-util"] } # 异步运行时核心 +# ── HTTP 客户端 ── +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } # HTTP 请求(微信 API + DeepSeek API + 工具调用) +# ── 序列化 ── +serde = { version = "1.0", features = ["derive"] } # 序列化/反序列化 +serde_json = "1.0" # JSON 处理 +serde_yaml = "0.9" # YAML 解析(工具规范文件) +# ── 时间 ── +chrono = "0.4" \ No newline at end of file diff --git a/ai/src/core.rs b/ai/src/core.rs new file mode 100644 index 0000000..7d6ec62 --- /dev/null +++ b/ai/src/core.rs @@ -0,0 +1,105 @@ +use crate::toolkit::{call_tools, get_tool}; +use common::ai::{ + ChatCompletionRequestBody, ChatCompletionRequestExtra, ChatCompletionRequestMessage, + ChatCompletionRequestThinking, ChatCompletionResponseBody, +}; +use common::queue::QueueMessage; + + + +pub async fn send_message( + sender: tokio::sync::mpsc::Sender, + messages: Vec, +) -> Result<(), reqwest::Error> { + let result = request_llm(messages).await; + match result { + Ok(result) => { + // println!("result:"); + for choice in result.unwrap().choices { + let messsage_content = choice.message.content; + let messsage_reasoning = choice.message.reasoning_content.unwrap_or_default(); + if !messsage_content.trim().is_empty() || !messsage_content.trim().is_empty() { + sender + .send(QueueMessage::Aissitant( + messsage_reasoning, + messsage_content, + )) + .await + .unwrap(); + } + match choice.message.tool_calls { + Some(tool_calls) => { + // eprintln!("调用了{}个工具", tool_calls.len()); + let queue_message = call_tools(tool_calls).await.unwrap(); + if let Some(queue_message) = queue_message { + sender.send(queue_message).await.unwrap(); + } + } + None => { + // eprintln!("没有调用工具"); + } + } + } + } + Err(err) => { + eprintln!("发送错误:{}", err); + } + } + + Ok(()) +} + +pub async fn request_llm( + messages: Vec, +) -> Result, reqwest::Error> { + let client = reqwest::Client::new(); + let body = ChatCompletionRequestBody { + model: "deepseek-v4-flash".to_string(), + messages: messages, + stream: false, + tools: get_tool(), + extra_body: Some(ChatCompletionRequestExtra { + thinking: ChatCompletionRequestThinking { + thinking_type: "enabled".to_string(), + }, + }), + }; + let resp = client + .post("http://100.64.52.162:9807/v1/chat/completions") + .bearer_auth(std::env::var("ROUTER_API_KEY").unwrap()) + .json(&body) + .send() + .await; + + match resp { + Ok(resp) => { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + // println!("response:\n{}", text); + if status.is_success() { + let result = serde_json::from_str::(&text).unwrap(); + return Ok(Some(result)); + } else { + eprintln!("HTTP 请求失败"); + eprintln!("status: {}", status); + eprintln!("body: {}", text); + } + } + Err(err) => { + eprintln!("请求发送失败: {}", err); + + if err.is_timeout() { + eprintln!("原因: 请求超时"); + } + + if err.is_connect() { + eprintln!("原因: 连接失败"); + } + + if let Some(url) = err.url() { + eprintln!("url: {}", url); + } + } + } + Ok(None) +} diff --git a/ai/src/lib.rs b/ai/src/lib.rs new file mode 100644 index 0000000..147a36b --- /dev/null +++ b/ai/src/lib.rs @@ -0,0 +1,2 @@ +pub mod core; +pub mod toolkit; \ No newline at end of file diff --git a/ai/src/toolkit.rs b/ai/src/toolkit.rs new file mode 100644 index 0000000..dbc36e3 --- /dev/null +++ b/ai/src/toolkit.rs @@ -0,0 +1,62 @@ +use chrono::Local; +use serde_json::error; + +use common::{ai::ChatCompletionResponseToolCall, queue::QueueMessage}; + +pub async fn call_tools( + tools: Vec, +) -> Result, error::Error> { + if let Some(tool) = tools.first() { + if let Some(funcation) = tool.function.as_ref() { + if funcation.name == "query_date_time" { + let date_time = Local::now(); + let formatted = date_time.format("%Y-%m-%d %H:%M:%S").to_string(); + return Ok(Some(QueueMessage::Tool( + tool.id.clone(), + funcation.name.clone(), + format!("现在时间:{}", formatted), + ))); + } + } + } + Ok(None) +} + +pub fn get_tool() -> Vec { + return vec![ + serde_json::json!({ + "type": "function", + "function": { + "name": "query_date_time", + "description": "查看当前时间", + "parameters": {"type": "object", "properties": {}, "required": []} + } + }), + // serde_json::json!({ + // "type": "function", + // "function": { + // "name": "query_capabilities", + // "description": "查询所有可用工具的列表、参数格式和用法说明", + // "parameters": {"type": "object", "properties": {}, "required": []} + // } + // }), + // serde_json::json!({ + // "type": "function", + // "function": { + // "name": "call_capability", + // "description": "调用指定的工具。先通过 query_capabilities 获取工具名和参数格式", + // "parameters": { + // "type": "object", + // "properties": { + // "name": {"type": "string", "description": "工具名称"}, + // "prompt": { + // "type": "object", + // "description": "工具参数对象,如 {\"location\":\"北京\"}" + // } + // }, + // "required": ["name", "prompt"] + // } + // } + // }), + ]; +} \ No newline at end of file diff --git a/common/Cargo.toml b/common/Cargo.toml new file mode 100644 index 0000000..3180525 --- /dev/null +++ b/common/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "common" +version = "0.1.0" +edition = "2024" + +[dependencies] +# ── 序列化 ── +serde = { version = "1.0", features = ["derive"] } # 序列化/反序列化 +serde_json = "1.0" diff --git a/common/src/ai.rs b/common/src/ai.rs new file mode 100644 index 0000000..a96866b --- /dev/null +++ b/common/src/ai.rs @@ -0,0 +1,107 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize)] +pub struct ChatCompletionRequestBody { + pub model: String, + pub messages: Vec, + pub stream: bool, + pub tools: Vec, + pub extra_body: Option, +} +#[derive(Debug, Serialize)] +pub struct ChatCompletionRequestExtra { + pub thinking: ChatCompletionRequestThinking, +} +#[derive(Debug, Serialize)] +pub struct ChatCompletionRequestThinking { + #[serde(rename = "type")] + pub thinking_type: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ChatCompletionRequestMessage { + pub role: String, + pub content: String, + pub tool_call_id: Option, +} +impl ChatCompletionRequestMessage { + pub fn new(role: String, content: String) -> Self { + Self { + role, + content, + tool_call_id: None, + } + } + pub fn tool(role: String, content: String, tool_call_id: String) -> Self { + Self { + role, + content, + tool_call_id: Some(tool_call_id), + } + } +} + +// {"choices":[{"finish_reason":"stop","index":0,"logprobs":null,"message":{"content":"你好!😊 有什么我可以帮你的吗?","role":"assistant"}}],"created":1782208455,"id":"02178220845277286f296e4c58ddf650991f54d763d292af8529b","model":"deepseek-v4-flash-260425","service_tier":"default","object":"chat.completion","usage":{"prompt_tokens":2005,"completion_tokens":125,"total_tokens":2130,"prompt_tokens_details":{"cached_tokens":0},"completion_tokens_details":{"reasoning_tokens":114}}} +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct ChatCompletionResponseBody { + id: String, + object: String, + created: i64, + model: String, + service_tier: String, + pub choices: Vec, + pub usage: ChatCompletionResponseUsage, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct ChatCompletionResponseChoice { + index: u32, + pub message: ChatCompletionResponseMessage, + logprobs: Option, + finish_reason: String, +} +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct ChatCompletionResponseMessage { + pub role: String, + pub content: String, + pub reasoning_content: Option, + pub tool_calls: Option>, +} +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct ChatCompletionResponseToolCall { + pub function: Option, + pub id: String, + #[serde(rename = "type")] + pub call_type: String, +} +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct ChatCompletionResponseFunction { + pub arguments: String, + pub name: String, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct ChatCompletionResponseUsage { + pub prompt_tokens: u32, + pub completion_tokens: u32, + pub total_tokens: u32, + pub prompt_tokens_details: ChatCompletionResponseUsagePrompt, + pub completion_tokens_details: ChatCompletionResponseUsageCompletion, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct ChatCompletionResponseUsagePrompt { + pub cached_tokens: u32, +} +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct ChatCompletionResponseUsageCompletion { + pub reasoning_tokens: u32, +} diff --git a/common/src/db/mod.rs b/common/src/db/mod.rs new file mode 100644 index 0000000..22d12a3 --- /dev/null +++ b/common/src/db/mod.rs @@ -0,0 +1 @@ +pub mod user; diff --git a/common/src/db/user.rs b/common/src/db/user.rs new file mode 100644 index 0000000..9ada404 --- /dev/null +++ b/common/src/db/user.rs @@ -0,0 +1,22 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct WechatUser { + pub id: isize, + pub user_name: String, + pub token: String, + pub account_id: String, + pub base_url: String, + pub user_id: String, + pub user_status: isize, + pub created_at: isize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WechatUserCreate { + pub user_name: String, + pub token: String, + pub account_id: String, + pub base_url: String, + pub user_id: String, +} diff --git a/common/src/lib.rs b/common/src/lib.rs new file mode 100644 index 0000000..1596c37 --- /dev/null +++ b/common/src/lib.rs @@ -0,0 +1,3 @@ +pub mod ai; +pub mod db; +pub mod queue; diff --git a/common/src/queue.rs b/common/src/queue.rs new file mode 100644 index 0000000..09820ee --- /dev/null +++ b/common/src/queue.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum QueueMessage { + Channel(String, String), + Aissitant(String, String), + Tool(String, String, String), +} + +#[derive(Clone, Deserialize, Serialize)] +pub struct ChannelMessage { + pub channel: String, + pub content: String, +} diff --git a/docker/Dockerfile.stdio-runner b/docker/Dockerfile.stdio-runner deleted file mode 100644 index 9baab1e..0000000 --- a/docker/Dockerfile.stdio-runner +++ /dev/null @@ -1,7 +0,0 @@ -# ias-stdio-runner — 运行普通 stdio 工具的极简隔离环境 -# 无网络、只读根、低权限。工具二进制通过 bind mount 提供,镜像本身不含工具。 -FROM debian:bookworm-slim -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates \ - && rm -rf /var/lib/apt/lists/* -ENTRYPOINT [] diff --git a/docker/Dockerfile.tool-builder b/docker/Dockerfile.tool-builder deleted file mode 100644 index 9b73538..0000000 --- a/docker/Dockerfile.tool-builder +++ /dev/null @@ -1,13 +0,0 @@ -# ias-tool-builder — tool_manager 编译 Rust 工具的隔离环境 -# 预缓存 serde_json(tool_manager 生成工具的唯一依赖),支持 --network none 离线编译。 -# CARGO_HOME=/tmp/cargo 并 chmod 777,使任意 uid(--user 运行)都能复用缓存。 -# 运行时由 tool_manager 挂载 tool_dir 到 /work,cargo 在 /work 内编译,产物写回宿主。 -FROM rust:1.88-slim -ENV CARGO_HOME=/tmp/cargo -RUN cargo new /tmp/seed && cd /tmp/seed \ - && printf 'serde_json = "1.0"\n' >> Cargo.toml \ - && cargo build --release \ - && rm -rf /tmp/seed \ - && chmod -R 777 /tmp/cargo -WORKDIR /work -ENTRYPOINT [] diff --git a/docker/build-images.sh b/docker/build-images.sh deleted file mode 100755 index 8454559..0000000 --- a/docker/build-images.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -# 构建沙箱基础镜像。 -# - ias-stdio-runner : 运行 tool_manager 产出的不受信任工具(无网络只读) -# - ias-tool-builder : tool_manager 编译 Rust 工具(离线) -# 无需为每个工具单独写 Dockerfile。 -set -euo pipefail -cd "$(dirname "$0")" # docker/ - -echo "=== 构建 ias-stdio-runner(无网络只读容器)===" -docker build -t ias-stdio-runner -f Dockerfile.stdio-runner . - -echo "=== 构建 ias-tool-builder(Rust 编译容器)===" -docker build -t ias-tool-builder -f Dockerfile.tool-builder . - -echo "=== 完成 ===" -docker images | grep -E '^ias-(stdio|tool)' diff --git a/docs/SECURITY_BOUNDARIES.md b/docs/SECURITY_BOUNDARIES.md deleted file mode 100644 index c5e47b4..0000000 --- a/docs/SECURITY_BOUNDARIES.md +++ /dev/null @@ -1,186 +0,0 @@ -# 两层安全边界设计 - -> 审批决定"能不能做",Docker 限制"做坏了能坏到哪里",审计回答"谁让系统新增或修改了什么能力"。 - -## 架构 - -``` -LLM 调用工具 - → registry.dispatch(name, params, user_id, approved) - → load spec + validate params - → 【授权边界】if high_risk && !approved: 统一审批(不依赖消息类型) - → 【隔离边界】按 sandbox.profile 选择执行方式: - - local : 本地 spawn(env_clear + 白名单注入) - - stdio : docker run ias-stdio-runner (无网络、只读根) - - network : docker run ias-network-runner(联网、资源限制) - - builder : docker run ias-tool-builder (离线编译,仅 tool_manager) - → stdin 传 envelope → stdout 读 JSON → 超时 kill → 容器销毁 -``` - -## 第一层:统一工具级审批(授权边界) - -**位置**:`src/tools/executor.rs` `execute_loop()` 入口 - -高风险工具在 **spawn 之前** 必须获得用户确认,与工具后续输出的消息类型(`result`/`http`/`db`)无关。 - -```rust -if spec.is_high_risk() && !approved { - // 发送确认码 → 等待用户回复 → Approved/Rejected/Expired -} -``` - -**修复的漏洞**:原实现审批只在 `http`/`db` 消息分支触发,`stdio → result` 可绕过。`tool_manager`(high risk)直接输出 result 即可零确认执行。 - -## 第二层:长期沙箱容器 + docker exec(隔离边界) - -### 适用范围(按威胁模型精确化) - -http/db 类工具的子进程是"瘦壳"——危险操作(联网、DB 读写)全在受信任的主进程里, -给它们套容器是纯开销,**没有隔离收益**。因此: - -| 场景 | 是否容器隔离 | 原因 | -|------|------------|------| -| 开发者预写工具(weather/amap/...) | ❌ 本地执行 | 子进程纯计算,危险操作在主进程 | -| tool_manager 产出的工具 | ✅ stdio 容器 | 含 LLM 提供的 rust_code,不受信任 | -| tool_manager 编译 | ✅ builder 容器 | cargo 执行任意 build script/proc macro | - -### 统一产物目录 - -所有工具二进制统一收集到 `tools/bin/`(`build.sh` 编译后复制,`tool_manager` 编译后也复制)。 -registry 解析路径时优先用 `tools/bin/`。 - -### 长期容器模式 - -daemon 启动时创建常驻容器 `ias-sandbox`,`tools/bin/` 只读挂载到 `/tools`: - -``` -docker run -d --rm --name ias-sandbox \ - --network none --cap-drop ALL --security-opt no-new-privileges \ - --read-only --memory 512m --cpus 1 --pids-limit 128 \ - --mount type=bind,source=tools/bin,target=/tools,readonly \ - --mount type=tmpfs,target=/tmp \ - ias-stdio-runner sleep infinity -``` - -工具调用通过 `docker exec` 在容器内执行(省去每次 `docker run` 的创建开销): - -``` -docker exec -i -e KEY=VAL ias-sandbox timeout -k 5 /tools/ --command -``` - -- 容器已运行则复用,daemon 重启不重建 -- `timeout -k 5` 超时 SIGTERM、5s 后 SIGKILL,避免孤儿进程 -- env 按 spec 白名单 `-e` 注入(容器启动时仅有 PATH) -- 新工具由 tool_manager 编译后复制到 `tools/bin/`,bind mount 自动可见,无需重启容器 - -### 两类基础镜像 - -| 镜像 | 用途 | 网络 | 文件系统 | -|------|------|------|----------| -| `ias-stdio-runner` | tool_manager 产出的不受信任工具(长期容器) | none | 只读根 + tmpfs /tmp | -| `ias-tool-builder` | tool_manager 编译 Rust 工具(一次性容器) | none | 可写 /work(bind mount) | - -构建:`docker/build-images.sh` - -### 容器安全基线 - -所有容器统一: -- `--rm` 一次性,用完销毁 -- `--cap-drop ALL` 丢弃所有 Linux capability -- `--security-opt no-new-privileges` 禁止提权 -- `--memory` / `--cpus` / `--pids-limit` 资源限制 -- 只挂载工具二进制(readonly)+ tmpfs /tmp -- 不挂载项目根目录、不挂载 docker.sock -- env 按白名单注入(`env_clear` + spec.env) - -### 沙箱配置(spec) - -```yaml -sandbox: - profile: stdio # local | stdio | builder - network: none # none | default - memory: 256m - cpus: "0.5" - pids_limit: 64 - read_only: true -``` - -默认 `profile: local`(不启用容器)。开发者预写工具保持默认。 -**tool_manager 产出的工具自动写入 `sandbox: profile: stdio`**(见 tool_manager `spec_yaml`), -不受信任代码默认进容器。 - -### 启用沙箱 - -```bash -export IAS_DOCKER_SANDBOX=1 # 工具执行走长期容器 docker exec -export IAS_DOCKER_BUILDER=1 # tool_manager 编译走 builder 容器 -``` - -未设置时退回本地执行(仍保留 env_clear + 白名单注入)。 -daemon 启动时自动 ensure `ias-sandbox` 容器,失败则回退本地执行。 - -### builder 容器特殊处理 - -`--cap-drop ALL` 丢掉 `CAP_DAC_OVERRIDE`,root 无法写 bind mount 的非 root 目录。 -解法:builder 容器以宿主 `uid:gid` 运行(`--user`),自然能写自己的目录。 -镜像内 `CARGO_HOME=/tmp/cargo` 预缓存 serde_json 并 `chmod 777`,任意 uid 可用。 -编译加 `--offline`,确保 `--network none` 下不尝试联网。 - -## tool_manager 加固 - -| 措施 | 实现 | -|------|------| -| 禁止自改 | `RESERVED_NAMES` 包含 `tool_manager`,禁止 create/update/build | -| 强制 high risk | `spec_yaml` 忽略调用方 risk_level,一律写 `high` | -| 审计日志 | 每次调用追加 `tools/.tool_audit.log`(ts/uid/action/tool/result) | -| 构建超时 | `timeout 180s` 包裹 cargo build | -| 失败清理 | 构建失败删除残留旧二进制,避免 reload 后调用旧版本"假成功" | -| 离线编译 | `IAS_DOCKER_BUILDER=1` 时在 builder 容器内 `--offline` 编译 | - -## 结构化结果 - -`executor` 返回 `ToolRunOutcome` 枚举替代裸字符串: - -```rust -enum ToolRunOutcome { - Ok(String), - Err { kind: ToolErrorKind, message: String }, -} -``` - -`daemon` 据此判断是否 reload(tool_manager 成功后重载注册表), -替代原 `is_tool_error()` 的中文前缀字符串匹配。 - -## 审批并发修复 - -`ApprovalManager::clean_expired()` 两阶段加锁: -收集过期 `code_hash` → 更新 DB → 重新加锁按 `code_hash` 定位删除。 -不依赖快照 index,避免并发回复导致 index 错位误删未过期审批。 - -## 验证 - -| 场景 | 结果 | -|------|------| -| tool_manager update 自身 | ❌ 拒绝(保留名) | -| create_tool risk_level=low | spec 强制 high | -| tool_manager 产出工具 spec | 自动含 `sandbox: profile: stdio` + 产物复制到 `tools/bin/` | -| datetime 本地执行 | +08:00(宿主时区,开发者工具默认不套容器) | -| tool_manager 产出工具 docker exec | 容器内 hostname=容器ID,证明确实走长期容器 exec | -| 容器常驻 | docker ps 可见 ias-sandbox,exec 完无孤儿进程 | -| builder 容器编译 serde_json | ✅ 离线成功 | -| cargo test | 25 passed | - -## 实施状态 - -| # | 步骤 | 状态 | -|---|------|------| -| 1 | 统一工具级审批 | ✅ | -| 2 | 禁止 tool_manager 自改 | ✅ | -| 3 | 强制生成工具 high risk | ✅ | -| 4 | env_clear 环境白名单 | ✅ | -| 5 | 修 clean_expired 并发 | ✅ | -| 6 | 三类 Docker 镜像 + sandbox spec | ✅ | -| 7 | stdio 工具迁移到一次性容器 | ✅ | -| 8 | network 工具迁移 | ➖ 不需要:工具走宿主代理联网,子进程纯计算,套容器无隔离收益 | -| 9 | tool_manager build 迁移到 builder 容器 | ✅ | -| 10 | 结构化结果替换 is_tool_error | ✅ | diff --git a/docs/TOOL_PLATFORM_PLAN.md b/docs/TOOL_PLATFORM_PLAN.md deleted file mode 100644 index d7a02ae..0000000 --- a/docs/TOOL_PLATFORM_PLAN.md +++ /dev/null @@ -1,115 +0,0 @@ -# iAs 工具平台化改造 —— 当前状态 - -> 最后更新:2024-06-18 - ---- - -## 一、架构 - -``` -主进程(单 tokio 进程) -├── 消息队列 (三消费者: LLM / Tool / Send) -├── 工具注册器 (扫描 tools/*/specs/*.tool.yaml) -├── 运行时 (executor::execute_loop: spawn → stdout → http/db/result) -├── 审批 (ApprovalManager, oneshot 挂起) -└── LLM (DeepSeek, 原生 tool_call, thinking 模式) - -工具层(独立进程,stdin→stdout,无网络依赖) -├── tools/datetime/specs/ (1 工具, 470 KB) -├── tools/weather/specs/ (3 工具, 437 KB) -├── tools/amap/specs/ (6 工具, 441 KB) -├── tools/memories/specs/ (5 工具, 437 KB) -├── tools/web_search/specs/ (1 工具, 433 KB) -└── tools/fetch_page/specs/ (1 工具, 435 KB) - 共 17 个工具, 总计 2.7 MB -``` - ---- - -## 二、LLM 调用协议 - -使用 DeepSeek 原生 `tool_call` (function calling),非代码块。 - -**2 个元工具注册给 API:** - -```json -[ - {"function": {"name": "query_capabilities", "description": "查询所有可用工具"}}, - {"function": {"name": "call_capability", "description": "调用工具", "parameters": {"name": "工具名", "prompt": "JSON参数"}}} -] -``` - -LLM 先调 `query_capabilities` 获取工具列表,再 `call_capability` 调具体工具。工具结果以 `role=tool` 插入上下文。 - ---- - -## 三、主进程↔工具协议 - -工具为短生命周期进程。每次 spawn 读 stdin 一行 JSON,写 stdout 一行 JSON。 - -**stdin 信封:** - -```json -{"params": {"location": "北京"}, "approved": true, "user_id": "wxid"} -``` - -后继调用追加 `response` 或 `db_response` 字段。 - -**stdout 消息:** - -| type | 含义 | 主进程行为 | -|------|------|-----------| -| `{"type":"http","method":"GET","url":"...","desc":"..."}` | HTTP 请求 | 审批→执行→re-spawn | -| `{"type":"db","operation":"read_memories","params":{...}}` | DB 请求 | 执行 DB→re-spawn | -| `{"type":"result","content":"..."}` | 最终结果 | 提取 content | - ---- - -## 四、工具规范 (YAML) - -每个工具目录下 `specs/*.tool.yaml`,16 个字段: - -```yaml -name: weather_now -desc: 查询指定城市的实时天气(温度、体感温度、风向风力、湿度、气压、能见度) -type: http -tool: weather -command: now -path: /target/release/weather -risk_level: low -timeout_secs: 10 -env: [QWEATHER_KEY] -params: - - name: location - required: true - desc: 城市名称,如"北京"、"上海"、"合肥" -``` - ---- - -## 五、日志 - -3 路文件日志 + 终端运维日志: - -| target | 文件 | 内容 | -|--------|------|------| -| `ias::msg` | msg.log | [用户] / [LLM] / [tool] | -| `ias::raw` | raw.log | [llm-request] / [llm-response] | -| `ias::err` | error.log | [spawn] [http] [timeout] [llm-chat] ... | - ---- - -## 六、已清理 - -| 删除 | 原因 | -|------|------| -| `src/tools/builtins/` (8 文件) | 迁移为独立工具 | -| `src/tools/api_tool.rs` | 合并到 executor | -| `src/tools/tool.rs` | Tool trait 不再需要 | -| `src/tools/builtin.rs` | 兼容层 | -| `src/tools/shell_tool.rs` | 不需要 | -| `src/tools/stdio_tool.rs` | 合并到 executor | -| `capability-lib/` | 工具不再需要底座 | -| `tools/dist/` `tools/rules/` | 改为 tools/*/specs/ | -| `cli.rs` ToolCommand 等枚举 | 改为动态 CLI | -| `conversation.rs` 代码块解析 | 改为原生 tool_call | diff --git a/docs/TOOL_PLATFORM_PLAN_IMPL.md b/docs/TOOL_PLATFORM_PLAN_IMPL.md deleted file mode 100644 index 2a773d2..0000000 --- a/docs/TOOL_PLATFORM_PLAN_IMPL.md +++ /dev/null @@ -1,96 +0,0 @@ -# 实现细节 - -> 2024-06-18 当前状态 - ---- - -## 关键文件清单 - -``` -src/ -├── daemon.rs → 主控 (LLM/Tool/Send Consumer, Registry, execute_builtin) -├── main.rs → CLI (cmd_tool_dynamic, cmd_tools) -├── cli.rs → clap 命令定义 -├── logger.rs → 三路日志初始化 -├── tools/ -│ ├── mod.rs → 模块声明 (6 个) -│ ├── spec.rs → ToolSpec + ParamSpec -│ ├── parser.rs → parse_dir() YAML 解析 (4 tests) -│ ├── registry.rs → Registry (load/list/dispatch) -│ ├── executor.rs → execute_loop (spawn→stdout→http/db/result) -│ └── approval.rs → ApprovalManager (6位确认码) -├── llm/ -│ ├── types.rs → Message, ToolCall, StreamChunk, ConversationConfig -│ ├── conversation.rs → Conversation (run_tool_loop, summarize) -│ ├── deepseek.rs → DeepSeek provider (tool_call 流式解析) -│ └── provider.rs → LlmProvider trait -└── context/ - ├── types.rs → ChatSession, add_tool_result - └── builder.rs → build_context (system prompt + 摘要 + 消息) -``` - ---- - -## 工具调用全链路 - -``` -用户消息 - → LLM Consumer: 创建 Conversation, 注册 2 元工具 - → LLM: 调 query_capabilities (tool_call) - → ToolExecutor → PipelineMessage::ToolCall → 消息队列 - → ToolConsumer → execute_builtin → query_capabilities → 返回工具列表 - → ToolResult → LLM Consumer → resume - - → LLM: 调 call_capability(name="weather_now", prompt='{"location":"北京"}') - → ToolConsumer → execute_builtin → call_capability 解包 - → registry.dispatch("weather_now", {"location":"北京"}) - → executor::execute_loop - → spawn weather --command now - → stdout: {"type":"http","url":"...","desc":"..."} - → 审批(如需要) → reqwest::get → response - → spawn weather --command now (带 response) - → stdout: {"type":"result","content":"北京晴,25°C"} - → ToolResult - → LLM Consumer → resume → 生成回复 - → Send Consumer → 微信 -``` - ---- - -## executor::execute_loop 状态机 - -``` -spawn tool - → stdout JSON parse - ├─ {"type":"result","content":"..."} → 返回 content - ├─ {"type":"http","method":"GET","url":"..."} - │ → 审批(high risk) → HTTP → 存入 response - │ → re-spawn (带 response 字段) - └─ {"type":"db","operation":"read_memories",...} - → 审批 → MemoryStore 操作 → 存入 db_response - → re-spawn (带 db_response 字段) -``` - ---- - -## 消息队列路由 - -```rust -MessageKind::UserMessage | ToolResult | ScheduledTask → LLM Consumer -MessageKind::ToolCall | ApprovalRequest → Tool Consumer -MessageKind::LLMReply → Send Consumer -``` - ---- - -## System Prompt - -``` -你是 iAs 智能助手,通过微信与用户交流。回复简洁自然。 - -你可以通过 function calling 调用两个元工具: -1. query_capabilities — 查询所有可用能力工具 -2. call_capability — 调用能力工具,参数 {"name":"工具名","prompt":"{\"参数\":\"值\"}"} - -流程:收到消息 → query_capabilities → call_capability → 回复 -``` diff --git a/service/Cargo.toml b/service/Cargo.toml new file mode 100644 index 0000000..3bdd5a8 --- /dev/null +++ b/service/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "service" +version = "0.1.0" +edition = "2024" + +[dependencies] +# ── 公共模块 ── +common = { path = "../common" } +# ── ai api 调用 ── +ai = { path = "../ai" } +# ── 微信工具包 ── +wechat = { path = "../wechat" } +# ── api ── +axum = "0.8.9" +# ── 异步运行时 ── +tokio = { version = "1.0", features = [ + "rt-multi-thread", + "macros", + "sync", + "time", + "process", + "signal", + "io-util", +] } # 异步运行时核心 +# ── HTTP 客户端 ── +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "stream", + "rustls-tls", +] } # HTTP 请求(微信 API + DeepSeek API + 工具调用) +# ── 序列化 ── +serde = { version = "1.0", features = ["derive"] } # 序列化/反序列化 +serde_json = "1.0" # JSON 处理 +serde_yaml = "0.9" # YAML 解析(工具规范文件) +# ── 环境变量 ── +dotenvy = "0.15.7" +# ── 数据库 ── +sqlx = { version = "0.9.0", features = [ + "runtime-tokio", + "tls-rustls", + "postgres", + "macros", + "migrate", + "chrono", +] } +# ── 错误处理 ── +anyhow = "1" +thiserror = "2" diff --git a/migrations/20260601000001_init.sql b/service/migrations/20260601000001_init.sql similarity index 100% rename from migrations/20260601000001_init.sql rename to service/migrations/20260601000001_init.sql diff --git a/migrations/20260601000002_pending_approvals.sql b/service/migrations/20260601000002_pending_approvals.sql similarity index 100% rename from migrations/20260601000002_pending_approvals.sql rename to service/migrations/20260601000002_pending_approvals.sql diff --git a/migrations/20260601000003_scheduled_tasks.sql b/service/migrations/20260601000003_scheduled_tasks.sql similarity index 100% rename from migrations/20260601000003_scheduled_tasks.sql rename to service/migrations/20260601000003_scheduled_tasks.sql diff --git a/migrations/20260601000004_llm_usage.sql b/service/migrations/20260601000004_llm_usage.sql similarity index 100% rename from migrations/20260601000004_llm_usage.sql rename to service/migrations/20260601000004_llm_usage.sql diff --git a/migrations/20260601000005_user_memories.sql b/service/migrations/20260601000005_user_memories.sql similarity index 100% rename from migrations/20260601000005_user_memories.sql rename to service/migrations/20260601000005_user_memories.sql diff --git a/migrations/20260601000006_session_summaries.sql b/service/migrations/20260601000006_session_summaries.sql similarity index 100% rename from migrations/20260601000006_session_summaries.sql rename to service/migrations/20260601000006_session_summaries.sql diff --git a/migrations/20260601000007_fix_llm_usage.sql b/service/migrations/20260601000007_fix_llm_usage.sql similarity index 100% rename from migrations/20260601000007_fix_llm_usage.sql rename to service/migrations/20260601000007_fix_llm_usage.sql diff --git a/migrations/20260601000008_fix_scheduled_tasks_user_id.sql b/service/migrations/20260601000008_fix_scheduled_tasks_user_id.sql similarity index 100% rename from migrations/20260601000008_fix_scheduled_tasks_user_id.sql rename to service/migrations/20260601000008_fix_scheduled_tasks_user_id.sql diff --git a/migrations/20260601000009_chat_dedup.sql b/service/migrations/20260601000009_chat_dedup.sql similarity index 100% rename from migrations/20260601000009_chat_dedup.sql rename to service/migrations/20260601000009_chat_dedup.sql diff --git a/migrations/20260601000010_fix_llm_usage_legacy_columns.sql b/service/migrations/20260601000010_fix_llm_usage_legacy_columns.sql similarity index 100% rename from migrations/20260601000010_fix_llm_usage_legacy_columns.sql rename to service/migrations/20260601000010_fix_llm_usage_legacy_columns.sql diff --git a/migrations/20260601000011_fix_chat_records_context_token.sql b/service/migrations/20260601000011_fix_chat_records_context_token.sql similarity index 100% rename from migrations/20260601000011_fix_chat_records_context_token.sql rename to service/migrations/20260601000011_fix_chat_records_context_token.sql diff --git a/migrations/20260601000012_fix_chat_dedup.sql b/service/migrations/20260601000012_fix_chat_dedup.sql similarity index 100% rename from migrations/20260601000012_fix_chat_dedup.sql rename to service/migrations/20260601000012_fix_chat_dedup.sql diff --git a/migrations/20260601000013_fix_llm_usage_kind_entry_newdb.sql b/service/migrations/20260601000013_fix_llm_usage_kind_entry_newdb.sql similarity index 100% rename from migrations/20260601000013_fix_llm_usage_kind_entry_newdb.sql rename to service/migrations/20260601000013_fix_llm_usage_kind_entry_newdb.sql diff --git a/migrations/20260601000014_scheduled_tasks_type.sql b/service/migrations/20260601000014_scheduled_tasks_type.sql similarity index 100% rename from migrations/20260601000014_scheduled_tasks_type.sql rename to service/migrations/20260601000014_scheduled_tasks_type.sql diff --git a/migrations/20260601000015_dummy_noop.sql b/service/migrations/20260601000015_dummy_noop.sql similarity index 100% rename from migrations/20260601000015_dummy_noop.sql rename to service/migrations/20260601000015_dummy_noop.sql diff --git a/migrations/20260601000016_session_summaries_user_idx.sql b/service/migrations/20260601000016_session_summaries_user_idx.sql similarity index 100% rename from migrations/20260601000016_session_summaries_user_idx.sql rename to service/migrations/20260601000016_session_summaries_user_idx.sql diff --git a/service/migrations/20260625031750_create_users.sql b/service/migrations/20260625031750_create_users.sql new file mode 100644 index 0000000..e46f8b5 --- /dev/null +++ b/service/migrations/20260625031750_create_users.sql @@ -0,0 +1,10 @@ +-- Add migration script here +CREATE TABLE ias_wechat_users ( + id BIGSERIAL PRIMARY KEY, + user_name VARCHAR(100) NOT NULL, + token VARCHAR(255), + account_id VARCHAR(255), + base_url VARCHAR(255), + user_status INTEGER, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); \ No newline at end of file diff --git a/service/migrations/20260625035946_wecaht_user_add_id.sql b/service/migrations/20260625035946_wecaht_user_add_id.sql new file mode 100644 index 0000000..a3bee0e --- /dev/null +++ b/service/migrations/20260625035946_wecaht_user_add_id.sql @@ -0,0 +1,2 @@ +-- Add migration script here +ALTER TABLE ias_wechat_users ADD COLUMN user_id VARCHAR(255); \ No newline at end of file diff --git a/service/src/channel/mod.rs b/service/src/channel/mod.rs new file mode 100644 index 0000000..49e84a4 --- /dev/null +++ b/service/src/channel/mod.rs @@ -0,0 +1 @@ +pub mod wechat; \ No newline at end of file diff --git a/service/src/channel/wechat.rs b/service/src/channel/wechat.rs new file mode 100644 index 0000000..df0de5a --- /dev/null +++ b/service/src/channel/wechat.rs @@ -0,0 +1,63 @@ +use std::time::Duration; +use wechat::{client::WeChatClient, types::WeixinMessage}; + +use crate::AppState; + +pub async fn init_wechat(state: Option) -> Result<(), Box> { + match state { + Some(state)=>{ + load_config(state); + } + None=>{} + } + + let client = WeChatClient::new(None); + + + let login = client + .login( + |qrcode_url| { + println!("二维码地址: {qrcode_url}"); + }, + 120, + ) + .await?; + + // println!("登录成功: account_id={}", login.account_id); + + // client.notify_start().await?; + + // loop { + // let updates = client.receive_messages().await?; + + // for msg in updates.msgs { + // if msg.msg_type != Some(WeixinMessage::TYPE_USER) { + // continue; + // } + + // let Some(from_user_id) = msg.from_user_id.as_deref() else { + // continue; + // }; + + // let Some(text) = msg.text_content() else { + // continue; + // }; + + // client + // .send_text( + // from_user_id, + // &format!("收到: {text}"), + // msg.context_token.as_deref(), + // ) + // .await?; + // } + + // tokio::time::sleep(Duration::from_secs(1)).await; + // } + Ok(()) +} + +fn load_config(state: AppState){ + let db = state.db; + +} \ No newline at end of file diff --git a/service/src/core/context.rs b/service/src/core/context.rs new file mode 100644 index 0000000..322bc0e --- /dev/null +++ b/service/src/core/context.rs @@ -0,0 +1,36 @@ +use std::sync::OnceLock; + +use tokio::sync::Mutex; + +use common::ai::ChatCompletionRequestMessage; + +static CHAT_CONTEXT: OnceLock>> = OnceLock::new(); + +fn chat_context() -> &'static Mutex> { + CHAT_CONTEXT.get_or_init(|| Mutex::new(Vec::new())) +} + +pub async fn push_context_message(msg: ChatCompletionRequestMessage) { + let mut context = chat_context().lock().await; + context.push(msg); +} + +pub async fn snapshot_context() -> Vec { + let context = chat_context().lock().await; + context.clone() +} + +pub async fn add_assistant_message(content: String) { + let message = ChatCompletionRequestMessage::new("assistant".to_string(), content); + push_context_message(message).await; +} + +pub async fn add_user_message(content: String) { + let message = ChatCompletionRequestMessage::new("user".to_string(), content); + push_context_message(message).await; +} + +pub async fn add_tool_message(call_id: String, result: String) { + let message = ChatCompletionRequestMessage::tool("user".to_string(), result, call_id); + push_context_message(message).await; +} diff --git a/service/src/core/mod.rs b/service/src/core/mod.rs new file mode 100644 index 0000000..7675c2e --- /dev/null +++ b/service/src/core/mod.rs @@ -0,0 +1,2 @@ +pub mod queue; +pub mod context; \ No newline at end of file diff --git a/service/src/core/queue.rs b/service/src/core/queue.rs new file mode 100644 index 0000000..7769525 --- /dev/null +++ b/service/src/core/queue.rs @@ -0,0 +1,62 @@ +use crate::{ + AppState, + core::context::{add_assistant_message, add_tool_message, add_user_message, snapshot_context}, +}; +use ai::core::send_message; +use axum::Json; +use axum::extract::State; +use common::queue::{ChannelMessage, QueueMessage}; +use serde_json::error; +use std::sync::Arc; +use tokio::sync::{mpsc, mpsc::Sender}; + +pub async fn create_queue() -> Result, error::Error> { + let (sender, mut receiver) = mpsc::channel::(100); + let result_sender = sender.clone(); + tokio::spawn(async move { + while let Some(msg) = receiver.recv().await { + let queue_sender = sender.clone(); + match msg { + QueueMessage::Aissitant(reasoning, content) => { + println!("\n\n收到llm回复:\n{}\n-------\n{}", reasoning, content); + add_assistant_message(content).await; + } + QueueMessage::Channel(name, text) => { + println!("\n\n收到来自{}消息:\n{}", name, text); + add_user_message(text).await; + send_message(queue_sender, snapshot_context().await) + .await + .unwrap(); + } + QueueMessage::Tool(tool_call_id, name, result) => { + println!("\n\n调用{}工具的结果:\n{}", name, result); + add_tool_message(tool_call_id, result).await; + send_message(queue_sender, snapshot_context().await) + .await + .unwrap(); + } + } + } + }); + Ok(result_sender) +} + +pub async fn send_to_queue( + State(state): State>, + Json(body): Json, +) -> Json { + match state + .sender + .send(QueueMessage::Channel(body.channel, body.content)) + .await + { + Ok(_) => Json(serde_json::json!({ + "success":true, + "message":"queued" + })), + Err(_) => Json(serde_json::json!({ + "success":true, + "message":"queue closed" + })), + } +} diff --git a/service/src/error.rs b/service/src/error.rs new file mode 100644 index 0000000..615c689 --- /dev/null +++ b/service/src/error.rs @@ -0,0 +1,33 @@ +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use serde_json::json; + +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("not found")] + NotFound, + #[error(transparent)] + AnyHow(#[from] anyhow::Error), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match self { + AppError::NotFound => (StatusCode::NOT_FOUND, "resource not found".to_string()), + AppError::AnyHow(err) => { + eprintln!("internal error: {err:?}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal server error".to_string(), + ) + } + }; + (status, Json(json!({"error":message}))).into_response() + } +} + +pub type AppResult = Result; diff --git a/service/src/http/app.rs b/service/src/http/app.rs new file mode 100644 index 0000000..c5c7551 --- /dev/null +++ b/service/src/http/app.rs @@ -0,0 +1,25 @@ +use std::sync::Arc; + +use axum::routing::{get, post}; +use axum::{Json, Router}; + +use crate::AppState; +use crate::core::queue::send_to_queue; + +pub async fn create_http(state: AppState, host: String) { + let app = Router::new() + .route("/message", post(send_to_queue)) + .route("/health", get(health)) + .with_state(Arc::new(state)); + + let listener = tokio::net::TcpListener::bind(host.clone()).await.unwrap(); + axum::serve(listener, app).await.unwrap(); + println!("服务启动在:{}", host); +} + +async fn health() -> Json { + Json(serde_json::json!({ + "success": true, + "message": "service is running" + })) +} diff --git a/service/src/http/mod.rs b/service/src/http/mod.rs new file mode 100644 index 0000000..02c0277 --- /dev/null +++ b/service/src/http/mod.rs @@ -0,0 +1 @@ +pub mod app; \ No newline at end of file diff --git a/service/src/main.rs b/service/src/main.rs new file mode 100644 index 0000000..dbe0471 --- /dev/null +++ b/service/src/main.rs @@ -0,0 +1,55 @@ +mod channel; +mod core; +mod error; +mod http; +mod repository; + +use common::queue::QueueMessage; +use core::queue::create_queue; +use http::app::create_http; +use sqlx::{PgPool, postgres::PgPoolOptions}; +use std::env; + +use tokio::sync::mpsc; + +use crate::channel::wechat::init_wechat; + +#[derive(Clone)] +pub struct AppState { + pub sender: mpsc::Sender, + pub db: PgPool, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + if std::env::var("RUST_ANALYZER").is_err() { + dotenv::dotenv().ok(); + } + // dotenvy::dotenv().ok(); + let name = env::var("NAME").unwrap_or_else(|_| "ias".to_string()); + let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0:9003".to_string()); + let db_url = env::var("DATABASE_URL").expect("必须设置DATABASE_URL"); + + let pool = PgPoolOptions::new() + .max_connections(10) + .connect(&db_url) + .await?; + + let sender = create_queue().await.unwrap(); + let state = AppState { + sender: sender, + db: pool, + }; + + println!("{} start in : {}", name, host); + create_http(state.clone(), host).await; + init_wechat(Some(state)).await.unwrap(); + + Ok(()) +} + +#[test] +fn test() { + let api_key = std::env::var("ROUTER_API_KEY").unwrap(); + println!("{}", api_key); +} diff --git a/service/src/repository/mod.rs b/service/src/repository/mod.rs new file mode 100644 index 0000000..a5188ff --- /dev/null +++ b/service/src/repository/mod.rs @@ -0,0 +1 @@ +pub mod user_repository; diff --git a/service/src/repository/user_repository.rs b/service/src/repository/user_repository.rs new file mode 100644 index 0000000..6215d39 --- /dev/null +++ b/service/src/repository/user_repository.rs @@ -0,0 +1,39 @@ +use sqlx::PgPool; + +use common::db::user::{WechatUser, WechatUserCreate}; + +pub struct UserRepositor; + +impl UserRepositor { + pub async fn create(pool: &PgPool, user: WechatUserCreate) -> anyhow::Result { + let record = sqlx::query!( + r#" + INSERT INTO ias_wechat_users (user_name,token,account_id,base_url,user_id,user_status) + VALUE ($1,$2,$3,$4,$5,1) + RETURNING id + "#, + user.user_name, + user.token, + user.account_id, + user.base_url, + user.user_id, + ) + .fetch_one(pool) + .await?; + Ok(record.id) + } + pub async fn find_by_user_id(pool: &PgPool, user_id: isize) -> anyhow::Result { + let user = sqlx::query_as!( + WechatUser, + r#" + SELECT * + FROM ias_wechat_users + WHERE user_id = $1 + "#, + user_id, + ) + .fetch_optional(pool) + .await?; + Ok(user) + } +} diff --git a/src/channel/mod.rs b/src/channel/mod.rs deleted file mode 100644 index 8ab4dc9..0000000 --- a/src/channel/mod.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! ## 渠道/通道基础类型 -//! -//! 定义消息的来源和目标渠道标识,以及从外部轮询收到的原始消息。 -//! -//! ### 设计意图 -//! -//! `ChannelId` 是本项目中所有消息路由的核心标识符。 -//! 它由 `platform`(平台名,如 "wechat")和 `user_id`(用户 ID)两部分组成, -//! 确保不同平台上的用户可以被唯一区分。 -//! -//! 未来如果要接入 Telegram、Discord 等新平台,只需要新增 `ChannelId` 的构造方法, -//! 消息队列的路由逻辑无需改动。 - -use serde::{Deserialize, Serialize}; -use std::fmt; - -/// ## 渠道标识 —— 区分消息来自哪个平台和哪个用户 -/// -/// 这是消息队列和 daemon 中所有消息路由的基础。 -/// 每个 `PipelineMessage` 都携带一个 `ChannelId`,用来追踪消息的来龙去脉。 -/// -/// ### 字段 -/// * `platform` — 平台名称,如 `"wechat"`、`"telegram"`、`"console"` -/// * `user_id` — 在该平台上的用户标识 -/// -/// ### 示例 -/// ``` -/// let ch = ChannelId::wechat("wxid_123"); -/// assert_eq!(ch.to_string(), "wechat:wxid_123"); -/// ``` -#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)] -pub struct ChannelId { - /// 平台名称,如 "wechat"、"telegram"、"console" - pub platform: String, - /// 在该平台上的用户 ID - pub user_id: String, -} - -impl ChannelId { - /// 快捷构造微信渠道 - pub fn wechat(user_id: impl Into) -> Self { - Self { - platform: "wechat".into(), - user_id: user_id.into(), - } - } -} - -impl fmt::Display for ChannelId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}:{}", self.platform, self.user_id) - } -} - -/// ## 从外部轮询收到的原始消息 -/// -/// 在 daemon 的主循环中,从 WeChat API 获取到的消息会被转换为这个结构, -/// 然后封装成 `PipelineMessage` 投递到消息队列。 -/// -/// * `channel` — 来源渠道(平台 + 用户) -/// * `text` — 消息文本 -/// * `message_id` — 消息 ID -/// * `context_token` — 微信的上下文令牌(用于多轮对话的上下文恢复) -#[derive(Debug, Clone, Serialize, Deserialize)] -#[allow(dead_code)] -pub struct IncomingMessage { - pub channel: ChannelId, - pub text: String, - pub message_id: String, - pub context_token: Option, -} diff --git a/src/cli.rs b/src/cli.rs deleted file mode 100644 index 3165b3d..0000000 --- a/src/cli.rs +++ /dev/null @@ -1,243 +0,0 @@ -//! ## CLI 子命令定义 —— clap 参数解析 -//! -//! 定义了 iAs 的所有命令行子命令和参数: -//! -//! | 命令 | 说明 | -//! |------|------| -//! | `ias login` | 扫码登录微信 | -//! | `ias listen --llm` | 启动 AI 自动回复 | -//! | `ias send --to --text ` | 手动发送消息 | -//! | `ias whoami` | 查看登录状态 | -//! | `ias usage` | Token 用量统计 | -//! | `ias service` | 后台服务模式 | -//! | `ias daemon` | 守护进程模式 | -//! | `ias tool ` | 调用内置工具 | - -use clap::{Parser, Subcommand}; - -/// ## iAs —— 微信 AI 智能助手 -/// -/// 支持扫码登录、长轮询收发消息、AI 自动回复(DeepSeek)、 -/// 内置工具(天气/搜索/备忘录/日期时间等)、Token 用量统计。 -/// -/// ### 快速开始 -/// ```bash -/// ias login # 扫码登录微信 -/// ias listen --llm # 启动 AI 自动回复 -/// ias tool weather 北京 # 查询天气 -/// ias tool search "最新新闻" # 联网搜索 -/// ``` -#[derive(Parser, Debug)] -#[command(name = "ias", version, about, long_about = None)] -pub struct Cli { - #[command(subcommand)] - pub command: Commands, -} - -#[derive(Subcommand, Debug)] -pub enum Commands { - /// 扫码登录微信 - /// - /// 在终端显示登录二维码,扫描后完成登录。 - /// 认证信息存入 PostgreSQL 或本地文件。 - /// - /// 示例: - /// ias login - /// ias login --timeout 600 - Login { - /// 登录超时秒数(默认 480s) - #[arg(long, default_value = "480")] - timeout: u64, - }, - - /// 守护进程模式(常驻后台,持有 WeChat 长连接和数据库) - /// - /// 示例: - /// ias daemon - /// ias daemon --log-file # 启用文件日志(配合 systemd) - Daemon { - /// 启用文件日志(默认输出到 stderr) - #[arg(long)] - log_file: bool, - }, - - /// 发送文本消息 - /// - /// 示例: - /// ias send --to wxid_xxx --text "你好" - Send { - /// 目标用户 ID - #[arg(long)] - to: String, - - /// 消息内容 - #[arg(long)] - text: String, - - /// 上下文令牌 - #[arg(long)] - context_token: Option, - }, - - /// 查看当前登录状态 - Whoami, - - /// 查看 LLM Token 使用统计 - /// - /// 默认查询最近 7 天。 - /// - /// 示例: - /// ias usage - /// ias usage --since 2026-06-01T00:00:00Z --model deepseek-v4-flash - Usage { - /// 起始时间(RFC3339 格式) - #[arg(long)] - since: Option, - - /// 结束时间(RFC3339 格式) - #[arg(long)] - until: Option, - - /// 按模型名称过滤 - #[arg(long)] - model: Option, - }, - - /// 管理定时任务(增删改查) - /// - /// 需要 PostgreSQL 数据库。 - /// - /// 示例: - /// ias task list - /// ias task add --name 每日天气 --user wx_xxx --command "curl -s wttr.in/Beijing?format=3" --interval 86400 - /// ias task update --name "新名称" --interval 3600 - /// ias task delete - /// ias task toggle - #[command(subcommand)] - ScheduledTask(ScheduledTaskAction), - - /// 列出所有可用的 LLM 工具 - /// - /// 显示 LLM 可以调用的所有工具名称、描述、参数格式和风险等级。 - /// - /// 示例: - /// ias tools - Tools, - - /// 调用注册的工具(支持 CLI 参数) - /// - /// 示例: - /// ias tool list # 列出所有可用工具 - /// ias tool get_current_datetime - /// ias tool weather_now --location 北京 - /// ias tool amap_poi_search --keywords 咖啡厅 --city 北京 - /// ias tool web_search --query "Rust async" - /// - /// 工具名来自 tools/rules/*.tool.yaml,参数自动匹配 YAML 定义。 - #[command( - after_help = "工具名来自 tools/rules/*.tool.yaml,参数自动匹配 YAML 定义。运行 ias tool list 查看所有可用工具。" - )] - Tool { - /// 工具名(如 weather_now、amap_poi_search、get_current_datetime) - /// 特殊值 "list" 列出所有可用工具 - #[arg(default_value = "list")] - name: String, - - /// 工具参数,格式 --key value - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] - args: Vec, - }, -} - -// ─── 定时任务子命令 ─── - -#[derive(Subcommand, Debug, Clone)] -pub enum ScheduledTaskAction { - /// 列出所有定时任务 - List, - - /// 添加定时任务 - /// - /// 示例: - /// ias task add --name 每日天气 --user wx_xxx --command "curl -s wttr.in/Beijing?format=3" --interval 86400 - Add { - /// 任务名称(唯一) - #[arg(long)] - name: String, - - /// 所属用户 ID - #[arg(long)] - user: String, - - /// 要执行的 shell 命令 - #[arg(long)] - command: String, - - /// 执行间隔(秒) - #[arg(long)] - interval: u32, - - /// 任务描述 - #[arg(long)] - description: Option, - - /// Shell 解释器(默认 /bin/bash) - #[arg(long)] - shell: Option, - - /// 工作目录 - #[arg(long)] - cwd: Option, - }, - - /// 更新定时任务 - /// - /// 示例: - /// ias task update --name "新名称" --interval 3600 - Update { - /// 任务 ID(UUID) - id: String, - - /// 新名称 - #[arg(long)] - name: Option, - - /// 新命令 - #[arg(long)] - command: Option, - - /// 新执行间隔(秒) - #[arg(long)] - interval: Option, - - /// 新描述 - #[arg(long)] - description: Option, - - /// 新 Shell 解释器 - #[arg(long)] - shell: Option, - - /// 新工作目录 - #[arg(long)] - cwd: Option, - }, - - /// 删除定时任务 - /// - /// 示例: - /// ias task delete - Delete { - /// 任务 ID(UUID) - id: String, - }, - - /// 启用/禁用定时任务 - /// - /// 示例: - /// ias task toggle - Toggle { - /// 任务 ID(UUID) - id: String, - }, -} diff --git a/src/context/builder.rs b/src/context/builder.rs deleted file mode 100644 index cf1a41c..0000000 --- a/src/context/builder.rs +++ /dev/null @@ -1,508 +0,0 @@ -//! ## 上下文构建器 —— Token 估算 + 上下文组装 + 摘要触发 -//! -//! 这是 LLM 调用前的关键步骤: -//! -//! 1. **Token 估算** — 中/英文分别估算 token 消耗 -//! 2. **上下文构建** — 从 ChatSession 中提取合适的消息,在 token budget 内保留最多上下文 -//! 3. **摘要触发** — 溢出摘要(token 超 budget)和空闲超时摘要(12h 无消息) -//! -//! ### Token 估算公式 -//! - 中文字符 ≈ 1.5 tokens/字 -//! - 英文字符 ≈ 0.25 tokens/字符 -//! - 每条消息 + 8 tokens 格式开销 - -use crate::context::types::ChatSession; -use crate::llm::conversation::Summarizer; -use crate::llm::types::{Message, Role}; -use std::collections::BTreeSet; -use std::sync::Arc; -use tokio::sync::Mutex; - -/// ## 估算消息的 token 数 -/// -/// 粗略估算一条消息的 token 消耗,用于 token budget 管理。 -/// 公式:`文本 token + 8`(8 是消息格式的开销)。 -/// -/// 注意这只是估算,不是精确值,但在实践中足够做预算判断。 -pub fn estimate_tokens(msg: &Message) -> u32 { - let text = msg.content.as_str(); - estimate_text_tokens(text) + 8 -} - -/// ## 估算纯文本的 token 数 -/// -/// 中文和英文的 token 消耗不同: -/// * 中文字符 ≈ 1.5 tokens/字(CJK 字符更密集) -/// * 英文字符 ≈ 0.25 tokens/字符 -/// -/// 这是一个粗略估算,用于在调用 API 之前判断上下文是否超 budget。 -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) -} - -fn matching_tool_call_anchor(recent: &[Message], tool_call_id: &str) -> Option { - recent.iter().enumerate().rev().find_map(|(idx, msg)| { - if msg.role != Role::Assistant { - return None; - } - let has_match = msg - .tool_calls - .as_ref() - .is_some_and(|calls| calls.iter().any(|call| call.id == tool_call_id)); - has_match.then_some(idx) - }) -} - -fn collect_context_block(recent: &[Message], start_idx: usize) -> (Vec, u32) { - let mut indexes = BTreeSet::from([start_idx]); - - let msg = &recent[start_idx]; - if msg.role == Role::Tool - && let Some(tool_call_id) = msg.tool_call_id.as_deref() - && let Some(anchor_idx) = matching_tool_call_anchor(recent, tool_call_id) - { - indexes.insert(anchor_idx); - } - - if msg.role == Role::Assistant - && let Some(tool_calls) = &msg.tool_calls - { - for (idx, next_msg) in recent.iter().enumerate().skip(start_idx + 1) { - if next_msg.role != Role::Tool { - continue; - } - if let Some(tool_call_id) = next_msg.tool_call_id.as_deref() - && tool_calls.iter().any(|call| call.id == tool_call_id) - { - indexes.insert(idx); - } - } - } - - let ordered: Vec = indexes.into_iter().collect(); - let tokens = ordered - .iter() - .map(|&idx| estimate_tokens(&recent[idx])) - .sum(); - (ordered, tokens) -} - -/// ## 构建给 LLM 的消息数组(带 token budget 管理) -/// -/// 这是 LLM 调用前的关键步骤 —— 从 `ChatSession` 中提取合适的消息, -/// 在 token 预算内尽可能保留更多上下文。 -/// -/// ### 构建策略 -/// 1. **系统提示词** — 始终包含 -/// 2. **溢出摘要** — 如果有最新的 overflow 摘要,加入(仍在 budget 内时) -/// 3. **近期消息** — 从 checkpoint 开始,从最新消息倒序添加,直到接近 budget -/// 4. **保护最近用户消息** — 确保最后一条用户消息始终在上下文中 -/// (即使超过 budget,也在 +2000 范围内强制包含) -pub async fn build_context(session: &Arc>, system_prompt: &str) -> Vec { - 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_indexes = BTreeSet::new(); - let mut tail_used = 0u32; - - // 预留 headroom 给 LLM 回复:500 tokens 或 budget 的 25%,取较小值 - let reserve = 500.min(s.token_budget / 4); - let headroom = s.token_budget - reserve; - - for start_idx in (0..recent.len()).rev() { - if included_indexes.contains(&start_idx) { - continue; - } - let (block_indexes, block_tokens) = collect_context_block(&recent, start_idx); - if used + tail_used + block_tokens > headroom { - break; - } - tail_used += block_tokens; - included_indexes.extend(block_indexes); - } - - // 保护:确保最近一条用户消息始终在上下文中 - if let Some((last_user_idx, last_user)) = recent - .iter() - .enumerate() - .rev() - .find(|(_, m)| m.role == Role::User) - { - if !included_indexes.contains(&last_user_idx) { - let t = estimate_tokens(last_user); - if used + tail_used + t <= s.token_budget + 2000 { - included_indexes.insert(last_user_idx); - } - } - } - - let included: Vec = included_indexes - .into_iter() - .map(|idx| recent[idx].clone()) - .collect(); - - messages.extend(included); - - messages -} - -/// ## 触发溢出摘要 —— 压缩 checkpoint 到当前位置之间的新消息 -/// -/// 当上下文 token 超 budget 时调用。把 checkpoint 之后的消息交给 LLM 生成摘要, -/// 然后推进 checkpoint,让旧消息可以被释放(或截断)。 -/// -/// ### 流程 -/// 1. 获取 checkpoint 到当前末尾的消息(最多 40 条) -/// 2. 用 LLM summarizer 生成摘要(不可用时回退到简单截断) -/// 3. 将摘要存入 `summaries` 列表 -/// 4. 持久化到数据库 -/// 5. 推进 checkpoint 到当前末尾 -/// 6. 如果 checkpoint 超过 200,截断最早的消息(保留最近 100 条用于调试) -pub async fn trigger_overflow_summary( - session: &Arc>, - summarizer: Option<&Summarizer>, -) -> bool { - let mut s = session.lock().await; - if s.messages.is_empty() { - return false; - } - - let prev_checkpoint = s.checkpoint; - let end = s.messages.len(); - - // checkpoint 始终在 user+assistant 对边界,直接用它作为摘要起点 - let start = prev_checkpoint; - if start >= end { - return false; - } - - let to_summarize: Vec<_> = s.messages[start..end] - .iter() - .take(40) // 最多 40 条 - .cloned() - .collect(); - - if to_summarize.is_empty() { - return false; - } - - let summary_text = generate_summary(&to_summarize, summarizer).await; - - s.summaries.push(super::types::SummaryEntry { - checkpoint: prev_checkpoint, - text: summary_text.clone(), - reason: super::types::SummaryReason::Overflow, - created_at: chrono::Utc::now(), - }); - - // 持久化到数据库 - s.save_summary_to_db(&summary_text, "overflow", to_summarize.len() as i32) - .await; - - s.checkpoint = end; - - // 清理太旧的消息(checkpoint 之前的保留最近 100 条用于调试) - if s.checkpoint > 200 { - let keep = s.checkpoint - 100; - s.messages.drain(0..keep); - // 调整 checkpoint 和 summary checkpoint - let shift = keep; - s.checkpoint -= shift; - for sum in &mut s.summaries { - if sum.checkpoint >= shift { - sum.checkpoint -= shift; - } - } - } - - true -} - -/// ## 触发空闲超时摘要(不注入上下文,只存档) -/// -/// 当距上条用户消息超过 12 小时时触发。 -/// 与溢出摘要不同,空闲摘要**不会**作为 system prompt 注入到下一次对话中, -/// 而是只保存到数据库,供 `read_summaries` 工具查询。 -/// -/// 生成摘要后会清空所有消息,checkpoint 重置为 0。 -/// 该摘要的 `created_at` 同时作为"会话边界"——之后加载历史时 -/// 不会包含此时间点之前的消息(见 `db::latest_session_boundary`)。 -pub async fn trigger_idle_summary( - session: &Arc>, - summarizer: Option<&Summarizer>, -) { - let _ = archive_session(session, summarizer, super::types::SummaryReason::Timeout, "timeout") - .await; -} - -/// ## 触发清空上下文摘要(`/clear` 指令) -/// -/// 用户发送 `/clear` 时调用。把当前会话消息交给 LLM 生成摘要并存档, -/// 然后清空消息、重置 checkpoint,使会话"过期"。 -/// -/// 与空闲超时摘要一样: -/// * 摘要**不会**注入到下一次对话,只存档供 `read_summaries` 查询 -/// * 摘要的 `created_at` 作为会话边界,之后加载历史不再包含此前的消息 -/// -/// 返回生成的摘要文本;若会话本就没有消息,返回 `None`。 -pub async fn trigger_clear_summary( - session: &Arc>, - summarizer: Option<&Summarizer>, -) -> Option { - archive_session(session, summarizer, super::types::SummaryReason::Clear, "clear").await -} - -/// 归档当前会话:生成摘要 → 存库 → 清空消息 → 重置 checkpoint。 -/// -/// `trigger_idle_summary` 与 `trigger_clear_summary` 共用此逻辑, -/// 仅 `reason` 不同(Timeout / Clear),二者都代表"会话过期"。 -/// -/// 返回生成的摘要文本;若会话没有消息可归档,返回 `None`。 -async fn archive_session( - session: &Arc>, - summarizer: Option<&Summarizer>, - reason: super::types::SummaryReason, - reason_str: &str, -) -> Option { - let mut s = session.lock().await; - if s.messages.is_empty() { - return None; - } - - let summary_text = generate_summary(&s.messages, summarizer).await; - let cp = s.messages.len(); - - s.summaries.push(super::types::SummaryEntry { - checkpoint: cp, - text: summary_text.clone(), - reason, - created_at: chrono::Utc::now(), - }); - - let msg_count = cp as i32; - s.save_summary_to_db(&summary_text, reason_str, msg_count) - .await; - - s.checkpoint = s.messages.len(); - - // 清理旧消息(checkpoint 之前的全部清除) - if s.checkpoint > 0 { - let shift = s.checkpoint; - s.messages.drain(0..shift); - s.checkpoint = 0; - for sum in &mut s.summaries { - if sum.checkpoint >= shift { - sum.checkpoint -= shift; - } - } - } - - Some(summary_text) -} - -/// 生成摘要:优先使用 LLM,不可用时回退到简单截断 -async fn generate_summary(messages: &[Message], summarizer: Option<&Summarizer>) -> String { - if let Some(summarizer) = summarizer - && messages.len() >= 3 - { - let prompt = build_summary_prompt(messages); - match summarizer(prompt).await { - Ok(text) if !text.is_empty() => { - tracing::info!(target: "ias::tool", "LLM 摘要: {:.100}...", text); - return text; - } - _ => tracing::warn!(target: "ias::tool", "LLM 摘要失败,回退到简单截断"), - } - } - summarize_messages(messages) -} - -/// 构建给 LLM 的摘要 prompt -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 - }) - .map(|m| { - let role = if m.role == crate::llm::types::Role::User { - "用户" - } else { - "助手" - }; - let content: String = m.content.chars().take(200).collect(); - format!("{}: {}", role, content) - }) - .collect::>() - .join("\n"); - - format!( - "请将以下对话压缩为简短摘要,保留关键事实、用户偏好、决定和待办事项。只输出摘要,不要其他内容。\n\n对话:\n{}", - convo - ) -} - -/// 简单的摘要生成(提取最近 N 条消息的关键内容) -fn summarize_messages(messages: &[Message]) -> String { - // 简单实现:提取用户消息的前 80 个字符作为摘要 - let lines: Vec = messages - .iter() - .filter(|m| { - m.role == crate::llm::types::Role::User || m.role == crate::llm::types::Role::Assistant - }) - .map(|m| { - let role = match m.role { - crate::llm::types::Role::User => "用户", - crate::llm::types::Role::Assistant => "助手", - _ => "", - }; - let preview: String = m.content.chars().take(80).collect(); - format!("{}: {}", role, preview) - }) - .collect(); - - if lines.is_empty() { - "暂无历史对话".to_string() - } else { - let result = format!( - "历史对话摘要({} 条消息):\n{}", - lines.len(), - lines.join("\n") - ); - if result.chars().count() > 2000 { - format!( - "{}...(已截断)", - result.chars().take(2000).collect::() - ) - } else { - result - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::context::types::ChatSession; - use crate::llm::types::{ToolCall, ToolFunctionCall}; - - #[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); - } - - #[tokio::test] - async fn keeps_tool_result_with_matching_assistant_tool_call() { - let mut session = ChatSession::new(); - session.token_budget = 80; - session.add_user("请重新查看工具列表".to_string()); - session.messages.push(Message::assistant_with_tool_calls( - "我先查看一下工具列表。", - vec![ToolCall { - id: "tc_1".to_string(), - call_type: "function".to_string(), - function: ToolFunctionCall { - name: "query_capabilities".to_string(), - arguments: "{}".to_string(), - }, - }], - )); - session.add_tool_result("tc_1", "query_capabilities", "工具列表如下"); - - let session = Arc::new(Mutex::new(session)); - let messages = build_context(&session, "sys").await; - - let has_tool = messages.iter().any(|m| m.role == Role::Tool); - let has_anchor = messages.iter().any(|m| { - m.tool_calls - .as_ref() - .is_some_and(|calls| calls.iter().any(|c| c.id == "tc_1")) - }); - let has_preface = messages - .iter() - .any(|m| m.role == Role::Assistant && m.content == "我先查看一下工具列表。"); - - assert!(has_tool); - assert!(has_anchor); - assert!(has_preface); - } - - #[tokio::test] - async fn trigger_clear_summary_archives_and_clears() { - use crate::context::types::SummaryReason; - - let mut session = ChatSession::new(); - session.add_user("今天天气怎么样".to_string()); - session.add_assistant("北京今天晴,25度".to_string()); - session.add_user("明天呢".to_string()); - session.add_assistant("明天多云转小雨".to_string()); - - let arc = Arc::new(Mutex::new(session)); - let summarizer: Summarizer = Arc::new(|_prompt: String| { - Box::pin(async { Ok("摘要:讨论了北京近两天天气".to_string()) }) - }); - - let summary = trigger_clear_summary(&arc, Some(&summarizer)).await; - - assert!(summary.is_some()); - assert_eq!(summary.unwrap(), "摘要:讨论了北京近两天天气"); - let s = arc.lock().await; - assert!(s.messages.is_empty(), "清空后消息应被清空"); - assert_eq!(s.checkpoint, 0); - assert!(s - .summaries - .iter() - .any(|x| x.reason == SummaryReason::Clear)); - } - - #[tokio::test] - async fn trigger_clear_summary_empty_session_returns_none() { - let arc = Arc::new(Mutex::new(ChatSession::new())); - let summarizer: Summarizer = - Arc::new(|_p: String| Box::pin(async { Ok("x".to_string()) })); - let summary = trigger_clear_summary(&arc, Some(&summarizer)).await; - assert!(summary.is_none()); - } -} diff --git a/src/context/mod.rs b/src/context/mod.rs deleted file mode 100644 index 348c4bc..0000000 --- a/src/context/mod.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! ## 上下文管理 —— 对话状态 + Token 预算 + 长期记忆 -//! -//! 管理 LLM 对话的上下文状态,包括: -//! -//! - `types` — `ChatSession` 核心数据结构(消息历史、摘要、checkpoint) -//! - `builder` — Token 预算估算、上下文构建、摘要触发 -//! - `tools` — `MemoryStore` 长期记忆管理器 -//! -//! ### 关键设计 -//! - **Checkpoint 机制**:checkpoint 之前的消息被压缩成摘要,之后的消息保持完整 -//! - **Token Budget**:默认 28000 tokens,留 4000 给回复 -//! - **双重摘要**:溢出摘要(token 超预算)和空闲超时摘要(12h 无消息) - -pub mod builder; -pub mod tools; -pub mod types; - -pub use tools::MemoryStore; -pub use types::HistoryEntry; diff --git a/src/context/tools.rs b/src/context/tools.rs deleted file mode 100644 index 2f9e6b8..0000000 --- a/src/context/tools.rs +++ /dev/null @@ -1,314 +0,0 @@ -//! ## 长期记忆管理器 + 摘要读取工具 -//! -//! 提供两个核心功能: -//! -//! 1. **MemoryStore** — 按用户隔离的长期记忆存储(内存缓存 + PostgreSQL 双写) -//! 2. **read_summaries** — 读取历史会话摘要(内存 + 数据库合并去重) -//! -//! ### 数据流 -//! ```text -//! 启动时 load(user_id) → 从数据库加载到缓存 -//! 对话中 read_for() → 从缓存读取,返回 "[id] ..." 格式 -//! 对话中 write_for() → 写入缓存 + 写入数据库,返回 id -//! 对话中 delete_for() / update_for() → 按 id 删除或更新 -//! ``` - -use sqlx::PgPool; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Instant; -use tokio::sync::Mutex; - -/// 缓存条目:数据库 id + 记忆内容 -type CacheEntry = (i32, String); - -/// 单用户缓存状态 -struct UserCache { - memories: Vec, - last_access: Instant, -} - -/// 缓存 TTL:1 小时无活动则清理 -const CACHE_TTL_SECS: u64 = 3600; - -/// 每用户记忆上限(防止单用户撑爆) -const MAX_MEMORIES_PER_USER: usize = 200; - -/// 默认返回给 LLM 的记忆条数 -const DEFAULT_MEMORY_LIMIT: usize = 30; - -/// ## 长期记忆管理器(内存 + PostgreSQL 双写) -/// -/// 为每个用户维护独立的长期记忆列表,支持读写删改操作。 -/// -/// ### 存储策略 -/// * **内存缓存** — `HashMap`,按 user_id 隔离 -/// * **数据库持久化** — 可选的 `PgPool`,写入时同时写到 PostgreSQL -/// -/// ### 数据流 -/// ```text -/// 启动时 load(user_id) → 从数据库加载到缓存 -/// 对话中 read_for() → 从缓存读取,返回 "[id] ..." 格式 -/// 对话中 write_for() → INSERT RETURNING id,缓存存 (id, content) -/// 对话中 delete_for() → 删缓存 + DELETE DB -/// 对话中 update_for() → 更新缓存 + UPDATE DB -/// ``` -pub struct MemoryStore { - pool: Option>, - cache: Arc>>, -} - -impl MemoryStore { - pub fn new(pool: Option>) -> Self { - Self { - pool, - cache: Arc::new(Mutex::new(HashMap::new())), - } - } - - /// 更新用户访问时间 - async fn touch(&self, uid: &str) { - let mut cache = self.cache.lock().await; - if let Some(uc) = cache.get_mut(uid) { - uc.last_access = Instant::now(); - } - } - - /// 清理过期缓存(超过 1h 无活动的用户) - async fn evict_expired(&self) { - let now = Instant::now(); - let mut cache = self.cache.lock().await; - cache.retain(|_, uc| now.duration_since(uc.last_access).as_secs() < CACHE_TTL_SECS); - } - - /// 条件加载:仅在缓存未命中时查 DB - pub async fn load_if_needed(&self, user_id: &str) { - // 先清理过期缓存 - self.evict_expired().await; - - let uid = if user_id.is_empty() { - "default" - } else { - user_id - }; - { - let cache = self.cache.lock().await; - if cache.contains_key(uid) { - return; // 缓存已有,跳过 DB 查询 - } - } - self.load(user_id).await; - } - - pub async fn load(&self, user_id: &str) { - let pool = match self.pool.as_ref() { - Some(p) => p.clone(), - None => return, - }; - let uid = if user_id.is_empty() { - "default" - } else { - user_id - }; - if let Ok(rows) = sqlx::query_as::<_, (i32, String)>( - "SELECT id, content FROM user_memories WHERE user_id = $1 ORDER BY id", - ) - .bind(uid) - .fetch_all(pool.as_ref()) - .await - { - self.cache.lock().await.insert( - uid.to_string(), - UserCache { - memories: rows, - last_access: Instant::now(), - }, - ); - } - } - - pub async fn read_for(&self, user_id: &str, limit: Option) -> String { - let uid = if user_id.is_empty() { - "default" - } else { - user_id - }; - self.touch(uid).await; - - let max = limit.unwrap_or(DEFAULT_MEMORY_LIMIT); - let cache = self.cache.lock().await; - match cache.get(uid) { - Some(uc) if !uc.memories.is_empty() => { - let mems = &uc.memories; - let start = if mems.len() > max { - mems.len() - max - } else { - 0 - }; - mems[start..] - .iter() - .map(|(id, content)| format!("[{}] {}", id, content)) - .collect::>() - .join("\n") - } - _ => "暂无长期记忆".to_string(), - } - } - - pub async fn write_for(&self, user_id: &str, content: &str) -> String { - let uid = if user_id.is_empty() { - "default" - } else { - user_id - }; - - // 空内容防御 - if content.trim().is_empty() { - return "记忆内容不能为空".to_string(); - } - - // 精确去重 - { - let cache = self.cache.lock().await; - if let Some(uc) = cache.get(uid) { - if uc.memories.iter().any(|(_, c)| c == content) { - return format!("该记忆已存在: {}", content); - } - } - } - - // 写入数据库(获取 id) - let new_id = if let Some(ref pool) = self.pool { - match sqlx::query_as::<_, (i32,)>( - "INSERT INTO user_memories (user_id, content) VALUES ($1, $2) RETURNING id", - ) - .bind(uid) - .bind(content) - .fetch_one(pool.as_ref()) - .await - { - Ok((id,)) => Some(id), - Err(_) => None, - } - } else { - None - }; - - // 无 DB 时生成临时负数 id - let id = match new_id { - Some(id) => id, - None => { - let cache = self.cache.lock().await; - -(cache - .get(uid) - .map(|uc| uc.memories.len() as i32 + 1) - .unwrap_or(1)) - } - }; - - // 写入缓存 - { - let mut cache = self.cache.lock().await; - let uc = cache.entry(uid.to_string()).or_insert_with(|| UserCache { - memories: Vec::new(), - last_access: Instant::now(), - }); - uc.last_access = Instant::now(); - uc.memories.push((id, content.to_string())); - - // 容量限制:超限删最旧的(id 最小的) - while uc.memories.len() > MAX_MEMORIES_PER_USER { - if let Some(pos) = uc - .memories - .iter() - .enumerate() - .min_by_key(|(_, (id, _))| *id) - .map(|(i, _)| i) - { - uc.memories.remove(pos); - } - } - } - - format!("已添加长期记忆 (id={}): {}", id, content) - } - - /// 删除指定 id 的记忆 - pub async fn delete_for(&self, user_id: &str, memory_id: i32) -> String { - let uid = if user_id.is_empty() { - "default" - } else { - user_id - }; - - let removed = { - let mut cache = self.cache.lock().await; - if let Some(uc) = cache.get_mut(uid) { - let old_len = uc.memories.len(); - uc.memories.retain(|(id, _)| *id != memory_id); - uc.last_access = Instant::now(); - old_len != uc.memories.len() - } else { - false - } - }; - - if !removed { - return format!("未找到 id={} 的记忆", memory_id); - } - - if let Some(ref pool) = self.pool { - let _ = sqlx::query("DELETE FROM user_memories WHERE id = $1 AND user_id = $2") - .bind(memory_id) - .bind(uid) - .execute(pool.as_ref()) - .await; - } - - format!("已删除记忆 (id={})", memory_id) - } - - /// 更新指定 id 的记忆内容 - pub async fn update_for(&self, user_id: &str, memory_id: i32, content: &str) -> String { - let uid = if user_id.is_empty() { - "default" - } else { - user_id - }; - - if content.trim().is_empty() { - return "记忆内容不能为空".to_string(); - } - - let found = { - let mut cache = self.cache.lock().await; - if let Some(uc) = cache.get_mut(uid) { - if let Some(entry) = uc.memories.iter_mut().find(|(id, _)| *id == memory_id) { - entry.1 = content.to_string(); - uc.last_access = Instant::now(); - true - } else { - false - } - } else { - false - } - }; - - if !found { - return format!("未找到 id={} 的记忆", memory_id); - } - - if let Some(ref pool) = self.pool { - let _ = - sqlx::query("UPDATE user_memories SET content = $1 WHERE id = $2 AND user_id = $3") - .bind(content) - .bind(memory_id) - .bind(uid) - .execute(pool.as_ref()) - .await; - } - - format!("已更新记忆 (id={}): {}", memory_id, content) - } -} diff --git a/src/context/types.rs b/src/context/types.rs deleted file mode 100644 index b165fd1..0000000 --- a/src/context/types.rs +++ /dev/null @@ -1,217 +0,0 @@ -//! ## 聊天会话状态 —— 核心数据结构 -//! -//! 这是整个项目中最重要的数据结构之一 —— 它维护了一次 LLM 对话的完整状态。 -//! -//! ### 关键设计 -//! - **消息历史** (`messages`) — 从 checkpoint 之后的消息保持完整 -//! - **摘要机制** (`summaries` + `checkpoint`) — checkpoint 之前的消息被压缩成摘要 -//! - **Token 预算** (`token_budget`) — 默认 28000,留 4000 给回复 -//! - **空闲超时** (`idle_timeout_secs`) — 默认 12 小时,超时后生成空闲摘要 -//! - **会话 ID** (`session_id`) — 每次新对话分配一个 Uuid,用于追踪工具调用往返 -//! -//! ### 消息生命周期 -//! 1. 用户消息到达 → `add_user()` → 存入 `messages` -//! 2. AI 回复 → `add_assistant()` → 存入 `messages` -//! 3. Token 超 budget → `trigger_overflow_summary()` → 压缩 checkpoint 前的消息 -//! 4. 12h 空闲 → `trigger_idle_summary()` → 生成摘要,清空消息 -//! 5. `/clear` 指令 → `trigger_clear_summary()` → 生成摘要归档,会话过期 - -use crate::llm::types::Message; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use uuid::Uuid; - -/// 历史对话条目(用于从数据库加载历史消息) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HistoryEntry { - pub role: String, - pub content: String, -} - -/// ## 摘要原因 -/// -/// 区分触发摘要的场景: -/// * `Overflow` — 上下文 token 总数超过预算(token budget),触发压缩 -/// * `Timeout` — 距上条用户消息超过 12 小时空闲,触发会话总结 -/// * `Clear` — 用户发送 `/clear` 主动清空上下文,触发会话过期归档 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum SummaryReason { - /// 上下文 token 超 budget 触发 - Overflow, - /// 距上条用户消息超过 12 小时触发 - Timeout, - /// 用户发送 `/clear` 主动清空上下文触发 - Clear, -} - -/// ## 一条摘要记录 -/// -/// * `checkpoint` — 摘要对应的消息位置,此位置之前的消息已被压缩成摘要 -/// * `text` — 摘要文本 -/// * `reason` — 生成原因(Overflow / Timeout) -/// * `created_at` — 生成时间 -#[derive(Debug, Clone)] -pub struct SummaryEntry { - /// 摘要对应的消息位置(checkpoint) - pub checkpoint: usize, - /// 摘要文本 - pub text: String, - /// 生成原因 - pub reason: SummaryReason, - /// 生成时间 - #[allow(dead_code)] - pub created_at: DateTime, -} - -/// ## 聊天会话状态(核心数据结构) -/// -/// 这是整个项目中最重要的数据结构之一 —— 它维护了一次 LLM 对话的完整状态。 -/// -/// ### 关键设计 -/// * **消息历史** (`messages`) — 从 checkpoint 之后的消息保持完整 -/// * **摘要机制** (`summaries` + `checkpoint`) — checkpoint 之前的消息被压缩成摘要, -/// 用 token budget 确保上下文不会超出 LLM 的限制 -/// * **Token 预算** (`token_budget`) — 默认 28000,留 4000 给回复 -/// * **空闲超时** (`idle_timeout_secs`) — 默认 12 小时,超时后生成空闲摘要 -/// * **会话 ID** (`session_id`) — 每次新对话分配一个 Uuid,用于追踪工具调用往返 -/// -/// ### 消息生命周期 -/// 1. 用户消息到达 → `add_user()` → 存入 `messages` -/// 2. AI 回复 → `add_assistant()` → 存入 `messages` -/// 3. Token 超 budget → `trigger_overflow_summary()` → 压缩 checkpoint 前的消息 -/// 4. 12h 空闲 → `trigger_idle_summary()` → 生成摘要,清空消息 -/// 5. `/clear` 指令 → `trigger_clear_summary()` → 生成摘要归档,会话过期 -#[derive(Debug, Clone)] -pub struct ChatSession { - pub user_id: String, - pub db_pool: Option>, - /// 当前正在处理消息的用户 ID(spawn 前设置,executor 中读取) - pub current_user_id: String, - /// 摘要总结点——此位置之前的消息已被压缩 - pub checkpoint: usize, - /// 全部消息(checkpoint 之后的保持完整) - pub messages: Vec, - /// 所有摘要(keyed by checkpoint) - pub summaries: Vec, - /// 上一条用户消息的时间 - pub last_user_at: Option>, - /// Token 预算(默认 28000,留 4000 给回复) - pub token_budget: u32, - /// 12 小时空闲阈值(秒) - pub idle_timeout_secs: i64, - /// 会话 ID(用于追踪工具调用往返) - pub session_id: Uuid, -} - -impl Default for ChatSession { - fn default() -> Self { - Self { - user_id: "default".to_string(), - db_pool: None, - current_user_id: String::new(), - checkpoint: 0, - messages: Vec::new(), - summaries: Vec::new(), - last_user_at: None, - token_budget: 28000, - idle_timeout_secs: 12 * 3600, - session_id: Uuid::new_v4(), - } - } -} - -impl ChatSession { - pub fn new() -> Self { - Self::default() - } - - /// 记录一条用户消息 - pub fn add_user(&mut self, content: String) { - self.messages.push(Message::user(content)); - self.last_user_at = Some(Utc::now()); - } - - /// 记录一条助手消息 - pub fn add_assistant(&mut self, content: String) { - self.messages.push(Message::assistant(content)); - } - - /// 记录一条带 tool_calls 的助手消息 - pub fn add_assistant_tool_calls( - &mut self, - content: impl Into, - tool_calls: Vec, - ) { - self.messages - .push(Message::assistant_with_tool_calls(content, tool_calls)); - } - - /// 记录一条工具结果(以 system 角色插入) - pub fn add_tool_result(&mut self, tool_call_id: &str, name: &str, result: &str) { - self.messages - .push(Message::tool_result(tool_call_id, name, result)); - } - - /// 是否因空闲超过阈值需要摘要 - pub fn is_idle_timeout(&self) -> bool { - if let Some(last) = self.last_user_at { - let elapsed = Utc::now().signed_duration_since(last).num_seconds(); - elapsed > self.idle_timeout_secs - } else { - false - } - } - - /// 获取检查点之后的消息 - pub fn recent_messages(&self) -> &[Message] { - if self.checkpoint < self.messages.len() { - &self.messages[self.checkpoint..] - } else { - &[] - } - } - - /// 获取最新的 overflow 摘要 - pub fn latest_overflow_summary(&self) -> Option<&SummaryEntry> { - self.summaries - .iter() - .rev() - .find(|s| s.reason == SummaryReason::Overflow) - } - - /// 从预载的历史记录初始化会话 - pub fn load_from_history(&mut self, history: &[HistoryEntry]) { - for entry in history { - match entry.role.as_str() { - "user" => self.add_user(entry.content.clone()), - "assistant" => self.add_assistant(entry.content.clone()), - _ => {} - } - } - if !history.is_empty() { - self.last_user_at = Some(chrono::Utc::now()); - } - } - - /// 从数据库加载最近的聊天记录到会话中 - - /// 保存摘要到数据库(委托给 db::models::save_summary) - pub async fn save_summary_to_db(&self, text: &str, reason: &str, message_count: i32) { - let Some(pool) = &self.db_pool else { return }; - let uid = if self.current_user_id.is_empty() { - if self.user_id.is_empty() { - "default" - } else { - &self.user_id - } - } else { - &self.current_user_id - }; - if let Err(e) = - crate::db::models::save_summary(pool, uid, text, reason, message_count).await - { - tracing::warn!(target: "ias::tool", "保存摘要到数据库失败: {}", e); - } - } -} diff --git a/src/daemon.rs b/src/daemon.rs deleted file mode 100644 index cdaac4d..0000000 --- a/src/daemon.rs +++ /dev/null @@ -1,1129 +0,0 @@ -//! ## Daemon —— 常驻守护进程(核心循环) -//! -//! 这是整个项目的核心入口循环。它的职责: -//! -//! 1. **持有 WeChat 长连接** — 通过 `WeChatClient` 长轮询接收微信消息 -//! 2. **持有数据库连接** — 可选 PostgreSQL,回退到文件存储 -//! 3. **管理审批流程** — `ApprovalManager` 处理高风险工具的确认码审批 -//! 4. **管理长期记忆** — `MemoryStore` 提供按用户隔离的记忆读写 -//! -//! ### 消息处理架构(三消费者 + 公平队列) -//! -//! daemon 将消息处理拆分为 3 个独立的 tokio async task(消费者), -//! 通过 `MessageQueue` + `QueueRunner` 做公平轮转路由: -//! -//! ```text -//! receive_messages → enqueue PipelineMessage -//! │ -//! ▼ -//! ┌──────────────────┐ -//! │ MessageQueue │ ← 公平轮转(A1, B1, A2, A3) -//! └────────┬─────────┘ -//! │ dequeue -//! ▼ -//! ┌──────────────────┐ -//! │ QueueRunner │ ← 按 MessageKind 路由 -//! └──┬──────┬──────┬─┘ -//! │ │ │ -//! ▼ ▼ ▼ -//! LLM Tool Send -//! Cons. Cons. Cons. -//! ``` -//! -//! - **LLM Consumer** — 调用 DeepSeek API 做对话 + 工具循环 -//! - **Tool Consumer** — 执行内置工具,高风险工具先走审批 -//! - **Send Consumer** — 将 LLM 回复通过 WeChat API 发回给用户 -//! -//! ### 架构 -//! -//! 采用统一的单进程 tokio task 架构。旧版 daemon+worker 分离模式(UDS IPC)已在 v2 中移除。 - -use crate::channel::ChannelId; -use crate::context::builder; -use crate::context::HistoryEntry; -use crate::context::MemoryStore; -use crate::db::Database; -use crate::llm::{ - ChatResult, Conversation, ConversationConfig, DEFAULT_SYSTEM_PROMPT, ToolExecution, - ToolExecutor, -}; -use crate::queue::{ConsumerChannels, EnqueueHandle, MessageKind, PipelineMessage, QueueRunner}; - -use crate::tools::approval::ApprovalManager; -use crate::tools::executor::ToolRunOutcome; -use crate::tools::registry::RegistryManager; -use crate::wechat::client::WeChatClient; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Instant; -use tokio::sync::Mutex; -use tracing::{error, info, warn}; -use uuid::Uuid; - -/// LLM Consumer 的会话缓存:`correlation_id → (Conversation, 创建时间, user_id)`。 -/// 额外记录 `user_id` 是为了在 `/clear` 时能按用户丢弃尚未完成的在途会话。 -type SessionMap = HashMap; - -/// ## Daemon 上下文 —— 消费者之间共享的全部状态 -/// -/// 这个结构体被所有三个消费者(LLM / Tool / Send)通过 `Arc` 共享访问。 -/// 每个字段都只读(或内部有锁),所以不需要额外的 `Mutex` 包装。 -struct DaemonCtx { - /// 数据库句柄 - db: Arc, - /// WeChat API 客户端(内部用 Arc 保护 token/updates_buf) - client: WeChatClient, - /// 本机器人的微信账号 ID - account_id: String, - /// 审批管理器 —— 处理高风险工具的用户确认码流程 - approval: Arc, - /// 工具注册器 —— 管理外部工具规范和执行 - registry: Arc, - /// 长期记忆管理器 —— 按用户隔离的记忆读写 - memory_store: Arc, - /// 系统提示词,覆盖默认值 - system_prompt: String, - /// 使用的模型名称(如 `deepseek-v4-flash`) - model: String, -} - -/// Daemon 入口 -/// -/// `sock_path` 保留用于兼容旧版 daemon 命令的 CLI 接口,实际已不再使用。 -pub async fn run(database: &Arc, memory_store: &Arc) { - // 1-3. 认证 & 客户端 - let auth = match load_auth(database).await { - Some(a) => a, - None => { - error!(target: "ias::err","[auth] 未登录"); - return; - } - }; - let mut client = WeChatClient::new(Some(auth.base_url.clone())); - client - .set_auth(&auth.token, &auth.account_id, &auth.base_url) - .await; - if let Some(buf) = crate::db::models::load_runtime(database.pool()).await { - client.set_updates_buf(&buf).await; - } - if let Err(e) = client.notify_start().await { - error!(target: "ias::err","[wechat] 注册监听器失败: {e}"); - return; - } - let account_id = auth.account_id.clone(); - - // 4-6. 审批管理器、注册器、模型配置 - let approval_manager = Arc::new(ApprovalManager::new(Some(Arc::new( - database.pool().clone(), - )))); - let registry = Arc::new(RegistryManager::load("tools")); - - // 沙箱容器(如果启用):daemon 启动时创建长期容器,失败则设置降级标志 - // executor 会据此回退本地执行,避免不受信任工具持续 SpawnFailed - if crate::tools::sandbox::enabled() { - if let Err(e) = crate::tools::sandbox::ensure_container("tools").await { - tracing::warn!(target: "ias::sandbox", "沙箱容器启动失败: {e}"); - } - } - - let system_prompt = std::env::var("WEIXIN_LLM_SYSTEM_PROMPT").unwrap_or_else(|_| String::new()); - let model = std::env::var("DEEPSEEK_MODEL").unwrap_or_else(|_| "deepseek-v4-flash".to_string()); - - // 7. Daemon 上下文 - let ctx = Arc::new(DaemonCtx { - db: database.clone(), - client: client.clone(), - account_id: account_id.clone(), - approval: approval_manager.clone(), - registry: registry.clone(), - memory_store: memory_store.clone(), - system_prompt, - model, - }); - - // 8. 审批过期清理 - let approval_clean = approval_manager.clone(); - tokio::spawn(async move { - loop { - tokio::time::sleep(std::time::Duration::from_secs(60)).await; - approval_clean.clean_expired().await; - } - }); - - // 9. 队列(容量 1024) - let (runner, channels) = QueueRunner::new(1024); - let enqueue_handle = runner.enqueue_handle(); - let shutdown_handle = runner.shutdown_handle(); // 不再 prefix _ - - // 10-11. 消费者 + 调度器 - let (consumer_handles, sessions) = - spawn_consumers(channels, ctx.clone(), enqueue_handle.clone()); - - let sched_db = database.pool().clone(); - let sched_enqueue = enqueue_handle.clone(); - tokio::spawn(async move { - let scheduler = crate::scheduler::Scheduler::new(Some(Arc::new(sched_db))); - scheduler - .run(move |to: &str, name: &str, msg: &str| { - let eq = sched_enqueue.clone(); - let uid = to.to_string(); - let text = msg.to_string(); - let tn = name.to_string(); - tokio::spawn(async move { - eq.enqueue(PipelineMessage { - channel: ChannelId::wechat(uid), - correlation_id: Uuid::new_v4(), - kind: MessageKind::ScheduledTask { - text, - task_name: tn, - }, - }) - .await; - }); - Ok(()) - }) - .await; - }); - - // 12. QueueRunner - let runner_handle = tokio::spawn(async move { - runner.run().await; - }); - - // 13. Session TTL 清理:普通会话 5 分钟,等待异步工具结果时延长到 30 分钟 - let sessions_for_clean = sessions.clone(); - tokio::spawn(async move { - loop { - tokio::time::sleep(std::time::Duration::from_secs(60)).await; - let now = Instant::now(); - sessions_for_clean - .lock() - .await - .retain(|_, (conv, created, _)| { - let ttl_secs = if conv.has_pending_async() { 1800 } else { 300 }; - now.duration_since(*created).as_secs() < ttl_secs - }); - } - }); - - // 13. 主循环 — 带 Ctrl+C 优雅退出 - let mut running = true; - while running { - tokio::select! { - msg_result = client.receive_messages() => { - match msg_result { - Ok(resp) => { - if !resp.get_updates_buf.is_empty() { - let _ = crate::db::models::save_runtime( - ctx.db.pool(), &resp.get_updates_buf, - ).await; - } - for msg in &resp.msgs { - if msg.msg_type != Some(crate::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!(target: "ias::msg", "[用户] {}: {}", from, text); - - let _ = crate::db::models::insert_chat_record( - ctx.db.pool(), "inbound", from, &ctx.account_id, text, "wechat", ctx_token, &msg_id, - ).await; - - // 审批回复 - if ctx.approval.handle_reply(from, text).await.is_some() { - continue; - } - - enqueue_handle.enqueue(PipelineMessage { - channel: ChannelId::wechat(from), - correlation_id: Uuid::new_v4(), - kind: MessageKind::UserMessage { - text: text.to_string(), message_id: msg_id, - context_token: ctx_token.map(String::from), - }, - }).await; - } - } - Err(e) => { - if !e.contains("超时") && !e.contains("timeout") { - error!(target: "ias::err","[wechat] 接收消息失败: {e}"); - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - } - } - } - } - _ = tokio::signal::ctrl_c() => { - running = false; - } - } - } - - shutdown_handle.shutdown(); - tokio::time::sleep(std::time::Duration::from_secs(3)).await; - for h in consumer_handles { - h.abort(); - } - runner_handle.abort(); -} - -// ─── 消费者 ─── - -fn spawn_consumers( - channels: ConsumerChannels, - ctx: Arc, - enqueue: EnqueueHandle, -) -> ( - Vec>, - Arc>, -) { - let sessions: Arc> = - Arc::new(Mutex::new(HashMap::new())); - - let mut handles = Vec::new(); - // LLM Consumer - { - let mut rx = channels.llm_rx; - let ctx = ctx.clone(); - let eq = enqueue.clone(); - let s = sessions.clone(); - handles.push(tokio::spawn(async move { - llm_consumer_loop(&mut rx, &ctx, &eq, &s).await; - })); - } - // Tool Consumer - { - let mut rx = channels.tool_rx; - let ctx = ctx.clone(); - let eq = enqueue.clone(); - handles.push(tokio::spawn(async move { - tool_consumer_loop(&mut rx, &ctx, &eq).await; - })); - } - // Send Consumer - { - let mut rx = channels.send_rx; - let ctx = ctx.clone(); - handles.push(tokio::spawn(async move { - send_consumer_loop(&mut rx, &ctx).await; - })); - } - (handles, sessions) -} - -// ─── LLM Consumer ─── - -async fn llm_consumer_loop( - rx: &mut tokio::sync::mpsc::Receiver, - ctx: &DaemonCtx, - enqueue: &EnqueueHandle, - sessions: &Arc>, -) { - while let Some(msg) = rx.recv().await { - let user_id = msg.channel.user_id.clone(); - let correlation_id = msg.correlation_id; - - match msg.kind { - MessageKind::UserMessage { - text, - message_id: _, - context_token, - } => { - // /clear 指令:截断上下文 + 生成摘要 + 按会话过期处理 - if is_clear_command(&text) { - handle_clear_command( - ctx, - enqueue, - sessions, - &user_id, - correlation_id, - context_token, - ) - .await; - continue; - } - // 加载数据 - ctx.memory_store.load_if_needed(&user_id).await; - let history = load_history(&ctx.db, &user_id).await; - - let sys_prompt = if ctx.system_prompt.is_empty() { - DEFAULT_SYSTEM_PROMPT.to_string() - } else { - ctx.system_prompt.clone() - }; - - let tools = vec![ - serde_json::json!({ - "type": "function", - "function": { - "name": "query_capabilities", - "description": "查询所有可用工具的列表、参数格式和用法说明", - "parameters": {"type": "object", "properties": {}, "required": []} - } - }), - serde_json::json!({ - "type": "function", - "function": { - "name": "call_capability", - "description": "调用指定的工具。先通过 query_capabilities 获取工具名和参数格式", - "parameters": { - "type": "object", - "properties": { - "name": {"type": "string", "description": "工具名称"}, - "prompt": { - "type": "object", - "description": "工具参数对象,如 {\"location\":\"北京\"}" - } - }, - "required": ["name", "prompt"] - } - } - }), - ]; - let tools = Some(tools); - - let cfg = ConversationConfig { - system_prompt: sys_prompt, - model: ctx.model.clone(), - tools, - ..Default::default() - }; - - let mut conv = match Conversation::new(cfg) { - Ok(c) => c, - Err(e) => { - error!(target: "ias::err","[llm-init] {e}"); - continue; - } - }; - - conv.session().lock().await.db_pool = Some(Arc::new(ctx.db.pool().clone())); - - { - let s_arc = conv.session(); - let mut s = s_arc.lock().await; - s.load_from_history(&history); - s.current_user_id = user_id.clone(); - } - - // 设置 ToolExecutor — 所有工具统一通过消息队列异步执行 - let eq = enqueue.clone(); - let uid = user_id.clone(); - let corr = correlation_id; - let session = conv.session(); - let tool_context_token = context_token.clone(); - - let executor: ToolExecutor = - Arc::new(move |name: &str, args_json: &str, tc_id: &str| { - let eq = eq.clone(); - let uid = uid.clone(); - let corr = corr; - let session = session.clone(); - let n = name.to_string(); - let args = args_json.to_string(); - let tool_call_id = tc_id.to_string(); - let context_token = tool_context_token.clone(); - - Box::pin(async move { - let ch = ChannelId::wechat(&uid); - let sid = { session.lock().await.session_id }; - eq.enqueue(PipelineMessage::tool_call( - ch, - corr, - sid, - &tool_call_id, - &n, - &args, - context_token, - )) - .await; - Ok(ToolExecution::Pending) - }) - }); - conv.set_tool_executor(executor); - - // 缓存 Conversation(记录创建时间用于 TTL) - sessions - .lock() - .await - .insert(correlation_id, (conv, Instant::now(), user_id.clone())); - - // 执行 LLM 对话 - run_llm_round( - &user_id, - correlation_id, - context_token, - &text, - sessions, - enqueue, - ) - .await; - } - - MessageKind::ToolResult { - session_id: _, - tool_call_id, - tool_name, - result, - context_token, - } => { - // 从缓存恢复会话,注入工具结果并恢复 LLM 循环 - let should_resume = { - let mut map = sessions.lock().await; - if let Some((conv, last_access, _)) = map.get_mut(&correlation_id) { - conv.session().lock().await.add_tool_result( - &tool_call_id, - &tool_name, - &result, - ); - *last_access = Instant::now(); // 更新访问时间,防止 TTL 误清理 - conv.notify_tool_result() - } else { - warn!(target: "ias::err","[session-expired] {correlation_id}"); - false - } - }; - - if should_resume { - resume_llm_round(&user_id, correlation_id, context_token, sessions, enqueue) - .await; - } - } - - MessageKind::ScheduledTask { text, task_name } => { - let prompt = format!("[定时任务: {}]\n{}", task_name, text); - - // 为保持简洁,定时任务直接作为用户消息处理(使用全新 correlation_id) - let msg = PipelineMessage { - channel: ChannelId::wechat(user_id.clone()), - correlation_id: Uuid::new_v4(), - kind: MessageKind::UserMessage { - text: prompt, - message_id: String::new(), - context_token: None, - }, - }; - // 重新投递给自己 — 简单但可靠 - // 注意: 这里用了一个技巧,重新入队而不是复制代码 - let eq = enqueue.clone(); - eq.enqueue(msg).await; - } - - _ => {} - } - } -} - -/// 执行一轮 LLM 对话,仅在无异步工具等待时发送回复并清理会话。 -async fn run_llm_round( - user_id: &str, - correlation_id: Uuid, - context_token: Option, - text: &str, - sessions: &Arc>, - enqueue: &EnqueueHandle, -) { - let result = { - let map = sessions.lock().await; - let conv = match map.get(&correlation_id) { - Some((c, _, _)) => c, - None => { - error!(target: "ias::err","[llm-session] conv不在缓存"); - return; - } - }; - - match conv.chat_with_tools(text.to_string()).await { - Ok(r) => r, - Err(e) => { - error!(target: "ias::err","[llm-chat] {e}"); - ChatResult { - reply: format!("处理失败: {}", e), - used_tools: false, - usage: None, - has_pending_async: false, - } - } - } - }; - - handle_chat_result( - user_id, - correlation_id, - context_token, - result, - sessions, - enqueue, - ) - .await; -} - -/// 在异步工具结果返回后恢复 LLM 循环(不追加用户消息)。 -async fn resume_llm_round( - user_id: &str, - correlation_id: Uuid, - context_token: Option, - sessions: &Arc>, - enqueue: &EnqueueHandle, -) { - let result = { - let map = sessions.lock().await; - let conv = match map.get(&correlation_id) { - Some((c, _, _)) => c, - None => { - error!(target: "ias::err","[llm-resume] conv不在缓存"); - return; - } - }; - - match conv.resume_tool_loop().await { - Ok(r) => r, - Err(e) => { - error!(target: "ias::err","[llm-resume] {e}"); - ChatResult { - reply: format!("处理失败: {}", e), - used_tools: false, - usage: None, - has_pending_async: false, - } - } - } - }; - - handle_chat_result( - user_id, - correlation_id, - context_token, - result, - sessions, - enqueue, - ) - .await; -} - -/// 统一处理 ChatResult:有异步等待则保留 session,否则发送回复并清理。 -async fn handle_chat_result( - user_id: &str, - correlation_id: Uuid, - context_token: Option, - result: ChatResult, - sessions: &Arc>, - enqueue: &EnqueueHandle, -) { - if result.has_pending_async { - // 有异步工具等待返回 → 保留 session - if !result.reply.is_empty() { - let ch = ChannelId::wechat(user_id); - let msg = PipelineMessage::llm_reply_with_source( - ch, - correlation_id, - &result.reply, - "llm_pending_tool", - context_token, - ); - enqueue.enqueue(msg).await; - } - return; - } - - // 对话完成:发送回复并清理 session - if !result.reply.is_empty() { - let ch = ChannelId::wechat(user_id); - let msg = PipelineMessage::llm_reply(ch, correlation_id, &result.reply, context_token); - enqueue.enqueue(msg).await; - } - sessions.lock().await.remove(&correlation_id); -} - -// ─── /clear 指令处理 ─── - -/// 判断文本是否为清空上下文指令 `/clear`(大小写不敏感,允许前后空白)。 -fn is_clear_command(text: &str) -> bool { - text.trim().eq_ignore_ascii_case("/clear") -} - -/// ## 处理 `/clear` 指令 —— 截断上下文 + 生成摘要 + 会话过期 -/// -/// 用户发送 `/clear` 时: -/// 1. 加载当前历史,用 LLM 生成会话摘要并归档(reason = clear) -/// 2. 摘要的 `created_at` 成为会话边界,之后加载历史不再包含此前的消息 -/// 3. 丢弃该用户尚未完成的在途会话(按会话过期处理) -/// 4. 回复用户清空确认(附带摘要预览) -async fn handle_clear_command( - ctx: &DaemonCtx, - enqueue: &EnqueueHandle, - sessions: &Arc>, - user_id: &str, - correlation_id: Uuid, - context_token: Option, -) { - info!(target: "ias::msg", "[clear] {} 请求清空上下文", user_id); - - // 1. 生成摘要:用一次性 Conversation 提供 summarizer + session - // 过滤掉历史里的 /clear 指令本身,避免污染摘要 - let history: Vec = load_history(&ctx.db, user_id) - .await - .into_iter() - .filter(|h| !is_clear_command(&h.content)) - .collect(); - - let summary = if history.is_empty() { - None - } else { - let cfg = ConversationConfig { - system_prompt: String::new(), - model: ctx.model.clone(), - ..Default::default() - }; - match Conversation::new(cfg) { - Ok(conv) => { - { - let s = conv.session(); - let mut s = s.lock().await; - s.db_pool = Some(Arc::new(ctx.db.pool().clone())); - s.load_from_history(&history); - s.current_user_id = user_id.to_string(); - } - let summarizer = conv.summarizer(); - builder::trigger_clear_summary(&conv.session(), Some(&summarizer)).await - } - Err(e) => { - error!(target: "ias::err", "[clear] 初始化摘要器失败: {e}"); - None - } - } - }; - - // 2. 丢弃该用户在途的会话(按会话过期处理) - sessions - .lock() - .await - .retain(|_, (_, _, uid)| uid.as_str() != user_id); - - // 3. 回复确认(附带摘要预览) - let reply = build_clear_reply(&summary); - - let msg = PipelineMessage::llm_reply_with_source( - ChannelId::wechat(user_id), - correlation_id, - &reply, - "clear", - context_token, - ); - enqueue.enqueue(msg).await; -} - -/// 根据 `trigger_clear_summary` 的返回值构建 `/clear` 回复文案。 -/// -/// * `None` — 无历史可清空 -/// * `Some("")` — 已清空但 LLM 摘要为空 -/// * `Some(text)` — 已清空,附带 300 字预览 -fn build_clear_reply(summary: &Option) -> String { - match summary { - None => "🧹 上下文已是空的,无需清空。".to_string(), - Some(text) if text.is_empty() => "🧹 已清空上下文,本次对话已归档。".to_string(), - Some(text) => { - let chars: Vec = text.chars().collect(); - let (preview, suffix) = if chars.len() > 300 { - ( - chars[..300].iter().collect::(), - "\n…(摘要已截断预览,完整内容已归档)", - ) - } else { - (text.clone(), "") - }; - format!( - "🧹 已清空上下文,本次对话已归档为摘要。\n\n📝 摘要预览:\n{}{}", - preview, suffix - ) - } - } -} - -// ─── Tool Consumer ─── - -async fn tool_consumer_loop( - rx: &mut tokio::sync::mpsc::Receiver, - ctx: &Arc, - enqueue: &EnqueueHandle, -) { - while let Some(msg) = rx.recv().await { - let user_id = msg.channel.user_id.clone(); - let correlation_id = msg.correlation_id; - - match msg.kind { - MessageKind::ToolCall { - session_id, - tool_call_id, - tool_name, - arguments, - context_token, - approved, - } => { - let ctx = ctx.clone(); - let enqueue = enqueue.clone(); - tokio::spawn(async move { - handle_tool_call( - ctx, - enqueue, - user_id, - correlation_id, - session_id, - tool_call_id, - tool_name, - arguments, - context_token, - approved, - ) - .await; - }); - } - _ => {} - } - } -} - -#[allow(clippy::too_many_arguments)] -async fn handle_tool_call( - ctx: Arc, - enqueue: EnqueueHandle, - user_id: String, - correlation_id: Uuid, - session_id: Uuid, - tool_call_id: String, - tool_name: String, - arguments: String, - context_token: Option, - approved: bool, -) { - // 内置工具 → 直接处理 - let builtin_result = execute_builtin(&tool_name, &arguments, &ctx, &user_id).await; - let output = if let Some(r) = builtin_result { - r - } else if tool_name == "call_capability" { - let cp: serde_json::Value = serde_json::from_str(&arguments).unwrap_or_default(); - let (target, sub_params) = unpack_call_capability_params(&cp); - let eq = enqueue.clone(); - let reply_context_token = context_token.clone(); - let wechat_cb = - move |to: &str, - text: &str| - -> std::pin::Pin + Send>> { - let eq = eq.clone(); - let to = to.to_string(); - let text = text.to_string(); - let reply_context_token = reply_context_token.clone(); - Box::pin(async move { - let ch = ChannelId::wechat(&to); - let msg = PipelineMessage::llm_reply_with_source( - ch, - correlation_id, - &text, - "tool_approval", - reply_context_token, - ); - eq.enqueue(msg).await; - }) - }; - let outcome = ctx - .registry - .dispatch( - target, - &sub_params, - &user_id, - approved, - &ctx.approval, - &ctx.memory_store, - &wechat_cb, - ) - .await; - if target == "tool_manager" && !outcome.is_err() { - let count = ctx.registry.reload().await; - format!( - "{}\n\n工具注册表已重载,当前可用工具数: {count}", - outcome.into_message() - ) - } else { - if let ToolRunOutcome::Err { ref kind, .. } = outcome { - tracing::warn!(target:"ias::err","[tool] {target} 执行失败: {kind:?}"); - } - outcome.into_message() - } - } else { - let params: serde_json::Value = serde_json::from_str(&arguments).unwrap_or_default(); - let eq = enqueue.clone(); - let reply_context_token = context_token.clone(); - let wechat_cb = - move |to: &str, - text: &str| - -> std::pin::Pin + Send>> { - let eq = eq.clone(); - let to = to.to_string(); - let text = text.to_string(); - let reply_context_token = reply_context_token.clone(); - Box::pin(async move { - let ch = ChannelId::wechat(&to); - let msg = PipelineMessage::llm_reply_with_source( - ch, - correlation_id, - &text, - "tool_approval", - reply_context_token, - ); - eq.enqueue(msg).await; - }) - }; - let outcome = ctx - .registry - .dispatch( - &tool_name, - ¶ms, - &user_id, - approved, - &ctx.approval, - &ctx.memory_store, - &wechat_cb, - ) - .await; - if tool_name == "tool_manager" && !outcome.is_err() { - let count = ctx.registry.reload().await; - format!( - "{}\n\n工具注册表已重载,当前可用工具数: {count}", - outcome.into_message() - ) - } else { - if let ToolRunOutcome::Err { ref kind, .. } = outcome { - tracing::warn!(target:"ias::err","[tool] {tool_name} 执行失败: {kind:?}"); - } - outcome.into_message() - } - }; - - enqueue - .enqueue(PipelineMessage::tool_result( - ChannelId::wechat(&user_id), - correlation_id, - session_id, - &tool_call_id, - &tool_name, - &output, - context_token, - )) - .await; -} - -fn unpack_call_capability_params(cp: &serde_json::Value) -> (&str, serde_json::Value) { - let target = cp["name"].as_str().unwrap_or(""); - let mut params = serde_json::Map::new(); - - if let Some(prompt) = cp.get("prompt") { - match prompt { - serde_json::Value::String(s) => { - if let Ok(v) = serde_json::from_str::(s) - && let Some(obj) = v.as_object() - { - params.extend(obj.clone()); - } - } - serde_json::Value::Object(obj) => { - params.extend(obj.clone()); - } - _ => {} - } - } - - if let Some(obj) = cp.as_object() { - for (key, value) in obj { - if key == "name" || key == "prompt" || key == "params" || key == "arguments" { - continue; - } - params.insert(key.clone(), value.clone()); - } - } - - for field in ["params", "arguments"] { - if let Some(obj) = cp.get(field).and_then(|v| v.as_object()) { - params.extend(obj.clone()); - } - } - - (target, serde_json::Value::Object(params)) -} - -// ─── 内置工具执行 ─── - -/// 处理内置上下文工具。返回 `Some(result)` 表示已处理, -/// `None` 表示不是内置工具,需走注册器。 -async fn execute_builtin( - name: &str, - arguments: &str, - ctx: &DaemonCtx, - user_id: &str, -) -> Option { - let params: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default(); - - match name { - "query_capabilities" => { - let tools = ctx.registry.list_tools().await; - if tools.is_empty() { - return Some("当前没有注册任何工具".into()); - } - // 格式化为 LLM 友好的文本列表(非 JSON),便于快速扫描和匹配 - let mut out = String::from("可用工具列表:\n"); - for t in &tools { - let params: Vec = t - .params - .iter() - .map(|p| format!("{}{}", p.name, if p.required { "(必填)" } else { "(可选)" })) - .collect(); - let param_str = if params.is_empty() { - String::from("无参数") - } else { - params.join(", ") - }; - out.push_str(&format!( - "• {} — {}\n 参数: {}\n", - t.name, t.desc, param_str - )); - } - out.push_str("\n使用方法:调用 call_capability,name 填以上任一工具名,prompt 填参数对象,如 {\"keywords\":\"咖啡\"}"); - Some(out) - } - "call_capability" => { - let target = params["name"].as_str().unwrap_or(""); - if target.is_empty() { - return Some("缺少 name 参数".into()); - } - // call_capability 由 ToolConsumer 层面特殊处理 - None - } - "read_summaries" => { - let sums = load_summaries(&ctx.db, user_id).await; - Some(if sums.is_empty() { - "暂无摘要".into() - } else { - sums.join("\n---\n") - }) - } - _ => None, - } -} - -// ─── Send Consumer ─── - -async fn send_consumer_loop( - rx: &mut tokio::sync::mpsc::Receiver, - ctx: &DaemonCtx, -) { - while let Some(msg) = rx.recv().await { - match msg.kind { - MessageKind::LLMReply { - text, - source, - context_token, - } => { - let user_id = &msg.channel.user_id; - info!(target: "ias::msg", "[LLM] -> {}: {}", user_id, text); - - match ctx - .client - .send_text(user_id, &text, context_token.as_deref()) - .await - { - Ok(sent_id) => { - let _ = crate::db::models::insert_chat_record( - ctx.db.pool(), - "outbound", - user_id, - &ctx.account_id, - &text, - &source, - context_token.as_deref(), - &sent_id, - ) - .await; - } - Err(e) => error!(target: "ias::err","[send] {e}"), - } - } - _ => {} - } - } -} - -// ─── 辅助函数 ─── - -async fn load_auth(database: &Arc) -> Option { - crate::db::models::load_auth(database.pool()).await -} - -async fn load_history(db: &Arc, user_id: &str) -> Vec { - match crate::db::models::list_recent_chat_records_for_context(db.pool(), user_id, 20).await { - Ok(records) => records - .into_iter() - .rev() - .map(|r| HistoryEntry { - role: if r.direction == "inbound" { - "user".into() - } else { - "assistant".into() - }, - content: r.text, - }) - .collect(), - Err(e) => { - warn!("加载历史失败: {}", e); - vec![] - } - } -} - -async fn load_summaries(db: &Arc, user_id: &str) -> Vec { - match crate::db::models::load_summaries(db.pool(), user_id, 5).await { - Ok(s) => s, - Err(e) => { - warn!("加载摘要失败: {}", e); - vec![] - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn clear_command_detection() { - assert!(is_clear_command("/clear")); - assert!(is_clear_command(" /clear ")); - assert!(is_clear_command("/CLEAR")); - assert!(is_clear_command("/Clear")); - assert!(is_clear_command("\t/clear\n")); - // 非精确匹配不应触发 - assert!(!is_clear_command("/clear all")); - assert!(!is_clear_command("请/clear")); - assert!(!is_clear_command("清空")); - assert!(!is_clear_command("")); - assert!(!is_clear_command("/clea")); - } - - #[test] - fn clear_reply_branches() { - // 无历史可清空 - assert_eq!(build_clear_reply(&None), "🧹 上下文已是空的,无需清空。"); - // 已清空但摘要为空(不应误报“已是空的”) - assert_eq!( - build_clear_reply(&Some(String::new())), - "🧹 已清空上下文,本次对话已归档。" - ); - // 正常摘要:附带预览 - let r = build_clear_reply(&Some("讨论了天气".to_string())); - assert!(r.starts_with("🧹 已清空上下文,本次对话已归档为摘要。")); - assert!(r.contains("讨论了天气")); - // 超长摘要:截断预览 - let long = "x".repeat(500); - let r = build_clear_reply(&Some(long)); - assert!(r.contains("摘要已截断预览")); - assert!(!r.contains(&"x".repeat(500))); - } -} diff --git a/src/db/mod.rs b/src/db/mod.rs deleted file mode 100644 index 1817c53..0000000 --- a/src/db/mod.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! ## 数据库管理 -//! -//! ### 职责 -//! 1. 从 `DATABASE_URL` 环境变量连接 PostgreSQL -//! 2. 自动运行 `./migrations/` 下的 SQL 迁移 -//! 3. 提供连接池引用给各模块使用 -//! -//! ### 回退策略 -//! 数据库是可选的。如果数据库不可用,系统会优雅地回退到文件存储 -//! (`StateManager`),核心功能仍然可用。 - -//! ## 数据库管理 —— PostgreSQL 连接 + 迁移 -//! -//! ### 职责 -//! 1. 从 `DATABASE_URL` 环境变量连接 PostgreSQL -//! 2. 自动运行 `./migrations/` 下的 SQL 迁移 -//! 3. 提供连接池引用给各模块使用 -//! -//! ### 回退策略 -//! 数据库是可选的。如果数据库不可用,系统会优雅地回退到文件存储 -//! (`StateManager`),核心功能仍然可用。 -//! -//! ### 表结构 -//! - `app_state` — 认证状态 KV 存储 -//! - `chat_records` — 收发消息记录 -//! - `llm_usage` — Token 用量统计 -//! - `user_memories` — 用户长期记忆 -//! - `pending_approvals` — 待审批记录 -//! - `session_summaries` — 会话摘要 -//! - `scheduled_tasks` — 定时任务 - -pub mod models; - -use sqlx::PgPool; -use sqlx::postgres::PgPoolOptions; -use std::time::Duration; - -/// ## 数据库管理器 -/// -/// 封装了 PostgreSQL 连接池的创建和迁移管理。 -/// 通过 `Database::connect()` 创建,所有数据库操作都在 `models` 模块中。 -pub struct Database { - pool: PgPool, -} - -impl Database { - /// 从 DATABASE_URL 环境变量连接并运行迁移 - pub async fn connect() -> Result { - let database_url = std::env::var("DATABASE_URL") - .map_err(|_| "请设置 DATABASE_URL 环境变量".to_string())?; - - let pool = PgPoolOptions::new() - .max_connections(5) - .acquire_timeout(Duration::from_secs(10)) - .connect(&database_url) - .await - .map_err(|e| format!("连接数据库失败: {}", e))?; - - // 运行迁移 - sqlx::migrate!("./migrations") - .run(&pool) - .await - .map_err(|e| format!("数据库迁移失败: {}", e))?; - - tracing::info!(target: "ias::auth", "数据库连接成功,迁移已完成"); - - Ok(Self { pool }) - } - - /// 获取连接池引用 - pub fn pool(&self) -> &PgPool { - &self.pool - } -} diff --git a/src/db/models.rs b/src/db/models.rs deleted file mode 100644 index e16740d..0000000 --- a/src/db/models.rs +++ /dev/null @@ -1,463 +0,0 @@ -//! ## 数据库查询(models) -//! -//! 所有数据库操作集中在此模块,包括: -//! -//! ### 表操作 -//! * `app_state` — 认证状态的持久化(key-value 存储) -//! * `chat_records` — 聊天记录(inbound/outbound) -//! * `llm_usage` — Token 用量记录和统计 -//! * `session_summaries` — 会话摘要存档 -//! * `user_memories` — 用户长期记忆 -//! * `pending_approvals` — 待审批记录 - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::PgPool; - -/// 认证状态 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AuthState { - pub token: String, - pub account_id: String, - pub base_url: String, -} - -// ─── 认证状态 ─── - -/// 从数据库加载认证状态 -pub async fn load_auth(pool: &PgPool) -> Option { - let row = - sqlx::query_as::<_, (serde_json::Value,)>("SELECT value FROM app_state WHERE key = 'auth'") - .fetch_optional(pool) - .await - .ok()??; - - serde_json::from_value(row.0).ok() -} - -/// 保存认证状态到数据库 -pub async fn save_auth(pool: &PgPool, auth: &AuthState) -> Result<(), String> { - let value = serde_json::to_value(auth).map_err(|e| format!("序列化 auth 失败: {}", e))?; - - sqlx::query( - r#" - INSERT INTO app_state (key, value, updated_at) - VALUES ('auth', $1, NOW()) - ON CONFLICT (key) DO UPDATE - SET value = EXCLUDED.value, updated_at = NOW() - "#, - ) - .bind(&value) - .execute(pool) - .await - .map_err(|e| format!("保存 auth 到数据库失败: {}", e))?; - - Ok(()) -} - -// ─── 运行时状态 (get_updates_buf) ─── - -/// 从数据库加载运行时状态 (get_updates_buf) -pub async fn load_runtime(pool: &PgPool) -> Option { - let row = sqlx::query_as::<_, (serde_json::Value,)>( - &"SELECT value FROM app_state WHERE key = 'runtime'", - ) - .fetch_optional(pool) - .await - .ok()??; - - row.0.as_str().map(|s| s.to_string()) -} - -/// 保存运行时状态 (get_updates_buf) 到数据库 -pub async fn save_runtime(pool: &PgPool, buf: &str) -> Result<(), String> { - let value = serde_json::Value::String(buf.to_string()); - sqlx::query( - r#" - INSERT INTO app_state (key, value, updated_at) - VALUES ('runtime', $1, NOW()) - ON CONFLICT (key) DO UPDATE - SET value = EXCLUDED.value, updated_at = NOW() - "#, - ) - .bind(&value) - .execute(pool) - .await - .map_err(|e| format!("保存 runtime 到数据库失败: {}", e))?; - Ok(()) -} - -// ─── 聊天记录 ─── - -#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] -pub struct ChatRecord { - pub id: i64, - pub created_at: DateTime, - pub direction: String, - pub user_id: String, - pub account_id: String, - pub text: String, - pub source: String, - pub context_token: String, - pub message_id: String, -} - -/// 插入一条聊天记录 -pub async fn insert_chat_record( - pool: &PgPool, - direction: &str, - user_id: &str, - account_id: &str, - text: &str, - source: &str, - context_token: Option<&str>, - message_id: &str, -) -> Result { - let ctx = context_token.unwrap_or(""); - - let row: Option<(i64,)> = sqlx::query_as( - r#" - INSERT INTO chat_records (direction, user_id, account_id, text, source, context_token, message_id) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (message_id) WHERE message_id NOT IN ('', '0') DO NOTHING - RETURNING id - "#, - ) - .bind(direction) - .bind(user_id) - .bind(account_id) - .bind(text) - .bind(source) - .bind(ctx) - .bind(message_id) - .fetch_optional(pool) - .await - .map_err(|e| format!("插入聊天记录失败: {}", e))?; - - Ok(row.map(|r| r.0).unwrap_or(0)) -} - -/// 查询最近可进入 LLM 上下文的聊天记录(排除工具等待/审批等中间提示)。 -/// -/// 会自动应用"会话边界":最近一次 `/clear` 或 12h 空闲超时摘要之前的消息 -/// 已归档为摘要,不再进入新会话的上下文(见 [`latest_session_boundary`])。 -pub async fn list_recent_chat_records_for_context( - pool: &PgPool, - user_id: &str, - limit: i64, -) -> Result, String> { - // 会话边界:最近一次"会话过期"摘要(/clear 或 12h 空闲超时)的时间点。 - // 边界之前的消息已归档为摘要,不再进入新会话的上下文。 - let boundary = latest_session_boundary(pool, user_id).await; - - let records = sqlx::query_as::<_, ChatRecord>( - r#" - SELECT id, created_at, direction, user_id, account_id, text, source, context_token, message_id - FROM chat_records - WHERE user_id = $1 - AND source NOT IN ('llm_pending_tool', 'tool_approval', 'clear') - AND created_at > COALESCE($2, '-infinity'::timestamptz) - ORDER BY created_at DESC - LIMIT $3 - "#, - ) - .bind(user_id) - .bind(boundary) - .bind(limit) - .fetch_all(pool) - .await - .map_err(|e| format!("查询上下文聊天记录失败: {}", e))?; - - Ok(records) -} - -/// ## 查询用户最近的"会话边界"时间点 -/// -/// 返回最近一次会话过期摘要(`/clear` 或 12h 空闲超时)的 `created_at`。 -/// 该时间点之前的聊天记录已归档为摘要,不应再进入新会话的上下文。 -/// -/// `Overflow` 摘要不构成边界(它发生在会话进行中,仅压缩早期消息)。 -pub async fn latest_session_boundary( - pool: &PgPool, - user_id: &str, -) -> Option> { - sqlx::query_as::<_, (DateTime,)>( - r#" - SELECT created_at - FROM session_summaries - WHERE user_id = $1 AND reason IN ('clear', 'timeout') - ORDER BY created_at DESC - LIMIT 1 - "#, - ) - .bind(user_id) - .fetch_optional(pool) - .await - .ok()? - .map(|(t,)| t) -} - -// ─── LLM 用量 ─── - -/// LLM 用量聚合统计 -#[derive(Debug, Clone, sqlx::FromRow)] -pub struct LlmUsageStats { - pub total_calls: i64, - pub total_prompt_tokens: i64, - pub total_completion_tokens: i64, - pub total_tokens: i64, - pub total_cache_hit: i64, - pub total_cache_miss: i64, -} - -/// 查询 LLM 用量统计(按时间范围过滤) -pub async fn query_llm_usage_stats( - pool: &PgPool, - since: Option>, - until: Option>, - model_filter: Option<&str>, -) -> Result { - let since = since.unwrap_or_else(|| chrono::Utc::now() - chrono::Duration::days(7)); - let until = until.unwrap_or_else(chrono::Utc::now); - - let row = sqlx::query_as::<_, LlmUsageStats>( - r#" - SELECT - COUNT(*)::bigint as total_calls, - COALESCE(SUM(prompt_tokens), 0)::bigint as total_prompt_tokens, - COALESCE(SUM(completion_tokens), 0)::bigint as total_completion_tokens, - COALESCE(SUM(total_tokens), 0)::bigint as total_tokens, - COALESCE(SUM(cache_hit_tokens), 0)::bigint as total_cache_hit, - COALESCE(SUM(cache_miss_tokens), 0)::bigint as total_cache_miss - FROM llm_usage - WHERE created_at >= $1 AND created_at <= $2 - AND ($3::text IS NULL OR model = $3) - "#, - ) - .bind(since) - .bind(until) - .bind(model_filter) - .fetch_one(pool) - .await - .map_err(|e| format!("查询 LLM 用量失败: {}", e))?; - - Ok(row) -} - -// ─── 会话摘要 ─── - -/// 保存一条摘要到数据库 -pub async fn save_summary( - pool: &PgPool, - user_id: &str, - text: &str, - reason: &str, - message_count: i32, -) -> Result<(), String> { - sqlx::query( - "INSERT INTO session_summaries (user_id, summary_text, reason, message_count) VALUES ($1, $2, $3, $4)", - ) - .bind(user_id) - .bind(text) - .bind(reason) - .bind(message_count) - .execute(pool) - .await - .map_err(|e| format!("保存摘要失败: {}", e))?; - Ok(()) -} - -/// 加载最近的摘要(用于 read_summaries 工具),按 user_id 过滤 -pub async fn load_summaries( - pool: &PgPool, - user_id: &str, - limit: i64, -) -> Result, String> { - let rows = sqlx::query_as::<_, (String,)>( - "SELECT summary_text FROM session_summaries WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2", - ) - .bind(user_id) - .bind(limit) - .fetch_all(pool) - .await - .map_err(|e| format!("查询摘要失败: {}", e))?; - Ok(rows.into_iter().map(|(t,)| t).collect()) -} - -// ─── 定时任务 ─── - -#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)] -pub struct ScheduledTask { - pub id: uuid::Uuid, - pub name: String, - pub description: String, - pub user_id: Option, - pub command: String, - pub shell: String, - pub cwd: String, - pub interval_seconds: i32, - pub enabled: bool, - pub next_run_at: DateTime, - pub last_run_at: Option>, - pub last_status: Option, - pub task_type: String, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// 列出所有定时任务 -pub async fn list_scheduled_tasks(pool: &PgPool) -> Result, String> { - let tasks = sqlx::query_as::<_, ScheduledTask>( - r#" - SELECT id, name, description, user_id, command, shell, cwd, - interval_seconds, enabled, next_run_at, last_run_at, - last_status, task_type, created_at, updated_at - FROM scheduled_tasks - ORDER BY next_run_at ASC - "#, - ) - .fetch_all(pool) - .await - .map_err(|e| format!("查询定时任务失败: {}", e))?; - Ok(tasks) -} - -/// 添加定时任务 -pub async fn add_scheduled_task( - pool: &PgPool, - name: &str, - user_id: &str, - command: &str, - interval_seconds: i32, - description: &str, - shell: &str, - cwd: &str, - task_type: &str, -) -> Result { - let id = uuid::Uuid::new_v4(); - sqlx::query( - r#" - INSERT INTO scheduled_tasks (id, name, description, user_id, command, shell, cwd, interval_seconds, task_type) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - "#, - ) - .bind(id) - .bind(name) - .bind(description) - .bind(user_id) - .bind(command) - .bind(shell) - .bind(cwd) - .bind(interval_seconds) - .bind(task_type) - .execute(pool) - .await - .map_err(|e| format!("添加定时任务失败: {}", e))?; - Ok(id) -} - -/// 更新定时任务(只更新非 None 的字段) -pub async fn update_scheduled_task( - pool: &PgPool, - id: &uuid::Uuid, - name: Option<&str>, - command: Option<&str>, - interval_seconds: Option, - description: Option<&str>, - shell: Option<&str>, - cwd: Option<&str>, -) -> Result { - let mut sets = Vec::new(); - let mut idx = 1u32; - - if name.is_some() { - sets.push(format!("name = ${}", idx)); - idx += 1; - } - if command.is_some() { - sets.push(format!("command = ${}", idx)); - idx += 1; - } - if interval_seconds.is_some() { - sets.push(format!("interval_seconds = ${}", idx)); - idx += 1; - } - if description.is_some() { - sets.push(format!("description = ${}", idx)); - idx += 1; - } - if shell.is_some() { - sets.push(format!("shell = ${}", idx)); - idx += 1; - } - if cwd.is_some() { - sets.push(format!("cwd = ${}", idx)); - idx += 1; - } - - if sets.is_empty() { - return Ok(false); - } - - sets.push("updated_at = NOW()".to_string()); - - let sql = format!( - "UPDATE scheduled_tasks SET {} WHERE id = ${}", - sets.join(", "), - idx - ); - - let mut query = sqlx::query(&sql).bind(id); - - if let Some(v) = name { - query = query.bind(v); - } - if let Some(v) = command { - query = query.bind(v); - } - if let Some(v) = interval_seconds { - query = query.bind(v); - } - if let Some(v) = description { - query = query.bind(v); - } - if let Some(v) = shell { - query = query.bind(v); - } - if let Some(v) = cwd { - query = query.bind(v); - } - - let rows = query - .execute(pool) - .await - .map_err(|e| format!("更新定时任务失败: {}", e))?; - - Ok(rows.rows_affected() > 0) -} - -/// 删除定时任务 -pub async fn delete_scheduled_task(pool: &PgPool, id: &uuid::Uuid) -> Result { - let rows = sqlx::query("DELETE FROM scheduled_tasks WHERE id = $1") - .bind(id) - .execute(pool) - .await - .map_err(|e| format!("删除定时任务失败: {}", e))?; - Ok(rows.rows_affected() > 0) -} - -/// 切换定时任务启用状态 -pub async fn toggle_scheduled_task(pool: &PgPool, id: &uuid::Uuid) -> Result<(bool, bool), String> { - let row = sqlx::query_as::<_, (bool,)>( - "UPDATE scheduled_tasks SET enabled = NOT enabled, updated_at = NOW() WHERE id = $1 RETURNING enabled", - ) - .bind(id) - .fetch_optional(pool) - .await - .map_err(|e| format!("切换定时任务状态失败: {}", e))?; - - match row { - Some((enabled,)) => Ok((true, enabled)), - None => Ok((false, false)), - } -} diff --git a/src/llm/conversation.rs b/src/llm/conversation.rs deleted file mode 100644 index e0189bb..0000000 --- a/src/llm/conversation.rs +++ /dev/null @@ -1,514 +0,0 @@ -//! ## Conversation —— LLM 对话管理器(核心类) -//! -//! 这是整个项目中"与 LLM 对话"的核心抽象,负责: -//! -//! ### 职责 -//! 1. **管理对话状态** — 持有 `ChatSession`(消息历史、摘要、token 预算) -//! 2. **流式对话** — 调用 provider 发起流式请求,逐 chunk 消费 -//! 3. **工具循环** — 自动处理 LLM 发起的工具调用,循环直到无工具或达到轮次上限 -//! 4. **异步工具** — 支持工具通过消息队列异步执行,结果回来后恢复对话 -//! 5. **上下文管理** — token 预算检查、溢出摘要、空闲超时摘要 -//! -//! ### 工具循环流程 -//! ```text -//! chat_with_tools(user_msg) -//! → run_tool_loop() -//! → 1. build_context() 构建上下文(含摘要、system prompt) -//! → 2. provider.chat_stream() 发起流式请求 -//! → 3. 消费流,收集文本 + tool_calls -//! → 4. 有 tool_calls?→ 执行 → 回到步骤 1(最多 5 轮) -//! → 5. 无 tool_calls?→ 返回最终回复 -//! ``` - -use crate::context::builder; -use crate::context::types::ChatSession; -use crate::llm::provider::{BoxedProvider, StreamReceiver, create_provider}; -use crate::llm::types::{ConversationConfig, Message, StreamChunk, ToolCall, Usage}; -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use tokio::sync::Mutex; - -/// ## 工具执行器返回值 -/// -/// `Ready` 表示工具结果已经可直接写入对话;`Pending` 表示工具调用已交给 -/// 外部队列,结果稍后通过 `ToolResult` 回喂。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ToolExecution { - Ready(String), - Pending, -} - -/// ## 工具执行器 —— LLM 调用的工具回调 -/// -/// 当 LLM 在对话中发起工具调用时,Conversation 会调用这个 executor 来执行工具。 -pub type ToolExecutor = Arc< - dyn Fn(&str, &str, &str) -> Pin> + Send>> - + Send - + Sync, ->; - -/// ## 摘要生成器 —— 用于生成对话摘要的回调 -/// -/// Conversation 在两种情况下会触发摘要: -/// 1. **溢出摘要** — 消息 token 总数接近 budget 时,压缩早期消息 -/// 2. **空闲超时摘要** — 距上条用户消息超过 12 小时时,总结本次会话 -pub type Summarizer = Arc< - dyn Fn(String) -> Pin> + Send>> + Send + Sync, ->; - -/// ## `chat_with_tools` 的返回结果 -/// -/// * `reply` — LLM 最终的文本回复 -/// * `used_tools` — 本轮对话中是否调用了工具 -/// * `usage` — Token 用量统计(可选) -/// * `has_pending_async` — 是否有异步工具调用仍在等待结果 -/// 如果为 true,调用方应该保留 Conversation 实例,等待 ToolResult 回来后再调用 -/// `resume_tool_loop()` 恢复对话 -#[derive(Debug)] -pub struct ChatResult { - pub reply: String, - #[allow(dead_code)] - pub used_tools: bool, - #[allow(dead_code)] - pub usage: Option, - /// 是否有异步工具调用仍在等待结果 - pub has_pending_async: bool, -} - -/// ## Conversation —— LLM 对话管理器(核心类) -/// -/// 这是整个项目中"与 LLM 对话"的核心抽象,负责: -/// -/// ### 职责 -/// 1. **管理对话状态** — 持有 `ChatSession`(消息历史、摘要、token 预算) -/// 2. **流式对话** — 调用 provider 发起流式请求,逐 chunk 消费 -/// 3. **工具循环** — 自动处理 LLM 发起的工具调用,循环直到无工具或达到轮次上限 -/// 4. **异步工具** — 支持工具通过消息队列异步执行,结果回来后恢复对话 -/// 5. **上下文管理** — token 预算检查、溢出摘要、空闲超时摘要 -/// -/// ### 工具循环流程 -/// ```text -/// chat_with_tools(user_msg) -/// → run_tool_loop() -/// → 1. build_context() 构建上下文(含摘要、system prompt) -/// → 2. provider.chat_stream() 发起流式请求 -/// → 3. 消费流,收集文本 + tool_calls -/// → 4. 有 tool_calls?→ 执行 → 回到步骤 1(最多 5 轮) -/// → 5. 无 tool_calls?→ 返回最终回复 -/// ``` -pub struct Conversation { - config: ConversationConfig, - provider: BoxedProvider, - session: Arc>, - tool_executor: Option, - /// 是否有异步工具调用等待结果返回 - pending_async_tools: Arc, - /// 待处理的异步工具数量(全部返回后才能 resume) - pending_async_count: Arc, -} - -impl Conversation { - /// ## 创建 Conversation - /// - /// 1. 通过 `create_provider()` 工厂从配置创建 LLM 提供商 - /// 2. 初始化空的 `ChatSession`(消息历史在收到第一条用户消息后按需加载) - /// 3. tool_executor 初始为 None,需要调用 `set_tool_executor` 设置 - pub fn new(config: ConversationConfig) -> Result { - let provider = create_provider(&config)?; - Ok(Self { - config, - provider, - session: Arc::new(Mutex::new(ChatSession::new())), - tool_executor: None, - pending_async_tools: Arc::new(AtomicBool::new(false)), - pending_async_count: Arc::new(AtomicU32::new(0)), - }) - } - - pub fn set_tool_executor(&mut self, executor: ToolExecutor) { - self.tool_executor = Some(executor); - } - /// 是否有异步工具调用正在等待结果 - #[allow(dead_code)] - pub fn has_pending_async(&self) -> bool { - self.pending_async_tools.load(Ordering::SeqCst) - } - pub fn session(&self) -> Arc> { - Arc::clone(&self.session) - } - #[allow(dead_code)] - pub fn model(&self) -> &str { - &self.config.model - } - #[allow(dead_code)] - pub fn provider_name(&self) -> &str { - self.provider.name() - } - #[allow(dead_code)] - pub fn spec(&self) -> &ConversationConfig { - &self.config - } - pub fn summarizer(&self) -> Summarizer { - self.create_summarizer() - } - - /// ## 创建 LLM 摘要生成器 - /// - /// 用当前 LLM provider 创建一个非流式调用的摘要器。 - /// 把消息列表拼成 prompt 发给 LLM,返回生成的摘要文本。 - /// - /// 摘要 prompt 为:"你是一个对话摘要助手,用中文输出简洁摘要。" - pub fn create_summarizer(&self) -> Summarizer { - let provider = self.provider.clone(); - Arc::new(move |prompt: String| { - let provider = provider.clone(); - Box::pin(async move { - let msgs = vec![ - Message::system("你是一个对话摘要助手,用中文输出简洁摘要。"), - Message::user(prompt), - ]; - let mut rx = provider - .chat_stream(&ConversationConfig::default(), &msgs) - .await - .map_err(|e| format!("摘要请求失败: {}", e))?; - let mut text = String::new(); - while let Some(chunk) = rx.recv().await { - match chunk { - StreamChunk::Text(t) => text.push_str(&t), - StreamChunk::Done { text: full, .. } => { - if !full.is_empty() { - text = full; - } - break; - } - StreamChunk::Error(e) => return Err(e), - _ => {} - } - } - Ok(text) - }) - }) - } - - /// ## 单次对话(无工具调用) - /// - /// 简单的流式对话:发送用户消息 → 获取 AI 回复。 - /// 不会触发工具循环,适用于不需要 function calling 的场景。 - /// - /// ### 流程 - /// 1. 检查是否空闲超时(距上条消息 > 12h),如果是则触发摘要 - /// 2. 将用户消息添加到 session - /// 3. 构建上下文(system prompt + 摘要 + 近期消息) - /// 4. 发起流式请求 - /// 5. 返回 `ChatHandle`(可逐 chunk 消费) - #[allow(dead_code)] - pub async fn chat(&self, user_message: String) -> Result { - // 空闲超时检查 - { - let s = self.session.lock().await; - if s.is_idle_timeout() { - drop(s); - 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 rx = self.provider.chat_stream(&self.config, &messages).await?; - - Ok(ChatHandle { - rx: Some(rx), - full_text: String::new(), - tool_calls: None, - usage: None, - session: Arc::clone(&self.session), - }) - } - - /// ## 对话 + 工具循环(主入口) - /// - /// 这是最常用的方法:发送用户消息,AI 可以选择回复文本或调用工具。 - /// 如果调用了工具,工具执行结果会被自动回喂给 AI,AI 再决定下一步操作。 - /// - /// ### 完整流程 - /// 1. 空闲超时检查 → 触发摘要 - /// 2. 添加用户消息到 session - /// 3. 进入 `run_tool_loop()` - /// - 构建上下文 → 调 LLM → 解析流 - /// - 有 tool_calls?→ 执行工具 → 继续循环(最多 5 轮) - /// - 无 tool_calls?→ 返回最终回复 - pub async fn chat_with_tools(&self, user_message: String) -> Result { - // ── 空闲超时检查 ── - { - let s = self.session.lock().await; - if s.is_idle_timeout() { - drop(s); - builder::trigger_idle_summary(&self.session, Some(&self.summarizer())).await; - tracing::info!(target: "ias::tool", "⏰ 检测到 12h 空闲,已生成摘要"); - } - } - - self.session.lock().await.add_user(user_message.clone()); - self.run_tool_loop().await - } - - /// 在异步工具结果返回后恢复工具循环(不追加用户消息) - pub async fn resume_tool_loop(&self) -> Result { - self.pending_async_tools.store(false, Ordering::SeqCst); - self.run_tool_loop().await - } - - /// 通知一个异步工具结果已返回,返回 true 表示所有异步工具均已返回 - pub fn notify_tool_result(&self) -> bool { - decrement_pending_count(&self.pending_async_count) - } - - /// 工具循环核心:调 LLM → 处理工具调用 → 循环直到无工具或需要异步等待 - async fn run_tool_loop(&self) -> Result { - let mut used_tools = false; - let mut last_usage = None; - let mut turn_count = 0u32; - - loop { - turn_count += 1; - if turn_count > 30 { - return Err("工具调用次数过多(最多30轮)".to_string()); - } - - let messages = 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(); - let mut tool_calls: Option> = None; - let mut rx = rx; - - while let Some(chunk) = rx.recv().await { - match chunk { - StreamChunk::Text(t) => full_text.push_str(&t), - StreamChunk::Done { - text, - reasoning, - tool_calls: tc, - usage, - } => { - if let Some(r) = reasoning - && !r.is_empty() - { - tracing::info!(target: "ias::raw", "[llm-reasoning] {}", r); - } - if !text.is_empty() { - full_text = text; - } - tool_calls = tc; - last_usage = usage; - break; - } - StreamChunk::Error(e) => return Err(e), - StreamChunk::Reasoning(r) => { - tracing::debug!(target: "ias::raw", "[llm-reasoning-delta] {}", r); - } - } - } - - // 解析代码块中的工具调用 - if let Some(calls) = tool_calls - && !calls.is_empty() - { - used_tools = true; - tracing::info!(target: "ias::tool", - "🔧 LLM 请求 {} 个工具: {}", - calls.len(), - calls.iter().map(|c| format!("{}({})", c.function.name, c.function.arguments)).collect::>().join(", ") - ); - - self.session - .lock() - .await - .add_assistant_tool_calls(full_text.clone(), calls.clone()); - - let mut has_async = false; - let mut async_count: u32 = 0; - for tc in &calls { - let result = match &self.tool_executor { - Some(exec) => { - match exec(&tc.function.name, &tc.function.arguments, &tc.id).await { - Ok(r) => r, - Err(e) => ToolExecution::Ready(format!("执行失败: {}", e)), - } - } - None => ToolExecution::Ready("工具执行器未配置".to_string()), - }; - - let result = match result { - ToolExecution::Ready(result) => result, - ToolExecution::Pending => { - has_async = true; - async_count += 1; - continue; - } - }; - - tracing::info!(target: "ias::msg", "[tool] {}: {}", tc.function.name, result); - self.session - .lock() - .await - .add_tool_result(&tc.id, &tc.function.name, &result); - } - - if has_async { - self.pending_async_tools.store(true, Ordering::SeqCst); - self.pending_async_count - .store(async_count, Ordering::SeqCst); - return Ok(ChatResult { - reply: full_text, - used_tools, - usage: last_usage, - has_pending_async: true, - }); - } - continue; - } - - // 无工具调用:记录回复 - if !full_text.is_empty() { - self.session.lock().await.add_assistant(full_text.clone()); - } - - // 检查 token 是否接近预算,触发溢出摘要 - { - let s = self.session.lock().await; - let recent = s.recent_messages(); - let estimated: u32 = recent.iter().map(builder::estimate_tokens).sum(); - if estimated > s.token_budget { - drop(s); - builder::trigger_overflow_summary(&self.session, Some(&self.summarizer())) - .await; - tracing::info!(target: "ias::tool", "📦 上下文超预算,已触发溢出摘要"); - } - } - - return Ok(ChatResult { - reply: full_text, - used_tools, - usage: last_usage, - has_pending_async: false, - }); - } - } -} - -fn decrement_pending_count(count: &AtomicU32) -> bool { - loop { - let current = count.load(Ordering::SeqCst); - if current == 0 { - return false; - } - if count - .compare_exchange(current, current - 1, Ordering::SeqCst, Ordering::SeqCst) - .is_ok() - { - return current == 1; - } - } -} - -// ─── ChatHandle ─── - -#[allow(dead_code)] -pub struct ChatHandle { - rx: Option, - full_text: String, - tool_calls: Option>, - usage: Option, - session: Arc>, -} - -#[allow(dead_code)] -impl ChatHandle { - pub async fn next_chunk(&mut self) -> Option { - let chunk = self.rx.as_mut()?.recv().await; - match &chunk { - Some(StreamChunk::Text(t)) => self.full_text.push_str(t), - Some(StreamChunk::Done { - text, - tool_calls, - usage, - .. - }) => { - if !text.is_empty() { - self.full_text = text.clone(); - } - self.tool_calls = tool_calls.clone(); - self.usage = usage.clone(); - if !self.full_text.is_empty() { - let mut s = self.session.lock().await; - s.add_assistant(self.full_text.clone()); - } - self.rx = None; - } - Some(StreamChunk::Error(_)) => { - self.rx = None; - } - _ => {} - } - chunk - } - - /// 消费所有流式块,返回完整文本 - pub async fn consume(&mut self) -> Result { - while let Some(chunk) = self.next_chunk().await { - if let StreamChunk::Error(e) = chunk { - return Err(e); - } - } - Ok(self.full_text.clone()) - } - - pub fn get_usage(&self) -> Option<&Usage> { - self.usage.as_ref() - } -} - -pub const DEFAULT_SYSTEM_PROMPT: &str = "\ -你是 iAs 智能助手,通过微信与用户交流。回复简洁自然。\n\ -\n\ -你可以通过 function calling 调用两个元工具:\n\ -1. `query_capabilities` — 查询所有可用能力工具\n\ -2. `call_capability` — 调用能力工具,参数 {\"name\":\"工具名\",\"prompt\":{\"参数\":\"值\"}}\n\ -\n\ -流程:收到消息 → query_capabilities → call_capability → 回复 -\ -\ -需要创建或修改本地工具时:先用 `tool_inspector`(action=read_tool)查看现有实现,再用 `tool_manager` 修改。`tool_manager` 是高风险操作,执行前会请求用户确认。\n\ -"; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn tool_execution_is_explicit_state() { - assert_eq!( - ToolExecution::Ready("__ASYNC_PENDING__weather".to_string()), - ToolExecution::Ready("__ASYNC_PENDING__weather".to_string()) - ); - assert_ne!( - ToolExecution::Ready("__ASYNC_PENDING__weather".to_string()), - ToolExecution::Pending - ); - } - - #[test] - fn notify_tool_result_does_not_underflow() { - let count = AtomicU32::new(0); - - assert!(!decrement_pending_count(&count)); - - count.store(1, Ordering::SeqCst); - assert!(decrement_pending_count(&count)); - assert!(!decrement_pending_count(&count)); - assert_eq!(count.load(Ordering::SeqCst), 0); - } -} diff --git a/src/llm/deepseek.rs b/src/llm/deepseek.rs deleted file mode 100644 index 76d4357..0000000 --- a/src/llm/deepseek.rs +++ /dev/null @@ -1,285 +0,0 @@ -//! ## DeepSeek LLM 提供商实现 -//! -//! 实现了 `LlmProvider` trait,通过 DeepSeek API 提供流式对话能力。 -//! -//! ### 配置 -//! 从环境变量读取: -//! - `DEEPSEEK_API_KEY` — API 密钥(必需) -//! - `DEEPSEEK_BASE_URL` — API 地址(可选,默认 `https://api.deepseek.com/v1`) -//! - `DEEPSEEK_MODEL` — 模型名(可选,默认 `deepseek-v4-flash`) -//! -//! ### 请求格式 -//! 发送 POST 到 `{base_url}/chat/completions`,标准 OpenAI-compatible 格式: -//! ```json -//! { -//! "model": "deepseek-v4-flash", -//! "messages": [...], -//! "stream": true, -//! "tools": [...], -//! "thinking": {...} -//! } -//! ``` -//! -//! ### 流式处理 -//! 使用 `reqwest` 的 `bytes_stream()` 逐 chunk 读取 SSE 流, -//! 通过 `parse_chat_chunk()` 解析每个 delta,通过 mpsc channel 发送给调用方。 -//! 工具调用的 delta 跨多个 chunk 拼接,在 Done 信号中一次性返回。 - -use crate::llm::provider::{ - LlmProvider, ParsedChunk, StreamReceiver, StreamSender, parse_chat_chunk, -}; -use crate::llm::types::{ConversationConfig, Message, StreamChunk, ToolCall, Usage}; -use async_trait::async_trait; -use reqwest::Client as HttpClient; -use std::collections::BTreeMap; -use tokio::sync::mpsc; - -/// ## DeepSeek LLM 提供商 -/// -/// 实现了 `LlmProvider` trait,通过 DeepSeek API 提供流式对话能力。 -/// -/// ### 配置 -/// 从环境变量读取: -/// * `DEEPSEEK_API_KEY` — API 密钥(必需) -/// * `DEEPSEEK_BASE_URL` — API 地址(可选,默认 `https://api.deepseek.com/v1`) -/// * `DEEPSEEK_MODEL` — 模型名(可选,默认 `deepseek-v4-flash`) -/// -/// ### 请求格式 -/// 发送 POST 到 `{base_url}/chat/completions`,标准 OpenAI-compatible 格式: -/// ```json -/// { -/// "model": "deepseek-v4-flash", -/// "messages": [...], -/// "stream": true, -/// "tools": [...], // 可选,function calling -/// "thinking": {...} // 可选,DeepSeek 推理模式 -/// } -/// ``` -pub struct DeepSeekProvider { - http: HttpClient, - api_key: String, - base_url: String, -} - -impl DeepSeekProvider { - pub fn new() -> Result { - let api_key = std::env::var("DEEPSEEK_API_KEY") - .map_err(|_| "请设置 DEEPSEEK_API_KEY 环境变量".to_string())?; - let base_url = std::env::var("DEEPSEEK_BASE_URL") - .unwrap_or_else(|_| "https://api.deepseek.com/v1".to_string()); - - Ok(Self { - http: HttpClient::builder() - .timeout(std::time::Duration::from_secs(120)) - .build() - .map_err(|e| format!("创建 HTTP client 失败: {}", e))?, - api_key, - base_url, - }) - } -} - -#[async_trait] -impl LlmProvider for DeepSeekProvider { - fn name(&self) -> &str { - "deepseek" - } - - async fn chat_stream( - &self, - config: &ConversationConfig, - messages: &[Message], - ) -> Result { - let url = format!("{}/chat/completions", self.base_url); - - let mut body = serde_json::json!({ - "model": config.model, - "messages": messages, - "temperature": config.temperature, - "max_tokens": config.max_tokens, - "stream": true, - }); - - // 如果有工具,添加 tools 参数 - if let Some(ref tools) = config.tools { - body["tools"] = serde_json::Value::Array(tools.clone()); - } - - if config.thinking { - body["thinking"] = serde_json::json!({"type": "enabled"}); - body["reasoning_effort"] = serde_json::json!("high"); - body.as_object_mut().map(|obj| { - obj.remove("temperature"); - obj.remove("top_p"); - }); - } - - let (tx, rx) = mpsc::channel::(64); - let http = self.http.clone(); - let api_key = self.api_key.clone(); - - tokio::spawn(async move { - if let Err(e) = stream_deepseek(http, &url, &api_key, body, tx.clone()).await { - let _ = tx.send(StreamChunk::Error(e)).await; - } - }); - - Ok(rx) - } -} - -async fn stream_deepseek( - http: HttpClient, - url: &str, - api_key: &str, - body: serde_json::Value, - tx: StreamSender, -) -> Result<(), String> { - let response = http - .post(url) - .header("Authorization", format!("Bearer {}", api_key)) - .header("Content-Type", "application/json") - .header("Accept", "text/event-stream") - .json(&body) - .send() - .await - .map_err(|e| format!("请求失败: {}", e))?; - - tracing::info!(target: "ias::raw", "[llm-request] {}", body); - - if !response.status().is_success() { - let status = response.status(); - let text = response.text().await.unwrap_or_default(); - tracing::error!(target: "ias::err", "[llm-api] HTTP {}: {}", status, text); - let _ = tx - .send(StreamChunk::Error(format!("HTTP {}: {}", status, text))) - .await; - return Err(format!("HTTP {}: {}", status, text)); - } - - let mut full_text = String::new(); - let mut full_reasoning = String::new(); - let mut usage: Option = None; - let mut tool_call_builders: BTreeMap, Option, String)> = - BTreeMap::new(); - - use futures_util::StreamExt; - let mut buf = String::new(); - let mut stream = response.bytes_stream(); - - while let Some(chunk_result) = stream.next().await { - let chunk = chunk_result.map_err(|e| format!("读取流失败: {}", e))?; - let text = String::from_utf8_lossy(&chunk); - buf.push_str(&text); - while let Some(line_end) = buf.find('\n') { - let line = buf[..line_end].to_string(); - buf = buf[line_end + 1..].to_string(); - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - if let Some(parsed) = parse_chat_chunk(trimmed) { - match parsed { - ParsedChunk::Text(t) => { - full_text.push_str(&t); - let _ = tx.send(StreamChunk::Text(t)).await; - } - ParsedChunk::Reasoning(r) => { - full_reasoning.push_str(&r); - let _ = tx.send(StreamChunk::Reasoning(r)).await; - } - ParsedChunk::ToolCallDelta { - index, - id, - name, - arguments, - } => { - let e = - tool_call_builders - .entry(index) - .or_insert((None, None, String::new())); - if let Some(i) = id { - e.0 = Some(i); - } - if let Some(n) = name { - e.1 = Some(n); - } - e.2.push_str(&arguments); - } - ParsedChunk::Usage(u) => { - usage = Some(u); - } - _ => {} - } - } - } - } - - if !buf.trim().is_empty() - && let Some(parsed) = parse_chat_chunk(buf.trim()) - { - match parsed { - ParsedChunk::Text(t) => full_text.push_str(&t), - ParsedChunk::Reasoning(r) => full_reasoning.push_str(&r), - ParsedChunk::ToolCallDelta { - index, - id, - name, - arguments, - } => { - let e = tool_call_builders - .entry(index) - .or_insert((None, None, String::new())); - if let Some(i) = id { - e.0 = Some(i); - } - if let Some(n) = name { - e.1 = Some(n); - } - e.2.push_str(&arguments); - } - ParsedChunk::Usage(u) => { - usage = Some(u); - } - _ => {} - } - } - - let tool_calls: Option> = if tool_call_builders.is_empty() { - None - } else { - Some( - tool_call_builders - .into_values() - .filter_map(|(id, name, args)| { - let name = name?; - let id = id.unwrap_or_else(|| format!("call_{}", rand::random::())); - Some(ToolCall { - id, - call_type: "function".to_string(), - function: crate::llm::types::ToolFunctionCall { - name, - arguments: args, - }, - }) - }) - .collect(), - ) - }; - - tracing::info!(target: "ias::raw", "[llm-response] text={} tool_calls={:?}", full_text, tool_calls); - - let _ = tx - .send(StreamChunk::Done { - text: full_text, - reasoning: if full_reasoning.is_empty() { - None - } else { - Some(full_reasoning) - }, - tool_calls, - usage, - }) - .await; - Ok(()) -} diff --git a/src/llm/mod.rs b/src/llm/mod.rs deleted file mode 100644 index 384f761..0000000 --- a/src/llm/mod.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! ## LLM 对话系统 —— DeepSeek API 集成 -//! -//! 封装与 DeepSeek 大语言模型的交互: -//! -//! - `types` — 消息/角色/工具调用/用量等核心类型 -//! - `provider` — `LlmProvider` trait 抽象 + SSE 流式解析 -//! - `deepseek` — DeepSeek API 流式客户端实现 -//! - `conversation` — `Conversation` 对话管理器(工具循环、摘要、上下文管理) -//! -//! ### 架构 -//! ```text -//! Conversation (对话管理器) -//! ├── ChatSession (消息历史 + 摘要 + checkpoint) -//! ├── LlmProvider (trait: chat_stream → StreamReceiver) -//! │ └── DeepSeekProvider (实现: SSE 流式请求) -//! └── ToolExecutor (回调: 执行 LLM 调用的工具) -//! ``` - -pub mod conversation; -pub mod deepseek; -pub mod provider; -pub mod types; - -pub use conversation::{ - ChatResult, Conversation, DEFAULT_SYSTEM_PROMPT, ToolExecution, ToolExecutor, -}; -pub use types::ConversationConfig; diff --git a/src/llm/provider.rs b/src/llm/provider.rs deleted file mode 100644 index 05f87e2..0000000 --- a/src/llm/provider.rs +++ /dev/null @@ -1,207 +0,0 @@ -//! ## LLM 提供商抽象(trait) + SSE 流式解析 -//! -//! 定义了 LLM 层的核心抽象接口 `LlmProvider`,以及 SSE 流式响应的解析工具。 -//! -//! ### 设计模式 -//! - `LlmProvider` trait — 所有 LLM 提供商需实现的接口 -//! - `create_provider()` — 工厂函数,根据配置创建对应的提供商实例 -//! - `parse_chat_chunk()` — SSE 流式 delta 解析器 -//! -//! ### 流式架构 -//! ```text -//! Provider.chat_stream() → mpsc::Receiver -//! ├── StreamChunk::Text (delta 文本) -//! ├── StreamChunk::Reasoning (思考内容) -//! ├── StreamChunk::Done (完成信号 + 工具调用 + 用量) -//! └── StreamChunk::Error (错误) -//! ``` - -use crate::llm::types::{ConversationConfig, Message, StreamChunk}; -use async_trait::async_trait; -use serde::Deserialize; -use std::sync::Arc; -use tokio::sync::mpsc; - -/// 流式返回:每个 StreamChunk 是 delta 或控制信号 -pub type StreamReceiver = mpsc::Receiver; -pub type StreamSender = mpsc::Sender; - -/// ## LLM 提供商抽象(trait) -/// -/// 这是 LLM 层的核心抽象接口,定义了"一个 LLM 提供商需要提供什么能力"。 -/// 目前唯一的实现是 `DeepSeekProvider`,但通过这个 trait 可以轻松添加 -/// 其他提供商(如 OpenAI、Claude 等)。 -/// -/// ### 关键设计 -/// * 使用 `async_trait` 宏支持异步 trait 方法 -/// * 返回 `StreamReceiver`(mpsc::Receiver),调用方可以按 chunk 消费流式结果 -/// * `Send + Sync` 保证可以在 tokio task 间安全共享 -#[async_trait] -pub trait LlmProvider: Send + Sync { - /// 提供商名称 - fn name(&self) -> &str; - - /// 发起流式对话 - async fn chat_stream( - &self, - config: &ConversationConfig, - messages: &[Message], - ) -> Result; -} - -// ─── 内置提供商注册 ─── - -pub type BoxedProvider = Arc; - -/// ## `create_provider` — 工厂函数:从配置创建恰当的 LLM 提供商 -/// -/// 目前只返回 DeepSeek,后续可以在这里根据配置 switch 到不同提供商。 -pub fn create_provider(_config: &ConversationConfig) -> Result { - Ok(Arc::new(super::deepseek::DeepSeekProvider::new()?)) -} - -// ─── 内部:SSE 解析工具 ─── - -/// ## 流式块解析结果(内部枚举) -/// -/// 用于从 SSE 的 JSON delta 中解析出不同种类的块。 -/// * `Text` — 普通文本 delta -/// * `Reasoning` — 思考内容(DeepSeek reasoning_content) -/// * `ToolCallDelta` — 工具调用的增量信息(可能需要跨多个 chunk 拼接) -/// * `FinishReason` — 结束原因(stop / tool_calls 等) -/// * `Usage` — 用量统计(通常在最后一个 chunk 中) -#[allow(dead_code)] -pub(crate) enum ParsedChunk { - Text(String), - Reasoning(String), - ToolCallDelta { - index: i32, - id: Option, - name: Option, - arguments: String, - }, - FinishReason(String), - Usage(super::types::Usage), -} - -/// ## 从 JSON body 中解析 DeepSeek/OpenAI 流式 delta -/// -/// ### 输入 -/// 一行 SSE 数据,格式为 `data: {json}`。 -/// -/// ### 处理逻辑 -/// 1. 跳过非 `data: ` 前缀的行和 `[DONE]` 行 -/// 2. 解析 JSON 为 `ChunkResponse`(内嵌多个 `choices`) -/// 3. 提取 usage(可能在任意一个 chunk 中) -/// 4. 对每个 choice,按优先级提取: -/// - tool_calls delta → `ToolCallDelta` -/// - reasoning_content → `Reasoning` -/// - content → `Text` -/// - finish_reason → `FinishReason` -pub(crate) fn parse_chat_chunk(line: &str) -> Option { - if !line.starts_with("data: ") { - return None; - } - - let data = line["data: ".len()..].trim(); - if data == "[DONE]" { - return None; - } - - #[derive(Deserialize)] - struct ToolCallDelta { - #[serde(default)] - index: Option, - #[serde(default)] - id: Option, - #[serde(rename = "type", default)] - #[allow(dead_code)] - call_type: Option, - #[serde(default)] - function: Option, - } - - #[derive(Deserialize)] - struct ToolFunctionDelta { - #[serde(default)] - name: Option, - #[serde(default)] - arguments: Option, - } - - #[derive(Deserialize)] - struct Delta { - #[serde(default)] - content: Option, - #[serde(default)] - reasoning_content: Option, - #[serde(default)] - tool_calls: Option>, - } - - #[derive(Deserialize)] - struct ChunkChoice { - delta: Delta, - #[serde(default)] - finish_reason: Option, - } - - #[derive(Deserialize)] - struct ChunkResponse { - choices: Vec, - #[serde(default)] - usage: Option, - } - - let parsed: ChunkResponse = match serde_json::from_str(data) { - Ok(p) => p, - Err(_) => return None, - }; - - // 提取 usage(可能在最后一个 chunk) - if let Some(ref usage) = parsed.usage - && usage.total_tokens > 0 - { - return Some(ParsedChunk::Usage(usage.clone())); - } - - // SSE 流式响应中每个 chunk 只包含一个 choice 的 delta - if let Some(choice) = parsed.choices.into_iter().next() { - // 工具调用 delta - 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 - .as_ref() - .and_then(|f| f.arguments.clone()) - .unwrap_or_default(); - let name = tc.function.as_ref().and_then(|f| f.name.clone()); - return Some(ParsedChunk::ToolCallDelta { - index: idx, - id: tc.id.clone(), - name, - arguments: args, - }); - } - - if let Some(reasoning) = &choice.delta.reasoning_content - && !reasoning.is_empty() - { - return Some(ParsedChunk::Reasoning(reasoning.clone())); - } - if let Some(content) = &choice.delta.content - && !content.is_empty() - { - return Some(ParsedChunk::Text(content.clone())); - } - if let Some(reason) = &choice.finish_reason - && !reason.is_empty() - { - return Some(ParsedChunk::FinishReason(reason.clone())); - } - } - - None -} diff --git a/src/llm/types.rs b/src/llm/types.rs deleted file mode 100644 index ba65e18..0000000 --- a/src/llm/types.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! LLM 类型定义(OpenAI 兼容) - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum Role { - System, - User, - Assistant, - Tool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Message { - pub role: Role, - #[serde(default)] - pub content: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_call_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_calls: Option>, -} - -impl Message { - pub fn system(c: impl Into) -> Self { - Self { - role: Role::System, - content: c.into(), - tool_call_id: None, - name: None, - tool_calls: None, - } - } - pub fn user(c: impl Into) -> Self { - Self { - role: Role::User, - content: c.into(), - tool_call_id: None, - name: None, - tool_calls: None, - } - } - pub fn assistant(c: impl Into) -> Self { - Self { - role: Role::Assistant, - content: c.into(), - tool_call_id: None, - name: None, - tool_calls: None, - } - } - pub fn assistant_with_tool_calls(content: impl Into, tc: Vec) -> Self { - Self { - role: Role::Assistant, - content: content.into(), - tool_call_id: None, - name: None, - tool_calls: Some(tc), - } - } - pub fn tool_result(tid: &str, n: &str, r: &str) -> Self { - Self { - role: Role::Tool, - content: r.into(), - tool_call_id: Some(tid.into()), - name: Some(n.into()), - tool_calls: None, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCall { - pub id: String, - #[serde(rename = "type")] - pub call_type: String, - pub function: ToolFunctionCall, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolFunctionCall { - pub name: String, - pub arguments: String, -} - -#[derive(Debug, Clone)] -pub enum StreamChunk { - Text(String), - Reasoning(String), - Done { - text: String, - reasoning: Option, - tool_calls: Option>, - usage: Option, - }, - Error(String), -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct Usage { - pub prompt_tokens: u32, - pub completion_tokens: u32, - pub total_tokens: u32, - #[serde(default)] - pub prompt_cache_hit_tokens: u32, - #[serde(default)] - pub prompt_cache_miss_tokens: u32, -} - -#[derive(Debug, Clone)] -pub struct ConversationConfig { - pub system_prompt: String, - pub model: String, - pub temperature: f32, - pub max_tokens: u32, - pub thinking: bool, - pub tools: Option>, -} - -impl Default for ConversationConfig { - fn default() -> Self { - Self { - system_prompt: "You are a helpful assistant.".into(), - model: "deepseek-v4-flash".into(), - temperature: 0.7, - max_tokens: 4096, - thinking: true, - tools: None, - } - } -} diff --git a/src/logger.rs b/src/logger.rs deleted file mode 100644 index ab4fd97..0000000 --- a/src/logger.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! 日志初始化 —— 三路日志 -//! -//! | target | 文件 | 内容 | -//! |--------|------|------| -//! | `ias::msg` | `msg.log` | 用户说了什么、LLM 回复了什么 | -//! | `ias::raw` | `raw.log` | LLM API 完整返回值 | -//! | `ias::err` | `error.log` | 系统报错 + 工具报错(含类型标记)| - -use std::path::PathBuf; -use tracing::Level; -use tracing_subscriber::filter::Targets; -use tracing_subscriber::layer::SubscriberExt; -use tracing_subscriber::util::SubscriberInitExt; -use tracing_subscriber::{EnvFilter, Layer}; - -pub fn init_logger(log_dir: Option<&str>, with_file: bool) { - let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); - - let terminal = tracing_subscriber::fmt::layer() - .with_target(false) - .with_ansi(true) - .with_writer(std::io::stderr); - - if with_file && let Some(dir) = log_dir { - let dir_path = PathBuf::from(dir); - std::fs::create_dir_all(&dir_path).ok(); - - let msg_appender = tracing_appender::rolling::daily(&dir_path, "msg.log"); - let msg_layer = tracing_subscriber::fmt::layer() - .with_target(false) - .with_ansi(false) - .with_writer(msg_appender) - .with_filter(Targets::new().with_target("ias::msg", Level::INFO)); - - let raw_appender = tracing_appender::rolling::daily(&dir_path, "raw.log"); - let raw_layer = tracing_subscriber::fmt::layer() - .with_target(false) - .with_ansi(false) - .with_writer(raw_appender) - .with_filter(Targets::new().with_target("ias::raw", Level::INFO)); - - let err_appender = tracing_appender::rolling::daily(&dir_path, "error.log"); - let err_layer = tracing_subscriber::fmt::layer() - .with_target(false) - .with_ansi(false) - .with_writer(err_appender) - .with_filter(Targets::new().with_target("ias::err", Level::INFO)); - - tracing_subscriber::registry() - .with(env_filter) - .with(terminal) - .with(msg_layer) - .with(raw_layer) - .with(err_layer) - .init(); - return; - } - - tracing_subscriber::registry() - .with(env_filter) - .with(terminal) - .init(); -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index d1bd959..0000000 --- a/src/main.rs +++ /dev/null @@ -1,551 +0,0 @@ -//! ## iAs —— 微信 AI 智能助手(入口) -//! -//! ### 系统架构概览 -//! -//! ```text -//! CLI (main.rs → clap) -//! ├── ias login → 扫码登录,保存 token 到 DB 或文件 -//! ├── ias daemon → 守护进程模式(主运行模式) -//! │ └── daemon.rs: 长轮询 → MessageQueue → 3 个 tokio 消费者 -//! │ ├── LLM Consumer → Conversation.chat_with_tools() → DeepSeek API -//! │ ├── Tool Consumer → tools::execute() / ApprovalManager -//! │ └── Send Consumer → WeChatClient.send_message() -//! ├── ias daemon --log-file → 等同 daemon 但启用文件日志 -//! ├── ias tool → 直接调用内置工具(无需登录) -//! └── ias usage → Token 消耗统计查询 -//! ``` -//! -//! ### 数据流 -//! ```text -//! 微信消息 → 存 chat_records → 加载历史 + 记忆 → 构建 LLM 上下文 -//! → Conversation.chat_with_tools() → LLM 回复或调工具 -//! → 高风险工具走审批 → 工具结果回喂 LLM → 最终回复发送 -//! ``` -//! -//! ### 关键设计决策 -//! 1. **单进程架构** — 统一用 tokio task -//! 2. **公平队列** — MessageQueue 按用户轮转,防止一个用户刷屏饿死其他人 -//! 3. **可选数据库** — PostgreSQL 不可用时回退到文件存储 -//! 4. **两层元工具** — LLM 通过 query_capabilities / call_capability 间接调用工具 - -mod channel; -mod cli; -mod context; -mod daemon; -mod db; -mod llm; -mod logger; -mod queue; -mod scheduler; - -mod tools; -mod wechat; - -use clap::Parser; -use cli::{Cli, Commands, ScheduledTaskAction}; -use context::MemoryStore; -use db::Database; -use std::sync::Arc; -use tracing::{error, info}; -use wechat::client::WeChatClient; - -#[tokio::main] -async fn main() { - let cli = Cli::parse(); - // .env 加载:先当前目录,再 ~/.ias/.env - if std::path::Path::new(".env").exists() { - dotenvy::dotenv().ok(); - } else if let Ok(home) = std::env::var("HOME") { - let fallback = std::path::PathBuf::from(&home).join(".ias").join(".env"); - if fallback.exists() { - dotenvy::from_path(&fallback).ok(); - tracing::info!(target: "ias::auth", "使用全局环境配置: {}", fallback.display()); - } - } - - let log_file = matches!(&cli.command, Commands::Daemon { log_file: true, .. }); - let log_dir = std::env::var_os("LOG_FILE_PATH") - .map(|os_str| os_str.to_string_lossy().into_owned()) - .unwrap_or_else(|| { - dirs::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(".ias") - .join("logs") - .to_string_lossy() - .into_owned() - }); - - logger::init_logger(Some(&log_dir), log_file); - - // 工具命令无需登录/数据库,提前处理 - let tool_cmd = match &cli.command { - Commands::Tool { name, args } => Some((name.clone(), args.clone())), - _ => None, - }; - if let Some((name, args)) = tool_cmd { - cmd_tool_dynamic(&name, &args).await; - return; - } - - let database = match Database::connect().await { - Ok(db) => { - info!(target: "ias::auth", "✅ 数据库连接成功"); - Arc::new(db) - } - Err(e) => { - error!(target: "ias::err","[db-connect] {}", e); - eprintln!("错误: 数据库连接失败 - {}", e); - std::process::exit(1); - } - }; - - // 长期记忆存储 - let memory_store = Arc::new(MemoryStore::new(Some(Arc::new(database.pool().clone())))); - memory_store.load("").await; - - match cli.command { - Commands::Login { timeout } => { - cmd_login(timeout, &database).await; - } - Commands::Send { - to, - text, - context_token, - } => { - cmd_send(&to, &text, context_token.as_deref(), &database).await; - } - Commands::Whoami => { - cmd_whoami(&database).await; - } - Commands::Usage { - since, - until, - model, - } => { - cmd_usage(&database, since, until, model).await; - } - Commands::Daemon { .. } => { - cmd_daemon(&database, &memory_store).await; - } - Commands::ScheduledTask(action) => { - cmd_scheduled_task(action, &database).await; - } - Commands::Tools => { - cmd_tools().await; - } - Commands::Tool { .. } => unreachable!("Tool 命令已提前处理"), - } -} - -// ─── 命令实现 ─── - -async fn cmd_daemon(database: &Arc, memory_store: &Arc) { - daemon::run(database, memory_store).await; -} - -async fn cmd_login(timeout_secs: u64, database: &Arc) { - let client = WeChatClient::new(None); - - match client - .login(|url| println!("二维码链接: {}", url), timeout_secs) - .await - { - Ok(result) => { - let auth = db::models::AuthState { - token: result.token.clone(), - account_id: result.account_id.clone(), - base_url: result.base_url.clone(), - }; - - // 存数据库 - if let Err(e) = db::models::save_auth(database.pool(), &auth).await { - error!(target: "ias::err","[auth-save]到数据库失败: {}", e); - } else { - info!(target: "ias::auth", "认证信息已保存到数据库"); - } - - println!("\n✅ 登录成功!"); - println!(" 账号: {}", result.account_id); - println!( - " Token: {}...", - &result.token[..std::cmp::min(16, result.token.len())] - ); - println!(" API: {}", result.base_url); - } - Err(e) => error!(target: "ias::err","[login] {}", e), - } -} - -async fn cmd_whoami(database: &Arc) { - let auth: Option = db::models::load_auth(database.pool()).await; - - match auth { - Some(a) => { - println!("账号: {}", a.account_id); - println!("Token: {}...", &a.token[..std::cmp::min(16, a.token.len())]); - println!("API: {}", a.base_url); - println!("存储: PostgreSQL"); - } - None => println!("未登录,请先执行 login 命令"), - } -} - -async fn cmd_usage( - database: &Arc, - since: Option, - until: Option, - model: Option, -) { - let db = database; - - let since_dt = since.and_then(|s| { - chrono::DateTime::parse_from_rfc3339(&s) - .ok() - .map(|d| d.with_timezone(&chrono::Utc)) - }); - let until_dt = until.and_then(|s| { - chrono::DateTime::parse_from_rfc3339(&s) - .ok() - .map(|d| d.with_timezone(&chrono::Utc)) - }); - let model_ref = model.as_deref(); - - match db::models::query_llm_usage_stats(db.pool(), since_dt, until_dt, model_ref).await { - Ok(stats) => { - let hit_rate = if stats.total_cache_hit + stats.total_cache_miss > 0 { - stats.total_cache_hit as f64 - / (stats.total_cache_hit + stats.total_cache_miss) as f64 - * 100.0 - } else { - 0.0 - }; - - println!("📊 LLM Token 使用统计"); - println!("{:=<40}", ""); - println!("调用次数: {}", stats.total_calls); - println!( - "Prompt Tokens: {} ({:.1}K)", - stats.total_prompt_tokens, - stats.total_prompt_tokens as f64 / 1000.0 - ); - println!( - "生成 Tokens: {} ({:.1}K)", - stats.total_completion_tokens, - stats.total_completion_tokens as f64 / 1000.0 - ); - println!( - "总 Tokens: {} ({:.1}K)", - stats.total_tokens, - stats.total_tokens as f64 / 1000.0 - ); - println!( - "缓存命中: {} ({:.1}%)", - stats.total_cache_hit, hit_rate - ); - println!( - "缓存未命中: {} ({:.1}%)", - stats.total_cache_miss, - 100.0 - hit_rate - ); - } - Err(e) => println!("查询失败: {}", e), - } -} - -async fn cmd_send(to: &str, text: &str, context_token: Option<&str>, database: &Arc) { - let auth: Option = db::models::load_auth(database.pool()).await; - - let auth = match auth { - Some(a) => a, - None => { - error!(target: "ias::err","[not-logged-in],请先执行 login 命令"); - return; - } - }; - - let base_url = auth.base_url.clone(); - let account_id = auth.account_id.clone(); - let mut client = WeChatClient::new(Some(auth.base_url)); - client - .set_auth(&auth.token, &auth.account_id, &base_url) - .await; - - match client.send_text(to, text, context_token).await { - Ok(msg_id) => { - println!("✅ 消息已发送, msg_id={}", msg_id); - - // 入库:发送的消息 - if let Err(e) = db::models::insert_chat_record( - database.pool(), - "outbound", - to, - &account_id, - text, - "manual", - context_token, - &msg_id, - ) - .await - { - error!(target: "ias::err","[chat-record] {}", e); - } - } - Err(e) => error!(target: "ias::err","[send] {}", e), - } -} - -// ─── 定时任务 CLI 命令 ─── - -async fn cmd_scheduled_task(action: ScheduledTaskAction, database: &Arc) { - let db = database; - - match action { - ScheduledTaskAction::List => match db::models::list_scheduled_tasks(db.pool()).await { - Ok(tasks) => { - if tasks.is_empty() { - println!("📋 没有定时任务"); - return; - } - println!("📋 定时任务列表:"); - println!("{:=<80}", ""); - for task in &tasks { - let status = if task.enabled { "🟢" } else { "🔴" }; - let last = task - .last_run_at - .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()) - .unwrap_or_else(|| "-".to_string()); - let next = task.next_run_at.format("%Y-%m-%d %H:%M:%S"); - let interval = if task.interval_seconds >= 86400 { - format!("{}天", task.interval_seconds / 86400) - } else if task.interval_seconds >= 3600 { - format!("{}小时", task.interval_seconds / 3600) - } else { - format!("{}秒", task.interval_seconds) - }; - println!("{} {}", status, task.name); - println!(" ID: {}", task.id); - println!(" 用户: {}", task.user_id.as_deref().unwrap_or("-")); - println!(" 命令: {}", task.command); - println!(" 间隔: {}", interval); - println!(" 上次执行: {}", last); - println!(" 下次执行: {}", next); - if let Some(ref s) = task.last_status { - println!(" 上次状态: {:.100}", s); - } - println!("{:=<80}", ""); - } - } - Err(e) => println!("❌ 查询失败: {}", e), - }, - ScheduledTaskAction::Add { - name, - user, - command, - interval, - description, - shell, - cwd, - } => { - let desc = description.unwrap_or_default(); - let sh = shell.unwrap_or_else(|| "/bin/bash".to_string()); - let wd = cwd.unwrap_or_default(); - match db::models::add_scheduled_task( - db.pool(), - &name, - &user, - &command, - interval as i32, - &desc, - &sh, - &wd, - "model", - ) - .await - { - Ok(id) => { - println!("✅ 定时任务已添加"); - println!(" ID: {}", id); - println!(" 名称: {}", name); - println!(" 间隔: {}秒", interval); - } - Err(e) => println!("❌ 添加失败: {}", e), - } - } - ScheduledTaskAction::Update { - id, - name, - command, - interval, - description, - shell, - cwd, - } => { - let uuid = match uuid::Uuid::parse_str(&id) { - Ok(u) => u, - Err(e) => { - println!("❌ 无效的 UUID: {}", e); - return; - } - }; - match db::models::update_scheduled_task( - db.pool(), - &uuid, - name.as_deref(), - command.as_deref(), - interval.map(|v| v as i32), - description.as_deref(), - shell.as_deref(), - cwd.as_deref(), - ) - .await - { - Ok(true) => println!("✅ 定时任务已更新"), - Ok(false) => println!("⚠️ 未找到该任务或无字段更新"), - Err(e) => println!("❌ 更新失败: {}", e), - } - } - ScheduledTaskAction::Delete { id } => { - let uuid = match uuid::Uuid::parse_str(&id) { - Ok(u) => u, - Err(e) => { - println!("❌ 无效的 UUID: {}", e); - return; - } - }; - match db::models::delete_scheduled_task(db.pool(), &uuid).await { - Ok(true) => println!("✅ 定时任务已删除"), - Ok(false) => println!("⚠️ 未找到该任务"), - Err(e) => println!("❌ 删除失败: {}", e), - } - } - ScheduledTaskAction::Toggle { id } => { - let uuid = match uuid::Uuid::parse_str(&id) { - Ok(u) => u, - Err(e) => { - println!("❌ 无效的 UUID: {}", e); - return; - } - }; - match db::models::toggle_scheduled_task(db.pool(), &uuid).await { - Ok((true, enabled)) => { - let status = if enabled { - "🟢 已启用" - } else { - "🔴 已禁用" - }; - println!("✅ 定时任务状态已切换: {}", status); - } - Ok((false, _)) => println!("⚠️ 未找到该任务"), - Err(e) => println!("❌ 切换失败: {}", e), - } - } - } -} - -// ─── 工具列表 CLI 命令 ─── - -async fn cmd_tools() { - let registry = tools::registry::Registry::load("tools"); - let tools = registry.list_tools(); - if tools.is_empty() { - println!("没有注册的工具"); - return; - } - println!("注册的工具({} 个):\n", tools.len()); - for t in &tools { - let params: Vec = t - .params - .iter() - .map(|p| format!("{}{}", p.name, if p.required { "*" } else { "" })) - .collect(); - println!(" {} — {}", t.name, t.desc); - if !params.is_empty() { - println!(" 参数: {}", params.join(", ")); - } - } -} - -async fn cmd_tool_dynamic(name: &str, args: &[String]) { - use crate::tools::registry::Registry; - - let registry = Registry::load("tools"); - - if name == "list" { - let tools = registry.list_tools(); - if tools.is_empty() { - println!("没有注册的工具"); - return; - } - println!("可用工具 ({} 个):\n", tools.len()); - for t in &tools { - let params: Vec = t - .params - .iter() - .map(|p| format!("{}{}", p.name, if p.required { "*" } else { "" })) - .collect(); - println!(" {} — {}", t.name, t.desc); - if !params.is_empty() { - println!(" 参数: {}", params.join(", ")); - } - } - println!("\n用法: ias tool <工具名> --key value ..."); - return; - } - - // 提前校验工具名,给出更明确的错误提示 - let spec = match registry.get(name) { - Some(spec) => spec, - None => { - eprintln!("未知工具: {name}"); - eprintln!("运行 ias tool list 查看可用工具"); - std::process::exit(1); - } - }; - if spec.tool == "memories" { - eprintln!("❌ 记忆工具需要数据库上下文,请通过 daemon/微信对话使用"); - std::process::exit(1); - } - - let mut params = serde_json::Map::new(); - let mut i = 0; - while i < args.len() { - if args[i].starts_with("--") { - let key = args[i].trim_start_matches("--").to_string(); - let val = if i + 1 < args.len() && !args[i + 1].starts_with("--") { - i += 1; - args[i].clone() - } else { - "true".to_string() - }; - params.insert(key, serde_json::Value::String(val)); - } - i += 1; - } - - let approval = std::sync::Arc::new(crate::tools::approval::ApprovalManager::new(None)); - let memory = std::sync::Arc::new(crate::context::MemoryStore::new(None)); - let cb: &( - dyn Fn(&str, &str) -> std::pin::Pin + Send>> - + Send - + Sync - ) = &|_: &str, _: &str| Box::pin(async {}); - let outcome = registry - .dispatch( - name, - &serde_json::Value::Object(params), - "cli", - true, - &approval, - &memory, - cb, - ) - .await; - - match outcome { - crate::tools::executor::ToolRunOutcome::Ok(s) => println!("{s}"), - crate::tools::executor::ToolRunOutcome::Err { message, .. } => { - eprintln!("❌ {message}"); - std::process::exit(1); - } - } -} diff --git a/src/queue/message_queue.rs b/src/queue/message_queue.rs deleted file mode 100644 index cbec7ea..0000000 --- a/src/queue/message_queue.rs +++ /dev/null @@ -1,610 +0,0 @@ -//! ## 多渠道公平轮转消息队列 (MessageQueue) -//! -//! ### 设计目标 -//! -//! 多用户场景下保证**公平性**:如果一个用户连续发了很多消息,不会让其他用户饿死。 -//! 出队顺序是**轮转(round-robin)**的: -//! - A 发了 3 条、B 发了 1 条 → 处理顺序 A1, B1, A2, A3 -//! -//! ### 消息关联追踪 -//! -//! 每条消息有一个 `correlation_id`(Uuid),用来追踪一次用户请求的完整生命周期: -//! 用户消息 → LLM 处理 → 工具调用 → 工具结果回喂 → 最终回复发送。 -//! 这样即使多用户并发,回复也不会串。 -//! -//! ### 架构位置 -//! -//! ```text -//! receive_messages → enqueue PipelineMessage -//! ┌─────────────────────────────┐ -//! │ MessageQueue │ -//! │ [User A] [User B] │ ← 每用户独立 VecDeque -//! │ pending: [A, B, A] │ ← 公平轮转索引 -//! └─────────────────────────────┘ -//! QueueRunner::run() → dequeue → 按 kind 路由到 mpsc 通道 -//! ├─ llm_tx (UserMessage, ToolResult) -//! ├─ tool_tx (ToolCall) -//! └─ send_tx (LLMReply) -//! ``` - -use crate::channel::ChannelId; -use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, VecDeque}; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::Notify; -use uuid::Uuid; - -// ─── 管线消息类型 ─── - -/// 消息种类 —— 区分不同消费路由 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum MessageKind { - /// 从外部收到的用户消息 → 交给 LLM Consumer - UserMessage { - text: String, - message_id: String, - context_token: Option, - }, - /// LLM 生成的回复文本 → 发给 Send Consumer - LLMReply { - text: String, - source: String, - context_token: Option, - }, - /// LLM 请求的工具调用 → 发给 Tool Consumer - ToolCall { - session_id: Uuid, - tool_call_id: String, - tool_name: String, - arguments: String, - context_token: Option, - /// 该工具调用是否已被用户批准(跳过审批流程) - approved: bool, - }, - /// 工具执行结果 → 回喂 LLM Consumer - ToolResult { - session_id: Uuid, - tool_call_id: String, - tool_name: String, - result: String, - context_token: Option, - }, - /// 定时任务到期 → 交给 LLM Consumer - ScheduledTask { text: String, task_name: String }, -} - -/// 管线消息 —— 携带路由元数据的统一消息类型 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PipelineMessage { - /// 来源/目标渠道(平台 + 用户),全程不变 - pub channel: ChannelId, - /// 关联 ID:追踪一次用户请求的完整链路(用户消息→LLM→工具→回喂→发送) - pub correlation_id: Uuid, - /// 消息种类 - pub kind: MessageKind, -} - -impl PipelineMessage { - /// 便捷:渠道中的 user_id - pub fn user_id(&self) -> &str { - &self.channel.user_id - } - /// 便捷:渠道中的 platform - #[allow(dead_code)] - pub fn platform(&self) -> &str { - &self.channel.platform - } - /// 快速构造一条用户消息 - #[allow(dead_code)] - pub fn user_message( - user_id: impl Into, - text: impl Into, - message_id: impl Into, - context_token: Option, - ) -> Self { - Self { - channel: ChannelId::wechat(user_id), - correlation_id: Uuid::new_v4(), - kind: MessageKind::UserMessage { - text: text.into(), - message_id: message_id.into(), - context_token, - }, - } - } - - /// 快速构造一条 LLM 回复 - pub fn llm_reply( - channel: ChannelId, - correlation_id: Uuid, - text: impl Into, - context_token: Option, - ) -> Self { - Self::llm_reply_with_source(channel, correlation_id, text, "llm", context_token) - } - - /// 快速构造一条带来源标记的 LLM 回复 - pub fn llm_reply_with_source( - channel: ChannelId, - correlation_id: Uuid, - text: impl Into, - source: impl Into, - context_token: Option, - ) -> Self { - Self { - channel, - correlation_id, - kind: MessageKind::LLMReply { - text: text.into(), - source: source.into(), - context_token, - }, - } - } - - /// 快速构造一条工具调用 - pub fn tool_call( - channel: ChannelId, - correlation_id: Uuid, - session_id: Uuid, - tool_call_id: impl Into, - tool_name: impl Into, - arguments: impl Into, - context_token: Option, - ) -> Self { - Self { - channel, - correlation_id, - kind: MessageKind::ToolCall { - session_id, - tool_call_id: tool_call_id.into(), - tool_name: tool_name.into(), - arguments: arguments.into(), - context_token, - approved: false, - }, - } - } - - /// 快速构造一条工具结果 - pub fn tool_result( - channel: ChannelId, - correlation_id: Uuid, - session_id: Uuid, - tool_call_id: impl Into, - tool_name: impl Into, - result: impl Into, - context_token: Option, - ) -> Self { - Self { - channel, - correlation_id, - kind: MessageKind::ToolResult { - session_id, - tool_call_id: tool_call_id.into(), - tool_name: tool_name.into(), - result: result.into(), - context_token, - }, - } - } -} - -// ─── 公平轮转队列 ─── - -/// 多渠道公平轮转消息队列 -/// -/// 按 ChannelId(platform + user_id)分队列,轮转出队保证公平。 -/// 出队后由 QueueRunner 按 MessageKind 路由到对应 mpsc 通道。 -pub struct MessageQueue { - /// 每渠道独立队列 - queues: HashMap>, - /// 有待处理消息的渠道(轮转顺序) - pending: VecDeque, - /// 空→非空时触发 Notify 唤醒消费者 - signal: Arc, - /// 定时唤醒间隔(兜底用) - poll_interval: Duration, -} - -impl MessageQueue { - pub fn new() -> Self { - Self { - queues: HashMap::new(), - pending: VecDeque::new(), - signal: Arc::new(Notify::new()), - poll_interval: Duration::from_millis(200), - } - } - - #[allow(dead_code)] - pub fn with_poll_interval(mut self, d: Duration) -> Self { - self.poll_interval = d; - self - } - - /// 入队。如果该渠道之前为空 → 加入 pending 尾部 + 通知 - pub fn enqueue(&mut self, item: PipelineMessage) { - let ch = item.channel.clone(); - let q = self.queues.entry(ch.clone()).or_default(); - let was_empty = q.is_empty(); - q.push_back(item); - if was_empty { - self.pending.push_back(ch); - self.signal.notify_one(); - } - } - - /// 公平轮转出队 - /// - /// 每次调用切换到下一个有消息的渠道: - /// - 从 pending 头部取出一个渠道 - /// - pop 其一条消息 - /// - 该渠道还有消息 → 放回 pending 尾部 - pub fn dequeue(&mut self) -> Option { - while let Some(ch) = self.pending.pop_front() { - if let Some(q) = self.queues.get_mut(&ch) - && let Some(item) = q.pop_front() - { - if !q.is_empty() { - self.pending.push_back(ch); - } else { - self.queues.remove(&ch); - } - return Some(item); - } - self.queues.remove(&ch); - } - None - } - - pub fn signal(&self) -> Arc { - Arc::clone(&self.signal) - } - #[allow(dead_code)] - pub fn poll_interval(&self) -> Duration { - self.poll_interval - } - - #[allow(dead_code)] - pub fn has_pending(&self, ch: &ChannelId) -> bool { - self.queues.get(ch).is_some_and(|q| !q.is_empty()) - } - - #[allow(dead_code)] - pub fn pending_count(&self) -> usize { - self.pending.len() - } - - /// 清空指定渠道的全部待处理消息 - #[allow(dead_code)] - pub fn drain_channel(&mut self, ch: &ChannelId) -> Vec { - let drained = self - .queues - .remove(ch) - .map(|mut q| q.drain(..).collect()) - .unwrap_or_default(); - self.pending.retain(|c| c != ch); - drained - } -} - -// ─── 消费者通道标识 ─── - -/// 消息路由目标 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConsumerTarget { - /// LLM 消费者(处理 UserMessage, ToolResult) - LLM, - /// 工具消费者(处理 ToolCall) - Tool, - /// 发送消费者(处理 LLMReply) - Send, -} - -impl PipelineMessage { - /// 判断这条消息应该路由到哪个消费者 - pub fn target(&self) -> ConsumerTarget { - match self.kind { - MessageKind::UserMessage { .. } - | MessageKind::ToolResult { .. } - | MessageKind::ScheduledTask { .. } => ConsumerTarget::LLM, - MessageKind::ToolCall { .. } => ConsumerTarget::Tool, - MessageKind::LLMReply { .. } => ConsumerTarget::Send, - } - } -} - -// ─── 测试 ─── - -#[cfg(test)] -mod tests { - use super::*; - use crate::queue::QueueRunner; - - fn make_user_msg(user: &str, text: &str) -> PipelineMessage { - PipelineMessage::user_message(user, text, "", None) - } - - #[test] - fn test_target_routing() { - let msg = PipelineMessage::user_message("u1", "hello", "m1", None); - assert_eq!(msg.target(), ConsumerTarget::LLM); - - let msg = PipelineMessage::llm_reply(ChannelId::wechat("u1"), Uuid::new_v4(), "hi", None); - assert_eq!(msg.target(), ConsumerTarget::Send); - - let msg = PipelineMessage::tool_call( - ChannelId::wechat("u1"), - Uuid::new_v4(), - Uuid::new_v4(), - "tc1", - "query_weather", - r#"{"location":"北京"}"#, - None, - ); - assert_eq!(msg.target(), ConsumerTarget::Tool); - } - - #[test] - fn test_fair_round_robin() { - let mut q = MessageQueue::new(); - q.enqueue(make_user_msg("A", "a1")); - q.enqueue(make_user_msg("A", "a2")); - q.enqueue(make_user_msg("A", "a3")); - q.enqueue(make_user_msg("B", "b1")); - assert_eq!(q.dequeue().unwrap().channel.user_id, "A"); - assert_eq!(q.dequeue().unwrap().channel.user_id, "B"); - assert_eq!(q.dequeue().unwrap().channel.user_id, "A"); - assert_eq!(q.dequeue().unwrap().channel.user_id, "A"); - assert!(q.dequeue().is_none()); - } - - #[test] - fn test_single_user() { - let mut q = MessageQueue::new(); - q.enqueue(make_user_msg("X", "hi")); - q.enqueue(make_user_msg("X", "hello")); - assert_eq!(q.dequeue().unwrap().channel.user_id, "X"); - assert_eq!(q.dequeue().unwrap().channel.user_id, "X"); - assert!(q.dequeue().is_none()); - } - - #[test] - fn test_empty_queue() { - let mut q = MessageQueue::new(); - assert!(q.dequeue().is_none()); - assert_eq!(q.pending_count(), 0); - } - - #[test] - fn test_user_id_preserved() { - let msg = PipelineMessage::user_message("wx_user_1", "天气?", "m1", None); - assert_eq!(msg.channel.user_id, "wx_user_1"); - - let reply = PipelineMessage::llm_reply( - ChannelId::wechat("wx_user_1"), - msg.correlation_id, - "晴天", - None, - ); - assert_eq!(reply.channel.user_id, "wx_user_1"); - assert_eq!(reply.correlation_id, msg.correlation_id); - } - - #[test] - fn test_mixed_item_types() { - let mut q = MessageQueue::new(); - q.enqueue(PipelineMessage::user_message("U", "user msg", "1", None)); - q.enqueue(PipelineMessage { - channel: ChannelId::wechat("U"), - correlation_id: Uuid::new_v4(), - kind: MessageKind::ScheduledTask { - text: "task result".into(), - task_name: "reminder".into(), - }, - }); - assert!(matches!( - q.dequeue().unwrap().kind, - MessageKind::UserMessage { .. } - )); - assert!(matches!( - q.dequeue().unwrap().kind, - MessageKind::ScheduledTask { .. } - )); - } - - // ─── 端到端关联测试 ─── - - /// 验证 user_id 从用户消息到 LLM 回复全程不变 - #[test] - fn test_user_id_pipeline_preserved() { - let user_id = "wx_user_1"; - - // 用户消息入队 - let user_msg = PipelineMessage::user_message(user_id, "天气怎么样?", "msg1", None); - assert_eq!(user_msg.channel.user_id, user_id); - let correlation_id = user_msg.correlation_id; - - // LLM 回复(模拟 LLM Consumer 生成) - let reply = PipelineMessage::llm_reply( - ChannelId::wechat(user_id), - correlation_id, - "今天晴天", - None, - ); - assert_eq!(reply.channel.user_id, user_id); - assert_eq!(reply.correlation_id, correlation_id); - - // 工具调用(模拟 LLM Consumer 生成) - let tool_call = PipelineMessage::tool_call( - ChannelId::wechat(user_id), - correlation_id, - Uuid::new_v4(), - "tc1", - "query_weather", - r#"{"location":"北京"}"#, - None, - ); - assert_eq!(tool_call.channel.user_id, user_id); - assert_eq!(tool_call.correlation_id, correlation_id); - - // 工具结果回喂(模拟 Tool Consumer 生成) - let tool_result = PipelineMessage::tool_result( - ChannelId::wechat(user_id), - correlation_id, - Uuid::new_v4(), - "tc1", - "query_weather", - "25°C", - None, - ); - assert_eq!(tool_result.channel.user_id, user_id); - assert_eq!(tool_result.correlation_id, correlation_id); - } - - /// 验证多个用户的消息不会串 - #[test] - fn test_multi_user_isolation() { - let mut q = MessageQueue::new(); - - q.enqueue(PipelineMessage::user_message("u1", "你好", "1", None)); - q.enqueue(PipelineMessage::user_message("u2", "hello", "2", None)); - q.enqueue(PipelineMessage::user_message("u1", "天气?", "3", None)); - - // 按公平轮转出队,验证 user_id 正确 - let msg1 = q.dequeue().unwrap(); - assert_eq!(msg1.channel.user_id, "u1"); - let msg2 = q.dequeue().unwrap(); - assert_eq!(msg2.channel.user_id, "u2"); - let msg3 = q.dequeue().unwrap(); - assert_eq!(msg3.channel.user_id, "u1"); - } - - /// 验证路由目标正确 - #[test] - fn test_consumer_target_routing() { - // UserMessage → LLM - let m = PipelineMessage::user_message("u1", "hi", "1", None); - assert_eq!(m.target(), ConsumerTarget::LLM); - - // ToolResult → LLM - let m = PipelineMessage::tool_result( - ChannelId::wechat("u1"), - Uuid::new_v4(), - Uuid::new_v4(), - "tc1", - "test", - "ok", - None, - ); - assert_eq!(m.target(), ConsumerTarget::LLM); - - // ScheduledTask → LLM - let m = PipelineMessage { - channel: ChannelId::wechat("u1"), - correlation_id: Uuid::new_v4(), - kind: MessageKind::ScheduledTask { - text: "task".into(), - task_name: "t1".into(), - }, - }; - assert_eq!(m.target(), ConsumerTarget::LLM); - - // ToolCall → Tool - let m = PipelineMessage::tool_call( - ChannelId::wechat("u1"), - Uuid::new_v4(), - Uuid::new_v4(), - "tc1", - "weather", - "{}", - None, - ); - assert_eq!(m.target(), ConsumerTarget::Tool); - - // LLMReply → Send - let m = PipelineMessage::llm_reply(ChannelId::wechat("u1"), Uuid::new_v4(), "reply", None); - assert_eq!(m.target(), ConsumerTarget::Send); - } - - /// 验证 QueueRunner 的路由逻辑:入队 → 路由到正确通道 - #[tokio::test] - async fn test_queue_runner_routing() { - let (runner, mut channels) = QueueRunner::new(16); - let enqueue = runner.enqueue_handle(); - - // 启动 runner(在后台运行) - let handle = tokio::spawn(async move { - runner.run().await; - }); - - // 入队一个 UserMessage → 应到 llm_rx - let user_msg = PipelineMessage::user_message("u1", "hello", "m1", None); - enqueue.enqueue(user_msg).await; - - // 入队一个 LLMReply → 应到 send_rx - let reply_msg = - PipelineMessage::llm_reply(ChannelId::wechat("u1"), Uuid::new_v4(), "reply", None); - enqueue.enqueue(reply_msg).await; - - // 验证 llm_rx 收到 UserMessage - let llm_msg = - tokio::time::timeout(std::time::Duration::from_secs(2), channels.llm_rx.recv()) - .await - .expect("llm_rx 超时") - .expect("llm_rx 通道关闭"); - assert_eq!(llm_msg.channel.user_id, "u1"); - assert!(matches!(llm_msg.kind, MessageKind::UserMessage { .. })); - - // 验证 send_rx 收到 LLMReply - let send_msg = - tokio::time::timeout(std::time::Duration::from_secs(2), channels.send_rx.recv()) - .await - .expect("send_rx 超时") - .expect("send_rx 通道关闭"); - assert_eq!(send_msg.channel.user_id, "u1"); - assert!(matches!(send_msg.kind, MessageKind::LLMReply { .. })); - - // 关闭 runner - handle.abort(); - } - - #[test] - fn test_llm_reply_source_defaults_and_overrides() { - let correlation_id = Uuid::new_v4(); - - let normal = PipelineMessage::llm_reply( - ChannelId::wechat("u1"), - correlation_id, - "reply", - Some("ctx".into()), - ); - match normal.kind { - MessageKind::LLMReply { - source, - context_token, - .. - } => { - assert_eq!(source, "llm"); - assert_eq!(context_token.as_deref(), Some("ctx")); - } - _ => panic!("expected LLMReply"), - } - - let pending = PipelineMessage::llm_reply_with_source( - ChannelId::wechat("u1"), - correlation_id, - "pending", - "llm_pending_tool", - None, - ); - match pending.kind { - MessageKind::LLMReply { source, .. } => { - assert_eq!(source, "llm_pending_tool"); - } - _ => panic!("expected LLMReply"), - } - } -} diff --git a/src/queue/mod.rs b/src/queue/mod.rs deleted file mode 100644 index 2f7b4aa..0000000 --- a/src/queue/mod.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! ## 消息队列 —— 多渠道公平轮转 + 三消费者路由 -//! -//! 这是 daemon 架构的消息管线核心: -//! -//! - `message_queue` — 公平轮转队列(按用户轮转出队) -//! - `runner` — 队列运行时(出队 → 按 MessageKind 路由到对应 mpsc 通道) -//! -//! ### 消费者路由 -//! ```text -//! MessageKind::UserMessage / ToolResult / ScheduledTask → LLM Consumer -//! MessageKind::ToolCall → Tool Consumer -//! MessageKind::LLMReply → Send Consumer -//! ``` - -pub mod message_queue; -pub mod runner; - -pub use message_queue::{ConsumerTarget, MessageKind, MessageQueue, PipelineMessage}; -pub use runner::{ConsumerChannels, EnqueueHandle, QueueRunner}; diff --git a/src/queue/runner.rs b/src/queue/runner.rs deleted file mode 100644 index 91c0196..0000000 --- a/src/queue/runner.rs +++ /dev/null @@ -1,221 +0,0 @@ -//! ## QueueRunner —— 消息队列运行时(路由调度器) -//! -//! 从公平轮转 `MessageQueue` 中出队消息,然后按 `MessageKind` 路由到对应的 -//! tokio mpsc 通道,由注册的消费者 task 处理。 -//! -//! ### 角色定位 -//! -//! QueueRunner 是整个消息管线的**调度核心**: -//! 1. 持有 `MessageQueue`(公平轮转)+ 3 个 mpsc 通道 -//! 2. 在 `run()` 循环中不断出队 → 按类型路由 → 投递到正确的消费者 -//! 3. 支持优雅关闭(AtomicBool 信号) -//! -//! ### 架构 -//! -//! ```text -//! ┌──────────────┐ -//! enqueue ──────→ │ MessageQueue │ ← 公平轮转 -//! └──────┬───────┘ -//! │ dequeue -//! ▼ -//! ┌──────────────┐ -//! │ QueueRunner │ ← 路由循环 -//! └──┬───┬───┬──┘ -//! ┌─┘ │ └─┐ -//! ▼ ▼ ▼ -//! llm_tx tool_tx send_tx -//! │ │ │ -//! ┌────┘ ┌─┘ ┌──┘ -//! ▼ ▼ ▼ -//! LLMCons. ToolCons. SendCons. -//! ``` - -use crate::queue::{ConsumerTarget, MessageQueue, PipelineMessage}; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use tokio::sync::{Mutex, Notify, mpsc}; -use tracing::{error, info, warn}; - -/// 消费者通道集 —— 创建 QueueRunner 时获得接收端 -pub struct ConsumerChannels { - pub llm_rx: mpsc::Receiver, - pub tool_rx: mpsc::Receiver, - pub send_rx: mpsc::Receiver, -} - -/// 队列运行时 —— 负责从公平轮转队列出队并按类型路由 -pub struct QueueRunner { - /// 公平轮转队列 - queue: Arc>, - /// LLM 消费者通道 - llm_tx: mpsc::Sender, - /// 工具消费者通道 - tool_tx: mpsc::Sender, - /// 发送消费者通道 - send_tx: mpsc::Sender, - /// 关闭信号 - shutdown: Arc, - /// 队列信号(空→非空通知) - signal: Arc, - /// 轮询间隔 - poll_interval: std::time::Duration, -} - -impl QueueRunner { - /// 创建 QueueRunner 和对应的消费者通道 - /// - /// `channel_size`: 每个 mpsc 通道的缓冲区大小 - pub fn new(channel_size: usize) -> (Self, ConsumerChannels) { - let (llm_tx, llm_rx) = mpsc::channel(channel_size); - let (tool_tx, tool_rx) = mpsc::channel(channel_size); - let (send_tx, send_rx) = mpsc::channel(channel_size); - - let queue = MessageQueue::new(); - let signal = queue.signal(); - let queue = Arc::new(Mutex::new(queue)); - - let runner = Self { - queue, - llm_tx, - tool_tx, - send_tx, - shutdown: Arc::new(AtomicBool::new(false)), - signal, - poll_interval: std::time::Duration::from_millis(200), - }; - - let channels = ConsumerChannels { - llm_rx, - tool_rx, - send_rx, - }; - - (runner, channels) - } - - /// 获取队列的写句柄(用于外部入队) - pub fn enqueue_handle(&self) -> EnqueueHandle { - EnqueueHandle { - queue: self.queue.clone(), - shutdown: self.shutdown.clone(), - } - } - - /// 获取关闭信号句柄 - pub fn shutdown_handle(&self) -> ShutdownHandle { - ShutdownHandle { - shutdown: self.shutdown.clone(), - } - } - - /// 启动路由主循环(阻塞,应在 tokio::spawn 中运行) - pub async fn run(&self) { - info!(target: "ias::queue", "QueueRunner 已启动"); - loop { - // 检查关闭信号 - if self.shutdown.load(Ordering::Relaxed) { - info!(target: "ias::queue", "QueueRunner 收到关闭信号,停止路由"); - break; - } - - // 出队 - let item = { - let mut q = self.queue.lock().await; - q.dequeue() - }; - - if let Some(msg) = item { - // 预提取字段(避免 borrow + move 冲突) - let target = msg.target(); - let user_id = msg.channel.user_id.clone(); - let correlation_id = msg.correlation_id; - - let result = match target { - ConsumerTarget::LLM => self.llm_tx.send(msg).await, - ConsumerTarget::Tool => self.tool_tx.send(msg).await, - ConsumerTarget::Send => self.send_tx.send(msg).await, - }; - - if let Err(_e) = result { - warn!( - target: "ias::queue", - "路由消息失败 (target={:?}, user={}, corr={}): 通道已关闭,跳过", - target, user_id, correlation_id - ); - // 不中断整个路由循环,跳过该消息继续处理 - } - } else { - // 队列为空,等待通知或超时 - tokio::select! { - _ = self.signal.notified() => { - // 有新的消息入队,继续循环 - } - _ = tokio::time::sleep(self.poll_interval) => { - // 超时兜底,继续检查 - } - } - } - } - info!(target: "ias::queue", "QueueRunner 已停止"); - } -} - -/// 入队句柄 —— 外部模块通过它向队列投放消息 -#[derive(Clone)] -pub struct EnqueueHandle { - queue: Arc>, - shutdown: Arc, -} - -impl EnqueueHandle { - /// 向队列投放一条消息 - pub async fn enqueue(&self, msg: PipelineMessage) { - if self.shutdown.load(Ordering::Relaxed) { - warn!( - target: "ias::queue", - "队列已关闭,丢弃消息: user={}", - msg.user_id() - ); - return; - } - self.queue.lock().await.enqueue(msg); - } -} - -/// 关闭句柄 -#[derive(Clone)] -pub struct ShutdownHandle { - shutdown: Arc, -} - -impl ShutdownHandle { - /// 发送关闭信号 - pub fn shutdown(&self) { - self.shutdown.store(true, Ordering::Relaxed); - } -} - -/// 等所有消费者处理完并关闭后,等待队列排空 -#[allow(dead_code)] -pub async fn drain_and_shutdown( - _enqueue: &EnqueueHandle, - runner_shutdown: &ShutdownHandle, - consumer_handles: Vec>, -) { - info!(target: "ias::queue", "开始关闭队列系统..."); - - // 1. 阻止新消息入队(设置关闭标志) - runner_shutdown.shutdown(); - - // 2. 等待排空(给一个合理的时间) - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - // 3. 等待消费者 task 完成 - for handle in consumer_handles { - if let Err(e) = handle.await { - error!(target: "ias::err","[consumer-panic] {:?}", e); - } - } - - info!(target: "ias::queue", "队列系统已完全关闭"); -} diff --git a/src/scheduler.rs b/src/scheduler.rs deleted file mode 100644 index 056184a..0000000 --- a/src/scheduler.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! ## 定时任务调度器 -//! -//! 轮询 PostgreSQL 中到期的 `scheduled_tasks` 表,执行任务后将结果通知用户。 -//! -//! ### 调度流程 -//! 1. 每 5 秒检查一次是否有到期任务(`next_run_at <= NOW()`) -//! 2. 使用 `FOR UPDATE SKIP LOCKED` 防止多个实例重复执行 -//! 3. 执行任务 shell 命令(120 秒超时) -//! 4. 更新 `next_run_at`(当前时间 + interval_seconds) -//! 5. 通过回调函数通知用户结果 -//! -//! ### 安全保障 -//! - `SKIP LOCKED` — 多实例部署时不会抢同一任务 -//! - `kill_on_drop` — 子进程在超时时会被杀掉 -//! - 120 秒超时 — 防止 shell 命令无限执行 - -use sqlx::PgPool; -use std::sync::Arc; -use std::time::Duration; -use tokio::time::interval; - -/// ## 定时任务调度器 -/// -/// 轮询 PostgreSQL 中到期的 `scheduled_tasks` 表,执行任务后将结果通知用户。 -/// -/// ### 调度流程 -/// 1. 每 5 秒检查一次是否有到期任务(`next_run_at <= NOW()`) -/// 2. 使用 `FOR UPDATE SKIP LOCKED` 防止多个实例重复执行 -/// 3. 执行任务 shell 命令(120 秒超时) -/// 4. 更新 `next_run_at`(当前时间 + interval_seconds) -/// 5. 通过回调函数通知用户结果 -/// -/// ### 安全保障 -/// * `SKIP LOCKED` — 多实例部署时不会抢同一任务 -/// * `kill_on_drop` — 子进程在超时时会被杀掉 -/// * 120 秒超时 — 防止 shell 命令无限执行 -pub struct Scheduler { - pool: Option>, -} - -impl Scheduler { - pub fn new(pool: Option>) -> Self { - Self { pool } - } - - /// 启动调度循环(在 listen_loop 中作为 tokio::spawn 调用) - /// 每 5 秒检查一次是否有到期任务,执行后通过 callback 发送结果 - pub async fn run(&self, mut on_task: impl FnMut(&str, &str, &str) -> Result<(), String>) { - let pool = match self.pool.as_ref() { - Some(p) => p.clone(), - None => { - tracing::info!(target: "ias::queue", "调度器:数据库未配置,跳过"); - return; - } - }; - - let mut tick = interval(Duration::from_secs(5)); - tracing::info!(target: "ias::queue", "调度器已启动(每 5s 检查)"); - - loop { - tick.tick().await; - - let tasks = match fetch_due_tasks(&pool).await { - Ok(t) => t, - Err(e) => { - tracing::warn!("调度器查询失败: {}", e); - continue; - } - }; - - for task in tasks { - tracing::info!(target: "ias::queue", "⏰ 执行定时任务: {}", task.name); - - // 执行任务 - let result = execute_task(&task).await; - - // 更新任务状态 - let _ = update_task_status(&pool, &task.id, &result).await; - - // 通知用户 - let notify_msg = format!("⏰ 定时任务: {}\n结果: {}", task.name, result); - if let Err(e) = on_task(&task.user_id, &task.name, ¬ify_msg) { - tracing::error!(target: "ias::err","[scheduler-send] {}", e); - } - - tracing::info!(target: "ias::queue", "✅ 定时任务完成: {} → {}", task.name, result); - } - } - } -} - -#[derive(Debug)] -struct DueTask { - id: uuid::Uuid, - name: String, - user_id: String, - command: String, - shell: String, - cwd: String, -} - -async fn fetch_due_tasks(pool: &PgPool) -> Result, String> { - let mut tx = pool - .begin() - .await - .map_err(|e| format!("开始事务失败: {}", e))?; - let rows = sqlx::query_as::<_, (uuid::Uuid, String, String, String, String, String)>( - r#" - SELECT id, name, user_id, command, shell, cwd - FROM scheduled_tasks - WHERE enabled = true AND next_run_at <= NOW() - ORDER BY next_run_at ASC - LIMIT 5 - FOR UPDATE SKIP LOCKED - "#, - ) - .fetch_all(&mut *tx) - .await - .map_err(|e| format!("查询到期任务失败: {}", e))?; - - // 立即标记为已调度(防止其他实例重复获取) - for (id, _, _, _, _, _) in &rows { - sqlx::query("UPDATE scheduled_tasks SET next_run_at = NOW() + (interval_seconds * INTERVAL '1 second') WHERE id = $1") - .bind(id) - .execute(&mut *tx) - .await - .ok(); - } - tx.commit() - .await - .map_err(|e| format!("提交事务失败: {}", e))?; - - Ok(rows - .into_iter() - .map(|(id, name, user_id, command, shell, cwd)| DueTask { - id, - name, - user_id, - command, - shell, - cwd, - }) - .collect()) -} - -async fn execute_task(task: &DueTask) -> String { - let shell = if task.shell.is_empty() { - "/bin/bash" - } else { - &task.shell - }; - let cwd = if task.cwd.is_empty() { - std::env::current_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default() - } else { - task.cwd.clone() - }; - - let result = tokio::time::timeout( - std::time::Duration::from_secs(120), - tokio::process::Command::new(shell) - .arg("-c") - .arg(&task.command) - .current_dir(&cwd) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - .output(), - ) - .await; - - match result { - Ok(Ok(output)) => { - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - - if output.status.success() { - if stdout.is_empty() { - "执行成功(无输出)".to_string() - } else { - stdout - } - } else { - format!( - "退出码 {}: {}", - output.status.code().unwrap_or(-1), - if stderr.is_empty() { &stdout } else { &stderr } - ) - } - } - Ok(Err(e)) => format!("进程错误: {}", e), - Err(_timeout) => "执行超时 (120s)".to_string(), - } -} - -async fn update_task_status( - pool: &PgPool, - task_id: &uuid::Uuid, - result: &str, -) -> Result<(), String> { - // 更新 last_run_at 和 next_run_at - sqlx::query( - r#" - UPDATE scheduled_tasks - SET last_run_at = NOW(), - next_run_at = NOW() + (interval_seconds * INTERVAL '1 second'), - last_status = $2 - WHERE id = $1 - "#, - ) - .bind(task_id) - .bind(result.chars().take(500).collect::()) - .execute(pool) - .await - .map_err(|e| format!("更新任务状态失败: {}", e))?; - - Ok(()) -} diff --git a/src/tools/approval.rs b/src/tools/approval.rs deleted file mode 100644 index fcab5af..0000000 --- a/src/tools/approval.rs +++ /dev/null @@ -1,333 +0,0 @@ -//! ## 审批管理器 —— 高风险工具的用户确认流程 -//! -//! 当 LLM 调用高风险工具时,需要用户通过微信输入确认码来批准。 -//! -//! ### 完整流程 -//! 1. `create(user_id, skill_name)` → 生成 6 位随机确认码,SHA-256 哈希后存储 -//! 2. 向用户发送 "⚠️ 操作确认\n\n技能:{name}\n确认码:{code}" -//! 3. 用户在微信聊天中输入确认码 -//! 4. `handle_reply(user_id, reply)` → 验证确认码哈希 -//! - 匹配 → `Approved` -//! - 不匹配 → 减少尝试次数(最多 3 次) -//! - 输入 0 或"取消" → `Rejected` -//! - 超时 → `Expired`(每 60 秒清理一次) -//! -//! ### 安全设计 -//! - 确认码用 SHA-256 哈希后存储,原始码不持久化 -//! - 每次生成随机 6 位数字(100000-999999) -//! - 最多 3 次尝试,超时 5 分钟 -//! - 同时支持内存和数据库双写 - -use rand::Rng; -use sha2::{Digest, Sha256}; -use sqlx::PgPool; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Instant; -use tokio::sync::{Mutex, oneshot}; - -/// ## 审批决策 -/// -/// * `Approved` — 用户输入了正确的确认码 -/// * `Rejected` — 用户拒绝了(输入 0 或"取消") -/// * `Expired` — 超时未确认(5 分钟有效) -#[derive(Debug, Clone, PartialEq)] -pub enum ApprovalDecision { - Approved, - Rejected, - Expired, -} - -/// 内存中待审批的条目 -struct PendingEntry { - code_hash: String, - skill_name: String, - attempts_left: i32, - expires_at: Instant, - /// 通知等待方 - sender: oneshot::Sender, -} - -/// ## 审批管理器 -/// -/// 管理高风险工具的用户确认流程。 -/// -/// ### 流程 -/// 1. `create(user_id, skill_name)` → 生成 6 位随机确认码,哈希后存储 -/// 2. 向用户发送"⚠️ 操作确认\n\n技能:{name}\n确认码:{code}" -/// 3. 用户在微信聊天中输入确认码 -/// 4. `handle_reply(user_id, reply)` → 验证确认码哈希 -/// - 匹配 → Approved -/// - 不匹配 → 减少尝试次数(最多 3 次) -/// - 输入 0 或"取消" → Rejected -/// - 超时 → Expired(每 60 秒清理一次) -/// -/// ### 存储 -/// * 内存 — `HashMap>`(快速访问) -/// * 数据库 — `pending_approvals` 表(持久化审计) -pub struct ApprovalManager { - pool: Option>, - /// user_id → 该用户的待审批队列 - pending: Arc>>>, -} - -impl ApprovalManager { - pub fn new(pool: Option>) -> Self { - Self { - pool, - pending: Arc::new(Mutex::new(HashMap::new())), - } - } - - /// 创建审批请求 - /// 返回 (确认码明文, 接收器) — 调用方 await 接收器等待用户确认 - pub async fn create( - &self, - user_id: &str, - skill_name: &str, - ) -> Result<(String, oneshot::Receiver), String> { - let code: u32 = rand::rng().random_range(100000..999999); - let code_str = code.to_string(); - let code_hash = format!("{:x}", Sha256::digest(code_str.as_bytes())); - - let (tx, rx) = oneshot::channel(); - - // 入库 - if let Some(ref pool) = self.pool { - let id = uuid::Uuid::new_v4(); - let expires_at = chrono::Utc::now() + chrono::Duration::minutes(5); - let params = serde_json::json!({"name": skill_name}); - - let _ = sqlx::query( - r#" - INSERT INTO pending_approvals (id, expires_at, user_id, skill_name, params, code_hash, status) - VALUES ($1, $2, $3, $4, $5, $6, 'pending') - "#, - ) - .bind(id) - .bind(expires_at) - .bind(user_id) - .bind(skill_name) - .bind(¶ms) - .bind(&code_hash) - .execute(pool.as_ref()) - .await; - } - - // 注册到内存 - let mut map = self.pending.lock().await; - map.entry(user_id.to_string()) - .or_default() - .push(PendingEntry { - code_hash, - skill_name: skill_name.to_string(), - attempts_left: 3, - expires_at: Instant::now() + std::time::Duration::from_secs(300), - sender: tx, - }); - - Ok((code_str, rx)) - } - - /// 测试专用:创建指定 TTL(秒)的审批请求 - #[cfg(test)] - async fn create_with_ttl( - &self, - user_id: &str, - skill_name: &str, - ttl_secs: u64, - ) -> Result<(String, oneshot::Receiver), String> { - let code: u32 = rand::rng().random_range(100000..999999); - let code_str = code.to_string(); - let code_hash = format!("{:x}", Sha256::digest(code_str.as_bytes())); - let (tx, rx) = oneshot::channel(); - - let mut map = self.pending.lock().await; - map.entry(user_id.to_string()) - .or_default() - .push(PendingEntry { - code_hash, - skill_name: skill_name.to_string(), - attempts_left: 3, - expires_at: Instant::now() + std::time::Duration::from_secs(ttl_secs), - sender: tx, - }); - - Ok((code_str, rx)) - } - - /// 处理用户回复(由消息循环调用) - /// 返回 (技能名, 决策);匹配失败返回 None - pub async fn handle_reply( - &self, - user_id: &str, - reply: &str, - ) -> Option<(String, ApprovalDecision)> { - let (result, name, code_hash) = { - let mut map = self.pending.lock().await; - let entries = map.get_mut(user_id)?; - if entries.is_empty() { - return None; - } - - let reply_hash = format!("{:x}", Sha256::digest(reply.as_bytes())); - let reply_trimmed = reply.trim(); - - if reply_trimmed == "0" || reply_trimmed == "取消" { - let entry = entries.remove(0); - let hash = entry.code_hash.clone(); - let _ = entry.sender.send(ApprovalDecision::Rejected); - (ApprovalDecision::Rejected, entry.skill_name.clone(), hash) - } else if let Some(idx) = entries.iter().position(|e| e.code_hash == reply_hash) { - let entry = entries.remove(idx); - let hash = entry.code_hash.clone(); - let _ = entry.sender.send(ApprovalDecision::Approved); - (ApprovalDecision::Approved, entry.skill_name.clone(), hash) - } else if let Some(entry) = entries.first_mut() { - entry.attempts_left -= 1; - if entry.attempts_left <= 0 { - let entry = entries.remove(0); - let n = entry.skill_name.clone(); - let hash = entry.code_hash.clone(); - let _ = entry.sender.send(ApprovalDecision::Rejected); - (ApprovalDecision::Rejected, n, hash) - } else { - return None; - } - } else { - return None; - } - }; - - // 更新数据库状态 - if let Some(ref pool) = self.pool { - let status = match result { - ApprovalDecision::Approved => "approved", - _ => "rejected", - }; - // 按 user_id + 最近 pending 记录更新 - let _ = sqlx::query( - "UPDATE pending_approvals SET status = $1, consumed_at = NOW() WHERE code_hash = $2 AND status = 'pending'", - ) - .bind(status) - .bind(&code_hash) - .execute(pool.as_ref()) - .await; - } - - Some((name, result)) - } - - /// 清理过期审批(按时间判定,通知等待方 + 更新 DB) - /// - /// 两阶段加锁:收集过期 code_hash → 更新 DB → 重新加锁按 code_hash 定位删除。 - /// 不依赖快照 index,避免并发回复导致 index 错位误删。 - pub async fn clean_expired(&self) { - let now = Instant::now(); - - // 第一阶段:持锁收集过期条目的 code_hash - let mut expired_hashes: Vec = Vec::new(); - { - let map = self.pending.lock().await; - for entries in map.values() { - for e in entries { - if now > e.expires_at { - expired_hashes.push(e.code_hash.clone()); - } - } - } - } - - if expired_hashes.is_empty() { - return; - } - - // DB 更新(按 code_hash 精确匹配,无错位问题) - if let Some(ref pool) = self.pool { - for code_hash in &expired_hashes { - let _ = sqlx::query( - "UPDATE pending_approvals SET status = 'expired' WHERE code_hash = $1 AND status = 'pending'", - ) - .bind(code_hash) - .execute(pool.as_ref()) - .await; - } - } - - // 第二阶段:重新持锁,按 code_hash 定位并移除 - // (即使两阶段间 handle_reply 增删了条目,按 hash 查找也能安全跳过) - let mut map = self.pending.lock().await; - for code_hash in &expired_hashes { - for entries in map.values_mut() { - if let Some(idx) = entries.iter().position(|e| &e.code_hash == code_hash) { - let entry = entries.remove(idx); - let _ = entry.sender.send(ApprovalDecision::Expired); - } - } - } - map.retain(|_, v| !v.is_empty()); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn clean_expired_only_removes_expired_entries() { - // 无 DB,纯内存 - let mgr = ApprovalManager::new(None); - - // 两条审批:一条立即过期(ttl=0),一条 300s 不过期 - let (_expired_code, expired_rx) = mgr.create_with_ttl("u1", "tool_a", 0).await.unwrap(); - let (live_code, live_rx) = mgr.create_with_ttl("u1", "tool_b", 300).await.unwrap(); - - // 立即过期的条目 expires_at 已成过去,sleep 确保时间推进 - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - - mgr.clean_expired().await; - - // 过期条目应收到 Expired - assert_eq!(expired_rx.await.unwrap(), ApprovalDecision::Expired); - // 未过期条目不应被误删:handle_reply 用正确确认码应能匹配 - let (name, decision) = mgr.handle_reply("u1", &live_code).await.unwrap(); - assert_eq!(name, "tool_b"); - assert_eq!(decision, ApprovalDecision::Approved); - // live_rx 应收到 Approved(未被 clean_expired 误标记) - assert_eq!(live_rx.await.unwrap(), ApprovalDecision::Approved); - } - - #[tokio::test] - async fn clean_expired_safe_when_no_pending() { - // 空 pending 不应 panic - let mgr = ApprovalManager::new(None); - mgr.clean_expired().await; - // 重复调用也安全 - mgr.clean_expired().await; - } - - #[tokio::test] - async fn clean_expired_skips_entries_already_removed_by_reply() { - // 回归保护:clean_expired 第一阶段收集过期 hash 后、第二阶段删除前, - // 若该条目已被 handle_reply 提前处理,clean_expired 应安全跳过,不误删其他条目。 - // (原 bug:按快照 index 删除会导致误删;现按 code_hash 删除应安全跳过) - let mgr = ApprovalManager::new(None); - - // 两条同用户审批,都设为立即过期 - let (_code_a, rx_a) = mgr.create_with_ttl("u1", "tool_a", 0).await.unwrap(); - let (_code_b, rx_b) = mgr.create_with_ttl("u1", "tool_b", 0).await.unwrap(); - - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - - // 先用 handle_reply “0” 取消最早的条目(tool_a),模拟并发回复 - let (name, decision) = mgr.handle_reply("u1", "0").await.unwrap(); - assert_eq!(name, "tool_a"); - assert_eq!(decision, ApprovalDecision::Rejected); - assert_eq!(rx_a.await.unwrap(), ApprovalDecision::Rejected); - - // 再 clean_expired:tool_a 已被 remove,按 hash 查找应跳过; - // tool_b 仍在且过期,应被正确标记 Expired(不会被误当成 tool_a 跳过) - mgr.clean_expired().await; - assert_eq!(rx_b.await.unwrap(), ApprovalDecision::Expired); - } -} diff --git a/src/tools/executor.rs b/src/tools/executor.rs deleted file mode 100644 index 6bee010..0000000 --- a/src/tools/executor.rs +++ /dev/null @@ -1,412 +0,0 @@ -//! # 工具执行器 -//! -//! `execute_loop()` spawn 工具进程,解析 stdout JSON 消息: -//! - `{"type":"http",...}` — 请求 HTTP -//! - `{"type":"db",...}` — 请求数据库操作 -//! - `{"type":"result","content":"..."}` — 最终结果 -//! -//! 所有消息必须是合法 JSON。 -//! -//! ## 安全边界(授权层) -//! **工具级审批在入口统一执行**:高风险工具在 spawn 之前必须获得用户确认, -//! 与工具后续输出的消息类型(result/http/db)无关 —— 杜绝 stdio→result 绕过。 - -use std::sync::Arc; -use std::time::Duration; - -use serde_json::{Value, json}; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt}; -use tokio::process::Command; - -use crate::context::MemoryStore; -use crate::tools::approval::{ApprovalDecision, ApprovalManager}; -use crate::tools::spec::ToolSpec; -use tracing::{error, info, warn}; - -struct HttpResponse { - status: u16, - body: Value, -} - -// ─── 结构化结果 ─── - -/// 工具执行错误类别(供调用方区分,替代字符串前缀匹配) -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ToolErrorKind { - SpawnFailed, - StdoutReadFailed, - StdoutTimeout, - ProcessTimeout, - ProcessAbnormal, - NonJsonOutput, - UnknownMessageType, - UnsupportedMethod, - HttpFailed, - ApprovalFailed, - ApprovalTimeout, - ApprovalRejected, - MaxRoundsExceeded, - UnknownTool, - MissingParams, -} - -/// 工具执行返回值:成功带内容,失败带类别与消息 -#[derive(Debug, Clone)] -pub enum ToolRunOutcome { - Ok(String), - Err { - kind: ToolErrorKind, - message: String, - }, -} - -impl ToolRunOutcome { - pub fn err(kind: ToolErrorKind, message: impl Into) -> Self { - ToolRunOutcome::Err { - kind, - message: message.into(), - } - } - - pub fn is_err(&self) -> bool { - matches!(self, ToolRunOutcome::Err { .. }) - } - - /// 取出最终展示给 LLM 的消息文本 - pub fn into_message(self) -> String { - match self { - ToolRunOutcome::Ok(s) => s, - ToolRunOutcome::Err { message, .. } => message, - } - } -} - -pub async fn execute_loop( - spec: &ToolSpec, - params: &Value, - user_id: &str, - approved: bool, - approval: &Arc, - memory: &Arc, - wechat_send: &( - dyn Fn(&str, &str) -> std::pin::Pin + Send>> - + Send - + Sync - ), -) -> ToolRunOutcome { - // ── 统一授权边界:高风险工具在 spawn 前必须通过用户审批 ── - // 不依赖后续消息类型,覆盖 stdio/http/db 所有路径 - if spec.is_high_risk() && !approved { - let (code, rx) = match approval.create(user_id, &spec.name).await { - Ok((c, r)) => (c, r), - Err(e) => return ToolRunOutcome::err(ToolErrorKind::ApprovalFailed, format!("审批失败: {e}")), - }; - wechat_send( - user_id, - &format!("⚠️ {}\n确认码: {}\n回复确认码继续,回复0取消", spec.desc, code), - ) - .await; - match rx.await.unwrap_or(ApprovalDecision::Expired) { - ApprovalDecision::Approved => { /* 授权通过,继续执行 */ } - ApprovalDecision::Rejected => { - return ToolRunOutcome::err(ToolErrorKind::ApprovalRejected, "操作已被用户取消") - } - ApprovalDecision::Expired => { - return ToolRunOutcome::err(ToolErrorKind::ApprovalTimeout, "审批超时,操作已取消") - } - } - } - - let start = std::time::Instant::now(); - let binary_path = spec.path.as_deref().unwrap_or(&spec.tool); - const MAX_ROUNDS: u32 = 20; - - let mut response: Option = None; - let mut db_response: Option = None; - let mut round = 0u32; - - loop { - round += 1; - if round > MAX_ROUNDS { - error!(target: "ias::err", "[loop] 工具 '{}' 超过最大轮次 {}", spec.name, MAX_ROUNDS); - return ToolRunOutcome::err( - ToolErrorKind::MaxRoundsExceeded, - format!("工具 '{}' 执行轮次过多(>{})", spec.name, MAX_ROUNDS), - ); - } - - // ── 信封 ── - let mut envelope = json!({"params":params,"approved":approved,"user_id":user_id}); - if let Some(ref r) = response { - envelope["response"] = json!({"status":r.status,"body":r.body}); - } - if let Some(ref d) = db_response { - envelope["db_response"] = d.clone(); - } - - // ── spawn ── - // 执行隔离边界:启用沙箱且工具 profile 非 local 时,通过 docker exec - // 在长期容器内执行;否则本地 spawn(env_clear + 白名单注入)。 - // 沙箱降级(ensure 失败或容器崩溃未恢复)时自动回退本地执行。 - let use_docker = if crate::tools::sandbox::should_use_docker(&spec.sandbox.profile) { - // 执行前确保容器就绪:未运行则重建,重建失败则降级 - crate::tools::sandbox::ensure_ready_or_degrade("tools").await - } else { - false - }; - let mut child = match spawn_child(spec, binary_path, use_docker).await { - Ok(c) => c, - Err(e) => { - error!(target: "ias::err","[spawn] {e}"); - return ToolRunOutcome::err(ToolErrorKind::SpawnFailed, format!("启动失败: {e}")); - } - }; - - { - let mut stdin = child.stdin.take().unwrap(); - let _ = stdin - .write_all( - serde_json::to_string(&envelope) - .unwrap_or_default() - .as_bytes(), - ) - .await; - } - - let timeout = Duration::from_secs(spec.timeout_secs); - - // ── 并发读取 stdout + stderr(避免管道缓冲区死锁)── - let stdout_reader = tokio::io::BufReader::new(child.stdout.take().unwrap()); - let stderr_reader = child.stderr.take().unwrap(); - - let (line_result, stderr_result) = tokio::join!( - tokio::time::timeout(timeout, async { - let mut r = stdout_reader; - let mut l = String::new(); - r.read_line(&mut l).await.map(|_| l) - }), - tokio::time::timeout(timeout, async { - let mut s = stderr_reader; - let mut b = vec![]; - s.read_to_end(&mut b) - .await - .map(|_| String::from_utf8_lossy(&b).to_string()) - }), - ); - - let line = match line_result { - Ok(Ok(l)) => l, - Ok(Err(e)) => { - let _ = child.start_kill(); - return ToolRunOutcome::err(ToolErrorKind::StdoutReadFailed, format!("读取stdout失败: {e}")); - } - Err(_) => { - let _ = child.start_kill(); - return ToolRunOutcome::err( - ToolErrorKind::StdoutTimeout, - format!("stdout超时({}s)", spec.timeout_secs), - ); - } - }; - - let stderr_output = match stderr_result { - Ok(Ok(s)) => s, - Ok(Err(_)) => String::new(), - Err(_) => String::new(), - }; - let stderr_trimmed = stderr_output.trim(); - if !stderr_trimmed.is_empty() { - warn!(target: "ias::err","[stderr] {}", stderr_trimmed); - } - - // ── 等待进程退出 ── - let status = match tokio::time::timeout(timeout, child.wait()).await { - Ok(Ok(s)) => s, - Ok(Err(e)) => { - error!(target: "ias::err","[process] {e}"); - return ToolRunOutcome::err(ToolErrorKind::ProcessAbnormal, format!("进程异常: {e}")); - } - Err(_) => { - let _ = child.start_kill(); - error!(target: "ias::err","[timeout] {}s", spec.timeout_secs); - return ToolRunOutcome::err( - ToolErrorKind::ProcessTimeout, - format!("进程超时({}s)", spec.timeout_secs), - ); - } - }; - - if !status.success() { - error!(target: "ias::err","[exit] code={} stderr: {}", status.code().unwrap_or(-1), stderr_trimmed); - let reason = if stderr_trimmed.is_empty() { - format!("进程退出码 {}", status.code().unwrap_or(-1)) - } else { - stderr_trimmed.to_string() - }; - return ToolRunOutcome::err( - ToolErrorKind::ProcessAbnormal, - format!("异常退出({}): {}", status.code().unwrap_or(-1), reason), - ); - } - - // ── 解析 ── - let trimmed = line.trim(); - let msg: Value = match serde_json::from_str(trimmed) { - Ok(v) => v, - Err(_) => { - warn!(target: "ias::err","[non-json] {:.100}", trimmed); - return ToolRunOutcome::err( - ToolErrorKind::NonJsonOutput, - format!("工具输出非JSON: {:.100}", trimmed), - ); - } - }; - - match msg["type"].as_str() { - Some("result") => { - let content = msg["content"].as_str().unwrap_or(""); - let elapsed = start.elapsed(); - info!(target: "ias::tool", "[done] {} 完成, 耗时 {:?}, {} 轮", spec.name, elapsed, round); - return ToolRunOutcome::Ok(content.to_string()); - } - Some("http") => { - let method = msg["method"].as_str().unwrap_or("GET"); - let url = msg["url"].as_str().unwrap_or(""); - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(25)) - .build() - .unwrap(); - let body_str = msg["body"].as_str(); - let resp = match (method, body_str) { - ("GET", _) => client.get(url).send().await, - ("POST", Some(b)) => { - client - .post(url) - .header("Content-Type", "application/json") - .body(b.to_string()) - .send() - .await - } - ("POST", None) => client.post(url).send().await, - (o, _) => { - return ToolRunOutcome::err(ToolErrorKind::UnsupportedMethod, format!("不支持的方法: {o}")) - } - }; - match resp { - Ok(r) => { - let s = r.status().as_u16(); - let b: Value = r.json().await.unwrap_or(Value::Null); - response = Some(HttpResponse { status: s, body: b }); - } - Err(e) => { - error!(target: "ias::err","[http] {e}"); - return ToolRunOutcome::err(ToolErrorKind::HttpFailed, format!("HTTP失败: {e}")); - } - } - } - Some("db") => { - let op = msg["operation"].as_str().unwrap_or(""); - let op_params = msg.get("params").cloned().unwrap_or(Value::Null); - - let result = match op { - "read_memories" => memory.read_for(user_id, None).await, - "write_memory" => { - let c = op_params["content"].as_str().unwrap_or(""); - if c.is_empty() { - "缺少 content".into() - } else { - memory.write_for(user_id, c).await - } - } - "delete_memory" => { - let id = op_params["memory_id"].as_i64().unwrap_or(-1) as i32; - memory.delete_for(user_id, id).await - } - "update_memory" => { - let id = op_params["memory_id"].as_i64().unwrap_or(-1) as i32; - let c = op_params["content"].as_str().unwrap_or(""); - memory.update_for(user_id, id, c).await - } - _ => format!("未知DB操作: {op}"), - }; - db_response = Some(json!({"result": result})); - } - _ => { - warn!(target: "ias::err","[unknown-type] {}", msg["type"]); - return ToolRunOutcome::err( - ToolErrorKind::UnknownMessageType, - format!("未知消息类型: {}", msg["type"]), - ); - } - } - } -} - -/// 本地执行:env_clear + PATH + spec.env 白名单注入 -fn build_local_cmd(spec: &ToolSpec, binary_path: &str) -> Command { - let mut cmd = Command::new(std::path::Path::new(binary_path)); - if let Some(c) = &spec.command { - cmd.arg("--command").arg(c); - } - cmd.env_clear(); - if let Ok(path) = std::env::var("PATH") { - cmd.env("PATH", path); - } - for key in &spec.env { - match std::env::var(key) { - Ok(v) => { - cmd.env(key, &v); - } - Err(_) => { - warn!(target: "ias::err", "[env] 工具 '{}' 需要的环境变量 '{}' 未设置", spec.name, key); - } - } - } - cmd -} - -/// spawn 工具进程。docker exec 模式下若首次 spawn 失败,尝试检测容器崩溃并重建后重试一次; -/// 重建失败则降级为本地执行(保留可用性,但仍受审批约束)。 -async fn spawn_child( - spec: &ToolSpec, - binary_path: &str, - use_docker: bool, -) -> Result { - let make_cmd = || { - let mut cmd = if use_docker { - crate::tools::sandbox::build_exec_cmd(spec) - } else { - build_local_cmd(spec, binary_path) - }; - cmd.stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true); - cmd - }; - - if let Ok(child) = make_cmd().spawn() { - return Ok(child); - } - - // 首次 spawn 失败。docker 模式下可能是容器崩溃,尝试重建后重试一次 - if use_docker && crate::tools::sandbox::recover_if_crashed("tools").await { - tracing::info!(target: "ias::sandbox", "沙箱容器已重建,重试执行"); - if let Ok(child) = make_cmd().spawn() { - return Ok(child); - } - // 重建后仍失败,降级本地执行 - tracing::warn!(target: "ias::sandbox", "重建后 exec 仍失败,降级本地执行"); - } else if use_docker { - tracing::warn!(target: "ias::sandbox", "容器不可用,降级本地执行"); - } - - // 降级:本地 spawn(审批已在入口完成,安全边界不受影响) - build_local_cmd(spec, binary_path) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn() -} diff --git a/src/tools/mod.rs b/src/tools/mod.rs deleted file mode 100644 index ecaf58b..0000000 --- a/src/tools/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! ## 工具系统 -//! -//! LLM 通过 function calling 的 `query_capabilities` + `call_capability` 两层元工具间接调用。 - -pub mod approval; -pub mod executor; -pub mod parser; -pub mod registry; -pub mod sandbox; -pub mod spec; diff --git a/src/tools/parser.rs b/src/tools/parser.rs deleted file mode 100644 index aecda5e..0000000 --- a/src/tools/parser.rs +++ /dev/null @@ -1,159 +0,0 @@ -//! ## 工具规范解析器 -//! -//! 扫描指定目录,解析所有 `*.tool.yaml` 文件为 `ToolSpec`。 - -use super::spec::ToolSpec; -use std::path::Path; - -/// 解析单个 YAML 文件 -fn parse_file(path: &Path) -> Result { - let content = std::fs::read_to_string(path).map_err(|e| format!("读取 {path:?} 失败: {e}"))?; - - serde_yaml::from_str::(&content).map_err(|e| format!("解析 {path:?} 失败: {e}")) -} - -/// 解析结果 -#[derive(Debug)] -pub struct ParseResult { - pub specs: Vec, - pub errors: Vec<(String, String)>, -} - -/// 扫描目录,解析所有 `*.tool.yaml`。 -pub fn parse_dir(dir: &Path) -> ParseResult { - let mut specs = Vec::new(); - let mut errors = Vec::new(); - - let entries = match std::fs::read_dir(dir) { - Ok(e) => e, - Err(e) => { - errors.push((dir.display().to_string(), format!("读取目录失败: {e}"))); - return ParseResult { specs, errors }; - } - }; - - for entry in entries.flatten() { - let path = entry.path(); - let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - if !file_name.ends_with(".tool.yaml") { - continue; - } - match parse_file(&path) { - Ok(spec) => specs.push(spec), - Err(e) => errors.push((file_name.to_string(), e)), - } - } - - ParseResult { specs, errors } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_minimal() { - let dir = std::env::temp_dir().join("ias_test_minimal"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write( - dir.join("test.tool.yaml"), - r#" -name: test_tool -desc: 测试 -type: http -tool: test -"#, - ) - .unwrap(); - - let result = parse_dir(&dir); - assert_eq!(result.specs.len(), 1); - let s = &result.specs[0]; - assert_eq!(s.name, "test_tool"); - assert_eq!(s.risk_level, "low"); - assert_eq!(s.timeout_secs, 30); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn test_parse_full() { - let dir = std::env::temp_dir().join("ias_test_full"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write( - dir.join("w.tool.yaml"), - r#" -name: weather_now -desc: 天气 -type: http -tool: weather -command: now -risk_level: high -timeout_secs: 10 -env: - - QWEATHER_KEY -params: - - name: location - required: true - desc: 城市 - - name: days - desc: 天数 -"#, - ) - .unwrap(); - - let result = parse_dir(&dir); - let s = &result.specs[0]; - assert_eq!(s.command.as_deref(), Some("now")); - assert_eq!(s.risk_level, "high"); - assert_eq!(s.timeout_secs, 10); - assert_eq!(s.env, vec!["QWEATHER_KEY"]); - assert_eq!(s.params.len(), 2); - assert!(s.params[0].required); - assert!(!s.params[1].required); - assert!(s.params[0].required); - assert!(!s.params[1].required); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn test_parse_bad_yaml() { - let dir = std::env::temp_dir().join("ias_test_bad"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("bad.tool.yaml"), "{{{ not yaml").unwrap(); - - let result = parse_dir(&dir); - assert_eq!(result.specs.len(), 0); - assert_eq!(result.errors.len(), 1); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn test_skips_non_yaml() { - let dir = std::env::temp_dir().join("ias_test_skip"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("readme.md"), "hello").unwrap(); - - let result = parse_dir(&dir); - assert!(result.specs.is_empty()); - assert!(result.errors.is_empty()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn test_parse_datetime_specs() { - let dir = Path::new("tools/rules"); - if !dir.exists() { - return; - } - let result = parse_dir(dir); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - assert!(result.specs.len() >= 1, "至少 1 个规范文件"); - - let names: Vec<&str> = result.specs.iter().map(|s| s.name.as_str()).collect(); - assert!(names.contains(&"get_current_datetime")); - } -} diff --git a/src/tools/registry.rs b/src/tools/registry.rs deleted file mode 100644 index d53cf4c..0000000 --- a/src/tools/registry.rs +++ /dev/null @@ -1,381 +0,0 @@ -//! # 工具注册器 -//! -//! 扫描每个工具目录下的 `specs/*.tool.yaml`,按 `name` 路由分发。 - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use crate::context::MemoryStore; -use crate::tools::approval::ApprovalManager; -use crate::tools::executor::{self, ToolErrorKind, ToolRunOutcome}; -use crate::tools::parser; -use crate::tools::spec::ToolSpec; -use serde_json::Value; -use tokio::sync::RwLock; - -#[derive(Debug, Clone, serde::Serialize)] -pub struct ToolSummary { - pub name: String, - pub desc: String, - pub params: Vec, -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct ParamSummary { - pub name: String, - pub required: bool, - pub desc: String, -} - -/// 解析工具目录。相对路径基于二进制所在目录。 -pub(crate) fn resolve_dir(dir: &str) -> PathBuf { - let path = Path::new(dir); - if path.is_absolute() { - return path.to_path_buf(); - } - let exe_dir = std::env::current_exe() - .ok() - .and_then(|e| e.parent().map(|p| p.to_path_buf())) - .unwrap_or_else(|| PathBuf::from(".")); - let candidate = exe_dir.join(dir); - if candidate.exists() { - return candidate; - } - let dev = exe_dir.join("../..").join(dir); - if dev.exists() { - tracing::info!(target:"ias::registry","dev模式: {}", dev.display()); - return dev; - } - path.to_path_buf() -} - -pub struct Registry { - tools: HashMap, -} - -pub struct RegistryManager { - root: String, - registry: RwLock, -} - -impl Registry { - /// 扫描工具根目录下各子目录的 `specs/*.tool.yaml`。 - pub fn load(dir: &str) -> Self { - let root = resolve_dir(dir); - let mut tools = HashMap::new(); - - let entries = match std::fs::read_dir(&root) { - Ok(e) => e, - Err(e) => { - tracing::warn!(target:"ias::registry","读取目录失败 {}: {}", root.display(), e); - return Self { tools }; - } - }; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let specs_dir = path.join("specs"); - if !specs_dir.exists() { - continue; - } - - let result = parser::parse_dir(&specs_dir); - for (file, err) in &result.errors { - tracing::warn!(target:"ias::registry","解析失败 {}: {}", file, err); - } - - for mut spec in result.specs { - // 优先使用统一产物目录 tools/bin/,不存在则 fallback 到工具子目录 - let binary = { - let unified = root.join("bin").join(&spec.tool); - if unified.exists() { - unified - } else { - match &spec.path { - Some(p) => { - let p = p.trim_start_matches('/'); - path.join(p) - } - None => { - // 默认: {tool_dir}/target/release/{tool} - path.join("target/release").join(&spec.tool) - } - } - } - }; - - if !binary.exists() { - tracing::warn!(target:"ias::registry","{} 二进制不存在: {}", spec.name, binary.display()); - continue; - } - - // 将绝对路径存入 path 字段(executor 直接使用) - spec.path = Some(binary.to_string_lossy().to_string()); - - tracing::info!(target:"ias::registry","注册: {} ({})", spec.name, spec.r#type); - tools.insert(spec.name.clone(), spec); - } - } - - Self { tools } - } - - pub fn list_tools(&self) -> Vec { - self.tools - .values() - .map(|s| ToolSummary { - name: s.name.clone(), - desc: s.desc.clone(), - params: s - .params - .iter() - .map(|p| ParamSummary { - name: p.name.clone(), - required: p.required, - desc: p.desc.clone(), - }) - .collect(), - }) - .collect() - } - - pub fn get(&self, name: &str) -> Option<&ToolSpec> { - self.tools.get(name) - } - - pub async fn dispatch( - &self, - name: &str, - params: &Value, - user_id: &str, - approved: bool, - approval: &Arc, - memory: &Arc, - wechat_send: &( - dyn Fn(&str, &str) -> std::pin::Pin + Send>> - + Send - + Sync - ), - ) -> ToolRunOutcome { - let spec = match self.get(name) { - Some(s) => s, - None => { - tracing::warn!(target:"ias::registry","未知: {name}"); - return ToolRunOutcome::err(ToolErrorKind::UnknownTool, format!("未知工具: {name}")); - } - }; - if let Some(err) = validate_required_params(spec, params) { - return ToolRunOutcome::err(ToolErrorKind::MissingParams, err); - } - executor::execute_loop( - spec, - params, - user_id, - approved, - approval, - memory, - wechat_send, - ) - .await - } - - #[allow(dead_code)] - pub fn len(&self) -> usize { - self.tools.len() - } -} - -impl RegistryManager { - pub fn load(dir: impl Into) -> Self { - let root = dir.into(); - Self { - registry: RwLock::new(Registry::load(&root)), - root, - } - } - - pub async fn reload(&self) -> usize { - let registry = Registry::load(&self.root); - let len = registry.len(); - *self.registry.write().await = registry; - len - } - - pub async fn list_tools(&self) -> Vec { - self.registry.read().await.list_tools() - } - - pub async fn dispatch( - &self, - name: &str, - params: &Value, - user_id: &str, - approved: bool, - approval: &Arc, - memory: &Arc, - wechat_send: &( - dyn Fn(&str, &str) -> std::pin::Pin + Send>> - + Send - + Sync - ), - ) -> ToolRunOutcome { - let spec = { - let registry = self.registry.read().await; - match registry.get(name) { - Some(spec) => spec.clone(), - None => { - tracing::warn!(target:"ias::registry","未知: {name}"); - return ToolRunOutcome::err(ToolErrorKind::UnknownTool, format!("未知工具: {name}")); - } - } - }; - - if let Some(err) = validate_required_params(&spec, params) { - return ToolRunOutcome::err(ToolErrorKind::MissingParams, err); - } - - executor::execute_loop( - &spec, - params, - user_id, - approved, - approval, - memory, - wechat_send, - ) - .await - } -} - -fn validate_required_params(spec: &ToolSpec, params: &Value) -> Option { - let Some(obj) = params.as_object() else { - return Some(format!("工具 '{}' 参数必须是 JSON 对象", spec.name)); - }; - - let missing: Vec<&str> = spec - .params - .iter() - .filter(|p| p.required) - .filter_map(|p| match obj.get(&p.name) { - Some(Value::Null) | None => Some(p.name.as_str()), - Some(Value::String(s)) if s.trim().is_empty() => Some(p.name.as_str()), - Some(_) => None, - }) - .collect(); - - if missing.is_empty() { - None - } else { - Some(format!( - "缺少必填参数: {}(工具: {})", - missing.join(", "), - spec.name - )) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - - #[test] - fn test_load_specs_dir() { - let dir = std::env::temp_dir().join("ias_test_specs_dir"); - let _ = std::fs::remove_dir_all(&dir); - let tool_dir = dir.join("mytool"); - std::fs::create_dir_all(tool_dir.join("specs")).unwrap(); - std::fs::create_dir_all(tool_dir.join("target/release")).unwrap(); - - std::fs::write( - tool_dir.join("specs/test.tool.yaml"), - r#" -name: test_tool -desc: 测试 -type: http -tool: mytool -path: /target/release/mytool -"#, - ) - .unwrap(); - - let mut f = std::fs::File::create(tool_dir.join("target/release/mytool")).unwrap(); - f.write_all(b"fake").unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = f.set_permissions(std::fs::Permissions::from_mode(0o755)); - } - - let registry = Registry::load(dir.to_str().unwrap()); - assert_eq!(registry.len(), 1); - - let tools = registry.list_tools(); - assert_eq!(tools[0].name, "test_tool"); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn test_load_real_tools() { - let dir = std::path::Path::new("tools"); - if !dir.exists() { - return; - } - let registry = Registry::load("tools"); - assert!(registry.len() >= 1, "至少 1 个工具"); - } - - #[test] - fn test_validate_required_params_missing() { - let spec = ToolSpec { - name: "weather_now".into(), - desc: "天气".into(), - r#type: "http".into(), - tool: "weather".into(), - command: None, - path: None, - risk_level: "low".into(), - timeout_secs: 10, - env: vec![], - params: vec![crate::tools::spec::ParamSpec { - name: "location".into(), - required: true, - desc: "城市".into(), - }], - sandbox: Default::default(), - }; - - let err = validate_required_params(&spec, &serde_json::json!({})).unwrap(); - assert!(err.contains("location")); - } - - #[test] - fn test_validate_required_params_present() { - let spec = ToolSpec { - name: "weather_now".into(), - desc: "天气".into(), - r#type: "http".into(), - tool: "weather".into(), - command: None, - path: None, - risk_level: "low".into(), - timeout_secs: 10, - env: vec![], - params: vec![crate::tools::spec::ParamSpec { - name: "location".into(), - required: true, - desc: "城市".into(), - }], - sandbox: Default::default(), - }; - - assert!( - validate_required_params(&spec, &serde_json::json!({"location": "北京"})).is_none() - ); - } -} diff --git a/src/tools/sandbox.rs b/src/tools/sandbox.rs deleted file mode 100644 index aab2433..0000000 --- a/src/tools/sandbox.rs +++ /dev/null @@ -1,216 +0,0 @@ -//! # 沙箱容器管理 -//! -//! 长期容器模式:daemon 启动时创建 `ias-sandbox` 容器(无网络、只读根、cap-drop ALL), -//! 统一产物目录 `tools/bin/` 只读挂载到容器 `/tools`。工具调用通过 `docker exec` -//! 在容器内执行,相比每次 `docker run` 省去容器创建/销毁开销。 -//! -//! ## 超时与清理 -//! `docker exec` 内用 `timeout -k 5 ` 包裹工具进程:超时发 SIGTERM, -//! 5 秒后仍不退出发 SIGKILL,确保容器内不累积孤儿进程。executor 的 tokio timeout -//! 作为外层保险,kill `docker exec` 客户端释放宿主资源。 - -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use tokio::process::Command; - -use crate::tools::spec::ToolSpec; - -/// 长期沙箱容器名 -pub const CONTAINER_NAME: &str = "ias-sandbox"; -/// 容器内产物挂载点 -const MOUNT_TARGET: &str = "/tools"; - -/// 降级标志:ensure_container 失败或容器崩溃后置 true, -/// executor 据此回退本地执行,避免不受信任工具持续 SpawnFailed -static DEGRADED: AtomicBool = AtomicBool::new(false); - -/// 全局沙箱开关:`IAS_DOCKER_SANDBOX=1` 启用 -pub fn enabled() -> bool { - std::env::var("IAS_DOCKER_SANDBOX") - .ok() - .as_deref() - == Some("1") -} - -/// 当前是否处于降级状态(ensure 失败或容器崩溃) -pub fn degraded() -> bool { - DEGRADED.load(Ordering::SeqCst) -} - -/// 执行前检查:沙箱启用且未降级时才走 docker exec -pub fn should_use_docker(profile: &str) -> bool { - enabled() && profile != "local" && !degraded() -} - -/// 确保 ias-sandbox 长期容器在运行。daemon 启动时调用。 -/// -/// - 容器已运行:直接复用 -/// - 容器不存在/已停止:清理后重新创建 -/// -/// 失败时设置降级标志,executor 据此回退本地执行。 -pub async fn ensure_container(dir: &str) -> Result<(), String> { - match ensure_container_inner(dir).await { - Ok(()) => { - DEGRADED.store(false, Ordering::SeqCst); - Ok(()) - } - Err(e) => { - DEGRADED.store(true, Ordering::SeqCst); - tracing::warn!(target: "ias::sandbox", "沙箱降级为本地执行: {e}"); - Err(e) - } - } -} - -async fn ensure_container_inner(dir: &str) -> Result<(), String> { - let root = locate_tools_root(dir)?; - let bin_dir = root.join("bin"); - std::fs::create_dir_all(&bin_dir).map_err(|e| format!("创建产物目录失败: {e}"))?; - - // 检查是否已在运行 - let running = Command::new("docker") - .args(["inspect", "-f", "{{.State.Running}}", CONTAINER_NAME]) - .output() - .await - .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true") - .unwrap_or(false); - - if running { - tracing::info!(target: "ias::sandbox", "沙箱容器已运行,复用"); - return Ok(()); - } - - // 清理旧的已停止容器 - let _ = Command::new("docker") - .args(["rm", "-f", CONTAINER_NAME]) - .output() - .await; - - // 启动长期容器 - let output = Command::new("docker") - .arg("run") - .arg("-d") - .arg("--rm") - .arg("--name") - .arg(CONTAINER_NAME) - .arg("--network") - .arg("none") - .arg("--cap-drop") - .arg("ALL") - .arg("--security-opt") - .arg("no-new-privileges") - .arg("--read-only") - .arg("--memory") - .arg("512m") - .arg("--cpus") - .arg("1") - .arg("--pids-limit") - .arg("128") - .arg("--mount") - .arg(format!( - "type=bind,source={},target={MOUNT_TARGET},readonly", - bin_dir.display() - )) - .arg("--mount") - .arg("type=tmpfs,target=/tmp") - .arg("ias-stdio-runner") - .args(["sleep", "infinity"]) - .output() - .await - .map_err(|e| format!("启动沙箱容器失败: {e}"))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("沙箱容器启动失败: {}", stderr.trim())); - } - - tracing::info!( - target: "ias::sandbox", - "沙箱容器已启动 ({} → {})", - bin_dir.display(), - MOUNT_TARGET - ); - Ok(()) -} - -/// exec 失败时调用:检测容器是否崩溃,崩溃则尝试重建。 -/// 重建成功返回 true(调用方可重试 exec);否则置降级标志返回 false。 -pub async fn recover_if_crashed(dir: &str) -> bool { - let running = Command::new("docker") - .args(["inspect", "-f", "{{.State.Running}}", CONTAINER_NAME]) - .output() - .await - .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true") - .unwrap_or(false); - - if running { - // 容器在运行,exec 失败是其他原因(如工具二进制不存在),不降级 - return false; - } - - tracing::warn!(target: "ias::sandbox", "沙箱容器已崩溃,尝试重建"); - ensure_container(dir).await.is_ok() -} - -/// 执行前调用:沙箱启用且未降级时,确保容器在运行。 -/// 容器不在运行则重建;重建失败置降级标志并返回 false(调用方降级本地执行)。 -pub async fn ensure_ready_or_degrade(dir: &str) -> bool { - if !enabled() || degraded() { - return false; - } - let running = Command::new("docker") - .args(["inspect", "-f", "{{.State.Running}}", CONTAINER_NAME]) - .output() - .await - .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true") - .unwrap_or(false); - if running { - return true; - } - // 容器不在运行,尝试重建 - if ensure_container(dir).await.is_ok() { - true - } else { - tracing::warn!(target: "ias::sandbox", "容器不可用,降级本地执行"); - false - } -} - -/// 构建 `docker exec` 命令,在长期容器内执行工具。 -/// -/// 容器内路径 = MOUNT_TARGET + 二进制文件名。 -/// 用 `timeout -k 5` 包裹:超时 SIGTERM,5 秒后 SIGKILL,避免孤儿进程。 -/// env 按 spec 白名单注入(`-e` 必须在容器名前)。 -pub fn build_exec_cmd(spec: &ToolSpec) -> Command { - // 直接用 spec.tool 作为容器内文件名,语义清晰,不依赖 spec.path 格式 - let container_path = format!("{MOUNT_TARGET}/{}", spec.tool); - - let mut cmd = Command::new("docker"); - cmd.arg("exec").arg("-i"); - - // env 白名单注入(OPTIONS,必须在容器名前) - for key in &spec.env { - if let Ok(v) = std::env::var(key) { - cmd.arg("-e").arg(format!("{key}={v}")); - } - } - - cmd.arg(CONTAINER_NAME); - - // timeout -k 5 :超时 SIGTERM,5s 后 SIGKILL - cmd.arg("timeout").arg("-k").arg("5").arg(spec.timeout_secs.to_string()); - cmd.arg(container_path); - - if let Some(c) = &spec.command { - cmd.arg("--command").arg(c); - } - cmd -} - -/// 复用 registry 的路径解析逻辑定位 tools 根目录并转绝对路径 -fn locate_tools_root(dir: &str) -> Result { - let resolved = crate::tools::registry::resolve_dir(dir); - resolved - .canonicalize() - .map_err(|e| format!("无法定位 tools 目录 {}: {e}", resolved.display())) -} diff --git a/src/tools/spec.rs b/src/tools/spec.rs deleted file mode 100644 index e45a734..0000000 --- a/src/tools/spec.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! ## 工具规范类型 -//! -//! 与 `*.tool.yaml` 格式一一对应的 Rust 结构体。 - -use serde::{Deserialize, Serialize}; - -/// 单个参数的声明 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ParamSpec { - pub name: String, - #[serde(default)] - pub required: bool, - pub desc: String, -} - -/// 沙箱配置(Docker 一次性容器隔离) -/// -/// ```yaml -/// sandbox: -/// profile: stdio # local | stdio | builder -/// network: none # none | default -/// memory: 256m -/// cpus: "0.5" -/// pids_limit: 64 -/// read_only: true -/// ``` -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(default)] -pub struct SandboxSpec { - /// 沙箱画像:`local` 本地执行(默认,不启用容器);`stdio` 无网络只读容器 - /// (供 tool_manager 产出的不受信任工具);`builder` 构建容器(供 tool_manager) - pub profile: String, - /// 网络:`none`(默认)或 `default` - pub network: String, - /// 内存限制,如 `256m` - pub memory: String, - /// CPU 限制,如 `0.5` - pub cpus: String, - /// PID 数量上限 - pub pids_limit: u32, - /// 只读根文件系统 - pub read_only: bool, -} - -impl Default for SandboxSpec { - fn default() -> Self { - Self { - profile: "local".into(), - network: "none".into(), - memory: "256m".into(), - cpus: "0.5".into(), - pids_limit: 64, - read_only: true, - } - } -} - -/// 工具规范(`*.tool.yaml` 的完整映射) -/// -/// ```yaml -/// name: weather_now -/// desc: 查询实时天气 -/// type: http -/// tool: weather -/// command: now -/// risk_level: low -/// timeout_secs: 10 -/// env: -/// - QWEATHER_KEY -/// params: -/// - name: location -/// required: true -/// desc: 城市名称 -/// ``` -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ToolSpec { - pub name: String, - pub desc: String, - pub r#type: String, - - pub tool: String, - - #[serde(default)] - pub command: Option, - - /// 二进制路径,相对于工具根目录(如 /target/release/amap) - #[serde(default)] - pub path: Option, - - #[serde(default = "default_risk_level")] - pub risk_level: String, - - #[serde(default = "default_timeout")] - pub timeout_secs: u64, - - #[serde(default)] - pub env: Vec, - - #[serde(default)] - pub params: Vec, - - /// 沙箱配置(Docker 隔离);默认 profile=local 不启用容器 - #[serde(default)] - pub sandbox: SandboxSpec, -} - -fn default_risk_level() -> String { - "low".into() -} - -fn default_timeout() -> u64 { - 30 -} - -impl ToolSpec { - /// 判断是否高风险 - pub fn is_high_risk(&self) -> bool { - self.risk_level == "high" - } -} diff --git a/tools/amap/Cargo.lock b/tools/amap/Cargo.lock deleted file mode 100644 index 6142a1c..0000000 --- a/tools/amap/Cargo.lock +++ /dev/null @@ -1,105 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "amap" -version = "0.1.0" -dependencies = [ - "serde_json", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "memchr" -version = "2.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tools/amap/Cargo.toml b/tools/amap/Cargo.toml deleted file mode 100644 index e176003..0000000 --- a/tools/amap/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "amap" -version = "0.1.0" -edition = "2024" - -[[bin]] -name = "amap" -path = "src/main.rs" - -[dependencies] -serde_json = "1.0" diff --git a/tools/amap/specs/amap_geocode.tool.yaml b/tools/amap/specs/amap_geocode.tool.yaml deleted file mode 100644 index 2b5d263..0000000 --- a/tools/amap/specs/amap_geocode.tool.yaml +++ /dev/null @@ -1,18 +0,0 @@ -name: amap_geocode -desc: 将文字地址转换为经纬度坐标。输入地址名称返回坐标。用于"天安门的坐标是什么"等需要将地名转为坐标的场景,通常作为其他地图工具的前置步骤 -type: http -tool: amap -command: geocode -path: /target/release/amap -risk_level: low -timeout_secs: 15 -env: - - AMAP_KEY -params: - - name: address - required: true - desc: 详细地址名称,如"北京市朝阳区天安门"、"合肥火车站"。越详细越精确 - - name: city - required: false - desc: 城市名称,可选。限定地理编码的城市范围 - diff --git a/tools/amap/specs/amap_map_link.tool.yaml b/tools/amap/specs/amap_map_link.tool.yaml deleted file mode 100644 index fbfc541..0000000 --- a/tools/amap/specs/amap_map_link.tool.yaml +++ /dev/null @@ -1,15 +0,0 @@ -name: amap_map_link -desc: 生成高德地图可视化链接。输入中心点坐标返回可分享的地图标记链接。用于"把这个位置发给我"等需要可视化展示的场景 -type: http -tool: amap -command: map_link -path: /target/release/amap -risk_level: low -timeout_secs: 15 -env: - - AMAP_KEY -params: - - name: center - required: false - desc: 地图中心点经纬度坐标,格式"经度,纬度",默认天安门坐标 - diff --git a/tools/amap/specs/amap_poi_search.tool.yaml b/tools/amap/specs/amap_poi_search.tool.yaml deleted file mode 100644 index 1bd4b01..0000000 --- a/tools/amap/specs/amap_poi_search.tool.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: amap_poi_search -desc: 搜索指定城市内的POI地点(餐饮、酒店、景点、商场、加油站、银行等)。返回名称和地址。用于"附近有什么好吃的""北京有什么景点"等问题 -type: http -tool: amap -command: poi_search -path: /target/release/amap -risk_level: low -timeout_secs: 15 -env: - - AMAP_KEY -params: - - name: keywords - required: true - desc: 搜索关键词,如"咖啡厅"、"银行"、"加油站"。支持POI类型名称 - - name: city - required: false - desc: 城市名称,限定搜索范围。不填则在当前城市搜索 - - name: radius - required: false - desc: 搜索半径(米),如 500、1000、3000。默认 1000 米 - diff --git a/tools/amap/specs/amap_reverse_geocode.tool.yaml b/tools/amap/specs/amap_reverse_geocode.tool.yaml deleted file mode 100644 index 1632192..0000000 --- a/tools/amap/specs/amap_reverse_geocode.tool.yaml +++ /dev/null @@ -1,15 +0,0 @@ -name: amap_reverse_geocode -desc: 将经纬度坐标转换为详细文字地址。输入"经度,纬度"返回结构化地址。用于"这个坐标是哪里"等反向查询 -type: http -tool: amap -command: reverse_geocode -path: /target/release/amap -risk_level: low -timeout_secs: 15 -env: - - AMAP_KEY -params: - - name: location - required: true - desc: 经纬度坐标,格式为"经度,纬度",如"116.397,39.908" - diff --git a/tools/amap/specs/amap_route_plan.tool.yaml b/tools/amap/specs/amap_route_plan.tool.yaml deleted file mode 100644 index 18f5860..0000000 --- a/tools/amap/specs/amap_route_plan.tool.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: amap_route_plan -desc: 规划两点之间的出行路线。支持驾车(driving)、步行(walking)、骑行(riding)、公交(transit)四种方式。返回距离和预计耗时。用于"从家到公司怎么走""走过去要多久"等问题 -type: http -tool: amap -command: route_plan -path: /target/release/amap -risk_level: low -timeout_secs: 15 -env: - - AMAP_KEY -params: - - name: origin - required: true - desc: 起点经纬度坐标,格式"经度,纬度"。可先用 amap_geocode 将地名转为坐标 - - name: destination - required: true - desc: 终点经纬度坐标,格式"经度,纬度"。可先用 amap_geocode 将地名转为坐标 - - name: type - required: false - desc: 出行方式:driving(驾车)、walking(步行)、riding(骑行)、transit(公交)。默认 driving - diff --git a/tools/amap/specs/amap_travel_plan.tool.yaml b/tools/amap/specs/amap_travel_plan.tool.yaml deleted file mode 100644 index 58cb483..0000000 --- a/tools/amap/specs/amap_travel_plan.tool.yaml +++ /dev/null @@ -1,18 +0,0 @@ -name: amap_travel_plan -desc: 为指定城市生成旅游规划。自动搜索城市内的热门景点、美食、住宿等POI。用于"北京三日游怎么安排""合肥有什么好玩的"等旅游咨询 -type: http -tool: amap -command: travel_plan -path: /target/release/amap -risk_level: low -timeout_secs: 15 -env: - - AMAP_KEY -params: - - name: city - required: true - desc: 目标城市名称,如"北京"、"杭州"、"成都" - - name: interests - required: false - desc: 感兴趣的关键词,逗号分隔,如"景点,美食,酒店"。默认"景点,美食" - diff --git a/tools/amap/src/main.rs b/tools/amap/src/main.rs deleted file mode 100644 index 8972b04..0000000 --- a/tools/amap/src/main.rs +++ /dev/null @@ -1,50 +0,0 @@ -fn main() { - let input: serde_json::Value = serde_json::from_reader(std::io::stdin()).unwrap(); - let mode = std::env::args().nth(2).unwrap_or_default(); - let p = &input["params"]; - let key = match std::env::var("AMAP_KEY") { - Ok(k) if !k.is_empty() => k, - _ => { - println!(r#"{{"type":"result","content":"错误: 缺少环境变量 AMAP_KEY(高德地图 API Key),请在 .env 中设置"}}"#); - return; - } - }; - - if input.get("response").is_none() && input.get("db_response").is_none() { - let (url, desc) = match mode.as_str() { - "poi_search" => (format!("https://restapi.amap.com/v5/place/text?keywords={}&city={}&key={key}", - p["keywords"].as_str().unwrap_or(""), p["city"].as_str().unwrap_or("")), format!("搜索: {}", p["keywords"].as_str().unwrap_or(""))), - "geocode" => (format!("https://restapi.amap.com/v3/geocode/geo?address={}&city={}&key={key}", - p["address"].as_str().unwrap_or(""), p["city"].as_str().unwrap_or("")), format!("编码: {}", p["address"].as_str().unwrap_or(""))), - "reverse_geocode" => (format!("https://restapi.amap.com/v3/geocode/regeo?location={}&key={key}", - p["location"].as_str().unwrap_or("")), "逆编码".into()), - "route_plan" => (format!("https://restapi.amap.com/v5/direction/{}?origin={}&destination={}&key={key}", - p["type"].as_str().unwrap_or("driving"), p["origin"].as_str().unwrap_or(""), p["destination"].as_str().unwrap_or("")), "路径规划".into()), - "travel_plan" => (format!("https://restapi.amap.com/v5/place/text?keywords={}&city={}&key={key}", - p["interests"].as_str().unwrap_or(""), p["city"].as_str().unwrap_or("")), format!("旅游: {}", p["city"].as_str().unwrap_or(""))), - "map_link" => (format!("https://uri.amap.com/marker?position={}", - p["center"].as_str().unwrap_or("116.397,39.908")), "地图链接".into()), - _ => { println!(r#"{{"type":"result","content":"未知模式"}}"#); return; } - }; - println!("{}", serde_json::json!({"type":"http","method":"GET","url":url,"desc":desc})); - } else { - let b = &input["response"]["body"]; - let content = match mode.as_str() { - "poi_search"|"travel_plan" => { - let n = b["pois"].as_array().map(|a| a.len()).unwrap_or(0); - let items: Vec = b["pois"].as_array().unwrap_or(&vec![]).iter().take(5) - .map(|x| format!("• {} ({})", x["name"].as_str().unwrap_or(""), x["address"].as_str().unwrap_or(""))).collect(); - format!("找到 {n} 个:\n{}", items.join("\n")) - } - "geocode" => { - let empty = vec![]; let g = b["geocodes"].as_array().unwrap_or(&empty); - g.first().map(|x| format!("{} → {}", x["formatted_address"].as_str().unwrap_or(""), x["location"].as_str().unwrap_or(""))).unwrap_or("未找到".into()) - } - "reverse_geocode" => b["regeocode"]["formatted_address"].as_str().unwrap_or("未找到").into(), - "route_plan" => format!("距离: {}m, 耗时: {}s", b["route"]["distance"].as_str().unwrap_or("?"), b["route"]["duration"].as_str().unwrap_or("?")), - "map_link" => "地图链接已生成".into(), - _ => format!("{b}"), - }; - println!("{}", serde_json::json!({"type":"result","content":content})); - } -} diff --git a/tools/build.sh b/tools/build.sh deleted file mode 100755 index e9db94c..0000000 --- a/tools/build.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -# 构建所有工具,产物统一收集到 bin/ 供长期沙箱容器挂载 -set -e -cd "$(dirname "$0")" - -TOOLS=(datetime weather amap web_search fetch_page memories tool_manager) - -mkdir -p bin - -echo "=== 构建工具 ===" -for tool in "${TOOLS[@]}"; do - echo " $tool..." - (cd "$tool" && cargo build --release --quiet) - cp "$tool/target/release/$tool" "bin/$tool" -done - -echo "=== 完成 ===" -ls -lh bin/* diff --git a/tools/datetime/Cargo.lock b/tools/datetime/Cargo.lock deleted file mode 100644 index 272173f..0000000 --- a/tools/datetime/Cargo.lock +++ /dev/null @@ -1,382 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "cc" -version = "1.2.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "datetime" -version = "0.1.0" -dependencies = [ - "chrono", - "serde_json", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "log" -version = "0.4.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" - -[[package]] -name = "memchr" -version = "2.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "wasm-bindgen" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tools/datetime/Cargo.toml b/tools/datetime/Cargo.toml deleted file mode 100644 index 1ecf410..0000000 --- a/tools/datetime/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "datetime" -version = "0.1.0" -edition = "2024" - -[[bin]] -name = "datetime" -path = "src/main.rs" - -[dependencies] -chrono = "0.4" -serde_json = "1.0" diff --git a/tools/datetime/specs/get_current_datetime.tool.yaml b/tools/datetime/specs/get_current_datetime.tool.yaml deleted file mode 100644 index 547e9a0..0000000 --- a/tools/datetime/specs/get_current_datetime.tool.yaml +++ /dev/null @@ -1,9 +0,0 @@ -name: get_current_datetime -desc: 获取当前系统本地日期时间,返回年月日、星期、时区。用于"现在几点""今天几号""星期几"等问题 -type: stdio -tool: datetime -path: /target/release/datetime -risk_level: low -timeout_secs: 3 -params: [] - diff --git a/tools/datetime/src/main.rs b/tools/datetime/src/main.rs deleted file mode 100644 index 2a0401c..0000000 --- a/tools/datetime/src/main.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! datetime — 本地时间 - -use chrono::Local; - -fn main() { - let _ = std::io::stdin().read_line(&mut String::new()); - let now = Local::now(); - let out = serde_json::json!({ - "type": "result", - "content": format!("当前时间: {}\n星期: {}\n时区: {}", - now.format("%Y-%m-%d %H:%M:%S"), now.format("%A"), now.offset()) - }); - println!("{out}"); -} diff --git a/tools/fetch_page/Cargo.lock b/tools/fetch_page/Cargo.lock deleted file mode 100644 index b92a599..0000000 --- a/tools/fetch_page/Cargo.lock +++ /dev/null @@ -1,105 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "fetch_page" -version = "0.1.0" -dependencies = [ - "serde_json", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "memchr" -version = "2.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tools/fetch_page/Cargo.toml b/tools/fetch_page/Cargo.toml deleted file mode 100644 index 327b89f..0000000 --- a/tools/fetch_page/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "fetch_page" -version = "0.1.0" -edition = "2024" - -[[bin]] -name = "fetch_page" -path = "src/main.rs" - -[dependencies] -serde_json = "1.0" diff --git a/tools/fetch_page/specs/fetch_page.tool.yaml b/tools/fetch_page/specs/fetch_page.tool.yaml deleted file mode 100644 index aa5ae69..0000000 --- a/tools/fetch_page/specs/fetch_page.tool.yaml +++ /dev/null @@ -1,12 +0,0 @@ -name: fetch_page -desc: 抓取指定URL网页并自动提取纯文本正文内容,过滤广告、导航、脚本等无关元素。返回最多30行文本。用于"帮我看看这个网页说了什么"等需要阅读网页内容的场景 -type: http -tool: fetch_page -path: /target/release/fetch_page -risk_level: low -timeout_secs: 30 -params: - - name: url - required: true - desc: 网页URL - diff --git a/tools/fetch_page/src/main.rs b/tools/fetch_page/src/main.rs deleted file mode 100644 index 6627fcf..0000000 --- a/tools/fetch_page/src/main.rs +++ /dev/null @@ -1,15 +0,0 @@ -fn main() { - let input: serde_json::Value = serde_json::from_reader(std::io::stdin()).unwrap(); - if input.get("response").is_none() && input.get("db_response").is_none() { - let url = input["params"]["url"].as_str().unwrap_or(""); - if url.is_empty() { println!(r#"{{"type":"result","content":"缺少url"}}"#); return; } - println!("{}", serde_json::json!({"type":"http","method":"GET","url":url,"desc":format!("抓取 {url}")})); - } else { - let html = input["response"]["body"].as_str().unwrap_or(""); - let mut r = String::new(); let mut tag = false; - for c in html.chars() { if c=='<' { tag=true; } else if c=='>' { tag=false; } else if !tag { r.push(c); } } - let text = r.replace(" "," ").replace("<","<").replace(">",">").replace("&","&"); - let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).take(30).collect(); - println!("{}", serde_json::json!({"type":"result","content":lines.join("\n")})); - } -} diff --git a/tools/memories/Cargo.lock b/tools/memories/Cargo.lock deleted file mode 100644 index a322bf2..0000000 --- a/tools/memories/Cargo.lock +++ /dev/null @@ -1,219 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libredox" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" -dependencies = [ - "libc", -] - -[[package]] -name = "memchr" -version = "2.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" - -[[package]] -name = "memories" -version = "0.1.0" -dependencies = [ - "dirs", - "serde", - "serde_json", -] - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom", - "libredox", - "thiserror", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tools/memories/Cargo.toml b/tools/memories/Cargo.toml deleted file mode 100644 index f71ef79..0000000 --- a/tools/memories/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "memories" -version = "0.1.0" -edition = "2024" - -[[bin]] -name = "memories" -path = "src/main.rs" - -[dependencies] -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -dirs = "6.0" diff --git a/tools/memories/specs/delete_memory.tool.yaml b/tools/memories/specs/delete_memory.tool.yaml deleted file mode 100644 index ea05b1c..0000000 --- a/tools/memories/specs/delete_memory.tool.yaml +++ /dev/null @@ -1,15 +0,0 @@ -name: delete_memory -desc: 按ID删除指定的长期记忆。用于"忘记我住哪里""删除那条记录"等清除记忆的请求 -type: stdio -tool: memories -command: delete -path: /target/release/memories -risk_level: high -timeout_secs: 5 -env: - - IAS_MEMORY_DIR -params: - - name: memory_id - required: true - desc: 记忆ID - diff --git a/tools/memories/specs/get_memory.tool.yaml b/tools/memories/specs/get_memory.tool.yaml deleted file mode 100644 index c0cadb8..0000000 --- a/tools/memories/specs/get_memory.tool.yaml +++ /dev/null @@ -1,15 +0,0 @@ -name: get_memory -desc: 按ID查看指定的单条记忆详情。用于需要确认某条具体记忆内容的场景 -type: stdio -tool: memories -command: get -path: /target/release/memories -risk_level: low -timeout_secs: 3 -env: - - IAS_MEMORY_DIR -params: - - name: memory_id - required: true - desc: 记忆的数字ID,从 read_memories 返回结果中获取 - diff --git a/tools/memories/specs/read_memories.tool.yaml b/tools/memories/specs/read_memories.tool.yaml deleted file mode 100644 index 596a796..0000000 --- a/tools/memories/specs/read_memories.tool.yaml +++ /dev/null @@ -1,12 +0,0 @@ -name: read_memories -desc: 读取用户的所有长期记忆。返回之前记录的个人偏好、习惯、重要信息等。用于"你还记得我住哪吗""我之前说过喜欢什么"等回忆类问题 -type: stdio -tool: memories -command: list -path: /target/release/memories -risk_level: low -timeout_secs: 3 -env: - - IAS_MEMORY_DIR -params: - diff --git a/tools/memories/specs/update_memory.tool.yaml b/tools/memories/specs/update_memory.tool.yaml deleted file mode 100644 index 2220471..0000000 --- a/tools/memories/specs/update_memory.tool.yaml +++ /dev/null @@ -1,18 +0,0 @@ -name: update_memory -desc: 按ID更新指定记忆的内容。用于"我搬家了,更新地址""把我名字改成XX"等修改记忆的请求 -type: stdio -tool: memories -command: update -path: /target/release/memories -risk_level: high -timeout_secs: 5 -env: - - IAS_MEMORY_DIR -params: - - name: memory_id - required: true - desc: 记忆ID - - name: content - required: true - desc: 更新后的新内容,覆盖原记忆 - diff --git a/tools/memories/specs/write_memory.tool.yaml b/tools/memories/specs/write_memory.tool.yaml deleted file mode 100644 index aa7ea4c..0000000 --- a/tools/memories/specs/write_memory.tool.yaml +++ /dev/null @@ -1,15 +0,0 @@ -name: write_memory -desc: 记录一条新的长期记忆。当用户透露个人偏好、习惯、重要信息时使用此工具 -type: stdio -tool: memories -command: add -path: /target/release/memories -risk_level: high -timeout_secs: 5 -env: - - IAS_MEMORY_DIR -params: - - name: content - required: true - desc: 要记录的记忆内容,用自然语言描述。如"用户住在合肥包河区联投新安里A区" - diff --git a/tools/memories/src/main.rs b/tools/memories/src/main.rs deleted file mode 100644 index f1e2ba6..0000000 --- a/tools/memories/src/main.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! memories — 长期记忆管理(通过主进程 DB 代理) - -fn main() { - let input: serde_json::Value = serde_json::from_reader(std::io::stdin()).unwrap(); - let mode = std::env::args().nth(2).unwrap_or_else(|| "list".to_string()); - let params = &input["params"]; - - if input.get("db_response").is_none() { - // 发出 DB 请求 - match mode.as_str() { - "list" => println!("{}", serde_json::json!({"type":"db","operation":"read_memories","desc":"读取记忆"})), - "add" => { - let c = params["content"].as_str().unwrap_or(""); - if c.is_empty() { println!(r#"{{"type":"result","content":"缺少content"}}"#); return; } - println!("{}", serde_json::json!({"type":"db","operation":"write_memory","params":{"content":c},"desc":format!("记录: {c}")})); - } - "get" => { - let id = params["memory_id"].as_u64().unwrap_or(0); - println!("{}", serde_json::json!({"type":"db","operation":"read_memories","params":{"memory_id":id},"desc":"读取记忆"})); - } - "delete" => { - let id = params["memory_id"].as_u64().unwrap_or(0); - println!("{}", serde_json::json!({"type":"db","operation":"delete_memory","params":{"memory_id":id},"desc":format!("删除记忆 {id}")})); - } - "update" => { - let id = params["memory_id"].as_u64().unwrap_or(0); - let c = params["content"].as_str().unwrap_or(""); - println!("{}", serde_json::json!({"type":"db","operation":"update_memory","params":{"memory_id":id,"content":c},"desc":format!("更新记忆 {id}")})); - } - _ => println!(r#"{{"type":"result","content":"未知操作"}}"#), - } - } else { - // 处理 DB 响应 - let result = input["db_response"]["result"].as_str().unwrap_or(""); - println!("{}", serde_json::json!({"type":"result","content":result})); - } -} diff --git a/tools/tool_inspector/Cargo.lock b/tools/tool_inspector/Cargo.lock deleted file mode 100644 index 8d2cc94..0000000 --- a/tools/tool_inspector/Cargo.lock +++ /dev/null @@ -1,105 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "memchr" -version = "2.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tool_inspector" -version = "0.1.0" -dependencies = [ - "serde_json", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tools/tool_inspector/Cargo.toml b/tools/tool_inspector/Cargo.toml deleted file mode 100644 index ef8fb39..0000000 --- a/tools/tool_inspector/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "tool_inspector" -version = "0.1.0" -edition = "2024" - -[[bin]] -name = "tool_inspector" -path = "src/main.rs" - -[dependencies] -serde_json = "1.0" diff --git a/tools/tool_inspector/specs/tool_inspector.tool.yaml b/tools/tool_inspector/specs/tool_inspector.tool.yaml deleted file mode 100644 index aa34b70..0000000 --- a/tools/tool_inspector/specs/tool_inspector.tool.yaml +++ /dev/null @@ -1,17 +0,0 @@ -name: tool_inspector -desc: 只读查看本地工具的源码、Cargo.toml 和 spec 配置。在用 tool_manager 修改某工具前,先用本工具查看其当前实现,避免盲目覆盖丢失逻辑。低风险,无需审批 -type: stdio -tool: tool_inspector -path: /target/release/tool_inspector -risk_level: low -timeout_secs: 10 -params: - - name: action - required: true - desc: 操作类型:list(列出所有工具及文件构成)、read_tool(读取某工具的全部 Cargo.toml/specs/src 源码)、read_file(读取工具目录下单个文件) - - name: tool_name - required: false - desc: 工具名称(read_tool/read_file 必填),只允许字母、数字、下划线和短横线 - - name: file - required: false - desc: read_file 时的相对路径,如 src/main.rs、Cargo.toml、specs/weather.tool.yaml;必须是相对路径,禁止 .. 和 target 目录 diff --git a/tools/tool_inspector/src/main.rs b/tools/tool_inspector/src/main.rs deleted file mode 100644 index 14c96a7..0000000 --- a/tools/tool_inspector/src/main.rs +++ /dev/null @@ -1,270 +0,0 @@ -//! tool_inspector — 只读查看本地工具的源码与配置 -//! -//! 用途:在用 tool_manager 修改工具前,先用本工具查看当前实现,避免盲目覆盖丢失逻辑。 -//! -//! 安全约束: -//! - 纯只读:不写文件、不执行命令、不联网 -//! - tool_name 与相对路径严格校验,禁止路径穿越 -//! - 跳过 target/ 构建产物目录 -//! - 低风险(risk_level: low),无需用户审批 - -use serde_json::{Value, json}; -use std::fs; -use std::path::{Component, Path, PathBuf}; - -fn main() { - let input: Value = match serde_json::from_reader(std::io::stdin()) { - Ok(v) => v, - Err(e) => return result(&format!("读取输入失败: {e}")), - }; - let params = &input["params"]; - let action = params["action"].as_str().unwrap_or(""); - - let out = match run(action, params) { - Ok(s) => s, - Err(e) => format!("查看失败: {e}"), - }; - result(&out); -} - -fn run(action: &str, params: &Value) -> Result { - let tools_root = locate_tools_root()?; - match action { - "list" => list_tools(&tools_root), - "read_tool" => { - let name = required_str(params, "tool_name")?; - validate_tool_name(name)?; - read_tool(&tools_root, name) - } - "read_file" => { - let name = required_str(params, "tool_name")?; - validate_tool_name(name)?; - let file = required_str(params, "file")?; - read_file(&tools_root, name, file) - } - _ => Err("action 必须是 list、read_tool 或 read_file".to_string()), - } -} - -/// 列出 tools 目录下所有工具及其文件构成 -fn list_tools(tools_root: &Path) -> Result { - let mut names: Vec = Vec::new(); - for entry in fs::read_dir(tools_root).map_err(|e| e.to_string())? { - let entry = entry.map_err(|e| e.to_string())?; - let path = entry.path(); - if !path.is_dir() { - continue; - } - let name = entry.file_name().to_string_lossy().to_string(); - // 跳过产物目录与隐藏目录 - if name == "bin" || name.starts_with('.') { - continue; - } - // 只有含 specs/src/Cargo.toml 之一的目录才视为工具 - if path.join("specs").exists() - || path.join("src").exists() - || path.join("Cargo.toml").exists() - { - names.push(name); - } - } - names.sort(); - - if names.is_empty() { - return Ok("tools 目录下没有工具".into()); - } - - let mut out = format!("本地工具列表({} 个):\n", names.len()); - for n in &names { - let dir = tools_root.join(n); - let mark = |has: bool| if has { "✓" } else { "✗" }; - out.push_str(&format!( - "• {n} (src:{} specs:{} Cargo.toml:{})\n", - mark(dir.join("src").exists()), - mark(dir.join("specs").exists()), - mark(dir.join("Cargo.toml").exists()), - )); - } - out.push_str("\n用 read_tool 查看某工具的全部源码与配置,或用 read_file 读取单个文件。"); - Ok(out) -} - -/// 一次性读取某工具的 Cargo.toml + 所有 specs + src 下所有 .rs 文件 -fn read_tool(tools_root: &Path, name: &str) -> Result { - let tool_dir = tools_root.join(name); - if !tool_dir.exists() { - return Err(format!("工具 {name} 不存在")); - } - let mut out = String::new(); - - if let Ok(c) = fs::read_to_string(tool_dir.join("Cargo.toml")) { - out.push_str(&format!("===== {name}/Cargo.toml =====\n{c}\n\n")); - } - - // specs/*.tool.yaml(可能有多个文件),按文件名排序 - let specs_dir = tool_dir.join("specs"); - if specs_dir.exists() { - let mut specs: Vec = fs::read_dir(&specs_dir) - .map_err(|e| e.to_string())? - .filter_map(|e| e.ok()) - .map(|e| e.path()) - .filter(|p| matches!(p.extension().and_then(|e| e.to_str()), Some("yaml" | "yml"))) - .collect(); - specs.sort(); - for spec in specs { - let rel = spec.strip_prefix(&tool_dir).unwrap_or(&spec).display().to_string(); - match fs::read_to_string(&spec) { - Ok(c) => out.push_str(&format!("===== {name}/{rel} =====\n{c}\n\n")), - Err(e) => out.push_str(&format!("===== {name}/{rel} =====\n(读取失败: {e})\n\n")), - } - } - } - - // src 下所有 .rs 文件(递归,支持多模块工具) - let mut src_files: Vec = Vec::new(); - collect_rs_files(&tool_dir.join("src"), &mut src_files)?; - if src_files.is_empty() { - out.push_str(&format!("({name} 无 src/*.rs 文件)\n")); - } else { - src_files.sort(); - for f in src_files { - let rel = f.strip_prefix(&tool_dir).unwrap_or(&f).display().to_string(); - match fs::read_to_string(&f) { - Ok(c) => out.push_str(&format!("===== {name}/{rel} =====\n{c}\n\n")), - Err(e) => out.push_str(&format!("===== {name}/{rel} =====\n(读取失败: {e})\n\n")), - } - } - } - - if out.is_empty() { - return Err(format!("工具 {name} 没有可读的源码或配置")); - } - Ok(out.trim_end().to_string()) -} - -/// 读取工具目录下指定的单个文件(严格防路径穿越) -fn read_file(tools_root: &Path, name: &str, file: &str) -> Result { - let tool_dir = tools_root.join(name); - if !tool_dir.exists() { - return Err(format!("工具 {name} 不存在")); - } - let rel = sanitize_rel_path(file)?; - let target = tool_dir.join(&rel); - - // 规范化后必须仍在 tool_dir 内(双重防穿越) - let canonical_tool = tool_dir.canonicalize().map_err(|e| e.to_string())?; - let canonical_target = target - .canonicalize() - .map_err(|_| format!("文件不存在: {file}"))?; - if !canonical_target.starts_with(&canonical_tool) { - return Err("路径越界,禁止访问工具目录之外的文件".into()); - } - if canonical_target.is_dir() { - return Err(format!("{file} 是目录,请用 read_tool 查看或指定具体文件")); - } - let content = fs::read_to_string(&canonical_target) - .map_err(|e| format!("读取 {file} 失败: {e}"))?; - Ok(format!("===== {name}/{file} =====\n{content}")) -} - -/// 递归收集 .rs 文件,跳过隐藏目录 -fn collect_rs_files(dir: &Path, out: &mut Vec) -> Result<(), String> { - if !dir.exists() { - return Ok(()); - } - for entry in fs::read_dir(dir).map_err(|e| e.to_string())? { - let entry = entry.map_err(|e| e.to_string())?; - let path = entry.path(); - if path.is_dir() { - if !entry.file_name().to_string_lossy().starts_with('.') { - collect_rs_files(&path, out)?; - } - } else if path.extension().and_then(|e| e.to_str()) == Some("rs") { - out.push(path); - } - } - Ok(()) -} - -/// 校验相对路径:禁止绝对路径、禁止 ..、禁止 target 构建产物 -fn sanitize_rel_path(file: &str) -> Result { - if file.is_empty() { - return Err("file 不能为空".into()); - } - let p = PathBuf::from(file); - for comp in p.components() { - match comp { - Component::Normal(_) | Component::CurDir => {} - Component::ParentDir => return Err("file 禁止包含 ..".into()), - Component::RootDir | Component::Prefix(_) => { - return Err("file 必须是相对路径".into()) - } - } - } - if p.starts_with("target") { - return Err("禁止读取 target 构建产物目录".into()); - } - Ok(p) -} - -fn validate_tool_name(name: &str) -> Result<(), String> { - if name.is_empty() { - return Err("缺少 tool_name".into()); - } - if !name - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') - { - return Err("tool_name 只允许字母、数字、下划线和短横线".into()); - } - Ok(()) -} - -fn required_str<'a>(params: &'a Value, key: &str) -> Result<&'a str, String> { - params[key] - .as_str() - .filter(|s| !s.is_empty()) - .ok_or_else(|| format!("缺少参数 {key}")) -} - -/// 定位 tools 根目录。 -/// -/// 产物可能位于两处之一: -/// - `tools/bin/tool_inspector`(统一产物目录,registry 优先使用) -/// - `tools/tool_inspector/target/release/tool_inspector`(构建产物) -/// -/// 因此不依赖固定向上层数,而是从 exe 向上找第一个“含 bin/ 子目录且至少含一个 -/// 工具子目录”的祖先,即为 tools 根。对两种位置均正确。 -fn locate_tools_root() -> Result { - let exe = std::env::current_exe().map_err(|e| e.to_string())?; - let mut cur = exe.parent(); - while let Some(dir) = cur { - if dir.join("bin").is_dir() && has_tool_subdir(dir) { - return Ok(dir.to_path_buf()); - } - cur = dir.parent(); - } - Err("无法定位 tools 根目录".into()) -} - -/// 判断目录下是否存在至少一个工具子目录(含 specs/Cargo.toml/src 之一) -fn has_tool_subdir(dir: &Path) -> bool { - let Ok(entries) = fs::read_dir(dir) else { - return false; - }; - for entry in entries.flatten() { - let p = entry.path(); - if p.is_dir() - && (p.join("specs").exists() - || p.join("Cargo.toml").exists() - || p.join("src").exists()) - { - return true; - } - } - false -} - -fn result(content: &str) { - println!("{}", json!({"type":"result","content":content})); -} diff --git a/tools/tool_manager/Cargo.lock b/tools/tool_manager/Cargo.lock deleted file mode 100644 index 4840004..0000000 --- a/tools/tool_manager/Cargo.lock +++ /dev/null @@ -1,105 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "memchr" -version = "2.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tool_manager" -version = "0.1.0" -dependencies = [ - "serde_json", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tools/tool_manager/Cargo.toml b/tools/tool_manager/Cargo.toml deleted file mode 100644 index 3f62d4a..0000000 --- a/tools/tool_manager/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "tool_manager" -version = "0.1.0" -edition = "2024" - -[[bin]] -name = "tool_manager" -path = "src/main.rs" - -[dependencies] -serde_json = "1.0" diff --git a/tools/tool_manager/specs/tool_manager.tool.yaml b/tools/tool_manager/specs/tool_manager.tool.yaml deleted file mode 100644 index 104f7ee..0000000 --- a/tools/tool_manager/specs/tool_manager.tool.yaml +++ /dev/null @@ -1,36 +0,0 @@ -name: tool_manager -desc: 创建、修改和构建本地能力工具。用于给助手新增或更新 tools 目录下的 stdio Rust 工具;高风险操作,执行前需要用户确认。 -type: stdio -tool: tool_manager -path: /target/release/tool_manager -risk_level: high -timeout_secs: 300 -env: - - HOME - - CARGO_HOME - - RUSTUP_HOME -params: - - name: action - required: true - desc: 操作类型:create_tool、update_tool 或 build_tool - - name: tool_name - required: true - desc: 工具名称,只允许字母、数字、下划线和短横线 - - name: description - required: false - desc: 工具说明,创建或更新 spec 时使用 - - name: rust_code - required: false - desc: 完整的 src/main.rs Rust 源码;省略时 create_tool 会生成默认模板 - - name: params - required: false - desc: 参数列表数组,每项包含 name、required、desc - - name: risk_level - required: false - desc: 已弃用,tool_manager 创建/更新的工具一律强制 high risk,传入值将被忽略 - - name: timeout_secs - required: false - desc: 新工具超时时间秒数,默认 30 - - name: overwrite - required: false - desc: create_tool 目标已存在时是否覆盖,默认 false diff --git a/tools/tool_manager/src/main.rs b/tools/tool_manager/src/main.rs deleted file mode 100644 index a8348c8..0000000 --- a/tools/tool_manager/src/main.rs +++ /dev/null @@ -1,397 +0,0 @@ -//! tool_manager — 受控创建、修改、构建本地工具 -//! -//! 安全约束: -//! - 不允许操作保留工具名(含 `tool_manager` 自身),防止自改后门 -//! - 由本工具创建/更新的工具一律 `risk_level: high`,忽略调用方传入值 -//! - 所有调用追加写入 `tools/.tool_audit.log` 审计日志 -//! - 构建使用 `timeout` 限制时长,失败时清理残留二进制,避免调用旧版本"假成功" - -use serde_json::{Value, json}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; - -/// 保留工具名 —— 禁止 create/update/build -const RESERVED_NAMES: &[&str] = &["target", "specs", "src", ".", "..", "tool_manager"]; - -/// 构建超时(秒)。需小于 tool_manager 自身的 timeout_secs。 -const BUILD_TIMEOUT_SECS: u64 = 180; - -fn main() { - let input: Value = match serde_json::from_reader(std::io::stdin()) { - Ok(v) => v, - Err(e) => return result(&format!("读取输入失败: {e}")), - }; - - let params = &input["params"]; - let user_id = input["user_id"].as_str().unwrap_or("unknown"); - let action = params["action"].as_str().unwrap_or(""); - let tool_name = params["tool_name"].as_str().unwrap_or(""); - - let (output, success) = match run(action, tool_name, params) { - Ok(msg) => (msg, true), - Err(e) => (format!("工具管理失败: {e}"), false), - }; - - // 审计日志(best effort,不影响主流程) - if let Ok(tools_root) = locate_tools_root() { - audit(&tools_root, user_id, action, tool_name, success, &output); - } - - result(&output); -} - -fn run(action: &str, tool_name: &str, params: &Value) -> Result { - validate_tool_name(tool_name)?; - let tools_root = locate_tools_root()?; - let tool_dir = tools_root.join(tool_name); - - match action { - "create_tool" => { - let overwrite = truthy(params.get("overwrite")); - if tool_dir.exists() && !overwrite { - return Err(format!( - "工具 {tool_name} 已存在;如需覆盖,请设置 overwrite=true" - )); - } - write_tool_project(&tool_dir, tool_name, params)?; - build_tool(&tool_dir, tool_name)?; - Ok(format!( - "已创建并构建工具 {tool_name}(risk_level 强制为 high)。可通过 query_capabilities 查看并用 call_capability 调用。" - )) - } - "update_tool" => { - if !tool_dir.exists() { - return Err(format!("工具 {tool_name} 不存在,无法修改")); - } - update_tool_project(&tool_dir, tool_name, params)?; - build_tool(&tool_dir, tool_name)?; - Ok(format!( - "已修改并构建工具 {tool_name}(risk_level 强制为 high)。注册表重载后即可使用最新版本。" - )) - } - "build_tool" => { - if !tool_dir.exists() { - return Err(format!("工具 {tool_name} 不存在,无法构建")); - } - build_tool(&tool_dir, tool_name)?; - Ok(format!("已构建工具 {tool_name}。")) - } - _ => Err("action 必须是 create_tool、update_tool 或 build_tool".to_string()), - } -} - -fn write_tool_project(tool_dir: &Path, tool_name: &str, params: &Value) -> Result<(), String> { - fs::create_dir_all(tool_dir.join("src")).map_err(|e| e.to_string())?; - fs::create_dir_all(tool_dir.join("specs")).map_err(|e| e.to_string())?; - fs::write(tool_dir.join("Cargo.toml"), cargo_toml(tool_name)).map_err(|e| e.to_string())?; - fs::write(tool_dir.join("src/main.rs"), rust_code(tool_name, params)) - .map_err(|e| e.to_string())?; - fs::write( - tool_dir - .join("specs") - .join(format!("{tool_name}.tool.yaml")), - spec_yaml(tool_name, params), - ) - .map_err(|e| e.to_string())?; - Ok(()) -} - -fn update_tool_project(tool_dir: &Path, tool_name: &str, params: &Value) -> Result<(), String> { - if let Some(code) = params["rust_code"].as_str() { - fs::write(tool_dir.join("src/main.rs"), code).map_err(|e| e.to_string())?; - } - - // spec 一旦由 tool_manager 管理,risk_level 恒为 high - let should_update_spec = params.get("description").is_some() - || params.get("params").is_some() - || params.get("risk_level").is_some() - || params.get("timeout_secs").is_some(); - if should_update_spec { - fs::create_dir_all(tool_dir.join("specs")).map_err(|e| e.to_string())?; - fs::write( - tool_dir - .join("specs") - .join(format!("{tool_name}.tool.yaml")), - spec_yaml(tool_name, params), - ) - .map_err(|e| e.to_string())?; - } - Ok(()) -} - -fn build_tool(tool_dir: &Path, tool_name: &str) -> Result<(), String> { - let binary = tool_dir.join("target/release").join(tool_name); - let use_builder = std::env::var("IAS_DOCKER_BUILDER") - .ok() - .as_deref() - == Some("1"); - let (uid, gid) = current_uid_gid(); - - // 启用 builder 容器时在隔离环境内离线编译,挂载 tool_dir 到 /work,产物写回宿主; - // 否则本地 cargo(受 timeout 限制) - let output = if use_builder { - Command::new("docker") - .arg("run").arg("--rm") - .arg("--network").arg("none") - .arg("--memory").arg("1g") - .arg("--cpus").arg("1") - .arg("--pids-limit").arg("128") - .arg("--cap-drop").arg("ALL") - .arg("--security-opt").arg("no-new-privileges") - .arg("--user").arg(format!("{}:{}", uid, gid)) - .arg("-v").arg(format!("{}:/work", tool_dir.display())) - .arg("-w").arg("/work") - .arg("-e").arg("CARGO_NET_OFFLINE=true") - .arg("-e").arg("CARGO_HOME=/tmp/cargo") - .arg("ias-tool-builder") - .arg("timeout").arg(BUILD_TIMEOUT_SECS.to_string()) - .arg("cargo").arg("build").arg("--release").arg("--offline") - .output() - } else { - Command::new("timeout") - .arg(BUILD_TIMEOUT_SECS.to_string()) - .arg("cargo") - .arg("build") - .arg("--release") - .current_dir(tool_dir) - .output() - } - .map_err(|e| format!("启动构建失败: {e}"))?; - - if !output.status.success() { - // 构建失败:删除可能残留的旧二进制,避免 reload 后仍调用旧版本造成"假成功" - let _ = fs::remove_file(&binary); - let code = output.status.code().unwrap_or(-1); - let stderr = String::from_utf8_lossy(&output.stderr); - let reason = if code == 124 { - format!("构建超时({BUILD_TIMEOUT_SECS}s)") - } else { - stderr.trim().to_string() - }; - return Err(format!("cargo build 失败(退出码 {code}): {reason}")); - } - - if !binary.exists() { - return Err(format!("构建后未找到二进制: {}", binary.display())); - } - - // 复制到统一产物目录 tools/bin/,供长期沙箱容器挂载使用 - let tools_root = tool_dir - .parent() - .ok_or_else(|| "无法定位 tools 根目录".to_string())?; - let bin_dir = tools_root.join("bin"); - fs::create_dir_all(&bin_dir).map_err(|e| format!("创建产物目录失败: {e}"))?; - fs::copy(&binary, bin_dir.join(tool_name)) - .map_err(|e| format!("复制产物到 bin 失败: {e}"))?; - - Ok(()) -} - -/// 定位 tools 根目录。 -/// -/// 产物可能位于两处之一: -/// - `tools/bin/tool_manager`(统一产物目录,registry 优先使用) -/// - `tools/tool_manager/target/release/tool_manager`(构建产物) -/// -/// 因此不依赖固定向上层数,而是从 exe 向上找第一个“含 bin/ 子目录且至少含一个 -/// 工具子目录”的祖先,即为 tools 根。对两种位置均正确。 -fn locate_tools_root() -> Result { - let exe = std::env::current_exe().map_err(|e| e.to_string())?; - let mut cur = exe.parent(); - while let Some(dir) = cur { - if dir.join("bin").is_dir() && has_tool_subdir(dir) { - return Ok(dir.to_path_buf()); - } - cur = dir.parent(); - } - Err("无法定位 tools 根目录".into()) -} - -/// 判断目录下是否存在至少一个工具子目录(含 specs/Cargo.toml/src 之一) -fn has_tool_subdir(dir: &Path) -> bool { - let Ok(entries) = fs::read_dir(dir) else { - return false; - }; - for entry in entries.flatten() { - let p = entry.path(); - if p.is_dir() - && (p.join("specs").exists() - || p.join("Cargo.toml").exists() - || p.join("src").exists()) - { - return true; - } - } - false -} - -fn cargo_toml(tool_name: &str) -> String { - format!( - r#"[package] -name = "{tool_name}" -version = "0.1.0" -edition = "2024" - -[[bin]] -name = "{tool_name}" -path = "src/main.rs" - -[dependencies] -serde_json = "1.0" -"# - ) -} - -fn rust_code(tool_name: &str, params: &Value) -> String { - params["rust_code"] - .as_str() - .map(str::to_string) - .unwrap_or_else(|| default_rust_code(tool_name)) -} - -fn default_rust_code(tool_name: &str) -> String { - format!( - r#"//! {tool_name} — generated tool - -fn main() {{ - let input: serde_json::Value = serde_json::from_reader(std::io::stdin()).unwrap_or_default(); - let content = format!("工具 {tool_name} 已收到参数: {{}}", input["params"]); - println!("{{}}", serde_json::json!({{"type":"result","content":content}})); -}} -"# - ) -} - -fn spec_yaml(tool_name: &str, params: &Value) -> String { - // 安全约束:tool_manager 产出的工具一律高风险,忽略调用方传入的 risk_level - let risk_level = "high"; - let timeout_secs = params["timeout_secs"].as_u64().unwrap_or(30).clamp(1, 300); - - let desc = params["description"] - .as_str() - .unwrap_or("由 tool_manager 创建的本地工具(自动标记 high risk)"); - - let mut out = format!( - "name: {tool_name}\n\ - desc: {}\n\ - type: stdio\n\ - tool: {tool_name}\n\ - path: /target/release/{tool_name}\n\ - risk_level: {risk_level}\n\ - timeout_secs: {timeout_secs}\n", - yaml_scalar(desc) - ); - - if let Some(items) = params["params"].as_array() { - out.push_str("params:\n"); - for item in items { - let Some(name) = item["name"].as_str() else { - continue; - }; - if validate_param_name(name).is_err() { - continue; - } - let required = item["required"].as_bool().unwrap_or(false); - let desc = item["desc"].as_str().unwrap_or(""); - out.push_str(&format!( - " - name: {name}\n required: {required}\n desc: {}\n", - yaml_scalar(desc) - )); - } - } else { - out.push_str("params: []\n"); - } - - // 安全约束:tool_manager 产出的工具不受信任(含 LLM 提供的 rust_code), - // 默认进 stdio 沙箱容器(无网络、只读根),防止数据外泄/越权文件读 - out.push_str("sandbox:\n profile: stdio\n"); - - out -} - -fn validate_tool_name(name: &str) -> Result<(), String> { - if name.is_empty() { - return Err("缺少 tool_name".to_string()); - } - if RESERVED_NAMES.contains(&name) { - return Err(format!( - "tool_name '{name}' 是保留名称,禁止 create/update/build" - )); - } - if !name - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') - { - return Err("tool_name 只允许字母、数字、下划线和短横线".to_string()); - } - Ok(()) -} - -fn validate_param_name(name: &str) -> Result<(), String> { - if name.is_empty() - || !name - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') - { - return Err("参数名非法".to_string()); - } - Ok(()) -} - -fn yaml_scalar(value: &str) -> String { - serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string()) -} - -fn truthy(value: Option<&Value>) -> bool { - match value { - Some(Value::Bool(v)) => *v, - Some(Value::String(v)) => matches!(v.as_str(), "true" | "1" | "yes" | "是"), - _ => false, - } -} - -/// 获取当前进程的 uid:gid,用于 builder 容器 --user,使容器内进程能写 bind mount 的宿主目录 -fn current_uid_gid() -> (String, String) { - let uid = String::from_utf8_lossy( - &Command::new("id").arg("-u").output().map(|o| o.stdout).unwrap_or_default(), - ) - .trim() - .to_string(); - let gid = String::from_utf8_lossy( - &Command::new("id").arg("-g").output().map(|o| o.stdout).unwrap_or_default(), - ) - .trim() - .to_string(); - (uid, gid) -} - -/// 追加审计日志到 tools 根目录下的 .tool_audit.log -fn audit( - tools_root: &Path, - user_id: &str, - action: &str, - tool_name: &str, - success: bool, - detail: &str, -) { - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - // detail 可能很长,截断到 200 字节避免日志膨胀 - let detail: String = detail.chars().take(200).collect(); - let line = format!( - "{ts}\t{user_id}\t{action}\t{tool_name}\t{}\t{detail}\n", - if success { "ok" } else { "err" } - ); - let path = tools_root.join(".tool_audit.log"); - if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&path) { - use std::io::Write; - let _ = f.write_all(line.as_bytes()); - } -} - -fn result(content: &str) { - println!("{}", json!({"type":"result","content":content})); -} diff --git a/tools/weather/Cargo.lock b/tools/weather/Cargo.lock deleted file mode 100644 index eb8ad2a..0000000 --- a/tools/weather/Cargo.lock +++ /dev/null @@ -1,105 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "memchr" -version = "2.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "weather" -version = "0.1.0" -dependencies = [ - "serde_json", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tools/weather/Cargo.toml b/tools/weather/Cargo.toml deleted file mode 100644 index 24a6af9..0000000 --- a/tools/weather/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "weather" -version = "0.1.0" -edition = "2024" - -[[bin]] -name = "weather" -path = "src/main.rs" - -[dependencies] -serde_json = "1.0" diff --git a/tools/weather/specs/weather_forecast.tool.yaml b/tools/weather/specs/weather_forecast.tool.yaml deleted file mode 100644 index 45051c7..0000000 --- a/tools/weather/specs/weather_forecast.tool.yaml +++ /dev/null @@ -1,15 +0,0 @@ -name: weather_forecast -desc: 查询指定城市未来7天的每日天气预报(白天/夜间天气、最高/最低温度)。用于"明天天气""这周天气"等问题 -type: http -tool: weather -command: forecast -path: /target/release/weather -risk_level: low -timeout_secs: 10 -env: - - QWEATHER_KEY -params: - - name: location - required: true - desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市 - diff --git a/tools/weather/specs/weather_hourly.tool.yaml b/tools/weather/specs/weather_hourly.tool.yaml deleted file mode 100644 index c60cca5..0000000 --- a/tools/weather/specs/weather_hourly.tool.yaml +++ /dev/null @@ -1,15 +0,0 @@ -name: weather_hourly -desc: 查询指定城市未来24小时逐小时天气(温度、天气状况、风力、降水概率)。用于"今晚会下雨吗""下午几点最热"等精确时间天气问题 -type: http -tool: weather -command: hourly -path: /target/release/weather -risk_level: low -timeout_secs: 10 -env: - - QWEATHER_KEY -params: - - name: location - required: true - desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市 - diff --git a/tools/weather/specs/weather_now.tool.yaml b/tools/weather/specs/weather_now.tool.yaml deleted file mode 100644 index 1331ab5..0000000 --- a/tools/weather/specs/weather_now.tool.yaml +++ /dev/null @@ -1,15 +0,0 @@ -name: weather_now -desc: 查询指定城市的实时天气(温度、体感温度、风向风力、湿度、气压、能见度)。用于回答"今天天气怎么样""北京多少度"等即时天气问题 -type: http -tool: weather -command: now -path: /target/release/weather -risk_level: low -timeout_secs: 10 -env: - - QWEATHER_KEY -params: - - name: location - required: true - desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市 - diff --git a/tools/weather/src/main.rs b/tools/weather/src/main.rs deleted file mode 100644 index 3ea2a13..0000000 --- a/tools/weather/src/main.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! weather — 和风天气 - -const HOST: &str = "https://devapi.qweather.com/v7"; - -fn main() { - let input: serde_json::Value = serde_json::from_reader(std::io::stdin()).unwrap(); - let mode = std::env::args().nth(2).unwrap_or_else(|| "now".to_string()); - let loc = input["params"]["location"].as_str().unwrap_or("北京"); - let key = match std::env::var("QWEATHER_KEY") { - Ok(k) if !k.is_empty() => k, - _ => { - println!(r#"{{"type":"result","content":"错误: 缺少环境变量 QWEATHER_KEY(和风天气 API Key),请在 .env 中设置"}}"#); - return; - } - }; - - if input.get("response").is_none() && input.get("db_response").is_none() { - let (path, desc) = match mode.as_str() { - "now" => ("weather/now", "实时天气"), - "forecast" => ("weather/7d", "7天预报"), - "hourly" => ("weather/24h", "24h逐时"), - _ => { println!(r#"{{"type":"result","content":"未知模式"}}"#); return; } - }; - println!("{}", serde_json::json!({ - "type":"http","method":"GET", - "url":format!("{HOST}/{path}?location={loc}&key={key}"), - "desc":desc - })); - } else { - let body = &input["response"]["body"]; - let ok = body["code"].as_str() == Some("200"); - let content = if !ok { "查询失败".into() } else { match mode.as_str() { - "now" => format!("{} {}°C(体感{}°C) {}风{}级 湿度{}%", - body["now"]["text"].as_str().unwrap_or("?"), body["now"]["temp"].as_str().unwrap_or("?"), - body["now"]["feelsLike"].as_str().unwrap_or("?"), body["now"]["windDir"].as_str().unwrap_or("?"), - body["now"]["windScale"].as_str().unwrap_or("?"), body["now"]["humidity"].as_str().unwrap_or("?")), - "forecast" => body["daily"].as_array().unwrap_or(&vec![]).iter().take(7) - .map(|d| format!("{}: {}~{}°C", d["fxDate"].as_str().unwrap_or(""), - d["tempMin"].as_str().unwrap_or(""), d["tempMax"].as_str().unwrap_or(""))) - .collect::>().join("\n"), - "hourly" => body["hourly"].as_array().unwrap_or(&vec![]).iter().take(12) - .map(|h| format!("{}: {} {}°C", h["fxTime"].as_str().unwrap_or(""), - h["text"].as_str().unwrap_or(""), h["temp"].as_str().unwrap_or(""))) - .collect::>().join("\n"), - _ => String::new(), - }}; - println!("{}", serde_json::json!({"type":"result","content":content})); - } -} diff --git a/tools/web_search/Cargo.lock b/tools/web_search/Cargo.lock deleted file mode 100644 index 72e005f..0000000 --- a/tools/web_search/Cargo.lock +++ /dev/null @@ -1,105 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "memchr" -version = "2.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "web_search" -version = "0.1.0" -dependencies = [ - "serde_json", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tools/web_search/Cargo.toml b/tools/web_search/Cargo.toml deleted file mode 100644 index 29faaa8..0000000 --- a/tools/web_search/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "web_search" -version = "0.1.0" -edition = "2024" - -[[bin]] -name = "web_search" -path = "src/main.rs" - -[dependencies] -serde_json = "1.0" diff --git a/tools/web_search/specs/web_search.tool.yaml b/tools/web_search/specs/web_search.tool.yaml deleted file mode 100644 index d4f2dda..0000000 --- a/tools/web_search/specs/web_search.tool.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: web_search -desc: 联网搜索互联网最新信息。支持 AI 摘要,返回标题、URL和内容摘要。用于查询实时资讯、最新事实、热点新闻等。需要 TAVILY_API_KEY -type: http -tool: web_search -path: /target/release/web_search -risk_level: low -timeout_secs: 30 -env: - - TAVILY_API_KEY -params: - - name: query - required: true - desc: 搜索关键词,如"Rust 2024 edition 新特性"、"今天比特币价格" - - name: max_results - desc: 返回的最大搜索结果数量(1-20),默认5条 - diff --git a/tools/web_search/src/main.rs b/tools/web_search/src/main.rs deleted file mode 100644 index 8cde1bd..0000000 --- a/tools/web_search/src/main.rs +++ /dev/null @@ -1,22 +0,0 @@ -fn main() { - let input: serde_json::Value = serde_json::from_reader(std::io::stdin()).unwrap(); - if input.get("response").is_none() && input.get("db_response").is_none() { - let q = input["params"]["query"].as_str().unwrap_or(""); - if q.is_empty() { println!(r#"{{"type":"result","content":"缺少query"}}"#); return; } - let n = input["params"]["max_results"].as_u64().unwrap_or(5); - let body = serde_json::json!({"query":q,"max_results":n,"include_answer":true}); - println!("{}", serde_json::json!({ - "type":"http","method":"POST","url":"https://api.tavily.com/search", - "body":body.to_string(),"desc":format!("搜索: {q}") - })); - } else { - let b = &input["response"]["body"]; - let mut o = String::new(); - if let Some(a) = b["answer"].as_str() { o.push_str(&format!("📝 {a}\n\n")); } - for (i,r) in b["results"].as_array().unwrap_or(&vec![]).iter().enumerate() { - o.push_str(&format!("{}. {} - {}\n", i+1, r["title"].as_str().unwrap_or(""), r["url"].as_str().unwrap_or(""))); - } - if o.is_empty() { o = "未找到结果".into(); } - println!("{}", serde_json::json!({"type":"result","content":o})); - } -} diff --git a/wechat/Cargo.toml b/wechat/Cargo.toml new file mode 100644 index 0000000..02a7e6b --- /dev/null +++ b/wechat/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "wechat" +version = "0.1.0" +edition = "2024" + +[dependencies] +# ── 加密与签名 ── +base64 = "0.22" # Base64 编码 +sha2 = "0.11.0" # SHA-256 哈希(审批码) +rand = "0.10.1" # 随机数 +# ── 异步运行时 ── +tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "sync", "time", "process", "signal", "io-util"] } # 异步运行时核心 +# ── HTTP 客户端 ── +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } # HTTP 请求(微信 API + DeepSeek API + 工具调用) +# ── 序列化 ── +serde = { version = "1.0", features = ["derive"] } # 序列化/反序列化 +serde_json = "1.0" # JSON 处理 +serde_yaml = "0.9" # YAML 解析(工具规范文件) +# ── 日志 ── +tracing = "0.1" # 结构化日志 +tracing-subscriber = { version = "0.3", features = ["env-filter"] } # 日志输出 +tracing-appender = "0.2.5" # 文件日志(日滚) +# ── 二维码 ── +qrcode = { version = "0.14", default-features = false } +# ── UUID ── +uuid = { version = "1", features = ["v4", "serde"] } # 唯一标识符 # 二维码生成(仅 Unicode 终端渲染) \ No newline at end of file diff --git a/wechat/README.md b/wechat/README.md new file mode 100644 index 0000000..f6e4c0e --- /dev/null +++ b/wechat/README.md @@ -0,0 +1,492 @@ +# wechat 模块使用文档 + +`wechat` 是一个封装微信 iLink Bot API 的 Rust crate,负责微信 Bot 的扫码登录、监听器生命周期管理、长轮询收消息和文本消息发送。 + +当前模块只提供库能力,不会自动启动后台任务,也不会自动持久化登录态。业务服务需要主动创建 `WeChatClient`、完成登录或恢复登录态,然后调用收发消息接口。 + +## 模块结构 + +```text +wechat/ + src/ + lib.rs # 模块导出 + client.rs # WeChatClient,封装所有 HTTP API 调用 + types.rs # iLink Bot API 请求、响应和消息结构 +``` + +公开模块: + +```rust +pub mod client; +pub mod types; +``` + +常用类型: + +```rust +use wechat::client::WeChatClient; +use wechat::types::{GetUpdatesResp, LoginResult, MessageItem, WeixinMessage}; +``` + +## Cargo 引用 + +本仓库根目录已经把 `wechat` 加入 workspace: + +```toml +[workspace] +members = ["service", "ai", "common", "wechat"] +``` + +如果其他 workspace crate 需要使用它,在对应 `Cargo.toml` 里加入: + +```toml +[dependencies] +wechat = { path = "../wechat" } +``` + +## 环境变量 + +| 变量 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `WEIXIN_BASE_URL` | 否 | `https://ilinkai.weixin.qq.com` | iLink Bot API 地址。测试、代理或私有部署时可覆盖。 | + +示例: + +```bash +export WEIXIN_BASE_URL="https://ilinkai.weixin.qq.com" +``` + +## 基本生命周期 + +推荐调用顺序: + +1. 创建客户端:`WeChatClient::new(None)` +2. 扫码登录:`login(...)` +3. 持久化登录结果:保存 `token`、`account_id`、`base_url` +4. 注册监听器:`notify_start()` +5. 循环拉取消息:`receive_messages()` +6. 处理用户消息并回复:`send_text(...)` +7. 服务退出前注销监听器:`notify_stop()` + +## 快速开始 + +下面示例会扫码登录,注册监听器,然后持续接收用户文本消息并原样回复。 + +```rust +use std::time::Duration; + +use wechat::client::WeChatClient; +use wechat::types::WeixinMessage; + +#[tokio::main] +async fn main() -> Result<(), String> { + let client = WeChatClient::new(None); + + let login = client + .login( + |qrcode_url| { + println!("二维码地址: {qrcode_url}"); + }, + 120, + ) + .await?; + + println!("登录成功: account_id={}", login.account_id); + + client.notify_start().await?; + + loop { + let updates = client.receive_messages().await?; + + for msg in updates.msgs { + if msg.msg_type != Some(WeixinMessage::TYPE_USER) { + continue; + } + + let Some(from_user_id) = msg.from_user_id.as_deref() else { + continue; + }; + + let Some(text) = msg.text_content() else { + continue; + }; + + client + .send_text(from_user_id, &format!("收到: {text}"), msg.context_token.as_deref()) + .await?; + } + + tokio::time::sleep(Duration::from_secs(1)).await; + } +} +``` + +## 登录 + +完整登录接口: + +```rust +pub async fn login( + &self, + on_qrcode: impl Fn(&str), + timeout_secs: u64, +) -> Result +``` + +参数说明: + +| 参数 | 说明 | +| --- | --- | +| `on_qrcode` | 获取二维码内容后的回调。当前实现会传入 `qrcode_img_content`,通常是可打开或可展示为二维码的内容。 | +| `timeout_secs` | 扫码确认总超时时间,单位秒。 | + +返回值: + +```rust +pub struct LoginResult { + pub token: String, + pub account_id: String, + pub base_url: String, + pub user_id: String, +} +``` + +登录内部流程: + +1. `get_qrcode()` 调用 `/ilink/bot/get_bot_qrcode` 获取二维码。 +2. 终端渲染二维码,同时打印二维码链接。 +3. `poll_qr_status()` 轮询 `/ilink/bot/get_qrcode_status`。 +4. 用户扫码并确认后,把 `bot_token` 和 `ilink_bot_id` 写入客户端内存。 +5. 返回 `LoginResult`,供业务层持久化。 + +扫码状态值: + +| 状态 | 含义 | 当前处理 | +| --- | --- | --- | +| `wait` | 等待扫码 | 继续轮询 | +| `scaned` | 已扫码,等待微信确认 | 提示用户确认 | +| `scaned_but_redirect` | 需要切换 IDC | 使用 `redirect_host` 更新轮询地址 | +| `confirmed` | 登录成功 | 保存 token/account_id 并返回 | +| `expired` | 二维码过期 | 返回错误 | + +## 恢复登录态 + +`wechat` 模块不负责把登录态写入数据库或文件。业务层应在登录成功后保存: + +- `LoginResult.token` +- `LoginResult.account_id` +- `LoginResult.base_url` +- `get_updates_buf()` 返回值,建议在每次 `receive_messages()` 成功后保存 + +下次启动时恢复: + +```rust +let mut client = WeChatClient::new(None); + +client + .set_auth( + "持久化保存的 token", + "持久化保存的 account_id", + "持久化保存的 base_url", + ) + .await; + +client + .set_updates_buf("持久化保存的 get_updates_buf") + .await; + +client.notify_start().await?; +``` + +注意:`set_auth` 需要 `&mut self`,因此恢复登录态时变量要声明为 `mut`。 + +## 注册和注销监听器 + +注册监听器: + +```rust +client.notify_start().await?; +``` + +注销监听器: + +```rust +client.notify_stop().await?; +``` + +这两个接口分别调用: + +- `POST /ilink/bot/msg/notifystart` +- `POST /ilink/bot/msg/notifystop` + +建议: + +- 登录或恢复登录态后先调用 `notify_start()`,再开始拉消息。 +- 进程收到退出信号时调用 `notify_stop()`。 +- 如果进程异常退出,下一次启动后仍应重新调用 `notify_start()`。 + +## 接收消息 + +接口: + +```rust +pub async fn receive_messages(&self) -> Result +``` + +响应结构: + +```rust +pub struct GetUpdatesResp { + pub ret: i32, + pub errcode: Option, + pub errmsg: Option, + pub msgs: Vec, + pub get_updates_buf: String, + pub longpolling_timeout_ms: Option, +} +``` + +说明: + +- 该接口调用 `/ilink/bot/getupdates`。 +- 客户端内部维护 `updates_buf`,并在响应里的 `get_updates_buf` 非空时自动更新。 +- 如果请求超时,当前实现返回一个空消息列表,不视为致命错误。 +- 如果 `ret != 0` 或 `errcode != 0`,当前实现会写 warn 日志,但仍把响应返回给调用方。 + +文本消息提取: + +```rust +for msg in updates.msgs { + if msg.msg_type == Some(WeixinMessage::TYPE_USER) { + if let Some(text) = msg.text_content() { + println!("收到文本: {text}"); + } + } +} +``` + +消息关键字段: + +| 字段 | 说明 | +| --- | --- | +| `msg_type` | `1` 表示用户消息,`2` 表示 Bot 消息。 | +| `from_user_id` | 发送者 ID。回复私聊消息时通常作为 `send_text` 的 `to`。 | +| `to_user_id` | 接收者 ID。 | +| `group_id` | 群消息 ID,群聊场景可能非空。 | +| `item_list` | 消息内容列表,可包含文本、图片、语音、文件、视频。 | +| `context_token` | 微信上下文令牌。回复时透传可保持多轮上下文。 | + +## 发送文本消息 + +接口: + +```rust +pub async fn send_text( + &self, + to: &str, + text: &str, + context_token: Option<&str>, +) -> Result +``` + +参数说明: + +| 参数 | 说明 | +| --- | --- | +| `to` | 接收者用户 ID。通常来自收到消息的 `from_user_id`。 | +| `text` | 要发送的文本内容。 | +| `context_token` | 可选上下文令牌,建议透传收到消息里的 `context_token`。 | + +返回值是本地生成的 `client_id`,可用于日志追踪。 + +示例: + +```rust +let client_id = client + .send_text( + from_user_id, + "你好,我是 Bot", + msg.context_token.as_deref(), + ) + .await?; + +println!("消息已提交: client_id={client_id}"); +``` + +当前发送实现固定构造: + +- `message_type = 2`,表示 Bot 消息 +- `message_state = 2`,表示完成态 +- `item_list = [MessageItem::text(text)]` + +## 非文本消息 + +协议类型已经定义了以下消息 item: + +- `TextItem` +- `ImageItem` +- `VoiceItem` +- `FileItem` +- `VideoItem` + +当前便捷发送接口只实现了 `send_text()`。如果需要发送图片、文件或视频,需要新增对应的发送方法,构造 `WeixinMessage.item_list` 中的 `MessageItem`。 + +接收侧可以通过 `MessageItem.item_type` 判断类型: + +```rust +for item in msg.item_list.unwrap_or_default() { + match item.item_type { + Some(MessageItem::TYPE_TEXT) => { + let text = item.text_item.and_then(|v| v.text); + println!("文本: {:?}", text); + } + Some(MessageItem::TYPE_IMAGE) => { + println!("图片: {:?}", item.image_item); + } + Some(MessageItem::TYPE_VOICE) => { + println!("语音: {:?}", item.voice_item); + } + Some(MessageItem::TYPE_FILE) => { + println!("文件: {:?}", item.file_item); + } + Some(MessageItem::TYPE_VIDEO) => { + println!("视频: {:?}", item.video_item); + } + _ => {} + } +} +``` + +## 长轮询循环建议 + +推荐业务层把接收循环放到独立 tokio task: + +```rust +let client_for_loop = client.clone(); + +tokio::spawn(async move { + loop { + match client_for_loop.receive_messages().await { + Ok(updates) => { + for msg in updates.msgs { + // 处理消息 + } + } + Err(err) => { + tracing::warn!("接收微信消息失败: {err}"); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } + } + } +}); +``` + +注意: + +- `WeChatClient` 实现了 `Clone`,内部 token、account_id 和 updates_buf 使用 `Arc>` 共享。 +- `receive_messages()` 已经处理普通超时,业务层不需要把空消息视为错误。 +- 处理消息时建议过滤 `WeixinMessage::TYPE_BOT`,避免重复处理 Bot 自己发出的消息。 +- 每次成功拉取后可以调用 `get_updates_buf()` 并持久化,降低重启后重复收消息的概率。 + +## 与 AI 回复集成示例 + +示例逻辑:收到用户文本后调用业务自己的 AI 函数,再把结果发回微信。 + +```rust +async fn handle_wechat_message(client: &WeChatClient, msg: WeixinMessage) -> Result<(), String> { + if msg.msg_type != Some(WeixinMessage::TYPE_USER) { + return Ok(()); + } + + let Some(from_user_id) = msg.from_user_id.as_deref() else { + return Ok(()); + }; + + let Some(user_text) = msg.text_content() else { + return Ok(()); + }; + + let answer = call_ai(user_text).await?; + + client + .send_text(from_user_id, &answer, msg.context_token.as_deref()) + .await?; + + Ok(()) +} + +async fn call_ai(input: &str) -> Result { + Ok(format!("AI 回复: {input}")) +} +``` + +## API 端点 + +`WeChatClient` 当前使用以下 iLink Bot API: + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| `GET` | `/ilink/bot/get_bot_qrcode` | 获取扫码登录二维码 | +| `GET` | `/ilink/bot/get_qrcode_status` | 查询扫码状态 | +| `POST` | `/ilink/bot/msg/notifystart` | 注册监听器 | +| `POST` | `/ilink/bot/msg/notifystop` | 注销监听器 | +| `POST` | `/ilink/bot/getupdates` | 长轮询拉取消息 | +| `POST` | `/ilink/bot/sendmessage` | 发送消息 | + +请求头由模块内部生成: + +- `Content-Type: application/json` +- `Content-Length` +- `iLink-App-ClientVersion` +- `X-WECHAT-UIN` +- 登录后请求额外带: + - `Authorization: Bearer ` + - `AuthorizationType: ilink_bot_token` + +## 日志 + +模块使用 `tracing` 输出日志,主要 target 为: + +```text +ias::auth +``` + +业务程序可初始化日志: + +```rust +tracing_subscriber::fmt() + .with_env_filter("info,ias::auth=debug") + .init(); +``` + +## 错误处理 + +所有公开异步方法当前返回 `Result<_, String>`。 + +常见错误: + +| 场景 | 典型错误 | 处理建议 | +| --- | --- | --- | +| 二维码过期 | `二维码已过期` | 重新调用 `login()`。 | +| 登录超时 | `登录超时` | 增大 `timeout_secs` 或重新登录。 | +| token 无效 | HTTP 401/403 或接口返回错误 | 清理持久化登录态,重新扫码登录。 | +| 长轮询超时 | 返回空 `msgs` | 正常情况,继续下一轮。 | +| 网络错误 | `请求失败` | 延迟重试,必要时检查 `WEIXIN_BASE_URL`。 | + +## 当前限制 + +- `ILINK_APP_ID` 当前是空字符串。如接入方要求 app id,需要在 `client.rs` 中配置或改造成环境变量。 +- 登录态只保存在内存中,重启恢复需要业务层调用 `set_auth()` 和 `set_updates_buf()`。 +- 只提供文本发送便捷方法 `send_text()`。 +- 群聊回复策略需要业务层结合 `group_id` 和实际 iLink 协议进一步确认。 +- `send_text()` 当前不解析服务端响应体,只在 HTTP 层确认请求成功后返回本地 `client_id`。 + +## 排障清单 + +1. 确认能访问 `WEIXIN_BASE_URL`。 +2. 确认扫码后在微信侧完成确认。 +3. 登录后先调用 `notify_start()`,再调用 `receive_messages()`。 +4. 如果收不到消息,检查 `get_updates_buf` 是否恢复了过旧状态;必要时清空后重试。 +5. 如果发送失败,检查 token 是否过期,以及 `to` 是否使用了正确的 `from_user_id`。 +6. 打开 `ias::auth=debug` 日志观察 API 请求和状态变化。 + diff --git a/src/wechat/client.rs b/wechat/src/client.rs similarity index 99% rename from src/wechat/client.rs rename to wechat/src/client.rs index b183d4a..5481f33 100644 --- a/src/wechat/client.rs +++ b/wechat/src/client.rs @@ -14,7 +14,7 @@ //! ### 环境变量 //! - `WEIXIN_BASE_URL` — API 地址(默认 `https://ilinkai.weixin.qq.com`) -use crate::wechat::types::*; +use crate::types::*; use base64::Engine; use reqwest::Client as HttpClient; use std::sync::Arc; diff --git a/src/wechat/mod.rs b/wechat/src/lib.rs similarity index 97% rename from src/wechat/mod.rs rename to wechat/src/lib.rs index 6e1bf55..2e0b64e 100644 --- a/src/wechat/mod.rs +++ b/wechat/src/lib.rs @@ -13,4 +13,4 @@ //! 5. `notify_stop()` — 注销监听器 pub mod client; -pub mod types; +pub mod types; \ No newline at end of file diff --git a/src/wechat/types.rs b/wechat/src/types.rs similarity index 100% rename from src/wechat/types.rs rename to wechat/src/types.rs