//! ## 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 { if !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)) } } } }