Spaces:
Runtime error
Runtime error
| /* REST + WebSocket client. | |
| * | |
| * The dashboard never polls for frames. It asks for state once when a session | |
| * starts and then consumes the push stream; REST is only used for commands and | |
| * for things that are not per-frame. | |
| */ | |
| const BASE = '/api'; | |
| async function request(path, options = {}) { | |
| const res = await fetch(BASE + path, { | |
| headers: options.body ? { 'content-type': 'application/json' } : undefined, | |
| ...options, | |
| }); | |
| if (!res.ok) { | |
| let detail = res.statusText; | |
| try { | |
| const body = await res.json(); | |
| detail = body.detail || body.error || detail; | |
| } catch { /* non-JSON error body */ } | |
| const err = new Error(typeof detail === 'string' ? detail : JSON.stringify(detail)); | |
| err.status = res.status; | |
| throw err; | |
| } | |
| return res.status === 204 ? null : res.json(); | |
| } | |
| export const api = { | |
| meta: () => request('/meta'), | |
| venues: () => request('/venues'), | |
| venue: id => request(`/venues/${id}`), | |
| scenarios: () => request('/scenarios'), | |
| benchmarks: () => request('/benchmarks'), | |
| start: payload => request('/simulation/start', { | |
| method: 'POST', body: JSON.stringify(payload), | |
| }), | |
| state: (id, agents = true) => request(`/simulation/${id}/state?agents=${agents}`), | |
| control: (id, payload) => request(`/simulation/${id}/control`, { | |
| method: 'POST', body: JSON.stringify(payload), | |
| }), | |
| stop: id => request(`/simulation/${id}`, { method: 'DELETE' }), | |
| simulateStrategies: (id, payload = {}) => request(`/simulation/${id}/strategy/simulate`, { | |
| method: 'POST', body: JSON.stringify(payload), | |
| }), | |
| applyStrategy: (id, strategyId) => request(`/simulation/${id}/strategy/apply`, { | |
| method: 'POST', body: JSON.stringify({ strategy_id: strategyId }), | |
| }), | |
| perceptionStatus: () => request('/perception/status'), | |
| perceptionAnalyze: (formData) => | |
| fetch(`${BASE}/perception/analyze`, { method: 'POST', body: formData }) | |
| .then(async r => { | |
| const body = await r.json().catch(() => ({})); | |
| if (!r.ok) throw new Error(body.detail || 'perception failed'); | |
| return body; | |
| }), | |
| }; | |
| /** Auto-reconnecting frame stream. */ | |
| export class FrameStream { | |
| constructor(sessionId, handlers) { | |
| this.sessionId = sessionId; | |
| this.handlers = handlers; | |
| this.ws = null; | |
| this.closed = false; | |
| this.retries = 0; | |
| this._connect(); | |
| } | |
| _connect() { | |
| if (this.closed) return; | |
| const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const url = `${proto}//${location.host}${BASE}/ws/simulation/${this.sessionId}`; | |
| let ws; | |
| try { | |
| ws = new WebSocket(url); | |
| } catch { | |
| this._scheduleRetry(); | |
| return; | |
| } | |
| this.ws = ws; | |
| ws.onopen = () => { | |
| this.retries = 0; | |
| this.handlers.onStatus?.('live'); | |
| }; | |
| ws.onmessage = ev => { | |
| let msg; | |
| try { msg = JSON.parse(ev.data); } catch { return; } | |
| if (msg.type === 'frame') this.handlers.onFrame?.(msg); | |
| else if (msg.type === 'strategy') this.handlers.onStrategy?.(msg.payload); | |
| else if (msg.type === 'error') this.handlers.onError?.(msg.detail); | |
| }; | |
| ws.onclose = () => { | |
| this.handlers.onStatus?.(this.closed ? 'closed' : 'reconnecting'); | |
| this._scheduleRetry(); | |
| }; | |
| ws.onerror = () => { /* onclose handles recovery */ }; | |
| } | |
| _scheduleRetry() { | |
| if (this.closed) return; | |
| this.retries += 1; | |
| if (this.retries > 8) { | |
| this.handlers.onStatus?.('offline'); | |
| return; | |
| } | |
| setTimeout(() => this._connect(), Math.min(600 * this.retries, 4000)); | |
| } | |
| close() { | |
| this.closed = true; | |
| try { this.ws?.close(); } catch { /* already gone */ } | |
| } | |
| } | |