mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 04:44:01 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f579b23f52 | ||
|
|
f0d3db3164 | ||
|
|
078a6c0db0 | ||
|
|
7af0453f34 |
No files matched your search
@@ -1,4 +0,0 @@
|
||||
- Without `OPENAI_API_KEY`, the mic now says the key is missing and to add it in
|
||||
Provider Settings, instead of showing the raw server error with a hint to
|
||||
check microphone permissions. `/api/realtime/token` returns
|
||||
`code: "API_KEY_MISSING"` with its 503 (asturksever, #1599).
|
||||
@@ -0,0 +1,5 @@
|
||||
- The CCTV Street View fallback serves only catalog cameras, at their catalog
|
||||
position, heading, field of view and pitch, and ignores coordinates a
|
||||
request supplies. It spends from the per-client Google budget
|
||||
(`GEV_RATELIMIT_GOOGLE_PER_MIN`, default 120), so the Google key can no
|
||||
longer be used as an unmetered Street View service (asturksever, #1602).
|
||||
@@ -1276,7 +1276,10 @@ and follows redirects only on the camera's origin. Catalog image URLs, and
|
||||
redirects on those hosts, are pinned by parsed URL to the agency's image
|
||||
directory. Every `/api/cctv` route refuses cross-site browser requests but
|
||||
accepts forwarding headers, so cameras work behind a reverse proxy; a forwarded
|
||||
request skips the key-bearing Street View fallback and gets the placeholder.
|
||||
request skips the key-bearing Street View fallback and gets the placeholder. The
|
||||
Street View fallback serves only catalog cameras, at the catalog position,
|
||||
heading, field of view and pitch, never coordinates a request supplies, and it
|
||||
shares the per-client Google budget (`GEV_RATELIMIT_GOOGLE_PER_MIN`, default 120).
|
||||
|
||||
The CCTV layer draws only the cameras on or near the screen. Zoomed out, it
|
||||
keeps one camera per ground cell sized to the view, preferring a healthy feed,
|
||||
|
||||
@@ -377,7 +377,9 @@
|
||||
"src/data/cctvFootprint.js",
|
||||
"src/sources/cctvTypes.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js"
|
||||
"src/i18n/marker.js",
|
||||
"server/providers/common/rate-limit.js",
|
||||
"src/sources/rateLimit.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
|
||||
+32
-16
@@ -19,7 +19,22 @@ import { admitSameSiteKeyless } from './common/same-site.js';
|
||||
import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs';
|
||||
import { createHlsPuller } from './cctv/stream.js';
|
||||
import { googleServerApiKey } from './places/google-key.js';
|
||||
import { clientKey, makeCostRateLimiter } from './common/rate-limit.js';
|
||||
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
|
||||
let _streetViewRateLimiter;
|
||||
/**
|
||||
* Per-IP budget for the Street View fallback, on the same setting as the
|
||||
* other Google routes (GEV_RATELIMIT_GOOGLE_PER_MIN; 0 disables it).
|
||||
*/
|
||||
function streetViewRateLimiter() {
|
||||
if (_streetViewRateLimiter === undefined)
|
||||
_streetViewRateLimiter = makeCostRateLimiter(
|
||||
process.env.GEV_RATELIMIT_GOOGLE_PER_MIN,
|
||||
120,
|
||||
);
|
||||
return _streetViewRateLimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin: CCTV camera proxy with source registry, frame/media serving,
|
||||
* fallback chain (upstream -> Street View -> synthetic SVG), and health tracking.
|
||||
@@ -509,13 +524,6 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
const source = sourceById.get(cameraId);
|
||||
const label = url.searchParams.get('label') || source?.name || cameraId;
|
||||
const city = url.searchParams.get('city') || source?.city || '';
|
||||
const lat = Number(url.searchParams.get('lat') || source?.lat);
|
||||
const lon = Number(url.searchParams.get('lon') || source?.lon);
|
||||
const heading = Number(
|
||||
url.searchParams.get('heading') || source?.headingDeg,
|
||||
);
|
||||
const fov = Number(url.searchParams.get('fov') || source?.fovDeg);
|
||||
const pitch = Number(url.searchParams.get('pitch') || source?.pitchDeg);
|
||||
|
||||
// Only use server-registered upstream URLs — never accept client-supplied URLs
|
||||
// (prevents SSRF via ?upstream= query parameter)
|
||||
@@ -549,18 +557,26 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
// request (whose client this server cannot see) gets the placeholder,
|
||||
// as if no key were configured. Any forwarding header counts, even an
|
||||
// empty one.
|
||||
// It is also only for catalog cameras, framed from the catalog (never
|
||||
// a spot the query names), within the per-IP Google budget.
|
||||
const forwarded = PROXY_SIGNALS.some(
|
||||
(name) => req.headers?.[name] !== undefined,
|
||||
);
|
||||
const sv = forwarded
|
||||
? null
|
||||
: await streetViewFallback({
|
||||
lat,
|
||||
lon,
|
||||
heading,
|
||||
fov,
|
||||
pitch,
|
||||
});
|
||||
const googleBudget = googleServerApiKey()
|
||||
? streetViewRateLimiter()
|
||||
: null;
|
||||
const sv =
|
||||
forwarded ||
|
||||
!source ||
|
||||
(googleBudget && !googleBudget(clientKey(req)))
|
||||
? null
|
||||
: await streetViewFallback({
|
||||
lat: Number(source.lat),
|
||||
lon: Number(source.lon),
|
||||
heading: Number(source.headingDeg),
|
||||
fov: Number(source.fovDeg),
|
||||
pitch: Number(source.pitchDeg ?? -10),
|
||||
});
|
||||
if (sv?.ok) {
|
||||
setHealth(cameraId, {
|
||||
status: 'degraded',
|
||||
|
||||
@@ -224,12 +224,7 @@ function createRealtimeTokenHandler({
|
||||
if (!bearer) {
|
||||
res.statusCode = 503;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'OPENAI_API_KEY is not set',
|
||||
code: 'API_KEY_MISSING',
|
||||
}),
|
||||
);
|
||||
res.end(JSON.stringify({ error: 'OPENAI_API_KEY is not set' }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,3 +528,30 @@ test('a forwarded same-origin request reaches every CCTV route but not the Stree
|
||||
assert.equal(direct.body, 'street-view');
|
||||
assert.equal(streetViewCalls(), 1);
|
||||
});
|
||||
|
||||
test('the Street View fallback frames catalog cameras only, from the catalog position', async (t) => {
|
||||
const app = mount(t, ({ url }) =>
|
||||
url.startsWith('https://maps.googleapis.com/')
|
||||
? trackedUpstream({
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: 'street-view',
|
||||
}).response
|
||||
: upstreamResponse({ status: 503 }),
|
||||
);
|
||||
process.env.GOOGLE_MAPS_SERVER_API_KEY = 'fixture-key';
|
||||
const streetView = () =>
|
||||
app.requests.filter(({ url }) =>
|
||||
url.startsWith('https://maps.googleapis.com/'),
|
||||
);
|
||||
|
||||
const unknown = await app.call('/frame/not-a-camera?lat=48.85&lon=2.29');
|
||||
assert.equal(unknown.headers['X-CCTV-Source'], 'synthetic');
|
||||
assert.deepEqual(streetView(), [], 'no Google call for an unknown id');
|
||||
|
||||
const steered = await app.call(
|
||||
`/frame/${STILL.id}?lat=48.85&lon=2.29&heading=90`,
|
||||
);
|
||||
assert.equal(steered.headers['X-CCTV-Source'], 'streetview');
|
||||
const location = new URL(streetView()[0].url).searchParams.get('location');
|
||||
assert.equal(location, `${STILL.lat},${STILL.lon}`);
|
||||
});
|
||||
@@ -150,10 +150,7 @@ test('the installed keyless HUD route stays successful after the voice quota is
|
||||
const firstToken = await invokeRoute(token);
|
||||
const secondToken = await invokeRoute(token);
|
||||
assert.equal(firstToken.statusCode, 503);
|
||||
assert.deepEqual(firstToken.body, {
|
||||
error: 'OPENAI_API_KEY is not set',
|
||||
code: 'API_KEY_MISSING',
|
||||
});
|
||||
assert.deepEqual(firstToken.body, { error: 'OPENAI_API_KEY is not set' });
|
||||
assert.equal(secondToken.statusCode, 429);
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
|
||||
@@ -166,7 +166,7 @@ const frameStyle = (panel) => ({
|
||||
<button class="gev-voice-error-dismiss" type="button" @click="controls?.dismissError()">{{ t('DISMISS') }}</button>
|
||||
</div>
|
||||
<div id="gev-voice-error-detail">{{ view.error.text }}</div>
|
||||
<div class="gev-voice-error-hint" :hidden="view.error.typed || view.error.hint === false">{{ t('Check microphone permission and network access, then try again.') }}</div>
|
||||
<div class="gev-voice-error-hint" :hidden="view.error.typed">{{ t('Check microphone permission and network access, then try again.') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -107,7 +107,7 @@ function initialState(panel) {
|
||||
t('Hold Space to speak · tap Space to activate focused controls'),
|
||||
},
|
||||
// A typed chat error reads as chat's, without the microphone hint.
|
||||
error: { text: '', typed: false, hint: true },
|
||||
error: { text: '', typed: false },
|
||||
// Connection and turn state beside the selected mode's indicator.
|
||||
phase: { text: '' },
|
||||
voicePanel: {
|
||||
|
||||
@@ -39,9 +39,8 @@ export function createRealtimeBackend({
|
||||
if (!response.ok) {
|
||||
const reason =
|
||||
typeof data?.error === 'string' ? data.error : data?.error?.message;
|
||||
throw Object.assign(
|
||||
new Error(reason || `Realtime token failed: HTTP ${response.status}`),
|
||||
typeof data?.code === 'string' ? { code: data.code } : {},
|
||||
throw new Error(
|
||||
reason || `Realtime token failed: HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
const token =
|
||||
|
||||
@@ -143,17 +143,3 @@ test('controller lifetime stops pending transport and releases resources through
|
||||
assert.equal(controller.stream, null);
|
||||
assert.equal(controller.isActive(), false);
|
||||
});
|
||||
|
||||
test('a missing server key keeps its code, so the UI can point to Provider Settings', async () => {
|
||||
const backend = createRealtimeBackend({
|
||||
tokenTransport: async () =>
|
||||
Response.json(
|
||||
{ error: 'OPENAI_API_KEY is not set', code: 'API_KEY_MISSING' },
|
||||
{ status: 503 },
|
||||
),
|
||||
});
|
||||
await assert.rejects(backend.requestToken(), {
|
||||
message: 'OPENAI_API_KEY is not set',
|
||||
code: 'API_KEY_MISSING',
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from './pointerContext.js';
|
||||
|
||||
import { shouldPauseRadioForVoice } from './realtimeProtocol.js';
|
||||
import { missingVoiceKeyText, postDebugLog } from './realtimeDiagnostics.js';
|
||||
import { postDebugLog } from './realtimeDiagnostics.js';
|
||||
|
||||
export {
|
||||
readStoredVoiceTier,
|
||||
@@ -436,9 +436,6 @@ export class GevRealtimeController extends RealtimeFacade {
|
||||
status === 'error'
|
||||
? resolvedDetail() || t('Voice session could not be started.')
|
||||
: '',
|
||||
// "Check microphone permission" is wrong advice for a missing key.
|
||||
hint: () =>
|
||||
!(status === 'error' && resolvedDetail() === missingVoiceKeyText()),
|
||||
});
|
||||
if (status === 'idle' || status === 'connecting' || status === 'error') {
|
||||
this.setVoiceSpeaker('idle');
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** The voice error when the server has no OpenAI key: where to add it. */
|
||||
export const missingVoiceKeyText = () =>
|
||||
t('Needs {envVars} — add it in Provider Settings', {
|
||||
envVars: 'OPENAI_API_KEY',
|
||||
});
|
||||
|
||||
const ERROR_LOG_LIMIT = 30;
|
||||
|
||||
const ERROR_STORAGE_KEY = 'gev-realtime-errors';
|
||||
@@ -91,7 +83,6 @@ function createErrorRecord(source, error, extra = {}) {
|
||||
String(error?.message || '').trim() ||
|
||||
'No browser error message supplied',
|
||||
errorDetail: rtcError?.errorDetail || null,
|
||||
code: typeof rtcError?.code === 'string' ? rtcError.code : null,
|
||||
sctpCauseCode: rtcError?.sctpCauseCode ?? null,
|
||||
receivedAlert: rtcError?.receivedAlert ?? null,
|
||||
sentAlert: rtcError?.sentAlert ?? null,
|
||||
@@ -100,8 +91,6 @@ function createErrorRecord(source, error, extra = {}) {
|
||||
}
|
||||
|
||||
function formatErrorForDisplay(record) {
|
||||
// A missing key is a setup step, not a connection fault: say where to add it.
|
||||
if (record.code === 'API_KEY_MISSING') return missingVoiceKeyText();
|
||||
const primary = [record.source, record.message].filter(Boolean).join(': ');
|
||||
const state = [
|
||||
record.errorDetail && `detail=${record.errorDetail}`,
|
||||
|
||||
Reference in new issue
Block a user