diff --git a/changelog.d/1299-voice-panel-settings.md b/changelog.d/1299-voice-panel-settings.md new file mode 100644 index 000000000..60a68e7d3 --- /dev/null +++ b/changelog.d/1299-voice-panel-settings.md @@ -0,0 +1,3 @@ +- Add a shared Voice Preview and Settings panel with browser-local preferences, + inactivity controls and accessible floating provider controls on compact layouts + (manjunath22466, #1299). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b4ed2bacb..7907a9640 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -571,6 +571,25 @@ replacement session; audio meters release failed initialization and reject revok frames. Delayed action results and post-capture continuations cannot resume a stopped conversation or send output into a replacement. See [voice ownership](VOICE-OWNERSHIP.md). +The opt-in voice panel shares Preview and Voice Settings, with browser-local +Preview and inactivity preferences. Voice-requested timeout changes apply directly +without a confirmation question; results distinguish a live change from a saved +browser preference. `set_voice_inactivity_timeout` accepts integer minutes from 1–60, +or 0 for None. Named `voice-settings` and `voice-preview` panel targets exit +Cockpit before opening the requested voice UI. `control_cockpit` also accepts +`voicePanel: "preview" | "settings"` for an explicit exit-and-show request. +Newer manual choices, Stop and provider replacement prevent an older operation +from revealing its UI. Ambiguous requests to open settings ask whether the user +means Voice Settings or Provider Settings before opening either panel. Named +Voice Settings applies directly; Provider Settings opens the existing local +provider dialog when available. Visual Presets targets the visual controls tray, +while Scenes targets saved scenes. Selecting Preview does not enable a disabled +Preview preference. Unverified speech captions use the localized Recent +speech label; ordinary user captions retain YOU. Caption attribution does not +control tool dispatch. + +Automatic Voice Preview uses output from the active mounted provider, including late replies that may answer an older request. Manual panel choices remain until genuine local input rearms automatic presentation. Preview stays off when disabled, remains hidden in Cockpit, and retires on Stop or provider replacement. Display events do not grant command authority. + Contacts nearest-aircraft follow-ups retain the snapshot's distance basis even after the camera moves. An explicit new scope replaces that basis. Provider instructions require an analyst query for requested lists and use the requested diff --git a/index.html b/index.html index a9874d550..26cf734fb 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ with the sources: src/materialSymbolsSubset.test.mjs fails when a source names a glyph this list is missing, because a missing glyph does not draw a box — the ligature never forms and the element renders the literal word. --> - + diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d15897175..3e286c79b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1656,6 +1656,12 @@ "src/voice/layerSummary.js", "src/voice/voiceCost.js", "src/voice/commands.js", + "src/voice/panel/actionObserver.js", + "src/voice/panel/host.js", + "src/voice/panel/inactivity.js", + "src/voice/panel/manualPanel.js", + "src/voice/panel/nativeActivity.js", + "src/voice/panel/preferences.js", "src/voice/session.js", "src/voice/sessionCommands.js", "src/voice/realtimeSession.js", @@ -2293,6 +2299,12 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js", "src/voice/commands.js", + "src/voice/panel/actionObserver.js", + "src/voice/panel/host.js", + "src/voice/panel/inactivity.js", + "src/voice/panel/manualPanel.js", + "src/voice/panel/nativeActivity.js", + "src/voice/panel/preferences.js", "src/voice/control.js", "src/voice/gevActions.js", "src/agent/worldPacket.js", diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index 9735f300c..c9c4a43b4 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -10,7 +10,7 @@ function realtimeInstructions( 'Do not require a wake phrase. Treat direct commands like "zoom into London" or "open datacenters" as GEV control requests.', 'Only control the app by calling the provided tools. Never invent tool names or arguments.', 'Call tools only for clear GEV control, navigation, visual-style, layer, or app-state requests. For ordinary conversation, answer normally without tools.', - 'For requests to open, show, reveal, or focus a menu/panel, call set_panel_open or show_data_layers_menu. "Open Context" means only set_panel_open{panelId:"global-context-panel",open:true}; it does not activate a Context sub-mode. "Open Contacts" means set_context_mode{mode:"contacts"}; that action expands the parent Context panel before activating Contacts.', + 'Panels: set_panel_open or show_data_layers_menu; "Open Context" is only set_panel_open{panelId:"global-context-panel",open:true}; "Open Contacts" is set_context_mode contacts (expands Context itself). Unnamed settings: ask "Voice settings or Provider settings?" first; named panels apply directly. Contacts, Space Missions and Cockpit need explicit naming; opening Context or selecting/viewing an aircraft never enters them. In Cockpit, next/previous uses control_cockpit; track_entity/fly_to_location are refused: never retry, exit Cockpit first.', 'For requests like "show me the datacenter layers", open the data layers menu and focus the matching layer row; do not enable the layer unless the user asks to turn it on.', 'For questions like "what am I looking at?", "what is in view?", "what is this?", "that selected thing", nearby datacenter, dam, cable, ship, or current view contents, call get_entity_context first, then answer from the returned scene/entity context.', 'For "what is this aircraft?", answer with get_entity_context\'s identityLine: callsign, operator, type and route from returned fields only, airport codes verbatim. Other questions about the selection answer from its properties. Never infer them from the callsign.', @@ -18,13 +18,12 @@ function realtimeInstructions( 'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").', 'Globe spin: plain Rotate, including a repeat after a refusal, calls only move_camera motion rotate_globe; the app checks fit. For rotation, call zoom_to_globe only for this turn\'s explicit user Reset/Show Globe View request, never from your own plan, an earlier refusal or a bare yes/ok. On globe-view-required say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" Do not ask a question or call more tools for that request. Explicit reset-and-rotate: await reset success before rotate_globe; never both together. After a failed reset, do not retry for that request. Local turn/look left/right: move_camera motion rotate in that direction, not pan.', 'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.', - 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.', + 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request.', 'For ANALYTICAL questions about loaded layer data — how many / which / fastest / highest / biggest / nearest / next flights, ships, fires, earthquakes, satellites, launches, storms, transit, bikes, bases, datacenters or dams ("how many flights over Texas", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Always set scope by WHERE: "around here" or "near this" while pointing is kind pointer, never view. Filter on the attribute the user names ("MODIS" → sensor eq MODIS). Say the count with its scope in a few words ("37 loaded flights over Texas") plus one or two examples by name. If the needed layer is off, say so and offer to turn it on. "Which of THOSE…" about the last answer → followUp=true with only the new filter/sort.', 'WORLD QUERIES — the query tools (get_weather, get_wind, search_places, places_nearby, plan_route, get_cyclones, get_fire_perimeters, get_bike_share, get_transit_vehicles, get_traffic_flow, situation_brief, military_awareness and the other get_* and find_* tools you have) look up data anywhere in the world without changing the map and without any layer being on. Use them when the user asks about a place they are not looking at or data whose layer is off ("what is the weather in Tokyo", "how do I drive from Austin to Dallas", "any wildfires in California"). For aircraft, ships, earthquakes, active fires, datacenters, dams and satellites overhead, use analyst_query over the layers instead, turning the layer on first when the user wants it. Use analyst_query and get_entity_context for what the app has loaded or what is on screen. Answer from the result summary and data, mention its source when it has one, and never claim the map changed. If the user also wants to SEE it, use the matching app action (fly_to_location, set_layer_visibility) as well.', 'WHERE, for every tool that takes a place or scope, in this order: (1) a place the user names; (2) explicit pointing — "this/that one/here/around here" while pointer_context shows pointing or a target → "pointer"; (3) the Contacts subject while Contacts is active, or the selection when the user refers to it; (4) the current view. A selected datacenter, dam, fire or cable never silently becomes the centre.', 'FEED STATE: when a count\'s feed is stale or degraded, add that one word ("12 flights in view, stale"); never call it live. If unavailable, give no number: say the feed is unavailable. Fallback feeds are valid data; name them only if asked.', 'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, a bare "near / nearby / how many aircraft" means the settled Contacts subject window: its count is contactsWindow.aircraft. For "Open Contacts and tell me how many aircraft are within 250 km of the active subject", call set_context_mode{mode:"contacts"} first; use its window count only when the requested radius matches contactsWindow.radiusKm. A count does NOT fulfill a requested list, ranking or filter: call analyst_query even when set_context_mode already returned the count. For "within 250 km of the active subject, list the five closest", call analyst_query{layers:["flights","military"],scope:{kind:"radius",km:250},sortBy:"distance",limit:5}, omitting center so the active Contacts subject supplies it. Use the actual requested km for any other radius; never substitute the panel\'s 250 km count for a different radius. Analyst results supply the requested items and filtered count; never invent a list from contactsWindow alone. The centre follows WHERE: explicit view, named place, pointing and explicit coordinates retain their scopes and never become Contacts queries. Never invent center coordinates such as 0,0. With Contacts active but no subject yet, use the view and name that scope. (2) With Contacts OFF, "nearby" means in view; "near " means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge: if a tool returns 46, say 46.', - 'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.', 'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.', 'READING THE VIEW: with no selected object or overlay entities, answer from get_entity_context\'s basemap context (tiles source, centre target, reverse-geocoded place, altitude, style, enabled layers); say there is nothing only when that is unavailable too. Prefer, in order: a nearby knownLandmarks name ("Eiffel Tower"); at local zoom nearbyPlaces, place.labels and viewportPlaces visible and street labels; viewportPlaces dominantCountry/Region/Locality over raw coordinates; the majority of viewportSamples over one reverse-geocoded address. Legible labels in an attached screenshot count too, but at global or regional viewScale never name a precise street or city from one centre pixel.', 'Do not mention disabled layers or stale selections.', diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index b8121a201..23d5da9de 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -44,7 +44,7 @@ export const ACTION_DESCRIPTIONS = { }, select_nearest_aircraft: { description: - 'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result. This does not open Contacts or Cockpit.', + 'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result.', $position: 1, parameters: { properties: { @@ -111,12 +111,18 @@ export const ACTION_DESCRIPTIONS = { }, }, set_panel_open: { - description: 'Open or close a GEV UI panel/dropdown.', + description: + 'Visual Presets/styles/filters=control-panel; Scenes/saved scenes=scene-panel; Voice Settings=voice-settings; voice control/Preview=voice-preview; Provider Settings/API keys=provider-settings. Voice panels exit Cockpit directly.', + $position: 1, + }, + set_voice_inactivity_timeout: { + description: + 'Set the requested exact inactivity duration directly: integer minutes 1–60, 0=None. Report applied/persisted truthfully; an unsaved live change is not saved.', $position: 1, }, set_context_mode: { description: - 'Enter or exit the Global Context sub-mode used by Contacts and Space Missions. Use Contacts only when the user explicitly requests Contacts, and Space Missions only when explicitly requested. A request to open the parent Context panel alone uses set_panel_open and must not activate either sub-mode. Selecting an aircraft does not imply Context.', + 'Enter/exit Contacts or Space Missions only when explicitly requested; opening Context alone uses set_panel_open.', $position: 1, parameters: { properties: { @@ -129,23 +135,23 @@ export const ACTION_DESCRIPTIONS = { }, control_cockpit: { description: - 'Read or control Cockpit when the user explicitly requests Cockpit: establish Contacts and enter from a selected or tracked aircraft; exit; or navigate nearby Contacts with optional filters. Selecting or viewing an aircraft alone must not enter Cockpit.', + 'Explicit Cockpit requests only: enter from a selected/tracked aircraft (establishes Contacts itself; no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.', $position: 1, parameters: { properties: { action: { description: - 'previous/next (or prev) navigates through nearby contacts in Cockpit context.', + 'previous/next (or prev) cycles nearby Cockpit contacts.', $position: 2, }, targetLayer: { description: - 'Optional contact layer filter for next/previous (for example military for a military-only cycle).', + 'Optional next/previous layer filter (e.g. military-only).', $position: 2, }, aircraftClass: { description: - 'Optional aircraft class filter (for example helicopter) when using next/previous navigation.', + 'Optional next/previous aircraft class filter (e.g. helicopter).', $position: 1, }, }, diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 603272e82..926687378 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1052,11 +1052,12 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // Globe rotation adds one camera motion and its descriptions; Globe View // framing/default guidance updates only move_camera and mode descriptions. // Radio markup and the complete production-request comparison pin locality. - // Restore the base camera/Globe View wording with the globe-rotation append. - assert.equal(block.length, 30878, 'serialized tool schema length drifted'); + // Panel adds timeout and named voice/provider targets; consolidated guidance + // preserves the mission mapping and globe-rotation arguments. + assert.equal(block.length, 31196, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '7b95f0f4258896fe5c3316265a19f3a6602809d4e7e681ee7ecd8f2df514ef1f', + '56c91b309fb583c8737a9c3c1530553dcc8159ba2f3c5d4d2db99921f28d8f1e', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 098c82f55..27f1e8250 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2952,5 +2952,19 @@ "Layers": "Capas", "Search": "Buscar", "More": "Más", - "Panels": "Paneles" + "Panels": "Paneles", + "Recent speech": "Habla reciente", + "Voice Settings": "Ajustes de voz", + "Preview": "Vista previa", + "Settings": "Ajustes", + "Close voice panel": "Cerrar panel de voz", + "Show Voice Preview": "Mostrar vista previa de voz", + "Voice inactivity timeout": "Tiempo de inactividad de voz", + "Custom": "Personalizado", + "Minutes": "Minutos", + "These preferences stay in this browser.": "Estas preferencias se guardan en este navegador.", + "Your choice applies now, but could not be saved.": "Tu elección se aplica ahora, pero no se pudo guardar.", + "Voice replies appear here.": "Las respuestas de voz aparecen aquí.", + "Preview is off.": "La vista previa está desactivada.", + "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz." } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 8d12ba43e..0d0a170d7 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3019,5 +3019,19 @@ "Layers": "Слои", "Search": "Поиск", "More": "Ещё", - "Panels": "Панели" + "Panels": "Панели", + "Recent speech": "Недавняя речь", + "Voice Settings": "Настройки голоса", + "Preview": "Предпросмотр", + "Settings": "Настройки", + "Close voice panel": "Закрыть голосовую панель", + "Show Voice Preview": "Показывать предпросмотр голоса", + "Voice inactivity timeout": "Тайм-аут бездействия голоса", + "Custom": "Другое", + "Minutes": "Минуты", + "These preferences stay in this browser.": "Эти настройки сохраняются в этом браузере.", + "Your choice applies now, but could not be saved.": "Ваш выбор применён, но сохранить его не удалось.", + "Voice replies appear here.": "Здесь появляются голосовые ответы.", + "Preview is off.": "Предпросмотр отключён.", + "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения." } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e727b8516..0b964456e 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2818,5 +2818,19 @@ "Layers": "图层", "Search": "搜索", "More": "更多", - "Panels": "面板" + "Panels": "面板", + "Recent speech": "最近语音", + "Voice Settings": "语音设置", + "Preview": "预览", + "Settings": "设置", + "Close voice panel": "关闭语音面板", + "Show Voice Preview": "显示语音预览", + "Voice inactivity timeout": "语音空闲超时", + "Custom": "自定义", + "Minutes": "分钟", + "These preferences stay in this browser.": "这些偏好设置仅保存在此浏览器中。", + "Your choice applies now, but could not be saved.": "选择已生效,但无法保存。", + "Voice replies appear here.": "语音回复显示在此处。", + "Preview is off.": "预览已关闭。", + "Automatic stop is unavailable for this voice connection.": "此语音连接不支持自动停止。" } diff --git a/src/keySetup.js b/src/keySetup.js index b67c9df66..550ac6e16 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -395,6 +395,7 @@ export async function initKeySetup({ const destroy = () => { if (disposed) return; disposed = true; + delete root.dataset.ready; unregisterUnsavedWork(); lifetime.abort(); signal?.removeEventListener('abort', destroy); @@ -591,6 +592,7 @@ export async function initKeySetup({ chip.addEventListener('click', openDialog); closeButton?.addEventListener('click', close); applyButton?.addEventListener('click', onApply); + root.dataset.ready = 'true'; // Remove buttons are rendered per row; delegate so re-renders stay wired. rowsHost?.addEventListener('click', (event) => { const authButton = event.target?.closest?.( diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 14ea0f3e0..dfe5b4257 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -1,5 +1,6 @@ import { readRealtimeSource } from './testSupport/readRealtimeSource.mjs'; import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js'; +import { realtimeInstructions } from '../server/providers/openai/instructions.js'; import { readShellSource } from './testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../build/application-html.js'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; @@ -25,13 +26,14 @@ const css = readStylesheet(new URL('../style.css', import.meta.url)); function realtimeTools() { return GEV_REALTIME_TOOLS; } -test('Realtime schema exposes the authoritative 30-tool inventory', () => { +test('Realtime schema exposes the authoritative 31-tool inventory', () => { const tools = realtimeTools(); - assert.equal(tools.length, 30); + assert.equal(tools.length, 31); const names = tools.map((tool) => tool.name); - assert.equal(new Set(names).size, 30, 'tool names are unique'); + assert.equal(new Set(names).size, 31, 'tool names are unique'); assert.ok(names.includes('set_context_mode')); assert.ok(names.includes('control_cockpit')); + assert.ok(names.includes('set_voice_inactivity_timeout')); assert.ok(names.includes('select_nearest_aircraft')); assert.ok(names.includes('control_radio')); assert.ok(names.includes('next_satellite_pass')); @@ -77,13 +79,12 @@ test('the counting contract is stated in the Realtime instructions', () => { }); test('Context panel opening stays distinct from Contacts activation', () => { - const start = voice.indexOf("'For requests to open, show, reveal, or focus a menu/panel"); - assert.ok(start >= 0, 'panel-routing instruction is missing'); - const text = voice.slice(start, voice.indexOf('\n', start)); - assert.match(text, /"Open Context" means only set_panel_open/); - assert.match(text, /does not activate a Context sub-mode/); - assert.match(text, /"Open Contacts" means set_context_mode\{mode:"contacts"\}/); - assert.match(text, /expands the parent Context panel before activating Contacts/); + const text = realtimeInstructions().split('\n').find(line => line.startsWith('Panels:')); + assert.ok(text, 'panel-routing instruction is missing'); + assert.match(text, /"Open Context" is only set_panel_open\{panelId:"global-context-panel",open:true\}/); + assert.match(text, /opening Context or selecting\/viewing an aircraft never enters them/); + assert.match(text, /"Open Contacts" is set_context_mode contacts/); + assert.match(text, /expands Context itself/); }); test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { @@ -99,14 +100,14 @@ test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { assert.match(text, /nearest airborne result/); assert.match(text, /healthy fallback feed is valid data/i); assert.match(text, /Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity/); - assert.match(text, /SELECT\/FIND never implies Contacts or Cockpit/); + const routing = realtimeInstructions().split('\n').find(line => line.startsWith('Panels:')); + assert.match(routing, /Contacts, Space Missions and Cockpit need explicit naming/); assert.match(text, /set_context_mode, or control_cockpit/); const byName = new Map(realtimeTools().map((tool) => [tool.name, tool])); - assert.match(byName.get('set_context_mode').description, /explicitly requests/); - assert.match(byName.get('set_context_mode').description, /selecting an aircraft does not imply Context/i); - assert.match(byName.get('control_cockpit').description, /explicitly requests Cockpit/); - assert.match(byName.get('control_cockpit').description, /must not enter Cockpit/); + assert.match(byName.get('set_context_mode').description, /only when explicitly requested/); + assert.match(routing, /selecting\/viewing an aircraft never enters them/); + assert.match(byName.get('control_cockpit').description, /Explicit Cockpit requests only/); assert.equal( byName.get('fly_to_location').parameters.properties.waitForArrival.type, 'boolean', @@ -117,7 +118,6 @@ test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { assert.match(nearest.description, /Atomically/); assert.match(nearest.description, /exclude on-ground records/); assert.match(nearest.description, /fallback feeds remain usable/); - assert.match(nearest.description, /does not open Contacts or Cockpit/); }); test('the two Context/Cockpit tools pin their enums and required arguments', () => { @@ -153,7 +153,7 @@ test('the edited existing tools changed exactly as intended', () => { const panel = byName.get('set_panel_open'); assert.deepEqual( panel.parameters.properties.panelId.enum, - ['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel'], + ['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel', 'voice-settings', 'voice-preview', 'provider-settings'], ); assert.deepEqual(panel.parameters.required, ['panelId', 'open']); @@ -183,6 +183,7 @@ test('no unchanged Realtime tool definition drifts silently', () => { 'set_context_mode', 'control_cockpit', 'set_panel_open', + 'set_voice_inactivity_timeout', 'get_current_view_state', 'fly_to_location', 'select_nearest_aircraft', diff --git a/src/standalone/tools.js b/src/standalone/tools.js index eec4d0796..fdeb08d37 100644 --- a/src/standalone/tools.js +++ b/src/standalone/tools.js @@ -19,6 +19,7 @@ export function createStandaloneTools(options) { ...options, voice: { supportedProviders: ['openai', 'gemini'], + panel: true, toolCatalog: loadToolCatalog, ...options?.voice, }, diff --git a/src/ui/styles/voice-card.css b/src/ui/styles/voice-card.css index ef3d77055..5afe57a26 100644 --- a/src/ui/styles/voice-card.css +++ b/src/ui/styles/voice-card.css @@ -387,3 +387,249 @@ body.recording-mode .gev-pointer-reticle { animation: none; } } + +/* Opt-in shared panel: header stays fixed while only the body scrolls. */ +#gev-voice-control .gev-voice-panel { + position: absolute; + bottom: calc(100% + 0.7rem); + left: 50%; + z-index: 10; + display: flex; + flex-direction: column; + width: clamp(18rem, 26vw, 22rem); + max-width: calc(100vw - 1.5rem); + max-height: min( + 28rem, + max(7.5rem, calc(100dvh - 12rem - var(--gev-provider-lift, 0px))) + ); + box-sizing: border-box; + padding: 0.6rem 0.7rem 0.65rem; + color: rgba(223, 245, 250, 0.92); + background: rgba(7, 22, 31, 0.96); + border: 1px solid rgba(54, 220, 255, 0.34); + border-radius: 0.6rem; + box-shadow: + 0 0.85rem 2rem rgba(0, 0, 0, 0.58), + 0 0 1.2rem rgba(0, 212, 255, 0.08); + backdrop-filter: blur(24px) saturate(1.3); + -webkit-backdrop-filter: blur(24px) saturate(1.3); + color-scheme: dark; + font: 0.75rem/1.45 var(--font-sans); + text-align: left; + transform: translateX(-50%); +} +:root[data-ui-theme='cyber'] #gev-voice-control .gev-voice-panel { + background-color: var(--cyber-panel-surface); + background-image: var(--cyber-panel-surface-detail); + border-color: var(--glass-border); + box-shadow: var(--cyber-panel-shadow); +} +#gev-voice-control .gev-voice-panel[hidden], +#gev-voice-control .gev-voice-panel [hidden] { + display: none; +} +#gev-voice-control .gev-voice-panel-header { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 0.35rem; + padding: 0 0 0.5rem; + border-bottom: 1px solid var(--glass-border); +} +.gev-voice-panel-title { + color: var(--voice-panel-accent, var(--accent)); + font: 600 0.65rem/1 var(--font-mono); + letter-spacing: 0.14em; +} +.gev-voice-panel-tabs { + margin-left: auto; +} +#gev-voice-control .gev-voice-panel-header .gev-voice-panel-tab { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + padding: 0; +} +#gev-voice-control .gev-voice-panel-tab-icon { + font-size: 18px; +} +.gev-voice-panel-header [role='tablist'] { + display: flex; + flex-wrap: wrap; + gap: 0.2rem; + min-width: 0; +} +#gev-voice-control + .gev-voice-panel + :is(.gev-voice-panel-header button, select, input[type='number']) { + min-height: 2rem; + box-sizing: border-box; + color: var(--text-primary); + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--glass-border); + border-radius: 0.3rem; + font: inherit; +} +#gev-voice-control .gev-voice-panel .gev-voice-panel-header button { + padding: 0 0.6rem; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.04em; + line-height: 1; + cursor: pointer; +} +#gev-voice-control #gev-voice-settings-close { + width: 2rem; + padding: 0; + font-size: 1rem; +} +#gev-voice-control .gev-voice-panel-header button:hover { + color: var(--text-primary); + border-color: var(--voice-panel-accent, var(--accent)); +} +#gev-voice-control .gev-voice-panel [role='tab'][aria-selected='true'] { + color: var(--voice-panel-accent, var(--accent)); + border-color: currentColor; + box-shadow: 0 -2px 0 currentColor inset; +} +#gev-voice-control .gev-voice-panel [role='tab']:focus, +#gev-voice-control .gev-voice-panel :focus-visible { + outline: 2px solid var(--voice-panel-accent, var(--accent)); + outline-offset: 2px; +} +.gev-voice-panel-body { + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + padding: 0.55rem 0.75rem 3px 3px; + line-height: 1.45; + overflow-wrap: anywhere; +} +.gev-voice-panel-body .gev-voice-card-who { + overflow-wrap: break-word; +} +.gev-voice-panel-body p { + margin: 0; +} +.gev-voice-preference { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + color: var(--text-primary); +} +.gev-voice-preference-toggle { + flex-direction: row-reverse; + justify-content: space-between; + min-height: 2.75rem; + font-weight: 600; + cursor: pointer; +} +.gev-voice-preference-toggle input { + flex: 0 0 auto; + width: 18px; + height: 18px; + margin: 0; + accent-color: var(--voice-panel-accent, var(--accent)); +} +.gev-voice-setting-title { + margin-bottom: 0.4rem; + font-weight: 600; +} +.gev-voice-timeout-group { + margin-top: 0.65rem; + padding-top: 0.65rem; + border-top: 1px solid var(--glass-border); +} +.gev-voice-timeout-controls { + display: flex; + align-items: center; + gap: 0.4rem; +} +#gev-voice-timeout-select { + flex: 1 1 auto; + min-width: 0; + padding: 0 0.45rem; +} +#gev-voice-timeout-input { + flex: 0 0 4.5rem; + width: 4.5rem; + padding: 0 0.45rem; + font-variant-numeric: tabular-nums; +} +.gev-voice-timeout-unit { + order: 1; + color: var(--text-dim); + font-size: 0.63rem; +} +.gev-voice-timeout-controls:has(#gev-voice-timeout-input[hidden]) + .gev-voice-timeout-unit { + display: none; +} +.gev-voice-settings-note { + color: rgba(190, 221, 230, 0.72); + font-size: 0.63rem; + line-height: 1.45; +} +.gev-voice-settings-note:not(:empty) { + margin-top: 0.25rem; +} +#gev-voice-control .gev-voice-panel .gev-voice-card { + position: static; + width: 100%; + max-height: none; + overflow: visible; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + transform: none; + animation: none; +} +#command-dock > #gev-voice-control:has(.gev-voice-panel:not([hidden])) { + z-index: 101; +} +#gev-voice-control:has(.gev-voice-panel:not([hidden])) .gev-voice-help-tray { + display: none; +} +#command-dock #gev-voice-control[data-provider-choice='true'] .gev-voice-panel { + bottom: min( + calc(100% + 4.8rem + var(--gev-provider-lift)), + calc(100dvh - 7.75rem) + ); +} + +#command-dock.dock-has-pinned-tray + #gev-voice-control:not([data-provider-choice='true']) + .gev-voice-panel { + bottom: min( + calc(100% + 1.7rem + var(--dock-pinned-stack-height)), + calc(100dvh - 18rem) + ); +} +@media (pointer: coarse) { + #gev-voice-control + .gev-voice-panel + :is( + .gev-voice-panel-header button, + select, + input[type='number'], + .gev-voice-card-dismiss + ) { + min-height: 44px; + } + #gev-voice-control + .gev-voice-panel + :is(.gev-voice-panel-header button, .gev-voice-card-dismiss) { + min-width: 44px; + } +} + +/* A connection error remains reachable even while the manual panel is open. */ +#gev-voice-control:has(.gev-voice-panel) .gev-voice-error-tray { + z-index: 12; +} diff --git a/src/ui/styles/voice-cost.css b/src/ui/styles/voice-cost.css index b43f40168..9d655e484 100644 --- a/src/ui/styles/voice-cost.css +++ b/src/ui/styles/voice-cost.css @@ -111,7 +111,7 @@ html:not([lang='en']) #command-dock .gev-voice-cost { padding-left: 0.1rem; } -html:not([lang='en']) #command-dock .gev-voice-tier-btn { +html:not([lang='en']) #command-dock .gev-voice-heading .gev-voice-tier-btn { min-width: 0; padding: 0 0.2rem; } @@ -232,3 +232,326 @@ html:not([lang='en']) pointer-events: auto; } } + +/* The opt-in provider and gear share a discoverable row at the existing anchor. */ +#gev-voice-control:has(> .gev-voice-settings-button) { + --gev-voice-settings-gap: 0.75rem; + --gev-voice-tray-clearance: 0px; +} +#gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field:not([hidden]) { + bottom: calc( + 100% + var(--gev-provider-lift, 0px) + var(--gev-voice-settings-gap) + ); + display: flex; + align-items: center; + gap: 6px; + padding: 6px 46px 6px 6px; + border-color: rgba(123, 189, 211, 0.28); + background: rgba(9, 18, 27, 0.88); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.46); + color-scheme: dark; + opacity: 1; + pointer-events: auto; +} +#gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field + > span { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +#gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field + select { + min-height: 34px; + font-size: 0.7rem; +} +#gev-voice-control .gev-voice-settings-button { + position: absolute; + top: 1rem; + right: 0.25rem; + z-index: 11; + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + min-width: 34px; + min-height: 34px; + padding: 0; + color: rgba(190, 221, 230, 0.72); + background: rgba(9, 18, 27, 0.92); + border: 1px solid var(--glass-border); + border-radius: 0.3rem; + font-size: 18px; + line-height: 1; + cursor: pointer; +} +#gev-voice-control[data-provider-choice='true']:has( + .gev-voice-provider-field:not([hidden]) + ) + .gev-voice-settings-button { + top: auto; + right: 7px; + bottom: calc( + 100% + var(--gev-provider-lift, 0px) + var(--gev-voice-settings-gap) + 7px + ); +} +#gev-voice-control + .gev-voice-settings-button:is(:hover, [aria-expanded='true']) { + color: var(--voice-panel-accent, var(--accent)); + border-color: currentColor; + background: rgba(0, 212, 255, 0.12); +} +#gev-voice-control .gev-voice-settings-button:focus-visible { + outline: 2px solid var(--voice-panel-accent, var(--accent)); + outline-offset: 2px; +} +:root[data-ui-theme='cyber'] + #gev-voice-control:has(> .gev-voice-settings-button) { + --voice-panel-accent: var(--cyber-red-bright); +} +:root[data-ui-theme='cyber'] + #gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field { + background: var(--cyber-panel-surface); + border-color: var(--glass-border); +} +@media (pointer: coarse) { + #gev-voice-control .gev-voice-settings-button { + width: 44px; + height: 44px; + min-width: 44px; + min-height: 44px; + } + #gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field:not([hidden]) { + padding-right: 56px; + } + #gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field + select { + min-height: 44px; + } +} + +/* Keep the row and popup on the same lift, with a small gap above pinned + trays. On very short screens the shared lift still yields to the viewport. */ +#command-dock.dock-has-pinned-tray + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + --gev-voice-settings-gap: 0.375rem; + --gev-provider-lift: min( + calc(var(--dock-pinned-stack-height) + var(--gev-voice-tray-clearance)), + max(0px, calc(100dvh - 20rem)) + ); +} + +/* Match the attribution-aware tray offsets without moving the trays. The + popup can scroll before the shared viewport budget caps its lift. */ +@media (max-width: 900px) { + #gev-voice-control:has(> .gev-voice-settings-button) { + --gev-voice-tray-clearance: calc(3rem - 1.2rem); + } +} +@media (max-width: 720px) { + #gev-voice-control:has(> .gev-voice-settings-button) { + --gev-voice-tray-clearance: calc(2vh + 2.5rem - 1.2rem); + } +} + +/* Short touch landscapes have room beside the dock, not above its pinned + stack. Keep the same chooser and gear together in that empty lower gutter; + the open popup remains a bounded, scrollable overlay above the microphone. */ +@media (pointer: coarse) and (orientation: landscape) and (min-width: 480px) and (max-height: 500px) { + #command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ), + #command-dock.dock-has-pinned-tray + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + --gev-provider-lift: 0px; + --gev-landscape-row-width: clamp( + 108px, + calc((100vw - var(--dock-unified-width)) / 2 - 0.875rem), + calc(100% - 2px) + ); + } + #command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + > .gev-voice-provider-field:not([hidden]) { + left: calc(50% - 50vw + 0.5rem); + right: auto; + bottom: 0.375rem; + width: var(--gev-landscape-row-width); + box-sizing: border-box; + } + #command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + > .gev-voice-settings-button { + left: calc(50% - 50vw + 0.5rem + var(--gev-landscape-row-width) - 51px); + right: auto; + top: auto; + bottom: calc(0.375rem + 7px); + } +} + +/* The revealed row clears pinned wings without making the Voice column's + noninteractive gaps cover their controls. Keep hit surfaces stable so hover + promotion cannot oscillate when the pointer crosses root padding. */ +#command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + pointer-events: none; +} +#command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + > :is( + #gev-voice-button, + .gev-voice-heading, + .gev-voice-readout, + .gev-voice-provider-field, + .gev-voice-settings-button, + .gev-voice-panel, + .gev-voice-error-tray + ) { + pointer-events: auto; +} +@media (hover: hover) and (pointer: fine) { + #command-dock.dock-has-pinned-tray + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ):is( + :hover, + [data-row-grace], + :has( + :focus-visible, + #gev-voice-provider:focus, + .gev-voice-panel[data-view='settings']:not([hidden]) + ) + ) { + z-index: 101; + } +} +@media (hover: none), (pointer: coarse) { + #command-dock.dock-has-pinned-tray + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + z-index: 101; + } +} + +/* Pointer grace crosses pinned trays without covering their controls. Keyboard + focus, the provider select and an open panel hold the shared row visible. */ +@media (hover: hover) and (pointer: fine) { + #command-dock + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + :is(.gev-voice-provider-field, .gev-voice-settings-button) { + transition: + opacity 160ms ease, + transform 180ms ease; + } + #command-dock + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ):not(:hover):not([data-row-grace]):not( + :has( + :focus-visible, + #gev-voice-provider:focus, + .gev-voice-panel[data-view='settings']:not([hidden]) + ) + ) + :is(.gev-voice-provider-field, .gev-voice-settings-button) { + opacity: 0; + pointer-events: none; + transform: translateY(8px); + } + #command-dock:not(.dock-has-pinned-tray) + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + .gev-voice-provider-field::after { + content: ''; + position: absolute; + top: 100%; + right: 0; + left: 0; + height: calc(var(--gev-voice-settings-gap) + 8px); + } +} + +/* Existing tier and estimate controls are readable Settings content in panel mode. */ +.gev-voice-cost-settings { + display: grid; + gap: 0.55rem; + margin-top: 0.75rem; + padding-top: 0.7rem; + border-top: 1px solid var(--glass-border); +} +.gev-voice-cost-settings:has(#gev-voice-tier[hidden]):has( + #gev-voice-cost-value[hidden] + ), +.gev-voice-cost-setting-row:has(> [hidden]) { + display: none; +} +.gev-voice-cost-setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 2rem; + color: var(--text-primary); + font-size: 0.68rem; +} +.gev-voice-cost-setting-row > span { + min-width: 0; +} +#gev-voice-control .gev-voice-cost-settings .gev-voice-tier-btn { + flex: 0 0 auto; + min-height: 2rem; + min-width: 3.25rem; + padding: 0.3rem 0.55rem; + font-size: 0.67rem; +} +.gev-voice-cost-settings .gev-voice-cost-value { + flex: 0 0 auto; + font-size: 0.75rem; +} +.gev-voice-cost-settings .gev-voice-cost-value[data-level='ok'] { + color: var(--voice-panel-accent, var(--accent)); +} +#gev-voice-control .gev-voice-settings-icon { + font-size: 18px; + line-height: 1; +} +@media (pointer: coarse) { + #gev-voice-control .gev-voice-cost-settings .gev-voice-tier-btn { + min-height: 44px; + min-width: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + #command-dock + #gev-voice-control:has(> .gev-voice-settings-button) + :is(.gev-voice-provider-field, .gev-voice-settings-button) { + transition: none; + } +} diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index 3f464cad1..bf7e2f66d 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -178,6 +178,9 @@ const schemas = [ 'global-context-panel', 'weather-panel', 'recent-imagery-panel', + 'voice-settings', + 'voice-preview', + 'provider-settings', ], }, open: { @@ -187,6 +190,15 @@ const schemas = [ required: ['panelId', 'open'], }, }, + { + name: 'set_voice_inactivity_timeout', + parameters: { + type: 'object', + additionalProperties: false, + properties: { minutes: { type: 'integer', minimum: 0, maximum: 60 } }, + required: ['minutes'], + }, + }, { name: 'set_context_mode', parameters: { @@ -223,6 +235,7 @@ const schemas = [ aircraftClass: { type: 'string', }, + voicePanel: { type: 'string', enum: ['preview', 'settings'] }, }, required: ['action'], }, diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index d65d7d8db..5cb882527 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -32,7 +32,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea .digest('hex'); assert.equal( digest, - // Re-derived for the voice layer manifest (generated layer enums and + // Re-derived for Provider Settings and explicit Visual Presets/Scenes routing, + // plus the deliberate direct Panel tool/target additions and voice layer manifest (generated layer enums and // aliases), point-and-ask (pointer sentinels, referent args) and the // consolidated tool wording (each policy stated once); model-facing tools // omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves @@ -41,8 +42,9 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // Air quality (ECCC AQHI) extends the manifest. // Globe rotation adds one motion; visual keyhole fit and explicit // reset guidance/defaults update only move_camera and mode descriptions. - // Restore base descriptions; append only globe-rotation defaults and fit guidance. - '967b5a043b812e2f8c1e190fc571cf12b960c9d4486df736902221aab4d99cf6', + // Panel adds named voice targets and timeout; shared Panel/Cockpit guidance + // is consolidated without changing either feature's arguments. + 'b2cd2decb77fb3f8f0ab65e7907c221580e21ee3e7e77581f1b52bf62126ae86', ); }); @@ -100,12 +102,18 @@ test('metadata cannot add tools, fields, types or enum values', () => { test('all legacy action arguments are byte-identical after removing the deliberate additions', () => { const legacy = structuredClone(GEV_ACTION_SCHEMAS).filter( - (tool) => !['next_satellite_pass', 'set_cyber_sonar'].includes(tool.name), + (tool) => + ![ + 'next_satellite_pass', + 'set_cyber_sonar', + 'set_voice_inactivity_timeout', + ].includes(tool.name), ); // Layer enums are generated from the voice layer manifest and pinned by // layerManifest.test.mjs; the two shipped right-rail panels are additive. const property = (name) => legacy.find((tool) => tool.name === name).parameters.properties; + delete property('control_cockpit').voicePanel; delete property('set_layer_visibility').layerId.enum; delete property('show_data_layers_menu').layerId.enum; delete property('get_entity_context').layerId.enum; @@ -123,7 +131,14 @@ test('all legacy action arguments are byte-identical after removing the delibera motion.enum = motion.enum.filter((value) => value !== 'rotate_globe'); const panels = property('set_panel_open').panelId; panels.enum = panels.enum.filter( - (id) => !['weather-panel', 'recent-imagery-panel'].includes(id), + (id) => + ![ + 'weather-panel', + 'recent-imagery-panel', + 'voice-settings', + 'voice-preview', + 'provider-settings', + ].includes(id), ); for (const tool of legacy) { // Point-and-ask adds `referent` arguments and 'pointer' enum values. @@ -228,3 +243,13 @@ test('globe reset guidance requires a separate explicit command after refusal', assert.match(paragraph, /bare yes\/ok/); assert.match(paragraph, /Do not ask a question/); }); + +test('direct voice timeout schema accepts None and exact integers, rejects malformed duration', () => { + const tool = GEV_REALTIME_TOOLS.find( + (tool) => tool.name === 'set_voice_inactivity_timeout', + ); + for (const minutes of [0, 1, 5, 60]) + assert.equal(validateArgs(tool.parameters, { minutes }).length, 0); + for (const minutes of [-1, 61, 1.5, '5']) + assert.ok(validateArgs(tool.parameters, { minutes }).length > 0); +}); diff --git a/src/voice/commands.js b/src/voice/commands.js index c5f92b209..1fed3bc12 100644 --- a/src/voice/commands.js +++ b/src/voice/commands.js @@ -2,17 +2,50 @@ import { createVoiceCommands as bindVoiceCommands } from './sessionCommands.js'; import { createRealtimeSession } from './realtimeSession.js'; import { createGeminiSession } from './geminiSession.js'; import { createProviderCommands } from './providerCommands.js'; +import { createVoicePanelHost } from './panel/host.js'; /** Default composition; callers may supply another session adapter factory. */ export function createVoiceCommands(options = {}) { + const panelHost = + options.panel === true + ? createVoicePanelHost({ + storage: options.panelStorage, + clock: options.panelClock, + readCockpitState: options.styleManager?.getCockpitState + ? () => options.styleManager.getCockpitState() + : undefined, + }) + : null; + const bind = (settings) => + bindVoiceCommands({ + ...settings, + ...(panelHost + ? { + panelHost, + nativePanelProvider: settings.createController + ? null + : settings.createSession === createRealtimeSession + ? 'openai' + : settings.createSession === createGeminiSession + ? 'gemini' + : null, + } + : {}), + }); + let commands; // Generic hosts retain the existing default; standalone opts in explicitly. if (!options.createSession && options.supportedProviders !== undefined) - return createProviderCommands(options, { - bind: bindVoiceCommands, + commands = createProviderCommands(options, { + bind, factories: { openai: createRealtimeSession, gemini: createGeminiSession }, }); - return bindVoiceCommands({ - createSession: createRealtimeSession, - ...options, - }); + else commands = bind({ createSession: createRealtimeSession, ...options }); + if (panelHost) { + const lifetime = commands.signal ?? commands.session?.signal; + lifetime?.addEventListener('abort', () => panelHost.destroy(), { + once: true, + }); + if (lifetime?.aborted) panelHost.destroy(); + } + return commands; } diff --git a/src/voice/control.js b/src/voice/control.js index c77197412..af9d37cbc 100644 --- a/src/voice/control.js +++ b/src/voice/control.js @@ -27,7 +27,7 @@ export function voiceStatusLabel(status) { } /** Build the voice control independently of its connection backend. */ -export function createVoiceControl({ reset = false } = {}) { +export function createVoiceControl({ reset = false, panel = false } = {}) { let root = document.getElementById('gev-voice-control'); if (root && reset) { root.remove(); @@ -43,13 +43,18 @@ export function createVoiceControl({ reset = false } = {}) { ${t('Voice provider')} + ${panel ? `` : ''}
${t('AI AGENT')}
${voiceStatusLabel('idle')}
-
+ ${ + panel + ? '' + : `
~$0.00 -
+
` + }
+ + + + +
+
+

${t('Voice replies appear here.')}

` + : '' + }
+ ${ + panel + ? ` + + + ` + : '' + }