a04447c7bc
为 iAs 项目的所有 Rust 源文件添加了系统化的模块级文档注释(//!), 覆盖全部 src/ 下的文件。每个文件包含模块职责、架构设计、数据流 和关键设计决策的说明。 主要变更: - 入口/CLI: main.rs, cli.rs — 系统架构概览、子命令说明 - Daemon/Worker: daemon.rs, worker.rs — 三消费者架构、旧架构说明 - 微信通道: mod.rs, client.rs, types.rs — API 端点、登录流程 - LLM 系统: mod.rs, types.rs, provider.rs, deepseek.rs, conversation.rs — 架构分层、流式处理、工具循环、摘要机制 - 上下文管理: mod.rs, types.rs, builder.rs, tools.rs — Checkpoint 机制、Token Budget、双重摘要 - 工具系统: mod.rs, types.rs, builtin.rs, approval.rs — 两层元工具架构、审批流程 - 内置工具: mod.rs + 6 个工具 — API 端点、认证方式、参数说明 - 消息队列: mod.rs, message_queue.rs, runner.rs — 公平轮转算法、三消费者路由 - 数据库/状态/调度/日志: db/mod.rs, state.rs, scheduler.rs, logger.rs — 表结构、回退策略、定时任务、日志初始化
385 lines
13 KiB
Rust
385 lines
13 KiB
Rust
//! ## Web 搜索工具 —— Tavily Search API
|
||
//!
|
||
//! 通过 Tavily 搜索引擎获取互联网实时信息。
|
||
//! Tavily 是一个专为 AI Agent 设计的搜索引擎,返回结构化搜索结果。
|
||
//!
|
||
//! ### API
|
||
//! - 端点: `POST https://api.tavily.com/search`
|
||
//! - 认证: `Authorization: Bearer <TAVILY_API_KEY>`
|
||
//! - 文档: 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<String>, // basic | advanced | fast | ultra-fast
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
chunks_per_source: Option<u32>, // 1-3, 仅 search_depth=advanced
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
max_results: Option<u32>, // 0-20
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
topic: Option<String>, // general | news | finance
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
time_range: Option<String>, // day | week | month | year | d | w | m | y
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
start_date: Option<String>, // YYYY-MM-DD
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
end_date: Option<String>, // YYYY-MM-DD
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
include_answer: Option<serde_json::Value>, // false | true(=basic) | "basic" | "advanced"
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
include_raw_content: Option<serde_json::Value>, // false | true(=markdown) | "markdown" | "text"
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
include_images: Option<bool>,
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
include_image_descriptions: Option<bool>,
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
include_favicon: Option<bool>,
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
include_domains: Option<Vec<String>>,
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
exclude_domains: Option<Vec<String>>,
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
country: Option<String>,
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
auto_parameters: Option<bool>, // 默认 false
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
exact_match: Option<bool>,
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
include_usage: Option<bool>,
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
safe_search: Option<bool>, // Enterprise only
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// 响应结构体
|
||
// ═══════════════════════════════════════════════
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[allow(dead_code)]
|
||
struct SearchResponse {
|
||
query: String,
|
||
|
||
#[serde(default)]
|
||
answer: Option<String>,
|
||
|
||
#[serde(default)]
|
||
images: Vec<serde_json::Value>,
|
||
|
||
#[serde(default)]
|
||
results: Vec<SearchResult>,
|
||
|
||
#[serde(default)]
|
||
response_time: serde_json::Value,
|
||
|
||
#[serde(default)]
|
||
auto_parameters: Option<serde_json::Value>,
|
||
|
||
#[serde(default)]
|
||
usage: Option<UsageInfo>,
|
||
|
||
#[serde(default)]
|
||
request_id: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[allow(dead_code)]
|
||
struct SearchResult {
|
||
title: String,
|
||
url: String,
|
||
content: String,
|
||
#[serde(default)]
|
||
score: Option<f64>,
|
||
#[serde(default)]
|
||
raw_content: Option<String>,
|
||
#[serde(default)]
|
||
favicon: Option<String>,
|
||
#[serde(default)]
|
||
images: Vec<serde_json::Value>,
|
||
#[serde(default)]
|
||
published_date: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct UsageInfo {
|
||
#[serde(default)]
|
||
credits: Option<u32>,
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// 工具元数据
|
||
// ═══════════════════════════════════════════════
|
||
|
||
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::<SearchResponse>(&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))
|
||
}
|
||
}
|
||
}
|
||
}
|