feat: 高德地图工具 + 多轮 bug 修复
新增: - 高德地图 6 个内置工具: amap_poi_search, amap_geocode, amap_reverse_geocode, amap_route_plan, amap_travel_plan, amap_map_link - CLI 子命令: ias tool amap (poi-search/geocode/route-plan/travel-plan/map-link) - 工具优先级指南: build_capability_guide() 含 amap > weather > web_search 优先级 - DEFAULT_SYSTEM_PROMPT 内置工具优先级说明(不再依赖环境变量) Bug 修复: 1. (HIGH) call_capability 参数契约修复 — 新增 unpack_call_params() 统一解包 prompt 字段 2. (MEDIUM) 天气 hours=0 不再调用逐时 API 3. (MEDIUM) 长期记忆 daemon 双写缓存+DB,无 DB 不丢失 4. (HIGH) 审批流程状态语义修复 — handle_reply 返回 ApprovalDecision,拒绝/过期不再当作通过 5. (HIGH) 审批有效期基于 expires_at 时间戳而非 receiver drop 6. (MEDIUM) 摘要保存改为 current_user_id 与读取一致 7. (MEDIUM) worker 审批通过后删除孤立 tool result,仅保留 approved_tool 状态放行 架构: - daemon + worker IPC 架构 (Unix Domain Socket) - build_capability_guide 共享函数消除 main.rs/worker.rs 重复
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
//! 日期时间工具
|
||||
use chrono::Local;
|
||||
|
||||
use super::super::types::{RiskLevel, SkillResult, SkillSpec};
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! 网页内容提取工具
|
||||
//!
|
||||
//! 使用可读性算法自动识别正文区域:清除广告/导航/页脚噪声,
|
||||
//! 按文本密度评分,取最高分区域。同时支持站点自定义 CSS 选择器。
|
||||
//!
|
||||
//! 站点选择器配置: `~/.ias/site_selectors.json`
|
||||
|
||||
use reqwest::Client as HttpClient;
|
||||
use scraper::{ElementRef, Html, Selector};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::super::types::{RiskLevel, SkillResult, SkillSpec};
|
||||
|
||||
/// 站点选择器配置文件路径
|
||||
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 关键词
|
||||
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) {
|
||||
if text.len() > 50 {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 先用可读性算法
|
||||
if let Some(text) = readability_extract(document) {
|
||||
if 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<String> {
|
||||
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::<Vec<_>>().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(|c| c == '.' || c == '。' || c == '?' || c == '?' || c == '!' || c == '!').count() as f64;
|
||||
score += sentences * 2.0;
|
||||
|
||||
// 段落数(<p> 标签)
|
||||
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) {
|
||||
if 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") {
|
||||
if let Some(el) = document.select(&sel).next() {
|
||||
let t: String = el.text().collect::<Vec<_>>().join(" ").trim().to_string();
|
||||
if !t.is_empty() { return t; }
|
||||
}
|
||||
}
|
||||
"无标题".to_string()
|
||||
}
|
||||
|
||||
fn try_selector(document: &Html, sel_str: &str) -> Option<String> {
|
||||
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<String, Vec<String>> {
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//! 备忘录工具 — 文件存储,支持 add/list/delete
|
||||
use chrono::Local;
|
||||
|
||||
use super::super::types::{RiskLevel, SkillResult, SkillSpec};
|
||||
|
||||
pub fn spec() -> SkillSpec {
|
||||
SkillSpec {
|
||||
name: "manage_memos".into(),
|
||||
description: "管理备忘录:添加/列出/删除".into(),
|
||||
risk_level: RiskLevel::Low,
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string", "enum": ["add", "list", "delete"]},
|
||||
"content": {"type": "string"},
|
||||
"id": {"type": "integer"}
|
||||
}
|
||||
}),
|
||||
timeout_secs: 10,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute(args_json: &str) -> SkillResult {
|
||||
let mut params: serde_json::Value = serde_json::from_str(args_json).unwrap_or_default();
|
||||
// call_capability 透传完整参数,action/content 在顶层
|
||||
// 如果顶层没有 action,尝试从 prompt 推断
|
||||
if params.get("action").is_none() {
|
||||
let prompt = params.get("prompt").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let action = if prompt.contains("添加") || prompt.contains("新增") || prompt.contains("add")
|
||||
{
|
||||
"add"
|
||||
} else if prompt.contains("删除") || prompt.contains("移除") || prompt.contains("delete")
|
||||
{
|
||||
"delete"
|
||||
} else {
|
||||
"list"
|
||||
};
|
||||
let content = params.get("prompt").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let memo_id: i32 = content
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse()
|
||||
.unwrap_or(0);
|
||||
params = serde_json::json!({"action": action, "content": content, "id": memo_id});
|
||||
}
|
||||
let action = params
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("list");
|
||||
let path = ".data/memos.json";
|
||||
if let Err(e) = std::fs::create_dir_all(".data") {
|
||||
return SkillResult::error(format!("创建目录失败: {}", e));
|
||||
}
|
||||
|
||||
let mut data: Vec<serde_json::Value> = if std::path::Path::new(path).exists() {
|
||||
serde_json::from_str(&std::fs::read_to_string(path).unwrap_or_default()).unwrap_or_default()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
match action {
|
||||
"add" => {
|
||||
let content = params.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if content.is_empty() {
|
||||
return SkillResult::error("请提供 content 参数");
|
||||
}
|
||||
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) = std::fs::write(path, serde_json::to_string_pretty(&data).unwrap()) {
|
||||
return SkillResult::error(format!("写入备忘录失败: {}", e));
|
||||
}
|
||||
SkillResult::ok(format!("已添加备忘录 #{}", next_id))
|
||||
}
|
||||
"list" => {
|
||||
if data.is_empty() {
|
||||
return SkillResult::ok("暂无备忘录".to_string());
|
||||
}
|
||||
let lines: Vec<String> = 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"))
|
||||
}
|
||||
"delete" => {
|
||||
let id = params.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
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) = std::fs::write(path, serde_json::to_string_pretty(&data).unwrap()) {
|
||||
return SkillResult::error(format!("写入备忘录失败: {}", e));
|
||||
}
|
||||
SkillResult::ok(format!("已删除备忘录 #{}", id))
|
||||
}
|
||||
_ => SkillResult::error("未知操作,支持: add, list, delete"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod amap;
|
||||
pub mod datetime;
|
||||
pub mod fetch_page;
|
||||
pub mod memos;
|
||||
pub mod weather;
|
||||
pub mod web_search;
|
||||
@@ -0,0 +1,677 @@
|
||||
// 和风天气查询工具(Rust 原生实现,替代 bash 脚本)
|
||||
//
|
||||
// API:
|
||||
// GeoAPI: GET /geo/v2/city/lookup?location=城市名 → city_id
|
||||
// Now: GET /v7/weather/now?location=city_id → 实时天气
|
||||
// Daily: GET /v7/weather/{3,7,10,15,30}d?location=id → 每日预报
|
||||
// Hourly: GET /v7/weather/{24,72,168}h?location=id → 逐时预报
|
||||
//
|
||||
// 认证: EdDSA JWT (Ed25519)
|
||||
// 缓存: city_id 内存缓存 1h 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<String, String> {
|
||||
let key_file = std::env::var("QWEATHER_JWT_PRIVATE_KEY_FILE")
|
||||
.unwrap_or_else(|_| "qweather/ed25519-private.pem".into());
|
||||
|
||||
let pem = std::fs::read_to_string(&key_file)
|
||||
.map_err(|e| format!("读取密钥文件 {} 失败: {}", key_file, e))?;
|
||||
|
||||
// 手动解析 PEM: 去掉头尾,base64 解码得到 DER,再用 from_pkcs8_der
|
||||
let der = pem
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with("-----"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
let der_bytes = general_purpose::STANDARD
|
||||
.decode(&der)
|
||||
.map_err(|e| format!("Base64 解码密钥失败: {e}"))?;
|
||||
|
||||
let signing_key = SigningKey::from_pkcs8_der(&der_bytes)
|
||||
.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<CityLocation>,
|
||||
}
|
||||
|
||||
#[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<WeatherNow>,
|
||||
}
|
||||
|
||||
#[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<DailyForecast>,
|
||||
}
|
||||
|
||||
#[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<HourlyForecast>,
|
||||
}
|
||||
|
||||
#[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<Mutex<HashMap<String, (String, Instant)>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
const CACHE_TTL: Duration = Duration::from_secs(3600);
|
||||
|
||||
fn get_cached_city(query: &str) -> Option<String> {
|
||||
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<Self, String> {
|
||||
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<String, String> {
|
||||
// 检查缓存
|
||||
if let Some(id) = get_cached_city(location) {
|
||||
tracing::debug!("🏙️ 城市缓存命中: {} → {}", 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!("🏙️ 城市查询: {} → {} ({})", location, city.name, id);
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// 实时天气
|
||||
async fn weather_now(&self, city_id: &str) -> Result<serde_json::Value, String> {
|
||||
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<serde_json::Value, String> {
|
||||
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<serde_json::Value> = 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<serde_json::Value, String> {
|
||||
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<serde_json::Value> = 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))
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// 公开接口
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
pub fn spec() -> SkillSpec {
|
||||
SkillSpec {
|
||||
name: "query_weather".into(),
|
||||
description:
|
||||
"查询指定城市的天气信息,包括实时天气、未来几天预报、逐小时预报。参数 location 为城市名"
|
||||
.into(),
|
||||
risk_level: RiskLevel::Low,
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {"type": "string", "description": "城市名,如 北京、上海"},
|
||||
"days": {"type": "integer", "description": "预报天数,默认 3,最大 30", "default": 3},
|
||||
"hours": {"type": "integer", "description": "逐时预报小时数,默认 0(不查询),最大 168", "default": 0}
|
||||
},
|
||||
"required": ["location"]
|
||||
}),
|
||||
timeout_secs: 15,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_params(params: &serde_json::Value) -> (&str, u32, u32) {
|
||||
let location = params
|
||||
.get("location")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("合肥");
|
||||
|
||||
let days = params
|
||||
.get("days")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(3)
|
||||
.min(30) as u32;
|
||||
|
||||
let hours = params
|
||||
.get("hours")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0)
|
||||
.min(168) as u32;
|
||||
|
||||
(location, days.max(1), hours)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn extract_days_from_prompt(prompt: &str) -> u32 {
|
||||
// 匹配模式: "7天" "3日" "未来7" "7d" "7D"
|
||||
for pat in &["天", "日", "d", "D"] {
|
||||
if let Some(pos) = prompt.find(pat) {
|
||||
// 向前找数字
|
||||
let before = &prompt[..pos];
|
||||
if let Some(num_str) = before
|
||||
.chars()
|
||||
.rev()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect::<String>()
|
||||
.into()
|
||||
{
|
||||
if let Ok(n) = num_str.parse::<u32>() {
|
||||
return n.min(30).max(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3 // 默认
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn extract_city_from_prompt(prompt: &str) -> &str {
|
||||
// 去掉常见前缀,取第一个2-3字中文词。GeoAPI 支持模糊搜索,
|
||||
// 即使多取一两个字也能正确匹配到城市
|
||||
let chars: Vec<char> = prompt.chars().collect();
|
||||
let byte_pos = |idx: usize| -> usize { chars[..idx].iter().map(|c| c.len_utf8()).sum() };
|
||||
|
||||
// 跳过前缀
|
||||
let mut i = 0;
|
||||
for prefix in &[
|
||||
"帮我查一下",
|
||||
"帮我看看",
|
||||
"帮我查",
|
||||
"帮我",
|
||||
"查一下",
|
||||
"查询",
|
||||
"看看",
|
||||
] {
|
||||
if prompt.starts_with(prefix) {
|
||||
i = prefix.chars().count();
|
||||
while i < chars.len() && chars[i] as u32 <= 0x4e00 {
|
||||
i += 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 跳过非中文
|
||||
while i < chars.len() && chars[i] as u32 <= 0x4e00 {
|
||||
i += 1;
|
||||
}
|
||||
// 取至多4个连续中文
|
||||
let start = i;
|
||||
while i < chars.len() && chars[i] as u32 > 0x4e00 && i - start < 4 {
|
||||
i += 1;
|
||||
}
|
||||
if i - start >= 2 {
|
||||
&prompt[byte_pos(start)..byte_pos(i)]
|
||||
} else {
|
||||
"北京"
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute(params: serde_json::Value) -> SkillResult {
|
||||
let (location, days, hours) = extract_params(¶ms);
|
||||
|
||||
tracing::info!(
|
||||
"🌤️ 查询天气: location={}, days={}, hours={}",
|
||||
location,
|
||||
days,
|
||||
hours
|
||||
);
|
||||
|
||||
let client = match QWeatherClient::new().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return SkillResult::error(format!("初始化天气客户端失败: {}", e)),
|
||||
};
|
||||
|
||||
// 先查城市 ID
|
||||
let city_id = match client.lookup_city(location).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => return SkillResult::error(e),
|
||||
};
|
||||
|
||||
// 并发查询
|
||||
let now_fut = client.weather_now(&city_id);
|
||||
let daily_fut = client.daily_forecast(&city_id, days);
|
||||
|
||||
// hours == 0 时跳过逐时预报,节省 API 调用
|
||||
let (now_result, daily_result, hourly_result) = if hours > 0 {
|
||||
let hourly_fut = client.hourly_forecast(&city_id, hours);
|
||||
let (n, d, h) = tokio::join!(now_fut, daily_fut, hourly_fut);
|
||||
(n, d, h)
|
||||
} else {
|
||||
let (n, d) = tokio::join!(now_fut, daily_fut);
|
||||
(n, d, Err("hours=0, skipped".into()))
|
||||
};
|
||||
|
||||
let now = match now_result {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
tracing::warn!("实时天气查询失败: {}", e);
|
||||
serde_json::Value::Null
|
||||
}
|
||||
};
|
||||
|
||||
let daily = match daily_result {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::warn!("每日预报查询失败: {}", e);
|
||||
serde_json::Value::Null
|
||||
}
|
||||
};
|
||||
|
||||
let hourly = match hourly_result {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::warn!("逐时预报查询失败: {}", e);
|
||||
serde_json::Value::Null
|
||||
}
|
||||
};
|
||||
|
||||
let output = serde_json::json!({
|
||||
"location": location,
|
||||
"now": now,
|
||||
"forecast": daily,
|
||||
"hourly": hourly,
|
||||
});
|
||||
|
||||
// 过滤 null 字段
|
||||
let mut output = output;
|
||||
if let Some(obj) = output.as_object_mut() {
|
||||
obj.retain(|_, v| !v.is_null());
|
||||
// 过滤 forecast/hourly 中每个元素内的空值
|
||||
for key in &["forecast", "hourly"] {
|
||||
if let Some(arr) = obj.get_mut(*key).and_then(|v| v.as_array_mut()) {
|
||||
for item in arr.iter_mut() {
|
||||
if let Some(m) = item.as_object_mut() {
|
||||
m.retain(|_, v| !v.is_null() && v.as_str().map_or(true, |s| !s.is_empty()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let text = serde_json::to_string(&output).unwrap_or_default();
|
||||
SkillResult::ok_with_data(text, output)
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_params() {
|
||||
let params = serde_json::json!({"location": "上海", "days": 5});
|
||||
let (loc, days, hours) = extract_params(¶ms);
|
||||
assert_eq!(loc, "上海");
|
||||
assert_eq!(days, 5);
|
||||
assert_eq!(hours, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_city_from_prompt() {
|
||||
// 取前缀后的连续中文给 GeoAPI 模糊搜索(多取不影响结果)
|
||||
assert_eq!(
|
||||
extract_city_from_prompt("查询合肥未来7天的天气预报"),
|
||||
"合肥未来"
|
||||
);
|
||||
assert_eq!(extract_city_from_prompt("北京今天天气"), "北京今天");
|
||||
assert_eq!(extract_city_from_prompt("上海"), "上海");
|
||||
assert_eq!(extract_city_from_prompt("帮我查一下广州"), "广州");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_days() {
|
||||
assert_eq!(extract_days_from_prompt("查询合肥未来7天的天气预报"), 7);
|
||||
assert_eq!(extract_days_from_prompt("3天预报"), 3);
|
||||
assert_eq!(extract_days_from_prompt("北京天气"), 3);
|
||||
}
|
||||
|
||||
/// 端到端测试:需要有效的 QWeather 环境变量
|
||||
#[tokio::test]
|
||||
async fn test_execute_weather_integration() {
|
||||
// 跳过无环境变量的情况
|
||||
if std::env::var("QWEATHER_JWT_KEY_ID").is_err() {
|
||||
eprintln!("跳过集成测试: 未设置 QWeather 环境变量");
|
||||
return;
|
||||
}
|
||||
|
||||
let result = super::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["now"].is_object(), "应有实时天气");
|
||||
assert!(data["forecast"].is_array(), "应有预报");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
//! Web 搜索工具 — Tavily Search API
|
||||
//!
|
||||
//! API: POST https://api.tavily.com/search
|
||||
//! 认证: Authorization: Bearer <TAVILY_API_KEY>
|
||||
//! 文档: https://docs.tavily.com/documentation/api-reference/endpoint/search
|
||||
|
||||
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)]
|
||||
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)]
|
||||
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>,
|
||||
}
|
||||
|
||||
// ─── 工具 spec ───
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user