physix-live / frontend /src /components /OpenEnvExplorerPane.tsx
Pratyush-01's picture
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;
}