chenbhao Claude Big Pickle commited on
Commit
5b13359
·
1 Parent(s): 9685a3e

feat: add LocationTool — geographic search with Amap/Google Maps

Browse files

Add 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 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
+ `