mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
Hold a voice tilt until its fly-to lands
"Fly to London and tilt the camera" reaches the app as two sibling tool calls, and OpenAI runs them concurrently. The tilt's navigation step cancelled the flight already in the air, so the camera tilted where it was; when the place still needed a lookup, the late flight replaced the tilt and London came out top-down. The voice runner now tracks its latest flight. A tilt sent while that flight is still looking up or flying waits for it to land (up to 30 s), then tilts at the destination. If the flight is cancelled, finds nothing or times out, the tilt is not run and says so.
This commit is contained in:
1 parent
69152868e4
commit
91a3f787d4
3 files changed
+207
-8
No files matched your search
+83
-7
@@ -276,6 +276,10 @@ export function createGevActionRunner({
|
||||
? { ...SPEECH_BUILDERS, ...speechBuilders }
|
||||
: SPEECH_BUILDERS;
|
||||
const _layerEnabledAt = new Map();
|
||||
// The latest voice flight. A tilt in the same reply as "fly to X" waits for
|
||||
// it to land: run at once, its navigation cancelled the flight, or the
|
||||
// flight (still geocoding) then replaced the tilt.
|
||||
let voiceFlight = null;
|
||||
let analystEngine;
|
||||
const resolveRegionRing = (name, signal) =>
|
||||
annotationResolver.resolveRegionRingForQuery(name, signal, placeSearch);
|
||||
@@ -1103,7 +1107,10 @@ export function createGevActionRunner({
|
||||
runOptions,
|
||||
);
|
||||
}
|
||||
return flyToRequestedLocation(viewer, args, {
|
||||
const flight = trackVoiceFlight();
|
||||
voiceFlight = flight;
|
||||
const flown = flyToRequestedLocation(viewer, args, {
|
||||
onFlightEnd: flight.settle,
|
||||
onProgress: reportProgress,
|
||||
placeSearch,
|
||||
searchNavigation,
|
||||
@@ -1131,6 +1138,14 @@ export function createGevActionRunner({
|
||||
if (viewer.trackedEntity) stopAllTracking(viewer, dataManager);
|
||||
},
|
||||
});
|
||||
// No flight started (not found, refused, cancelled): nothing to wait for.
|
||||
flown.then(
|
||||
(result) => {
|
||||
if (result?.ok !== true) flight.settle('failed');
|
||||
},
|
||||
() => flight.settle('failed'),
|
||||
);
|
||||
return flown;
|
||||
}
|
||||
|
||||
if (name === 'adjust_camera_zoom') {
|
||||
@@ -1173,6 +1188,23 @@ export function createGevActionRunner({
|
||||
}
|
||||
|
||||
if (name === 'move_camera') {
|
||||
if (
|
||||
String(args.motion || '').toLowerCase() === 'tilt' &&
|
||||
voiceFlight &&
|
||||
!voiceFlight.done
|
||||
) {
|
||||
const status = await waitForVoiceFlight(voiceFlight, runOptions.signal);
|
||||
if (!current())
|
||||
return { ok: false, action: 'move_camera', cancelled: true };
|
||||
if (status !== 'arrived')
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
motion: 'tilt',
|
||||
error:
|
||||
'Not run: the flight this tilt was waiting for did not arrive.',
|
||||
};
|
||||
}
|
||||
return moveCamera(args, (navigate, releaseOptions) =>
|
||||
runManagedVoiceNavigation(
|
||||
styleManager,
|
||||
@@ -2690,6 +2722,42 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) {
|
||||
}
|
||||
|
||||
/** Run one validated voice camera mutation through the UI-owned authority seam. */
|
||||
/** A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. */
|
||||
function trackVoiceFlight() {
|
||||
let resolve;
|
||||
const flight = {
|
||||
done: false,
|
||||
ended: new Promise((settle) => {
|
||||
resolve = settle;
|
||||
}),
|
||||
settle: (status) => {
|
||||
if (flight.done) return;
|
||||
flight.done = true;
|
||||
resolve(status);
|
||||
},
|
||||
};
|
||||
return flight;
|
||||
}
|
||||
|
||||
/** Longest a tilt waits for a flight (geocode plus flight) before giving up. */
|
||||
const VOICE_FLIGHT_WAIT_MS = 30_000;
|
||||
|
||||
/** Wait for a voice flight to end; 'timeout' or 'aborted' if it does not. */
|
||||
function waitForVoiceFlight(flight, signal) {
|
||||
if (signal?.aborted) return Promise.resolve('aborted');
|
||||
return new Promise((resolve) => {
|
||||
const finish = (status) => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener?.('abort', onAbort);
|
||||
resolve(status);
|
||||
};
|
||||
const onAbort = () => finish('aborted');
|
||||
const timer = setTimeout(() => finish('timeout'), VOICE_FLIGHT_WAIT_MS);
|
||||
signal?.addEventListener?.('abort', onAbort, { once: true });
|
||||
flight.ended.then(finish);
|
||||
});
|
||||
}
|
||||
|
||||
function runManagedVoiceNavigation(
|
||||
styleManager,
|
||||
noun,
|
||||
@@ -3252,6 +3320,7 @@ async function flyToRequestedLocation(
|
||||
runImmediate = null,
|
||||
beginDeferred = null,
|
||||
reassertDeferred = null,
|
||||
onFlightEnd = null,
|
||||
} = {},
|
||||
) {
|
||||
const requestedRangeM = Number(args.rangeM);
|
||||
@@ -3275,12 +3344,19 @@ async function flyToRequestedLocation(
|
||||
settleArrival = resolve;
|
||||
})
|
||||
: null;
|
||||
const arrivalHooks = arrival
|
||||
? {
|
||||
onComplete: () => settleArrival?.('arrived'),
|
||||
onCancel: () => settleArrival?.('cancelled'),
|
||||
}
|
||||
: {};
|
||||
const arrivalHooks =
|
||||
arrival || onFlightEnd
|
||||
? {
|
||||
onComplete: () => {
|
||||
settleArrival?.('arrived');
|
||||
onFlightEnd?.('arrived');
|
||||
},
|
||||
onCancel: () => {
|
||||
settleArrival?.('cancelled');
|
||||
onFlightEnd?.('cancelled');
|
||||
},
|
||||
}
|
||||
: {};
|
||||
const afterArrival = async (result, label) => {
|
||||
if (!arrival || result?.ok !== true) return result;
|
||||
onProgress('fly', label);
|
||||
|
||||
@@ -217,6 +217,129 @@ test('dependent voice navigation waits for the destination viewport to arrive',
|
||||
assert.equal(result.arrived, true);
|
||||
});
|
||||
|
||||
// "fly to London and tilt the camera" arrives as two sibling calls.
|
||||
// Run at once, the tilt's navigation cancelled the flight; with a geocoded
|
||||
// place, the late flight replaced the tilt.
|
||||
function tiltAfterFlightHarness() {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const harness = createVoiceNavigationHarness();
|
||||
const { viewer, styleManager } = harness;
|
||||
const flights = [];
|
||||
viewer.camera.flyTo = (options) => {
|
||||
harness.order.push('fly');
|
||||
flights.push(options);
|
||||
};
|
||||
styleManager.runImmediateLocationNavigation = (navigate) => (
|
||||
styleManager.runImmediateNavigation('location', navigate)
|
||||
);
|
||||
return { ...harness, flights };
|
||||
}
|
||||
|
||||
const settledYet = (promise) => {
|
||||
let settled = false;
|
||||
promise.then(() => { settled = true; });
|
||||
return () => settled;
|
||||
};
|
||||
|
||||
test('a tilt in the same reply as a fly-to waits for the flight to land', async () => {
|
||||
const { viewer, styleManager, order, flights } = tiltAfterFlightHarness();
|
||||
const runner = createGevActionRunner({
|
||||
viewer,
|
||||
styleManager,
|
||||
dataManager: { layers: new Map(), getAll: () => [] },
|
||||
});
|
||||
const fly = await runner('fly_to_location', { locationId: 'austin' });
|
||||
assert.equal(fly.ok, true);
|
||||
const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 });
|
||||
const tiltDone = settledYet(tilt);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(tiltDone(), false, 'the tilt must wait while the camera flies');
|
||||
assert.equal(
|
||||
order.slice(order.indexOf('fly')).includes('cancel'),
|
||||
false,
|
||||
'the tilt must not cancel the flight',
|
||||
);
|
||||
flights[0].complete();
|
||||
const result = await tilt;
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.motion, 'tilt');
|
||||
});
|
||||
|
||||
test('a tilt waits through a geocoded fly-to search, then for its flight', async () => {
|
||||
const { viewer, styleManager } = tiltAfterFlightHarness();
|
||||
let finishSearch;
|
||||
let landFlight;
|
||||
const runner = createGevActionRunner({
|
||||
viewer,
|
||||
styleManager,
|
||||
dataManager: { layers: new Map(), getAll: () => [] },
|
||||
searchNavigation: (_viewer, query, options) => new Promise((resolve) => {
|
||||
finishSearch = () => {
|
||||
landFlight = options.onComplete;
|
||||
resolve({ label: query, navigationMode: 'city-overview' });
|
||||
};
|
||||
}),
|
||||
});
|
||||
const fly = runner('fly_to_location', { query: 'Somewhere Far' });
|
||||
const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 });
|
||||
const tiltDone = settledYet(tilt);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(tiltDone(), false, 'the tilt must wait for the place lookup');
|
||||
finishSearch();
|
||||
assert.equal((await fly).ok, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(tiltDone(), false, 'the tilt must wait for the flight too');
|
||||
landFlight();
|
||||
assert.equal((await tilt).ok, true);
|
||||
});
|
||||
|
||||
test('a tilt waiting on a flight that never lands is not run', async () => {
|
||||
const { viewer, styleManager, flights } = tiltAfterFlightHarness();
|
||||
const runner = createGevActionRunner({
|
||||
viewer,
|
||||
styleManager,
|
||||
dataManager: { layers: new Map(), getAll: () => [] },
|
||||
});
|
||||
await runner('fly_to_location', { locationId: 'austin' });
|
||||
const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 });
|
||||
flights[0].cancel();
|
||||
const result = await tilt;
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /did not arrive/);
|
||||
});
|
||||
|
||||
test('a tilt beside a fly-to that found nothing is not run', async () => {
|
||||
const { viewer, styleManager } = tiltAfterFlightHarness();
|
||||
const runner = createGevActionRunner({
|
||||
viewer,
|
||||
styleManager,
|
||||
dataManager: { layers: new Map(), getAll: () => [] },
|
||||
searchNavigation: async () => null,
|
||||
});
|
||||
const fly = runner('fly_to_location', { query: 'Nowhere' });
|
||||
const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 });
|
||||
assert.equal((await fly).ok, false);
|
||||
// Nothing flew: the tilt answers at once and honestly, never at the old view.
|
||||
const result = await tilt;
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /did not arrive/);
|
||||
// A later, separate tilt is not held by the failed lookup.
|
||||
assert.equal((await runner('move_camera', { motion: 'tilt', pitchDeg: -45 })).ok, true);
|
||||
});
|
||||
|
||||
test('a tilt with no voice flight pending runs at once', async () => {
|
||||
const { viewer, styleManager, flights } = tiltAfterFlightHarness();
|
||||
const runner = createGevActionRunner({
|
||||
viewer,
|
||||
styleManager,
|
||||
dataManager: { layers: new Map(), getAll: () => [] },
|
||||
});
|
||||
await runner('fly_to_location', { locationId: 'austin' });
|
||||
flights[0].complete();
|
||||
const result = await runner('move_camera', { motion: 'tilt', pitchDeg: -45 });
|
||||
assert.equal(result.ok, true);
|
||||
});
|
||||
|
||||
test('nearest-aircraft voice action serializes layer enable, arrival, refresh, airborne query, and selection', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const { viewer, styleManager } = createVoiceNavigationHarness();
|
||||
|
||||
Reference in new issue
Block a user