| import { z } from 'zod/v4' |
| import { buildTool, type ToolDef } from '../../Tool.js' |
| import { lazySchema } from '../../utils/lazySchema.js' |
| import { logError } from '../../utils/log.js' |
| import { LOCATION_TOOL_NAME, PROMPT } from './prompt.js' |
| import { |
| getToolUseSummary, |
| renderToolResultMessage, |
| renderToolUseMessage, |
| renderToolUseProgressMessage, |
| } from './UI.js' |
| import { fetchImagesAsInline } from '../../utils/inlineImageUtils.js' |
|
|
| |
|
|
| |
| const CHINA_CITIES = new Set([ |
| '北京', '上海', '广州', '深圳', '成都', '杭州', '武汉', '西安', '重庆', |
| '南京', '天津', '苏州', '长沙', '郑州', '东莞', '青岛', '沈阳', '宁波', |
| '昆明', '大连', '厦门', '合肥', '佛山', '福州', '哈尔滨', '济南', '温州', |
| '长春', '石家庄', '常州', '泉州', '南宁', '贵阳', '南昌', '太原', '烟台', |
| '嘉兴', '南通', '金华', '珠海', '惠州', '徐州', '海口', '乌鲁木齐', '绍兴', |
| '中山', '台州', '兰州', 'beijing', 'shanghai', 'guangzhou', 'shenzhen', |
| 'chengdu', 'hangzhou', 'wuhan', 'xi\'an', 'chongqing', 'nanjing', |
| 'tianjin', 'suzhou', 'changsha', 'zhengzhou', 'qingdao', 'shenyang', |
| 'kunming', 'dalian', 'xiamen', 'hefei', 'foshan', 'fuzhou', 'harbin', |
| 'jinan', 'changchun', 'shijiazhuang', 'nanning', 'guiyang', 'nanchang', |
| 'taiyuan', 'haikou', 'urumqi', 'lanzhou', 'macau', 'macao', 'hong kong', |
| 'xianggang', |
| ]) |
|
|
| const NON_CHINA_COUNTRIES = new Set([ |
| 'japan', 'korea', 'south korea', 'usa', 'united states', 'uk', 'united kingdom', |
| 'france', 'germany', 'italy', 'spain', 'australia', 'canada', 'brazil', |
| 'india', 'thailand', 'vietnam', 'singapore', 'malaysia', 'indonesia', |
| 'philippines', 'russia', 'mexico', 'turkey', 'egypt', 'switzerland', |
| 'sweden', 'norway', 'finland', 'denmark', 'netherlands', 'belgium', |
| 'portugal', 'greece', 'ireland', 'new zealand', 'argentina', 'chile', |
| 'peru', 'colombia', 'south africa', 'nigeria', 'kenya', 'morocco', |
| '美国', '日本', '韩国', '英国', '法国', '德国', '意大利', '西班牙', |
| '澳大利亚', '加拿大', '巴西', '印度', '泰国', '越南', '新加坡', |
| '马来西亚', '俄罗斯', '土耳其', '埃及', '瑞士', '荷兰', '新西兰', |
| ]) |
|
|
| function isChinaMainland(location: string): boolean { |
| |
| const normalized = location.replace(/[\u2018\u2019]/g, "'") |
|
|
| |
| if (/[\u4e00-\u9fff]/.test(normalized)) { |
| |
| const lower = normalized.toLowerCase() |
| for (const country of NON_CHINA_COUNTRIES) { |
| if (lower.includes(country)) return false |
| } |
| return true |
| } |
|
|
| const lower = normalized.toLowerCase().trim() |
|
|
| |
| for (const city of CHINA_CITIES) { |
| if (lower.startsWith(city) || lower.includes(` ${city}`) || lower.includes(`, ${city}`)) { |
| return true |
| } |
| } |
|
|
| |
| if (NON_CHINA_COUNTRIES.has(lower)) return false |
|
|
| |
| return false |
| } |
|
|
| |
| function parseCoords(input: string): { lat: number; lng: number } | null { |
| const trimmed = input.trim() |
| const match = trimmed.match(/^(-?\d+\.?\d*)\s*[,,]\s*(-?\d+\.?\d*)$/) |
| if (!match) return null |
| const lat = parseFloat(match[1]) |
| const lng = parseFloat(match[2]) |
| if (isNaN(lat) || isNaN(lng)) return null |
| if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null |
| return { lat, lng } |
| } |
|
|
| |
| function isChinaCoords(lat: number, lng: number): boolean { |
| return lat >= 18 && lat <= 54 && lng >= 73 && lng <= 135 |
| } |
|
|
| |
| function coordsToLocation(lat: number, lng: number): LocationResult { |
| return { |
| lat, |
| lng, |
| formattedAddress: `${lat.toFixed(6)}, ${lng.toFixed(6)}`, |
| displayName: `${lat.toFixed(6)}, ${lng.toFixed(6)}`, |
| } |
| } |
|
|
| |
| type IpGeoInfo = { |
| ip: string |
| city: string |
| region: string |
| country: string |
| lat?: number |
| lng?: number |
| } |
|
|
| |
| async function scanWiFi(): Promise<{ macAddress: string; signalStrength: number }[]> { |
| try { |
| const cmd = new Deno.Command('nmcli', { |
| args: ['-t', '-f', 'BSSID,SIGNAL', 'device', 'wifi', 'list'], |
| stdout: 'piped', |
| stderr: 'null', |
| }) |
| const { stdout } = await cmd.output() |
| const text = new TextDecoder().decode(stdout) |
| return text.trim().split('\n') |
| .filter(Boolean) |
| .map(line => { |
| |
| const fields = line.split(/(?<!\\):/) |
| if (fields.length < 2) return null |
| const bssid = fields[0].replace(/\\([\da-fA-F]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))) |
| const signal = parseInt(fields[1], 10) |
| if (isNaN(signal)) return null |
| return { macAddress: bssid, signalStrength: signal } |
| }) |
| .filter((a): a is NonNullable<typeof a> => a !== null) |
| } catch { |
| return [] |
| } |
| } |
|
|
| |
| |
| async function amapWiFiGeolocateAPI(wifiAPs: { macAddress: string; signalStrength: number }[]): Promise<{ lat: number; lng: number; accuracy: number } | null> { |
| const key = process.env.AMAP_API_KEY |
| if (!key) return null |
| try { |
| if (wifiAPs.length < 2) return null |
|
|
| |
| |
| const macs = wifiAPs.slice(0, 30).map(ap => |
| `${ap.macAddress},${-ap.signalStrength},` |
| ).join('|') |
|
|
| const params = new URLSearchParams({ |
| key, |
| accesstype: '1', |
| cdma: '0', |
| macs, |
| output: 'json', |
| }) |
|
|
| const res = await fetch(`https://apilocate.amap.com/position?${params}`, { |
| method: 'GET', |
| signal: AbortSignal.timeout(10000), |
| }) |
| if (!res.ok) return null |
| const data = await res.json() as any |
| |
| if (data.status === '1' && data.result?.location) { |
| const [lng, lat] = data.result.location.split(',').map(Number) |
| if (!isNaN(lat) && !isNaN(lng)) { |
| return { |
| lat, |
| lng, |
| accuracy: data.result.radius || 0, |
| } |
| } |
| } |
| return null |
| } catch { |
| return null |
| } |
| } |
|
|
| |
| async function googleGeolocateAPI(wifiAPs: { macAddress: string; signalStrength: number }[]): Promise<{ lat: number; lng: number; accuracy: number } | null> { |
| const key = process.env.GOOGLE_MAPS_API_KEY |
| if (!key) return null |
| try { |
| const body: any = {} |
| if (wifiAPs.length > 0) { |
| body.wifiAccessPoints = wifiAPs.slice(0, 20) |
| body.considerIp = false |
| } else { |
| body.considerIp = true |
| } |
| const res = await fetch('https://www.googleapis.com/geolocation/v1/geolocate?key=' + key, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(body), |
| signal: AbortSignal.timeout(10000), |
| }) |
| if (!res.ok) return null |
| const data = await res.json() as any |
| if (data.location?.lat && data.location?.lng) { |
| return { |
| lat: data.location.lat, |
| lng: data.location.lng, |
| accuracy: data.accuracy || 0, |
| } |
| } |
| return null |
| } catch { |
| return null |
| } |
| } |
|
|
| |
| |
| |
| |
| async function ipGeolocate(): Promise<IpGeoInfo> { |
| const services = [ |
| 'https://ipinfo.io/json', |
| 'https://ipapi.co/json/', |
| 'http://ip-api.com/json/', |
| ] |
| for (const url of services) { |
| try { |
| const res = await fetch(url, { |
| headers: { 'Accept': 'application/json' }, |
| signal: AbortSignal.timeout(5000), |
| }) |
| if (!res.ok) continue |
| const data = await res.json() as any |
|
|
| |
| if (data.city) { |
| const loc = data.loc?.split(',').map(Number) as [number, number] | undefined |
| return { |
| ip: data.ip || 'unknown', |
| city: data.city, |
| region: data.region || '', |
| country: data.country || '', |
| lat: loc?.[0], |
| lng: loc?.[1], |
| } |
| } |
| |
| if (data.city) { |
| return { |
| ip: data.ip || 'unknown', |
| city: data.city, |
| region: data.region || '', |
| country: data.country_name || data.country || '', |
| lat: data.latitude, |
| lng: data.longitude, |
| } |
| } |
| |
| if (data.city) { |
| return { |
| ip: data.query || 'unknown', |
| city: data.city, |
| region: data.regionName || data.region || '', |
| country: data.country || '', |
| lat: data.lat, |
| lng: data.lon, |
| } |
| } |
| } catch { |
| continue |
| } |
| } |
| throw new Error('Could not determine location via IP (all geolocation services failed)') |
| } |
|
|
| |
|
|
| const inputSchema = lazySchema(() => |
| z.strictObject({ |
| action: z.enum(['geocode', 'search_places', 'get_directions', 'plan_trip', 'locate']) |
| .describe('The action to perform'), |
| location: z.any().optional().default('').describe('The location name, address, or origin for directions (optional for locate; defaults to empty string)'), |
| destination: z.string().optional() |
| .describe('Destination location (required for get_directions and plan_trip)'), |
| query: z.string().optional() |
| .describe('Search query for places (e.g., restaurants, attractions, hotels). Required for search_places'), |
| radius: z.number().min(1).max(50000).optional().default(5000) |
| .describe('Search radius in meters (default: 5000, max: 50000)'), |
| mode: z.enum(['driving', 'walking', 'transit', 'bicycling']).optional().default('driving') |
| .describe('Transport mode for directions (default: driving)'), |
| waypoints: z.array(z.string()).max(10).optional() |
| .describe('Intermediate stops for multi-city trip planning (max 10)'), |
| type: z.string().optional() |
| .describe('POI type filter for Amap (e.g., 餐饮, 酒店, 景点, 购物). Only applies to China locations'), |
| region: z.enum(['china', 'international']).optional() |
| .describe('Force region. Auto-detected if not specified'), |
| language: z.string().optional().default('zh-CN') |
| .describe('Response language (default: zh-CN)'), |
| }), |
| ) |
|
|
| type Input = z.infer<ReturnType<typeof inputSchema>> |
|
|
| type LocationResult = { |
| lat: number |
| lng: number |
| formattedAddress: string |
| displayName: string |
| } |
|
|
| type PlaceResult = { |
| name: string |
| address: string |
| lat: number |
| lng: number |
| rating?: number |
| types?: string[] |
| phone?: string |
| website?: string |
| openingHours?: string |
| photos?: string[] |
| |
| distance?: string |
| businessArea?: string |
| cost?: string |
| recommendation?: string |
| tag?: string |
| } |
|
|
| type DirectionStep = { |
| instruction: string |
| distance: string |
| duration: string |
| mode?: string |
| polyline?: string |
| } |
|
|
| type DirectionLeg = { |
| steps: DirectionStep[] |
| distance: string |
| duration: string |
| startAddress: string |
| endAddress: string |
| } |
|
|
| type DirectionsResult = { |
| origin: string |
| destination: string |
| mode: string |
| legs: DirectionLeg[] |
| totalDistance: string |
| totalDuration: string |
| polyline?: string |
| transitInfo?: string |
| tolls?: string |
| trafficRestriction?: string |
| } |
|
|
| type Output = { |
| action: string |
| region: 'china' | 'international' |
| geocoding?: LocationResult |
| places?: PlaceResult[] |
| directions?: DirectionsResult |
| error?: string |
| rawData?: unknown |
| ipGeo?: IpGeoInfo |
| images?: Array<{ url: string; base64: string; mediaType: string }> |
| } |
|
|
| |
|
|
| function getAmapKey(): string { |
| const key = process.env.AMAP_API_KEY |
| if (!key) throw new Error('AMAP_API_KEY environment variable is not set') |
| return key |
| } |
|
|
| async function amapGeocode(location: string, city?: string): Promise<LocationResult> { |
| const key = getAmapKey() |
| const params = new URLSearchParams({ key, address: location }) |
| if (city) params.set('city', city) |
|
|
| const res = await fetch(`https://restapi.amap.com/v3/geocode/geo?${params}`, { |
| headers: { 'Accept': 'application/json' }, |
| signal: AbortSignal.timeout(10000), |
| }) |
| if (!res.ok) throw new Error(`Amap geocode HTTP ${res.status}`) |
|
|
| const data = await res.json() as any |
| if (data.status !== '1' || !data.geocodes?.length) { |
| throw new Error(`Amap geocode failed: ${data.info || 'no results'}`) |
| } |
|
|
| const [lng, lat] = data.geocodes[0].location.split(',').map(Number) |
| return { |
| lat, |
| lng, |
| formattedAddress: data.geocodes[0].formatted_address || location, |
| displayName: data.geocodes[0].formatted_address || location, |
| } |
| } |
|
|
| async function amapSearchPlaces( |
| location: string, |
| query?: string, |
| type?: string, |
| radius?: number, |
| |
| geo?: LocationResult, |
| ): Promise<PlaceResult[]> { |
| const key = getAmapKey() |
|
|
| |
| const resolved = geo ?? await amapGeocode(location) |
|
|
| const allPois: any[] = [] |
| const maxPages = 20 |
| const pageSize = 1000 |
|
|
| for (let page = 1; page <= maxPages; page++) { |
| const params = new URLSearchParams({ |
| key, |
| location: `${resolved.lng},${resolved.lat}`, |
| radius: String(radius || 5000), |
| offset: String(pageSize), |
| page: String(page), |
| extensions: 'all', |
| }) |
| if (query) params.set('keywords', query) |
| if (type) params.set('types', type) |
|
|
| const res = await fetch(`https://restapi.amap.com/v3/place/around?${params}`, { |
| headers: { 'Accept': 'application/json' }, |
| signal: AbortSignal.timeout(15000), |
| }) |
|
|
| if (!res.ok) throw new Error(`Amap around search HTTP ${res.status}`) |
| const data = await res.json() as any |
|
|
| if (data.status !== '1') { |
| throw new Error(`Amap search failed: ${data.info}`) |
| } |
|
|
| const pois = data.pois || [] |
| allPois.push(...pois) |
|
|
| |
| if (pois.length < pageSize || allPois.length >= Number(data.count || 0)) break |
|
|
| |
| await new Promise(r => setTimeout(r, 350)) |
| } |
|
|
| return allPois.map((poi: any) => ({ |
| name: poi.name, |
| address: poi.address || '', |
| lat: parseFloat(poi.location?.split(',')[1] || '0'), |
| lng: parseFloat(poi.location?.split(',')[0] || '0'), |
| distance: poi.distance, |
| businessArea: poi.business_area, |
| cost: poi.cost, |
| type: poi.type, |
| tag: (poi.type || '').split(';').filter(Boolean).join(', '), |
| photos: (poi.photos || []).map((p: any) => p.url).filter(Boolean), |
| openingHours: poi.opentime || poi.biz_ext?.opentime, |
| recommendation: poi.recommend || poi.biz_ext?.rating, |
| })) |
| } |
|
|
| async function amapDirections( |
| origin: string, |
| destination: string, |
| mode: string, |
| |
| origGeo?: LocationResult, |
| |
| destGeo?: LocationResult, |
| ): Promise<DirectionsResult> { |
| const key = getAmapKey() |
| const originGeo = origGeo ?? await amapGeocode(origin) |
| const destinationGeo = destGeo ?? await amapGeocode(destination) |
|
|
| |
| const amapMode = mode === 'transit' ? 'transit' : 'driving' |
| const apiUrl = amapMode === 'transit' |
| ? 'https://restapi.amap.com/v3/direction/transit/integrated' |
| : 'https://restapi.amap.com/v3/direction/driving' |
|
|
| const params = new URLSearchParams({ |
| key, |
| origin: `${originGeo.lng},${originGeo.lat}`, |
| destination: `${destinationGeo.lng},${destinationGeo.lat}`, |
| extensions: 'all', |
| strategy: '0', |
| }) |
|
|
| if (amapMode === 'transit') { |
| params.set('city', '') |
| params.set('cityd', '') |
| } |
|
|
| const res = await fetch(`${apiUrl}?${params}`, { |
| headers: { 'Accept': 'application/json' }, |
| signal: AbortSignal.timeout(15000), |
| }) |
|
|
| if (!res.ok) throw new Error(`Amap directions HTTP ${res.status}`) |
| const data = await res.json() as any |
|
|
| if (data.status !== '1' || !data.route) { |
| throw new Error(`Amap directions failed: ${data.info || 'no route'}`) |
| } |
|
|
| const route = data.route |
| const path = route.paths?.[0] || route.transits?.[0] |
| if (!path) throw new Error('No route found') |
|
|
| const legs: DirectionLeg[] = [{ |
| steps: (path.steps || []).map((s: any) => ({ |
| instruction: s.instruction || '', |
| distance: `${(parseFloat(s.distance) / 1000).toFixed(1)} km`, |
| duration: `${Math.round(parseFloat(s.duration) / 60)} min`, |
| polyline: s.polyline, |
| })), |
| distance: `${(parseFloat(path.distance) / 1000).toFixed(1)} km`, |
| duration: `${Math.round(parseFloat(path.duration) / 60)} min`, |
| startAddress: origin, |
| endAddress: destination, |
| }] |
|
|
| const result: DirectionsResult = { |
| origin, |
| destination, |
| mode, |
| legs, |
| totalDistance: legs[0].distance, |
| totalDuration: legs[0].duration, |
| } |
|
|
| if (amapMode === 'transit') { |
| result.transitInfo = `Transit: ${path.transit_mode || 'public transport'}` |
| result.tolls = path.tolls ? `¥${path.tolls}` : undefined |
| } |
|
|
| return result |
| } |
|
|
| |
|
|
| function getGoogleKey(): string { |
| const key = process.env.GOOGLE_MAPS_API_KEY |
| if (!key) throw new Error('GOOGLE_MAPS_API_KEY environment variable is not set') |
| return key |
| } |
|
|
| async function googleGeocode(location: string, language?: string): Promise<LocationResult> { |
| const key = getGoogleKey() |
| const params = new URLSearchParams({ address: location, key }) |
| if (language) params.set('language', language) |
|
|
| const res = await fetch(`https://maps.googleapis.com/maps/api/geocode/json?${params}`, { |
| headers: { 'Accept': 'application/json' }, |
| signal: AbortSignal.timeout(10000), |
| }) |
|
|
| if (!res.ok) throw new Error(`Google geocode HTTP ${res.status}`) |
| const data = await res.json() as any |
|
|
| if (data.status !== 'OK' || !data.results?.length) { |
| throw new Error(`Google geocode failed: ${data.status} — ${data.error_message || 'no results'}`) |
| } |
|
|
| const result = data.results[0] |
| return { |
| lat: result.geometry.location.lat, |
| lng: result.geometry.location.lng, |
| formattedAddress: result.formatted_address, |
| displayName: result.formatted_address, |
| } |
| } |
|
|
| async function googleSearchPlaces( |
| location: string, |
| query?: string, |
| radius?: number, |
| language?: string, |
| |
| geo?: LocationResult, |
| ): Promise<PlaceResult[]> { |
| const key = getGoogleKey() |
| const resolved = geo ?? await googleGeocode(location, language) |
| const allPlaces: any[] = [] |
| const baseUrl = query |
| ? 'https://maps.googleapis.com/maps/api/place/textsearch/json' |
| : 'https://maps.googleapis.com/maps/api/place/nearbysearch/json' |
|
|
| let nextPageToken: string | undefined |
|
|
| for (let page = 0; page < 3; page++) { |
| const params = new URLSearchParams({ key, language: language || 'zh-CN' }) |
|
|
| if (page === 0) { |
| if (query) { |
| params.set('query', `${query} in ${location}`) |
| } else { |
| params.set('location', `${resolved.lat},${resolved.lng}`) |
| params.set('radius', String(radius || 5000)) |
| } |
| } else if (nextPageToken) { |
| |
| await new Promise(r => setTimeout(r, 2000)) |
| params.set('pagetoken', nextPageToken) |
| } |
|
|
| const res = await fetch(`${baseUrl}?${params}`, { |
| headers: { 'Accept': 'application/json' }, |
| signal: AbortSignal.timeout(15000), |
| }) |
|
|
| if (!res.ok) throw new Error(`Google places search HTTP ${res.status}`) |
| const data = await res.json() as any |
| if (data.status !== 'OK') { |
| if (allPlaces.length > 0) break |
| throw new Error(`Google places search failed: ${data.status} — ${data.error_message || ''}`) |
| } |
|
|
| allPlaces.push(...(data.results || [])) |
| nextPageToken = data.next_page_token |
| if (!nextPageToken) break |
| } |
|
|
| return parseGooglePlacesResults(allPlaces, resolved) |
| } |
|
|
| function parseGooglePlacesResults(results: any[], geo: LocationResult): PlaceResult[] { |
| return (results || []).map((place: any) => ({ |
| name: place.name, |
| address: place.formatted_address || place.vicinity || '', |
| lat: place.geometry?.location?.lat || geo.lat, |
| lng: place.geometry?.location?.lng || geo.lng, |
| rating: place.rating, |
| types: place.types, |
| phone: place.formatted_phone_number, |
| website: place.website, |
| openingHours: place.opening_hours?.open_now ? 'Open now' : undefined, |
| photos: (place.photos || []).map((p: any) => |
| `https://maps.googleapis.com/maps/api/place/photo?maxwidth=800&photoreference=${p.photo_reference}&key=${getGoogleKey()}` |
| ), |
| cost: place.price_level ? '💰'.repeat(place.price_level) : undefined, |
| })) |
| } |
|
|
| async function googleDirections( |
| origin: string, |
| destination: string, |
| mode: string, |
| language?: string, |
| ): Promise<DirectionsResult> { |
| const key = getGoogleKey() |
| const params = new URLSearchParams({ |
| origin, |
| destination, |
| mode, |
| key, |
| language: language || 'zh-CN', |
| alternatives: 'true', |
| }) |
|
|
| const res = await fetch(`https://maps.googleapis.com/maps/api/directions/json?${params}`, { |
| headers: { 'Accept': 'application/json' }, |
| signal: AbortSignal.timeout(15000), |
| }) |
|
|
| if (!res.ok) throw new Error(`Google directions HTTP ${res.status}`) |
| const data = await res.json() as any |
|
|
| if (data.status !== 'OK' || !data.routes?.length) { |
| throw new Error(`Google directions failed: ${data.status} — ${data.error_message || 'no route'}`) |
| } |
|
|
| const route = data.routes[0] |
| const legs: DirectionLeg[] = (route.legs || []).map((leg: any) => ({ |
| steps: (leg.steps || []).map((s: any) => ({ |
| instruction: s.html_instructions?.replace(/<[^>]+>/g, '') || '', |
| distance: s.distance?.text || '', |
| duration: s.duration?.text || '', |
| mode: s.travel_mode, |
| })), |
| distance: leg.distance?.text || '', |
| duration: leg.duration?.text || '', |
| startAddress: leg.start_address || origin, |
| endAddress: leg.end_address || destination, |
| })) |
|
|
| return { |
| origin, |
| destination, |
| mode, |
| legs, |
| totalDistance: legs[0]?.distance || '', |
| totalDuration: legs[0]?.duration || '', |
| polyline: route.overview_polyline?.points, |
| } |
| } |
|
|
| |
|
|
| async function handleGeocode( |
| location: string, |
| region: 'china' | 'international', |
| language?: string, |
| |
| coords?: LocationResult, |
| ): Promise<LocationResult> { |
| if (coords) return coords |
| if (region === 'china') { |
| return await amapGeocode(location) |
| } |
| return await googleGeocode(location, language) |
| } |
|
|
| async function handleSearchPlaces( |
| location: string, |
| query: string | undefined, |
| type: string | undefined, |
| radius: number | undefined, |
| region: 'china' | 'international', |
| language?: string, |
| |
| coords?: LocationResult, |
| ): Promise<PlaceResult[]> { |
| if (region === 'china') { |
| return await amapSearchPlaces(location, query, type, radius, coords) |
| } |
| return await googleSearchPlaces(location, query, radius, language, coords) |
| } |
|
|
| async function handleDirections( |
| origin: string, |
| destination: string, |
| mode: string, |
| region: 'china' | 'international', |
| language?: string, |
| |
| origCoords?: LocationResult, |
| |
| destCoords?: LocationResult, |
| ): Promise<DirectionsResult> { |
| if (region === 'china') { |
| return await amapDirections(origin, destination, mode, origCoords, destCoords) |
| } |
| return await googleDirections(origin, destination, mode, language) |
| } |
|
|
| async function handlePlanTrip( |
| origin: string, |
| destination: string, |
| waypoints: string[] | undefined, |
| mode: string, |
| region: 'china' | 'international', |
| language?: string, |
| |
| origCoords?: LocationResult, |
| |
| destCoords?: LocationResult, |
| ) { |
| |
| if (waypoints && waypoints.length > 0) { |
| |
| const legs: DirectionLeg[] = [] |
| let currentOrigin = origin |
| let currentCoords = origCoords |
|
|
| for (const wp of waypoints) { |
| const leg = await handleDirections(currentOrigin, wp, mode, region, language, currentCoords) |
| legs.push(...leg.legs) |
| currentOrigin = wp |
| currentCoords = undefined |
| } |
| |
| const finalLeg = await handleDirections(currentOrigin, destination, mode, region, language, undefined, destCoords) |
| legs.push(...finalLeg.legs) |
|
|
| const totalDist = legs.reduce((sum, l) => sum + parseFloat(l.distance) || 0, 0) |
| const totalDur = legs.reduce((sum, l) => sum + parseFloat(l.duration) || 0, 0) |
|
|
| return { |
| origin, |
| destination, |
| mode, |
| legs, |
| totalDistance: `${totalDist.toFixed(1)} km`, |
| totalDuration: `${Math.round(totalDur)} min`, |
| waypoints: [origin, ...waypoints, destination], |
| } |
| } |
|
|
| |
| return await handleDirections(origin, destination, mode, region, language) |
| } |
|
|
| |
|
|
| type LocationToolProgress = { |
| type: 'query_update' | 'results_received' |
| action?: string |
| location?: string |
| } |
|
|
| export const LocationTool = buildTool({ |
| name: LOCATION_TOOL_NAME, |
| searchHint: 'geographic search, POI, maps, directions, travel planning', |
| shouldDefer: true, |
|
|
| getToolUseSummary, |
| getActivityDescription(input) { |
| const loc = input?.location || '' |
| const action = input?.action || 'query' |
| return `${action} for "${loc}"` |
| }, |
|
|
| isEnabled() { |
| return true |
| }, |
|
|
| get inputSchema() { |
| return inputSchema() |
| }, |
|
|
| isConcurrencySafe() { |
| return true |
| }, |
|
|
| isReadOnly() { |
| return true |
| }, |
|
|
| toAutoClassifierInput(input) { |
| return input?.action |
| ? `${input.action}: ${input?.location || '(IP auto-detect)'}` |
| : '' |
| }, |
|
|
| async checkPermissions() { |
| return { behavior: 'allow' } |
| }, |
|
|
| async prompt() { |
| return PROMPT |
| }, |
|
|
| renderToolUseMessage, |
| renderToolUseProgressMessage, |
| renderToolResultMessage, |
|
|
| extractSearchText(output) { |
| if (!output) return '' |
| const parts: string[] = [] |
| if (output.geocoding) parts.push(`geocode: ${output.geocoding.formattedAddress}`) |
| if (output.places?.length) parts.push(`places: ${output.places.length} results`) |
| if (output.directions) parts.push(`route: ${output.directions.origin} → ${output.directions.destination}`) |
| return parts.join('; ') |
| }, |
|
|
| async validateInput(input) { |
| if (input?.action !== 'locate' && (!input?.location || input?.location.length === 0)) { |
| return { result: false, message: 'Missing location parameter', errorCode: 1 } |
| } |
| if (input.action === 'get_directions' || input.action === 'plan_trip') { |
| if (!input.destination) { |
| return { result: false, message: 'Missing destination parameter for directions/trip', errorCode: 1 } |
| } |
| } |
| if (input.action === 'search_places' && !input.query && !input.type) { |
| return { result: false, message: 'Missing query or type parameter for place search', errorCode: 1 } |
| } |
| return { result: true } |
| }, |
|
|
| async call(input, _context, _canUseTool, _parentMessage, onProgress) { |
| const { action, location, destination, query, radius, mode, waypoints, type, region: forcedRegion, language } = input |
|
|
| |
| const originCoords = typeof location === 'string' ? parseCoords(location) : undefined |
| const destCoords = destination ? parseCoords(destination) : undefined |
|
|
| |
| let region = forcedRegion |
| if (!region && originCoords) { |
| region = isChinaCoords(originCoords.lat, originCoords.lng) ? 'china' : 'international' |
| } |
| if (!region && location) { |
| region = isChinaMainland(location) ? 'china' : 'international' |
| } |
| if (!region) { |
| region = 'international' |
| } |
|
|
| |
| const originLocationResult = originCoords ? coordsToLocation(originCoords.lat, originCoords.lng) : undefined |
| const destLocationResult = destCoords ? coordsToLocation(destCoords.lat, destCoords.lng) : undefined |
|
|
| if (onProgress) { |
| onProgress({ |
| toolUseID: 'location-start', |
| data: { type: 'query_update', action, location: location || '(locate)' }, |
| }) |
| } |
|
|
| try { |
| let ipGeo: IpGeoInfo | undefined |
| let geocoding: LocationResult | undefined |
| let places: PlaceResult[] | undefined |
| let directions: DirectionsResult | undefined |
| let images: Output['images'] |
|
|
| switch (action) { |
| case 'geocode': |
| geocoding = await handleGeocode(location, region, language, originLocationResult) |
| break |
|
|
| case 'search_places': |
| places = await handleSearchPlaces(location, query, type, radius ?? 5000, region, language, originLocationResult) |
| break |
|
|
| case 'get_directions': |
| if (!destination) throw new Error('Destination is required for directions') |
| directions = await handleDirections(location, destination, mode ?? 'driving', region, language, originLocationResult, destLocationResult) |
| break |
|
|
| case 'plan_trip': |
| if (!destination) throw new Error('Destination is required for trip planning') |
| const planResult = await handlePlanTrip(location, destination, waypoints, mode ?? 'driving', region, language, originLocationResult, destLocationResult) |
| directions = planResult as DirectionsResult |
| break |
|
|
| case 'locate': { |
| |
| const wifiAPs = await scanWiFi() |
| const amapLoc = await amapWiFiGeolocateAPI(wifiAPs) |
| if (amapLoc) { |
| region = isChinaCoords(amapLoc.lat, amapLoc.lng) ? 'china' : 'international' |
| geocoding = coordsToLocation(amapLoc.lat, amapLoc.lng) |
| ipGeo = { |
| ip: 'amap-wifi', |
| city: `${amapLoc.lat.toFixed(4)},${amapLoc.lng.toFixed(4)}`, |
| region: `accuracy: ${amapLoc.accuracy}m`, |
| country: region === 'china' ? 'CN' : 'unknown', |
| lat: amapLoc.lat, |
| lng: amapLoc.lng, |
| } |
| break |
| } |
|
|
| |
| const googleLoc = await googleGeolocateAPI(wifiAPs) |
| if (googleLoc) { |
| region = 'international' |
| geocoding = coordsToLocation(googleLoc.lat, googleLoc.lng) |
| ipGeo = { |
| ip: 'google-geolocation', |
| city: `${googleLoc.lat.toFixed(4)},${googleLoc.lng.toFixed(4)}`, |
| region: `accuracy: ${googleLoc.accuracy}m`, |
| country: 'unknown', |
| lat: googleLoc.lat, |
| lng: googleLoc.lng, |
| } |
| break |
| } |
|
|
| |
| ipGeo = await ipGeolocate() |
| const detectRegion = ipGeo.country === 'CN' ? 'china' : 'international' |
| const detectCoords = ipGeo.lat && ipGeo.lng |
| ? coordsToLocation(ipGeo.lat, ipGeo.lng) |
| : undefined |
| if (detectCoords) { |
| geocoding = detectCoords |
| } else { |
| geocoding = await handleGeocode(ipGeo.city, detectRegion, language) |
| } |
| region = detectRegion |
| break |
| } |
| } |
|
|
| if (onProgress) { |
| onProgress({ |
| toolUseID: 'location-results', |
| data: { |
| type: 'results_received', |
| action, |
| location: action === 'locate' ? ipGeo?.city : location, |
| ...(places && { count: places.length }), |
| } as any, |
| }) |
| } |
|
|
| if (places && places.length > 0) { |
| const photoUrls = places |
| .flatMap((p) => p.photos ?? []) |
| .slice(0, 6) |
| if (photoUrls.length > 0) { |
| images = await fetchImagesAsInline(photoUrls, 6) |
| } |
| } |
|
|
| return { |
| data: { |
| action, |
| region, |
| geocoding, |
| places, |
| directions, |
| ipGeo, |
| images, |
| } satisfies Output, |
| } |
| } catch (error) { |
| const errorMessage = error instanceof Error ? error.message : String(error) |
| logError('LocationTool error', error) |
|
|
| return { |
| data: { |
| action, |
| region, |
| error: errorMessage, |
| ipGeo, |
| } satisfies Output, |
| } |
| } |
| }, |
|
|
| mapToolResultToToolResultBlockParam(output: Output, toolUseID: string) { |
| const lines: string[] = [] |
| lines.push(`📍 Location Result [${output.action}]`) |
| lines.push(`Region: ${output.region === 'china' ? '中国大陆' : '海外'}`) |
| if (output.ipGeo) { |
| const g = output.ipGeo |
| lines.push(`IP: ${g.ip} — ${g.city}, ${g.region} (${g.country})`) |
| if (g.lat && g.lng) lines.push(`Coords: ${g.lat}, ${g.lng}`) |
| } |
| lines.push('') |
|
|
| if (output.error) { |
| lines.push(`Error: ${output.error}`) |
| } |
|
|
| if (output.geocoding) { |
| const g = output.geocoding |
| lines.push(`┌─ 地理编码结果`) |
| lines.push(`│ 地址: ${g.formattedAddress}`) |
| lines.push(`│ 坐标: ${g.lat}, ${g.lng}`) |
| lines.push(`│ 地图: https://${output.region === 'china' ? 'uri.amap.com/marker?position=' : 'www.google.com/maps?q='}${g.lat},${g.lng}`) |
| lines.push(`└────\n`) |
| } |
|
|
| if (output.places && output.places.length > 0) { |
| lines.push(`┌─ 搜索结果 (${output.places.length} 条)`) |
| output.places.forEach((p, i) => { |
| lines.push(`│`) |
| lines.push(`│ [${i + 1}] ${p.name}`) |
| if (p.address) lines.push(`│ 地址: ${p.address}`) |
| if (p.distance) lines.push(`│ 距离: ${p.distance}米`) |
| if (p.tag) lines.push(`│ 类别: ${p.tag}`) |
| if (p.rating) lines.push(`│ 评分: ${'⭐'.repeat(Math.round(p.rating))} (${p.rating})`) |
| if (p.cost) lines.push(`│ 消费: ${p.cost}`) |
| if (p.openingHours) lines.push(`│ 营业: ${p.openingHours}`) |
| if (p.phone) lines.push(`│ 电话: ${p.phone}`) |
| if (p.photos?.length) { |
| lines.push(`│ 照片: ${p.photos.map((ph, idx) => `[图${idx + 1}](${ph})`).join(' ')}`) |
| } |
| }) |
| lines.push(`└────\n`) |
| } |
|
|
| if (output.directions) { |
| const d = output.directions |
| lines.push(`┌─ 路线规划`) |
| lines.push(`│ 起点: ${d.origin}`) |
| lines.push(`│ 终点: ${d.destination}`) |
| lines.push(`│ 交通方式: ${d.mode}`) |
| lines.push(`│ 总距离: ${d.totalDistance}`) |
| lines.push(`│ 预计耗时: ${d.totalDuration}`) |
| if (d.transitInfo) lines.push(`│ 公共交通: ${d.transitInfo}`) |
| if (d.tolls) lines.push(`│ 通行费: ${d.tolls}`) |
|
|
| if (d.legs.length > 0) { |
| lines.push(`│`) |
| d.legs.forEach((leg, li) => { |
| if (d.legs.length > 1) lines.push(`│ 路段 ${li + 1}: ${leg.startAddress} → ${leg.endAddress}`) |
| lines.push(`│ 距离: ${leg.distance} | 时间: ${leg.duration}`) |
| leg.steps.slice(0, 10).forEach(s => { |
| const instr = s.instruction.length > 80 ? s.instruction.slice(0, 80) + '…' : s.instruction |
| lines.push(`│ → ${instr} (${s.distance}, ${s.duration})`) |
| }) |
| if (leg.steps.length > 10) { |
| lines.push(`│ … 还有 ${leg.steps.length - 10} 个步骤`) |
| } |
| }) |
| } |
| lines.push(`└────`) |
| } |
|
|
| return { |
| tool_use_id: toolUseID, |
| type: 'tool_result', |
| content: [ |
| { type: 'text', text: lines.join('\n') }, |
| ...(output.images?.map((img) => ({ |
| type: 'image' as const, |
| source: { |
| type: 'base64' as const, |
| data: img.base64, |
| media_type: img.mediaType as 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp', |
| }, |
| })) ?? []), |
| ], |
| } |
| }, |
| }) satisfies ToolDef<ReturnType<typeof inputSchema>, Output, LocationToolProgress> |
|
|