diff --git a/examples/smart-cockpit/README.md b/examples/smart-cockpit/README.md index 43211bfe..5cb9f43a 100644 --- a/examples/smart-cockpit/README.md +++ b/examples/smart-cockpit/README.md @@ -97,10 +97,15 @@ 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. - 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. +- The foreground Agent directly calls weather, vehicle-location, vehicle-state, + window, sunroof, headlight, climate, navigation-stop, route-view, navigation + voice/preference, and music transport tools through standard MCP. These + low-latency commands execute inline without a redundant second confirmation. +- Vehicle-location queries and navigation origins share the Cockpit Service's + `vehicleLocation()` adapter. Without a vehicle GPS integration the example + explicitly reports its demo fallback; a deployment replaces only that service. +- Route preference buttons write the authoritative cockpit state. The next route + inherits that preference unless the user explicitly chooses another one. - 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 87eb9acd..6915e0fd 100644 --- a/examples/smart-cockpit/README_ZH.md +++ b/examples/smart-cockpit/README_ZH.md @@ -82,8 +82,12 @@ npm run example:smart-cockpit - UI 通过场景自己的 HTTP/SSE 通道展示车辆、路线、音乐、天气和订单状态,以及细粒度场景进度;Gateway 不解析这些对象。 - 用户可以通过语音创建和运行持久化的座舱自定义技能;技能是按座舱隔离的用户工作流, 由后台 Agent 加载后编排现有 MCP 工具。它不是动态 MCP 插件、A2A Agent Card 或全局 Agent Skill。 -- 前台 Agent 负责实时聊天,通过标准 MCP 直接调用天气、车况以及车窗、天窗、 - 大灯、空调和短导航工具;用户明确说出的单次车辆控制、导航视图、导航播报和当前路线偏好指令直接执行,不再增加重复确认。 +- 前台 Agent 通过标准 MCP 直接调用天气、车辆位置、车况、车窗、天窗、大灯、 + 空调、停止导航、导航视图/播报/偏好和音乐播放控制工具;这些低延迟指令直接执行, + 不再增加重复确认。 +- 位置查询与导航起点共用 Cockpit Service 的 `vehicleLocation()` 适配入口; + 未接车机 GPS 时会明确返回 Demo 默认位置,部署时只需替换该服务。 +- 路线偏好按钮写入座舱权威状态;用户未另行指定时,后续导航会继承该偏好。 - 其他座舱任务通过固定的 `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 5e86084f..8ac523c7 100644 --- a/examples/smart-cockpit/agent/executor.mjs +++ b/examples/smart-cockpit/agent/executor.mjs @@ -23,6 +23,7 @@ export const COCKPIT_AGENT_PROMPT = `你是智能座舱的后台 Agent,负责 - 用户只是找地点或周边 POI、没有要求导航时,调用 navigation_search_place。 - “回家”“去公司”等常用地点导航优先调用 navigation_to_favorite;设置家/公司/学校地址时调用 navigation_set_favorite。 - 导航静音、详细播报、简洁播报调用 navigation_set_voice;查看全程、跟车视角、北向上调用 navigation_set_view。 +- 用户明确要求停止导航时调用 navigation_stop,不要要求目的地或改用路线查询。 - 闪购中,只有“看看”“搜一下”“有哪些”等浏览意图使用 search;“帮我点”“来一份”“就这个”“加入购物车”使用 add_to_cart,不得退回再次搜索。 - 闪购加购后必须先返回订单预览;只有用户在后续指令中明确确认后,才调用 confirm_order。 - 用户明确要求创建自定义技能时,调用 custom_skill_create 保存名称、简介和可执行步骤;未得到创建意图时不要擅自保存。 diff --git a/examples/smart-cockpit/client/src/App.jsx b/examples/smart-cockpit/client/src/App.jsx index f8cf95ab..ec2bfddb 100644 --- a/examples/smart-cockpit/client/src/App.jsx +++ b/examples/smart-cockpit/client/src/App.jsx @@ -125,7 +125,7 @@ export default function App() { const [chatMessages, setChatMessages] = useState([]) const navState = cockpitState?.navigation || { status: 'idle' } const mapActions = useMemo(() => [], []) - const [routeStrategy, setRouteStrategy] = useState(0) + const routeStrategy = Number(navState.strategy) || 0 const musicState = cockpitState?.music || { playing: false, currentIndex: 0 } const flashBuyState = cockpitState?.flashbuy || INITIAL_FLASH_BUY_STATE const weatherState = cockpitState?.weather || INITIAL_WEATHER_STATE @@ -151,6 +151,10 @@ export default function App() { runCockpitCommand('navigation_to_favorite', { favoriteType }) }, [runCockpitCommand]) + const changeRouteStrategy = useCallback((strategy) => { + runCockpitCommand('navigation_set_route_strategy', { strategy }) + }, [runCockpitCommand]) + const openDestinationInput = useCallback(() => { setShowChat(true) }, []) @@ -350,6 +354,7 @@ export default function App() { progress: voiceProgress, error: voiceError, activateVoice, + deactivateVoice, sendInput, } = useVoiceSession({ muted: voiceMuted, @@ -361,11 +366,12 @@ export default function App() { }) const toggleVoiceMute = useCallback(() => { if (!voiceMuted) { + deactivateVoice() setVoiceMuted(true) return } if (activateVoice()) setVoiceMuted(false) - }, [activateVoice, voiceMuted]) + }, [activateVoice, deactivateVoice, voiceMuted]) const visualProgress = cockpitProgress || voiceProgress const handleTextMessage = useCallback((text) => ( @@ -431,7 +437,7 @@ export default function App() { navProgress={visualProgress} mapActions={mapActions} routeStrategy={routeStrategy} - onStrategyChange={setRouteStrategy} + onStrategyChange={changeRouteStrategy} onFavoriteNavigate={navigateToFavorite} onFavoriteSetup={openDestinationInput} onSearchDestination={openDestinationInput} diff --git a/examples/smart-cockpit/client/src/components/ChatPanel.jsx b/examples/smart-cockpit/client/src/components/ChatPanel.jsx index 021c0040..52834cc3 100644 --- a/examples/smart-cockpit/client/src/components/ChatPanel.jsx +++ b/examples/smart-cockpit/client/src/components/ChatPanel.jsx @@ -6,6 +6,7 @@ function formatArgs(args) { } const TOOL_TAGS = { + vehicle_location_query: { label: '位置查询', cls: 'tag-car' }, vehicle_state_query: { label: '车况查询', cls: 'tag-car' }, vehicle_window_control: { label: '车窗控制', cls: 'tag-car' }, vehicle_sunroof_control: { label: '天窗控制', cls: 'tag-car' }, diff --git a/examples/smart-cockpit/client/src/components/MapPanel.jsx b/examples/smart-cockpit/client/src/components/MapPanel.jsx index cb5a683f..7b3fc8de 100644 --- a/examples/smart-cockpit/client/src/components/MapPanel.jsx +++ b/examples/smart-cockpit/client/src/components/MapPanel.jsx @@ -1,6 +1,7 @@ import { useRef, useEffect, useMemo, useCallback, useState } from 'react' import AMapLoader from '@amap/amap-jsapi-loader' import { navigationRouteKey, navigationRouteView } from '../projections/navigation-route' +import { routeFlowFrame } from '../projections/route-flow' window._AMapSecurityConfig = { securityJsCode: import.meta.env.VITE_AMAP_SECRET, @@ -222,13 +223,14 @@ export default function MapPanel({ map.add([traveled, ahead, flow]) routeFlowLayersRef.current = [traveled, ahead, flow] - const segmentSize = Math.max(6, Math.floor(points.length * 0.08)) const startTime = performance.now() const tick = (now) => { - const cycle = 2200 - const progress = ((now - startTime) % cycle) / cycle - const startIndex = Math.floor(progress * Math.max(1, points.length - segmentSize)) - flow.setPath(points.slice(startIndex, Math.min(points.length, startIndex + segmentSize))) + const frame = routeFlowFrame(points, now - startTime) + flow.setPath(frame.path) + if (frame.done) { + stopRouteFlow() + return + } flowRafRef.current = requestAnimationFrame(tick) } diff --git a/examples/smart-cockpit/client/src/hooks/useVoiceSession.js b/examples/smart-cockpit/client/src/hooks/useVoiceSession.js index 4987f52f..807bae17 100644 --- a/examples/smart-cockpit/client/src/hooks/useVoiceSession.js +++ b/examples/smart-cockpit/client/src/hooks/useVoiceSession.js @@ -11,6 +11,7 @@ import { import { cockpitConnectionError, cockpitVoiceConnectionMode, + publishCockpitVoiceIntent, } from './voiceSessionMode' import { rememberTaskProgress, @@ -125,6 +126,7 @@ export default function useVoiceSession({ const mediaRequestRef = useRef(null) const inputSampleRateRef = useRef(INPUT_SAMPLE_RATE) const mutedRef = useRef(muted) + const publishedMutedRef = useRef(null) const personaRef = useRef(persona) const voiceRef = useRef(voice) const personaSyncRef = useRef({ generation: 0, pending: '', published: '' }) @@ -143,7 +145,17 @@ export default function useVoiceSession({ startTimers: new Map(), }) - useEffect(() => { mutedRef.current = muted }, [muted]) + const publishMutedState = useCallback((nextMuted, client = clientRef.current) => { + mutedRef.current = nextMuted + publishedMutedRef.current = publishCockpitVoiceIntent( + client, + nextMuted, + publishedMutedRef.current, + ) + return publishedMutedRef.current === nextMuted + }, []) + + useEffect(() => { publishMutedState(muted) }, [muted, publishMutedState]) useEffect(() => { onVoiceMessageRef.current = onVoiceMessage }, [onVoiceMessage]) useEffect(() => { onConversationRecoveryRef.current = onConversationRecovery @@ -324,6 +336,9 @@ export default function useVoiceSession({ audio: AUDIO_CAPTURE_CONSTRAINTS, }) mediaRequestRef.current = mediaRequest + // Publish the intent while the click still owns browser activation. + // Microphone permission may resolve later and must not delay UNMUTE. + publishMutedState(false) // The capture effect consumes both promises. Attach handlers here too so // a fast rejection cannot become unhandled before React runs the effect. activation.ready.catch(() => {}) @@ -335,7 +350,11 @@ export default function useVoiceSession({ setVoiceState('error') return false } - }, []) + }, [publishMutedState]) + + const deactivateVoice = useCallback(() => { + publishMutedState(true) + }, [publishMutedState]) useEffect(() => { const handleEvent = (event) => { @@ -419,9 +438,11 @@ export default function useVoiceSession({ }, onStatus: status => { if (status.state === 'ready') { + publishedMutedRef.current = mutedRef.current publishAssistantProfile(client) syncOutputVoice(client) } else if (['connecting', 'disconnected', 'unavailable'].includes(status.state)) { + publishedMutedRef.current = null for (const sync of [personaSyncRef.current, voiceSyncRef.current]) { sync.generation += 1 sync.pending = '' @@ -460,7 +481,6 @@ export default function useVoiceSession({ useEffect(() => { if (muted) { - clientRef.current?.send({ type: GatewayClientEvent.MUTE }) const frame = requestAnimationFrame(() => { setInputLevel(0) setOutputLevel(0) @@ -526,7 +546,6 @@ export default function useVoiceSession({ } source.connect(processor) processor.connect(context.destination) - clientRef.current?.send({ type: GatewayClientEvent.UNMUTE }) setError(null) const data = new Float32Array(analyser.fftSize) @@ -582,6 +601,7 @@ export default function useVoiceSession({ progress, error: connectionError || error, activateVoice, + deactivateVoice, sendInput, } } diff --git a/examples/smart-cockpit/client/src/hooks/voiceSessionMode.js b/examples/smart-cockpit/client/src/hooks/voiceSessionMode.js index ffea69a7..b0127370 100644 --- a/examples/smart-cockpit/client/src/hooks/voiceSessionMode.js +++ b/examples/smart-cockpit/client/src/hooks/voiceSessionMode.js @@ -1,5 +1,13 @@ export const COCKPIT_CONNECTION_INTERRUPTED = '对话中控连接中断,正在重连' +export function publishCockpitVoiceIntent(client, muted, publishedMuted = null) { + if (!client || publishedMuted === muted) return publishedMuted + const sent = client.send({ + type: muted ? 'mute' : 'unmute', + }) === true + return sent ? muted : publishedMuted +} + export function cockpitVoiceConnectionMode(muted, outputVoice = '') { const enabled = muted !== true return { diff --git a/examples/smart-cockpit/client/src/projections/route-flow.js b/examples/smart-cockpit/client/src/projections/route-flow.js new file mode 100644 index 00000000..ef44e482 --- /dev/null +++ b/examples/smart-cockpit/client/src/projections/route-flow.js @@ -0,0 +1,20 @@ +export const ROUTE_FLOW_DURATION_MS = 2200 + +export function routeFlowFrame(points, elapsedMs, { + durationMs = ROUTE_FLOW_DURATION_MS, +} = {}) { + if (!Array.isArray(points) || !points.length) { + return { path: [], done: true } + } + const duration = Number.isFinite(durationMs) && durationMs > 0 + ? durationMs + : ROUTE_FLOW_DURATION_MS + const progress = Math.min(1, Math.max(0, Number(elapsedMs) || 0) / duration) + const segmentSize = Math.min(points.length, Math.max(6, Math.floor(points.length * 0.08))) + const lastStart = Math.max(0, points.length - segmentSize) + const startIndex = Math.min(lastStart, Math.floor(progress * lastStart)) + return { + path: points.slice(startIndex, startIndex + segmentSize), + done: progress >= 1, + } +} diff --git a/examples/smart-cockpit/client/test/route-flow.test.mjs b/examples/smart-cockpit/client/test/route-flow.test.mjs new file mode 100644 index 00000000..6de75c31 --- /dev/null +++ b/examples/smart-cockpit/client/test/route-flow.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + ROUTE_FLOW_DURATION_MS, + routeFlowFrame, +} from '../src/projections/route-flow.js' + +test('plays the route highlight once and reaches a terminal frame', () => { + const points = Array.from({ length: 100 }, (_, index) => index) + const start = routeFlowFrame(points, 0) + const middle = routeFlowFrame(points, ROUTE_FLOW_DURATION_MS / 2) + const end = routeFlowFrame(points, ROUTE_FLOW_DURATION_MS) + const afterEnd = routeFlowFrame(points, ROUTE_FLOW_DURATION_MS * 3) + + assert.equal(start.done, false) + assert.equal(middle.done, false) + assert.ok(middle.path[0] > start.path[0]) + assert.equal(end.done, true) + assert.deepEqual(afterEnd, end) + assert.equal(end.path.at(-1), points.at(-1)) +}) + +test('handles short and missing routes without scheduling an endless animation', () => { + assert.deepEqual(routeFlowFrame([], 0), { path: [], done: true }) + assert.deepEqual(routeFlowFrame([1, 2, 3], ROUTE_FLOW_DURATION_MS), { + path: [1, 2, 3], + done: true, + }) +}) diff --git a/examples/smart-cockpit/client/test/voice-session-mode.test.mjs b/examples/smart-cockpit/client/test/voice-session-mode.test.mjs index 0f5fcd4c..3f63e814 100644 --- a/examples/smart-cockpit/client/test/voice-session-mode.test.mjs +++ b/examples/smart-cockpit/client/test/voice-session-mode.test.mjs @@ -4,8 +4,38 @@ import { COCKPIT_CONNECTION_INTERRUPTED, cockpitConnectionError, cockpitVoiceConnectionMode, + publishCockpitVoiceIntent, } from '../src/hooks/voiceSessionMode.js' +test('publishes voice intent once and retries it after a disconnected send', () => { + const events = [] + let connected = false + const client = { + send(event) { + if (!connected) return false + events.push(event) + return true + }, + } + + let published = publishCockpitVoiceIntent(client, false) + assert.equal(published, null) + assert.deepEqual(events, []) + + connected = true + published = publishCockpitVoiceIntent(client, false, published) + assert.equal(published, false) + assert.deepEqual(events, [{ type: 'unmute' }]) + + published = publishCockpitVoiceIntent(client, false, published) + assert.equal(published, false) + assert.equal(events.length, 1) + + published = publishCockpitVoiceIntent(client, true, published) + assert.equal(published, true) + assert.deepEqual(events.at(-1), { type: 'mute' }) +}) + test('keeps a muted cockpit Client voice-capable without claiming voice', () => { assert.deepEqual(cockpitVoiceConnectionMode(true), { voiceEnabled: false, diff --git a/examples/smart-cockpit/docs/architecture.md b/examples/smart-cockpit/docs/architecture.md index b149d279..f31475cf 100644 --- a/examples/smart-cockpit/docs/architecture.md +++ b/examples/smart-cockpit/docs/architecture.md @@ -51,8 +51,10 @@ cockpit-client ── GCP 6.0 ──► cockpit-gateway ── A2A ──► coc - `cockpit-service` 是车辆、导航、音乐、天气和闪购状态的唯一来源。 - UI 通过 HTTP 获取快照、执行面板操作,通过 SSE 接收状态变化。 -- Gateway 的前台 Agent 通过 `/mcp/frontend` 直接使用天气、车况以及车窗、天窗、 - 大灯、空调和短导航工具;明确的单次车辆控制、导航视图、导航播报和当前路线偏好口头指令直接执行,不增加重复确认。 +- Gateway 的前台 Agent 通过 `/mcp/frontend` 直接使用天气、车辆位置、车况、单次车辆控制、 + 停止导航、导航视图/播报/偏好和音乐播放控制工具;明确的低延迟指令直接执行。 +- `service/vehicle-location.mjs` 将车机定位收敛为单一适配边界:位置查询、导航起点和 + “当前位置”收藏都使用同一状态,未接真实定位时才使用带来源标记的 Demo 回退。 - 后台 Agent 通过 `/mcp/backend` 使用完整工具面,支持组合任务以及自定义技能的 发现、创建、加载和执行。 - 两个工具面由 `service/tools/registry.mjs` 显式组合,但共用同一份执行器和座舱状态; diff --git a/examples/smart-cockpit/docs/test-matrix.md b/examples/smart-cockpit/docs/test-matrix.md index 08e117be..403f94ce 100644 --- a/examples/smart-cockpit/docs/test-matrix.md +++ b/examples/smart-cockpit/docs/test-matrix.md @@ -2,7 +2,7 @@ | 边界 | 覆盖内容 | 自动化入口 | |---|---|---| -| cockpit-service | 多座舱隔离、车控校验、音乐、导航阶段、闪购确认、自定义技能持久化/更新/删除、场景状态事件 | `examples/smart-cockpit/service/test` | +| cockpit-service | 多座舱隔离、车控校验、车辆定位适配、音乐、导航阶段/偏好、闪购确认、自定义技能持久化/更新/删除、场景状态事件 | `examples/smart-cockpit/service/test` | | MCP | 前台低延迟白名单、后台完整编排工具面、自定义技能固定工具契约、参数传递、与 HTTP 共用单一状态 | `examples/smart-cockpit/service/test/server.test.mjs`、`examples/smart-cockpit/gateway/test/frontend-tools.test.mjs` | | cockpit-agent | Qwen3.8-Flash 思考模式、标准函数工具、多轮工具循环、自定义技能发现/加载/真实工具执行、歧义追问 | `examples/smart-cockpit/agent/test`(模型使用确定性测试替身) | | A2A → MCP | 标准 Task 生命周期、真实领域状态变更和有序多途经点导航 | `examples/smart-cockpit/agent/test/integration.test.mjs` | @@ -12,7 +12,7 @@ | 启动预检 | Realtime 配置、四进程端口、无效端口 | `examples/smart-cockpit/bootstrap/test/preflight.test.mjs` | | GCP 客户端 | 握手、重连、回放、播放回执、Task 与会话恢复 | 根目录 Gateway Client SDK / protocol tests | | BackendPort/A2A | 取消、超时、断线、重复终态、输入与权限映射 | `server/test/a2a-backend-adapter.test.mjs` 及 Backend tests | -| cockpit-client | 场景活动到导航/音乐/闪购面板的投影、自定义技能列表/详情/删除、Task 进度语义去重、ESLint 与生产构建 | `examples/smart-cockpit/client/test/`、`npm run example:smart-cockpit:lint`、`npm run example:smart-cockpit:build` | +| cockpit-client | 场景活动到导航/音乐/闪购面板的投影、路线动画终止、解除静音意图与麦克风权限解耦、自定义技能列表/详情/删除、Task 进度语义去重、ESLint 与生产构建 | `examples/smart-cockpit/client/test/`、`npm run example:smart-cockpit:lint`、`npm run example:smart-cockpit:build` | 统一运行: diff --git a/examples/smart-cockpit/gateway/frontend-mcp.json b/examples/smart-cockpit/gateway/frontend-mcp.json index a1e3e7f7..93a95cae 100644 --- a/examples/smart-cockpit/gateway/frontend-mcp.json +++ b/examples/smart-cockpit/gateway/frontend-mcp.json @@ -9,6 +9,10 @@ "enabled": true, "description": "查询指定城市的当日天气、温度和出行提示。这是可由前台语音助手直接调用的座舱示例工具。" }, + "vehicle_location_query": { + "enabled": true, + "description": "直接查询车辆当前位置。用户问‘我在哪’、‘车在哪’或‘当前位置’时调用;不要通过 spawn_thinking 查询。" + }, "vehicle_state_query": { "enabled": true, "description": "直接查询车窗、天窗、大灯和空调等车辆当前状态。用户询问是否开启、当前档位或整体车况时使用;不要把状态问题当成控制指令。" @@ -31,7 +35,7 @@ }, "navigation_set_route_strategy": { "enabled": true, - "description": "在当前导航或路线预览中立即切换路线偏好并重新规划路线。用户说“躲避拥堵”“避开拥堵”“不走高速”“少收费”“高速优先”“时间优先”时直接调用;不要只口头回应,也不要再通过 spawn_thinking 重复执行同一操作。" + "description": "立即设置路线偏好:有当前路线时重新规划,无路线时作为后续导航的默认偏好。用户说“躲避拥堵”“避开拥堵”“不走高速”“少收费”“高速优先”“时间优先”时直接调用;不要只口头回应,也不要再通过 spawn_thinking 重复执行同一操作。" }, "navigation_set_voice": { "enabled": true, @@ -40,6 +44,22 @@ "navigation_set_view": { "enabled": true, "description": "立即设置地图导航视图。用户说“查看全程”“路线全览”“回到跟车视角”“北向上”时直接调用;不要只口头回应,也不要再通过 spawn_thinking 重复执行同一操作。" + }, + "navigation_stop": { + "enabled": true, + "description": "立即停止当前导航并清空路线。用户说停止、取消或结束导航时直接调用;不要调用 cancel_agent_task,也不要通过 spawn_thinking 提交。" + }, + "music_pause": { + "enabled": true, + "description": "立即暂停当前音乐。用户要求暂停时直接调用,不要通过 spawn_thinking 提交。" + }, + "music_next": { + "enabled": true, + "description": "立即切换到下一首音乐。用户要求切下一首时直接调用,不要通过 spawn_thinking 提交。" + }, + "music_previous": { + "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 f0d4fd16..3db923d0 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 735712bd..89b6899b 100644 --- a/examples/smart-cockpit/gateway/test/frontend-tools.test.mjs +++ b/examples/smart-cockpit/gateway/test/frontend-tools.test.mjs @@ -10,6 +10,9 @@ import { test('calls selected cockpit tools inline through the frontend MCP client', async t => { const service = new CockpitService({ services: { + async vehicleLocation() { + return { city: '杭州市', district: '余杭区', address: '文一西路969号', lng: 120.1, lat: 30.2 } + }, 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: [] } @@ -31,6 +34,7 @@ test('calls selected cockpit tools inline through the frontend MCP client', asyn url: `${server.origin}/mcp/frontend`, tools: { weather: { enabled: true }, + vehicle_location_query: { enabled: true }, vehicle_state_query: { enabled: true }, vehicle_window_control: { enabled: true }, vehicle_sunroof_control: { enabled: true }, @@ -39,6 +43,10 @@ test('calls selected cockpit tools inline through the frontend MCP client', asyn navigation_set_route_strategy: { enabled: true }, navigation_set_voice: { enabled: true }, navigation_set_view: { enabled: true }, + navigation_stop: { enabled: true }, + music_pause: { enabled: true }, + music_next: { enabled: true }, + music_previous: { enabled: true }, }, }, }, @@ -49,6 +57,7 @@ test('calls selected cockpit tools inline through the frontend MCP client', asyn const tools = await client.initialize() assert.deepEqual(tools.map(tool => tool.name), [ 'mcp__cockpit__weather', + 'mcp__cockpit__vehicle_location_query', 'mcp__cockpit__vehicle_state_query', 'mcp__cockpit__vehicle_window_control', 'mcp__cockpit__vehicle_sunroof_control', @@ -57,9 +66,15 @@ test('calls selected cockpit tools inline through the frontend MCP client', asyn 'mcp__cockpit__navigation_set_route_strategy', 'mcp__cockpit__navigation_set_voice', 'mcp__cockpit__navigation_set_view', + 'mcp__cockpit__navigation_stop', + 'mcp__cockpit__music_pause', + 'mcp__cockpit__music_next', + 'mcp__cockpit__music_previous', ]) const output = await client.execute('mcp__cockpit__weather', { city: '杭州' }) assert.match(output.text, /杭州,晴,27°/u) + const location = await client.execute('mcp__cockpit__vehicle_location_query', {}) + assert.match(location.text, /文一西路969号/u) await client.execute('mcp__cockpit__vehicle_window_control', { action: 'open', window: 'windowFL', @@ -92,12 +107,16 @@ test('calls selected cockpit tools inline through the frontend MCP client', asyn broadcastMode: 'brief', }) assert.match(voice.text, /简洁播报/u) + await client.execute('mcp__cockpit__music_next', {}) + await client.execute('mcp__cockpit__music_previous', {}) + await client.execute('mcp__cockpit__music_pause', {}) + await client.execute('mcp__cockpit__navigation_stop', {}) 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.status, 'idle') 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 d481c759..acf486e3 100644 --- a/examples/smart-cockpit/service/cockpit-service.mjs +++ b/examples/smart-cockpit/service/cockpit-service.mjs @@ -4,11 +4,25 @@ import { COCKPIT_TOOL_NAMES, executeCockpitTool, } from './tools/registry.mjs' +import { normalizeVehicleLocation } from './vehicle-location.mjs' function clean(value) { return String(value || '').trim() } +const LOCATION_AWARE_TOOLS = new Set([ + 'vehicle_location_query', + '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', +]) + function emptyServices() { return { async resolvePlace() { return null }, @@ -103,6 +117,9 @@ export class CockpitService { if (!COCKPIT_TOOL_NAMES.includes(name)) { throw new Error(`Unknown cockpit tool: ${name}`) } + if (LOCATION_AWARE_TOOLS.has(name)) { + await this.#refreshVehicleLocation(cockpitId) + } const reportActivity = event => { try { onActivity?.(event) @@ -122,4 +139,20 @@ export class CockpitService { store: this.store, }) } + + async #refreshVehicleLocation(cockpitId) { + if (typeof this.services.vehicleLocation !== 'function') return + let location = null + try { + location = normalizeVehicleLocation(await this.services.vehicleLocation()) + } catch { + return + } + if (!location) return + const current = this.snapshot(cockpitId).location + if (JSON.stringify(current) === JSON.stringify(location)) return + this.store.update(cockpitId, ['location'], next => { + next.location = location + }) + } } diff --git a/examples/smart-cockpit/service/integrations/amap/services.mjs b/examples/smart-cockpit/service/integrations/amap/services.mjs index 9311cb7d..6865e2ac 100644 --- a/examples/smart-cockpit/service/integrations/amap/services.mjs +++ b/examples/smart-cockpit/service/integrations/amap/services.mjs @@ -14,6 +14,7 @@ export function createAmapCockpitServices({ encode = geocode, route = drivingRoute, forecast = getWeather, + locate, } = {}) { return { async resolvePlace(name, city) { @@ -28,5 +29,6 @@ export function createAmapCockpitServices({ searchNearbyPlaces: searchNearby, drivingRoute: route, weather: forecast, + ...(typeof locate === 'function' ? { vehicleLocation: locate } : {}), } } diff --git a/examples/smart-cockpit/service/state-store.mjs b/examples/smart-cockpit/service/state-store.mjs index 79675a8a..3c307f48 100644 --- a/examples/smart-cockpit/service/state-store.mjs +++ b/examples/smart-cockpit/service/state-store.mjs @@ -2,7 +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' +import { DEFAULT_VEHICLE_LOCATION } from './vehicle-location.mjs' const DEFAULT_COCKPIT_ID = 'default' @@ -32,6 +32,7 @@ export function createInitialCockpitState(now = Date.now()) { acMode: 'cool', acFan: 3, }, + location: clone(DEFAULT_VEHICLE_LOCATION), navigation: { status: 'idle', destination: null, @@ -52,7 +53,6 @@ export function createInitialCockpitState(now = Date.now()) { broadcastMode: 'standard', }, viewMode: 'follow', - currentLocation: DEFAULT_ORIGIN.location, }, music: { playing: false, @@ -136,7 +136,7 @@ export class CockpitStateStore { type: 'cockpit.state.updated', cockpitId: id, version: state.version, - changed: Object.freeze(['vehicle', 'navigation', 'music', 'flashbuy', 'weather']), + changed: Object.freeze(['vehicle', 'location', 'navigation', 'music', 'flashbuy', 'weather']), state: this.snapshot(id), }) for (const listener of this.listeners.get(id) || []) { diff --git a/examples/smart-cockpit/service/test/cockpit-service.test.mjs b/examples/smart-cockpit/service/test/cockpit-service.test.mjs index 08fb6641..872136f4 100644 --- a/examples/smart-cockpit/service/test/cockpit-service.test.mjs +++ b/examples/smart-cockpit/service/test/cockpit-service.test.mjs @@ -10,16 +10,28 @@ import { CockpitStateStore } from '../state-store.mjs' function fixture() { let timestamp = 1_700_000_000_000 const activities = [] + const routeOrigins = [] const store = new CockpitStateStore({ now: () => timestamp++ }) const service = new CockpitService({ store, now: () => timestamp++, random: () => 0.25, services: { + async vehicleLocation() { + return { + name: '测试车位', + city: '测试市', + district: '测试区', + address: '测试路1号', + lng: 121.1, + lat: 31.2, + } + }, async resolvePlace(name) { return name === '不存在' ? null : name === '西湖' ? '120.1,30.2' : '120.2,30.3' }, async drivingRoute(origin, destination) { + routeOrigins.push(origin) return { origin, destination, @@ -43,10 +55,39 @@ function fixture() { return { service, activities, + routeOrigins, options: { cockpitId: 'car-one', onActivity: event => activities.push(event) }, } } +test('queries a live vehicle location without changing unrelated cockpit state', async () => { + const { service, options } = fixture() + const output = await service.execute('vehicle_location_query', {}, options) + + assert.match(output.content, /测试车位/u) + assert.deepEqual(output.changed, []) + assert.equal(output.data.location.coordinates, '121.1,31.2') + assert.equal(service.snapshot('car-one').location.source, 'vehicle') +}) + +test('keeps local navigation controls independent from the location adapter', async () => { + let requests = 0 + const service = new CockpitService({ + services: { + async vehicleLocation() { + requests += 1 + return null + }, + }, + }) + + await service.execute('navigation_set_view', { viewMode: 'overview' }) + await service.execute('navigation_set_voice', { mute: true }) + await service.execute('navigation_stop', {}) + + assert.equal(requests, 0) +}) + test('keeps isolated authoritative state per cockpit', async () => { const { service } = fixture() await service.execute('vehicle_window_control', { @@ -86,7 +127,7 @@ test('updates music state without returning UI actions', async () => { }) test('projects navigation progress and route state separately', async () => { - const { service, activities, options } = fixture() + const { service, activities, options, routeOrigins } = fixture() const output = await service.execute('navigation_start', { destination: '西湖', waypoints: ['黄龙体育中心', '城西银泰'], @@ -97,6 +138,7 @@ test('projects navigation progress and route state separately', async () => { assert.deepEqual(output.data.navigation.waypoints, ['黄龙体育中心', '城西银泰']) assert.equal(output.data.navigation.map.markers.length, 3) assert.equal(output.data.navigation.map.polylines.length, 3) + assert.equal(routeOrigins[0], '121.1,31.2') assert.deepEqual(activities.map(event => event.status), [ 'searching_destination', 'destination_locked', @@ -109,6 +151,20 @@ test('projects navigation progress and route state separately', async () => { ]) }) +test('persists a route preference before a destination is selected', async () => { + const { service, options } = fixture() + const preference = await service.execute('navigation_set_route_strategy', { + strategy: 13, + }, options) + assert.match(preference.content, /后续路线偏好设为高速优先/u) + assert.equal(preference.data.navigation.strategy, 13) + + const route = await service.execute('navigation_start', { + destination: '西湖', + }, options) + assert.equal(route.data.navigation.strategy, 13) +}) + test('publishes scenario activity independently from the call observer', async () => { const { service, options } = fixture() const published = [] @@ -305,7 +361,14 @@ test('resets cockpit state and publishes a full state update', async () => { 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.deepEqual(events.at(-1).changed, [ + 'vehicle', + 'location', + 'navigation', + 'music', + 'flashbuy', + 'weather', + ]) assert.equal(events.at(-1).state.navigation.status, 'idle') }) diff --git a/examples/smart-cockpit/service/test/server.test.mjs b/examples/smart-cockpit/service/test/server.test.mjs index 012dffb4..44814b1a 100644 --- a/examples/smart-cockpit/service/test/server.test.mjs +++ b/examples/smart-cockpit/service/test/server.test.mjs @@ -181,17 +181,19 @@ 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, 27) + assert.equal(backendTools.tools.length, 28) 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')) assert.ok(backendTools.tools.some(tool => tool.name === 'vehicle_window_control')) assert.ok(backendTools.tools.some(tool => tool.name === 'vehicle_headlights_control')) + assert.ok(backendTools.tools.some(tool => tool.name === 'vehicle_location_query')) assert.ok(backendTools.tools.some(tool => tool.name === 'custom_skill_create')) const frontendTools = await frontend.listTools() assert.deepEqual(frontendTools.tools.map(tool => tool.name), [ 'weather', + 'vehicle_location_query', 'vehicle_state_query', 'vehicle_window_control', 'vehicle_sunroof_control', @@ -200,6 +202,10 @@ test('scopes frontend tools while retaining a complete backend MCP surface', asy 'navigation_set_route_strategy', 'navigation_set_voice', 'navigation_set_view', + 'navigation_stop', + 'music_pause', + 'music_next', + 'music_previous', ]) const output = await backend.callTool({ @@ -216,6 +222,13 @@ test('scopes frontend tools while retaining a complete backend MCP surface', asy assert.equal(weather.isError, undefined) assert.match(weather.content[0].text, /杭州,晴,25°/u) + const location = await frontend.callTool({ + name: 'vehicle_location_query', + arguments: {}, + }) + assert.equal(location.isError, undefined) + assert.match(location.content[0].text, /云谷园区/u) + const window = await frontend.callTool({ name: 'vehicle_window_control', arguments: { action: 'open', window: 'windowFL' }, @@ -254,12 +267,16 @@ test('scopes frontend tools while retaining a complete backend MCP surface', asy assert.equal(strategy.isError, undefined) assert.equal(strategy.structuredContent.navigation.strategy, 4) - const unavailable = await frontend.callTool({ + const pause = await frontend.callTool({ name: 'music_pause', arguments: {}, }) - assert.equal(unavailable.isError, true) - assert.match(unavailable.content[0].text, /not available on this MCP surface/u) + assert.equal(pause.isError, undefined) + const stopped = await frontend.callTool({ + name: 'navigation_stop', + arguments: {}, + }) + assert.equal(stopped.isError, undefined) const state = await fetch(`${server.origin}/api/cockpit/state?cockpitId=mcp-car`) .then(response => response.json()) @@ -270,6 +287,7 @@ test('scopes frontend tools while retaining a complete backend MCP surface', asy assert.equal(state.weather.dayweather, '晴') assert.equal(state.navigation.viewMode, 'overview') assert.equal(state.navigation.strategy, 4) + assert.equal(state.navigation.status, 'idle') }) test('serves persistent custom skill management to the scenario UI', async t => { diff --git a/examples/smart-cockpit/service/tools/navigation/README.md b/examples/smart-cockpit/service/tools/navigation/README.md index 661e8c7b..207b493f 100644 --- a/examples/smart-cockpit/service/tools/navigation/README.md +++ b/examples/smart-cockpit/service/tools/navigation/README.md @@ -12,6 +12,10 @@ - 服务层能力:编排高德 MCP / 高德 Web API,例如地点搜索、地理编码、周边搜索、驾车路线规划。 - 状态层:维护当前导航状态、目的地、途经点、常用地点、路线、地图 marker/polyline、语音和视图设置。 +路线起点、“当前位置”收藏和 `vehicle_location_query` 共用 +`service/vehicle-location.mjs` 的定位结果。默认位置只是 Demo 回退;接入 +车机 GPS 时通过 Cockpit Service 的 `services.vehicleLocation()` 替换。 + 这样可以减少模型在低层地图 API 之间的选择负担,同时保留车机场景需要的运行态控制。 ## 工具大类 @@ -34,7 +38,7 @@ | `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_set_route_strategy` | “换成不走高速”“改成少收费”“避开拥堵” | `strategy` | 有路线时重新规划;无路线时作为后续导航的默认偏好 | 本地状态 + 必要时的驾车路线 | | `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` | 地点解析或当前位置 | diff --git a/examples/smart-cockpit/service/tools/navigation/execute.mjs b/examples/smart-cockpit/service/tools/navigation/execute.mjs index bce05f47..5f38a0bf 100644 --- a/examples/smart-cockpit/service/tools/navigation/execute.mjs +++ b/examples/smart-cockpit/service/tools/navigation/execute.mjs @@ -28,7 +28,7 @@ function normalizeStrategy(value, fallback = 0) { } function currentOrigin(state) { - return state.navigation?.currentLocation || DEFAULT_ORIGIN.location + return state.location?.coordinates || DEFAULT_ORIGIN.location } function activeNavigation(state) { @@ -361,15 +361,21 @@ async function changeDestination(args, context) { } async function setRouteStrategy(args, context) { - const { services, snapshot } = context + const { cockpitId, services, snapshot, store } = context const state = snapshot() + const strategy = normalizeStrategy(args.strategy, null) + if (strategy === null) return toolResult('请提供有效的路线偏好', state, [], { navigation: state.navigation }) if (!activeNavigation(state)) { - return toolResult('当前没有路线,请先告诉我要去哪里', state, [], { - navigation: state.navigation, + const nextState = store.update(cockpitId, ['navigation'], next => { + next.navigation.strategy = strategy }) + return toolResult( + `已将后续路线偏好设为${STRATEGY_LABELS.get(strategy)}`, + nextState, + ['navigation'], + { navigation: nextState.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({ @@ -436,7 +442,7 @@ async function navigateToFavorite(args, context) { status: 'navigating', destination: favorite.name || FAVORITE_LABELS[favoriteType] || '常用地点', waypoints: [], - strategy: normalizeStrategy(args.strategy, 0), + strategy: normalizeStrategy(args.strategy, snapshot().navigation.strategy), context: { ...context, services: { @@ -601,7 +607,7 @@ export async function executeNavigationTool(name, args, context) { status: name === 'navigation_start' ? 'navigating' : 'preview', destination, waypoints, - strategy: normalizeStrategy(args.strategy, 0), + strategy: normalizeStrategy(args.strategy, snapshot().navigation.strategy), context, }) } diff --git a/examples/smart-cockpit/service/tools/navigation/manifest.json b/examples/smart-cockpit/service/tools/navigation/manifest.json index 4f4ea61e..d223c837 100644 --- a/examples/smart-cockpit/service/tools/navigation/manifest.json +++ b/examples/smart-cockpit/service/tools/navigation/manifest.json @@ -181,7 +181,7 @@ { "name": "navigation_set_route_strategy", "label": "切换路线偏好", - "description": "在当前导航或路线预览中切换路线偏好,并基于当前目的地和途经点重新规划路线。用户说“换成不走高速”“改成少收费”“避开拥堵”时使用;如果用户同时给出新目的地,应使用 navigation_start 或 navigation_route_query。", + "description": "设置路线偏好。有当前导航或路线预览时,基于当前目的地和途经点重新规划;无路线时,作为后续导航的默认偏好。用户说“换成不走高速”“改成少收费”“避开拥堵”时使用;如果用户同时给出新目的地,应使用 navigation_start 或 navigation_route_query。", "enabled": true, "parameters": { "type": "object", diff --git a/examples/smart-cockpit/service/tools/registry.mjs b/examples/smart-cockpit/service/tools/registry.mjs index 532a552b..72515e98 100644 --- a/examples/smart-cockpit/service/tools/registry.mjs +++ b/examples/smart-cockpit/service/tools/registry.mjs @@ -20,6 +20,7 @@ function definition(tool) { readOnlyHint: [ 'custom_skill_list', 'custom_skill_load', + 'vehicle_location_query', 'vehicle_state_query', 'navigation_route_query', 'navigation_search_place', @@ -98,6 +99,7 @@ if (new Set(COCKPIT_TOOL_NAMES).size !== COCKPIT_TOOL_NAMES.length) { // capability; this is not a dynamic plugin layer. export const FRONTEND_TOOL_NAMES = Object.freeze([ 'weather', + 'vehicle_location_query', 'vehicle_state_query', 'vehicle_window_control', 'vehicle_sunroof_control', @@ -106,6 +108,10 @@ export const FRONTEND_TOOL_NAMES = Object.freeze([ 'navigation_set_route_strategy', 'navigation_set_voice', 'navigation_set_view', + 'navigation_stop', + 'music_pause', + 'music_next', + 'music_previous', ]) const toolDefinitionsByName = new Map( diff --git a/examples/smart-cockpit/service/tools/vehicle/execute.mjs b/examples/smart-cockpit/service/tools/vehicle/execute.mjs index 38531e66..78518c92 100644 --- a/examples/smart-cockpit/service/tools/vehicle/execute.mjs +++ b/examples/smart-cockpit/service/tools/vehicle/execute.mjs @@ -1,4 +1,5 @@ import { clean, toolResult } from '../shared.mjs' +import { vehicleLocationText } from '../../vehicle-location.mjs' const WINDOW_PARTS = ['windowFL', 'windowFR', 'windowRL', 'windowRR'] const VEHICLE_PARTS = [...WINDOW_PARTS, 'sunroof', 'headlights'] @@ -29,6 +30,14 @@ function stateText(part = 'all', vehicle) { export function executeVehicleTool(name, args, context) { const { cockpitId, snapshot, store } = context + if (name === 'vehicle_location_query') { + const state = snapshot() + const location = state.location + const prefix = location.source === 'demo-default' + ? '演示车辆当前定位为' + : '车辆当前位于' + return toolResult(`${prefix}${vehicleLocationText(location)}`, state, [], { location }) + } if (name === 'vehicle_state_query') { const state = snapshot() return toolResult(stateText(args.part, state.vehicle), state, [], { diff --git a/examples/smart-cockpit/service/tools/vehicle/manifest.json b/examples/smart-cockpit/service/tools/vehicle/manifest.json index ec4bb119..f4af5d2a 100644 --- a/examples/smart-cockpit/service/tools/vehicle/manifest.json +++ b/examples/smart-cockpit/service/tools/vehicle/manifest.json @@ -4,6 +4,16 @@ "description": "座舱示例的车辆状态查询、车窗、天窗、大灯与空调控制能力。", "enabled": true, "functions": [ + { + "name": "vehicle_location_query", + "label": "车辆位置查询", + "description": "查询车辆当前的城市、区域、地址和坐标。用于回答“我在哪”、“当前位置”等问题。", + "enabled": true, + "parameters": { + "type": "object", + "properties": {} + } + }, { "name": "vehicle_state_query", "label": "车况查询", diff --git a/examples/smart-cockpit/service/vehicle-location.mjs b/examples/smart-cockpit/service/vehicle-location.mjs new file mode 100644 index 00000000..3340d7c0 --- /dev/null +++ b/examples/smart-cockpit/service/vehicle-location.mjs @@ -0,0 +1,52 @@ +import { DEFAULT_ORIGIN } from './tools/navigation/catalog.mjs' + +const [defaultLongitude, defaultLatitude] = DEFAULT_ORIGIN.location + .split(',') + .map(Number) + +export const DEFAULT_VEHICLE_LOCATION = Object.freeze({ + name: DEFAULT_ORIGIN.name, + city: '杭州市', + district: '余杭区', + address: '文一西路969号', + longitude: defaultLongitude, + latitude: defaultLatitude, + coordinates: DEFAULT_ORIGIN.location, + source: 'demo-default', +}) + +function clean(value) { + return String(value || '').trim() +} + +function coordinates(value) { + const pair = clean(value?.coordinates || value?.location) + .split(',') + .map(Number) + const longitude = Number(value?.longitude ?? value?.lng ?? pair[0]) + const latitude = Number(value?.latitude ?? value?.lat ?? pair[1]) + if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return null + return { longitude, latitude, coordinates: `${longitude},${latitude}` } +} + +export function normalizeVehicleLocation(value) { + if (!value || typeof value !== 'object') return null + const point = coordinates(value) + if (!point) return null + return Object.freeze({ + name: clean(value.name), + city: clean(value.city), + district: clean(value.district), + address: clean(value.address), + ...point, + source: clean(value.source) || 'vehicle', + }) +} + +export function vehicleLocationText(location) { + const details = [location.city, location.district, location.address] + .filter(Boolean) + .join('') + if (location.name && details) return `${location.name}(${details})` + return location.name || details || '未知位置' +} diff --git a/examples/smart-cockpit/test/tool-surfaces.test.mjs b/examples/smart-cockpit/test/tool-surfaces.test.mjs index 6e2c8a44..ec8d6da9 100644 --- a/examples/smart-cockpit/test/tool-surfaces.test.mjs +++ b/examples/smart-cockpit/test/tool-surfaces.test.mjs @@ -10,6 +10,7 @@ import { COCKPIT_SPAWN_THINKING_DESCRIPTION } from '../gateway/spawn-thinking-to test('adds a foreground fast path while retaining a complete backend orchestration surface', () => { assert.deepEqual(FRONTEND_TOOL_DEFINITIONS.map(tool => tool.name), [ 'weather', + 'vehicle_location_query', 'vehicle_state_query', 'vehicle_window_control', 'vehicle_sunroof_control', @@ -18,8 +19,12 @@ test('adds a foreground fast path while retaining a complete backend orchestrati 'navigation_set_route_strategy', 'navigation_set_voice', 'navigation_set_view', + 'navigation_stop', + 'music_pause', + 'music_next', + 'music_previous', ]) - assert.equal(BACKEND_TOOL_DEFINITIONS.length, 27) + assert.equal(BACKEND_TOOL_DEFINITIONS.length, 28) const backendNames = BACKEND_TOOL_DEFINITIONS.map(tool => tool.name) assert.ok(backendNames.includes('vehicle_sunroof_control')) assert.ok(backendNames.includes('vehicle_climate_control')) @@ -28,6 +33,7 @@ test('adds a foreground fast path while retaining a complete backend orchestrati assert.ok(backendNames.includes('weather')) assert.ok(backendNames.includes('vehicle_window_control')) assert.ok(backendNames.includes('vehicle_headlights_control')) + assert.ok(backendNames.includes('vehicle_location_query')) assert.ok(backendNames.includes('custom_skill_list')) assert.ok(backendNames.includes('custom_skill_create')) assert.ok(backendNames.includes('custom_skill_load')) @@ -47,6 +53,7 @@ test('binds the cockpit frontend profile to the scoped MCP configuration', () => assert.equal(config.servers.cockpit.url, '${COCKPIT_FRONTEND_MCP_URL}') assert.deepEqual(Object.keys(config.servers.cockpit.tools), [ 'weather', + 'vehicle_location_query', 'vehicle_state_query', 'vehicle_window_control', 'vehicle_sunroof_control', @@ -55,9 +62,15 @@ test('binds the cockpit frontend profile to the scoped MCP configuration', () => 'navigation_set_route_strategy', 'navigation_set_voice', 'navigation_set_view', + 'navigation_stop', + 'music_pause', + 'music_next', + 'music_previous', ]) assert.equal(config.servers.cockpit.tools.vehicle_window_control.enabled, true) assert.equal(config.servers.cockpit.tools.navigation_set_view.enabled, true) + assert.equal(config.servers.cockpit.tools.vehicle_location_query.enabled, true) + assert.equal(config.servers.cockpit.tools.navigation_stop.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)) @@ -70,7 +83,8 @@ 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) + 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),