diff --git a/.gitignore b/.gitignore index 05d3b8c..fc62fbf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Rust 构建产物 /target +tools/*/target/ # 运行时数据(状态文件、缓存、聊天记录本地副本等) .data/ diff --git a/Cargo.lock b/Cargo.lock index e8544b3..d3c40c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -926,10 +926,12 @@ dependencies = [ "futures-util", "qrcode", "rand 0.9.4", + "regex", "reqwest", "scraper", "serde", "serde_json", + "serde_yaml", "sha2", "sqlx", "tokio", @@ -1676,6 +1678,18 @@ dependencies = [ "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" @@ -1931,6 +1945,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "servo_arc" version = "0.4.3" @@ -2661,6 +2688,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 59c6b80..8ad320c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "str # ── 序列化 ── serde = { version = "1.0", features = ["derive"] } # 序列化/反序列化 serde_json = "1.0" # JSON 处理 +serde_yaml = "0.9" # YAML 解析(工具规范文件) # ── 环境变量加载 ── dotenvy = "0.15" # .env 文件加载 # ── CLI 参数解析 ── @@ -42,6 +43,8 @@ sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uui dirs = "6.0.0" # 系统目录路径 # ── HTML 解析 ── scraper = "0.27.0" # HTML 解析(fetch_page 工具) +# ── 正则表达式 ── +regex = "1.0" # 代码块解析 [[bin]] name = "ias" diff --git a/docs/TOOL_PLATFORM_PLAN.md b/docs/TOOL_PLATFORM_PLAN.md new file mode 100644 index 0000000..d7a02ae --- /dev/null +++ b/docs/TOOL_PLATFORM_PLAN.md @@ -0,0 +1,115 @@ +# 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 new file mode 100644 index 0000000..2a773d2 --- /dev/null +++ b/docs/TOOL_PLATFORM_PLAN_IMPL.md @@ -0,0 +1,96 @@ +# 实现细节 + +> 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/src/channel/mod.rs b/src/channel/mod.rs index fb4e487..8ab4dc9 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -39,7 +39,10 @@ pub struct ChannelId { impl ChannelId { /// 快捷构造微信渠道 pub fn wechat(user_id: impl Into) -> Self { - Self { platform: "wechat".into(), user_id: user_id.into() } + Self { + platform: "wechat".into(), + user_id: user_id.into(), + } } } diff --git a/src/cli.rs b/src/cli.rs index 51e7562..3165b3d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -124,14 +124,29 @@ pub enum Commands { /// 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( - subcommand, - after_help = "示例:\n ias tool datetime\n ias tool weather 北京\n ias tool weather 上海 --days 7\n ias tool search \"Rust 2024 edition\"\n ias tool fetch https://example.com/article\n ias tool memos list\n ias tool memos add \"明天下午2点开会\"\n ias tool memos delete 3\n ias tool amap poi-search --keywords 肯德基 --city 北京\n ias tool amap geocode --address 西直门\n ias tool amap route-plan --type walking --origin 116.397,39.909 --destination 116.427,39.903\n ias tool amap travel-plan --city 北京 --interests 景点,美食 --route-type walking" + after_help = "工具名来自 tools/rules/*.tool.yaml,参数自动匹配 YAML 定义。运行 ias tool list 查看所有可用工具。" )] - Tool(ToolCommand), + 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, + }, } // ─── 定时任务子命令 ─── @@ -226,187 +241,3 @@ pub enum ScheduledTaskAction { id: String, }, } - -// ─── 工具子命令 ─── - -#[derive(Subcommand, Debug, Clone)] -pub enum ToolCommand { - /// 获取当前日期时间(北京时间 UTC+8) - Datetime, - - /// 管理备忘录 - #[command(subcommand)] - Memos(MemosAction), - - /// 查询指定城市的天气 - /// - /// 示例: - /// ias tool weather 北京 - /// ias tool weather 上海 --days 7 - Weather { - /// 城市名称 - location: String, - - /// 查询天数(1-7,默认 3) - #[arg(long, default_value = "3")] - days: u32, - }, - - /// 联网搜索最新信息(需配置 TAVILY_API_KEY) - /// - /// 示例: - /// ias tool search "最新 AI 新闻" - /// ias tool search "Rust async" --max-results 10 --no-answer - /// ias tool search "比特币" --topic finance --time-range week - Search { - /// 搜索关键词 - query: String, - - /// 最大结果数(默认 5) - #[arg(long, default_value = "5")] - max_results: u32, - - /// 不包含 AI 生成的摘要 - #[arg(long)] - no_answer: bool, - - /// 搜索主题: general / news / finance - #[arg(long)] - topic: Option, - - /// 发布时间过滤: day / week / month / year - #[arg(long)] - time_range: Option, - }, - - /// 抓取网页并提取正文内容 - Fetch { - /// 网页 URL - url: String, - }, - - /// 高德地图工具(POI搜索、地理编码、路径规划、旅游规划) - #[command(subcommand)] - Amap(AmapAction), -} - -#[derive(Subcommand, Debug, Clone)] -pub enum AmapAction { - /// POI(地点)搜索 - PoiSearch { - /// 搜索关键词 - #[arg(long)] - keywords: String, - - /// 城市名称 - #[arg(long)] - city: Option, - - /// 中心点坐标(经度,纬度),用于周边搜索 - #[arg(long)] - location: Option, - - /// 搜索半径(米),默认 1000 - #[arg(long)] - radius: Option, - - /// 页码,默认 1 - #[arg(long, default_value = "1")] - page: u32, - - /// 每页数量,默认 10 - #[arg(long, default_value = "10")] - offset: u32, - }, - - /// 地理编码:地址 → 坐标 - Geocode { - /// 地址名称 - #[arg(long)] - address: String, - - /// 城市名称(可选) - #[arg(long)] - city: Option, - }, - - /// 逆地理编码:坐标 → 地址 - ReverseGeocode { - /// 坐标(经度,纬度) - #[arg(long)] - location: String, - }, - - /// 路径规划 - RoutePlan { - /// 出行方式:walking/driving/riding/transit - #[arg(long)] - r#type: String, - - /// 起点坐标(经度,纬度) - #[arg(long)] - origin: String, - - /// 终点坐标(经度,纬度) - #[arg(long)] - destination: String, - - /// 城市名称(公交方式必填) - #[arg(long)] - city: Option, - - /// 途经点(驾车可选,多个用;分隔) - #[arg(long)] - waypoints: Option, - - /// 策略(驾车默认10躲避拥堵,公交默认0最快捷) - #[arg(long)] - strategy: Option, - }, - - /// 智能旅游规划 - TravelPlan { - /// 城市名称 - #[arg(long)] - city: String, - - /// 兴趣点关键词(逗号分隔),如 景点,美食,酒店 - #[arg(long, default_value = "景点,美食")] - interests: String, - - /// 路线类型:walking/driving/riding/transit - #[arg(long, default_value = "walking")] - route_type: String, - }, - - /// 生成地图可视化链接 - MapLink { - /// 地图数据 JSON 数组 - #[arg(long)] - data: String, - }, -} - -#[derive(Subcommand, Debug, Clone)] -pub enum MemosAction { - /// 列出所有备忘录 - List, - - /// 添加备忘录 - /// - /// 示例: - /// ias tool memos add "明天下午2点开会" - Add { - /// 备忘录内容 - content: String, - }, - - /// 删除指定 ID 的备忘录 - /// - /// 示例: - /// ias tool memos delete 3 - Delete { - /// 备忘录 ID - id: i64, - }, -} diff --git a/src/context/builder.rs b/src/context/builder.rs index e7d84c4..c6b6a19 100644 --- a/src/context/builder.rs +++ b/src/context/builder.rs @@ -57,9 +57,10 @@ fn matching_tool_call_anchor(recent: &[Message], tool_call_id: &str) -> Option (Vec, u } let ordered: Vec = indexes.into_iter().collect(); - let tokens = ordered.iter().map(|&idx| estimate_tokens(&recent[idx])).sum(); + let tokens = ordered + .iter() + .map(|&idx| estimate_tokens(&recent[idx])) + .sum(); (ordered, tokens) } @@ -136,12 +140,16 @@ pub async fn build_context(session: &Arc>, system_prompt: &st 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 > s.token_budget - 500 { + if used + tail_used + block_tokens > headroom { break; } tail_used += block_tokens; @@ -293,16 +301,17 @@ pub async fn trigger_idle_summary( /// 生成摘要:优先使用 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 摘要失败,回退到简单截断"), + && 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) } @@ -392,14 +401,17 @@ mod tests { 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.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)); @@ -411,8 +423,12 @@ mod tests { .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); } } diff --git a/src/context/tools.rs b/src/context/tools.rs index 531327a..2f9e6b8 100644 --- a/src/context/tools.rs +++ b/src/context/tools.rs @@ -86,7 +86,11 @@ impl MemoryStore { // 先清理过期缓存 self.evict_expired().await; - let uid = if user_id.is_empty() { "default" } else { user_id }; + let uid = if user_id.is_empty() { + "default" + } else { + user_id + }; { let cache = self.cache.lock().await; if cache.contains_key(uid) { @@ -101,7 +105,11 @@ impl MemoryStore { Some(p) => p.clone(), None => return, }; - let uid = if user_id.is_empty() { "default" } else { user_id }; + 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", ) @@ -120,7 +128,11 @@ impl MemoryStore { } pub async fn read_for(&self, user_id: &str, limit: Option) -> String { - let uid = if user_id.is_empty() { "default" } else { user_id }; + let uid = if user_id.is_empty() { + "default" + } else { + user_id + }; self.touch(uid).await; let max = limit.unwrap_or(DEFAULT_MEMORY_LIMIT); @@ -128,7 +140,11 @@ impl MemoryStore { 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 }; + let start = if mems.len() > max { + mems.len() - max + } else { + 0 + }; mems[start..] .iter() .map(|(id, content)| format!("[{}] {}", id, content)) @@ -140,7 +156,11 @@ impl MemoryStore { } pub async fn write_for(&self, user_id: &str, content: &str) -> String { - let uid = if user_id.is_empty() { "default" } else { user_id }; + let uid = if user_id.is_empty() { + "default" + } else { + user_id + }; // 空内容防御 if content.trim().is_empty() { @@ -189,12 +209,10 @@ impl MemoryStore { // 写入缓存 { 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(), - }); + 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())); @@ -217,7 +235,11 @@ impl MemoryStore { /// 删除指定 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 uid = if user_id.is_empty() { + "default" + } else { + user_id + }; let removed = { let mut cache = self.cache.lock().await; @@ -248,7 +270,11 @@ impl MemoryStore { /// 更新指定 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 }; + let uid = if user_id.is_empty() { + "default" + } else { + user_id + }; if content.trim().is_empty() { return "记忆内容不能为空".to_string(); @@ -274,18 +300,15 @@ impl MemoryStore { } 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; + 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 index 0492e5a..97edb20 100644 --- a/src/context/types.rs +++ b/src/context/types.rs @@ -133,22 +133,21 @@ impl ChatSession { } /// 记录一条带 tool_calls 的助手消息 - pub fn add_assistant_tool_calls(&mut self, tool_calls: Vec) { + pub fn add_assistant_tool_calls( + &mut self, + content: impl Into, + tool_calls: Vec, + ) { self.messages - .push(Message::assistant_with_tool_calls(tool_calls)); + .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 add_system_note(&mut self, note: &str) { - self.messages.push(Message::system(note)); - } - /// 是否因空闲超过阈值需要摘要 pub fn is_idle_timeout(&self) -> bool { if let Some(last) = self.last_user_at { @@ -196,11 +195,17 @@ impl ChatSession { 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 } + 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 { + 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 index a789692..cf7bc8b 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -47,7 +47,8 @@ use crate::llm::{ }; use crate::queue::{ConsumerChannels, EnqueueHandle, MessageKind, PipelineMessage, QueueRunner}; -use crate::tools::approval::{ApprovalDecision, ApprovalManager}; +use crate::tools::approval::ApprovalManager; +use crate::tools::registry::Registry; use crate::wechat::client::WeChatClient; use std::collections::HashMap; use std::sync::Arc; @@ -69,17 +70,14 @@ struct DaemonCtx { account_id: String, /// 审批管理器 —— 处理高风险工具的用户确认码流程 approval: Arc, + /// 工具注册器 —— 管理外部工具规范和执行 + registry: Arc, /// 长期记忆管理器 —— 按用户隔离的记忆读写 memory_store: Arc, - /// 注册给 LLM 的工具列表(JSON 格式,符合 function calling 规范) - tools_list: Vec, /// 系统提示词,覆盖默认值 system_prompt: String, /// 使用的模型名称(如 `deepseek-v4-flash`) model: String, - /// 待审批的上下文:`user_id → (原始文本, context_token, 工具名, 工具参数, 创建时间)` - /// 用户在聊天中发送确认码时,从这里面查找匹配项 - approval_ctx: Mutex, String, String, Instant)>>, } /// Daemon 入口 @@ -90,7 +88,7 @@ pub async fn run(database: &Arc, memory_store: &Arc) { let auth = match load_auth(database).await { Some(a) => a, None => { - error!(target: "ias::auth", "未登录"); + error!(target: "ias::err","[auth] 未登录"); return; } }; @@ -102,46 +100,37 @@ pub async fn run(database: &Arc, memory_store: &Arc) { client.set_updates_buf(&buf).await; } if let Err(e) = client.notify_start().await { - error!(target: "ias::auth", "注册监听器失败: {}", e); + error!(target: "ias::err","[wechat] 注册监听器失败: {e}"); return; } let account_id = auth.account_id.clone(); - info!(target: "ias::auth", "Daemon 已启动"); - // 4-6. 审批管理器、工具列表、模型配置 + // 4-6. 审批管理器、注册器、模型配置 let approval_manager = Arc::new(ApprovalManager::new(Some(Arc::new( database.pool().clone(), )))); - let tools_list = build_tools_list(); + let registry = Arc::new(Registry::load("tools")); 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 上下文(注意 approval_ctx 为 5 元组) + // 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(), - tools_list: tools_list.clone(), system_prompt, model, - approval_ctx: Mutex::new(HashMap::new()), }); // 8. 审批过期清理 - let ctx_for_clean = ctx.clone(); 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; - let now = Instant::now(); - ctx_for_clean - .approval_ctx - .lock() - .await - .retain(|_, (_, _, _, _, created)| now.duration_since(*created).as_secs() < 300); } }); @@ -179,14 +168,13 @@ pub async fn run(database: &Arc, memory_store: &Arc) { }) .await; }); - info!(target: "ias::queue", "定时任务调度器已启动"); // 12. QueueRunner let runner_handle = tokio::spawn(async move { runner.run().await; }); - // 13. Session TTL 清理(5 分钟无活动) + // 13. Session TTL 清理:普通会话 5 分钟,等待异步工具结果时延长到 30 分钟 let sessions_for_clean = sessions.clone(); tokio::spawn(async move { loop { @@ -195,12 +183,13 @@ pub async fn run(database: &Arc, memory_store: &Arc) { sessions_for_clean .lock() .await - .retain(|_, (_, created)| now.duration_since(*created).as_secs() < 300); + .retain(|_, (conv, created)| { + let ttl_secs = if conv.has_pending_async() { 1800 } else { 300 }; + now.duration_since(*created).as_secs() < ttl_secs + }); } }); - info!(target: "ias::auth", "开始监听消息..."); - // 13. 主循环 — 带 Ctrl+C 优雅退出 let mut running = true; while running { @@ -219,33 +208,14 @@ pub async fn run(database: &Arc, memory_store: &Arc) { 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::queue", "收到消息 from={}: {}", from, text); + 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 let Some((skill_name, decision)) = ctx.approval.handle_reply(from, text).await { - match decision { - ApprovalDecision::Approved => { - info!(target: "ias::auth", "审批通过: {}", skill_name); - // 5 元组: (orig_text, context_token, tool_name, tool_args, timestamp) - if let Some((orig_text, orig_ctx, _tn, _ta, _ts)) = ctx.approval_ctx.lock().await.remove(from) { - enqueue_handle.enqueue(PipelineMessage { - channel: ChannelId::wechat(from), - correlation_id: Uuid::new_v4(), - kind: MessageKind::UserMessage { - text: orig_text, message_id: String::new(), - context_token: orig_ctx, approved_tool: Some(skill_name), - }, - }).await; - } - } - ApprovalDecision::Rejected | ApprovalDecision::Expired => { - ctx.approval_ctx.lock().await.remove(from); - } - } + if ctx.approval.handle_reply(from, text).await.is_some() { continue; } @@ -254,34 +224,31 @@ pub async fn run(database: &Arc, memory_store: &Arc) { correlation_id: Uuid::new_v4(), kind: MessageKind::UserMessage { text: text.to_string(), message_id: msg_id, - context_token: ctx_token.map(String::from), approved_tool: None, + context_token: ctx_token.map(String::from), }, }).await; } } Err(e) => { if !e.contains("超时") && !e.contains("timeout") { - error!(target: "ias::auth", "接收消息失败: {}", e); + error!(target: "ias::err","[wechat] 接收消息失败: {e}"); tokio::time::sleep(std::time::Duration::from_secs(2)).await; } } } } _ = tokio::signal::ctrl_c() => { - info!(target: "ias::auth", "收到 Ctrl+C,优雅关闭中..."); running = false; } } } shutdown_handle.shutdown(); - info!(target: "ias::queue", "等待消费者处理剩余消息..."); tokio::time::sleep(std::time::Duration::from_secs(3)).await; for h in consumer_handles { h.abort(); } runner_handle.abort(); - info!(target: "ias::auth", "Daemon 已停止"); } // ─── 消费者 ─── @@ -336,8 +303,6 @@ async fn llm_consumer_loop( enqueue: &EnqueueHandle, sessions: &Arc>>, ) { - info!(target: "ias::queue", "LLM Consumer 已启动"); - while let Some(msg) = rx.recv().await { let user_id = msg.channel.user_id.clone(); let correlation_id = msg.correlation_id; @@ -347,7 +312,6 @@ async fn llm_consumer_loop( text, message_id: _, context_token, - approved_tool, } => { // 加载数据 ctx.memory_store.load_if_needed(&user_id).await; @@ -359,17 +323,47 @@ async fn llm_consumer_loop( 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: Some(ctx.tools_list.clone()), + tools, ..Default::default() }; let mut conv = match Conversation::new(cfg) { Ok(c) => c, Err(e) => { - error!("LLM 初始化失败: {}", e); + error!(target: "ias::err","[llm-init] {e}"); continue; } }; @@ -383,19 +377,12 @@ async fn llm_consumer_loop( s.current_user_id = user_id.clone(); } - if let Some(tool) = &approved_tool { - conv.session() - .lock() - .await - .add_system_note(&format!("\n[用户已批准工具: {}]", tool)); - } - // 设置 ToolExecutor — 所有工具统一通过消息队列异步执行 let eq = enqueue.clone(); let uid = user_id.clone(); let corr = correlation_id; let session = conv.session(); - let approved_tool = approved_tool.clone(); + let tool_context_token = context_token.clone(); let executor: ToolExecutor = Arc::new(move |name: &str, args_json: &str, tc_id: &str| { @@ -405,35 +392,22 @@ async fn llm_consumer_loop( let session = session.clone(); let n = name.to_string(); let args = args_json.to_string(); - let is_approved = approved_tool.as_ref() == Some(&n); 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 }; - if is_approved { - eq.enqueue(PipelineMessage::approved_tool_call( - ch, - corr, - sid, - &tool_call_id, - &n, - &args, - None, - )) - .await; - } else { - eq.enqueue(PipelineMessage::tool_call( - ch, - corr, - sid, - &tool_call_id, - &n, - &args, - None, - )) - .await; - } + eq.enqueue(PipelineMessage::tool_call( + ch, + corr, + sid, + &tool_call_id, + &n, + &args, + context_token, + )) + .await; Ok(format!("{}{}", ASYNC_MARKER, n)) }) }); @@ -476,7 +450,7 @@ async fn llm_consumer_loop( *last_access = Instant::now(); // 更新访问时间,防止 TTL 误清理 conv.notify_tool_result() } else { - warn!(target: "ias::queue", "会话已过期: corr={}", correlation_id); + warn!(target: "ias::err","[session-expired] {correlation_id}"); false } }; @@ -488,7 +462,6 @@ async fn llm_consumer_loop( } MessageKind::ScheduledTask { text, task_name } => { - info!(target: "ias::queue", "LLM Consumer: 定时任务 user={} task={}", user_id, task_name); let prompt = format!("[定时任务: {}]\n{}", task_name, text); // 为保持简洁,定时任务直接作为用户消息处理(使用全新 correlation_id) @@ -499,7 +472,6 @@ async fn llm_consumer_loop( text: prompt, message_id: String::new(), context_token: None, - approved_tool: None, }, }; // 重新投递给自己 — 简单但可靠 @@ -508,13 +480,9 @@ async fn llm_consumer_loop( eq.enqueue(msg).await; } - other => warn!(target: "ias::queue", - "LLM Consumer 收到不期望的消息: {:?}", - std::mem::discriminant(&other) - ), + _ => {} } } - info!(target: "ias::queue", "LLM Consumer 已停止"); } /// 执行一轮 LLM 对话,仅在无异步工具等待时发送回复并清理会话。 @@ -526,14 +494,12 @@ async fn run_llm_round( sessions: &Arc>>, enqueue: &EnqueueHandle, ) { - info!(target: "ias::queue", "══════════════════════════════════════════════════"); - info!(target: "ias::queue", "🔄 LLM 回合开始 user={} text={}", user_id, text); let result = { let map = sessions.lock().await; let conv = match map.get(&correlation_id) { Some((c, _)) => c, None => { - error!("run_llm_round: conv 不在缓存"); + error!(target: "ias::err","[llm-session] conv不在缓存"); return; } }; @@ -541,7 +507,7 @@ async fn run_llm_round( match conv.chat_with_tools(text.to_string()).await { Ok(r) => r, Err(e) => { - error!("LLM 对话失败: {}", e); + error!(target: "ias::err","[llm-chat] {e}"); ChatResult { reply: format!("处理失败: {}", e), used_tools: false, @@ -571,14 +537,12 @@ async fn resume_llm_round( sessions: &Arc>>, enqueue: &EnqueueHandle, ) { - info!(target: "ias::queue", "══════════════════════════════════════════════════"); - info!(target: "ias::queue", "🔄 LLM 恢复 user={}", user_id); let result = { let map = sessions.lock().await; let conv = match map.get(&correlation_id) { Some((c, _)) => c, None => { - error!("resume_llm_round: conv 不在缓存"); + error!(target: "ias::err","[llm-resume] conv不在缓存"); return; } }; @@ -586,7 +550,7 @@ async fn resume_llm_round( match conv.resume_tool_loop().await { Ok(r) => r, Err(e) => { - error!("LLM 恢复失败: {}", e); + error!(target: "ias::err","[llm-resume] {e}"); ChatResult { reply: format!("处理失败: {}", e), used_tools: false, @@ -621,7 +585,13 @@ async fn handle_chat_result( // 有异步工具等待返回 → 保留 session if !result.reply.is_empty() { let ch = ChannelId::wechat(user_id); - let msg = PipelineMessage::llm_reply(ch, correlation_id, &result.reply, context_token); + let msg = PipelineMessage::llm_reply_with_source( + ch, + correlation_id, + &result.reply, + "llm_pending_tool", + context_token, + ); enqueue.enqueue(msg).await; } return; @@ -634,18 +604,15 @@ async fn handle_chat_result( enqueue.enqueue(msg).await; } sessions.lock().await.remove(&correlation_id); - info!(target: "ias::queue", "✅ LLM 回合结束 user={}", user_id); } // ─── Tool Consumer ─── async fn tool_consumer_loop( rx: &mut tokio::sync::mpsc::Receiver, - ctx: &DaemonCtx, + ctx: &Arc, enqueue: &EnqueueHandle, ) { - info!(target: "ias::queue", "Tool Consumer 已启动"); - while let Some(msg) = rx.recv().await { let user_id = msg.channel.user_id.clone(); let correlation_id = msg.correlation_id; @@ -659,199 +626,227 @@ async fn tool_consumer_loop( context_token, approved, } => { - info!(target: "ias::tool", - "Tool Consumer: user={} tool={} args={}", - user_id, tool_name, arguments - ); - - // 高风险工具 → 审批(除非已被用户批准) - if !approved && crate::tools::is_high_risk(&tool_name) { - info!(target: "ias::tool", "高风险工具 {} 需要审批", tool_name); - ctx.approval_ctx.lock().await.insert( - user_id.clone(), - ( - format!("[工具: {}]", tool_name), - context_token.clone(), - tool_name.clone(), - arguments.clone(), - Instant::now(), - ), - ); - match ctx.approval.create(&user_id, &tool_name).await { - Ok((code, _rx)) => { - let m = format!( - "⚠️ 操作确认\n\n技能:{}\n\n确认码:{}\n\n回复确认码以继续\n回复 0 或「取消」以取消\n(5分钟内有效)", - tool_name, code - ); - // 通过消息队列发送审批提示(不直接调 send_text) - let ch = ChannelId::wechat(&user_id); - enqueue - .enqueue(PipelineMessage::llm_reply(ch, correlation_id, &m, None)) - .await; - } - Err(e) => error!(target: "ias::tool", "创建审批失败: {}", e), - } - continue; - } - - // 执行工具 — 所有工具统一在此处理 - let output = execute_tool(&tool_name, &arguments, ctx, &user_id).await; - - enqueue - .enqueue(PipelineMessage::tool_result( - ChannelId::wechat(&user_id), + 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, - &output, + tool_call_id, + tool_name, + arguments, context_token, - )) + approved, + ) .await; - } - MessageKind::ApprovalRequest { .. } => { - warn!(target: "ias::queue", "Tool Consumer 收到 ApprovalRequest"); - } - other => warn!(target: "ias::queue", - "Tool Consumer 收到不期望: {:?}", - std::mem::discriminant(&other) - ), - } - } - info!(target: "ias::queue", "Tool Consumer 已停止"); -} - -// ─── 统一工具执行 ─── - -/// 所有工具的统一执行入口 — 仅此一处 -/// -/// `call_capability` 通过递归派发到此函数,需 boxing 避免无限大小。 -fn execute_tool<'a>( - name: &'a str, - arguments: &'a str, - ctx: &'a DaemonCtx, - user_id: &'a str, -) -> std::pin::Pin + Send + 'a>> { - Box::pin(async move { - // 上下文工具 - match name { - "read_memories" => { - let result = ctx.memory_store.read_for(user_id, None).await; - tracing::info!(target: "ias::tool", "🔧 execute_tool: read_memories → {}", result); - return result; - } - "write_memory" => { - let params: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default(); - let content = params.get("content").and_then(|v| v.as_str()).unwrap_or(""); - if content.is_empty() { - return "请提供 content 参数".to_string(); - } - ctx.memory_store.write_for(user_id, content).await; - let result = format!("已添加长期记忆: {}", content); - tracing::info!(target: "ias::tool", "🔧 execute_tool: write_memory → {}", result); - return result; - } - "delete_memory" => { - let params: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default(); - let memory_id = params - .get("memory_id") - .and_then(|v| v.as_i64()) - .unwrap_or(-1) as i32; - if memory_id <= 0 { - return "请提供有效的 memory_id 参数".to_string(); - } - let result = ctx.memory_store.delete_for(user_id, memory_id).await; - tracing::info!(target: "ias::tool", "🔧 execute_tool: delete_memory({}) → {}", memory_id, result); - return result; - } - "update_memory" => { - let params: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default(); - let memory_id = params - .get("memory_id") - .and_then(|v| v.as_i64()) - .unwrap_or(-1) as i32; - let content = params.get("content").and_then(|v| v.as_str()).unwrap_or(""); - if memory_id <= 0 { - return "请提供有效的 memory_id 参数".to_string(); - } - if content.is_empty() { - return "请提供 content 参数".to_string(); - } - let result = ctx - .memory_store - .update_for(user_id, memory_id, content) - .await; - tracing::info!(target: "ias::tool", "🔧 execute_tool: update_memory({}) → {}", memory_id, result); - return result; - } - "read_summaries" => { - let sums = load_summaries(&ctx.db, user_id).await; - let result = if sums.is_empty() { - "暂无历史摘要".to_string() - } else { - sums.iter() - .enumerate() - .map(|(i, s)| format!("{}. {}", i + 1, s)) - .collect::>() - .join("\n---\n") - }; - tracing::info!(target: "ias::tool", "🔧 execute_tool: read_summaries → {}条", sums.len()); - return result; - } - "query_capabilities" => { - let specs = crate::tools::specs(); - let result = crate::tools::build_capability_guide(&specs); - tracing::info!(target: "ias::tool", - "🔧 execute_tool: query_capabilities → {}个工具", - specs.len() - ); - return result; - } - "call_capability" => { - // 解包 {name, prompt} → 提取目标工具名和参数,递归派发 - let cp: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default(); - let target_name = cp - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - if target_name.is_empty() { - return "请提供 name 参数".to_string(); - } - // 防止嵌套 call_capability - if target_name == "call_capability" { - return "不支持嵌套调用 call_capability".to_string(); - } - let target_args = serde_json::to_string(&crate::tools::unpack_call_params(&cp)) - .unwrap_or_default(); - tracing::info!(target: "ias::tool", - "🔧 execute_tool: call_capability → {} args={}", - target_name, - target_args - ); - // 递归派发 - return execute_tool(&target_name, &target_args, ctx, user_id).await; + }); } _ => {} } + } +} - // 内置工具注册表 - match crate::tools::execute(name, arguments).await { - Some(r) => { - tracing::info!(target: "ias::tool", - "🔧 execute_tool: {} → success={} output={}", - name, - r.success, - r.output - ); - r.output +#[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; + }) + }; + ctx.registry + .dispatch( + target, + &sub_params, + &user_id, + approved, + &ctx.approval, + &ctx.memory_store, + &wechat_cb, + ) + .await + } 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; + }) + }; + ctx.registry + .dispatch( + &tool_name, + ¶ms, + &user_id, + approved, + &ctx.approval, + &ctx.memory_store, + &wechat_cb, + ) + .await + }; + + 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()); + } } - None => { - tracing::warn!(target: "ias::tool", "🔧 execute_tool: 未知工具 {}", name); - format!("未知工具: {}", name) + 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(); + 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 ─── @@ -860,21 +855,15 @@ async fn send_consumer_loop( rx: &mut tokio::sync::mpsc::Receiver, ctx: &DaemonCtx, ) { - info!(target: "ias::queue", "Send Consumer 已启动"); - 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::queue", - "Send Consumer: user={} text_len={} text={}", - user_id, - text.len(), - text - ); + info!(target: "ias::msg", "[LLM] -> {}: {}", user_id, text); match ctx .client @@ -882,33 +871,24 @@ async fn send_consumer_loop( .await { Ok(sent_id) => { - info!(target: "ias::queue", - "回复成功 user={} msg_id={}", - user_id.chars().take(5).collect::(), - sent_id - ); let _ = crate::db::models::insert_chat_record( ctx.db.pool(), "outbound", user_id, &ctx.account_id, &text, - "llm", + &source, context_token.as_deref(), &sent_id, ) .await; } - Err(e) => error!("发送回复失败: {}", e), + Err(e) => error!(target: "ias::err","[send] {e}"), } } - other => warn!(target: "ias::queue", - "Send Consumer 收到不期望: {:?}", - std::mem::discriminant(&other) - ), + _ => {} } } - info!(target: "ias::queue", "Send Consumer 已停止"); } // ─── 辅助函数 ─── @@ -918,7 +898,7 @@ async fn load_auth(database: &Arc) -> Option, user_id: &str) -> Vec { - match crate::db::models::list_recent_chat_records(db.pool(), user_id, 20).await { + match crate::db::models::list_recent_chat_records_for_context(db.pool(), user_id, 20).await { Ok(records) => records .into_iter() .rev() @@ -947,82 +927,3 @@ async fn load_summaries(db: &Arc, user_id: &str) -> Vec { } } } - -fn build_tools_list() -> Vec { - let mut list = Vec::new(); - list.push(serde_json::json!({ - "type": "function", "function": { - "name": "query_capabilities", - "description": "列出所有可用工具的详细说明、参数格式和调用示例。", - "parameters": { "type": "object", "properties": {} } - } - })); - list.push(serde_json::json!({ - "type": "function", "function": { - "name": "call_capability", - "description": "调用一个已注册的工具。name 为工具名。", - "parameters": { - "type": "object", - "properties": { - "name": { "type": "string", "description": "工具名称" }, - "prompt": { "type": "string", "description": "json格式参数" } - }, - "required": ["name"] - } - } - })); - list.push(serde_json::json!({ - "type": "function", "function": { - "name": "read_memories", "description": "读取用户的长期记忆", - "parameters": { "type": "object", "properties": {} } - } - })); - list.push(serde_json::json!({ - "type": "function", "function": { - "name": "write_memory", "description": "记录用户的重要信息或偏好", - "parameters": { "type": "object", "properties": { "content": { "type": "string" } }, "required": ["content"] } - } - })); - list.push(serde_json::json!({ - "type": "function", "function": { - "name": "read_summaries", "description": "读取历史会话摘要", - "parameters": { "type": "object", "properties": {} } - } - })); - list.push(serde_json::json!({ - "type": "function", "function": { - "name": "delete_memory", - "description": "删除指定 id 的长期记忆。id 来自 read_memories 返回的 [id] 前缀。", - "parameters": { - "type": "object", - "properties": { - "memory_id": { "type": "integer", "description": "要删除的记忆 id" } - }, - "required": ["memory_id"] - } - } - })); - list.push(serde_json::json!({ - "type": "function", "function": { - "name": "update_memory", - "description": "更新指定 id 的长期记忆内容。id 来自 read_memories 返回的 [id] 前缀。", - "parameters": { - "type": "object", - "properties": { - "memory_id": { "type": "integer", "description": "要更新的记忆 id" }, - "content": { "type": "string", "description": "新的记忆内容" } - }, - "required": ["memory_id", "content"] - } - } - })); - - let specs = crate::tools::specs(); - for spec in &specs { - list.push(serde_json::json!({ - "type": "function", - "function": { "name": spec.name, "description": spec.description, "parameters": spec.parameters } - })); - } - list -} diff --git a/src/db/models.rs b/src/db/models.rs index 8022bf9..ff01f6a 100644 --- a/src/db/models.rs +++ b/src/db/models.rs @@ -26,12 +26,11 @@ pub struct AuthState { /// 从数据库加载认证状态 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()??; + 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() } @@ -60,8 +59,8 @@ pub async fn save_auth(pool: &PgPool, auth: &AuthState) -> Result<(), String> { /// 从数据库加载运行时状态 (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'", + let row = sqlx::query_as::<_, (serde_json::Value,)>( + &"SELECT value FROM app_state WHERE key = 'runtime'", ) .fetch_optional(pool) .await @@ -138,8 +137,8 @@ pub async fn insert_chat_record( Ok(row.map(|r| r.0).unwrap_or(0)) } -/// 查询最近的聊天记录(用户维度) -pub async fn list_recent_chat_records( +/// 查询最近可进入 LLM 上下文的聊天记录(排除工具等待/审批等中间提示)。 +pub async fn list_recent_chat_records_for_context( pool: &PgPool, user_id: &str, limit: i64, @@ -149,6 +148,7 @@ pub async fn list_recent_chat_records( 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') ORDER BY created_at DESC LIMIT $2 "#, @@ -157,7 +157,7 @@ pub async fn list_recent_chat_records( .bind(limit) .fetch_all(pool) .await - .map_err(|e| format!("查询聊天记录失败: {}", e))?; + .map_err(|e| format!("查询上下文聊天记录失败: {}", e))?; Ok(records) } @@ -182,9 +182,7 @@ pub async fn query_llm_usage_stats( until: Option>, model_filter: Option<&str>, ) -> Result { - let since = since.unwrap_or_else(|| { - chrono::Utc::now() - chrono::Duration::days(7) - }); + 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>( @@ -235,7 +233,11 @@ pub async fn save_summary( } /// 加载最近的摘要(用于 read_summaries 工具),按 user_id 过滤 -pub async fn load_summaries(pool: &PgPool, user_id: &str, limit: i64) -> Result, String> { +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", ) @@ -334,22 +336,28 @@ pub async fn update_scheduled_task( let mut idx = 1u32; if name.is_some() { - sets.push(format!("name = ${}", idx)); idx += 1; + sets.push(format!("name = ${}", idx)); + idx += 1; } if command.is_some() { - sets.push(format!("command = ${}", idx)); idx += 1; + sets.push(format!("command = ${}", idx)); + idx += 1; } if interval_seconds.is_some() { - sets.push(format!("interval_seconds = ${}", idx)); idx += 1; + sets.push(format!("interval_seconds = ${}", idx)); + idx += 1; } if description.is_some() { - sets.push(format!("description = ${}", idx)); idx += 1; + sets.push(format!("description = ${}", idx)); + idx += 1; } if shell.is_some() { - sets.push(format!("shell = ${}", idx)); idx += 1; + sets.push(format!("shell = ${}", idx)); + idx += 1; } if cwd.is_some() { - sets.push(format!("cwd = ${}", idx)); idx += 1; + sets.push(format!("cwd = ${}", idx)); + idx += 1; } if sets.is_empty() { @@ -366,12 +374,24 @@ pub async fn update_scheduled_task( 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); } + 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) diff --git a/src/llm/conversation.rs b/src/llm/conversation.rs index f2b9e05..48ff0ef 100644 --- a/src/llm/conversation.rs +++ b/src/llm/conversation.rs @@ -26,8 +26,8 @@ use crate::llm::provider::{BoxedProvider, StreamReceiver, create_provider}; use crate::llm::types::{ConversationConfig, Message, StreamChunk, ToolCall, Usage}; use std::future::Future; use std::pin::Pin; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use tokio::sync::Mutex; /// ## 工具执行器 —— LLM 调用的工具回调 @@ -262,7 +262,19 @@ impl Conversation { /// 通知一个异步工具结果已返回,返回 true 表示所有异步工具均已返回 pub fn notify_tool_result(&self) -> bool { - self.pending_async_count.fetch_sub(1, Ordering::SeqCst) == 1 + loop { + let current = self.pending_async_count.load(Ordering::SeqCst); + if current == 0 { + return false; + } + if self + .pending_async_count + .compare_exchange(current, current - 1, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + return current == 1; + } + } } /// 工具循环核心:调 LLM → 处理工具调用 → 循环直到无工具或需要异步等待 @@ -273,8 +285,8 @@ impl Conversation { loop { turn_count += 1; - if turn_count > 5 { - return Err("工具调用次数过多(最多5轮)".to_string()); + if turn_count > 30 { + return Err("工具调用次数过多(最多30轮)".to_string()); } let messages = builder::build_context(&self.session, &self.config.system_prompt).await; @@ -289,10 +301,15 @@ impl Conversation { 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; } @@ -301,71 +318,69 @@ impl Conversation { 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(", ") - ); + && !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) => format!("执行失败: {}", e), + } + } + None => "工具执行器未配置".to_string(), + }; + + if result.starts_with(ASYNC_MARKER) { + has_async = true; + async_count += 1; + continue; + } + + tracing::info!(target: "ias::msg", "[tool] {}: {}", tc.function.name, result); self.session .lock() .await - .add_assistant_tool_calls(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) => format!("执行失败: {}", e), - } - } - None => "工具执行器未配置".to_string(), - }; - - if result.starts_with(ASYNC_MARKER) { - has_async = true; - async_count += 1; - tracing::info!(target: "ias::tool", "📦 {} → 异步入队", tc.function.name); - // 不添加到 session,等待 ToolResult 异步回喂 - continue; - } - - tracing::info!(target: "ias::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; + .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()); } @@ -451,85 +466,11 @@ impl ChatHandle { } pub const DEFAULT_SYSTEM_PROMPT: &str = "\ -你是一个运行在 iAs 智能助手系统中的 AI 助手,通过微信与用户交流。你的回复应简洁、自然、实用,\ -除非用户明确要求详细说明,否则回复控制在 120 汉字以内。\ -\ -════════════════════════════════════════════════════════════\ -## 🔧 工具体系说明(核心)\ -\ -你拥有两层工具架构来访问外部能力:\ -\ -### 第一层:元工具(直接在你的 function calling 列表中)\ -| 工具名 | 用途 |\ -|--------|------|\ -| query_capabilities | 列出所有 21 个能力工具的详细说明、参数、使用示例 |\ -| call_capability | 按名称调用任意一个能力工具,传 JSON 参数 |\ -| read_memories | 读取当前用户的长期记忆(偏好、习惯、重要信息)|\ -| write_memory | 记录一条长期记忆(用户喜好、重要决定等)|\ -| delete_memory | 删除指定 id 的长期记忆 |\ -| update_memory | 更新指定 id 的长期记忆内容 |\ -| read_summaries | 查看历史会话摘要,了解之前的对话背景 |\ -\ -### 第二层:能力工具(通过 call_capability 调用)\ -共 21 个,分为 6 大类:\ -\ -🏙️ 高德地图系列(6个)— 出行/地点/路线/旅游一站式服务\ - amap_poi_search → 搜索地点(餐饮/景点/酒店/加油站等)\ - amap_geocode → 地址转坐标(如'天安门' → 116.397,39.908)\ - amap_reverse_geocode → 坐标转地址(如 116.397,39.908 → '北京市东城区...')\ - amap_route_plan → 路径规划(驾车/公交/步行/骑行/电动车)\ - amap_travel_plan → 智能旅游规划(自动搜景点+规划路线)\ - amap_map_link → 生成高德地图可视化链接(POI/路线展示)\ -\ -🌤️ 天气系列(3个)— 实时/预报/逐时\ - weather_now → 当前天气(温度/体感/风向/湿度/气压/能见度)\ - weather_forecast → 未来N天预报(高低温/白天夜间天气/紫外线)\ - weather_hourly → 未来逐小时预报(温度/天气/风力/降水概率)\ -\ -📝 备忘录系列(4个)— 个人便签管理\ - memos_list → 列出所有备忘录\ - memos_add → 添加备忘录\ - memos_get → 查看指定备忘录详情\ - memos_delete → 删除指定备忘录\ -\ -⏰ 定时任务系列(5个)— 周期执行的自动化脚本\ - scheduled_task_list → 列出所有定时任务及运行状态\ - scheduled_task_add → 添加定时任务(名称/用户/命令/间隔)\ - scheduled_task_update → 修改定时任务字段\ - scheduled_task_delete → 删除定时任务(系统任务不可删)\ - scheduled_task_toggle → 启用/禁用定时任务\ -\ -🌐 联网工具(2个)— 搜索与网页抓取\ - web_search → Tavily 联网搜索(含AI摘要/新闻/财经/多过滤)\ - fetch_page → 抓取网页并提取正文(自动过滤广告导航)\ -\ -🕐 基础工具(1个)\ - get_current_datetime → 获取当前北京时间\ -\ -════════════════════════════════════════════════════════════\ -## 🎯 工具选择决策树(每次必看)\ -\ -收到用户请求后,按以下顺序判断:\ -\ -1. 📍 涉及地点/路线/旅游/导航?\ - → 高德地图系列。先用 amap_geocode 转地址为坐标,再路由到对应工具\ -\n2. 🌤️ 查询天气?\ - → weather_now(当前)/ weather_forecast(未来几天)/ weather_hourly(逐时)\ -\n3. 📝 需要记录/查看/删除备忘录?\ - → memos_add / memos_list / memos_get / memos_delete\ -\n4. ⏰ 需要设置/管理定时任务?\ - → scheduled_task_* 系列。先 list 确认现状,再 add/update/delete/toggle\ -\n5. 🌐 需要最新信息/事实查询/抓取网页?\ - → web_search(搜索)/ fetch_page(抓取具体URL)\ -\n6. 🕐 需要当前时间?\ - → get_current_datetime\ -\n7. 以上都不匹配\ - → web_search 搜索相关信息\ -\n════════════════════════════════════════════════════════════\ -## 📐 工作流程\ -\n1. 收到用户消息后,首先调用 query_capabilities 获取最新工具列表(含参数格式)\ -2. 按决策树判断需要哪个工具\ -3. 用 call_capability 调用:参数格式为 {\"name\": \"工具名\", \"prompt\": \"{\\\"参数键\\\": \\\"参数值\\\"}\"}\n4. 获取结果后,整理成自然语言回复用户\ -\n提示:对复杂任务,可组合多个工具。例如:\ - '北京三日游' → amap_travel_plan\ - '明天去上海穿什么' → weather_forecast(location='上海')\n '附近有什么好吃的' → amap_poi_search(keywords='美食', ...)\n '帮我记下:下周二开会' → memos_add(content='下周二开会')\n '每天8点提醒我喝水' → scheduled_task_add(name='喝水提醒', command='echo 喝水', interval=86400)\n\n════════════════════════════════════════════════════════════\n## ⚠️ 关键规则\n\n1. 先查后调:调用任何能力工具前,先用 query_capabilities 确认参数格式\n2. 只能调一个:call_capability 一次只调用一个工具,不支持批量\n3. 坐标格式:高德地图的坐标参数格式为'经度,纬度'(注意顺序)\n4. 系统任务保护:scheduled_task 中标记为 [系统] 的不可修改/删除/禁用\n5. 主动记忆:当用户透露偏好、习惯、重要信息时,主动用 write_memory 记录\n6. 回忆历史:对话开始时可调用 read_summaries 了解历史背景\n7. 回复简洁:工具返回的原始数据用自然语言概括,不要逐字复述\n\n你是一个乐于助人、主动思考的助手。在给出最终答案前,先想清楚是否需要调用工具。\n"; +你是 iAs 智能助手,通过微信与用户交流。回复简洁自然。\n\ +\n\ +你可以通过 function calling 调用两个元工具:\n\ +1. `query_capabilities` — 查询所有可用能力工具\n\ +2. `call_capability` — 调用能力工具,参数 {\"name\":\"工具名\",\"prompt\":{\"参数\":\"值\"}}\n\ +\n\ +流程:收到消息 → query_capabilities → call_capability → 回复\n\ +"; diff --git a/src/llm/deepseek.rs b/src/llm/deepseek.rs index eb14025..76d4357 100644 --- a/src/llm/deepseek.rs +++ b/src/llm/deepseek.rs @@ -145,22 +145,18 @@ async fn stream_deepseek( .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)); } - // 记录请求摘要 - // tracing::info!( - // "🌐 LLM 请求: model={} messages_count={}", - // body.get("model").and_then(|v| v.as_str()).unwrap_or("?"), - // body.get("messages").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0), - // ); - let mut full_text = String::new(); let mut full_reasoning = String::new(); let mut usage: Option = None; @@ -175,7 +171,6 @@ async fn stream_deepseek( 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(); @@ -183,7 +178,6 @@ async fn stream_deepseek( if trimmed.is_empty() { continue; } - if let Some(parsed) = parse_chat_chunk(trimmed) { match parsed { ParsedChunk::Text(t) => { @@ -200,49 +194,49 @@ async fn stream_deepseek( name, arguments, } => { - let entry = + let e = tool_call_builders .entry(index) .or_insert((None, None, String::new())); - if let Some(call_id) = id { - entry.0 = Some(call_id); + if let Some(i) = id { + e.0 = Some(i); } - if let Some(call_name) = name { - entry.1 = Some(call_name); + if let Some(n) = name { + e.1 = Some(n); } - entry.2.push_str(&arguments); + e.2.push_str(&arguments); } - ParsedChunk::FinishReason(_) => {} ParsedChunk::Usage(u) => { usage = Some(u); } + _ => {} } } } } - // 检查最后的 buffer 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 entry = tool_call_builders + let e = tool_call_builders .entry(index) .or_insert((None, None, String::new())); - if let Some(call_id) = id { - entry.0 = Some(call_id); + if let Some(i) = id { + e.0 = Some(i); } - if let Some(call_name) = name { - entry.1 = Some(call_name); + if let Some(n) = name { + e.1 = Some(n); } - entry.2.push_str(&arguments); + e.2.push_str(&arguments); } ParsedChunk::Usage(u) => { usage = Some(u); @@ -251,7 +245,6 @@ async fn stream_deepseek( } } - // 构建 tool_calls let tool_calls: Option> = if tool_call_builders.is_empty() { None } else { @@ -274,13 +267,7 @@ async fn stream_deepseek( ) }; - // 记录完整的响应输出 - // tracing::info!( - // "🌐 LLM 响应: text_len={} tool_calls={} usage={:?}", - // full_text.len(), - // tool_calls.as_ref().map(|tc| tc.len()).unwrap_or(0), - // usage - // ); + tracing::info!(target: "ias::raw", "[llm-response] text={} tool_calls={:?}", full_text, tool_calls); let _ = tx .send(StreamChunk::Done { diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 9a24a62..eb6b26b 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -21,5 +21,7 @@ pub mod deepseek; pub mod provider; pub mod types; -pub use conversation::{ChatResult, Conversation, ToolExecutor, ASYNC_MARKER, DEFAULT_SYSTEM_PROMPT}; +pub use conversation::{ + ASYNC_MARKER, ChatResult, Conversation, DEFAULT_SYSTEM_PROMPT, ToolExecutor, +}; pub use types::ConversationConfig; diff --git a/src/llm/provider.rs b/src/llm/provider.rs index 6bb3737..05f87e2 100644 --- a/src/llm/provider.rs +++ b/src/llm/provider.rs @@ -160,42 +160,47 @@ pub(crate) fn parse_chat_chunk(line: &str) -> Option { // 提取 usage(可能在最后一个 chunk) if let Some(ref usage) = parsed.usage - && usage.total_tokens > 0 { - return Some(ParsedChunk::Usage(usage.clone())); - } + && 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, - }); - } + && 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())); - } + && !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())); - } + && !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())); - } + && !reason.is_empty() + { + return Some(ParsedChunk::FinishReason(reason.clone())); + } } None diff --git a/src/llm/types.rs b/src/llm/types.rs index 01c86ef..ba65e18 100644 --- a/src/llm/types.rs +++ b/src/llm/types.rs @@ -1,25 +1,7 @@ -//! ## LLM 对话核心类型定义 -//! -//! 定义了与 LLM API 交互的全部核心类型: -//! -//! - `Role` — 对话角色(System / User / Assistant / Tool) -//! - `Message` — 单条消息(支持文本、工具调用、工具结果) -//! - `ToolCall` — LLM 发起的工具调用请求 -//! - `StreamChunk` — 流式响应块(Text / Reasoning / Done / Error) -//! - `Usage` — Token 用量统计 -//! - `ConversationConfig` — 对话配置参数 -//! -//! 这些类型符合 OpenAI/DeepSeek 的 Chat Completion API 标准格式。 +//! LLM 类型定义(OpenAI 兼容) use serde::{Deserialize, Serialize}; -/// ## 对话角色(System / User / Assistant / Tool) -/// -/// 符合 OpenAI/DeepSeek 的 Chat Completion API 标准角色定义。 -/// * `System` — 系统提示词,设定 AI 的行为和限制 -/// * `User` — 用户消息 -/// * `Assistant` — AI 回复(可以是纯文本或带 tool_calls) -/// * `Tool` — 工具执行结果的回馈 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum Role { @@ -29,17 +11,6 @@ pub enum Role { Tool, } -/// ## LLM 消息 —— 符合 OpenAI/DeepSeek Chat Completion API 格式 -/// -/// 这是整个 LLM 交互的核心类型。`Vec` 构成一次对话的全部上下文, -/// 被序列化为 JSON 发送给 DeepSeek API。 -/// -/// ### 字段 -/// * `role` — 消息角色(system/user/assistant/tool) -/// * `content` — 消息内容(文本) -/// * `tool_call_id` — 如果是 tool 角色的结果消息,关联到原始 tool_call 的 ID -/// * `name` — 工具名(tool 角色时使用) -/// * `tool_calls` — assistant 发起工具调用时携带的调用列表 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Message { pub role: Role, @@ -54,81 +25,80 @@ pub struct Message { } impl Message { - pub fn system(content: impl Into) -> Self { - Self { role: Role::System, content: content.into(), tool_call_id: None, name: None, tool_calls: None } + 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(content: impl Into) -> Self { - Self { role: Role::User, content: content.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(content: impl Into) -> Self { - Self { role: Role::Assistant, content: content.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(tool_calls: Vec) -> Self { - Self { role: Role::Assistant, content: String::new(), tool_call_id: None, name: None, tool_calls: Some(tool_calls) } + 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(tool_call_id: &str, name: &str, result: &str) -> Self { - Self { role: Role::Tool, content: result.to_string(), tool_call_id: Some(tool_call_id.to_string()), name: Some(name.to_string()), tool_calls: None } + 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, + } } } -/// ## 工具调用 —— LLM 发起的一个工具调用请求 -/// -/// 当 LLM 认为需要调用工具时,会在回复中携带这个结构。 -/// * `id` — 唯一标识这个调用(用于 tool result 回馈时匹配) -/// * `call_type` — 固定为 "function" -/// * `function` — 函数名 + JSON 格式的参数 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolCall { pub id: String, #[serde(rename = "type")] - pub call_type: String, // "function" + pub call_type: String, pub function: ToolFunctionCall, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolFunctionCall { pub name: String, - pub arguments: String, // JSON string + pub arguments: String, } -/// ## 流式响应块 —— 从 DeepSeek API 的 SSE 流中解析出的每个 delta -/// -/// ### 变体 -/// * `Text` — 文本片段 delta(逐步拼接得到完整回复) -/// * `Reasoning` — 思考/推理内容(DeepSeek 的 thinking 机制) -/// * `Done` — 完成信号,携带完整文本 + 可选的工具调用 + 用量统计 -/// * `Error` — 错误 #[derive(Debug, Clone)] -#[allow(dead_code)] pub enum StreamChunk { - /// 文本片段 delta Text(String), - /// 思考/推理内容(DeepSeek thinking) Reasoning(String), - /// 完成(携带完整文本,以及可选的工具调用) Done { text: String, - #[allow(dead_code)] reasoning: Option, tool_calls: Option>, usage: Option, }, - /// 错误 Error(String), } -/// ## Token 用量统计 -/// -/// DeepSeek API 在流式响应的最后一个 chunk 中会附带用量信息。 -/// * `prompt_tokens` — 提示词消耗的 token 数 -/// * `completion_tokens` — 生成文本消耗的 token 数 -/// * `total_tokens` = prompt + completion -/// * `prompt_cache_hit_tokens` — 命中上下文的缓存 token 数(省钱) -/// * `prompt_cache_miss_tokens` — 未命中缓存的 token 数 #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Usage { pub prompt_tokens: u32, @@ -140,15 +110,6 @@ pub struct Usage { pub prompt_cache_miss_tokens: u32, } -/// ## 对话配置 —— 创建 Conversation 时的参数 -/// -/// ### 关键字段 -/// * `system_prompt` — 系统提示词,决定 AI 的行为方式 -/// * `model` — 模型名称(如 `deepseek-v4-flash`) -/// * `temperature` — 生成随机性(0.0-1.0,越高越有创意) -/// * `max_tokens` — 最大生成 token 数 -/// * `thinking` — 是否启用 DeepSeek 的 thinking 模式 -/// * `tools` — OpenAI 格式的工具定义列表(function calling 用) #[derive(Debug, Clone)] pub struct ConversationConfig { pub system_prompt: String, @@ -156,17 +117,14 @@ pub struct ConversationConfig { pub temperature: f32, pub max_tokens: u32, pub thinking: bool, - /// LLM function calling 的工具定义(JSON 数组) pub tools: Option>, } impl Default for ConversationConfig { fn default() -> Self { Self { - system_prompt: "You are a concise and helpful assistant replying in Chinese. \ - Keep replies practical and natural." - .to_string(), - model: "deepseek-v4-flash".to_string(), + system_prompt: "You are a helpful assistant.".into(), + model: "deepseek-v4-flash".into(), temperature: 0.7, max_tokens: 4096, thinking: true, diff --git a/src/logger.rs b/src/logger.rs index fb00020..ab4fd97 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -1,97 +1,60 @@ -//! ## 日志初始化 —— 四类日志分文件存储 +//! 日志初始化 —— 三路日志 //! -//! ### 日志分类 -//! | 类别 | target | 文件 | 内容 | -//! |------|--------|------|------| -//! | 认证日志 | `ias::auth` | `auth.log` | 登录、token、监听器注册 | -//! | 消息队列日志 | `ias::queue` | `queue.log` | 消息入队/出队内容、队列路由 | -//! | 工具日志 | `ias::tool` | `tool.log` | 工具调用参数、输出、耗时、工具错误 | -//! | 错误日志 | (无特殊target) | `error.log` | 所有非工具调用的 ERROR/WARN | -//! -//! ### 两种模式 -//! - **终端模式** — 彩色输出到 stderr,适合开发调试 -//! - **文件模式** (`with_file=true`) — 终端 + 四类日滚文件 -//! -//! 日志级别通过 `RUST_LOG` 环境变量控制(默认 `info`)。 +//! | 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::{filter_fn, Targets}; +use tracing_subscriber::filter::Targets; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::{EnvFilter, Layer}; -/// ## 日志初始化 —— 四类日志分文件存储 -/// -/// ### 日志分类 -/// * **认证日志** (`auth.log`) — 登录、token、监听器注册等认证相关 -/// * **消息队列日志** (`queue.log`) — 消息入队/出队内容、队列路由 -/// * **工具日志** (`tool.log`) — 工具调用参数、输出、耗时、工具错误 -/// * **错误日志** (`error.log`) — 所有非工具调用的 ERROR/WARN 级别日志 -/// -/// ### 两种模式 -/// * **终端模式** — 彩色输出到 stderr,适合开发调试 -/// * **文件模式** (`with_file=true`) — 终端 + 四类日滚文件 -/// -/// 日志级别通过 `RUST_LOG` 环境变量控制(默认 `info`)。 pub fn init_logger(log_dir: Option<&str>, with_file: bool) { - let env_filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("info")); + 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_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(); + if with_file && let Some(dir) = log_dir { + let dir_path = PathBuf::from(dir); + std::fs::create_dir_all(&dir_path).ok(); - // ── 认证日志 ── - let auth_appender = tracing_appender::rolling::daily(&dir_path, "auth.log"); - let auth_layer = tracing_subscriber::fmt::layer() - .with_target(false) - .with_ansi(false) - .with_writer(auth_appender) - .with_filter(Targets::new().with_target("ias::auth", Level::INFO)); + 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 queue_appender = tracing_appender::rolling::daily(&dir_path, "queue.log"); - let queue_layer = tracing_subscriber::fmt::layer() - .with_target(false) - .with_ansi(false) - .with_writer(queue_appender) - .with_filter(Targets::new().with_target("ias::queue", 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 tool_appender = tracing_appender::rolling::daily(&dir_path, "tool.log"); - let tool_layer = tracing_subscriber::fmt::layer() - .with_target(false) - .with_ansi(false) - .with_writer(tool_appender) - .with_filter(Targets::new().with_target("ias::tool", 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)); - // ── 错误日志(所有非工具调用的 WARN/ERROR 级别日志) ── - let error_appender = tracing_appender::rolling::daily(&dir_path, "error.log"); - let error_layer = tracing_subscriber::fmt::layer() - .with_target(false) - .with_ansi(false) - .with_writer(error_appender) - .with_filter(filter_fn(|metadata| { - metadata.level() <= &Level::WARN - && !metadata.target().starts_with("ias::tool") - })); - - tracing_subscriber::registry() - .with(env_filter) - .with(terminal) - .with(auth_layer) - .with(queue_layer) - .with(tool_layer) - .with(error_layer) - .init(); - return; - } + 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) diff --git a/src/main.rs b/src/main.rs index 149343b..bfa97f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,7 +42,7 @@ mod tools; mod wechat; use clap::Parser; -use cli::{AmapAction, Cli, Commands, MemosAction, ScheduledTaskAction, ToolCommand}; +use cli::{Cli, Commands, ScheduledTaskAction}; use context::MemoryStore; use db::Database; use std::sync::Arc; @@ -79,11 +79,11 @@ async fn main() { // 工具命令无需登录/数据库,提前处理 let tool_cmd = match &cli.command { - Commands::Tool(cmd) => Some(cmd), + Commands::Tool { name, args } => Some((name.clone(), args.clone())), _ => None, }; - if let Some(cmd) = tool_cmd { - cmd_tool(cmd.clone()).await; + if let Some((name, args)) = tool_cmd { + cmd_tool_dynamic(&name, &args).await; return; } @@ -93,7 +93,7 @@ async fn main() { Arc::new(db) } Err(e) => { - error!("数据库连接失败: {}", e); + error!(target: "ias::err","[db-connect] {}", e); eprintln!("错误: 数据库连接失败 - {}", e); std::process::exit(1); } @@ -133,7 +133,7 @@ async fn main() { Commands::Tools => { cmd_tools().await; } - Commands::Tool(..) => unreachable!("Tool 命令已提前处理"), + Commands::Tool { .. } => unreachable!("Tool 命令已提前处理"), } } @@ -159,7 +159,7 @@ async fn cmd_login(timeout_secs: u64, database: &Arc) { // 存数据库 if let Err(e) = db::models::save_auth(database.pool(), &auth).await { - error!(target: "ias::auth", "保存 auth 到数据库失败: {}", e); + error!(target: "ias::err","[auth-save]到数据库失败: {}", e); } else { info!(target: "ias::auth", "认证信息已保存到数据库"); } @@ -172,7 +172,7 @@ async fn cmd_login(timeout_secs: u64, database: &Arc) { ); println!(" API: {}", result.base_url); } - Err(e) => error!(target: "ias::auth", "登录失败: {}", e), + Err(e) => error!(target: "ias::err","[login] {}", e), } } @@ -252,18 +252,13 @@ async fn cmd_usage( } } -async fn cmd_send( - to: &str, - text: &str, - context_token: Option<&str>, - database: &Arc, -) { +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::auth", "未登录,请先执行 login 命令"); + error!(target: "ias::err","[not-logged-in],请先执行 login 命令"); return; } }; @@ -281,21 +276,21 @@ async fn cmd_send( // 入库:发送的消息 if let Err(e) = db::models::insert_chat_record( - database.pool(), - "outbound", - to, - &account_id, - text, - "manual", - context_token, - &msg_id, - ) - .await + database.pool(), + "outbound", + to, + &account_id, + text, + "manual", + context_token, + &msg_id, + ) + .await { - error!("保存聊天记录失败: {}", e); + error!(target: "ias::err","[chat-record] {}", e); } } - Err(e) => error!("发送失败: {}", e), + Err(e) => error!(target: "ias::err","[send] {}", e), } } @@ -450,232 +445,124 @@ async fn cmd_scheduled_task(action: ScheduledTaskAction, database: &Arc = 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; } - println!("📋 LLM 可调用的工具列表(共 {} 个):\n", specs.len()); - println!("{:=<80}", ""); - - for spec in &specs { - let risk = match spec.risk_level { - tools::types::RiskLevel::Low => "低", - tools::types::RiskLevel::High => "高 ⚠️", - }; - println!("🔹 {}", spec.name); - println!(" 描述: {}", spec.description); - println!(" 风险: {}", risk); - println!(" 超时: {}秒", spec.timeout_secs); - - // 显示参数 - if let Some(props) = spec - .parameters - .get("properties") - .and_then(|v| v.as_object()) - { - if !props.is_empty() { - println!(" 参数:"); - for (name, info) in props { - let desc = info - .get("description") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let required = spec - .parameters - .get("required") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().any(|r| r.as_str() == Some(name))) - .unwrap_or(false); - let req_mark = if required { "*必填" } else { "可选" }; - println!(" - {} ({}) {}", name, req_mark, desc); - } - } + // 提前校验工具名,给出更明确的错误提示 + let spec = match registry.get(name) { + Some(spec) => spec, + None => { + eprintln!("未知工具: {name}"); + eprintln!("运行 ias tool list 查看可用工具"); + std::process::exit(1); } - println!("{:=<80}", ""); + }; + if spec.tool == "memories" { + eprintln!("❌ 记忆工具需要数据库上下文,请通过 daemon/微信对话使用"); + std::process::exit(1); } -} -// ─── 工具 CLI 命令 ─── - -async fn cmd_tool(cmd: ToolCommand) { - match cmd { - ToolCommand::Datetime => { - let result = tools::builtins::datetime::execute(); - println!("{}", result.output); - } - ToolCommand::Memos(action) => { - let result = match action { - MemosAction::List => { - tools::builtins::memos::list_execute(serde_json::json!({})).await - } - MemosAction::Add { content } => { - tools::builtins::memos::add_execute(serde_json::json!({"content": content})) - .await - } - MemosAction::Delete { id } => { - tools::builtins::memos::delete_execute(serde_json::json!({"id": id})).await - } + 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() }; - println!("{}", result.output); - } - ToolCommand::Weather { location, days } => { - let now_params = serde_json::json!({"location": location}); - let fc_params = serde_json::json!({"location": location, "days": days}); - let now = tools::builtins::weather::now_execute(now_params).await; - let fc = tools::builtins::weather::forecast_execute(fc_params).await; - println!("=== 实时天气 ==="); - println!("{}", now.output); - println!("\n=== 天气预报 ==="); - println!("{}", fc.output); - } - ToolCommand::Search { - query, - max_results, - no_answer, - topic, - time_range, - } => { - let mut params = serde_json::json!({ - "query": query, - "max_results": max_results, - }); - if no_answer { - params["include_answer"] = serde_json::json!(false); - } - if let Some(t) = topic { - params["topic"] = serde_json::json!(t); - } - if let Some(tr) = time_range { - params["time_range"] = serde_json::json!(tr); - } - let result = tools::builtins::web_search::execute(params).await; - println!("{}", result.output); - } - ToolCommand::Fetch { url } => { - let params = serde_json::json!({"url": url}); - let result = tools::builtins::fetch_page::execute(params).await; - println!("{}", result.output); - } - ToolCommand::Amap(action) => { - cmd_amap(action).await; + params.insert(key, serde_json::Value::String(val)); } + i += 1; } -} -async fn cmd_amap(action: AmapAction) { - match action { - AmapAction::PoiSearch { - keywords, - city, - location, - radius, - page, - offset, - } => { - let mut params = serde_json::json!({ - "keywords": keywords, - "page": page, - "offset": offset, - }); - if let Some(c) = city { - params["city"] = serde_json::json!(c); - } - if let Some(l) = location { - params["location"] = serde_json::json!(l); - } - if let Some(r) = radius { - params["radius"] = serde_json::json!(r); - } - let result = tools::builtins::amap::poi_search_execute(params).await; - println!( - "{}\n{}", - result.output, - serde_json::to_string(&result.data).unwrap_or_default() - ); - } - AmapAction::Geocode { address, city } => { - let mut params = serde_json::json!({"address": address}); - if let Some(c) = city { - params["city"] = serde_json::json!(c); - } - let result = tools::builtins::amap::geocode_execute(params).await; - println!( - "{}\n{}", - result.output, - serde_json::to_string(&result.data).unwrap_or_default() - ); - } - AmapAction::ReverseGeocode { location } => { - let params = serde_json::json!({"location": location}); - let result = tools::builtins::amap::reverse_geocode_execute(params).await; - println!( - "{}\n{}", - result.output, - serde_json::to_string(&result.data).unwrap_or_default() - ); - } - AmapAction::RoutePlan { - r#type, - origin, - destination, - city, - waypoints, - strategy, - } => { - let mut params = serde_json::json!({ - "type": r#type, - "origin": origin, - "destination": destination, - }); - if let Some(c) = city { - params["city"] = serde_json::json!(c); - } - if let Some(w) = waypoints { - params["waypoints"] = serde_json::json!(w); - } - if let Some(s) = strategy { - params["strategy"] = serde_json::json!(s); - } - let result = tools::builtins::amap::route_plan_execute(params).await; - println!( - "{}\n{}", - result.output, - serde_json::to_string(&result.data).unwrap_or_default() - ); - } - AmapAction::TravelPlan { - city, - interests, - route_type, - } => { - let interests_arr: Vec = interests - .split(',') - .map(|s| serde_json::json!(s.trim())) - .collect(); - let params = serde_json::json!({ - "city": city, - "interests": interests_arr, - "route_type": route_type, - }); - let result = tools::builtins::amap::travel_plan_execute(params).await; - println!( - "{}\n{}", - result.output, - serde_json::to_string(&result.data).unwrap_or_default() - ); - } - AmapAction::MapLink { data } => { - let map_data: serde_json::Value = - serde_json::from_str(&data).unwrap_or(serde_json::json!([])); - let params = serde_json::json!({"map_data": map_data}); - let result = tools::builtins::amap::map_link_execute(params).await; - println!( - "{}\n{}", - result.output, - serde_json::to_string(&result.data).unwrap_or_default() - ); - } + 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 result = registry + .dispatch( + name, + &serde_json::Value::Object(params), + "cli", + true, + &approval, + &memory, + cb, + ) + .await; + + // 检测是否执行出错(匹配 execute_loop 返回的所有错误前缀) + let is_error = result.starts_with("异常退出") + || result.starts_with("启动失败") + || result.starts_with("工具输出非JSON") + || result.starts_with("HTTP失败") + || result.starts_with("读取stdout失败") + || result.starts_with("stdout超时") + || result.starts_with("进程超时") + || result.starts_with("进程异常") + || result.starts_with("未知消息类型") + || result.starts_with("未知工具") + || result.starts_with("不支持的方法") + || result.starts_with("审批失败") + || result.starts_with("审批超时") + || result.starts_with("操作已被用户取消") + || result.starts_with("缺少必填参数") + || result.starts_with("工具 '"); + + if is_error { + eprintln!("❌ {result}"); + std::process::exit(1); + } else { + println!("{result}"); } } diff --git a/src/queue/message_queue.rs b/src/queue/message_queue.rs index 7f9b775..cbec7ea 100644 --- a/src/queue/message_queue.rs +++ b/src/queue/message_queue.rs @@ -23,7 +23,7 @@ //! └─────────────────────────────┘ //! QueueRunner::run() → dequeue → 按 kind 路由到 mpsc 通道 //! ├─ llm_tx (UserMessage, ToolResult) -//! ├─ tool_tx (ToolCall, ApprovalRequest) +//! ├─ tool_tx (ToolCall) //! └─ send_tx (LLMReply) //! ``` @@ -45,12 +45,11 @@ pub enum MessageKind { text: String, message_id: String, context_token: Option, - /// 审批通过后携带的已批准工具名 - approved_tool: Option, }, /// LLM 生成的回复文本 → 发给 Send Consumer LLMReply { text: String, + source: String, context_token: Option, }, /// LLM 请求的工具调用 → 发给 Tool Consumer @@ -71,19 +70,8 @@ pub enum MessageKind { result: String, context_token: Option, }, - /// 高风险工具 → 需用户审批 - ApprovalRequest { - session_id: Uuid, - tool_name: String, - tool_args: String, - /// 审批后要追加到用户消息中的文本 - approval_prompt: String, - }, /// 定时任务到期 → 交给 LLM Consumer - ScheduledTask { - text: String, - task_name: String, - }, + ScheduledTask { text: String, task_name: String }, } /// 管线消息 —— 携带路由元数据的统一消息类型 @@ -99,10 +87,14 @@ pub struct PipelineMessage { impl PipelineMessage { /// 便捷:渠道中的 user_id - pub fn user_id(&self) -> &str { &self.channel.user_id } + pub fn user_id(&self) -> &str { + &self.channel.user_id + } /// 便捷:渠道中的 platform #[allow(dead_code)] - pub fn platform(&self) -> &str { &self.channel.platform } + pub fn platform(&self) -> &str { + &self.channel.platform + } /// 快速构造一条用户消息 #[allow(dead_code)] pub fn user_message( @@ -118,7 +110,6 @@ impl PipelineMessage { text: text.into(), message_id: message_id.into(), context_token, - approved_tool: None, }, } } @@ -129,12 +120,24 @@ impl PipelineMessage { 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, }, } @@ -164,30 +167,6 @@ impl PipelineMessage { } } - /// 快速构造一条已批准的工具调用(跳过审批) - pub fn approved_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: true, - }, - } - } - /// 快速构造一条工具结果 pub fn tool_result( channel: ChannelId, @@ -266,14 +245,15 @@ impl MessageQueue { 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); + && 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 @@ -317,7 +297,7 @@ impl MessageQueue { pub enum ConsumerTarget { /// LLM 消费者(处理 UserMessage, ToolResult) LLM, - /// 工具消费者(处理 ToolCall, ApprovalRequest) + /// 工具消费者(处理 ToolCall) Tool, /// 发送消费者(处理 LLMReply) Send, @@ -330,9 +310,7 @@ impl PipelineMessage { MessageKind::UserMessage { .. } | MessageKind::ToolResult { .. } | MessageKind::ScheduledTask { .. } => ConsumerTarget::LLM, - MessageKind::ToolCall { .. } | MessageKind::ApprovalRequest { .. } => { - ConsumerTarget::Tool - } + MessageKind::ToolCall { .. } => ConsumerTarget::Tool, MessageKind::LLMReply { .. } => ConsumerTarget::Send, } } @@ -405,7 +383,12 @@ mod tests { 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); + 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); } @@ -445,7 +428,12 @@ mod tests { let correlation_id = user_msg.correlation_id; // LLM 回复(模拟 LLM Consumer 生成) - let reply = PipelineMessage::llm_reply(ChannelId::wechat(user_id), correlation_id, "今天晴天", None); + 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); @@ -502,32 +490,38 @@ mod tests { 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); + 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() }, + kind: MessageKind::ScheduledTask { + text: "task".into(), + task_name: "t1".into(), + }, }; assert_eq!(m.target(), ConsumerTarget::LLM); // ToolCall → Tool - let m = PipelineMessage::tool_call(ChannelId::wechat("u1"), Uuid::new_v4(), Uuid::new_v4(), "tc1", "weather", "{}", None); - assert_eq!(m.target(), ConsumerTarget::Tool); - - // ApprovalRequest → Tool - let m = PipelineMessage { - channel: ChannelId::wechat("u1"), - correlation_id: Uuid::new_v4(), - kind: MessageKind::ApprovalRequest { - session_id: Uuid::new_v4(), - tool_name: "high_risk_tool".into(), - tool_args: "{}".into(), - approval_prompt: "".into(), - }, - }; + 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 @@ -551,32 +545,66 @@ mod tests { enqueue.enqueue(user_msg).await; // 入队一个 LLMReply → 应到 send_rx - let reply_msg = PipelineMessage::llm_reply(ChannelId::wechat("u1"), Uuid::new_v4(), "reply", None); + 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 通道关闭"); + 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 通道关闭"); + 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 index 61396eb..2f7b4aa 100644 --- a/src/queue/mod.rs +++ b/src/queue/mod.rs @@ -8,7 +8,7 @@ //! ### 消费者路由 //! ```text //! MessageKind::UserMessage / ToolResult / ScheduledTask → LLM Consumer -//! MessageKind::ToolCall / ApprovalRequest → Tool Consumer +//! MessageKind::ToolCall → Tool Consumer //! MessageKind::LLMReply → Send Consumer //! ``` diff --git a/src/queue/runner.rs b/src/queue/runner.rs index 1f29c8c..91c0196 100644 --- a/src/queue/runner.rs +++ b/src/queue/runner.rs @@ -31,9 +31,9 @@ //! ``` use crate::queue::{ConsumerTarget, MessageQueue, PipelineMessage}; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use tokio::sync::{mpsc, Mutex, Notify}; +use std::sync::atomic::{AtomicBool, Ordering}; +use tokio::sync::{Mutex, Notify, mpsc}; use tracing::{error, info, warn}; /// 消费者通道集 —— 创建 QueueRunner 时获得接收端 @@ -213,7 +213,7 @@ pub async fn drain_and_shutdown( // 3. 等待消费者 task 完成 for handle in consumer_handles { if let Err(e) = handle.await { - error!(target: "ias::queue", "消费者 task 结束异常: {:?}", e); + error!(target: "ias::err","[consumer-panic] {:?}", e); } } diff --git a/src/scheduler.rs b/src/scheduler.rs index ee363c3..056184a 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -80,7 +80,7 @@ impl Scheduler { // 通知用户 let notify_msg = format!("⏰ 定时任务: {}\n结果: {}", task.name, result); if let Err(e) = on_task(&task.user_id, &task.name, ¬ify_msg) { - tracing::error!("发送定时任务通知失败: {}", e); + tracing::error!(target: "ias::err","[scheduler-send] {}", e); } tracing::info!(target: "ias::queue", "✅ 定时任务完成: {} → {}", task.name, result); @@ -100,7 +100,10 @@ struct DueTask { } async fn fetch_due_tasks(pool: &PgPool) -> Result, String> { - let mut tx = pool.begin().await.map_err(|e| format!("开始事务失败: {}", e))?; + 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 @@ -123,7 +126,9 @@ async fn fetch_due_tasks(pool: &PgPool) -> Result, String> { .await .ok(); } - tx.commit().await.map_err(|e| format!("提交事务失败: {}", e))?; + tx.commit() + .await + .map_err(|e| format!("提交事务失败: {}", e))?; Ok(rows .into_iter() @@ -161,8 +166,9 @@ async fn execute_task(task: &DueTask) -> String { .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .kill_on_drop(true) - .output() - ).await; + .output(), + ) + .await; match result { Ok(Ok(output)) => { diff --git a/src/tools/api_tool.rs b/src/tools/api_tool.rs deleted file mode 100644 index 1feb9d7..0000000 --- a/src/tools/api_tool.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! ## API 工具 —— 通过 Rust 代码实现的工具 -//! -//! 所有通过 Rust 代码直接实现的工具(HTTP API 调用、本地计算、文件 I/O 等) -//! 都包装为 `ApiTool`。它是目前最常用的工具类型。 - -use async_trait::async_trait; - -use super::tool::{Tool, ToolHandler}; -use super::types::{SkillResult, SkillSpec}; - -/// ## API 工具 -/// -/// 包装一个异步闭包作为工具实现。 -/// 适用于所有通过 Rust 代码直接实现的工具。 -/// -/// ### 示例 -/// ```rust,ignore -/// let tool = ApiTool::new( -/// datetime::spec(), -/// Arc::new(|params| Box::pin(async move { -/// Ok(SkillResult::ok("done")) -/// })), -/// ); -/// ``` -pub struct ApiTool { - spec: SkillSpec, - handler: ToolHandler, -} - -impl ApiTool { - pub fn new(spec: SkillSpec, handler: ToolHandler) -> Self { - Self { spec, handler } - } -} - -#[async_trait] -impl Tool for ApiTool { - fn spec(&self) -> &SkillSpec { - &self.spec - } - - async fn execute(&self, params: serde_json::Value) -> SkillResult { - (self.handler)(params).await - } -} diff --git a/src/tools/approval.rs b/src/tools/approval.rs index 87454b0..11e7ce6 100644 --- a/src/tools/approval.rs +++ b/src/tools/approval.rs @@ -131,7 +131,11 @@ impl ApprovalManager { /// 处理用户回复(由消息循环调用) /// 返回 (技能名, 决策);匹配失败返回 None - pub async fn handle_reply(&self, user_id: &str, reply: &str) -> Option<(String, ApprovalDecision)> { + 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)?; @@ -216,10 +220,11 @@ impl ApprovalManager { let mut map = self.pending.lock().await; for (uid, i, _) in expired.iter().rev() { if let Some(entries) = map.get_mut(uid) - && *i < entries.len() { - let entry = entries.remove(*i); - let _ = entry.sender.send(ApprovalDecision::Expired); - } + && *i < entries.len() + { + let entry = entries.remove(*i); + let _ = entry.sender.send(ApprovalDecision::Expired); + } } map.retain(|_, v| !v.is_empty()); } diff --git a/src/tools/builtin.rs b/src/tools/builtin.rs deleted file mode 100644 index a1e8d0a..0000000 --- a/src/tools/builtin.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! ## 内置工具注册表 —— 旧接口兼容层 -//! -//! 保留 `BuiltinRegistry` 结构体以保持向后兼容。 -//! 所有方法委托给 `crate::tools::` 模块级函数(新主入口)。 - -use crate::tools::types::{SkillResult, SkillSpec}; - -/// ## 内置工具注册表(旧接口兼容) -/// -/// 保持与现有代码兼容的静态方法接口。 -/// 所有方法委托给 `crate::tools::` 模块级函数。 -#[allow(dead_code)] -pub struct BuiltinRegistry; - -#[allow(dead_code)] -impl BuiltinRegistry { - /// 按名称执行工具 - pub async fn execute(name: &str, args_json: &str) -> Option { - crate::tools::execute(name, args_json).await - } - - /// 返回所有内置工具的 spec 列表 - pub fn specs() -> Vec { - crate::tools::specs() - } - - /// 判断工具是否为高风险 - pub fn is_high_risk(name: &str) -> bool { - crate::tools::is_high_risk(name) - } - - /// 判断名称是否对应一个已注册的内置工具 - pub fn is_builtin(name: &str) -> bool { - crate::tools::is_builtin(name) - } -} diff --git a/src/tools/builtins/amap.rs b/src/tools/builtins/amap.rs deleted file mode 100644 index c6fe7e7..0000000 --- a/src/tools/builtins/amap.rs +++ /dev/null @@ -1,978 +0,0 @@ -//! ## 高德地图综合工具(Rust 原生实现 — v5 API) -//! -//! 提供 6 个高德地图相关的工具: -//! -//! | 工具名 | 功能 | API 版本 | -//! |--------|------|---------| -//! | `amap_poi_search` | POI(地点)搜索 + 周边搜索 | v5 | -//! | `amap_geocode` | 地理编码:地址 → 坐标 | v3 | -//! | `amap_reverse_geocode` | 逆地理编码:坐标 → 地址 | v3 | -//! | `amap_route_plan` | 路径规划(步行/驾车/骑行/公交) | v5 | -//! | `amap_travel_plan` | 智能旅游规划(搜索兴趣点 + 规划路线) | v5 | -//! | `amap_map_link` | 生成地图可视化链接 | - | -//! -//! ### 认证 -//! 环境变量 `AMAP_WEBSERVICE_KEY` 或 `AMAP_KEY` -//! 获取 Key: https://lbs.amap.com/api/webservice/create-project-and-key -//! -//! ### API 端点 -//! - POI 文本搜索: `GET /v5/place/text` -//! - POI 周边搜索: `GET /v5/place/around` -//! - 地理编码: `GET /v3/geocode/geo` -//! - 逆地理编码: `GET /v3/geocode/regeo` -//! - 步行/驾车/骑行/公交路线: `GET /v5/direction/{type}` -//! - 所有请求必须带 `appname=amap-lbs-skill` - -// 高德地图综合工具(Rust 原生实现 — v5 API) -// -// API: -// POI 搜索: GET /v5/place/text → poi 列表 -// POI 周边搜索: GET /v5/place/around → poi 列表 -// 地理编码: GET /v3/geocode/geo → 坐标 -// 逆地理编码: GET /v3/geocode/regeo → 地址 -// 步行路线: GET /v5/direction/walking → 路线 -// 驾车路线: GET /v5/direction/driving → 路线 -// 骑行路线: GET /v5/direction/bicycling → 路线 -// 电动车骑行: GET /v5/direction/electrobike → 路线 -// 公交路线: GET /v5/direction/transit/integrated → 路线 -// -// 认证: AMAP_WEBSERVICE_KEY 或 AMAP_KEY 环境变量 -// 所有请求必须带 appname=amap-lbs-skill - -use reqwest::Client; -use std::time::Duration; -use crate::tools::types::{RiskLevel, SkillResult, SkillSpec}; - -// ═══════════════════════════════════════════════ -// AmapClient — 共享的高德 API 客户端 -// ═══════════════════════════════════════════════ - -struct AmapClient { - http: Client, - key: String, -} - -impl AmapClient { - fn new() -> Result { - let key = std::env::var("AMAP_WEBSERVICE_KEY") - .or_else(|_| std::env::var("AMAP_KEY")) - .map_err(|_| { - "未设置高德 API Key,请设置环境变量 AMAP_WEBSERVICE_KEY 或 AMAP_KEY。\ - 获取 Key: https://lbs.amap.com/api/webservice/create-project-and-key" - .to_string() - })?; - - Ok(Self { - http: Client::builder() - .timeout(Duration::from_secs(15)) - .build() - .map_err(|e| format!("创建 HTTP client 失败: {}", e))?, - key, - }) - } - - /// POI 文本搜索 (v5) - async fn poi_text_search( - &self, - keywords: &str, - city: Option<&str>, - page_num: u32, - page_size: u32, - ) -> Result { - let region = city.unwrap_or(""); - let pn = page_num.to_string(); - let ps = page_size.to_string(); - - let resp: serde_json::Value = self - .http - .get("https://restapi.amap.com/v5/place/text") - .query(&[ - ("key", self.key.as_str()), - ("keywords", keywords), - ("region", region), - ("page_num", &pn), - ("page_size", &ps), - ("appname", "amap-lbs-skill"), - ]) - .send() - .await - .map_err(|e| format!("POI 搜索请求失败: {}", e))? - .json() - .await - .map_err(|e| format!("解析 POI 搜索响应失败: {}", e))?; - - if resp["status"].as_str() != Some("1") { - return Err(format!( - "POI 搜索失败: {}", - resp["info"].as_str().unwrap_or("未知错误") - )); - } - Ok(resp) - } - - /// POI 周边搜索 (v5) - async fn poi_around( - &self, - keywords: &str, - location: &str, - radius: u32, - page_num: u32, - page_size: u32, - sortrule: &str, - ) -> Result { - let pn = page_num.to_string(); - let ps = page_size.to_string(); - let r = radius.to_string(); - - let resp: serde_json::Value = self - .http - .get("https://restapi.amap.com/v5/place/around") - .query(&[ - ("key", self.key.as_str()), - ("keywords", keywords), - ("location", location), - ("radius", &r), - ("page_num", &pn), - ("page_size", &ps), - ("sortrule", sortrule), - ("appname", "amap-lbs-skill"), - ]) - .send() - .await - .map_err(|e| format!("周边搜索请求失败: {}", e))? - .json() - .await - .map_err(|e| format!("解析周边搜索响应失败: {}", e))?; - - if resp["status"].as_str() != Some("1") { - return Err(format!( - "周边搜索失败: {}", - resp["info"].as_str().unwrap_or("未知错误") - )); - } - Ok(resp) - } - - /// 地理编码:地址 → 坐标 (v3) - async fn geocode( - &self, - address: &str, - city: Option<&str>, - ) -> Result { - let mut params = vec![ - ("key", self.key.as_str()), - ("address", address), - ("output", "JSON"), - ("appname", "amap-lbs-skill"), - ]; - let city_val; - if let Some(c) = city { - city_val = c.to_string(); - params.push(("city", &city_val)); - } - - let resp: serde_json::Value = self - .http - .get("https://restapi.amap.com/v3/geocode/geo") - .query(¶ms) - .send() - .await - .map_err(|e| format!("地理编码请求失败: {}", e))? - .json() - .await - .map_err(|e| format!("解析地理编码响应失败: {}", e))?; - - if resp["status"].as_str() != Some("1") { - return Err(format!( - "地理编码失败: {}", - resp["info"].as_str().unwrap_or("未知错误") - )); - } - Ok(resp) - } - - /// 逆地理编码:坐标 → 地址 (v3) - async fn regeocode(&self, location: &str) -> Result { - let resp: serde_json::Value = self - .http - .get("https://restapi.amap.com/v3/geocode/regeo") - .query(&[ - ("key", self.key.as_str()), - ("location", location), - ("output", "JSON"), - ("appname", "amap-lbs-skill"), - ]) - .send() - .await - .map_err(|e| format!("逆地理编码请求失败: {}", e))? - .json() - .await - .map_err(|e| format!("解析逆地理编码响应失败: {}", e))?; - - if resp["status"].as_str() != Some("1") { - return Err(format!( - "逆地理编码失败: {}", - resp["info"].as_str().unwrap_or("未知错误") - )); - } - Ok(resp) - } - - /// 步行路线规划 (v5) - async fn walking_route( - &self, - origin: &str, - destination: &str, - ) -> Result { - let resp: serde_json::Value = self - .http - .get("https://restapi.amap.com/v5/direction/walking") - .query(&[ - ("key", self.key.as_str()), - ("origin", origin), - ("destination", destination), - ("show_fields", "cost"), - ("appname", "amap-lbs-skill"), - ]) - .send() - .await - .map_err(|e| format!("步行路线请求失败: {}", e))? - .json() - .await - .map_err(|e| format!("解析步行路线响应失败: {}", e))?; - - if resp["status"].as_str() != Some("1") { - return Err(format!( - "步行路线失败: {}", - resp["info"].as_str().unwrap_or("未知错误") - )); - } - Ok(resp) - } - - /// 驾车路线规划 (v5) - async fn driving_route( - &self, - origin: &str, - destination: &str, - waypoints: Option<&str>, - strategy: u32, - ) -> Result { - let ss = strategy.to_string(); - let mut params = vec![ - ("key", self.key.as_str()), - ("origin", origin), - ("destination", destination), - ("strategy", &ss), - // ("show_fields", "cost"), - ("appname", "amap-lbs-skill"), - ]; - if let Some(wp) = waypoints { - params.push(("waypoints", wp)); - } - - tracing::info!(target: "ias::tool", - "驾车路线请求 parms: {:?}", - serde_json::to_string(¶ms).unwrap_or_default() - ); - - let resp: serde_json::Value = self - .http - .get("https://restapi.amap.com/v5/direction/driving") - .query(¶ms) - .send() - .await - .map_err(|e| format!("驾车路线请求失败: {}", e))? - .json() - .await - .map_err(|e| format!("解析驾车路线响应失败: {}", e))?; - - if resp["status"].as_str() != Some("1") { - tracing::info!(target: "ias::tool", - "驾车路线失败 parms: {:?}", - serde_json::to_string(&resp).unwrap_or_default() - ); - return Err(format!( - "驾车路线失败: {}", - resp["info"].as_str().unwrap_or("未知错误") - )); - } - Ok(resp) - } - - /// 骑行路线规划 (v5) - async fn riding_route( - &self, - origin: &str, - destination: &str, - ) -> Result { - let resp: serde_json::Value = self - .http - .get("https://restapi.amap.com/v5/direction/bicycling") - .query(&[ - ("key", self.key.as_str()), - ("origin", origin), - ("destination", destination), - // ("show_fields", "cost"), - ("appname", "amap-lbs-skill"), - ]) - .send() - .await - .map_err(|e| format!("骑行路线请求失败: {}", e))? - .json() - .await - .map_err(|e| format!("解析骑行路线响应失败: {}", e))?; - - if resp["status"].as_str() != Some("1") { - return Err(format!( - "骑行路线失败: {}", - resp["info"].as_str().unwrap_or("未知错误") - )); - } - Ok(resp) - } - - /// 电动车骑行路线规划 (v5) - async fn electrobike_route( - &self, - origin: &str, - destination: &str, - ) -> Result { - let resp: serde_json::Value = self - .http - .get("https://restapi.amap.com/v5/direction/electrobike") - .query(&[ - ("key", self.key.as_str()), - ("origin", origin), - ("destination", destination), - ("show_fields", "cost"), - ("appname", "amap-lbs-skill"), - ]) - .send() - .await - .map_err(|e| format!("电动车骑行路线请求失败: {}", e))? - .json() - .await - .map_err(|e| format!("解析电动车骑行路线响应失败: {}", e))?; - - if resp["status"].as_str() != Some("1") { - return Err(format!( - "电动车骑行路线失败: {}", - resp["info"].as_str().unwrap_or("未知错误") - )); - } - Ok(resp) - } - - /// 公交路线规划 (v5) - async fn transit_route( - &self, - origin: &str, - destination: &str, - city1: &str, - city2: &str, - strategy: u32, - ) -> Result { - let resp: serde_json::Value = self - .http - .get("https://restapi.amap.com/v5/direction/transit/integrated") - .query(&[ - ("key", self.key.as_str()), - ("origin", origin), - ("destination", destination), - ("city1", city1), - ("city2", city2), - ("strategy", &strategy.to_string()), - ("show_fields", "cost"), - ("appname", "amap-lbs-skill"), - ]) - .send() - .await - .map_err(|e| format!("公交路线请求失败: {}", e))? - .json() - .await - .map_err(|e| format!("解析公交路线响应失败: {}", e))?; - - if resp["status"].as_str() != Some("1") { - return Err(format!( - "公交路线失败: {}", - resp["info"].as_str().unwrap_or("未知错误") - )); - } - Ok(resp) - } -} - -// ═══════════════════════════════════════════════ -// 辅助:URL 编码 -// ═══════════════════════════════════════════════ - -// ═══════════════════════════════════════════════ -// 1. amap_poi_search — POI 搜索 -// ═══════════════════════════════════════════════ - -pub fn poi_search_spec() -> SkillSpec { - SkillSpec { - name: "amap_poi_search".into(), - description: "高德地图 POI(地点)搜索,支持关键词搜索、城市限定、周边搜索。\ - 参数 keywords 为搜索关键词,city 为城市名(可选),location 为中心点坐标'经度,纬度'(周边搜索时必填),\ - radius 为搜索半径米(默认1000),page 为页码(默认1),offset 为每页数量(默认10,最大25)" - .into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "keywords": {"type": "string", "description": "搜索关键词,如 肯德基、酒店、景点"}, - "city": {"type": "string", "description": "城市名称或城市编码,如 北京、上海"}, - "location": {"type": "string", "description": "中心点坐标,格式:经度,纬度。用于周边搜索"}, - "radius": {"type": "integer", "description": "搜索半径(米),默认1000"}, - "page": {"type": "integer", "description": "页码,默认1"}, - "offset": {"type": "integer", "description": "每页数量,默认10,最大25"} - }, - "required": ["keywords"] - }), - timeout_secs: 15, - } -} - -pub async fn poi_search_execute(params: serde_json::Value) -> SkillResult { - let keywords = params["keywords"].as_str().unwrap_or(""); - if keywords.is_empty() { - return SkillResult::error("参数错误:缺少 keywords(搜索关键词)"); - } - - let city = params["city"].as_str(); - let location = params["location"].as_str(); - let radius = params["radius"].as_u64().unwrap_or(1000) as u32; - // CLI 参数兼容:page/offset → v5 page_num/page_size - let page_num = params["page"].as_u64().unwrap_or(1) as u32; - let page_size = params["offset"].as_u64().unwrap_or(10).min(25) as u32; - - let client = match AmapClient::new() { - Ok(c) => c, - Err(e) => return SkillResult::error(e), - }; - - let result = if let Some(loc) = location { - tracing::info!(target: "ias::tool", - "📍 周边搜索: {} @ {} (radius={}m, sort=distance)", - keywords, - loc, - radius - ); - client - .poi_around(keywords, loc, radius, page_num, page_size, "distance") - .await - } else { - tracing::info!(target: "ias::tool", "🔍 POI 搜索: {} city={:?}", keywords, city); - client - .poi_text_search(keywords, city, page_num, page_size) - .await - }; - - match result { - Ok(data) => SkillResult::ok_with_data("✅ POI 搜索完成".to_string(), data), - Err(e) => SkillResult::error(e), - } -} - -// ═══════════════════════════════════════════════ -// 2. amap_geocode — 地理编码 -// ═══════════════════════════════════════════════ - -pub fn geocode_spec() -> SkillSpec { - SkillSpec { - name: "amap_geocode".into(), - description: - "高德地图地理编码:将地址转换为经纬度坐标。参数 address 为地址名称,city 为城市名(可选)" - .into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "address": {"type": "string", "description": "地址名称,如 西直门、北京南站、天安门"}, - "city": {"type": "string", "description": "城市名称,可选,用于缩小搜索范围"} - }, - "required": ["address"] - }), - timeout_secs: 10, - } -} - -pub async fn geocode_execute(params: serde_json::Value) -> SkillResult { - let address = params["address"].as_str().unwrap_or(""); - if address.is_empty() { - return SkillResult::error("参数错误:缺少 address(地址名称)"); - } - let city = params["city"].as_str(); - - let client = match AmapClient::new() { - Ok(c) => c, - Err(e) => return SkillResult::error(e), - }; - - tracing::info!(target: "ias::tool", "📍 地理编码: {} city={:?}", address, city); - - match client.geocode(address, city).await { - Ok(data) => SkillResult::ok_with_data("✅ 地理编码完成".to_string(), data), - Err(e) => SkillResult::error(e), - } -} - -// ═══════════════════════════════════════════════ -// 3. amap_reverse_geocode — 逆地理编码 -// ═══════════════════════════════════════════════ - -pub fn reverse_geocode_spec() -> SkillSpec { - SkillSpec { - name: "amap_reverse_geocode".into(), - description: - "高德地图逆地理编码:将经纬度坐标转换为地址描述。参数 location 为坐标,格式'经度,纬度'" - .into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "location": {"type": "string", "description": "经纬度坐标,格式:经度,纬度"} - }, - "required": ["location"] - }), - timeout_secs: 10, - } -} - -pub async fn reverse_geocode_execute(params: serde_json::Value) -> SkillResult { - let location = params["location"].as_str().unwrap_or(""); - if location.is_empty() { - return SkillResult::error("参数错误:缺少 location(经纬度坐标)"); - } - - let client = match AmapClient::new() { - Ok(c) => c, - Err(e) => return SkillResult::error(e), - }; - - tracing::info!(target: "ias::tool", "📍 逆地理编码: {}", location); - - match client.regeocode(location).await { - Ok(data) => SkillResult::ok_with_data("✅ 逆地理编码完成".to_string(), data), - Err(e) => SkillResult::error(e), - } -} - -// ═══════════════════════════════════════════════ -// 4. amap_route_plan — 路径规划 -// ═══════════════════════════════════════════════ - -pub fn route_plan_spec() -> SkillSpec { - SkillSpec { - name: "amap_route_plan".into(), - description: "高德地图路径规划(v5),支持步行(walking)、驾车(driving)、骑行(riding)、电动车(electrobike)、公交(transit)。\ - 参数 origin/destination 为起终点坐标'经度,纬度',type 为出行方式,\ - city 为城市 citycode(公交必填,用于 city1/city2),waypoints 为途经点(驾车可选,多个用;分隔)" - .into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "type": {"type": "string", "description": "出行方式", "enum": ["walking", "driving", "riding", "electrobike", "transit"]}, - "origin": {"type": "string", "description": "起点坐标,格式:经度,纬度"}, - "destination": {"type": "string", "description": "终点坐标,格式:经度,纬度"}, - "city": {"type": "string", "description": "城市 citycode(公交方式必填,同时用作 city1 和 city2)"}, - "waypoints": {"type": "string", "description": "途经点坐标,多个用;分隔(仅驾车方式)"}, - "strategy": {"type": "integer", "description": "策略:驾车默认32(高德推荐),公交默认0(推荐模式)"} - }, - "required": ["type", "origin", "destination"] - }), - timeout_secs: 15, - } -} - -pub async fn route_plan_execute(params: serde_json::Value) -> SkillResult { - let route_type = params["type"].as_str().unwrap_or(""); - let origin = params["origin"].as_str().unwrap_or(""); - let destination = params["destination"].as_str().unwrap_or(""); - - if route_type.is_empty() || origin.is_empty() || destination.is_empty() { - return SkillResult::error("参数错误:缺少 type/origin/destination"); - } - - let client = match AmapClient::new() { - Ok(c) => c, - Err(e) => return SkillResult::error(e), - }; - - tracing::info!(target: "ias::tool", - "🛣️ 路径规划: type={}, {} → {}", - route_type, - origin, - destination - ); - - match route_type { - "walking" => match client.walking_route(origin, destination).await { - Ok(data) => SkillResult::ok_with_data("✅ 步行路线规划完成".to_string(), data), - Err(e) => SkillResult::error(e), - }, - "driving" => { - let waypoints = params["waypoints"].as_str(); - let strategy = params["strategy"].as_u64().unwrap_or(32) as u32; - match client - .driving_route(origin, destination, waypoints, strategy) - .await - { - Ok(data) => SkillResult::ok_with_data("✅ 驾车路线规划完成".to_string(), data), - Err(e) => SkillResult::error(e), - } - } - "riding" => match client.riding_route(origin, destination).await { - Ok(data) => SkillResult::ok_with_data("✅ 骑行路线规划完成".to_string(), data), - Err(e) => SkillResult::error(e), - }, - "electrobike" => match client.electrobike_route(origin, destination).await { - Ok(data) => SkillResult::ok_with_data("✅ 电动车路线规划完成".to_string(), data), - Err(e) => SkillResult::error(e), - }, - "transit" => { - let city_code = params["city"].as_str().unwrap_or(""); - if city_code.is_empty() { - return SkillResult::error( - "公交路线需要提供 city 参数(城市 citycode,如 010 表示北京)", - ); - } - let strategy = params["strategy"].as_u64().unwrap_or(0) as u32; - match client - .transit_route(origin, destination, city_code, city_code, strategy) - .await - { - Ok(data) => SkillResult::ok_with_data("✅ 公交路线规划完成".to_string(), data), - Err(e) => SkillResult::error(e), - } - } - other => SkillResult::error(format!( - "不支持的出行方式: {other},支持 walking/driving/riding/electrobike/transit" - )), - } -} - -// ═══════════════════════════════════════════════ -// 5. amap_travel_plan — 旅游规划 -// ═══════════════════════════════════════════════ - -pub fn travel_plan_spec() -> SkillSpec { - SkillSpec { - name: "amap_travel_plan".into(), - description: "高德地图智能旅游规划:自动搜索城市内的兴趣点并规划游览路线。\ - 参数 city 为城市名(必填),interests 为兴趣点关键词数组如['景点','美食'],\ - route_type 为路线类型 walking/driving/riding/electrobike/transit(默认walking)" - .into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "city": {"type": "string", "description": "城市名称,如 北京、杭州、上海"}, - "interests": {"type": "array", "items": {"type": "string"}, "description": "兴趣点关键词,如 ['景点','美食','酒店']"}, - "route_type": {"type": "string", "description": "路线类型", "enum": ["walking", "driving", "riding", "electrobike", "transit"]} - }, - "required": ["city"] - }), - timeout_secs: 60, - } -} - -pub async fn travel_plan_execute(params: serde_json::Value) -> SkillResult { - let city = params["city"].as_str().unwrap_or(""); - if city.is_empty() { - return SkillResult::error("参数错误:缺少 city(城市名称)"); - } - - let interests: Vec<&str> = params["interests"] - .as_array() - .map(|a| a.iter().filter_map(|v| v.as_str()).collect()) - .unwrap_or_else(|| vec!["景点", "美食"]); - - let route_type = params["route_type"].as_str().unwrap_or("walking"); - let valid_types = ["walking", "driving", "riding", "electrobike", "transit"]; - if !valid_types.contains(&route_type) { - return SkillResult::error(format!( - "无效的路线类型: {route_type},支持 walking/driving/riding/electrobike/transit" - )); - } - - let client = match AmapClient::new() { - Ok(c) => c, - Err(e) => return SkillResult::error(e), - }; - - tracing::info!(target: "ias::tool", - "🗺️ 旅游规划: city={}, interests={:?}, route={}", - city, - interests, - route_type - ); - - let mut all_pois: Vec = Vec::new(); - let mut map_tasks: Vec = Vec::new(); - - // 第一步:搜索各类兴趣点 - for interest in &interests { - tracing::info!(target: "ias::tool", "📍 搜索 {}...", interest); - match client.poi_text_search(interest, Some(city), 1, 5).await { - Ok(data) => { - if let Some(pois) = data["pois"].as_array() { - for poi in pois { - let name = poi["name"].as_str().unwrap_or("未知"); - let address = poi["address"].as_str().unwrap_or(""); - let loc_str = poi["location"].as_str().unwrap_or("0,0"); - let parts: Vec = - loc_str.split(',').filter_map(|s| s.parse().ok()).collect(); - let lng = parts.first().copied().unwrap_or(0.0); - let lat = parts.get(1).copied().unwrap_or(0.0); - - map_tasks.push(serde_json::json!({ - "type": "poi", - "lnglat": [lng, lat], - "sort": interest, - "text": name, - "remark": address - })); - } - all_pois.extend(pois.clone()); - tracing::info!(target: "ias::tool", " 找到 {} 个 {}", pois.len(), interest); - } - } - Err(e) => tracing::warn!(target: "ias::tool", " 搜索 {} 失败: {}", interest, e), - } - } - - if all_pois.is_empty() { - return SkillResult::error(format!("在 {city} 未找到相关兴趣点")); - } - - // 第二步:规划 POI 之间的路线 - if all_pois.len() >= 2 { - for i in 0..all_pois.len() - 1 { - let start = &all_pois[i]; - let end = &all_pois[i + 1]; - let start_loc = start["location"].as_str().unwrap_or("0,0"); - let end_loc = end["location"].as_str().unwrap_or("0,0"); - - let start_parts: Vec = start_loc - .split(',') - .filter_map(|s| s.parse().ok()) - .collect(); - let end_parts: Vec = end_loc.split(',').filter_map(|s| s.parse().ok()).collect(); - - let start_name = start["name"].as_str().unwrap_or("?"); - let end_name = end["name"].as_str().unwrap_or("?"); - - let mut route_task = serde_json::json!({ - "type": "route", - "routeType": route_type, - "start": start_parts, - "end": end_parts, - "remark": format!("从 {start_name} 到 {end_name}") - }); - - if route_type == "transit" { - route_task["city1"] = serde_json::json!(city); - route_task["city2"] = serde_json::json!(city); - } - - map_tasks.push(route_task); - } - } - - let map_link = generate_map_link(&map_tasks); - - let route_type_cn = match route_type { - "walking" => "步行", - "driving" => "驾车", - "riding" => "骑行", - "electrobike" => "电动车骑行", - "transit" => "公交", - _ => route_type, - }; - - let poi_names: Vec<&str> = all_pois.iter().filter_map(|p| p["name"].as_str()).collect(); - - let text = format!( - "🗺️ {city} 旅游规划完成({route_type_cn})\n📍 推荐地点({} 个):{}\n🔗 地图链接:{map_link}", - all_pois.len(), - poi_names.join(" → ") - ); - - let output = serde_json::json!({ - "city": city, - "route_type": route_type, - "poi_count": all_pois.len(), - "pois": all_pois, - "route_count": map_tasks.iter().filter(|t| t["type"] == "route").count(), - "map_tasks": map_tasks, - "map_link": map_link, - }); - - SkillResult::ok_with_data(text, output) -} - -// ═══════════════════════════════════════════════ -// 6. amap_map_link — 生成地图可视化链接 -// ═══════════════════════════════════════════════ - -pub fn map_link_spec() -> SkillSpec { - SkillSpec { - name: "amap_map_link".into(), - description: "生成高德地图可视化链接,支持 POI 和路线展示。\ - 参数 map_data 为数组,每项包含:type('poi'|'route')、lnglat/start/end、sort/text/remark 等字段" - .into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "map_data": {"type": "array", "description": "地图数据数组"} - }, - "required": ["map_data"] - }), - timeout_secs: 5, - } -} - -pub async fn map_link_execute(params: serde_json::Value) -> SkillResult { - let map_data = match params.get("map_data") { - Some(serde_json::Value::Array(arr)) => arr.clone(), - Some(_) => return SkillResult::error("map_data 必须是数组"), - None => return SkillResult::error("缺少 map_data 参数"), - }; - - if map_data.is_empty() { - return SkillResult::error("map_data 不能为空"); - } - - let link = generate_map_link(&map_data); - let text = format!("🗺️ 地图可视化链接:\n{link}"); - let output = serde_json::json!({ - "map_link": link, - "item_count": map_data.len() - }); - SkillResult::ok_with_data(text, output) -} - -fn generate_map_link(map_task_data: &[serde_json::Value]) -> String { - let base_url = "https://a.amap.com/jsapi_demo_show/static/openclaw/travel_plan.html"; - let data_str = serde_json::to_string(map_task_data).unwrap_or_default(); - let encoded = urlencoding(&data_str); - format!("{base_url}?data={encoded}") -} - -fn urlencoding(s: &str) -> String { - let mut out = String::with_capacity(s.len() * 3); - for c in s.chars() { - match c { - ' ' => out.push_str("%20"), - ':' => out.push_str("%3A"), - '/' => out.push_str("%2F"), - '?' => out.push_str("%3F"), - '#' => out.push_str("%23"), - '[' => out.push_str("%5B"), - ']' => out.push_str("%5D"), - '@' => out.push_str("%40"), - '!' => out.push_str("%21"), - '$' => out.push_str("%24"), - '&' => out.push_str("%26"), - '\'' => out.push_str("%27"), - '(' => out.push_str("%28"), - ')' => out.push_str("%29"), - '*' => out.push_str("%2A"), - '+' => out.push_str("%2B"), - ',' => out.push_str("%2C"), - ';' => out.push_str("%3B"), - '=' => out.push_str("%3D"), - '%' => out.push_str("%25"), - '{' => out.push_str("%7B"), - '}' => out.push_str("%7D"), - '"' => out.push_str("%22"), - '\\' => out.push_str("%5C"), - other => out.push(other), - } - } - out -} - -// ═══════════════════════════════════════════════ -// 统一分发入口 -// ═══════════════════════════════════════════════ - -#[allow(dead_code)] -pub fn specs() -> Vec { - vec![ - poi_search_spec(), - geocode_spec(), - reverse_geocode_spec(), - route_plan_spec(), - travel_plan_spec(), - map_link_spec(), - ] -} - -#[allow(dead_code)] -pub async fn execute(name: &str, params: serde_json::Value) -> Option { - match name { - "amap_poi_search" => Some(poi_search_execute(params).await), - "amap_geocode" => Some(geocode_execute(params).await), - "amap_reverse_geocode" => Some(reverse_geocode_execute(params).await), - "amap_route_plan" => Some(route_plan_execute(params).await), - "amap_travel_plan" => Some(travel_plan_execute(params).await), - "amap_map_link" => Some(map_link_execute(params).await), - _ => None, - } -} - -// ═══════════════════════════════════════════════ -// 测试 -// ═══════════════════════════════════════════════ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_url_encoding() { - let encoded = urlencoding("https://example.com/path?q=hello world"); - assert!(!encoded.contains("://")); - assert!(encoded.contains("%3A%2F%2F")); - assert!(encoded.contains("%20")); - } - - #[test] - fn test_specs_count() { - let s = specs(); - assert_eq!(s.len(), 6); - for spec in &s { - assert!(!spec.name.is_empty()); - assert!(!spec.description.is_empty()); - } - } - - #[tokio::test] - async fn test_poi_search_integration() { - if std::env::var("AMAP_WEBSERVICE_KEY").is_err() && std::env::var("AMAP_KEY").is_err() { - eprintln!("跳过集成测试: 未设置 AMAP_WEBSERVICE_KEY"); - return; - } - let result = - poi_search_execute(serde_json::json!({"keywords": "肯德基", "city": "北京"})).await; - assert!(result.success, "POI 搜索失败: {}", result.output); - } - - #[tokio::test] - async fn test_geocode_integration() { - if std::env::var("AMAP_WEBSERVICE_KEY").is_err() && std::env::var("AMAP_KEY").is_err() { - eprintln!("跳过集成测试: 未设置 AMAP_WEBSERVICE_KEY"); - return; - } - let result = geocode_execute(serde_json::json!({"address": "天安门"})).await; - assert!(result.success, "地理编码失败: {}", result.output); - let data = result.data.unwrap(); - assert!(data["location"].as_str().is_some()); - } -} diff --git a/src/tools/builtins/datetime.rs b/src/tools/builtins/datetime.rs deleted file mode 100644 index e80cd54..0000000 --- a/src/tools/builtins/datetime.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! ## 日期时间工具 —— 获取当前北京时间 -//! -//! 最简单的内置工具,无网络依赖,纯粹返回本地时间。 -//! 用于 LLM 需要知道当前时间时调用。 -//! -//! ### 风险等级 -//! `Low` — 无需用户确认,直接执行 -//! -//! ### 输出格式 -//! ```json -//! {"datetime": "2026-06-10 14:30:00"} -//! ``` - -use chrono::Local; - -use super::super::types::{RiskLevel, SkillResult, SkillSpec}; - -/// 返回该工具的元数据描述(供 query_capabilities 元工具使用) -pub fn spec() -> SkillSpec { - SkillSpec { - name: "get_current_datetime".into(), - description: "获取当前日期时间(北京时间 UTC+8)".into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({"type":"object","properties":{"format":{"type":"string"}}}), - timeout_secs: 5, - } -} - -/// 执行日期时间查询 -/// -/// 使用 chrono::Local 获取系统当前时间,格式化为 "YYYY-MM-DD HH:MM:SS"。 -/// 注意:系统时区需设置为 Asia/Shanghai(北京时间 UTC+8)。 -pub fn execute() -> SkillResult { - let now = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); - SkillResult::ok(serde_json::json!({"datetime": now}).to_string()) -} diff --git a/src/tools/builtins/fetch_page.rs b/src/tools/builtins/fetch_page.rs deleted file mode 100644 index 467a490..0000000 --- a/src/tools/builtins/fetch_page.rs +++ /dev/null @@ -1,359 +0,0 @@ -//! ## 网页内容提取工具 —— 可读性算法提取正文 -//! -//! 自动识别网页正文区域,清除广告、导航、页脚等噪声, -//! 按文本密度评分,取最高分区域作为正文。 -//! -//! ### 提取策略(三层降级) -//! 1. **自定义选择器** — 从 `~/.ias/site_selectors.json` 读取按站点的 CSS 选择器 -//! 2. **可读性算法** — 清除噪声 → 对块级元素评分 → 取最高分 -//! 3. **回退** — 取 `` 的全部文本 -//! -//! ### 评分因子 -//! - 文本长度(基础分) -//! - 逗号/句号数(句子结构丰富度) -//! - `

` 标签数(段落丰富度) -//! - 链接密度惩罚(链接太多 → 导航/索引) -//! - 代码块惩罚(代码多 → 降分) -//! -//! ### 配置 -//! 站点选择器配置文件: `~/.ias/site_selectors.json` -//! 格式: `{"example.com": ["article.main", "#content"]}` - -use reqwest::Client as HttpClient; -use scraper::{ElementRef, Html, Selector}; -use std::collections::HashMap; - -use super::super::types::{RiskLevel, SkillResult, SkillSpec}; - -/// 站点选择器配置文件路径(相对于 home 目录) -const CONFIG_PATH: &str = ".ias/site_selectors.json"; - -/// 最大输出字符数(超过则截断) -const MAX_OUTPUT_CHARS: usize = 6000; - -/// 需要移除的 HTML 标签列表(这些标签内的内容不可能是正文) -const STRIP_TAGS: &[&str] = &[ - "script", "style", "noscript", "iframe", "svg", - "nav", "header", "footer", "aside", "form", -]; - -/// 需要移除的 class/id 关键词列表 -/// 匹配元素的 class 或 id 属性,包含这些关键词的视为噪声元素 -const NOISE_PATTERNS: &[&str] = &[ - "ad", "ads", "advert", "banner", - "sidebar", "side-bar", "widget", - "footer", "header", "nav", "menu", "navbar", - "comment", "comments", - "social", "share", "sharing", - "related", "recommend", "recommended", - "subscribe", "newsletter", "signup", - "cookie", "consent", "popup", "modal", - "breadcrumb", "pagination", - "print", "disclaimer", - "toc", "table-of-contents", - "metadata", "infobox", "navbox", - "reference", "references", "reflist", - "catlinks", "mw-jump-link", "mw-editsection", - "edit-section", "noprint", "thumb", -]; - -/// 内容评分阈值:低于此分的元素不视为正文候选 -const MIN_SCORE_THRESHOLD: f64 = 50.0; - -// ─── 工具 spec ─── - -pub fn spec() -> SkillSpec { - SkillSpec { - name: "fetch_page".into(), - description: "抓取网页并自动提取正文内容。自动过滤广告、导航、页脚等噪声,适用于阅读文章、获取信息。可通过 ~/.ias/site_selectors.json 配置按站点的 CSS 选择器精确定位。".into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "url": { "type": "string", "description": "网页 URL" } - }, - "required": ["url"] - }), - timeout_secs: 30, - } -} - -// ─── 执行 ─── - -pub async fn execute(params: serde_json::Value) -> SkillResult { - let url_str = params - .get("url") - .and_then(|v| v.as_str()) - .or_else(|| params.get("prompt").and_then(|v| v.as_str())) - .unwrap_or(""); - - if url_str.is_empty() { - return SkillResult::error("请提供 url 参数"); - } - - let url = match reqwest::Url::parse(url_str) { - Ok(u) => u, - Err(e) => return SkillResult::error(format!("无效的 URL: {}", e)), - }; - let domain = url.host_str().unwrap_or("unknown"); - - // 抓取页面 - let http = match HttpClient::builder() - .timeout(std::time::Duration::from_secs(20)) - .user_agent("Mozilla/5.0 (compatible; iAs-Bot/1.0)") - .build() - { - Ok(c) => c, - Err(e) => return SkillResult::error(format!("创建 HTTP 客户端失败: {}", e)), - }; - - let resp = match http.get(url.clone()).send().await { - Ok(r) => r, - Err(e) => { - if e.is_timeout() { return SkillResult::error("请求超时"); } - return SkillResult::error(format!("请求失败: {}", e)); - } - }; - - if !resp.status().is_success() { - return SkillResult::error(format!("HTTP {}", resp.status())); - } - - let html_text = match resp.text().await { - Ok(t) => t, - Err(e) => return SkillResult::error(format!("读取响应失败: {}", e)), - }; - - let document = Html::parse_document(&html_text); - let title = extract_title(&document); - - // ── 提取正文 ── - let content = extract_content(&document, domain); - - let display = if content.chars().count() > MAX_OUTPUT_CHARS { - let truncated: String = content.chars().take(MAX_OUTPUT_CHARS).collect(); - format!("{}\n\n... (内容已截断)", truncated) - } else { - content - }; - - let mut output = String::new(); - output.push_str(&format!("📄 {}\n\n🔗 {}\n\n{}", title, url_str, display)); - - SkillResult::ok(output) -} - -// ─── 核心:可读性提取 ─── - -/// 提取主要内容 -fn extract_content(document: &Html, domain: &str) -> String { - // 1. 先尝试自定义选择器 - let config = load_config(); - if let Some(selectors) = config.get(domain) { - for sel_str in selectors { - if let Some(text) = try_selector(document, sel_str) - && text.len() > 50 { - return text; - } - } - } - - // 2. 先用可读性算法 - if let Some(text) = readability_extract(document) - && text.len() > 50 { - return text; - } - - // 3. 回退到 body - if let Some(text) = try_selector(document, "body") { - return text; - } - - "无法提取内容".to_string() -} - -/// 可读性算法:清除噪声 → 评分 → 取最高分 -fn readability_extract(document: &Html) -> Option { - let body = document.root_element(); - - // 直接对原始文档评分(跳过噪声元素) - let candidates = score_elements(&body); - - // 取最高分 - candidates - .into_iter() - .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) - .map(|(el, _score)| { - let text = extract_clean_text(&el); - // 合并空白行 - let lines: Vec<&str> = text.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect(); - lines.join("\n") - }) - .filter(|t| t.len() > 50) -} - -/// 评分所有候选元素 -fn score_elements<'a>(root: &ElementRef<'a>) -> Vec<(ElementRef<'a>, f64)> { - let mut candidates: Vec<(ElementRef, f64)> = Vec::new(); - - // 收集所有块级元素 - let block_tags = [ - "div", "article", "section", "main", "p", - ]; - - for tag in &block_tags { - if let Ok(sel) = Selector::parse(tag) { - for el in root.select(&sel) { - // 跳过噪声 - if is_noise(&el) { - continue; - } - let score = score_element(&el); - if score > MIN_SCORE_THRESHOLD { - candidates.push((el, score)); - } - } - } - } - - candidates -} - -/// 判断元素是否为噪声 -fn is_noise(el: &ElementRef) -> bool { - let tag = el.value().name().to_lowercase(); - if STRIP_TAGS.contains(&tag.as_str()) { - return true; - } - - let class = el.value().attr("class").unwrap_or("").to_lowercase(); - let id = el.value().attr("id").unwrap_or("").to_lowercase(); - let combined = format!("{} {}", class, id); - - for pattern in NOISE_PATTERNS { - if combined.contains(pattern) { - return true; - } - } - - false -} - -/// 评分单个元素 -fn score_element(el: &ElementRef) -> f64 { - let text = el.text().collect::>().join(""); - let text_len = text.trim().len() as f64; - - // 基础分 = 文本长度 - let mut score = text_len; - - // 逗号数 → 句子结构丰富度 - let commas = text.matches(',').count() as f64; - score += commas * 3.0; - - // 句号/问号/感叹号 → 完整句子 - let sentences = text.matches(['.', '。', '?', '?', '!', '!']).count() as f64; - score += sentences * 2.0; - - // 段落数(

标签) - if let Ok(p_sel) = Selector::parse("p") { - let p_count = el.select(&p_sel).count() as f64; - score += p_count * 5.0; - } - - // 链接密度惩罚:链接文字占总文字比例越高,越像导航/索引 - let link_text: String = el - .select(&Selector::parse("a").unwrap_or_else(|_| unreachable!())) - .flat_map(|a| a.text()) - .collect(); - let link_len = link_text.trim().len() as f64; - if text_len > 0.0 { - let link_ratio = link_len / text_len; - if link_ratio > 0.8 { - score *= 0.05; // 几乎全是链接 → 导航/目录 - } else if link_ratio > 0.5 { - score *= 0.15; - } else if link_ratio > 0.3 { - score *= 0.4; - } - } - - // 代码块惩罚(代码块多 → 技术文档,保留但降分) - if let Ok(code_sel) = Selector::parse("pre, code") { - let code_count = el.select(&code_sel).count() as f64; - score -= code_count * 10.0; - } - - score -} - -/// 提取元素内纯文本(过滤噪声标签) -fn extract_clean_text(el: &ElementRef) -> String { - let mut text = String::new(); - - for child in el.descendants() { - // 跳过噪声元素 - if let Some(child_el) = ElementRef::wrap(child) - && is_noise(&child_el) { - continue; - } - - if let Some(t) = child.value().as_text() { - let t = t.trim(); - if !t.is_empty() { - if !text.is_empty() && !text.ends_with('\n') && !text.ends_with(' ') { - text.push(' '); - } - text.push_str(t); - } - } - - // 块级标签换行 - if let Some(el_ref) = ElementRef::wrap(child) { - let tag = el_ref.value().name(); - if matches!(tag, "br" | "p" | "div" | "li" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "tr" | "article" | "section" | "blockquote") { - text.push('\n'); - } - } - } - - text -} - -// ─── 辅助 ─── - -fn extract_title(document: &Html) -> String { - if let Ok(sel) = Selector::parse("title") - && let Some(el) = document.select(&sel).next() { - let t: String = el.text().collect::>().join(" ").trim().to_string(); - if !t.is_empty() { return t; } - } - "无标题".to_string() -} - -fn try_selector(document: &Html, sel_str: &str) -> Option { - let sel = Selector::parse(sel_str).ok()?; - let mut text = String::new(); - for element in document.select(&sel) { - text.push_str(&extract_clean_text(&element)); - text.push('\n'); - } - let trimmed = text.trim().to_string(); - if trimmed.is_empty() { None } else { Some(trimmed) } -} - -fn load_config() -> HashMap> { - let home = match dirs::home_dir() { - Some(h) => h, - None => return HashMap::new(), - }; - let path = home.join(CONFIG_PATH); - if !path.exists() { - return HashMap::new(); - } - let content = match std::fs::read_to_string(&path) { - Ok(c) => c, - Err(_) => return HashMap::new(), - }; - serde_json::from_str(&content).unwrap_or_default() -} diff --git a/src/tools/builtins/memos.rs b/src/tools/builtins/memos.rs deleted file mode 100644 index baf7ba7..0000000 --- a/src/tools/builtins/memos.rs +++ /dev/null @@ -1,190 +0,0 @@ -//! ## 备忘录工具 —— 本地文件存储的简易备忘录 -//! -//! 拆分为 4 个独立工具: -//! - `memos_list` — 列出所有备忘录 -//! - `memos_add` — 添加备忘录 -//! - `memos_get` — 查看指定备忘录 -//! - `memos_delete` — 按 ID 删除备忘录 -//! -//! ### 存储 -//! 数据存储在 `.data/memos.json` 文件中,JSON 数组格式。 -//! 每条记录包含:`id`、`content`、`created_at`。 - -use chrono::Local; - -use super::super::types::{RiskLevel, SkillResult, SkillSpec}; - -/// 数据文件路径 -const DATA_PATH: &str = ".data/memos.json"; - -/// 读取所有备忘录 -fn load_all() -> Vec { - if std::path::Path::new(DATA_PATH).exists() { - serde_json::from_str(&std::fs::read_to_string(DATA_PATH).unwrap_or_default()).unwrap_or_default() - } else { - vec![] - } -} - -/// 写入所有备忘录 -fn save_all(data: &[serde_json::Value]) -> Result<(), String> { - std::fs::create_dir_all(".data").map_err(|e| format!("创建目录失败: {}", e))?; - std::fs::write(DATA_PATH, serde_json::to_string_pretty(data).unwrap()) - .map_err(|e| format!("写入备忘录失败: {}", e)) -} - -// ═══════════════════════════════════════════════ -// 1. memos_list — 列出所有备忘录 -// ═══════════════════════════════════════════════ - -pub fn list_spec() -> SkillSpec { - SkillSpec { - name: "memos_list".into(), - description: "列出所有备忘录,显示每条备忘录的编号、内容和创建时间".into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({"type": "object", "properties": {}}), - timeout_secs: 5, - } -} - -pub async fn list_execute(_params: serde_json::Value) -> SkillResult { - let data = load_all(); - if data.is_empty() { - return SkillResult::ok("暂无备忘录。你可以通过 memos_add 添加一条备忘录。"); - } - let lines: Vec = data - .iter() - .map(|m| { - format!( - "#{} [{}] {}", - m["id"], - m.get("created_at").and_then(|v| v.as_str()).unwrap_or("?"), - m["content"].as_str().unwrap_or("?") - ) - }) - .collect(); - SkillResult::ok(lines.join("\n")) -} - -// ═══════════════════════════════════════════════ -// 2. memos_add — 添加备忘录 -// ═══════════════════════════════════════════════ - -pub fn add_spec() -> SkillSpec { - SkillSpec { - name: "memos_add".into(), - description: "添加一条备忘录,自动分配编号。参数 content 为备忘录内容".into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "content": {"type": "string", "description": "备忘录内容,如 明天下午2点开会"} - }, - "required": ["content"] - }), - timeout_secs: 5, - } -} - -pub async fn add_execute(params: serde_json::Value) -> SkillResult { - let content = params.get("content").and_then(|v| v.as_str()).unwrap_or(""); - if content.is_empty() { - return SkillResult::error("请提供 content(备忘录内容)"); - } - - let mut data = load_all(); - let next_id = data - .iter() - .filter_map(|m| m.get("id").and_then(|v| v.as_i64())) - .max() - .unwrap_or(0) - + 1; - data.push(serde_json::json!({ - "id": next_id, - "content": content, - "created_at": Local::now().format("%Y-%m-%d %H:%M").to_string() - })); - if let Err(e) = save_all(&data) { - return SkillResult::error(e); - } - SkillResult::ok(format!("✅ 已添加备忘录 #{}", next_id)) -} - -// ═══════════════════════════════════════════════ -// 3. memos_get — 查看指定备忘录 -// ═══════════════════════════════════════════════ - -pub fn get_spec() -> SkillSpec { - SkillSpec { - name: "memos_get".into(), - description: "查看指定编号的备忘录详情。参数 id 为备忘录编号".into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "id": {"type": "integer", "description": "备忘录编号"} - }, - "required": ["id"] - }), - timeout_secs: 5, - } -} - -pub async fn get_execute(params: serde_json::Value) -> SkillResult { - let id = params.get("id").and_then(|v| v.as_i64()).unwrap_or(0); - if id <= 0 { - return SkillResult::error("请提供有效的 id(备忘录编号)"); - } - - let data = load_all(); - match data.iter().find(|m| m.get("id").and_then(|v| v.as_i64()) == Some(id)) { - Some(memo) => { - let output = format!( - "#{} [{}]\n{}", - memo["id"], - memo.get("created_at").and_then(|v| v.as_str()).unwrap_or("?"), - memo["content"].as_str().unwrap_or("?") - ); - SkillResult::ok(output) - } - None => SkillResult::error(format!("未找到备忘录 #{}", id)), - } -} - -// ═══════════════════════════════════════════════ -// 4. memos_delete — 删除备忘录 -// ═══════════════════════════════════════════════ - -pub fn delete_spec() -> SkillSpec { - SkillSpec { - name: "memos_delete".into(), - description: "删除指定编号的备忘录。参数 id 为备忘录编号".into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "id": {"type": "integer", "description": "要删除的备忘录编号"} - }, - "required": ["id"] - }), - timeout_secs: 5, - } -} - -pub async fn delete_execute(params: serde_json::Value) -> SkillResult { - let id = params.get("id").and_then(|v| v.as_i64()).unwrap_or(0); - if id <= 0 { - return SkillResult::error("请提供有效的 id(备忘录编号)"); - } - - let mut data = load_all(); - let before = data.len(); - data.retain(|m| m.get("id").and_then(|v| v.as_i64()) != Some(id)); - if data.len() == before { - return SkillResult::error(format!("未找到备忘录 #{}", id)); - } - if let Err(e) = save_all(&data) { - return SkillResult::error(e); - } - SkillResult::ok(format!("✅ 已删除备忘录 #{}", id)) -} diff --git a/src/tools/builtins/mod.rs b/src/tools/builtins/mod.rs deleted file mode 100644 index 43ff4ae..0000000 --- a/src/tools/builtins/mod.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! ## 内置工具实现集合 -//! -//! 每个子模块对应一个具体的工具实现: -//! -//! | 模块 | 工具名 | 功能 | -//! |------|--------|------| -//! | `datetime` | `get_current_datetime` | 获取当前北京时间 | -//! | `weather` | `weather_now` / `weather_forecast` / `weather_hourly` | 和风天气查询(实时/预报/逐时) | -//! | `web_search` | `web_search` | Tavily 联网搜索 | -//! | `fetch_page` | `fetch_page` | 网页正文提取(可读性算法) | -//! | `memos` | `memos_list` / `memos_add` / `memos_get` / `memos_delete` | 备忘录增删查 | -//! | `amap` | `amap_*` | 高德地图系列(POI/地理编码/路径规划/旅游规划) | -//! -//! 每个工具都提供 `spec()` 返回元数据,和 `execute()` 执行入口。 -//! 通过 `BuiltinRegistry` 统一注册和路由。 - -pub mod amap; -pub mod datetime; -// pub mod email; -pub mod fetch_page; -pub mod memos; -pub mod scheduled_task; -pub mod weather; -pub mod web_search; diff --git a/src/tools/builtins/scheduled_task.rs b/src/tools/builtins/scheduled_task.rs deleted file mode 100644 index 7014fee..0000000 --- a/src/tools/builtins/scheduled_task.rs +++ /dev/null @@ -1,318 +0,0 @@ -//! ## 定时任务管理工具 —— 增删改查定时任务 -//! -//! 拆分为 5 个独立工具: -//! - `scheduled_task_list` — 列出所有定时任务 -//! - `scheduled_task_add` — 添加定时任务 -//! - `scheduled_task_update` — 更新定时任务 -//! - `scheduled_task_delete` — 删除定时任务 -//! - `scheduled_task_toggle` — 启用/禁用定时任务 -//! -//! ### 任务类型 -//! - `model` — 模型任务,LLM 可以自由增删改查 -//! - `system` — 系统任务,LLM 只能查看,不能修改/删除 - -use std::sync::LazyLock; - -use crate::tools::types::{RiskLevel, SkillResult, SkillSpec}; - -/// 延迟初始化的数据库连接池 -static POOL: LazyLock> = LazyLock::new(|| { - let url = std::env::var("DATABASE_URL").ok()?; - let pool = sqlx::PgPool::connect_lazy(&url).ok()?; - Some(pool) -}); - -fn pool() -> Option<&'static sqlx::PgPool> { - POOL.as_ref() -} - -/// 检查任务是否为 system 类型(不可修改) -async fn check_system_task(pool: &sqlx::PgPool, id: &uuid::Uuid) -> Result { - let row = sqlx::query_scalar::<_, String>( - "SELECT task_type FROM scheduled_tasks WHERE id = $1", - ) - .bind(id) - .fetch_optional(pool) - .await - .map_err(|e| format!("查询任务类型失败: {}", e))?; - - match row { - Some(t) => Ok(t == "system"), - None => Err("未找到该任务".to_string()), - } -} - -// ═══════════════════════════════════════════════ -// 1. scheduled_task_list — 列出所有定时任务 -// ═══════════════════════════════════════════════ - -pub fn list_spec() -> SkillSpec { - SkillSpec { - name: "scheduled_task_list".into(), - description: "列出所有定时任务(名称、ID、命令、间隔、状态、上次/下次执行时间)。系统任务标记 [系统] 标签。需要 PostgreSQL 数据库。".into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({"type": "object", "properties": {}}), - timeout_secs: 10, - } -} - -pub async fn list_execute(_params: serde_json::Value) -> SkillResult { - let pool = match pool() { - Some(p) => p, - None => return SkillResult::error("数据库未配置,请设置 DATABASE_URL 环境变量"), - }; - let tasks = match crate::db::models::list_scheduled_tasks(pool).await { - Ok(t) => t, - Err(e) => return SkillResult::error(format!("查询失败: {}", e)), - }; - - if tasks.is_empty() { - return SkillResult::ok("暂无定时任务。你可以通过 scheduled_task_add 来创建,例如每天早8点播报天气。"); - } - - let mut lines = vec![format!("📋 共有 {} 个定时任务:\n", tasks.len())]; - for task in &tasks { - let status = if task.enabled { "🟢" } else { "🔴" }; - let type_tag = if task.task_type == "system" { " [系统]" } else { "" }; - 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) - }; - let last = task - .last_run_at - .map(|t| t.format("%m-%d %H:%M").to_string()) - .unwrap_or_else(|| "-".to_string()); - let next = task.next_run_at.format("%m-%d %H:%M"); - - lines.push(format!( - "{status}{type_tag} {name}\n ID: {id}\n 命令: {cmd}\n 间隔: {interval}\n 上次: {last} 下次: {next}", - status = status, - type_tag = type_tag, - name = task.name, - id = task.id, - cmd = task.command, - interval = interval, - last = last, - next = next, - )); - } - - SkillResult::ok(lines.join("\n")) -} - -// ═══════════════════════════════════════════════ -// 2. scheduled_task_add — 添加定时任务 -// ═══════════════════════════════════════════════ - -pub fn add_spec() -> SkillSpec { - SkillSpec { - name: "scheduled_task_add".into(), - description: "添加一个定时任务,按指定间隔自动执行 shell 命令。参数 name 为任务名称,user 为所属用户 ID,command 为要执行的命令,interval 为执行间隔(秒)。可选参数:description(描述)、shell(Shell解释器,默认/bin/bash)、cwd(工作目录)、task_type(任务类型,默认model)。需要 PostgreSQL 数据库。".into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "name": {"type": "string", "description": "任务名称(唯一)"}, - "user": {"type": "string", "description": "所属用户 ID"}, - "command": {"type": "string", "description": "要执行的 shell 命令"}, - "interval": {"type": "integer", "description": "执行间隔(秒),如 86400 为每天"}, - "description": {"type": "string", "description": "任务描述(可选)"}, - "shell": {"type": "string", "description": "Shell 解释器,默认 /bin/bash(可选)"}, - "cwd": {"type": "string", "description": "工作目录(可选)"}, - "task_type": {"type": "string", "description": "任务类型:model(默认)或 system(可选)"} - }, - "required": ["name", "user", "command", "interval"] - }), - timeout_secs: 10, - } -} - -pub async fn add_execute(params: serde_json::Value) -> SkillResult { - let pool = match pool() { - Some(p) => p, - None => return SkillResult::error("数据库未配置,请设置 DATABASE_URL 环境变量"), - }; - let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); - let user = params.get("user").and_then(|v| v.as_str()).unwrap_or(""); - let command = params.get("command").and_then(|v| v.as_str()).unwrap_or(""); - let interval = params.get("interval").and_then(|v| v.as_i64()).unwrap_or(0) as i32; - let task_type = params.get("task_type").and_then(|v| v.as_str()).unwrap_or("model"); - - if name.is_empty() { return SkillResult::error("请提供 name(任务名称)"); } - if user.is_empty() { return SkillResult::error("请提供 user(所属用户 ID)"); } - if command.is_empty() { return SkillResult::error("请提供 command(要执行的命令)"); } - if interval <= 0 { return SkillResult::error("请提供 interval(执行间隔,大于0秒)"); } - if task_type != "model" && task_type != "system" { - return SkillResult::error("task_type 必须是 model 或 system"); - } - - let description = params.get("description").and_then(|v| v.as_str()).unwrap_or(""); - let shell = params.get("shell").and_then(|v| v.as_str()).unwrap_or("/bin/bash"); - let cwd = params.get("cwd").and_then(|v| v.as_str()).unwrap_or(""); - - match crate::db::models::add_scheduled_task(pool, name, user, command, interval, description, shell, cwd, task_type).await { - Ok(id) => SkillResult::ok(format!( - "✅ 定时任务已添加\n 名称: {name}\n 命令: {command}\n 间隔: {interval}秒\n 类型: {task_type}\n ID: {id}\n\n任务将按设定间隔自动执行,执行结果会通知你。", - name = name, command = command, interval = interval, task_type = task_type, id = id - )), - Err(e) => SkillResult::error(format!("添加失败: {}", e)), - } -} - -// ═══════════════════════════════════════════════ -// 3. scheduled_task_update — 更新定时任务 -// ═══════════════════════════════════════════════ - -pub fn update_spec() -> SkillSpec { - SkillSpec { - name: "scheduled_task_update".into(), - description: "更新定时任务的字段(只更新传入的字段)。参数 id 为任务 UUID(必填),可选字段:name/command/interval/description/shell/cwd。系统任务不可修改。需要 PostgreSQL 数据库。".into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "id": {"type": "string", "description": "任务 UUID"}, - "name": {"type": "string", "description": "新任务名称(可选)"}, - "command": {"type": "string", "description": "新命令(可选)"}, - "interval": {"type": "integer", "description": "新执行间隔秒数(可选)"}, - "description": {"type": "string", "description": "新描述(可选)"}, - "shell": {"type": "string", "description": "新 Shell 解释器(可选)"}, - "cwd": {"type": "string", "description": "新工作目录(可选)"} - }, - "required": ["id"] - }), - timeout_secs: 10, - } -} - -pub async fn update_execute(params: serde_json::Value) -> SkillResult { - let pool = match pool() { - Some(p) => p, - None => return SkillResult::error("数据库未配置,请设置 DATABASE_URL 环境变量"), - }; - let id_str = params.get("id").and_then(|v| v.as_str()).unwrap_or(""); - let id = match uuid::Uuid::parse_str(id_str) { - Ok(u) => u, - Err(_) => return SkillResult::error("请提供有效的 id(任务 UUID)"), - }; - - // 检查是否为系统任务 - match check_system_task(pool, &id).await { - Ok(true) => return SkillResult::error("❌ 系统任务不可修改。系统任务由管理员配置,如需变更请联系管理员。"), - Ok(false) => {} - Err(e) => return SkillResult::error(e), - } - - let name = params.get("name").and_then(|v| v.as_str()); - let command = params.get("command").and_then(|v| v.as_str()); - let interval = params.get("interval").and_then(|v| v.as_i64()).map(|v| v as i32); - let description = params.get("description").and_then(|v| v.as_str()); - let shell = params.get("shell").and_then(|v| v.as_str()); - let cwd = params.get("cwd").and_then(|v| v.as_str()); - - if name.is_none() && command.is_none() && interval.is_none() - && description.is_none() && shell.is_none() && cwd.is_none() - { - return SkillResult::error("请至少提供要更新的字段(name/command/interval/description/shell/cwd)"); - } - - match crate::db::models::update_scheduled_task(pool, &id, name, command, interval, description, shell, cwd).await { - Ok(true) => SkillResult::ok("✅ 定时任务已更新"), - Ok(false) => SkillResult::error("未找到该任务或无字段更新"), - Err(e) => SkillResult::error(format!("更新失败: {}", e)), - } -} - -// ═══════════════════════════════════════════════ -// 4. scheduled_task_delete — 删除定时任务 -// ═══════════════════════════════════════════════ - -pub fn delete_spec() -> SkillSpec { - SkillSpec { - name: "scheduled_task_delete".into(), - description: "删除指定 UUID 的定时任务。系统任务不可删除。需要 PostgreSQL 数据库。".into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "id": {"type": "string", "description": "要删除的任务 UUID"} - }, - "required": ["id"] - }), - timeout_secs: 10, - } -} - -pub async fn delete_execute(params: serde_json::Value) -> SkillResult { - let pool = match pool() { - Some(p) => p, - None => return SkillResult::error("数据库未配置,请设置 DATABASE_URL 环境变量"), - }; - let id_str = params.get("id").and_then(|v| v.as_str()).unwrap_or(""); - let id = match uuid::Uuid::parse_str(id_str) { - Ok(u) => u, - Err(_) => return SkillResult::error("请提供有效的 id(任务 UUID)"), - }; - - match check_system_task(pool, &id).await { - Ok(true) => return SkillResult::error("❌ 系统任务不可删除。系统任务由管理员配置,如需变更请联系管理员。"), - Ok(false) => {} - Err(e) => return SkillResult::error(e), - } - - match crate::db::models::delete_scheduled_task(pool, &id).await { - Ok(true) => SkillResult::ok("✅ 定时任务已删除"), - Ok(false) => SkillResult::error("未找到该任务"), - Err(e) => SkillResult::error(format!("删除失败: {}", e)), - } -} - -// ═══════════════════════════════════════════════ -// 5. scheduled_task_toggle — 启用/禁用定时任务 -// ═══════════════════════════════════════════════ - -pub fn toggle_spec() -> SkillSpec { - SkillSpec { - name: "scheduled_task_toggle".into(), - description: "切换定时任务的启用/禁用状态。系统任务不可切换。需要 PostgreSQL 数据库。".into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "id": {"type": "string", "description": "任务 UUID"} - }, - "required": ["id"] - }), - timeout_secs: 10, - } -} - -pub async fn toggle_execute(params: serde_json::Value) -> SkillResult { - let pool = match pool() { - Some(p) => p, - None => return SkillResult::error("数据库未配置,请设置 DATABASE_URL 环境变量"), - }; - let id_str = params.get("id").and_then(|v| v.as_str()).unwrap_or(""); - let id = match uuid::Uuid::parse_str(id_str) { - Ok(u) => u, - Err(_) => return SkillResult::error("请提供有效的 id(任务 UUID)"), - }; - - match check_system_task(pool, &id).await { - Ok(true) => return SkillResult::error("❌ 系统任务不可启用/禁用。系统任务由管理员配置,如需变更请联系管理员。"), - Ok(false) => {} - Err(e) => return SkillResult::error(e), - } - - match crate::db::models::toggle_scheduled_task(pool, &id).await { - Ok((true, enabled)) => { - let status = if enabled { "🟢 已启用" } else { "🔴 已禁用" }; - SkillResult::ok(format!("✅ 定时任务状态已切换: {}", status)) - } - Ok((false, _)) => SkillResult::error("未找到该任务"), - Err(e) => SkillResult::error(format!("切换失败: {}", e)), - } -} diff --git a/src/tools/builtins/weather.rs b/src/tools/builtins/weather.rs deleted file mode 100644 index f6c0b31..0000000 --- a/src/tools/builtins/weather.rs +++ /dev/null @@ -1,620 +0,0 @@ -//! ## 和风天气查询工具(Rust 原生实现) -//! -//! 通过和风天气 API 查询实时天气、每日预报和逐小时预报。 -//! -//! ### API 端点 -//! - 城市查询: `GET /geo/v2/city/lookup?location=城市名` → 获取 city_id -//! - 实时天气: `GET /v7/weather/now?location=city_id` -//! - 每日预报: `GET /v7/weather/{3,7,10,15,30}d?location=id` -//! - 逐时预报: `GET /v7/weather/{24,72,168}h?location=id` -//! -//! ### 认证方式 -//! 使用 EdDSA (Ed25519) JWT 进行 API 认证。 -//! 密钥对存放在 `qweather/` 目录下。 -//! -//! ### 环境变量 -//! - `QWEATHER_API_HOST` — API 地址(默认 `api.qweather.com`) -//! - `QWEATHER_JWT_KEY_ID` — JWT Key ID -//! - `QWEATHER_JWT_PROJECT_ID` — 项目 ID -//! - `QWEATHER_JWT_PRIVATE_KEY_FILE` — Ed25519 私钥路径 -//! -//! ### 缓存策略 -//! city_id 在内存中缓存 1 小时(TTL),减少重复查询。 - -use base64::{Engine as _, engine::general_purpose}; -use chrono::Utc; -use ed25519_dalek::pkcs8::DecodePrivateKey; -use ed25519_dalek::{Signer, SigningKey}; -use reqwest::Client; -use serde::Deserialize; -use std::collections::HashMap; -use std::sync::Mutex; -use std::time::{Duration, Instant}; - -use crate::tools::types::{RiskLevel, SkillResult, SkillSpec}; - -// ═══════════════════════════════════════════════ -// JWT 认证 -// ═══════════════════════════════════════════════ - -fn base64url(data: &[u8]) -> String { - general_purpose::URL_SAFE_NO_PAD.encode(data) -} - -fn generate_jwt() -> Result { - let key_file = std::env::var("QWEATHER_JWT_PRIVATE_KEY_FILE").unwrap_or_else(|_| { - dirs::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(".ias") - .join("qweather") - .join("ed25519-private.pem") - .to_string_lossy() - .to_string() - }); - - let pem = std::fs::read_to_string(&key_file) - .map_err(|e| format!("读取密钥文件 {} 失败: {}", key_file, e))?; - - let signing_key = SigningKey::from_pkcs8_pem(&pem) - .map_err(|e| format!("解析 Ed25519 密钥失败: {e}"))?; - - let key_id = std::env::var("QWEATHER_JWT_KEY_ID").unwrap_or_default(); - let project_id = std::env::var("QWEATHER_JWT_PROJECT_ID").unwrap_or_default(); - let ttl: i64 = std::env::var("QWEATHER_JWT_TTL_SECONDS") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(3600); - - let now = Utc::now().timestamp(); - - // Header - let header = serde_json::json!({"alg": "EdDSA", "kid": key_id}); - let header_b64 = base64url(header.to_string().as_bytes()); - - // Payload - let payload = serde_json::json!({ - "sub": project_id, - "iat": now - 30, - "exp": now + ttl, - }); - let payload_b64 = base64url(payload.to_string().as_bytes()); - - // Sign - let message = format!("{}.{}", header_b64, payload_b64); - let signature = signing_key - .try_sign(message.as_bytes()) - .map_err(|e| format!("JWT 签名失败: {e}"))?; - - Ok(format!( - "{}.{}.{}", - header_b64, - payload_b64, - base64url(&signature.to_bytes()) - )) -} - -// ═══════════════════════════════════════════════ -// API 响应类型 -// ═══════════════════════════════════════════════ - -#[derive(Deserialize)] -struct CityLookupResponse { - code: String, - #[serde(default)] - location: Vec, -} - -#[derive(Deserialize)] -#[allow(dead_code)] -struct CityLocation { - id: String, - name: String, - #[serde(default)] - adm1: String, - #[serde(default)] - adm2: String, -} - -#[derive(Deserialize)] -struct WeatherNowResponse { - code: String, - #[serde(default)] - now: Option, -} - -#[derive(Deserialize)] -struct WeatherNow { - #[serde(default)] - temp: String, - #[serde(rename = "feelsLike", default)] - feels_like: String, - #[serde(default)] - text: String, - #[serde(rename = "windDir", default)] - wind_dir: String, - #[serde(rename = "windScale", default)] - wind_scale: String, - #[serde(rename = "windSpeed", default)] - wind_speed: String, - #[serde(default)] - humidity: String, - #[serde(default)] - precip: String, - #[serde(default)] - pressure: String, - #[serde(default)] - vis: String, -} - -#[derive(Deserialize)] -struct DailyForecastResponse { - code: String, - #[serde(default)] - daily: Vec, -} - -#[derive(Deserialize)] -struct DailyForecast { - #[serde(rename = "fxDate", default)] - fx_date: String, - #[serde(rename = "tempMax", default)] - temp_max: String, - #[serde(rename = "tempMin", default)] - temp_min: String, - #[serde(rename = "textDay", default)] - text_day: String, - #[serde(rename = "textNight", default)] - text_night: String, - #[serde(rename = "windDirDay", default)] - wind_dir_day: String, - #[serde(rename = "windScaleDay", default)] - wind_scale_day: String, - #[serde(default)] - humidity: String, - #[serde(rename = "uvIndex", default)] - uv_index: String, -} - -#[derive(Deserialize)] -struct HourlyForecastResponse { - code: String, - #[serde(default)] - hourly: Vec, -} - -#[derive(Deserialize)] -struct HourlyForecast { - #[serde(rename = "fxTime", default)] - fx_time: String, - #[serde(default)] - temp: String, - #[serde(default)] - text: String, - #[serde(rename = "windDir", default)] - wind_dir: String, - #[serde(rename = "windScale", default)] - wind_scale: String, - #[serde(default)] - humidity: String, - #[serde(default)] - pop: String, - #[serde(default)] - precip: String, -} - -// ═══════════════════════════════════════════════ -// 城市 ID 缓存 -// ═══════════════════════════════════════════════ - -/// 持久化 city_id 映射,减少 API 调用 -use std::sync::LazyLock; -static CITY_CACHE: LazyLock>> = - LazyLock::new(|| Mutex::new(HashMap::new())); -const CACHE_TTL: Duration = Duration::from_secs(3600); - -fn get_cached_city(query: &str) -> Option { - let cache = CITY_CACHE.lock().unwrap(); - cache.get(query).and_then(|(id, ts)| { - if ts.elapsed() < CACHE_TTL { - Some(id.clone()) - } else { - None - } - }) -} - -fn cache_city(query: String, city_id: String) { - let mut cache = CITY_CACHE.lock().unwrap(); - cache.insert(query, (city_id, Instant::now())); -} - -// ═══════════════════════════════════════════════ -// HTTP 客户端 -// ═══════════════════════════════════════════════ - -struct QWeatherClient { - http: Client, - jwt: String, - api_host: String, -} - -impl QWeatherClient { - async fn new() -> Result { - let jwt = generate_jwt()?; - let api_host = - std::env::var("QWEATHER_API_HOST").unwrap_or_else(|_| "api.qweather.com".into()); - - Ok(Self { - http: Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .map_err(|e| format!("创建 HTTP client 失败: {}", e))?, - jwt, - api_host, - }) - } - - /// 城市查询 → LocationID - async fn lookup_city(&self, location: &str) -> Result { - // 检查缓存 - if let Some(id) = get_cached_city(location) { - tracing::debug!(target: "ias::tool", "🏙️ 城市缓存命中: {} → {}", location, id); - return Ok(id); - } - - let resp: CityLookupResponse = self - .http - .get("https://geoapi.qweather.com/v2/city/lookup") - .query(&[("location", location), ("number", "1")]) - .header("Authorization", format!("Bearer {}", self.jwt)) - .send() - .await - .map_err(|e| format!("城市查询请求失败: {}", e))? - .json() - .await - .map_err(|e| format!("解析城市查询响应失败: {}", e))?; - - if resp.code != "200" { - return Err(format!("城市查询 API 返回: {}", resp.code)); - } - - let city = resp - .location - .first() - .ok_or_else(|| format!("未找到城市: {}", location))?; - - let id = city.id.clone(); - cache_city(location.to_string(), id.clone()); - tracing::debug!(target: "ias::tool", "🏙️ 城市查询: {} → {} ({})", location, city.name, id); - - Ok(id) - } - - /// 实时天气 - async fn weather_now(&self, city_id: &str) -> Result { - let resp: WeatherNowResponse = self - .http - .get(format!("https://{}/v7/weather/now", self.api_host)) - .query(&[("location", city_id), ("lang", "zh"), ("unit", "m")]) - .header("Authorization", format!("Bearer {}", self.jwt)) - .send() - .await - .map_err(|e| format!("实时天气请求失败: {}", e))? - .json() - .await - .map_err(|e| format!("解析实时天气响应失败: {}", e))?; - - if resp.code != "200" { - return Err(format!("实时天气 API 返回: {}", resp.code)); - } - - let now = resp.now.ok_or("实时天气数据为空")?; - - Ok(serde_json::json!({ - "temp": now.temp, - "feelsLike": now.feels_like, - "text": now.text, - "windDir": now.wind_dir, - "windScale": now.wind_scale, - "windSpeed": now.wind_speed, - "humidity": now.humidity, - "precip": now.precip, - "pressure": now.pressure, - "vis": now.vis, - })) - } - - /// 每日预报 - async fn daily_forecast(&self, city_id: &str, days: u32) -> Result { - let d = match days { - 0..=3 => "3d", - 4..=7 => "7d", - 8..=10 => "10d", - 11..=15 => "15d", - _ => "30d", - }; - - let resp: DailyForecastResponse = self - .http - .get(format!("https://{}/v7/weather/{}", self.api_host, d)) - .query(&[("location", city_id), ("lang", "zh"), ("unit", "m")]) - .header("Authorization", format!("Bearer {}", self.jwt)) - .send() - .await - .map_err(|e| format!("每日预报请求失败: {}", e))? - .json() - .await - .map_err(|e| format!("解析每日预报响应失败: {}", e))?; - - if resp.code != "200" { - return Err(format!("每日预报 API 返回: {}", resp.code)); - } - - let forecast: Vec = resp - .daily - .into_iter() - .take(days as usize) - .map(|d| { - serde_json::json!({ - "date": d.fx_date, - "tempMax": d.temp_max, - "tempMin": d.temp_min, - "textDay": d.text_day, - "textNight": d.text_night, - "windDirDay": d.wind_dir_day, - "windScaleDay": d.wind_scale_day, - "humidity": d.humidity, - "uvIndex": d.uv_index, - }) - }) - .collect(); - - Ok(serde_json::Value::Array(forecast)) - } - - /// 逐时预报 - async fn hourly_forecast( - &self, - city_id: &str, - hours: u32, - ) -> Result { - let h = match hours { - 0..=24 => "24h", - 25..=72 => "72h", - _ => "168h", - }; - - let resp: HourlyForecastResponse = self - .http - .get(format!("https://{}/v7/weather/{}", self.api_host, h)) - .query(&[("location", city_id), ("lang", "zh"), ("unit", "m")]) - .header("Authorization", format!("Bearer {}", self.jwt)) - .send() - .await - .map_err(|e| format!("逐时预报请求失败: {}", e))? - .json() - .await - .map_err(|e| format!("解析逐时预报响应失败: {}", e))?; - - if resp.code != "200" { - return Err(format!("逐时预报 API 返回: {}", resp.code)); - } - - let forecast: Vec = resp - .hourly - .into_iter() - .take(hours as usize) - .map(|h| { - serde_json::json!({ - "time": h.fx_time, - "temp": h.temp, - "text": h.text, - "windDir": h.wind_dir, - "windScale": h.wind_scale, - "humidity": h.humidity, - "pop": h.pop, - "precip": h.precip, - }) - }) - .collect(); - - Ok(serde_json::Value::Array(forecast)) - } -} - -// ═══════════════════════════════════════════════ -// 公开接口 — 3 个独立工具 -// ═══════════════════════════════════════════════ - -/// 1. weather_now — 实时天气 -pub fn now_spec() -> SkillSpec { - SkillSpec { - name: "weather_now".into(), - description: "查询指定城市的实时天气(温度、体感温度、天气状况、风向风力、湿度、降水量、气压、能见度)。参数 location 为城市名".into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "location": {"type": "string", "description": "城市名,如 北京、上海"} - }, - "required": ["location"] - }), - timeout_secs: 10, - } -} - -pub async fn now_execute(params: serde_json::Value) -> SkillResult { - let location = params - .get("location") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if location.is_empty() { - return SkillResult::error("请提供 location(城市名)"); - } - - let client = match QWeatherClient::new().await { - Ok(c) => c, - Err(e) => return SkillResult::error(format!("初始化天气客户端失败: {}", e)), - }; - let city_id = match client.lookup_city(location).await { - Ok(id) => id, - Err(e) => return SkillResult::error(e), - }; - - match client.weather_now(&city_id).await { - Ok(data) => { - let output = serde_json::json!({"location": location, "now": data}); - SkillResult::ok_with_data(serde_json::to_string(&output).unwrap_or_default(), output) - } - Err(e) => SkillResult::error(e), - } -} - -/// 2. weather_forecast — 每日预报 -pub fn forecast_spec() -> SkillSpec { - SkillSpec { - name: "weather_forecast".into(), - description: "查询指定城市未来几天的天气预报(最高/最低温度、白天/夜间天气状况、风向风力、湿度、紫外线指数)。参数 location 为城市名,days 为预报天数(默认3,最大30)".into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "location": {"type": "string", "description": "城市名,如 北京、上海"}, - "days": {"type": "integer", "description": "预报天数,默认3,最大30"} - }, - "required": ["location"] - }), - timeout_secs: 10, - } -} - -pub async fn forecast_execute(params: serde_json::Value) -> SkillResult { - let location = params - .get("location") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if location.is_empty() { - return SkillResult::error("请提供 location(城市名)"); - } - let days = params - .get("days") - .and_then(|v| v.as_u64()) - .unwrap_or(3) - .min(30) - .max(1) as u32; - - let client = match QWeatherClient::new().await { - Ok(c) => c, - Err(e) => return SkillResult::error(format!("初始化天气客户端失败: {}", e)), - }; - let city_id = match client.lookup_city(location).await { - Ok(id) => id, - Err(e) => return SkillResult::error(e), - }; - - match client.daily_forecast(&city_id, days).await { - Ok(data) => { - let output = serde_json::json!({"location": location, "forecast": data}); - SkillResult::ok_with_data(serde_json::to_string(&output).unwrap_or_default(), output) - } - Err(e) => SkillResult::error(e), - } -} - -/// 3. weather_hourly — 逐时预报 -pub fn hourly_spec() -> SkillSpec { - SkillSpec { - name: "weather_hourly".into(), - description: "查询指定城市未来逐小时的天气预报(温度、天气状况、风向风力、湿度、降水概率、降水量)。参数 location 为城市名,hours 为预报小时数(默认24,最大168)".into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "location": {"type": "string", "description": "城市名,如 北京、上海"}, - "hours": {"type": "integer", "description": "预报小时数,默认24,最大168"} - }, - "required": ["location"] - }), - timeout_secs: 10, - } -} - -pub async fn hourly_execute(params: serde_json::Value) -> SkillResult { - let location = params - .get("location") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if location.is_empty() { - return SkillResult::error("请提供 location(城市名)"); - } - let hours = params - .get("hours") - .and_then(|v| v.as_u64()) - .unwrap_or(24) - .min(168) - .max(1) as u32; - - let client = match QWeatherClient::new().await { - Ok(c) => c, - Err(e) => return SkillResult::error(format!("初始化天气客户端失败: {}", e)), - }; - let city_id = match client.lookup_city(location).await { - Ok(id) => id, - Err(e) => return SkillResult::error(e), - }; - - match client.hourly_forecast(&city_id, hours).await { - Ok(data) => { - let output = serde_json::json!({"location": location, "hourly": data}); - SkillResult::ok_with_data(serde_json::to_string(&output).unwrap_or_default(), output) - } - Err(e) => SkillResult::error(e), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_jwt_generation() { - // 仅验证能生成非空 token(需要有效的密钥文件) - let result = generate_jwt(); - if result.is_ok() { - let jwt = result.unwrap(); - let parts: Vec<&str> = jwt.split('.').collect(); - assert_eq!(parts.len(), 3, "JWT 应该有 3 部分"); - assert!(!parts[0].is_empty()); - assert!(!parts[1].is_empty()); - assert!(!parts[2].is_empty()); - } - } - - /// 端到端测试:需要有效的 QWeather 环境变量 - #[tokio::test] - async fn test_now_execute_integration() { - if std::env::var("QWEATHER_JWT_KEY_ID").is_err() { - eprintln!("跳过集成测试: 未设置 QWeather 环境变量"); - return; - } - let result = super::now_execute(serde_json::json!({"location": "合肥"})).await; - assert!(result.success, "查询失败: {}", result.output); - let data = result.data.expect("应有 data 字段"); - assert_eq!(data["location"], "合肥"); - assert!(data["now"].is_object(), "应有实时天气"); - } - - #[tokio::test] - async fn test_forecast_execute_integration() { - if std::env::var("QWEATHER_JWT_KEY_ID").is_err() { - eprintln!("跳过集成测试: 未设置 QWeather 环境变量"); - return; - } - let result = - super::forecast_execute(serde_json::json!({"location": "合肥", "days": 3})).await; - assert!(result.success, "查询失败: {}", result.output); - let data = result.data.expect("应有 data 字段"); - assert_eq!(data["location"], "合肥"); - assert!(data["forecast"].is_array(), "应有预报"); - } -} diff --git a/src/tools/builtins/web_search.rs b/src/tools/builtins/web_search.rs deleted file mode 100644 index 8b93001..0000000 --- a/src/tools/builtins/web_search.rs +++ /dev/null @@ -1,383 +0,0 @@ -//! ## Web 搜索工具 —— Tavily Search API -//! -//! 通过 Tavily 搜索引擎获取互联网实时信息。 -//! Tavily 是一个专为 AI Agent 设计的搜索引擎,返回结构化搜索结果。 -//! -//! ### API -//! - 端点: `POST https://api.tavily.com/search` -//! - 认证: `Authorization: Bearer ` -//! - 文档: https://docs.tavily.com/documentation/api-reference/endpoint/search -//! -//! ### 功能特性 -//! - 支持 AI 摘要(include_answer) -//! - 支持多种搜索深度(basic / advanced / fast / ultra-fast) -//! - 支持按主题过滤(general / news / finance) -//! - 支持按时间范围过滤 -//! - 支持限定/排除域名 -//! - 支持按国家优先搜索结果 - -use reqwest::Client as HttpClient; -use serde::{Deserialize, Serialize}; - -use super::super::types::{RiskLevel, SkillResult, SkillSpec}; - -// ═══════════════════════════════════════════════ -// 请求结构体 -// ═══════════════════════════════════════════════ - -#[derive(Debug, Serialize)] -struct SearchRequest { - query: String, - - #[serde(skip_serializing_if = "Option::is_none")] - search_depth: Option, // basic | advanced | fast | ultra-fast - - #[serde(skip_serializing_if = "Option::is_none")] - chunks_per_source: Option, // 1-3, 仅 search_depth=advanced - - #[serde(skip_serializing_if = "Option::is_none")] - max_results: Option, // 0-20 - - #[serde(skip_serializing_if = "Option::is_none")] - topic: Option, // general | news | finance - - #[serde(skip_serializing_if = "Option::is_none")] - time_range: Option, // day | week | month | year | d | w | m | y - - #[serde(skip_serializing_if = "Option::is_none")] - start_date: Option, // YYYY-MM-DD - - #[serde(skip_serializing_if = "Option::is_none")] - end_date: Option, // YYYY-MM-DD - - #[serde(skip_serializing_if = "Option::is_none")] - include_answer: Option, // false | true(=basic) | "basic" | "advanced" - - #[serde(skip_serializing_if = "Option::is_none")] - include_raw_content: Option, // false | true(=markdown) | "markdown" | "text" - - #[serde(skip_serializing_if = "Option::is_none")] - include_images: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - include_image_descriptions: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - include_favicon: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - include_domains: Option>, - - #[serde(skip_serializing_if = "Option::is_none")] - exclude_domains: Option>, - - #[serde(skip_serializing_if = "Option::is_none")] - country: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - auto_parameters: Option, // 默认 false - - #[serde(skip_serializing_if = "Option::is_none")] - exact_match: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - include_usage: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - safe_search: Option, // Enterprise only -} - -// ═══════════════════════════════════════════════ -// 响应结构体 -// ═══════════════════════════════════════════════ - -#[derive(Debug, Deserialize)] -#[allow(dead_code)] -struct SearchResponse { - query: String, - - #[serde(default)] - answer: Option, - - #[serde(default)] - images: Vec, - - #[serde(default)] - results: Vec, - - #[serde(default)] - response_time: serde_json::Value, - - #[serde(default)] - auto_parameters: Option, - - #[serde(default)] - usage: Option, - - #[serde(default)] - request_id: Option, -} - -#[derive(Debug, Deserialize)] -#[allow(dead_code)] -struct SearchResult { - title: String, - url: String, - content: String, - #[serde(default)] - score: Option, - #[serde(default)] - raw_content: Option, - #[serde(default)] - favicon: Option, - #[serde(default)] - images: Vec, - #[serde(default)] - published_date: Option, -} - -#[derive(Debug, Deserialize)] -struct UsageInfo { - #[serde(default)] - credits: Option, -} - -// ═══════════════════════════════════════════════ -// 工具元数据 -// ═══════════════════════════════════════════════ - -pub fn spec() -> SkillSpec { - SkillSpec { - name: "web_search".into(), - description: - "联网搜索最新信息。通过 Tavily API 搜索互联网,获取实时、准确的结果。\ - 适用于需要最新资讯、事实查询的场景。".into(), - risk_level: RiskLevel::Low, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "query": {"type": "string", "description": "搜索关键词"}, - "max_results": {"type": "integer", "description": "最大结果数 1-20(默认5)"}, - "include_answer": {"type": "boolean", "description": "是否包含 AI 摘要(默认 true)"}, - "search_depth": {"type": "string", "enum": ["basic","advanced","fast","ultra-fast"], - "description": "basic 均衡 / advanced 深度(2 credits) / fast 快速 / ultra-fast 极速"}, - "topic": {"type": "string", "enum": ["general","news","finance"], - "description": "general 通用 / news 新闻 / finance 财经"}, - "time_range": {"type": "string", "enum": ["day","week","month","year"], - "description": "按发布时间过滤(仅 topic=news/finance 时有效)"}, - "start_date": {"type": "string", "description": "起始日期 YYYY-MM-DD"}, - "end_date": {"type": "string", "description": "结束日期 YYYY-MM-DD"}, - "include_domains": {"type": "array", "items": {"type": "string"}, - "description": "限定搜索域名列表"}, - "exclude_domains": {"type": "array", "items": {"type": "string"}, - "description": "排除搜索域名列表"}, - "country": {"type": "string", "description": "优先指定国家的搜索结果(仅 topic=general)"} - }, - "required": ["query"] - }), - timeout_secs: 30, - } -} - -// ═══════════════════════════════════════════════ -// 执行入口 -// ═══════════════════════════════════════════════ - -pub async fn execute(params: serde_json::Value) -> SkillResult { - let api_key = match std::env::var("TAVILY_API_KEY") { - Ok(k) if !k.is_empty() => k, - _ => return SkillResult::error("未配置 TAVILY_API_KEY 环境变量"), - }; - - // —— 解析参数 —— - - let query = params - .get("query") - .and_then(|v| v.as_str()) - .or_else(|| params.get("prompt").and_then(|v| v.as_str())) - .unwrap_or(""); - if query.is_empty() { - return SkillResult::error("请提供 query 参数"); - } - - let max_results = params - .get("max_results") - .and_then(|v| v.as_u64()) - .map(|n| n.min(20) as u32); - - let include_answer = params - .get("include_answer") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - - let search_depth = params - .get("search_depth") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let topic = params - .get("topic") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let time_range = params - .get("time_range") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let start_date = params - .get("start_date") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let end_date = params - .get("end_date") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let include_domains = params.get("include_domains").and_then(|v| { - v.as_array().map(|arr| { - arr.iter() - .filter_map(|item| item.as_str().map(String::from)) - .collect() - }) - }); - - let exclude_domains = params.get("exclude_domains").and_then(|v| { - v.as_array().map(|arr| { - arr.iter() - .filter_map(|item| item.as_str().map(String::from)) - .collect() - }) - }); - - let country = params - .get("country") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - // days 参数转换为 time_range(便捷兼容) - let time_range = time_range.or_else(|| { - params - .get("days") - .and_then(|v| v.as_u64()) - .map(|d| format!("{}d", d)) - }); - - // —— 发起请求 —— - - let http = match HttpClient::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - { - Ok(c) => c, - Err(e) => return SkillResult::error(format!("创建 HTTP 客户端失败: {}", e)), - }; - - let body = SearchRequest { - query: query.to_string(), - search_depth, - chunks_per_source: None, - max_results: Some(max_results.unwrap_or(5)), - topic, - time_range, - start_date, - end_date, - include_answer: if include_answer { - Some(serde_json::Value::Bool(true)) - } else { - None - }, - include_raw_content: None, - include_images: None, - include_image_descriptions: None, - include_favicon: None, - include_domains, - exclude_domains, - country, - auto_parameters: None, - exact_match: None, - include_usage: None, - safe_search: None, - }; - - match http - .post("https://api.tavily.com/search") - .header("Authorization", format!("Bearer {}", api_key)) - .header("Content-Type", "application/json") - .json(&body) - .send() - .await - { - Ok(resp) => { - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - return SkillResult::error(format!( - "Tavily API 错误 ({}): {:.300}", - status, text - )); - } - - let resp_text = resp.text().await.unwrap_or_default(); - match serde_json::from_str::(&resp_text) { - Ok(data) => { - let mut output = String::new(); - - // AI 摘要 - if let Some(ref answer) = data.answer - && !answer.is_empty() { - output.push_str(&format!("📝 {}\n\n", answer)); - } - - // 搜索结果 - if data.results.is_empty() { - output.push_str("未找到相关结果。"); - } else { - for (i, r) in data.results.iter().enumerate() { - output.push_str(&format!( - "{}. **{}**\n 🔗 {}\n {}\n\n", - i + 1, - r.title, - r.url, - r.content - )); - } - } - - // 用量信息 - let credits = data - .usage - .as_ref() - .and_then(|u| u.credits) - .map(|c| format!("{} credits", c)) - .unwrap_or_default(); - let rt = data.response_time; - output.push_str(&format!( - "⏱ {} | {} 条结果{}", - rt, - data.results.len(), - if credits.is_empty() { - String::new() - } else { - format!(" | {}", credits) - } - )); - - SkillResult::ok(output) - } - Err(e) => SkillResult::error(format!( - "解析 Tavily 响应失败: {} — 原始: {:.300}", - e, resp_text - )), - } - } - Err(e) => { - if e.is_timeout() { - SkillResult::error("Tavily 搜索超时") - } else { - SkillResult::error(format!("Tavily 请求失败: {}", e)) - } - } - } -} diff --git a/src/tools/executor.rs b/src/tools/executor.rs new file mode 100644 index 0000000..00e019f --- /dev/null +++ b/src/tools/executor.rs @@ -0,0 +1,297 @@ +//! # 工具执行器 +//! +//! `execute_loop()` spawn 工具进程,解析 stdout JSON 消息: +//! - `{"type":"http",...}` — 请求 HTTP +//! - `{"type":"db",...}` — 请求数据库操作 +//! - `{"type":"result","content":"..."}` — 最终结果 +//! +//! 所有消息必须是合法 JSON。审批在 http/db 操作前处理。 + +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, +} + +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 + ), +) -> String { + 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 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 ── + let binary = std::path::Path::new(binary_path); + let mut cmd = Command::new(&binary); + if let Some(c) = &spec.command { + cmd.arg("--command").arg(c); + } + // 按工具 spec 定义的 env 列表,从当前进程环境推送对应变量 + 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.stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + error!(target: "ias::err","[spawn] {e}"); + return 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 format!("读取stdout失败: {e}"); + } + Err(_) => { + let _ = child.start_kill(); + return 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 format!("进程异常: {e}"); + } + Err(_) => { + let _ = child.start_kill(); + error!(target: "ias::err","[timeout] {}s", spec.timeout_secs); + return 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 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 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 content.to_string(); + } + Some("http") => { + let method = msg["method"].as_str().unwrap_or("GET"); + let url = msg["url"].as_str().unwrap_or(""); + let desc = msg["desc"].as_str().unwrap_or(""); + + 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 format!("审批失败: {e}"), + }; + wechat_send( + user_id, + &format!("⚠️ {}\n确认码: {}\n回复确认码继续,回复0取消", desc, code), + ) + .await; + let d = rx.await.unwrap_or(ApprovalDecision::Expired); + match d { + ApprovalDecision::Approved => { /* 继续执行 */ } + ApprovalDecision::Rejected => return "操作已被用户取消".to_string(), + ApprovalDecision::Expired => return "审批超时,操作已取消".to_string(), + } + } + + 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 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 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 desc = msg["desc"].as_str().unwrap_or(op); + + 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 format!("审批失败: {e}"), + }; + wechat_send( + user_id, + &format!("⚠️ {}\n确认码: {}\n回复确认码继续,回复0取消", desc, code), + ) + .await; + let d = rx.await.unwrap_or(ApprovalDecision::Expired); + match d { + ApprovalDecision::Approved => { /* 继续执行 */ } + ApprovalDecision::Rejected => return "操作已被用户取消".to_string(), + ApprovalDecision::Expired => return "审批超时,操作已取消".to_string(), + } + } + + 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 { + let write_result = memory.write_for(user_id, c).await; + write_result + } + } + "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 format!("未知消息类型: {}", msg["type"]); + } + } + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 41aca26..2ea5781 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -1,489 +1,9 @@ -//! ## 工具系统 —— 抽象工具 + 内置工具 + 审批管理 +//! ## 工具系统 //! -//! 所有工具分为三种类型: -//! -//! | 类型 | 说明 | 示例 | -//! |------|------|------| -//! | `ApiTool` | Rust 代码实现的工具(HTTP API / 本地计算 / 文件 I/O) | 天气、搜索、高德地图 | -//! | `StdioTool` | 通过子进程 stdin/stdout 通信的工具 | 外部脚本、可执行程序 | -//! | `ShellTool` | 通过 shell 命令执行的工具 | 定时任务、系统命令 | -//! -//! ### 架构设计(两层元工具) -//! -//! LLM 不直接感知每个具体工具,而是通过两个**元工具**间接调用: -//! -//! 1. **`query_capabilities`** — 列出所有可用工具的名称、描述、参数格式 -//! 2. **`call_capability`** — 按名称调用工具,参数通过 JSON 传递 -//! -//! ### 工具执行流程 -//! ```text -//! LLM 请求 call_capability("weather_now", {"location":"北京"}) -//! → daemon 解析出 target_name="weather_now",提取参数 -//! → 高风险?→ 走 ApprovalManager 审批流 -//! → 低风险?→ ToolRegistry::execute() -//! → 结果返回 LLM 继续对话 -//! ``` +//! LLM 通过 function calling 的 `query_capabilities` + `call_capability` 两层元工具间接调用。 -pub mod api_tool; pub mod approval; -pub mod builtin; -pub mod builtins; -pub mod shell_tool; -pub mod stdio_tool; -pub mod tool; -pub mod types; - -/// 重新导出核心类型 -pub use api_tool::ApiTool; -pub use tool::ToolRegistry; -pub use types::{SkillResult, SkillSpec}; - -// 以下类型已定义但尚未被现有代码直接引用 -// 新工具可通过这些类型实现: -// - ApiTool: Rust 代码实现的工具 -// - StdioTool: 子进程 stdin/stdout 通信的工具 -// - ShellTool: shell 命令执行的工具 -// - Tool trait: 所有工具的基类 -#[allow(unused_imports)] -pub use shell_tool::ShellTool; -#[allow(unused_imports)] -pub use stdio_tool::StdioTool; -#[allow(unused_imports)] -pub use tool::Tool; - -/// ## 从 `call_capability` 的 `{name, prompt}` JSON 中提取目标工具的真实参数 -/// -/// `call_capability` 的参数格式为 `{name: "工具名", prompt: "参数JSON字符串"}`。 -/// 这个函数负责从这种格式中提取出工具真正的参数。 -/// -/// ### 提取逻辑 -/// 1. 如果 `prompt` 是 JSON 字符串 → parse 后合并 -/// 2. 如果 `prompt` 是 JSON 对象 → 直接合并 -/// 3. 顶层字段(非 name/prompt)也合并进去(支持直接传参) -pub fn unpack_call_params(cp: &serde_json::Value) -> serde_json::Value { - let mut params = serde_json::Map::new(); - - // 1. 提取 prompt 字段中的参数 - 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()); - } - _ => {} - } - } - - // 2. 合并顶层字段(排除 name、prompt) - if let Some(obj) = cp.as_object() { - for (k, v) in obj { - if k == "name" || k == "prompt" { - continue; - } - params.insert(k.clone(), v.clone()); - } - } - - serde_json::Value::Object(params) -} - -use std::sync::LazyLock; - -// ═══════════════════════════════════════════════ -// 全局工具注册表(主入口) -// ═══════════════════════════════════════════════ - -/// 全局内置工具注册表 -pub static REGISTRY: LazyLock = LazyLock::new(build_registry); - -/// 构建注册表:注册所有内置工具 -fn build_registry() -> ToolRegistry { - let mut registry = ToolRegistry::new(); - - // ── ApiTool: 日期时间(纯本地计算) ── - registry.register(Box::new(ApiTool::new( - self::builtins::datetime::spec(), - std::sync::Arc::new(|_params| { - Box::pin(async { self::builtins::datetime::execute() }) - }), - ))); - - // ── ApiTool: 备忘录 — 列出(文件 I/O) ── - registry.register(Box::new(ApiTool::new( - self::builtins::memos::list_spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::memos::list_execute(params).await }) - }), - ))); - - // ── ApiTool: 备忘录 — 添加(文件 I/O) ── - registry.register(Box::new(ApiTool::new( - self::builtins::memos::add_spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::memos::add_execute(params).await }) - }), - ))); - - // ── ApiTool: 备忘录 — 查看(文件 I/O) ── - registry.register(Box::new(ApiTool::new( - self::builtins::memos::get_spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::memos::get_execute(params).await }) - }), - ))); - - // ── ApiTool: 备忘录 — 删除(文件 I/O) ── - registry.register(Box::new(ApiTool::new( - self::builtins::memos::delete_spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::memos::delete_execute(params).await }) - }), - ))); - - // ── ApiTool: 天气 — 实时天气(HTTP API) ── - registry.register(Box::new(ApiTool::new( - self::builtins::weather::now_spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::weather::now_execute(params).await }) - }), - ))); - - // ── ApiTool: 天气 — 每日预报(HTTP API) ── - registry.register(Box::new(ApiTool::new( - self::builtins::weather::forecast_spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::weather::forecast_execute(params).await }) - }), - ))); - - // ── ApiTool: 天气 — 逐时预报(HTTP API) ── - registry.register(Box::new(ApiTool::new( - self::builtins::weather::hourly_spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::weather::hourly_execute(params).await }) - }), - ))); - - // ── ApiTool: 联网搜索(HTTP API) ── - registry.register(Box::new(ApiTool::new( - self::builtins::web_search::spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::web_search::execute(params).await }) - }), - ))); - - // ── ApiTool: 网页抓取(HTTP API) ── - registry.register(Box::new(ApiTool::new( - self::builtins::fetch_page::spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::fetch_page::execute(params).await }) - }), - ))); - - // ── ApiTool: 高德地图 — POI搜索 ── - registry.register(Box::new(ApiTool::new( - self::builtins::amap::poi_search_spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::amap::poi_search_execute(params).await }) - }), - ))); - - // ── ApiTool: 高德地图 — 地理编码 ── - registry.register(Box::new(ApiTool::new( - self::builtins::amap::geocode_spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::amap::geocode_execute(params).await }) - }), - ))); - - // ── ApiTool: 高德地图 — 逆地理编码 ── - registry.register(Box::new(ApiTool::new( - self::builtins::amap::reverse_geocode_spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::amap::reverse_geocode_execute(params).await }) - }), - ))); - - // ── ApiTool: 高德地图 — 路径规划 ── - registry.register(Box::new(ApiTool::new( - self::builtins::amap::route_plan_spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::amap::route_plan_execute(params).await }) - }), - ))); - - // ── ApiTool: 高德地图 — 旅游规划 ── - registry.register(Box::new(ApiTool::new( - self::builtins::amap::travel_plan_spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::amap::travel_plan_execute(params).await }) - }), - ))); - - // ── ApiTool: 高德地图 — 地图链接 ── - registry.register(Box::new(ApiTool::new( - self::builtins::amap::map_link_spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::amap::map_link_execute(params).await }) - }), - ))); - - // ── ApiTool: 定时任务 — 列出(PostgreSQL) ── - registry.register(Box::new(ApiTool::new( - self::builtins::scheduled_task::list_spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::scheduled_task::list_execute(params).await }) - }), - ))); - - // ── ApiTool: 定时任务 — 添加(PostgreSQL) ── - registry.register(Box::new(ApiTool::new( - self::builtins::scheduled_task::add_spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::scheduled_task::add_execute(params).await }) - }), - ))); - - // ── ApiTool: 定时任务 — 更新(PostgreSQL) ── - registry.register(Box::new(ApiTool::new( - self::builtins::scheduled_task::update_spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::scheduled_task::update_execute(params).await }) - }), - ))); - - // ── ApiTool: 定时任务 — 删除(PostgreSQL) ── - registry.register(Box::new(ApiTool::new( - self::builtins::scheduled_task::delete_spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::scheduled_task::delete_execute(params).await }) - }), - ))); - - // ── ApiTool: 定时任务 — 切换状态(PostgreSQL) ── - registry.register(Box::new(ApiTool::new( - self::builtins::scheduled_task::toggle_spec(), - std::sync::Arc::new(|params| { - Box::pin(async { self::builtins::scheduled_task::toggle_execute(params).await }) - }), - ))); - - registry -} - -/// 按名称执行工具(主入口) -pub async fn execute(name: &str, args_json: &str) -> Option { - REGISTRY.execute(name, args_json).await -} - -/// 返回所有工具的 spec 列表 -pub fn specs() -> Vec { - REGISTRY.specs().into_iter().cloned().collect() -} - -/// 判断工具是否为高风险 -pub fn is_high_risk(name: &str) -> bool { - REGISTRY.is_high_risk(name) -} - -/// 判断名称是否对应一个已注册的工具 -pub fn is_builtin(name: &str) -> bool { - REGISTRY.is_builtin(name) -} - -/// 构建工具调用指南(含优先级说明) -/// 返回给 LLM 的工具列表 + 使用建议 -pub fn build_capability_guide(specs: &[SkillSpec]) -> String { - let mut lines = vec![ - "📋 iAs 能力工具全览(共 {} 个):".to_string(), - String::new(), - "调用方式:call_capability({ name: \"工具名\", prompt: \"{...参数...}\" })".to_string(), - "提示:先查看参数格式,再构造调用。所有参数按下面列出的格式传递。".to_string(), - String::new(), - ]; - - // ════════════════════════════════════════ - // 分类 1: 高德地图 - // ════════════════════════════════════════ - let amap_specs: Vec<&SkillSpec> = specs.iter().filter(|s| s.name.starts_with("amap_")).collect(); - if amap_specs.is_empty() { - lines.push("## 🗺️ 高德地图(未配置 API Key,暂不可用)".to_string()); - } else { - lines.push("## 🗺️ 高德地图 — 出行/路线/地点/旅游一站式服务".to_string()); - lines.push("".to_string()); - lines.push(" 📍 amap_geocode → 地址 → 坐标(如:address='天安门', city='北京')".to_string()); - lines.push(" 📍 amap_reverse_geocode → 坐标 → 地址(如:location='116.397,39.908')".to_string()); - lines.push(" 🔍 amap_poi_search → 搜索地点(如:keywords='咖啡厅', city='上海', radius=1000)".to_string()); - lines.push(" 🚗 amap_route_plan → 路径规划(origin/destination 为坐标,type 可选 driving/walking/riding/electrobike/transit)".to_string()); - lines.push(" 🎯 amap_travel_plan → 智能旅游规划(city 城市名, interests 兴趣词数组, route_type 路线类型)".to_string()); - lines.push(" 🖼️ amap_map_link → 生成地图可视化链接".to_string()); - lines.push("".to_string()); - lines.push(" 💡 使用提示:旅游/路径规划前,先用 amap_geocode 把地名转坐标".to_string()); - for spec in &amap_specs { - format_spec_detail(&mut lines, spec); - } - } - - // ════════════════════════════════════════ - // 分类 2: 天气 - // ════════════════════════════════════════ - lines.push("".to_string()); - lines.push("## 🌤️ 天气 — 实时/预报/逐时".to_string()); - lines.push("".to_string()); - lines.push(" ☀️ weather_now → 实时天气(location='北京')".to_string()); - lines.push(" 📅 weather_forecast → 未来N天预报(location='上海', days=7)".to_string()); - lines.push(" 🕐 weather_hourly → 逐小时预报(location='深圳', hours=48)".to_string()); - for spec in specs.iter().filter(|s| s.name.starts_with("weather_")) { - format_spec_detail(&mut lines, spec); - } - - // ════════════════════════════════════════ - // 分类 3: 备忘录 - // ════════════════════════════════════════ - lines.push("".to_string()); - lines.push("## 📝 备忘录 — 个人便签管理".to_string()); - lines.push("".to_string()); - lines.push(" 📋 memos_list → 查看所有备忘录".to_string()); - lines.push(" ➕ memos_add → 添加(content='明天下午2点开会')".to_string()); - lines.push(" 🔍 memos_get → 查看详情(id=1)".to_string()); - lines.push(" 🗑️ memos_delete → 删除(id=1)".to_string()); - for spec in specs.iter().filter(|s| s.name.starts_with("memos_")) { - format_spec_detail(&mut lines, spec); - } - - // ════════════════════════════════════════ - // 分类 4: 定时任务 - // ════════════════════════════════════════ - lines.push("".to_string()); - lines.push("## ⏰ 定时任务 — 周期执行的自动化脚本".to_string()); - lines.push("".to_string()); - lines.push(" 📋 scheduled_task_list → 列出所有任务及状态(系统任务标记 [系统])".to_string()); - lines.push(" ➕ scheduled_task_add → 添加(name/command/interval 必填,如: 每天早上8点发送天气)".to_string()); - lines.push(" ✏️ scheduled_task_update → 修改字段(id 必填,只改传入的字段)".to_string()); - lines.push(" 🗑️ scheduled_task_delete → 删除(系统任务不可删)".to_string()); - lines.push(" 🔄 scheduled_task_toggle → 启用/禁用切换".to_string()); - lines.push("".to_string()); - lines.push(" 💡 使用提示:添加定时任务前先 list 查看现有任务,避免重复".to_string()); - lines.push(" ⚠️ 系统任务(标记 [系统])不可修改、删除或切换状态".to_string()); - for spec in specs.iter().filter(|s| s.name.starts_with("scheduled_")) { - format_spec_detail(&mut lines, spec); - } - - // ════════════════════════════════════════ - // 分类 5: 联网 - // ════════════════════════════════════════ - lines.push("".to_string()); - lines.push("## 🌐 联网工具 — 搜索与网页抓取".to_string()); - lines.push("".to_string()); - lines.push(" 🔍 web_search → Tavily 联网搜索(支持 AI 摘要/新闻/财经/国家过滤/时间范围)".to_string()); - lines.push(" 📄 fetch_page → 抓取网页并提取正文(自动过滤广告/导航/页脚)".to_string()); - for spec in specs.iter().filter(|s| s.name == "web_search" || s.name == "fetch_page") { - format_spec_detail(&mut lines, spec); - } - - // ════════════════════════════════════════ - // 分类 6: 基础 - // ════════════════════════════════════════ - lines.push("".to_string()); - lines.push("## 🕐 基础工具".to_string()); - lines.push("".to_string()); - lines.push(" 🕐 get_current_datetime → 获取当前北京时间(无参数)".to_string()); - for spec in specs.iter().filter(|s| s.name == "get_current_datetime") { - format_spec_detail(&mut lines, spec); - } - - lines.push("".to_string()); - lines.push("═══════════════════════════════════════════════════".to_string()); - lines.push("## ⚡ 工具选择优先级(必读)".to_string()); - lines.push("".to_string()); - lines.push(" 1. 📍 地点/路线/旅游/导航 → amap_* 系列".to_string()); - lines.push(" 2. 🌤️ 天气 → weather_now / weather_forecast / weather_hourly".to_string()); - lines.push(" 3. 📝 备忘录 → memos_* 系列".to_string()); - lines.push(" 4. ⏰ 定时任务 → scheduled_task_* 系列".to_string()); - lines.push(" 5. 🌐 最新信息/网页 → web_search / fetch_page".to_string()); - lines.push(" 6. 🕐 当前时间 → get_current_datetime".to_string()); - lines.push(" 7. 以上都不匹配 → web_search 搜索相关信息".to_string()); - lines.push("".to_string()); - lines.push("📌 组合示例:".to_string()); - lines.push(" • '北京三日游怎么安排' → amap_travel_plan(city='北京')".to_string()); - lines.push(" • '明天去杭州穿什么' → weather_forecast(location='杭州', days=1)".to_string()); - lines.push(" • '附近有什么好吃的' → amap_poi_search(keywords='美食', ...)".to_string()); - lines.push(" • '帮我记下下周二开会' → memos_add(content='下周二开会')".to_string()); - lines.push(" • '每天8点推送天气预报' → scheduled_task_add(name='天气推送', ...)".to_string()); - lines.push("".to_string()); - lines.push("📌 每次调用前确认参数格式正确,坐标格式为'经度,纬度'".to_string()); - - lines.join("\n") -} - -fn format_spec_detail(lines: &mut Vec, spec: &SkillSpec) { - let risk = if spec.risk_level == crate::tools::types::RiskLevel::High { - " ⚠️需确认" - } else { - "" - }; - lines.push(format!( - "\n🔹 {} ({}){}", - spec.name, spec.timeout_secs, risk - )); - lines.push(format!(" 描述: {}", spec.description)); - if !spec - .parameters - .get("properties") - .and_then(|v| v.as_object()) - .is_none_or(|o| o.is_empty()) - { - // 精简参数显示 - if let Some(props) = spec.parameters.get("properties").and_then(|v| v.as_object()) { - let required: Vec<&str> = spec - .parameters - .get("required") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) - .unwrap_or_default(); - for (name, info) in props { - let desc = info.get("description").and_then(|v| v.as_str()).unwrap_or(""); - let req = if required.contains(&name.as_str()) { "*必填" } else { "可选" }; - lines.push(format!(" - {} ({}) {}", name, req, desc)); - } - } - } -} - -#[cfg(test)] -mod tests { - #[test] - fn test_unpack_call_params() { - use super::unpack_call_params; - - // prompt 是 JSON 字符串 - let cp = serde_json::json!({"name": "amap_poi_search", "prompt": "{\"keywords\":\"肯德基\",\"city\":\"北京\"}"}); - let result = unpack_call_params(&cp); - assert_eq!(result["keywords"], "肯德基"); - assert_eq!(result["city"], "北京"); - assert!(result.get("name").is_none()); - assert!(result.get("prompt").is_none()); - - // prompt 是 JSON 对象 - let cp = serde_json::json!({"name": "query_weather", "prompt": {"location": "上海", "days": 7}}); - let result = unpack_call_params(&cp); - assert_eq!(result["location"], "上海"); - assert_eq!(result["days"], 7); - - // 仅有 name,无 prompt - let cp = serde_json::json!({"name": "get_current_datetime"}); - let result = unpack_call_params(&cp); - assert!(result.as_object().unwrap().is_empty()); - - // 顶层字段合并 - let cp = serde_json::json!({"name": "call_capability", "prompt": "{\"a\": 1}", "extra": 42}); - let result = unpack_call_params(&cp); - assert_eq!(result["a"], 1); - assert_eq!(result["extra"], 42); - } -} +pub mod executor; +pub mod parser; +pub mod registry; +pub mod spec; diff --git a/src/tools/parser.rs b/src/tools/parser.rs new file mode 100644 index 0000000..aecda5e --- /dev/null +++ b/src/tools/parser.rs @@ -0,0 +1,159 @@ +//! ## 工具规范解析器 +//! +//! 扫描指定目录,解析所有 `*.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 new file mode 100644 index 0000000..6e0b985 --- /dev/null +++ b/src/tools/registry.rs @@ -0,0 +1,304 @@ +//! # 工具注册器 +//! +//! 扫描每个工具目录下的 `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; +use crate::tools::parser; +use crate::tools::spec::ToolSpec; +use serde_json::Value; + +#[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, +} + +/// 解析工具目录。相对路径基于二进制所在目录。 +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, +} + +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 { + // 解析二进制路径 + let binary = 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 + ), + ) -> String { + let spec = match self.get(name) { + Some(s) => s, + None => { + tracing::warn!(target:"ias::registry","未知: {name}"); + return format!("未知工具: {name}"); + } + }; + if let Some(err) = validate_required_params(spec, params) { + return 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() + } +} + +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(), + }], + }; + + 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(), + }], + }; + + assert!( + validate_required_params(&spec, &serde_json::json!({"location": "北京"})).is_none() + ); + } +} diff --git a/src/tools/shell_tool.rs b/src/tools/shell_tool.rs deleted file mode 100644 index 608caaa..0000000 --- a/src/tools/shell_tool.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! ## Shell 工具 —— 通过 shell 命令执行的工具 -//! -//! 执行一条 shell 命令(通过 `/bin/bash -c` 或自定义 shell), -//! 将参数通过环境变量 `TOOL_PARAMS` 传入,从 stdout 获取结果。 -//! -//! ### 参数传递 -//! 参数以 JSON 字符串形式通过环境变量 `TOOL_PARAMS` 传入子进程。 -//! -//! ### 输出格式 -//! stdout 的内容作为工具的输出结果返回。 -//! 如果输出是 JSON 格式的 `SkillResult`,则解析后返回结构化结果。 - -use async_trait::async_trait; -use std::time::Duration; -use tokio::process::Command; - -use super::tool::Tool; -use super::types::{SkillResult, SkillSpec}; - -/// ## Shell 工具 -/// -/// 通过 shell 命令执行的工具。 -/// -/// 当前未被现有代码直接引用,供后续扩展使用。 -#[allow(dead_code)] -pub struct ShellTool { - spec: SkillSpec, - command: String, - shell: String, - cwd: Option, - timeout: Duration, -} - -#[allow(dead_code)] -impl ShellTool { - /// 创建 Shell 工具 - /// - /// ### 参数 - /// - `spec` — 工具元数据 - /// - `command` — shell 命令字符串 - /// - `timeout_secs` — 超时秒数(默认 30) - pub fn new(spec: SkillSpec, command: impl Into, timeout_secs: u64) -> Self { - Self { - spec, - command: command.into(), - shell: "/bin/bash".into(), - cwd: None, - timeout: Duration::from_secs(timeout_secs.max(5)), - } - } - - /// 设置 shell 解释器 - pub fn with_shell(mut self, shell: impl Into) -> Self { - self.shell = shell.into(); - self - } - - /// 设置工作目录 - pub fn with_cwd(mut self, cwd: impl Into) -> Self { - self.cwd = Some(cwd.into()); - self - } -} - -#[async_trait] -impl Tool for ShellTool { - fn spec(&self) -> &SkillSpec { - &self.spec - } - - async fn execute(&self, params: serde_json::Value) -> SkillResult { - let params_str = serde_json::to_string(¶ms).unwrap_or_default(); - - let mut cmd = Command::new(&self.shell); - cmd.arg("-c") - .arg(&self.command) - .env("TOOL_PARAMS", ¶ms_str) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true); - - if let Some(ref cwd) = self.cwd { - cmd.current_dir(cwd); - } - - let output = match tokio::time::timeout(self.timeout, cmd.output()).await { - Ok(Ok(output)) => output, - Ok(Err(e)) => return SkillResult::error(format!("子进程错误: {}", e)), - Err(_) => { - return SkillResult::error(format!("执行超时 ({}s)", self.timeout.as_secs())) - } - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - return SkillResult::error(format!( - "退出码 {}: {}", - output.status.code().unwrap_or(-1), - if stderr.is_empty() { stdout } else { stderr } - )); - } - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if stdout.is_empty() { - return SkillResult::ok("执行成功(无输出)"); - } - - // 尝试解析 JSON 结果 - if let Ok(result) = serde_json::from_str::(&stdout) { - return result; - } - - // 非 JSON 输出,当作纯文本结果 - SkillResult::ok(stdout) - } -} diff --git a/src/tools/spec.rs b/src/tools/spec.rs new file mode 100644 index 0000000..b3de764 --- /dev/null +++ b/src/tools/spec.rs @@ -0,0 +1,74 @@ +//! ## 工具规范类型 +//! +//! 与 `*.tool.yaml` 格式一一对应的 Rust 结构体。 + +use serde::Deserialize; + +/// 单个参数的声明 +#[derive(Debug, Clone, Deserialize)] +pub struct ParamSpec { + pub name: String, + #[serde(default)] + pub required: bool, + pub desc: String, +} + +/// 工具规范(`*.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)] +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, +} + +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/src/tools/stdio_tool.rs b/src/tools/stdio_tool.rs deleted file mode 100644 index b0076ab..0000000 --- a/src/tools/stdio_tool.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! ## Stdio 工具 —— 通过子进程 stdin/stdout 通信的工具 -//! -//! 启动一个子进程,通过 stdin 发送 JSON 参数,从 stdout 读取 JSON 结果。 -//! 适用于外部脚本、可执行程序等。 -//! -//! ### 协议 -//! - stdin: 一行 JSON 字符串 -//! - stdout: 一行 JSON 字符串(`{"success": true/false, "output": "...", "data": ...}`) -//! - 超时: 默认 30 秒,可通过 `timeout_secs` 配置 - -use async_trait::async_trait; -use std::time::Duration; -use tokio::process::Command; -use tokio::io::AsyncWriteExt; - -use super::tool::Tool; -use super::types::{SkillResult, SkillSpec}; - -/// ## Stdio 工具 -/// -/// 通过子进程 stdin/stdout 通信的工具。 -/// -/// 当前未被现有代码直接引用,供后续扩展使用。 -#[allow(dead_code)] -pub struct StdioTool { - spec: SkillSpec, - command: String, - args: Vec, - timeout: Duration, -} - -impl StdioTool { - /// 创建 Stdio 工具 - #[allow(dead_code)] - /// - /// ### 参数 - /// - `spec` — 工具元数据 - /// - `command` — 可执行文件路径 - /// - `args` — 启动参数 - /// - `timeout_secs` — 超时秒数(默认 30) - pub fn new( - spec: SkillSpec, - command: impl Into, - args: Vec, - timeout_secs: u64, - ) -> Self { - Self { - spec, - command: command.into(), - args, - timeout: Duration::from_secs(timeout_secs.max(5)), - } - } -} - -#[async_trait] -impl Tool for StdioTool { - fn spec(&self) -> &SkillSpec { - &self.spec - } - - async fn execute(&self, params: serde_json::Value) -> SkillResult { - let input = serde_json::to_string(¶ms).unwrap_or_default(); - - let mut child = match Command::new(&self.command) - .args(&self.args) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn() - { - Ok(c) => c, - Err(e) => return SkillResult::error(format!("启动子进程失败: {}", e)), - }; - - // 写入 stdin - if let Some(mut stdin) = child.stdin.take() { - if let Err(e) = stdin.write_all(input.as_bytes()).await { - return SkillResult::error(format!("写入 stdin 失败: {}", e)); - } - // 关闭 stdin 表示输入结束 - let _ = stdin.shutdown().await; - } - - // 等待子进程完成(带超时) - let output = match tokio::time::timeout(self.timeout, child.wait_with_output()).await { - Ok(Ok(output)) => output, - Ok(Err(e)) => return SkillResult::error(format!("子进程错误: {}", e)), - Err(_) => return SkillResult::error(format!("执行超时 ({}s)", self.timeout.as_secs())), - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - return SkillResult::error(format!( - "退出码 {}: {}", - output.status.code().unwrap_or(-1), - if stderr.is_empty() { stdout } else { stderr } - )); - } - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if stdout.is_empty() { - return SkillResult::ok("执行成功(无输出)"); - } - - // 尝试解析 JSON 结果 - if let Ok(result) = serde_json::from_str::(&stdout) { - return result; - } - - // 非 JSON 输出,当作纯文本结果 - SkillResult::ok(stdout) - } -} diff --git a/src/tools/tool.rs b/src/tools/tool.rs deleted file mode 100644 index be44c0c..0000000 --- a/src/tools/tool.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! ## 工具抽象 —— Tool trait + ToolRegistry -//! -//! 定义了所有工具的基类 `Tool` trait,以及统一的注册表 `ToolRegistry`。 -//! 所有具体工具(API / Stdio / Shell)都实现此 trait。 - -use async_trait::async_trait; -use std::collections::HashMap; - -use super::types::{RiskLevel, SkillResult, SkillSpec}; - -/// Boxed future for async tool handlers -pub type BoxFuture = std::pin::Pin + Send>>; - -/// Async handler function: JSON params → SkillResult -pub type ToolHandler = - std::sync::Arc BoxFuture + Send + Sync>; - -/// ## 工具基类 -/// -/// 所有工具(API / Stdio / Shell)都实现此 trait。 -/// -/// ### 方法 -/// - `spec()` — 返回工具的元数据(名称、描述、参数、风险等级) -/// - `execute()` — 执行工具,接收 JSON 参数,返回执行结果 -#[async_trait] -pub trait Tool: Send + Sync { - fn spec(&self) -> &SkillSpec; - async fn execute(&self, params: serde_json::Value) -> SkillResult; -} - -/// ## 工具注册表 -/// -/// 管理所有已注册的工具,提供按名称查找和执行的能力。 -/// -/// ### 用法 -/// ```rust,ignore -/// let mut registry = ToolRegistry::new(); -/// registry.register(Box::new(api_tool)); -/// registry.register(Box::new(shell_tool)); -/// -/// let result = registry.execute("tool_name", r#"{"key":"value"}"#).await; -/// ``` -pub struct ToolRegistry { - tools: HashMap>, -} - -impl ToolRegistry { - pub fn new() -> Self { - Self { - tools: HashMap::new(), - } - } - - /// 注册一个工具 - pub fn register(&mut self, tool: Box) { - let name = tool.spec().name.clone(); - self.tools.insert(name, tool); - } - - /// 按名称执行工具 - pub async fn execute(&self, name: &str, params_json: &str) -> Option { - let params: serde_json::Value = serde_json::from_str(params_json).unwrap_or_default(); - match self.tools.get(name) { - Some(tool) => Some(tool.execute(params).await), - None => None, - } - } - - /// 按名称获取工具引用 - #[allow(dead_code)] - pub fn get(&self, name: &str) -> Option<&dyn Tool> { - self.tools.get(name).map(|t| t.as_ref()) - } - - /// 返回所有工具的 spec 引用列表 - pub fn specs(&self) -> Vec<&SkillSpec> { - self.tools.values().map(|t| t.spec()).collect() - } - - /// 判断名称是否对应一个已注册的工具 - pub fn is_builtin(&self, name: &str) -> bool { - self.tools.contains_key(name) - } - - /// 判断工具是否为高风险(需要审批) - pub fn is_high_risk(&self, name: &str) -> bool { - self.tools - .get(name) - .map_or(false, |t| t.spec().risk_level == RiskLevel::High) - } -} diff --git a/src/tools/types.rs b/src/tools/types.rs deleted file mode 100644 index e4bee0e..0000000 --- a/src/tools/types.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! ## 工具系统类型定义 -//! -//! 定义了工具系统的核心类型: -//! -//! - `RiskLevel` — 工具的安全级别(Low/High) -//! - `SkillSpec` — 工具的元数据描述(名称、参数、风险等级) -//! - `SkillResult` — 工具执行结果 -//! - `ExecutionContext` — 工具执行时的上下文环境 -//! - `WechatSender` — 微信发送回调类型别名 - -use serde::{Deserialize, Serialize}; -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; - -/// ## 风险等级 —— 工具的安全级别 -/// -/// * `Low` — 低风险,直接执行(如天气查询、日期时间) -/// * `High` — 高风险,需要用户输入确认码审批(如写备忘录、发消息) - -#[derive(Debug, Clone, PartialEq, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum RiskLevel { - Low, - High, -} - -impl RiskLevel {} - -/// ## 工具元数据(SkillSpec)—— 工具的自描述信息 -/// -/// 用于给 LLM 展示"有什么工具可用、怎么用"。 -/// * `name` — 工具名称 -/// * `description` — 工具描述 -/// * `risk_level` — 风险等级(Low/High) -/// * `parameters` — JSON Schema 格式的参数定义 -/// * `timeout_secs` — 执行超时时间 - -#[derive(Debug, Clone, Deserialize)] -pub struct SkillSpec { - pub name: String, - pub description: String, - #[serde(default = "default_risk_level")] - pub risk_level: RiskLevel, - #[serde(default)] - pub parameters: serde_json::Value, - #[serde(default = "default_timeout")] - #[allow(dead_code)] - pub timeout_secs: u64, -} - -fn default_risk_level() -> RiskLevel { - RiskLevel::Low -} -fn default_timeout() -> u64 { - 30 -} - -/// ## 工具执行结果 -/// -/// * `success` — 执行是否成功 -/// * `output` — 可读的输出文本(将返回给 LLM) -/// * `data` — 结构化数据(可选),用于后续处理 - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SkillResult { - pub success: bool, - pub output: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, -} - -impl SkillResult { - pub fn ok(output: impl Into) -> Self { - Self { - success: true, - output: output.into(), - data: None, - } - } - - pub fn ok_with_data(output: impl Into, data: serde_json::Value) -> Self { - Self { - success: true, - output: output.into(), - data: Some(data), - } - } - - pub fn error(msg: impl Into) -> Self { - Self { - success: false, - output: msg.into(), - data: None, - } - } -} - -/// ## 执行上下文 —— 工具执行时需要的环境信息 -/// -/// 当工具在 daemon 上下文中执行时,携带以下信息: -/// * `user_id` — 触发工具的用户 -/// * `approval_manager` — 审批管理器(高风险工具需要) -/// * `send_wechat` — 微信发送回调(用于向用户发送审批提示) - -pub type WechatSender = Arc< - dyn Fn(&str, &str) -> Pin> + Send>> + Send + Sync, ->; - -#[allow(dead_code)] -pub struct ExecutionContext { - pub user_id: String, - pub approval_manager: Option>, - pub send_wechat: Option, -} - -impl ExecutionContext { - #[allow(dead_code)] - pub fn new(user_id: impl Into) -> Self { - Self { - user_id: user_id.into(), - approval_manager: None, - send_wechat: None, - } - } - - #[allow(dead_code)] - pub fn with_approval(mut self, mgr: Arc) -> Self { - self.approval_manager = Some(mgr); - self - } - - #[allow(dead_code)] - pub fn with_wechat(mut self, send: WechatSender) -> Self { - self.send_wechat = Some(send); - self - } -} diff --git a/src/wechat/client.rs b/src/wechat/client.rs index 230818f..b183d4a 100644 --- a/src/wechat/client.rs +++ b/src/wechat/client.rs @@ -86,7 +86,7 @@ impl WeChatClient { // ─── 公共方法 ─── /// ## 获取登录二维码链接 -/// + /// /// 调用 iLink API `/ilink/bot/get_bot_qrcode` 获取二维码。 /// 二维码内容是一个 URL,浏览器打开后显示二维码图片。 pub async fn get_qrcode(&self) -> Result<(String, String), String> { @@ -413,7 +413,7 @@ impl WeChatClient { // ─── 内部方法 ─── /// ## 公共请求头 -/// + /// /// 每个请求都携带以下头信息: /// * `Content-Type: application/json` /// * `iLink-App-ClientVersion` — 客户端版本号编码 diff --git a/src/wechat/types.rs b/src/wechat/types.rs index ab0341b..5a72493 100644 --- a/src/wechat/types.rs +++ b/src/wechat/types.rs @@ -134,8 +134,7 @@ pub struct RefMessage { /// 每种类型有对应的 `*_item` 字段,其他字段保持 None。 /// /// `text()` 便捷方法用于快速构造文本消息(发送消息时使用)。 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[derive(Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct MessageItem { #[serde(rename = "type", skip_serializing_if = "Option::is_none")] pub item_type: Option, @@ -183,7 +182,6 @@ impl MessageItem { } } - /// ## 微信消息结构 /// /// 对应 iLink API 返回的单个消息。所有字段都是 Option 的, @@ -197,8 +195,7 @@ impl MessageItem { /// * `context_token` — 微信上下文令牌(用于多轮对话的上下文恢复) /// * `group_id` — 群聊 ID(群消息时非空) -#[derive(Debug, Clone, Serialize, Deserialize)] -#[derive(Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct WeixinMessage { #[serde(skip_serializing_if = "Option::is_none")] pub seq: Option, @@ -230,7 +227,6 @@ pub struct WeixinMessage { pub context_token: Option, } - impl WeixinMessage { #[allow(dead_code)] pub const TYPE_NONE: i32 = 0; diff --git a/tools/amap/Cargo.lock b/tools/amap/Cargo.lock new file mode 100644 index 0000000..6142a1c --- /dev/null +++ b/tools/amap/Cargo.lock @@ -0,0 +1,105 @@ +# 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 new file mode 100644 index 0000000..e176003 --- /dev/null +++ b/tools/amap/Cargo.toml @@ -0,0 +1,11 @@ +[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 new file mode 100644 index 0000000..3e516ff --- /dev/null +++ b/tools/amap/specs/amap_geocode.tool.yaml @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..ade1812 --- /dev/null +++ b/tools/amap/specs/amap_map_link.tool.yaml @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..57781b3 --- /dev/null +++ b/tools/amap/specs/amap_poi_search.tool.yaml @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000..5fdd89b --- /dev/null +++ b/tools/amap/specs/amap_reverse_geocode.tool.yaml @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..c41f351 --- /dev/null +++ b/tools/amap/specs/amap_route_plan.tool.yaml @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000..ab69515 --- /dev/null +++ b/tools/amap/specs/amap_travel_plan.tool.yaml @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..8972b04 --- /dev/null +++ b/tools/amap/src/main.rs @@ -0,0 +1,50 @@ +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 new file mode 100755 index 0000000..3b5e6f9 --- /dev/null +++ b/tools/build.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# 构建所有工具 +set -e +cd "$(dirname "$0")" + +TOOLS=(datetime weather amap web_search fetch_page memories) + +echo "=== 构建工具 ===" +for tool in "${TOOLS[@]}"; do + echo " $tool..." + (cd "$tool" && cargo build --release --quiet) +done + +echo "=== 完成 ===" +for tool in "${TOOLS[@]}"; do + ls -lh "$tool/target/release/$tool" 2>/dev/null +done diff --git a/tools/datetime/Cargo.lock b/tools/datetime/Cargo.lock new file mode 100644 index 0000000..272173f --- /dev/null +++ b/tools/datetime/Cargo.lock @@ -0,0 +1,382 @@ +# 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 new file mode 100644 index 0000000..1ecf410 --- /dev/null +++ b/tools/datetime/Cargo.toml @@ -0,0 +1,12 @@ +[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 new file mode 100644 index 0000000..afd8b1f --- /dev/null +++ b/tools/datetime/specs/get_current_datetime.tool.yaml @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..2a0401c --- /dev/null +++ b/tools/datetime/src/main.rs @@ -0,0 +1,14 @@ +//! 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 new file mode 100644 index 0000000..b92a599 --- /dev/null +++ b/tools/fetch_page/Cargo.lock @@ -0,0 +1,105 @@ +# 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 new file mode 100644 index 0000000..327b89f --- /dev/null +++ b/tools/fetch_page/Cargo.toml @@ -0,0 +1,11 @@ +[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 new file mode 100644 index 0000000..00c115c --- /dev/null +++ b/tools/fetch_page/specs/fetch_page.tool.yaml @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..6627fcf --- /dev/null +++ b/tools/fetch_page/src/main.rs @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..a322bf2 --- /dev/null +++ b/tools/memories/Cargo.lock @@ -0,0 +1,219 @@ +# 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 new file mode 100644 index 0000000..f71ef79 --- /dev/null +++ b/tools/memories/Cargo.toml @@ -0,0 +1,13 @@ +[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 new file mode 100644 index 0000000..6b7499e --- /dev/null +++ b/tools/memories/specs/delete_memory.tool.yaml @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..cf1a93e --- /dev/null +++ b/tools/memories/specs/get_memory.tool.yaml @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..30d0827 --- /dev/null +++ b/tools/memories/specs/read_memories.tool.yaml @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..0038a6f --- /dev/null +++ b/tools/memories/specs/update_memory.tool.yaml @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..2a7497c --- /dev/null +++ b/tools/memories/specs/write_memory.tool.yaml @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..f1e2ba6 --- /dev/null +++ b/tools/memories/src/main.rs @@ -0,0 +1,37 @@ +//! 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/weather/Cargo.lock b/tools/weather/Cargo.lock new file mode 100644 index 0000000..eb8ad2a --- /dev/null +++ b/tools/weather/Cargo.lock @@ -0,0 +1,105 @@ +# 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 new file mode 100644 index 0000000..24a6af9 --- /dev/null +++ b/tools/weather/Cargo.toml @@ -0,0 +1,11 @@ +[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 new file mode 100644 index 0000000..59bd20f --- /dev/null +++ b/tools/weather/specs/weather_forecast.tool.yaml @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..9356910 --- /dev/null +++ b/tools/weather/specs/weather_hourly.tool.yaml @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..8dd1aa8 --- /dev/null +++ b/tools/weather/specs/weather_now.tool.yaml @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..3ea2a13 --- /dev/null +++ b/tools/weather/src/main.rs @@ -0,0 +1,49 @@ +//! 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 new file mode 100644 index 0000000..72e005f --- /dev/null +++ b/tools/web_search/Cargo.lock @@ -0,0 +1,105 @@ +# 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 new file mode 100644 index 0000000..29faaa8 --- /dev/null +++ b/tools/web_search/Cargo.toml @@ -0,0 +1,11 @@ +[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 new file mode 100644 index 0000000..4cca1da --- /dev/null +++ b/tools/web_search/specs/web_search.tool.yaml @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..8cde1bd --- /dev/null +++ b/tools/web_search/src/main.rs @@ -0,0 +1,22 @@ +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})); + } +}