//! ## 高德地图综合工具(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 { 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 { 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 { 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 { 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(¶ms) .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 { 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 { 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 { 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(¶ms).unwrap_or_default() ); let resp: serde_json::Value = self .http .get("https://restapi.amap.com/v5/direction/driving") .query(¶ms) .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 { 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 { 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 { 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 = Vec::new(); let mut map_tasks: Vec = 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 = 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 = start_loc .split(',') .filter_map(|s| s.parse().ok()) .collect(); let end_parts: Vec = 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 { 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 { 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()); } }