From f86b8954f664a052bcbd36c275cce86369418223 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 10 Nov 2026 16:01:50 -0800 Subject: [PATCH] fix(voice): a missing-key error keeps its wording and hides the microphone hint after a language change (#1599) The voice error was matched against the translated key message to decide whether to show the microphone hint, so switching language brought the hint back and left the message in the old language. The error now carries a missing-key flag into the status, which words the message at render time. A controller test covers the language switch, and CURRENT-STATE.md documents the keyless voice response. --- docs/CURRENT-STATE.md | 2 +- src/voice/gevRealtime.test.mjs | 30 ++++++++++++++++++++++++++++++ src/voice/realtimeController.js | 16 ++++++++++++---- src/voice/realtimeDiagnostics.js | 6 +++++- 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b04d4cf2e..d9721b2a3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6269,7 +6269,7 @@ and unreachable upstream (502/504) separately from road geometry. `GEV MIC` button (bottom UI) starts an OpenAI Realtime session over WebRTC: -- **Token flow**: browser fetches a short-lived client secret from `/api/realtime/token`; the Vite middleware uses `OPENAI_API_KEY` by default, or the signed-in local ChatGPT/Codex OAuth access token when OAuth was explicitly selected in Provider Settings, and posts the full session config (instructions, tool schemas, VAD, truncation) to `api.openai.com/v1/realtime/client_secrets`. Both long-lived credentials stay server-side; OAuth minting is loopback-only. SDP exchange goes directly to `api.openai.com/v1/realtime/calls` with the ephemeral token. +- **Token flow**: browser fetches a short-lived client secret from `/api/realtime/token`; the Vite middleware uses `OPENAI_API_KEY` by default, or the signed-in local ChatGPT/Codex OAuth access token when OAuth was explicitly selected in Provider Settings, and posts the full session config (instructions, tool schemas, VAD, truncation) to `api.openai.com/v1/realtime/client_secrets`. Both long-lived credentials stay server-side; OAuth minting is loopback-only. SDP exchange goes directly to `api.openai.com/v1/realtime/calls` with the ephemeral token. With no OpenAI key the token route answers `503` with code `API_KEY_MISSING`, and the voice control shows `Needs OPENAI_API_KEY — add it in Provider Settings` in the interface language, reworded on a language change, without the microphone-permission hint. - **Session defaults** (env-tunable): model `gpt-realtime-2` (or `gpt-realtime-2.1-mini` when the MINI tier is selected — see the model-tier entry below), voice `marin`, reasoning effort `low`, semantic VAD with low eagerness, no response interruption, context window truncated to ~3,000 post-instruction tokens with 0.5 retention ratio — the conversational window stays short because map state is fetched live per turn. - **Thirty tools** (argument schemas in `src/voice/actionSchemas.js`, served with their descriptions by `server/providers/openai/tools.js`, executed client-side in `src/voice/gevActions.js`): `fly_to_location`, `select_nearest_aircraft`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_visual_style`, `get_entity_context`, `get_current_view_state`, `set_hud`, `set_cyber_sonar`, `set_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_cctv`, `set_context_mode`, `control_cockpit`, `control_radio`, `track_entity`, `stop_tracking`, `frame_overhead`, `annotate_map`, `clear_annotations`, `move_camera`, `fly_route`, `analyst_query`, `next_iss_pass`, and `next_satellite_pass`. diff --git a/src/voice/gevRealtime.test.mjs b/src/voice/gevRealtime.test.mjs index 859932e08..426a15f5c 100644 --- a/src/voice/gevRealtime.test.mjs +++ b/src/voice/gevRealtime.test.mjs @@ -35,6 +35,7 @@ import { } from './gevRealtime.js'; import { createVoiceCostTracker } from './voiceCost.js'; import { createTestVoiceUi } from '../testSupport/voiceUi.mjs'; +import { setLocale } from '../i18n/index.js'; test('push-to-talk recognizes Space by code or key', () => { assert.equal(PUSH_TO_TALK_HOLD_DELAY_MS, 500); @@ -3734,3 +3735,32 @@ test('the controller forwards viewport escalation diagnostics to its debug log', }], ); }); + +test('a missing server key says where to add it, without the microphone hint, in every language', () => { + const ui = createTestVoiceUi(); + const controller = new GevRealtimeController({ + ui, + runner: async () => ({ ok: true }), + radioLayer: { subscribePlaybackControls: () => () => {}, setVoiceDucked() {} }, + }); + try { + controller.reportError( + 'Realtime connection', + Object.assign(new Error('OPENAI_API_KEY is not set'), { code: 'API_KEY_MISSING' }), + ); + assert.equal(ui.state.error.text, 'Needs OPENAI_API_KEY — add it in Provider Settings'); + assert.equal(ui.state.error.hint, false); + + setLocale('es'); + assert.notEqual(ui.state.error.text, 'Needs OPENAI_API_KEY — add it in Provider Settings'); + assert.match(ui.state.error.text, /OPENAI_API_KEY/); + assert.equal(ui.state.error.hint, false); + setLocale('en'); + + controller.reportError('Realtime connection', new Error('ICE failed')); + assert.match(ui.state.error.text, /ICE failed/); + assert.equal(ui.state.error.hint, true); + } finally { + setLocale('en'); + } +}); diff --git a/src/voice/realtimeController.js b/src/voice/realtimeController.js index a7dd07021..56f501f95 100644 --- a/src/voice/realtimeController.js +++ b/src/voice/realtimeController.js @@ -407,7 +407,14 @@ export class GevRealtimeController extends RealtimeFacade { } } - setStatus(status, detail) { + /** + * @param {string} status + * @param {string} [detail] + * @param {{missingKey?: boolean}} [options] True when the error is a + * missing server key: it is worded in the current language and shown + * without the microphone hint. + */ + setStatus(status, detail, { missingKey = false } = {}) { this.status = status; this.emitSessionEvent({ type: 'state', state: status, detail }); this.ui.update('root', { @@ -435,11 +442,12 @@ export class GevRealtimeController extends RealtimeFacade { this.ui.update('error', { text: () => status === 'error' - ? resolvedDetail() || t('Voice session could not be started.') + ? (missingKey && missingVoiceKeyText()) || + resolvedDetail() || + t('Voice session could not be started.') : '', // "Check microphone permission" is wrong advice for a missing key. - hint: () => - !(status === 'error' && resolvedDetail() === missingVoiceKeyText()), + hint: () => !(status === 'error' && missingKey), }); if (status === 'idle' || status === 'connecting' || status === 'error') { this.setVoiceSpeaker('idle'); diff --git a/src/voice/realtimeDiagnostics.js b/src/voice/realtimeDiagnostics.js index dcdacf08e..6efa71dfc 100644 --- a/src/voice/realtimeDiagnostics.js +++ b/src/voice/realtimeDiagnostics.js @@ -183,7 +183,11 @@ export class RealtimeDiagnostics { storeErrors(this.errors); console.error('[GEV Realtime]', record); this.debugLog('error', record); - this.setStatus('error', formatErrorForDisplay(record)); + this.setStatus( + 'error', + formatErrorForDisplay(record), + record.code === 'API_KEY_MISSING' ? { missingKey: true } : undefined, + ); return record; }