Files
iAs/src/tools/builtins/amap.rs
T
wunianxiao 57c030a62a refactor(logger): 日志体系优化,分四类文件存储
- 认证日志 (auth.log): 登录/token/监听器注册,target=ias::auth
- 消息队列日志 (queue.log): 入队/出队内容、消费路由,target=ias::queue
- 工具日志 (tool.log): 调用参数/输出/耗时/工具错误,target=ias::tool
- 错误日志 (error.log): 所有非工具调用的 ERROR/WARN 自动捕获

使用 tracing-subscriber 多层过滤架构,终端输出保持不变
2026-06-17 11:29:13 +08:00

979 lines
37 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.
//! ## 高德地图综合工具(Rust 原生实现 — v5 API
//!
//! 提供 6 个高德地图相关的工具:
//!
//! | 工具名 | 功能 | API 版本 |
//! |--------|------|---------|
//! | `amap_poi_search` | POI(地点)搜索 + 周边搜索 | v5 |
//! | `amap_geocode` | 地理编码:地址 → 坐标 | v3 |
//! | `amap_reverse_geocode` | 逆地理编码:坐标 → 地址 | v3 |
//! | `amap_route_plan` | 路径规划(步行/驾车/骑行/公交) | v5 |
//! | `amap_travel_plan` | 智能旅游规划(搜索兴趣点 + 规划路线) | v5 |
//! | `amap_map_link` | 生成地图可视化链接 | - |
//!
//! ### 认证
//! 环境变量 `AMAP_WEBSERVICE_KEY` 或 `AMAP_KEY`
//! 获取 Key: https://lbs.amap.com/api/webservice/create-project-and-key
//!
//! ### API 端点
//! - POI 文本搜索: `GET /v5/place/text`
//! - POI 周边搜索: `GET /v5/place/around`
//! - 地理编码: `GET /v3/geocode/geo`
//! - 逆地理编码: `GET /v3/geocode/regeo`
//! - 步行/驾车/骑行/公交路线: `GET /v5/direction/{type}`
//! - 所有请求必须带 `appname=amap-lbs-skill`
// 高德地图综合工具(Rust 原生实现 — v5 API
//
// API:
// POI 搜索: GET /v5/place/text → poi 列表
// POI 周边搜索: GET /v5/place/around → poi 列表
// 地理编码: GET /v3/geocode/geo → 坐标
// 逆地理编码: GET /v3/geocode/regeo → 地址
// 步行路线: GET /v5/direction/walking → 路线
// 驾车路线: GET /v5/direction/driving → 路线
// 骑行路线: GET /v5/direction/bicycling → 路线
// 电动车骑行: GET /v5/direction/electrobike → 路线
// 公交路线: GET /v5/direction/transit/integrated → 路线
//
// 认证: AMAP_WEBSERVICE_KEY 或 AMAP_KEY 环境变量
// 所有请求必须带 appname=amap-lbs-skill
use reqwest::Client;
use std::time::Duration;
use crate::tools::types::{RiskLevel, SkillResult, SkillSpec};
// ═══════════════════════════════════════════════
// AmapClient — 共享的高德 API 客户端
// ═══════════════════════════════════════════════
struct AmapClient {
http: Client,
key: String,
}
impl AmapClient {
fn new() -> Result<Self, String> {
let key = std::env::var("AMAP_WEBSERVICE_KEY")
.or_else(|_| std::env::var("AMAP_KEY"))
.map_err(|_| {
"未设置高德 API Key,请设置环境变量 AMAP_WEBSERVICE_KEY 或 AMAP_KEY。\
获取 Key: https://lbs.amap.com/api/webservice/create-project-and-key"
.to_string()
})?;
Ok(Self {
http: Client::builder()
.timeout(Duration::from_secs(15))
.build()
.map_err(|e| format!("创建 HTTP client 失败: {}", e))?,
key,
})
}
/// POI 文本搜索 (v5)
async fn poi_text_search(
&self,
keywords: &str,
city: Option<&str>,
page_num: u32,
page_size: u32,
) -> Result<serde_json::Value, String> {
let region = city.unwrap_or("");
let pn = page_num.to_string();
let ps = page_size.to_string();
let resp: serde_json::Value = self
.http
.get("https://restapi.amap.com/v5/place/text")
.query(&[
("key", self.key.as_str()),
("keywords", keywords),
("region", region),
("page_num", &pn),
("page_size", &ps),
("appname", "amap-lbs-skill"),
])
.send()
.await
.map_err(|e| format!("POI 搜索请求失败: {}", e))?
.json()
.await
.map_err(|e| format!("解析 POI 搜索响应失败: {}", e))?;
if resp["status"].as_str() != Some("1") {
return Err(format!(
"POI 搜索失败: {}",
resp["info"].as_str().unwrap_or("未知错误")
));
}
Ok(resp)
}
/// POI 周边搜索 (v5)
async fn poi_around(
&self,
keywords: &str,
location: &str,
radius: u32,
page_num: u32,
page_size: u32,
sortrule: &str,
) -> Result<serde_json::Value, String> {
let pn = page_num.to_string();
let ps = page_size.to_string();
let r = radius.to_string();
let resp: serde_json::Value = self
.http
.get("https://restapi.amap.com/v5/place/around")
.query(&[
("key", self.key.as_str()),
("keywords", keywords),
("location", location),
("radius", &r),
("page_num", &pn),
("page_size", &ps),
("sortrule", sortrule),
("appname", "amap-lbs-skill"),
])
.send()
.await
.map_err(|e| format!("周边搜索请求失败: {}", e))?
.json()
.await
.map_err(|e| format!("解析周边搜索响应失败: {}", e))?;
if resp["status"].as_str() != Some("1") {
return Err(format!(
"周边搜索失败: {}",
resp["info"].as_str().unwrap_or("未知错误")
));
}
Ok(resp)
}
/// 地理编码:地址 → 坐标 (v3)
async fn geocode(
&self,
address: &str,
city: Option<&str>,
) -> Result<serde_json::Value, String> {
let mut params = vec![
("key", self.key.as_str()),
("address", address),
("output", "JSON"),
("appname", "amap-lbs-skill"),
];
let city_val;
if let Some(c) = city {
city_val = c.to_string();
params.push(("city", &city_val));
}
let resp: serde_json::Value = self
.http
.get("https://restapi.amap.com/v3/geocode/geo")
.query(&params)
.send()
.await
.map_err(|e| format!("地理编码请求失败: {}", e))?
.json()
.await
.map_err(|e| format!("解析地理编码响应失败: {}", e))?;
if resp["status"].as_str() != Some("1") {
return Err(format!(
"地理编码失败: {}",
resp["info"].as_str().unwrap_or("未知错误")
));
}
Ok(resp)
}
/// 逆地理编码:坐标 → 地址 (v3)
async fn regeocode(&self, location: &str) -> Result<serde_json::Value, String> {
let resp: serde_json::Value = self
.http
.get("https://restapi.amap.com/v3/geocode/regeo")
.query(&[
("key", self.key.as_str()),
("location", location),
("output", "JSON"),
("appname", "amap-lbs-skill"),
])
.send()
.await
.map_err(|e| format!("逆地理编码请求失败: {}", e))?
.json()
.await
.map_err(|e| format!("解析逆地理编码响应失败: {}", e))?;
if resp["status"].as_str() != Some("1") {
return Err(format!(
"逆地理编码失败: {}",
resp["info"].as_str().unwrap_or("未知错误")
));
}
Ok(resp)
}
/// 步行路线规划 (v5)
async fn walking_route(
&self,
origin: &str,
destination: &str,
) -> Result<serde_json::Value, String> {
let resp: serde_json::Value = self
.http
.get("https://restapi.amap.com/v5/direction/walking")
.query(&[
("key", self.key.as_str()),
("origin", origin),
("destination", destination),
("show_fields", "cost"),
("appname", "amap-lbs-skill"),
])
.send()
.await
.map_err(|e| format!("步行路线请求失败: {}", e))?
.json()
.await
.map_err(|e| format!("解析步行路线响应失败: {}", e))?;
if resp["status"].as_str() != Some("1") {
return Err(format!(
"步行路线失败: {}",
resp["info"].as_str().unwrap_or("未知错误")
));
}
Ok(resp)
}
/// 驾车路线规划 (v5)
async fn driving_route(
&self,
origin: &str,
destination: &str,
waypoints: Option<&str>,
strategy: u32,
) -> Result<serde_json::Value, String> {
let ss = strategy.to_string();
let mut params = vec![
("key", self.key.as_str()),
("origin", origin),
("destination", destination),
("strategy", &ss),
// ("show_fields", "cost"),
("appname", "amap-lbs-skill"),
];
if let Some(wp) = waypoints {
params.push(("waypoints", wp));
}
tracing::info!(target: "ias::tool",
"驾车路线请求 parms: {:?}",
serde_json::to_string(&params).unwrap_or_default()
);
let resp: serde_json::Value = self
.http
.get("https://restapi.amap.com/v5/direction/driving")
.query(&params)
.send()
.await
.map_err(|e| format!("驾车路线请求失败: {}", e))?
.json()
.await
.map_err(|e| format!("解析驾车路线响应失败: {}", e))?;
if resp["status"].as_str() != Some("1") {
tracing::info!(target: "ias::tool",
"驾车路线失败 parms: {:?}",
serde_json::to_string(&resp).unwrap_or_default()
);
return Err(format!(
"驾车路线失败: {}",
resp["info"].as_str().unwrap_or("未知错误")
));
}
Ok(resp)
}
/// 骑行路线规划 (v5)
async fn riding_route(
&self,
origin: &str,
destination: &str,
) -> Result<serde_json::Value, String> {
let resp: serde_json::Value = self
.http
.get("https://restapi.amap.com/v5/direction/bicycling")
.query(&[
("key", self.key.as_str()),
("origin", origin),
("destination", destination),
// ("show_fields", "cost"),
("appname", "amap-lbs-skill"),
])
.send()
.await
.map_err(|e| format!("骑行路线请求失败: {}", e))?
.json()
.await
.map_err(|e| format!("解析骑行路线响应失败: {}", e))?;
if resp["status"].as_str() != Some("1") {
return Err(format!(
"骑行路线失败: {}",
resp["info"].as_str().unwrap_or("未知错误")
));
}
Ok(resp)
}
/// 电动车骑行路线规划 (v5)
async fn electrobike_route(
&self,
origin: &str,
destination: &str,
) -> Result<serde_json::Value, String> {
let resp: serde_json::Value = self
.http
.get("https://restapi.amap.com/v5/direction/electrobike")
.query(&[
("key", self.key.as_str()),
("origin", origin),
("destination", destination),
("show_fields", "cost"),
("appname", "amap-lbs-skill"),
])
.send()
.await
.map_err(|e| format!("电动车骑行路线请求失败: {}", e))?
.json()
.await
.map_err(|e| format!("解析电动车骑行路线响应失败: {}", e))?;
if resp["status"].as_str() != Some("1") {
return Err(format!(
"电动车骑行路线失败: {}",
resp["info"].as_str().unwrap_or("未知错误")
));
}
Ok(resp)
}
/// 公交路线规划 (v5)
async fn transit_route(
&self,
origin: &str,
destination: &str,
city1: &str,
city2: &str,
strategy: u32,
) -> Result<serde_json::Value, String> {
let resp: serde_json::Value = self
.http
.get("https://restapi.amap.com/v5/direction/transit/integrated")
.query(&[
("key", self.key.as_str()),
("origin", origin),
("destination", destination),
("city1", city1),
("city2", city2),
("strategy", &strategy.to_string()),
("show_fields", "cost"),
("appname", "amap-lbs-skill"),
])
.send()
.await
.map_err(|e| format!("公交路线请求失败: {}", e))?
.json()
.await
.map_err(|e| format!("解析公交路线响应失败: {}", e))?;
if resp["status"].as_str() != Some("1") {
return Err(format!(
"公交路线失败: {}",
resp["info"].as_str().unwrap_or("未知错误")
));
}
Ok(resp)
}
}
// ═══════════════════════════════════════════════
// 辅助:URL 编码
// ═══════════════════════════════════════════════
// ═══════════════════════════════════════════════
// 1. amap_poi_search — POI 搜索
// ═══════════════════════════════════════════════
pub fn poi_search_spec() -> SkillSpec {
SkillSpec {
name: "amap_poi_search".into(),
description: "高德地图 POI(地点)搜索,支持关键词搜索、城市限定、周边搜索。\
参数 keywords 为搜索关键词,city 为城市名(可选),location 为中心点坐标'经度,纬度'(周边搜索时必填),\
radius 为搜索半径米(默认1000),page 为页码(默认1),offset 为每页数量(默认10,最大25)"
.into(),
risk_level: RiskLevel::Low,
parameters: serde_json::json!({
"type": "object",
"properties": {
"keywords": {"type": "string", "description": "搜索关键词,如 肯德基、酒店、景点"},
"city": {"type": "string", "description": "城市名称或城市编码,如 北京、上海"},
"location": {"type": "string", "description": "中心点坐标,格式:经度,纬度。用于周边搜索"},
"radius": {"type": "integer", "description": "搜索半径(米),默认1000"},
"page": {"type": "integer", "description": "页码,默认1"},
"offset": {"type": "integer", "description": "每页数量,默认10,最大25"}
},
"required": ["keywords"]
}),
timeout_secs: 15,
}
}
pub async fn poi_search_execute(params: serde_json::Value) -> SkillResult {
let keywords = params["keywords"].as_str().unwrap_or("");
if keywords.is_empty() {
return SkillResult::error("参数错误:缺少 keywords(搜索关键词)");
}
let city = params["city"].as_str();
let location = params["location"].as_str();
let radius = params["radius"].as_u64().unwrap_or(1000) as u32;
// CLI 参数兼容:page/offset → v5 page_num/page_size
let page_num = params["page"].as_u64().unwrap_or(1) as u32;
let page_size = params["offset"].as_u64().unwrap_or(10).min(25) as u32;
let client = match AmapClient::new() {
Ok(c) => c,
Err(e) => return SkillResult::error(e),
};
let result = if let Some(loc) = location {
tracing::info!(target: "ias::tool",
"📍 周边搜索: {} @ {} (radius={}m, sort=distance)",
keywords,
loc,
radius
);
client
.poi_around(keywords, loc, radius, page_num, page_size, "distance")
.await
} else {
tracing::info!(target: "ias::tool", "🔍 POI 搜索: {} city={:?}", keywords, city);
client
.poi_text_search(keywords, city, page_num, page_size)
.await
};
match result {
Ok(data) => SkillResult::ok_with_data("✅ POI 搜索完成".to_string(), data),
Err(e) => SkillResult::error(e),
}
}
// ═══════════════════════════════════════════════
// 2. amap_geocode — 地理编码
// ═══════════════════════════════════════════════
pub fn geocode_spec() -> SkillSpec {
SkillSpec {
name: "amap_geocode".into(),
description:
"高德地图地理编码:将地址转换为经纬度坐标。参数 address 为地址名称,city 为城市名(可选)"
.into(),
risk_level: RiskLevel::Low,
parameters: serde_json::json!({
"type": "object",
"properties": {
"address": {"type": "string", "description": "地址名称,如 西直门、北京南站、天安门"},
"city": {"type": "string", "description": "城市名称,可选,用于缩小搜索范围"}
},
"required": ["address"]
}),
timeout_secs: 10,
}
}
pub async fn geocode_execute(params: serde_json::Value) -> SkillResult {
let address = params["address"].as_str().unwrap_or("");
if address.is_empty() {
return SkillResult::error("参数错误:缺少 address(地址名称)");
}
let city = params["city"].as_str();
let client = match AmapClient::new() {
Ok(c) => c,
Err(e) => return SkillResult::error(e),
};
tracing::info!(target: "ias::tool", "📍 地理编码: {} city={:?}", address, city);
match client.geocode(address, city).await {
Ok(data) => SkillResult::ok_with_data("✅ 地理编码完成".to_string(), data),
Err(e) => SkillResult::error(e),
}
}
// ═══════════════════════════════════════════════
// 3. amap_reverse_geocode — 逆地理编码
// ═══════════════════════════════════════════════
pub fn reverse_geocode_spec() -> SkillSpec {
SkillSpec {
name: "amap_reverse_geocode".into(),
description:
"高德地图逆地理编码:将经纬度坐标转换为地址描述。参数 location 为坐标,格式'经度,纬度'"
.into(),
risk_level: RiskLevel::Low,
parameters: serde_json::json!({
"type": "object",
"properties": {
"location": {"type": "string", "description": "经纬度坐标,格式:经度,纬度"}
},
"required": ["location"]
}),
timeout_secs: 10,
}
}
pub async fn reverse_geocode_execute(params: serde_json::Value) -> SkillResult {
let location = params["location"].as_str().unwrap_or("");
if location.is_empty() {
return SkillResult::error("参数错误:缺少 location(经纬度坐标)");
}
let client = match AmapClient::new() {
Ok(c) => c,
Err(e) => return SkillResult::error(e),
};
tracing::info!(target: "ias::tool", "📍 逆地理编码: {}", location);
match client.regeocode(location).await {
Ok(data) => SkillResult::ok_with_data("✅ 逆地理编码完成".to_string(), data),
Err(e) => SkillResult::error(e),
}
}
// ═══════════════════════════════════════════════
// 4. amap_route_plan — 路径规划
// ═══════════════════════════════════════════════
pub fn route_plan_spec() -> SkillSpec {
SkillSpec {
name: "amap_route_plan".into(),
description: "高德地图路径规划(v5),支持步行(walking)、驾车(driving)、骑行(riding)、电动车(electrobike)、公交(transit)。\
参数 origin/destination 为起终点坐标'经度,纬度',type 为出行方式,\
city 为城市 citycode(公交必填,用于 city1/city2),waypoints 为途经点(驾车可选,多个用;分隔)"
.into(),
risk_level: RiskLevel::Low,
parameters: serde_json::json!({
"type": "object",
"properties": {
"type": {"type": "string", "description": "出行方式", "enum": ["walking", "driving", "riding", "electrobike", "transit"]},
"origin": {"type": "string", "description": "起点坐标,格式:经度,纬度"},
"destination": {"type": "string", "description": "终点坐标,格式:经度,纬度"},
"city": {"type": "string", "description": "城市 citycode(公交方式必填,同时用作 city1 和 city2"},
"waypoints": {"type": "string", "description": "途经点坐标,多个用;分隔(仅驾车方式)"},
"strategy": {"type": "integer", "description": "策略:驾车默认32(高德推荐),公交默认0(推荐模式)"}
},
"required": ["type", "origin", "destination"]
}),
timeout_secs: 15,
}
}
pub async fn route_plan_execute(params: serde_json::Value) -> SkillResult {
let route_type = params["type"].as_str().unwrap_or("");
let origin = params["origin"].as_str().unwrap_or("");
let destination = params["destination"].as_str().unwrap_or("");
if route_type.is_empty() || origin.is_empty() || destination.is_empty() {
return SkillResult::error("参数错误:缺少 type/origin/destination");
}
let client = match AmapClient::new() {
Ok(c) => c,
Err(e) => return SkillResult::error(e),
};
tracing::info!(target: "ias::tool",
"🛣️ 路径规划: type={}, {} → {}",
route_type,
origin,
destination
);
match route_type {
"walking" => match client.walking_route(origin, destination).await {
Ok(data) => SkillResult::ok_with_data("✅ 步行路线规划完成".to_string(), data),
Err(e) => SkillResult::error(e),
},
"driving" => {
let waypoints = params["waypoints"].as_str();
let strategy = params["strategy"].as_u64().unwrap_or(32) as u32;
match client
.driving_route(origin, destination, waypoints, strategy)
.await
{
Ok(data) => SkillResult::ok_with_data("✅ 驾车路线规划完成".to_string(), data),
Err(e) => SkillResult::error(e),
}
}
"riding" => match client.riding_route(origin, destination).await {
Ok(data) => SkillResult::ok_with_data("✅ 骑行路线规划完成".to_string(), data),
Err(e) => SkillResult::error(e),
},
"electrobike" => match client.electrobike_route(origin, destination).await {
Ok(data) => SkillResult::ok_with_data("✅ 电动车路线规划完成".to_string(), data),
Err(e) => SkillResult::error(e),
},
"transit" => {
let city_code = params["city"].as_str().unwrap_or("");
if city_code.is_empty() {
return SkillResult::error(
"公交路线需要提供 city 参数(城市 citycode,如 010 表示北京)",
);
}
let strategy = params["strategy"].as_u64().unwrap_or(0) as u32;
match client
.transit_route(origin, destination, city_code, city_code, strategy)
.await
{
Ok(data) => SkillResult::ok_with_data("✅ 公交路线规划完成".to_string(), data),
Err(e) => SkillResult::error(e),
}
}
other => SkillResult::error(format!(
"不支持的出行方式: {other},支持 walking/driving/riding/electrobike/transit"
)),
}
}
// ═══════════════════════════════════════════════
// 5. amap_travel_plan — 旅游规划
// ═══════════════════════════════════════════════
pub fn travel_plan_spec() -> SkillSpec {
SkillSpec {
name: "amap_travel_plan".into(),
description: "高德地图智能旅游规划:自动搜索城市内的兴趣点并规划游览路线。\
参数 city 为城市名(必填),interests 为兴趣点关键词数组如['景点','美食']\
route_type 为路线类型 walking/driving/riding/electrobike/transit(默认walking"
.into(),
risk_level: RiskLevel::Low,
parameters: serde_json::json!({
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名称,如 北京、杭州、上海"},
"interests": {"type": "array", "items": {"type": "string"}, "description": "兴趣点关键词,如 ['景点','美食','酒店']"},
"route_type": {"type": "string", "description": "路线类型", "enum": ["walking", "driving", "riding", "electrobike", "transit"]}
},
"required": ["city"]
}),
timeout_secs: 60,
}
}
pub async fn travel_plan_execute(params: serde_json::Value) -> SkillResult {
let city = params["city"].as_str().unwrap_or("");
if city.is_empty() {
return SkillResult::error("参数错误:缺少 city(城市名称)");
}
let interests: Vec<&str> = params["interests"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_else(|| vec!["景点", "美食"]);
let route_type = params["route_type"].as_str().unwrap_or("walking");
let valid_types = ["walking", "driving", "riding", "electrobike", "transit"];
if !valid_types.contains(&route_type) {
return SkillResult::error(format!(
"无效的路线类型: {route_type},支持 walking/driving/riding/electrobike/transit"
));
}
let client = match AmapClient::new() {
Ok(c) => c,
Err(e) => return SkillResult::error(e),
};
tracing::info!(target: "ias::tool",
"🗺️ 旅游规划: city={}, interests={:?}, route={}",
city,
interests,
route_type
);
let mut all_pois: Vec<serde_json::Value> = Vec::new();
let mut map_tasks: Vec<serde_json::Value> = Vec::new();
// 第一步:搜索各类兴趣点
for interest in &interests {
tracing::info!(target: "ias::tool", "📍 搜索 {}...", interest);
match client.poi_text_search(interest, Some(city), 1, 5).await {
Ok(data) => {
if let Some(pois) = data["pois"].as_array() {
for poi in pois {
let name = poi["name"].as_str().unwrap_or("未知");
let address = poi["address"].as_str().unwrap_or("");
let loc_str = poi["location"].as_str().unwrap_or("0,0");
let parts: Vec<f64> =
loc_str.split(',').filter_map(|s| s.parse().ok()).collect();
let lng = parts.first().copied().unwrap_or(0.0);
let lat = parts.get(1).copied().unwrap_or(0.0);
map_tasks.push(serde_json::json!({
"type": "poi",
"lnglat": [lng, lat],
"sort": interest,
"text": name,
"remark": address
}));
}
all_pois.extend(pois.clone());
tracing::info!(target: "ias::tool", " 找到 {} 个 {}", pois.len(), interest);
}
}
Err(e) => tracing::warn!(target: "ias::tool", " 搜索 {} 失败: {}", interest, e),
}
}
if all_pois.is_empty() {
return SkillResult::error(format!("{city} 未找到相关兴趣点"));
}
// 第二步:规划 POI 之间的路线
if all_pois.len() >= 2 {
for i in 0..all_pois.len() - 1 {
let start = &all_pois[i];
let end = &all_pois[i + 1];
let start_loc = start["location"].as_str().unwrap_or("0,0");
let end_loc = end["location"].as_str().unwrap_or("0,0");
let start_parts: Vec<f64> = start_loc
.split(',')
.filter_map(|s| s.parse().ok())
.collect();
let end_parts: Vec<f64> = end_loc.split(',').filter_map(|s| s.parse().ok()).collect();
let start_name = start["name"].as_str().unwrap_or("?");
let end_name = end["name"].as_str().unwrap_or("?");
let mut route_task = serde_json::json!({
"type": "route",
"routeType": route_type,
"start": start_parts,
"end": end_parts,
"remark": format!("从 {start_name} 到 {end_name}")
});
if route_type == "transit" {
route_task["city1"] = serde_json::json!(city);
route_task["city2"] = serde_json::json!(city);
}
map_tasks.push(route_task);
}
}
let map_link = generate_map_link(&map_tasks);
let route_type_cn = match route_type {
"walking" => "步行",
"driving" => "驾车",
"riding" => "骑行",
"electrobike" => "电动车骑行",
"transit" => "公交",
_ => route_type,
};
let poi_names: Vec<&str> = all_pois.iter().filter_map(|p| p["name"].as_str()).collect();
let text = format!(
"🗺️ {city} 旅游规划完成({route_type_cn}\n📍 推荐地点({} 个):{}\n🔗 地图链接:{map_link}",
all_pois.len(),
poi_names.join("")
);
let output = serde_json::json!({
"city": city,
"route_type": route_type,
"poi_count": all_pois.len(),
"pois": all_pois,
"route_count": map_tasks.iter().filter(|t| t["type"] == "route").count(),
"map_tasks": map_tasks,
"map_link": map_link,
});
SkillResult::ok_with_data(text, output)
}
// ═══════════════════════════════════════════════
// 6. amap_map_link — 生成地图可视化链接
// ═══════════════════════════════════════════════
pub fn map_link_spec() -> SkillSpec {
SkillSpec {
name: "amap_map_link".into(),
description: "生成高德地图可视化链接,支持 POI 和路线展示。\
参数 map_data 为数组,每项包含:type('poi'|'route')、lnglat/start/end、sort/text/remark 等字段"
.into(),
risk_level: RiskLevel::Low,
parameters: serde_json::json!({
"type": "object",
"properties": {
"map_data": {"type": "array", "description": "地图数据数组"}
},
"required": ["map_data"]
}),
timeout_secs: 5,
}
}
pub async fn map_link_execute(params: serde_json::Value) -> SkillResult {
let map_data = match params.get("map_data") {
Some(serde_json::Value::Array(arr)) => arr.clone(),
Some(_) => return SkillResult::error("map_data 必须是数组"),
None => return SkillResult::error("缺少 map_data 参数"),
};
if map_data.is_empty() {
return SkillResult::error("map_data 不能为空");
}
let link = generate_map_link(&map_data);
let text = format!("🗺️ 地图可视化链接:\n{link}");
let output = serde_json::json!({
"map_link": link,
"item_count": map_data.len()
});
SkillResult::ok_with_data(text, output)
}
fn generate_map_link(map_task_data: &[serde_json::Value]) -> String {
let base_url = "https://a.amap.com/jsapi_demo_show/static/openclaw/travel_plan.html";
let data_str = serde_json::to_string(map_task_data).unwrap_or_default();
let encoded = urlencoding(&data_str);
format!("{base_url}?data={encoded}")
}
fn urlencoding(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for c in s.chars() {
match c {
' ' => out.push_str("%20"),
':' => out.push_str("%3A"),
'/' => out.push_str("%2F"),
'?' => out.push_str("%3F"),
'#' => out.push_str("%23"),
'[' => out.push_str("%5B"),
']' => out.push_str("%5D"),
'@' => out.push_str("%40"),
'!' => out.push_str("%21"),
'$' => out.push_str("%24"),
'&' => out.push_str("%26"),
'\'' => out.push_str("%27"),
'(' => out.push_str("%28"),
')' => out.push_str("%29"),
'*' => out.push_str("%2A"),
'+' => out.push_str("%2B"),
',' => out.push_str("%2C"),
';' => out.push_str("%3B"),
'=' => out.push_str("%3D"),
'%' => out.push_str("%25"),
'{' => out.push_str("%7B"),
'}' => out.push_str("%7D"),
'"' => out.push_str("%22"),
'\\' => out.push_str("%5C"),
other => out.push(other),
}
}
out
}
// ═══════════════════════════════════════════════
// 统一分发入口
// ═══════════════════════════════════════════════
#[allow(dead_code)]
pub fn specs() -> Vec<SkillSpec> {
vec![
poi_search_spec(),
geocode_spec(),
reverse_geocode_spec(),
route_plan_spec(),
travel_plan_spec(),
map_link_spec(),
]
}
#[allow(dead_code)]
pub async fn execute(name: &str, params: serde_json::Value) -> Option<SkillResult> {
match name {
"amap_poi_search" => Some(poi_search_execute(params).await),
"amap_geocode" => Some(geocode_execute(params).await),
"amap_reverse_geocode" => Some(reverse_geocode_execute(params).await),
"amap_route_plan" => Some(route_plan_execute(params).await),
"amap_travel_plan" => Some(travel_plan_execute(params).await),
"amap_map_link" => Some(map_link_execute(params).await),
_ => None,
}
}
// ═══════════════════════════════════════════════
// 测试
// ═══════════════════════════════════════════════
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_url_encoding() {
let encoded = urlencoding("https://example.com/path?q=hello world");
assert!(!encoded.contains("://"));
assert!(encoded.contains("%3A%2F%2F"));
assert!(encoded.contains("%20"));
}
#[test]
fn test_specs_count() {
let s = specs();
assert_eq!(s.len(), 6);
for spec in &s {
assert!(!spec.name.is_empty());
assert!(!spec.description.is_empty());
}
}
#[tokio::test]
async fn test_poi_search_integration() {
if std::env::var("AMAP_WEBSERVICE_KEY").is_err() && std::env::var("AMAP_KEY").is_err() {
eprintln!("跳过集成测试: 未设置 AMAP_WEBSERVICE_KEY");
return;
}
let result =
poi_search_execute(serde_json::json!({"keywords": "肯德基", "city": "北京"})).await;
assert!(result.success, "POI 搜索失败: {}", result.output);
}
#[tokio::test]
async fn test_geocode_integration() {
if std::env::var("AMAP_WEBSERVICE_KEY").is_err() && std::env::var("AMAP_KEY").is_err() {
eprintln!("跳过集成测试: 未设置 AMAP_WEBSERVICE_KEY");
return;
}
let result = geocode_execute(serde_json::json!({"address": "天安门"})).await;
assert!(result.success, "地理编码失败: {}", result.output);
let data = result.data.unwrap();
assert!(data["location"].as_str().is_some());
}
}