"use client"; import { useCallback, useEffect, useRef, useState } from "react"; /** Client-side data layer: every call goes through the Next proxy (/api/*) which adds the API token. */ export async function api(path: string, init?: RequestInit): Promise { const res = await fetch(`/api${path}`, { ...init, headers: { "content-type": "application/json", ...(init?.headers ?? {}) }, cache: "no-store" }); if (!res.ok) { let msg = `${res.status}`; try { msg = ((await res.json()) as { error?: string }).error ?? msg; } catch { /* ignore */ } throw new Error(msg); } return (await res.json()) as T; } export function useApi(path: string | null, opts: { refreshMs?: number; deps?: unknown[] } = {}) { const [data, setData] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(!!path); const [tick, setTick] = useState(0); const refresh = useCallback(() => setTick((t) => t + 1), []); useEffect(() => { if (!path) return; let alive = true; api(path) .then((d) => { if (!alive) return; setData(d); setError(null); }) .catch((e: Error) => alive && setError(e.message)) .finally(() => alive && setLoading(false)); const id = opts.refreshMs ? setInterval(() => setTick((t) => t + 1), opts.refreshMs) : undefined; return () => { alive = false; if (id) clearInterval(id); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [path, tick, ...(opts.deps ?? [])]); return { data, error, loading, refresh }; } export interface LiveEvent { event_id: string; session_id: string; platform: string; event_type: string; step: number | null; ts: string; payload: Record; provenance?: { surface: string; confidence: number }[] | null; } /** Server-Sent Events subscription (live feed). Keeps the last `max` events. */ export function useLive(opts: { session?: string; types?: string[]; max?: number; enabled?: boolean } = {}) { const [events, setEvents] = useState([]); const [connected, setConnected] = useState(false); const [rate, setRate] = useState(0); // events per minute (rolling) const stamps = useRef([]); const max = opts.max ?? 250; const enabled = opts.enabled !== false; useEffect(() => { if (!enabled) return; const params = new URLSearchParams(); if (opts.session) params.set("session", opts.session); if (opts.types?.length) params.set("types", opts.types.join(",")); const es = new EventSource(`/api/stream?${params}`); es.onopen = () => setConnected(true); es.onerror = () => setConnected(false); es.addEventListener("observation", (m) => { const ev = JSON.parse((m as MessageEvent).data) as LiveEvent; const now = Date.now(); stamps.current.push(now); stamps.current = stamps.current.filter((t) => now - t < 60_000); setRate(stamps.current.length); setEvents((prev) => [ev, ...prev].slice(0, max)); }); const id = setInterval(() => { const now = Date.now(); stamps.current = stamps.current.filter((t) => now - t < 60_000); setRate(stamps.current.length); }, 5000); return () => { es.close(); clearInterval(id); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [opts.session, opts.types?.join(","), enabled, max]); return { events, connected, rate }; }