Files
gods-eye-view/src/voice/pointerReticle.js
T
Bilawal Sidhu a19897ad15 feat(voice): voice core — honest layer reach, spoken summary with on-screen card, narration, point-and-ask
- Voice layer manifest (src/voice/layerManifest.js) generates the layer enums,
  aliases and analyst field hints; a test fails when a catalog layer has no entry.
- analyst_query validates fields, operators, units, values and scopes, counts up
  to 250,000 loaded records per layer, reports complete/partial/unanswered, tells
  LAYER_OFF / NOT_READY / FEED_UNAVAILABLE apart from zero, refuses mismatched
  follow-ups and never lets a superseded query replace follow-up memory. Seven
  more layers expose analyst records.
- Tool results carry a code-built say line and a display card; the voice card in
  the mic control shows captions, plan steps and the result
  (src/voice/resultDisplay.js is the one adapter). Slow tools narrate their
  current step out of band; holding Space while the assistant talks cuts it off.
  Turn spans go to the debug log; caption transcription is metered; the log
  omits what was said unless GEV_VOICE_LOG_CONTENT=1.
- Point and ask: the Space-down pointer snapshot resolves "this", "that one" and
  "around here" through 'pointer' targets, and "the second one" through
  referent:n from the last numbered list.
- Prompt states each policy once (instructions + tools 48.5k -> 46.7k chars).
- Voice benchmark harness (scripts/qa-voice-bench.mjs, scripts/voice-bench/)
  shares its phrase table and grading with scripts/qa-voice-routing.mjs.
2026-09-28 21:19:23 -05:00

136 lines
3.8 KiB
JavaScript

/**
* A subtle reticle at the point "this" resolved to, shown while the turn is
* live so the user sees what was understood before the answer.
*
* It is a fixed-position overlay (pointer-events: none) placed from the
* snapshot's canvas pixels, so it never touches the Cesium scene. The reticle
* marks where the user pointed, not a tracked world position: it leaves as
* soon as a tool moves the camera, when the reply completes, when the session
* ends, and after {@link RETICLE_MAX_MS} at most. Clean-UI and recording mode
* hide it through CSS.
*/
export const RETICLE_MAX_MS = 10000;
export const RETICLE_LINGER_MS = 1500;
/** Tools that move the camera away from the pointed spot. */
export const CAMERA_TOOLS = new Set([
'fly_to_location',
'track_entity',
'zoom_to_globe',
'adjust_camera_zoom',
'move_camera',
'fly_route',
'frame_overhead',
'select_nearest_aircraft',
'control_cockpit',
]);
export class PointerReticle {
/**
* @param {object} options
* @param {Document} options.doc Document that owns the overlay.
* @param {() => {left: number, top: number}|null} options.rectOf Canvas
* client rect, read at show time.
* @param {Function} [options.setTimer]
* @param {Function} [options.clearTimer]
*/
constructor({
doc,
rectOf,
setTimer = (fn, ms) => setTimeout(fn, ms),
clearTimer = (id) => clearTimeout(id),
}) {
Object.assign(this, { doc, rectOf, setTimer, clearTimer });
this.node = null;
this.timer = null;
// Tools running in this turn, keyed by call id (name when absent).
this.running = new Set();
this.visible = false;
this.destroyed = false;
}
ensureNode() {
if (this.node || !this.doc?.createElement) return this.node;
const node = this.doc.createElement('div');
node.className = 'gev-pointer-reticle';
node.setAttribute?.('aria-hidden', 'true');
node.hidden = true;
this.doc.body?.appendChild?.(node);
this.node = node;
return node;
}
schedule(ms) {
this.cancelTimer();
this.timer = this.setTimer(() => {
this.timer = null;
this.hide();
}, ms);
}
cancelTimer() {
if (this.timer === null) return;
this.clearTimer(this.timer);
this.timer = null;
}
/** Show at a snapshot's canvas pixels. */
show(pointer) {
if (this.destroyed || !pointer?.screenPx) return;
const rect = this.rectOf?.();
const node = this.ensureNode();
if (!rect || !node) return;
node.style.left = `${Math.round(rect.left + pointer.screenPx.x)}px`;
node.style.top = `${Math.round(rect.top + pointer.screenPx.y)}px`;
node.dataset.target = pointer.kind === 'this' ? 'entity' : 'ground';
node.hidden = false;
this.visible = true;
this.running.clear();
this.schedule(RETICLE_MAX_MS);
}
hide() {
this.cancelTimer();
this.running.clear();
if (!this.visible) return;
this.visible = false;
if (this.node) this.node.hidden = true;
}
/** Follow provider-neutral session events. */
handle(event) {
if (this.destroyed || !event?.type) return;
switch (event.type) {
case 'pointer':
if (event.pointer) this.show(event.pointer);
else this.hide();
return;
case 'state':
if (['idle', 'error'].includes(event.state)) this.hide();
return;
case 'action-call':
if (!this.visible) return;
if (CAMERA_TOOLS.has(event.name)) this.hide();
else this.running.add(event.callId || event.name);
return;
case 'action-result':
this.running.delete(event.callId || event.name);
return;
case 'completion':
if (this.visible && this.running.size === 0)
this.schedule(RETICLE_LINGER_MS);
return;
default:
}
}
destroy() {
if (this.destroyed) return;
this.hide();
this.destroyed = true;
this.node?.remove?.();
this.node = null;
}
}