diff --git a/src/tools/builtins/amap.rs b/src/tools/builtins/amap.rs index 6798d5e..d2af46c 100644 --- a/src/tools/builtins/amap.rs +++ b/src/tools/builtins/amap.rs @@ -1,14 +1,15 @@ -// 高德地图综合工具(Rust 原生实现) +// 高德地图综合工具(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 /v3/direction/walking → 路线 -// 驾车路线: GET /v3/direction/driving → 路线 -// 骑行路线: GET /v4/direction/bicycling → 路线 -// 公交路线: GET /v3/direction/transit/integrated → 路线 +// 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 @@ -46,30 +47,29 @@ impl AmapClient { }) } - /// POI 文本搜索 + /// POI 文本搜索 (v5) async fn poi_text_search( &self, keywords: &str, city: Option<&str>, - page: u32, - offset: u32, + page_num: u32, + page_size: u32, ) -> Result { - let mut params = vec![ - ("key", self.key.as_str()), - ("keywords", keywords), - ("appname", "amap-lbs-skill"), - ]; let region = city.unwrap_or(""); - params.push(("region", region)); - let page_str = page.to_string(); - params.push(("page", &page_str)); - let offset_str = offset.to_string(); - params.push(("offset", &offset_str)); + 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(¶ms) + .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))? @@ -86,18 +86,19 @@ impl AmapClient { Ok(resp) } - /// POI 周边搜索 + /// POI 周边搜索 (v5) async fn poi_around( &self, keywords: &str, location: &str, radius: u32, - page: u32, - offset: u32, + page_num: u32, + page_size: u32, + sortrule: &str, ) -> Result { - let page_str = page.to_string(); - let offset_str = offset.to_string(); - let radius_str = radius.to_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 @@ -106,9 +107,10 @@ impl AmapClient { ("key", self.key.as_str()), ("keywords", keywords), ("location", location), - ("radius", &radius_str), - ("page", &page_str), - ("offset", &offset_str), + ("radius", &r), + ("page_num", &pn), + ("page_size", &ps), + ("sortrule", sortrule), ("appname", "amap-lbs-skill"), ]) .send() @@ -127,7 +129,7 @@ impl AmapClient { Ok(resp) } - /// 地理编码:地址 → 坐标 + /// 地理编码:地址 → 坐标 (v3) async fn geocode(&self, address: &str, city: Option<&str>) -> Result { let mut params = vec![ ("key", self.key.as_str()), @@ -161,7 +163,7 @@ impl AmapClient { Ok(resp) } - /// 逆地理编码:坐标 → 地址 + /// 逆地理编码:坐标 → 地址 (v3) async fn regeocode(&self, location: &str) -> Result { let resp: serde_json::Value = self .http @@ -188,7 +190,7 @@ impl AmapClient { Ok(resp) } - /// 步行路线规划 + /// 步行路线规划 (v5) async fn walking_route( &self, origin: &str, @@ -196,11 +198,12 @@ impl AmapClient { ) -> Result { let resp: serde_json::Value = self .http - .get("https://restapi.amap.com/v3/direction/walking") + .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() @@ -219,7 +222,7 @@ impl AmapClient { Ok(resp) } - /// 驾车路线规划 + /// 驾车路线规划 (v5) async fn driving_route( &self, origin: &str, @@ -227,13 +230,13 @@ impl AmapClient { waypoints: Option<&str>, strategy: u32, ) -> Result { - let strategy_s = strategy.to_string(); + let ss = strategy.to_string(); let mut params = vec![ ("key", self.key.as_str()), ("origin", origin), ("destination", destination), - ("strategy", &strategy_s), - ("extensions", "base"), + ("strategy", &ss), + ("show_fields", "cost"), ("appname", "amap-lbs-skill"), ]; if let Some(wp) = waypoints { @@ -242,7 +245,7 @@ impl AmapClient { let resp: serde_json::Value = self .http - .get("https://restapi.amap.com/v3/direction/driving") + .get("https://restapi.amap.com/v5/direction/driving") .query(¶ms) .send() .await @@ -260,7 +263,7 @@ impl AmapClient { Ok(resp) } - /// 骑行路线规划 + /// 骑行路线规划 (v5) async fn riding_route( &self, origin: &str, @@ -268,11 +271,12 @@ impl AmapClient { ) -> Result { let resp: serde_json::Value = self .http - .get("https://restapi.amap.com/v4/direction/bicycling") + .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() @@ -282,32 +286,67 @@ impl AmapClient { .await .map_err(|e| format!("解析骑行路线响应失败: {}", e))?; - if resp["errcode"].as_i64() != Some(0) { + if resp["status"].as_str() != Some("1") { return Err(format!( "骑行路线失败: {}", - resp["errmsg"].as_str().unwrap_or("未知错误") + resp["info"].as_str().unwrap_or("未知错误") )); } Ok(resp) } - /// 公交路线规划 - async fn transit_route( + /// 电动车骑行路线规划 (v5) + async fn electrobike_route( &self, origin: &str, destination: &str, - city: &str, - strategy: u32, ) -> Result { let resp: serde_json::Value = self .http - .get("https://restapi.amap.com/v3/direction/transit/integrated") + .get("https://restapi.amap.com/v5/direction/electrobike") .query(&[ ("key", self.key.as_str()), ("origin", origin), ("destination", destination), - ("city", city), + ("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() @@ -327,6 +366,73 @@ impl AmapClient { } } +// ═══════════════════════════════════════════════ +// 辅助:距离计算与格式化 +// ═══════════════════════════════════════════════ + +/// Haversine 公式计算两点间距离(米) +fn haversine_distance(lng1: f64, lat1: f64, lng2: f64, lat2: f64) -> f64 { + const R: f64 = 6_371_000.0; + let d_lat = (lat2 - lat1).to_radians(); + let d_lng = (lng2 - lng1).to_radians(); + let a = (d_lat / 2.0).sin().powi(2) + + lat1.to_radians().cos() * lat2.to_radians().cos() * (d_lng / 2.0).sin().powi(2); + let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt()); + R * c +} + +/// 格式化距离: ≥1000m → km,<1000m → m,0 → 空 +fn format_distance(meters: f64) -> String { + if meters >= 1000.0 { + format!("{:.1} km", meters / 1000.0) + } else if meters >= 10.0 { + format!("{:.0} m", meters) + } else { + String::new() + } +} + +/// 解析 "经度,纬度" 字符串为 (lng, lat) +fn parse_location(s: &str) -> Option<(f64, f64)> { + let parts: Vec = s.split(',').filter_map(|p| p.trim().parse().ok()).collect(); + if parts.len() == 2 { Some((parts[0], parts[1])) } else { None } +} + +// ═══════════════════════════════════════════════ +// 辅助:解析 v5 路线中的数值字段(可能是字符串或数字) +// ═══════════════════════════════════════════════ + +fn val_f64(v: &serde_json::Value) -> f64 { + v.as_str() + .and_then(|s| s.parse().ok()) + .or_else(|| v.as_f64()) + .unwrap_or(0.0) +} + +/// 获取 path 下的 distance(v5 为字符串) +fn path_distance(path: &serde_json::Value) -> f64 { + val_f64(&path["distance"]) +} + +/// 获取 path.cost.duration(v5 需 show_fields=cost) +fn path_duration(path: &serde_json::Value) -> f64 { + val_f64(&path["cost"]["duration"]) +} + +/// 获取 path.cost.tolls(驾车) +fn path_tolls(path: &serde_json::Value) -> f64 { + val_f64(&path["cost"]["tolls"]) +} + +/// 获取 path.traffic_lights(驾车 v5 直接在 path 下) +fn path_traffic_lights(path: &serde_json::Value) -> u32 { + path["traffic_lights"] + .as_str() + .and_then(|s| s.parse().ok()) + .or_else(|| path["traffic_lights"].as_u64().map(|v| v as u32)) + .unwrap_or(0) +} + // ═══════════════════════════════════════════════ // 1. amap_poi_search — POI 搜索 // ═══════════════════════════════════════════════ @@ -356,12 +462,7 @@ pub fn poi_search_spec() -> SkillSpec { } pub async fn poi_search_execute(params: serde_json::Value) -> SkillResult { - let keywords = params["keywords"] - .as_str() - .unwrap_or_else(|| { - tracing::warn!("amap_poi_search: 缺少 keywords"); - "" - }); + let keywords = params["keywords"].as_str().unwrap_or(""); if keywords.is_empty() { return SkillResult::error("参数错误:缺少 keywords(搜索关键词)"); } @@ -369,25 +470,24 @@ pub async fn poi_search_execute(params: serde_json::Value) -> SkillResult { let city = params["city"].as_str(); let location = params["location"].as_str(); let radius = params["radius"].as_u64().unwrap_or(1000) as u32; - let page = params["page"].as_u64().unwrap_or(1) as u32; - let offset = params["offset"].as_u64().unwrap_or(10).min(25) 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), }; - // 有 location → 周边搜索,否则 → 文本搜索 - let result = if let Some(loc) = location { - tracing::info!("📍 周边搜索: {} @ {} (radius={}m)", keywords, loc, radius); - client - .poi_around(keywords, loc, radius, page, offset) - .await + // 解析参考坐标(用于计算每个 POI 的距离) + let ref_loc: Option<(f64, f64)> = location.and_then(|s| parse_location(s)); + + let result = if let Some((rlng, rlat)) = ref_loc { + tracing::info!("📍 周边搜索: {} @ {},{} (radius={}m, sort=distance)", keywords, rlng, rlat, radius); + client.poi_around(keywords, &format!("{rlng},{rlat}"), radius, page_num, page_size, "distance").await } else { tracing::info!("🔍 POI 搜索: {} city={:?}", keywords, city); - client - .poi_text_search(keywords, city, page, offset) - .await + client.poi_text_search(keywords, city, page_num, page_size).await }; match result { @@ -395,9 +495,55 @@ pub async fn poi_search_execute(params: serde_json::Value) -> SkillResult { let count = data["count"].as_str().unwrap_or("?"); let pois = &data["pois"]; let poi_count = pois.as_array().map(|a| a.len()).unwrap_or(0); - let text = format!( - "✅ 搜索「{keywords}」共 {count} 条结果,当前页显示 {poi_count} 条", + let mut text = format!( + "✅ 搜索「{keywords}」共 {count} 条结果,当前页显示 {poi_count} 条:\n", ); + if let Some(arr) = pois.as_array() { + for (i, poi) in arr.iter().enumerate() { + let name = poi["name"].as_str().unwrap_or("未知"); + let address = poi["address"].as_str().unwrap_or(""); + let loc = poi["location"].as_str().unwrap_or("未知"); + let ptype = poi["type"].as_str().unwrap_or(""); + + // 距离:优先用 Haversine 计算(有参考坐标时),否则用 API 返回值 + let dist = match ref_loc { + Some((rlng, rlat)) => { + if let Some((plng, plat)) = parse_location(loc) { + let d = haversine_distance(rlng, rlat, plng, plat); + format!(" | {}", format_distance(d)) + } else { + String::new() + } + } + None => poi["distance"] + .as_str() + .filter(|s| !s.is_empty()) + .and_then(|s| s.parse::().ok()) + .map(|d| format!(" | {}", format_distance(d))) + .unwrap_or_default(), + }; + + let tel = poi["tel"] + .as_str() + .filter(|t| !t.is_empty()) + .map(|t| format!(" | 📞 {t}")) + .unwrap_or_default(); + let rating = poi["rating"] + .as_str() + .filter(|s| !s.is_empty()) + .map(|s| format!(" | ⭐{s}")) + .unwrap_or_default(); + let cost = poi["cost"] + .as_str() + .filter(|s| !s.is_empty()) + .map(|s| format!(" | 人均{s}元")) + .unwrap_or_default(); + text.push_str(&format!( + " {}. {name} [{ptype}]\n 📍 {address}{dist}{tel}{rating}{cost}\n 🗺️ {loc}\n", + i + 1 + )); + } + } SkillResult::ok_with_data(text, data) } Err(e) => SkillResult::error(e), @@ -450,10 +596,16 @@ pub async fn geocode_execute(params: serde_json::Value) -> SkillResult { } let loc = arr[0]["location"].as_str().unwrap_or("未知"); let formatted = arr[0]["formatted_address"].as_str().unwrap_or(address); - let text = format!("📍 {formatted} 坐标: {loc}"); + let pname = arr[0]["pname"].as_str().unwrap_or(""); + let cityname = arr[0]["cityname"].as_str().unwrap_or(""); + let adname = arr[0]["adname"].as_str().unwrap_or(""); + let text = format!("📍 {formatted}({pname}{cityname}{adname})坐标: {loc}"); let out = serde_json::json!({ "address": formatted, "location": loc, + "province": pname, + "city": cityname, + "district": adname, "top_result": arr[0] }); SkillResult::ok_with_data(text, out) @@ -520,20 +672,20 @@ pub async fn reverse_geocode_execute(params: serde_json::Value) -> SkillResult { pub fn route_plan_spec() -> SkillSpec { SkillSpec { name: "amap_route_plan".into(), - description: "高德地图路径规划,支持步行(walking)、驾车(driving)、骑行(riding)、公交(transit)四种出行方式。\ - 参数 origin 为起点坐标'经度,纬度',destination 为终点坐标'经度,纬度',\ - type 为出行方式,city 为城市名(公交必填),waypoints 为途经点(驾车可选,多个用;分隔)" + 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": "出行方式:walking/driving/riding/transit", "enum": ["walking", "driving", "riding", "transit"]}, + "type": {"type": "string", "description": "出行方式", "enum": ["walking", "driving", "riding", "electrobike", "transit"]}, "origin": {"type": "string", "description": "起点坐标,格式:经度,纬度"}, "destination": {"type": "string", "description": "终点坐标,格式:经度,纬度"}, - "city": {"type": "string", "description": "城市名称(公交方式必填)"}, + "city": {"type": "string", "description": "城市 citycode(公交方式必填,同时用作 city1 和 city2)"}, "waypoints": {"type": "string", "description": "途经点坐标,多个用;分隔(仅驾车方式)"}, - "strategy": {"type": "integer", "description": "策略:驾车默认10(躲避拥堵),公交默认0(最快捷)"} + "strategy": {"type": "integer", "description": "策略:驾车默认32(高德推荐),公交默认0(推荐模式)"} }, "required": ["type", "origin", "destination"] }), @@ -548,8 +700,7 @@ pub async fn route_plan_execute(params: serde_json::Value) -> SkillResult { if route_type.is_empty() || origin.is_empty() || destination.is_empty() { return SkillResult::error( - "参数错误:缺少 type/origin/destination。格式示例:\ - {\"type\":\"walking\",\"origin\":\"116.397428,39.90923\",\"destination\":\"116.427281,39.903719\"}", + "参数错误:缺少 type/origin/destination", ); } @@ -558,172 +709,151 @@ pub async fn route_plan_execute(params: serde_json::Value) -> SkillResult { Err(e) => return SkillResult::error(e), }; - tracing::info!( - "🛣️ 路径规划: type={}, {} → {}", - route_type, - origin, - destination - ); + tracing::info!("🛣️ 路径规划: type={}, {} → {}", route_type, origin, destination); match route_type { - "walking" => { - match client.walking_route(origin, destination).await { - Ok(data) => { - let summary = route_summary(&data, "walking"); - SkillResult::ok_with_data(summary, data) - } - Err(e) => SkillResult::error(e), + "walking" => match client.walking_route(origin, destination).await { + Ok(data) => { + let (summary, out) = build_route_output(&data, "walking"); + SkillResult::ok_with_data(summary, out) } - } + Err(e) => SkillResult::error(e), + }, "driving" => { let waypoints = params["waypoints"].as_str(); - let strategy = params["strategy"].as_u64().unwrap_or(10) as u32; - match client - .driving_route(origin, destination, waypoints, strategy) - .await - { + let strategy = params["strategy"].as_u64().unwrap_or(32) as u32; + match client.driving_route(origin, destination, waypoints, strategy).await { Ok(data) => { - let summary = route_summary(&data, "driving"); - SkillResult::ok_with_data(summary, data) + let (summary, out) = build_route_output(&data, "driving"); + SkillResult::ok_with_data(summary, out) } Err(e) => SkillResult::error(e), } } - "riding" => { - match client.riding_route(origin, destination).await { - Ok(data) => { - let summary = route_summary(&data, "riding"); - SkillResult::ok_with_data(summary, data) - } - Err(e) => SkillResult::error(e), + "riding" => match client.riding_route(origin, destination).await { + Ok(data) => { + let (summary, out) = build_route_output(&data, "riding"); + SkillResult::ok_with_data(summary, out) } - } + Err(e) => SkillResult::error(e), + }, + "electrobike" => match client.electrobike_route(origin, destination).await { + Ok(data) => { + let (summary, out) = build_route_output(&data, "electrobike"); + SkillResult::ok_with_data(summary, out) + } + Err(e) => SkillResult::error(e), + }, "transit" => { - let city = params["city"].as_str().unwrap_or(""); - if city.is_empty() { - return SkillResult::error("公交路线需要提供 city 参数(城市名称)"); + 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, strategy) - .await - { + match client.transit_route(origin, destination, city_code, city_code, strategy).await { Ok(data) => { - let summary = route_summary(&data, "transit"); - SkillResult::ok_with_data(summary, data) + let (summary, out) = build_route_output(&data, "transit"); + SkillResult::ok_with_data(summary, out) } Err(e) => SkillResult::error(e), } } other => SkillResult::error(format!( - "不支持的出行方式: {other},支持 walking/driving/riding/transit" + "不支持的出行方式: {other},支持 walking/driving/riding/electrobike/transit" )), } } -/// 提取路线摘要信息 -fn route_summary(data: &serde_json::Value, route_type: &str) -> String { +/// 构建路线输出:文本摘要 + JSON 数据 +fn build_route_output(data: &serde_json::Value, route_type: &str) -> (String, serde_json::Value) { + let route = &data["route"]; + match route_type { - "walking" => { - if let Some(path) = data["route"]["paths"] - .as_array() - .and_then(|a| a.first()) - { - let dist = path["distance"] - .as_str() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0.0); - let dur = path["duration"] - .as_str() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0.0); - format!( - "🚶 步行路线:距离 {:.2} 公里,预计 {} 分钟", - dist / 1000.0, - (dur / 60.0) as u32 - ) + "walking" | "riding" | "electrobike" => { + let icon = match route_type { + "walking" => "🚶", + "riding" => "🚴", + "electrobike" => "🛵", + _ => "📍", + }; + let cn = match route_type { + "walking" => "步行", + "riding" => "骑行", + "electrobike" => "电动车骑行", + _ => "", + }; + if let Some(path) = route["paths"].as_array().and_then(|a| a.first()) { + let dist = path_distance(path) / 1000.0; + let dur = (path_duration(path) / 60.0) as u32; + let text = format!("{icon} {cn}路线:距离 {dist:.2} 公里,预计 {dur} 分钟"); + let out = serde_json::json!({ + "route_type": route_type, + "origin": route["origin"], + "destination": route["destination"], + "distance_km": format!("{:.2}", dist), + "duration_min": dur, + "path_count": route["paths"].as_array().map(|a| a.len()).unwrap_or(0), + }); + (text, out) } else { - "🚶 步行路线规划完成".into() + (format!("{icon} {cn}路线规划完成"), serde_json::json!({"route_type": route_type})) } } "driving" => { - if let Some(path) = data["route"]["paths"] - .as_array() - .and_then(|a| a.first()) - { - let dist = path["distance"] + if let Some(path) = route["paths"].as_array().and_then(|a| a.first()) { + let dist = path_distance(path) / 1000.0; + let dur = (path_duration(path) / 60.0) as u32; + let tolls = path_tolls(path); + let lights = path_traffic_lights(path); + let restriction = path["restriction"] .as_str() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0.0); - let dur = path["duration"] - .as_str() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0.0); - let tolls = path["tolls"] - .as_str() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0.0); - format!( - "🚗 驾车路线:距离 {:.2} 公里,预计 {} 分钟,过路费 {} 元", - dist / 1000.0, - (dur / 60.0) as u32, - tolls - ) + .map(|r| if r == "0" { "无/已规避" } else { "有限行路段" }) + .unwrap_or("未知"); + let text = format!( + "🚗 驾车路线:距离 {dist:.2} 公里,预计 {dur} 分钟,过路费 {tolls} 元,红绿灯 {lights} 个,限行 {restriction}" + ); + let out = serde_json::json!({ + "route_type": "driving", + "origin": route["origin"], + "destination": route["destination"], + "distance_km": format!("{:.2}", dist), + "duration_min": dur, + "tolls": tolls, + "traffic_lights": lights, + "restriction": restriction, + "path_count": route["paths"].as_array().map(|a| a.len()).unwrap_or(0), + }); + (text, out) } else { - "🚗 驾车路线规划完成".into() - } - } - "riding" => { - if let Some(path) = data["data"]["paths"] - .as_array() - .and_then(|a| a.first()) - { - let dist = path["distance"].as_f64().unwrap_or(0.0); - let dur = path["duration"].as_f64().unwrap_or(0.0); - format!( - "🚴 骑行路线:距离 {:.2} 公里,预计 {} 分钟", - dist / 1000.0, - (dur / 60.0) as u32 - ) - } else { - "🚴 骑行路线规划完成".into() + ("🚗 驾车路线规划完成".into(), serde_json::json!({"route_type": "driving"})) } } "transit" => { - let count = data["route"]["transits"] - .as_array() - .map(|a| a.len()) - .unwrap_or(0); - if let Some(t) = data["route"]["transits"] - .as_array() - .and_then(|a| a.first()) - { - let dur = t["duration"] - .as_str() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0.0); - let cost = t["cost"].as_str().and_then(|s| s.parse::().ok()); - let wdist = t["walking_distance"] - .as_str() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0.0); - let mut text = format!( - "🚌 公交路线:共 {count} 个方案,推荐方案预计 {} 分钟", + let transits = route["transits"].as_array(); + let count = transits.map(|a| a.len()).unwrap_or(0); + if let Some(t) = transits.and_then(|a| a.first()) { + let dur = val_f64(&t["cost"]["duration"]); + let fee = val_f64(&t["cost"]["transit_fee"]); + let dist = val_f64(&t["distance"]) / 1000.0; + let text = format!( + "🚌 公交路线:共 {count} 个方案,推荐方案 {dist:.2} 公里,预计 {} 分钟,费用 {fee} 元", (dur / 60.0) as u32 ); - if let Some(c) = cost { - text.push_str(&format!(",费用 {c} 元")); - } - text.push_str(&format!( - ",步行 {:.0} 米", - wdist - )); - text + let out = serde_json::json!({ + "route_type": "transit", + "origin": route["origin"], + "destination": route["destination"], + "scheme_count": count, + "distance_km": format!("{:.2}", dist), + "duration_min": (dur / 60.0) as u32, + "cost": fee, + }); + (text, out) } else { - format!("🚌 公交路线规划完成,共 {count} 个方案") + (format!("🚌 公交路线规划完成,共 {count} 个方案"), serde_json::json!({"route_type": "transit", "scheme_count": count})) } } - _ => "路线规划完成".into(), + _ => ("路线规划完成".into(), serde_json::json!({})), } } @@ -736,7 +866,7 @@ pub fn travel_plan_spec() -> SkillSpec { name: "amap_travel_plan".into(), description: "高德地图智能旅游规划:自动搜索城市内的兴趣点并规划游览路线。\ 参数 city 为城市名(必填),interests 为兴趣点关键词数组如['景点','美食'],\ - route_type 为路线类型 walking/driving/riding/transit(默认walking)" + route_type 为路线类型 walking/driving/riding/electrobike/transit(默认walking)" .into(), risk_level: RiskLevel::Low, parameters: serde_json::json!({ @@ -744,7 +874,7 @@ pub fn travel_plan_spec() -> SkillSpec { "properties": { "city": {"type": "string", "description": "城市名称,如 北京、杭州、上海"}, "interests": {"type": "array", "items": {"type": "string"}, "description": "兴趣点关键词,如 ['景点','美食','酒店']"}, - "route_type": {"type": "string", "description": "路线类型:walking/driving/riding/transit", "enum": ["walking", "driving", "riding", "transit"]} + "route_type": {"type": "string", "description": "路线类型", "enum": ["walking", "driving", "riding", "electrobike", "transit"]} }, "required": ["city"] }), @@ -760,18 +890,14 @@ pub async fn travel_plan_execute(params: serde_json::Value) -> SkillResult { let interests: Vec<&str> = params["interests"] .as_array() - .map(|a| { - a.iter() - .filter_map(|v| v.as_str()) - .collect::>() - }) + .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", "transit"]; + let valid_types = ["walking", "driving", "riding", "electrobike", "transit"]; if !valid_types.contains(&route_type) { return SkillResult::error(format!( - "无效的路线类型: {route_type},支持 walking/driving/riding/transit" + "无效的路线类型: {route_type},支持 walking/driving/riding/electrobike/transit" )); } @@ -780,12 +906,7 @@ pub async fn travel_plan_execute(params: serde_json::Value) -> SkillResult { Err(e) => return SkillResult::error(e), }; - tracing::info!( - "🗺️ 旅游规划: city={}, interests={:?}, route={}", - city, - interests, - route_type - ); + tracing::info!("🗺️ 旅游规划: city={}, interests={:?}, route={}", city, interests, route_type); let mut all_pois: Vec = Vec::new(); let mut map_tasks: Vec = Vec::new(); @@ -793,26 +914,19 @@ pub async fn travel_plan_execute(params: serde_json::Value) -> SkillResult { // 第一步:搜索各类兴趣点 for interest in &interests { tracing::info!("📍 搜索 {}...", interest); - - match client - .poi_text_search(interest, Some(city), 1, 5) - .await - { + 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<&str> = loc_str.split(',').collect(); - let lng = parts - .first() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0.0); - let lat = parts - .get(1) - .and_then(|s| s.parse::().ok()) - .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", @@ -826,9 +940,7 @@ pub async fn travel_plan_execute(params: serde_json::Value) -> SkillResult { tracing::info!(" 找到 {} 个 {}", pois.len(), interest); } } - Err(e) => { - tracing::warn!(" 搜索 {} 失败: {}", interest, e); - } + Err(e) => tracing::warn!(" 搜索 {} 失败: {}", interest, e), } } @@ -865,43 +977,41 @@ pub async fn travel_plan_execute(params: serde_json::Value) -> SkillResult { }); if route_type == "transit" { - route_task["city"] = serde_json::json!(city); + 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 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}", + "🗺️ {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, - "route_type": route_type, }); SkillResult::ok_with_data(text, output) @@ -921,7 +1031,7 @@ pub fn map_link_spec() -> SkillSpec { parameters: serde_json::json!({ "type": "object", "properties": { - "map_data": {"type": "array", "description": "地图数据数组,每项格式:{\"type\":\"poi\",\"lnglat\":[lng,lat],\"sort\":\"分类\",\"text\":\"名称\",\"remark\":\"备注\"} 或 {\"type\":\"route\",\"routeType\":\"walking\",\"start\":[lng,lat],\"end\":[lng,lat],\"remark\":\"描述\"}"} + "map_data": {"type": "array", "description": "地图数据数组"} }, "required": ["map_data"] }), @@ -949,52 +1059,40 @@ pub async fn map_link_execute(params: serde_json::Value) -> SkillResult { 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); // 手动 URL 编码(只编码特殊字符) + let encoded = urlencoding(&data_str); format!("{base_url}?data={encoded}") } -/// 简易 URL 编码(仅编码必要的特殊字符) fn urlencoding(s: &str) -> String { - s.chars() - .map(|c| match c { - ' ' => "%20".to_string(), - ':' => "%3A".to_string(), - '/' => "%2F".to_string(), - '?' => "%3F".to_string(), - '#' => "%23".to_string(), - '[' => "%5B".to_string(), - ']' => "%5D".to_string(), - '@' => "%40".to_string(), - '!' => "%21".to_string(), - '$' => "%24".to_string(), - '&' => "%26".to_string(), - '\'' => "%27".to_string(), - '(' => "%28".to_string(), - ')' => "%29".to_string(), - '*' => "%2A".to_string(), - '+' => "%2B".to_string(), - ',' => "%2C".to_string(), - ';' => "%3B".to_string(), - '=' => "%3D".to_string(), - '%' => "%25".to_string(), - '{' => "%7B".to_string(), - '}' => "%7D".to_string(), - '"' => "%22".to_string(), - '\\' => "%5C".to_string(), - other => other.to_string(), - }) - .collect() + 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 } // ═══════════════════════════════════════════════ // 统一分发入口 // ═══════════════════════════════════════════════ -/// 所有 amap 工具的 spec 列表 pub fn specs() -> Vec { vec![ poi_search_spec(), @@ -1006,7 +1104,6 @@ pub fn specs() -> Vec { ] } -/// 根据名称分发执行 pub async fn execute(name: &str, params: serde_json::Value) -> Option { match name { "amap_poi_search" => Some(poi_search_execute(params).await), @@ -1027,65 +1124,104 @@ pub async fn execute(name: &str, params: serde_json::Value) -> Option