feat:修复一些BUG

This commit is contained in:
2026-01-08 09:49:01 +08:00
parent 820861752b
commit 472b1a0d5e
33 changed files with 1643 additions and 742 deletions

View File

@@ -0,0 +1,609 @@
"""
旅行规划插件
基于高德地图 API提供以下功能
- 地点搜索与地理编码
- 天气查询(实况 + 4天预报
- 景点/酒店/餐厅搜索
- 路径规划(驾车/公交/步行)
- 周边搜索
支持 LLM 函数调用,可与 AIChat 插件配合使用。
"""
import tomllib
from pathlib import Path
from typing import Any, Dict, List
from loguru import logger
from utils.plugin_base import PluginBase
from .amap_client import AmapClient, AmapConfig
class TravelPlanner(PluginBase):
"""旅行规划插件"""
description = "旅行规划助手,支持天气查询、景点搜索、路线规划"
author = "ShiHao"
version = "1.0.0"
def __init__(self):
super().__init__()
self.config = None
self.amap: AmapClient = None
async def async_init(self):
"""插件异步初始化"""
# 读取配置
config_path = Path(__file__).parent / "config.toml"
with open(config_path, "rb") as f:
self.config = tomllib.load(f)
# 初始化高德 API 客户端
amap_config = self.config.get("amap", {})
api_key = amap_config.get("api_key", "")
secret = amap_config.get("secret", "")
if not api_key:
logger.warning("TravelPlanner: 未配置高德 API Key请在 config.toml 中设置")
else:
self.amap = AmapClient(AmapConfig(
api_key=api_key,
secret=secret,
timeout=amap_config.get("timeout", 30)
))
if secret:
logger.success(f"TravelPlanner 插件已加载API Key: {api_key[:8]}...(已启用数字签名)")
else:
logger.success(f"TravelPlanner 插件已加载API Key: {api_key[:8]}...(未配置安全密钥)")
async def on_disable(self):
"""插件禁用时关闭连接"""
await super().on_disable()
if self.amap:
await self.amap.close()
logger.info("TravelPlanner: 已关闭高德 API 连接")
# ==================== LLM 工具定义 ====================
def get_llm_tools(self) -> List[Dict]:
"""返回 LLM 可调用的工具列表"""
return [
{
"type": "function",
"function": {
"name": "search_location",
"description": "【旅行工具】将地名转换为坐标和行政区划信息。仅当用户明确询问某个地点的位置信息时使用。",
"parameters": {
"type": "object",
"properties": {
"address": {
"type": "string",
"description": "地址或地名,如:北京市、西湖、故宫"
},
"city": {
"type": "string",
"description": "所在城市,可选。填写可提高搜索精度"
}
},
"required": ["address"]
}
}
},
{
"type": "function",
"function": {
"name": "query_weather",
"description": "【旅行工具】查询城市天气预报。仅当用户明确询问某城市的天气情况时使用,如'北京天气怎么样''杭州明天会下雨吗'",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,如:北京、杭州、上海"
},
"forecast": {
"type": "boolean",
"description": "是否查询预报天气。true=未来4天预报false=当前实况"
}
},
"required": ["city"]
}
}
},
{
"type": "function",
"function": {
"name": "search_poi",
"description": "【旅行工具】搜索地点(景点、酒店、餐厅等)。仅当用户明确要求查找某城市的景点、酒店、餐厅等时使用。",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "搜索城市,如:杭州、北京"
},
"keyword": {
"type": "string",
"description": "搜索关键词,如:西湖、希尔顿酒店、火锅"
},
"category": {
"type": "string",
"enum": ["景点", "酒店", "餐厅", "购物", "交通"],
"description": "POI 类别。不填则搜索所有类别"
},
"limit": {
"type": "integer",
"description": "返回结果数量默认10最大20"
}
},
"required": ["city"]
}
}
},
{
"type": "function",
"function": {
"name": "search_nearby",
"description": "【旅行工具】搜索某地点周边的设施。仅当用户明确要求查找某地点附近的餐厅、酒店等时使用,如'西湖附近有什么好吃的'",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "中心地点名称,如:西湖、故宫"
},
"city": {
"type": "string",
"description": "所在城市"
},
"keyword": {
"type": "string",
"description": "搜索关键词"
},
"category": {
"type": "string",
"enum": ["景点", "酒店", "餐厅", "购物", "交通"],
"description": "POI 类别"
},
"radius": {
"type": "integer",
"description": "搜索半径默认3000最大50000"
}
},
"required": ["location", "city"]
}
}
},
{
"type": "function",
"function": {
"name": "plan_route",
"description": "【旅行工具】规划两地之间的出行路线。仅当用户明确要求规划从A到B的路线时使用'从北京到杭州怎么走''上海到苏州的高铁'",
"parameters": {
"type": "object",
"properties": {
"origin": {
"type": "string",
"description": "起点地名,如:北京、上海虹桥站"
},
"destination": {
"type": "string",
"description": "终点地名,如:杭州、西湖"
},
"origin_city": {
"type": "string",
"description": "起点所在城市"
},
"destination_city": {
"type": "string",
"description": "终点所在城市(跨城时必填)"
},
"mode": {
"type": "string",
"enum": ["driving", "transit", "walking"],
"description": "出行方式driving=驾车transit=公交/高铁walking=步行。默认 transit"
}
},
"required": ["origin", "destination", "origin_city"]
}
}
},
{
"type": "function",
"function": {
"name": "get_travel_info",
"description": "【旅行工具】获取目的地城市的旅行信息(天气、景点、交通)。仅当用户明确表示要去某城市旅游并询问相关信息时使用,如'我想去杭州玩,帮我看看''北京旅游攻略'",
"parameters": {
"type": "object",
"properties": {
"destination": {
"type": "string",
"description": "目的地城市,如:杭州、成都"
},
"origin": {
"type": "string",
"description": "出发城市,如:北京、上海。填写后会规划交通路线"
}
},
"required": ["destination"]
}
}
}
]
async def execute_llm_tool(
self,
tool_name: str,
arguments: Dict[str, Any],
bot,
from_wxid: str
) -> Dict[str, Any]:
"""执行 LLM 工具调用"""
if not self.amap:
return {"success": False, "message": "高德 API 未配置,请联系管理员设置 API Key"}
try:
if tool_name == "search_location":
return await self._tool_search_location(arguments)
elif tool_name == "query_weather":
return await self._tool_query_weather(arguments)
elif tool_name == "search_poi":
return await self._tool_search_poi(arguments)
elif tool_name == "search_nearby":
return await self._tool_search_nearby(arguments)
elif tool_name == "plan_route":
return await self._tool_plan_route(arguments)
elif tool_name == "get_travel_info":
return await self._tool_get_travel_info(arguments)
else:
return {"success": False, "message": f"未知工具: {tool_name}"}
except Exception as e:
logger.error(f"TravelPlanner 工具执行失败: {tool_name}, 错误: {e}")
return {"success": False, "message": f"工具执行失败: {str(e)}"}
# ==================== 工具实现 ====================
async def _tool_search_location(self, args: Dict) -> Dict:
"""地点搜索工具"""
address = args.get("address", "")
city = args.get("city")
result = await self.amap.geocode(address, city)
if not result["success"]:
return {"success": False, "message": result.get("error", "地点搜索失败")}
return {
"success": True,
"message": f"已找到地点:{result['formatted_address']}",
"data": {
"name": address,
"formatted_address": result["formatted_address"],
"location": result["location"],
"province": result["province"],
"city": result["city"],
"district": result["district"],
"adcode": result["adcode"]
}
}
async def _tool_query_weather(self, args: Dict) -> Dict:
"""天气查询工具"""
city = args.get("city", "")
forecast = args.get("forecast", True)
extensions = "all" if forecast else "base"
result = await self.amap.get_weather(city, extensions)
if not result["success"]:
return {"success": False, "message": result.get("error", "天气查询失败")}
if result["type"] == "live":
return {
"success": True,
"message": f"{result['city']}当前天气:{result['weather']}{result['temperature']}",
"data": {
"city": result["city"],
"weather": result["weather"],
"temperature": result["temperature"],
"humidity": result["humidity"],
"wind": f"{result['winddirection']}{result['windpower']}",
"reporttime": result["reporttime"]
}
}
else:
forecasts = result["forecasts"]
weather_text = "\n".join([
f"- {f['date']} 星期{self._weekday_cn(f['week'])}:白天{f['dayweather']} {f['daytemp']}℃,夜间{f['nightweather']} {f['nighttemp']}"
for f in forecasts
])
return {
"success": True,
"message": f"{result['city']}未来天气预报:\n{weather_text}",
"data": {
"city": result["city"],
"province": result["province"],
"forecasts": forecasts,
"reporttime": result["reporttime"]
}
}
async def _tool_search_poi(self, args: Dict) -> Dict:
"""POI 搜索工具"""
city = args.get("city", "")
keyword = args.get("keyword")
category = args.get("category")
limit = min(args.get("limit", 10), 20)
# 获取 POI 类型代码
types = None
if category:
poi_types = self.config.get("poi_types", {})
types = poi_types.get(category)
result = await self.amap.search_poi(
keywords=keyword,
types=types,
city=city,
citylimit=True,
offset=limit
)
if not result["success"]:
return {"success": False, "message": result.get("error", "搜索失败")}
pois = result["pois"]
if not pois:
return {"success": False, "message": f"{city}未找到相关地点"}
# 格式化输出
poi_list = []
for i, poi in enumerate(pois, 1):
info = f"{i}. {poi['name']}"
if poi.get("address"):
info += f" - {poi['address']}"
if poi.get("rating"):
info += f"{poi['rating']}"
if poi.get("cost"):
info += f" 人均¥{poi['cost']}"
poi_list.append(info)
return {
"success": True,
"message": f"{city}找到{len(pois)}个结果:\n" + "\n".join(poi_list),
"data": {
"city": city,
"category": category or "全部",
"count": len(pois),
"pois": pois
}
}
async def _tool_search_nearby(self, args: Dict) -> Dict:
"""周边搜索工具"""
location_name = args.get("location", "")
city = args.get("city", "")
keyword = args.get("keyword")
category = args.get("category")
radius = min(args.get("radius", 3000), 50000)
# 先获取中心点坐标
geo_result = await self.amap.geocode(location_name, city)
if not geo_result["success"]:
return {"success": False, "message": f"无法定位 {location_name}"}
location = geo_result["location"]
# 获取 POI 类型代码
types = None
if category:
poi_types = self.config.get("poi_types", {})
types = poi_types.get(category)
result = await self.amap.search_around(
location=location,
keywords=keyword,
types=types,
radius=radius,
offset=10
)
if not result["success"]:
return {"success": False, "message": result.get("error", "周边搜索失败")}
pois = result["pois"]
if not pois:
return {"success": False, "message": f"{location_name}周边未找到相关地点"}
# 格式化输出
poi_list = []
for i, poi in enumerate(pois, 1):
info = f"{i}. {poi['name']}"
if poi.get("distance"):
info += f" ({poi['distance']}米)"
if poi.get("rating"):
info += f"{poi['rating']}"
poi_list.append(info)
return {
"success": True,
"message": f"{location_name}周边{radius}米内找到{len(pois)}个结果:\n" + "\n".join(poi_list),
"data": {
"center": location_name,
"radius": radius,
"category": category or "全部",
"count": len(pois),
"pois": pois
}
}
async def _tool_plan_route(self, args: Dict) -> Dict:
"""路线规划工具"""
origin = args.get("origin", "")
destination = args.get("destination", "")
origin_city = args.get("origin_city", "")
destination_city = args.get("destination_city", origin_city)
mode = args.get("mode", "transit")
# 获取起终点坐标
origin_geo = await self.amap.geocode(origin, origin_city)
if not origin_geo["success"]:
return {"success": False, "message": f"无法定位起点:{origin}"}
dest_geo = await self.amap.geocode(destination, destination_city)
if not dest_geo["success"]:
return {"success": False, "message": f"无法定位终点:{destination}"}
origin_loc = origin_geo["location"]
dest_loc = dest_geo["location"]
# 根据模式规划路线
if mode == "driving":
result = await self.amap.route_driving(origin_loc, dest_loc)
if not result["success"]:
return {"success": False, "message": result.get("error", "驾车路线规划失败")}
distance_km = result["distance"] / 1000
duration_h = result["duration"] / 3600
msg = f"🚗 驾车路线:{origin}{destination}\n"
msg += f"距离:{distance_km:.1f}公里,预计{self._format_duration(result['duration'])}\n"
if result["tolls"]:
msg += f"收费:约{result['tolls']}\n"
if result["taxi_cost"]:
msg += f"打车费用:约{result['taxi_cost']}"
return {
"success": True,
"message": msg,
"data": result
}
elif mode == "transit":
result = await self.amap.route_transit(
origin_loc, dest_loc,
city=origin_city,
cityd=destination_city if destination_city != origin_city else None
)
if not result["success"]:
return {"success": False, "message": result.get("error", "公交路线规划失败")}
msg = f"🚄 公交/高铁路线:{origin}{destination}\n"
for i, transit in enumerate(result["transits"][:2], 1):
msg += f"\n方案{i}{self._format_duration(transit['duration'])}"
if transit.get("cost"):
msg += f",约{transit['cost']}"
msg += "\n"
for seg in transit["segments"]:
if seg["type"] == "walking" and seg["distance"] > 100:
msg += f" 🚶 步行{seg['distance']}\n"
elif seg["type"] == "bus":
msg += f" 🚌 {seg['name']}{seg['departure_stop']}{seg['arrival_stop']}{seg['via_num']}站)\n"
elif seg["type"] == "railway":
msg += f" 🚄 {seg['trip']} {seg['name']}{seg['departure_stop']} {seg.get('departure_time', '')}{seg['arrival_stop']} {seg.get('arrival_time', '')}\n"
return {
"success": True,
"message": msg.strip(),
"data": result
}
elif mode == "walking":
result = await self.amap.route_walking(origin_loc, dest_loc)
if not result["success"]:
return {"success": False, "message": result.get("error", "步行路线规划失败")}
return {
"success": True,
"message": f"🚶 步行路线:{origin}{destination}\n距离:{result['distance']}米,预计{self._format_duration(result['duration'])}",
"data": result
}
return {"success": False, "message": f"不支持的出行方式:{mode}"}
async def _tool_get_travel_info(self, args: Dict) -> Dict:
"""一键获取旅行信息"""
destination = args.get("destination", "")
origin = args.get("origin")
info = {"destination": destination}
msg_parts = [f"📍 {destination} 旅行信息\n"]
# 1. 查询天气
weather_result = await self.amap.get_weather(destination, "all")
if weather_result["success"]:
info["weather"] = weather_result
msg_parts.append("🌤️ 天气预报:")
for f in weather_result["forecasts"][:3]:
msg_parts.append(f" {f['date']} {f['dayweather']} {f['nighttemp']}~{f['daytemp']}")
# 2. 搜索热门景点
poi_result = await self.amap.search_poi(
types="110000", # 景点
city=destination,
citylimit=True,
offset=5
)
if poi_result["success"] and poi_result["pois"]:
info["attractions"] = poi_result["pois"]
msg_parts.append("\n🏞️ 热门景点:")
for poi in poi_result["pois"][:5]:
rating = f"{poi['rating']}" if poi.get("rating") else ""
msg_parts.append(f"{poi['name']}{rating}")
# 3. 规划交通路线(如果提供了出发地)
if origin:
origin_geo = await self.amap.geocode(origin)
dest_geo = await self.amap.geocode(destination)
if origin_geo["success"] and dest_geo["success"]:
route_result = await self.amap.route_transit(
origin_geo["location"],
dest_geo["location"],
city=origin_geo.get("city", origin),
cityd=dest_geo.get("city", destination)
)
if route_result["success"] and route_result["transits"]:
info["route"] = route_result
transit = route_result["transits"][0]
msg_parts.append(f"\n🚄 从{origin}出发:")
msg_parts.append(f" 预计{self._format_duration(transit['duration'])}")
# 显示主要交通工具
for seg in transit["segments"]:
if seg["type"] == "railway":
msg_parts.append(f" {seg['trip']}{seg['departure_stop']}{seg['arrival_stop']}")
break
return {
"success": True,
"message": "\n".join(msg_parts),
"data": info
}
# ==================== 辅助方法 ====================
def _weekday_cn(self, week: str) -> str:
"""星期数字转中文"""
mapping = {"1": "", "2": "", "3": "", "4": "", "5": "", "6": "", "7": ""}
return mapping.get(str(week), week)
def _format_duration(self, seconds: int) -> str:
"""格式化时长"""
if seconds < 60:
return f"{seconds}"
elif seconds < 3600:
return f"{seconds // 60}分钟"
else:
hours = seconds // 3600
minutes = (seconds % 3600) // 60
if minutes:
return f"{hours}小时{minutes}分钟"
return f"{hours}小时"