57c030a62a
- 认证日志 (auth.log): 登录/token/监听器注册,target=ias::auth - 消息队列日志 (queue.log): 入队/出队内容、消费路由,target=ias::queue - 工具日志 (tool.log): 调用参数/输出/耗时/工具错误,target=ias::tool - 错误日志 (error.log): 所有非工具调用的 ERROR/WARN 自动捕获 使用 tracing-subscriber 多层过滤架构,终端输出保持不变
979 lines
37 KiB
Rust
979 lines
37 KiB
Rust
//! ## 高德地图综合工具(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(¶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<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(¶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<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());
|
||
}
|
||
}
|