mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
i18n: fix review findings and polish ru/es strings
Fixes the 51 P0–P2 findings from the full review of the i18n branch and the issues found while checking every new string on screen in ru and es. - Translate errors and statuses that still reached ru/es in English: feed and tile errors, provider statuses, the ALPR chip, the traffic mode chip, voice plan step labels, fire feed values, receiver bands, scene save reasons and the Cesium attribution link. - Show preset city names in the UI language (src/ui/placeNames.js); data and everything sent to the model stay English. - Share layer names between the layer panel, HUD and voice card (src/ui/layerNames.js). - Keep long ru/es strings whole: voice card error lines, analyst titles, the voice tier status, the Nepal evidence button, the ALPR retry chip. English layout is unchanged. - Ask before a language switch drops unsaved work. - Catalog fixes: glossary terms (пуск, пресет), gender agreement and consistent "peticiones" in es, fewer calques in both languages. - Fix English text that was itself wrong (e.g. "Exit cockpit to fly to a weather", raw tool ids in voice plan steps). Catalogs: es/ru 2535 messages each, complete. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8029747b7e
commit
00704ddc89
212 files changed
+9871
-909
No files matched your search
@@ -0,0 +1,102 @@
|
||||
// K-8: the analyst card shows its headline, scope, chips and caveats in the
|
||||
// interface language; the spoken headline stays English for the model.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { attachVoiceResult as withSpeech } from './speech.js';
|
||||
import {
|
||||
initialVoiceCardState,
|
||||
reduceVoiceCard,
|
||||
voiceCardView,
|
||||
} from './voiceCardPresentation.js';
|
||||
|
||||
function cardFor(name, result, args = {}) {
|
||||
const shaped = withSpeech(name, result, args);
|
||||
return {
|
||||
shaped,
|
||||
view: voiceCardView(
|
||||
[
|
||||
{ type: 'action-call', callId: 'c1', name, arguments: args },
|
||||
{ type: 'action-result', callId: 'c1', name, result: shaped },
|
||||
].reduce(reduceVoiceCard, initialVoiceCardState()),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const analyst = {
|
||||
ok: true,
|
||||
count: 3,
|
||||
complete: true,
|
||||
scopeLabel: 'anywhere in the loaded data',
|
||||
partial: true,
|
||||
feedState: 'degraded',
|
||||
unanswered: ['earthquakes'],
|
||||
coverage: { layersQueried: [{ layerKey: 'flights', source: 'OpenSky' }] },
|
||||
display: { scope: 'anywhere in the loaded data' },
|
||||
items: [],
|
||||
};
|
||||
|
||||
const radius = {
|
||||
...analyst,
|
||||
count: 1234,
|
||||
complete: false,
|
||||
partial: false,
|
||||
feedState: 'nominal',
|
||||
unanswered: [],
|
||||
scopeLabel: 'in view',
|
||||
display: {
|
||||
scope: 'within 42 km of the view centre',
|
||||
window: 'last 24 h',
|
||||
caveat:
|
||||
'counted 1,000 of 2,345 loaded records; earthquakes loading; flights: distance is ground distance, not overhead',
|
||||
},
|
||||
coverage: {
|
||||
layersQueried: [{ layerKey: 'flights', source: 'OpenSky' }],
|
||||
note: 'the flights layer loads by viewport',
|
||||
},
|
||||
};
|
||||
|
||||
test('K-8: analyst card in es has no English text', () => {
|
||||
setLocale('es');
|
||||
try {
|
||||
const { view } = cardFor('analyst_query', analyst);
|
||||
assert.doesNotMatch(view.result.title, /anywhere in the loaded data/);
|
||||
assert.ok(!view.result.chips.includes('partial'));
|
||||
assert.ok(!view.result.chips.includes('degraded'));
|
||||
assert.ok(!view.result.notes.some((n) => n.startsWith('Not answered')));
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('K-8: analyst scope, window and caveats in ru', () => {
|
||||
setLocale('ru');
|
||||
try {
|
||||
const { shaped, view } = cardFor('analyst_query', radius);
|
||||
assert.match(shaped.say, /^At least 1,234 aircraft in view/);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(view.result),
|
||||
/aircraft|within|view centre|last 24 h|counted|loading|ground distance|loads by viewport/,
|
||||
);
|
||||
assert.match(view.result.title, /^1\s234 /);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('K-8: the English card reads as before, layers by name', () => {
|
||||
const { shaped, view } = cardFor('analyst_query', analyst);
|
||||
assert.equal(view.result.title, '3 aircraft anywhere in the loaded data');
|
||||
assert.deepEqual(view.result.chips, ['degraded', 'partial']);
|
||||
assert.ok(view.result.notes.includes('Not answered: Earthquakes (24h)'));
|
||||
// The model still gets the layer key.
|
||||
assert.match(shaped.say, /earthquakes not answered/);
|
||||
const capped = cardFor('analyst_query', radius).view.result;
|
||||
assert.equal(capped.title, 'At least 1,234 aircraft in view');
|
||||
assert.deepEqual(capped.lines, [
|
||||
'within 42 km of the view centre',
|
||||
'last 24 h',
|
||||
]);
|
||||
assert.ok(capped.notes.includes('counted 1,000 of 2,345 loaded records'));
|
||||
assert.ok(capped.notes.includes('the flights layer loads by viewport'));
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { SPEECH_BUILDERS, attachVoiceResult } from './speech.js';
|
||||
import { resolveDeicticArgs } from './deixis.js';
|
||||
export { readLayerLifecycleSummary } from './layerSummary.js';
|
||||
import { defaultGeospatial } from '../search/defaults.js';
|
||||
import { msg } from '../i18n/index.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
CITY_POIS,
|
||||
@@ -1442,7 +1443,7 @@ function controlScene(sceneDirector, args = {}) {
|
||||
};
|
||||
}
|
||||
if (sceneAction === 'stop') {
|
||||
sceneDirector.stopScene('Stopped by voice');
|
||||
sceneDirector.stopScene(msg('Stopped by voice'));
|
||||
return { ok: true, action: 'control_scene', running: false };
|
||||
}
|
||||
if (sceneAction === 'next') {
|
||||
|
||||
@@ -1079,7 +1079,9 @@ test('generic layer visibility exposes lifecycle truth for every manager phase a
|
||||
});
|
||||
// The voice envelope is additive and states the failure plainly.
|
||||
assert.equal(say, "Couldn't turn on radio.");
|
||||
assert.equal(display.title, 'radio');
|
||||
// The card names the layer, not its id; the result keeps the id.
|
||||
assert.equal(display.title, 'Radio');
|
||||
assert.deepEqual(display.lines, ['Radio layer unavailable']);
|
||||
});
|
||||
|
||||
test('generic voice visibility maps Space Missions to the explicit mission layer', async () => {
|
||||
|
||||
@@ -124,7 +124,8 @@ test('a partial answer names the layers it could not read', async () => {
|
||||
assert.match(result.say, /Partial; earthquakes not answered/);
|
||||
assert.deepEqual(result.unanswered, ['earthquakes']);
|
||||
assert.ok(view.chips.includes('partial'));
|
||||
assert.ok(view.notes.includes('Not answered: earthquakes'));
|
||||
// The card names the layer; the result keeps its key for the model.
|
||||
assert.ok(view.notes.includes('Not answered: Earthquakes (24h)'));
|
||||
});
|
||||
|
||||
test('"the second one" is the second row the card lists', async () => {
|
||||
|
||||
@@ -105,7 +105,15 @@ export class RealtimeCost {
|
||||
warn: formatCostUsd(state.warnUsd),
|
||||
cap: formatCostUsd(state.capUsd),
|
||||
},
|
||||
) + (state.note ? ` ${state.note}` : '');
|
||||
) +
|
||||
// `state.note` is the tracker's English diagnostic; the tooltip says
|
||||
// the same in the interface language.
|
||||
(state.incomplete
|
||||
? ' ' +
|
||||
t(
|
||||
'Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.',
|
||||
)
|
||||
: '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,12 +154,12 @@ export class RealtimeCost {
|
||||
}
|
||||
this.syncCostUi();
|
||||
if (this.isActive() && this.ui?.detail) {
|
||||
this.setStatus(
|
||||
this.status,
|
||||
t('{tier} applies next session', {
|
||||
tier: this.voiceTier.toUpperCase(),
|
||||
}),
|
||||
);
|
||||
// The tier as the interface names it; the stored value stays English.
|
||||
const tier =
|
||||
resolveVoiceModel(this.voiceTier).tier === 'mini'
|
||||
? t('MINI')
|
||||
: t('STANDARD');
|
||||
this.setStatus(this.status, t('{tier} applies next session', { tier }));
|
||||
}
|
||||
return this.voiceTier;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// K-10: the API-key cost tooltip states an incomplete estimate in the
|
||||
// interface language; the tracker keeps its English note for diagnostics.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { RealtimeCost } from './realtimeCost.js';
|
||||
import { createVoiceCostTracker } from './voiceCost.js';
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`API-key cost tooltip for an incomplete session has no English (${locale})`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const costTracker = createVoiceCostTracker({ tier: 'standard' });
|
||||
costTracker.markIncomplete();
|
||||
const host = {
|
||||
costTracker,
|
||||
voiceTier: 'standard',
|
||||
cloudVoiceAuth: 'api-key',
|
||||
sessionCloudVoiceAuth: 'api-key',
|
||||
isActive: () => false,
|
||||
ui: {
|
||||
costValue: { hidden: true, dataset: {}, title: '', textContent: '' },
|
||||
},
|
||||
};
|
||||
RealtimeCost.prototype.syncCostUi.call(host);
|
||||
const title = host.ui.costValue.title;
|
||||
assert.ok(title.length > 0);
|
||||
assert.doesNotMatch(
|
||||
title,
|
||||
/Estimate is incomplete|still in flight/,
|
||||
title,
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('API-key cost tooltip in English still ends with the tracker note', () => {
|
||||
const costTracker = createVoiceCostTracker({ tier: 'standard' });
|
||||
costTracker.markIncomplete();
|
||||
const host = {
|
||||
costTracker,
|
||||
voiceTier: 'standard',
|
||||
cloudVoiceAuth: 'api-key',
|
||||
sessionCloudVoiceAuth: 'api-key',
|
||||
isActive: () => false,
|
||||
ui: {
|
||||
costValue: { hidden: true, dataset: {}, title: '', textContent: '' },
|
||||
},
|
||||
};
|
||||
RealtimeCost.prototype.syncCostUi.call(host);
|
||||
assert.ok(host.ui.costValue.title.endsWith(` ${costTracker.state().note}`));
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// K-11: the status after a tier switch names the tier in the interface
|
||||
// language, as the tier button does; the stored tier value stays English.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale, t } from '../i18n/index.js';
|
||||
import { RealtimeCost } from './realtimeCost.js';
|
||||
|
||||
function harness() {
|
||||
const button = { textContent: '', title: '', setAttribute() {} };
|
||||
const ui = { tierButton: button, detail: {} };
|
||||
const statusCalls = [];
|
||||
const cost = new RealtimeCost({
|
||||
readUi: () => ui,
|
||||
readStatus: () => 'listening',
|
||||
operations: {
|
||||
isActive: () => true,
|
||||
isVoiceSessionSettled: () => false,
|
||||
setStatus: (status, detail) => statusCalls.push(detail),
|
||||
},
|
||||
});
|
||||
return { cost, button, statusCalls };
|
||||
}
|
||||
|
||||
const EXPECTED = {
|
||||
en: {
|
||||
mini: 'MINI applies next session',
|
||||
standard: 'STANDARD applies next session',
|
||||
},
|
||||
ru: {
|
||||
mini: 'МИНИ — со следующей сессии',
|
||||
standard: 'СТАНДАРТ — со следующей сессии',
|
||||
},
|
||||
es: {
|
||||
mini: 'MINI desde la próxima sesión',
|
||||
standard: 'ESTÁNDAR desde la próxima sesión',
|
||||
},
|
||||
};
|
||||
|
||||
for (const locale of Object.keys(EXPECTED)) {
|
||||
test(`${locale}: tier status names the tier in the interface language`, (ctx) => {
|
||||
setLocale(locale);
|
||||
ctx.after(() => setLocale('en'));
|
||||
for (const tier of ['mini', 'standard']) {
|
||||
const { cost, button, statusCalls } = harness();
|
||||
assert.equal(cost.setVoiceTier(tier), tier, 'stored tier stays English');
|
||||
assert.equal(statusCalls.at(-1), EXPECTED[locale][tier]);
|
||||
assert.equal(button.textContent, tier === 'mini' ? t('MINI') : t('STD'));
|
||||
}
|
||||
});
|
||||
}
|
||||
+310
-11
@@ -9,11 +9,26 @@
|
||||
* referents the card lists (the referent registry reads the same answer, so
|
||||
* "the second one" always means the second row on screen).
|
||||
*
|
||||
* What the model receives stays English (`analystHeadline` feeds the spoken
|
||||
* answer); the card shows the same answer in the active language.
|
||||
*
|
||||
* Pure: no DOM, no Cesium.
|
||||
* @module voice/resultDisplay
|
||||
*/
|
||||
|
||||
import { voiceLayer } from './layerManifest.js';
|
||||
import {
|
||||
formatDate,
|
||||
formatNumber,
|
||||
getLocale,
|
||||
msg,
|
||||
t,
|
||||
tn,
|
||||
translateKnown,
|
||||
} from '../i18n/index.js';
|
||||
import { layerNameForId, sourceLabel } from '../ui/layerNames.js';
|
||||
import { localizePlaceName } from '../ui/placeNames.js';
|
||||
import { formatQuantity } from '../ui/unitLabels.js';
|
||||
|
||||
const LAYER_NOUNS = Object.freeze({
|
||||
flights: 'aircraft',
|
||||
@@ -52,13 +67,17 @@ export function analystNounFor(layerKeys) {
|
||||
|
||||
/** The count/scope headline shared by the analyst card and spoken answer. */
|
||||
const ANALYST_HEADLINE_MAX = 64;
|
||||
// The card's headline wraps, and a translation runs longer than the English:
|
||||
// "Al menos 12 aeronaves militares en un radio de 40 km de Los Ángeles" is 67.
|
||||
// English keeps the spoken bound, so its card title still equals `say`.
|
||||
const SHOWN_HEADLINE_MAX = 120;
|
||||
|
||||
function boundedHeadline(value) {
|
||||
function boundedHeadline(value, max = ANALYST_HEADLINE_MAX) {
|
||||
const text = String(value || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (text.length <= ANALYST_HEADLINE_MAX) return text;
|
||||
return `${text.slice(0, ANALYST_HEADLINE_MAX - 1).trimEnd()}…`;
|
||||
if (text.length <= max) return text;
|
||||
return `${text.slice(0, max - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function analystHeadline(result) {
|
||||
@@ -72,6 +91,282 @@ export function analystHeadline(result) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- the analyst card in the active language ---------------- */
|
||||
|
||||
/**
|
||||
* The count and its noun as the card shows them. The English matches
|
||||
* `analystNounFor`, one form for any count as the spoken headline has it.
|
||||
*/
|
||||
function shownCount(count, noun) {
|
||||
const values = { n: formatNumber(count) };
|
||||
switch (noun) {
|
||||
case 'aircraft':
|
||||
return tn(count, '{n} aircraft', '{n} aircraft', values);
|
||||
case 'military aircraft':
|
||||
return tn(
|
||||
count,
|
||||
'{n} military aircraft',
|
||||
'{n} military aircraft',
|
||||
values,
|
||||
);
|
||||
case 'ships':
|
||||
return tn(count, '{n} ships', '{n} ships', values);
|
||||
case 'satellites':
|
||||
return tn(count, '{n} satellites', '{n} satellites', values);
|
||||
case 'launches':
|
||||
return tn(count, '{n} launches', '{n} launches', values);
|
||||
case 'earthquakes':
|
||||
return tn(count, '{n} earthquakes', '{n} earthquakes', values);
|
||||
case 'fires':
|
||||
return tn(count, '{n} fires', '{n} fires', values);
|
||||
case 'fire perimeters':
|
||||
return tn(count, '{n} fire perimeters', '{n} fire perimeters', values);
|
||||
case 'cyclones':
|
||||
return tn(count, '{n} cyclones', '{n} cyclones', values);
|
||||
case 'buses':
|
||||
return tn(count, '{n} buses', '{n} buses', values);
|
||||
case 'bike share':
|
||||
return tn(count, '{n} bike share', '{n} bike share', values);
|
||||
case 'flock cameras':
|
||||
return tn(count, '{n} flock cameras', '{n} flock cameras', values);
|
||||
case 'military bases':
|
||||
return tn(count, '{n} military bases', '{n} military bases', values);
|
||||
case 'datacenters':
|
||||
return tn(count, '{n} datacenters', '{n} datacenters', values);
|
||||
case 'dams':
|
||||
return tn(count, '{n} dams', '{n} dams', values);
|
||||
case 'results':
|
||||
return tn(count, '{n} results', '{n} results', values);
|
||||
default:
|
||||
return `${values.n} ${noun}`;
|
||||
}
|
||||
}
|
||||
|
||||
const shownRadius = (km) => formatQuantity(Number(km), 'km');
|
||||
|
||||
const FIXED_SCOPES = new Map(
|
||||
[msg('anywhere in the loaded data'), msg('in view')].map((text) => [
|
||||
text,
|
||||
text,
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* The engine's scope phrases (analystEngine, the Contacts window in
|
||||
* gevActions) as the card shows them. The English is exactly the engine's;
|
||||
* a phrase not listed shows as given.
|
||||
*/
|
||||
const SCOPE_PATTERNS = [
|
||||
[
|
||||
/^within (\d+(?:\.\d+)?) km of the view centre$/,
|
||||
([, km]) =>
|
||||
t('within {radius} of the view centre', { radius: shownRadius(km) }),
|
||||
],
|
||||
[
|
||||
/^within (\d+(?:\.\d+)?) km of (.+) \(Contacts window\)$/,
|
||||
([, km, place]) =>
|
||||
t('within {radius} of {place} (Contacts window)', {
|
||||
radius: shownRadius(km),
|
||||
place: localizePlaceName(place),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^within (\d+(?:\.\d+)?) km of (.+)$/,
|
||||
([, km, place]) =>
|
||||
t('within {radius} of {place}', {
|
||||
radius: shownRadius(km),
|
||||
place: localizePlaceName(place),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^within (\d+(?:\.\d+)?) km$/,
|
||||
([, km]) => t('within {radius}', { radius: shownRadius(km) }),
|
||||
],
|
||||
[/^over (.+)$/, ([, region]) => t('over {region}', { region })],
|
||||
];
|
||||
|
||||
function shownScope(text) {
|
||||
const scope = String(text || '').trim();
|
||||
if (!scope) return '';
|
||||
if (FIXED_SCOPES.has(scope)) return t(FIXED_SCOPES.get(scope));
|
||||
for (const [pattern, show] of SCOPE_PATTERNS) {
|
||||
const match = pattern.exec(scope);
|
||||
if (match) return show(match);
|
||||
}
|
||||
return translateKnown(scope);
|
||||
}
|
||||
|
||||
/** The analyst headline in the active language, for the card. */
|
||||
function shownAnalystHeadline(result) {
|
||||
const keys = (result?.coverage?.layersQueried || []).map(
|
||||
(layer) => layer.layerKey,
|
||||
);
|
||||
const count = Number(result?.count) || 0;
|
||||
const counted = shownCount(count, analystNounFor(keys));
|
||||
const items =
|
||||
result?.complete === false
|
||||
? t('At least {items}', { items: counted })
|
||||
: counted;
|
||||
const scope = shownScope(result?.scopeLabel);
|
||||
return boundedHeadline(
|
||||
scope ? t('{items} {scope}', { items, scope }) : items,
|
||||
getLocale() === 'en' ? ANALYST_HEADLINE_MAX : SHOWN_HEADLINE_MAX,
|
||||
);
|
||||
}
|
||||
|
||||
/** A layer key as the card names it. */
|
||||
function shownLayerKey(key) {
|
||||
const text = String(key || '').trim();
|
||||
return layerNameForId(text) || text;
|
||||
}
|
||||
|
||||
const shownLayerKeys = (list) =>
|
||||
String(list || '')
|
||||
.split(/,\s*/)
|
||||
.map(shownLayerKey)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
/** The feed windows layers declare in the voice manifest. */
|
||||
const FIXED_WINDOWS = new Map(
|
||||
[msg('last 24 h'), msg('past 30 d and upcoming')].map((text) => [text, text]),
|
||||
);
|
||||
|
||||
function shownWindow(text) {
|
||||
return String(text || '')
|
||||
.split(/;\s*/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map((part) =>
|
||||
FIXED_WINDOWS.has(part)
|
||||
? t(FIXED_WINDOWS.get(part))
|
||||
: translateKnown(part),
|
||||
)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
const UTC_CLOCK = Object.freeze({
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
const UTC_DATE = Object.freeze({
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
/**
|
||||
* The snapshot time the note carries (ISO, as the model got it) as the card
|
||||
* shows it: "14:32:05 UTC", with the date first ("15.10.2026, 14:32:05 UTC")
|
||||
* when the snapshot is not from today (UTC). English shows the ISO time as
|
||||
* before; a value that is not a time shows as written.
|
||||
*/
|
||||
export function shownSnapshotTime(iso, now = Date.now()) {
|
||||
const time = Date.parse(iso);
|
||||
if (getLocale() === 'en' || !Number.isFinite(time)) return iso;
|
||||
const clock = `${formatDate(time, UTC_CLOCK)} UTC`;
|
||||
const today = new Date(now).toISOString().slice(0, 10);
|
||||
return new Date(time).toISOString().slice(0, 10) === today
|
||||
? clock
|
||||
: `${formatDate(time, UTC_DATE)}, ${clock}`;
|
||||
}
|
||||
|
||||
/** Coverage notes and caveats the engine and the action runner write. */
|
||||
const FIXED_NOTES = new Map(
|
||||
[
|
||||
msg('the flights layer loads by viewport'),
|
||||
msg('distance is ground distance, not overhead'),
|
||||
msg(
|
||||
'the count is a lower bound because at least one layer reached its retained limit.',
|
||||
),
|
||||
].map((text) => [text, text]),
|
||||
);
|
||||
|
||||
const LAYER_STATUS_NOTES = Object.freeze({
|
||||
off: (label) => t('{label} off', { label }),
|
||||
loading: (label) => t('{label} loading', { label }),
|
||||
error: (label) => t('{label} error', { label }),
|
||||
});
|
||||
|
||||
const NOTE_PATTERNS = [
|
||||
[
|
||||
/^counted the first ([\d,]+) loaded records$/,
|
||||
([, returned]) =>
|
||||
t('counted the first {returned} loaded records', {
|
||||
returned: formatNumber(Number(returned.replace(/,/g, ''))),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^counted ([\d,]+) of ([\d,]+) loaded records$/,
|
||||
([, returned, total]) =>
|
||||
t('counted {returned} of {total} loaded records', {
|
||||
returned: formatNumber(Number(returned.replace(/,/g, ''))),
|
||||
total: formatNumber(Number(total.replace(/,/g, ''))),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^(.+) just turned on — still loading, counts will rise$/,
|
||||
([, layers]) =>
|
||||
t('{layers} just turned on — still loading, counts will rise', {
|
||||
layers: shownLayerKeys(layers),
|
||||
}),
|
||||
],
|
||||
[
|
||||
// The note ends in a period unless a lower-bound clause follows it.
|
||||
/^Contacts panel snapshot(?: at (\S+))? — the same retained cohort supplies the card, speech, and follow-up memory(\.?)$/,
|
||||
([, time, period]) =>
|
||||
(time
|
||||
? t(
|
||||
'Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory',
|
||||
{ time: shownSnapshotTime(time) },
|
||||
)
|
||||
: t(
|
||||
'Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory',
|
||||
)) + period,
|
||||
],
|
||||
[
|
||||
/^([a-z0-9-]+) (off|loading|error)$/,
|
||||
([, key, status]) =>
|
||||
layerNameForId(key)
|
||||
? LAYER_STATUS_NOTES[status](shownLayerKey(key))
|
||||
: null,
|
||||
],
|
||||
[
|
||||
/^([a-z0-9-]+): (.+)$/,
|
||||
([, key, note]) =>
|
||||
layerNameForId(key) ? `${shownLayerKey(key)}: ${shownNote(note)}` : null,
|
||||
],
|
||||
];
|
||||
|
||||
function shownNote(text) {
|
||||
if (FIXED_NOTES.has(text)) return t(FIXED_NOTES.get(text));
|
||||
for (const [pattern, show] of NOTE_PATTERNS) {
|
||||
const match = pattern.exec(text);
|
||||
const shown = match ? show(match) : null;
|
||||
if (shown) return shown;
|
||||
}
|
||||
return translateKnown(text);
|
||||
}
|
||||
|
||||
/** Feed states as the card's chips show them; the values stay English. */
|
||||
const FEED_STATE_LABELS = Object.freeze({
|
||||
loading: msg('loading'),
|
||||
degraded: msg('degraded'),
|
||||
stale: msg('stale'),
|
||||
partial: msg('partial'),
|
||||
fallback: msg('fallback'),
|
||||
unavailable: msg('unavailable'),
|
||||
});
|
||||
|
||||
function shownFeedState(state) {
|
||||
const key = FEED_STATE_LABELS[state];
|
||||
return key ? t(key) : state;
|
||||
}
|
||||
|
||||
function analystCard(result) {
|
||||
if (!result?.ok) return null;
|
||||
const display = result.display || {};
|
||||
@@ -91,28 +386,32 @@ function analystCard(result) {
|
||||
].map((label) => ({ label }));
|
||||
return {
|
||||
display: {
|
||||
title: analystHeadline(result),
|
||||
title: shownAnalystHeadline(result),
|
||||
// The precise scope, unless the title already says exactly that.
|
||||
lines: [
|
||||
display.scope !== result.scopeLabel ? display.scope : null,
|
||||
display.window,
|
||||
display.scope !== result.scopeLabel ? shownScope(display.scope) : null,
|
||||
shownWindow(display.window),
|
||||
].filter(Boolean),
|
||||
chips: [
|
||||
...(result.feedState && result.feedState !== 'nominal'
|
||||
? [{ label: result.feedState }]
|
||||
? [{ label: shownFeedState(result.feedState) }]
|
||||
: []),
|
||||
...(result.partial ? [{ label: 'partial' }] : []),
|
||||
...(result.partial ? [{ label: t('partial') }] : []),
|
||||
],
|
||||
// Coverage qualifications lead the feed detail. The bounded card groups
|
||||
// overflow in its existing Notes disclosure rather than dropping them.
|
||||
preserveNotes: true,
|
||||
notes: [
|
||||
...notesOf(result.coverage?.note, display.caveat),
|
||||
...notesOf(result.coverage?.note, display.caveat).map(shownNote),
|
||||
...(unanswered.length
|
||||
? [`Not answered: ${unanswered.join(', ')}`]
|
||||
? [
|
||||
t('Not answered: {layers}', {
|
||||
layers: unanswered.map(shownLayerKey).join(', '),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
sources,
|
||||
sources: sources.map(({ label }) => ({ label: sourceLabel(label) })),
|
||||
},
|
||||
referents: Array.isArray(result.items) ? result.items : [],
|
||||
resultSet: true,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { createVoiceSession } from './session.js';
|
||||
import { realtimeSessionEvent } from './realtimeEvents.js';
|
||||
import { createVoiceCommands } from './sessionCommands.js';
|
||||
import { describeUnsavedWork } from '../i18n/index.js';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
@@ -300,17 +301,24 @@ test('common button and annotation bindings work with an alternate adapter and c
|
||||
assert.equal(ui.helpDetail.textContent, 'Activate to toggle voice');
|
||||
assert.equal(ui.status.textContent, 'LISTENING');
|
||||
assert.equal(ui.detail.textContent, 'Ready');
|
||||
// K-27: switching language (a reload) asks first during a session.
|
||||
assert.deepEqual(describeUnsavedWork(), ['the active voice session']);
|
||||
subscribed({ status: 'resolved' });
|
||||
assert.deepEqual(events, [
|
||||
{ type: 'map_annotation_outline', status: 'resolved' },
|
||||
]);
|
||||
button.dispatchEvent(new Event('click'));
|
||||
assert.equal(controls.state, 'idle');
|
||||
assert.deepEqual(describeUnsavedWork(), []);
|
||||
button.dispatchEvent(new Event('click'));
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
assert.deepEqual(describeUnsavedWork(), ['the active voice session']);
|
||||
lifetime.abort();
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'disposal unregisters');
|
||||
assert.equal(subscribed, null);
|
||||
assert.equal(removed, 1);
|
||||
button.dispatchEvent(new Event('click'));
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(calls, 2);
|
||||
} finally {
|
||||
globalThis.window = previous;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createVoiceControl, voiceStatusLabel } from './control.js';
|
||||
import { createVoiceSession } from './session.js';
|
||||
import { VoiceCardControls } from './voiceCard.js';
|
||||
import { PointerReticle } from './pointerReticle.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { msg, registerUnsavedWork, t } from '../i18n/index.js';
|
||||
|
||||
function createDefaultReticle(pointer) {
|
||||
const element = pointer?.element;
|
||||
@@ -107,6 +107,10 @@ export function createVoiceCommands({
|
||||
card?.outline(event);
|
||||
session.sendMapEvent({ type: 'map_annotation_outline', ...event });
|
||||
});
|
||||
// A reload (the language switch) ends the conversation: ask first.
|
||||
const unregisterUnsavedWork = registerUnsavedWork(() =>
|
||||
session.isActive() ? msg('the active voice session') : null,
|
||||
);
|
||||
const buttonHandler = () => {
|
||||
if (adapter.ignoreButtonClick?.()) return;
|
||||
if (session.isActive()) session.stop();
|
||||
@@ -117,6 +121,7 @@ export function createVoiceCommands({
|
||||
'abort',
|
||||
() => {
|
||||
ui.button.removeEventListener('click', buttonHandler);
|
||||
unregisterUnsavedWork();
|
||||
annotationUnsubscribe?.();
|
||||
updateStatus();
|
||||
updateCard?.();
|
||||
@@ -130,6 +135,7 @@ export function createVoiceCommands({
|
||||
);
|
||||
if (session.disposed) {
|
||||
ui.button.removeEventListener('click', buttonHandler);
|
||||
unregisterUnsavedWork();
|
||||
annotationUnsubscribe?.();
|
||||
updateStatus();
|
||||
updateCard?.();
|
||||
|
||||
+271
-36
@@ -19,11 +19,27 @@
|
||||
*
|
||||
* Only `display` and the plan/progress labels are interface text, translated
|
||||
* with t(). `say`, `identityLine` and the progress lines are not shown: they go
|
||||
* to the voice model to be spoken, so they stay as written.
|
||||
* to the voice model to be spoken, so they stay as written. The tool result
|
||||
* itself (layer names, sources, `error`) stays English as the model got it;
|
||||
* the card shows those through the shown-name helpers and `shownError`.
|
||||
*/
|
||||
|
||||
import { analystHeadline } from './resultDisplay.js';
|
||||
import { formatNumber, msg, t, tn } from '../i18n/index.js';
|
||||
import {
|
||||
formatNumber,
|
||||
getLocale,
|
||||
msg,
|
||||
t,
|
||||
tn,
|
||||
translateKnown,
|
||||
} from '../i18n/index.js';
|
||||
import {
|
||||
layerNameForId,
|
||||
shownLayerName,
|
||||
sourceLabel,
|
||||
} from '../ui/layerNames.js';
|
||||
import { localizePlaceName } from '../ui/placeNames.js';
|
||||
import { formatQuantity } from '../ui/unitLabels.js';
|
||||
|
||||
const LAYER_NOUNS = Object.freeze({
|
||||
flights: 'aircraft',
|
||||
@@ -78,10 +94,150 @@ function shownFeet(meters) {
|
||||
return t('{value} feet', { value: formatNumber(rounded) });
|
||||
}
|
||||
|
||||
function kilometers(km) {
|
||||
function roundedKm(km) {
|
||||
const value = Number(km);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return `${value < 10 ? Number(value.toFixed(1)) : Math.round(value)} km`;
|
||||
return value < 10 ? Number(value.toFixed(1)) : Math.round(value);
|
||||
}
|
||||
|
||||
function kilometers(km) {
|
||||
const value = roundedKm(km);
|
||||
return value === null ? null : `${value} km`;
|
||||
}
|
||||
|
||||
/** The card's distance, in the active language: "2,5 км". */
|
||||
function shownKm(km) {
|
||||
const value = roundedKm(km);
|
||||
return value === null ? null : formatQuantity(value, 'km');
|
||||
}
|
||||
|
||||
/** Sources as the card lists them. */
|
||||
function shownSources(source) {
|
||||
return source ? [{ label: sourceLabel(source) }] : [];
|
||||
}
|
||||
|
||||
/*
|
||||
* Tool errors stay English in the result: the model reads them. The card shows
|
||||
* the ones it knows in the interface language. Each English text here is
|
||||
* exactly what gevActions returns, so English shows as before; an error not
|
||||
* listed shows as given.
|
||||
*/
|
||||
const KNOWN_ERRORS = new Map(
|
||||
[
|
||||
msg('The flights layer is not enabled'),
|
||||
msg('The military layer is not enabled'),
|
||||
msg('The satellites layer is not enabled'),
|
||||
msg('The ais-live-vessels layer is not enabled'),
|
||||
msg('Could not enable the requested layer'),
|
||||
msg('Could not disable the requested layer'),
|
||||
msg('Radio layer unavailable'),
|
||||
msg('Camera navigation is unavailable in the current view'),
|
||||
msg('Camera navigation policy unavailable'),
|
||||
msg('Nearest-aircraft selection supports Flights or Military Flights only'),
|
||||
msg(
|
||||
'Nearest-aircraft selection needs a preset, place name, or latitude and longitude',
|
||||
),
|
||||
msg('Could not arrive at the requested place'),
|
||||
msg('The nearest airborne aircraft could not be selected'),
|
||||
].map((text) => [text, text]),
|
||||
);
|
||||
|
||||
function noneWithinView(target, radius) {
|
||||
switch (target) {
|
||||
case 'flights':
|
||||
return t('No flights within {radius} of the current view', { radius });
|
||||
case 'military':
|
||||
return t('No military within {radius} of the current view', { radius });
|
||||
case 'satellites':
|
||||
return t('No satellites within {radius} of the current view', {
|
||||
radius,
|
||||
});
|
||||
case 'vessels':
|
||||
return t('No vessels within {radius} of the current view', { radius });
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const ERROR_PATTERNS = [
|
||||
[
|
||||
/^(.+) is enabled on the (.+) feed, but no airborne aircraft is loaded in the (.+) view yet$/,
|
||||
([, layer, source, place]) =>
|
||||
t(
|
||||
'{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet',
|
||||
{
|
||||
layer: shownLayerName(layer),
|
||||
source: sourceLabel(source),
|
||||
place: localizePlaceName(place),
|
||||
},
|
||||
),
|
||||
],
|
||||
[
|
||||
/^(.+) is enabled, but no airborne aircraft is loaded in the (.+) view yet$/,
|
||||
([, layer, place]) =>
|
||||
t(
|
||||
'{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet',
|
||||
{ layer: shownLayerName(layer), place: localizePlaceName(place) },
|
||||
),
|
||||
],
|
||||
[
|
||||
/^(.+) is enabled, but its aircraft feed is unavailable$/,
|
||||
([, layer]) =>
|
||||
t('{layer} is enabled, but its aircraft feed is unavailable', {
|
||||
layer: shownLayerName(layer),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^(.+) is enabled, but its destination refresh did not complete$/,
|
||||
([, layer]) =>
|
||||
t('{layer} is enabled, but its destination refresh did not complete', {
|
||||
layer: shownLayerName(layer),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^(.+) is enabled, but (.+) is unavailable$/,
|
||||
([, layer, source]) =>
|
||||
t('{layer} is enabled, but {source} is unavailable', {
|
||||
layer: shownLayerName(layer),
|
||||
source: sourceLabel(source),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^Could not arrive at (.+)$/,
|
||||
([, place]) =>
|
||||
t('Could not arrive at {place}', { place: localizePlaceName(place) }),
|
||||
],
|
||||
[
|
||||
/^(.+) is no longer loaded$/,
|
||||
([, name]) => t('{name} is no longer loaded', { name }),
|
||||
],
|
||||
[
|
||||
/^Unknown target layer: (.*)$/,
|
||||
([, target]) => t('Unknown target layer: {target}', { target }),
|
||||
],
|
||||
[
|
||||
/^No (\S+) within (\d+(?:\.\d+)?) km of the current view$/,
|
||||
([, target, km]) => noneWithinView(target, shownKm(km)),
|
||||
],
|
||||
];
|
||||
|
||||
/** A tool error as the card shows it. */
|
||||
function shownError(error) {
|
||||
if (typeof error !== 'string' || !error) return null;
|
||||
const known = KNOWN_ERRORS.get(error);
|
||||
if (known) return t(known);
|
||||
for (const [pattern, show] of ERROR_PATTERNS) {
|
||||
const match = pattern.exec(error);
|
||||
const shown = match ? show(match) : null;
|
||||
if (shown) return shown;
|
||||
}
|
||||
return translateKnown(error);
|
||||
}
|
||||
|
||||
// The card wraps a failed result's lines rather than cutting them: a
|
||||
// translated error runs longer than the English (voiceCardPresentation).
|
||||
function errorLines(result) {
|
||||
return [shownError(result?.error)].filter(Boolean);
|
||||
}
|
||||
|
||||
function sentence(text) {
|
||||
@@ -189,6 +345,12 @@ function shownPair(items) {
|
||||
}
|
||||
|
||||
/** Ids such as `austin` or `local-firms` read as names on screen. */
|
||||
// A place as the card shows it: a preset city in the interface language,
|
||||
// whether the call named it ("Tokyo") or gave its id ("tokyo").
|
||||
function shownPlace(value, max) {
|
||||
return localizePlaceName(displayName(value, max));
|
||||
}
|
||||
|
||||
function displayName(value, max) {
|
||||
const label = spokenLabel(value, max);
|
||||
return /^[a-z0-9-]+$/.test(label)
|
||||
@@ -203,6 +365,12 @@ function layerLabel(result) {
|
||||
return spokenLabel(result?.label || result?.layerId || 'Layer');
|
||||
}
|
||||
|
||||
/** The layer as the card names it, in the active language. */
|
||||
function shownLayerLabel(result) {
|
||||
const name = shownLayerName(result?.label, result?.layerId);
|
||||
return spokenLabel(name || t('Layer'));
|
||||
}
|
||||
|
||||
/* ---------------- per-tool builders ---------------- */
|
||||
|
||||
function setLayerVisibility(result, args = {}) {
|
||||
@@ -212,7 +380,7 @@ function setLayerVisibility(result, args = {}) {
|
||||
const wanted = args.enabled === false ? 'off' : 'on';
|
||||
return {
|
||||
say: `Couldn't turn ${wanted} ${label}.`,
|
||||
display: { title: label, lines: [result?.error].filter(Boolean) },
|
||||
display: { title: shownLayerLabel(result), lines: errorLines(result) },
|
||||
};
|
||||
}
|
||||
const on = result.enabled !== false;
|
||||
@@ -224,30 +392,31 @@ function setLayerVisibility(result, args = {}) {
|
||||
return {
|
||||
say,
|
||||
display: {
|
||||
title: switchedTitle(label, on),
|
||||
title: switchedTitle(shownLayerLabel(result), on),
|
||||
chips: on ? feedChips(result.feedState) : [],
|
||||
sources: result.source ? [{ label: result.source }] : [],
|
||||
sources: shownSources(result.source),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function flyToLocation(result) {
|
||||
const label = spokenLabel(result?.label || result?.query || 'there');
|
||||
const title = spokenLabel(result?.label || result?.query) || t('there');
|
||||
if (result?.cancelled) return null;
|
||||
if (!result?.ok) {
|
||||
return {
|
||||
say: sentence(`Couldn't find ${label}`),
|
||||
display: { title: label, lines: [result?.error].filter(Boolean) },
|
||||
display: { title, lines: errorLines(result) },
|
||||
};
|
||||
}
|
||||
return {
|
||||
say: sentence(result.arrived ? `Over ${label}` : `Flying to ${label}`),
|
||||
display: {
|
||||
title: label,
|
||||
title,
|
||||
lines: result.rangeM
|
||||
? [
|
||||
t('Camera range {range}', {
|
||||
range: kilometers(result.rangeM / 1000),
|
||||
range: shownKm(result.rangeM / 1000),
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
@@ -274,7 +443,7 @@ function frameOverhead(result) {
|
||||
say,
|
||||
display: {
|
||||
title: frameTitle(result?.layerId),
|
||||
lines: [result?.error].filter(Boolean),
|
||||
lines: errorLines(result),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -294,7 +463,9 @@ function frameOverhead(result) {
|
||||
display: {
|
||||
title: inFrameTitle(result.layerId, count),
|
||||
lines: [
|
||||
t('Within {radius} of the view', { radius }),
|
||||
t('Within {radius} of the view', {
|
||||
radius: shownKm(result?.radiusKm),
|
||||
}),
|
||||
result.detectionEnabled ? t('Labels on') : null,
|
||||
].filter(Boolean),
|
||||
notes:
|
||||
@@ -309,8 +480,10 @@ function selectNearestAircraft(result) {
|
||||
result?.location?.label || result?.location || 'there',
|
||||
);
|
||||
const noun = LAYER_NOUNS[result?.layerId] || 'aircraft';
|
||||
const shownLocation =
|
||||
spokenLabel(result?.location?.label || result?.location) || t('there');
|
||||
const feed = result?.feed || {};
|
||||
const sources = feed.source ? [{ label: feed.source }] : [];
|
||||
const sources = shownSources(feed.source);
|
||||
if (result?.cancelled) return null;
|
||||
if (!result?.ok) {
|
||||
let say;
|
||||
@@ -325,7 +498,7 @@ function selectNearestAircraft(result) {
|
||||
say,
|
||||
display: {
|
||||
title: nearestTitle(result?.layerId),
|
||||
lines: [result?.error].filter(Boolean),
|
||||
lines: errorLines(result),
|
||||
chips: feedChips(feed.state),
|
||||
sources,
|
||||
},
|
||||
@@ -352,7 +525,10 @@ function selectNearestAircraft(result) {
|
||||
title: name,
|
||||
lines: [
|
||||
distance
|
||||
? t('{distance} from {location}', { distance, location })
|
||||
? t('{distance} from {location}', {
|
||||
distance: shownKm(aircraft.distanceKm),
|
||||
location: shownLocation,
|
||||
})
|
||||
: null,
|
||||
altitude
|
||||
? t('Altitude {altitude}', {
|
||||
@@ -410,8 +586,12 @@ function getEntityContext(result) {
|
||||
say: null,
|
||||
display: {
|
||||
title,
|
||||
lines: [selected.layerName].filter(Boolean),
|
||||
sources: selected.source ? [{ label: selected.source }] : [],
|
||||
lines: [
|
||||
selected.layerName
|
||||
? shownLayerName(selected.layerName, selected.layerId)
|
||||
: null,
|
||||
].filter(Boolean),
|
||||
sources: shownSources(selected.source),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -443,7 +623,7 @@ function getEntityContext(result) {
|
||||
? t('Route {route}', { route: routeText(properties, SHOWN_ROUTE) })
|
||||
: null,
|
||||
].filter(Boolean),
|
||||
sources: selected.source ? [{ label: selected.source }] : [],
|
||||
sources: shownSources(selected.source),
|
||||
},
|
||||
referents: [
|
||||
{
|
||||
@@ -491,7 +671,7 @@ function getCurrentViewState(result) {
|
||||
title: t('View state'),
|
||||
lines: enabled.map(
|
||||
(layer) =>
|
||||
`${spokenLabel(layer.name || layer.id, 32)} · ${layer.count ?? 0} · ${feedStateLabel(layer.feedState || 'nominal')}`,
|
||||
`${spokenLabel(shownLayerName(layer.name, layer.id), 32)} · ${layer.count ?? 0} · ${feedStateLabel(layer.feedState || 'nominal')}`,
|
||||
),
|
||||
},
|
||||
schedule: 'silent',
|
||||
@@ -627,8 +807,52 @@ function annotationTargets(args) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// frame_overhead's targets as the schema names them; English is unchanged.
|
||||
function frameStepLabel(target) {
|
||||
switch (target) {
|
||||
case 'flights':
|
||||
return t('Frame flights');
|
||||
case 'military':
|
||||
return t('Frame military');
|
||||
case 'satellites':
|
||||
return t('Frame satellites');
|
||||
case 'vessels':
|
||||
return t('Frame vessels');
|
||||
default:
|
||||
return t('Frame {target}', { target });
|
||||
}
|
||||
}
|
||||
|
||||
// The other tools' plan steps, one label per tool in actionSchemas. The tool
|
||||
// name itself is English model vocabulary and never shows.
|
||||
const STEP_TITLES = Object.freeze({
|
||||
zoom_to_globe: msg('Zoom out to globe'),
|
||||
show_data_layers_menu: msg('Open data layers'),
|
||||
set_panel_open: msg('Open or close a panel'),
|
||||
set_context_mode: msg('Change context mode'),
|
||||
control_cockpit: msg('Control cockpit'),
|
||||
set_visual_style: msg('Change visual style'),
|
||||
set_hud: msg('Adjust HUD'),
|
||||
set_cyber_sonar: msg('Adjust cyber sonar'),
|
||||
set_detection: msg('Adjust detection overlay'),
|
||||
set_map_stack: msg('Change map stack'),
|
||||
set_post_processing: msg('Adjust post-processing'),
|
||||
control_scene: msg('Control scene'),
|
||||
control_cctv: msg('Control CCTV'),
|
||||
control_radio: msg('Control radio'),
|
||||
track_entity: msg('Track contact'),
|
||||
stop_tracking: msg('Stop tracking'),
|
||||
clear_annotations: msg('Clear marks'),
|
||||
move_camera: msg('Move camera'),
|
||||
fly_route: msg('Fly route'),
|
||||
analyst_query: msg('Analyst query'),
|
||||
next_iss_pass: msg('Next ISS pass'),
|
||||
next_satellite_pass: msg('Next satellite pass'),
|
||||
});
|
||||
|
||||
/** A short on-screen label for one tool call in the voice card's plan. */
|
||||
export function planStepLabel(name, args = {}) {
|
||||
if (Object.hasOwn(STEP_TITLES, name)) return t(STEP_TITLES[name]);
|
||||
switch (name) {
|
||||
case 'annotate_map': {
|
||||
const targets = annotationTargets(args);
|
||||
@@ -641,29 +865,40 @@ export function planStepLabel(name, args = {}) {
|
||||
})
|
||||
: t('Mark {targets}', { targets: shown });
|
||||
}
|
||||
case 'fly_to_location':
|
||||
return t('Fly to {place}', {
|
||||
place: displayName(args.query || args.locationId || 'location', 40),
|
||||
});
|
||||
case 'select_nearest_aircraft':
|
||||
return t('Nearest aircraft to {place}', {
|
||||
place: displayName(args.locationQuery || args.locationId || 'here', 32),
|
||||
});
|
||||
case 'set_layer_visibility':
|
||||
return switchedTitle(
|
||||
displayName(args.layerId || 'Layer', 32),
|
||||
args.enabled !== false,
|
||||
);
|
||||
case 'fly_to_location': {
|
||||
const place = args.query || args.locationId;
|
||||
return place
|
||||
? t('Fly to {place}', { place: shownPlace(place, 40) })
|
||||
: t('Fly to Location');
|
||||
}
|
||||
case 'select_nearest_aircraft': {
|
||||
const place = args.locationQuery || args.locationId;
|
||||
return place
|
||||
? t('Nearest aircraft to {place}', { place: shownPlace(place, 32) })
|
||||
: t('Nearest aircraft to Here');
|
||||
}
|
||||
case 'set_layer_visibility': {
|
||||
const on = args.enabled !== false;
|
||||
const label =
|
||||
layerNameForId(args.layerId) ||
|
||||
(args.layerId ? displayName(args.layerId, 32) : '');
|
||||
// "Layer" alone is a noun the languages inflect the state for.
|
||||
if (!label) return on ? t('Layer on') : t('Layer off');
|
||||
return switchedTitle(label, on);
|
||||
}
|
||||
case 'adjust_camera_zoom':
|
||||
return args.direction === 'out' ? t('Zoom out') : t('Zoom in');
|
||||
case 'get_entity_context':
|
||||
return t('Read the view');
|
||||
case 'get_current_view_state':
|
||||
return t('Check view state');
|
||||
case 'frame_overhead':
|
||||
return t('Frame {target}', {
|
||||
target: spokenLabel(args.target || 'flights', 24),
|
||||
});
|
||||
return frameStepLabel(spokenLabel(args.target || 'flights', 24));
|
||||
default:
|
||||
return spokenLabel(String(name || 'Action').replace(/_/g, ' '), 40);
|
||||
// A tool the card has no label for: English shows its name as before.
|
||||
return getLocale() === 'en'
|
||||
? spokenLabel(String(name || 'Action').replace(/_/g, ' '), 40)
|
||||
: t('Action');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// K-24: on a failed tool step the voice card shows the English `result.error`
|
||||
// from gevActions under a translated title and step status.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { attachVoiceResult, spokenLabel } from './speech.js';
|
||||
import {
|
||||
initialVoiceCardState,
|
||||
reduceVoiceCard,
|
||||
voiceCardView,
|
||||
} from './voiceCardPresentation.js';
|
||||
|
||||
// Error texts as gevActions.js builds them (lines 2442, 646–647, 2476).
|
||||
const CASES = [
|
||||
{
|
||||
name: 'frame_overhead',
|
||||
args: { target: 'military' },
|
||||
result: {
|
||||
ok: false,
|
||||
action: 'frame_overhead',
|
||||
layerId: 'military',
|
||||
error: 'The military layer is not enabled',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'select_nearest_aircraft',
|
||||
args: { locationQuery: 'Madrid' },
|
||||
result: {
|
||||
ok: false,
|
||||
action: 'select_nearest_aircraft',
|
||||
stage: 'nearest',
|
||||
cancelled: false,
|
||||
error:
|
||||
'Live Flights is enabled, but no airborne aircraft is loaded in the Madrid view yet',
|
||||
location: { label: 'Madrid' },
|
||||
layer: { label: 'Live Flights' },
|
||||
feed: { state: 'nominal', source: 'OpenSky' },
|
||||
count: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'frame_overhead',
|
||||
args: { target: 'flights' },
|
||||
result: {
|
||||
ok: false,
|
||||
action: 'frame_overhead',
|
||||
layerId: 'flights',
|
||||
radiusKm: 150,
|
||||
count: 0,
|
||||
error: 'No flights within 150 km of the current view',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function cardFor(locale, { name, args, result }) {
|
||||
setLocale(locale);
|
||||
let state = reduceVoiceCard(initialVoiceCardState(), {
|
||||
type: 'action-call',
|
||||
id: 'c1',
|
||||
name,
|
||||
arguments: args,
|
||||
});
|
||||
state = reduceVoiceCard(state, {
|
||||
type: 'action-result',
|
||||
id: 'c1',
|
||||
name,
|
||||
result: attachVoiceResult(name, result, args),
|
||||
});
|
||||
return voiceCardView(state);
|
||||
}
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
for (const c of CASES) {
|
||||
test(`${locale}: ${c.name} failure shows the card lines in the UI language`, () => {
|
||||
const view = cardFor(locale, c);
|
||||
try {
|
||||
assert.ok(view.result.visible, 'card shows a result');
|
||||
assert.notEqual(view.result.title, '', 'card has a title');
|
||||
// Title and step status are translated (this part passes today).
|
||||
assert.notEqual(
|
||||
view.steps[0].statusText,
|
||||
'failed',
|
||||
'step status translated',
|
||||
);
|
||||
// The failure line is the raw English tool error.
|
||||
for (const line of view.result.lines) {
|
||||
assert.ok(
|
||||
!/\b(is not enabled|no airborne aircraft|of the current view)\b/.test(
|
||||
line,
|
||||
),
|
||||
`English tool error shown under ${locale} title "${view.result.title}" / step "${view.steps[0].label}: ${view.steps[0].statusText}": "${line}"`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('en: the card shows the tool error as the model got it', () => {
|
||||
for (const c of CASES) {
|
||||
const view = cardFor('en', c);
|
||||
// Whole, not cut at 80 characters: the card wraps an error line.
|
||||
assert.deepEqual(view.result.lines, [
|
||||
spokenLabel(c.result.error, Infinity),
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// K-7: the voice card names layers, sources and distances in the interface
|
||||
// language; what the model receives stays English.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { attachVoiceResult, planStepLabel } from './speech.js';
|
||||
import { voiceCardView } from './voiceCardPresentation.js';
|
||||
|
||||
for (const locale of ['ru', 'es']) {
|
||||
test(`voice card is in ${locale} for a layer switch`, (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale(locale);
|
||||
const out = attachVoiceResult(
|
||||
'set_layer_visibility',
|
||||
{
|
||||
ok: true,
|
||||
layerId: 'flights',
|
||||
label: 'Live Flights',
|
||||
enabled: true,
|
||||
source: 'Flights',
|
||||
},
|
||||
{ layerId: 'flights', enabled: true },
|
||||
);
|
||||
const view = voiceCardView({ steps: [], result: out });
|
||||
assert.doesNotMatch(out.display.title, /Live Flights/);
|
||||
assert.doesNotMatch(JSON.stringify(view), /Flights/);
|
||||
});
|
||||
test(`voice card distance is in ${locale}`, (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale(locale);
|
||||
const out = attachVoiceResult('fly_to_location', {
|
||||
ok: true,
|
||||
label: 'Paris',
|
||||
rangeM: 2500,
|
||||
});
|
||||
assert.doesNotMatch(out.display.lines[0], /2\.5 km/);
|
||||
});
|
||||
test(`plan label is in ${locale}`, (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale(locale);
|
||||
const a = planStepLabel('select_nearest_aircraft', {});
|
||||
const b = planStepLabel('set_layer_visibility', {
|
||||
layerId: 'earthquakes',
|
||||
enabled: true,
|
||||
});
|
||||
assert.doesNotMatch(a, /Here/);
|
||||
assert.doesNotMatch(b, /Earthquakes/);
|
||||
});
|
||||
}
|
||||
|
||||
test('en: layer switch, distance and plan labels read as before', () => {
|
||||
const out = attachVoiceResult(
|
||||
'set_layer_visibility',
|
||||
{
|
||||
ok: true,
|
||||
layerId: 'flights',
|
||||
label: 'Live Flights',
|
||||
enabled: true,
|
||||
source: 'Flights',
|
||||
},
|
||||
{ layerId: 'flights', enabled: true },
|
||||
);
|
||||
assert.equal(out.say, 'Live Flights on.');
|
||||
assert.equal(out.display.title, 'Live Flights on');
|
||||
assert.deepEqual(out.display.sources, [{ label: 'Flights' }]);
|
||||
const fly = attachVoiceResult('fly_to_location', {
|
||||
ok: true,
|
||||
label: 'Paris',
|
||||
rangeM: 2500,
|
||||
});
|
||||
assert.equal(fly.display.lines[0].replace(/\s/g, ' '), 'Camera range 2.5 km');
|
||||
assert.equal(
|
||||
planStepLabel('select_nearest_aircraft', {}),
|
||||
'Nearest aircraft to Here',
|
||||
);
|
||||
assert.equal(
|
||||
planStepLabel('frame_overhead', { target: 'vessels' }),
|
||||
'Frame vessels',
|
||||
);
|
||||
// The plan names the layer as its result card does, not by its id.
|
||||
assert.equal(
|
||||
planStepLabel('set_layer_visibility', {
|
||||
layerId: 'earthquakes',
|
||||
enabled: true,
|
||||
}),
|
||||
'Earthquakes (24h) on',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
// PDF review C3, B4, E5, E6 and proposal 1: the voice card's plan steps name
|
||||
// every tool in the interface language, the "Layer" step agrees in gender,
|
||||
// error lines and long headlines show whole, and the Contacts snapshot time
|
||||
// reads as a clock, not ISO. What the model receives stays English.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { GEV_ACTION_SCHEMAS } from './actionSchemas.js';
|
||||
import { presentResult, shownSnapshotTime } from './resultDisplay.js';
|
||||
import { attachVoiceResult, planStepLabel } from './speech.js';
|
||||
import {
|
||||
initialVoiceCardState,
|
||||
reduceVoiceCard,
|
||||
voiceCardView,
|
||||
} from './voiceCardPresentation.js';
|
||||
|
||||
const TOOLS = GEV_ACTION_SCHEMAS.map((schema) => schema.name);
|
||||
|
||||
function inLocale(locale, run) {
|
||||
setLocale(locale);
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
}
|
||||
|
||||
test('C3: every tool has a plan label, never its raw name', () => {
|
||||
for (const name of TOOLS) {
|
||||
const label = planStepLabel(name, {});
|
||||
assert.notEqual(label, name.replace(/_/g, ' '), name);
|
||||
assert.doesNotMatch(label, /_/, name);
|
||||
}
|
||||
assert.equal(planStepLabel('analyst_query', {}), 'Analyst query');
|
||||
assert.equal(
|
||||
planStepLabel('adjust_camera_zoom', { direction: 'in' }),
|
||||
'Zoom in',
|
||||
);
|
||||
assert.equal(
|
||||
planStepLabel('adjust_camera_zoom', { direction: 'out' }),
|
||||
'Zoom out',
|
||||
);
|
||||
// A tool the card does not know keeps its name in English.
|
||||
assert.equal(planStepLabel('brand_new_tool', {}), 'brand new tool');
|
||||
});
|
||||
|
||||
for (const locale of ['ru', 'es']) {
|
||||
test(`C3: ${locale} plan labels are all translated`, () => {
|
||||
const english = Object.fromEntries(
|
||||
TOOLS.map((name) => [name, planStepLabel(name, { direction: 'out' })]),
|
||||
);
|
||||
inLocale(locale, () => {
|
||||
for (const name of TOOLS) {
|
||||
// A layer switch without a layer is checked below (B4).
|
||||
const label = planStepLabel(name, { direction: 'out' });
|
||||
assert.notEqual(label, english[name], `${name}: "${label}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('C3/E10: analyst query and an unknown tool in ru and es', () => {
|
||||
inLocale('ru', () => {
|
||||
assert.equal(planStepLabel('analyst_query', {}), 'Аналитический запрос');
|
||||
assert.equal(planStepLabel('brand_new_tool', {}), 'Действие');
|
||||
});
|
||||
inLocale('es', () => {
|
||||
assert.equal(planStepLabel('analyst_query', {}), 'Consulta analítica');
|
||||
assert.equal(planStepLabel('brand_new_tool', {}), 'Acción');
|
||||
});
|
||||
});
|
||||
|
||||
test('B4: a layer step with no layer agrees with "Capa"', () => {
|
||||
const step = (enabled) => planStepLabel('set_layer_visibility', { enabled });
|
||||
assert.equal(step(true), 'Layer on');
|
||||
assert.equal(step(false), 'Layer off');
|
||||
inLocale('es', () => {
|
||||
assert.equal(step(true), 'Capa: activada');
|
||||
assert.equal(step(false), 'Capa: desactivada');
|
||||
// A named layer keeps the shared "{label} on" wording.
|
||||
assert.match(
|
||||
planStepLabel('set_layer_visibility', {
|
||||
layerId: 'earthquakes',
|
||||
enabled: true,
|
||||
}),
|
||||
/: activado$/,
|
||||
);
|
||||
});
|
||||
inLocale('ru', () => {
|
||||
assert.equal(step(true), 'Слой: вкл.');
|
||||
assert.equal(step(false), 'Слой: выкл.');
|
||||
});
|
||||
});
|
||||
|
||||
function cardAfter(name, result, args = {}) {
|
||||
let state = reduceVoiceCard(initialVoiceCardState(), {
|
||||
type: 'action-call',
|
||||
id: 'c1',
|
||||
name,
|
||||
arguments: args,
|
||||
});
|
||||
state = reduceVoiceCard(state, {
|
||||
type: 'action-result',
|
||||
id: 'c1',
|
||||
name,
|
||||
result: attachVoiceResult(name, result, args),
|
||||
});
|
||||
return voiceCardView(state);
|
||||
}
|
||||
|
||||
const NO_PLACE = {
|
||||
ok: false,
|
||||
action: 'select_nearest_aircraft',
|
||||
stage: 'location',
|
||||
error:
|
||||
'Nearest-aircraft selection needs a preset, place name, or latitude and longitude',
|
||||
feed: {},
|
||||
};
|
||||
|
||||
test('proposal 1: an error line shows whole, in every language', () => {
|
||||
for (const [locale, expected] of [
|
||||
['en', NO_PLACE.error],
|
||||
[
|
||||
'ru',
|
||||
'Для выбора ближайшего самолёта нужен пресет, название места или широта и долгота',
|
||||
],
|
||||
[
|
||||
'es',
|
||||
'Para seleccionar la aeronave más cercana se necesita un preajuste, un nombre de lugar o la latitud y la longitud',
|
||||
],
|
||||
]) {
|
||||
const view = inLocale(locale, () =>
|
||||
cardAfter('select_nearest_aircraft', NO_PLACE),
|
||||
);
|
||||
assert.deepEqual(view.result.lines, [expected], locale);
|
||||
}
|
||||
});
|
||||
|
||||
test('proposal 1: a successful line keeps its 80-character cap', () => {
|
||||
const long = 'x'.repeat(120);
|
||||
const view = voiceCardView({
|
||||
...initialVoiceCardState(),
|
||||
result: { display: { title: 'T', lines: [long] } },
|
||||
});
|
||||
assert.equal(view.result.lines[0].length, 80);
|
||||
});
|
||||
|
||||
test('E5: a translated analyst headline fits the card title whole', () => {
|
||||
const title =
|
||||
'Al menos 12 aeronaves militares en un radio de 40 km de Los Angeles';
|
||||
const card = () =>
|
||||
voiceCardView({
|
||||
...initialVoiceCardState(),
|
||||
result: { display: { title, lines: [] } },
|
||||
}).result.title;
|
||||
assert.equal(inLocale('es', card), title);
|
||||
// English keeps its 64-character title, equal to the spoken headline.
|
||||
assert.equal(card().length, 64);
|
||||
});
|
||||
|
||||
test('E6: the snapshot time reads as a UTC clock, dated when not today', () => {
|
||||
const iso = '2026-10-15T14:32:05.000Z';
|
||||
const sameDay = Date.parse('2026-10-15T23:00:00Z');
|
||||
const nextDay = Date.parse('2026-10-16T00:10:00Z');
|
||||
assert.equal(shownSnapshotTime(iso, sameDay), iso, 'English as before');
|
||||
inLocale('ru', () => {
|
||||
assert.equal(shownSnapshotTime(iso, sameDay), '14:32:05 UTC');
|
||||
assert.equal(shownSnapshotTime(iso, nextDay), '15.10.2026, 14:32:05 UTC');
|
||||
});
|
||||
inLocale('es', () => {
|
||||
assert.equal(shownSnapshotTime(iso, sameDay), '14:32:05 UTC');
|
||||
assert.equal(shownSnapshotTime(iso, nextDay), '15/10/2026, 14:32:05 UTC');
|
||||
assert.equal(shownSnapshotTime('not a time'), 'not a time');
|
||||
});
|
||||
});
|
||||
|
||||
test('E6: the Contacts note shows the clock; the model note stays ISO', () => {
|
||||
const evaluatedAt = new Date();
|
||||
evaluatedAt.setUTCHours(14, 32, 5, 0);
|
||||
const iso = evaluatedAt.toISOString();
|
||||
const note = `Contacts panel snapshot at ${iso} — the same retained cohort supplies the card, speech, and follow-up memory.`;
|
||||
const result = {
|
||||
ok: true,
|
||||
count: 12,
|
||||
complete: true,
|
||||
scopeLabel: 'in view',
|
||||
display: { scope: 'in view' },
|
||||
coverage: { note },
|
||||
items: [],
|
||||
};
|
||||
const notes = inLocale('es', () =>
|
||||
presentResult('analyst_query', result).display.notes.join(' | '),
|
||||
);
|
||||
assert.match(notes, /\(14:32:05 UTC\)/);
|
||||
assert.doesNotMatch(notes, /T14:32/);
|
||||
assert.equal(result.coverage.note, note, 'the result is not changed');
|
||||
});
|
||||
|
||||
// C12: preset places show in the interface language on the card; the model's
|
||||
// error and scope stay English.
|
||||
test('C12: plan steps and errors name a preset place in the UI language', () => {
|
||||
const failed = {
|
||||
ok: false,
|
||||
action: 'select_nearest_aircraft',
|
||||
stage: 'nearest',
|
||||
error:
|
||||
'Live Flights is enabled, but no airborne aircraft is loaded in the Tokyo view yet',
|
||||
location: { label: 'Tokyo' },
|
||||
feed: { state: 'nominal' },
|
||||
};
|
||||
assert.equal(
|
||||
planStepLabel('fly_to_location', { query: 'Tokyo' }),
|
||||
'Fly to Tokyo',
|
||||
);
|
||||
inLocale('es', () => {
|
||||
assert.match(planStepLabel('fly_to_location', { query: 'Tokyo' }), /Tokio/);
|
||||
assert.match(
|
||||
planStepLabel('select_nearest_aircraft', { locationId: 'tokyo' }),
|
||||
/Tokio/,
|
||||
);
|
||||
const view = cardAfter('select_nearest_aircraft', failed);
|
||||
assert.match(view.result.lines[0], /Tokio/);
|
||||
});
|
||||
inLocale('ru', () => {
|
||||
assert.match(planStepLabel('fly_to_location', { query: 'Tokyo' }), /Токио/);
|
||||
});
|
||||
assert.match(failed.error, /Tokyo view/, 'the result stays English');
|
||||
});
|
||||
|
||||
test('C12: the analyst scope names Los Angeles in the UI language', () => {
|
||||
const result = {
|
||||
ok: true,
|
||||
count: 12,
|
||||
complete: false,
|
||||
scopeLabel: 'within 40 km of Los Angeles',
|
||||
display: { scope: 'within 40 km of Los Angeles' },
|
||||
coverage: { layersQueried: [{ layerKey: 'military' }] },
|
||||
items: [],
|
||||
};
|
||||
const title = inLocale(
|
||||
'es',
|
||||
() => presentResult('analyst_query', result).display.title,
|
||||
);
|
||||
assert.match(title, /Los Ángeles$/, 'the headline is not cut at 64');
|
||||
assert.equal(result.scopeLabel, 'within 40 km of Los Angeles');
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { planStepLabel, progressStepLabel, spokenLabel } from './speech.js';
|
||||
import { presentResult } from './resultDisplay.js';
|
||||
import { MAX_DISPLAYED_REFERENTS, normalizeReferents } from './referents.js';
|
||||
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
import { getLocale, msg, t } from '../i18n/index.js';
|
||||
/**
|
||||
* Pure presentation of the voice card nested in the voice control: captions,
|
||||
* the current plan, the latest result and its numbered referents.
|
||||
@@ -17,6 +17,16 @@ const MAX_STEPS = 6;
|
||||
const MAX_LINES = 6;
|
||||
const MAX_NOTES = 6;
|
||||
const MAX_CAPTION = 220;
|
||||
// Titles and lines wrap in the card; the caps only stop a runaway value. A
|
||||
// translated headline runs longer than the English one ("Al menos 12 aeronaves
|
||||
// militares en un radio de 40 km de Los Ángeles"), so other languages cut it at
|
||||
// 120; English keeps 64. A failed result's lines (its error) are never cut.
|
||||
const MAX_TITLE = 64;
|
||||
const MAX_TRANSLATED_TITLE = 120;
|
||||
const MAX_LINE = 80;
|
||||
|
||||
const titleMax = () =>
|
||||
getLocale() === 'en' ? MAX_TITLE : MAX_TRANSLATED_TITLE;
|
||||
|
||||
const STATUS_TEXT = Object.freeze({
|
||||
running: msg('in progress'),
|
||||
@@ -205,12 +215,14 @@ export function reduceVoiceCard(state, event) {
|
||||
...next,
|
||||
result: {
|
||||
display: shown.display,
|
||||
// A failed result's lines are its error: shown whole, wrapped.
|
||||
wholeLines: result.ok === false,
|
||||
referents: normalizeReferents(
|
||||
shown.referents,
|
||||
MAX_DISPLAYED_REFERENTS,
|
||||
),
|
||||
},
|
||||
announce: spokenLabel(shown.display.title, 64),
|
||||
announce: spokenLabel(shown.display.title, titleMax()),
|
||||
});
|
||||
}
|
||||
case 'outline': {
|
||||
@@ -293,10 +305,12 @@ export function voiceCardView(state) {
|
||||
})),
|
||||
result: {
|
||||
visible: Boolean(display?.title),
|
||||
title: spokenLabel(display?.title || '', 64),
|
||||
title: spokenLabel(display?.title || '', titleMax()),
|
||||
chips: (display?.chips || []).map((chip) => spokenLabel(chip.label, 24)),
|
||||
lines: (display?.lines || [])
|
||||
.map((line) => spokenLabel(line, 80))
|
||||
.map((line) =>
|
||||
spokenLabel(line, state.result?.wholeLines ? Infinity : MAX_LINE),
|
||||
)
|
||||
.slice(0, MAX_LINES),
|
||||
referents: (state.result?.referents || [])
|
||||
.slice(0, MAX_DISPLAYED_REFERENTS)
|
||||
|
||||
Reference in new issue
Block a user