diff --git a/.env.example b/.env.example index 55cb367a0..0876262b2 100644 --- a/.env.example +++ b/.env.example @@ -115,7 +115,8 @@ GEMINI_LIVE_MODEL=gemini-3.8-live # Client Token). Client-exposed by design, like GOOGLE_MAPS_API_KEY. MAPILLARY_CLIENT_TOKEN= -# Optional: OpenSky auth mode (oauth | basic | auto | anon) +# Optional: OpenSky auth mode (oauth | auto | anon). OpenSky retired +# username/password (basic) sign-in on 2026-03-18; "basic" now loads anonymously. # Default: oauth — requires client credentials below # Set to "anon" to skip aircraft auth (rate-limited but works) OPENSKY_AUTH_MODE=oauth @@ -128,7 +129,9 @@ OPENSKY_CLIENT_SECRET= # Optional paid Patreon tokens raise request limits; there is no free API key. LL2_API_TOKEN= -# Optional: OpenSky credentials JSON file path (alternative to above) +# Optional: OpenSky credentials JSON file path, used when the two values above +# are empty: the credentials.json your OpenSky account page downloads when you +# create an API client. Use an absolute path for compatibility with launchers. # OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json # Optional: dev server config diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..e8c9f8bdf --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,59 @@ +## What this changes + + + +## How I verified it + +- [ ] `npm run build` +- [ ] `npm test` +- [ ] `npm run test:track` (dev server running) +- [ ] Feature gate for the area I touched: + + + +## New layer checklist + + + +**Screenshots** + + + +**Recording** + + + +**Proposed category** + + + +Category: + +**Data source and licence** + +- Source (provider and endpoint): +- Licence or terms (link): +- Attribution shown in the app: +- Needs a key? (none / optional / required): +- Fetched at runtime, or bundled in the repo: + + + +- [ ] Added a row to `DATA_SOURCES.md` +- [ ] Credit is registered in `src/data/dataCredits.js` +- [ ] Share-link token assigned with `npm run layer-token:next`, if the layer is shareable + +## Docs + +- [ ] `docs/CURRENT-STATE.md` updated and a `changelog.d/-.md` fragment added, or this PR doesn't change runtime behaviour diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ad3a7368..f5d3a4842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ never derived: an aircraft that has not transmitted a rate reads as `null` rather than a level-flight `0`. +- Bound how often the GBFS proxy will relay. `/api/gbfs` already restricted + where it could go — https, an allowlisted host, a station feed path, no + redirects, a 12-second deadline and a 5 MB body cap — but nothing restricted + how often, so the dev server would make unlimited requests at a third-party + bikeshare operator on any caller's behalf. The route now admits 120 + requests/minute per client address, refusing the rest with 429 and + `Retry-After` before any outbound fetch. The bikeshare layer needs about 15 a + minute with every catalogued system in range, so ordinary use is unchanged. + - Add nine Ontario transit feeds: TTC, MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, Burlington, Barrie and Kingston, each carrying its own licence quote and attribution. A feed can now name its own modes, so Toronto's diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 442b9c1bf..a7fe8ab76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -108,7 +108,7 @@ prints anything; never fix that by renaming or deleting PR A's published token. The highest-leverage places to jump in: - **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane. -- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers//`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template. +- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers//`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template, and see [Adding a new layer](#adding-a-new-layer) for what the PR needs. - **🎙️ Extend voice control.** Voice arguments are defined in `src/voice/actionSchemas.js`, with server-side descriptions in `server/providers/openai/tools.js` and client-side execution in `src/voice/gevActions.js`. Keep the tool surface tight and the responses honest (confirm only what actually happened). - **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`. - **🐛 Fix bugs / improve the first-run experience.** See [docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md). @@ -116,7 +116,7 @@ The highest-leverage places to jump in: ## Architecture in one minute - **No framework.** Vanilla JS + [CesiumJS](https://cesium.com/platform/cesiumjs/) + [Vite](https://vitejs.dev/). -- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/ui.js` and `src/data/.js` entries retain compatibility; new code belongs with its focused owner. +- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/data/.js` entries retain compatibility; new code belongs with its focused owner. - Sources acquire records; renderers own Cesium resources. Import `gods-eye-view/layers//source` when only a source factory is needed. Common voice controls consume the session interface; protocol adapters own connection details. - **Secrets stay server-side.** Anything needing a private key goes through a local proxy under `server/providers/`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens. - `docs/CURRENT-STATE.md` is the authoritative runtime reference — read it first. @@ -153,6 +153,33 @@ ownership and adoption process. 3. If you change runtime behavior, update `docs/CURRENT-STATE.md` in the same PR, and add your changelog entry as `changelog.d/-.md` instead of editing `CHANGELOG.md` — see [changelog.d/README.md](changelog.d/README.md). Distinct filenames don't conflict; a release assembles the directory into `CHANGELOG.md`. A documentation claim that needs a regression test should assert against `docs/CURRENT-STATE.md` or `README.md`, which a PR still edits directly. 4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead. 5. Describe what you changed and how you verified it (screenshots welcome for anything visual). +6. If you add a new layer, fill in the new-layer section of the PR template. See [Adding a new layer](#adding-a-new-layer). + +## Adding a new layer + +A new layer changes what everyone sees on the globe, so maintainers need to see +it working and know where its data comes from before reviewing the code. PRs +that add a layer must include all four of these in the description. The PR +template has a section for them. + +1. **Screenshots.** At least one of the layer on the globe, plus its info card + or panel if it has one. +2. **A screen recording**, preferably 15 to 30 seconds: turn the layer on, move + around, and open a record. Drag an `.mp4` or `.mov` into the PR description. + GitHub accepts videos up to 10 MB on free accounts, so link to a larger file + instead of attaching it. +3. **The proposed category.** Say which layer-panel group the layer belongs in. + The groups are defined in `PANEL_GROUPS` in `src/ui/layerPanel.js` (today: + Movement, Cameras, Infrastructure, Events, Weather and Utilities). A layer + that isn't listed there falls under "Other layers". If none of the groups + fits, propose a new one and explain why. +4. **The data source and licence.** Name the provider and endpoint, link its + licence or terms, give the attribution shown in the app, and say whether it + needs a key and whether the data is fetched at runtime or bundled. Add the + same details to [DATA_SOURCES.md](DATA_SOURCES.md) and register the credit + in `src/data/dataCredits.js`. + +Maintainers may hold a layer PR until all four are there. ## Maintainers diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 7295899c0..e31d0d3f0 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -178,7 +178,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. -- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. +- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty successful payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. - **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app). - **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line. - **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable. diff --git a/Dockerfile b/Dockerfile index d34f4887a..433d170a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,11 @@ COPY --chown=node:node . . ENV HOST=0.0.0.0 ENV PORT=4173 EXPOSE 4173 +# node:slim has no curl, so probe with Node's built-in fetch. 127.0.0.1 is +# always on the Host allowlist, and the generous start period covers Vite's +# first-run dependency optimisation. +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:'+(Number.parseInt(process.env.PORT,10)||4173)+'/').then((r)=>process.exit(r.ok?0:1),()=>process.exit(1))"] # The launcher is `npm run dev` plus one step: it trusts the container's # gateway address for Provider Settings, because that is where Docker delivers # the host's own browser from (never 127.0.0.1). See scripts/docker-start.mjs. diff --git a/README.md b/README.md index 00fb9c24f..0a7509efa 100644 --- a/README.md +++ b/README.md @@ -111,13 +111,15 @@ app's **POWER UP** panel; [Keys & Costs](#-api-keys) explains the options. Available on **Windows, macOS, and Linux**. The Pinokio maintainer reports cross-platform testing of the fixed installer. The launcher installs the -locked dependencies, finds a free local port, and opens the app. +locked dependencies, finds a free local port, and opens the app in your system +browser. The Server menu item keeps the launcher console available. **Tried before and installation failed?** Update Pinokio and try again. Version 8.2 fixes the launcher installation issue; [details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g). -**Pinokio opens but the globe is very choppy?** Before treating that as a GPU +**Using an older launcher or opening the embedded view manually?** If the globe +is very choppy, before treating that as a GPU compatibility or minimum-hardware problem, copy the local server URL shown by Pinokio and open the same URL in your normal hardware-accelerated browser. One Pinokio 8.2.0 user reported severe stutter in Pinokio's embedded view that went @@ -177,6 +179,16 @@ Publishing it wider is the [Sharing an instance](#-sharing-an-instance) opt-in, When you widen the publish, also set `GEV_KEY_SETUP_TRUSTED_PEERS=none` so the launcher trusts no gateway address. +```bash +GEV_KEY_SETUP_TRUSTED_PEERS=none docker compose up +``` + +Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` from your +shell or the project's `.env`; an exported shell value takes precedence. You can +also put `GEV_KEY_SETUP_TRUSTED_PEERS=none` in `.env` for a persistent setting. +Unset or blank trusted peers retain automatic gateway discovery; `none` explicitly +disables it. The startup log confirms that no additional peers are trusted. +
Running on Linux, a NAS, or behind a reverse proxy @@ -197,6 +209,8 @@ env UID=1026 GID=100 docker compose up -d Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front of a command is silently ignored. If the container can't write the checkout, it says so at start. Docker Desktop on macOS and Windows maps ownership for you. +On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`. + Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)).
@@ -333,6 +347,10 @@ Thirty tools, four jobs — the commands below come straight from the product's **🎥 Direct it** — drone-operator camera verbs: +“Rotate the globe” spins Earth to the right continuously; ask for left, a speed, +or stop. If it refuses, follow its reset-and-rotate or further-zoom guidance; +camera input stops rotation. + > 🗣️ _"Take me to Tokyo."_ · _"Orbit around this area slowly."_ · _"Draw the walking route from the Capitol to Zilker Park."_ → _"Fly the route we just drew."_ · _"Zoom out to a globe view."_ **🖊️ Annotate it** — a whiteboard over the real world: @@ -478,7 +496,7 @@ How the globe handles live data: ``` src/ ├── main.js # Bootstrap: Google 3D tiles, layer registration -├── ui.js # Runtime UI — panels, HUD, styles, control facade +├── ui/ # Runtime UI — panels, HUD, styles, controls ├── hud.js # Intelligence HUD + AI scene summary ├── keySetup.js # POWER UP panel — in-app provider keys (dev server only) ├── mapStackController.js # Basemap switching — Google 3D / Esri / OSM / ion stacks diff --git a/SECURITY.md b/SECURITY.md index 708bb1583..4423eead3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,6 +2,11 @@ God's Eye View is a local-first client for **public** data. It is built for exploration, demos, and learning — not as a hardened production service. This document explains the security model so you can run it safely and report issues responsibly. +Vite's development file server also denies `.gev-logs/`, `.gev-cache/`, and +the package checkout's root `dist/` directory. Dependency `dist/` directories +remain available. This filesystem deny list does not filter `public/` assets or +preview output; never put private artifacts in a public asset directory. + ## Reporting a vulnerability Please report security issues **privately** — do not open a public issue for anything exploitable. @@ -100,6 +105,11 @@ The dev server is a **key broker**: every server-side key above is spendable by `compose.yaml` publishes on `127.0.0.1`: **widen that mapping and every LAN client arrives from the same gateway address, which hands them Provider Settings too.** Treat publishing beyond loopback as the LAN opt-in above. + Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` from + the shell or project `.env` before the launcher runs. Set trusted peers to + `none` when widening the publish: it becomes an explicit empty list before + Vite loads configuration, so the gateway cannot regain Provider Settings + access. Unset or blank values preserve automatic gateway discovery. - **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider. - **Pinokio LAN and Cloudflare sharing are refused.** The current supported Pinokio release re-reads sharing state when an app registers its Open URL and diff --git a/build/vite.js b/build/vite.js index a02f2340f..960603814 100644 --- a/build/vite.js +++ b/build/vite.js @@ -1,9 +1,29 @@ +import { fileURLToPath } from 'node:url'; import { applicationHtmlPlugin } from './application-html.js'; import cesium from 'vite-plugin-cesium'; import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js'; import { embedFramingPlugin } from './embed-framing.js'; import { panelBuildPlugin } from './panel.js'; +/** + * The repository root (this file lives at `/build/vite.js`), as a + * forward-slash path for glob matching. Vite's `fs.deny` patterns are + * matched against the full absolute file path, and a bare `**\/dist/**` + * matches a `dist` directory at *any* depth — including dependencies that + * ship their own, such as `node_modules/vite/dist/client/client.mjs` (the + * dev client itself). Anchoring to this repository's root denies only the + * project's own build output. + */ +const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)) + .replace(/\\/g, '/') + .replace(/\/$/, ''); + +export function escapeFsDenyPath(path) { + return path.replace(/[()[\]{}*?!+@|]/g, '\\$&'); +} + +const REPO_ROOT_GLOB = escapeFsDenyPath(REPO_ROOT); + /** * Content-Security-Policy for every document the dev/preview server serves. * No inline script is permitted, and the only foreign script origins are the @@ -165,7 +185,19 @@ export function createBrowserViteConfig({ // build/allowedHosts.js); IP addresses are always accepted. allowedHosts: [...allowedHosts], fs: { - deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'], + deny: [ + '.env', + '.env.*', + '*.{crt,pem}', + '**/.git/**', + '**/ENVIRONMENT', + '**/.gev-logs/**', + '**/.gev-cache/**', + // Root-anchored: a bare '**/dist/**' would also deny every + // dependency's own dist/ (e.g. node_modules/vite/dist/client), + // breaking /@vite/client and other dev-client assets. + `${REPO_ROOT_GLOB}/dist/**`, + ], }, // These headers protect the document containing Provider Settings and // give the whole page a real Content-Security-Policy (BROWSER_CSP). diff --git a/changelog.d/1066-keyless-google-token.md b/changelog.d/1066-keyless-google-token.md new file mode 100644 index 000000000..c07cbade8 --- /dev/null +++ b/changelog.d/1066-keyless-google-token.md @@ -0,0 +1,3 @@ +- Keyless standalone startup answers an unavailable Google tile-token endpoint + with uncached 204 instead of a browser-visible 404. Existing providers take + precedence, and unrelated API routes behave as before. (dvilrgamerz, #1066) diff --git a/changelog.d/1207-voice-entity-labels-english.md b/changelog.d/1207-voice-entity-labels-english.md new file mode 100644 index 000000000..b69cdc7ed --- /dev/null +++ b/changelog.d/1207-voice-entity-labels-english.md @@ -0,0 +1,3 @@ +- Keep NASA FIRMS fire and ALPR camera labels English in voice tool results + (`track_entity`, `get_entity_context`, pointer context); the voice card still + shows them in the interface language (daikaginza, #1207). diff --git a/changelog.d/1222-pinokio-open-in-browser.md b/changelog.d/1222-pinokio-open-in-browser.md new file mode 100644 index 000000000..bc6b62402 --- /dev/null +++ b/changelog.d/1222-pinokio-open-in-browser.md @@ -0,0 +1,3 @@ +- Open God's Eye View from Pinokio in the system browser using the supported + `popout` menu option. The application's framing policy is unchanged + (NukeThemAII, #1222). diff --git a/changelog.d/1225-globe-rotation.md b/changelog.d/1225-globe-rotation.md new file mode 100644 index 000000000..844966030 --- /dev/null +++ b/changelog.d/1225-globe-rotation.md @@ -0,0 +1,2 @@ +- Add continuous globe rotation with direction and speed controls and a globe-fit + check before starting (manjunath22466, #1225). diff --git a/changelog.d/1226-first-run-touch-dismiss.md b/changelog.d/1226-first-run-touch-dismiss.md new file mode 100644 index 000000000..3eb802dac --- /dev/null +++ b/changelog.d/1226-first-run-touch-dismiss.md @@ -0,0 +1,4 @@ +- The first-run card can be closed on a phone or tablet. "ESC to dismiss" is + now a button that closes the card the same way ESC does; touch screens read + DISMISS, and phones no longer hide it, so choosing a mission is not the only + way out (KnottyDyes, #1226). diff --git a/changelog.d/1229-opensky-oauth-only.md b/changelog.d/1229-opensky-oauth-only.md new file mode 100644 index 000000000..25159830b --- /dev/null +++ b/changelog.d/1229-opensky-oauth-only.md @@ -0,0 +1,8 @@ +- OpenSky sign-in matches what OpenSky accepts since 2026-03-18. The server + now reads `OPENSKY_CREDENTIALS_FILE` (the `credentials.json` OpenSky's + account page downloads) on every platform, not only through the macOS + launcher, and no longer sends retired username/password Basic auth: + `OPENSKY_AUTH_MODE=basic` loads anonymously with one warning saying what to + set instead (quanticsoul4772, #1229). +- Credential-file warnings omit parser excerpts and filesystem error details; + the path example also works with the macOS launchers. diff --git a/changelog.d/1232-gbfs-invalid-coordinates.md b/changelog.d/1232-gbfs-invalid-coordinates.md new file mode 100644 index 000000000..4f3da2a97 --- /dev/null +++ b/changelog.d/1232-gbfs-invalid-coordinates.md @@ -0,0 +1,3 @@ +- Skip bike-share stations with missing, blank or invalid coordinates instead + of placing them at coerced zero coordinates or accepting out-of-range values. + Explicit zero coordinates and numeric strings remain supported (Bortlesboat, #1232). diff --git a/changelog.d/1239-traffic-stale-flow.md b/changelog.d/1239-traffic-stale-flow.md new file mode 100644 index 000000000..5338e8e60 --- /dev/null +++ b/changelog.d/1239-traffic-stale-flow.md @@ -0,0 +1,3 @@ +- `get_traffic_flow` flags stale cached TomTom tiles and reports the oldest + tile fetch time (`stale`, `oldest_tile_at`), with pending freshness metadata + released on failed or cancelled tile reads (quanticsoul4772, #1239). diff --git a/changelog.d/1244-terrain-test-cache-isolation.md b/changelog.d/1244-terrain-test-cache-isolation.md new file mode 100644 index 000000000..5efacd3bb --- /dev/null +++ b/changelog.d/1244-terrain-test-cache-isolation.md @@ -0,0 +1,4 @@ +- The terrain-heights bounds tests no longer read the checkout's + `.gev-cache/terrain-heights.json`. Each test gets a cache file of its own, so + `npm test` no longer fails after the dev server has cached terrain near + Austin (KnottyDyes, #1244). diff --git a/changelog.d/1265-docker-trusted-peers.md b/changelog.d/1265-docker-trusted-peers.md new file mode 100644 index 000000000..f86fe3732 --- /dev/null +++ b/changelog.d/1265-docker-trusted-peers.md @@ -0,0 +1,3 @@ +- Honor Docker Compose trusted-peer and allowed-host settings from the shell + or project `.env`, and let `GEV_KEY_SETUP_TRUSTED_PEERS=none` disable gateway + access to Provider Settings before Vite starts (asturksever, #1265). diff --git a/changelog.d/1268-radio-issue-fix.md b/changelog.d/1268-radio-issue-fix.md new file mode 100644 index 000000000..ab68243d6 --- /dev/null +++ b/changelog.d/1268-radio-issue-fix.md @@ -0,0 +1,2 @@ +- Normalize station tags and retain valid mixed rows; skipped malformed rows still report degraded coverage (asturksever, #1268). +- Correct Radio QA push-to-talk setup to target the mounted voice controller (asturksever, #1268). diff --git a/changelog.d/1269-aircraft-issue-fix.md b/changelog.d/1269-aircraft-issue-fix.md new file mode 100644 index 000000000..8984a9141 --- /dev/null +++ b/changelog.d/1269-aircraft-issue-fix.md @@ -0,0 +1 @@ +- Coalesce refreshes and cool down opensky failures (asturksever, #1269). diff --git a/changelog.d/1270-space-issue-fix.md b/changelog.d/1270-space-issue-fix.md new file mode 100644 index 000000000..b483c4c01 --- /dev/null +++ b/changelog.d/1270-space-issue-fix.md @@ -0,0 +1 @@ +- Respect provider cooldowns and bound tle groups (asturksever, #1270). diff --git a/changelog.d/1271-share-issue-fix.md b/changelog.d/1271-share-issue-fix.md new file mode 100644 index 000000000..7a01330ba --- /dev/null +++ b/changelog.d/1271-share-issue-fix.md @@ -0,0 +1 @@ +- Reject inherited style keys during restore (asturksever, #1271). diff --git a/changelog.d/1272-search-issue-fix.md b/changelog.d/1272-search-issue-fix.md new file mode 100644 index 000000000..48b803534 --- /dev/null +++ b/changelog.d/1272-search-issue-fix.md @@ -0,0 +1 @@ +- Normalize geocoder bias across the antimeridian (asturksever, #1272). diff --git a/changelog.d/1273-history-issue-fix.md b/changelog.d/1273-history-issue-fix.md new file mode 100644 index 000000000..f672a5c22 --- /dev/null +++ b/changelog.d/1273-history-issue-fix.md @@ -0,0 +1 @@ +- Retire unavailable history and keep local trails (asturksever, #1273). diff --git a/changelog.d/1274-import-issue-fix.md b/changelog.d/1274-import-issue-fix.md new file mode 100644 index 000000000..99bbc1429 --- /dev/null +++ b/changelog.d/1274-import-issue-fix.md @@ -0,0 +1 @@ +- Scan the kml root delimiter in bounded time (asturksever, #1274). diff --git a/changelog.d/1275-forecast-issue-fix.md b/changelog.d/1275-forecast-issue-fix.md new file mode 100644 index 000000000..2013bc4f2 --- /dev/null +++ b/changelog.d/1275-forecast-issue-fix.md @@ -0,0 +1,3 @@ +- Preserve enable during first-load replacement (asturksever, #1275). +- Release disabled weather camera listeners and make teardown resource leaks fail the browser probe (asturksever, #1275). +- Keep visible-page weather teardown probes focused through the browser testing API (asturksever, #1275). diff --git a/changelog.d/1276-terrain-issue-fix.md b/changelog.d/1276-terrain-issue-fix.md new file mode 100644 index 000000000..17e8f4b50 --- /dev/null +++ b/changelog.d/1276-terrain-issue-fix.md @@ -0,0 +1 @@ +- Bound each height request to one upstream chunk (asturksever, #1276). diff --git a/changelog.d/1278-overpass-issue-fix.md b/changelog.d/1278-overpass-issue-fix.md new file mode 100644 index 000000000..4b028f046 --- /dev/null +++ b/changelog.d/1278-overpass-issue-fix.md @@ -0,0 +1 @@ +- Reserve singleflight before disk cache reads (asturksever, #1278). diff --git a/changelog.d/1279-launch-issue-fix.md b/changelog.d/1279-launch-issue-fix.md new file mode 100644 index 000000000..ec451e7ae --- /dev/null +++ b/changelog.d/1279-launch-issue-fix.md @@ -0,0 +1 @@ +- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279). diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md new file mode 100644 index 000000000..3ce673038 --- /dev/null +++ b/changelog.d/1281-traffic-issue-fix.md @@ -0,0 +1,8 @@ +- Hold rendering only for animated content (asturksever, #1281). +- Exercise current vector road sources and published camera bounds in Traffic fixture QA (asturksever, #1281). +- Establish browser-level foreground visibility for Traffic QA and reject hidden-page samples (asturksever, #1281). +- Supply CORS headers on cross-origin traffic fixtures and report browser exceptions without disabling browser security (asturksever, #1281). +- Isolate the Traffic keyless browser scenario from the keyed page’s workers and intercepted cache (asturksever, #1281). +- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1281). +- Intercept only Traffic provider URLs, leaving Cesium worker and module loading untouched (asturksever, #1281). +- Settle an unfinished Traffic height correction when the last animated point leaves, so the layer stops reporting loading once its render hold is released (asturksever, #1281). diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md new file mode 100644 index 000000000..98885fc20 --- /dev/null +++ b/changelog.d/1282-polling-issue-fix.md @@ -0,0 +1,7 @@ +- Pause periodic network updates in hidden tabs; the overdue update on return restarts the polling schedule, so the next poll follows one interval later even when that update fails (asturksever, #1282). +- Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282). +- Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282). +- Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282). +- Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282). +- Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282). +- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1282). diff --git a/changelog.d/1288-zoomfor-legacy-shape.md b/changelog.d/1288-zoomfor-legacy-shape.md new file mode 100644 index 000000000..e23b524e8 --- /dev/null +++ b/changelog.d/1288-zoomfor-legacy-shape.md @@ -0,0 +1,4 @@ +- A Street Level provider whose `zoomFor` still answers `{zoom, kind}`, the + shape before the core went slim, draws its lines again instead of a silent + blank; an overview level or any answer that is not a whole zoom shows the + zoom-in hint (asturksever, #1288). diff --git a/changelog.d/1298-simplified-chinese.md b/changelog.d/1298-simplified-chinese.md new file mode 100644 index 000000000..d35d08cf1 --- /dev/null +++ b/changelog.d/1298-simplified-chinese.md @@ -0,0 +1,4 @@ +- Add Simplified Chinese (zh-CN) to the language picker, with localized dates + and readable Chinese labels. Based on translations by @abiyeenzo (#914) + and @EDIT-Aeterna (#979). + (German, @gekh, #1298). diff --git a/changelog.d/1303-wind-stale.md b/changelog.d/1303-wind-stale.md new file mode 100644 index 000000000..cb54224a5 --- /dev/null +++ b/changelog.d/1303-wind-stale.md @@ -0,0 +1,2 @@ +- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a + cached model run (quanticsoul4772, #1303). diff --git a/changelog.d/1304-antimeridian-distances.md b/changelog.d/1304-antimeridian-distances.md new file mode 100644 index 000000000..fc7744f86 --- /dev/null +++ b/changelog.d/1304-antimeridian-distances.md @@ -0,0 +1,2 @@ +- Point distances wrap longitude differences across the antimeridian, in voice + proximity checks and `places_nearby` (quanticsoul4772, #1304). diff --git a/changelog.d/1321-dead-code-cleanup.md b/changelog.d/1321-dead-code-cleanup.md new file mode 100644 index 000000000..e8cad2a23 --- /dev/null +++ b/changelog.d/1321-dead-code-cleanup.md @@ -0,0 +1,6 @@ +- Remove dead code across the repository: three modules nothing imports + (`src/ui.js`, `src/standalone/ui.js`, `src/data/localLayers.js`), exports + and locals nothing reads, a duplicate copy of the bundled dams data and an + unused ship model. The app no longer builds an unused default wind layer + and Bhote Koshi locator layer when it loads. Package exports are unchanged + (samehkhamis, #1321). diff --git a/changelog.d/1324-street-level-provider-exports.md b/changelog.d/1324-street-level-provider-exports.md new file mode 100644 index 000000000..a3dc4cb6b --- /dev/null +++ b/changelog.d/1324-street-level-provider-exports.md @@ -0,0 +1,8 @@ +- Export the KartaView and Panoramax Street Level providers, so a host composing + its own provider list can offer the keyless providers without Mapillary + (samehkhamis, #1324). +- A Mapillary source can take a `graphEndpoint`, a Graph API base that adds the + token itself (such as a same-origin proxy). Its sequence lookups go there + through the source's transport, and the photo viewer uses MapillaryJS's + Graph provider at that endpoint, so no client token is needed in the page + (samehkhamis, #1324). diff --git a/changelog.d/1325-phone-layout.md b/changelog.d/1325-phone-layout.md new file mode 100644 index 000000000..c1653df62 --- /dev/null +++ b/changelog.d/1325-phone-layout.md @@ -0,0 +1,4 @@ +- Add a phone layout for viewports 720px and narrower: the globe stays full + screen and a bottom tab bar (Layers, Cameras, Context, Search, More) opens one + panel sheet at a time, keeping attribution visible and scene STOP reachable. + Adapted from the phone tab-shell in #203 by blearymoth (samehkhamis, #1325). diff --git a/changelog.d/1326-terrain-cache-eviction.md b/changelog.d/1326-terrain-cache-eviction.md new file mode 100644 index 000000000..ccb374ccd --- /dev/null +++ b/changelog.d/1326-terrain-cache-eviction.md @@ -0,0 +1,3 @@ +- Evict terrain-height cache entries in fetch-time order without repeatedly + sorting the full cache, while keeping refreshed points newest and ordering + restored entries by their fetch time (daikaginza, #1326). diff --git a/changelog.d/1338-mobile-contact-context.md b/changelog.d/1338-mobile-contact-context.md new file mode 100644 index 000000000..e240dc82a --- /dev/null +++ b/changelog.d/1338-mobile-contact-context.md @@ -0,0 +1,4 @@ +- Improve touch selection of aircraft and vessels through the shared contact + picker, reveal tracked aircraft details when opening Context, support downward + swipe dismissal of phone drawers while preserving scrolling and controls, and + enlarge the phone logo (samehkhamis, #1338). diff --git a/changelog.d/1339-hud-dms-carry.md b/changelog.d/1339-hud-dms-carry.md new file mode 100644 index 000000000..588668afe --- /dev/null +++ b/changelog.d/1339-hud-dms-carry.md @@ -0,0 +1,3 @@ +- Carry HUD LAT/LON seconds that round up to 60.00" into the minutes and + degrees, so longitude -122.35 reads `122°21'00.00"W` instead of + `122°20'60.00"W` (rudycelekli, #1339). diff --git a/changelog.d/1340-search-separated-hemisphere.md b/changelog.d/1340-search-separated-hemisphere.md new file mode 100644 index 000000000..5b89020e4 --- /dev/null +++ b/changelog.d/1340-search-separated-hemisphere.md @@ -0,0 +1,3 @@ +- Read a hemisphere letter that is separated from its number, so a search for + `40.7128 N 74.0060 W` flies there instead of falling through to the geocoders + (Rudy Celekli, @rudycelekli, #1340). diff --git a/changelog.d/1341-kartaview-impossible-timestamps.md b/changelog.d/1341-kartaview-impossible-timestamps.md new file mode 100644 index 000000000..302ff38f4 --- /dev/null +++ b/changelog.d/1341-kartaview-impossible-timestamps.md @@ -0,0 +1,3 @@ +- Reject impossible KartaView photo timestamps, so 31 February is unknown + instead of rolling forward into March and passing the imagery date filter + (Rudy Celekli, @rudycelekli, #1341). diff --git a/changelog.d/1342-firms-acquisition-calendar.md b/changelog.d/1342-firms-acquisition-calendar.md new file mode 100644 index 000000000..e0003636d --- /dev/null +++ b/changelog.d/1342-firms-acquisition-calendar.md @@ -0,0 +1,3 @@ +- Keep an impossible wildfire acquisition date off the detection clock, so a + fire is not labeled with a later day it did not happen + (Rudy Celekli, @rudycelekli, #1342). diff --git a/changelog.d/1343-route-ramp-lane-wording.md b/changelog.d/1343-route-ramp-lane-wording.md new file mode 100644 index 000000000..4f6817e9c --- /dev/null +++ b/changelog.d/1343-route-ramp-lane-wording.md @@ -0,0 +1,4 @@ +- Phrase slight and sharp ramps as directions, so a ramp reads "slightly left" + instead of "on the slightly left", and a lane names its side in both + the proxy response and localized browser instructions + (Rudy Celekli, @rudycelekli, #1343). diff --git a/changelog.d/1345-weather-staging-wake.md b/changelog.d/1345-weather-staging-wake.md new file mode 100644 index 000000000..fec4d54ec --- /dev/null +++ b/changelog.d/1345-weather-staging-wake.md @@ -0,0 +1,3 @@ +- Wake staged weather imagery on a 150 ms timer while waiting for readiness, + avoiding a continuous render loop and cancelling the wake when work ends + (@nu-stats, #1345). diff --git a/changelog.d/1349-cyclone-classification-codes.md b/changelog.d/1349-cyclone-classification-codes.md new file mode 100644 index 000000000..c22debfa1 --- /dev/null +++ b/changelog.d/1349-cyclone-classification-codes.md @@ -0,0 +1,3 @@ +- Label NHC cyclone classifications by their published meanings: `PTC` is a + post-tropical cyclone, `PC` a potential tropical cyclone, and subtropical + systems arrive as `STS` and `STD` (rudycelekli, #1349). diff --git a/changelog.d/1350-cyclone-movement-knots.md b/changelog.d/1350-cyclone-movement-knots.md new file mode 100644 index 000000000..3fc8c8f9f --- /dev/null +++ b/changelog.d/1350-cyclone-movement-knots.md @@ -0,0 +1,3 @@ +- Convert NHC cyclone movement speed from miles per hour to knots, so + `movement.speedKt` in cyclone query results is no longer about 15% high + (rudycelekli, #1350). diff --git a/changelog.d/1351-ais-static-keeps-motion.md b/changelog.d/1351-ais-static-keeps-motion.md new file mode 100644 index 000000000..52dd9bede --- /dev/null +++ b/changelog.d/1351-ais-static-keeps-motion.md @@ -0,0 +1,6 @@ +- Keep a tracked vessel's speed, course, heading and fix time when AISStream + static data arrives; the static message only merges name, type, destination + and IMO (rudycelekli, #1351). +- Apply changed static metadata immediately, retaining known fields when a + later static report omits them, without refreshing the vessel's position + (samehkhamis, #1351). diff --git a/changelog.d/1352-cockpit-destination-heading.md b/changelog.d/1352-cockpit-destination-heading.md new file mode 100644 index 000000000..c7a5ea9b5 --- /dev/null +++ b/changelog.d/1352-cockpit-destination-heading.md @@ -0,0 +1,3 @@ +- Read a destination heading that rounds to north as 000. 359.6° was shown as + 360° on the destination bearing while the compass tape already uses 000 + (Rudy Celekli, @rudycelekli, #1352). diff --git a/changelog.d/1353-transit-ninety-second-age.md b/changelog.d/1353-transit-ninety-second-age.md new file mode 100644 index 000000000..50611060d --- /dev/null +++ b/changelog.d/1353-transit-ninety-second-age.md @@ -0,0 +1,3 @@ +- Count a transit report that is 90 seconds old as one minute. The card kept + seconds through 89s, then rounded 90s up to "Reported 2 min ago" + (Rudy Celekli, @rudycelekli, #1353). diff --git a/changelog.d/1354-firms-frp-ten-megawatts.md b/changelog.d/1354-firms-frp-ten-megawatts.md new file mode 100644 index 000000000..394eba1df --- /dev/null +++ b/changelog.d/1354-firms-frp-ten-megawatts.md @@ -0,0 +1,3 @@ +- Drop the decimal when fire radiative power rounds to 10 MW. A 9.96 MW + detection was labeled 10.0 MW because the one-decimal cutoff ran before + rounding (Rudy Celekli, @rudycelekli, #1354). diff --git a/changelog.d/1366-aircraft-models-no-environment-map.md b/changelog.d/1366-aircraft-models-no-environment-map.md new file mode 100644 index 000000000..8c5ad49c3 --- /dev/null +++ b/changelog.d/1366-aircraft-models-no-environment-map.md @@ -0,0 +1,5 @@ +- Aircraft 3D models (Flights, Military Flights, Local ADS-B) skip Cesium's + dynamic environment map, a sky render and GPU readback per model. With + ~150 models in the scene at Atlanta on Google 3D, GPU time per frame at + rest falls from 14.3-14.8 ms to 12.1-12.5 ms; a same-model comparison + showed no visible change (quanticsoul4772, #1366). diff --git a/changelog.d/1367-turned-off-layer-memory.md b/changelog.d/1367-turned-off-layer-memory.md new file mode 100644 index 000000000..08bbbad81 --- /dev/null +++ b/changelog.d/1367-turned-off-layer-memory.md @@ -0,0 +1,4 @@ +- Turning a layer off frees the memory its entities held. Cesium kept + removed layers' draw commands in reused per-pass arrays, so Data Centers + kept 116 MiB after it was switched off, submarine cables 74 MiB and dams + 30 MiB; they now keep 13, 9 and 9 MiB (quanticsoul4772, #1367). diff --git a/changelog.d/1378-touch-zoom-aircraft-updates.md b/changelog.d/1378-touch-zoom-aircraft-updates.md new file mode 100644 index 000000000..81acefb00 --- /dev/null +++ b/changelog.d/1378-touch-zoom-aircraft-updates.md @@ -0,0 +1,5 @@ +- Increase touch-first globe zoom sensitivity to three times the default while + retaining Cesium collision and distance limits. +- Reuse one delayed display timestamp per civil-aircraft fleet update and cache + extrapolation coordinate frames until their fix coordinates change. Preserve + contact counts, movement cadence, tracked motion and ground corrections. diff --git a/changelog.d/1379-aircraft-label-candidates.md b/changelog.d/1379-aircraft-label-candidates.md new file mode 100644 index 000000000..f51ff101d --- /dev/null +++ b/changelog.d/1379-aircraft-label-candidates.md @@ -0,0 +1,4 @@ +- Reduce repeated work in aircraft detection candidates by caching unchanged + callsign/registration text and reusing resolved fleet models. Metadata updates, + live positions and tracked-aircraft/model handoffs remain immediate + (samehkhamis, #1379). diff --git a/changelog.d/1384-hud-summary-moving.md b/changelog.d/1384-hud-summary-moving.md new file mode 100644 index 000000000..21965af96 --- /dev/null +++ b/changelog.d/1384-hud-summary-moving.md @@ -0,0 +1,4 @@ +- The HUD's AI place summary drops a reply that arrives after the camera has + started moving, so a summary asked for before a flight no longer names the + old place mid-flight. Cancelled or partially typed summaries retry even if the place + context stays the same (quanticsoul4772, #1384). diff --git a/changelog.d/1385-route-duration-minute.md b/changelog.d/1385-route-duration-minute.md new file mode 100644 index 000000000..fa354766b --- /dev/null +++ b/changelog.d/1385-route-duration-minute.md @@ -0,0 +1,3 @@ +- Show a route leg of 59.5 seconds or more as 1 min. The route summary and step + cards kept seconds below 60, then rounded 59.6 s up to "60 s" + (Rudy Celekli, @rudycelekli, #1385). diff --git a/changelog.d/1386-route-distance-kilometre.md b/changelog.d/1386-route-distance-kilometre.md new file mode 100644 index 000000000..0a60fcc62 --- /dev/null +++ b/changelog.d/1386-route-distance-kilometre.md @@ -0,0 +1,3 @@ +- Show a route leg of 995 m or more as 1.0 km. The route summary and step + cards kept meters below 1000, then rounded 996 m up to "1000 m" + (Rudy Celekli, @rudycelekli, #1386). diff --git a/changelog.d/1387-awareness-distance-kilometre.md b/changelog.d/1387-awareness-distance-kilometre.md new file mode 100644 index 000000000..4414b7c5f --- /dev/null +++ b/changelog.d/1387-awareness-distance-kilometre.md @@ -0,0 +1,3 @@ +- Show a Contacts entry 999.5 m or more away as 1.0 km. The panel kept meters + below 1000, then rounded 999.6 m up to "1000 m" + (Rudy Celekli, @rudycelekli, #1387). diff --git a/changelog.d/1388-draw-line-kilometre.md b/changelog.d/1388-draw-line-kilometre.md new file mode 100644 index 000000000..137075707 --- /dev/null +++ b/changelog.d/1388-draw-line-kilometre.md @@ -0,0 +1,3 @@ +- Show a drawn line of 999.5 m or more as 1.0 km. The draw hint kept metres + below 1000, then rounded 999.6 m up to "1000 m" + (Rudy Celekli, @rudycelekli, #1388). diff --git a/changelog.d/1389-cockpit-wind-north.md b/changelog.d/1389-cockpit-wind-north.md new file mode 100644 index 000000000..cc81c362e --- /dev/null +++ b/changelog.d/1389-cockpit-wind-north.md @@ -0,0 +1,3 @@ +- Show cockpit wind from 359.5° or more as 0°, not 360°. Rounding 359.6° + printed "N · 360°" on the local wind direction + (Rudy Celekli, @rudycelekli, #1389). diff --git a/changelog.d/1390-awareness-bearing-north.md b/changelog.d/1390-awareness-bearing-north.md new file mode 100644 index 000000000..af7fb4da3 --- /dev/null +++ b/changelog.d/1390-awareness-bearing-north.md @@ -0,0 +1,2 @@ +- Render awareness-overlay bearings and courses that round through north as + `000°`, rather than `360°` (Rudy Celekli, #1390). diff --git a/changelog.d/1391-docker-selinux.md b/changelog.d/1391-docker-selinux.md new file mode 100644 index 000000000..186147a2e --- /dev/null +++ b/changelog.d/1391-docker-selinux.md @@ -0,0 +1,4 @@ +- Start the Docker install on SELinux hosts such as Fedora and RHEL. Compose now + mounts the checkout with `:z`; without it the container could not read `/app` + and stopped with "Cannot find module '/app/scripts/docker-start.mjs'" + (Anıl Güvenç, @anilguvenc, #1391). diff --git a/changelog.d/1394-docker-healthcheck.md b/changelog.d/1394-docker-healthcheck.md new file mode 100644 index 000000000..75ead9667 --- /dev/null +++ b/changelog.d/1394-docker-healthcheck.md @@ -0,0 +1,2 @@ +- Report Docker container health with a local HTTP check using Node, with the + same port parsing as the launcher (John Rogers, @14traits, #1394). diff --git a/changelog.d/1395-fires-tool-acquired-time.md b/changelog.d/1395-fires-tool-acquired-time.md new file mode 100644 index 000000000..17d74e481 --- /dev/null +++ b/changelog.d/1395-fires-tool-acquired-time.md @@ -0,0 +1,6 @@ +- Report a fire detection's acquisition time as a UTC timestamp in + `get_active_fires`. The tool joined FIRMS's unpadded date and time, so a + 00:45 UTC detection read "2026-01-01 45"; it now reads + `2026-01-01T00:45:00.000Z`. Missing or invalid dates and times remain + unknown rather than rolling into another calendar day + (Rudy Celekli, @rudycelekli, #1395). diff --git a/changelog.d/1396-local-adsb-track-north.md b/changelog.d/1396-local-adsb-track-north.md new file mode 100644 index 000000000..9235a19a3 --- /dev/null +++ b/changelog.d/1396-local-adsb-track-north.md @@ -0,0 +1,3 @@ +- Show a locally heard aircraft tracking 359.5° or more as TRK 0°. The Local + ADS-B card rounded the track without wrapping, so 359.7° read "TRK 360°" + (Rudy Celekli, @rudycelekli, #1396). diff --git a/changelog.d/1398-sharpen-zero-off.md b/changelog.d/1398-sharpen-zero-off.md new file mode 100644 index 000000000..ab05aa6f2 --- /dev/null +++ b/changelog.d/1398-sharpen-zero-off.md @@ -0,0 +1,2 @@ +- The Sharpen slider at 0 now turns the sharpen pass off instead of still + sharpening at a low amount (quanticsoul4772, #1398). diff --git a/changelog.d/1399-ground-snap-clock-step.md b/changelog.d/1399-ground-snap-clock-step.md new file mode 100644 index 000000000..73196986a --- /dev/null +++ b/changelog.d/1399-ground-snap-clock-step.md @@ -0,0 +1,3 @@ +- Aircraft on the ground keep getting ground-height samples after the + computer's clock steps back, instead of waiting for it to catch up + (quanticsoul4772, #1399). diff --git a/changelog.d/1403-remove-unused-modules.md b/changelog.d/1403-remove-unused-modules.md new file mode 100644 index 000000000..da95c7dcd --- /dev/null +++ b/changelog.d/1403-remove-unused-modules.md @@ -0,0 +1,5 @@ +- Remove four modules nothing in the app imports: `src/app/sourceSlot.js`, + `src/data/flowTiles.js`, `src/data/issPass.js` and `src/data/localGeojson.js`. + The local GeoJSON layer tests now build layers the way the catalog does, so + they keep covering `localGeojsonCore.js`. Package exports are unchanged + (samehkhamis, #1403). diff --git a/changelog.d/1410-scope-controls-hud-readability.md b/changelog.d/1410-scope-controls-hud-readability.md new file mode 100644 index 000000000..38cfdf48e --- /dev/null +++ b/changelog.d/1410-scope-controls-hud-readability.md @@ -0,0 +1,8 @@ +- Add a Scope checkbox and extend feathering to 200%, retaining scope settings + across HUD layouts, share links and scenes. First run opens with Scope ON at + 100% feather; explicit settings and historical links retain their values. Give + Tactical, Operator and Minimal text black backplates only when Scope is OFF, + including vertical metadata, without changing the original HUD text color, glow or + opacity. Scope ON retains its original HUD presentation (bilawalsidhu, #1410). + Keep title and tagline positions stable when toggling Scope, include the eye + in the title backing, and brighten the two secondary labels only with Scope OFF. diff --git a/changelog.d/948-vite-fs-deny.md b/changelog.d/948-vite-fs-deny.md new file mode 100644 index 000000000..642bafb1f --- /dev/null +++ b/changelog.d/948-vite-fs-deny.md @@ -0,0 +1,4 @@ +- Harden Vite's filesystem deny list for local-only artifacts: repo `dist/`, + `.gev-logs/`, and `.gev-cache/` are denied without blocking dependency + `dist/` directories, and checkout paths containing glob metacharacters are + escaped before building the root-anchored pattern (blowing-a-raspberry, #948). diff --git a/compose.yaml b/compose.yaml index 7826bf376..38cf438ca 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,6 +5,11 @@ services: # lands in the bind-mounted repo, so run as you, not root. UID/GID are # unset on Docker Desktop, where the 1000 fallback is ignored anyway. user: "${UID:-1000}:${GID:-1000}" + # Forward explicit shell/.env policy before the launcher discovers a gateway. + # Unset values stay unset so the default loopback-only install still works. + environment: + - GEV_KEY_SETUP_TRUSTED_PEERS + - GEV_ALLOWED_HOSTS ports: # Publish to loopback only. Docker's default ("4173:4173") would bind # every host interface, which quietly turns the LAN warning in the README @@ -17,5 +22,8 @@ services: # UI land in the .env on your host instead of vanishing with the # container. node_modules stays in the image: sharp and friends are # compiled for Linux and must not be shadowed by a host install. - - .:/app + # `:z` relabels the checkout for containers on SELinux hosts (Fedora, + # RHEL); without it the container cannot read /app at all and Node + # reports the launcher as "Cannot find module". Elsewhere it is ignored. + - .:/app:z - /app/node_modules diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index a23ba3e0f..9f8e0f629 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -79,17 +79,13 @@ moves it. Do not label all ingestion modules platform-independent by filename. | Retained entry | Owner and current reason | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `src/ui.js` | UI facade; direct callers retain standalone StyleManager defaults. Normal assembly uses `src/ui/composition.js` | | `src/data/manager.js` | Lifecycle/panel compatibility and existing tests; normal assembly constructs lifecycle and presentation separately | | `src/data/.js` | Legacy default instances; new assembly calls layer factories with supplied sources | -| `src/data/localGeojson.js` | Infrastructure compatibility factories; catalog imports services from `src/app/localGeojsonServices.js` directly | -| `src/app/sourceSlot.js` | Re-export for direct callers/tests; portable slot ownership is `src/sources/sourceSlot.js` | | `src/app/sources.js`, `src/services/application.js` | Existing default/override API; current application construction supplies its own instances | | `server/providers/local.js` | Existing Node composition barrel and provider helper re-exports | -The two exact standalone-import exceptions are `src/ui.js` to -`src/standalone/catalog.js` and `server/providers/local.js` to -`server/standalone/key-setup.js`. They do not authorize new compatibility +The one exact standalone-import exception is `server/providers/local.js` to +`server/standalone/key-setup.js`. It does not authorize new compatibility back-edges. Voice actions use plain `src/data/feedState.js`, not the manager facade. Settings filesystem hardening belongs in `server/standalone/key-setup-hardening.mjs`; its settings policy is unchanged. @@ -237,7 +233,7 @@ DOM elements and callbacks; importing it creates no browser state. `destroy()` removes owned listeners and cancels pending work without changing saved state or moving focus. Call it before removing or replacing the controls. -`src/ui.js` retains panel layout, persistence, share restoration and application +`src/ui/applicationShell.js` retains panel layout, persistence, share restoration and application reactions to state changes. Map Source selection and Location draft cleanup are provided through callbacks. The component imports no globe, data, application or server modules. Package checks and scoped formatting cover this entry. @@ -470,8 +466,8 @@ release listeners; pending action feedback is limited to its current owner. ## UI assembly and styles `ui/shell` assembles controls from supplied existing layer, navigation, terrain, -rendering, HUD and share operations. `src/standalone/ui.js` provides the running -application's instances; `src/ui.js` remains the compatibility entry. The shell +rendering, HUD and share operations. `src/standalone/controls.js` provides the running +application's instances. The shell imports no standalone bootstrap or concrete live layer implementation. Panel layout, position/drag, notices, recording and deferred UI work have separate owners with synchronous cleanup. Existing scene, share and HUD engines retain diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 72f6db4b8..5527e29b7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,31 @@ # God's Eye View Current State +Docker Compose mounts the checkout with the shared SELinux `:z` label so +containers can read it on enforcing SELinux hosts. The separate image-owned +`node_modules` volume and loopback-only port publishing remain unchanged. + +The HUD's LAT/LON degrees-minutes-seconds readout rounds the coordinate once, to +hundredths of a second, and then splits it. A value within 0.005" of the next +whole minute carries into the minutes and degrees, so `-122.35` reads +`122°21'00.00"W` rather than `122°20'60.00"W`. The field widths and hemisphere +letters are unchanged. + +Aircraft detection candidates reuse normalized display text until callsign or +registration changes, including in-place enrichment. Each candidate resolves its +fleet model once per collection. Positions, model ownership and tracked-aircraft +anchors remain live on every collection; contact admission is unchanged. + +Civil aircraft share one delayed render timestamp per fleet update. Extrapolated +fixes reuse their local coordinate frame until their coordinates change, including +in-place ground-height corrections. Update cadence, contact counts, interpolation, +turn rates and tracked-aircraft motion are unchanged. Cached frames are weakly +keyed by each position fix and do not retain retired history. + +Touch-first devices use three times the default Cesium zoom sensitivity, so +pinching traverses globe and street scales with fewer gestures. Cesium retains +its distance-aware zoom, collision handling and minimum/maximum distance limits. +Fine-pointer desktop mouse and trackpad sensitivity is unchanged. + Analyst region queries resolve unique Natural Earth names offline without geocoding, so Gold Coast and Ivory Coast name the West African natural regions. Duplicate names require a geocoded containment anchor; when several entries for @@ -9,6 +35,15 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled lookups preserve the previous analyst cohort and boundary source. Navigation search keeps a successful geocode, with no search or view-based region recovery. +Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh and restarts that layer's polling schedule from the return, so the next poll follows one interval later whether or not the catch-up update succeeds; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals. + +Terrain height requests accept at most 64 points, matching the browser service's +existing batch size and one bounded upstream chunk. Larger requests return HTTP +413 before disk-cache loading or upstream work; malformed coordinates remain 400. +The browser continues to split larger workloads into sequential 64-point requests. + +Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading. + Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, @@ -161,6 +196,23 @@ Regression coverage lives in `src/tools/area.test.mjs`, `src/tools/queries/environment.test.mjs` and `src/layers/perimeters/cards.test.mjs`. +## Scope display controls + +Display's native Scope checkbox defaults ON with feather at 100%, adjustable +from 0–200%. OFF disables the slider while retaining its value. HUD layout +changes preserve both settings; share links and scenes restore them. The +historical missing-`scf` fallback remains 35%; explicit 0% keeps the hard edge. + +With Scope OFF, Tactical, Operator and Minimal add text-sized black backplates +at 70% opacity behind telemetry, vertical metadata and scene chrome. Original +HUD text color, glow and opacity are preserved. Scope OFF brightens the tagline +and Active Style label with the shared secondary text color. The title plate +covers the eye and title without moving the title or tagline. Scope ON retains +the original HUD presentation; Cyber retains its existing skin. + +`node scripts/qa-scope-hud.mjs` checks native input, range, layout persistence, +restoration and both Scope presentations. `QA_SCREENSHOT_DIR` saves screenshots. + ## Cyber HUD — September 30, 2026 Display > HUD > Layout includes Cyber, also available through the HUD voice @@ -270,6 +322,11 @@ catalog construction owns each instance. Wind animates one forecast without advancing forecast time. Separate Weather observation layers provide radar and satellite history; none claims measured cloud volume. +Weather imagery releases its camera move listener on disable and registers it once on re-enable. Disabling also clears active imagery work. +A staged weather frame with loaded tiles and no pending requests rechecks +readiness on a single 150 ms wake timer instead of requesting every render. +The existing readiness conditions and two settled frames still govern promotion; +completion, cancellation, replacement and teardown clear the wake timer. Weather imagery drapes onto the globe basemap. On photorealistic 3D Tiles each observed product is instead a raised, translucent shell: one rectangle over the product bounds at a fixed height (global infrared 5.5 km, regional infrared @@ -395,6 +452,9 @@ Cyclones combine fixed NHC/CPHC status and summary GIS endpoints through a bound same-origin provider. The status endpoint lacks browser CORS; each installation fetches keyless public sources at runtime, with no centralized ingestion service. The five-minute snapshot covers Atlantic and eastern/central North Pacific only. +Classification labels follow NHC status codes: `PC` is a potential tropical +cyclone, `PTC` a post-tropical cyclone, and `STS`/`STD` a subtropical +storm/depression. Unknown codes remain visible unchanged. Current positions, advisory issue time and position time stay distinct. NHC may release an advisory before its nominal time (routinely up to about 30 minutes, rarely about an hour), so advisory issue and position times up to and including @@ -407,6 +467,8 @@ than relabeling older geometry. The cone represents forecast center uncertainty, not storm size or the full hazard area. Successful empty and unavailable states are distinct. One owned native Cesium data source preserves polygon holes and geographic seams and releases on disable; forecast animation is not implied. +NHC movement speed is converted from miles per hour to rounded knots by the +proxy; advisory wind intensity is already in knots and is passed through. Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source. Consult the linked official advisory for safety decisions. @@ -443,6 +505,14 @@ than a reading. Heading keeps its existing separate check for its own sentinel. Genuine readings are unchanged, including a stopped vessel's zero and the highest speed and course the fields can encode. +For an already tracked vessel, AISStream static messages update name, type, +destination and IMO without changing its position, motion, fix timestamps, +position freshness or trail. Non-empty static values replace earlier values; +omitted, empty or unavailable fields retain known metadata across Class B's +separate reports and in the cache used by the next position update. Position +reports still prefer their own metadata name. Invalid Class B parts are ignored; +absent fields prefer the latest live value over the static cache. + Native ` +
Feather - - - 11% + + 100%
diff --git a/src/ui/templates/scene-chrome.html b/src/ui/templates/scene-chrome.html index b3c9785f1..ba786c30b 100644 --- a/src/ui/templates/scene-chrome.html +++ b/src/ui/templates/scene-chrome.html @@ -11,13 +11,13 @@

GOD'S EYE VIEW

-

NO PLACE LEFT BEHIND

+

NO PLACE LEFT BEHIND

- ACTIVE STYLE - NORMAL + ACTIVE STYLE + NORMAL
diff --git a/src/ui/templates/welcome.html b/src/ui/templates/welcome.html index 852c232d5..2d9e0b4c5 100644 --- a/src/ui/templates/welcome.html +++ b/src/ui/templates/welcome.html @@ -44,7 +44,13 @@ Don't show this again - ESC to dismiss + +
diff --git a/src/ui/visualEffects.js b/src/ui/visualEffects.js index 8b5234ef2..9d10f0cef 100644 --- a/src/ui/visualEffects.js +++ b/src/ui/visualEffects.js @@ -159,18 +159,30 @@ export class VisualEffects { this.requestRender('bloom'); } + /** + * Sharpen runs only when it is on and its slider is above 0, as the style + * stages do: the pass costs the same at any amount (~1.5 ms GPU per frame + * on the Arc, 2026-10-24), and `0.1 + value * 2` still sharpened at 0. + */ + syncSharpenEnabled() { + if (this.stopped || !this.sharpenStage) return; + this.sharpenStage.enabled = + this.sharpenEnabled && this.sharpenIntensity > 0.001; + } + applySharpenIntensity(value) { if (this.stopped) return; this.sharpenIntensity = value; if (this.sharpenStage) this.sharpenStage.uniforms.amount = 0.1 + value * 2.0; + this.syncSharpenEnabled(); this.requestRender('sharpen'); } setSharpenEnabled(enabled) { if (this.stopped) return; this.sharpenEnabled = !!enabled; - if (this.sharpenStage) this.sharpenStage.enabled = this.sharpenEnabled; + this.syncSharpenEnabled(); this.requestRender('sharpen'); } diff --git a/src/ui/visualEffects.test.mjs b/src/ui/visualEffects.test.mjs index 68fa45962..9e9542c52 100644 --- a/src/ui/visualEffects.test.mjs +++ b/src/ui/visualEffects.test.mjs @@ -224,6 +224,24 @@ test('sharpen uses the existing uniform scale and keeps values while disabled', f.effects.destroy(); }); +test('sharpen at 0 runs no pass, and comes back with the slider', () => { + const f = fixture(); + f.effects.initPostProcess(0.49); + f.effects.setSharpenEnabled(true); + assert.equal(f.effects.sharpenStage.enabled, true); + f.effects.applySharpenIntensity(0); + assert.equal( + f.effects.sharpenStage.enabled, + false, + 'no residual sharpen at 0', + ); + f.effects.setSharpenEnabled(true); + assert.equal(f.effects.sharpenStage.enabled, false, 'on at 0 is still off'); + f.effects.applySharpenIntensity(0.2); + assert.equal(f.effects.sharpenStage.enabled, true); + f.effects.destroy(); +}); + test('stop revokes pending work immediately but retains stages until final destruction', () => { const f = fixture(); f.effects.initStyles(); diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index db074dda8..1d5f16a28 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -1,3 +1,5 @@ +import { clampScopeFeatherPct } from '../scopeMaskPolicy.js'; +import { syncScopeControls } from './displayControls.js'; import { STYLE_STATUS_LABELS } from './visualPresets.js'; import { syncSegmentedControl } from './segmentedControl.js'; import { t } from '../i18n/index.js'; @@ -10,7 +12,6 @@ import { MILITARY_DETECTION_PRESET, } from './effects.js'; import { createStyleParameters } from './visualInput.js'; -import * as Cesium from 'cesium'; import { BLOOM_SCALE_VERSION, clampBloomIntensity, @@ -137,11 +138,10 @@ export class VisualSettings { } if (typeof scopeEnabled === 'boolean') { setScopeMaskEnabled(scopeEnabled); - this._scopeBtn?.classList.toggle('active', scopeEnabled); - this._scopeBtn?.setAttribute('aria-pressed', String(scopeEnabled)); + syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, scopeEnabled); } if (typeof scopeFeatherPct === 'number' && this._scopeFeatherSlider) { - const pct = Math.max(0, Math.min(100, Math.round(scopeFeatherPct))); + const pct = clampScopeFeatherPct(scopeFeatherPct); this._scopeFeatherSlider.value = String(pct); if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; @@ -1303,11 +1303,14 @@ export class VisualSettings { const scopeState = state.scope || {}; if (typeof scopeState.enabled === 'boolean') { setScopeMaskEnabled(scopeState.enabled); - this._scopeBtn?.classList.toggle('active', scopeState.enabled); - this._scopeBtn?.setAttribute('aria-pressed', String(scopeState.enabled)); + syncScopeControls( + this._scopeBtn, + this._scopeFeatherSlider, + scopeState.enabled, + ); } if (typeof scopeState.featherPct === 'number' && this._scopeFeatherSlider) { - const pct = Math.max(0, Math.min(100, Math.round(scopeState.featherPct))); + const pct = clampScopeFeatherPct(scopeState.featherPct); this._scopeFeatherSlider.value = String(pct); if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; @@ -1538,6 +1541,11 @@ export class VisualSettings { preserveCyberRestore = false, } = {}, ) { + if ( + typeof styleName !== 'string' || + (styleName !== 'normal' && !Object.hasOwn(STYLES, styleName)) + ) + return; const { setDetectionStyle } = this.services; if (!restore) this.shareLinkManager?.claimRestoreLane?.('visual'); if (this.hud.getVariant() === 'cyber') { diff --git a/src/view/index.js b/src/view/index.js index 8ab56d5a4..fc5385aaa 100644 --- a/src/view/index.js +++ b/src/view/index.js @@ -370,7 +370,9 @@ export function viewFromParams(params) { pitch_deg: number('pitch'), }, layers: state?.enabledLayerIds ?? [], - style: STYLES_BY_URL_NAME[params.get('style')] ?? null, + style: Object.hasOwn(STYLES_BY_URL_NAME, params.get('style')) + ? STYLES_BY_URL_NAME[params.get('style')] + : null, map: VIEW_MAPS.includes(map) ? map : null, follow, annotations: annotationsFromParams(params), diff --git a/src/view/index.test.mjs b/src/view/index.test.mjs index 818feb1ba..34299c498 100644 --- a/src/view/index.test.mjs +++ b/src/view/index.test.mjs @@ -126,3 +126,20 @@ test('annotations travel in the link, keeping only what the app draws', async () // Too long for a link: the view keeps them, the link leaves them out. assert.equal(viewToParams(many).has('an'), false); }); + +test('inherited and unknown style keys do not corrupt a restored view', () => { + for (const style of [ + 'constructor', + '__proto__', + 'toString', + 'hasOwnProperty', + 'unknown', + ]) { + const view = viewFromParams( + new URLSearchParams({ lat: '10', lon: '20', style, map: 'osm' }), + ); + assert.equal(view.style, null); + assert.equal(view.map, 'osm'); + assert.equal(view.camera.lat, 10); + } +}); diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index d0810bf71..3f464cad1 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -729,7 +729,7 @@ const schemas = [ properties: { motion: { type: 'string', - enum: ['orbit', 'pan', 'tilt', 'rotate', 'stop'], + enum: ['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe', 'stop'], }, direction: { type: 'string', diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index b295e6f1b..617d0077f 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -1,3 +1,4 @@ +import { realtimeInstructions } from '../../server/providers/openai/instructions.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; @@ -38,8 +39,11 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // requested radius and center authority in descriptions only. // Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts. // Air quality (ECCC AQHI) extends the manifest. - // S2 GhostMaps extends the manifest. - '64a7ae9719cbf53f2c5b57739c1e84e8b60dc4ede040decd48a9c06d1ccbcbca', + // Globe rotation adds one motion; visual keyhole fit and explicit + // reset guidance/defaults update only move_camera and mode descriptions. + // Restore base descriptions; append only globe-rotation defaults and fit guidance. + // S2 GhostMaps adds its layer id to the generated layer enums. + '1f95d78074559217872ccbdfa57d4363264ea88c1a22b259e6ebba3c8189b806', ); }); @@ -107,6 +111,17 @@ test('all legacy action arguments are byte-identical after removing the delibera delete property('show_data_layers_menu').layerId.enum; delete property('get_entity_context').layerId.enum; delete property('analyst_query').layers.items.enum; + // Earth-centered rotation is additive; local camera yaw keeps its contract. + const motion = property('move_camera').motion; + assert.deepEqual(motion.enum, [ + 'orbit', + 'pan', + 'tilt', + 'rotate', + 'rotate_globe', + 'stop', + ]); + motion.enum = motion.enum.filter((value) => value !== 'rotate_globe'); const panels = property('set_panel_open').panelId; panels.enum = panels.enum.filter( (id) => !['weather-panel', 'recent-imagery-panel'].includes(id), @@ -146,6 +161,24 @@ test('all legacy action arguments are byte-identical after removing the delibera ); }); +test('globe rotation is an additive camera motion with the existing control arguments', () => { + const schema = GEV_ACTION_SCHEMAS.find( + (tool) => tool.name === 'move_camera', + ).parameters; + for (const motion of ['rotate_globe', 'rotate', 'orbit', 'pan', 'tilt']) { + assert.deepEqual( + validateArgs(schema, { + motion, + direction: 'right', + speed: 'slow', + mode: 'continuous', + }), + [], + ); + } + assert.ok(validateArgs(schema, { motion: 'spin_camera' }).length > 0); +}); + test('track_entity accepts a referent alone but rejects an empty target', () => { const schema = GEV_ACTION_SCHEMAS.find( (tool) => tool.name === 'track_entity', @@ -184,3 +217,15 @@ test('the benchmark still rejects an empty track_entity although the model-facin [], ); }); + +test('globe reset guidance requires a separate explicit command after refusal', () => { + const paragraph = realtimeInstructions() + .split('\n') + .find((line) => line.includes('globe-view-required')); + assert.ok(paragraph); + assert.match(paragraph, /calls only move_camera motion rotate_globe/); + assert.match(paragraph, /explicit user Reset\/Show Globe View/); + assert.match(paragraph, /never from your own plan/); + assert.match(paragraph, /bare yes\/ok/); + assert.match(paragraph, /Do not ask a question/); +}); diff --git a/src/voice/cloudVoiceAuth.js b/src/voice/cloudVoiceAuth.js index 77feae8aa..5cf9ca882 100644 --- a/src/voice/cloudVoiceAuth.js +++ b/src/voice/cloudVoiceAuth.js @@ -1,5 +1,5 @@ -export const CLOUD_VOICE_AUTH_MODES = Object.freeze(['api-key', 'oauth']); -export const DEFAULT_CLOUD_VOICE_AUTH_MODE = 'api-key'; +const CLOUD_VOICE_AUTH_MODES = Object.freeze(['api-key', 'oauth']); +const DEFAULT_CLOUD_VOICE_AUTH_MODE = 'api-key'; export const CLOUD_VOICE_AUTH_STORAGE_KEY = 'godsEyeView.voice.cloudAuth'; function storageRef(storage) { @@ -11,7 +11,7 @@ function storageRef(storage) { } } -export function normalizeCloudVoiceAuthMode(mode) { +function normalizeCloudVoiceAuthMode(mode) { const normalized = String(mode || '') .trim() .toLowerCase(); diff --git a/src/voice/deixis.js b/src/voice/deixis.js index d68fcd61a..25d7939eb 100644 --- a/src/voice/deixis.js +++ b/src/voice/deixis.js @@ -18,7 +18,7 @@ import { finiteOrNull } from './pointerContext.js'; -export const POINTER_SENTINEL = 'pointer'; +const POINTER_SENTINEL = 'pointer'; const TRACKABLE_LAYERS = new Set([ 'flights', diff --git a/src/voice/geminiSession.js b/src/voice/geminiSession.js index 04a573f76..041f1f643 100644 --- a/src/voice/geminiSession.js +++ b/src/voice/geminiSession.js @@ -21,6 +21,7 @@ import { shouldSendViewportImage, shouldStopVoiceAfterRadioTool, responseInstructionForToolResult, + isGlobeViewRefusal, } from './realtimeProtocol.js'; const SOCKET_PATH = @@ -182,6 +183,8 @@ export class GeminiSessionController { this.model = null; this.epoch = 0; this.turn = 0; + this.globeToolsBlocked = false; + this.globeResetFailed = false; this.inputGeneration = 0; this.turnAbort = new AbortController(); this.disposed = false; @@ -659,6 +662,8 @@ export class GeminiSessionController { return; if (!pushToTalk || activityCaptureId === metadata.captureId) return; this.captureOwner.admitted = true; + this.globeToolsBlocked = false; + this.globeResetFailed = false; if ( !this.interrupt('push-to-talk', { keepPlayback: !this.pttPlaybackSuppressed, @@ -1294,6 +1299,8 @@ export class GeminiSessionController { this.replyPending = false; if (!this.interrupt()) return; } else if (!this.retiredReplyPending && !this.interrupt()) return; + this.globeToolsBlocked = false; + this.globeResetFailed = false; // Local activity ownership is only for expiry liveness, not tools. // Assign after retiring observed output so its boundary cannot clear // a newer detected activity that has not received a reply yet. @@ -1573,6 +1580,7 @@ export class GeminiSessionController { .then(async () => { if (!this.owns(epoch) || turn !== this.turn) return; if (this.cancelledCalls.has(call.id)) { + if (call.name === 'zoom_to_globe') this.globeResetFailed = true; if (this.tools.get(call.id) === action) this.tools.delete(call.id); this.pendingTools--; if (!this.pendingTools) { @@ -1618,8 +1626,28 @@ export class GeminiSessionController { (authority !== 'playback' || handoffEpoch === this.radio.radioHandoffEpoch); let result; + let skippedGlobeRotation = false; + let blockedGlobeTool = false; try { if (!actionCurrent()) throw cancelled(); + // Live has no response-scoped tool_choice. Recheck here so queued siblings + // cannot reset after a rotation refusal. + if (this.globeToolsBlocked) { + blockedGlobeTool = true; + throw new Error( + 'Wait for fresh user input before calling another tool.', + ); + } + if ( + this.globeResetFailed && + call.name === 'move_camera' && + String(args.motion || '').toLowerCase() === 'rotate_globe' + ) { + skippedGlobeRotation = true; + throw new Error( + 'Not run: the Globe View reset/rotation sequence failed or was cancelled.', + ); + } if (stronger) reservation = this.radio.reserveRadioToolHandoff({ abortScope }); handoffEpoch = this.radio.radioHandoffEpoch; @@ -1689,7 +1717,12 @@ export class GeminiSessionController { } catch (error) { result = { ok: false, - tool: call.name, + ...(skippedGlobeRotation + ? { action: call.name, reason: 'globe-reset-failed', skipped: true } + : { + tool: call.name, + ...(blockedGlobeTool ? { skipped: true } : {}), + }), ...(radioVisibility ? { layerId: 'radio' } : {}), error: error?.name === 'AbortError' @@ -1716,6 +1749,11 @@ export class GeminiSessionController { this.noteRadioStrongerCommit(action, abortScope, turn); } if (!this.owns(epoch) || turn !== this.turn) return; + if ( + call.name === 'zoom_to_globe' && + (result?.ok !== true || result.cancelled) + ) + this.globeResetFailed = true; const playbackObsolete = authority === 'playback' && handoffEpoch !== this.radio.radioHandoffEpoch; if (radioControl || radioVisibility) { @@ -1744,6 +1782,8 @@ export class GeminiSessionController { this.stopAfterReply = true; // Native call cancellation suppresses its output, without canceling siblings. if (!this.cancelledCalls.has(call.id)) { + if (isGlobeViewRefusal(result) && !skippedGlobeRotation) + this.globeToolsBlocked = true; this.postToolOutput = false; this.modelComplete = false; this.replyPending = true; @@ -1751,7 +1791,9 @@ export class GeminiSessionController { const mapEvents = mapSnapshot.map((text) => JSON.parse(text)); const response = { ...(result ?? { ok: false, error: 'Command returned no result' }), - responseGuidance: responseInstructionForToolResult(result), + responseGuidance: blockedGlobeTool + ? 'That action did not run. Wait for fresh user input; do not claim any change or call another tool.' + : responseInstructionForToolResult(result), }; this.send( { @@ -1902,6 +1944,8 @@ export class GeminiSessionController { { onSent: () => { if (!owned() || this.typedIntent !== typedIntent) return; + this.globeToolsBlocked = false; + this.globeResetFailed = false; this.replyPending = true; this.bindTextIntent(typedIntent); if (!owned() || this.typedIntent !== typedIntent) return; @@ -2455,6 +2499,8 @@ export class GeminiSessionController { this.cancelledCalls.clear(); this.pendingMapEvents = []; this.userTurnPending = false; + this.globeToolsBlocked = false; + this.globeResetFailed = false; this.replyPending = false; this.unrequestedReply = false; this.automaticTurnOpen = false; diff --git a/src/voice/geminiSession.test.mjs b/src/voice/geminiSession.test.mjs index b2f380b37..9ce8d351a 100644 --- a/src/voice/geminiSession.test.mjs +++ b/src/voice/geminiSession.test.mjs @@ -3398,3 +3398,215 @@ test('a narration callback that submits a successor keeps the old packet tools f assert.deepEqual(h.calls, []); h.c.stop(); }); + +for (const reply of ['typed', 'push-to-talk', 'open-mic']) { + test(`globe refusal blocks queued and later tools until fresh ${reply} input`, async () => { + const names = []; + const h = harness({ + runAction: async (name) => { + names.push(name); + return name === 'move_camera' + ? { ok: false, action: 'move_camera', reason: 'globe-view-required' } + : { ok: true, action: name }; + }, + }); + try { + await h.c.start({ pushToTalk: reply === 'push-to-talk' }); + establishToolInput(h, 'rotate the globe'); + const ws = h.sockets[0]; + ws.message({ + toolCall: { + functionCalls: [ + { + id: 'rotate', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + { id: 'queued-reset', name: 'zoom_to_globe', args: {} }, + ], + }, + }); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera']); + const blocked = ws.sent + .flatMap((entry) => entry.toolResponse?.functionResponses || []) + .find((call) => call.id === 'queued-reset'); + assert.equal(blocked.response.ok, false); + assert.match(blocked.response.error, /fresh user input/i); + assert.equal(blocked.response.skipped, true); + assert.match(blocked.response.responseGuidance, /did not run/i); + assert.doesNotMatch( + blocked.response.responseGuidance, + /confirm.*completed/i, + ); + requestTool(ws, 'repeated-rotation', 'move_camera', { + motion: 'rotate_globe', + }); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera']); + const repeated = toolResponses(ws).find( + (call) => call.id === 'repeated-rotation', + ).response; + assert.equal(repeated.ok, false); + assert.equal(repeated.skipped, true); + assert.match(repeated.responseGuidance, /did not run/i); + assert.doesNotMatch( + repeated.responseGuidance, + /confirm.*completed/i, + ); + + if (reply === 'typed') { + const stimuli = [ + () => {}, + () => ws.message({ serverContent: { interrupted: true } }), + () => + ws.message({ + serverContent: { + inputTranscription: { text: 'reset to globe view' }, + }, + }), + () => { + h.c.input.setMicrophoneEnabled(false); + h.c.input.setMicrophoneEnabled(true); + }, + ]; + for (const [i, stimulus] of stimuli.entries()) { + stimulus(); + requestTool(ws, `unrequested-reset-${i}`, 'zoom_to_globe', {}); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera']); + } + } + // Finish the refusal reply before admitting the actual next user input. + ws.message({ serverContent: { turnComplete: true } }); + if (reply === 'typed') + assert.equal(h.c.sendTextCommand('reset to globe view'), true); + else if (reply === 'push-to-talk') { + h.c.input.pushToTalkKeyHeld = true; + h.c.input.spaceKeyHeld = true; + h.c.input.setMicrophoneEnabled(true); + h.c.input.setMicrophoneEnabled(false); + } else ws.message({ voiceActivity: { type: 'ACTIVITY_START' } }); + requestTool(ws, 'accepted-reset', 'zoom_to_globe', {}); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera', 'zoom_to_globe']); + } finally { + h.c.stop(); + } + }); +} + +test('a cancelled globe reset skips queued rotation, preserves Stop and recovers on fresh input', async () => { + const calls = []; + let resets = 0; + const h = harness({ + runAction: async (name, args) => { + calls.push(name === 'move_camera' ? args.motion : name); + return name === 'zoom_to_globe' && resets++ === 0 + ? { ok: false, action: name, cancelled: true } + : { ok: true, action: name }; + }, + }); + try { + await h.c.start(); + establishToolInput(h, 'reset to globe view and rotate'); + const ws = h.sockets[0]; + ws.message({ + toolCall: { + functionCalls: [ + { id: 'reset-failed', name: 'zoom_to_globe', args: {} }, + { + id: 'rotate-skipped', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + { id: 'stop-allowed', name: 'move_camera', args: { motion: 'stop' } }, + ], + }, + }); + await h.c.toolQueue; + assert.deepEqual(calls, ['zoom_to_globe', 'stop']); + const skipped = toolResponses(ws).find( + (call) => call.id === 'rotate-skipped', + ).response; + assert.equal(skipped.ok, false); + assert.equal(skipped.reason, 'globe-reset-failed'); + assert.equal(skipped.skipped, true); + assert.equal( + toolResponses(ws).find((call) => call.id === 'stop-allowed').response.ok, + true, + ); + + ws.message({ serverContent: { turnComplete: true } }); + establishToolInput(h, 'reset to globe view and rotate'); + ws.message({ + toolCall: { + functionCalls: [ + { id: 'reset-success', name: 'zoom_to_globe', args: {} }, + { + id: 'rotate-success', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + ], + }, + }); + await h.c.toolQueue; + assert.deepEqual(calls, [ + 'zoom_to_globe', + 'stop', + 'zoom_to_globe', + 'rotate_globe', + ]); + } finally { + h.c.stop(); + } +}); + +test('cancelling a queued globe reset also skips its dependent rotation', async () => { + const blocker = deferred(); + const calls = []; + const h = harness({ + runAction: async (name, args) => { + calls.push(name === 'move_camera' ? args.motion : name); + return args.motion === 'pan' + ? blocker.promise + : { ok: true, action: name }; + }, + }); + try { + await h.c.start(); + establishToolInput(h, 'pan, reset to globe view and rotate'); + const ws = h.sockets[0]; + ws.message({ + toolCall: { + functionCalls: [ + { + id: 'blocker', + name: 'move_camera', + args: { motion: 'pan', direction: 'left' }, + }, + { id: 'reset-cancelled', name: 'zoom_to_globe', args: {} }, + { + id: 'rotate-after-cancel', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + ], + }, + }); + await tick(); + ws.message({ toolCallCancellation: { ids: ['reset-cancelled'] } }); + blocker.resolve({ ok: true, action: 'move_camera' }); + await h.c.toolQueue; + assert.deepEqual(calls, ['pan']); + const skipped = toolResponses(ws).find( + (call) => call.id === 'rotate-after-cancel', + ).response; + assert.equal(skipped.reason, 'globe-reset-failed'); + assert.equal(skipped.skipped, true); + } finally { + blocker.resolve({ ok: true }); + h.c.stop(); + } +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index d8b63565f..f7613aed1 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1,3 +1,4 @@ +import { interruptGlobeRotation } from '../cameraVerbs.js'; import { layerSnapshot, feedProvenanceEnvelope, @@ -262,7 +263,9 @@ export function createGevActionRunner({ }), )); installViewTargetPrewarm(viewer); - initCameraVerbs(viewer, getViewTargetCartesian); + initCameraVerbs(viewer, getViewTargetCartesian, { + isCockpitActive: () => Boolean(styleManager?.getCockpitState?.()?.active), + }); // Nested calls (select_nearest_aircraft composing layer + flight) use the // raw runner; only the outer call gets a voice envelope. const runGevAction = async function runGevAction( @@ -298,6 +301,7 @@ export function createGevActionRunner({ // Zoom during an active orbit adjusts the orbit RADIUS (spiral in/out) — // a straight camera move would be snapped back by the per-frame lookAt. if (name === 'adjust_camera_zoom') { + interruptGlobeRotation('voice-zoom-intent'); const zoomOut = String(args.direction || '').toLowerCase() === 'out'; const amt = String(args.amount || 'medium').toLowerCase(); const factor = { little: 1.25, medium: 1.6, lot: 2.4 }[amt] || 1.6; @@ -4369,46 +4373,6 @@ function dominantValue(values) { }; } -function summarizeEntity(viewer, entity, { includeProperties = false } = {}) { - const now = Cesium.JulianDate.now(); - if (entity.__gevContextId) { - const store = window.__gevContextStore; - const record = store?.entities?.get(entity.__gevContextId); - if (record) return summarizeContextRecord(record, { includeProperties }); - } - const props = propertyObject(entity); - const layerId = entity.__localLayerId || props.layerId || null; - const tags = props.tags || {}; - const label = cleanText( - props.name || - tags.name || - tags['name:en'] || - tags.official_name || - tags.operator || - props.operator || - entity.name || - layerTitle(layerId), - ); - const position = - entity.__localBaseCartesian || - entity.position?.getValue?.(now) || - polygonCenter(entity, now); - const carto = position ? Cesium.Cartographic.fromCartesian(position) : null; - return { - id: String(entity.id || ''), - name: label || layerTitle(layerId), - layerId, - layerName: layerTitle(layerId), - latitude: carto - ? Number(Cesium.Math.toDegrees(carto.latitude).toFixed(6)) - : null, - longitude: carto - ? Number(Cesium.Math.toDegrees(carto.longitude).toFixed(6)) - : null, - properties: includeProperties ? compactProperties(props) : undefined, - }; -} - function summarizeContextRecord(record, { includeProperties = false } = {}) { return { id: String(record.id || ''), @@ -4427,31 +4391,6 @@ function summarizeContextRecord(record, { includeProperties = false } = {}) { }; } -function polygonCenter(entity, now) { - const hierarchy = entity.polygon?.hierarchy?.getValue?.(now); - const positions = hierarchy?.positions; - if (!positions?.length) return null; - return Cesium.BoundingSphere.fromPoints(positions).center; -} - -function propertyObject(entity) { - const raw = entity?.properties?.getValue?.(Cesium.JulianDate.now()) || {}; - return unwrapProperties(raw); -} - -function unwrapProperties(value) { - if (!value || typeof value !== 'object') return value; - if (Array.isArray(value)) return value.map(unwrapProperties); - const out = {}; - for (const [key, entry] of Object.entries(value)) { - out[key] = - entry && typeof entry.getValue === 'function' - ? unwrapProperties(entry.getValue(Cesium.JulianDate.now())) - : unwrapProperties(entry); - } - return out; -} - function compactProperties(props) { const preferredKeys = [ 'name', diff --git a/src/voice/globeResetConfirmation.test.mjs b/src/voice/globeResetConfirmation.test.mjs new file mode 100644 index 000000000..be2778e6d --- /dev/null +++ b/src/voice/globeResetConfirmation.test.mjs @@ -0,0 +1,1131 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { GevRealtimeController } from './realtimeController.js'; +import { + CALL_DEDUPE_MS, + responseInstructionForToolResult, +} from './realtimeProtocol.js'; + +const refusal = { + ok: false, + action: 'move_camera', + reason: 'globe-view-required', +}; + +function harness(result) { + const sent = []; + const calls = []; + const controller = new GevRealtimeController({ + ui: {}, + runner: async (name, args, context) => { + calls.push(name); + return typeof result === 'function' + ? result(name, args, context) + : result; + }, + }); + controller.dc = { readyState: 'open' }; + controller.sendRealtimeEvent = (event) => { + sent.push(event); + return true; + }; + controller.debugLog = () => {}; + controller.setStatus = () => {}; + controller.setVoiceSpeaker = () => {}; + controller.recordUsage = () => {}; + controller.captureViewportScreenshot = async () => null; + controller.cancelRadioHandoff = () => {}; + return { controller, sent, calls }; +} + +async function invoke( + controller, + { + responseId = 'rotation-response', + callId = 'rotation-call', + name = 'move_camera', + args = { motion: 'rotate_globe' }, + } = {}, +) { + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'response.function_call_arguments.done', + response_id: responseId, + item_id: `${callId}-item`, + call_id: callId, + name, + arguments: JSON.stringify(args), + }), + }); +} +/** A real user input boundary: the provider detected new speech. */ +async function speak(controller) { + await controller.handleRealtimeEvent({ + data: JSON.stringify({ type: 'input_audio_buffer.speech_started' }), + }); +} +function begin(controller, id = 'rotation-response') { + controller.updateResponseState({ + type: 'response.created', + response: { id }, + }); +} +function complete(controller, id = 'rotation-response') { + controller.updateResponseState({ + type: 'response.done', + response: { id, status: 'completed' }, + }); +} + +test('close-up rotation refusal instructs an explicit reset in a response with tools disabled', async () => { + const { controller, sent } = harness(refusal); + await invoke(controller); + complete(controller); + const response = sent.find( + (event) => event.type === 'response.create', + )?.response; + assert.equal(response?.tool_choice, 'none'); + assert.equal( + response.instructions, + responseInstructionForToolResult(refusal), + ); + assert.match(response.instructions, /do not ask a question/i); + assert.doesNotMatch(response.instructions, /confirm the completed/); +}); + +test('normal camera success retains the existing unrestricted followup', async () => { + const { controller, sent } = harness({ + ok: true, + action: 'move_camera', + motion: 'rotate', + }); + await invoke(controller); + complete(controller); + const response = sent.find( + (event) => event.type === 'response.create', + )?.response; + assert.equal(typeof response?.instructions, 'string'); + assert.equal(Object.hasOwn(response, 'tool_choice'), false); +}); + +test('a newer typed request drops the old refusal followup and its tool restriction', async () => { + const { controller, sent } = harness(refusal); + await invoke(controller); + controller.sendTextCommand('turn the camera right'); + complete(controller); + const responses = sent.filter((event) => event.type === 'response.create'); + assert.equal(responses.length, 1); + assert.equal(responses[0].response?.tool_choice, undefined); + assert.equal(controller.pendingResponseInstructions, null); +}); + +test('refusal instructions do not interpolate untrusted failure text or alter other tool results', () => { + const instruction = responseInstructionForToolResult({ + ...refusal, + error: 'UNTRUSTED_ERROR', + say: 'UNTRUSTED_SAY', + }); + assert.doesNotMatch(instruction, /UNTRUSTED/); + const unavailable = responseInstructionForToolResult({ + ...refusal, + reason: 'globe-reset-unavailable', + }); + assert.doesNotMatch(unavailable, /Reset to Globe View and rotate the globe/); + assert.match(unavailable, /do not offer or perform a reset/i); + const completedReset = responseInstructionForToolResult({ + action: 'zoom_to_globe', + ok: true, + }); + assert.equal( + completedReset, + responseInstructionForToolResult({ ok: true, action: 'move_camera' }), + ); + for (const outcome of [ + { action: 'zoom_to_globe', ok: false }, + { action: 'zoom_to_globe', ok: true, cancelled: true }, + { tool: 'zoom_to_globe', ok: false }, + ]) { + const resetFailure = responseInstructionForToolResult({ + ...outcome, + error: 'UNTRUSTED_ERROR', + say: 'UNTRUSTED_SAY', + }); + assert.notEqual(resetFailure, completedReset); + assert.match(resetFailure, /did not finish/i); + assert.doesNotMatch(resetFailure, /UNTRUSTED/); + } + assert.equal( + responseInstructionForToolResult({ + ok: true, + action: 'get_current_view_state', + }), + responseInstructionForToolResult({ ok: true, action: 'move_camera' }), + ); +}); + +test('globe refusal blocks same-response tools and preserves the reset instruction, but not the next response', async () => { + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? refusal : { ok: true, action: name }, + ); + begin(controller); + await invoke(controller); + await invoke(controller, { + callId: 'reset-call', + name: 'zoom_to_globe', + args: {}, + }); + // The second delivery of the original call must not produce another output. + await invoke(controller); + complete(controller); + assert.deepEqual(calls, ['move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['rotation-call', 'reset-call'], + ); + assert.equal(JSON.parse(outputs[1].item.output).ok, false); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.equal( + replies[0].response.instructions, + responseInstructionForToolResult(refusal), + ); + // A fresh explicit reset request is a separate user input. + await speak(controller); + begin(controller, 'fresh-response'); + await invoke(controller, { + responseId: 'fresh-response', + callId: 'fresh-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller, 'fresh-response'); + assert.deepEqual(calls, ['move_camera', 'zoom_to_globe']); + assert.equal( + sent.filter((event) => event.type === 'response.create').at(-1).response + .tool_choice, + undefined, + ); +}); + +test('a reset arriving during pending globe admission cannot bypass the refusal', async () => { + let finishRotation; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? pending : { ok: true, action: name }, + ); + begin(controller); + const rotation = invoke(controller, { args: { motion: 'ROTATE_GLOBE' } }); + const reset = invoke(controller, { + callId: 'overlap-reset', + name: 'zoom_to_globe', + args: {}, + }); + finishRotation(refusal); + await Promise.all([rotation, reset]); + complete(controller); + assert.deepEqual(calls, ['move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.equal(outputs.length, 2); + assert.equal( + JSON.parse( + outputs.find((event) => event.item.call_id === 'overlap-reset').item + .output, + ).ok, + false, + ); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.equal( + replies[0].response.instructions, + responseInstructionForToolResult(refusal), + ); +}); + +test('successful pending globe admission releases a queued ordinary tool', async () => { + let finishRotation; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? pending : { ok: true, action: name }, + ); + begin(controller); + const rotation = invoke(controller); + const reset = invoke(controller, { + callId: 'allowed-reset', + name: 'zoom_to_globe', + args: {}, + }); + assert.deepEqual(calls, ['move_camera']); + finishRotation({ ok: true, action: 'move_camera', motion: 'rotate_globe' }); + await Promise.all([rotation, reset]); + complete(controller); + assert.deepEqual(calls, ['move_camera', 'zoom_to_globe']); + assert.equal( + sent.filter((event) => event.type === 'response.create').at(-1).response + .tool_choice, + undefined, + ); +}); + +test('rotation waits for an earlier reset before evaluating the globe', async () => { + let finishReset; + let atGlobe = false; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness(async (name) => { + if (name === 'zoom_to_globe') { + await pending; + atGlobe = true; + return { ok: true, action: name }; + } + return atGlobe + ? { ok: true, action: name, motion: 'rotate_globe' } + : refusal; + }); + begin(controller); + const reset = invoke(controller, { + callId: 'first-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller); + assert.deepEqual(calls, ['zoom_to_globe']); + finishReset(); + await Promise.all([reset, rotation]); + complete(controller); + assert.deepEqual(calls, ['zoom_to_globe', 'move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['first-reset', 'rotation-call'], + ); + assert.equal(JSON.parse(outputs[1].item.output).ok, true); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, undefined); +}); + +for (const outcome of [ + 'cancelled', + 'thrown', + 'retry-refused', + 'settled-failure', +]) { + test(`a ${outcome} reset sequence cannot suggest another reset`, async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness(async (name) => { + if (name !== 'zoom_to_globe') return refusal; + await pending; + if (outcome === 'thrown') throw new Error('reset failed'); + return { + ok: outcome === 'retry-refused', + action: name, + cancelled: outcome === 'cancelled', + }; + }); + begin(controller); + const reset = invoke(controller, { + callId: 'failed-reset', + name: 'zoom_to_globe', + args: {}, + }); + if (outcome === 'settled-failure') { + finishReset(); + await reset; + } + const rotation = invoke(controller); + finishReset(); + await Promise.all([reset, rotation]); + complete(controller); + assert.deepEqual( + calls, + outcome === 'retry-refused' + ? ['zoom_to_globe', 'move_camera'] + : ['zoom_to_globe'], + ); + const outputs = sent.filter( + (event) => event.item?.call_id === 'rotation-call', + ); + assert.equal(outputs.length, 1); + const result = JSON.parse(outputs[0].item.output); + assert.equal(result.ok, false); + assert.equal(result.reason, 'globe-reset-failed'); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.doesNotMatch( + replies[0].response.instructions, + /Reset to Globe View and rotate the globe/, + ); + }); +} + +test( + 'Stop releases a rotation waiting for reset and isolates a reused response id', + { timeout: 1000 }, + async () => { + let finishOld, finishNew; + const oldReset = new Promise((resolve) => { + finishOld = resolve; + }); + const newReset = new Promise((resolve) => { + finishNew = resolve; + }); + let resets = 0; + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' + ? ++resets === 1 + ? oldReset + : newReset + : { ok: true, action: name }, + ); + const oldChannel = controller.dc; + oldChannel.close = () => { + oldChannel.readyState = 'closed'; + }; + begin(controller); + const old = invoke(controller, { + callId: 'old-reset', + name: 'zoom_to_globe', + args: {}, + }); + const waiter = invoke(controller); + controller.stop(); + await waiter; + assert.deepEqual(calls, ['zoom_to_globe']); + assert.equal(sent.length, 0); + controller.dc = { readyState: 'open' }; + begin(controller); + const replacement = invoke(controller, { + callId: 'new-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller, { callId: 'new-rotation' }); + finishOld({ ok: false, action: 'zoom_to_globe' }); + await old; + assert.deepEqual(calls, ['zoom_to_globe', 'zoom_to_globe']); + assert.equal(sent.length, 0); + finishNew({ ok: true, action: 'zoom_to_globe' }); + await Promise.all([replacement, rotation]); + complete(controller); + assert.deepEqual(calls, ['zoom_to_globe', 'zoom_to_globe', 'move_camera']); + assert.deepEqual( + sent + .filter((event) => event.item?.type === 'function_call_output') + .map((event) => event.item.call_id), + ['new-reset', 'new-rotation'], + ); + }, +); + +test( + 'a typed turn releases rotation without waiting for its reset runner', + { timeout: 1000 }, + async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness(() => pending); + delete controller.cancelRadioHandoff; + begin(controller); + const reset = invoke(controller, { + callId: 'stale-first-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller); + // The tool response can finish while its reset and rotation still wait. + complete(controller); + controller.sendTextCommand('pan left'); + await rotation; + assert.deepEqual(calls, ['zoom_to_globe']); + const output = sent.find( + (event) => event.item?.call_id === 'rotation-call', + ); + assert.equal(JSON.parse(output.item.output).superseded, true); + assert.equal(controller.pendingResponseInstructions, null); + begin(controller, 'typed-response'); + complete(controller, 'typed-response'); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response?.tool_choice, undefined); + finishReset({ ok: false, action: 'zoom_to_globe' }); + await reset; + // This protects rotation's cancellation only. Ordinary reset-result + // follow-ups after supersession remain the inherited sibling-tool limit. + }, +); + +test('an aborted reset still blocks a late same-response rotation', async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? pending : { ok: true, action: name }, + ); + delete controller.cancelRadioHandoff; + begin(controller); + const reset = invoke(controller, { + callId: 'aborted-reset', + name: 'zoom_to_globe', + args: {}, + }); + try { + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'error', + error: { code: 'conversation_already_has_active_response' }, + }), + }); + await invoke(controller); + assert.deepEqual(calls, ['zoom_to_globe']); + const outputs = sent.filter( + (event) => event.item?.call_id === 'rotation-call', + ); + assert.equal(outputs.length, 1); + const result = JSON.parse(outputs[0].item.output); + assert.equal(result.skipped, true); + assert.equal(result.reason, 'globe-reset-failed'); + } finally { + finishReset({ ok: false, action: 'zoom_to_globe', cancelled: true }); + await reset; + } + complete(controller); + assert.equal( + sent.find((event) => event.type === 'response.create').response.tool_choice, + 'none', + ); +}); + +test('an earlier tool finishing late cannot replace the globe refusal followup', async () => { + let finishLookup; + const pending = new Promise((resolve) => { + finishLookup = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? refusal : pending, + ); + begin(controller); + const lookup = invoke(controller, { + callId: 'lookup-call', + name: 'get_current_view_state', + args: {}, + }); + await invoke(controller); + finishLookup({ ok: true, action: 'get_current_view_state' }); + await lookup; + complete(controller); + assert.deepEqual(calls, ['get_current_view_state', 'move_camera']); + assert.equal( + sent.filter((event) => event.item?.type === 'function_call_output').length, + 2, + ); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.equal( + replies[0].response.instructions, + responseInstructionForToolResult(refusal), + ); +}); + +test('a duplicate queued behind successive admissions cannot outlive the dedupe window', async (t) => { + let clock = 0; + t.mock.method(performance, 'now', () => clock); + let finishFirst, finishSecond, secondStarted; + const first = new Promise((resolve) => { + finishFirst = resolve; + }); + const second = new Promise((resolve) => { + finishSecond = resolve; + }); + const enteredSecond = new Promise((resolve) => { + secondStarted = resolve; + }); + let executions = 0; + const { controller, sent, calls } = harness(() => { + if (++executions === 1) return first; + secondStarted(); + return second; + }); + begin(controller); + const original = invoke(controller); + const queued = invoke(controller, { callId: 'queued-rotation' }); + const duplicate = controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'response.output_item.done', + response_id: 'rotation-response', + item: { + type: 'function_call', + id: 'queued-rotation-item', + call_id: 'queued-rotation', + name: 'move_camera', + arguments: JSON.stringify({ motion: 'rotate_globe' }), + }, + }), + }); + finishFirst({ ok: true, action: 'move_camera', motion: 'rotate_globe' }); + await enteredSecond; + clock += CALL_DEDUPE_MS + 1; + finishSecond({ ok: true, action: 'move_camera', motion: 'rotate_globe' }); + await Promise.all([original, queued, duplicate]); + complete(controller); + assert.deepEqual(calls, ['move_camera', 'move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['rotation-call', 'queued-rotation'], + ); + assert.equal( + sent.filter((event) => event.type === 'response.create').length, + 1, + ); +}); + +test( + 'Stop releases an admission waiter and stale completion cannot affect its replacement', + { timeout: 1000 }, + async () => { + let finishRotation; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? pending : { ok: true, action: name }, + ); + const oldChannel = controller.dc; + oldChannel.close = () => { + oldChannel.readyState = 'closed'; + }; + begin(controller); + const rotation = invoke(controller); + const queued = invoke(controller, { + callId: 'stopped-reset', + name: 'zoom_to_globe', + args: {}, + }); + controller.stop(); + await queued; // Must settle even though the original runner has not completed. + assert.equal(oldChannel.readyState, 'closed'); + assert.deepEqual(calls, ['move_camera']); + assert.equal(sent.length, 0); + controller.dc = { readyState: 'open' }; + begin(controller); // Reuse the id to expose any leaked old refusal state. + finishRotation(refusal); + await rotation; + assert.equal(sent.length, 0); + await invoke(controller, { + callId: 'replacement-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller); + assert.deepEqual(calls, ['move_camera', 'zoom_to_globe']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['replacement-reset'], + ); + assert.equal(JSON.parse(outputs[0].item.output).ok, true); + assert.equal( + sent.find((event) => event.type === 'response.create').response + .tool_choice, + undefined, + ); + }, +); + +for (const completedFirst of [false, true]) { + test(`a newer typed turn retires its queued sibling after response completion: ${completedFirst}`, async () => { + let finishRotation; + let oldSignal; + let abortObserved = false; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name, args, context) => { + if (args.motion === 'rotate_globe') { + oldSignal = context.signal; + oldSignal.addEventListener('abort', () => { + abortObserved = true; + }); + return pending; + } + return { ok: true, action: name }; + }); + // Use the actual cancellation owner rather than the lightweight harness stub. + delete controller.cancelRadioHandoff; + begin(controller); + const rotation = invoke(controller); + const sibling = invoke(controller, { + callId: 'stale-reset', + name: 'zoom_to_globe', + args: {}, + }); + if (completedFirst) complete(controller); + controller.sendTextCommand('pan left'); + assert.equal(oldSignal.aborted, true); + assert.equal(abortObserved, true); + finishRotation(refusal); + await Promise.all([rotation, sibling]); + if (!completedFirst) complete(controller); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['rotation-call', 'stale-reset'], + ); + assert.equal(JSON.parse(outputs[1].item.output).superseded, true); + assert.deepEqual(calls, ['move_camera']); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response?.tool_choice, undefined); + assert.equal(controller.pendingResponseInstructions, null); + begin(controller, 'new-turn-response'); + await invoke(controller, { + responseId: 'new-turn-response', + callId: 'new-pan', + args: { motion: 'pan', direction: 'left' }, + }); + complete(controller, 'new-turn-response'); + assert.deepEqual(calls, ['move_camera', 'move_camera']); + const finalOutput = sent + .filter((event) => event.item?.type === 'function_call_output') + .at(-1); + assert.equal(finalOutput.item.call_id, 'new-pan'); + assert.equal(JSON.parse(finalOutput.item.output).ok, true); + assert.equal( + sent.filter((event) => event.type === 'response.create').at(-1).response + ?.tool_choice, + undefined, + ); + }); +} + +test('a newer typed turn during visual context cannot revive the old refusal followup', async () => { + let finishContext; + let enterContext; + const entered = new Promise((resolve) => { + enterContext = resolve; + }); + const pending = new Promise((resolve) => { + finishContext = resolve; + }); + const { controller, sent } = harness(refusal); + controller.setStatus = (status) => { + controller.status = status; + }; + delete controller.cancelRadioHandoff; + controller.sendVisualContextIfUseful = async () => { + enterContext(); + await pending; + }; + begin(controller); + const rotation = invoke(controller); + await entered; + controller.sendTextCommand('pan left'); + finishContext(); + await rotation; + complete(controller); + assert.equal(controller.status, 'listening'); + assert.equal( + sent.filter((event) => event.item?.type === 'function_call_output').length, + 1, + ); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response?.tool_choice, undefined); + assert.equal(controller.pendingResponseInstructions, null); +}); + +test('stale globe completion preserves a newer tool status until that tool finishes', async () => { + let finishContext; + let enterContext; + let finishPan; + const entered = new Promise((resolve) => { + enterContext = resolve; + }); + const pendingContext = new Promise((resolve) => { + finishContext = resolve; + }); + const pendingPan = new Promise((resolve) => { + finishPan = resolve; + }); + const { controller, sent } = harness((name, args) => + args.motion === 'rotate_globe' ? refusal : pendingPan, + ); + controller.setStatus = (status) => { + controller.status = status; + }; + delete controller.cancelRadioHandoff; + controller.sendVisualContextIfUseful = async (result) => { + if (result === refusal) { + enterContext(); + await pendingContext; + } + }; + begin(controller); + const rotation = invoke(controller); + await entered; + controller.sendTextCommand('pan left'); + complete(controller); + begin(controller, 'new-pan-response'); + const pan = invoke(controller, { + responseId: 'new-pan-response', + callId: 'new-pan-call', + args: { motion: 'pan', direction: 'left' }, + }); + assert.equal(controller.status, 'executing'); + finishContext(); + await rotation; + assert.equal(controller.status, 'executing'); + assert.equal(controller.pendingResponseInstructions, null); + finishPan({ ok: true, action: 'move_camera', motion: 'pan' }); + await pan; + complete(controller, 'new-pan-response'); + assert.equal(controller.status, 'listening'); + assert.equal( + sent.filter((event) => event.item?.type === 'function_call_output').length, + 2, + ); + assert.equal( + sent + .filter((event) => event.type === 'response.create') + .every((event) => event.response?.tool_choice === undefined), + true, + ); +}); + +const rotated = { + ok: true, + action: 'move_camera', + motion: 'rotate_globe', + direction: 'right', + mode: 'continuous', +}; +/** A close-up camera that fits only after an executed reset. */ +function closeUpCamera() { + const state = { atGlobeView: false, resets: 0 }; + const result = (name) => { + if (name === 'zoom_to_globe') { + state.resets += 1; + state.atGlobeView = true; + return { ok: true, action: 'zoom_to_globe', cancelled: false }; + } + return state.atGlobeView ? rotated : refusal; + }; + return { state, result }; +} +test('OpenAI: explicit reset and reset-then-rotate stay direct', async () => { + const camera = closeUpCamera(); + const { controller, calls } = harness(camera.result); + await speak(controller); + begin(controller, 'reset'); + await invoke(controller, { + responseId: 'reset', + callId: 'explicit-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller, 'reset'); + assert.equal(camera.state.resets, 1); + // A fresh "Reset Globe View and rotate" resets, then rotates in order. + camera.state.atGlobeView = false; + await speak(controller); + begin(controller, 'both'); + await invoke(controller, { + responseId: 'both', + callId: 'reset-first', + name: 'zoom_to_globe', + args: {}, + }); + await invoke(controller, { responseId: 'both', callId: 'then-rotate' }); + complete(controller, 'both'); + assert.equal(camera.state.resets, 2); + assert.deepEqual(calls, ['zoom_to_globe', 'zoom_to_globe', 'move_camera']); +}); + +test('a failed reset survives real tool followups and a rejected typed send', async () => { + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? { ok: false, action: name } : rotated, + ); + begin(controller, 'reset-response'); + await invoke(controller, { + responseId: 'reset-response', + callId: 'failed-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller, 'reset-response'); + assert.equal( + sent.filter((event) => event.type === 'response.create').length, + 1, + ); + for (const id of ['followup', 'after-rejected-text']) { + if (id === 'after-rejected-text') { + const send = controller.sendRealtimeEvent; + controller.sendRealtimeEvent = (event, source) => + source === 'client.user_text' ? false : send(event, source); + controller.sendTextCommand('rotate the globe'); + controller.sendRealtimeEvent = send; + } + begin(controller, id); + await invoke(controller, { responseId: id, callId: `${id}-rotation` }); + complete(controller, id); + const output = sent.find( + (event) => event.item?.call_id === `${id}-rotation`, + ); + assert.equal(JSON.parse(output.item.output).reason, 'globe-reset-failed'); + assert.equal(JSON.parse(output.item.output).skipped, true); + } + assert.deepEqual(calls, ['zoom_to_globe']); + const reply = sent.filter((event) => event.type === 'response.create').at(-1); + assert.equal(reply.response.tool_choice, 'none'); + assert.doesNotMatch( + reply.response.instructions, + /Reset to Globe View and rotate the globe/, + ); +}); + +test('failed reset skips waiting rotation without blocking queued Stop or lookup', async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name, args) => + name === 'zoom_to_globe' + ? pending + : { ok: true, action: name, motion: args.motion }, + ); + begin(controller); + const reset = invoke(controller, { + callId: 'pending-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller); + const stop = invoke(controller, { + callId: 'queued-stop', + args: { motion: 'stop' }, + }); + const lookup = invoke(controller, { + callId: 'queued-lookup', + name: 'get_current_view_state', + args: {}, + }); + finishReset({ ok: false, action: 'zoom_to_globe', cancelled: true }); + await Promise.all([reset, rotation, stop, lookup]); + complete(controller); + assert.deepEqual(calls, [ + 'zoom_to_globe', + 'move_camera', + 'get_current_view_state', + ]); + for (const id of ['queued-stop', 'queued-lookup']) { + const output = sent.find((event) => event.item?.call_id === id); + assert.equal(JSON.parse(output.item.output).ok, true); + } + const output = sent.find((event) => event.item?.call_id === 'rotation-call'); + assert.equal(JSON.parse(output.item.output).skipped, true); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); +}); + +for (const input of ['text', 'speech']) { + test(`aborted reset stays failed across followups; fresh ${input} isolates late settlement`, async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? pending : rotated, + ); + delete controller.cancelRadioHandoff; + begin(controller, 'old-reset'); + const reset = invoke(controller, { + responseId: 'old-reset', + callId: 'old-reset-call', + name: 'zoom_to_globe', + args: {}, + }); + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'error', + error: { code: 'conversation_already_has_active_response' }, + }), + }); + complete(controller, 'old-reset'); + begin(controller, 'old-followup'); + await invoke(controller, { + responseId: 'old-followup', + callId: 'blocked-rotation', + }); + assert.deepEqual(calls, ['zoom_to_globe']); + const skipped = sent.find( + (event) => event.item?.call_id === 'blocked-rotation', + ); + assert.equal(JSON.parse(skipped.item.output).reason, 'globe-reset-failed'); + if (input === 'text') controller.sendTextCommand('rotate the globe'); + else await speak(controller); + begin(controller, 'fresh'); + await invoke(controller, { responseId: 'fresh', callId: 'fresh-rotation' }); + finishReset({ ok: false, action: 'zoom_to_globe', cancelled: true }); + await reset; + await invoke(controller, { + responseId: 'fresh', + callId: 'after-old-settlement', + }); + assert.deepEqual(calls, ['zoom_to_globe', 'move_camera', 'move_camera']); + }); +} + +test('a real tool followup waits for a reset still pending in the previous response', async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? pending : { ok: true, action: name }, + ); + begin(controller, 'reset-response'); + const reset = invoke(controller, { + responseId: 'reset-response', + callId: 'pending-reset', + name: 'zoom_to_globe', + args: {}, + }); + await invoke(controller, { + responseId: 'reset-response', + callId: 'fast-lookup', + name: 'get_current_view_state', + args: {}, + }); + complete(controller, 'reset-response'); + assert.equal( + sent.filter((event) => event.type === 'response.create').length, + 1, + ); + assert.equal( + JSON.parse( + sent.find((event) => event.item?.call_id === 'fast-lookup').item.output, + ).ok, + true, + ); + begin(controller, 'followup'); + const rotation = invoke(controller, { + responseId: 'followup', + callId: 'waiting-rotation', + }); + assert.deepEqual(calls, ['zoom_to_globe', 'get_current_view_state']); + finishReset({ ok: true, action: 'zoom_to_globe' }); + await Promise.all([reset, rotation]); + assert.deepEqual(calls, [ + 'zoom_to_globe', + 'get_current_view_state', + 'move_camera', + ]); + assert.equal( + sent.filter((event) => event.item?.call_id === 'waiting-rotation').length, + 1, + ); +}); + +test('a recoverable collision keeps a completed reset successful for its rotation', async () => { + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' + ? { ok: true, action: name } + : { ok: true, action: name, motion: 'rotate_globe' }, + ); + delete controller.cancelRadioHandoff; + begin(controller); + await invoke(controller, { + callId: 'done-reset', + name: 'zoom_to_globe', + args: {}, + }); + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'error', + error: { code: 'conversation_already_has_active_response' }, + }), + }); + await invoke(controller); + complete(controller); + assert.deepEqual(calls, ['zoom_to_globe', 'move_camera']); + const output = sent.find((event) => event.item?.call_id === 'rotation-call'); + assert.equal(JSON.parse(output.item.output).ok, true); +}); + +test('an old response reset cannot poison a fresh spoken rotation request', async () => { + for (const lateReset of [false, true]) { + const { controller, calls } = harness((name) => + name === 'zoom_to_globe' + ? { ok: false, action: name } + : { ok: true, action: name, motion: 'rotate_globe' }, + ); + // Use the production speech-start cancellation wiring, not the harness stub. + delete controller.cancelRadioHandoff; + begin(controller, 'old-reset-response'); + if (lateReset) await speak(controller); + await invoke(controller, { + responseId: 'old-reset-response', + callId: 'old-reset', + name: 'zoom_to_globe', + args: {}, + }); + if (!lateReset) await speak(controller); + begin(controller, 'fresh-rotation-response'); + await invoke(controller, { + responseId: 'fresh-rotation-response', + callId: 'fresh-rotation', + }); + assert.deepEqual( + calls, + ['zoom_to_globe', 'move_camera'], + lateReset + ? 'late old reset must retain its original request' + : 'fresh speech clears prior reset failure', + ); + } +}); + +test('a late rotation without a reset retains existing arrival-time policy', async () => { + const { controller, calls } = harness({ + ok: true, + action: 'move_camera', + motion: 'rotate_globe', + }); + delete controller.cancelRadioHandoff; + begin(controller, 'old-rotation-response'); + await speak(controller); + await invoke(controller, { + responseId: 'old-rotation-response', + callId: 'late-rotation', + }); + assert.deepEqual(calls, ['move_camera']); +}); diff --git a/src/voice/narration.js b/src/voice/narration.js index a91dbbe2b..ae97931f7 100644 --- a/src/voice/narration.js +++ b/src/voice/narration.js @@ -4,7 +4,7 @@ import { progressLine } from './speech.js'; * Timing policy for filling silence while tools run. Tuned against the turn * spans' `max_silence_ms`. */ -export const NARRATION_TIMING = Object.freeze({ +const NARRATION_TIMING = Object.freeze({ /** Start the earcon when nothing is audible this long after end of speech. */ earconAfterMs: 700, /** First spoken progress line when a tool is still running after this. */ diff --git a/src/voice/pointerContext.js b/src/voice/pointerContext.js index 3f9d09b6d..590e33a52 100644 --- a/src/voice/pointerContext.js +++ b/src/voice/pointerContext.js @@ -20,15 +20,15 @@ */ /** Movement below this many CSS pixels is still the same dwell. */ -export const POINTER_DWELL_PX = 12; +const POINTER_DWELL_PX = 12; /** Stillness that makes a dwell point. */ export const POINTER_DWELL_MS = 250; /** A resting pointer stays a referent this long after it last moved. */ export const POINTER_FRESH_MS = 30000; /** Pixels from the canvas edge that count as "parked", never a referent. */ -export const POINTER_EDGE_PX = 8; +const POINTER_EDGE_PX = 8; /** A dwell pick is reused by a snapshot only while this recent (contacts move). */ -export const POINTER_DWELL_REUSE_MS = 1000; +const POINTER_DWELL_REUSE_MS = 1000; /** * Open mic has no gesture, so the pointer only counts when it moved or * settled on the map this recently: a cursor resting there for minutes is @@ -76,7 +76,7 @@ export function cameraPoseKey(viewer) { } /** Bound and de-control a label before it goes to the model or the card. */ -export function pointerLabel(value, max = LABEL_MAX) { +function pointerLabel(value, max = LABEL_MAX) { const clean = String(value ?? '') .replace(/[\p{Cc}\p{Cf}]+/gu, ' ') .replace(/\s+/g, ' ') diff --git a/src/voice/pointerPick.js b/src/voice/pointerPick.js index 33ccab9cb..e74671eda 100644 --- a/src/voice/pointerPick.js +++ b/src/voice/pointerPick.js @@ -4,7 +4,7 @@ import { cameraPoseKey } from './pointerContext.js'; import * as defaultAnnotationResolver from '../annotations/annotationResolver.js'; /** "Around here" radius as a share of camera height, clamped to sane bounds. */ -export function pointerRadiusKm(cameraHeightM) { +function pointerRadiusKm(cameraHeightM) { const heightKm = Number(cameraHeightM) / 1000; if (!Number.isFinite(heightKm) || heightKm <= 0) return 50; return Math.round(Math.min(400, Math.max(5, heightKm * 0.75))); diff --git a/src/voice/pointerReticle.js b/src/voice/pointerReticle.js index 96729a933..0702d7345 100644 --- a/src/voice/pointerReticle.js +++ b/src/voice/pointerReticle.js @@ -14,7 +14,7 @@ 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([ +const CAMERA_TOOLS = new Set([ 'fly_to_location', 'track_entity', 'zoom_to_globe', diff --git a/src/voice/realtimeDiagnostics.js b/src/voice/realtimeDiagnostics.js index 630e29433..55e078280 100644 --- a/src/voice/realtimeDiagnostics.js +++ b/src/voice/realtimeDiagnostics.js @@ -1,10 +1,10 @@ -export const ERROR_LOG_LIMIT = 30; +const ERROR_LOG_LIMIT = 30; -export const ERROR_STORAGE_KEY = 'gev-realtime-errors'; +const ERROR_STORAGE_KEY = 'gev-realtime-errors'; -export const DEBUG_LOG_URL = '/api/realtime/debug-log'; +const DEBUG_LOG_URL = '/api/realtime/debug-log'; -export function createDebugSessionId() { +function createDebugSessionId() { const randomPart = Math.random().toString(36).slice(2, 10); return `gev-${Date.now().toString(36)}-${randomPart}`; } @@ -47,7 +47,7 @@ export function sanitizeDebugValue(value, depth = 0) { return output; } -export function sanitizeDebugString(value) { +function sanitizeDebugString(value) { if (value.startsWith('data:image/')) { return `[Redacted image data URL, ${value.length} chars]`; } @@ -65,13 +65,13 @@ export function sanitizeDebugString(value) { : redacted; } -export function isSecretLikeKey(key) { +function isSecretLikeKey(key) { return /(?:api[_-]?key|authorization|bearer|client[_-]?secret|token|secret|password)/i.test( key, ); } -export function createErrorRecord(source, error, extra = {}) { +function createErrorRecord(source, error, extra = {}) { const rtcError = error?.error || error; return { timestamp: new Date().toISOString(), @@ -90,7 +90,7 @@ export function createErrorRecord(source, error, extra = {}) { }; } -export function formatErrorForDisplay(record) { +function formatErrorForDisplay(record) { const primary = [record.source, record.message].filter(Boolean).join(': '); const state = [ record.errorDetail && `detail=${record.errorDetail}`, @@ -105,7 +105,7 @@ export function formatErrorForDisplay(record) { return state ? `${primary}\n${state}` : primary; } -export function removeEmptyValues(value) { +function removeEmptyValues(value) { return Object.fromEntries( Object.entries(value || {}).filter( ([, item]) => item !== null && item !== undefined && item !== '', @@ -113,14 +113,7 @@ export function removeEmptyValues(value) { ); } -export function compactText(value, maxLength) { - const text = String(value || '') - .replace(/\s+/g, ' ') - .trim(); - return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`; -} - -export function loadStoredErrors() { +function loadStoredErrors() { try { const value = JSON.parse(localStorage.getItem(ERROR_STORAGE_KEY) || '[]'); return Array.isArray(value) ? value.slice(0, ERROR_LOG_LIMIT) : []; @@ -129,7 +122,7 @@ export function loadStoredErrors() { } } -export function storeErrors(errors) { +function storeErrors(errors) { try { localStorage.setItem( ERROR_STORAGE_KEY, diff --git a/src/voice/realtimeNarration.test.mjs b/src/voice/realtimeNarration.test.mjs index c9cd3e55f..e47a888c8 100644 --- a/src/voice/realtimeNarration.test.mjs +++ b/src/voice/realtimeNarration.test.mjs @@ -135,7 +135,7 @@ test('an old progress completion leaves the active in-band response alone', asyn }); test('user speech stops an audible progress line: cancel and clear its audio', async () => { - const { controller, sent, event, progressLine } = controllerFixture(); + const { sent, event, progressLine } = controllerFixture(); await progressLine('resp_p'); await event({ type: 'output_audio_buffer.started', response_id: 'resp_p' }); sent.length = 0; diff --git a/src/voice/realtimePreferences.js b/src/voice/realtimePreferences.js index 1d4d1dd23..d3b78a922 100644 --- a/src/voice/realtimePreferences.js +++ b/src/voice/realtimePreferences.js @@ -8,12 +8,12 @@ import { // Voice cost control (repo-wide `godsEyeView..` convention; // the neighbouring ERROR_STORAGE_KEY predates it). -export const VOICE_TIER_STORAGE_KEY = 'godsEyeView.voiceCost.tier'; +const VOICE_TIER_STORAGE_KEY = 'godsEyeView.voiceCost.tier'; -export const VOICE_LIMITS_STORAGE_KEY = 'godsEyeView.voiceCost.limits'; +const VOICE_LIMITS_STORAGE_KEY = 'godsEyeView.voiceCost.limits'; /** Best-effort localStorage handle; absent in tests and locked-down browsers. */ -export function voiceStorage(storage) { +function voiceStorage(storage) { if (storage) return storage; try { return typeof localStorage === 'undefined' ? null : localStorage; diff --git a/src/voice/realtimeProtocol.js b/src/voice/realtimeProtocol.js index 14105229c..a721bb7c9 100644 --- a/src/voice/realtimeProtocol.js +++ b/src/voice/realtimeProtocol.js @@ -99,6 +99,18 @@ export function hasStructuredViewIdentity(result) { ); } +export function isGlobeViewRefusal(result) { + return ( + result?.action === 'move_camera' && + result.ok === false && + [ + 'globe-view-required', + 'globe-reset-unavailable', + 'globe-reset-failed', + ].includes(result.reason) + ); +} + export function responseInstructionForToolResult(result) { if (result?.action === 'control_radio' && result.radioPlaybackSuppressed) { if (result.audioState === 'paused') { @@ -123,6 +135,20 @@ export function responseInstructionForToolResult(result) { : 'Be concise: useful names, place, layer and one or two notable properties.', ].join(' '); } + if (isGlobeViewRefusal(result)) { + // A refusal is not a completed action. Keep tool text out of instructions. + if (result.reason === 'globe-reset-failed') + return 'The globe did not rotate after the Globe View reset attempt. Say so briefly. Do not retry, offer or perform another reset for this request.'; + if (result.reason === 'globe-reset-unavailable') + return 'The globe did not rotate. Ask the user to zoom out further; the Globe View preset does not fit this viewport. Do not offer or perform a reset.'; + return 'The globe did not rotate. Say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" The suggested command is not a user request. Do not ask a question, perform a reset, or call tools in this reply.'; + } + if ( + (result?.action ?? result?.tool) === 'zoom_to_globe' && + (result.ok !== true || result.cancelled) + ) { + return 'The Globe View reset did not finish. Say so briefly; do not claim Globe View was reached. Do not retry the reset or rotate the globe for this request.'; + } if (result?.action === 'adjust_camera_zoom') { return result.ok ? `Confirm once that the camera zoomed ${result.direction}. Do not claim any other change.` diff --git a/src/voice/realtimeTurns.js b/src/voice/realtimeTurns.js index cbc8d5dbc..29c4b3bc0 100644 --- a/src/voice/realtimeTurns.js +++ b/src/voice/realtimeTurns.js @@ -6,6 +6,7 @@ import { SUPERSEDED_RESPONSE_MEMORY, shouldStopVoiceAfterRadioTool, responseInstructionForToolResult, + isGlobeViewRefusal, extractFunctionCalls, callDedupeKeys, parseArguments, @@ -13,7 +14,7 @@ import { import { narrationLabel } from './speech.js'; /** Out-of-band progress responses carry this metadata tag. */ -export const PROGRESS_RESPONSE_TAG = 'progress'; +const PROGRESS_RESPONSE_TAG = 'progress'; /** * Speaks one code-authored progress line out of band. The line travels as @@ -66,6 +67,11 @@ export class RealtimeTurns { this.pendingUserTextResponse = false; this.activeResponseId = null; this.supersededResponseIds = new Set(); + this.globeRefusals = new Map(); + this.globeAdmissionGates = new Map(); + this.globeResets = new Map(); + this.globeResponseRequests = new Map(); + this.globeRequest = { failed: false, pending: new Set() }; this.activeToolAbortControllers = new Set(); this.narration = null; this.narrationSequence = 0; @@ -138,6 +144,7 @@ export class RealtimeTurns { }, }; if (!this.sendRealtimeEvent(itemEvent, 'client.user_text')) return; + this.globeRequest = { failed: false, pending: new Set() }; this.requestUserTextResponse(); } @@ -330,6 +337,7 @@ export class RealtimeTurns { this.userTurnPending = true; this.pendingResponseInstructions = null; this.cancelRadioHandoff({ abortTools: true }); + this.globeRequest = { failed: false, pending: new Set() }; this.setVoiceSpeaker('user'); // Point-and-ask: read the pointer as speech starts (open mic), or reuse // the push-to-talk keydown snapshot, before the user audio commits. @@ -384,6 +392,27 @@ export class RealtimeTurns { } const toolResponseId = payload.response_id || payload.response?.id || null; + // Follow-up responses retain reset ownership until fresh admitted input. + const arrivalRequest = this.globeRequest; + const globeRequest = + this.globeResponseRequests.get(toolResponseId) ?? arrivalRequest; + // Later siblings must wait for globe admission before they can mutate state. + // Other tool responses keep their existing concurrency. + let heldForAdmission = false; + while (toolResponseId && this.globeAdmissionGates.has(toolResponseId)) { + // Drop claimed duplicates before every wait, before their keys can expire. + if ( + calls.every((call) => + callDedupeKeys(call).some((key) => this.processedCalls.has(key)), + ) + ) + return; + heldForAdmission = true; + await this.globeAdmissionGates.get(toolResponseId).promise; + if (!this.ownsConversation(eventChannel) || this.isSessionEnding()) + return; + } + // A newer typed command superseded the response these calls belong to. // They are stale intent — dispatching one would let the old turn mutate // the map after the operator asked for something else. @@ -394,7 +423,10 @@ export class RealtimeTurns { // hazard `callDedupeKeys` is written to avoid). So each refused call gets // a terminal output saying plainly that the turn moved on. No // `response.create` follows — the deferred typed turn is the single answer. - if (this.isSupersededResponse(toolResponseId)) { + if ( + this.isSupersededResponse(toolResponseId) || + (heldForAdmission && arrivalRequest !== this.globeRequest) + ) { this.pruneProcessedCalls(); for (const call of calls) { const keys = callDedupeKeys(call); @@ -429,7 +461,19 @@ export class RealtimeTurns { if (keys.some((key) => this.processedCalls.has(key))) continue; keys.forEach((key) => this.processedCalls.set(key, performance.now())); + if (this.globeRefusals.get(toolResponseId)?.blocking) { + this.sendToolOutput(call.call_id || call.id, { + ok: false, + action: call.name, + skipped: true, + error: 'Not run: this response must wait for the Globe View reply.', + }); + continue; + } + let result; + let globeAdmissionGate = null; + let globeResetGate = null; const resultChannel = this.dc; let radioHandoffEpochAtStart = this.radio.radioHandoffEpoch; let toolController = null; @@ -443,6 +487,80 @@ export class RealtimeTurns { this.narration?.preamble(); try { const parsedArguments = parseArguments(call.arguments); + if ( + toolResponseId && + call.name === 'move_camera' && + String(parsedArguments.motion || '').toLowerCase() === 'rotate_globe' + ) { + let resolve; + const promise = new Promise((settle) => { + resolve = settle; + }); + globeAdmissionGate = { promise, resolve }; + this.globeAdmissionGates.set(toolResponseId, globeAdmissionGate); + // OpenAI delivers sibling calls concurrently. A reset dispatched + // first must finish before rotation evaluates the camera's fit. + // Hold later siblings at admission while this rotation waits. + const resets = this.globeResets.get(toolResponseId); + if ( + resets || + globeRequest.failed || + globeRequest.pending.size || + arrivalRequest !== this.globeRequest + ) { + while (resets?.pending.size || globeRequest.pending.size) + await Promise.all( + [ + ...new Set([ + ...(resets?.pending || []), + ...globeRequest.pending, + ]), + ].map((gate) => gate.promise), + ); + if (!this.ownsConversation(eventChannel) || this.isSessionEnding()) + return; + keys.forEach((key) => + this.processedCalls.set(key, performance.now()), + ); + const superseded = + this.isSupersededResponse(toolResponseId) || + arrivalRequest !== this.globeRequest; + if (superseded || resets?.failed || globeRequest.failed) { + result = { + ok: false, + action: call.name, + reason: 'globe-reset-failed', + skipped: true, + ...(superseded ? { superseded: true } : {}), + error: superseded + ? 'Superseded by a newer command from the operator — this call was not run.' + : 'Not run: the Globe View reset/rotation sequence failed or was cancelled.', + }; + } + } + } + if (toolResponseId && call.name === 'zoom_to_globe') { + let resolve; + const promise = new Promise((settle) => { + resolve = settle; + }); + let resets = this.globeResets.get(toolResponseId); + if (!resets) { + resets = { pending: new Set(), failed: false }; + this.globeResets.set(toolResponseId, resets); + // Retain settled outcomes for later siblings, without evicting an + // active reset that another response still needs to await. + for (const [id, record] of this.globeResets) { + if (this.globeResets.size <= SUPERSEDED_RESPONSE_MEMORY) break; + if (!record.pending.size && id !== toolResponseId) + this.globeResets.delete(id); + } + } + globeResetGate = { promise, resolve, resets, request: globeRequest }; + resets.pending.add(globeResetGate); + globeRequest.pending.add(globeResetGate); + } + const isRadioControlCall = call.name === 'control_radio'; const isRadioVisibilityCall = call.name === 'set_layer_visibility' && @@ -499,7 +617,7 @@ export class RealtimeTurns { authorityDomain: radioAuthorityDomain, }); } - result = await (this.actionExecutor || this.runner)( + result ??= await (this.actionExecutor || this.runner)( call.name, parsedArguments, { @@ -515,7 +633,28 @@ export class RealtimeTurns { radioHandoffEpochAtStart === this.radio.radioHandoffEpoch), }, ); + if ( + globeAdmissionGate && + this.globeResets.has(toolResponseId) && + result?.reason === 'globe-view-required' + ) + result = { ...result, reason: 'globe-reset-failed' }; if (!this.ownsConversation(eventChannel)) return; + if ( + isGlobeViewRefusal(result) && + toolResponseId && + arrivalRequest === this.globeRequest && + !this.isSupersededResponse(toolResponseId) + ) { + this.globeRefusals.set(toolResponseId, { + result, + followupQueued: false, + blocking: !result.skipped, + }); + while (this.globeRefusals.size > SUPERSEDED_RESPONSE_MEMORY) { + this.globeRefusals.delete(this.globeRefusals.keys().next().value); + } + } if (result?.ok && result.radioPlaybackRequested) { const sessionIsCurrent = this.activeToolAbortControllers.has(toolController) && @@ -595,6 +734,23 @@ export class RealtimeTurns { : {}), }; } finally { + if (globeResetGate) { + const { resets, request } = globeResetGate; + if (result?.ok !== true || result.cancelled) { + resets.failed = true; + request.failed = true; + } + resets.pending.delete(globeResetGate); + request.pending.delete(globeResetGate); + globeResetGate.resolve(); + } + if (globeAdmissionGate) { + if ( + this.globeAdmissionGates.get(toolResponseId) === globeAdmissionGate + ) + this.globeAdmissionGates.delete(toolResponseId); + globeAdmissionGate.resolve(); + } if (toolController) { this.activeToolAbortControllers.delete(toolController); this.radio.releaseTool(toolController); @@ -671,13 +827,38 @@ export class RealtimeTurns { }); } if (!this.ownsConversation(eventChannel)) return; - // Keep the Radio handoff wording authoritative even when another tool - // result follows Radio in the same multi-intent response. - this.queueResponseCreate( - responseInstructionForToolResult( - this.radio.pendingRadioPlaybackResult || lastResult, - ), - ); + const refusal = this.globeRefusals.get(toolResponseId); + // A newer turn may arrive during admission or the viewport-image await. + // Preserve its reply; neither the refusal map nor lastResult may revive + // an old refusal follow-up after supersession. + if ( + (this.isSupersededResponse(toolResponseId) || + arrivalRequest !== this.globeRequest) && + (refusal || isGlobeViewRefusal(lastResult) || lastResult?.superseded) + ) { + // Complete an idle turn below, but leave a newer running tool's status + // authoritative while suppressing this obsolete refusal follow-up. + if (this.activeToolAbortControllers.size) return; + } else if (refusal) { + // Even an earlier sibling finishing late cannot replace the reset instruction. + if (!refusal.followupQueued) { + refusal.followupQueued = true; + this.queueResponseCreate({ + instructions: responseInstructionForToolResult(refusal.result), + tool_choice: 'none', + }); + } + } else { + // Keep Radio handoff wording authoritative for ordinary multi-tool replies. + const followupResult = + this.radio.pendingRadioPlaybackResult || lastResult; + const instructions = responseInstructionForToolResult(followupResult); + this.queueResponseCreate( + isGlobeViewRefusal(followupResult) + ? { instructions, tool_choice: 'none' } + : instructions, + ); + } } this.setStatus('listening', t('Ask or command')); } @@ -966,6 +1147,22 @@ export class RealtimeTurns { this.userTurnPending = false; this.activeResponseId = payload.response?.id || payload.response_id || null; + // Late calls retain only their globe reset/request ownership. Ordinary + // tool admission and concurrency continue to use their existing policy. + if ( + this.activeResponseId && + !this.globeResponseRequests.has(this.activeResponseId) + ) { + this.globeResponseRequests.set( + this.activeResponseId, + this.globeRequest, + ); + while (this.globeResponseRequests.size > SUPERSEDED_RESPONSE_MEMORY) + this.globeResponseRequests.delete( + this.globeResponseRequests.keys().next().value, + ); + } + this.setVoiceSpeaker('ai'); return; } @@ -1045,7 +1242,8 @@ export class RealtimeTurns { const sent = this.sendRealtimeEvent( { type: 'response.create', - response: { instructions }, + response: + typeof instructions === 'string' ? { instructions } : instructions, }, 'client.response_create.tool_followup', ); @@ -1057,6 +1255,20 @@ export class RealtimeTurns { controller.abort(); this.activeToolAbortControllers.clear(); this.radio.clearTools(); + this.releaseGlobeResets(); + } + + releaseGlobeResets() { + for (const resets of this.globeResets.values()) { + if (!resets.pending.size) continue; + resets.failed = true; + for (const gate of resets.pending) { + gate.request.failed = true; + gate.request.pending.delete(gate); + gate.resolve(); + } + resets.pending.clear(); + } } reset() { @@ -1068,6 +1280,13 @@ export class RealtimeTurns { this.pendingUserTextResponse = false; this.activeResponseId = null; this.supersededResponseIds.clear(); + this.globeRefusals.clear(); + for (const gate of this.globeAdmissionGates.values()) gate.resolve(); + this.globeAdmissionGates.clear(); + this.releaseGlobeResets(); + this.globeResets.clear(); + this.globeResponseRequests.clear(); + this.globeRequest = { failed: false, pending: new Set() }; this.narration?.cancel(); this.assistantAudioPlaying = false; this.speechResponseIds.clear(); diff --git a/src/voice/realtimeViewport.js b/src/voice/realtimeViewport.js index 755913906..e229b6cc2 100644 --- a/src/voice/realtimeViewport.js +++ b/src/voice/realtimeViewport.js @@ -5,17 +5,17 @@ import { import { cameraPoseKey } from './pointerContext.js'; /** The live viewer's camera pose identity (Cesium-free read). */ -export function readViewerCameraKey() { +function readViewerCameraKey() { return cameraPoseKey(globalThis.window?.__godsEyeView?.viewer); } // Viewport-screenshot size guards (M13). The old code clamped WIDTH only, so a // tall portrait window produced an oversized capture whose dc.send could throw. // Cap total pixels (clamps both dimensions) and drop the image entirely if the // encoded data URL is still too big for the data channel. -export const VIEWPORT_MAX_PIXELS = 1200 * 900; +const VIEWPORT_MAX_PIXELS = 1200 * 900; // ~1.08 MP, matches the old 1200px-wide landscape budget -export const VIEWPORT_MAX_ENCODED_BYTES = 200 * 1024; +const VIEWPORT_MAX_ENCODED_BYTES = 200 * 1024; export async function captureViewportImage() { const viewer = window.__godsEyeView?.viewer; @@ -66,11 +66,11 @@ export async function captureViewportImage() { } /** Side of the point-and-ask crop, in CSS pixels, and its output size. */ -export const POINTER_CROP_SIZE = 512; +const POINTER_CROP_SIZE = 512; /** A pointer snapshot older than this no longer matches the frame. */ -export const POINTER_CROP_MAX_AGE_MS = 20000; +const POINTER_CROP_MAX_AGE_MS = 20000; /** View scales where a crop shows buildings and terrain, not a smear. */ -export const POINTER_CROP_SCALES = new Set(['local', 'city']); +const POINTER_CROP_SCALES = new Set(['local', 'city']); /** * Square source rectangle around a canvas point, clamped inside the canvas, @@ -128,7 +128,7 @@ export function pointerCropRect({ * @returns {Promise<{dataUrl: string, rect: object}|null>} JPEG data URL and * the crop's normalized bounds in the full canvas, or null. */ -export async function capturePointerCrop(screenPx) { +async function capturePointerCrop(screenPx) { const viewer = window.__godsEyeView?.viewer; const source = viewer?.scene?.canvas; if (!source?.width || !source?.height || !screenPx) return null; @@ -259,7 +259,7 @@ export async function renderFreshCesiumFrame(viewer) { } } -export function isNearlyBlackFrame(ctx, width, height) { +function isNearlyBlackFrame(ctx, width, height) { const sampleWidth = Math.min(48, width); const sampleHeight = Math.min(32, height); if (!sampleWidth || !sampleHeight) return true; diff --git a/src/voice/resultDisplay.js b/src/voice/resultDisplay.js index 642db2bb4..ac99f86d7 100644 --- a/src/voice/resultDisplay.js +++ b/src/voice/resultDisplay.js @@ -59,7 +59,7 @@ function notesOf(...values) { ]; } -export function analystNounFor(layerKeys) { +function analystNounFor(layerKeys) { const keys = [...new Set(layerKeys.filter(Boolean))]; if (keys.length !== 1) return 'results'; const key = keys[0]; diff --git a/src/voice/speech.js b/src/voice/speech.js index ab129aad3..711805502 100644 --- a/src/voice/speech.js +++ b/src/voice/speech.js @@ -115,6 +115,18 @@ 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. @@ -592,7 +604,7 @@ function getEntityContext(result) { return { say: null, display: { - title, + title: spokenLabel(shownEntityLabel(title)), lines: [ selected.layerName ? shownLayerName(selected.layerName, selected.layerId) diff --git a/src/voice/speechLint.js b/src/voice/speechLint.js index 9b6c02ab4..f1b1c1878 100644 --- a/src/voice/speechLint.js +++ b/src/voice/speechLint.js @@ -7,7 +7,7 @@ * Phrases that add length without adding an answer. Tool numbers are spoken * verbatim, so approximations are hedges too. */ -export const HEDGE_PHRASES = Object.freeze([ +const HEDGE_PHRASES = Object.freeze([ 'keep in mind', "it's worth noting", 'it is worth noting', diff --git a/src/voice/turnMetrics.js b/src/voice/turnMetrics.js index 13e9c6940..b2c447cee 100644 --- a/src/voice/turnMetrics.js +++ b/src/voice/turnMetrics.js @@ -1,7 +1,7 @@ import { countWords, findHedges } from './speechLint.js'; /** Quiet period after the last audio or response before a turn is closed. */ -export const TURN_SETTLE_MS = 2500; +const TURN_SETTLE_MS = 2500; /** * Per-turn latency and speech spans for one voice session. diff --git a/src/voice/voiceCard.js b/src/voice/voiceCard.js index 72b754287..09619bb54 100644 --- a/src/voice/voiceCard.js +++ b/src/voice/voiceCard.js @@ -5,7 +5,7 @@ import { } from './voiceCardPresentation.js'; /** Hide an idle card this long after its last update. */ -export const VOICE_CARD_IDLE_MS = 14000; +const VOICE_CARD_IDLE_MS = 14000; const ELEMENT_IDS = Object.freeze({ card: 'gev-voice-card', diff --git a/src/voice/voiceCardLocale.test.mjs b/src/voice/voiceCardLocale.test.mjs index 08274d21b..9c70d91f9 100644 --- a/src/voice/voiceCardLocale.test.mjs +++ b/src/voice/voiceCardLocale.test.mjs @@ -3,8 +3,16 @@ 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'; +import { + attachVoiceResult, + planStepLabel, + shownEntityLabel, +} from './speech.js'; +import { + initialVoiceCardState, + reduceVoiceCard, + voiceCardView, +} from './voiceCardPresentation.js'; for (const locale of ['ru', 'es']) { test(`voice card is in ${locale} for a layer switch`, (t) => { @@ -86,3 +94,49 @@ test('en: layer switch, distance and plan labels in English', () => { 'Earthquakes (24h) on', ); }); + +// FIRMS and ALPR register English entity labels: the model reads them as data +// (get_entity_context, pointer_context), the card shows them translated. +const ENTITY_LABELS = { + ru: { fire: 'Пожар · FRP 12 МВт', alpr: 'Камера ALPR' }, + es: { fire: 'Incendio · FRP 12 MW', alpr: 'Cámara ALPR' }, +}; + +for (const [locale, shown] of Object.entries(ENTITY_LABELS)) { + test(`entity labels stay English in the result and show in ${locale}`, (t) => { + t.after(() => setLocale('en')); + setLocale(locale); + const out = attachVoiceResult('get_entity_context', { + ok: true, + selected: { + id: 'fire-1', + name: 'Fire · FRP 12 MW', + layerId: 'local-firms', + layerName: 'NASA FIRMS', + }, + }); + assert.equal(out.selected.name, 'Fire · FRP 12 MW'); + assert.equal(out.display.title, shown.fire); + + const state = reduceVoiceCard(initialVoiceCardState(), { + type: 'pointer', + pointer: { kind: 'this', label: 'ALPR camera' }, + }); + assert.equal(state.pointer.label, 'ALPR camera'); + assert.equal(voiceCardView(state).pointer.text, shown.alpr); + assert.equal(state.announce, `This: ${shown.alpr}`); + }); +} + +test('only the registered entity labels are translated', () => { + assert.equal(shownEntityLabel('Fire · FRP 4.9 MW'), 'Fire · FRP 4.9 MW'); + assert.equal(shownEntityLabel('ALPR camera'), 'ALPR camera'); + setLocale('ru'); + try { + assert.equal(shownEntityLabel('Fire · FRP 4.9 MW'), 'Пожар · FRP 4.9 МВт'); + assert.equal(shownEntityLabel('UPS793'), 'UPS793'); + assert.equal(shownEntityLabel('Fire Station 5'), 'Fire Station 5'); + } finally { + setLocale('en'); + } +}); diff --git a/src/voice/voiceCardPresentation.js b/src/voice/voiceCardPresentation.js index f8bf8ff7d..218241488 100644 --- a/src/voice/voiceCardPresentation.js +++ b/src/voice/voiceCardPresentation.js @@ -1,4 +1,9 @@ -import { planStepLabel, progressStepLabel, spokenLabel } from './speech.js'; +import { + planStepLabel, + progressStepLabel, + shownEntityLabel, + spokenLabel, +} from './speech.js'; import { presentResult } from './resultDisplay.js'; import { MAX_DISPLAYED_REFERENTS, normalizeReferents } from './referents.js'; @@ -132,7 +137,7 @@ export function reduceVoiceCard(state, event) { return reveal({ ...state, pointer, - announce: `${kind}: ${spokenLabel(pointer.label, 32)}`, + announce: `${kind}: ${spokenLabel(shownEntityLabel(pointer.label), 32)}`, }); } case 'transcript': { @@ -288,7 +293,7 @@ export function voiceCardView(state) { pointer: { visible: Boolean(state.pointer), kind: state.pointer?.kind === 'here' ? t('HERE') : t('THIS'), - text: spokenLabel(state.pointer?.label || '', 32), + text: spokenLabel(shownEntityLabel(state.pointer?.label || ''), 32), }, assistant: { visible: Boolean(state.assistant), diff --git a/style.css b/style.css index 7f041cabe..1b55c459f 100644 --- a/style.css +++ b/style.css @@ -32,4 +32,6 @@ @import './src/ui/styles/weather.css'; @import './src/ui/styles/panel-surfaces.css'; @import './src/ui/styles/segmented-control.css'; +@import './src/ui/styles/cjk.css'; +@import './src/ui/styles/mobile-shell.css'; @import './src/ui/styles/cyber.css'; diff --git a/tools/pano-pinhole.mjs b/tools/pano-pinhole.mjs index 36ceaa546..42fee8fbd 100644 --- a/tools/pano-pinhole.mjs +++ b/tools/pano-pinhole.mjs @@ -34,13 +34,11 @@ * Dependencies: sharp (devDependency) */ -import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; +import { mkdirSync } from 'node:fs'; import { resolve, join, basename, extname } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { projectRoot } from '../scripts/project-root.mjs'; import sharp from 'sharp'; -const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PROJECT_ROOT = projectRoot(import.meta.url); // --------------------------------------------------------------------------- diff --git a/tools/sat-ortho.mjs b/tools/sat-ortho.mjs index d42039e46..514bb22d4 100644 --- a/tools/sat-ortho.mjs +++ b/tools/sat-ortho.mjs @@ -32,11 +32,9 @@ import { readFileSync, mkdirSync } from 'node:fs'; import { resolve, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { projectRoot } from '../scripts/project-root.mjs'; import sharp from 'sharp'; -const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PROJECT_ROOT = projectRoot(import.meta.url); const TILE_SIZE = 256; const MAX_CONCURRENT = 8; diff --git a/tools/streetview-headings.mjs b/tools/streetview-headings.mjs index 708ae7b93..e9a124d22 100644 --- a/tools/streetview-headings.mjs +++ b/tools/streetview-headings.mjs @@ -28,7 +28,6 @@ import { projectRoot } from '../scripts/project-root.mjs'; import { parseEnv } from 'node:util'; import { resolveGoogleServerKey } from '../scripts/google-server-key.mjs'; -const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PROJECT_ROOT = projectRoot(import.meta.url); function parseArgs() { diff --git a/tools/streetview-panorama.mjs b/tools/streetview-panorama.mjs index c93f487bd..73010aa6c 100755 --- a/tools/streetview-panorama.mjs +++ b/tools/streetview-panorama.mjs @@ -24,11 +24,9 @@ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { resolve, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { projectRoot } from '../scripts/project-root.mjs'; import sharp from 'sharp'; -const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PROJECT_ROOT = projectRoot(import.meta.url); // --------------------------------------------------------------------------- @@ -159,7 +157,7 @@ async function fetchTile(apiKey, session, panoId, z, x, y) { // --------------------------------------------------------------------------- async function stitchPanorama(apiKey, session, panoId, metadata, zoom) { - const { imageWidth, imageHeight, tileWidth, tileHeight } = metadata; + const { tileWidth, tileHeight } = metadata; // Street View tile grids follow a fixed power-of-2 scheme: // zoom 0: 1×1 (360° FOV, entire panorama in one tile)