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:
2026-06-03 17:01:04 +08:00
parent e379e498b5
commit b2fe054eaf
23 changed files with 4367 additions and 263 deletions
+9 -5
View File
@@ -3,6 +3,7 @@ use sha2::{Digest, Sha256};
use sqlx::PgPool;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{Mutex, oneshot};
/// 审批决策
@@ -18,6 +19,7 @@ struct PendingEntry {
code_hash: String,
skill_name: String,
attempts_left: i32,
expires_at: Instant,
/// 通知等待方
sender: oneshot::Sender<ApprovalDecision>,
}
@@ -80,6 +82,7 @@ impl ApprovalManager {
code_hash,
skill_name: skill_name.to_string(),
attempts_left: 3,
expires_at: Instant::now() + std::time::Duration::from_secs(300),
sender: tx,
});
@@ -87,8 +90,8 @@ impl ApprovalManager {
}
/// 处理用户回复(由消息循环调用)
/// 匹配成功时返回 Some(技能名)匹配失败返回 None
pub async fn handle_reply(&self, user_id: &str, reply: &str) -> Option<String> {
/// 返回 (技能名, 决策)匹配失败返回 None
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)?;
@@ -141,17 +144,18 @@ impl ApprovalManager {
.await;
}
Some(name)
Some((name, result))
}
/// 清理过期审批(通知等待方 + 更新 DB)
/// 清理过期审批(按时间判定,通知等待方 + 更新 DB
pub async fn clean_expired(&self) {
let now = Instant::now();
let mut expired = Vec::new();
{
let map = self.pending.lock().await;
for (uid, entries) in map.iter() {
for (i, e) in entries.iter().enumerate() {
if e.sender.is_closed() {
if now > e.expires_at {
expired.push((uid.clone(), i, e.code_hash.clone()));
}
}
+30 -121
View File
@@ -1,17 +1,32 @@
use crate::tools::types::{RiskLevel, SkillResult};
use chrono::Local;
/// 内置工具执行器:统一处理参数校验、执行、错误返回
/// 内置工具注册表:名称 → 执行、spec 列表、风险判断
pub struct BuiltinRegistry;
impl BuiltinRegistry {
pub async fn execute(name: &str, args_json: &str) -> Option<SkillResult> {
match name {
"get_current_datetime" => Some(execute_datetime()),
"manage_memos" => Some(handle_memos(args_json).await),
"get_current_datetime" => Some(super::builtins::datetime::execute()),
"manage_memos" => Some(super::builtins::memos::execute(args_json).await),
"query_weather" => {
let params: serde_json::Value = serde_json::from_str(args_json).unwrap_or_default();
Some(super::weather::execute(params).await)
let params: serde_json::Value =
serde_json::from_str(args_json).unwrap_or_default();
Some(super::builtins::weather::execute(params).await)
}
"web_search" => {
let params: serde_json::Value =
serde_json::from_str(args_json).unwrap_or_default();
Some(super::builtins::web_search::execute(params).await)
}
"fetch_page" => {
let params: serde_json::Value =
serde_json::from_str(args_json).unwrap_or_default();
Some(super::builtins::fetch_page::execute(params).await)
}
n if n.starts_with("amap_") => {
let params: serde_json::Value =
serde_json::from_str(args_json).unwrap_or_default();
super::builtins::amap::execute(n, params).await
}
_ => None,
}
@@ -19,23 +34,15 @@ impl BuiltinRegistry {
/// 返回所有内置工具的 spec 列表
pub fn specs() -> Vec<super::types::SkillSpec> {
vec![
super::types::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,
},
super::types::SkillSpec {
name: "manage_memos".into(),
description: "管理备忘录:添加/列出/删除。高风险操作(add/delete)需确认".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,
},
super::weather::spec(),
]
let mut specs = vec![
super::builtins::datetime::spec(),
super::builtins::memos::spec(),
super::builtins::weather::spec(),
super::builtins::web_search::spec(),
super::builtins::fetch_page::spec(),
];
specs.extend(super::builtins::amap::specs());
specs
}
pub fn is_high_risk(name: &str) -> bool {
@@ -48,101 +55,3 @@ impl BuiltinRegistry {
Self::specs().iter().any(|s| s.name == name)
}
}
fn execute_datetime() -> SkillResult {
let now = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
SkillResult::ok(serde_json::json!({"datetime": now}).to_string())
}
async fn handle_memos(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"),
}
}
File diff suppressed because it is too large Load Diff
+19
View File
@@ -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())
}
+348
View File
@@ -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()
}
+114
View File
@@ -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"),
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod amap;
pub mod datetime;
pub mod fetch_page;
pub mod memos;
pub mod weather;
pub mod web_search;
@@ -30,8 +30,8 @@ fn base64url(data: &[u8]) -> String {
}
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 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))?;
@@ -72,10 +72,16 @@ fn generate_jwt() -> Result<String, String> {
// Sign
let message = format!("{}.{}", header_b64, payload_b64);
let signature = signing_key.try_sign(message.as_bytes())
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())))
Ok(format!(
"{}.{}.{}",
header_b64,
payload_b64,
base64url(&signature.to_bytes())
))
}
// ═══════════════════════════════════════════════
@@ -226,8 +232,8 @@ struct QWeatherClient {
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());
let api_host =
std::env::var("QWEATHER_API_HOST").unwrap_or_else(|_| "api.qweather.com".into());
Ok(Self {
http: Client::builder()
@@ -310,11 +316,7 @@ impl QWeatherClient {
}
/// 每日预报
async fn daily_forecast(
&self,
city_id: &str,
days: u32,
) -> Result<serde_json::Value, String> {
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",
@@ -418,7 +420,9 @@ impl QWeatherClient {
pub fn spec() -> SkillSpec {
SkillSpec {
name: "query_weather".into(),
description: "查询指定城市的天气信息,包括实时天气、未来几天预报、逐小时预报。参数 location 为城市名".into(),
description:
"查询指定城市的天气信息,包括实时天气、未来几天预报、逐小时预报。参数 location 为城市名"
.into(),
risk_level: RiskLevel::Low,
parameters: serde_json::json!({
"type": "object",
@@ -437,7 +441,7 @@ fn extract_params(params: &serde_json::Value) -> (&str, u32, u32) {
let location = params
.get("location")
.and_then(|v| v.as_str())
.unwrap_or("北京");
.unwrap_or("合肥");
let days = params
.get("days")
@@ -489,18 +493,32 @@ fn extract_city_from_prompt(prompt: &str) -> &str {
// 跳过前缀
let mut i = 0;
for prefix in &["帮我查一下", "帮我看看", "帮我查", "帮我", "查一下", "查询", "看看"] {
for prefix in &[
"帮我查一下",
"帮我看看",
"帮我查",
"帮我",
"查一下",
"查询",
"看看",
] {
if prompt.starts_with(prefix) {
i = prefix.chars().count();
while i < chars.len() && chars[i] as u32 <= 0x4e00 { i += 1; }
while i < chars.len() && chars[i] as u32 <= 0x4e00 {
i += 1;
}
break;
}
}
// 跳过非中文
while i < chars.len() && chars[i] as u32 <= 0x4e00 { i += 1; }
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; }
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 {
@@ -509,23 +527,14 @@ fn extract_city_from_prompt(prompt: &str) -> &str {
}
pub async fn execute(params: serde_json::Value) -> SkillResult {
// call_capability 透传但没有 structured 参数 → 告知正确格式
if params.get("location").is_none()
&& params.get("name").and_then(|v| v.as_str()) == Some("query_weather")
{
let spec = spec();
return SkillResult::error(format!(
"参数格式错误。请将 query_weather 所需的参数直接放在 call_capability 的 prompt 字段中,格式:\
prompt: {{\"location\": \"城市名\", \"days\": 3, \"hours\": 0}}。\
{} schema{}",
spec.description,
serde_json::to_string(&spec.parameters).unwrap_or_default()
));
}
let (location, days, hours) = extract_params(&params);
tracing::info!("🌤️ 查询天气: location={}, days={}, hours={}", location, days, hours);
tracing::info!(
"🌤️ 查询天气: location={}, days={}, hours={}",
location,
days,
hours
);
let client = match QWeatherClient::new().await {
Ok(c) => c,
@@ -541,9 +550,16 @@ pub async fn execute(params: serde_json::Value) -> SkillResult {
// 并发查询
let now_fut = client.weather_now(&city_id);
let daily_fut = client.daily_forecast(&city_id, days);
let hourly_fut = client.hourly_forecast(&city_id, hours);
let (now_result, daily_result, hourly_result) = tokio::join!(now_fut, daily_fut, hourly_fut);
// 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,
@@ -626,7 +642,10 @@ mod tests {
#[test]
fn test_extract_city_from_prompt() {
// 取前缀后的连续中文给 GeoAPI 模糊搜索(多取不影响结果)
assert_eq!(extract_city_from_prompt("查询合肥未来7天的天气预报"), "合肥未来");
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("帮我查一下广州"), "广州");
+362
View File
@@ -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))
}
}
}
}
+145 -1
View File
@@ -1,4 +1,148 @@
pub mod approval;
pub mod builtin;
pub mod builtins;
pub mod types;
pub mod weather;
/// 从 call_capability 的 `{name, prompt}` JSON 中提取目标工具的真实参数。
///
/// 逻辑:
/// - 如果 prompt 是 JSON 字符串 → parse 后合并
/// - 如果 prompt 是 JSON 对象 → 直接合并
/// - 顶层字段(非 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::<serde_json::Value>(s) {
if 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)
}
#[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);
}
}
use types::SkillSpec;
/// 构建工具调用指南(含优先级说明)
/// 返回给 LLM 的工具列表 + 使用建议
pub fn build_capability_guide(specs: &[SkillSpec]) -> String {
let mut lines = vec![
"📋 可用工具及调用优先级指南:".to_string(),
String::new(),
"## 🗺️ 高德地图工具(amap_*)— 旅游、路线、地点查询优先使用:".to_string(),
];
// 先列出 amap 工具
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 {
for spec in &amap_specs {
format_spec(&mut lines, spec);
}
}
lines.push(String::new());
lines.push(" ▸ 旅游规划、路径规划、地点/周边搜索、地理编码 → 优先使用 amap_* 系列工具".to_string());
lines.push(String::new());
lines.push("## 🌤️ 天气工具 — 天气相关查询:".to_string());
for spec in specs.iter().filter(|s| s.name == "query_weather") {
format_spec(&mut lines, spec);
}
lines.push(String::new());
lines.push(" ▸ 任何天气相关问题(实时天气、天气预报、逐小时预报等)→ 使用 query_weather".to_string());
lines.push(String::new());
lines.push("## 🔍 其他工具:".to_string());
for spec in specs.iter().filter(|s| !s.name.starts_with("amap_") && s.name != "query_weather") {
format_spec(&mut lines, spec);
}
lines.push(String::new());
lines.push("## ⚡ 工具选择优先级:".to_string());
lines.push(" 1. 旅游/路线/地点/地址查询 → amap_* 系列".to_string());
lines.push(" 2. 天气查询 → query_weather".to_string());
lines.push(" 3. 联网搜索/网页抓取 → web_search / fetch_page".to_string());
lines.push(" 4. 以上都不匹配的需求 → web_search 搜索最新信息".to_string());
lines.push(String::new());
lines.push("📌 注意:每次调用工具前请确认它确实是当前最合适的工具。".to_string());
lines.join("\n")
}
fn format_spec(lines: &mut Vec<String>, spec: &SkillSpec) {
let risk = if spec.risk_level == crate::tools::types::RiskLevel::High {
" ⚠️需确认"
} else {
""
};
lines.push(format!(
"\n🔹 {} {}{}",
spec.name, risk, spec.description
));
if !spec
.parameters
.get("properties")
.and_then(|v| v.as_object())
.map_or(true, |o| o.is_empty())
{
lines.push(format!(
" 参数: {}",
serde_json::to_string(&spec.parameters).unwrap_or_default()
));
}
}
+1 -5
View File
@@ -12,11 +12,7 @@ pub enum RiskLevel {
High,
}
impl RiskLevel {
pub fn is_high(&self) -> bool {
matches!(self, RiskLevel::High)
}
}
impl RiskLevel {}
// ─── 工具元数据 ───