Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions examples/smart-cockpit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions examples/smart-cockpit/README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 工具面,完成车控、
导航、音乐、闪购和自定义技能任务,包括有序的多途经点导航。
Expand Down
1 change: 1 addition & 0 deletions examples/smart-cockpit/agent/executor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 保存名称、简介和可执行步骤;未得到创建意图时不要擅自保存。
Expand Down
12 changes: 9 additions & 3 deletions examples/smart-cockpit/client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}, [])
Expand Down Expand Up @@ -350,6 +354,7 @@ export default function App() {
progress: voiceProgress,
error: voiceError,
activateVoice,
deactivateVoice,
sendInput,
} = useVoiceSession({
muted: voiceMuted,
Expand All @@ -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) => (
Expand Down Expand Up @@ -431,7 +437,7 @@ export default function App() {
navProgress={visualProgress}
mapActions={mapActions}
routeStrategy={routeStrategy}
onStrategyChange={setRouteStrategy}
onStrategyChange={changeRouteStrategy}
onFavoriteNavigate={navigateToFavorite}
onFavoriteSetup={openDestinationInput}
onSearchDestination={openDestinationInput}
Expand Down
1 change: 1 addition & 0 deletions examples/smart-cockpit/client/src/components/ChatPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
12 changes: 7 additions & 5 deletions examples/smart-cockpit/client/src/components/MapPanel.jsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
}

Expand Down
28 changes: 24 additions & 4 deletions examples/smart-cockpit/client/src/hooks/useVoiceSession.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import {
cockpitConnectionError,
cockpitVoiceConnectionMode,
publishCockpitVoiceIntent,
} from './voiceSessionMode'
import {
rememberTaskProgress,
Expand Down Expand Up @@ -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: '' })
Expand All @@ -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
Expand Down Expand Up @@ -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(() => {})
Expand All @@ -335,7 +350,11 @@ export default function useVoiceSession({
setVoiceState('error')
return false
}
}, [])
}, [publishMutedState])

const deactivateVoice = useCallback(() => {
publishMutedState(true)
}, [publishMutedState])

useEffect(() => {
const handleEvent = (event) => {
Expand Down Expand Up @@ -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 = ''
Expand Down Expand Up @@ -460,7 +481,6 @@ export default function useVoiceSession({

useEffect(() => {
if (muted) {
clientRef.current?.send({ type: GatewayClientEvent.MUTE })
const frame = requestAnimationFrame(() => {
setInputLevel(0)
setOutputLevel(0)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -582,6 +601,7 @@ export default function useVoiceSession({
progress,
error: connectionError || error,
activateVoice,
deactivateVoice,
sendInput,
}
}
8 changes: 8 additions & 0 deletions examples/smart-cockpit/client/src/hooks/voiceSessionMode.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions examples/smart-cockpit/client/src/projections/route-flow.js
Original file line number Diff line number Diff line change
@@ -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,
}
}
29 changes: 29 additions & 0 deletions examples/smart-cockpit/client/test/route-flow.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
})
})
30 changes: 30 additions & 0 deletions examples/smart-cockpit/client/test/voice-session-mode.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions examples/smart-cockpit/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 显式组合,但共用同一份执行器和座舱状态;
Expand Down
4 changes: 2 additions & 2 deletions examples/smart-cockpit/docs/test-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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` |

统一运行:

Expand Down
Loading