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
+174 -27
View File
@@ -1,15 +1,18 @@
mod cli;
mod context;
mod daemon;
mod db;
mod ipc;
mod llm;
mod logger;
mod scheduler;
mod state;
mod tools;
mod wechat;
mod worker;
use clap::Parser;
use cli::{Cli, Commands};
use cli::{AmapAction, Cli, Commands, MemosAction, ToolCommand};
use context::MemoryStore;
use db::Database;
use llm::{Conversation, ConversationConfig, DEFAULT_SYSTEM_PROMPT, ToolExecutor, Usage};
@@ -42,6 +45,17 @@ async fn main() {
}
let file_state = state::StateManager::new(".data/weixin-ilink");
// 工具命令无需登录/数据库,提前处理
let tool_cmd = match &cli.command {
Commands::Tool(cmd) => Some(cmd),
_ => None,
};
if let Some(cmd) = tool_cmd {
cmd_tool(cmd.clone()).await;
return;
}
let database = match Database::connect().await {
Ok(db) => {
info!("✅ 数据库连接成功");
@@ -87,11 +101,29 @@ async fn main() {
cmd_usage(&database, since, until, model).await;
}
Commands::Service => cmd_listen(true, false, &database, &file_state, &memory_store).await,
Commands::Daemon { sock } => {
cmd_daemon(sock, &database, &file_state, &memory_store).await;
}
Commands::Worker { sock } => {
if let Err(e) = worker::run(&sock).await {
error!("Worker 失败: {}", e);
}
}
Commands::Tool(..) => unreachable!("Tool 命令已提前处理"),
}
}
// ─── 命令实现 ───
async fn cmd_daemon(
sock_path: String,
database: &Option<Arc<Database>>,
file_state: &state::StateManager,
memory_store: &Arc<MemoryStore>,
) {
daemon::run(sock_path, database, file_state, memory_store).await;
}
async fn cmd_login(
timeout_secs: u64,
database: &Option<Arc<Database>>,
@@ -339,10 +371,11 @@ async fn cmd_listen(
// query_capabilities: 列出所有内置工具
if n == "query_capabilities" {
let specs = tools::builtin::BuiltinRegistry::specs();
return Ok(build_capability_list(&specs));
return Ok(tools::build_capability_guide(&specs));
}
// 确定目标工具名和参数call_capability 提取 name + 透传完整参数)
// 确定目标工具名和参数
// call_capability: 从 {name, prompt} 中提取 name,从 prompt 中解包真实参数
let (target_name, target_args) = if n == "call_capability" {
let cp: serde_json::Value = serde_json::from_str(&args).unwrap_or_default();
let name = cp
@@ -350,7 +383,8 @@ async fn cmd_listen(
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default();
(name, serde_json::to_string(&cp).unwrap_or_default())
let unpacked = tools::unpack_call_params(&cp);
(name, serde_json::to_string(&unpacked).unwrap_or_default())
} else {
(n.clone(), args.clone())
};
@@ -841,30 +875,143 @@ async fn builtin_approve(ctx: &ExecutionContext, name: &str) -> Result<bool, Str
}
}
fn build_capability_list(specs: &[tools::types::SkillSpec]) -> String {
let mut lines = vec!["📋 可用工具:".to_string()];
for spec in specs {
let risk = if spec.risk_level == 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()
));
// ─── 工具 CLI 命令 ───
async fn cmd_tool(cmd: ToolCommand) {
match cmd {
ToolCommand::Datetime => {
let result = tools::builtins::datetime::execute();
println!("{}", result.output);
}
ToolCommand::Memos(action) => {
let args = match action {
MemosAction::List => serde_json::json!({"action": "list"}),
MemosAction::Add { content } => {
serde_json::json!({"action": "add", "content": content})
}
MemosAction::Delete { id } => {
serde_json::json!({"action": "delete", "id": id})
}
};
let args_str = serde_json::to_string(&args).unwrap_or_default();
let result = tools::builtins::memos::execute(&args_str).await;
println!("{}", result.output);
}
ToolCommand::Weather { location, days } => {
let params = serde_json::json!({"location": location, "days": days});
let result = tools::builtins::weather::execute(params).await;
println!("{}", result.output);
}
ToolCommand::Search {
query,
max_results,
no_answer,
topic,
time_range,
} => {
let mut params = serde_json::json!({
"query": query,
"max_results": max_results,
});
if no_answer {
params["include_answer"] = serde_json::json!(false);
}
if let Some(t) = topic {
params["topic"] = serde_json::json!(t);
}
if let Some(tr) = time_range {
params["time_range"] = serde_json::json!(tr);
}
let result = tools::builtins::web_search::execute(params).await;
println!("{}", result.output);
}
ToolCommand::Fetch { url } => {
let params = serde_json::json!({"url": url});
let result = tools::builtins::fetch_page::execute(params).await;
println!("{}", result.output);
}
ToolCommand::Amap(action) => {
cmd_amap(action).await;
}
}
}
async fn cmd_amap(action: AmapAction) {
match action {
AmapAction::PoiSearch {
keywords,
city,
location,
radius,
page,
offset,
} => {
let mut params = serde_json::json!({
"keywords": keywords,
"page": page,
"offset": offset,
});
if let Some(c) = city { params["city"] = serde_json::json!(c); }
if let Some(l) = location { params["location"] = serde_json::json!(l); }
if let Some(r) = radius { params["radius"] = serde_json::json!(r); }
let result = tools::builtins::amap::execute("amap_poi_search", params).await;
if let Some(r) = result { println!("{}", r.output); }
}
AmapAction::Geocode { address, city } => {
let mut params = serde_json::json!({"address": address});
if let Some(c) = city { params["city"] = serde_json::json!(c); }
let result = tools::builtins::amap::execute("amap_geocode", params).await;
if let Some(r) = result { println!("{}", r.output); }
}
AmapAction::ReverseGeocode { location } => {
let params = serde_json::json!({"location": location});
let result = tools::builtins::amap::execute("amap_reverse_geocode", params).await;
if let Some(r) = result { println!("{}", r.output); }
}
AmapAction::RoutePlan {
r#type,
origin,
destination,
city,
waypoints,
strategy,
} => {
let mut params = serde_json::json!({
"type": r#type,
"origin": origin,
"destination": destination,
});
if let Some(c) = city { params["city"] = serde_json::json!(c); }
if let Some(w) = waypoints { params["waypoints"] = serde_json::json!(w); }
if let Some(s) = strategy { params["strategy"] = serde_json::json!(s); }
let result = tools::builtins::amap::execute("amap_route_plan", params).await;
if let Some(r) = result { println!("{}", r.output); }
}
AmapAction::TravelPlan {
city,
interests,
route_type,
} => {
let interests_arr: Vec<serde_json::Value> = interests
.split(',')
.map(|s| serde_json::json!(s.trim()))
.collect();
let params = serde_json::json!({
"city": city,
"interests": interests_arr,
"route_type": route_type,
});
let result = tools::builtins::amap::execute("amap_travel_plan", params).await;
if let Some(r) = result { println!("{}", r.output); }
}
AmapAction::MapLink { data } => {
let map_data: serde_json::Value =
serde_json::from_str(&data).unwrap_or(serde_json::json!([]));
let params = serde_json::json!({"map_data": map_data});
let result = tools::builtins::amap::execute("amap_map_link", params).await;
if let Some(r) = result { println!("{}", r.output); }
}
}
lines.join("\n")
}