Spaces:
Sleeping
Sleeping
cleanup: trim verbose comments, drop dead code, fix stale tests, proper Dockerfile + .gitignore
7f40db3 verified | /** Interactive playground for the PhysiX OpenEnv interface. | |
| * | |
| * Lets the user drive an episode by hand: start a session (= one | |
| * `reset`), then submit one or more actions (= `step`s) directly, | |
| * no LLM in the loop. Shows the JSON request body, JSON response, | |
| * and a copy-pasteable curl command for every call so judges can | |
| * see exactly the wire format. | |
| * | |
| * Why this talks to `/interactive/sessions/*` rather than the bare | |
| * `/reset` and `/step`: | |
| * | |
| * The official OpenEnv HTTP server (`openenv.core.env_server`) | |
| * constructs a fresh ``Environment`` instance on every call to | |
| * `/reset` and `/step`, so the bare endpoints are intentionally | |
| * stateless and cannot carry an episode across requests. PhysiX | |
| * layers a per-browser session router on top of that for stateful | |
| * flows; this pane uses it so visitors actually get to step | |
| * through an episode. The bare `/reset`, `/schema`, and `/metadata` | |
| * endpoints are still surfaced lower in the page as a read-only | |
| * reference for the underlying contract. | |
| */ | |
| import { useCallback, useEffect, useMemo, useState } from "react"; | |
| import { TrajectoryCanvas } from "@/components/TrajectoryCanvas"; | |
| import { cn } from "@/lib/cn"; | |
| import { | |
| type DirectStepResponse, | |
| InteractiveApiError, | |
| InteractiveClient, | |
| type InteractiveStartResponse, | |
| type OpenEnvMetadata, | |
| type OpenEnvResetResponse, | |
| type OpenEnvSchemaResponse, | |
| type SessionSummary, | |
| type SystemDescriptor, | |
| } from "@/lib/interactiveClient"; | |
| import { pickPrimaryVariable } from "@/lib/trajectory"; | |
| import type { | |
| PhysiXAction, | |
| RewardBreakdown, | |
| TrajectorySample, | |
| } from "@/types/physix"; | |
| const DEFAULT_EQUATION = "d2y/dt2 = -9.81"; | |
| const DEFAULT_PARAMS_JSON = "{}"; | |
| const DEFAULT_RATIONALE = "Free-fall under gravity."; | |
| // Same zero-reward shape used by RunWithLlmPane so the OpenEnv tab | |
| // renders the identical reward layout when no step has run yet. | |
| const ZERO_REWARD: RewardBreakdown = { | |
| match: 0, | |
| progress: 0, | |
| simplicity: 0, | |
| format: 0, | |
| total: 0, | |
| shape: 0, | |
| freq: 0, | |
| amplitude: 0, | |
| }; | |
| function getApiBaseUrl(): string { | |
| const fromEnv = (import.meta as ImportMeta & { env?: Record<string, string> }) | |
| .env?.VITE_PHYSIX_API_URL; | |
| return (fromEnv ?? "http://localhost:8000").replace(/\/+$/, ""); | |
| } | |
| interface CallRecord<T> { | |
| status: "idle" | "running" | "ok" | "error"; | |
| result: T | null; | |
| error: string | null; | |
| /** Wall-clock latency in ms, recorded client-side. */ | |
| latencyMs: number | null; | |
| /** The exact request body sent (or null for GET). */ | |
| requestBody: unknown; | |
| /** Endpoint label shown in the curl block. */ | |
| url: string | null; | |
| method: "GET" | "POST" | "DELETE"; | |
| } | |
| const idleCall = <T,>( | |
| method: CallRecord<T>["method"] = "GET", | |
| ): CallRecord<T> => ({ | |
| status: "idle", | |
| result: null, | |
| error: null, | |
| latencyMs: null, | |
| requestBody: null, | |
| url: null, | |
| method, | |
| }); | |
| export function OpenEnvExplorerPane(): JSX.Element { | |
| const apiBaseUrl = useMemo(() => getApiBaseUrl(), []); | |
| const client = useMemo( | |
| () => new InteractiveClient({ baseUrl: apiBaseUrl }), | |
| [apiBaseUrl], | |
| ); | |
| // --- Catalogue of physical systems (used to populate the reset selector) --- | |
| const [systems, setSystems] = useState<SystemDescriptor[] | null>(null); | |
| const [systemsError, setSystemsError] = useState<string | null>(null); | |
| useEffect(() => { | |
| let cancelled = false; | |
| void (async () => { | |
| try { | |
| const list = await client.listSystems(); | |
| if (!cancelled) setSystems(list); | |
| } catch (err) { | |
| if (!cancelled) setSystemsError(formatErr(err)); | |
| } | |
| })(); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, [client]); | |
| // --- Session state ----------------------------------------------------- | |
| // Session id of the currently active episode. null = no session yet. | |
| const [sessionId, setSessionId] = useState<string | null>(null); | |
| const [summary, setSummary] = useState<SessionSummary | null>(null); | |
| // --- Form state ------------------------------------------------------- | |
| const [systemId, setSystemId] = useState<string>("free_fall"); | |
| const [seed, setSeed] = useState<string>("42"); | |
| const [maxTurns, setMaxTurns] = useState<number>(8); | |
| const [equation, setEquation] = useState<string>(DEFAULT_EQUATION); | |
| const [paramsJson, setParamsJson] = useState<string>(DEFAULT_PARAMS_JSON); | |
| const [paramsError, setParamsError] = useState<string | null>(null); | |
| const [rationale, setRationale] = useState<string>(DEFAULT_RATIONALE); | |
| // --- Per-endpoint call records ---------------------------------------- | |
| const [metadataCall, setMetadataCall] = useState<CallRecord<OpenEnvMetadata>>( | |
| idleCall<OpenEnvMetadata>("GET"), | |
| ); | |
| const [schemaCall, setSchemaCall] = useState<CallRecord<OpenEnvSchemaResponse>>( | |
| idleCall<OpenEnvSchemaResponse>("GET"), | |
| ); | |
| const [statelessResetCall, setStatelessResetCall] = useState< | |
| CallRecord<OpenEnvResetResponse> | |
| >(idleCall<OpenEnvResetResponse>("POST")); | |
| const [resetCall, setResetCall] = useState<CallRecord<InteractiveStartResponse>>( | |
| idleCall<InteractiveStartResponse>("POST"), | |
| ); | |
| const [stepCall, setStepCall] = useState<CallRecord<DirectStepResponse>>( | |
| idleCall<DirectStepResponse>("POST"), | |
| ); | |
| // Auto-fetch metadata + schema once on mount. | |
| useEffect(() => { | |
| void runMetadata(); | |
| void runSchema(); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, []); | |
| // Refresh the session summary whenever the session id changes (and | |
| // after every successful step below). | |
| const refreshSummary = useCallback(async (): Promise<void> => { | |
| if (!sessionId) return; | |
| try { | |
| const s = await client.getSummary(sessionId); | |
| setSummary(s); | |
| } catch { | |
| // Session may have been GC'd; clear it so the user gets a fresh | |
| // start on the next reset. | |
| setSummary(null); | |
| } | |
| }, [client, sessionId]); | |
| useEffect(() => { | |
| void refreshSummary(); | |
| }, [refreshSummary]); | |
| const runMetadata = useCallback(async (): Promise<void> => { | |
| setMetadataCall({ | |
| ...idleCall<OpenEnvMetadata>("GET"), | |
| status: "running", | |
| url: `${apiBaseUrl}/metadata`, | |
| }); | |
| const t0 = performance.now(); | |
| try { | |
| const result = await client.openEnvMetadata(); | |
| setMetadataCall({ | |
| status: "ok", | |
| result, | |
| error: null, | |
| latencyMs: performance.now() - t0, | |
| requestBody: null, | |
| method: "GET", | |
| url: `${apiBaseUrl}/metadata`, | |
| }); | |
| } catch (err) { | |
| setMetadataCall({ | |
| status: "error", | |
| result: null, | |
| error: formatErr(err), | |
| latencyMs: performance.now() - t0, | |
| requestBody: null, | |
| method: "GET", | |
| url: `${apiBaseUrl}/metadata`, | |
| }); | |
| } | |
| }, [apiBaseUrl, client]); | |
| const runSchema = useCallback(async (): Promise<void> => { | |
| setSchemaCall({ | |
| ...idleCall<OpenEnvSchemaResponse>("GET"), | |
| status: "running", | |
| url: `${apiBaseUrl}/schema`, | |
| }); | |
| const t0 = performance.now(); | |
| try { | |
| const result = await client.openEnvSchema(); | |
| setSchemaCall({ | |
| status: "ok", | |
| result, | |
| error: null, | |
| latencyMs: performance.now() - t0, | |
| requestBody: null, | |
| method: "GET", | |
| url: `${apiBaseUrl}/schema`, | |
| }); | |
| } catch (err) { | |
| setSchemaCall({ | |
| status: "error", | |
| result: null, | |
| error: formatErr(err), | |
| latencyMs: performance.now() - t0, | |
| requestBody: null, | |
| method: "GET", | |
| url: `${apiBaseUrl}/schema`, | |
| }); | |
| } | |
| }, [apiBaseUrl, client]); | |
| const runStatelessReset = useCallback(async (): Promise<void> => { | |
| const seedNum = parseSeed(seed); | |
| const body: Record<string, unknown> = {}; | |
| if (seedNum !== null) body.seed = seedNum; | |
| if (systemId) body.system_id = systemId; | |
| setStatelessResetCall({ | |
| status: "running", | |
| result: null, | |
| error: null, | |
| latencyMs: null, | |
| requestBody: body, | |
| method: "POST", | |
| url: `${apiBaseUrl}/reset`, | |
| }); | |
| const t0 = performance.now(); | |
| try { | |
| const result = await client.openEnvReset({ | |
| seed: seedNum, | |
| system_id: systemId || null, | |
| }); | |
| setStatelessResetCall({ | |
| status: "ok", | |
| result, | |
| error: null, | |
| latencyMs: performance.now() - t0, | |
| requestBody: body, | |
| method: "POST", | |
| url: `${apiBaseUrl}/reset`, | |
| }); | |
| } catch (err) { | |
| setStatelessResetCall({ | |
| status: "error", | |
| result: null, | |
| error: formatErr(err), | |
| latencyMs: performance.now() - t0, | |
| requestBody: body, | |
| method: "POST", | |
| url: `${apiBaseUrl}/reset`, | |
| }); | |
| } | |
| }, [apiBaseUrl, client, seed, systemId]); | |
| const runReset = useCallback(async (): Promise<void> => { | |
| const seedNum = parseSeed(seed); | |
| const body: Record<string, unknown> = { max_turns: maxTurns }; | |
| if (seedNum !== null) body.seed = seedNum; | |
| if (systemId) body.system_id = systemId; | |
| setResetCall({ | |
| status: "running", | |
| result: null, | |
| error: null, | |
| latencyMs: null, | |
| requestBody: body, | |
| method: "POST", | |
| url: `${apiBaseUrl}/interactive/sessions`, | |
| }); | |
| const t0 = performance.now(); | |
| try { | |
| // End the previous session if any, so we don't leak server memory. | |
| if (sessionId) { | |
| await client.endSession(sessionId).catch(() => { | |
| /* best-effort */ | |
| }); | |
| } | |
| const result = await client.startSession({ | |
| system_id: systemId || undefined, | |
| seed: seedNum ?? undefined, | |
| max_turns: maxTurns, | |
| }); | |
| setResetCall({ | |
| status: "ok", | |
| result, | |
| error: null, | |
| latencyMs: performance.now() - t0, | |
| requestBody: body, | |
| method: "POST", | |
| url: `${apiBaseUrl}/interactive/sessions`, | |
| }); | |
| setSessionId(result.session_id); | |
| // Reset the previous step result so the canvas reflects the | |
| // fresh reset and the user can see only the observed trajectory. | |
| setStepCall(idleCall<DirectStepResponse>("POST")); | |
| setSummary({ | |
| session_id: result.session_id, | |
| system_id: result.system.system_id, | |
| turn: 0, | |
| max_turns: result.max_turns, | |
| converged: false, | |
| done: false, | |
| }); | |
| } catch (err) { | |
| setResetCall({ | |
| status: "error", | |
| result: null, | |
| error: formatErr(err), | |
| latencyMs: performance.now() - t0, | |
| requestBody: body, | |
| method: "POST", | |
| url: `${apiBaseUrl}/interactive/sessions`, | |
| }); | |
| } | |
| }, [apiBaseUrl, client, maxTurns, seed, sessionId, systemId]); | |
| const runStep = useCallback(async (): Promise<void> => { | |
| const parsedParams: Record<string, number> = {}; | |
| if (paramsJson.trim()) { | |
| try { | |
| const parsed = JSON.parse(paramsJson) as unknown; | |
| if ( | |
| parsed === null || | |
| typeof parsed !== "object" || | |
| Array.isArray(parsed) | |
| ) { | |
| setParamsError("params must be a JSON object, e.g. {\"k\": 4.0}."); | |
| return; | |
| } | |
| for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) { | |
| if (typeof v !== "number" || !Number.isFinite(v)) { | |
| setParamsError(`params.${k} must be a finite number.`); | |
| return; | |
| } | |
| parsedParams[k] = v; | |
| } | |
| } catch (err) { | |
| setParamsError(`params is not valid JSON: ${(err as Error).message}`); | |
| return; | |
| } | |
| } | |
| setParamsError(null); | |
| const action: PhysiXAction = { | |
| equation: equation.trim(), | |
| params: parsedParams, | |
| rationale: rationale.trim(), | |
| }; | |
| const body = { action }; | |
| // Lazy-reset: starting a session before the first step is the same | |
| // contract as `gym.Env.reset()` before the first `step()`. | |
| let activeSessionId = sessionId; | |
| if (!activeSessionId) { | |
| try { | |
| const seedNum = parseSeed(seed); | |
| const start = await client.startSession({ | |
| system_id: systemId || undefined, | |
| seed: seedNum ?? undefined, | |
| max_turns: maxTurns, | |
| }); | |
| activeSessionId = start.session_id; | |
| setSessionId(activeSessionId); | |
| setResetCall({ | |
| status: "ok", | |
| result: start, | |
| error: null, | |
| latencyMs: 0, | |
| requestBody: { auto: true }, | |
| method: "POST", | |
| url: `${apiBaseUrl}/interactive/sessions`, | |
| }); | |
| } catch (err) { | |
| setStepCall({ | |
| status: "error", | |
| result: null, | |
| error: `Auto-reset failed: ${formatErr(err)}`, | |
| latencyMs: 0, | |
| requestBody: body, | |
| method: "POST", | |
| url: `${apiBaseUrl}/interactive/sessions/(none)/step`, | |
| }); | |
| return; | |
| } | |
| } | |
| const stepUrl = `${apiBaseUrl}/interactive/sessions/${activeSessionId}/step`; | |
| setStepCall({ | |
| status: "running", | |
| result: null, | |
| error: null, | |
| latencyMs: null, | |
| requestBody: body, | |
| method: "POST", | |
| url: stepUrl, | |
| }); | |
| const t0 = performance.now(); | |
| try { | |
| const result = await client.directStep(activeSessionId, action); | |
| setStepCall({ | |
| status: "ok", | |
| result, | |
| error: null, | |
| latencyMs: performance.now() - t0, | |
| requestBody: body, | |
| method: "POST", | |
| url: stepUrl, | |
| }); | |
| void refreshSummary(); | |
| } catch (err) { | |
| setStepCall({ | |
| status: "error", | |
| result: null, | |
| error: formatErr(err), | |
| latencyMs: performance.now() - t0, | |
| requestBody: body, | |
| method: "POST", | |
| url: stepUrl, | |
| }); | |
| } | |
| }, [ | |
| apiBaseUrl, | |
| client, | |
| equation, | |
| maxTurns, | |
| paramsJson, | |
| rationale, | |
| refreshSummary, | |
| seed, | |
| sessionId, | |
| systemId, | |
| ]); | |
| // Trajectory bits to draw: prefer the latest step's observation, fall | |
| // back to the reset's, fall back to nothing. | |
| const observed: TrajectorySample[] = useMemo(() => { | |
| const fromStep = stepCall.result?.observation.trajectory; | |
| const fromReset = resetCall.result?.observation.trajectory; | |
| return fromStep ?? fromReset ?? []; | |
| }, [stepCall.result, resetCall.result]); | |
| const stateVariables: string[] = useMemo(() => { | |
| const fromStep = stepCall.result?.observation.state_variables; | |
| const fromReset = resetCall.result?.observation.state_variables; | |
| return fromStep ?? fromReset ?? ["y", "vy"]; | |
| }, [stepCall.result, resetCall.result]); | |
| const primaryVariable = pickPrimaryVariable(stateVariables); | |
| const predicted: TrajectorySample[] = | |
| stepCall.result?.predicted_trajectory ?? []; | |
| // Pull the reward breakdown off the last successful step so the panel | |
| // below renders the same layout the LLM tabs use. Falls back to the | |
| // all-zero stub before any step has been taken. | |
| const lastReward: RewardBreakdown = | |
| stepCall.result?.observation.reward_breakdown ?? ZERO_REWARD; | |
| const hasReward = stepCall.status === "ok" && stepCall.result !== null; | |
| const hasReset = resetCall.status === "ok" && sessionId !== null; | |
| return ( | |
| <section className="flex flex-col gap-6"> | |
| <header className="flex flex-col gap-2"> | |
| <p className="heading-eyebrow text-accentBlue">OpenEnv interface</p> | |
| <h2 className="text-2xl font-semibold leading-tight"> | |
| Drive the env directly: reset, step, observe. | |
| </h2> | |
| <p className="max-w-3xl text-sm leading-relaxed text-textMuted"> | |
| PhysiX-Live implements the standard{" "} | |
| <a | |
| className="text-primary hover:underline" | |
| href="https://github.com/meta-pytorch/openenv" | |
| target="_blank" | |
| rel="noreferrer" | |
| > | |
| OpenEnv | |
| </a>{" "} | |
| contract: a fresh environment per <span className="font-mono">/reset</span>, | |
| then one observation per <span className="font-mono">/step</span>{" "} | |
| until <span className="font-mono">done</span>. Because the bare | |
| OpenEnv HTTP routes are stateless (they construct a new env per | |
| request), the actual playable flow below uses the per-session | |
| interactive router that wraps the same env with a session id — | |
| equivalent to a long-lived <span className="font-mono">gym.Env</span>{" "} | |
| handle. The bare <span className="font-mono">/reset</span>,{" "} | |
| <span className="font-mono">/schema</span>, and{" "} | |
| <span className="font-mono">/metadata</span> endpoints are | |
| surfaced at the bottom for reference. | |
| </p> | |
| </header> | |
| <StatusStrip | |
| baseUrl={apiBaseUrl} | |
| metadata={metadataCall.result} | |
| summary={summary} | |
| /> | |
| <div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> | |
| {/* Reset card */} | |
| <div className="panel flex flex-col gap-4"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-3"> | |
| <span className="badge bg-primary/15 text-primary">POST</span> | |
| <h3 className="font-mono text-sm"> | |
| /interactive/sessions | |
| </h3> | |
| </div> | |
| <CallStatusPill record={resetCall} /> | |
| </div> | |
| <p className="text-sm text-textMuted"> | |
| Begin a new episode. Equivalent to{" "} | |
| <span className="font-mono">/reset</span> on a stateful env. The | |
| response includes a <span className="font-mono">session_id</span>{" "} | |
| you'll pass to subsequent <span className="font-mono">/step</span>{" "} | |
| calls. | |
| </p> | |
| <label className="flex flex-col gap-1 text-xs text-textMuted"> | |
| system_id | |
| <select | |
| className="rounded-lg border border-border bg-surfaceMuted px-3 py-2 text-sm text-textPrimary" | |
| value={systemId} | |
| onChange={(e) => setSystemId(e.target.value)} | |
| disabled={resetCall.status === "running"} | |
| > | |
| <option value="">(server picks at random)</option> | |
| {(systems ?? []).map((s) => ( | |
| <option key={s.system_id} value={s.system_id}> | |
| {s.system_id} — [{s.state_variables.join(", ")}] | |
| </option> | |
| ))} | |
| </select> | |
| {systemsError && ( | |
| <span className="text-xs text-primary"> | |
| Couldn't load systems: {systemsError} | |
| </span> | |
| )} | |
| </label> | |
| <div className="grid grid-cols-2 gap-3"> | |
| <label className="flex flex-col gap-1 text-xs text-textMuted"> | |
| seed | |
| <input | |
| className="rounded-lg border border-border bg-surfaceMuted px-3 py-2 font-mono text-sm text-textPrimary" | |
| type="number" | |
| value={seed} | |
| onChange={(e) => setSeed(e.target.value)} | |
| disabled={resetCall.status === "running"} | |
| placeholder="(omit for random)" | |
| /> | |
| </label> | |
| <label className="flex flex-col gap-1 text-xs text-textMuted"> | |
| max_turns | |
| <input | |
| className="rounded-lg border border-border bg-surfaceMuted px-3 py-2 font-mono text-sm text-textPrimary" | |
| type="number" | |
| min={1} | |
| max={32} | |
| value={maxTurns} | |
| onChange={(e) => | |
| setMaxTurns(Math.max(1, Math.min(32, Number(e.target.value) || 1))) | |
| } | |
| disabled={resetCall.status === "running"} | |
| /> | |
| </label> | |
| </div> | |
| <button | |
| type="button" | |
| className="btn-primary self-start" | |
| onClick={() => void runReset()} | |
| disabled={resetCall.status === "running"} | |
| > | |
| {resetCall.status === "running" ? "Resetting…" : "POST reset"} | |
| </button> | |
| <CurlBlock | |
| method="POST" | |
| url={`${apiBaseUrl}/interactive/sessions`} | |
| body={resetCall.requestBody} | |
| /> | |
| <ResponseBlock record={resetCall} kind="reset" /> | |
| </div> | |
| {/* Step card */} | |
| <div className="panel flex flex-col gap-4"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-3"> | |
| <span className="badge bg-accentBlue/15 text-accentBlue"> | |
| POST | |
| </span> | |
| <h3 className="font-mono text-sm"> | |
| /interactive/sessions/{summary?.session_id?.slice(0, 8) ?? "{id}"}/step | |
| </h3> | |
| </div> | |
| <CallStatusPill record={stepCall} /> | |
| </div> | |
| <p className="text-sm text-textMuted"> | |
| Submit one action. PhysiX expects an ODE in its small SymPy | |
| grammar plus optional numerical parameter substitutions. | |
| {!hasReset && ( | |
| <> | |
| {" "} | |
| <span className="text-accentAmber"> | |
| No active session yet — clicking step will auto-reset | |
| using the values from the card on the left. | |
| </span> | |
| </> | |
| )} | |
| </p> | |
| <label className="flex flex-col gap-1 text-xs text-textMuted"> | |
| equation | |
| <input | |
| className="rounded-lg border border-border bg-surfaceMuted px-3 py-2 font-mono text-sm text-textPrimary" | |
| value={equation} | |
| onChange={(e) => setEquation(e.target.value)} | |
| disabled={stepCall.status === "running"} | |
| placeholder="d2y/dt2 = -9.81" | |
| /> | |
| </label> | |
| <label className="flex flex-col gap-1 text-xs text-textMuted"> | |
| params (JSON object of name → number) | |
| <input | |
| className="rounded-lg border border-border bg-surfaceMuted px-3 py-2 font-mono text-sm text-textPrimary" | |
| value={paramsJson} | |
| onChange={(e) => setParamsJson(e.target.value)} | |
| disabled={stepCall.status === "running"} | |
| placeholder='{"k": 4.2}' | |
| /> | |
| {paramsError && ( | |
| <span className="text-xs text-primary">{paramsError}</span> | |
| )} | |
| </label> | |
| <label className="flex flex-col gap-1 text-xs text-textMuted"> | |
| rationale (free text) | |
| <input | |
| className="rounded-lg border border-border bg-surfaceMuted px-3 py-2 text-sm text-textPrimary" | |
| value={rationale} | |
| onChange={(e) => setRationale(e.target.value)} | |
| disabled={stepCall.status === "running"} | |
| placeholder="Why this hypothesis?" | |
| /> | |
| </label> | |
| <button | |
| type="button" | |
| className="btn-primary self-start" | |
| onClick={() => void runStep()} | |
| disabled={stepCall.status === "running" || !equation.trim()} | |
| > | |
| {stepCall.status === "running" ? "Stepping…" : "POST step"} | |
| </button> | |
| <CurlBlock | |
| method="POST" | |
| url={ | |
| sessionId | |
| ? `${apiBaseUrl}/interactive/sessions/${sessionId}/step` | |
| : `${apiBaseUrl}/interactive/sessions/{session_id}/step` | |
| } | |
| body={stepCall.requestBody} | |
| /> | |
| <ResponseBlock record={stepCall} kind="step" /> | |
| </div> | |
| </div> | |
| {/* Trajectory preview */} | |
| <div className="panel flex flex-col gap-3"> | |
| <div className="flex items-center justify-between"> | |
| <h3 className="font-mono text-sm"> | |
| Last observation —{" "} | |
| <span className="text-textMuted">{primaryVariable}(t)</span> | |
| </h3> | |
| <span className="text-xs text-textMuted"> | |
| {observed.length} sample{observed.length === 1 ? "" : "s"} ·{" "} | |
| {stateVariables.join(", ") || "—"} | |
| {hasReward && ( | |
| <> | |
| {" · "}total{" "} | |
| <span className="font-mono text-textPrimary"> | |
| {lastReward.total.toFixed(3)} | |
| </span> | |
| </> | |
| )} | |
| </span> | |
| </div> | |
| {/* Dense reward row — same layout as the LLM tabs. Only shown | |
| once an actual /step has scored, otherwise the all-zero | |
| stub would mislead users into thinking match=0 is real. */} | |
| {hasReward && <DenseRewardRow reward={lastReward} />} | |
| <TrajectoryCanvas | |
| observed={observed} | |
| predicted={predicted} | |
| variable={primaryVariable} | |
| variableLabel={primaryVariable} | |
| predictedProgress={1} | |
| /> | |
| {observed.length === 0 && ( | |
| <p className="text-xs text-textMuted"> | |
| No observation yet — call <span className="font-mono">/reset</span>{" "} | |
| above to load one. | |
| </p> | |
| )} | |
| </div> | |
| {/* Stateless reference endpoints. */} | |
| <div className="flex flex-col gap-3"> | |
| <h3 className="text-sm font-semibold text-textPrimary"> | |
| Stateless reference endpoints | |
| </h3> | |
| <p className="text-xs text-textMuted"> | |
| These three endpoints come from the OpenEnv core HTTP layer. | |
| They construct a new environment per request, so a follow-up{" "} | |
| <span className="font-mono">/step</span> on the bare{" "} | |
| <span className="font-mono">/reset</span> would 500. Useful for | |
| inspection — for an episode use the session-backed cards above. | |
| </p> | |
| <div className="grid grid-cols-1 gap-4 lg:grid-cols-3"> | |
| <ReferenceCard | |
| title="POST /reset" | |
| description="Single-shot reset on a fresh env. Returns the initial observation." | |
| record={statelessResetCall} | |
| onRetry={runStatelessReset} | |
| customAction={ | |
| <button | |
| type="button" | |
| className="btn-secondary self-start" | |
| onClick={() => void runStatelessReset()} | |
| disabled={statelessResetCall.status === "running"} | |
| > | |
| {statelessResetCall.status === "running" ? "Calling…" : "Call"} | |
| </button> | |
| } | |
| /> | |
| <ReferenceCard | |
| title="GET /schema" | |
| description="JSON schemas for the action / observation / state objects." | |
| record={schemaCall} | |
| onRetry={runSchema} | |
| /> | |
| <ReferenceCard | |
| title="GET /metadata" | |
| description="Self-describing environment metadata (name, version, README)." | |
| record={metadataCall} | |
| onRetry={runMetadata} | |
| /> | |
| </div> | |
| </div> | |
| </section> | |
| ); | |
| } | |
| // ---------------- supporting components ---------------- | |
| interface StatusStripProps { | |
| baseUrl: string; | |
| metadata: OpenEnvMetadata | null; | |
| summary: SessionSummary | null; | |
| } | |
| function StatusStrip({ | |
| baseUrl, | |
| metadata, | |
| summary, | |
| }: StatusStripProps): JSX.Element { | |
| return ( | |
| <div className="panel-muted flex flex-wrap items-center gap-x-6 gap-y-2 text-sm"> | |
| <span className="flex items-center gap-2"> | |
| <span className="heading-eyebrow text-textMuted">Server</span> | |
| <span className="font-mono text-textPrimary">{baseUrl}</span> | |
| </span> | |
| {metadata && ( | |
| <span className="flex items-center gap-2"> | |
| <span className="heading-eyebrow text-textMuted">Env</span> | |
| <span className="font-mono text-textPrimary"> | |
| {metadata.name} | |
| {metadata.version ? ` · v${metadata.version}` : ""} | |
| </span> | |
| </span> | |
| )} | |
| <span className="flex items-center gap-2"> | |
| <span className="heading-eyebrow text-textMuted">session_id</span> | |
| <span className="font-mono text-textPrimary"> | |
| {summary?.session_id?.slice(0, 12) ?? "—"} | |
| {summary?.session_id ? "…" : ""} | |
| </span> | |
| </span> | |
| <span className="flex items-center gap-2"> | |
| <span className="heading-eyebrow text-textMuted">turn</span> | |
| <span className="font-mono text-textPrimary"> | |
| {summary ? `${summary.turn} / ${summary.max_turns}` : "—"} | |
| </span> | |
| </span> | |
| {summary?.converged && ( | |
| <span className="rounded-full border border-accentGreen/40 bg-accentGreen/10 px-2 py-0.5 text-[11px] uppercase tracking-wider text-accentGreen"> | |
| converged | |
| </span> | |
| )} | |
| {summary?.done && !summary.converged && ( | |
| <span className="rounded-full border border-textMuted/40 bg-surfaceMuted px-2 py-0.5 text-[11px] uppercase tracking-wider text-textMuted"> | |
| budget exhausted | |
| </span> | |
| )} | |
| </div> | |
| ); | |
| } | |
| interface CallStatusPillProps { | |
| record: CallRecord<unknown>; | |
| } | |
| function CallStatusPill({ record }: CallStatusPillProps): JSX.Element | null { | |
| if (record.status === "idle") return null; | |
| const map: Record<"running" | "ok" | "error", { label: string; cls: string }> = { | |
| running: { | |
| label: "running", | |
| cls: "border-accentBlue/40 text-accentBlue", | |
| }, | |
| ok: { | |
| label: `200 · ${record.latencyMs?.toFixed(0)}ms`, | |
| cls: "border-accentGreen/40 text-accentGreen", | |
| }, | |
| error: { | |
| label: "error", | |
| cls: "border-primary/50 text-primary", | |
| }, | |
| }; | |
| const { label, cls } = map[record.status]; | |
| return ( | |
| <span | |
| className={`inline-flex items-center rounded-full border px-2 py-0.5 font-mono text-xs ${cls}`} | |
| > | |
| {label} | |
| </span> | |
| ); | |
| } | |
| interface CurlBlockProps { | |
| method: "GET" | "POST" | "DELETE"; | |
| url: string; | |
| body: unknown; | |
| } | |
| function CurlBlock({ method, url, body }: CurlBlockProps): JSX.Element { | |
| const cmd = useMemo(() => buildCurl(method, url, body), [method, url, body]); | |
| const [copied, setCopied] = useState(false); | |
| async function copy(): Promise<void> { | |
| try { | |
| await navigator.clipboard.writeText(cmd); | |
| setCopied(true); | |
| setTimeout(() => setCopied(false), 1500); | |
| } catch { | |
| // clipboard blocked — silent | |
| } | |
| } | |
| return ( | |
| <div className="rounded-lg border border-border bg-surfaceMuted"> | |
| <div className="flex items-center justify-between px-3 py-2 text-xs text-textMuted"> | |
| <span className="heading-eyebrow">curl</span> | |
| <button | |
| type="button" | |
| onClick={() => void copy()} | |
| className="rounded px-2 py-0.5 text-xs text-textMuted hover:text-textPrimary" | |
| > | |
| {copied ? "copied!" : "copy"} | |
| </button> | |
| </div> | |
| <pre className="overflow-x-auto px-3 pb-3 font-mono text-xs leading-relaxed text-textPrimary"> | |
| {cmd} | |
| </pre> | |
| </div> | |
| ); | |
| } | |
| interface ResponseBlockProps { | |
| record: CallRecord<unknown>; | |
| kind: "reset" | "step"; | |
| } | |
| function ResponseBlock({ record, kind }: ResponseBlockProps): JSX.Element | null { | |
| if (record.status === "idle") return null; | |
| if (record.status === "running") { | |
| return <p className="text-xs text-textMuted">Awaiting response…</p>; | |
| } | |
| if (record.status === "error") { | |
| return ( | |
| <div className="rounded-lg border border-primary/40 bg-primary/10 p-3 text-xs text-primary"> | |
| <span className="heading-eyebrow text-primary">error</span> | |
| <pre className="mt-1 whitespace-pre-wrap font-mono">{record.error}</pre> | |
| </div> | |
| ); | |
| } | |
| // ok | |
| const body = pruneObservation(record.result, kind); | |
| return ( | |
| <details className="rounded-lg border border-border bg-surfaceMuted" open> | |
| <summary className="cursor-pointer px-3 py-2 text-xs text-textMuted"> | |
| <span className="heading-eyebrow text-accentGreen">200 OK</span> | |
| <span className="ml-2 font-mono text-textPrimary"> | |
| {record.latencyMs?.toFixed(0)}ms | |
| </span> | |
| <span className="ml-2">· response (trajectory truncated)</span> | |
| </summary> | |
| <pre className="max-h-72 overflow-auto px-3 pb-3 font-mono text-xs leading-relaxed text-textPrimary"> | |
| {JSON.stringify(body, null, 2)} | |
| </pre> | |
| </details> | |
| ); | |
| } | |
| interface ReferenceCardProps { | |
| title: string; | |
| description: string; | |
| record: CallRecord<unknown>; | |
| onRetry: () => void; | |
| customAction?: JSX.Element; | |
| } | |
| function ReferenceCard({ | |
| title, | |
| description, | |
| record, | |
| onRetry, | |
| customAction, | |
| }: ReferenceCardProps): JSX.Element { | |
| return ( | |
| <div className="panel flex flex-col gap-3"> | |
| <div className="flex items-center justify-between"> | |
| <h3 className="font-mono text-sm">{title}</h3> | |
| <CallStatusPill record={record} /> | |
| </div> | |
| <p className="text-sm text-textMuted">{description}</p> | |
| {customAction} | |
| {record.status === "error" && ( | |
| <div className="rounded-lg border border-primary/40 bg-primary/10 p-3 text-xs text-primary"> | |
| <pre className="whitespace-pre-wrap font-mono">{record.error}</pre> | |
| <button | |
| type="button" | |
| className="mt-1 underline" | |
| onClick={onRetry} | |
| > | |
| retry | |
| </button> | |
| </div> | |
| )} | |
| {record.status === "ok" && record.result !== null && ( | |
| <details className="rounded-lg border border-border bg-surfaceMuted"> | |
| <summary className="cursor-pointer px-3 py-2 text-xs text-textMuted"> | |
| <span className="heading-eyebrow text-accentGreen">200 OK</span> | |
| <span className="ml-2">· view JSON</span> | |
| </summary> | |
| <pre className="max-h-72 overflow-auto px-3 pb-3 font-mono text-xs leading-relaxed text-textPrimary"> | |
| {JSON.stringify(record.result, null, 2)} | |
| </pre> | |
| </details> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // ---------------- reward display ---------------- | |
| // | |
| // Kept visually in sync with RunWithLlmPane: four trainable reward | |
| // components on top (match / progress / simplicity / format) and three | |
| // diagnostic-only sub-scores (shape / freq / amplitude) on the bottom | |
| // labelled "diag". Diag exists because R² collapses to zero on small | |
| // phase shifts, which makes match=0 misleading on its | |
| // own; shape/freq/amplitude give partial credit for "visual closeness" | |
| // without ever feeding into the reward total or the trainer. | |
| function DenseRewardRow({ reward }: { reward: RewardBreakdown }): JSX.Element { | |
| const rewardComponents: { name: string; value: number }[] = [ | |
| { name: "match", value: reward.match ?? 0 }, | |
| { name: "progress", value: reward.progress ?? 0 }, | |
| { name: "simplicity", value: reward.simplicity ?? 0 }, | |
| { name: "format", value: reward.format ?? 0 }, | |
| ]; | |
| const diagComponents: { name: string; value: number }[] = [ | |
| { name: "shape", value: reward.shape ?? 0 }, | |
| { name: "freq", value: reward.freq ?? 0 }, | |
| { name: "amplitude", value: reward.amplitude ?? 0 }, | |
| ]; | |
| return ( | |
| <div className="flex flex-col gap-2 rounded-md border border-border bg-surface px-3 py-2 font-mono text-[11px]"> | |
| <div className="grid grid-cols-4 gap-2"> | |
| {rewardComponents.map(({ name, value }) => ( | |
| <RewardCell key={name} name={name} value={value} /> | |
| ))} | |
| </div> | |
| <div className="flex items-center gap-2 border-t border-border/60 pt-2"> | |
| <span | |
| className="text-[10px] uppercase tracking-wider text-textMuted" | |
| title="Diagnostic-only — not part of the reward total or training signal. Shows visual closeness (shape / freq / amplitude) for cases where R² collapses to zero." | |
| > | |
| diag | |
| </span> | |
| <div className="grid flex-1 grid-cols-3 gap-2"> | |
| {diagComponents.map(({ name, value }) => ( | |
| <RewardCell key={name} name={name} value={value} muted /> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function RewardCell({ | |
| name, | |
| value, | |
| muted = false, | |
| }: { | |
| name: string; | |
| value: number; | |
| muted?: boolean; | |
| }): JSX.Element { | |
| return ( | |
| <div className="flex flex-col gap-1"> | |
| <div className="flex items-baseline justify-between"> | |
| <span className="text-textMuted">{name}</span> | |
| <span className={muted ? "text-textMuted" : "text-textPrimary"}> | |
| {value.toFixed(2)} | |
| </span> | |
| </div> | |
| <div | |
| className="h-1 w-full overflow-hidden rounded-full bg-border" | |
| aria-hidden | |
| > | |
| <div | |
| className={cn( | |
| "h-full rounded-full", | |
| value >= 0.7 | |
| ? muted | |
| ? "bg-accentBlue/60" | |
| : "bg-accentGreen/70" | |
| : value >= 0.3 | |
| ? "bg-accentAmber/70" | |
| : "bg-textMuted/40", | |
| )} | |
| style={{ width: `${Math.max(0, Math.min(1, value)) * 100}%` }} | |
| /> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // ---------------- helpers ---------------- | |
| function formatErr(err: unknown): string { | |
| if (err instanceof InteractiveApiError) return `[${err.status}] ${err.detail}`; | |
| if (err instanceof Error) return err.message; | |
| return String(err); | |
| } | |
| function parseSeed(raw: string): number | null { | |
| const trimmed = raw.trim(); | |
| if (!trimmed) return null; | |
| const n = Number(trimmed); | |
| if (!Number.isInteger(n) || n < 0) return null; | |
| return n; | |
| } | |
| function buildCurl( | |
| method: "GET" | "POST" | "DELETE", | |
| url: string, | |
| body: unknown, | |
| ): string { | |
| if (method === "GET" || method === "DELETE") { | |
| return `curl -X ${method} ${shellQuote(url)}`; | |
| } | |
| const payload = body == null ? "{}" : JSON.stringify(body); | |
| return [ | |
| `curl -X ${method}`, | |
| ` ${shellQuote(url)}`, | |
| ` -H ${shellQuote("Content-Type: application/json")}`, | |
| ` -d ${shellQuote(payload)}`, | |
| ].join(" \\\n"); | |
| } | |
| function shellQuote(s: string): string { | |
| return `'${s.replace(/'/g, "'\\''")}'`; | |
| } | |
| /** | |
| * The trajectory array dominates the JSON payload (often 100+ samples). | |
| * Truncate it for the response viewer so the JSON pane stays readable — | |
| * the canvas above already plots the full thing. | |
| */ | |
| function pruneObservation( | |
| result: unknown, | |
| _kind: "reset" | "step", | |
| ): unknown { | |
| if (result === null || typeof result !== "object") return result; | |
| const r = result as Record<string, unknown>; | |
| const obs = r.observation as Record<string, unknown> | undefined; | |
| if (!obs) return result; | |
| const traj = obs.trajectory; | |
| if (Array.isArray(traj) && traj.length > 6) { | |
| return { | |
| ...r, | |
| observation: { | |
| ...obs, | |
| trajectory: [ | |
| ...traj.slice(0, 3), | |
| `… ${traj.length - 6} more samples …`, | |
| ...traj.slice(-3), | |
| ], | |
| }, | |
| }; | |
| } | |
| return result; | |
| } | |