Files
iAs/src/tools/builtins/web_search.rs
T
wunianxiao a04447c7bc docs: 为全部 38 个源文件补充详细模块级注释
为 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
  — 表结构、回退策略、定时任务、日志初始化
2026-06-10 09:46:42 +08:00

385 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! ## 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))
}
}
}
}