Spaces:
Paused
Paused
File size: 3,778 Bytes
963e554 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 | /**
* Studio-UX helper service: dashboard layout persistence and capability
* probing for optional backends.
*
* Layout persistence is dual-path: localStorage always (works offline /
* without the optional router), plus best-effort sync to the small
* /api/studio-ux backend when it is mounted, so layouts survive browser
* storage resets and follow the backend store.
*/
const LS_PREFIX = 'sf-studio-ux:';
async function handle<T>(res: Response): Promise<T> {
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`API ${res.status}: ${body || res.statusText}`);
}
return res.json() as Promise<T>;
}
// ---------------------------------------------------------------- layouts
export function loadLayoutLocal<T>(key: string): T | null {
try {
const raw = localStorage.getItem(LS_PREFIX + key);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
export function saveLayoutLocal<T>(key: string, value: T): void {
try {
localStorage.setItem(LS_PREFIX + key, JSON.stringify(value));
} catch {
/* storage full / private mode — backend sync still applies */
}
}
export async function loadLayoutRemote<T>(key: string): Promise<T | null> {
try {
const res = await fetch(`/api/studio-ux/layouts/${encodeURIComponent(key)}`);
if (!res.ok) return null;
const body = (await res.json()) as { key: string; value: T | null };
return body.value ?? null;
} catch {
return null;
}
}
export function saveLayoutRemote<T>(key: string, value: T): void {
// fire-and-forget; localStorage is the guaranteed path
fetch(`/api/studio-ux/layouts/${encodeURIComponent(key)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
}).catch(() => undefined);
}
/** Load a layout preferring the backend copy, falling back to localStorage. */
export async function loadLayout<T>(key: string): Promise<T | null> {
const remote = await loadLayoutRemote<T>(key);
if (remote !== null) return remote;
return loadLayoutLocal<T>(key);
}
export function saveLayout<T>(key: string, value: T): void {
saveLayoutLocal(key, value);
saveLayoutRemote(key, value);
}
// ---------------------------------------------------------------- capability probe
export interface Capabilities {
ssamAnalyze: boolean;
bevfusion: boolean;
nextgenCounterfactual: boolean;
studioUx: boolean;
}
let capCache: Capabilities | null = null;
async function probe(url: string, init?: RequestInit): Promise<boolean> {
try {
const res = await fetch(url, init);
// Any non-404 answer means the router is mounted (405/422 count as present).
return res.status !== 404;
} catch {
return false;
}
}
/**
* Feature-detect the optional scene-consuming / persistence APIs once per
* session. Pages hide actions whose backing endpoint is absent.
*/
export async function getCapabilities(force = false): Promise<Capabilities> {
if (capCache && !force) return capCache;
const [ssamAnalyze, bevfusion, nextgenCounterfactual, studioUx] = await Promise.all([
probe('/api/safety/ssam/analyze', { method: 'OPTIONS' }),
probe('/api/bevfusion/status'),
probe('/api/nextgen/counterfactual', { method: 'OPTIONS' }),
probe('/api/studio-ux/layouts/__probe__'),
]);
capCache = { ssamAnalyze, bevfusion, nextgenCounterfactual, studioUx };
return capCache;
}
export async function fetchJson<T>(url: string, body?: unknown): Promise<T> {
const res = await fetch(url, {
method: body === undefined ? 'GET' : 'POST',
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
return handle<T>(res);
}
|