mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
997 lines
31 KiB
JavaScript
997 lines
31 KiB
JavaScript
/**
|
|
* Voice result envelope: what a tool result should SAY and what it should
|
|
* SHOW, composed by code rather than by prompt prose.
|
|
*
|
|
* { say, display, referents?, schedule? }
|
|
*
|
|
* - `say` is one short spoken line (≤25 words, numbers verbatim). Material
|
|
* caveats are folded in as one or two words ("stale", "feed unavailable").
|
|
* `null` means the model continues naturally with nothing extra to say.
|
|
* - `display` is for the voice card and is never read aloud unless the user
|
|
* asks why, how sure, or which source.
|
|
* - `referents` number the things the card lists, so "the second one" can
|
|
* resolve later.
|
|
* - `schedule` is `speak` (default) or `silent` (a lookup the model uses to
|
|
* continue, not something to read back).
|
|
*
|
|
* Builders are small pure functions keyed by tool name. Tools without one are
|
|
* returned unchanged.
|
|
*
|
|
* Only `display` and plan/progress labels are UI text (t()). `say`,
|
|
* `identityLine`, spoken progress lines and the tool result stay English for
|
|
* the model; the card shows them through the shown* helpers.
|
|
*/
|
|
|
|
import { analystHeadline } from './resultDisplay.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 { CITY_POIS } from '../cityPois.js';
|
|
import { formatQuantity } from '../ui/unitLabels.js';
|
|
|
|
const LAYER_NOUNS = Object.freeze({
|
|
flights: 'aircraft',
|
|
military: 'military aircraft',
|
|
'ais-live-vessels': 'ships',
|
|
satellites: 'satellites',
|
|
});
|
|
|
|
/** Feed states that change what a spoken answer means. */
|
|
const MATERIAL_FEED_TAGS = Object.freeze({
|
|
stale: 'stale',
|
|
degraded: 'degraded',
|
|
unavailable: 'feed unavailable',
|
|
});
|
|
|
|
const MAX_LABEL = 48;
|
|
|
|
/** Collapse whitespace and control characters, and bound a spoken label. */
|
|
export function spokenLabel(value, max = MAX_LABEL) {
|
|
const text = String(value ?? '')
|
|
.replace(/[\p{Cc}\p{Cf}]+/gu, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
if (text.length <= max) return text;
|
|
return `${text.slice(0, max - 1).trimEnd()}…`;
|
|
}
|
|
|
|
function listPhrase(items) {
|
|
const list = items.filter(Boolean);
|
|
if (list.length <= 1) return list[0] || '';
|
|
if (list.length === 2) return `${list[0]} and ${list[1]}`;
|
|
return `${list.slice(0, -1).join(', ')} and ${list.at(-1)}`;
|
|
}
|
|
|
|
function feetRounded(meters) {
|
|
// A missing altitude is unknown, not sea level: Number(null) is 0.
|
|
return Number.isFinite(meters)
|
|
? Math.round((meters * 3.28084) / 100) * 100
|
|
: null;
|
|
}
|
|
|
|
function feet(meters) {
|
|
const rounded = feetRounded(meters);
|
|
if (rounded === null) return null;
|
|
return `${rounded.toLocaleString('en-US')} feet`;
|
|
}
|
|
|
|
/** The card's altitude, in the active language. */
|
|
function shownFeet(meters) {
|
|
const rounded = feetRounded(meters);
|
|
if (rounded === null) return null;
|
|
return t('{value} feet', { value: formatNumber(rounded) });
|
|
}
|
|
|
|
function roundedKm(km) {
|
|
const value = Number(km);
|
|
if (!Number.isFinite(value)) return null;
|
|
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) }] : [];
|
|
}
|
|
|
|
// Entity labels that layers register as English data (firms/, alpr/); keep in
|
|
// step with the producers. Other names (callsigns, places) show as they came.
|
|
const ENTITY_LABELS = new Set([msg('ALPR camera')]);
|
|
const FIRE_LABEL = /^Fire · FRP (\S+) MW$/;
|
|
|
|
/** An entity label as the card shows it, in the active language. */
|
|
export function shownEntityLabel(label) {
|
|
if (ENTITY_LABELS.has(label)) return t(label);
|
|
const fire = FIRE_LABEL.exec(label ?? '');
|
|
return fire ? t('Fire · FRP {frp} MW', { frp: fire[1] }) : label;
|
|
}
|
|
|
|
/*
|
|
* Tool errors stay English in the result (the model reads them). The card
|
|
* translates the known ones; the English must match gevActions exactly.
|
|
*/
|
|
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) {
|
|
const trimmed = String(text || '').trim();
|
|
if (!trimmed) return null;
|
|
return /[.!?…]$/.test(trimmed) ? trimmed : `${trimmed}.`;
|
|
}
|
|
|
|
function feedTag(state) {
|
|
return MATERIAL_FEED_TAGS[state] || null;
|
|
}
|
|
|
|
/** Feed states as the card shows them; the state values stay English. */
|
|
const FEED_STATE_LABELS = Object.freeze({
|
|
nominal: msg('nominal'),
|
|
loading: msg('loading'),
|
|
degraded: msg('degraded'),
|
|
stale: msg('stale'),
|
|
partial: msg('partial'),
|
|
fallback: msg('fallback'),
|
|
unavailable: msg('unavailable'),
|
|
});
|
|
|
|
function feedStateLabel(state) {
|
|
const key = FEED_STATE_LABELS[state];
|
|
return key ? t(key) : state;
|
|
}
|
|
|
|
/** A feed chip only when the state is worth seeing; nominal is the default. */
|
|
function feedChips(state) {
|
|
return state && state !== 'nominal' ? [{ label: feedStateLabel(state) }] : [];
|
|
}
|
|
|
|
/** '{label} on' / '{label} off' as the card shows it. */
|
|
function switchedTitle(label, on) {
|
|
return on ? t('{label} on', { label }) : t('{label} off', { label });
|
|
}
|
|
|
|
// Card titles name the layer's things in whole phrases, so each language can
|
|
// inflect the noun; the English matches LAYER_NOUNS.
|
|
function frameTitle(layerId) {
|
|
switch (layerId) {
|
|
case 'flights':
|
|
return t('Frame aircraft');
|
|
case 'military':
|
|
return t('Frame military aircraft');
|
|
case 'ais-live-vessels':
|
|
return t('Frame ships');
|
|
case 'satellites':
|
|
return t('Frame satellites');
|
|
default:
|
|
return t('Frame contacts');
|
|
}
|
|
}
|
|
|
|
function inFrameTitle(layerId, count) {
|
|
switch (layerId) {
|
|
case 'flights':
|
|
return tn(
|
|
count,
|
|
'{count} aircraft in frame',
|
|
'{count} aircraft in frame',
|
|
);
|
|
case 'military':
|
|
return tn(
|
|
count,
|
|
'{count} military aircraft in frame',
|
|
'{count} military aircraft in frame',
|
|
);
|
|
case 'ais-live-vessels':
|
|
return tn(count, '{count} ships in frame', '{count} ships in frame');
|
|
case 'satellites':
|
|
return tn(
|
|
count,
|
|
'{count} satellites in frame',
|
|
'{count} satellites in frame',
|
|
);
|
|
default:
|
|
return tn(
|
|
count,
|
|
'{count} contacts in frame',
|
|
'{count} contacts in frame',
|
|
);
|
|
}
|
|
}
|
|
|
|
function nearestTitle(layerId) {
|
|
switch (layerId) {
|
|
case 'military':
|
|
return t('Nearest military aircraft');
|
|
case 'ais-live-vessels':
|
|
return t('Nearest ships');
|
|
case 'satellites':
|
|
return t('Nearest satellites');
|
|
default:
|
|
return t('Nearest aircraft');
|
|
}
|
|
}
|
|
|
|
/** Two shown items joined, or the one item. */
|
|
function shownPair(items) {
|
|
return items.length === 2
|
|
? t('{first} and {second}', { first: items[0], second: items[1] })
|
|
: items[0] || '';
|
|
}
|
|
|
|
/** A preset city id (`sf`, `nyc`) as its English name; any other value unchanged. */
|
|
function presetPlaceName(value) {
|
|
const id = typeof value === 'string' ? value.trim() : '';
|
|
return Object.hasOwn(CITY_POIS, id) ? CITY_POIS[id].name : value;
|
|
}
|
|
|
|
// 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", "nyc").
|
|
function shownPlace(value, max) {
|
|
return localizePlaceName(displayName(presetPlaceName(value), max));
|
|
}
|
|
|
|
/** Ids such as `austin` or `local-firms` read as names on screen. */
|
|
function displayName(value, max) {
|
|
const label = spokenLabel(value, max);
|
|
return /^[a-z0-9-]+$/.test(label)
|
|
? label
|
|
.split('-')
|
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(' ')
|
|
: label;
|
|
}
|
|
|
|
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 = {}) {
|
|
const label = layerLabel(result);
|
|
if (result?.cancelled) return null;
|
|
if (!result?.ok) {
|
|
const wanted = args.enabled === false ? 'off' : 'on';
|
|
return {
|
|
say: `Couldn't turn ${wanted} ${label}.`,
|
|
display: { title: shownLayerLabel(result), lines: errorLines(result) },
|
|
};
|
|
}
|
|
const on = result.enabled !== false;
|
|
const tag = on ? feedTag(result.feedState) : null;
|
|
const say =
|
|
tag === 'feed unavailable'
|
|
? `${label} on, but its feed is unavailable.`
|
|
: sentence(`${label} ${on ? 'on' : 'off'}${tag ? `, ${tag}` : ''}`);
|
|
return {
|
|
say,
|
|
display: {
|
|
title: switchedTitle(shownLayerLabel(result), on),
|
|
chips: on ? feedChips(result.feedState) : [],
|
|
sources: shownSources(result.source),
|
|
},
|
|
};
|
|
}
|
|
|
|
function flyToLocation(result) {
|
|
const label = spokenLabel(result?.label || result?.query || 'there');
|
|
const title =
|
|
spokenLabel(localizePlaceName(result?.label || result?.query)) ||
|
|
t('there');
|
|
if (result?.cancelled) return null;
|
|
if (!result?.ok) {
|
|
return {
|
|
say: sentence(`Couldn't find ${label}`),
|
|
display: { title, lines: errorLines(result) },
|
|
};
|
|
}
|
|
return {
|
|
say: sentence(result.arrived ? `Over ${label}` : `Flying to ${label}`),
|
|
display: {
|
|
title,
|
|
lines: result.rangeM
|
|
? [
|
|
t('Camera range {range}', {
|
|
range: shownKm(result.rangeM / 1000),
|
|
}),
|
|
]
|
|
: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
function frameOverhead(result) {
|
|
const noun = LAYER_NOUNS[result?.layerId] || 'contacts';
|
|
const radius = kilometers(result?.radiusKm);
|
|
if (result?.cancelled) return null;
|
|
if (!result?.ok) {
|
|
// Absence is asserted only for an explicit empty query; a refused or
|
|
// unavailable camera move says the frame did not happen.
|
|
const error = result?.error || '';
|
|
let say;
|
|
if (/not enabled/i.test(error))
|
|
say = `The ${noun} layer is off. Turn it on?`;
|
|
else if (result?.count === 0 && radius)
|
|
say = `No ${noun} within ${radius}.`;
|
|
else if (/unknown target/i.test(error)) say = "I can't frame that layer.";
|
|
else say = `Couldn't frame the ${noun} right now.`;
|
|
return {
|
|
say,
|
|
display: {
|
|
title: frameTitle(result?.layerId),
|
|
lines: errorLines(result),
|
|
},
|
|
};
|
|
}
|
|
// The layer query returns at most 80, so a full page is a lower bound.
|
|
const count = Number(result.count) || 0;
|
|
const countText = count >= 80 ? `at least ${count}` : String(count);
|
|
const referents = (result.nearest || [])
|
|
.filter((entry) => entry?.label || entry?.id)
|
|
.map((entry, index) => ({
|
|
n: index + 1,
|
|
id: entry.icao24 || entry.mmsi || entry.id || null,
|
|
label: spokenLabel(entry.label || entry.id),
|
|
layerId: result.layerId,
|
|
}));
|
|
return {
|
|
say: sentence(`Framed ${countText} ${noun} within ${radius}`),
|
|
display: {
|
|
title: inFrameTitle(result.layerId, count),
|
|
lines: [
|
|
t('Within {radius} of the view', {
|
|
radius: shownKm(result?.radiusKm),
|
|
}),
|
|
result.detectionEnabled ? t('Labels on') : null,
|
|
].filter(Boolean),
|
|
notes:
|
|
count >= 80 ? [t('Framing uses the nearest 80 loaded contacts')] : [],
|
|
},
|
|
referents,
|
|
};
|
|
}
|
|
|
|
function selectNearestAircraft(result) {
|
|
const location = spokenLabel(
|
|
result?.location?.label || result?.location || 'there',
|
|
);
|
|
const noun = LAYER_NOUNS[result?.layerId] || 'aircraft';
|
|
const shownLocation =
|
|
spokenLabel(
|
|
localizePlaceName(result?.location?.label || result?.location),
|
|
) || t('there');
|
|
const feed = result?.feed || {};
|
|
const sources = shownSources(feed.source);
|
|
if (result?.cancelled) return null;
|
|
if (!result?.ok) {
|
|
let say;
|
|
if (result?.stage === 'location') say = `Couldn't get to ${location}.`;
|
|
else if (feed.state === 'unavailable')
|
|
say = 'The aircraft feed is unavailable.';
|
|
else if (result?.stage === 'nearest')
|
|
say = `No airborne ${noun} loaded near ${location} yet.`;
|
|
else if (result?.stage === 'layer') say = `Couldn't turn on ${noun}.`;
|
|
else say = `Couldn't select the nearest ${noun}.`;
|
|
return {
|
|
say,
|
|
display: {
|
|
title: nearestTitle(result?.layerId),
|
|
lines: errorLines(result),
|
|
chips: feedChips(feed.state),
|
|
sources,
|
|
},
|
|
};
|
|
}
|
|
const aircraft = result.aircraft || {};
|
|
const name = spokenLabel(aircraft.callsign || result.label || aircraft.id);
|
|
const distance = kilometers(aircraft.distanceKm);
|
|
const altitude = feet(aircraft.altitudeM);
|
|
const tag = feedTag(feed.state);
|
|
const say = sentence(
|
|
[
|
|
`Selected ${name}`,
|
|
distance ? `${distance} from ${location}` : null,
|
|
altitude,
|
|
tag,
|
|
]
|
|
.filter(Boolean)
|
|
.join(', '),
|
|
);
|
|
return {
|
|
say,
|
|
display: {
|
|
title: name,
|
|
lines: [
|
|
distance
|
|
? t('{distance} from {location}', {
|
|
distance: shownKm(aircraft.distanceKm),
|
|
location: shownLocation,
|
|
})
|
|
: null,
|
|
altitude
|
|
? t('Altitude {altitude}', {
|
|
altitude: shownFeet(aircraft.altitudeM),
|
|
})
|
|
: null,
|
|
].filter(Boolean),
|
|
chips: feedChips(feed.state),
|
|
sources,
|
|
},
|
|
referents: [
|
|
{ n: 1, id: aircraft.id || null, label: name, layerId: result.layerId },
|
|
],
|
|
};
|
|
}
|
|
|
|
const SPOKEN_ROUTE = Object.freeze({
|
|
between: (origin, destination) => `${origin} to ${destination}`,
|
|
from: (origin) => `from ${origin}`,
|
|
to: (destination) => `to ${destination}`,
|
|
});
|
|
|
|
const SHOWN_ROUTE = Object.freeze({
|
|
between: (origin, destination) =>
|
|
t('{origin} to {destination}', { origin, destination }),
|
|
from: (origin) => t('from {origin}', { origin }),
|
|
to: (destination) => t('to {destination}', { destination }),
|
|
});
|
|
|
|
function routeText(properties, words = SPOKEN_ROUTE) {
|
|
const route = spokenLabel(properties.route || '', 40);
|
|
if (route)
|
|
return route
|
|
.split(/\s*(?:-|→|>)\s*/)
|
|
.reduce((origin, destination) => words.between(origin, destination));
|
|
const origin = spokenLabel(properties.routeOrigin || '', 12);
|
|
const destination = spokenLabel(properties.routeDestination || '', 12);
|
|
if (origin && destination) return words.between(origin, destination);
|
|
if (origin) return words.from(origin);
|
|
if (destination) return words.to(destination);
|
|
return null;
|
|
}
|
|
|
|
function getEntityContext(result) {
|
|
const selected = result?.selected;
|
|
if (!result?.ok || !selected) return null;
|
|
const properties = selected.properties || {};
|
|
const isAircraft =
|
|
selected.layerId === 'flights' || selected.layerId === 'military';
|
|
const title = spokenLabel(
|
|
properties.callsign || selected.name || selected.id || 'Selection',
|
|
);
|
|
if (!isAircraft) {
|
|
return {
|
|
say: null,
|
|
display: {
|
|
title: spokenLabel(shownEntityLabel(title)),
|
|
lines: [
|
|
selected.layerName
|
|
? shownLayerName(selected.layerName, selected.layerId)
|
|
: null,
|
|
].filter(Boolean),
|
|
sources: shownSources(selected.source),
|
|
},
|
|
};
|
|
}
|
|
// Operator, type and route are always covered, from returned fields only;
|
|
// a missing one is a short phrase rather than a sentence. This is the
|
|
// answer to "what is this aircraft?" only; other questions about the
|
|
// selection (altitude, speed, registration) answer from its properties.
|
|
const route = routeText(properties);
|
|
const identityLine = sentence(
|
|
[
|
|
title,
|
|
spokenLabel(properties.operator || '', 32) || 'operator unknown',
|
|
spokenLabel(properties.type || '', 32) || 'type unknown',
|
|
route || 'no route on file',
|
|
].join(', '),
|
|
);
|
|
return {
|
|
say: null,
|
|
identityLine,
|
|
display: {
|
|
title,
|
|
lines: [
|
|
properties.registration
|
|
? t('Registration {registration}', {
|
|
registration: spokenLabel(properties.registration, 16),
|
|
})
|
|
: null,
|
|
route
|
|
? t('Route {route}', { route: routeText(properties, SHOWN_ROUTE) })
|
|
: null,
|
|
].filter(Boolean),
|
|
sources: shownSources(selected.source),
|
|
},
|
|
referents: [
|
|
{
|
|
n: 1,
|
|
id: selected.id || null,
|
|
label: title,
|
|
layerId: selected.layerId,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function getCurrentViewState(result) {
|
|
if (!result?.ok) return null;
|
|
const heightM = Number(result.camera?.heightM);
|
|
const height = Number.isFinite(heightM)
|
|
? heightM >= 1000
|
|
? `${Math.round(heightM / 1000).toLocaleString('en-US')} km up`
|
|
: `${Math.round(heightM)} m up`
|
|
: null;
|
|
const layers = Array.isArray(result.layers) ? result.layers : [];
|
|
const enabled = layers.filter((layer) => layer.enabled !== false);
|
|
const names = enabled.map((layer) => spokenLabel(layer.name || layer.id, 24));
|
|
const tagged = enabled
|
|
.filter((layer) => feedTag(layer.feedState))
|
|
.map(
|
|
(layer) =>
|
|
`${spokenLabel(layer.name || layer.id, 24)} ${feedTag(layer.feedState)}`,
|
|
);
|
|
const style = spokenLabel(result.style || 'normal', 20);
|
|
const parts = [
|
|
height ? `Camera ${height}` : null,
|
|
`${style} style`,
|
|
names.length
|
|
? `${listPhrase(names.slice(0, 4))}${names.length > 4 ? ` and ${names.length - 4} more` : ''} on`
|
|
: 'no layers on',
|
|
tagged.length ? listPhrase(tagged.slice(0, 2)) : null,
|
|
];
|
|
return {
|
|
say: parts
|
|
.filter(Boolean)
|
|
.map((part) => sentence(part[0].toUpperCase() + part.slice(1)))
|
|
.join(' '),
|
|
display: {
|
|
title: t('View state'),
|
|
lines: enabled.map(
|
|
(layer) =>
|
|
`${spokenLabel(shownLayerName(layer.name, layer.id), 32)} · ${layer.count ?? 0} · ${feedStateLabel(layer.feedState || 'nominal')}`,
|
|
),
|
|
},
|
|
schedule: 'silent',
|
|
};
|
|
}
|
|
|
|
function annotateMap(result) {
|
|
const failed = (
|
|
Array.isArray(result?.failedLabels) ? result.failedLabels : []
|
|
)
|
|
.map((label) => spokenLabel(label, 40))
|
|
.filter(Boolean);
|
|
const drawn = (Array.isArray(result?.items) ? result.items : []).filter(
|
|
(item) => item?.ok,
|
|
);
|
|
const referents = drawn.map((item, index) => ({
|
|
n: index + 1,
|
|
id: item.id || null,
|
|
label: spokenLabel(item.label || item.target || `Mark ${index + 1}`),
|
|
latitude: item.latitude,
|
|
longitude: item.longitude,
|
|
}));
|
|
const parts = [];
|
|
if (failed.length)
|
|
parts.push(`Couldn't place ${listPhrase(failed.slice(0, 3))}.`);
|
|
if (result?.ok && result.routeFallback)
|
|
parts.push('That line is straight-line distance, not a street route.');
|
|
if (result?.capped) parts.push('The map is full. Clear it first?');
|
|
return {
|
|
// Successful marks are not announced; the model keeps explaining.
|
|
say: parts.length ? parts.join(' ') : null,
|
|
display: {
|
|
title: result?.ok
|
|
? tn(drawn.length, 'Marked {count} place', 'Marked {count} places')
|
|
: t('Nothing marked'),
|
|
// The numbered referents list the marks; lines stay for extra facts.
|
|
lines: [],
|
|
notes: [
|
|
...(failed.length
|
|
? [t('Not found: {places}', { places: failed.join(', ') })]
|
|
: []),
|
|
...(result?.outlinePending ? [t('Tracing outlines')] : []),
|
|
],
|
|
},
|
|
referents,
|
|
};
|
|
}
|
|
|
|
function analystQuery(result) {
|
|
if (!result?.ok || result.cancelled) return null;
|
|
const headline = spokenLabel(analystHeadline(result), 96);
|
|
const state =
|
|
result.feedState ||
|
|
result.feedProvenance?.overall ||
|
|
result.coverage?.feedProvenance?.overall;
|
|
const tag = feedTag(state);
|
|
const scope = spokenLabel(result.scopeLabel || '', 96);
|
|
const answer =
|
|
tag === 'feed unavailable'
|
|
? sentence(
|
|
`Feed unavailable${scope ? ` ${scope}` : ''}; no authoritative count${result.complete === false ? ', retained coverage is a lower bound' : ''}`,
|
|
)
|
|
: sentence(`${headline}${tag ? `, ${tag}` : ''}`);
|
|
const unanswered = (Array.isArray(result.unanswered) ? result.unanswered : [])
|
|
.map((layer) => spokenLabel(layer, 24))
|
|
.filter(Boolean);
|
|
const missing = unanswered.length
|
|
? `Partial; ${listPhrase(unanswered.slice(0, 2))}${unanswered.length > 2 ? ` and ${unanswered.length - 2} more` : ''} not answered.`
|
|
: 'Partial answer.';
|
|
return {
|
|
say: `${answer}${result.partial ? ` ${missing}` : ''}`,
|
|
};
|
|
}
|
|
|
|
/** Default builders by tool name. */
|
|
export const SPEECH_BUILDERS = Object.freeze({
|
|
set_layer_visibility: setLayerVisibility,
|
|
fly_to_location: flyToLocation,
|
|
frame_overhead: frameOverhead,
|
|
select_nearest_aircraft: selectNearestAircraft,
|
|
get_entity_context: getEntityContext,
|
|
get_current_view_state: getCurrentViewState,
|
|
annotate_map: annotateMap,
|
|
analyst_query: analystQuery,
|
|
});
|
|
|
|
/**
|
|
* Attach `say`/`display`/`referents`/`schedule` to a tool result. A result that
|
|
* has no builder, is not an object, or makes its builder throw is returned
|
|
* unchanged, so speech composition can never break an action.
|
|
*/
|
|
export function attachVoiceResult(
|
|
name,
|
|
result,
|
|
args = {},
|
|
builders = SPEECH_BUILDERS,
|
|
) {
|
|
const builder = builders?.[name];
|
|
if (typeof builder !== 'function' || !result || typeof result !== 'object')
|
|
return result;
|
|
let envelope;
|
|
try {
|
|
envelope = builder(result, args);
|
|
} catch {
|
|
return result;
|
|
}
|
|
if (!envelope) return result;
|
|
const out = { ...result };
|
|
if ('say' in envelope) out.say = envelope.say ?? null;
|
|
if (envelope.identityLine) out.identityLine = envelope.identityLine;
|
|
if (envelope.display) out.display = envelope.display;
|
|
if (envelope.referents?.length) out.referents = envelope.referents;
|
|
if (envelope.schedule) out.schedule = envelope.schedule;
|
|
return out;
|
|
}
|
|
|
|
/* ---------------- plan steps and progress lines ---------------- */
|
|
|
|
function annotationTargets(args) {
|
|
const list = Array.isArray(args?.annotations) ? args.annotations : [];
|
|
return list
|
|
.map((spec) =>
|
|
spokenLabel(
|
|
spec?.label ||
|
|
spec?.target ||
|
|
spec?.points
|
|
?.map((point) => point?.target)
|
|
.filter(Boolean)
|
|
.join(' → '),
|
|
40,
|
|
),
|
|
)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
// frame_overhead targets as the schema names them.
|
|
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 });
|
|
}
|
|
}
|
|
|
|
// Plan-step labels for the other tools in actionSchemas. A tool missing here
|
|
// shows its raw name in English only.
|
|
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_voice_inactivity_timeout: msg('Voice inactivity timeout'),
|
|
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);
|
|
if (!targets.length) return t('Mark the map');
|
|
const shown = shownPair(targets.slice(0, 2));
|
|
return targets.length > 2
|
|
? t('Mark {targets} +{more}', {
|
|
targets: shown,
|
|
more: targets.length - 2,
|
|
})
|
|
: t('Mark {targets}', { targets: shown });
|
|
}
|
|
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 frameStepLabel(spokenLabel(args.target || 'flights', 24));
|
|
default:
|
|
// A tool with no label: its name in English, "Action" elsewhere.
|
|
return getLocale() === 'en'
|
|
? spokenLabel(String(name || 'Action').replace(/_/g, ' '), 40)
|
|
: t('Action');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The spoken subject of a tool call for "Still working on …", or '' when the
|
|
* call has no natural one.
|
|
*/
|
|
export function narrationLabel(name, args = {}) {
|
|
switch (name) {
|
|
case 'annotate_map':
|
|
return annotationTargets(args)[0] || '';
|
|
case 'fly_to_location':
|
|
return displayName(
|
|
presetPlaceName(args.query || args.locationId || ''),
|
|
32,
|
|
);
|
|
case 'select_nearest_aircraft':
|
|
return displayName(
|
|
presetPlaceName(args.locationQuery || args.locationId || ''),
|
|
32,
|
|
);
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/** On-screen labels for progress steps. */
|
|
const STEP_LABELS = Object.freeze({
|
|
resolve: msg('Finding places'),
|
|
outline: msg('Tracing outline'),
|
|
search: msg('Looking up place'),
|
|
fly: msg('Flying'),
|
|
layer: msg('Turning on layer'),
|
|
refresh: msg('Loading aircraft'),
|
|
nearest: msg('Picking nearest'),
|
|
});
|
|
|
|
/** On-screen label for a progress step. */
|
|
export function progressStepLabel(step, label) {
|
|
const base = STEP_LABELS[step]
|
|
? t(STEP_LABELS[step])
|
|
: step
|
|
? spokenLabel(step, 24)
|
|
: t('Working');
|
|
// The join is a catalog string so each language spaces its colon.
|
|
return label
|
|
? t('{name}: {state}', { name: base, state: spokenLabel(label, 32) })
|
|
: base;
|
|
}
|
|
|
|
/**
|
|
* Spoken progress line for a slow tool's current step, or null when the step
|
|
* should stay silent. Lines are code-authored: no hedges, no invented facts.
|
|
*/
|
|
export function progressLine(step, label) {
|
|
const place = spokenLabel(label, 32);
|
|
switch (step) {
|
|
case 'resolve':
|
|
return place ? `Finding ${place} on the map.` : 'Finding those places.';
|
|
case 'search':
|
|
return place ? `Looking up ${place}.` : null;
|
|
case 'fly':
|
|
return place ? `Heading to ${place}.` : null;
|
|
case 'layer':
|
|
return place ? `Turning on ${place}.` : null;
|
|
case 'refresh':
|
|
return 'Loading aircraft there.';
|
|
case 'nearest':
|
|
return 'Picking the nearest aircraft.';
|
|
case 'still':
|
|
return place ? `Still working on ${place}.` : 'Still working on it.';
|
|
default:
|
|
return null;
|
|
}
|
|
}
|