mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 15:22:21 +00:00
fix(voice): messages worded before reaching the status also follow a language change (#1661)
The radio and push-to-talk helpers shared by both voice providers now pass English catalog text, and the OpenAI controller words its detail when shown, like the Gemini session. A token failure without a server message carries its status as sourceParams, so it is worded again with the number. The docs say sourceDetail is the message as given, catalog text when it is the app's own.
This commit is contained in:
1 parent
44dd6d48a8
commit
874f83ba48
9 files changed
+107
-19
No files matched your search
@@ -361,23 +361,31 @@ export class GeminiSessionController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{missingKey?: boolean}} [options] True when the server has no
|
||||
* Gemini key: the control then drops its microphone hint.
|
||||
* @param {{missingKey?: boolean, params?: object}} [options] `missingKey`
|
||||
* is true when the server has no Gemini key: the control then drops its
|
||||
* microphone hint. `params` fill the placeholders of `detail`.
|
||||
*/
|
||||
setStatus(status, detail, recovery, { missingKey = false } = {}) {
|
||||
setStatus(
|
||||
status,
|
||||
detail,
|
||||
recovery,
|
||||
{ missingKey = false, params = null } = {},
|
||||
) {
|
||||
this.status = status;
|
||||
if (status === 'listening' && this.input.pushToTalkMode)
|
||||
detail = this.input.pushToTalkKeyHeld
|
||||
? msg('Release Space to send')
|
||||
: msg('Hold Space to talk');
|
||||
// `detail` is worded in the current language; `sourceDetail` keeps the
|
||||
// English catalog text, so a view can word it again after a language
|
||||
// change.
|
||||
// message as given (English catalog text, with `sourceParams` for its
|
||||
// placeholders, when it is ours), so a view can word it again after a
|
||||
// language change.
|
||||
const event = {
|
||||
type: 'state',
|
||||
state: status,
|
||||
detail: translateKnown(detail),
|
||||
detail: params ? t(detail, params) : translateKnown(detail),
|
||||
...(typeof detail === 'string' && { sourceDetail: detail }),
|
||||
...(params && { sourceParams: params }),
|
||||
};
|
||||
if (recovery) event.recovery = translateKnown(recovery);
|
||||
if (missingKey) event.missingKey = true;
|
||||
@@ -603,6 +611,15 @@ export class GeminiSessionController {
|
||||
}),
|
||||
),
|
||||
typeof data?.code === 'string' ? { code: data.code } : {},
|
||||
// The status words this again in the current language when shown.
|
||||
typeof data?.error === 'string'
|
||||
? {}
|
||||
: {
|
||||
wording: {
|
||||
text: msg('Gemini token request failed (HTTP {status})'),
|
||||
params: { status: response.status },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (
|
||||
typeof data?.model !== 'string' ||
|
||||
@@ -976,11 +993,14 @@ export class GeminiSessionController {
|
||||
)
|
||||
: error?.name === 'TimeoutError'
|
||||
? msg('Gemini connection timed out. Try again.')
|
||||
: error?.message || msg('Gemini voice could not start');
|
||||
this.lastError = detail;
|
||||
: error?.wording?.text ||
|
||||
error?.message ||
|
||||
msg('Gemini voice could not start');
|
||||
this.lastError = error?.wording ? error.message : detail;
|
||||
this.stop({ preserveStatus: true });
|
||||
this.setStatus('error', detail, recovery, {
|
||||
missingKey: error?.code === 'API_KEY_MISSING',
|
||||
params: error?.wording?.params,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3688,3 +3688,24 @@ test('an error keeps its English catalog text beside the worded detail', async (
|
||||
);
|
||||
assert.notEqual(error.detail, error.sourceDetail);
|
||||
});
|
||||
|
||||
test('a token failure without a message keeps its status for the next language', async (t2) => {
|
||||
t2.after(() => setLocale('en'));
|
||||
setLocale('es');
|
||||
const h = harness({
|
||||
fetchImpl: async () => Response.json({}, { status: 502 }),
|
||||
});
|
||||
await h.c.start();
|
||||
const error = h.events
|
||||
.filter((event) => event.type === 'state' && event.state === 'error')
|
||||
.at(-1);
|
||||
assert.equal(
|
||||
error.sourceDetail,
|
||||
'Gemini token request failed (HTTP {status})',
|
||||
);
|
||||
assert.deepEqual(error.sourceParams, { status: 502 });
|
||||
assert.equal(
|
||||
error.detail,
|
||||
t('Gemini token request failed (HTTP {status})', { status: 502 }),
|
||||
);
|
||||
});
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
} from './gevRealtime.js';
|
||||
import { createVoiceCostTracker } from './voiceCost.js';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { setLocale, t } from '../i18n/index.js';
|
||||
|
||||
test('push-to-talk recognizes Space by code or key', () => {
|
||||
assert.equal(PUSH_TO_TALK_HOLD_DELAY_MS, 500);
|
||||
@@ -3764,3 +3764,21 @@ test('a missing server key says where to add it, without the microphone hint, in
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('a status from a shared helper is worded again after a language change', () => {
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
runner: async () => ({ ok: true }),
|
||||
radioLayer: { subscribePlaybackControls: () => () => {}, setVoiceDucked() {} },
|
||||
});
|
||||
try {
|
||||
controller.setStatus('listening', 'Radio did not start');
|
||||
assert.equal(ui.state.detail.text, 'Radio did not start');
|
||||
setLocale('es');
|
||||
assert.equal(ui.state.detail.text, t('Radio did not start'));
|
||||
assert.notEqual(ui.state.detail.text, 'Radio did not start');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -928,3 +928,27 @@ test('a provider message is worded again after a language change', async (t2) =>
|
||||
assert.equal(ui.state.detail.text, t('Ask or command'));
|
||||
assert.notEqual(ui.state.detail.text, 'Ask or command');
|
||||
});
|
||||
|
||||
test('a provider message with placeholders is worded again with them', async (t2) => {
|
||||
t2.after(() => setLocale('en'));
|
||||
const f = compositionFixture(t2, {});
|
||||
f.select('gemini');
|
||||
await settle();
|
||||
const ui = f.views.at(-1);
|
||||
const text = 'Gemini token request failed (HTTP {status})';
|
||||
f.instances.at(-1).hooks.emit({
|
||||
type: 'state',
|
||||
state: 'error',
|
||||
detail: t(text, { status: 502 }),
|
||||
sourceDetail: text,
|
||||
sourceParams: { status: 502 },
|
||||
});
|
||||
assert.equal(ui.state.error.text, 'Gemini token request failed (HTTP 502)');
|
||||
setLocale('es');
|
||||
assert.equal(ui.state.error.text, t(text, { status: 502 }));
|
||||
assert.match(ui.state.error.text, /502/);
|
||||
assert.notEqual(
|
||||
ui.state.error.text,
|
||||
'Gemini token request failed (HTTP 502)',
|
||||
);
|
||||
});
|
||||
@@ -54,7 +54,7 @@ export {
|
||||
|
||||
import { createRealtimeBackend } from './realtimeBackend.js';
|
||||
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, translateKnown } from '../i18n/index.js';
|
||||
import { voiceStatusLabel } from './control.js';
|
||||
|
||||
// Machine states (dataset.status, CSS); voiceStatusLabel() translates labels.
|
||||
@@ -416,10 +416,13 @@ export class GevRealtimeController extends RealtimeFacade {
|
||||
*/
|
||||
setStatus(status, detail, { missingKey = false } = {}) {
|
||||
this.status = status;
|
||||
// A detail may be English catalog text (from the helpers this controller
|
||||
// shares with Gemini): it is worded in the current language when shown.
|
||||
this.emitSessionEvent({
|
||||
type: 'state',
|
||||
state: status,
|
||||
detail,
|
||||
detail: translateKnown(detail),
|
||||
...(typeof detail === 'string' && { sourceDetail: detail }),
|
||||
...(missingKey && { missingKey: true }),
|
||||
});
|
||||
this.ui.update('root', {
|
||||
@@ -437,7 +440,7 @@ export class GevRealtimeController extends RealtimeFacade {
|
||||
? held
|
||||
? t('Release Space to send')
|
||||
: t('Hold Space to talk')
|
||||
: detail;
|
||||
: translateKnown(detail);
|
||||
const primaryDetail = () =>
|
||||
status === 'error'
|
||||
? t('VOICE UNAVAILABLE')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
import {
|
||||
PUSH_TO_TALK_HOLD_DELAY_MS,
|
||||
MICROPHONE_VISUALIZER_GATE,
|
||||
@@ -124,7 +124,7 @@ export class RealtimeInput {
|
||||
this.ui.update('root', { pushToTalk: 'held' });
|
||||
this.setMicrophoneEnabled(true);
|
||||
if (this.status === 'listening')
|
||||
this.setStatus('listening', t('Release Space to send'));
|
||||
this.setStatus('listening', msg('Release Space to send'));
|
||||
} else {
|
||||
this.start({ pushToTalk: true });
|
||||
// start() performs a controlled stop() before connecting. Restore the
|
||||
@@ -201,7 +201,7 @@ export class RealtimeInput {
|
||||
if (!this.pushToTalkMode) return;
|
||||
this.setMicrophoneEnabled(false);
|
||||
if (this.status === 'listening')
|
||||
this.setStatus('listening', t('Hold Space to talk'));
|
||||
this.setStatus('listening', msg('Hold Space to talk'));
|
||||
else this.updateVoiceButtonLabel();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { msg } from '../i18n/index.js';
|
||||
import {
|
||||
silenceRadioForVoice,
|
||||
startPreparedRadioAfterPlaybackReady,
|
||||
@@ -261,7 +261,7 @@ export class RealtimeRadio {
|
||||
if (radioHandoff.result?.ok || radioHandoff.cancelled || !stillCurrent)
|
||||
return;
|
||||
if (this.dc?.readyState === 'open' && !this.userTurnPending) {
|
||||
this.setStatus('listening', t('Radio did not start'));
|
||||
this.setStatus('listening', msg('Radio did not start'));
|
||||
this.queueResponseCreate(
|
||||
'Say exactly one short correction: “The Radio station could not start. Voice is still on.”',
|
||||
);
|
||||
|
||||
@@ -122,7 +122,9 @@ export function createVoiceCommands({
|
||||
source === 'Voice off'
|
||||
? ''
|
||||
: event.sourceDetail
|
||||
? translateKnown(event.sourceDetail)
|
||||
? event.sourceParams
|
||||
? t(event.sourceDetail, event.sourceParams)
|
||||
: translateKnown(event.sourceDetail)
|
||||
: event.detail;
|
||||
ui.update('detail', {
|
||||
text: () => detail() || (idle ? t('Voice off') : t('Voice active')),
|
||||
|
||||
Reference in new issue
Block a user