diff --git a/examples/smart-cockpit/README.md b/examples/smart-cockpit/README.md index fd724119..43211bfe 100644 --- a/examples/smart-cockpit/README.md +++ b/examples/smart-cockpit/README.md @@ -97,10 +97,10 @@ A preflight validates the Realtime configuration and all four ports before any c - Users can create and run persistent cockpit-specific workflows by voice. The backend Agent loads these workflows and composes existing MCP tools; they are not dynamic MCP plugins, A2A Agent Card entries, or globally installed Agent Skills. -- The foreground Agent owns realtime conversation and directly calls weather, - vehicle-state, window, sunroof, headlight, and climate tools through standard - MCP. Explicit single vehicle-control commands execute inline without a redundant - second confirmation. + vehicle-state, window, sunroof, headlight, climate, and short in-route + navigation tools through standard MCP. Explicit single vehicle-control, + route-view, voice, and current-route preference commands execute inline + without a redundant second confirmation. - Other cockpit work goes through the fixed `spawn_thinking` bridge. The example backend attaches over A2A, and Qwen3.8-Flash discovers the complete backend MCP surface for vehicle control, navigation, music, flash-buy, and custom workflows, diff --git a/examples/smart-cockpit/README_ZH.md b/examples/smart-cockpit/README_ZH.md index 1d2e0b22..87eb9acd 100644 --- a/examples/smart-cockpit/README_ZH.md +++ b/examples/smart-cockpit/README_ZH.md @@ -83,7 +83,7 @@ npm run example:smart-cockpit - 用户可以通过语音创建和运行持久化的座舱自定义技能;技能是按座舱隔离的用户工作流, 由后台 Agent 加载后编排现有 MCP 工具。它不是动态 MCP 插件、A2A Agent Card 或全局 Agent Skill。 - 前台 Agent 负责实时聊天,通过标准 MCP 直接调用天气、车况以及车窗、天窗、 - 大灯和空调工具;用户明确说出的单次车辆控制指令直接执行,不再增加重复确认。 + 大灯、空调和短导航工具;用户明确说出的单次车辆控制、导航视图、导航播报和当前路线偏好指令直接执行,不再增加重复确认。 - 其他座舱任务通过固定的 `spawn_thinking` 桥梁提交给后台。示例后台通过 A2A 接入 Gateway,Qwen3.8-Flash 会发现并调用独立的后台 MCP 工具面,完成车控、 导航、音乐、闪购和自定义技能任务,包括有序的多途经点导航。 diff --git a/examples/smart-cockpit/agent/executor.mjs b/examples/smart-cockpit/agent/executor.mjs index abc38267..5e86084f 100644 --- a/examples/smart-cockpit/agent/executor.mjs +++ b/examples/smart-cockpit/agent/executor.mjs @@ -18,6 +18,11 @@ export const COCKPIT_AGENT_PROMPT = `你是智能座舱的后台 Agent,负责 - 导航请求可以包含多个有序途经点。将中间地点放入 waypoints,最后一个地点作为 destination。 - 用户明确说“导航到”“带我去”“去某地”或“开始导航”时直接调用 navigation_start,成功后不要再次询问是否开始。 - 只有用户明确说“查路线”“怎么走”“多远”“多久”或“先看看路线”时才调用 navigation_route_query。 +- 用户在已有导航中说“中途去一下”“顺路去”“加个途经点”时调用 navigation_add_waypoint;说“不去这个途经点了”“取消途经点”时调用 navigation_remove_waypoint。 +- 用户在已有导航中说“目的地改成”“换个地方”时调用 navigation_change_destination;只说“换成不走高速”“改成少收费”“避开拥堵”时调用 navigation_set_route_strategy。 +- 用户只是找地点或周边 POI、没有要求导航时,调用 navigation_search_place。 +- “回家”“去公司”等常用地点导航优先调用 navigation_to_favorite;设置家/公司/学校地址时调用 navigation_set_favorite。 +- 导航静音、详细播报、简洁播报调用 navigation_set_voice;查看全程、跟车视角、北向上调用 navigation_set_view。 - 闪购中,只有“看看”“搜一下”“有哪些”等浏览意图使用 search;“帮我点”“来一份”“就这个”“加入购物车”使用 add_to_cart,不得退回再次搜索。 - 闪购加购后必须先返回订单预览;只有用户在后续指令中明确确认后,才调用 confirm_order。 - 用户明确要求创建自定义技能时,调用 custom_skill_create 保存名称、简介和可执行步骤;未得到创建意图时不要擅自保存。 diff --git a/examples/smart-cockpit/client/src/App.css b/examples/smart-cockpit/client/src/App.css index a00e35a6..22db6340 100644 --- a/examples/smart-cockpit/client/src/App.css +++ b/examples/smart-cockpit/client/src/App.css @@ -833,6 +833,13 @@ button { font-weight: 700; } +.nav-voice-badge { + padding-left: 8px; + border-left: 1px solid rgba(95, 124, 134, 0.18); + color: #5c6a72; + font-weight: 700; +} + .nav-ai-dot { width: 8px; height: 8px; @@ -1118,6 +1125,126 @@ button { transform: scale(1.08); } +.destination-search-panel { + position: absolute; + top: 22px; + left: 24px; + z-index: 4; + width: min(360px, calc(100% - 48px)); + display: grid; + grid-template-columns: 1fr; + gap: 0; + border-radius: 8px; + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(255, 255, 255, 0.9); + box-shadow: 0 12px 28px rgba(80, 111, 123, 0.14); + backdrop-filter: blur(14px); + overflow: hidden; +} + +.destination-search-field { + min-height: 52px; + display: flex; + align-items: center; + gap: 10px; + padding: 0 18px; + background: rgba(255, 255, 255, 0.72); + border: 0; + border-bottom: 1px solid rgba(95, 124, 134, 0.12); + color: #7a8991; + font-size: 14px; + font-weight: 800; + text-align: left; + transition: background 140ms ease, color 140ms ease; +} + +.destination-search-field:hover { + background: rgba(255, 255, 255, 0.94); + color: #40515a; +} + +.destination-search-field .icon { + width: 19px; + height: 19px; + flex-shrink: 0; +} + +.destination-shortcuts { + display: grid; + grid-template-columns: 1fr; +} + +.destination-shortcut { + min-height: 58px; + display: grid; + grid-template-columns: 34px minmax(0, 1fr) 56px; + align-items: center; + gap: 10px; + padding: 9px 12px 9px 14px; + border-bottom: 1px solid rgba(95, 124, 134, 0.1); +} + +.destination-shortcut:last-child { + border-bottom: 0; +} + +.destination-shortcut-icon { + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 50%; + background: rgba(95, 124, 134, 0.12); + color: #40515a; + font-size: 13px; + font-weight: 900; +} + +.destination-shortcut.is-configured .destination-shortcut-icon { + background: rgba(53, 214, 194, 0.14); + color: #168b81; +} + +.destination-shortcut-text { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.destination-shortcut-text strong { + color: #26333a; + font-size: 14px; + line-height: 1.1; +} + +.destination-shortcut-text span { + max-width: 100%; + color: #6b7980; + font-size: 12px; + line-height: 1.25; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.destination-shortcut-action { + height: 32px; + border-radius: 8px; + border: 1px solid rgba(53, 214, 194, 0.34); + background: rgba(53, 214, 194, 0.14); + color: #168b81; + font-size: 12px; + font-weight: 800; + transition: background 140ms ease, border-color 140ms ease, color 140ms ease; +} + +.destination-shortcut-action:hover { + background: #35d6c2; + border-color: #35d6c2; + color: #fff; +} + .map-overview-btn { flex-shrink: 0; height: 40px; @@ -2519,6 +2646,7 @@ button { gap: 4px; } +.chat-clear, .chat-close { width: 28px; height: 28px; @@ -2529,6 +2657,11 @@ button { transition: background 150ms ease, color 150ms ease; } +.chat-clear:hover { + background: rgba(95, 124, 134, 0.1); + color: #2e3c44; +} + .chat-close:hover { background: rgba(215, 68, 103, 0.1); color: #d74467; diff --git a/examples/smart-cockpit/client/src/App.jsx b/examples/smart-cockpit/client/src/App.jsx index 66c64b0e..f8cf95ab 100644 --- a/examples/smart-cockpit/client/src/App.jsx +++ b/examples/smart-cockpit/client/src/App.jsx @@ -69,6 +69,12 @@ function getClientId() { return id } +function createClientId() { + const id = crypto.randomUUID() + localStorage.setItem('clientId', id) + return id +} + function getStoredChoice(key, fallback, validValues) { const value = localStorage.getItem(key) return validValues.includes(value) ? value : fallback @@ -86,13 +92,14 @@ function parseHash() { } export default function App() { - const clientId = useMemo(() => getClientId(), []) + const [clientId, setClientId] = useState(() => getClientId()) const cockpitId = import.meta.env.VITE_COCKPIT_ID || 'default' const { state: cockpitState, progress: cockpitProgress, activity: cockpitActivity, execute: executeCockpitCommand, + reset: resetCockpitState, } = useCockpitState(cockpitId) const { skills: customSkills, @@ -140,6 +147,14 @@ export default function App() { runCockpitCommand('music_play', { query: PLAYLIST[index]?.title }) }, [runCockpitCommand]) + const navigateToFavorite = useCallback((favoriteType) => { + runCockpitCommand('navigation_to_favorite', { favoriteType }) + }, [runCockpitCommand]) + + const openDestinationInput = useCallback(() => { + setShowChat(true) + }, []) + useEffect(() => { localStorage.setItem(PERSONA_STORAGE_KEY, selectedPersona) }, [selectedPersona]) @@ -357,6 +372,15 @@ export default function App() { sendInput([{ type: 'text', text }]) ), [sendInput]) + const handleClearDebug = useCallback(() => { + voiceAssistantMessageIdRef.current = null + setChatMessages([]) + setClientId(createClientId()) + resetCockpitState().catch(error => { + console.warn('Cockpit reset failed', error) + }) + }, [resetCockpitState]) + useEffect(() => { const targetScreen = cockpitScreenForProgress(visualProgress, { navigationActive: navState.status === 'navigating', @@ -402,7 +426,16 @@ export default function App() { onToggleVoiceMute={toggleVoiceMute} /> {screen === 'main' && ( - + )} {screen === 'music' && ( @@ -423,7 +456,7 @@ export default function App() { /> )} - {showChat && } + {showChat && } openSettings('persona')} onToggleChat={toggleChat} carState={carState} musicState={musicState} onTogglePlay={musicState.playing ? musicPause : musicPlay} onOpenMusic={openMusic} onOpenFlashBuy={openFlashBuy} /> diff --git a/examples/smart-cockpit/client/src/components/ChatPanel.jsx b/examples/smart-cockpit/client/src/components/ChatPanel.jsx index 0ff4c2a5..021c0040 100644 --- a/examples/smart-cockpit/client/src/components/ChatPanel.jsx +++ b/examples/smart-cockpit/client/src/components/ChatPanel.jsx @@ -14,6 +14,15 @@ const TOOL_TAGS = { navigation_start: { label: '开始导航', cls: 'tag-nav' }, navigation_route_query: { label: '路线查询', cls: 'tag-nav' }, navigation_stop: { label: '停止导航', cls: 'tag-nav' }, + navigation_add_waypoint: { label: '增加途经点', cls: 'tag-nav' }, + navigation_remove_waypoint: { label: '删除途经点', cls: 'tag-nav' }, + navigation_change_destination: { label: '变更目的地', cls: 'tag-nav' }, + navigation_set_route_strategy: { label: '路线偏好', cls: 'tag-nav' }, + navigation_search_place: { label: '地点搜索', cls: 'tag-nav' }, + navigation_to_favorite: { label: '常用地点导航', cls: 'tag-nav' }, + navigation_set_favorite: { label: '设置常用地点', cls: 'tag-nav' }, + navigation_set_voice: { label: '导航播报', cls: 'tag-nav' }, + navigation_set_view: { label: '导航视图', cls: 'tag-nav' }, maps_geo: { label: '地理编码', cls: 'tag-nav' }, maps_text_search: { label: '地点搜索', cls: 'tag-nav' }, maps_search_detail: { label: '地点详情', cls: 'tag-nav' }, @@ -50,6 +59,7 @@ function getDefaultPosition(panel) { export default function ChatPanel({ onClose, + onClear, messages, onMessagesChange, onSendMessage, @@ -136,6 +146,19 @@ export default function ChatPanel({ Qwen Audio Agent Smart Cockpit · 调试 + event.stopPropagation()} + aria-label="清空" + title="清空" + > + + + + + + diff --git a/examples/smart-cockpit/client/src/components/MapPanel.jsx b/examples/smart-cockpit/client/src/components/MapPanel.jsx index a3098c0f..cb5a683f 100644 --- a/examples/smart-cockpit/client/src/components/MapPanel.jsx +++ b/examples/smart-cockpit/client/src/components/MapPanel.jsx @@ -1,6 +1,6 @@ import { useRef, useEffect, useMemo, useCallback, useState } from 'react' import AMapLoader from '@amap/amap-jsapi-loader' -import { navigationRouteView } from '../projections/navigation-route' +import { navigationRouteKey, navigationRouteView } from '../projections/navigation-route' window._AMapSecurityConfig = { securityJsCode: import.meta.env.VITE_AMAP_SECRET, @@ -35,7 +35,21 @@ const STRATEGIES = [ { value: 2, label: '时间优先' }, ] -export default function MapPanel({ navState, navProgress, mapActions, routeStrategy, onStrategyChange }) { +const DESTINATION_SHORTCUTS = [ + { type: 'home', label: '家', title: '回家' }, + { type: 'office', label: '公司', title: '去公司' }, +] + +export default function MapPanel({ + navState, + navProgress, + mapActions, + routeStrategy, + onStrategyChange, + onFavoriteNavigate, + onFavoriteSetup, + onSearchDestination, +}) { const mapRef = useRef(null) const mapInstance = useRef(null) const amapRef = useRef(null) @@ -53,6 +67,8 @@ export default function MapPanel({ navState, navProgress, mapActions, routeStrat const flowRafRef = useRef(null) const cameraRafRef = useRef(null) const cameraTimersRef = useRef([]) + const currentViewModeRef = useRef(navState?.viewMode || 'follow') + const lastAppliedViewModeRef = useRef(navState?.viewMode || 'follow') const [cameraStage, setCameraStage] = useState('idle') const [mapReady, setMapReady] = useState(false) @@ -394,7 +410,24 @@ export default function MapPanel({ navState, navProgress, mapActions, routeStrat if (puck) puck.style.transform = `rotate(${bearing}deg)` }, []) - const routeInfo = useMemo(() => navigationRouteView(navState), [navState]) + const routeKey = navigationRouteKey(navState) + const routeInfo = useMemo(() => navigationRouteView(JSON.parse(routeKey)), [routeKey]) + const navigationViewMode = navState?.viewMode || 'follow' + const navigationVoiceLabel = useMemo(() => { + if (navState?.voice?.muted) return '导航静音' + if (navState?.voice?.broadcastMode === 'detailed') return '详细播报' + if (navState?.voice?.broadcastMode === 'brief') return '简洁播报' + return '标准播报' + }, [navState?.voice?.broadcastMode, navState?.voice?.muted]) + const destinationShortcuts = useMemo(() => DESTINATION_SHORTCUTS.map(item => { + const favorite = navState?.favorites?.[item.type] + const subtitle = favorite?.address || favorite?.name || '点击设置' + return { + ...item, + subtitle, + configured: Boolean(favorite?.location), + } + }), [navState?.favorites]) const activeNavProgress = useMemo(() => ( navProgress?.domain === 'navigation' && navProgress.message ? navProgress : null @@ -430,6 +463,29 @@ export default function MapPanel({ navState, navProgress, mapActions, routeStrat `${escapeHtml(name)}` ), [escapeHtml]) + const applyRouteViewMode = useCallback((viewMode) => { + const AMap = amapRef.current + if (!AMap || !routeInfo?.polyline) return + + const points = parsePolyline(routeInfo.polyline, AMap) + if (points.length < 2) return + + clearCameraTimeline() + if (viewMode === 'overview') { + setCameraStage('settle') + tweenCamera(getFullRouteCamera(points), CAMERA_ROUTE_OVERVIEW_DURATION) + return + } + + const followCamera = getFollowCamera(points) + rotateVehiclePuck(followCamera.rotation) + setCameraStage(routeInfo.status === 'navigating' ? 'tracking' : 'settle') + tweenCamera({ + ...followCamera, + rotation: viewMode === 'north_up' ? 0 : followCamera.rotation, + }, CAMERA_SETTLE_DURATION) + }, [clearCameraTimeline, getFollowCamera, getFullRouteCamera, parsePolyline, routeInfo, rotateVehiclePuck, tweenCamera]) + const clearPreview = useCallback(() => { const map = mapInstance.current if (!map) return @@ -623,10 +679,18 @@ export default function MapPanel({ navState, navProgress, mapActions, routeStrat animateRoute(routeLayers.animated, points, () => { startRouteFlow(points) setCameraStage('settle') + const viewMode = currentViewModeRef.current + if (viewMode === 'overview') { + tweenCamera(getFullRouteCamera(points), CAMERA_SETTLE_DURATION) + return + } if (route.status !== 'navigating') return const followCamera = getFollowCamera(points) rotateVehiclePuck(followCamera.rotation) - tweenCamera(followCamera, CAMERA_SETTLE_DURATION, () => setCameraStage('tracking')) + tweenCamera({ + ...followCamera, + rotation: viewMode === 'north_up' ? 0 : followCamera.rotation, + }, CAMERA_SETTLE_DURATION, () => setCameraStage('tracking')) }) } }, CAMERA_EXPAND_DURATION + CAMERA_DESTINATION_HOLD) @@ -667,7 +731,14 @@ export default function MapPanel({ navState, navProgress, mapActions, routeStrat scheduleCameraStep(() => setCameraStage('idle'), 0) tweenCamera({ center: DEFAULT_CENTER, zoom: 14, pitch: 42, rotation: 0 }, 520) } - }, [navState?.status, routeInfo, mapReady, clearPreview, clearRouteLayers, createRouteLayers, animateRoute, parsePolyline, clearCameraTimeline, stopRouteFlow, scheduleCameraStep, tweenCamera, calculateBearing, getRouteCamera, getFollowCamera, getDestinationMarkerContent, startRouteFlow, rotateVehiclePuck]) + }, [navState?.status, routeInfo, mapReady, clearPreview, clearRouteLayers, createRouteLayers, animateRoute, parsePolyline, clearCameraTimeline, stopRouteFlow, scheduleCameraStep, tweenCamera, calculateBearing, getRouteCamera, getFollowCamera, getFullRouteCamera, getDestinationMarkerContent, startRouteFlow, rotateVehiclePuck]) + + useEffect(() => { + currentViewModeRef.current = navigationViewMode + if (lastAppliedViewModeRef.current === navigationViewMode) return + lastAppliedViewModeRef.current = navigationViewMode + applyRouteViewMode(navigationViewMode) + }, [applyRouteViewMode, navigationViewMode]) useEffect(() => { if (!mapReady || routeInfo || !activeNavProgress) return undefined @@ -711,17 +782,6 @@ export default function MapPanel({ navState, navProgress, mapActions, routeStrat mapInstance.current?.setZoomAndCenter(16, DEFAULT_CENTER) } - function handleViewFullRoute() { - const AMap = amapRef.current - if (!AMap || !routeInfo?.polyline) return - - const points = parsePolyline(routeInfo.polyline, AMap) - if (points.length < 2) return - - clearCameraTimeline() - tweenCamera(getFullRouteCamera(points), CAMERA_ROUTE_OVERVIEW_DURATION) - } - return ( @@ -733,6 +793,7 @@ export default function MapPanel({ navState, navProgress, mapActions, routeStrat AI 已为你规划路线 {routeInfo.durationMin} 分钟 + {navigationVoiceLabel} )} {routeInfo && ( @@ -775,12 +836,43 @@ export default function MapPanel({ navState, navProgress, mapActions, routeStrat )} + {!routeInfo && !activeNavProgress && ( + + + + + + 搜索目的地 + + + {destinationShortcuts.map(item => ( + + {item.label.slice(0, 1)} + + {item.title} + {item.subtitle} + + ( + item.configured + ? onFavoriteNavigate?.(item.type) + : onFavoriteSetup?.(item.type) + )} + > + {item.configured ? '导航' : '设置'} + + + ))} + + + )} {routeInfo?.polyline && ( - + applyRouteViewMode('overview')}> 查看全程 diff --git a/examples/smart-cockpit/client/src/hooks/useCockpitState.js b/examples/smart-cockpit/client/src/hooks/useCockpitState.js index 67e13a2c..0c3407a6 100644 --- a/examples/smart-cockpit/client/src/hooks/useCockpitState.js +++ b/examples/smart-cockpit/client/src/hooks/useCockpitState.js @@ -85,5 +85,18 @@ export default function useCockpitState(cockpitId) { return result }, [cockpitId]) - return { state, progress, activity, error, execute } + const reset = useCallback(async () => { + const response = await fetch(`${serviceOrigin()}/api/cockpit/reset`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cockpitId }), + }) + const result = await response.json() + if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`) + setState(result) + setProgress(null) + return result + }, [cockpitId]) + + return { state, progress, activity, error, execute, reset } } diff --git a/examples/smart-cockpit/client/src/projections/navigation-route.js b/examples/smart-cockpit/client/src/projections/navigation-route.js index c71c992b..4a655897 100644 --- a/examples/smart-cockpit/client/src/projections/navigation-route.js +++ b/examples/smart-cockpit/client/src/projections/navigation-route.js @@ -28,3 +28,18 @@ export function navigationRouteView(navigation) { waypointLocations, } } + +export function navigationRouteKey(navigation) { + return JSON.stringify({ + status: navigation?.status || 'idle', + destination: navigation?.destination || '', + route: navigation?.route || null, + map: { + markers: (navigation?.map?.markers || []).map(marker => ({ + role: marker?.role, + index: marker?.index, + location: marker?.location, + })), + }, + }) +} diff --git a/examples/smart-cockpit/client/test/navigation-route.test.mjs b/examples/smart-cockpit/client/test/navigation-route.test.mjs index 4e362cdc..ba058c26 100644 --- a/examples/smart-cockpit/client/test/navigation-route.test.mjs +++ b/examples/smart-cockpit/client/test/navigation-route.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { navigationRouteView } from '../src/projections/navigation-route.js' +import { navigationRouteKey, navigationRouteView } from '../src/projections/navigation-route.js' test('projects authoritative navigation state into the map view contract', () => { assert.deepEqual(navigationRouteView({ @@ -51,3 +51,26 @@ test('supports route previews and ignores idle navigation', () => { route: { legs: [] }, }).status, 'preview') }) + +test('keeps route key stable for voice and view changes', () => { + const navigation = { + status: 'navigating', + destination: '西湖', + viewMode: 'follow', + voice: { muted: false, broadcastMode: 'standard' }, + route: { + distKm: '12.3', + durationMin: 25, + arrival: '15:10', + legs: [{ polyline: '120.0,30.0;120.1,30.1' }], + }, + map: { + markers: [{ role: 'destination', location: '120.1,30.1' }], + }, + } + assert.equal(navigationRouteKey(navigation), navigationRouteKey({ + ...navigation, + viewMode: 'overview', + voice: { muted: true, broadcastMode: 'brief' }, + })) +}) diff --git a/examples/smart-cockpit/docs/architecture.md b/examples/smart-cockpit/docs/architecture.md index f2e95b79..b149d279 100644 --- a/examples/smart-cockpit/docs/architecture.md +++ b/examples/smart-cockpit/docs/architecture.md @@ -52,7 +52,7 @@ cockpit-client ── GCP 6.0 ──► cockpit-gateway ── A2A ──► coc - `cockpit-service` 是车辆、导航、音乐、天气和闪购状态的唯一来源。 - UI 通过 HTTP 获取快照、执行面板操作,通过 SSE 接收状态变化。 - Gateway 的前台 Agent 通过 `/mcp/frontend` 直接使用天气、车况以及车窗、天窗、 - 大灯和空调工具;明确的单次车辆控制口头指令直接执行,不增加重复确认。 + 大灯、空调和短导航工具;明确的单次车辆控制、导航视图、导航播报和当前路线偏好口头指令直接执行,不增加重复确认。 - 后台 Agent 通过 `/mcp/backend` 使用完整工具面,支持组合任务以及自定义技能的 发现、创建、加载和执行。 - 两个工具面由 `service/tools/registry.mjs` 显式组合,但共用同一份执行器和座舱状态; diff --git a/examples/smart-cockpit/gateway/frontend-mcp.json b/examples/smart-cockpit/gateway/frontend-mcp.json index abe162ac..a1e3e7f7 100644 --- a/examples/smart-cockpit/gateway/frontend-mcp.json +++ b/examples/smart-cockpit/gateway/frontend-mcp.json @@ -28,6 +28,18 @@ "vehicle_climate_control": { "enabled": true, "description": "立即打开或关闭空调,或设置用户明确指定的温度、制冷制热模式和风量;不要把状态查询当成控制指令,也不要再通过 spawn_thinking 重复执行同一操作。" + }, + "navigation_set_route_strategy": { + "enabled": true, + "description": "在当前导航或路线预览中立即切换路线偏好并重新规划路线。用户说“躲避拥堵”“避开拥堵”“不走高速”“少收费”“高速优先”“时间优先”时直接调用;不要只口头回应,也不要再通过 spawn_thinking 重复执行同一操作。" + }, + "navigation_set_voice": { + "enabled": true, + "description": "立即设置导航静音或播报模式。用户说“导航静音”“取消静音”“详细播报”“简洁播报”“标准播报”时直接调用;不要只口头回应,也不要再通过 spawn_thinking 重复执行同一操作。" + }, + "navigation_set_view": { + "enabled": true, + "description": "立即设置地图导航视图。用户说“查看全程”“路线全览”“回到跟车视角”“北向上”时直接调用;不要只口头回应,也不要再通过 spawn_thinking 重复执行同一操作。" } } } diff --git a/examples/smart-cockpit/gateway/spawn-thinking-tool.mjs b/examples/smart-cockpit/gateway/spawn-thinking-tool.mjs index 0eae1494..f0d4fd16 100644 --- a/examples/smart-cockpit/gateway/spawn-thinking-tool.mjs +++ b/examples/smart-cockpit/gateway/spawn-thinking-tool.mjs @@ -1,6 +1,6 @@ export const COCKPIT_SPAWN_THINKING_DESCRIPTION = [ '将需要座舱 Agent 执行的任务异步提交到后台。', - '用于单点或多途经点导航与当前路线查询、音乐播放,以及示例闪购流程。天气、车况和单次车辆控制由前台工具直接执行,不要提交到后台。', + '用于单点或多途经点导航、音乐播放,以及示例闪购流程。天气、车况、单次车辆控制、导航视图、导航播报和当前路线偏好由前台工具直接执行,不要提交到后台。', '复杂、多步骤或包含多个地点的座舱指令也应提交给后台,不要由前台自行判断不支持。', '用户要求创建、更新或运行自定义座舱技能时也应提交给后台;objective 必须保留技能名称、触发语义和完整步骤。', '闪购的搜索、加购预览和确认下单是不同任务;必须在 objective 中忠实保留用户选定的商品和当前动作,不要把加购改写为搜索。', diff --git a/examples/smart-cockpit/gateway/test/frontend-tools.test.mjs b/examples/smart-cockpit/gateway/test/frontend-tools.test.mjs index 629f521b..735712bd 100644 --- a/examples/smart-cockpit/gateway/test/frontend-tools.test.mjs +++ b/examples/smart-cockpit/gateway/test/frontend-tools.test.mjs @@ -10,6 +10,10 @@ import { test('calls selected cockpit tools inline through the frontend MCP client', async t => { const service = new CockpitService({ services: { + async resolvePlace() { return '120.1,30.2' }, + async drivingRoute() { + return { distance: 1_000, duration: 120, polyline: '120.0,30.0;120.1,30.2', trafficSegments: [] } + }, async weather(city) { return { city, dayweather: '晴', daytemp: '27' } }, @@ -32,6 +36,9 @@ test('calls selected cockpit tools inline through the frontend MCP client', asyn vehicle_sunroof_control: { enabled: true }, vehicle_headlights_control: { enabled: true }, vehicle_climate_control: { enabled: true }, + navigation_set_route_strategy: { enabled: true }, + navigation_set_voice: { enabled: true }, + navigation_set_view: { enabled: true }, }, }, }, @@ -47,6 +54,9 @@ test('calls selected cockpit tools inline through the frontend MCP client', asyn 'mcp__cockpit__vehicle_sunroof_control', 'mcp__cockpit__vehicle_headlights_control', 'mcp__cockpit__vehicle_climate_control', + 'mcp__cockpit__navigation_set_route_strategy', + 'mcp__cockpit__navigation_set_voice', + 'mcp__cockpit__navigation_set_view', ]) const output = await client.execute('mcp__cockpit__weather', { city: '杭州' }) assert.match(output.text, /杭州,晴,27°/u) @@ -69,9 +79,25 @@ test('calls selected cockpit tools inline through the frontend MCP client', asyn }) assert.match(state.text, /主驾车窗: 开启/u) assert.match(state.text, /大灯: 开启/u) + await service.execute('navigation_start', { destination: '西湖' }) + const view = await client.execute('mcp__cockpit__navigation_set_view', { + viewMode: 'overview', + }) + assert.match(view.text, /路线全览/u) + const strategy = await client.execute('mcp__cockpit__navigation_set_route_strategy', { + strategy: 4, + }) + assert.match(strategy.text, /躲避拥堵/u) + const voice = await client.execute('mcp__cockpit__navigation_set_voice', { + broadcastMode: 'brief', + }) + assert.match(voice.text, /简洁播报/u) assert.equal(service.snapshot().weather.daytemp, '27') assert.equal(service.snapshot().vehicle.windowFL, 1) assert.equal(service.snapshot().vehicle.sunroof, 1) assert.equal(service.snapshot().vehicle.headlights, 1) assert.equal(service.snapshot().vehicle.acTemp, 22) + assert.equal(service.snapshot().navigation.viewMode, 'overview') + assert.equal(service.snapshot().navigation.strategy, 4) + assert.equal(service.snapshot().navigation.voice.broadcastMode, 'brief') }) diff --git a/examples/smart-cockpit/service/cockpit-service.mjs b/examples/smart-cockpit/service/cockpit-service.mjs index 994c993f..d481c759 100644 --- a/examples/smart-cockpit/service/cockpit-service.mjs +++ b/examples/smart-cockpit/service/cockpit-service.mjs @@ -12,6 +12,8 @@ function clean(value) { function emptyServices() { return { async resolvePlace() { return null }, + async searchPlaces() { return [] }, + async searchNearbyPlaces() { return [] }, async drivingRoute() { return null }, async weather() { return null }, } @@ -53,6 +55,10 @@ export class CockpitService { } } + reset(cockpitId = 'default') { + return this.store.reset(cockpitId) + } + listSkills(cockpitId = 'default') { return this.customSkills.list(cockpitId) } diff --git a/examples/smart-cockpit/service/integrations/amap/client.mjs b/examples/smart-cockpit/service/integrations/amap/client.mjs index 236b36c2..ad0abbef 100644 --- a/examples/smart-cockpit/service/integrations/amap/client.mjs +++ b/examples/smart-cockpit/service/integrations/amap/client.mjs @@ -116,19 +116,62 @@ export async function geocode(address, city) { } export async function searchPlace(keywords, city) { + const places = await searchPlaces(keywords, { city, limit: 1 }) + return places[0] || null +} + +function normalizePoi(poi) { + if (!poi) return null + return { + id: poi.id || '', + name: poi.name || '', + type: poi.type || poi.typecode || '', + address: Array.isArray(poi.address) ? poi.address.join('') : poi.address || '', + distance: poi.distance ? Number(poi.distance) || null : null, + location: poi.location || '', + } +} + +export async function searchPlaces(keywords, { + city, + types, + limit = 5, +} = {}) { const args = { keywords } if (city) args.city = city + if (types) args.types = types const text = extractText(await callMcp('maps_text_search', args)) - if (!text) return null + if (!text) return [] try { - const poi = JSON.parse(text).pois?.[0] - if (poi?.location) return { location: poi.location, name: poi.name } - if (poi?.id) { - const location = await getPoiLocation(poi.id) - if (location) return { location, name: poi.name } + const pois = JSON.parse(text).pois || [] + const places = [] + for (const poi of pois.slice(0, limit)) { + const place = normalizePoi(poi) + if (!place) continue + if (!place.location && place.id) place.location = await getPoiLocation(place.id) || '' + places.push(place) } + return places + } catch {} + return [] +} + +export async function searchNearbyPlaces({ + keywords, + location, + radius, + limit = 5, +} = {}) { + const args = { location } + if (keywords) args.keywords = keywords + if (radius) args.radius = String(radius) + const text = extractText(await callMcp('maps_around_search', args)) + if (!text) return [] + try { + const pois = JSON.parse(text).pois || [] + return pois.slice(0, limit).map(normalizePoi).filter(Boolean) } catch {} - return null + return [] } async function getPoiLocation(id) { diff --git a/examples/smart-cockpit/service/integrations/amap/services.mjs b/examples/smart-cockpit/service/integrations/amap/services.mjs index af149ba9..9311cb7d 100644 --- a/examples/smart-cockpit/service/integrations/amap/services.mjs +++ b/examples/smart-cockpit/service/integrations/amap/services.mjs @@ -3,10 +3,14 @@ import { geocode, getWeather, searchPlace, + searchPlaces, + searchNearbyPlaces, } from './client.mjs' export function createAmapCockpitServices({ search = searchPlace, + searchMany = searchPlaces, + searchNearby = searchNearbyPlaces, encode = geocode, route = drivingRoute, forecast = getWeather, @@ -20,6 +24,8 @@ export function createAmapCockpitServices({ if (place?.location) return place.location return encode(name, city) }, + searchPlaces: searchMany, + searchNearbyPlaces: searchNearby, drivingRoute: route, weather: forecast, } diff --git a/examples/smart-cockpit/service/server.mjs b/examples/smart-cockpit/service/server.mjs index 2fb2b28d..4ab64fe5 100644 --- a/examples/smart-cockpit/service/server.mjs +++ b/examples/smart-cockpit/service/server.mjs @@ -148,6 +148,11 @@ export class CockpitServiceServer { json(response, 200, output) return } + if (url.pathname === '/api/cockpit/reset' && request.method === 'POST') { + const body = await readJson(request) + json(response, 200, this.service.reset(cockpitId(request, url, body))) + return + } const mcpTools = url.pathname === '/mcp/frontend' ? FRONTEND_TOOL_DEFINITIONS : url.pathname === '/mcp/backend' diff --git a/examples/smart-cockpit/service/state-store.mjs b/examples/smart-cockpit/service/state-store.mjs index efdaba4c..79675a8a 100644 --- a/examples/smart-cockpit/service/state-store.mjs +++ b/examples/smart-cockpit/service/state-store.mjs @@ -2,6 +2,7 @@ import { DEFAULT_DELIVERY_ADDRESS, } from './tools/flashbuy/catalog.mjs' import { SONGS } from './tools/music/catalog.mjs' +import { DEFAULT_ORIGIN } from './tools/navigation/catalog.mjs' const DEFAULT_COCKPIT_ID = 'default' @@ -34,10 +35,24 @@ export function createInitialCockpitState(now = Date.now()) { navigation: { status: 'idle', destination: null, + destinationLocation: null, waypoints: [], + waypointLocations: [], strategy: 0, route: null, map: { markers: [], polylines: [] }, + favorites: { + home: null, + office: null, + school: null, + custom: null, + }, + voice: { + muted: false, + broadcastMode: 'standard', + }, + viewMode: 'follow', + currentLocation: DEFAULT_ORIGIN.location, }, music: { playing: false, @@ -117,7 +132,21 @@ export class CockpitStateStore { const id = normalizeCockpitId(cockpitId) const state = createInitialCockpitState(this.now()) this.records.set(id, state) - return this.snapshot(id) + const event = Object.freeze({ + type: 'cockpit.state.updated', + cockpitId: id, + version: state.version, + changed: Object.freeze(['vehicle', 'navigation', 'music', 'flashbuy', 'weather']), + state: this.snapshot(id), + }) + for (const listener of this.listeners.get(id) || []) { + try { + listener(event) + } catch { + // State observers cannot interrupt cockpit operations. + } + } + return event.state } #record(cockpitId) { diff --git a/examples/smart-cockpit/service/test/cockpit-service.test.mjs b/examples/smart-cockpit/service/test/cockpit-service.test.mjs index dc2adc98..08fb6641 100644 --- a/examples/smart-cockpit/service/test/cockpit-service.test.mjs +++ b/examples/smart-cockpit/service/test/cockpit-service.test.mjs @@ -32,6 +32,12 @@ function fixture() { async weather(city) { return { city, dayweather: '小雨', daytemp: '9', nighttemp: '5' } }, + async searchPlaces(query) { + return [{ name: query, location: '120.3,30.4' }] + }, + async searchNearbyPlaces({ keywords }) { + return [{ name: keywords, location: '120.4,30.5', distance: 800 }] + }, }, }) return { @@ -140,6 +146,91 @@ test('queries the current route without requiring another destination', async () assert.equal(current.data.navigation.status, 'navigating') }) +test('updates an active navigation route with waypoints destination and strategy', async () => { + const { service, options } = fixture() + await service.execute('navigation_start', { destination: '西湖' }, options) + + const added = await service.execute('navigation_add_waypoint', { + waypoint: '城西银泰', + }, options) + assert.match(added.content, /已增加途经点城西银泰/u) + assert.deepEqual(added.data.navigation.waypoints, ['城西银泰']) + assert.equal(added.data.navigation.map.markers.length, 2) + assert.equal(added.data.navigation.map.polylines.length, 2) + + const changed = await service.execute('navigation_change_destination', { + destination: '萧山机场', + }, options) + assert.match(changed.content, /已将目的地改为萧山机场/u) + assert.equal(changed.data.navigation.destination, '萧山机场') + assert.deepEqual(changed.data.navigation.waypoints, ['城西银泰']) + + const strategy = await service.execute('navigation_set_route_strategy', { + strategy: 5, + }, options) + assert.match(strategy.content, /不走高速/u) + assert.equal(strategy.data.navigation.strategy, 5) + + const removed = await service.execute('navigation_remove_waypoint', { + waypoint: '城西银泰', + }, options) + assert.match(removed.content, /已删除途经点城西银泰/u) + assert.deepEqual(removed.data.navigation.waypoints, []) + assert.equal(removed.data.navigation.map.markers.length, 1) +}) + +test('supports place search favorites voice and view navigation tools', async () => { + const { service, options } = fixture() + const search = await service.execute('navigation_search_place', { + query: '充电站', + nearby: true, + }, options) + assert.match(search.content, /找到1个地点/u) + assert.equal(search.data.results[0].name, '充电站') + assert.deepEqual(search.changed, []) + + const favorite = await service.execute('navigation_set_favorite', { + favoriteType: 'home', + address: '西湖', + }, options) + assert.match(favorite.content, /设置为家/u) + assert.equal(favorite.data.navigation.favorites.home.label, '家') + assert.equal(favorite.data.navigation.favorites.home.name, '西湖') + assert.equal(favorite.data.navigation.favorites.home.address, '西湖') + + const home = await service.execute('navigation_to_favorite', { + favoriteType: 'home', + }, options) + assert.match(home.content, /已开始导航到西湖/u) + assert.equal(home.data.navigation.status, 'navigating') + + const voice = await service.execute('navigation_set_voice', { + mute: true, + broadcastMode: 'brief', + }, options) + assert.match(voice.content, /导航静音/u) + assert.equal(voice.data.navigation.voice.muted, true) + assert.equal(voice.data.navigation.voice.broadcastMode, 'brief') + + const view = await service.execute('navigation_set_view', { + viewMode: 'overview', + }, options) + assert.match(view.content, /路线全览/u) + assert.equal(view.data.navigation.viewMode, 'overview') + + const invalidVoice = await service.execute('navigation_set_voice', { + broadcastMode: 'verbose', + }, options) + assert.match(invalidVoice.content, /有效的导航播报模式/u) + assert.equal(invalidVoice.data.navigation.voice.broadcastMode, 'brief') + + const invalidView = await service.execute('navigation_set_view', { + viewMode: 'bird_eye', + }, options) + assert.match(invalidView.content, /有效的导航视图模式/u) + assert.equal(invalidView.data.navigation.viewMode, 'overview') +}) + test('requires a preview and explicit confirmation before ordering', async () => { const { service, activities, options } = fixture() const premature = await service.execute('flashbuy', { @@ -198,6 +289,26 @@ test('publishes versioned snapshots after state changes', async () => { assert.equal(events[0].version, events[0].state.version) }) +test('resets cockpit state and publishes a full state update', async () => { + const { service, options } = fixture() + await service.execute('navigation_set_favorite', { + favoriteType: 'home', + address: '西湖', + }, options) + await service.execute('music_play', { query: '稻香' }, options) + + const events = [] + const unsubscribe = service.subscribe('car-one', event => events.push(event)) + const reset = service.reset('car-one') + unsubscribe() + + assert.equal(reset.version, 1) + assert.equal(reset.navigation.favorites.home, null) + assert.equal(reset.music.playing, false) + assert.deepEqual(events.at(-1).changed, ['vehicle', 'navigation', 'music', 'flashbuy', 'weather']) + assert.equal(events.at(-1).state.navigation.status, 'idle') +}) + test('stores custom skills outside transient cockpit state and publishes catalog changes', async t => { const root = await mkdtemp(resolve(tmpdir(), 'qwen-cockpit-service-skills-')) t.after(() => rm(root, { recursive: true, force: true })) diff --git a/examples/smart-cockpit/service/test/server.test.mjs b/examples/smart-cockpit/service/test/server.test.mjs index 3717963f..012dffb4 100644 --- a/examples/smart-cockpit/service/test/server.test.mjs +++ b/examples/smart-cockpit/service/test/server.test.mjs @@ -61,6 +61,14 @@ test('serves state and commands over the scenario HTTP boundary', async t => { const state = await fetch(`${server.origin}/api/cockpit/state?cockpitId=http-car`) .then(response => response.json()) assert.equal(state.vehicle.headlights, 1) + + const reset = await fetch(`${server.origin}/api/cockpit/reset`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cockpitId: 'http-car' }), + }).then(response => response.json()) + assert.equal(reset.vehicle.headlights, 0) + assert.equal(reset.navigation.status, 'idle') }) test('streams authoritative state changes to cockpit panels', async t => { @@ -173,7 +181,7 @@ test('scopes frontend tools while retaining a complete backend MCP surface', asy t.after(() => frontend.close()) const backendTools = await backend.listTools() - assert.equal(backendTools.tools.length, 18) + assert.equal(backendTools.tools.length, 27) assert.ok(backendTools.tools.some(tool => tool.name === 'vehicle_climate_control')) assert.ok(backendTools.tools.some(tool => tool.name === 'flashbuy')) assert.ok(backendTools.tools.some(tool => tool.name === 'weather')) @@ -189,6 +197,9 @@ test('scopes frontend tools while retaining a complete backend MCP surface', asy 'vehicle_sunroof_control', 'vehicle_headlights_control', 'vehicle_climate_control', + 'navigation_set_route_strategy', + 'navigation_set_voice', + 'navigation_set_view', ]) const output = await backend.callTool({ @@ -226,6 +237,23 @@ test('scopes frontend tools while retaining a complete backend MCP surface', asy assert.equal(climate.isError, undefined) assert.equal(climate.structuredContent.vehicle.acFan, 4) + await backend.callTool({ + name: 'navigation_start', + arguments: { destination: '西湖' }, + }) + const view = await frontend.callTool({ + name: 'navigation_set_view', + arguments: { viewMode: 'overview' }, + }) + assert.equal(view.isError, undefined) + assert.equal(view.structuredContent.navigation.viewMode, 'overview') + const strategy = await frontend.callTool({ + name: 'navigation_set_route_strategy', + arguments: { strategy: 4 }, + }) + assert.equal(strategy.isError, undefined) + assert.equal(strategy.structuredContent.navigation.strategy, 4) + const unavailable = await frontend.callTool({ name: 'music_pause', arguments: {}, @@ -240,6 +268,8 @@ test('scopes frontend tools while retaining a complete backend MCP surface', asy assert.equal(state.vehicle.sunroof, 1) assert.equal(state.vehicle.windowFL, 1) assert.equal(state.weather.dayweather, '晴') + assert.equal(state.navigation.viewMode, 'overview') + assert.equal(state.navigation.strategy, 4) }) test('serves persistent custom skill management to the scenario UI', async t => { diff --git a/examples/smart-cockpit/service/tools/README.md b/examples/smart-cockpit/service/tools/README.md index fb762b84..d5af4c2d 100644 --- a/examples/smart-cockpit/service/tools/README.md +++ b/examples/smart-cockpit/service/tools/README.md @@ -14,6 +14,9 @@ shared cockpit state and external services through the execution context. foreground Realtime Agent. - The backend MCP surface retains every scenario tool so the replaceable cockpit Agent can compose them in complex tasks and user-defined workflows. +- Some immediate navigation tools, such as route view, voice and current-route + strategy changes, are intentionally exposed on the foreground fast path while + remaining available on the complete backend surface. Both surfaces use the standard MCP contract. Adding a group requires no change to the Gateway protocol or the cockpit UI protocol. A domain group may safely diff --git a/examples/smart-cockpit/service/tools/navigation/README.md b/examples/smart-cockpit/service/tools/navigation/README.md new file mode 100644 index 00000000..661e8c7b --- /dev/null +++ b/examples/smart-cockpit/service/tools/navigation/README.md @@ -0,0 +1,111 @@ +# Navigation tool design + +本文记录 smart-cockpit 示例中的导航 function call 设计。 + +## 设计定位 + +导航工具保持车机业务工具粒度,而不是把高德 MCP 的 `maps_*` 基础能力直接暴露给模型。 + +设计分层: + +- 模型可见工具:表达用户意图和车机导航动作,例如开始导航、查询路线、增加途经点、修改路线偏好。 +- 服务层能力:编排高德 MCP / 高德 Web API,例如地点搜索、地理编码、周边搜索、驾车路线规划。 +- 状态层:维护当前导航状态、目的地、途经点、常用地点、路线、地图 marker/polyline、语音和视图设置。 + +这样可以减少模型在低层地图 API 之间的选择负担,同时保留车机场景需要的运行态控制。 + +## 工具大类 + +| 大类 | 工具 | 目标 | +| --- | --- | --- | +| 路线生命周期 | `navigation_start`, `navigation_route_query`, `navigation_stop` | 开始导航、查询/预览路线、停止导航 | +| 导航中修改 | `navigation_add_waypoint`, `navigation_remove_waypoint`, `navigation_change_destination`, `navigation_set_route_strategy` | 在当前路线基础上加点、删点、改终点、改偏好 | +| 地点/POI 查询 | `navigation_search_place` | 搜索地点或附近 POI,但不开始导航 | +| 常用地点 | `navigation_to_favorite`, `navigation_set_favorite` | 导航到家/公司/学校等常用地点,或设置常用地点 | +| 呈现/播报 | `navigation_set_voice`, `navigation_set_view` | 设置导航静音、播报模式和地图视图 | + +## Function call 表 + +| Function | 什么时候调用 | 关键参数 | 状态影响 | 内部对应高德能力 | +| --- | --- | --- | --- | --- | +| `navigation_start` | 用户明确说“导航到”“带我去”“去某地”或“开始导航” | `destination`, `waypoints?`, `strategy?` | `status=navigating`,写入目的地、途经点和路线 | `maps_text_search` / `maps_search_detail` / `maps_geo` + 驾车路线 | +| `navigation_route_query` | 用户只想看路线、多久、多远、怎么走、先看看路线 | `destination?`, `waypoints?`, `strategy?` | 有目的地时 `status=preview`;无目的地时只读当前路线 | 有目的地时同上;无目的地时不调用高德 | +| `navigation_stop` | 停止、退出、取消导航 | 无 | `status=idle`,清空目的地、途经点、路线和地图图层 | 本地状态操作 | +| `navigation_add_waypoint` | “中途去一下”“顺路去”“加个途经点”“先去一下” | `waypoint`, `insertPosition?`, `strategy?` | 保持当前 `status`,插入途经点并重新规划 | 地点解析 + 驾车路线 | +| `navigation_remove_waypoint` | “取消途经点”“不去刚才那个地方了”“删掉第几个途经点” | `waypoint?`, `index?` | 保持当前 `status`,删除途经点并重新规划 | 本地状态 + 驾车路线 | +| `navigation_change_destination` | “目的地改成”“换个地方”“不去那里了去这里” | `destination`, `strategy?` | 保持当前 `status`,改最终目的地并重新规划 | 地点解析 + 驾车路线 | +| `navigation_set_route_strategy` | “换成不走高速”“改成少收费”“避开拥堵” | `strategy` | 保持当前 `status`,改路线偏好并重新规划 | 驾车路线 | +| `navigation_search_place` | “附近有没有充电站”“找个加油站”,但没有要求导航 | `query?`, `category?`, `nearby?`, `radius?` | 不修改导航状态 | `maps_text_search` / `maps_around_search` / `maps_search_detail` | +| `navigation_to_favorite` | “回家”“去公司”“去学校” | `favoriteType`, `strategy?` | `status=navigating`,基于常用地点开始导航 | 读取本地收藏地址 + 驾车路线 | +| `navigation_set_favorite` | “把这里设为家”“设置公司地址”“把某地设为学校” | `favoriteType`, `address?`, `useCurrentLocation?` | 更新 `navigation.favorites` | 地点解析或当前位置 | +| `navigation_set_voice` | “导航静音”“取消静音”“详细播报”“简洁播报” | `mute?`, `broadcastMode?` | 更新 `navigation.voice` | 本地状态操作 | +| `navigation_set_view` | “查看全程”“回到车头视角”“切到全览” | `viewMode` | 更新 `navigation.viewMode` | 本地状态操作 | + +## 路线偏好 + +`strategy` 同时用于新路线和当前路线重规划: + +- 用户给新目的地时,作为 `navigation_start` 或 `navigation_route_query` 参数。 +- 用户只要求修改当前路线偏好时,调用 `navigation_set_route_strategy`。 + +| 值 | 含义 | +| --- | --- | +| `0` | 智能推荐 | +| `13` | 高速优先 | +| `5` | 不走高速 | +| `4` | 躲避拥堵 | +| `11` | 少收费 | +| `14` | 大路优先 | +| `2` | 时间优先 | + +示例: + +| 用户话术 | 推荐调用 | +| --- | --- | +| “导航去机场,别走高速” | `navigation_start({ destination: "机场", strategy: 5 })` | +| “查一下去西湖最快路线” | `navigation_route_query({ destination: "西湖", strategy: 2 })` | +| “换成少收费路线” | `navigation_set_route_strategy({ strategy: 11 })` | +| “改成高速优先去上海” | `navigation_start({ destination: "上海", strategy: 13 })` | + +## 高德 MCP 对应关系 + +路线类工具是业务编排工具,内部大致链路: + +1. 通过 `maps_text_search` 搜索 POI。 +2. 必要时用 `maps_search_detail` 查询 POI 详情。 +3. 搜索失败时用 `maps_geo` 做地址到坐标的兜底解析。 +4. 调用驾车路线能力规划路线。当前代码直接请求高德驾车路线 Web API,语义上对应 `maps_direction_driving`。 +5. 将路线结果写入本地 `navigation` 状态,并更新地图 marker/polyline。 + +地点查询类工具: + +- 普通地点搜索使用 `maps_text_search`。 +- 周边搜索使用 `maps_around_search`。 +- 需要补全 POI 坐标时使用 `maps_search_detail`。 + +本地控制类工具不对应高德 MCP: + +- `navigation_stop` +- `navigation_set_voice` +- `navigation_set_view` + +## 常用地点展示 + +常用地点状态服务于导航和 UI 展示,推荐保存为 `{ label, name, address, location }`: + +- `label` 是用户可识别的类型,例如“家”“公司”。 +- `name` 是设置时使用的地点名或 POI 名。 +- `address` 是 UI 副标题,缺省时可回退到 `name`。 +- `location` 是路线规划使用的经纬度。 + +主地图空闲态显示目的地搜索面板:顶部是“搜索目的地”入口,下方是“回家/去公司”快捷行。未设置时显示“点击设置”,已设置时显示地点信息和导航入口。具体 marker 不常驻地图,只在用户查看常用地点或导航到该地点时强调。 + +## 调用原则 + +- 用户给新目的地时,调用 `navigation_start` 或 `navigation_route_query`。 +- 用户在已有导航中修改路线时,调用 `navigation_add_waypoint`、`navigation_remove_waypoint`、`navigation_change_destination` 或 `navigation_set_route_strategy`。 +- 用户只是找地点时,调用 `navigation_search_place`。 +- 常用地点导航使用 `navigation_to_favorite`,设置常用地点使用 `navigation_set_favorite`。 +- 导航语音和视图设置不要混入路线工具,分别使用 `navigation_set_voice` 和 `navigation_set_view`。 +- 高德 MCP 基础能力由服务层编排,不直接暴露给模型。 +- 不按话术或固定参数拆工具;话术写入 description/examples,固定值做成枚举参数。 diff --git a/examples/smart-cockpit/service/tools/navigation/execute.mjs b/examples/smart-cockpit/service/tools/navigation/execute.mjs index 56d297a2..bce05f47 100644 --- a/examples/smart-cockpit/service/tools/navigation/execute.mjs +++ b/examples/smart-cockpit/service/tools/navigation/execute.mjs @@ -1,8 +1,87 @@ import { DEFAULT_ORIGIN } from './catalog.mjs' import { clean, reportActivity, toolResult } from '../shared.mjs' -async function planRoute(destination, waypoints, strategy, { now, services }) { - const stops = [DEFAULT_ORIGIN.location, ...waypoints, destination] +const MAX_WAYPOINTS = 8 +const VALID_STRATEGIES = new Set([0, 13, 5, 4, 11, 14, 2]) +const VALID_VOICE_MODES = new Set(['standard', 'detailed', 'brief']) +const VALID_VIEW_MODES = new Set(['follow', 'overview', 'north_up']) +const STRATEGY_LABELS = new Map([ + [0, '智能推荐'], + [13, '高速优先'], + [5, '不走高速'], + [4, '躲避拥堵'], + [11, '少收费'], + [14, '大路优先'], + [2, '时间优先'], +]) +const FAVORITE_LABELS = { + home: '家', + office: '公司', + school: '学校', + custom: '常用地点', +} + +function normalizeStrategy(value, fallback = 0) { + if (value === undefined || value === null || value === '') return fallback + const strategy = Number(value) + return VALID_STRATEGIES.has(strategy) ? strategy : fallback +} + +function currentOrigin(state) { + return state.navigation?.currentLocation || DEFAULT_ORIGIN.location +} + +function activeNavigation(state) { + const navigation = state.navigation || {} + return navigation.status !== 'idle' && Boolean(navigation.destination) +} + +function routeStatusText(status) { + return status === 'navigating' ? '导航' : '规划' +} + +function markerLocation(navigation, role, index = null) { + return (navigation.map?.markers || []).find(marker => ( + marker.role === role && (index === null || marker.index === index) + ))?.location || null +} + +function knownDestinationLocation(navigation) { + return navigation.destinationLocation || markerLocation(navigation, 'destination') +} + +function knownWaypointLocations(navigation) { + if (Array.isArray(navigation.waypointLocations) && navigation.waypointLocations.length) { + return [...navigation.waypointLocations] + } + return (navigation.waypoints || []).map((_, index) => ( + markerLocation(navigation, 'waypoint', index) + )) +} + +async function resolvePlace(name, city, services) { + return services.resolvePlace(clean(name), city) +} + +async function resolveExistingRouteLocations(navigation, services) { + let destinationLocation = knownDestinationLocation(navigation) + if (!destinationLocation && navigation.destination) { + destinationLocation = await resolvePlace(navigation.destination, DEFAULT_ORIGIN.city, services) + } + const waypointLocations = knownWaypointLocations(navigation) + const resolvedWaypoints = [] + for (let index = 0; index < (navigation.waypoints || []).length; index += 1) { + resolvedWaypoints.push( + waypointLocations[index] + || await resolvePlace(navigation.waypoints[index], DEFAULT_ORIGIN.city, services), + ) + } + if (!destinationLocation || resolvedWaypoints.some(location => !location)) return null + return { destinationLocation, waypointLocations: resolvedWaypoints } +} + +async function planRoute(origin, destination, waypoints, strategy, { now, services }) { + const stops = [origin, ...waypoints, destination] const legs = [] for (let index = 1; index < stops.length; index += 1) { const leg = await services.drivingRoute(stops[index - 1], stops[index], strategy) @@ -22,21 +101,458 @@ async function planRoute(destination, waypoints, strategy, { now, services }) { } } +function buildNavigationMap(destination, destinationLocation, waypoints, waypointLocations, route) { + return { + markers: [ + { role: 'destination', name: destination, location: destinationLocation }, + ...waypoints.map((name, index) => ({ + role: 'waypoint', + index, + name, + location: waypointLocations[index], + })), + ], + polylines: route.legs.map((leg, index) => ({ + segment: index, + polyline: leg.polyline, + trafficSegments: leg.trafficSegments || [], + })), + } +} + +function commitRoute(store, cockpitId, { + status, + destination, + destinationLocation, + waypoints, + waypointLocations, + strategy, + route, +}) { + return store.update(cockpitId, ['navigation'], next => { + next.navigation = { + ...next.navigation, + status, + destination, + destinationLocation, + waypoints, + waypointLocations, + strategy, + route, + map: buildNavigationMap(destination, destinationLocation, waypoints, waypointLocations, route), + } + }) +} + +function routeContent(prefix, destination, waypoints, route, strategy = null) { + const waypointText = waypoints.length ? `,途经${waypoints.join('、')}` : '' + const strategyText = strategy === null ? '' : `,${STRATEGY_LABELS.get(strategy) || '已更新偏好'}` + return `${prefix}${destination}${waypointText}${strategyText},全程${route.distKm}公里,约${route.durationMin}分钟` +} + +async function createRoutePlan({ + name, + status, + destination, + waypoints, + strategy, + context, +}) { + const { cockpitId, onActivity, services, snapshot, store } = context + const origin = currentOrigin(snapshot()) + reportActivity(onActivity, 'navigation', 'searching_destination', '正在查找目的地') + const destinationLocation = await resolvePlace(destination, DEFAULT_ORIGIN.city, services) + if (!destinationLocation) { + reportActivity(onActivity, 'navigation', 'destination_not_found', `没有找到${destination}`) + return toolResult(`无法找到“${destination}”的位置信息`, snapshot(), []) + } + reportActivity(onActivity, 'navigation', 'destination_locked', `已找到${destination}`) + const waypointLocations = [] + for (const waypoint of waypoints) { + reportActivity(onActivity, 'navigation', 'searching_waypoint', `正在查找途经点${waypoint}`) + const location = await resolvePlace(waypoint, DEFAULT_ORIGIN.city, services) + if (!location) { + reportActivity(onActivity, 'navigation', 'waypoint_not_found', `没有找到${waypoint}`) + return toolResult(`无法找到途经点“${waypoint}”的位置信息`, snapshot(), []) + } + waypointLocations.push(location) + reportActivity(onActivity, 'navigation', 'waypoint_locked', `已找到途经点${waypoint}`) + } + reportActivity(onActivity, 'navigation', 'planning_route', '正在规划路线') + const route = await planRoute(origin, destinationLocation, waypointLocations, strategy, context) + if (!route) { + reportActivity(onActivity, 'navigation', 'route_failed', '路线规划失败') + return toolResult('路线规划失败,请稍后重试', snapshot(), []) + } + const state = commitRoute(store, cockpitId, { + status, + destination, + destinationLocation, + waypoints, + waypointLocations, + strategy, + route, + }) + const activityStatus = name === 'navigation_start' ? 'navigation_started' : 'route_ready' + reportActivity(onActivity, 'navigation', activityStatus, name === 'navigation_start' ? '开始导航' : '路线规划好了') + const prefix = name === 'navigation_start' ? '已开始导航到' : '已规划到' + return toolResult( + routeContent(prefix, destination, waypoints, route), + state, + ['navigation'], + { navigation: state.navigation }, + ) +} + +async function replanExistingRoute({ + context, + status, + destination, + destinationLocation, + waypoints, + waypointLocations, + strategy, + planningMessage = '正在重新规划路线', +}) { + const { cockpitId, onActivity, snapshot, store } = context + reportActivity(onActivity, 'navigation', 'planning_route', planningMessage) + const route = await planRoute(currentOrigin(snapshot()), destinationLocation, waypointLocations, strategy, context) + if (!route) { + reportActivity(onActivity, 'navigation', 'route_failed', '路线规划失败') + return toolResult('路线规划失败,请稍后重试', snapshot(), []) + } + const state = commitRoute(store, cockpitId, { + status, + destination, + destinationLocation, + waypoints, + waypointLocations, + strategy, + route, + }) + reportActivity(onActivity, 'navigation', 'route_ready', '路线已更新') + return { state, route } +} + +async function addWaypoint(args, context) { + const { onActivity, services, snapshot } = context + const state = snapshot() + if (!activeNavigation(state)) { + return toolResult('当前没有进行中的导航,请先告诉我要去哪里', state, [], { + navigation: state.navigation, + }) + } + const waypoint = clean(args.waypoint) + if (!waypoint) return toolResult('请告诉我要增加哪个途经点', state, [], { navigation: state.navigation }) + const existing = await resolveExistingRouteLocations(state.navigation, services) + if (!existing) return toolResult('当前路线信息不完整,请重新发起导航', state, [], { navigation: state.navigation }) + + reportActivity(onActivity, 'navigation', 'searching_waypoint', `正在查找途经点${waypoint}`) + const waypointLocation = await resolvePlace(waypoint, DEFAULT_ORIGIN.city, services) + if (!waypointLocation) { + reportActivity(onActivity, 'navigation', 'waypoint_not_found', `没有找到${waypoint}`) + return toolResult(`无法找到途经点“${waypoint}”的位置信息`, snapshot(), []) + } + reportActivity(onActivity, 'navigation', 'waypoint_locked', `已找到途经点${waypoint}`) + const insertIndex = args.insertPosition === 'before_destination' ? state.navigation.waypoints.length : 0 + const waypoints = [...state.navigation.waypoints] + const waypointLocations = [...existing.waypointLocations] + waypoints.splice(insertIndex, 0, waypoint) + waypointLocations.splice(insertIndex, 0, waypointLocation) + const strategy = normalizeStrategy(args.strategy, state.navigation.strategy) + const output = await replanExistingRoute({ + context, + status: state.navigation.status, + destination: state.navigation.destination, + destinationLocation: existing.destinationLocation, + waypoints, + waypointLocations, + strategy, + }) + if (output.content) return output + return toolResult( + routeContent(`已增加途经点${waypoint},继续${routeStatusText(output.state.navigation.status)}到`, output.state.navigation.destination, waypoints, output.route), + output.state, + ['navigation'], + { navigation: output.state.navigation }, + ) +} + +async function removeWaypoint(args, context) { + const { services, snapshot } = context + const state = snapshot() + if (!activeNavigation(state)) { + return toolResult('当前没有进行中的导航,请先告诉我要去哪里', state, [], { + navigation: state.navigation, + }) + } + if (!state.navigation.waypoints.length) { + return toolResult('当前路线没有途经点', state, [], { navigation: state.navigation }) + } + const waypointName = clean(args.waypoint) + const requestedIndex = Number(args.index) + const index = waypointName + ? state.navigation.waypoints.findIndex(item => item === waypointName) + : Number.isInteger(requestedIndex) ? requestedIndex - 1 : -1 + if (index < 0 || index >= state.navigation.waypoints.length) { + return toolResult('没有找到要删除的途经点', state, [], { navigation: state.navigation }) + } + const existing = await resolveExistingRouteLocations(state.navigation, services) + if (!existing) return toolResult('当前路线信息不完整,请重新发起导航', state, [], { navigation: state.navigation }) + + const removed = state.navigation.waypoints[index] + const waypoints = state.navigation.waypoints.filter((_, itemIndex) => itemIndex !== index) + const waypointLocations = existing.waypointLocations.filter((_, itemIndex) => itemIndex !== index) + const output = await replanExistingRoute({ + context, + status: state.navigation.status, + destination: state.navigation.destination, + destinationLocation: existing.destinationLocation, + waypoints, + waypointLocations, + strategy: state.navigation.strategy, + }) + if (output.content) return output + return toolResult( + routeContent(`已删除途经点${removed},继续${routeStatusText(output.state.navigation.status)}到`, output.state.navigation.destination, waypoints, output.route), + output.state, + ['navigation'], + { navigation: output.state.navigation }, + ) +} + +async function changeDestination(args, context) { + const { onActivity, services, snapshot } = context + const state = snapshot() + if (!activeNavigation(state)) { + return toolResult('当前没有进行中的导航,请先告诉我要去哪里', state, [], { + navigation: state.navigation, + }) + } + const destination = clean(args.destination) + if (!destination) return toolResult('请告诉我要把目的地改成哪里', state, [], { navigation: state.navigation }) + + reportActivity(onActivity, 'navigation', 'searching_destination', '正在查找目的地') + const destinationLocation = await resolvePlace(destination, DEFAULT_ORIGIN.city, services) + if (!destinationLocation) { + reportActivity(onActivity, 'navigation', 'destination_not_found', `没有找到${destination}`) + return toolResult(`无法找到“${destination}”的位置信息`, snapshot(), []) + } + reportActivity(onActivity, 'navigation', 'destination_locked', `已找到${destination}`) + const existing = await resolveExistingRouteLocations(state.navigation, services) + if (!existing) return toolResult('当前路线信息不完整,请重新发起导航', state, [], { navigation: state.navigation }) + const strategy = normalizeStrategy(args.strategy, state.navigation.strategy) + const output = await replanExistingRoute({ + context, + status: state.navigation.status, + destination, + destinationLocation, + waypoints: state.navigation.waypoints, + waypointLocations: existing.waypointLocations, + strategy, + }) + if (output.content) return output + return toolResult( + routeContent('已将目的地改为', destination, output.state.navigation.waypoints, output.route), + output.state, + ['navigation'], + { navigation: output.state.navigation }, + ) +} + +async function setRouteStrategy(args, context) { + const { services, snapshot } = context + const state = snapshot() + if (!activeNavigation(state)) { + return toolResult('当前没有路线,请先告诉我要去哪里', state, [], { + navigation: state.navigation, + }) + } + const strategy = normalizeStrategy(args.strategy, null) + if (strategy === null) return toolResult('请提供有效的路线偏好', state, [], { navigation: state.navigation }) + const existing = await resolveExistingRouteLocations(state.navigation, services) + if (!existing) return toolResult('当前路线信息不完整,请重新发起导航', state, [], { navigation: state.navigation }) + const output = await replanExistingRoute({ + context, + status: state.navigation.status, + destination: state.navigation.destination, + destinationLocation: existing.destinationLocation, + waypoints: state.navigation.waypoints, + waypointLocations: existing.waypointLocations, + strategy, + }) + if (output.content) return output + return toolResult( + routeContent('已切换路线偏好,继续到', output.state.navigation.destination, output.state.navigation.waypoints, output.route, strategy), + output.state, + ['navigation'], + { navigation: output.state.navigation }, + ) +} + +async function searchPlace(args, context) { + const { onActivity, services, snapshot } = context + const state = snapshot() + const query = clean(args.query) || clean(args.category) + if (!query) return toolResult('请告诉我要搜索什么地点', state, [], { results: [] }) + reportActivity(onActivity, 'navigation', 'place_searching', `正在搜索${query}`) + const radius = Number(args.radius) > 0 ? Number(args.radius) : 3000 + let results = [] + if (args.nearby && typeof services.searchNearbyPlaces === 'function') { + results = await services.searchNearbyPlaces({ + keywords: query, + location: currentOrigin(state), + radius, + }) + } else if (typeof services.searchPlaces === 'function') { + results = await services.searchPlaces(query, { + city: DEFAULT_ORIGIN.city, + types: clean(args.category), + }) + } + if (!results.length) { + const location = await resolvePlace(query, DEFAULT_ORIGIN.city, services) + if (location) results = [{ name: query, location }] + } + reportActivity(onActivity, 'navigation', 'place_results_ready', results.length ? '地点搜索完成' : '没有找到相关地点') + const content = results.length + ? `找到${results.length}个地点:${results.slice(0, 3).map(item => item.name || item.location).join('、')}` + : `没有找到“${query}”相关地点` + return toolResult(content, state, [], { results }) +} + +async function navigateToFavorite(args, context) { + const { snapshot } = context + const state = snapshot() + const favoriteType = clean(args.favoriteType) + const favorite = state.navigation.favorites?.[favoriteType] + if (!favorite?.location) { + return toolResult(`还没有设置${FAVORITE_LABELS[favoriteType] || '这个常用地点'}`, state, [], { + navigation: state.navigation, + }) + } + return createRoutePlan({ + name: 'navigation_start', + status: 'navigating', + destination: favorite.name || FAVORITE_LABELS[favoriteType] || '常用地点', + waypoints: [], + strategy: normalizeStrategy(args.strategy, 0), + context: { + ...context, + services: { + ...context.services, + async resolvePlace() { return favorite.location }, + }, + }, + }) +} + +async function setFavorite(args, context) { + const { cockpitId, services, snapshot, store } = context + const state = snapshot() + const favoriteType = clean(args.favoriteType) + if (!Object.hasOwn(FAVORITE_LABELS, favoriteType)) { + return toolResult('请提供有效的常用地点类型', state, [], { navigation: state.navigation }) + } + const address = clean(args.address) + let location = null + let name = address + let displayAddress = address + if (args.useCurrentLocation) { + location = currentOrigin(state) + name = '当前位置' + displayAddress = '当前位置' + } else if (address) { + location = await resolvePlace(address, DEFAULT_ORIGIN.city, services) + } + if (!location) { + return toolResult(`无法设置${FAVORITE_LABELS[favoriteType]},请提供有效地址或使用当前位置`, state, [], { + navigation: state.navigation, + }) + } + const nextState = store.update(cockpitId, ['navigation'], next => { + next.navigation = { + ...next.navigation, + favorites: { + ...next.navigation.favorites, + [favoriteType]: { + label: FAVORITE_LABELS[favoriteType], + name, + address: displayAddress, + location, + }, + }, + } + }) + return toolResult(`已将${name}设置为${FAVORITE_LABELS[favoriteType]}`, nextState, ['navigation'], { + navigation: nextState.navigation, + }) +} + +function setVoice(args, context) { + const { cockpitId, snapshot, store } = context + const state = snapshot() + const hasMute = typeof args.mute === 'boolean' + const broadcastMode = clean(args.broadcastMode) + if (!hasMute && !broadcastMode) { + return toolResult('请告诉我要设置导航静音还是播报模式', state, [], { navigation: state.navigation }) + } + if (broadcastMode && !VALID_VOICE_MODES.has(broadcastMode)) { + return toolResult('请提供有效的导航播报模式', state, [], { navigation: state.navigation }) + } + const nextState = store.update(cockpitId, ['navigation'], next => { + next.navigation = { + ...next.navigation, + voice: { + ...next.navigation.voice, + muted: hasMute ? args.mute : next.navigation.voice?.muted || false, + broadcastMode: broadcastMode || next.navigation.voice?.broadcastMode || 'standard', + }, + } + }) + const parts = [] + if (hasMute) parts.push(args.mute ? '已开启导航静音' : '已关闭导航静音') + if (broadcastMode) { + const modeText = broadcastMode === 'detailed' ? '详细播报' : broadcastMode === 'brief' ? '简洁播报' : '标准播报' + parts.push(`已切换到${modeText}`) + } + return toolResult(parts.join(','), nextState, ['navigation'], { navigation: nextState.navigation }) +} + +function setView(args, context) { + const { cockpitId, snapshot, store } = context + const state = snapshot() + const viewMode = clean(args.viewMode) + if (!viewMode) return toolResult('请告诉我要切换到哪种导航视图', state, [], { navigation: state.navigation }) + if (!VALID_VIEW_MODES.has(viewMode)) { + return toolResult('请提供有效的导航视图模式', state, [], { navigation: state.navigation }) + } + const nextState = store.update(cockpitId, ['navigation'], next => { + next.navigation = { + ...next.navigation, + viewMode, + } + }) + const modeText = viewMode === 'overview' ? '路线全览' : viewMode === 'north_up' ? '北向上' : '跟车视角' + return toolResult(`已切换到${modeText}`, nextState, ['navigation'], { navigation: nextState.navigation }) +} + export async function executeNavigationTool(name, args, context) { const { cockpitId, onActivity, - services, snapshot, store, } = context if (name === 'navigation_stop') { const state = store.update(cockpitId, ['navigation'], next => { next.navigation = { + ...next.navigation, status: 'idle', destination: null, + destinationLocation: null, waypoints: [], - strategy: next.navigation.strategy, + waypointLocations: [], route: null, map: { markers: [], polylines: [] }, } @@ -45,6 +561,16 @@ export async function executeNavigationTool(name, args, context) { return toolResult('已停止导航', state, ['navigation'], { navigation: state.navigation }) } + if (name === 'navigation_add_waypoint') return addWaypoint(args, context) + if (name === 'navigation_remove_waypoint') return removeWaypoint(args, context) + if (name === 'navigation_change_destination') return changeDestination(args, context) + if (name === 'navigation_set_route_strategy') return setRouteStrategy(args, context) + if (name === 'navigation_search_place') return searchPlace(args, context) + if (name === 'navigation_to_favorite') return navigateToFavorite(args, context) + if (name === 'navigation_set_favorite') return setFavorite(args, context) + if (name === 'navigation_set_voice') return setVoice(args, context) + if (name === 'navigation_set_view') return setView(args, context) + const destination = clean(args.destination) if (!destination && name === 'navigation_route_query') { const state = snapshot() @@ -58,7 +584,7 @@ export async function executeNavigationTool(name, args, context) { ? `,途经${navigation.waypoints.join('、')}` : '' return toolResult( - `当前正${navigation.status === 'navigating' ? '导航' : '规划'}到${navigation.destination}${waypointText},` + `当前正${routeStatusText(navigation.status)}到${navigation.destination}${waypointText},` + `全程${navigation.route.distKm}公里,约${navigation.route.durationMin}分钟`, state, [], @@ -69,63 +595,13 @@ export async function executeNavigationTool(name, args, context) { const waypoints = (Array.isArray(args.waypoints) ? args.waypoints : []) .map(clean) .filter(Boolean) - .slice(0, 8) - const strategy = Number(args.strategy) || 0 - reportActivity(onActivity, 'navigation', 'searching_destination', '正在查找目的地') - const destinationLocation = await services.resolvePlace(destination, DEFAULT_ORIGIN.city) - if (!destinationLocation) { - reportActivity(onActivity, 'navigation', 'destination_not_found', `没有找到${destination}`) - const state = snapshot() - return toolResult(`无法找到“${destination}”的位置信息`, state, []) - } - reportActivity(onActivity, 'navigation', 'destination_locked', `已找到${destination}`) - const waypointLocations = [] - for (const waypoint of waypoints) { - reportActivity(onActivity, 'navigation', 'searching_waypoint', `正在查找途经点${waypoint}`) - const location = await services.resolvePlace(waypoint, DEFAULT_ORIGIN.city) - if (!location) { - reportActivity(onActivity, 'navigation', 'waypoint_not_found', `没有找到${waypoint}`) - return toolResult(`无法找到途经点“${waypoint}”的位置信息`, snapshot(), []) - } - waypointLocations.push(location) - reportActivity(onActivity, 'navigation', 'waypoint_locked', `已找到途经点${waypoint}`) - } - reportActivity(onActivity, 'navigation', 'planning_route', '正在规划路线') - const route = await planRoute(destinationLocation, waypointLocations, strategy, context) - if (!route) { - reportActivity(onActivity, 'navigation', 'route_failed', '路线规划失败') - return toolResult('路线规划失败,请稍后重试', snapshot(), []) - } - const state = store.update(cockpitId, ['navigation'], next => { - next.navigation = { - status: name === 'navigation_start' ? 'navigating' : 'preview', - destination, - waypoints, - strategy, - route, - map: { - markers: [ - { role: 'destination', name: destination, location: destinationLocation }, - ...waypoints.map((name, index) => ({ - role: 'waypoint', - index, - name, - location: waypointLocations[index], - })), - ], - polylines: route.legs.map((leg, index) => ({ - segment: index, - polyline: leg.polyline, - trafficSegments: leg.trafficSegments || [], - })), - }, - } + .slice(0, MAX_WAYPOINTS) + return createRoutePlan({ + name, + status: name === 'navigation_start' ? 'navigating' : 'preview', + destination, + waypoints, + strategy: normalizeStrategy(args.strategy, 0), + context, }) - const status = name === 'navigation_start' ? 'navigation_started' : 'route_ready' - reportActivity(onActivity, 'navigation', status, name === 'navigation_start' ? '开始导航' : '路线规划好了') - const waypointText = waypoints.length ? `,途经${waypoints.join('、')}` : '' - const content = name === 'navigation_start' - ? `已开始导航到${destination}${waypointText},全程${route.distKm}公里,约${route.durationMin}分钟` - : `已规划到${destination}${waypointText}的路线,全程${route.distKm}公里,约${route.durationMin}分钟` - return toolResult(content, state, ['navigation'], { navigation: state.navigation }) } diff --git a/examples/smart-cockpit/service/tools/navigation/manifest.json b/examples/smart-cockpit/service/tools/navigation/manifest.json index 2d029f69..4f4ea61e 100644 --- a/examples/smart-cockpit/service/tools/navigation/manifest.json +++ b/examples/smart-cockpit/service/tools/navigation/manifest.json @@ -1,7 +1,7 @@ { "domain": "navigation", "label": "导航", - "description": "座舱示例的地点搜索、路线规划、开始导航与停止导航能力。", + "description": "座舱示例的地点搜索、路线规划、导航中修改、常用地点与导航呈现能力。", "enabled": true, "functions": [ { @@ -88,6 +88,265 @@ "type": "object", "properties": {} } + }, + { + "name": "navigation_add_waypoint", + "label": "增加途经点", + "description": "在当前导航或路线预览中增加一个途经点并重新规划路线。用户说“中途去一下”“顺路去”“加个途经点”“先去一下”时使用;如果用户同时给出新的最终目的地,应使用 navigation_start 或 navigation_change_destination。", + "enabled": true, + "parameters": { + "type": "object", + "properties": { + "waypoint": { + "type": "string", + "description": "要增加的途经点名称或地址" + }, + "insertPosition": { + "type": "string", + "enum": [ + "next", + "before_destination" + ], + "description": "插入位置,默认 next 表示作为下一站;before_destination 表示插入到最终目的地之前" + }, + "strategy": { + "type": "number", + "enum": [ + 0, + 13, + 5, + 4, + 11, + 14, + 2 + ], + "description": "路线偏好;未提供时沿用当前导航策略" + } + }, + "required": [ + "waypoint" + ] + } + }, + { + "name": "navigation_remove_waypoint", + "label": "删除途经点", + "description": "从当前导航或路线预览中删除一个途经点并重新规划路线。用户说“取消途经点”“不去刚才那个地方了”“删掉第几个途经点”时使用。", + "enabled": true, + "parameters": { + "type": "object", + "properties": { + "waypoint": { + "type": "string", + "description": "要删除的途经点名称;和 index 二选一" + }, + "index": { + "type": "number", + "description": "要删除的途经点序号,从 1 开始;和 waypoint 二选一" + } + } + } + }, + { + "name": "navigation_change_destination", + "label": "变更目的地", + "description": "在当前导航或路线预览中将最终目的地改成新地点,并保留现有途经点重新规划路线。用户说“目的地改成”“换个地方”“不去那里了去这里”时使用。", + "enabled": true, + "parameters": { + "type": "object", + "properties": { + "destination": { + "type": "string", + "description": "新的最终目的地名称或地址" + }, + "strategy": { + "type": "number", + "enum": [ + 0, + 13, + 5, + 4, + 11, + 14, + 2 + ], + "description": "路线偏好;未提供时沿用当前导航策略" + } + }, + "required": [ + "destination" + ] + } + }, + { + "name": "navigation_set_route_strategy", + "label": "切换路线偏好", + "description": "在当前导航或路线预览中切换路线偏好,并基于当前目的地和途经点重新规划路线。用户说“换成不走高速”“改成少收费”“避开拥堵”时使用;如果用户同时给出新目的地,应使用 navigation_start 或 navigation_route_query。", + "enabled": true, + "parameters": { + "type": "object", + "properties": { + "strategy": { + "type": "number", + "enum": [ + 0, + 13, + 5, + 4, + 11, + 14, + 2 + ], + "description": "路线偏好: 0=智能推荐, 13=高速优先, 5=不走高速, 4=躲避拥堵, 11=少收费, 14=大路优先, 2=时间优先" + } + }, + "required": [ + "strategy" + ] + } + }, + { + "name": "navigation_search_place", + "label": "地点搜索", + "description": "搜索地点或周边 POI,但不开始导航。用户只说“找一下”“附近有没有”“查一下哪里有”时使用;如果用户明确要去某地,应使用 navigation_start。", + "enabled": true, + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "搜索关键词,例如充电站、加油站、餐厅或具体地点名称" + }, + "category": { + "type": "string", + "description": "POI 类别;当用户表达“我饿了”等隐含需求时可归一为 restaurant" + }, + "nearby": { + "type": "boolean", + "description": "是否按当前位置周边搜索" + }, + "radius": { + "type": "number", + "description": "周边搜索半径,单位米" + } + } + } + }, + { + "name": "navigation_to_favorite", + "label": "导航到常用地点", + "description": "导航到已保存的常用地点。用户说“回家”“去公司”“去学校”等常用地点导航时使用。", + "enabled": true, + "parameters": { + "type": "object", + "properties": { + "favoriteType": { + "type": "string", + "enum": [ + "home", + "office", + "school", + "custom" + ], + "description": "常用地点类型" + }, + "strategy": { + "type": "number", + "enum": [ + 0, + 13, + 5, + 4, + 11, + 14, + 2 + ], + "description": "路线偏好;未提供时使用智能推荐" + } + }, + "required": [ + "favoriteType" + ] + } + }, + { + "name": "navigation_set_favorite", + "label": "设置常用地点", + "description": "设置或更新常用地点。用户说“把这里设为家”“设置公司地址”“把某地设为学校”时使用。", + "enabled": true, + "parameters": { + "type": "object", + "properties": { + "favoriteType": { + "type": "string", + "enum": [ + "home", + "office", + "school", + "custom" + ], + "description": "常用地点类型" + }, + "address": { + "type": "string", + "description": "常用地点名称或地址;useCurrentLocation 为 true 时可省略" + }, + "useCurrentLocation": { + "type": "boolean", + "description": "是否把当前位置保存为常用地点" + } + }, + "required": [ + "favoriteType" + ] + } + }, + { + "name": "navigation_set_voice", + "label": "导航语音设置", + "description": "设置导航静音或播报模式。用户说“导航静音”“取消静音”“详细播报”“简洁播报”时使用。", + "enabled": true, + "parameters": { + "type": "object", + "properties": { + "mute": { + "type": "boolean", + "description": "是否开启导航静音" + }, + "broadcastMode": { + "type": "string", + "enum": [ + "standard", + "detailed", + "brief" + ], + "description": "播报模式:standard=标准播报, detailed=详细播报, brief=简洁播报" + } + } + } + }, + { + "name": "navigation_set_view", + "label": "导航视图设置", + "description": "设置地图或导航视图。用户说“查看全程”“回到车头视角”“切到全览”时使用。", + "enabled": true, + "parameters": { + "type": "object", + "properties": { + "viewMode": { + "type": "string", + "enum": [ + "follow", + "overview", + "north_up" + ], + "description": "视图模式:follow=跟车视角, overview=路线全览, north_up=北向上" + } + }, + "required": [ + "viewMode" + ] + } } ] } diff --git a/examples/smart-cockpit/service/tools/registry.mjs b/examples/smart-cockpit/service/tools/registry.mjs index 7d540bb9..532a552b 100644 --- a/examples/smart-cockpit/service/tools/registry.mjs +++ b/examples/smart-cockpit/service/tools/registry.mjs @@ -22,10 +22,21 @@ function definition(tool) { 'custom_skill_load', 'vehicle_state_query', 'navigation_route_query', + 'navigation_search_place', 'music_search', 'weather', ].includes(tool.name), - openWorldHint: ['navigation_start', 'navigation_route_query', 'weather'].includes(tool.name), + openWorldHint: [ + 'navigation_start', + 'navigation_route_query', + 'navigation_add_waypoint', + 'navigation_change_destination', + 'navigation_set_route_strategy', + 'navigation_search_place', + 'navigation_to_favorite', + 'navigation_set_favorite', + 'weather', + ].includes(tool.name), destructiveHint: tool.name === 'flashbuy', }, }) @@ -92,6 +103,9 @@ export const FRONTEND_TOOL_NAMES = Object.freeze([ 'vehicle_sunroof_control', 'vehicle_headlights_control', 'vehicle_climate_control', + 'navigation_set_route_strategy', + 'navigation_set_voice', + 'navigation_set_view', ]) const toolDefinitionsByName = new Map( diff --git a/examples/smart-cockpit/test/tool-surfaces.test.mjs b/examples/smart-cockpit/test/tool-surfaces.test.mjs index 1744f49a..6e2c8a44 100644 --- a/examples/smart-cockpit/test/tool-surfaces.test.mjs +++ b/examples/smart-cockpit/test/tool-surfaces.test.mjs @@ -15,11 +15,16 @@ test('adds a foreground fast path while retaining a complete backend orchestrati 'vehicle_sunroof_control', 'vehicle_headlights_control', 'vehicle_climate_control', + 'navigation_set_route_strategy', + 'navigation_set_voice', + 'navigation_set_view', ]) - assert.equal(BACKEND_TOOL_DEFINITIONS.length, 18) + assert.equal(BACKEND_TOOL_DEFINITIONS.length, 27) const backendNames = BACKEND_TOOL_DEFINITIONS.map(tool => tool.name) assert.ok(backendNames.includes('vehicle_sunroof_control')) assert.ok(backendNames.includes('vehicle_climate_control')) + assert.ok(backendNames.includes('navigation_set_route_strategy')) + assert.ok(backendNames.includes('navigation_set_view')) assert.ok(backendNames.includes('weather')) assert.ok(backendNames.includes('vehicle_window_control')) assert.ok(backendNames.includes('vehicle_headlights_control')) @@ -47,8 +52,12 @@ test('binds the cockpit frontend profile to the scoped MCP configuration', () => 'vehicle_sunroof_control', 'vehicle_headlights_control', 'vehicle_climate_control', + 'navigation_set_route_strategy', + 'navigation_set_voice', + 'navigation_set_view', ]) assert.equal(config.servers.cockpit.tools.vehicle_window_control.enabled, true) + assert.equal(config.servers.cockpit.tools.navigation_set_view.enabled, true) assert.ok(!('approval' in config.servers.cockpit.tools.vehicle_window_control)) assert.equal(config.servers.cockpit.tools.vehicle_climate_control.enabled, true) assert.ok(!('approval' in config.servers.cockpit.tools.vehicle_climate_control)) @@ -61,4 +70,17 @@ test('keeps asynchronous cockpit acknowledgements natural and action-specific', assert.match(COCKPIT_SPAWN_THINKING_DESCRIPTION, /不固定话术/u) assert.match(COCKPIT_SPAWN_THINKING_DESCRIPTION, /忠实保留用户选定的商品和当前动作/u) assert.match(COCKPIT_SPAWN_THINKING_DESCRIPTION, /不要把加购改写为搜索/u) + assert.match(COCKPIT_SPAWN_THINKING_DESCRIPTION, /导航视图、导航播报和当前路线偏好/u) + + const frontendConfig = JSON.parse(readFileSync( + new URL('../gateway/frontend-mcp.json', import.meta.url), + 'utf8', + )) + const navigationViewDescription = frontendConfig.servers.cockpit.tools.navigation_set_view.description + assert.match(navigationViewDescription, /直接调用/u) + assert.match(navigationViewDescription, /不要只口头回应/u) + assert.match( + frontendConfig.servers.cockpit.tools.navigation_set_route_strategy.description, + /躲避拥堵/u, + ) })