Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Calibration from "@/pages/Calibration";
import Recording from "@/pages/Recording";
import Training from "@/pages/Training";
import Inference from "@/pages/Inference";
import Demo from "@/pages/Demo";
import EditDataset from "@/pages/EditDataset";
import Upload from "@/pages/Upload";

Expand Down Expand Up @@ -44,6 +45,7 @@ function App() {
<Route path="/training" element={<Training />} />
<Route path="/training/:jobId" element={<Training />} />
<Route path="/inference" element={<Inference />} />
<Route path="/demo" element={<Demo />} />
<Route path="/calibration" element={<Calibration />} />
<Route path="/edit-dataset" element={<EditDataset />} />

Expand Down
23 changes: 16 additions & 7 deletions frontend/src/components/landing/InferenceModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
listJobCheckpoints,
} from "@/lib/checkpointsApi";
import { startInference } from "@/lib/inferenceApi";
import { saveDemoPreset } from "@/lib/demoPreset";
import CheckpointDropdown from "@/components/jobs/CheckpointDropdown";
import { useAvailableCameras } from "@/hooks/useAvailableCameras";
import { useCameraStream } from "@/hooks/useCameraStream";
Expand Down Expand Up @@ -225,14 +226,22 @@ const InferenceModal: React.FC<Props> = ({
fps: DEFAULT_FPS,
};
}
const request = {
follower_port: robot.follower_port,
follower_config: robot.follower_config,
policy_ref: selectedRef,
task,
cameras: cameraDict,
duration_s: durationS,
};
try {
await startInference(baseUrl, fetchWithHeaders, {
follower_port: robot.follower_port,
follower_config: robot.follower_config,
policy_ref: selectedRef,
task,
cameras: cameraDict,
duration_s: durationS,
await startInference(baseUrl, fetchWithHeaders, request);
// The last successfully started inference becomes the demo-mode preset.
saveDemoPreset({
request,
policyLabel: `${jobId} · step ${selectedStep}`,
robotName: robot.name,
savedAt: new Date().toISOString(),
});
onOpenChange(false);
navigate("/inference");
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/lib/demoPreset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { StartInferenceRequest } from "./inferenceApi";

const STORAGE_KEY = "lelab-demo-preset";

export interface DemoPreset {
request: StartInferenceRequest;
policyLabel: string;
robotName: string;
savedAt: string;
}

export function getDemoPreset(): DemoPreset | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const preset = JSON.parse(raw) as DemoPreset;
return preset && preset.request ? preset : null;
} catch {
return null;
}
}

export function saveDemoPreset(preset: DemoPreset) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(preset));
} catch {
// localStorage unavailable — demo mode simply stays unconfigured.
}
}
262 changes: 262 additions & 0 deletions frontend/src/pages/Demo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import React, { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeft, Maximize, Minimize, Play, Square } from "lucide-react";
import { useApi } from "@/contexts/ApiContext";
import { useToast } from "@/hooks/use-toast";
import {
InferenceStatus,
getInferenceStatus,
startInference,
stopInference,
} from "@/lib/inferenceApi";
import { DemoPreset, getDemoPreset } from "@/lib/demoPreset";

const formatTime = (totalSeconds: number): string => {
const s = Math.max(0, Math.floor(totalSeconds));
const mins = Math.floor(s / 60);
const secs = s % 60;
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
};

const CameraFeed: React.FC<{
baseUrl: string;
camId: number;
label: string;
}> = ({ baseUrl, camId, label }) => {
const [tick, setTick] = useState(0);
const [loaded, setLoaded] = useState(false);

useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 150);
return () => clearInterval(id);
}, []);

return (
<div className="relative rounded-xl overflow-hidden border border-gray-800 bg-gray-900 min-h-[180px] flex items-center justify-center">
{!loaded && (
<span className="absolute text-gray-600 text-sm">
waiting for camera…
</span>
)}
<img
src={`${baseUrl}/demo-camera/${camId}?t=${tick}`}
alt={`Live camera ${label}`}
onLoad={() => setLoaded(true)}
className={`w-full h-full object-contain ${loaded ? "opacity-100" : "opacity-0"}`}
/>
<span className="absolute bottom-2 left-3 text-xs text-gray-300 bg-black/60 rounded px-2 py-0.5">
{label}
</span>
</div>
);
};

const Demo = () => {
const navigate = useNavigate();
const { baseUrl, fetchWithHeaders } = useApi();
const { toast } = useToast();

const [preset] = useState<DemoPreset | null>(() => getDemoPreset());
const [status, setStatus] = useState<InferenceStatus | null>(null);
const [submitting, setSubmitting] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);

const active = !!status?.inference_active;

useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const s = await getInferenceStatus(baseUrl, fetchWithHeaders);
if (!cancelled) setStatus(s);
} catch {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One failed status fetch nulls the status, which makes active false, which unmounts the STOP button while the arm is still moving. A single dropped request during a demo should not remove the only way to stop the robot, and a demo is exactly when the laptop is on unfamiliar wifi or being carried around.

Keep the last known status on a failed poll and show a "lost connection" banner over it instead. Inference.tsx already takes this shape: its catch toasts and leaves status alone.

if (!cancelled) setStatus(null);
}
};
poll();
const id = setInterval(poll, 1000);
return () => {
cancelled = true;
clearInterval(id);
};
}, [baseUrl, fetchWithHeaders]);

useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
}, []);

const toggleFullscreen = useCallback(() => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
document.documentElement.requestFullscreen().catch(() => {
// Fullscreen may be blocked (iframe/permissions) — non-fatal.
});
}
}, []);

const handleStart = async () => {
if (!preset) return;
setSubmitting(true);
try {
await startInference(baseUrl, fetchWithHeaders, preset.request);
} catch (e) {
toast({
title: "Couldn't start the demo",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
} finally {
setSubmitting(false);
}
};

const handleStop = async () => {
setSubmitting(true);
try {
await stopInference(baseUrl, fetchWithHeaders);
} catch (e) {
toast({
title: "Couldn't stop the demo",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
} finally {
setSubmitting(false);
}
};

const elapsed = status?.rollout_elapsed_s ?? status?.elapsed_s ?? 0;
const duration = status?.duration_s ?? preset?.request.duration_s ?? 0;
const progress =
active && duration > 0 ? Math.min(100, (elapsed / duration) * 100) : 0;

return (
<div className="min-h-screen bg-black text-white flex flex-col select-none">
<div className="flex items-center justify-between p-4">
<button
onClick={() => navigate("/")}
className="flex items-center gap-2 text-gray-600 hover:text-gray-300 text-sm transition-colors"
aria-label="Back to home"
>
<ArrowLeft className="w-4 h-4" />
exit
</button>
<button
onClick={toggleFullscreen}
className="text-gray-600 hover:text-gray-300 transition-colors"
aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
>
{isFullscreen ? (
<Minimize className="w-5 h-5" />
) : (
<Maximize className="w-5 h-5" />
)}
</button>
</div>

<div className="flex-1 flex flex-col items-center justify-center gap-10 px-6 pb-16">
{!preset ? (
<div className="text-center max-w-md space-y-4">
<h1 className="text-3xl font-bold">Demo mode</h1>
<p className="text-gray-400 leading-relaxed">
No demo configured yet. Run an inference once from the landing
page (green play button on a trained model) — its exact settings
will be reused here for one-click demos.
</p>
<button
onClick={() => navigate("/")}
className="text-red-400 hover:text-red-300 underline underline-offset-4"
>
Go to the landing page
</button>
</div>
) : active ? (
<>
<div className="flex items-center gap-3 text-gray-400">
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
</span>
Robot running
</div>
{!status?.rollout_started_at ? (
<div className="flex flex-col items-center gap-3 text-gray-400">
<span className="h-10 w-10 rounded-full border-4 border-gray-700 border-t-green-500 animate-spin" />
<span>Loading policy — the robot will move in ~30 s…</span>
</div>
) : (
Object.keys(preset.request.cameras).length > 0 && (
<div
className={`grid gap-4 w-full max-w-4xl ${
Object.keys(preset.request.cameras).length > 1
? "grid-cols-1 sm:grid-cols-2"
: "grid-cols-1"
}`}
>
{Object.entries(preset.request.cameras).map(([name, cfg]) => (
<CameraFeed
key={name}
baseUrl={baseUrl}
camId={cfg.camera_index ?? 0}
label={name}
/>
))}
</div>
)
)}
<div className="text-6xl md:text-7xl font-bold font-mono tabular-nums">
{formatTime(elapsed)}
</div>
{duration > 0 && (
<div className="w-full max-w-xl h-2 rounded-full bg-gray-800 overflow-hidden">
<div
className="h-full bg-green-500 transition-all duration-1000 ease-linear"
style={{ width: `${progress}%` }}
/>
</div>
)}
<button
onClick={handleStop}
disabled={submitting}
className="mt-4 flex items-center justify-center gap-3 rounded-full bg-red-600 hover:bg-red-500 disabled:opacity-50 text-white text-2xl font-bold px-14 py-6 shadow-lg shadow-red-600/30 transition-all active:scale-95"
>
<Square className="w-7 h-7 fill-current" />
STOP
</button>
</>
) : (
<>
<div className="text-center space-y-2">
<h1 className="text-3xl md:text-4xl font-bold">
{preset.request.task || "Robot demo"}
</h1>
<p className="text-gray-500 text-sm">
{preset.policyLabel} · {preset.robotName} ·{" "}
{preset.request.duration_s}s
</p>
</div>
<button
onClick={handleStart}
disabled={submitting}
className="flex items-center justify-center gap-4 rounded-full bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white text-3xl font-bold px-16 py-8 shadow-lg shadow-green-600/30 transition-all active:scale-95"
>
<Play className="w-9 h-9 fill-current" />
{submitting ? "STARTING…" : "START"}
</button>
{status?.exited && status.exit_code !== 0 && status.exit_code != null && (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will almost never be seen. handle_inference_status returns the exited payload only on the single poll where it notices proc.poll() is not None, then sets _inference_proc = None, so every later poll returns the live dict with no exited and no exit_code. The page polls once a second and assigns straight into state, so this line renders for roughly one second and then disappears.

That matters more here than on the inference page, because of what triggers it. The preset is a snapshot living in the browser, and every field in it can go stale against the machine: follower_port gets renumbered on replug or reboot, camera_index moves when a webcam is unplugged, and a local policy_ref is an absolute path that a cleaned training dir invalidates. Missing calibration and unresolvable policy refs fail loudly at start, which is fine, but a stale port or a missing camera lets the start succeed and only fails inside the subprocess, landing exactly here. The result on stage is: press START, "the robot will move in ~30 s", nothing moves, and the page is back on START with no reason given.

Worth latching the exit payload in a ref and keeping it rendered until the next START. /inference-status also returns error (the real exception pulled out of the log) and hint (a plain-language line for the common SO-101 failures, including a wrong or unplugged port). Neither is declared on InferenceStatus in inferenceApi.ts yet, and both are far more useful to a presenter than an exit code plus "check the inference page for logs".

<p className="text-xs text-red-400">
Last run exited with code {status.exit_code} — check the
inference page for logs.
</p>
)}
</>
)}
</div>
</div>
);
};

export default Demo;
21 changes: 15 additions & 6 deletions frontend/src/pages/Landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,12 +270,21 @@ const Landing = () => {
<h3 className="font-semibold text-lg text-left h-10 flex items-center">
Create a model
</h3>
<Button
onClick={handleTrainingClick}
className="w-full bg-green-500 hover:bg-green-600 text-white"
>
Training
</Button>
<div className="flex gap-2">
<Button
onClick={handleTrainingClick}
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
>
Training
</Button>
<Button
onClick={() => navigate("/demo")}
variant="outline"
className="flex-1 border-gray-600 text-gray-300 bg-gray-800 hover:bg-gray-700 hover:text-white"
>
Demo mode
</Button>
</div>
</div>
</div>
</div>
Expand Down
Loading