feat: add LocationTool — geographic search with Amap/Google Maps
Browse filesAdd a new LocationTool supporting geocoding, POI search, directions,
and trip planning. Automatically selects Amap (China) or Google Maps
(international) based on region detection.
Co-Authored-By: Claude Big Pickle <noreply@anthropic.com>
- docs/tools/overview.md +59 -0
- docs/tools/tool-reference.md +1 -0
- src/constants/tools.ts +2 -0
- src/tools.ts +3 -0
- src/tools/LocationTool/LocationTool.ts +1083 -0
- src/tools/LocationTool/UI.tsx +93 -0
- src/tools/LocationTool/prompt.ts +45 -0
docs/tools/overview.md
CHANGED
|
@@ -202,6 +202,7 @@ LLM 请求工具调用
|
|
| 202 |
| `SkillToolProgress` | 技能执行进度 | SkillTool |
|
| 203 |
| `TaskOutputProgress` | 后台任务输出进度 | TaskOutputTool |
|
| 204 |
| `WebSearchProgress` | 网络搜索进度 | WebSearchTool |
|
|
|
|
| 205 |
| `REPLToolProgress` | REPL 工具进度 | REPLTool |
|
| 206 |
| `HookProgress` | 钩子执行进度 | Hooks |
|
| 207 |
|
|
@@ -267,3 +268,61 @@ WebSearchTool 支持两个搜索后端:
|
|
| 267 |
- `/home/yuki/Code/Agent/VersperClaw/src/tools.ts` — 工具注册与过滤
|
| 268 |
- `/home/yuki/Code/Agent/VersperClaw/src/services/tools/toolExecution.ts` — 执行引擎
|
| 269 |
- `/home/yuki/Code/Agent/VersperClaw/src/services/tools/toolHooks.ts` — 工具钩子
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
| `SkillToolProgress` | 技能执行进度 | SkillTool |
|
| 203 |
| `TaskOutputProgress` | 后台任务输出进度 | TaskOutputTool |
|
| 204 |
| `WebSearchProgress` | 网络搜索进度 | WebSearchTool |
|
| 205 |
+
| `LocationToolProgress` | 地理位置搜索进度 | LocationTool |
|
| 206 |
| `REPLToolProgress` | REPL 工具进度 | REPLTool |
|
| 207 |
| `HookProgress` | 钩子执行进度 | Hooks |
|
| 208 |
|
|
|
|
| 268 |
- `/home/yuki/Code/Agent/VersperClaw/src/tools.ts` — 工具注册与过滤
|
| 269 |
- `/home/yuki/Code/Agent/VersperClaw/src/services/tools/toolExecution.ts` — 执行引擎
|
| 270 |
- `/home/yuki/Code/Agent/VersperClaw/src/services/tools/toolHooks.ts` — 工具钩子
|
| 271 |
+
|
| 272 |
+
---
|
| 273 |
+
|
| 274 |
+
### LocationTool
|
| 275 |
+
|
| 276 |
+
地理位置与地图搜索工具。根据地区自动选择地图服务商:
|
| 277 |
+
|
| 278 |
+
| 地区 | 服务商 | 环境变量 |
|
| 279 |
+
|------|--------|----------|
|
| 280 |
+
| 中国大陆 | 高德地图 (Amap) | `AMAP_API_KEY` |
|
| 281 |
+
| 海外 | Google Maps | `GOOGLE_MAPS_API_KEY` |
|
| 282 |
+
|
| 283 |
+
**支持的操作:**
|
| 284 |
+
|
| 285 |
+
| 动作 | 功能 | Amap API | Google API |
|
| 286 |
+
|------|------|----------|------------|
|
| 287 |
+
| `locate` | IP 地理定位 (无需 location 参数) | — | — |
|
| 288 |
+
| `geocode` | 地址→坐标 | 地理编码 | Geocoding |
|
| 289 |
+
| `search_places` | 周边/关键词搜索 POI | POI 周边搜索 | Places / Nearby Search |
|
| 290 |
+
| `get_directions` | 两点间路线规划 | 驾车/公交路径规划 | Directions |
|
| 291 |
+
| `plan_trip` | 多途经点行程规划 | 分段路径拼接 | 多航点 Directions |
|
| 292 |
+
|
| 293 |
+
**地区自动检测:** 含中文字符或已知中国城市名 → Amap,否则 → Google。支持 `region` 参数强制指定。
|
| 294 |
+
|
| 295 |
+
**配合搜索工具:** Prompt 层面指导模型在获取地点后,可调用 WebSearchTool 查攻略/评价,或用 WebFetchTool 获取详情页内容。
|
| 296 |
+
|
| 297 |
+
**输入参数:** `action` (必填), `location` (必填), `destination`, `query`, `radius` (默认 5000m), `mode` (driving/walking/transit/bicycling), `waypoints`, `type` (Amap POI 类型过滤), `region` (强制指定), `language`
|
| 298 |
+
|
| 299 |
+
相关文件:
|
| 300 |
+
- `/home/yuki/Code/Agent/VersperClaw/src/tools/LocationTool/LocationTool.ts` — 主工具逻辑
|
| 301 |
+
- `/home/yuki/Code/Agent/VersperClaw/src/tools/LocationTool/prompt.ts` — 提示词
|
| 302 |
+
- `/home/yuki/Code/Agent/VersperClaw/src/tools/LocationTool/UI.tsx` — 渲染组件
|
| 303 |
+
|
| 304 |
+
#### API Key 申请指南
|
| 305 |
+
|
| 306 |
+
**高德地图 API Key(中国大陆用)**
|
| 307 |
+
|
| 308 |
+
1. **注册账号** — 打开 [高德开放平台](https://lbs.amap.com/),点击右上角「注册」
|
| 309 |
+
2. **创建应用** — 登录后进入控制台 → 「应用管理」→ 「创建新应用」
|
| 310 |
+
3. **添加 Key** — 在创建的应用中点击「添加 Key」→ 服务平台选择 **「Web 服务」**
|
| 311 |
+
4. **获取 Key** — 创建成功后复制 Key,设为环境变量:
|
| 312 |
+
```bash
|
| 313 |
+
export AMAP_API_KEY=你的高德Key
|
| 314 |
+
```
|
| 315 |
+
|
| 316 |
+
**Google Maps API Key(海外用)**
|
| 317 |
+
|
| 318 |
+
1. **创建项目** — 打开 [Google Cloud Console](https://console.cloud.google.com/),创建新项目
|
| 319 |
+
2. **启用 API** — 进入「API 和服务」→「库」,搜索并启用以下 API:
|
| 320 |
+
- **Geocoding API**(地址 → 坐标)
|
| 321 |
+
- **Places API**(地点搜索)
|
| 322 |
+
- **Directions API**(路线规划)
|
| 323 |
+
3. **创建凭据** — 「API 和服务」→「凭据」→「创建凭据」→「API 密钥」
|
| 324 |
+
4. **限制密钥**(强烈建议)— 在凭据页面设置 API 限制,仅允许上面启用的三个 API,避免滥用
|
| 325 |
+
5. 设为环境变量:
|
| 326 |
+
```bash
|
| 327 |
+
export GOOGLE_MAPS_API_KEY=你的GoogleKey
|
| 328 |
+
```
|
docs/tools/tool-reference.md
CHANGED
|
@@ -35,6 +35,7 @@
|
|
| 35 |
|----------|------|----------|----------|
|
| 36 |
| **WebSearch** (WebSearchTool) | 搜索 | 执行网络搜索。支持 Tavily API 和本地 SearXNG 两种后端(优先本地 SearXNG) | `query` (必填, 最少 2 字符) |
|
| 37 |
| **WebFetch** (WebFetchTool) | 搜索 | 抓取指定 URL 的内容,并对内容执行 prompt 处理(提取、总结等) | `url` (必填), `prompt` (必填) |
|
|
|
|
| 38 |
| **ToolSearch** (ToolSearchTool) | 搜索 | 搜索延迟加载的工具。当 ToolSearch 启用时,部分工具的 schema 不会随初始提示发送,需通过此工具查询后再调用 | `query` (必填, 支持 "select:name" 精确选择), `max_results` (默认 5) |
|
| 39 |
|
| 40 |
---
|
|
|
|
| 35 |
|----------|------|----------|----------|
|
| 36 |
| **WebSearch** (WebSearchTool) | 搜索 | 执行网络搜索。支持 Tavily API 和本地 SearXNG 两种后端(优先本地 SearXNG) | `query` (必填, 最少 2 字符) |
|
| 37 |
| **WebFetch** (WebFetchTool) | 搜索 | 抓取指定 URL 的内容,并对内容执行 prompt 处理(提取、总结等) | `url` (必填), `prompt` (必填) |
|
| 38 |
+
| **LocationTool** (LocationTool) | 搜索 | 地理位置与地图搜索。中国大陆使用高德地图,海外使用 Google Maps。支持地理编码、POI 搜索、路线规划、行程规划 | `action` (必填), `location` (必填), `destination`, `query`, `radius`, `mode`, `waypoints`, `type`, `region`, `language` |
|
| 39 |
| **ToolSearch** (ToolSearchTool) | 搜索 | 搜索延迟加载的工具。当 ToolSearch 启用时,部分工具的 schema 不会随初始提示发送,需通过此工具查询后再调用 | `query` (必填, 支持 "select:name" 精确选择), `max_results` (默认 5) |
|
| 40 |
|
| 41 |
---
|
src/constants/tools.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { ASK_USER_QUESTION_TOOL_NAME } from '../tools/AskUserQuestionTool/prompt
|
|
| 8 |
import { TASK_STOP_TOOL_NAME } from '../tools/TaskStopTool/prompt.js'
|
| 9 |
import { FILE_READ_TOOL_NAME } from '../tools/FileReadTool/prompt.js'
|
| 10 |
import { WEB_SEARCH_TOOL_NAME } from '../tools/WebSearchTool/prompt.js'
|
|
|
|
| 11 |
import { TODO_WRITE_TOOL_NAME } from '../tools/TodoWriteTool/constants.js'
|
| 12 |
import { GREP_TOOL_NAME } from '../tools/GrepTool/prompt.js'
|
| 13 |
import { WEB_FETCH_TOOL_NAME } from '../tools/WebFetchTool/prompt.js'
|
|
@@ -55,6 +56,7 @@ export const CUSTOM_AGENT_DISALLOWED_TOOLS = new Set([
|
|
| 55 |
export const ASYNC_AGENT_ALLOWED_TOOLS = new Set([
|
| 56 |
FILE_READ_TOOL_NAME,
|
| 57 |
WEB_SEARCH_TOOL_NAME,
|
|
|
|
| 58 |
TODO_WRITE_TOOL_NAME,
|
| 59 |
GREP_TOOL_NAME,
|
| 60 |
WEB_FETCH_TOOL_NAME,
|
|
|
|
| 8 |
import { TASK_STOP_TOOL_NAME } from '../tools/TaskStopTool/prompt.js'
|
| 9 |
import { FILE_READ_TOOL_NAME } from '../tools/FileReadTool/prompt.js'
|
| 10 |
import { WEB_SEARCH_TOOL_NAME } from '../tools/WebSearchTool/prompt.js'
|
| 11 |
+
import { LOCATION_TOOL_NAME } from '../tools/LocationTool/prompt.js'
|
| 12 |
import { TODO_WRITE_TOOL_NAME } from '../tools/TodoWriteTool/constants.js'
|
| 13 |
import { GREP_TOOL_NAME } from '../tools/GrepTool/prompt.js'
|
| 14 |
import { WEB_FETCH_TOOL_NAME } from '../tools/WebFetchTool/prompt.js'
|
|
|
|
| 56 |
export const ASYNC_AGENT_ALLOWED_TOOLS = new Set([
|
| 57 |
FILE_READ_TOOL_NAME,
|
| 58 |
WEB_SEARCH_TOOL_NAME,
|
| 59 |
+
LOCATION_TOOL_NAME,
|
| 60 |
TODO_WRITE_TOOL_NAME,
|
| 61 |
GREP_TOOL_NAME,
|
| 62 |
WEB_FETCH_TOOL_NAME,
|
src/tools.ts
CHANGED
|
@@ -80,6 +80,7 @@ import { ReadMcpResourceTool } from './tools/ReadMcpResourceTool/ReadMcpResource
|
|
| 80 |
import { ToolSearchTool } from './tools/ToolSearchTool/ToolSearchTool.js'
|
| 81 |
import { DebugSessionTool } from './tools/DebugSessionTool.js'
|
| 82 |
import { FriendEmotionTool } from './tools/FriendEmotionTool.js'
|
|
|
|
| 83 |
// Friend ScreenObserve removed — voice + emotion only
|
| 84 |
import { EnterPlanModeTool } from './tools/EnterPlanModeTool/EnterPlanModeTool.js'
|
| 85 |
import { EnterWorktreeTool } from './tools/EnterWorktreeTool/EnterWorktreeTool.js'
|
|
@@ -252,6 +253,8 @@ export function getAllBaseTools(): Tools {
|
|
| 252 |
DebugSessionTool,
|
| 253 |
// Friend VRM desktop pet tools — enabled when the plugin is active
|
| 254 |
FriendEmotionTool,
|
|
|
|
|
|
|
| 255 |
ListMcpResourcesTool,
|
| 256 |
ReadMcpResourceTool,
|
| 257 |
// Include ToolSearchTool when tool search might be enabled (optimistic check)
|
|
|
|
| 80 |
import { ToolSearchTool } from './tools/ToolSearchTool/ToolSearchTool.js'
|
| 81 |
import { DebugSessionTool } from './tools/DebugSessionTool.js'
|
| 82 |
import { FriendEmotionTool } from './tools/FriendEmotionTool.js'
|
| 83 |
+
import { LocationTool } from './tools/LocationTool/LocationTool.js'
|
| 84 |
// Friend ScreenObserve removed — voice + emotion only
|
| 85 |
import { EnterPlanModeTool } from './tools/EnterPlanModeTool/EnterPlanModeTool.js'
|
| 86 |
import { EnterWorktreeTool } from './tools/EnterWorktreeTool/EnterWorktreeTool.js'
|
|
|
|
| 253 |
DebugSessionTool,
|
| 254 |
// Friend VRM desktop pet tools — enabled when the plugin is active
|
| 255 |
FriendEmotionTool,
|
| 256 |
+
// Location & mapping tool — uses Amap (China) or Google Maps (international)
|
| 257 |
+
LocationTool,
|
| 258 |
ListMcpResourcesTool,
|
| 259 |
ReadMcpResourceTool,
|
| 260 |
// Include ToolSearchTool when tool search might be enabled (optimistic check)
|
src/tools/LocationTool/LocationTool.ts
ADDED
|
@@ -0,0 +1,1083 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { z } from 'zod/v4'
|
| 2 |
+
import { buildTool, type ToolDef } from '../../Tool.js'
|
| 3 |
+
import { lazySchema } from '../../utils/lazySchema.js'
|
| 4 |
+
import { logError } from '../../utils/log.js'
|
| 5 |
+
import { LOCATION_TOOL_NAME, PROMPT } from './prompt.js'
|
| 6 |
+
import {
|
| 7 |
+
getToolUseSummary,
|
| 8 |
+
renderToolResultMessage,
|
| 9 |
+
renderToolUseMessage,
|
| 10 |
+
renderToolUseProgressMessage,
|
| 11 |
+
} from './UI.js'
|
| 12 |
+
|
| 13 |
+
// ── Region detection ────────────────────────────────────────────────────────
|
| 14 |
+
|
| 15 |
+
/** Chinese cities known to be in mainland China */
|
| 16 |
+
const CHINA_CITIES = new Set([
|
| 17 |
+
'北京', '上海', '广州', '深圳', '成都', '杭州', '武汉', '西安', '重庆',
|
| 18 |
+
'南京', '天津', '苏州', '长沙', '郑州', '东莞', '青岛', '沈阳', '宁波',
|
| 19 |
+
'昆明', '大连', '厦门', '合肥', '佛山', '福州', '哈尔滨', '济南', '温州',
|
| 20 |
+
'长春', '石家庄', '常州', '泉州', '南宁', '贵阳', '南昌', '太原', '烟台',
|
| 21 |
+
'嘉兴', '南通', '金华', '珠海', '惠州', '徐州', '海口', '乌鲁木齐', '绍兴',
|
| 22 |
+
'中山', '台州', '兰州', 'beijing', 'shanghai', 'guangzhou', 'shenzhen',
|
| 23 |
+
'chengdu', 'hangzhou', 'wuhan', 'xi\'an', 'chongqing', 'nanjing',
|
| 24 |
+
'tianjin', 'suzhou', 'changsha', 'zhengzhou', 'qingdao', 'shenyang',
|
| 25 |
+
'kunming', 'dalian', 'xiamen', 'hefei', 'foshan', 'fuzhou', 'harbin',
|
| 26 |
+
'jinan', 'changchun', 'shijiazhuang', 'nanning', 'guiyang', 'nanchang',
|
| 27 |
+
'taiyuan', 'haikou', 'urumqi', 'lanzhou', 'macau', 'macao', 'hong kong',
|
| 28 |
+
'xianggang',
|
| 29 |
+
])
|
| 30 |
+
|
| 31 |
+
const NON_CHINA_COUNTRIES = new Set([
|
| 32 |
+
'japan', 'korea', 'south korea', 'usa', 'united states', 'uk', 'united kingdom',
|
| 33 |
+
'france', 'germany', 'italy', 'spain', 'australia', 'canada', 'brazil',
|
| 34 |
+
'india', 'thailand', 'vietnam', 'singapore', 'malaysia', 'indonesia',
|
| 35 |
+
'philippines', 'russia', 'mexico', 'turkey', 'egypt', 'switzerland',
|
| 36 |
+
'sweden', 'norway', 'finland', 'denmark', 'netherlands', 'belgium',
|
| 37 |
+
'portugal', 'greece', 'ireland', 'new zealand', 'argentina', 'chile',
|
| 38 |
+
'peru', 'colombia', 'south africa', 'nigeria', 'kenya', 'morocco',
|
| 39 |
+
'美国', '日本', '韩国', '英国', '法国', '德国', '意大利', '西班牙',
|
| 40 |
+
'澳大利亚', '加拿大', '巴西', '印度', '泰国', '越南', '新加坡',
|
| 41 |
+
'马来西亚', '俄罗斯', '土耳其', '埃及', '瑞士', '荷兰', '新西兰',
|
| 42 |
+
])
|
| 43 |
+
|
| 44 |
+
function isChinaMainland(location: string): boolean {
|
| 45 |
+
// Normalize curly/smart quotes to straight quotes for matching
|
| 46 |
+
const normalized = location.replace(/[\u2018\u2019]/g, "'")
|
| 47 |
+
|
| 48 |
+
// Contains Chinese characters → likely China
|
| 49 |
+
if (/[\u4e00-\u9fff]/.test(normalized)) {
|
| 50 |
+
// Check for non-China country names in Chinese
|
| 51 |
+
const lower = normalized.toLowerCase()
|
| 52 |
+
for (const country of NON_CHINA_COUNTRIES) {
|
| 53 |
+
if (lower.includes(country)) return false
|
| 54 |
+
}
|
| 55 |
+
return true
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const lower = normalized.toLowerCase().trim()
|
| 59 |
+
|
| 60 |
+
// Check Chinese city names
|
| 61 |
+
for (const city of CHINA_CITIES) {
|
| 62 |
+
if (lower.startsWith(city) || lower.includes(` ${city}`) || lower.includes(`, ${city}`)) {
|
| 63 |
+
return true
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// If it looks like a country name, check against known non-China list
|
| 68 |
+
if (NON_CHINA_COUNTRIES.has(lower)) return false
|
| 69 |
+
|
| 70 |
+
// Default: if location is purely alphabetic/pinyin, assume non-China
|
| 71 |
+
return false
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/** Parse "lat,lng" coordinate string (e.g. "34.2583,108.9286") */
|
| 75 |
+
function parseCoords(input: string): { lat: number; lng: number } | null {
|
| 76 |
+
const trimmed = input.trim()
|
| 77 |
+
const match = trimmed.match(/^(-?\d+\.?\d*)\s*[,,]\s*(-?\d+\.?\d*)$/)
|
| 78 |
+
if (!match) return null
|
| 79 |
+
const lat = parseFloat(match[1])
|
| 80 |
+
const lng = parseFloat(match[2])
|
| 81 |
+
if (isNaN(lat) || isNaN(lng)) return null
|
| 82 |
+
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null
|
| 83 |
+
return { lat, lng }
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/** Detect China mainland from coordinates (rough bounding box) */
|
| 87 |
+
function isChinaCoords(lat: number, lng: number): boolean {
|
| 88 |
+
return lat >= 18 && lat <= 54 && lng >= 73 && lng <= 135
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/** Build a LocationResult from parsed coordinates */
|
| 92 |
+
function coordsToLocation(lat: number, lng: number): LocationResult {
|
| 93 |
+
return {
|
| 94 |
+
lat,
|
| 95 |
+
lng,
|
| 96 |
+
formattedAddress: `${lat.toFixed(6)}, ${lng.toFixed(6)}`,
|
| 97 |
+
displayName: `${lat.toFixed(6)}, ${lng.toFixed(6)}`,
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/** IP geolocation result for the locate action */
|
| 102 |
+
type IpGeoInfo = {
|
| 103 |
+
ip: string
|
| 104 |
+
city: string
|
| 105 |
+
region: string
|
| 106 |
+
country: string
|
| 107 |
+
lat?: number
|
| 108 |
+
lng?: number
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/** Scan nearby WiFi access points via nmcli */
|
| 112 |
+
async function scanWiFi(): Promise<{ macAddress: string; signalStrength: number }[]> {
|
| 113 |
+
try {
|
| 114 |
+
const cmd = new Deno.Command('nmcli', {
|
| 115 |
+
args: ['-t', '-f', 'BSSID,SIGNAL', 'device', 'wifi', 'list'],
|
| 116 |
+
stdout: 'piped',
|
| 117 |
+
stderr: 'null',
|
| 118 |
+
})
|
| 119 |
+
const { stdout } = await cmd.output()
|
| 120 |
+
const text = new TextDecoder().decode(stdout)
|
| 121 |
+
return text.trim().split('\n')
|
| 122 |
+
.filter(Boolean)
|
| 123 |
+
.map(line => {
|
| 124 |
+
// nmcli -t escapes colons inside values as \:, split on unescaped :
|
| 125 |
+
const fields = line.split(/(?<!\\):/)
|
| 126 |
+
if (fields.length < 2) return null
|
| 127 |
+
const bssid = fields[0].replace(/\\([\da-fA-F]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
|
| 128 |
+
const signal = parseInt(fields[1], 10)
|
| 129 |
+
if (isNaN(signal)) return null
|
| 130 |
+
return { macAddress: bssid, signalStrength: signal }
|
| 131 |
+
})
|
| 132 |
+
.filter((a): a is NonNullable<typeof a> => a !== null)
|
| 133 |
+
} catch {
|
| 134 |
+
return []
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/** Google Geolocation API — uses WiFi AP data for precise positioning */
|
| 139 |
+
// Amap Wi‑Fi 基站定位(国内)— 使用 apilocate.amap.com/position(GET)
|
| 140 |
+
async function amapWiFiGeolocateAPI(wifiAPs: { macAddress: string; signalStrength: number }[]): Promise<{ lat: number; lng: number; accuracy: number } | null> {
|
| 141 |
+
const key = process.env.AMAP_API_KEY
|
| 142 |
+
if (!key) return null
|
| 143 |
+
try {
|
| 144 |
+
if (wifiAPs.length < 2) return null // Amap 要求至少 2 个 AP
|
| 145 |
+
|
| 146 |
+
// 构造 macs 参数:mac1,signal1,ssid1|mac2,signal2,ssid2|...
|
| 147 |
+
// signal 为负 dBm(nmcli 返回 0‑100,取负作为近似值)
|
| 148 |
+
const macs = wifiAPs.slice(0, 30).map(ap =>
|
| 149 |
+
`${ap.macAddress},${-ap.signalStrength},`
|
| 150 |
+
).join('|')
|
| 151 |
+
|
| 152 |
+
const params = new URLSearchParams({
|
| 153 |
+
key,
|
| 154 |
+
accesstype: '1',
|
| 155 |
+
cdma: '0',
|
| 156 |
+
macs,
|
| 157 |
+
output: 'json',
|
| 158 |
+
})
|
| 159 |
+
|
| 160 |
+
const res = await fetch(`https://apilocate.amap.com/position?${params}`, {
|
| 161 |
+
method: 'GET',
|
| 162 |
+
signal: AbortSignal.timeout(10000),
|
| 163 |
+
})
|
| 164 |
+
if (!res.ok) return null
|
| 165 |
+
const data = await res.json() as any
|
| 166 |
+
// Amap 返回 { status: "1", info: "OK", result: { location: "lng,lat", radius: number, ... } }
|
| 167 |
+
if (data.status === '1' && data.result?.location) {
|
| 168 |
+
const [lng, lat] = data.result.location.split(',').map(Number)
|
| 169 |
+
if (!isNaN(lat) && !isNaN(lng)) {
|
| 170 |
+
return {
|
| 171 |
+
lat,
|
| 172 |
+
lng,
|
| 173 |
+
accuracy: data.result.radius || 0,
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
return null
|
| 178 |
+
} catch {
|
| 179 |
+
return null
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
// Google Geolocation API(海外)
|
| 184 |
+
async function googleGeolocateAPI(wifiAPs: { macAddress: string; signalStrength: number }[]): Promise<{ lat: number; lng: number; accuracy: number } | null> {
|
| 185 |
+
const key = process.env.GOOGLE_MAPS_API_KEY
|
| 186 |
+
if (!key) return null
|
| 187 |
+
try {
|
| 188 |
+
const body: any = {}
|
| 189 |
+
if (wifiAPs.length > 0) {
|
| 190 |
+
body.wifiAccessPoints = wifiAPs.slice(0, 20)
|
| 191 |
+
body.considerIp = false
|
| 192 |
+
} else {
|
| 193 |
+
body.considerIp = true
|
| 194 |
+
}
|
| 195 |
+
const res = await fetch('https://www.googleapis.com/geolocation/v1/geolocate?key=' + key, {
|
| 196 |
+
method: 'POST',
|
| 197 |
+
headers: { 'Content-Type': 'application/json' },
|
| 198 |
+
body: JSON.stringify(body),
|
| 199 |
+
signal: AbortSignal.timeout(10000),
|
| 200 |
+
})
|
| 201 |
+
if (!res.ok) return null
|
| 202 |
+
const data = await res.json() as any
|
| 203 |
+
if (data.location?.lat && data.location?.lng) {
|
| 204 |
+
return {
|
| 205 |
+
lat: data.location.lat,
|
| 206 |
+
lng: data.location.lng,
|
| 207 |
+
accuracy: data.accuracy || 0,
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
return null
|
| 211 |
+
} catch {
|
| 212 |
+
return null
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
/**
|
| 217 |
+
* Detect user location via IP (3 fallback services).
|
| 218 |
+
* Only used by the explicit `locate` action.
|
| 219 |
+
*/
|
| 220 |
+
async function ipGeolocate(): Promise<IpGeoInfo> {
|
| 221 |
+
const services = [
|
| 222 |
+
'https://ipinfo.io/json',
|
| 223 |
+
'https://ipapi.co/json/',
|
| 224 |
+
'http://ip-api.com/json/',
|
| 225 |
+
]
|
| 226 |
+
for (const url of services) {
|
| 227 |
+
try {
|
| 228 |
+
const res = await fetch(url, {
|
| 229 |
+
headers: { 'Accept': 'application/json' },
|
| 230 |
+
signal: AbortSignal.timeout(5000),
|
| 231 |
+
})
|
| 232 |
+
if (!res.ok) continue
|
| 233 |
+
const data = await res.json() as any
|
| 234 |
+
|
| 235 |
+
// ipinfo.io
|
| 236 |
+
if (data.city) {
|
| 237 |
+
const loc = data.loc?.split(',').map(Number) as [number, number] | undefined
|
| 238 |
+
return {
|
| 239 |
+
ip: data.ip || 'unknown',
|
| 240 |
+
city: data.city,
|
| 241 |
+
region: data.region || '',
|
| 242 |
+
country: data.country || '',
|
| 243 |
+
lat: loc?.[0],
|
| 244 |
+
lng: loc?.[1],
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
// ipapi.co
|
| 248 |
+
if (data.city) {
|
| 249 |
+
return {
|
| 250 |
+
ip: data.ip || 'unknown',
|
| 251 |
+
city: data.city,
|
| 252 |
+
region: data.region || '',
|
| 253 |
+
country: data.country_name || data.country || '',
|
| 254 |
+
lat: data.latitude,
|
| 255 |
+
lng: data.longitude,
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
// ip-api.com
|
| 259 |
+
if (data.city) {
|
| 260 |
+
return {
|
| 261 |
+
ip: data.query || 'unknown',
|
| 262 |
+
city: data.city,
|
| 263 |
+
region: data.regionName || data.region || '',
|
| 264 |
+
country: data.country || '',
|
| 265 |
+
lat: data.lat,
|
| 266 |
+
lng: data.lon,
|
| 267 |
+
}
|
| 268 |
+
}
|
| 269 |
+
} catch {
|
| 270 |
+
continue
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
throw new Error('Could not determine location via IP (all geolocation services failed)')
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
// ── Input / Output schemas ───────────────────────────────────────────────────
|
| 277 |
+
|
| 278 |
+
const inputSchema = lazySchema(() =>
|
| 279 |
+
z.strictObject({
|
| 280 |
+
action: z.enum(['geocode', 'search_places', 'get_directions', 'plan_trip', 'locate'])
|
| 281 |
+
.describe('The action to perform'),
|
| 282 |
+
location: z.any().optional().default('').describe('The location name, address, or origin for directions (optional for locate; defaults to empty string)'),
|
| 283 |
+
destination: z.string().optional()
|
| 284 |
+
.describe('Destination location (required for get_directions and plan_trip)'),
|
| 285 |
+
query: z.string().optional()
|
| 286 |
+
.describe('Search query for places (e.g., restaurants, attractions, hotels). Required for search_places'),
|
| 287 |
+
radius: z.number().min(1).max(50000).optional().default(5000)
|
| 288 |
+
.describe('Search radius in meters (default: 5000, max: 50000)'),
|
| 289 |
+
mode: z.enum(['driving', 'walking', 'transit', 'bicycling']).optional().default('driving')
|
| 290 |
+
.describe('Transport mode for directions (default: driving)'),
|
| 291 |
+
waypoints: z.array(z.string()).max(10).optional()
|
| 292 |
+
.describe('Intermediate stops for multi-city trip planning (max 10)'),
|
| 293 |
+
type: z.string().optional()
|
| 294 |
+
.describe('POI type filter for Amap (e.g., 餐饮, 酒店, 景点, 购物). Only applies to China locations'),
|
| 295 |
+
region: z.enum(['china', 'international']).optional()
|
| 296 |
+
.describe('Force region. Auto-detected if not specified'),
|
| 297 |
+
language: z.string().optional().default('zh-CN')
|
| 298 |
+
.describe('Response language (default: zh-CN)'),
|
| 299 |
+
}),
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
type Input = z.infer<ReturnType<typeof inputSchema>>
|
| 303 |
+
|
| 304 |
+
type LocationResult = {
|
| 305 |
+
lat: number
|
| 306 |
+
lng: number
|
| 307 |
+
formattedAddress: string
|
| 308 |
+
displayName: string
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
type PlaceResult = {
|
| 312 |
+
name: string
|
| 313 |
+
address: string
|
| 314 |
+
lat: number
|
| 315 |
+
lng: number
|
| 316 |
+
rating?: number
|
| 317 |
+
types?: string[]
|
| 318 |
+
phone?: string
|
| 319 |
+
website?: string
|
| 320 |
+
openingHours?: string
|
| 321 |
+
photos?: string[]
|
| 322 |
+
/** Amap-specific fields */
|
| 323 |
+
distance?: string
|
| 324 |
+
businessArea?: string
|
| 325 |
+
cost?: string
|
| 326 |
+
recommendation?: string
|
| 327 |
+
tag?: string
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
type DirectionStep = {
|
| 331 |
+
instruction: string
|
| 332 |
+
distance: string
|
| 333 |
+
duration: string
|
| 334 |
+
mode?: string
|
| 335 |
+
polyline?: string
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
type DirectionLeg = {
|
| 339 |
+
steps: DirectionStep[]
|
| 340 |
+
distance: string
|
| 341 |
+
duration: string
|
| 342 |
+
startAddress: string
|
| 343 |
+
endAddress: string
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
type DirectionsResult = {
|
| 347 |
+
origin: string
|
| 348 |
+
destination: string
|
| 349 |
+
mode: string
|
| 350 |
+
legs: DirectionLeg[]
|
| 351 |
+
totalDistance: string
|
| 352 |
+
totalDuration: string
|
| 353 |
+
polyline?: string
|
| 354 |
+
transitInfo?: string
|
| 355 |
+
tolls?: string
|
| 356 |
+
trafficRestriction?: string
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
type Output = {
|
| 360 |
+
action: string
|
| 361 |
+
region: 'china' | 'international'
|
| 362 |
+
geocoding?: LocationResult
|
| 363 |
+
places?: PlaceResult[]
|
| 364 |
+
directions?: DirectionsResult
|
| 365 |
+
error?: string
|
| 366 |
+
rawData?: unknown
|
| 367 |
+
ipGeo?: IpGeoInfo
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
// ── Amap API helpers ─────────────────────────────────────────────────────────
|
| 371 |
+
|
| 372 |
+
function getAmapKey(): string {
|
| 373 |
+
const key = process.env.AMAP_API_KEY
|
| 374 |
+
if (!key) throw new Error('AMAP_API_KEY environment variable is not set')
|
| 375 |
+
return key
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
async function amapGeocode(location: string, city?: string): Promise<LocationResult> {
|
| 379 |
+
const key = getAmapKey()
|
| 380 |
+
const params = new URLSearchParams({ key, address: location })
|
| 381 |
+
if (city) params.set('city', city)
|
| 382 |
+
|
| 383 |
+
const res = await fetch(`https://restapi.amap.com/v3/geocode/geo?${params}`, {
|
| 384 |
+
headers: { 'Accept': 'application/json' },
|
| 385 |
+
signal: AbortSignal.timeout(10000),
|
| 386 |
+
})
|
| 387 |
+
if (!res.ok) throw new Error(`Amap geocode HTTP ${res.status}`)
|
| 388 |
+
|
| 389 |
+
const data = await res.json() as any
|
| 390 |
+
if (data.status !== '1' || !data.geocodes?.length) {
|
| 391 |
+
throw new Error(`Amap geocode failed: ${data.info || 'no results'}`)
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
const [lng, lat] = data.geocodes[0].location.split(',').map(Number)
|
| 395 |
+
return {
|
| 396 |
+
lat,
|
| 397 |
+
lng,
|
| 398 |
+
formattedAddress: data.geocodes[0].formatted_address || location,
|
| 399 |
+
displayName: data.geocodes[0].formatted_address || location,
|
| 400 |
+
}
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
async function amapSearchPlaces(
|
| 404 |
+
location: string,
|
| 405 |
+
query?: string,
|
| 406 |
+
type?: string,
|
| 407 |
+
radius?: number,
|
| 408 |
+
/** Pre-resolved coordinates (skip geocoding) */
|
| 409 |
+
geo?: LocationResult,
|
| 410 |
+
): Promise<PlaceResult[]> {
|
| 411 |
+
const key = getAmapKey()
|
| 412 |
+
|
| 413 |
+
// Use pre-resolved coordinates if available, otherwise geocode
|
| 414 |
+
const resolved = geo ?? await amapGeocode(location)
|
| 415 |
+
|
| 416 |
+
const params = new URLSearchParams({
|
| 417 |
+
key,
|
| 418 |
+
location: `${resolved.lng},${resolved.lat}`,
|
| 419 |
+
radius: String(radius || 5000),
|
| 420 |
+
offset: '25',
|
| 421 |
+
page: '1',
|
| 422 |
+
extensions: 'all',
|
| 423 |
+
})
|
| 424 |
+
if (query) params.set('keywords', query)
|
| 425 |
+
if (type) params.set('types', type)
|
| 426 |
+
|
| 427 |
+
const res = await fetch(`https://restapi.amap.com/v3/place/around?${params}`, {
|
| 428 |
+
headers: { 'Accept': 'application/json' },
|
| 429 |
+
signal: AbortSignal.timeout(15000),
|
| 430 |
+
})
|
| 431 |
+
|
| 432 |
+
if (!res.ok) throw new Error(`Amap around search HTTP ${res.status}`)
|
| 433 |
+
const data = await res.json() as any
|
| 434 |
+
|
| 435 |
+
if (data.status !== '1') {
|
| 436 |
+
throw new Error(`Amap search failed: ${data.info}`)
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
return (data.pois || []).map((poi: any) => ({
|
| 440 |
+
name: poi.name,
|
| 441 |
+
address: poi.address || '',
|
| 442 |
+
lat: parseFloat(poi.location?.split(',')[1] || '0'),
|
| 443 |
+
lng: parseFloat(poi.location?.split(',')[0] || '0'),
|
| 444 |
+
distance: poi.distance,
|
| 445 |
+
businessArea: poi.business_area,
|
| 446 |
+
cost: poi.cost,
|
| 447 |
+
type: poi.type,
|
| 448 |
+
tag: (poi.type || '').split(';').filter(Boolean).join(', '),
|
| 449 |
+
photos: (poi.photos || []).map((p: any) => p.url).filter(Boolean),
|
| 450 |
+
openingHours: poi.opentime || poi.biz_ext?.opentime,
|
| 451 |
+
recommendation: poi.recommend || poi.biz_ext?.rating,
|
| 452 |
+
}))
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
async function amapDirections(
|
| 456 |
+
origin: string,
|
| 457 |
+
destination: string,
|
| 458 |
+
mode: string,
|
| 459 |
+
/** Pre-resolved origin coordinates (skip geocoding) */
|
| 460 |
+
origGeo?: LocationResult,
|
| 461 |
+
/** Pre-resolved destination coordinates (skip geocoding) */
|
| 462 |
+
destGeo?: LocationResult,
|
| 463 |
+
): Promise<DirectionsResult> {
|
| 464 |
+
const key = getAmapKey()
|
| 465 |
+
const originGeo = origGeo ?? await amapGeocode(origin)
|
| 466 |
+
const destinationGeo = destGeo ?? await amapGeocode(destination)
|
| 467 |
+
|
| 468 |
+
// Map our mode to Amap mode
|
| 469 |
+
const amapMode = mode === 'transit' ? 'transit' : 'driving'
|
| 470 |
+
const apiUrl = amapMode === 'transit'
|
| 471 |
+
? 'https://restapi.amap.com/v3/direction/transit/integrated'
|
| 472 |
+
: 'https://restapi.amap.com/v3/direction/driving'
|
| 473 |
+
|
| 474 |
+
const params = new URLSearchParams({
|
| 475 |
+
key,
|
| 476 |
+
origin: `${originGeo.lng},${originGeo.lat}`,
|
| 477 |
+
destination: `${destinationGeo.lng},${destinationGeo.lat}`,
|
| 478 |
+
extensions: 'all',
|
| 479 |
+
strategy: '0',
|
| 480 |
+
})
|
| 481 |
+
|
| 482 |
+
if (amapMode === 'transit') {
|
| 483 |
+
params.set('city', '')
|
| 484 |
+
params.set('cityd', '')
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
const res = await fetch(`${apiUrl}?${params}`, {
|
| 488 |
+
headers: { 'Accept': 'application/json' },
|
| 489 |
+
signal: AbortSignal.timeout(15000),
|
| 490 |
+
})
|
| 491 |
+
|
| 492 |
+
if (!res.ok) throw new Error(`Amap directions HTTP ${res.status}`)
|
| 493 |
+
const data = await res.json() as any
|
| 494 |
+
|
| 495 |
+
if (data.status !== '1' || !data.route) {
|
| 496 |
+
throw new Error(`Amap directions failed: ${data.info || 'no route'}`)
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
const route = data.route
|
| 500 |
+
const path = route.paths?.[0] || route.transits?.[0]
|
| 501 |
+
if (!path) throw new Error('No route found')
|
| 502 |
+
|
| 503 |
+
const legs: DirectionLeg[] = [{
|
| 504 |
+
steps: (path.steps || []).map((s: any) => ({
|
| 505 |
+
instruction: s.instruction || '',
|
| 506 |
+
distance: `${(parseFloat(s.distance) / 1000).toFixed(1)} km`,
|
| 507 |
+
duration: `${Math.round(parseFloat(s.duration) / 60)} min`,
|
| 508 |
+
polyline: s.polyline,
|
| 509 |
+
})),
|
| 510 |
+
distance: `${(parseFloat(path.distance) / 1000).toFixed(1)} km`,
|
| 511 |
+
duration: `${Math.round(parseFloat(path.duration) / 60)} min`,
|
| 512 |
+
startAddress: origin,
|
| 513 |
+
endAddress: destination,
|
| 514 |
+
}]
|
| 515 |
+
|
| 516 |
+
const result: DirectionsResult = {
|
| 517 |
+
origin,
|
| 518 |
+
destination,
|
| 519 |
+
mode,
|
| 520 |
+
legs,
|
| 521 |
+
totalDistance: legs[0].distance,
|
| 522 |
+
totalDuration: legs[0].duration,
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
if (amapMode === 'transit') {
|
| 526 |
+
result.transitInfo = `Transit: ${path.transit_mode || 'public transport'}`
|
| 527 |
+
result.tolls = path.tolls ? `¥${path.tolls}` : undefined
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
return result
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
// ── Google Maps API helpers ───────────────────────────────────────────────────
|
| 534 |
+
|
| 535 |
+
function getGoogleKey(): string {
|
| 536 |
+
const key = process.env.GOOGLE_MAPS_API_KEY
|
| 537 |
+
if (!key) throw new Error('GOOGLE_MAPS_API_KEY environment variable is not set')
|
| 538 |
+
return key
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
async function googleGeocode(location: string, language?: string): Promise<LocationResult> {
|
| 542 |
+
const key = getGoogleKey()
|
| 543 |
+
const params = new URLSearchParams({ address: location, key })
|
| 544 |
+
if (language) params.set('language', language)
|
| 545 |
+
|
| 546 |
+
const res = await fetch(`https://maps.googleapis.com/maps/api/geocode/json?${params}`, {
|
| 547 |
+
headers: { 'Accept': 'application/json' },
|
| 548 |
+
signal: AbortSignal.timeout(10000),
|
| 549 |
+
})
|
| 550 |
+
|
| 551 |
+
if (!res.ok) throw new Error(`Google geocode HTTP ${res.status}`)
|
| 552 |
+
const data = await res.json() as any
|
| 553 |
+
|
| 554 |
+
if (data.status !== 'OK' || !data.results?.length) {
|
| 555 |
+
throw new Error(`Google geocode failed: ${data.status} — ${data.error_message || 'no results'}`)
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
const result = data.results[0]
|
| 559 |
+
return {
|
| 560 |
+
lat: result.geometry.location.lat,
|
| 561 |
+
lng: result.geometry.location.lng,
|
| 562 |
+
formattedAddress: result.formatted_address,
|
| 563 |
+
displayName: result.formatted_address,
|
| 564 |
+
}
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
async function googleSearchPlaces(
|
| 568 |
+
location: string,
|
| 569 |
+
query?: string,
|
| 570 |
+
radius?: number,
|
| 571 |
+
language?: string,
|
| 572 |
+
/** Pre-resolved coordinates (skip geocoding) */
|
| 573 |
+
geo?: LocationResult,
|
| 574 |
+
): Promise<PlaceResult[]> {
|
| 575 |
+
const key = getGoogleKey()
|
| 576 |
+
const resolved = geo ?? await googleGeocode(location, language)
|
| 577 |
+
|
| 578 |
+
const params = new URLSearchParams({
|
| 579 |
+
key,
|
| 580 |
+
language: language || 'zh-CN',
|
| 581 |
+
})
|
| 582 |
+
|
| 583 |
+
if (query) {
|
| 584 |
+
// Text search for specific queries
|
| 585 |
+
params.set('query', `${query} in ${location}`)
|
| 586 |
+
const res = await fetch(`https://maps.googleapis.com/maps/api/place/textsearch/json?${params}`, {
|
| 587 |
+
headers: { 'Accept': 'application/json' },
|
| 588 |
+
signal: AbortSignal.timeout(15000),
|
| 589 |
+
})
|
| 590 |
+
|
| 591 |
+
if (!res.ok) throw new Error(`Google places search HTTP ${res.status}`)
|
| 592 |
+
const data = await res.json() as any
|
| 593 |
+
if (data.status !== 'OK') {
|
| 594 |
+
throw new Error(`Google places search failed: ${data.status} — ${data.error_message || ''}`)
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
return parseGooglePlacesResults(data.results, resolved)
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
// Nearby search
|
| 601 |
+
params.set('location', `${resolved.lat},${resolved.lng}`)
|
| 602 |
+
params.set('radius', String(radius || 5000))
|
| 603 |
+
|
| 604 |
+
const res = await fetch(`https://maps.googleapis.com/maps/api/place/nearbysearch/json?${params}`, {
|
| 605 |
+
headers: { 'Accept': 'application/json' },
|
| 606 |
+
signal: AbortSignal.timeout(15000),
|
| 607 |
+
})
|
| 608 |
+
|
| 609 |
+
if (!res.ok) throw new Error(`Google nearby search HTTP ${res.status}`)
|
| 610 |
+
const data = await res.json() as any
|
| 611 |
+
if (data.status !== 'OK') {
|
| 612 |
+
throw new Error(`Google nearby search failed: ${data.status} — ${data.error_message || ''}`)
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
return parseGooglePlacesResults(data.results, resolved)
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
function parseGooglePlacesResults(results: any[], geo: LocationResult): PlaceResult[] {
|
| 619 |
+
return (results || []).slice(0, 20).map((place: any) => ({
|
| 620 |
+
name: place.name,
|
| 621 |
+
address: place.formatted_address || place.vicinity || '',
|
| 622 |
+
lat: place.geometry?.location?.lat || geo.lat,
|
| 623 |
+
lng: place.geometry?.location?.lng || geo.lng,
|
| 624 |
+
rating: place.rating,
|
| 625 |
+
types: place.types,
|
| 626 |
+
phone: place.formatted_phone_number,
|
| 627 |
+
website: place.website,
|
| 628 |
+
openingHours: place.opening_hours?.open_now ? 'Open now' : undefined,
|
| 629 |
+
photos: (place.photos || []).slice(0, 3).map((p: any) =>
|
| 630 |
+
`https://maps.googleapis.com/maps/api/place/photo?maxwidth=800&photoreference=${p.photo_reference}&key=${getGoogleKey()}`
|
| 631 |
+
),
|
| 632 |
+
cost: place.price_level ? '💰'.repeat(place.price_level) : undefined,
|
| 633 |
+
}))
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
async function googleDirections(
|
| 637 |
+
origin: string,
|
| 638 |
+
destination: string,
|
| 639 |
+
mode: string,
|
| 640 |
+
language?: string,
|
| 641 |
+
): Promise<DirectionsResult> {
|
| 642 |
+
const key = getGoogleKey()
|
| 643 |
+
const params = new URLSearchParams({
|
| 644 |
+
origin,
|
| 645 |
+
destination,
|
| 646 |
+
mode,
|
| 647 |
+
key,
|
| 648 |
+
language: language || 'zh-CN',
|
| 649 |
+
alternatives: 'true',
|
| 650 |
+
})
|
| 651 |
+
|
| 652 |
+
const res = await fetch(`https://maps.googleapis.com/maps/api/directions/json?${params}`, {
|
| 653 |
+
headers: { 'Accept': 'application/json' },
|
| 654 |
+
signal: AbortSignal.timeout(15000),
|
| 655 |
+
})
|
| 656 |
+
|
| 657 |
+
if (!res.ok) throw new Error(`Google directions HTTP ${res.status}`)
|
| 658 |
+
const data = await res.json() as any
|
| 659 |
+
|
| 660 |
+
if (data.status !== 'OK' || !data.routes?.length) {
|
| 661 |
+
throw new Error(`Google directions failed: ${data.status} — ${data.error_message || 'no route'}`)
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
const route = data.routes[0]
|
| 665 |
+
const legs: DirectionLeg[] = (route.legs || []).map((leg: any) => ({
|
| 666 |
+
steps: (leg.steps || []).map((s: any) => ({
|
| 667 |
+
instruction: s.html_instructions?.replace(/<[^>]+>/g, '') || '',
|
| 668 |
+
distance: s.distance?.text || '',
|
| 669 |
+
duration: s.duration?.text || '',
|
| 670 |
+
mode: s.travel_mode,
|
| 671 |
+
})),
|
| 672 |
+
distance: leg.distance?.text || '',
|
| 673 |
+
duration: leg.duration?.text || '',
|
| 674 |
+
startAddress: leg.start_address || origin,
|
| 675 |
+
endAddress: leg.end_address || destination,
|
| 676 |
+
}))
|
| 677 |
+
|
| 678 |
+
return {
|
| 679 |
+
origin,
|
| 680 |
+
destination,
|
| 681 |
+
mode,
|
| 682 |
+
legs,
|
| 683 |
+
totalDistance: legs[0]?.distance || '',
|
| 684 |
+
totalDuration: legs[0]?.duration || '',
|
| 685 |
+
polyline: route.overview_polyline?.points,
|
| 686 |
+
}
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
// ── Main call logic ───────────────────────────────────────────────────────────
|
| 690 |
+
|
| 691 |
+
async function handleGeocode(
|
| 692 |
+
location: string,
|
| 693 |
+
region: 'china' | 'international',
|
| 694 |
+
language?: string,
|
| 695 |
+
/** Pre-resolved coordinates (skip geocoding) */
|
| 696 |
+
coords?: LocationResult,
|
| 697 |
+
): Promise<LocationResult> {
|
| 698 |
+
if (coords) return coords
|
| 699 |
+
if (region === 'china') {
|
| 700 |
+
return await amapGeocode(location)
|
| 701 |
+
}
|
| 702 |
+
return await googleGeocode(location, language)
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
async function handleSearchPlaces(
|
| 706 |
+
location: string,
|
| 707 |
+
query: string | undefined,
|
| 708 |
+
type: string | undefined,
|
| 709 |
+
radius: number | undefined,
|
| 710 |
+
region: 'china' | 'international',
|
| 711 |
+
language?: string,
|
| 712 |
+
/** Pre-resolved coordinates (skip geocoding) */
|
| 713 |
+
coords?: LocationResult,
|
| 714 |
+
): Promise<PlaceResult[]> {
|
| 715 |
+
if (region === 'china') {
|
| 716 |
+
return await amapSearchPlaces(location, query, type, radius, coords)
|
| 717 |
+
}
|
| 718 |
+
return await googleSearchPlaces(location, query, radius, language, coords)
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
async function handleDirections(
|
| 722 |
+
origin: string,
|
| 723 |
+
destination: string,
|
| 724 |
+
mode: string,
|
| 725 |
+
region: 'china' | 'international',
|
| 726 |
+
language?: string,
|
| 727 |
+
/** Pre-resolved origin coordinates */
|
| 728 |
+
origCoords?: LocationResult,
|
| 729 |
+
/** Pre-resolved destination coordinates */
|
| 730 |
+
destCoords?: LocationResult,
|
| 731 |
+
): Promise<DirectionsResult> {
|
| 732 |
+
if (region === 'china') {
|
| 733 |
+
return await amapDirections(origin, destination, mode, origCoords, destCoords)
|
| 734 |
+
}
|
| 735 |
+
return await googleDirections(origin, destination, mode, language)
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
async function handlePlanTrip(
|
| 739 |
+
origin: string,
|
| 740 |
+
destination: string,
|
| 741 |
+
waypoints: string[] | undefined,
|
| 742 |
+
mode: string,
|
| 743 |
+
region: 'china' | 'international',
|
| 744 |
+
language?: string,
|
| 745 |
+
/** Pre-resolved origin coordinates */
|
| 746 |
+
origCoords?: LocationResult,
|
| 747 |
+
/** Pre-resolved destination coordinates */
|
| 748 |
+
destCoords?: LocationResult,
|
| 749 |
+
) {
|
| 750 |
+
// Multi-stop: combine waypoints into a single route
|
| 751 |
+
if (waypoints && waypoints.length > 0) {
|
| 752 |
+
// We run directions sequentially for each leg to get complete info
|
| 753 |
+
const legs: DirectionLeg[] = []
|
| 754 |
+
let currentOrigin = origin
|
| 755 |
+
let currentCoords = origCoords
|
| 756 |
+
|
| 757 |
+
for (const wp of waypoints) {
|
| 758 |
+
const leg = await handleDirections(currentOrigin, wp, mode, region, language, currentCoords)
|
| 759 |
+
legs.push(...leg.legs)
|
| 760 |
+
currentOrigin = wp
|
| 761 |
+
currentCoords = undefined // waypoints are strings, not coordinates
|
| 762 |
+
}
|
| 763 |
+
// Final leg
|
| 764 |
+
const finalLeg = await handleDirections(currentOrigin, destination, mode, region, language, undefined, destCoords)
|
| 765 |
+
legs.push(...finalLeg.legs)
|
| 766 |
+
|
| 767 |
+
const totalDist = legs.reduce((sum, l) => sum + parseFloat(l.distance) || 0, 0)
|
| 768 |
+
const totalDur = legs.reduce((sum, l) => sum + parseFloat(l.duration) || 0, 0)
|
| 769 |
+
|
| 770 |
+
return {
|
| 771 |
+
origin,
|
| 772 |
+
destination,
|
| 773 |
+
mode,
|
| 774 |
+
legs,
|
| 775 |
+
totalDistance: `${totalDist.toFixed(1)} km`,
|
| 776 |
+
totalDuration: `${Math.round(totalDur)} min`,
|
| 777 |
+
waypoints: [origin, ...waypoints, destination],
|
| 778 |
+
}
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
// Simple point-to-point
|
| 782 |
+
return await handleDirections(origin, destination, mode, region, language)
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
// ── Tool export ───────────────────────────────────────────────────────────────
|
| 786 |
+
|
| 787 |
+
type LocationToolProgress = {
|
| 788 |
+
type: 'query_update' | 'results_received'
|
| 789 |
+
action?: string
|
| 790 |
+
location?: string
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
export const LocationTool = buildTool({
|
| 794 |
+
name: LOCATION_TOOL_NAME,
|
| 795 |
+
searchHint: 'geographic search, POI, maps, directions, travel planning',
|
| 796 |
+
shouldDefer: true,
|
| 797 |
+
|
| 798 |
+
getToolUseSummary,
|
| 799 |
+
getActivityDescription(input) {
|
| 800 |
+
const loc = input?.location || ''
|
| 801 |
+
const action = input?.action || 'query'
|
| 802 |
+
return `${action} for "${loc}"`
|
| 803 |
+
},
|
| 804 |
+
|
| 805 |
+
isEnabled() {
|
| 806 |
+
return true
|
| 807 |
+
},
|
| 808 |
+
|
| 809 |
+
get inputSchema() {
|
| 810 |
+
return inputSchema()
|
| 811 |
+
},
|
| 812 |
+
|
| 813 |
+
isConcurrencySafe() {
|
| 814 |
+
return true
|
| 815 |
+
},
|
| 816 |
+
|
| 817 |
+
isReadOnly() {
|
| 818 |
+
return true
|
| 819 |
+
},
|
| 820 |
+
|
| 821 |
+
toAutoClassifierInput(input) {
|
| 822 |
+
return input?.action
|
| 823 |
+
? `${input.action}: ${input?.location || '(IP auto-detect)'}`
|
| 824 |
+
: ''
|
| 825 |
+
},
|
| 826 |
+
|
| 827 |
+
async checkPermissions() {
|
| 828 |
+
return { behavior: 'allow' }
|
| 829 |
+
},
|
| 830 |
+
|
| 831 |
+
async prompt() {
|
| 832 |
+
return PROMPT
|
| 833 |
+
},
|
| 834 |
+
|
| 835 |
+
renderToolUseMessage,
|
| 836 |
+
renderToolUseProgressMessage,
|
| 837 |
+
renderToolResultMessage,
|
| 838 |
+
|
| 839 |
+
extractSearchText(output) {
|
| 840 |
+
if (!output) return ''
|
| 841 |
+
const parts: string[] = []
|
| 842 |
+
if (output.geocoding) parts.push(`geocode: ${output.geocoding.formattedAddress}`)
|
| 843 |
+
if (output.places?.length) parts.push(`places: ${output.places.length} results`)
|
| 844 |
+
if (output.directions) parts.push(`route: ${output.directions.origin} → ${output.directions.destination}`)
|
| 845 |
+
return parts.join('; ')
|
| 846 |
+
},
|
| 847 |
+
|
| 848 |
+
async validateInput(input) {
|
| 849 |
+
if (input?.action !== 'locate' && (!input?.location || input?.location.length === 0)) {
|
| 850 |
+
return { result: false, message: 'Missing location parameter', errorCode: 1 }
|
| 851 |
+
}
|
| 852 |
+
if (input.action === 'get_directions' || input.action === 'plan_trip') {
|
| 853 |
+
if (!input.destination) {
|
| 854 |
+
return { result: false, message: 'Missing destination parameter for directions/trip', errorCode: 1 }
|
| 855 |
+
}
|
| 856 |
+
}
|
| 857 |
+
if (input.action === 'search_places' && !input.query && !input.type) {
|
| 858 |
+
return { result: false, message: 'Missing query or type parameter for place search', errorCode: 1 }
|
| 859 |
+
}
|
| 860 |
+
return { result: true }
|
| 861 |
+
},
|
| 862 |
+
|
| 863 |
+
async call(input, _context, _canUseTool, _parentMessage, onProgress) {
|
| 864 |
+
const { action, location, destination, query, radius, mode, waypoints, type, region: forcedRegion, language } = input
|
| 865 |
+
|
| 866 |
+
// Detect coordinate input (e.g. "34.2583,108.9286")
|
| 867 |
+
const originCoords = typeof location === 'string' ? parseCoords(location) : undefined
|
| 868 |
+
const destCoords = destination ? parseCoords(destination) : undefined
|
| 869 |
+
|
| 870 |
+
// Region: coordinates override text-based detection
|
| 871 |
+
let region = forcedRegion
|
| 872 |
+
if (!region && originCoords) {
|
| 873 |
+
region = isChinaCoords(originCoords.lat, originCoords.lng) ? 'china' : 'international'
|
| 874 |
+
}
|
| 875 |
+
if (!region && location) {
|
| 876 |
+
region = isChinaMainland(location) ? 'china' : 'international'
|
| 877 |
+
}
|
| 878 |
+
if (!region) {
|
| 879 |
+
region = 'international'
|
| 880 |
+
}
|
| 881 |
+
|
| 882 |
+
// Build pre-resolved origin LocationResult from coordinates
|
| 883 |
+
const originLocationResult = originCoords ? coordsToLocation(originCoords.lat, originCoords.lng) : undefined
|
| 884 |
+
const destLocationResult = destCoords ? coordsToLocation(destCoords.lat, destCoords.lng) : undefined
|
| 885 |
+
|
| 886 |
+
if (onProgress) {
|
| 887 |
+
onProgress({
|
| 888 |
+
toolUseID: 'location-start',
|
| 889 |
+
data: { type: 'query_update', action, location: location || '(locate)' },
|
| 890 |
+
})
|
| 891 |
+
}
|
| 892 |
+
|
| 893 |
+
try {
|
| 894 |
+
let ipGeo: IpGeoInfo | undefined
|
| 895 |
+
let geocoding: LocationResult | undefined
|
| 896 |
+
let places: PlaceResult[] | undefined
|
| 897 |
+
let directions: DirectionsResult | undefined
|
| 898 |
+
|
| 899 |
+
switch (action) {
|
| 900 |
+
case 'geocode':
|
| 901 |
+
geocoding = await handleGeocode(location, region, language, originLocationResult)
|
| 902 |
+
break
|
| 903 |
+
|
| 904 |
+
case 'search_places':
|
| 905 |
+
places = await handleSearchPlaces(location, query, type, radius ?? 5000, region, language, originLocationResult)
|
| 906 |
+
break
|
| 907 |
+
|
| 908 |
+
case 'get_directions':
|
| 909 |
+
if (!destination) throw new Error('Destination is required for directions')
|
| 910 |
+
directions = await handleDirections(location, destination, mode ?? 'driving', region, language, originLocationResult, destLocationResult)
|
| 911 |
+
break
|
| 912 |
+
|
| 913 |
+
case 'plan_trip':
|
| 914 |
+
if (!destination) throw new Error('Destination is required for trip planning')
|
| 915 |
+
const planResult = await handlePlanTrip(location, destination, waypoints, mode ?? 'driving', region, language, originLocationResult, destLocationResult)
|
| 916 |
+
directions = planResult as DirectionsResult
|
| 917 |
+
break
|
| 918 |
+
|
| 919 |
+
case 'locate': {
|
| 920 |
+
// 1. 首先尝试国内 Amap Wi‑Fi 定位(若在中国大陆)
|
| 921 |
+
const wifiAPs = await scanWiFi()
|
| 922 |
+
const amapLoc = await amapWiFiGeolocateAPI(wifiAPs)
|
| 923 |
+
if (amapLoc) {
|
| 924 |
+
region = isChinaCoords(amapLoc.lat, amapLoc.lng) ? 'china' : 'international'
|
| 925 |
+
geocoding = coordsToLocation(amapLoc.lat, amapLoc.lng)
|
| 926 |
+
ipGeo = {
|
| 927 |
+
ip: 'amap-wifi',
|
| 928 |
+
city: `${amapLoc.lat.toFixed(4)},${amapLoc.lng.toFixed(4)}`,
|
| 929 |
+
region: `accuracy: ${amapLoc.accuracy}m`,
|
| 930 |
+
country: region === 'china' ? 'CN' : 'unknown',
|
| 931 |
+
lat: amapLoc.lat,
|
| 932 |
+
lng: amapLoc.lng,
|
| 933 |
+
}
|
| 934 |
+
break
|
| 935 |
+
}
|
| 936 |
+
|
| 937 |
+
// 2. 若 Amap 未返回或不在中国大陆,尝试国外 Google Geolocation API
|
| 938 |
+
const googleLoc = await googleGeolocateAPI(wifiAPs)
|
| 939 |
+
if (googleLoc) {
|
| 940 |
+
region = 'international'
|
| 941 |
+
geocoding = coordsToLocation(googleLoc.lat, googleLoc.lng)
|
| 942 |
+
ipGeo = {
|
| 943 |
+
ip: 'google-geolocation',
|
| 944 |
+
city: `${googleLoc.lat.toFixed(4)},${googleLoc.lng.toFixed(4)}`,
|
| 945 |
+
region: `accuracy: ${googleLoc.accuracy}m`,
|
| 946 |
+
country: 'unknown',
|
| 947 |
+
lat: googleLoc.lat,
|
| 948 |
+
lng: googleLoc.lng,
|
| 949 |
+
}
|
| 950 |
+
break
|
| 951 |
+
}
|
| 952 |
+
|
| 953 |
+
// 3. 最后回退到 IP 定位(城市级)
|
| 954 |
+
ipGeo = await ipGeolocate()
|
| 955 |
+
const detectRegion = ipGeo.country === 'CN' ? 'china' : 'international'
|
| 956 |
+
const detectCoords = ipGeo.lat && ipGeo.lng
|
| 957 |
+
? coordsToLocation(ipGeo.lat, ipGeo.lng)
|
| 958 |
+
: undefined
|
| 959 |
+
if (detectCoords) {
|
| 960 |
+
geocoding = detectCoords
|
| 961 |
+
} else {
|
| 962 |
+
geocoding = await handleGeocode(ipGeo.city, detectRegion, language)
|
| 963 |
+
}
|
| 964 |
+
region = detectRegion
|
| 965 |
+
break
|
| 966 |
+
}
|
| 967 |
+
}
|
| 968 |
+
|
| 969 |
+
if (onProgress) {
|
| 970 |
+
onProgress({
|
| 971 |
+
toolUseID: 'location-results',
|
| 972 |
+
data: {
|
| 973 |
+
type: 'results_received',
|
| 974 |
+
action,
|
| 975 |
+
location: action === 'locate' ? ipGeo?.city : location,
|
| 976 |
+
...(places && { count: places.length }),
|
| 977 |
+
} as any,
|
| 978 |
+
})
|
| 979 |
+
}
|
| 980 |
+
|
| 981 |
+
return {
|
| 982 |
+
data: {
|
| 983 |
+
action,
|
| 984 |
+
region,
|
| 985 |
+
geocoding,
|
| 986 |
+
places,
|
| 987 |
+
directions,
|
| 988 |
+
ipGeo,
|
| 989 |
+
} satisfies Output,
|
| 990 |
+
}
|
| 991 |
+
} catch (error) {
|
| 992 |
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
| 993 |
+
logError('LocationTool error', error)
|
| 994 |
+
|
| 995 |
+
return {
|
| 996 |
+
data: {
|
| 997 |
+
action,
|
| 998 |
+
region,
|
| 999 |
+
error: errorMessage,
|
| 1000 |
+
ipGeo,
|
| 1001 |
+
} satisfies Output,
|
| 1002 |
+
}
|
| 1003 |
+
}
|
| 1004 |
+
},
|
| 1005 |
+
|
| 1006 |
+
mapToolResultToToolResultBlockParam(output: Output, toolUseID: string) {
|
| 1007 |
+
const lines: string[] = []
|
| 1008 |
+
lines.push(`📍 Location Result [${output.action}]`)
|
| 1009 |
+
lines.push(`Region: ${output.region === 'china' ? '中国大陆' : '海外'}`)
|
| 1010 |
+
if (output.ipGeo) {
|
| 1011 |
+
const g = output.ipGeo
|
| 1012 |
+
lines.push(`IP: ${g.ip} — ${g.city}, ${g.region} (${g.country})`)
|
| 1013 |
+
if (g.lat && g.lng) lines.push(`Coords: ${g.lat}, ${g.lng}`)
|
| 1014 |
+
}
|
| 1015 |
+
lines.push('')
|
| 1016 |
+
|
| 1017 |
+
if (output.error) {
|
| 1018 |
+
lines.push(`Error: ${output.error}`)
|
| 1019 |
+
}
|
| 1020 |
+
|
| 1021 |
+
if (output.geocoding) {
|
| 1022 |
+
const g = output.geocoding
|
| 1023 |
+
lines.push(`┌─ 地理编码结果`)
|
| 1024 |
+
lines.push(`│ 地址: ${g.formattedAddress}`)
|
| 1025 |
+
lines.push(`│ 坐标: ${g.lat}, ${g.lng}`)
|
| 1026 |
+
lines.push(`│ 地图: https://${output.region === 'china' ? 'uri.amap.com/marker?position=' : 'www.google.com/maps?q='}${g.lat},${g.lng}`)
|
| 1027 |
+
lines.push(`└────\n`)
|
| 1028 |
+
}
|
| 1029 |
+
|
| 1030 |
+
if (output.places && output.places.length > 0) {
|
| 1031 |
+
lines.push(`┌─ 搜索结果 (${output.places.length} 条)`)
|
| 1032 |
+
output.places.forEach((p, i) => {
|
| 1033 |
+
lines.push(`│`)
|
| 1034 |
+
lines.push(`│ [${i + 1}] ${p.name}`)
|
| 1035 |
+
if (p.address) lines.push(`│ 地址: ${p.address}`)
|
| 1036 |
+
if (p.distance) lines.push(`│ 距离: ${p.distance}米`)
|
| 1037 |
+
if (p.tag) lines.push(`│ 类别: ${p.tag}`)
|
| 1038 |
+
if (p.rating) lines.push(`│ 评分: ${'⭐'.repeat(Math.round(p.rating))} (${p.rating})`)
|
| 1039 |
+
if (p.cost) lines.push(`│ 消费: ${p.cost}`)
|
| 1040 |
+
if (p.openingHours) lines.push(`│ 营业: ${p.openingHours}`)
|
| 1041 |
+
if (p.phone) lines.push(`│ 电话: ${p.phone}`)
|
| 1042 |
+
if (p.photos?.length) {
|
| 1043 |
+
lines.push(`│ 照片: ${p.photos.map((ph, idx) => `[图${idx + 1}](${ph})`).join(' ')}`)
|
| 1044 |
+
}
|
| 1045 |
+
})
|
| 1046 |
+
lines.push(`└────\n`)
|
| 1047 |
+
}
|
| 1048 |
+
|
| 1049 |
+
if (output.directions) {
|
| 1050 |
+
const d = output.directions
|
| 1051 |
+
lines.push(`┌─ 路线规划`)
|
| 1052 |
+
lines.push(`│ 起点: ${d.origin}`)
|
| 1053 |
+
lines.push(`│ 终点: ${d.destination}`)
|
| 1054 |
+
lines.push(`│ 交通方式: ${d.mode}`)
|
| 1055 |
+
lines.push(`│ 总距离: ${d.totalDistance}`)
|
| 1056 |
+
lines.push(`│ 预计耗时: ${d.totalDuration}`)
|
| 1057 |
+
if (d.transitInfo) lines.push(`│ 公共交通: ${d.transitInfo}`)
|
| 1058 |
+
if (d.tolls) lines.push(`│ 通行费: ${d.tolls}`)
|
| 1059 |
+
|
| 1060 |
+
if (d.legs.length > 0) {
|
| 1061 |
+
lines.push(`│`)
|
| 1062 |
+
d.legs.forEach((leg, li) => {
|
| 1063 |
+
if (d.legs.length > 1) lines.push(`│ 路段 ${li + 1}: ${leg.startAddress} → ${leg.endAddress}`)
|
| 1064 |
+
lines.push(`│ 距离: ${leg.distance} | 时间: ${leg.duration}`)
|
| 1065 |
+
leg.steps.slice(0, 10).forEach(s => {
|
| 1066 |
+
const instr = s.instruction.length > 80 ? s.instruction.slice(0, 80) + '…' : s.instruction
|
| 1067 |
+
lines.push(`│ → ${instr} (${s.distance}, ${s.duration})`)
|
| 1068 |
+
})
|
| 1069 |
+
if (leg.steps.length > 10) {
|
| 1070 |
+
lines.push(`│ … 还有 ${leg.steps.length - 10} 个步骤`)
|
| 1071 |
+
}
|
| 1072 |
+
})
|
| 1073 |
+
}
|
| 1074 |
+
lines.push(`└────`)
|
| 1075 |
+
}
|
| 1076 |
+
|
| 1077 |
+
return {
|
| 1078 |
+
tool_use_id: toolUseID,
|
| 1079 |
+
type: 'tool_result',
|
| 1080 |
+
content: lines.join('\n'),
|
| 1081 |
+
}
|
| 1082 |
+
},
|
| 1083 |
+
}) satisfies ToolDef<ReturnType<typeof inputSchema>, Output, LocationToolProgress>
|
src/tools/LocationTool/UI.tsx
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react'
|
| 2 |
+
import { MessageResponse } from '../../components/MessageResponse.js'
|
| 3 |
+
import { TOOL_SUMMARY_MAX_LENGTH } from '../../constants/toolLimits.js'
|
| 4 |
+
import { Box, Text } from '../../ink.js'
|
| 5 |
+
import { truncate } from '../../utils/format.js'
|
| 6 |
+
|
| 7 |
+
export function renderToolUseMessage(
|
| 8 |
+
input: Partial<{
|
| 9 |
+
action: string
|
| 10 |
+
location: string
|
| 11 |
+
destination: string
|
| 12 |
+
query: string
|
| 13 |
+
}>,
|
| 14 |
+
{ verbose }: { verbose: boolean },
|
| 15 |
+
): React.ReactNode {
|
| 16 |
+
const { action, location, destination, query } = input
|
| 17 |
+
if (!action || !location) {
|
| 18 |
+
return null
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const actionLabel: Record<string, string> = {
|
| 22 |
+
geocode: '地理编码',
|
| 23 |
+
search_places: '搜索地点',
|
| 24 |
+
get_directions: '路线规划',
|
| 25 |
+
plan_trip: '行程规划',
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
let msg = `${actionLabel[action] || action}: ${location}`
|
| 29 |
+
if (destination) msg += ` → ${destination}`
|
| 30 |
+
if (query && verbose) msg += ` (${query})`
|
| 31 |
+
|
| 32 |
+
return msg
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function renderToolUseProgressMessage(): React.ReactNode {
|
| 36 |
+
return null
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export function renderToolResultMessage(output: {
|
| 40 |
+
action: string
|
| 41 |
+
region?: string
|
| 42 |
+
geocoding?: { formattedAddress: string; lat: number; lng: number }
|
| 43 |
+
places?: unknown[]
|
| 44 |
+
directions?: { origin: string; destination: string; totalDistance: string; totalDuration: string }
|
| 45 |
+
error?: string
|
| 46 |
+
}): React.ReactNode {
|
| 47 |
+
if (!output) return null
|
| 48 |
+
|
| 49 |
+
if (output.error) {
|
| 50 |
+
return (
|
| 51 |
+
<MessageResponse>
|
| 52 |
+
<Text color="red">Location error: {output.error}</Text>
|
| 53 |
+
</MessageResponse>
|
| 54 |
+
)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const parts: string[] = []
|
| 58 |
+
const regionLabel = output.region === 'china' ? '中国大陆' : '海外'
|
| 59 |
+
|
| 60 |
+
if (output.geocoding) {
|
| 61 |
+
const g = output.geocoding
|
| 62 |
+
parts.push(`坐标: ${g.lat}, ${g.lng}`)
|
| 63 |
+
}
|
| 64 |
+
if (output.places?.length) {
|
| 65 |
+
parts.push(`找到 ${output.places.length} 个地点`)
|
| 66 |
+
}
|
| 67 |
+
if (output.directions) {
|
| 68 |
+
const d = output.directions
|
| 69 |
+
parts.push(`${d.origin} → ${d.destination}: ${d.totalDistance}, ${d.totalDuration}`)
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
<MessageResponse>
|
| 74 |
+
<Text>
|
| 75 |
+
[{regionLabel}] {parts.join(' | ')}
|
| 76 |
+
</Text>
|
| 77 |
+
</MessageResponse>
|
| 78 |
+
)
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
export function getToolUseSummary(
|
| 82 |
+
input: Partial<{
|
| 83 |
+
action: string
|
| 84 |
+
location: string
|
| 85 |
+
destination: string
|
| 86 |
+
query: string
|
| 87 |
+
}> | undefined,
|
| 88 |
+
): string | null {
|
| 89 |
+
if (!input?.action || !input?.location) return null
|
| 90 |
+
let summary = `${input.action}: ${input.location}`
|
| 91 |
+
if (input.destination) summary += ` → ${input.destination}`
|
| 92 |
+
return truncate(summary, TOOL_SUMMARY_MAX_LENGTH)
|
| 93 |
+
}
|
src/tools/LocationTool/prompt.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const LOCATION_TOOL_NAME = 'LocationTool'
|
| 2 |
+
|
| 3 |
+
export const PROMPT = `
|
| 4 |
+
LocationTool — Search geographic information, points of interest, and plan routes/travel.
|
| 5 |
+
|
| 6 |
+
This tool provides location-based services using two map providers:
|
| 7 |
+
- **高德地图 (Amap)**: Used for China mainland locations (cities, POIs, routes within China)
|
| 8 |
+
- **Google Maps**: Used for non-China mainland locations (international cities, cross-country routes)
|
| 9 |
+
|
| 10 |
+
**Capabilities:**
|
| 11 |
+
1. **Locate** — Detect the user's current location using Wi‑Fi fingerprinting (Amap for China, Google for abroad). If Wi‑Fi data unavailable, falls back to IP geolocation. Use \`action: "locate"\` without \`location\`.
|
| 12 |
+
2. **Geocoding** — Convert a place name to coordinates (latitude/longitude)
|
| 13 |
+
3. **Search Places** — Find nearby or in-city points of interest (restaurants, attractions, hotels, shopping, transportation, etc.)
|
| 14 |
+
4. **Get Directions** — Get routes between two locations with multiple transport modes (driving, walking, transit, bicycling)
|
| 15 |
+
5. **Plan Trip** — Multi-stop travel planning between cities/countries
|
| 16 |
+
|
| 17 |
+
**When to use this tool:**
|
| 18 |
+
- User asks "where am I?" → use the \`locate\` action
|
| 19 |
+
- User asks about places, POIs, or things to do in a specific city/area
|
| 20 |
+
- User needs directions or route information between locations
|
| 21 |
+
- User wants to plan travel between cities or countries
|
| 22 |
+
- User asks about geographic coordinates of a place
|
| 23 |
+
|
| 24 |
+
**Combining with other tools:**
|
| 25 |
+
- After getting place/POI results from LocationTool, you can use **WebSearchTool** to search for travel guides, reviews, or additional information about specific places
|
| 26 |
+
- Use **WebFetchTool** to fetch detailed information from URLs found in search results (e.g., attraction pages, hotel booking sites, travel blog posts)
|
| 27 |
+
- Example: Search places → WebSearch for reviews → WebFetch for detailed info
|
| 28 |
+
|
| 29 |
+
**Transport modes for directions:**
|
| 30 |
+
- For China (Amap): "driving", "walking", "transit" (公交/地铁)
|
| 31 |
+
- For international (Google): "driving", "walking", "transit", "bicycling"
|
| 32 |
+
|
| 33 |
+
**API Keys:**
|
| 34 |
+
- \`AMAP_API_KEY\` env var — required for China mainland queries
|
| 35 |
+
- \`GOOGLE_MAPS_API_KEY\` env var — required for non-China queries
|
| 36 |
+
|
| 37 |
+
**Coordinate input:**
|
| 38 |
+
- You can pass \`location\` as "lat,lng" coordinates (e.g. \`"34.2583,108.9286"\`) to search/directions directly by coordinates, bypassing geocoding
|
| 39 |
+
- Region is auto-detected from coordinates using China's bounding box
|
| 40 |
+
|
| 41 |
+
**Region auto-detection:**
|
| 42 |
+
- Locations containing Chinese characters or recognized Chinese city names → uses Amap
|
| 43 |
+
- Other locations → uses Google Maps
|
| 44 |
+
- You can also explicitly specify the \`region\` parameter
|
| 45 |
+
`
|