mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
style: format extracted local services
This commit is contained in:
1 parent
233285fe12
commit
5223b6df5b
33 files changed
+1423
-468
No files matched your search
@@ -83,5 +83,36 @@
|
||||
"server/providers/radio/transport.js",
|
||||
"src/tooling/mediaProviders.test.mjs",
|
||||
"server/standalone/api-not-found.js",
|
||||
"src/tooling/previewServing.test.mjs"
|
||||
"src/tooling/previewServing.test.mjs",
|
||||
"server/providers/common/request.js",
|
||||
"server/providers/common/source-root.js",
|
||||
"server/providers/local.js",
|
||||
"server/providers/military-installations.js",
|
||||
"server/providers/military-installations/cache.js",
|
||||
"server/providers/military-installations/constants.js",
|
||||
"server/providers/military-installations/query.js",
|
||||
"server/providers/openai.js",
|
||||
"server/providers/openai/constants.js",
|
||||
"server/providers/openai/debug-log.js",
|
||||
"server/providers/openai/hud-summary.js",
|
||||
"server/providers/openai/instructions.js",
|
||||
"server/providers/openai/rate-limit.js",
|
||||
"server/providers/openai/realtime.js",
|
||||
"server/providers/openai/tools.js",
|
||||
"server/providers/overpass.js",
|
||||
"server/providers/overpass/cache.js",
|
||||
"server/providers/overpass/constants.js",
|
||||
"server/providers/overpass/geometry.js",
|
||||
"server/providers/overpass/query.js",
|
||||
"server/providers/overpass/transport.js",
|
||||
"server/providers/regional.js",
|
||||
"server/providers/regional/briefing.js",
|
||||
"server/providers/regional/http.js",
|
||||
"server/providers/regional/news.js",
|
||||
"server/providers/regional/place.js",
|
||||
"server/providers/regional/query.js",
|
||||
"server/providers/regional/weather-effects.js",
|
||||
"server/providers/regional/weather.js",
|
||||
"server/standalone/key-setup.js",
|
||||
"src/tooling/localServices.test.mjs"
|
||||
]
|
||||
+39
-120
@@ -10,47 +10,27 @@
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js"
|
||||
],
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"application": {
|
||||
"exports": [
|
||||
"./application"
|
||||
],
|
||||
"modules": [
|
||||
"src/app/application.js"
|
||||
],
|
||||
"exports": ["./application"],
|
||||
"modules": ["src/app/application.js"],
|
||||
"external": []
|
||||
},
|
||||
"viewer": {
|
||||
"exports": [
|
||||
"./application/viewer"
|
||||
],
|
||||
"modules": [
|
||||
"src/app/viewer.js"
|
||||
],
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
"exports": ["./application/viewer"],
|
||||
"modules": ["src/app/viewer.js"],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"vite-build": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./build/vite"
|
||||
],
|
||||
"modules": [
|
||||
"build/vite.js"
|
||||
],
|
||||
"external": [
|
||||
"vite-plugin-cesium"
|
||||
]
|
||||
"exports": ["./build/vite"],
|
||||
"modules": ["build/vite.js"],
|
||||
"external": ["vite-plugin-cesium"]
|
||||
},
|
||||
"live-providers": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/live"
|
||||
],
|
||||
"exports": ["./server/providers/live"],
|
||||
"modules": [
|
||||
"server/providers/live.js",
|
||||
"server/providers/common/http.js",
|
||||
@@ -65,24 +45,16 @@
|
||||
"src/data/aisStreamAdapter.js",
|
||||
"src/data/aisWatchdog.js"
|
||||
],
|
||||
"external": [
|
||||
"ws"
|
||||
]
|
||||
"external": ["ws"]
|
||||
},
|
||||
"adsb-lol-source": {
|
||||
"exports": [
|
||||
"./sources/adsb-lol"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/adsbLolFallback.js"
|
||||
],
|
||||
"exports": ["./sources/adsb-lol"],
|
||||
"modules": ["src/data/adsbLolFallback.js"],
|
||||
"external": []
|
||||
},
|
||||
"place-providers": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/places"
|
||||
],
|
||||
"exports": ["./server/providers/places"],
|
||||
"modules": [
|
||||
"server/providers/places.js",
|
||||
"server/providers/places/google.js",
|
||||
@@ -97,19 +69,13 @@
|
||||
"external": []
|
||||
},
|
||||
"place-payloads": {
|
||||
"exports": [
|
||||
"./sources/places"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/placeProviderPayloads.js"
|
||||
],
|
||||
"exports": ["./sources/places"],
|
||||
"modules": ["src/data/placeProviderPayloads.js"],
|
||||
"external": []
|
||||
},
|
||||
"space-providers": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/space"
|
||||
],
|
||||
"exports": ["./server/providers/space"],
|
||||
"modules": [
|
||||
"server/providers/space.js",
|
||||
"server/providers/space/celestrak.js",
|
||||
@@ -120,19 +86,13 @@
|
||||
"external": []
|
||||
},
|
||||
"space-requests": {
|
||||
"exports": [
|
||||
"./sources/space"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/spaceProviderRequests.js"
|
||||
],
|
||||
"exports": ["./sources/space"],
|
||||
"modules": ["src/data/spaceProviderRequests.js"],
|
||||
"external": []
|
||||
},
|
||||
"terrain-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/terrain"
|
||||
],
|
||||
"exports": ["./server/providers/terrain"],
|
||||
"modules": [
|
||||
"server/providers/terrain.js",
|
||||
"src/data/terrainHeightsProxy.js"
|
||||
@@ -140,70 +100,41 @@
|
||||
"external": []
|
||||
},
|
||||
"terrain-source": {
|
||||
"exports": [
|
||||
"./sources/terrain"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/terrainHeightsProxy.js"
|
||||
],
|
||||
"exports": ["./sources/terrain"],
|
||||
"modules": ["src/data/terrainHeightsProxy.js"],
|
||||
"external": []
|
||||
},
|
||||
"traffic-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/traffic"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/traffic.js",
|
||||
"src/data/tomtomTiles.js"
|
||||
],
|
||||
"exports": ["./server/providers/traffic"],
|
||||
"modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"],
|
||||
"external": []
|
||||
},
|
||||
"traffic-source": {
|
||||
"exports": [
|
||||
"./sources/traffic"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/tomtomTiles.js"
|
||||
],
|
||||
"exports": ["./sources/traffic"],
|
||||
"modules": ["src/data/tomtomTiles.js"],
|
||||
"external": []
|
||||
},
|
||||
"firms-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/firms"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/firms.js",
|
||||
"src/data/firmsCsv.js"
|
||||
],
|
||||
"exports": ["./server/providers/firms"],
|
||||
"modules": ["server/providers/firms.js", "src/data/firmsCsv.js"],
|
||||
"external": []
|
||||
},
|
||||
"gbfs-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/gbfs"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/gbfs.js",
|
||||
"src/data/gbfsSource.js"
|
||||
],
|
||||
"exports": ["./server/providers/gbfs"],
|
||||
"modules": ["server/providers/gbfs.js", "src/data/gbfsSource.js"],
|
||||
"external": []
|
||||
},
|
||||
"gbfs-source": {
|
||||
"exports": [
|
||||
"./sources/gbfs"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/gbfsSource.js"
|
||||
],
|
||||
"exports": ["./sources/gbfs"],
|
||||
"modules": ["src/data/gbfsSource.js"],
|
||||
"external": []
|
||||
},
|
||||
"cctv-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/cctv"
|
||||
],
|
||||
"exports": ["./server/providers/cctv"],
|
||||
"modules": [
|
||||
"server/providers/cctv.js",
|
||||
"server/providers/cctv/catalog.js",
|
||||
@@ -220,9 +151,7 @@
|
||||
},
|
||||
"radio-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/radio"
|
||||
],
|
||||
"exports": ["./server/providers/radio"],
|
||||
"modules": [
|
||||
"server/providers/radio.js",
|
||||
"server/providers/radio/catalog.js",
|
||||
@@ -236,9 +165,7 @@
|
||||
},
|
||||
"overpass-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/overpass"
|
||||
],
|
||||
"exports": ["./server/providers/overpass"],
|
||||
"modules": [
|
||||
"server/providers/common/geo.js",
|
||||
"server/providers/common/http.js",
|
||||
@@ -257,9 +184,7 @@
|
||||
},
|
||||
"military-installations-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/military-installations"
|
||||
],
|
||||
"exports": ["./server/providers/military-installations"],
|
||||
"modules": [
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/query.js",
|
||||
@@ -276,9 +201,7 @@
|
||||
},
|
||||
"regional-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/regional"
|
||||
],
|
||||
"exports": ["./server/providers/regional"],
|
||||
"modules": [
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/query.js",
|
||||
@@ -297,9 +220,7 @@
|
||||
},
|
||||
"openai-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/openai"
|
||||
],
|
||||
"exports": ["./server/providers/openai"],
|
||||
"modules": [
|
||||
"server/providers/common/rate-limit.js",
|
||||
"server/providers/common/request.js",
|
||||
@@ -319,9 +240,7 @@
|
||||
},
|
||||
"standalone-key-setup": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/standalone/key-setup"
|
||||
],
|
||||
"exports": ["./server/standalone/key-setup"],
|
||||
"modules": [
|
||||
"scripts/pinokio-environment.mjs",
|
||||
"server/providers/common/source-root.js",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
/** Read a request body with a hard byte cap; throws { code:'BODY_TOO_LARGE' } past the cap. */
|
||||
async function readRequestBodyCapped(req, maxBytes) {
|
||||
const chunks = [];
|
||||
|
||||
@@ -2,6 +2,9 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/** Resolve defaultSourceRoot for ESM context. */
|
||||
const defaultSourceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const defaultSourceRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../../..',
|
||||
);
|
||||
|
||||
export { defaultSourceRoot };
|
||||
@@ -47,8 +47,17 @@ function localProviderPlugins() {
|
||||
|
||||
export { localProviderPlugins };
|
||||
|
||||
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream } from './cctv.js';
|
||||
export { createRadioProxyMiddleware, isPublicRadioAddress, normalizeRadioBrowserStation, publicRadioStation, publicRadioHttpsUrl } from './radio.js';
|
||||
export {
|
||||
CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||
fetchCctvImageFromUpstream,
|
||||
} from './cctv.js';
|
||||
export {
|
||||
createRadioProxyMiddleware,
|
||||
isPublicRadioAddress,
|
||||
normalizeRadioBrowserStation,
|
||||
publicRadioStation,
|
||||
publicRadioHttpsUrl,
|
||||
} from './radio.js';
|
||||
export { LL2_CACHE_TTL_MS, launchLibraryRequestHeaders } from './space.js';
|
||||
export { googlePlacesContextProxy } from './places.js';
|
||||
export { googleServerApiKey } from './places.js';
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
import { MILITARY_INSTALLATION_ELEMENT_CAP, MILITARY_INSTALLATION_MAX_RESPONSE_BYTES, MILITARY_INSTALLATION_DISK_TTL_MS, MILITARY_INSTALLATION_STALE_MS } from './military-installations/constants.js';
|
||||
import {
|
||||
MILITARY_INSTALLATION_ELEMENT_CAP,
|
||||
MILITARY_INSTALLATION_MAX_RESPONSE_BYTES,
|
||||
MILITARY_INSTALLATION_DISK_TTL_MS,
|
||||
MILITARY_INSTALLATION_STALE_MS,
|
||||
} from './military-installations/constants.js';
|
||||
import { fetchOverpassPayload } from './overpass/transport.js';
|
||||
import { _militaryInstallationCache, trimMilitaryInstallationCache, writeMilitaryInstallationDisk, resolveMilitaryInstallationTier, readMilitaryInstallationDisk } from './military-installations/cache.js';
|
||||
import { validMilitaryInstallationBox, quantizeMilitaryInstallationBox, militaryInstallationCacheKey, militaryInstallationFailureReason } from './military-installations/query.js';
|
||||
import {
|
||||
_militaryInstallationCache,
|
||||
trimMilitaryInstallationCache,
|
||||
writeMilitaryInstallationDisk,
|
||||
resolveMilitaryInstallationTier,
|
||||
readMilitaryInstallationDisk,
|
||||
} from './military-installations/cache.js';
|
||||
import {
|
||||
validMilitaryInstallationBox,
|
||||
quantizeMilitaryInstallationBox,
|
||||
militaryInstallationCacheKey,
|
||||
militaryInstallationFailureReason,
|
||||
} from './military-installations/query.js';
|
||||
import { coalesceProxyRequest } from './common/http.js';
|
||||
|
||||
const _militaryInstallationsRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 90, globalMax: 300 });
|
||||
const _militaryInstallationsRateLimiter = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: 90,
|
||||
globalMax: 300,
|
||||
});
|
||||
|
||||
const _militaryInstallationInFlight = new Map();
|
||||
|
||||
@@ -17,12 +37,23 @@ function militaryInstallationsProxy() {
|
||||
`data=${encodeURIComponent(ql)}`,
|
||||
MILITARY_INSTALLATION_MAX_RESPONSE_BYTES,
|
||||
);
|
||||
if (upstream.status >= 400 || upstream.rateLimited || upstream.runtimeError) {
|
||||
throw Object.assign(new Error('Mapped installation upstream unavailable'), {
|
||||
installationReason: upstream.rateLimited ? 'rate_limited'
|
||||
: upstream.status === 504 ? 'timeout'
|
||||
: upstream.runtimeError ? 'query_failed' : 'unavailable',
|
||||
});
|
||||
if (
|
||||
upstream.status >= 400 ||
|
||||
upstream.rateLimited ||
|
||||
upstream.runtimeError
|
||||
) {
|
||||
throw Object.assign(
|
||||
new Error('Mapped installation upstream unavailable'),
|
||||
{
|
||||
installationReason: upstream.rateLimited
|
||||
? 'rate_limited'
|
||||
: upstream.status === 504
|
||||
? 'timeout'
|
||||
: upstream.runtimeError
|
||||
? 'query_failed'
|
||||
: 'unavailable',
|
||||
},
|
||||
);
|
||||
}
|
||||
const parsed = JSON.parse(upstream.body);
|
||||
const elements = Array.isArray(parsed?.elements)
|
||||
@@ -53,7 +84,10 @@ function militaryInstallationsProxy() {
|
||||
return;
|
||||
}
|
||||
if (!_militaryInstallationsRateLimiter(clientKey(req))) {
|
||||
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '5' });
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '5',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
@@ -61,7 +95,11 @@ function militaryInstallationsProxy() {
|
||||
const requested = validMilitaryInstallationBox(url.searchParams);
|
||||
if (!requested) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'A non-dateline bbox no larger than 10 degrees is required' }));
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'A non-dateline bbox no larger than 10 degrees is required',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Query the SNAPPED box, not the raw viewport: neighbouring views then
|
||||
@@ -70,7 +108,9 @@ function militaryInstallationsProxy() {
|
||||
// response, so a truncated tile can never starve the actual viewport. It
|
||||
// is keyed separately so exact and snapped answers never collide.
|
||||
const exact = url.searchParams.get('exact') === '1';
|
||||
const box = exact ? requested : quantizeMilitaryInstallationBox(requested);
|
||||
const box = exact
|
||||
? requested
|
||||
: quantizeMilitaryInstallationBox(requested);
|
||||
// Key at the precision the query actually uses (see militaryInstallationCacheKey).
|
||||
const key = exact
|
||||
? `exact:${militaryInstallationCacheKey(box, 5)}`
|
||||
@@ -81,7 +121,8 @@ function militaryInstallationsProxy() {
|
||||
cacheKey: key,
|
||||
memoryCache: _militaryInstallationCache,
|
||||
inFlight: _militaryInstallationInFlight,
|
||||
readDisk: () => readMilitaryInstallationDisk(key, MILITARY_INSTALLATION_DISK_TTL_MS),
|
||||
readDisk: () =>
|
||||
readMilitaryInstallationDisk(key, MILITARY_INSTALLATION_DISK_TTL_MS),
|
||||
now,
|
||||
});
|
||||
if (preflight.source !== 'UPSTREAM') {
|
||||
@@ -89,8 +130,14 @@ function militaryInstallationsProxy() {
|
||||
_militaryInstallationCache.set(key, preflight.entry);
|
||||
trimMilitaryInstallationCache();
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=60', 'X-Military-Installations': preflight.source });
|
||||
res.end(JSON.stringify({ ...preflight.entry.payload, status: 'cached' }));
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
'X-Military-Installations': preflight.source,
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({ ...preflight.entry.payload, status: 'cached' }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const request = coalesceProxyRequest(
|
||||
@@ -108,7 +155,11 @@ function militaryInstallationsProxy() {
|
||||
res.end(JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
if (cached && now - cached.cachedAt <= MILITARY_INSTALLATION_STALE_MS) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Military-Installations': 'STALE' });
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Military-Installations': 'STALE',
|
||||
});
|
||||
res.end(JSON.stringify({ ...cached.payload, status: 'stale' }));
|
||||
return;
|
||||
}
|
||||
@@ -116,12 +167,24 @@ function militaryInstallationsProxy() {
|
||||
// layer (the same serve-stale rule the Overpass proxy applies).
|
||||
const stale = await readMilitaryInstallationDisk(key, Infinity);
|
||||
if (stale) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Military-Installations': 'STALE-DISK' });
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Military-Installations': 'STALE-DISK',
|
||||
});
|
||||
res.end(JSON.stringify({ ...stale.payload, status: 'stale' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify({ error: 'Mapped installation context is temporarily unavailable', reason: militaryInstallationFailureReason(error) }));
|
||||
res.writeHead(503, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'Mapped installation context is temporarily unavailable',
|
||||
reason: militaryInstallationFailureReason(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { MILITARY_INSTALLATION_CACHE_MS, MILITARY_INSTALLATION_ELEMENT_CAP, MILITARY_INSTALLATION_DISK_TTL_MS, MILITARY_INSTALLATION_DISK_DIR, MILITARY_INSTALLATION_MAX_CACHE } from './constants.js';
|
||||
import {
|
||||
MILITARY_INSTALLATION_CACHE_MS,
|
||||
MILITARY_INSTALLATION_ELEMENT_CAP,
|
||||
MILITARY_INSTALLATION_DISK_TTL_MS,
|
||||
MILITARY_INSTALLATION_DISK_DIR,
|
||||
MILITARY_INSTALLATION_MAX_CACHE,
|
||||
} from './constants.js';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
@@ -32,10 +38,13 @@ async function resolveMilitaryInstallationTier({
|
||||
cacheMs = MILITARY_INSTALLATION_CACHE_MS,
|
||||
}) {
|
||||
const cached = memoryCache.get(cacheKey);
|
||||
if (cached && now - cached.cachedAt <= cacheMs) return { source: 'HIT', entry: cached };
|
||||
if (cached && now - cached.cachedAt <= cacheMs)
|
||||
return { source: 'HIT', entry: cached };
|
||||
if (inFlight.has(cacheKey)) return { source: 'UPSTREAM', entry: null };
|
||||
const disk = await readDisk();
|
||||
return disk ? { source: 'DISK', entry: disk } : { source: 'UPSTREAM', entry: null };
|
||||
return disk
|
||||
? { source: 'DISK', entry: disk }
|
||||
: { source: 'UPSTREAM', entry: null };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,8 +60,11 @@ async function resolveMilitaryInstallationTier({
|
||||
* @returns {?{payload: object, cachedAt: number}}
|
||||
*/
|
||||
function migrateMilitaryInstallationEntry(entry) {
|
||||
if (!entry?.payload || typeof entry.payload.saturated === 'boolean') return entry;
|
||||
const elements = Array.isArray(entry.payload.elements) ? entry.payload.elements : [];
|
||||
if (!entry?.payload || typeof entry.payload.saturated === 'boolean')
|
||||
return entry;
|
||||
const elements = Array.isArray(entry.payload.elements)
|
||||
? entry.payload.elements
|
||||
: [];
|
||||
return {
|
||||
...entry,
|
||||
payload: {
|
||||
@@ -68,13 +80,24 @@ function militaryInstallationDiskFresh(
|
||||
maxAgeMs = MILITARY_INSTALLATION_DISK_TTL_MS,
|
||||
now = Date.now(),
|
||||
) {
|
||||
if (!entry || !Number.isFinite(entry.cachedAt) || !Array.isArray(entry.payload?.elements)) return false;
|
||||
if (
|
||||
!entry ||
|
||||
!Number.isFinite(entry.cachedAt) ||
|
||||
!Array.isArray(entry.payload?.elements)
|
||||
)
|
||||
return false;
|
||||
return now - entry.cachedAt <= maxAgeMs;
|
||||
}
|
||||
|
||||
/** Cache key -> stable disk-cache file path. */
|
||||
function militaryInstallationDiskPath(cacheKey, dir = MILITARY_INSTALLATION_DISK_DIR) {
|
||||
return path.join(dir, `${createHash('sha1').update(cacheKey).digest('hex')}.json`);
|
||||
function militaryInstallationDiskPath(
|
||||
cacheKey,
|
||||
dir = MILITARY_INSTALLATION_DISK_DIR,
|
||||
) {
|
||||
return path.join(
|
||||
dir,
|
||||
`${createHash('sha1').update(cacheKey).digest('hex')}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,7 +111,9 @@ async function readMilitaryInstallationDisk(
|
||||
dir = MILITARY_INSTALLATION_DISK_DIR,
|
||||
) {
|
||||
try {
|
||||
const entry = JSON.parse(await fsp.readFile(militaryInstallationDiskPath(cacheKey, dir), 'utf8'));
|
||||
const entry = JSON.parse(
|
||||
await fsp.readFile(militaryInstallationDiskPath(cacheKey, dir), 'utf8'),
|
||||
);
|
||||
if (!militaryInstallationDiskFresh(entry, maxAgeMs)) return null;
|
||||
return migrateMilitaryInstallationEntry(entry);
|
||||
} catch {
|
||||
@@ -117,7 +142,10 @@ async function writeMilitaryInstallationDisk(
|
||||
await fsp.rename(temp, target);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[Installations Proxy] disk cache write failed:', err?.message || err);
|
||||
console.warn(
|
||||
'[Installations Proxy] disk cache write failed:',
|
||||
err?.message || err,
|
||||
);
|
||||
await fsp.rm(temp, { force: true }).catch(() => {});
|
||||
return false;
|
||||
}
|
||||
@@ -131,4 +159,13 @@ function trimMilitaryInstallationCache() {
|
||||
}
|
||||
}
|
||||
|
||||
export { _militaryInstallationCache, trimMilitaryInstallationCache, writeMilitaryInstallationDisk, resolveMilitaryInstallationTier, readMilitaryInstallationDisk, migrateMilitaryInstallationEntry, militaryInstallationDiskFresh, militaryInstallationDiskPath };
|
||||
export {
|
||||
_militaryInstallationCache,
|
||||
trimMilitaryInstallationCache,
|
||||
writeMilitaryInstallationDisk,
|
||||
resolveMilitaryInstallationTier,
|
||||
readMilitaryInstallationDisk,
|
||||
migrateMilitaryInstallationEntry,
|
||||
militaryInstallationDiskFresh,
|
||||
militaryInstallationDiskPath,
|
||||
};
|
||||
@@ -33,7 +33,11 @@ const MILITARY_INSTALLATION_ELEMENT_CAP = 700;
|
||||
const MILITARY_INSTALLATION_DISK_TTL_MS = 30 * 86_400_000;
|
||||
|
||||
/** Disk-cache directory for mapped installation payloads. */
|
||||
const MILITARY_INSTALLATION_DISK_DIR = path.join(process.cwd(), '.gev-cache', 'military-installations');
|
||||
const MILITARY_INSTALLATION_DISK_DIR = path.join(
|
||||
process.cwd(),
|
||||
'.gev-cache',
|
||||
'military-installations',
|
||||
);
|
||||
|
||||
/**
|
||||
* Cache-key grid step in degrees (~5.5 km).
|
||||
@@ -45,4 +49,13 @@ const MILITARY_INSTALLATION_DISK_DIR = path.join(process.cwd(), '.gev-cache', 'm
|
||||
*/
|
||||
const MILITARY_INSTALLATION_BBOX_STEP_DEG = 0.05;
|
||||
|
||||
export { MILITARY_INSTALLATION_ELEMENT_CAP, MILITARY_INSTALLATION_MAX_RESPONSE_BYTES, MILITARY_INSTALLATION_DISK_TTL_MS, MILITARY_INSTALLATION_STALE_MS, MILITARY_INSTALLATION_CACHE_MS, MILITARY_INSTALLATION_DISK_DIR, MILITARY_INSTALLATION_MAX_CACHE, MILITARY_INSTALLATION_BBOX_STEP_DEG };
|
||||
export {
|
||||
MILITARY_INSTALLATION_ELEMENT_CAP,
|
||||
MILITARY_INSTALLATION_MAX_RESPONSE_BYTES,
|
||||
MILITARY_INSTALLATION_DISK_TTL_MS,
|
||||
MILITARY_INSTALLATION_STALE_MS,
|
||||
MILITARY_INSTALLATION_CACHE_MS,
|
||||
MILITARY_INSTALLATION_DISK_DIR,
|
||||
MILITARY_INSTALLATION_MAX_CACHE,
|
||||
MILITARY_INSTALLATION_BBOX_STEP_DEG,
|
||||
};
|
||||
@@ -7,12 +7,17 @@ import { requiredFiniteQueryNumber } from '../common/query.js';
|
||||
* @param {number} [stepDeg]
|
||||
* @returns {{south:number, west:number, north:number, east:number}}
|
||||
*/
|
||||
function quantizeMilitaryInstallationBox(box, stepDeg = MILITARY_INSTALLATION_BBOX_STEP_DEG) {
|
||||
function quantizeMilitaryInstallationBox(
|
||||
box,
|
||||
stepDeg = MILITARY_INSTALLATION_BBOX_STEP_DEG,
|
||||
) {
|
||||
// Round the ratio first: 29.9999/0.05 lands a hair under an exact grid line
|
||||
// in binary floating point, which would otherwise snap a whole cell too far.
|
||||
const snap = (value, grow) => {
|
||||
const cells = Number((value / stepDeg).toFixed(9));
|
||||
return Number(((grow > 0 ? Math.ceil(cells) : Math.floor(cells)) * stepDeg).toFixed(6));
|
||||
return Number(
|
||||
((grow > 0 ? Math.ceil(cells) : Math.floor(cells)) * stepDeg).toFixed(6),
|
||||
);
|
||||
};
|
||||
return {
|
||||
south: Math.max(-90, snap(box.south, -1)),
|
||||
@@ -46,15 +51,35 @@ function validMilitaryInstallationBox(params) {
|
||||
const north = requiredFiniteQueryNumber(params, 'north');
|
||||
const east = requiredFiniteQueryNumber(params, 'east');
|
||||
if (![south, west, north, east].every(Number.isFinite)) return null;
|
||||
if (south < -90 || north > 90 || west < -180 || east > 180 || south >= north || west >= east) return null;
|
||||
if (
|
||||
south < -90 ||
|
||||
north > 90 ||
|
||||
west < -180 ||
|
||||
east > 180 ||
|
||||
south >= north ||
|
||||
west >= east
|
||||
)
|
||||
return null;
|
||||
if (north - south > 10 || east - west > 10) return null;
|
||||
return { south, west, north, east };
|
||||
}
|
||||
|
||||
/** Safe, evidence-based reason for an installation upstream failure. */
|
||||
function militaryInstallationFailureReason(error) {
|
||||
if (['rate_limited', 'timeout', 'query_failed'].includes(error?.installationReason)) return error.installationReason;
|
||||
return ['AbortError', 'TimeoutError'].includes(error?.name) ? 'timeout' : 'unavailable';
|
||||
if (
|
||||
['rate_limited', 'timeout', 'query_failed'].includes(
|
||||
error?.installationReason,
|
||||
)
|
||||
)
|
||||
return error.installationReason;
|
||||
return ['AbortError', 'TimeoutError'].includes(error?.name)
|
||||
? 'timeout'
|
||||
: 'unavailable';
|
||||
}
|
||||
|
||||
export { validMilitaryInstallationBox, quantizeMilitaryInstallationBox, militaryInstallationCacheKey, militaryInstallationFailureReason };
|
||||
export {
|
||||
validMilitaryInstallationBox,
|
||||
quantizeMilitaryInstallationBox,
|
||||
militaryInstallationCacheKey,
|
||||
militaryInstallationFailureReason,
|
||||
};
|
||||
@@ -9,13 +9,22 @@ import { createRealtimeTokenHandler } from './openai/realtime.js';
|
||||
* Keeps OPENAI_API_KEY server-side while the browser connects to the
|
||||
* Realtime API over WebRTC with a short-lived secret.
|
||||
*/
|
||||
function openAiRealtimeProxy({ sourceRoot = defaultSourceRoot, annotationGuidance } = {}) {
|
||||
function openAiRealtimeProxy({
|
||||
sourceRoot = defaultSourceRoot,
|
||||
annotationGuidance,
|
||||
} = {}) {
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/openai/hud-summary', handleHudSummary);
|
||||
|
||||
middlewares.use('/api/realtime/debug-log', createDebugLogHandler({ sourceRoot }));
|
||||
middlewares.use(
|
||||
'/api/realtime/debug-log',
|
||||
createDebugLogHandler({ sourceRoot }),
|
||||
);
|
||||
|
||||
middlewares.use('/api/realtime/token', createRealtimeTokenHandler({ annotationGuidance }));
|
||||
middlewares.use(
|
||||
'/api/realtime/token',
|
||||
createRealtimeTokenHandler({ annotationGuidance }),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -16,4 +16,12 @@ const OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT = 0.5;
|
||||
|
||||
const OPENAI_HUD_SUMMARY_MODEL_DEFAULT = 'gpt-5-nano';
|
||||
|
||||
export { OPENAI_HUD_SUMMARY_MODEL_DEFAULT, OPENAI_REALTIME_MODEL_MINI_DEFAULT, OPENAI_REALTIME_MODEL_DEFAULT, OPENAI_REALTIME_VOICE_DEFAULT, OPENAI_REALTIME_REASONING_DEFAULT, OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT, OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT };
|
||||
export {
|
||||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
||||
OPENAI_REALTIME_MODEL_MINI_DEFAULT,
|
||||
OPENAI_REALTIME_MODEL_DEFAULT,
|
||||
OPENAI_REALTIME_VOICE_DEFAULT,
|
||||
OPENAI_REALTIME_REASONING_DEFAULT,
|
||||
OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT,
|
||||
OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT,
|
||||
};
|
||||
@@ -6,9 +6,12 @@ import fs from 'node:fs';
|
||||
const REALTIME_DEBUG_LOG_MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
const REALTIME_DEBUG_LOG_DIR = path.join(sourceRoot, '.gev-logs');
|
||||
const REALTIME_DEBUG_LOG_FILE = path.join(REALTIME_DEBUG_LOG_DIR, 'realtime-conversations.jsonl');
|
||||
return async (req, res) => {
|
||||
const REALTIME_DEBUG_LOG_DIR = path.join(sourceRoot, '.gev-logs');
|
||||
const REALTIME_DEBUG_LOG_FILE = path.join(
|
||||
REALTIME_DEBUG_LOG_DIR,
|
||||
'realtime-conversations.jsonl',
|
||||
);
|
||||
return async (req, res) => {
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
@@ -20,16 +23,23 @@ return async (req, res) => {
|
||||
const body = await readRequestBody(req, REALTIME_DEBUG_LOG_MAX_BYTES);
|
||||
const record = JSON.parse(body || '{}');
|
||||
fs.mkdirSync(REALTIME_DEBUG_LOG_DIR, { recursive: true });
|
||||
fs.appendFileSync(REALTIME_DEBUG_LOG_FILE, `${JSON.stringify({
|
||||
fs.appendFileSync(
|
||||
REALTIME_DEBUG_LOG_FILE,
|
||||
`${JSON.stringify({
|
||||
loggedAt: new Date().toISOString(),
|
||||
...record,
|
||||
})}\n`);
|
||||
})}\n`,
|
||||
);
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
} catch (error) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: error?.message || 'Failed to write Realtime debug log' }));
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error?.message || 'Failed to write Realtime debug log',
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ function extractOpenAiResponseText(data) {
|
||||
}
|
||||
if (!Array.isArray(data?.output)) return '';
|
||||
return data.output
|
||||
.flatMap((item) => Array.isArray(item?.content) ? item.content : [])
|
||||
.flatMap((item) => (Array.isArray(item?.content) ? item.content : []))
|
||||
.map((part) => part?.text || part?.output_text || '')
|
||||
.join(' ')
|
||||
.trim();
|
||||
@@ -58,7 +58,9 @@ async function handleHudSummary(req, res) {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: process.env.OPENAI_HUD_SUMMARY_MODEL || OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
||||
model:
|
||||
process.env.OPENAI_HUD_SUMMARY_MODEL ||
|
||||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
||||
instructions: [
|
||||
"Write one concise intelligence-HUD summary for God's Eye View.",
|
||||
'Use only the supplied place, street, nearby-place, and enabled-layer text labels.',
|
||||
@@ -76,15 +78,23 @@ async function handleHudSummary(req, res) {
|
||||
res.statusCode = response.ok && summary ? 200 : response.status || 502;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(JSON.stringify({
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
summary: summary || null,
|
||||
error: response.ok ? null : data.error?.message || 'OpenAI HUD summary request failed',
|
||||
}));
|
||||
error: response.ok
|
||||
? null
|
||||
: data.error?.message || 'OpenAI HUD summary request failed',
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
res.statusCode = 502;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: error?.message || 'OpenAI HUD summary request failed' }));
|
||||
}
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error?.message || 'OpenAI HUD summary request failed',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { handleHudSummary };
|
||||
@@ -1,8 +1,10 @@
|
||||
const DEFAULT_ANNOTATION_GUIDANCE =
|
||||
'Use a single annotate_map call with several annotations when you are describing multiple related places at once. Set flyTo true only when the user is not already looking at the place; if every mark in a call lands off-screen the app auto-frames them, so when unsure leave flyTo false. Do NOT say out loud that you are drawing, highlighting, or annotating — just speak naturally about the places while the marks appear. ANNOTATIONS ACCUMULATE AND PERSIST — keep adding marks as you explore; you can fly around, change topic, and jump between far-apart places and the marks STAY, so the user can build up the map and show people things. Do NOT clear on your own initiative: never pass clearPrevious, and call clear_annotations ONLY when the user EXPLICITLY asks to clear or reset the map.';
|
||||
|
||||
const DEFAULT_ANNOTATION_GUIDANCE = 'Use a single annotate_map call with several annotations when you are describing multiple related places at once. Set flyTo true only when the user is not already looking at the place; if every mark in a call lands off-screen the app auto-frames them, so when unsure leave flyTo false. Do NOT say out loud that you are drawing, highlighting, or annotating — just speak naturally about the places while the marks appear. ANNOTATIONS ACCUMULATE AND PERSIST — keep adding marks as you explore; you can fly around, change topic, and jump between far-apart places and the marks STAY, so the user can build up the map and show people things. Do NOT clear on your own initiative: never pass clearPrevious, and call clear_annotations ONLY when the user EXPLICITLY asks to clear or reset the map.';
|
||||
|
||||
function realtimeInstructions(annotationGuidance = DEFAULT_ANNOTATION_GUIDANCE) {
|
||||
return [
|
||||
function realtimeInstructions(
|
||||
annotationGuidance = DEFAULT_ANNOTATION_GUIDANCE,
|
||||
) {
|
||||
return [
|
||||
"You are GEV Voice Control, a concise voice controller for a Cesium geospatial app called God's Eye View.",
|
||||
'Have a natural spoken conversation with the user while the mic session is active.',
|
||||
'Do not require a wake phrase. Treat direct commands like "zoom into London" or "open datacenters" as GEV control requests.',
|
||||
|
||||
@@ -9,7 +9,10 @@ let _openAiRateLimiter;
|
||||
|
||||
/** OpenAI cost endpoints (realtime/token + hud-summary). Null = unlimited (default). */
|
||||
function openAiRateLimiter() {
|
||||
if (_openAiRateLimiter === undefined) _openAiRateLimiter = makeOptInRateLimiter(process.env.GEV_RATELIMIT_OPENAI_PER_MIN);
|
||||
if (_openAiRateLimiter === undefined)
|
||||
_openAiRateLimiter = makeOptInRateLimiter(
|
||||
process.env.GEV_RATELIMIT_OPENAI_PER_MIN,
|
||||
);
|
||||
return _openAiRateLimiter;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
import { resolveVoiceModel, isKnownVoiceTier } from '../../../src/voice/voiceCost.js';
|
||||
import { OPENAI_REALTIME_MODEL_MINI_DEFAULT, OPENAI_REALTIME_MODEL_DEFAULT, OPENAI_REALTIME_VOICE_DEFAULT, OPENAI_REALTIME_REASONING_DEFAULT, OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT, OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT } from './constants.js';
|
||||
import {
|
||||
resolveVoiceModel,
|
||||
isKnownVoiceTier,
|
||||
} from '../../../src/voice/voiceCost.js';
|
||||
import {
|
||||
OPENAI_REALTIME_MODEL_MINI_DEFAULT,
|
||||
OPENAI_REALTIME_MODEL_DEFAULT,
|
||||
OPENAI_REALTIME_VOICE_DEFAULT,
|
||||
OPENAI_REALTIME_REASONING_DEFAULT,
|
||||
OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT,
|
||||
OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT,
|
||||
} from './constants.js';
|
||||
import { realtimeInstructions } from './instructions.js';
|
||||
import { GEV_REALTIME_TOOLS } from './tools.js';
|
||||
|
||||
function createRealtimeTokenHandler({ annotationGuidance } = {}) {
|
||||
return async (req, res) => {
|
||||
return async (req, res) => {
|
||||
if (req.method !== 'GET' && req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
@@ -32,7 +42,9 @@ return async (req, res) => {
|
||||
// a wrong upstream model id is then a config fix, not a code change.
|
||||
const requestedTier = (() => {
|
||||
try {
|
||||
return new URL(req.url || '', 'http://localhost').searchParams.get('tier');
|
||||
return new URL(req.url || '', 'http://localhost').searchParams.get(
|
||||
'tier',
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -40,17 +52,31 @@ return async (req, res) => {
|
||||
const tier = resolveVoiceModel(requestedTier).tier;
|
||||
const model =
|
||||
tier === 'mini'
|
||||
? process.env.OPENAI_REALTIME_MODEL_MINI || OPENAI_REALTIME_MODEL_MINI_DEFAULT
|
||||
? process.env.OPENAI_REALTIME_MODEL_MINI ||
|
||||
OPENAI_REALTIME_MODEL_MINI_DEFAULT
|
||||
: process.env.OPENAI_REALTIME_MODEL || OPENAI_REALTIME_MODEL_DEFAULT;
|
||||
const voice = process.env.OPENAI_REALTIME_VOICE || OPENAI_REALTIME_VOICE_DEFAULT;
|
||||
const effort = process.env.OPENAI_REALTIME_REASONING_EFFORT || OPENAI_REALTIME_REASONING_DEFAULT;
|
||||
const contextTokenLimit = Math.round(Math.max(
|
||||
const voice =
|
||||
process.env.OPENAI_REALTIME_VOICE || OPENAI_REALTIME_VOICE_DEFAULT;
|
||||
const effort =
|
||||
process.env.OPENAI_REALTIME_REASONING_EFFORT ||
|
||||
OPENAI_REALTIME_REASONING_DEFAULT;
|
||||
const contextTokenLimit = Math.round(
|
||||
Math.max(
|
||||
1000,
|
||||
Math.min(12000, Number(process.env.OPENAI_REALTIME_CONTEXT_TOKENS) || OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT)
|
||||
));
|
||||
Math.min(
|
||||
12000,
|
||||
Number(process.env.OPENAI_REALTIME_CONTEXT_TOKENS) ||
|
||||
OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT,
|
||||
),
|
||||
),
|
||||
);
|
||||
const contextRetentionRatio = Math.max(
|
||||
0.1,
|
||||
Math.min(1, Number(process.env.OPENAI_REALTIME_CONTEXT_RETENTION) || OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT)
|
||||
Math.min(
|
||||
1,
|
||||
Number(process.env.OPENAI_REALTIME_CONTEXT_RETENTION) ||
|
||||
OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT,
|
||||
),
|
||||
);
|
||||
const sessionConfig = {
|
||||
session: {
|
||||
@@ -83,7 +109,9 @@ return async (req, res) => {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.openai.com/v1/realtime/client_secrets', {
|
||||
const response = await fetch(
|
||||
'https://api.openai.com/v1/realtime/client_secrets',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
@@ -91,10 +119,14 @@ return async (req, res) => {
|
||||
'OpenAI-Safety-Identifier': 'gev-local-dev',
|
||||
},
|
||||
body: JSON.stringify(sessionConfig),
|
||||
});
|
||||
},
|
||||
);
|
||||
const body = await response.text();
|
||||
res.statusCode = response.status;
|
||||
res.setHeader('Content-Type', response.headers.get('content-type') || 'application/json');
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
response.headers.get('content-type') || 'application/json',
|
||||
);
|
||||
// Which tier/model this secret was actually minted for. The upstream
|
||||
// body is passed through untouched (the client parses it verbatim), so
|
||||
// these headers are the authoritative echo — including the case where a
|
||||
@@ -108,7 +140,11 @@ return async (req, res) => {
|
||||
} catch (error) {
|
||||
res.statusCode = 502;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: error?.message || 'Failed to create Realtime token' }));
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error?.message || 'Failed to create Realtime token',
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
+455
-105
@@ -1,38 +1,52 @@
|
||||
|
||||
const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'fly_to_location',
|
||||
description: "Fly the God's Eye View camera to a known city, geocoded country/region/city/landmark, or explicit WGS84 coordinate. Countries/cities frame the whole place; landmarks/buildings use close framing.",
|
||||
description:
|
||||
"Fly the God's Eye View camera to a known city, geocoded country/region/city/landmark, or explicit WGS84 coordinate. Countries/cities frame the whole place; landmarks/buildings use close framing.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
locationId: {
|
||||
type: 'string',
|
||||
enum: ['austin', 'sf', 'nyc', 'tokyo', 'london', 'paris', 'dubai', 'dc'],
|
||||
description: 'Known city preset ID. Use when the requested place matches one of these cities.',
|
||||
enum: [
|
||||
'austin',
|
||||
'sf',
|
||||
'nyc',
|
||||
'tokyo',
|
||||
'london',
|
||||
'paris',
|
||||
'dubai',
|
||||
'dc',
|
||||
],
|
||||
description:
|
||||
'Known city preset ID. Use when the requested place matches one of these cities.',
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Plain place search query, e.g. "London", "Eiffel Tower", or "Dubai Marina".',
|
||||
description:
|
||||
'Plain place search query, e.g. "London", "Eiffel Tower", or "Dubai Marina".',
|
||||
},
|
||||
latitude: { type: 'number', minimum: -90, maximum: 90 },
|
||||
longitude: { type: 'number', minimum: -180, maximum: 180 },
|
||||
viewMode: {
|
||||
type: 'string',
|
||||
enum: ['close', 'overview'],
|
||||
description: 'Optional framing intent. Usually omit this; GEV infers whole-place framing for countries/cities and close framing for landmarks.',
|
||||
description:
|
||||
'Optional framing intent. Usually omit this; GEV infers whole-place framing for countries/cities and close framing for landmarks.',
|
||||
},
|
||||
rangeM: {
|
||||
type: 'number',
|
||||
minimum: 100,
|
||||
maximum: 20000000,
|
||||
description: 'Optional camera range from the target in meters. Omit it for automatic whole-country/whole-city or close-landmark framing; provide it only when the user explicitly requests a numeric height or distance.',
|
||||
description:
|
||||
'Optional camera range from the target in meters. Omit it for automatic whole-country/whole-city or close-landmark framing; provide it only when the user explicitly requests a numeric height or distance.',
|
||||
},
|
||||
waitForArrival: {
|
||||
type: 'boolean',
|
||||
description: 'Set true when a later tool depends on the destination viewport. The result then waits for the camera flight and returns arrived=true; cancellation returns ok=false.',
|
||||
description:
|
||||
'Set true when a later tool depends on the destination viewport. The result then waits for the camera flight and returns arrived=true; cancellation returns ok=false.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -40,7 +54,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'select_nearest_aircraft',
|
||||
description: 'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result. This does not open Contacts or Cockpit.',
|
||||
description:
|
||||
'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result. This does not open Contacts or Cockpit.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -48,12 +63,23 @@ const GEV_REALTIME_TOOLS = [
|
||||
layerId: {
|
||||
type: 'string',
|
||||
enum: ['flights', 'military'],
|
||||
description: 'Aircraft layer to enable and search. Use flights unless the user explicitly asks for military aircraft.',
|
||||
description:
|
||||
'Aircraft layer to enable and search. Use flights unless the user explicitly asks for military aircraft.',
|
||||
},
|
||||
locationId: {
|
||||
type: 'string',
|
||||
enum: ['austin', 'sf', 'nyc', 'tokyo', 'london', 'paris', 'dubai', 'dc'],
|
||||
description: 'Known city preset ID when the place matches one of these cities.',
|
||||
enum: [
|
||||
'austin',
|
||||
'sf',
|
||||
'nyc',
|
||||
'tokyo',
|
||||
'london',
|
||||
'paris',
|
||||
'dubai',
|
||||
'dc',
|
||||
],
|
||||
description:
|
||||
'Known city preset ID when the place matches one of these cities.',
|
||||
},
|
||||
locationQuery: {
|
||||
type: 'string',
|
||||
@@ -69,7 +95,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'adjust_camera_zoom',
|
||||
description: 'Move the current Cesium camera closer to or farther from what it is presently looking at. Use for relative zoom requests without changing location.',
|
||||
description:
|
||||
'Move the current Cesium camera closer to or farther from what it is presently looking at. Use for relative zoom requests without changing location.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -81,7 +108,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
amount: {
|
||||
type: 'string',
|
||||
enum: ['little', 'medium', 'lot'],
|
||||
description: 'Use little for phrases like "a bit" or "a little", medium for ordinary zoom requests, and lot for "way out/in".',
|
||||
description:
|
||||
'Use little for phrases like "a bit" or "a little", medium for ordinary zoom requests, and lot for "way out/in".',
|
||||
},
|
||||
},
|
||||
required: ['direction', 'amount'],
|
||||
@@ -90,7 +118,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'zoom_to_globe',
|
||||
description: 'Pull the camera out to an ABSOLUTE full-Earth globe view (~18,000 km altitude, the whole planet in frame), keeping the current region centered. Use for "globe view", "whole earth", "see the planet", "zoom all the way out". Never use adjust_camera_zoom for these — its relative steps cannot reach the globe.',
|
||||
description:
|
||||
'Pull the camera out to an ABSOLUTE full-Earth globe view (~18,000 km altitude, the whole planet in frame), keeping the current region centered. Use for "globe view", "whole earth", "see the planet", "zoom all the way out". Never use adjust_camera_zoom for these — its relative steps cannot reach the globe.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -134,7 +163,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'show_data_layers_menu',
|
||||
description: 'Open the data layers dropdown/menu and optionally scroll to a specific layer row without toggling it.',
|
||||
description:
|
||||
'Open the data layers dropdown/menu and optionally scroll to a specific layer row without toggling it.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -171,7 +201,16 @@ const GEV_REALTIME_TOOLS = [
|
||||
properties: {
|
||||
panelId: {
|
||||
type: 'string',
|
||||
enum: ['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel'],
|
||||
enum: [
|
||||
'data-panel',
|
||||
'location-bar',
|
||||
'control-panel',
|
||||
'cctv-panel',
|
||||
'radio-panel',
|
||||
'scene-panel',
|
||||
'pp-toggles',
|
||||
'global-context-panel',
|
||||
],
|
||||
},
|
||||
open: { type: 'boolean' },
|
||||
},
|
||||
@@ -181,7 +220,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'set_context_mode',
|
||||
description: 'Enter or exit the Global Context sub-mode used by Contacts and Space Missions. Use Contacts only when the user explicitly requests Contacts, and Space Missions only when explicitly requested. A request to open the parent Context panel alone uses set_panel_open and must not activate either sub-mode. Selecting an aircraft does not imply Context.',
|
||||
description:
|
||||
'Enter or exit the Global Context sub-mode used by Contacts and Space Missions. Use Contacts only when the user explicitly requests Contacts, and Space Missions only when explicitly requested. A request to open the parent Context panel alone uses set_panel_open and must not activate either sub-mode. Selecting an aircraft does not imply Context.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -198,7 +238,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'control_cockpit',
|
||||
description: 'Read or control Cockpit when the user explicitly requests Cockpit: establish Contacts and enter from a selected or tracked aircraft; exit; or navigate nearby Contacts with optional filters. Selecting or viewing an aircraft alone must not enter Cockpit.',
|
||||
description:
|
||||
'Read or control Cockpit when the user explicitly requests Cockpit: establish Contacts and enter from a selected or tracked aircraft; exit; or navigate nearby Contacts with optional filters. Selecting or viewing an aircraft alone must not enter Cockpit.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -206,16 +247,24 @@ const GEV_REALTIME_TOOLS = [
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['enter', 'exit', 'previous', 'next', 'prev', 'status'],
|
||||
description: 'previous/next (or prev) navigates through nearby contacts in Cockpit context.',
|
||||
description:
|
||||
'previous/next (or prev) navigates through nearby contacts in Cockpit context.',
|
||||
},
|
||||
targetLayer: {
|
||||
type: 'string',
|
||||
enum: ['flights', 'military', 'ais-live-vessels', 'military-installations'],
|
||||
description: 'Optional contact layer filter for next/previous (for example military for a military-only cycle).',
|
||||
enum: [
|
||||
'flights',
|
||||
'military',
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
],
|
||||
description:
|
||||
'Optional contact layer filter for next/previous (for example military for a military-only cycle).',
|
||||
},
|
||||
aircraftClass: {
|
||||
type: 'string',
|
||||
description: 'Optional aircraft class filter (for example helicopter) when using next/previous navigation.',
|
||||
description:
|
||||
'Optional aircraft class filter (for example helicopter) when using next/previous navigation.',
|
||||
},
|
||||
},
|
||||
required: ['action'],
|
||||
@@ -231,7 +280,15 @@ const GEV_REALTIME_TOOLS = [
|
||||
properties: {
|
||||
style: {
|
||||
type: 'string',
|
||||
enum: ['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow'],
|
||||
enum: [
|
||||
'normal',
|
||||
'retro',
|
||||
'surveillance',
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
],
|
||||
},
|
||||
},
|
||||
required: ['style'],
|
||||
@@ -240,7 +297,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'get_entity_context',
|
||||
description: 'Get current GEV scene context, including basemap/3D-tile target context, selected entity metadata if active, and entities currently visible in the camera view.',
|
||||
description:
|
||||
'Get current GEV scene context, including basemap/3D-tile target context, selected entity metadata if active, and entities currently visible in the camera view.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -248,7 +306,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
scope: {
|
||||
type: 'string',
|
||||
enum: ['auto', 'selected', 'in_view'],
|
||||
description: 'Use auto by default. selected returns the clicked/selected entity; in_view returns visible entities near the screen center.',
|
||||
description:
|
||||
'Use auto by default. selected returns the clicked/selected entity; in_view returns visible entities near the screen center.',
|
||||
},
|
||||
layerId: {
|
||||
type: 'string',
|
||||
@@ -271,7 +330,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'get_current_view_state',
|
||||
description: 'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action.',
|
||||
description:
|
||||
'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -281,12 +341,17 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'set_hud',
|
||||
description: 'Control the intelligence HUD overlay: visibility and/or layout variant.',
|
||||
description:
|
||||
'Control the intelligence HUD overlay: visibility and/or layout variant.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
visible: { type: 'string', enum: ['on', 'off', 'auto'], description: 'auto restores style-driven show/hide.' },
|
||||
visible: {
|
||||
type: 'string',
|
||||
enum: ['on', 'off', 'auto'],
|
||||
description: 'auto restores style-driven show/hide.',
|
||||
},
|
||||
layout: { type: 'string', enum: ['tactical', 'operator', 'minimal'] },
|
||||
},
|
||||
},
|
||||
@@ -294,30 +359,52 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'set_detection',
|
||||
description: 'Control the detection overlay: on/off, density-derived Sparse/Balanced/Dense profile, and Elastic/Weighted layer allocation.',
|
||||
description:
|
||||
'Control the detection overlay: on/off, density-derived Sparse/Balanced/Dense profile, and Elastic/Weighted layer allocation.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
enabled: { type: 'boolean', description: 'false turns detection OFF; true restores the current density-derived profile.' },
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'false turns detection OFF; true restores the current density-derived profile.',
|
||||
},
|
||||
mode: { type: 'string', enum: ['sparse', 'balanced', 'dense'] },
|
||||
densityPct: { type: 'number', description: 'Density snaps to 0, 25, 50, 75, or 100 and derives the active profile.' },
|
||||
allocationStrategy: { type: 'string', enum: ['elastic', 'weighted'], description: 'Elastic splits evenly then lends unused slots; Weighted follows demand and semantic weight.' },
|
||||
densityPct: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Density snaps to 0, 25, 50, 75, or 100 and derives the active profile.',
|
||||
},
|
||||
allocationStrategy: {
|
||||
type: 'string',
|
||||
enum: ['elastic', 'weighted'],
|
||||
description:
|
||||
'Elastic splits evenly then lends unused slots; Weighted follows demand and semantic weight.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'set_map_stack',
|
||||
description: 'Switch the basemap/imagery stack (NOT the satellites data layer and NOT a visual style filter).',
|
||||
description:
|
||||
'Switch the basemap/imagery stack (NOT the satellites data layer and NOT a visual style filter).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
stack: {
|
||||
type: 'string',
|
||||
enum: ['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm'],
|
||||
description: 'photoreal = Google 3D. Use bing-aerial only when the user explicitly says "Bing aerial" — "satellite(s)" never means a basemap; only the explicit phrase "Esri" / "Esri imagery" means esri-imagery.',
|
||||
enum: [
|
||||
'photoreal',
|
||||
'bing-aerial',
|
||||
'bing-labels',
|
||||
'esri-imagery',
|
||||
'osm',
|
||||
],
|
||||
description:
|
||||
'photoreal = Google 3D. Use bing-aerial only when the user explicitly says "Bing aerial" — "satellite(s)" never means a basemap; only the explicit phrase "Esri" / "Esri imagery" means esri-imagery.',
|
||||
},
|
||||
},
|
||||
required: ['stack'],
|
||||
@@ -326,7 +413,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'set_post_processing',
|
||||
description: 'Control bloom and sharpen post-processing toggles and intensities.',
|
||||
description:
|
||||
'Control bloom and sharpen post-processing toggles and intensities.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -336,7 +424,10 @@ const GEV_REALTIME_TOOLS = [
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
enabled: { type: 'boolean' },
|
||||
intensityPct: { type: 'number', description: '0-200 (UI percent).' },
|
||||
intensityPct: {
|
||||
type: 'number',
|
||||
description: '0-200 (UI percent).',
|
||||
},
|
||||
},
|
||||
},
|
||||
sharpen: {
|
||||
@@ -344,7 +435,10 @@ const GEV_REALTIME_TOOLS = [
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
enabled: { type: 'boolean' },
|
||||
intensityPct: { type: 'number', description: '0-100 (UI percent).' },
|
||||
intensityPct: {
|
||||
type: 'number',
|
||||
description: '0-100 (UI percent).',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -353,13 +447,20 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'control_scene',
|
||||
description: 'Cinematic scene playback: list scenes, play one scene by name, stop, advance, or read status. Play starts a single named scene and returns immediately.',
|
||||
description:
|
||||
'Cinematic scene playback: list scenes, play one scene by name, stop, advance, or read status. Play starts a single named scene and returns immediately.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['list', 'play', 'stop', 'next', 'status'] },
|
||||
sceneId: { type: 'string', description: 'Scene id or (partial) title for play.' },
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['list', 'play', 'stop', 'next', 'status'],
|
||||
},
|
||||
sceneId: {
|
||||
type: 'string',
|
||||
description: 'Scene id or (partial) title for play.',
|
||||
},
|
||||
},
|
||||
required: ['action'],
|
||||
},
|
||||
@@ -367,14 +468,38 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'control_cctv',
|
||||
description: 'CCTV camera operations: enable/disable the layer, select a camera by name, next/prev/nearest/focus, toggle coverage wedges / projection overlay / auto-hop, "viewshed" for color-coded per-camera coverage volumes, and "adjust" for the on-camera calibration gizmo.',
|
||||
description:
|
||||
'CCTV camera operations: enable/disable the layer, select a camera by name, next/prev/nearest/focus, toggle coverage wedges / projection overlay / auto-hop, "viewshed" for color-coded per-camera coverage volumes, and "adjust" for the on-camera calibration gizmo.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['enable', 'disable', 'select', 'next', 'prev', 'nearest', 'focus', 'coverage', 'viewshed', 'adjust', 'projection', 'autohop'] },
|
||||
cameraQuery: { type: 'string', description: 'Camera name or id for select.' },
|
||||
enabled: { type: 'boolean', description: 'Explicit on/off for coverage/viewshed/adjust/projection/autohop; omit to toggle.' },
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'enable',
|
||||
'disable',
|
||||
'select',
|
||||
'next',
|
||||
'prev',
|
||||
'nearest',
|
||||
'focus',
|
||||
'coverage',
|
||||
'viewshed',
|
||||
'adjust',
|
||||
'projection',
|
||||
'autohop',
|
||||
],
|
||||
},
|
||||
cameraQuery: {
|
||||
type: 'string',
|
||||
description: 'Camera name or id for select.',
|
||||
},
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Explicit on/off for coverage/viewshed/adjust/projection/autohop; omit to toggle.',
|
||||
},
|
||||
},
|
||||
required: ['action'],
|
||||
},
|
||||
@@ -382,32 +507,85 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'control_radio',
|
||||
description: 'Control Internet Radio playback without moving the map. Use select whenever the request includes a station category, name, country, coordinates, or nearby place—even when the user says play. Use play only for an unqualified "turn on/start the radio" request so the current or nearest station begins. Enable only reveals the Radio layer/markers without audio. Also supports disable, resume, pause, stop, next/previous, volume, and status.',
|
||||
description:
|
||||
'Control Internet Radio playback without moving the map. Use select whenever the request includes a station category, name, country, coordinates, or nearby place—even when the user says play. Use play only for an unqualified "turn on/start the radio" request so the current or nearest station begins. Enable only reveals the Radio layer/markers without audio. Also supports disable, resume, pause, stop, next/previous, volume, and status.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['enable', 'disable', 'play', 'resume', 'pause', 'stop', 'next', 'previous', 'volume', 'select', 'status'],
|
||||
description: 'Use select for any request qualified by category, station, country, coordinates, or place. Use play only for an unqualified turn on/start/listen request. Use enable only when the user explicitly asks to show or enable the Radio layer or its markers without requesting audio.',
|
||||
enum: [
|
||||
'enable',
|
||||
'disable',
|
||||
'play',
|
||||
'resume',
|
||||
'pause',
|
||||
'stop',
|
||||
'next',
|
||||
'previous',
|
||||
'volume',
|
||||
'select',
|
||||
'status',
|
||||
],
|
||||
description:
|
||||
'Use select for any request qualified by category, station, country, coordinates, or place. Use play only for an unqualified turn on/start/listen request. Use enable only when the user explicitly asks to show or enable the Radio layer or its markers without requesting audio.',
|
||||
},
|
||||
volumePct: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
maximum: 100,
|
||||
description:
|
||||
'Required for volume; sets the persistent Radio playback volume.',
|
||||
},
|
||||
volumePct: { type: 'number', minimum: 0, maximum: 100, description: 'Required for volume; sets the persistent Radio playback volume.' },
|
||||
category: {
|
||||
type: 'string',
|
||||
enum: ['all', 'news', 'talk', 'weather', 'public-safety', 'aviation-marine', 'traffic-transit', 'music'],
|
||||
description: 'Station category for select/next/previous. When the user requests playback with a category, action must be select, not play.',
|
||||
enum: [
|
||||
'all',
|
||||
'news',
|
||||
'talk',
|
||||
'weather',
|
||||
'public-safety',
|
||||
'aviation-marine',
|
||||
'traffic-transit',
|
||||
'music',
|
||||
],
|
||||
description:
|
||||
'Station category for select/next/previous. When the user requests playback with a category, action must be select, not play.',
|
||||
},
|
||||
locationId: {
|
||||
type: 'string',
|
||||
enum: ['austin', 'sf', 'nyc', 'tokyo', 'london', 'paris', 'dubai', 'dc'],
|
||||
enum: [
|
||||
'austin',
|
||||
'sf',
|
||||
'nyc',
|
||||
'tokyo',
|
||||
'london',
|
||||
'paris',
|
||||
'dubai',
|
||||
'dc',
|
||||
],
|
||||
description: 'Known nearby-city anchor for select.',
|
||||
},
|
||||
locationQuery: { type: 'string', maxLength: 120, description: 'Place to search near, such as "Austin, Texas" or "Seattle". Selection does not fly the camera.' },
|
||||
locationQuery: {
|
||||
type: 'string',
|
||||
maxLength: 120,
|
||||
description:
|
||||
'Place to search near, such as "Austin, Texas" or "Seattle". Selection does not fly the camera.',
|
||||
},
|
||||
latitude: { type: 'number', minimum: -90, maximum: 90 },
|
||||
longitude: { type: 'number', minimum: -180, maximum: 180 },
|
||||
country: { type: 'string', maxLength: 80, description: 'Country code or name filter, for example US or United States.' },
|
||||
stationQuery: { type: 'string', maxLength: 120, description: 'Optional station name/tag substring.' },
|
||||
country: {
|
||||
type: 'string',
|
||||
maxLength: 80,
|
||||
description:
|
||||
'Country code or name filter, for example US or United States.',
|
||||
},
|
||||
stationQuery: {
|
||||
type: 'string',
|
||||
maxLength: 120,
|
||||
description: 'Optional station name/tag substring.',
|
||||
},
|
||||
},
|
||||
required: ['action'],
|
||||
},
|
||||
@@ -415,13 +593,22 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'track_entity',
|
||||
description: 'Find and follow a specific aircraft (callsign/ICAO hex), ship (name/MMSI), or satellite (name/NORAD id) on enabled layers. Camera follows the entity.',
|
||||
description:
|
||||
'Find and follow a specific aircraft (callsign/ICAO hex), ship (name/MMSI), or satellite (name/NORAD id) on enabled layers. Camera follows the entity.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Callsign, ship name, satellite name, ICAO hex, MMSI, or NORAD id.' },
|
||||
layerId: { type: 'string', description: 'Optional layer hint: flights | military | ais-live-vessels | satellites.' },
|
||||
query: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Callsign, ship name, satellite name, ICAO hex, MMSI, or NORAD id.',
|
||||
},
|
||||
layerId: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional layer hint: flights | military | ais-live-vessels | satellites.',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
@@ -429,7 +616,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'stop_tracking',
|
||||
description: 'Stop following the tracked aircraft/satellite and clear any selected vessel.',
|
||||
description:
|
||||
'Stop following the tracked aircraft/satellite and clear any selected vessel.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -439,13 +627,21 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'frame_overhead',
|
||||
description: 'Cinematically frame entities near the current view: pulls the camera back and angles it so nearby aircraft, ships, or satellites are visible together.',
|
||||
description:
|
||||
'Cinematically frame entities near the current view: pulls the camera back and angles it so nearby aircraft, ships, or satellites are visible together.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
target: { type: 'string', enum: ['flights', 'military', 'satellites', 'vessels'] },
|
||||
radiusKm: { type: 'number', description: 'Search radius around the view target. Defaults: 150 aircraft, 120 ships, 3000 satellites.' },
|
||||
target: {
|
||||
type: 'string',
|
||||
enum: ['flights', 'military', 'satellites', 'vessels'],
|
||||
},
|
||||
radiusKm: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Search radius around the view target. Defaults: 150 aircraft, 120 ships, 3000 satellites.',
|
||||
},
|
||||
},
|
||||
required: ['target'],
|
||||
},
|
||||
@@ -453,14 +649,16 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'annotate_map',
|
||||
description: "Draw annotations on the 3D map to visually point out what you are talking about — like sketching on a whiteboard over the world. Use this whenever you mention a specific place, building, campus, boundary, district, or a relationship between two places, so the user can SEE what you mean. Give place NAMES (preferred) or explicit lat/lng; the app resolves them to real-world positions and real building/area outlines — never guess pixel positions. Call this as you begin describing something, and you may mark several places in one call.",
|
||||
description:
|
||||
'Draw annotations on the 3D map to visually point out what you are talking about — like sketching on a whiteboard over the world. Use this whenever you mention a specific place, building, campus, boundary, district, or a relationship between two places, so the user can SEE what you mean. Give place NAMES (preferred) or explicit lat/lng; the app resolves them to real-world positions and real building/area outlines — never guess pixel positions. Call this as you begin describing something, and you may mark several places in one call.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
annotations: {
|
||||
type: 'array',
|
||||
description: 'One or more things to mark. Mark multiple related places together when describing them as a group.',
|
||||
description:
|
||||
'One or more things to mark. Mark multiple related places together when describing them as a group.',
|
||||
minItems: 1,
|
||||
maxItems: 24,
|
||||
items: {
|
||||
@@ -470,19 +668,30 @@ const GEV_REALTIME_TOOLS = [
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['pin', 'highlight', 'area', 'arrow', 'route', 'label'],
|
||||
description: 'pin = planted marker at a spot; highlight = pulsing ring drawing the eye to a point; area = trace the outline of a building/campus/compound/district; arrow = a connector from one place to another (use target as the origin and toTarget as the destination); route = a path through several waypoints (use the points array); label = a floating text callout.',
|
||||
description:
|
||||
'pin = planted marker at a spot; highlight = pulsing ring drawing the eye to a point; area = trace the outline of a building/campus/compound/district; arrow = a connector from one place to another (use target as the origin and toTarget as the destination); route = a path through several waypoints (use the points array); label = a floating text callout.',
|
||||
},
|
||||
target: {
|
||||
type: 'string',
|
||||
maxLength: 200,
|
||||
description:
|
||||
'Place name to resolve, e.g. "Palace of Fine Arts, San Francisco", "the Pentagon", "Presidio of San Francisco". Preferred over coordinates. For a specific monument/statue/feature that sits within a larger landmark, use its OWN name + city ("Tejano Monument, Austin", "Texas African American History Memorial, Austin") — do NOT phrase it as "X at the Texas State Capitol", which makes the geocoder collapse several of them onto the same centroid so they stack on one spot.',
|
||||
},
|
||||
target: { type: 'string', maxLength: 200, description: 'Place name to resolve, e.g. "Palace of Fine Arts, San Francisco", "the Pentagon", "Presidio of San Francisco". Preferred over coordinates. For a specific monument/statue/feature that sits within a larger landmark, use its OWN name + city ("Tejano Monument, Austin", "Texas African American History Memorial, Austin") — do NOT phrase it as "X at the Texas State Capitol", which makes the geocoder collapse several of them onto the same centroid so they stack on one spot.' },
|
||||
points: {
|
||||
type: 'array',
|
||||
description: 'For type=route: 2+ ordered waypoints the path passes through, each a place name (or coordinates / screen point).',
|
||||
description:
|
||||
'For type=route: 2+ ordered waypoints the path passes through, each a place name (or coordinates / screen point).',
|
||||
minItems: 2,
|
||||
maxItems: 12,
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
target: { type: 'string', maxLength: 200, description: 'Waypoint place name.' },
|
||||
target: {
|
||||
type: 'string',
|
||||
maxLength: 200,
|
||||
description: 'Waypoint place name.',
|
||||
},
|
||||
latitude: { type: 'number', minimum: -90, maximum: 90 },
|
||||
longitude: { type: 'number', minimum: -180, maximum: 180 },
|
||||
screenX: { type: 'number', minimum: 0, maximum: 1 },
|
||||
@@ -493,32 +702,101 @@ const GEV_REALTIME_TOOLS = [
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['walking', 'driving', 'cycling'],
|
||||
description: 'For type=route: travel mode for a real street-following route (the app returns distance + time). Pick from the verb the user used ("walk" → walking, "drive" → driving). Defaults to walking.',
|
||||
description:
|
||||
'For type=route: travel mode for a real street-following route (the app returns distance + time). Pick from the verb the user used ("walk" → walking, "drive" → driving). Defaults to walking.',
|
||||
},
|
||||
latitude: {
|
||||
type: 'number',
|
||||
minimum: -90,
|
||||
maximum: 90,
|
||||
description:
|
||||
'Explicit latitude (use only if no good place name exists).',
|
||||
},
|
||||
latitude: { type: 'number', minimum: -90, maximum: 90, description: 'Explicit latitude (use only if no good place name exists).' },
|
||||
longitude: { type: 'number', minimum: -180, maximum: 180 },
|
||||
toTarget: { type: 'string', maxLength: 200, description: 'For type=arrow: the destination place name.' },
|
||||
toTarget: {
|
||||
type: 'string',
|
||||
maxLength: 200,
|
||||
description: 'For type=arrow: the destination place name.',
|
||||
},
|
||||
toLatitude: { type: 'number', minimum: -90, maximum: 90 },
|
||||
toLongitude: { type: 'number', minimum: -180, maximum: 180 },
|
||||
label: { type: 'string', maxLength: 120, description: 'Short caption shown on the map (a few words). Optional.' },
|
||||
label: {
|
||||
type: 'string',
|
||||
maxLength: 120,
|
||||
description:
|
||||
'Short caption shown on the map (a few words). Optional.',
|
||||
},
|
||||
color: {
|
||||
type: 'string',
|
||||
enum: ['primary', 'amber', 'cyan', 'green', 'red'],
|
||||
description: 'Accent color. primary = neutral, amber = point of interest, cyan = infrastructure, green = confirmed/safe, red = alert.',
|
||||
description:
|
||||
'Accent color. primary = neutral, amber = point of interest, cyan = infrastructure, green = confirmed/safe, red = alert.',
|
||||
},
|
||||
footprint: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'For type=area/highlight: trace the real building or campus outline from map data. Defaults true for area.',
|
||||
},
|
||||
intent: {
|
||||
type: 'string',
|
||||
enum: ['the_thing', 'around_the_thing'],
|
||||
description:
|
||||
'For type=area: "the_thing" (default) outlines the place itself (its footprint/boundary); "around_the_thing" highlights a surrounding zone (a buffered radius around it). Infer from phrasing: "the Capitol"/"show me X" → the_thing; "around/near/by X" or "the area around X" → around_the_thing.',
|
||||
},
|
||||
entityKind: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'building',
|
||||
'compound',
|
||||
'district',
|
||||
'street',
|
||||
'point_feature',
|
||||
],
|
||||
description:
|
||||
'What KIND of thing the target IS — a fact, not a style choice: building = one structure; compound = campus/grounds/mall/park; district = neighborhood or area of a city; street = a named road/corridor; point_feature = monument/statue/memorial/plaque/fountain or other small point landmark. Set it whenever you know it — it routes the resolver to the right footprint source (point_feature anchors monuments as precise points instead of adopting a nearby building outline).',
|
||||
},
|
||||
screenX: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
description:
|
||||
'Fallback only: when you cannot name/geocode the place but can SEE it in the latest viewport screenshot, the normalized horizontal position (0=left, 1=right) of the spot. The app converts it back to a real world point under that pixel.',
|
||||
},
|
||||
screenY: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
description:
|
||||
'Fallback only: normalized vertical position (0=top, 1=bottom) of the spot in the latest viewport screenshot.',
|
||||
},
|
||||
toScreenX: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
description:
|
||||
'For type=arrow: normalized x of the arrow destination from the screenshot (pixel fallback).',
|
||||
},
|
||||
toScreenY: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
description:
|
||||
'For type=arrow: normalized y of the arrow destination from the screenshot (pixel fallback).',
|
||||
},
|
||||
footprint: { type: 'boolean', description: 'For type=area/highlight: trace the real building or campus outline from map data. Defaults true for area.' },
|
||||
intent: { type: 'string', enum: ['the_thing', 'around_the_thing'], description: 'For type=area: "the_thing" (default) outlines the place itself (its footprint/boundary); "around_the_thing" highlights a surrounding zone (a buffered radius around it). Infer from phrasing: "the Capitol"/"show me X" → the_thing; "around/near/by X" or "the area around X" → around_the_thing.' },
|
||||
entityKind: { type: 'string', enum: ['building', 'compound', 'district', 'street', 'point_feature'], description: 'What KIND of thing the target IS — a fact, not a style choice: building = one structure; compound = campus/grounds/mall/park; district = neighborhood or area of a city; street = a named road/corridor; point_feature = monument/statue/memorial/plaque/fountain or other small point landmark. Set it whenever you know it — it routes the resolver to the right footprint source (point_feature anchors monuments as precise points instead of adopting a nearby building outline).' },
|
||||
screenX: { type: 'number', minimum: 0, maximum: 1, description: 'Fallback only: when you cannot name/geocode the place but can SEE it in the latest viewport screenshot, the normalized horizontal position (0=left, 1=right) of the spot. The app converts it back to a real world point under that pixel.' },
|
||||
screenY: { type: 'number', minimum: 0, maximum: 1, description: 'Fallback only: normalized vertical position (0=top, 1=bottom) of the spot in the latest viewport screenshot.' },
|
||||
toScreenX: { type: 'number', minimum: 0, maximum: 1, description: 'For type=arrow: normalized x of the arrow destination from the screenshot (pixel fallback).' },
|
||||
toScreenY: { type: 'number', minimum: 0, maximum: 1, description: 'For type=arrow: normalized y of the arrow destination from the screenshot (pixel fallback).' },
|
||||
},
|
||||
required: ['type'],
|
||||
},
|
||||
},
|
||||
flyTo: { type: 'boolean', description: 'Also move the camera to frame the first annotation. Default false — leave false if the user is already looking at the spot.' },
|
||||
persist: { type: 'boolean', description: 'Keep annotations until cleared (true, default) or let them auto-fade after ~20s (false).' },
|
||||
flyTo: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Also move the camera to frame the first annotation. Default false — leave false if the user is already looking at the spot.',
|
||||
},
|
||||
persist: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Keep annotations until cleared (true, default) or let them auto-fade after ~20s (false).',
|
||||
},
|
||||
},
|
||||
required: ['annotations'],
|
||||
},
|
||||
@@ -526,7 +804,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'clear_annotations',
|
||||
description: 'Erase ALL map annotations previously drawn with annotate_map. Call this ONLY when the user EXPLICITLY asks to clear or reset the map. Annotations accumulate and persist across navigation and topic changes by design — never clear on your own initiative.',
|
||||
description:
|
||||
'Erase ALL map annotations previously drawn with annotate_map. Call this ONLY when the user EXPLICITLY asks to clear or reset the map. Annotations accumulate and persist across navigation and topic changes by design — never clear on your own initiative.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -536,15 +815,29 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'move_camera',
|
||||
description: 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").',
|
||||
description:
|
||||
'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
motion: { type: 'string', enum: ['orbit', 'pan', 'tilt', 'rotate', 'stop'] },
|
||||
direction: { type: 'string', enum: ['left', 'right', 'up', 'down'], description: 'Required except for orbit (defaults right/clockwise) and stop.' },
|
||||
motion: {
|
||||
type: 'string',
|
||||
enum: ['orbit', 'pan', 'tilt', 'rotate', 'stop'],
|
||||
},
|
||||
direction: {
|
||||
type: 'string',
|
||||
enum: ['left', 'right', 'up', 'down'],
|
||||
description:
|
||||
'Required except for orbit (defaults right/clockwise) and stop.',
|
||||
},
|
||||
speed: { type: 'string', enum: ['slow', 'normal', 'fast'] },
|
||||
mode: { type: 'string', enum: ['once', 'continuous'], description: 'once = bounded eased nudge (default); continuous = until stop/manual input.' },
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['once', 'continuous'],
|
||||
description:
|
||||
'once = bounded eased nudge (default); continuous = until stop/manual input.',
|
||||
},
|
||||
},
|
||||
required: ['motion'],
|
||||
},
|
||||
@@ -552,12 +845,16 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'fly_route',
|
||||
description: 'Cinematic dolly along an EXISTING route annotation (drawn earlier with annotate_map type=route) — flies the street-following path from start to end. Omit label for the newest route. If no route is drawn, this fails with guidance: draw the route first.',
|
||||
description:
|
||||
'Cinematic dolly along an EXISTING route annotation (drawn earlier with annotate_map type=route) — flies the street-following path from start to end. Omit label for the newest route. If no route is drawn, this fails with guidance: draw the route first.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
label: { type: 'string', description: 'Match an existing route mark by (partial) label.' },
|
||||
label: {
|
||||
type: 'string',
|
||||
description: 'Match an existing route mark by (partial) label.',
|
||||
},
|
||||
speed: { type: 'string', enum: ['slow', 'normal', 'fast'] },
|
||||
},
|
||||
},
|
||||
@@ -565,59 +862,112 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'analyst_query',
|
||||
description: 'Answer questions ABOUT the data currently loaded on the map — counts, lists, superlatives, and attribute filters over live layers (flights, military, ships, fires, earthquakes). Examples: "how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "fastest thing in view". Queries ONLY client-side data from ENABLED layers — if the needed layer is off, say so and offer to enable it. For a follow-up about the previous answer\'s set ("which of those is closest?"), set followUp=true and send only the new filters/sort.',
|
||||
description:
|
||||
'Answer questions ABOUT the data currently loaded on the map — counts, lists, superlatives, and attribute filters over live layers (flights, military, ships, fires, earthquakes). Examples: "how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "fastest thing in view". Queries ONLY client-side data from ENABLED layers — if the needed layer is off, say so and offer to enable it. For a follow-up about the previous answer\'s set ("which of those is closest?"), set followUp=true and send only the new filters/sort.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
layers: {
|
||||
type: 'array',
|
||||
items: { type: 'string', enum: ['flights', 'military', 'ais-live-vessels', 'local-firms', 'earthquakes'] },
|
||||
description: 'Layers to query. fires/wildfires → local-firms; ships/vessels → ais-live-vessels.',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'flights',
|
||||
'military',
|
||||
'ais-live-vessels',
|
||||
'local-firms',
|
||||
'earthquakes',
|
||||
],
|
||||
},
|
||||
description:
|
||||
'Layers to query. fires/wildfires → local-firms; ships/vessels → ais-live-vessels.',
|
||||
},
|
||||
scope: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
description: 'Spatial scope. Default: view (near the camera). Use kind=region for "over Texas"-style asks; kind=anywhere for global questions.',
|
||||
description:
|
||||
'Spatial scope. Default: view (near the camera). Use kind=region for "over Texas"-style asks; kind=anywhere for global questions.',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['view', 'region', 'radius', 'anywhere'] },
|
||||
name: { type: 'string', description: 'For kind=region: a state/country ("Texas", "France") or a named natural region ("the Alps", "Gulf of Mexico").' },
|
||||
kind: {
|
||||
type: 'string',
|
||||
enum: ['view', 'region', 'radius', 'anywhere'],
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description:
|
||||
'For kind=region: a state/country ("Texas", "France") or a named natural region ("the Alps", "Gulf of Mexico").',
|
||||
},
|
||||
km: { type: 'number', description: 'For kind=radius.' },
|
||||
center: { type: 'object', additionalProperties: false, properties: { lat: { type: 'number' }, lon: { type: 'number' } } },
|
||||
center: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { lat: { type: 'number' }, lon: { type: 'number' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
filters: {
|
||||
type: 'array',
|
||||
description: 'Attribute predicates, ANDed. ALTITUDE IS METERS (40,000 ft = 12192). Fields: altitudeM, speedMps, military, onGround, aircraftClass, callsign, operator, routeOrigin, routeDestination, originCountry (flights); speedKts, shipType, destination (ships); frp, confidence (fires); magnitude, depthKm, place (earthquakes).',
|
||||
description:
|
||||
'Attribute predicates, ANDed. ALTITUDE IS METERS (40,000 ft = 12192). Fields: altitudeM, speedMps, military, onGround, aircraftClass, callsign, operator, routeOrigin, routeDestination, originCountry (flights); speedKts, shipType, destination (ships); frp, confidence (fires); magnitude, depthKm, place (earthquakes).',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
field: { type: 'string' },
|
||||
op: { type: 'string', enum: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq', 'contains'] },
|
||||
op: {
|
||||
type: 'string',
|
||||
enum: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq', 'contains'],
|
||||
},
|
||||
value: {},
|
||||
},
|
||||
required: ['field', 'op', 'value'],
|
||||
},
|
||||
},
|
||||
sortBy: { type: 'string', description: 'Field to rank by, or "distance" for nearest-first.' },
|
||||
sortBy: {
|
||||
type: 'string',
|
||||
description: 'Field to rank by, or "distance" for nearest-first.',
|
||||
},
|
||||
sortDir: { type: 'string', enum: ['asc', 'desc'] },
|
||||
limit: { type: 'number' },
|
||||
followUp: { type: 'boolean', description: 'true = re-query the PREVIOUS result set instead of fresh data.' },
|
||||
followUp: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'true = re-query the PREVIOUS result set instead of fresh data.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'next_iss_pass',
|
||||
description: "When the user asks when the ISS / the space station will next fly over: returns the next visible ISS pass for the current camera location (or an explicit lat/lon) — rise time (ISO + minutes from now), rise compass direction, peak elevation, and duration. Requires the satellites layer to have loaded its catalog at least once this session; if it hasn't, tell the user to enable the satellites layer and try again.",
|
||||
description:
|
||||
"When the user asks when the ISS / the space station will next fly over: returns the next visible ISS pass for the current camera location (or an explicit lat/lon) — rise time (ISO + minutes from now), rise compass direction, peak elevation, and duration. Requires the satellites layer to have loaded its catalog at least once this session; if it hasn't, tell the user to enable the satellites layer and try again.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
latitude: { type: 'number', minimum: -90, maximum: 90, description: 'Optional observer latitude. Omit to use the current camera position.' },
|
||||
longitude: { type: 'number', minimum: -180, maximum: 180, description: 'Optional observer longitude. Omit to use the current camera position.' },
|
||||
minElevationDeg: { type: 'number', minimum: 5, maximum: 60, description: 'Minimum peak elevation (deg) to count as a pass. Default 10.' },
|
||||
latitude: {
|
||||
type: 'number',
|
||||
minimum: -90,
|
||||
maximum: 90,
|
||||
description:
|
||||
'Optional observer latitude. Omit to use the current camera position.',
|
||||
},
|
||||
longitude: {
|
||||
type: 'number',
|
||||
minimum: -180,
|
||||
maximum: 180,
|
||||
description:
|
||||
'Optional observer longitude. Omit to use the current camera position.',
|
||||
},
|
||||
minElevationDeg: {
|
||||
type: 'number',
|
||||
minimum: 5,
|
||||
maximum: 60,
|
||||
description:
|
||||
'Minimum peak elevation (deg) to count as a pass. Default 10.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
import { readRequestBodyCapped } from './common/request.js';
|
||||
import { OVERPASS_MAX_BODY_BYTES, OVERPASS_MAX_CONCURRENT } from './overpass/constants.js';
|
||||
import {
|
||||
OVERPASS_MAX_BODY_BYTES,
|
||||
OVERPASS_MAX_CONCURRENT,
|
||||
} from './overpass/constants.js';
|
||||
import { sanitizeOverpassBody } from './overpass/query.js';
|
||||
import { resolveOverpassPreflight, _overpassCache, readOverpassDisk, overpassDiskTtlMs, readStaleOverpass, trimOverpassCache, writeOverpassDisk } from './overpass/cache.js';
|
||||
import { overpassPayloadIsData, fetchOverpassPayload } from './overpass/transport.js';
|
||||
import {
|
||||
resolveOverpassPreflight,
|
||||
_overpassCache,
|
||||
readOverpassDisk,
|
||||
overpassDiskTtlMs,
|
||||
readStaleOverpass,
|
||||
trimOverpassCache,
|
||||
writeOverpassDisk,
|
||||
} from './overpass/cache.js';
|
||||
import {
|
||||
overpassPayloadIsData,
|
||||
fetchOverpassPayload,
|
||||
} from './overpass/transport.js';
|
||||
import { installRouteMiddleware } from './places/routes.js';
|
||||
|
||||
/** @type {Map<string,Promise>} In-flight Overpass requests keyed by normalized query body. */
|
||||
@@ -11,7 +25,11 @@ const _overpassInFlight = new Map();
|
||||
|
||||
let _overpassConcurrent = 0;
|
||||
|
||||
const _overpassRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 90, globalMax: 300 });
|
||||
const _overpassRateLimiter = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: 90,
|
||||
globalMax: 300,
|
||||
});
|
||||
|
||||
/**
|
||||
* Write a completed Overpass payload to the HTTP response.
|
||||
@@ -57,7 +75,9 @@ function overpassProxy() {
|
||||
// Collect POST body with a hard byte cap (Overpass QL queries are small)
|
||||
let body;
|
||||
try {
|
||||
body = (await readRequestBodyCapped(req, OVERPASS_MAX_BODY_BYTES)).toString();
|
||||
body = (
|
||||
await readRequestBodyCapped(req, OVERPASS_MAX_BODY_BYTES)
|
||||
).toString();
|
||||
} catch (err) {
|
||||
if (err?.code === 'BODY_TOO_LARGE') {
|
||||
res.writeHead(413, { 'Content-Type': 'application/json' });
|
||||
@@ -90,11 +110,15 @@ function overpassProxy() {
|
||||
inFlight: _overpassInFlight,
|
||||
// Fresh-enough disk entries survive restarts and skip the public
|
||||
// mirrors; boundary-class queries keep their month-long TTL.
|
||||
readDisk: () => readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
|
||||
readDisk: () =>
|
||||
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
|
||||
allowUpstream: () => _overpassRateLimiter(clientKey(req)),
|
||||
});
|
||||
if (preflight.source === 'RATE_LIMITED') {
|
||||
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '5' });
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '5',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
@@ -119,8 +143,15 @@ function overpassProxy() {
|
||||
// From here onward the request is genuinely upstream-bound and has
|
||||
// consumed one local limiter slot. Cache and dedupe hits above do not.
|
||||
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT) {
|
||||
res.writeHead(503, { 'Content-Type': 'application/json', 'Retry-After': '2' });
|
||||
res.end(JSON.stringify({ error: 'Overpass proxy busy — try again shortly' }));
|
||||
res.writeHead(503, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '2',
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'Overpass proxy busy — try again shortly',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
_overpassConcurrent += 1;
|
||||
@@ -158,9 +189,7 @@ function overpassProxy() {
|
||||
sendOverpassResponse(res, payload, 'MISS');
|
||||
} catch (e) {
|
||||
// Every mirror threw (network-level). Same serve-stale rule.
|
||||
const stale = cacheKey
|
||||
? await readStaleOverpass(cacheKey)
|
||||
: null;
|
||||
const stale = cacheKey ? await readStaleOverpass(cacheKey) : null;
|
||||
if (stale) {
|
||||
sendOverpassResponse(res, stale, 'STALE');
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { isOverpassBoundaryQuery } from './query.js';
|
||||
import { OVERPASS_BOUNDARY_DISK_TTL_MS, OVERPASS_DISK_TTL_MS, OVERPASS_DISK_DIR, OVERPASS_CACHE_MS, OVERPASS_CACHE_MAX_ENTRIES } from './constants.js';
|
||||
import {
|
||||
OVERPASS_BOUNDARY_DISK_TTL_MS,
|
||||
OVERPASS_DISK_TTL_MS,
|
||||
OVERPASS_DISK_DIR,
|
||||
OVERPASS_CACHE_MS,
|
||||
OVERPASS_CACHE_MAX_ENTRIES,
|
||||
} from './constants.js';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
@@ -10,12 +16,17 @@ const _overpassCache = new Map();
|
||||
|
||||
/** Disk TTL for a query: boundary geometry keeps for a month, the rest 7 days. */
|
||||
function overpassDiskTtlMs(cacheKey) {
|
||||
return isOverpassBoundaryQuery(cacheKey) ? OVERPASS_BOUNDARY_DISK_TTL_MS : OVERPASS_DISK_TTL_MS;
|
||||
return isOverpassBoundaryQuery(cacheKey)
|
||||
? OVERPASS_BOUNDARY_DISK_TTL_MS
|
||||
: OVERPASS_DISK_TTL_MS;
|
||||
}
|
||||
|
||||
/** Normalized Overpass query -> stable disk-cache file path. */
|
||||
function overpassDiskPath(cacheKey) {
|
||||
return path.join(OVERPASS_DISK_DIR, `${createHash('sha1').update(cacheKey).digest('hex')}.json`);
|
||||
return path.join(
|
||||
OVERPASS_DISK_DIR,
|
||||
`${createHash('sha1').update(cacheKey).digest('hex')}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,7 +38,12 @@ async function readOverpassDisk(cacheKey, maxAgeMs) {
|
||||
try {
|
||||
const raw = await fsp.readFile(overpassDiskPath(cacheKey), 'utf8');
|
||||
const payload = JSON.parse(raw);
|
||||
if (!payload || typeof payload.body !== 'string' || !Number.isFinite(payload.cachedAt)) return null;
|
||||
if (
|
||||
!payload ||
|
||||
typeof payload.body !== 'string' ||
|
||||
!Number.isFinite(payload.cachedAt)
|
||||
)
|
||||
return null;
|
||||
// Older versions persisted 4xx refusals with normal data TTLs. Ignore
|
||||
// them on both fresh and stale reads so an upgrade can recover immediately.
|
||||
if (!overpassPayloadIsData(payload)) return null;
|
||||
@@ -40,9 +56,17 @@ async function readOverpassDisk(cacheKey, maxAgeMs) {
|
||||
|
||||
/** Fire-and-forget disk write for a successful Overpass payload. */
|
||||
function writeOverpassDisk(cacheKey, payload) {
|
||||
fsp.mkdir(OVERPASS_DISK_DIR, { recursive: true })
|
||||
.then(() => fsp.writeFile(overpassDiskPath(cacheKey), JSON.stringify(payload)))
|
||||
.catch((err) => console.warn('[Overpass Proxy] disk cache write failed:', err?.message || err));
|
||||
fsp
|
||||
.mkdir(OVERPASS_DISK_DIR, { recursive: true })
|
||||
.then(() =>
|
||||
fsp.writeFile(overpassDiskPath(cacheKey), JSON.stringify(payload)),
|
||||
)
|
||||
.catch((err) =>
|
||||
console.warn(
|
||||
'[Overpass Proxy] disk cache write failed:',
|
||||
err?.message || err,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,7 +95,8 @@ async function resolveOverpassPreflight({
|
||||
cacheMs = OVERPASS_CACHE_MS,
|
||||
}) {
|
||||
const cached = memoryCache.get(cacheKey);
|
||||
if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs) return { source: 'HIT', payload: cached };
|
||||
if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs)
|
||||
return { source: 'HIT', payload: cached };
|
||||
|
||||
const pending = inFlight.get(cacheKey);
|
||||
if (pending) return { source: 'INFLIGHT', payload: await pending };
|
||||
@@ -87,7 +112,9 @@ async function resolveOverpassPreflight({
|
||||
/** Return only last-good Overpass data, regardless of its age. */
|
||||
async function readStaleOverpass(cacheKey) {
|
||||
const cached = _overpassCache.get(cacheKey);
|
||||
return overpassPayloadIsData(cached) ? cached : readOverpassDisk(cacheKey, Infinity);
|
||||
return overpassPayloadIsData(cached)
|
||||
? cached
|
||||
: readOverpassDisk(cacheKey, Infinity);
|
||||
}
|
||||
|
||||
/** Evict oldest Overpass cache entries until size is within the cap. */
|
||||
@@ -99,4 +126,12 @@ function trimOverpassCache() {
|
||||
}
|
||||
}
|
||||
|
||||
export { readOverpassDisk, resolveOverpassPreflight, _overpassCache, overpassDiskTtlMs, readStaleOverpass, trimOverpassCache, writeOverpassDisk };
|
||||
export {
|
||||
readOverpassDisk,
|
||||
resolveOverpassPreflight,
|
||||
_overpassCache,
|
||||
overpassDiskTtlMs,
|
||||
readStaleOverpass,
|
||||
trimOverpassCache,
|
||||
writeOverpassDisk,
|
||||
};
|
||||
@@ -97,12 +97,38 @@ const OVERPASS_MAX_BBOX_DEG = 12;
|
||||
const OVERPASS_ELEMENT_TYPES = 'node|way|relation|nwr|nw|nr|wr|rel';
|
||||
|
||||
/** Element-selector (incl. `area`) whose statements must be individually bounded. */
|
||||
const OVERPASS_SELECTOR_RE = new RegExp(`\\b(?:${OVERPASS_ELEMENT_TYPES}|area)\\b`);
|
||||
const OVERPASS_SELECTOR_RE = new RegExp(
|
||||
`\\b(?:${OVERPASS_ELEMENT_TYPES}|area)\\b`,
|
||||
);
|
||||
|
||||
/** An element selector bounded BY an area — the country-scan abuse shape. */
|
||||
const OVERPASS_AREA_ELEMENT_RE = new RegExp(`\\b(?:${OVERPASS_ELEMENT_TYPES})\\s*\\(\\s*area\\b`, 'i');
|
||||
const OVERPASS_AREA_ELEMENT_RE = new RegExp(
|
||||
`\\b(?:${OVERPASS_ELEMENT_TYPES})\\s*\\(\\s*area\\b`,
|
||||
'i',
|
||||
);
|
||||
|
||||
/** A single bbox 4-tuple `(s,w,n,e)` (non-global so it does not advance lastIndex). */
|
||||
const OVERPASS_BBOX_RE = /\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\)/;
|
||||
const OVERPASS_BBOX_RE =
|
||||
/\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\)/;
|
||||
|
||||
export { OVERPASS_BOUNDARY_DISK_TTL_MS, OVERPASS_DISK_TTL_MS, OVERPASS_DISK_DIR, OVERPASS_CACHE_MS, OVERPASS_CACHE_MAX_ENTRIES, OVERPASS_MAX_BODY_BYTES, OVERPASS_MAX_CONCURRENT, OVERPASS_MAX_AROUND_M, OVERPASS_MAX_BBOX_DEG, OVERPASS_AREA_ELEMENT_RE, OVERPASS_SELECTOR_RE, OVERPASS_BBOX_RE, OVERPASS_MAX_QL_TIMEOUT, OVERPASS_SIMPLIFY_MIN_BYTES, OVERPASS_SIMPLIFY_MIN_POINTS, OVERPASS_SIMPLIFY_TOLERANCE_DEG, OVERPASS_MAX_RESPONSE_BYTES, OVERPASS_UPSTREAMS, OVERPASS_TIMEOUT_MS };
|
||||
export {
|
||||
OVERPASS_BOUNDARY_DISK_TTL_MS,
|
||||
OVERPASS_DISK_TTL_MS,
|
||||
OVERPASS_DISK_DIR,
|
||||
OVERPASS_CACHE_MS,
|
||||
OVERPASS_CACHE_MAX_ENTRIES,
|
||||
OVERPASS_MAX_BODY_BYTES,
|
||||
OVERPASS_MAX_CONCURRENT,
|
||||
OVERPASS_MAX_AROUND_M,
|
||||
OVERPASS_MAX_BBOX_DEG,
|
||||
OVERPASS_AREA_ELEMENT_RE,
|
||||
OVERPASS_SELECTOR_RE,
|
||||
OVERPASS_BBOX_RE,
|
||||
OVERPASS_MAX_QL_TIMEOUT,
|
||||
OVERPASS_SIMPLIFY_MIN_BYTES,
|
||||
OVERPASS_SIMPLIFY_MIN_POINTS,
|
||||
OVERPASS_SIMPLIFY_TOLERANCE_DEG,
|
||||
OVERPASS_MAX_RESPONSE_BYTES,
|
||||
OVERPASS_UPSTREAMS,
|
||||
OVERPASS_TIMEOUT_MS,
|
||||
};
|
||||
@@ -1,4 +1,8 @@
|
||||
import { OVERPASS_SIMPLIFY_MIN_BYTES, OVERPASS_SIMPLIFY_MIN_POINTS, OVERPASS_SIMPLIFY_TOLERANCE_DEG } from './constants.js';
|
||||
import {
|
||||
OVERPASS_SIMPLIFY_MIN_BYTES,
|
||||
OVERPASS_SIMPLIFY_MIN_POINTS,
|
||||
OVERPASS_SIMPLIFY_TOLERANCE_DEG,
|
||||
} from './constants.js';
|
||||
|
||||
/** Iterative Douglas-Peucker on [{lat,lon},...] (planar-degree approx — fine at
|
||||
* the ~44 m tolerance used here). Endpoints always kept. */
|
||||
@@ -51,7 +55,10 @@ function simplifyElementGeometry(el, minPoints, toleranceDeg) {
|
||||
}
|
||||
if (Array.isArray(el?.members)) {
|
||||
for (const member of el.members) {
|
||||
if (Array.isArray(member?.geometry) && member.geometry.length >= minPoints) {
|
||||
if (
|
||||
Array.isArray(member?.geometry) &&
|
||||
member.geometry.length >= minPoints
|
||||
) {
|
||||
member.geometry = douglasPeucker(member.geometry, toleranceDeg);
|
||||
}
|
||||
}
|
||||
@@ -73,7 +80,8 @@ function simplifyOverpassPayloadBody(bodyText, opts = {}) {
|
||||
const minBytes = opts.minBytes ?? OVERPASS_SIMPLIFY_MIN_BYTES;
|
||||
const minPoints = opts.minPoints ?? OVERPASS_SIMPLIFY_MIN_POINTS;
|
||||
const toleranceDeg = opts.toleranceDeg ?? OVERPASS_SIMPLIFY_TOLERANCE_DEG;
|
||||
if (typeof bodyText !== 'string' || bodyText.length < minBytes) return bodyText;
|
||||
if (typeof bodyText !== 'string' || bodyText.length < minBytes)
|
||||
return bodyText;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(bodyText);
|
||||
@@ -81,7 +89,8 @@ function simplifyOverpassPayloadBody(bodyText, opts = {}) {
|
||||
return bodyText;
|
||||
}
|
||||
if (!Array.isArray(data?.elements)) return bodyText;
|
||||
for (const el of data.elements) simplifyElementGeometry(el, minPoints, toleranceDeg);
|
||||
for (const el of data.elements)
|
||||
simplifyElementGeometry(el, minPoints, toleranceDeg);
|
||||
try {
|
||||
return JSON.stringify(data);
|
||||
} catch {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { OVERPASS_MAX_AROUND_M, OVERPASS_MAX_BBOX_DEG, OVERPASS_AREA_ELEMENT_RE, OVERPASS_SELECTOR_RE, OVERPASS_BBOX_RE, OVERPASS_MAX_QL_TIMEOUT } from './constants.js';
|
||||
import {
|
||||
OVERPASS_MAX_AROUND_M,
|
||||
OVERPASS_MAX_BBOX_DEG,
|
||||
OVERPASS_AREA_ELEMENT_RE,
|
||||
OVERPASS_SELECTOR_RE,
|
||||
OVERPASS_BBOX_RE,
|
||||
OVERPASS_MAX_QL_TIMEOUT,
|
||||
} from './constants.js';
|
||||
|
||||
/**
|
||||
* Whether a normalized Overpass query is BOUNDARY-class (admin `is_in` lookups
|
||||
@@ -41,8 +48,14 @@ function stripOverpassNoise(src) {
|
||||
const quote = c;
|
||||
i += 1;
|
||||
while (i < n) {
|
||||
if (src[i] === '\\') { i += 2; continue; } // escaped char
|
||||
if (src[i] === quote) { i += 1; break; } // closing quote
|
||||
if (src[i] === '\\') {
|
||||
i += 2;
|
||||
continue;
|
||||
} // escaped char
|
||||
if (src[i] === quote) {
|
||||
i += 1;
|
||||
break;
|
||||
} // closing quote
|
||||
i += 1;
|
||||
}
|
||||
out += quote + quote; // collapse the literal to empty quotes
|
||||
@@ -69,11 +82,17 @@ function stripOverpassNoise(src) {
|
||||
|
||||
function sanitizeOverpassBody(rawBody) {
|
||||
let params;
|
||||
try { params = new URLSearchParams(rawBody); } catch { return { ok: false, error: 'Malformed query body' }; }
|
||||
try {
|
||||
params = new URLSearchParams(rawBody);
|
||||
} catch {
|
||||
return { ok: false, error: 'Malformed query body' };
|
||||
}
|
||||
const all = params.getAll('data');
|
||||
if (all.length !== 1) return { ok: false, error: 'Exactly one data query is required' };
|
||||
if (all.length !== 1)
|
||||
return { ok: false, error: 'Exactly one data query is required' };
|
||||
const data = all[0];
|
||||
if (!data || !data.trim()) return { ok: false, error: 'Missing Overpass data query' };
|
||||
if (!data || !data.trim())
|
||||
return { ok: false, error: 'Missing Overpass data query' };
|
||||
|
||||
// Blank quoted literals + strip comments in one lexer pass so a fake bound or a
|
||||
// `//` inside a string can't hide an unbounded selector (or satisfy a bound).
|
||||
@@ -89,9 +108,17 @@ function sanitizeOverpassBody(rawBody) {
|
||||
}
|
||||
}
|
||||
// Reject world-sized / oversized bboxes.
|
||||
for (const m of stripped.matchAll(/\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g)) {
|
||||
const s = Number(m[1]); const w = Number(m[2]); const n = Number(m[3]); const e = Number(m[4]);
|
||||
if (Math.abs(n - s) > OVERPASS_MAX_BBOX_DEG || Math.abs(e - w) > OVERPASS_MAX_BBOX_DEG) {
|
||||
for (const m of stripped.matchAll(
|
||||
/\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g,
|
||||
)) {
|
||||
const s = Number(m[1]);
|
||||
const w = Number(m[2]);
|
||||
const n = Number(m[3]);
|
||||
const e = Number(m[4]);
|
||||
if (
|
||||
Math.abs(n - s) > OVERPASS_MAX_BBOX_DEG ||
|
||||
Math.abs(e - w) > OVERPASS_MAX_BBOX_DEG
|
||||
) {
|
||||
return { ok: false, error: 'Overpass bbox too large' };
|
||||
}
|
||||
}
|
||||
@@ -122,7 +149,10 @@ function sanitizeOverpassBody(rawBody) {
|
||||
// misread as a spatial bound. Bounds live in (...) / function calls / set
|
||||
// refs, never inside [...], so the probe loses nothing real.
|
||||
const outSets = [];
|
||||
const body = stmt.replace(/->\s*\.(\w+)/g, (_, name) => { outSets.push(name); return ' '; });
|
||||
const body = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
|
||||
outSets.push(name);
|
||||
return ' ';
|
||||
});
|
||||
const probe = body.replace(/\[[^\]]*\]/g, ' ');
|
||||
|
||||
// Reject element-in-area scans on the TAG-STRIPPED probe, so a tag filter
|
||||
@@ -131,15 +161,21 @@ function sanitizeOverpassBody(rawBody) {
|
||||
// SELECTS admin areas (area.set) and pivots (rel(pivot.x)), never node/way/
|
||||
// relation(area...). The probe collapses tags so `way (area.a)` is caught.
|
||||
if (OVERPASS_AREA_ELEMENT_RE.test(probe)) {
|
||||
return { ok: false, error: 'Overpass area-bounded element selector not allowed' };
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Overpass area-bounded element selector not allowed',
|
||||
};
|
||||
}
|
||||
|
||||
const hasSelector = OVERPASS_SELECTOR_RE.test(probe);
|
||||
const inputSets = [...probe.matchAll(/(?<!\d)\.([a-z_]\w*)/gi)].map((m) => m[1]);
|
||||
const directBound = /around:\s*\d/.test(probe)
|
||||
|| OVERPASS_BBOX_RE.test(probe)
|
||||
|| /is_in\s*\(/.test(probe) // is_in(lat,lon) — the function form only
|
||||
|| /\barea\s*\(/.test(probe); // area(id) — bounded as a set definition
|
||||
const inputSets = [...probe.matchAll(/(?<!\d)\.([a-z_]\w*)/gi)].map(
|
||||
(m) => m[1],
|
||||
);
|
||||
const directBound =
|
||||
/around:\s*\d/.test(probe) ||
|
||||
OVERPASS_BBOX_RE.test(probe) ||
|
||||
/is_in\s*\(/.test(probe) || // is_in(lat,lon) — the function form only
|
||||
/\barea\s*\(/.test(probe); // area(id) — bounded as a set definition
|
||||
|
||||
const setBound = inputSets.some((s) => boundedSets.has(s));
|
||||
const bounded = directBound || setBound;
|
||||
@@ -153,7 +189,8 @@ function sanitizeOverpassBody(rawBody) {
|
||||
|
||||
const clamped = data.replace(
|
||||
/\[timeout:\s*(\d+)\s*\]/gi,
|
||||
(_, n) => `[timeout:${Math.min(Number(n) || OVERPASS_MAX_QL_TIMEOUT, OVERPASS_MAX_QL_TIMEOUT)}]`,
|
||||
(_, n) =>
|
||||
`[timeout:${Math.min(Number(n) || OVERPASS_MAX_QL_TIMEOUT, OVERPASS_MAX_QL_TIMEOUT)}]`,
|
||||
);
|
||||
return { ok: true, body: `data=${encodeURIComponent(clamped)}` };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { OVERPASS_MAX_RESPONSE_BYTES, OVERPASS_UPSTREAMS, OVERPASS_TIMEOUT_MS } from './constants.js';
|
||||
import {
|
||||
OVERPASS_MAX_RESPONSE_BYTES,
|
||||
OVERPASS_UPSTREAMS,
|
||||
OVERPASS_TIMEOUT_MS,
|
||||
} from './constants.js';
|
||||
import { readResponseTextCapped } from '../common/http.js';
|
||||
import { simplifyOverpassPayloadBody } from './geometry.js';
|
||||
|
||||
@@ -13,10 +17,12 @@ import { simplifyOverpassPayloadBody } from './geometry.js';
|
||||
*/
|
||||
function overpassLooksRateLimited(bodyText) {
|
||||
const text = String(bodyText || '').toLowerCase();
|
||||
return text.includes('rate_limited')
|
||||
|| text.includes('quota of your ip address')
|
||||
|| text.includes('dispatcher_client::request_read_and_idx::rate_limited')
|
||||
|| text.includes('too many requests');
|
||||
return (
|
||||
text.includes('rate_limited') ||
|
||||
text.includes('quota of your ip address') ||
|
||||
text.includes('dispatcher_client::request_read_and_idx::rate_limited') ||
|
||||
text.includes('too many requests')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,9 +32,11 @@ function overpassLooksRateLimited(bodyText) {
|
||||
*/
|
||||
function overpassLooksRuntimeError(bodyText) {
|
||||
const text = String(bodyText || '').toLowerCase();
|
||||
return text.includes('runtime error')
|
||||
|| text.includes('timed out')
|
||||
|| text.includes('out of memory');
|
||||
return (
|
||||
text.includes('runtime error') ||
|
||||
text.includes('timed out') ||
|
||||
text.includes('out of memory')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,10 +50,13 @@ function overpassLooksRuntimeError(bodyText) {
|
||||
*/
|
||||
function overpassPayloadIsData(payload) {
|
||||
const status = Number(payload?.status);
|
||||
return Number.isFinite(status)
|
||||
&& status >= 200 && status < 300
|
||||
&& !payload.rateLimited
|
||||
&& !payload.runtimeError;
|
||||
return (
|
||||
Number.isFinite(status) &&
|
||||
status >= 200 &&
|
||||
status < 300 &&
|
||||
!payload.rateLimited &&
|
||||
!payload.runtimeError
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,12 +68,16 @@ function overpassPayloadIsData(payload) {
|
||||
* @param {object} [options] Server-only endpoint and I/O overrides for tests.
|
||||
* @returns {Promise<{status:number,body:string,contentType:string,endpoint:string,rateLimited:boolean}>}
|
||||
*/
|
||||
async function fetchOverpassPayload(body, maxResponseBytes = OVERPASS_MAX_RESPONSE_BYTES, {
|
||||
async function fetchOverpassPayload(
|
||||
body,
|
||||
maxResponseBytes = OVERPASS_MAX_RESPONSE_BYTES,
|
||||
{
|
||||
endpoints = OVERPASS_UPSTREAMS,
|
||||
fetchImpl = fetch,
|
||||
readBody = readResponseTextCapped,
|
||||
simplify = simplifyOverpassPayloadBody,
|
||||
} = {}) {
|
||||
} = {},
|
||||
) {
|
||||
let lastError = null;
|
||||
let lastRateLimitPayload = null;
|
||||
let lastRefusalPayload = null;
|
||||
@@ -83,9 +98,11 @@ async function fetchOverpassPayload(body, maxResponseBytes = OVERPASS_MAX_RESPON
|
||||
});
|
||||
|
||||
const responseBody = await readBody(upstream, maxResponseBytes);
|
||||
const contentType = upstream.headers.get('content-type') || 'application/json';
|
||||
const contentType =
|
||||
upstream.headers.get('content-type') || 'application/json';
|
||||
const status = upstream.status;
|
||||
const rateLimited = status === 429 || overpassLooksRateLimited(responseBody);
|
||||
const rateLimited =
|
||||
status === 429 || overpassLooksRateLimited(responseBody);
|
||||
const runtimeError = overpassLooksRuntimeError(responseBody);
|
||||
const payload = {
|
||||
status,
|
||||
@@ -116,7 +133,9 @@ async function fetchOverpassPayload(body, maxResponseBytes = OVERPASS_MAX_RESPON
|
||||
// every mirror has had the chance to answer it.
|
||||
if (status < 200 || status >= 300) {
|
||||
if (!lastRefusalPayload) lastRefusalPayload = payload;
|
||||
lastError = new Error(`Overpass upstream returned ${status} (${endpoint})`);
|
||||
lastError = new Error(
|
||||
`Overpass upstream returned ${status} (${endpoint})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export { regionalBriefProxy, regionalBriefHasAnySource } from './regional/briefing.js';
|
||||
export {
|
||||
regionalBriefProxy,
|
||||
regionalBriefHasAnySource,
|
||||
} from './regional/briefing.js';
|
||||
export { weatherEffectsProxy } from './regional/weather-effects.js';
|
||||
export { validRegionalPoint } from './regional/query.js';
|
||||
@@ -18,7 +18,11 @@ const _regionalBriefCache = new Map();
|
||||
|
||||
const _regionalBriefInFlight = new Map();
|
||||
|
||||
const _regionalBriefRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 30, globalMax: 90 });
|
||||
const _regionalBriefRateLimiter = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: 30,
|
||||
globalMax: 90,
|
||||
});
|
||||
|
||||
function trimRegionalBriefCache() {
|
||||
while (_regionalBriefCache.size > REGIONAL_BRIEF_MAX_CACHE) {
|
||||
@@ -40,13 +44,15 @@ function regionalBriefProxy() {
|
||||
fetchRegionalWeather(point),
|
||||
]);
|
||||
const place = placeResult.status === 'fulfilled' ? placeResult.value : null;
|
||||
const weather = weatherResult.status === 'fulfilled' ? weatherResult.value : null;
|
||||
const weather =
|
||||
weatherResult.status === 'fulfilled' ? weatherResult.value : null;
|
||||
const news = await fetchRegionalNews(place);
|
||||
if (!regionalBriefHasAnySource({ place, weather, news })) {
|
||||
throw new Error('All regional briefing sources unavailable');
|
||||
}
|
||||
const payload = {
|
||||
status: place && weather && news.status !== 'unavailable' ? 'ready' : 'partial',
|
||||
status:
|
||||
place && weather && news.status !== 'unavailable' ? 'ready' : 'partial',
|
||||
retrievedAt: new Date().toISOString(),
|
||||
coordinates: point,
|
||||
place,
|
||||
@@ -71,7 +77,10 @@ function regionalBriefProxy() {
|
||||
return;
|
||||
}
|
||||
if (!_regionalBriefRateLimiter(clientKey(req))) {
|
||||
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '10' });
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '10',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
@@ -79,18 +88,28 @@ function regionalBriefProxy() {
|
||||
const point = validRegionalPoint(url.searchParams);
|
||||
if (!point) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Valid latitude and longitude are required' }));
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'Valid latitude and longitude are required',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const key = `${(Math.round(point.latitude * 10) / 10).toFixed(1)},${(Math.round(point.longitude * 10) / 10).toFixed(1)}`;
|
||||
const now = Date.now();
|
||||
const cached = _regionalBriefCache.get(key);
|
||||
if (cached && now - cached.cachedAt <= REGIONAL_BRIEF_CACHE_MS) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=60', 'X-Regional-Brief': 'HIT' });
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
'X-Regional-Brief': 'HIT',
|
||||
});
|
||||
res.end(JSON.stringify({ ...cached.payload, status: 'cached' }));
|
||||
return;
|
||||
}
|
||||
const request = coalesceProxyRequest(_regionalBriefInFlight, key, () => refresh(point, key));
|
||||
const request = coalesceProxyRequest(_regionalBriefInFlight, key, () =>
|
||||
refresh(point, key),
|
||||
);
|
||||
try {
|
||||
const payload = await request.promise;
|
||||
res.writeHead(200, {
|
||||
@@ -101,12 +120,23 @@ function regionalBriefProxy() {
|
||||
res.end(JSON.stringify(payload));
|
||||
} catch {
|
||||
if (cached && now - cached.cachedAt <= REGIONAL_BRIEF_STALE_MS) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Regional-Brief': 'STALE' });
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Regional-Brief': 'STALE',
|
||||
});
|
||||
res.end(JSON.stringify({ ...cached.payload, status: 'stale' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify({ error: 'Regional briefing is temporarily unavailable' }));
|
||||
res.writeHead(503, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'Regional briefing is temporarily unavailable',
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { readResponseJsonCapped, readResponseTextCapped } from '../common/http.js';
|
||||
import {
|
||||
readResponseJsonCapped,
|
||||
readResponseTextCapped,
|
||||
} from '../common/http.js';
|
||||
|
||||
const REGIONAL_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
async function fetchRegionalJson(url, {
|
||||
async function fetchRegionalJson(
|
||||
url,
|
||||
{
|
||||
headers = {},
|
||||
timeoutMs = 9000,
|
||||
maxBytes = REGIONAL_MAX_RESPONSE_BYTES,
|
||||
} = {}) {
|
||||
} = {},
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
@@ -18,11 +24,14 @@ async function fetchRegionalJson(url, {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRegionalText(url, {
|
||||
async function fetchRegionalText(
|
||||
url,
|
||||
{
|
||||
headers = {},
|
||||
timeoutMs = 9000,
|
||||
maxBytes = REGIONAL_MAX_RESPONSE_BYTES,
|
||||
} = {}) {
|
||||
} = {},
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
|
||||
@@ -4,24 +4,39 @@ import { normalizeRegionalArticles } from '../../../src/data/regionalBrief.js';
|
||||
function decodeRssText(value) {
|
||||
return String(value || '')
|
||||
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
||||
.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'")
|
||||
.replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function rssTag(block, tag) {
|
||||
return decodeRssText(new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(block)?.[1] || '');
|
||||
return decodeRssText(
|
||||
new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(
|
||||
block,
|
||||
)?.[1] || '',
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRssArticles(xml, limit = 5) {
|
||||
const seen = new Set();
|
||||
const articles = [];
|
||||
for (const match of String(xml || '').matchAll(/<item>([\s\S]*?)<\/item>/gi)) {
|
||||
for (const match of String(xml || '').matchAll(
|
||||
/<item>([\s\S]*?)<\/item>/gi,
|
||||
)) {
|
||||
const item = match[1];
|
||||
const title = rssTag(item, 'title').slice(0, 180);
|
||||
const url = rssTag(item, 'link');
|
||||
let parsedUrl;
|
||||
try { parsedUrl = new URL(url); } catch { continue; }
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!title || !['http:', 'https:'].includes(parsedUrl.protocol)) continue;
|
||||
const source = rssTag(item, 'source');
|
||||
const signature = `${title.toLowerCase()}|${source.toLowerCase() || parsedUrl.hostname}`;
|
||||
@@ -32,7 +47,9 @@ function normalizeRssArticles(xml, limit = 5) {
|
||||
title,
|
||||
url: parsedUrl.href,
|
||||
domain: source || parsedUrl.hostname.replace(/^www\./, ''),
|
||||
publishedAt: Number.isNaN(Date.parse(rawDate)) ? null : new Date(rawDate).toISOString(),
|
||||
publishedAt: Number.isNaN(Date.parse(rawDate))
|
||||
? null
|
||||
: new Date(rawDate).toISOString(),
|
||||
sourceCountry: null,
|
||||
});
|
||||
if (articles.length >= limit) break;
|
||||
@@ -42,7 +59,8 @@ function normalizeRssArticles(xml, limit = 5) {
|
||||
|
||||
async function fetchRegionalNews(place) {
|
||||
const query = place?.locality || place?.region || place?.country;
|
||||
if (!query) return { status: 'unavailable', query: null, articles: [], source: null };
|
||||
if (!query)
|
||||
return { status: 'unavailable', query: null, articles: [], source: null };
|
||||
const rssParams = new URLSearchParams({
|
||||
q: String(query).replace(/["\\]/g, ' ').trim(),
|
||||
hl: 'en-US',
|
||||
@@ -50,13 +68,19 @@ async function fetchRegionalNews(place) {
|
||||
ceid: 'US:en',
|
||||
});
|
||||
try {
|
||||
const xml = await fetchRegionalText(`https://news.google.com/rss/search?${rssParams}`, {
|
||||
const xml = await fetchRegionalText(
|
||||
`https://news.google.com/rss/search?${rssParams}`,
|
||||
{
|
||||
headers: { 'User-Agent': 'GodsEyeView/0.1' },
|
||||
timeoutMs: 12_000,
|
||||
});
|
||||
},
|
||||
);
|
||||
const articles = normalizeRssArticles(xml, 5);
|
||||
if (articles.length) return { status: 'ready', query, articles, source: 'Google News RSS' };
|
||||
} catch { /* fall through to the existing free index */ }
|
||||
if (articles.length)
|
||||
return { status: 'ready', query, articles, source: 'Google News RSS' };
|
||||
} catch {
|
||||
/* fall through to the existing free index */
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
query: `"${String(query).replace(/["\\]/g, ' ').trim()}"`,
|
||||
mode: 'artlist',
|
||||
@@ -66,12 +90,20 @@ async function fetchRegionalNews(place) {
|
||||
timespan: '48h',
|
||||
});
|
||||
try {
|
||||
const payload = await fetchRegionalJson(`https://api.gdeltproject.org/api/v2/doc/doc?${params}`, {
|
||||
const payload = await fetchRegionalJson(
|
||||
`https://api.gdeltproject.org/api/v2/doc/doc?${params}`,
|
||||
{
|
||||
headers: { 'User-Agent': 'GodsEyeView/0.1' },
|
||||
timeoutMs: 12_000,
|
||||
});
|
||||
},
|
||||
);
|
||||
const articles = normalizeRegionalArticles(payload, 5);
|
||||
return { status: articles.length ? 'ready' : 'empty', query, articles, source: 'GDELT fallback' };
|
||||
return {
|
||||
status: articles.length ? 'ready' : 'empty',
|
||||
query,
|
||||
articles,
|
||||
source: 'GDELT fallback',
|
||||
};
|
||||
} catch {
|
||||
return { status: 'unavailable', query, articles: [], source: null };
|
||||
}
|
||||
|
||||
@@ -18,12 +18,16 @@ function fetchRegionalPlace(point) {
|
||||
addressdetails: '1',
|
||||
'accept-language': 'en',
|
||||
});
|
||||
const payload = await fetchRegionalJson(`https://nominatim.openstreetmap.org/reverse?${params}`, {
|
||||
const payload = await fetchRegionalJson(
|
||||
`https://nominatim.openstreetmap.org/reverse?${params}`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'GodsEyeView/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
'User-Agent':
|
||||
'GodsEyeView/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
Referer: 'https://github.com/bilawalsidhu/gods-eye-view',
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
return normalizeRegionalPlace(payload);
|
||||
});
|
||||
_nominatimQueue = task.catch(() => null);
|
||||
|
||||
@@ -4,7 +4,8 @@ function validRegionalPoint(params) {
|
||||
const latitude = requiredFiniteQueryNumber(params, 'latitude');
|
||||
const longitude = requiredFiniteQueryNumber(params, 'longitude');
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return null;
|
||||
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180)
|
||||
return null;
|
||||
return { latitude, longitude };
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,11 @@ const _weatherEffectsCache = new Map();
|
||||
|
||||
const _weatherEffectsInFlight = new Map();
|
||||
|
||||
const _weatherEffectsRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 45, globalMax: 120 });
|
||||
const _weatherEffectsRateLimiter = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: 45,
|
||||
globalMax: 120,
|
||||
});
|
||||
|
||||
function trimWeatherEffectsCache() {
|
||||
while (_weatherEffectsCache.size > WEATHER_EFFECTS_MAX_CACHE) {
|
||||
@@ -46,7 +50,10 @@ function weatherEffectsProxy() {
|
||||
return;
|
||||
}
|
||||
if (!_weatherEffectsRateLimiter(clientKey(req))) {
|
||||
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '10' });
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '10',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
@@ -54,7 +61,11 @@ function weatherEffectsProxy() {
|
||||
const point = validRegionalPoint(url.searchParams);
|
||||
if (!point) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Valid latitude and longitude are required' }));
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'Valid latitude and longitude are required',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const key = `${(Math.round(point.latitude * 10) / 10).toFixed(1)},${(Math.round(point.longitude * 10) / 10).toFixed(1)}`;
|
||||
@@ -69,7 +80,9 @@ function weatherEffectsProxy() {
|
||||
res.end(JSON.stringify({ ...cached.payload, status: 'cached' }));
|
||||
return;
|
||||
}
|
||||
const request = coalesceProxyRequest(_weatherEffectsInFlight, key, () => refresh(point, key));
|
||||
const request = coalesceProxyRequest(_weatherEffectsInFlight, key, () =>
|
||||
refresh(point, key),
|
||||
);
|
||||
try {
|
||||
const payload = await request.promise;
|
||||
res.writeHead(200, {
|
||||
@@ -88,8 +101,15 @@ function weatherEffectsProxy() {
|
||||
res.end(JSON.stringify({ ...cached.payload, status: 'stale' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify({ error: 'Weather effects are temporarily unavailable' }));
|
||||
res.writeHead(503, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'Weather effects are temporarily unavailable',
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,13 +7,17 @@ async function fetchRegionalWeather(point) {
|
||||
const params = new URLSearchParams({
|
||||
latitude: point.latitude.toFixed(5),
|
||||
longitude: point.longitude.toFixed(5),
|
||||
current: 'temperature_2m,apparent_temperature,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,visibility',
|
||||
current:
|
||||
'temperature_2m,apparent_temperature,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,visibility',
|
||||
timezone: 'UTC',
|
||||
});
|
||||
try {
|
||||
const payload = await fetchRegionalJson(`https://api.open-meteo.com/v1/forecast?${params}`, {
|
||||
const payload = await fetchRegionalJson(
|
||||
`https://api.open-meteo.com/v1/forecast?${params}`,
|
||||
{
|
||||
maxBytes: WEATHER_EFFECTS_MAX_RESPONSE_BYTES,
|
||||
});
|
||||
},
|
||||
);
|
||||
return normalizeRegionalWeather(payload);
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { knownKeySetupEnvVars, admitKeySetupRequest, isKeySetupExternallyManaged, keySetupStatus, validateKeySetupUpdates, upsertDotenvValues } from '../../src/keySetupCore.mjs';
|
||||
import {
|
||||
knownKeySetupEnvVars,
|
||||
admitKeySetupRequest,
|
||||
isKeySetupExternallyManaged,
|
||||
keySetupStatus,
|
||||
validateKeySetupUpdates,
|
||||
upsertDotenvValues,
|
||||
} from '../../src/keySetupCore.mjs';
|
||||
import { defaultSourceRoot } from '../providers/common/source-root.js';
|
||||
import path from 'node:path';
|
||||
import { readEnvironmentSource as readPinokioEnvironmentSource } from '../../scripts/pinokio-environment.mjs';
|
||||
@@ -23,9 +30,15 @@ const LAUNCHER_AT_BOOT = process.env.GEV_LAUNCHER;
|
||||
* Recomputing the snapshot there would classify the panel's own keys as
|
||||
* external (read-only) until the whole process is relaunched.
|
||||
*/
|
||||
const PROVIDER_ENV_AT_BOOT = globalThis.__GEV_PROVIDER_ENV_AT_BOOT ??= Object.freeze(Object.fromEntries(
|
||||
[...knownKeySetupEnvVars()].map((name) => [name, String(process.env[name] ?? '').trim()]),
|
||||
));
|
||||
const PROVIDER_ENV_AT_BOOT = (globalThis.__GEV_PROVIDER_ENV_AT_BOOT ??=
|
||||
Object.freeze(
|
||||
Object.fromEntries(
|
||||
[...knownKeySetupEnvVars()].map((name) => [
|
||||
name,
|
||||
String(process.env[name] ?? '').trim(),
|
||||
]),
|
||||
),
|
||||
));
|
||||
|
||||
/**
|
||||
* `dev-fresh.sh` resolves dotenv and Keychain values before it starts Vite, so
|
||||
@@ -79,8 +92,13 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
// `GEV_LAUNCHER=pinokio` line in someone's .env would silently redirect a
|
||||
// plain `npm run dev` to write the Pinokio store it never loaded.
|
||||
const pinokioManaged = () => LAUNCHER_AT_BOOT === 'pinokio';
|
||||
const storeName = () => (pinokioManaged() ? 'pinokio-environment' : 'env-file');
|
||||
const storePath = () => path.join(sourceRoot, ...(pinokioManaged() ? ['pinokio', 'ENVIRONMENT'] : ['.env']));
|
||||
const storeName = () =>
|
||||
pinokioManaged() ? 'pinokio-environment' : 'env-file';
|
||||
const storePath = () =>
|
||||
path.join(
|
||||
sourceRoot,
|
||||
...(pinokioManaged() ? ['pinokio', 'ENVIRONMENT'] : ['.env']),
|
||||
);
|
||||
// Read the store, distinguishing "no store yet" from "cannot read this
|
||||
// store". Only ENOENT means empty. Every other failure — a permission error,
|
||||
// an I/O fault, an undecodable file — must ABORT the save: upserting into a
|
||||
@@ -97,7 +115,9 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
return fs.readFileSync(storePath(), 'utf8');
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return ''; // The first saved key births the file.
|
||||
const unreadable = new Error('the existing configuration could not be read, so nothing was changed');
|
||||
const unreadable = new Error(
|
||||
'the existing configuration could not be read, so nothing was changed',
|
||||
);
|
||||
unreadable.code = 'GEV_STORE_UNREADABLE';
|
||||
throw unreadable;
|
||||
}
|
||||
@@ -114,7 +134,8 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
};
|
||||
// The gate itself is pure and unit-tested (admitKeySetupRequest in
|
||||
// src/keySetupCore.mjs) — this just feeds it the request.
|
||||
const admit = (req) => admitKeySetupRequest({
|
||||
const admit = (req) =>
|
||||
admitKeySetupRequest({
|
||||
method: req.method,
|
||||
remoteAddress: req.socket?.remoteAddress,
|
||||
hostHeader: req.headers?.host,
|
||||
@@ -147,7 +168,9 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
// offered); 'external' = supplied by env/Keychain/another workflow
|
||||
// (read-only — the panel must never rewrite or delete it).
|
||||
key.managed = key.set
|
||||
? (key.envVars.some((name) => isExternallyManaged(name, inStore)) ? 'external' : 'file')
|
||||
? key.envVars.some((name) => isExternallyManaged(name, inStore))
|
||||
? 'external'
|
||||
: 'file'
|
||||
: null;
|
||||
}
|
||||
return { ...status, store: storeName() };
|
||||
@@ -161,7 +184,9 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
// Never write THROUGH a symlink into a credential path.
|
||||
try {
|
||||
if (fs.lstatSync(filepath).isSymbolicLink()) {
|
||||
throw new Error('refusing to write a credential store that is a symlink');
|
||||
throw new Error(
|
||||
'refusing to write a credential store that is a symlink',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error; // absent is fine — first save.
|
||||
@@ -183,7 +208,9 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
// hardening failure aborts with the previous store fully intact and the
|
||||
// secret never on disk unprotected — no rollback path to get wrong.
|
||||
if (!hardenCredentialFile(tmp)) {
|
||||
const error = new Error('could not restrict the credential file to your account; nothing was saved');
|
||||
const error = new Error(
|
||||
'could not restrict the credential file to your account; nothing was saved',
|
||||
);
|
||||
error.code = 'GEV_HARDEN_FAILED';
|
||||
throw error;
|
||||
}
|
||||
@@ -215,18 +242,23 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
// only install via configureServer (never configurePreviewServer), so they
|
||||
// are absent from preview today — but pinning apply here makes that a
|
||||
// guarantee rather than an accident of which hook a future edit uses.
|
||||
apply: (_config, { command, isPreview }) => command === 'serve' && !isPreview,
|
||||
apply: (_config, { command, isPreview }) =>
|
||||
command === 'serve' && !isPreview,
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/api/setup/status', (req, res) => {
|
||||
if (req.method !== 'GET') return respond(res, 405, { error: 'Method not allowed' });
|
||||
if (req.method !== 'GET')
|
||||
return respond(res, 405, { error: 'Method not allowed' });
|
||||
const admission = admit(req);
|
||||
if (!admission.ok) return respond(res, admission.status, { error: admission.error });
|
||||
if (!admission.ok)
|
||||
return respond(res, admission.status, { error: admission.error });
|
||||
respond(res, 200, providerStatus());
|
||||
});
|
||||
server.middlewares.use('/api/setup/keys', (req, res) => {
|
||||
if (req.method !== 'POST') return respond(res, 405, { error: 'Method not allowed' });
|
||||
if (req.method !== 'POST')
|
||||
return respond(res, 405, { error: 'Method not allowed' });
|
||||
const admission = admit(req);
|
||||
if (!admission.ok) return respond(res, admission.status, { error: admission.error });
|
||||
if (!admission.ok)
|
||||
return respond(res, admission.status, { error: admission.error });
|
||||
let body = '';
|
||||
let overflowed = false;
|
||||
req.on('data', (chunk) => {
|
||||
@@ -237,7 +269,8 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
}
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (overflowed) return respond(res, 413, { error: 'Request too large' });
|
||||
if (overflowed)
|
||||
return respond(res, 413, { error: 'Request too large' });
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(body || '{}');
|
||||
@@ -266,10 +299,17 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
// "saved world-readable" must never be reported as a generic write
|
||||
// error. Everything else returns a fixed message (a raw filesystem
|
||||
// error can carry an absolute path; that stays in the server log).
|
||||
if (error?.code === 'GEV_HARDEN_FAILED' || error?.code === 'GEV_STORE_UNREADABLE') {
|
||||
return respond(res, 500, { error: `The key was not saved: ${error.message}` });
|
||||
if (
|
||||
error?.code === 'GEV_HARDEN_FAILED' ||
|
||||
error?.code === 'GEV_STORE_UNREADABLE'
|
||||
) {
|
||||
return respond(res, 500, {
|
||||
error: `The key was not saved: ${error.message}`,
|
||||
});
|
||||
}
|
||||
return respond(res, 500, { error: `Could not write the ${storeName()} store` });
|
||||
return respond(res, 500, {
|
||||
error: `Could not write the ${storeName()} store`,
|
||||
});
|
||||
}
|
||||
// Live for the server-side proxies immediately; the restart below is
|
||||
// what re-injects the client-exposed defines (Google, Cesium ion).
|
||||
@@ -289,7 +329,10 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
// .env watcher may fire too; a second queued restart is harmless.
|
||||
setTimeout(() => {
|
||||
server.restart().catch((error) => {
|
||||
console.warn('[KeySetup] Dev-server restart failed:', error?.message || error);
|
||||
console.warn(
|
||||
'[KeySetup] Dev-server restart failed:',
|
||||
error?.message || error,
|
||||
);
|
||||
});
|
||||
}, 250);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Readable } from 'node:stream';
|
||||
import { mkdtempSync, readFileSync, existsSync, rmSync, statSync } from 'node:fs';
|
||||
import {
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { overpassProxy } from 'gods-eye-view/server/providers/overpass';
|
||||
import { militaryInstallationsProxy } from 'gods-eye-view/server/providers/military-installations';
|
||||
import { regionalBriefProxy, weatherEffectsProxy } from 'gods-eye-view/server/providers/regional';
|
||||
import {
|
||||
regionalBriefProxy,
|
||||
weatherEffectsProxy,
|
||||
} from 'gods-eye-view/server/providers/regional';
|
||||
import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
|
||||
import { keySetupEndpoint } from 'gods-eye-view/server/standalone/key-setup';
|
||||
import { realtimeInstructions } from '../../server/providers/openai/instructions.js';
|
||||
@@ -20,24 +29,57 @@ function install(plugin, preview = false) {
|
||||
});
|
||||
return routes;
|
||||
}
|
||||
function request(handler, { method = 'GET', url = '/', body = '', origin = 'http://localhost:4173' } = {}) {
|
||||
function request(
|
||||
handler,
|
||||
{
|
||||
method = 'GET',
|
||||
url = '/',
|
||||
body = '',
|
||||
origin = 'http://localhost:4173',
|
||||
} = {},
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = Readable.from(body ? [Buffer.from(body)] : []);
|
||||
Object.assign(req, { method, url, headers: { host: 'localhost:4173', origin, 'content-type': 'application/json' }, socket: { remoteAddress: '127.0.0.1' } });
|
||||
Object.assign(req, {
|
||||
method,
|
||||
url,
|
||||
headers: {
|
||||
host: 'localhost:4173',
|
||||
origin,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
});
|
||||
const headers = {};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader(name, value) { headers[name.toLowerCase()] = value; },
|
||||
writeHead(status, values) { this.statusCode = status; for (const [k, v] of Object.entries(values)) this.setHeader(k, v); },
|
||||
end(body = '') { resolve({ status: this.statusCode, headers, body: String(body), json: () => JSON.parse(String(body)) }); },
|
||||
setHeader(name, value) {
|
||||
headers[name.toLowerCase()] = value;
|
||||
},
|
||||
writeHead(status, values) {
|
||||
this.statusCode = status;
|
||||
for (const [k, v] of Object.entries(values)) this.setHeader(k, v);
|
||||
},
|
||||
end(body = '') {
|
||||
resolve({
|
||||
status: this.statusCode,
|
||||
headers,
|
||||
body: String(body),
|
||||
json: () => JSON.parse(String(body)),
|
||||
});
|
||||
},
|
||||
};
|
||||
Promise.resolve(handler(req, res)).catch(reject);
|
||||
});
|
||||
}
|
||||
function env(t, name, value) {
|
||||
const old = process.env[name];
|
||||
if (value === undefined) delete process.env[name]; else process.env[name] = value;
|
||||
t.after(() => { if (old === undefined) delete process.env[name]; else process.env[name] = old; });
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
t.after(() => {
|
||||
if (old === undefined) delete process.env[name];
|
||||
else process.env[name] = old;
|
||||
});
|
||||
}
|
||||
function root(t) {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'gev-services-'));
|
||||
@@ -46,12 +88,29 @@ function root(t) {
|
||||
}
|
||||
|
||||
test('standalone service guards run in development and preview without upstream acquisition', async (t) => {
|
||||
t.mock.method(globalThis, 'fetch', () => { throw Error('invalid requests must not fetch'); });
|
||||
t.mock.method(globalThis, 'fetch', () => {
|
||||
throw Error('invalid requests must not fetch');
|
||||
});
|
||||
for (const preview of [false, true]) {
|
||||
for (const [factory, route] of [[overpassProxy, '/api/overpass'], [militaryInstallationsProxy, '/api/military-installations'], [regionalBriefProxy, '/api/regional-brief'], [weatherEffectsProxy, '/api/weather-effects']]) {
|
||||
for (const [factory, route] of [
|
||||
[overpassProxy, '/api/overpass'],
|
||||
[militaryInstallationsProxy, '/api/military-installations'],
|
||||
[regionalBriefProxy, '/api/regional-brief'],
|
||||
[weatherEffectsProxy, '/api/weather-effects'],
|
||||
]) {
|
||||
const routes = install(factory(), preview);
|
||||
assert.equal((await request(routes.get(route), { method: 'DELETE' })).status, 405);
|
||||
assert.equal((await request(routes.get(route), { method: route === '/api/overpass' ? 'POST' : 'GET' })).status, 400);
|
||||
assert.equal(
|
||||
(await request(routes.get(route), { method: 'DELETE' })).status,
|
||||
405,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(routes.get(route), {
|
||||
method: route === '/api/overpass' ? 'POST' : 'GET',
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -64,17 +123,36 @@ test('weather-only requests share upstream work and retain fresh and stale respo
|
||||
calls++;
|
||||
assert.equal(new URL(url).hostname, 'api.open-meteo.com');
|
||||
if (calls > 1) throw Error('offline');
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
return Response.json({ current: { time: '2026-09-12T12:00', temperature_2m: 20, weather_code: 0, wind_speed_10m: 10 } });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
return Response.json({
|
||||
current: {
|
||||
time: '2026-09-12T12:00',
|
||||
temperature_2m: 20,
|
||||
weather_code: 0,
|
||||
wind_speed_10m: 10,
|
||||
},
|
||||
});
|
||||
});
|
||||
const handler = install(weatherEffectsProxy()).get('/api/weather-effects');
|
||||
const query = { url: '/?latitude=34.61&longitude=-112.43' };
|
||||
const pair = await Promise.all([request(handler, query), request(handler, query)]);
|
||||
assert.deepEqual(pair.map(r => r.headers['x-weather-effects']).sort(), ['INFLIGHT', 'MISS']);
|
||||
const pair = await Promise.all([
|
||||
request(handler, query),
|
||||
request(handler, query),
|
||||
]);
|
||||
assert.deepEqual(pair.map((r) => r.headers['x-weather-effects']).sort(), [
|
||||
'INFLIGHT',
|
||||
'MISS',
|
||||
]);
|
||||
assert.equal(calls, 1);
|
||||
assert.equal((await request(handler, query)).headers['x-weather-effects'], 'HIT');
|
||||
assert.equal(
|
||||
(await request(handler, query)).headers['x-weather-effects'],
|
||||
'HIT',
|
||||
);
|
||||
now += 6 * 60_000;
|
||||
assert.equal((await request(handler, query)).headers['x-weather-effects'], 'STALE');
|
||||
assert.equal(
|
||||
(await request(handler, query)).headers['x-weather-effects'],
|
||||
'STALE',
|
||||
);
|
||||
});
|
||||
|
||||
test('Realtime handler preserves tools and default instructions, isolates supplied annotation guidance, and keeps the upstream key server-side', async (t) => {
|
||||
@@ -83,17 +161,33 @@ test('Realtime handler preserves tools and default instructions, isolates suppli
|
||||
const sent = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
assert.equal(url, 'https://api.openai.com/v1/realtime/client_secrets');
|
||||
assert.equal(options.headers.Authorization, 'Bearer fixture-upstream-secret');
|
||||
assert.equal(
|
||||
options.headers.Authorization,
|
||||
'Bearer fixture-upstream-secret',
|
||||
);
|
||||
sent.push(JSON.parse(options.body));
|
||||
return Response.json({ value: 'fixture-ephemeral' });
|
||||
});
|
||||
for (const [options, guidance] of [[{}, undefined], [{ annotationGuidance: 'Fixture annotation instruction.' }, 'Fixture annotation instruction.'], [{}, undefined]]) {
|
||||
const response = await request(install(openAiRealtimeProxy(options)).get('/api/realtime/token'), { url: '/?tier=unknown' });
|
||||
for (const [options, guidance] of [
|
||||
[{}, undefined],
|
||||
[
|
||||
{ annotationGuidance: 'Fixture annotation instruction.' },
|
||||
'Fixture annotation instruction.',
|
||||
],
|
||||
[{}, undefined],
|
||||
]) {
|
||||
const response = await request(
|
||||
install(openAiRealtimeProxy(options)).get('/api/realtime/token'),
|
||||
{ url: '/?tier=unknown' },
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers['x-gev-voice-tier'], 'standard');
|
||||
assert.equal(response.headers['x-gev-voice-tier-fallback'], '1');
|
||||
assert.equal(response.body.includes('fixture-upstream-secret'), false);
|
||||
assert.equal(sent.at(-1).session.instructions, realtimeInstructions(guidance));
|
||||
assert.equal(
|
||||
sent.at(-1).session.instructions,
|
||||
realtimeInstructions(guidance),
|
||||
);
|
||||
assert.deepEqual(sent.at(-1).session.tools, GEV_REALTIME_TOOLS);
|
||||
}
|
||||
assert.notEqual(sent[0].session.instructions, sent[1].session.instructions);
|
||||
@@ -101,30 +195,63 @@ test('Realtime handler preserves tools and default instructions, isolates suppli
|
||||
});
|
||||
|
||||
test('debug logging resolves each supplied application directory independently', async (t) => {
|
||||
const first = root(t), second = root(t);
|
||||
for (const [sourceRoot, marker] of [[first, 'first'], [second, 'second']]) {
|
||||
const handler = install(openAiRealtimeProxy({ sourceRoot })).get('/api/realtime/debug-log');
|
||||
assert.equal((await request(handler, { method: 'POST', body: JSON.stringify({ marker }) })).status, 204);
|
||||
const file = path.join(sourceRoot, '.gev-logs/realtime-conversations.jsonl');
|
||||
const first = root(t),
|
||||
second = root(t);
|
||||
for (const [sourceRoot, marker] of [
|
||||
[first, 'first'],
|
||||
[second, 'second'],
|
||||
]) {
|
||||
const handler = install(openAiRealtimeProxy({ sourceRoot })).get(
|
||||
'/api/realtime/debug-log',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(handler, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ marker }),
|
||||
})
|
||||
).status,
|
||||
204,
|
||||
);
|
||||
const file = path.join(
|
||||
sourceRoot,
|
||||
'.gev-logs/realtime-conversations.jsonl',
|
||||
);
|
||||
assert.equal(JSON.parse(readFileSync(file, 'utf8')).marker, marker);
|
||||
}
|
||||
});
|
||||
|
||||
test('key setup writes only the supplied application root, retains request guards and stays absent from preview', async (t) => {
|
||||
const first = root(t), untouched = root(t);
|
||||
const first = root(t),
|
||||
untouched = root(t);
|
||||
env(t, 'OPENAI_API_KEY', undefined);
|
||||
const plugin = keySetupEndpoint({ sourceRoot: first });
|
||||
assert.equal(plugin.apply({}, { command: 'serve', isPreview: true }), false);
|
||||
assert.equal(plugin.configurePreviewServer, undefined);
|
||||
const routes = install(plugin);
|
||||
const handler = routes.get('/api/setup/keys');
|
||||
const body = JSON.stringify({ OPENAI_API_KEY: 'sk-fixture-only-not-a-real-key' });
|
||||
assert.equal((await request(handler, { method: 'POST', body, origin: 'https://example.com' })).status, 403);
|
||||
const body = JSON.stringify({
|
||||
OPENAI_API_KEY: 'sk-fixture-only-not-a-real-key',
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await request(handler, {
|
||||
method: 'POST',
|
||||
body,
|
||||
origin: 'https://example.com',
|
||||
})
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
assert.equal(existsSync(path.join(first, '.env')), false);
|
||||
const saved = await request(handler, { method: 'POST', body });
|
||||
assert.equal(saved.status, 200);
|
||||
assert.match(readFileSync(path.join(first, '.env'), 'utf8'), /OPENAI_API_KEY=sk-fixture-only-not-a-real-key/);
|
||||
if (process.platform !== 'win32') assert.equal(statSync(path.join(first, '.env')).mode & 0o777, 0o600);
|
||||
assert.match(
|
||||
readFileSync(path.join(first, '.env'), 'utf8'),
|
||||
/OPENAI_API_KEY=sk-fixture-only-not-a-real-key/,
|
||||
);
|
||||
if (process.platform !== 'win32')
|
||||
assert.equal(statSync(path.join(first, '.env')).mode & 0o777, 0o600);
|
||||
assert.equal(saved.body.includes('sk-fixture-only-not-a-real-key'), false);
|
||||
assert.equal(existsSync(path.join(untouched, '.env')), false);
|
||||
});
|
||||
Reference in new issue
Block a user