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.
This commit is contained in:
Sameh Khamis committed 2026-11-10 16:11:30 -08:00
1 parent 0687e0e3da
commit f86b8954f6
4 files changed
+48 -6

No files matched your search

+1 -1
View File
@@ -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`.
+30
View File
@@ -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');
}
});
+12 -4
View File
@@ -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');
+5 -1
View File
@@ -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;
}