nikodembartnik's picture
Upload folder using huggingface_hub
096ef48 verified
Raw History Blame Contribute Delete
50.8 kB
import {
ROBOTS,
TELEOPS,
POLICIES,
CAMERA_TYPES,
DEVICES,
ROLLOUT_STRATEGIES,
INFERENCE_BACKENDS,
MOTOR_FAMILIES,
byId,
groupsOf,
docUrl,
} from "./catalog.js";
/* ══════════════════════════ storage ══════════════════════════ */
const STORE_KEY = "lerobot-command-builder:v1";
const THEME_KEY = "lerobot-command-builder:theme";
/* localStorage can throw outright (Safari in a third-party iframe, private
* windows, "block site data"). Every access goes through these two so a blocked
* store degrades to an in-memory session instead of a blank page. */
let storageBlocked = false;
function readStore() {
try {
const raw = localStorage.getItem(STORE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
storageBlocked = true;
return null;
}
}
function writeStore(data) {
try {
localStorage.setItem(STORE_KEY, JSON.stringify(data));
return true;
} catch {
storageBlocked = true;
return false;
}
}
/* ══════════════════════════ defaults ══════════════════════════ */
function defaultProfile(name = "My SO-101") {
return {
name,
hfUser: "",
// robot
robotType: "so101_follower",
robotId: "my_follower",
robotPort: "",
robotPortLeft: "",
robotPortRight: "",
robotSide: "",
robotFamily: "dm",
robotIp: "localhost",
robotRobotIp: "192.168.123.164",
robotRemoteIp: "",
robotSdkUrl: "http://localhost:8000",
// teleop
teleopType: "so101_leader",
teleopId: "my_leader",
teleopPort: "",
teleopPortLeft: "",
teleopPortRight: "",
teleopSide: "",
teleopFamily: "dm",
teleopPhoneOs: "ios",
// cameras
cameras: [{ name: "front", type: "opencv", path: "0", width: 640, height: 480, fps: 30 }],
// command style
multiline: true,
useHfVar: false,
displayData: true,
// teleoperate
teleopFps: 60,
// record
datasetName: "",
taskDesc: "",
numEpisodes: 50,
episodeTime: 30,
resetTime: 10,
datasetFps: 30,
pushToHub: true,
privateDataset: false,
resumeRecording: false,
// train
trainDataset: "",
policyType: "act",
device: "cuda",
batchSize: 8,
steps: 100000,
outputDir: "",
policyRepo: "",
jobTarget: "",
wandb: true,
resumeTraining: false,
// rollout
policyPath: "",
rolloutStrategy: "base",
inferenceType: "sync",
rolloutDuration: 60,
rolloutFps: 30,
rolloutDataset: "",
interactive: false,
};
}
function defaultStore() {
const p = defaultProfile();
return { version: 1, active: p.name, profiles: [p] };
}
/* ══════════════════════════ app state ══════════════════════════ */
let store = readStore() || defaultStore();
if (!Array.isArray(store.profiles) || store.profiles.length === 0) store = defaultStore();
/* Merge in any keys added by a newer version of the app. */
store.profiles = store.profiles.map((p) => ({ ...defaultProfile(p.name), ...p }));
const $ = (sel) => document.querySelector(sel);
const el = (tag, props = {}, children = []) => {
const node = Object.assign(document.createElement(tag), props);
for (const c of [].concat(children)) node.append(c);
return node;
};
function profile() {
return store.profiles.find((p) => p.name === store.active) || store.profiles[0];
}
let saveTimer = null;
function persist() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
const ok = writeStore(store);
const pill = $("#savedPill");
if (!ok || storageBlocked) {
pill.textContent = "Not saved β€” storage blocked";
pill.dataset.state = "blocked";
pill.title =
"This browser is blocking localStorage for this page. Open the Space directly at its *.hf.space URL, or use Export JSON / Copy share link to keep your setup.";
} else {
pill.textContent = "Saved locally βœ“";
pill.dataset.state = "saved";
setTimeout(() => {
if (pill.dataset.state === "saved") {
pill.textContent = "Saved locally";
pill.dataset.state = "";
}
}, 1400);
}
}, 220);
}
/* ══════════════════════════ shell helpers ══════════════════════════ */
const NEEDS_QUOTES = /[\s"'`$&|<>();*?!\\]/;
/** A `--flag=value` argument. `quoting`: "auto" | "raw" | "text". */
const A = (flag, value, quoting = "auto") => ({ flag, value: String(value), quoting });
/** A bare `--flag` argument. */
const RAW = (text) => ({ raw: text });
/* A single-token value containing <ANGLE_BRACKETS> is a placeholder for the
* user to fill in, written bare the way the LeRobot docs write them. */
const IS_PLACEHOLDER = (v) => /<[^>\s]+>/.test(v) && !/\s/.test(v);
function quoteValue(value, quoting) {
if (quoting === "raw") return value;
if (quoting === "text") return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
if (value === "") return '""';
if (IS_PLACEHOLDER(value)) return value;
// Leave $VAR expansions alone; they are there on purpose.
if (/^[$][A-Za-z_][A-Za-z0-9_]*(\/[\w.$/-]*)?$/.test(value)) return value;
return NEEDS_QUOTES.test(value) ? `"${value.replace(/(["\\`])/g, "\\$1")}"` : value;
}
const argText = (arg) => (arg.raw !== undefined ? arg.raw : `${arg.flag}=${quoteValue(arg.value, arg.quoting)}`);
const escapeHtml = (s) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
function argHtml(arg) {
if (arg.raw !== undefined) return `<span class="t-val">${escapeHtml(arg.raw)}</span>`;
return `<span class="t-flag">${escapeHtml(arg.flag)}</span>=<span class="t-val">${escapeHtml(
quoteValue(arg.value, arg.quoting),
)}</span>`;
}
/* Only wrap lines that are genuinely long: a short `pip install lerobot` reads
* worse split across three lines than a 12-flag lerobot-record does on one. */
const WRAP_AT_FLAGS = 3;
const shouldWrap = (line, multiline) =>
multiline && line.args.filter((a) => a.flag !== undefined).length >= WRAP_AT_FLAGS;
const JOIN_WRAPPED = " \\\n ";
/** Render one line of a block to plain text. */
function lineText(line, multiline) {
if (line.comment !== undefined) return `# ${line.comment}`;
const parts = [line.prog, ...line.args.map(argText)];
return parts.join(shouldWrap(line, multiline) ? JOIN_WRAPPED : " ");
}
function lineHtml(line, multiline) {
if (line.comment !== undefined) return `<span class="t-comment"># ${escapeHtml(line.comment)}</span>`;
const parts = [`<span class="t-cmd">${escapeHtml(line.prog)}</span>`, ...line.args.map(argHtml)];
return parts.join(shouldWrap(line, multiline) ? JOIN_WRAPPED : " ");
}
const blockText = (block, multiline) => block.lines.map((l) => lineText(l, multiline)).join("\n");
const blockHtml = (block, multiline) => block.lines.map((l) => lineHtml(l, multiline)).join("\n");
/* ══════════════════════════ hardware β†’ flags ══════════════════════════ */
/**
* Connection flags for a robot or teleoperator, e.g. `--robot.port=/dev/ttyACM0`
* or the nested `--robot.left_arm_config.port=` pair used by bimanual configs.
*/
function connArgs(ns, entry, p) {
if (!entry) return [];
const K = ns === "robot" ? "robot" : "teleop";
const side = ns === "robot" ? "FOLLOWER" : "LEADER";
const or = (flag, value, placeholder) => A(flag, String(value ?? "").trim() || placeholder);
const args = [];
for (const field of entry.fields) {
switch (field) {
case "port":
args.push(or(`--${ns}.port`, p[`${K}Port`], `<${side}_PORT>`));
break;
case "biport":
args.push(or(`--${ns}.left_arm_config.port`, p[`${K}PortLeft`], `<LEFT_${side}_PORT>`));
args.push(or(`--${ns}.right_arm_config.port`, p[`${K}PortRight`], `<RIGHT_${side}_PORT>`));
break;
case "side":
if (p[`${K}Side`]) args.push(A(`--${ns}.side`, p[`${K}Side`]));
break;
case "biside":
args.push(A(`--${ns}.left_arm_config.side`, "left"));
args.push(A(`--${ns}.right_arm_config.side`, "right"));
break;
case "family":
args.push(A(`--${ns}.motor_family`, p[`${K}Family`]));
break;
case "bifamily":
args.push(A(`--${ns}.left_arm_config.motor_family`, p[`${K}Family`]));
args.push(A(`--${ns}.right_arm_config.motor_family`, p[`${K}Family`]));
break;
case "ip":
args.push(or(`--${ns}.ip_address`, p[`${K}Ip`], "<ROBOT_IP>"));
break;
case "robot_ip":
args.push(or(`--${ns}.robot_ip`, p[`${K}RobotIp`], "<ROBOT_IP>"));
break;
case "remote_ip":
args.push(or(`--${ns}.remote_ip`, p[`${K}RemoteIp`], "<ROBOT_IP>"));
break;
case "sdk_url":
args.push(or(`--${ns}.sdk_url`, p[`${K}SdkUrl`], "<SDK_URL>"));
break;
case "phone_os":
args.push(A(`--${ns}.phone_os`, p[`${K}PhoneOs`]));
break;
}
}
return args;
}
/** Ordered `--robot.type --robot.id <conn flags>` group. */
function deviceArgs(ns, entry, p) {
if (!entry) return [];
const K = ns === "robot" ? "robot" : "teleop";
const args = [A(`--${ns}.type`, entry.id)];
if (entry.calibrate && p[`${K}Id`]) args.push(A(`--${ns}.id`, p[`${K}Id`]));
return args.concat(connArgs(ns, entry, p));
}
/** `--robot.cameras='{ front: {type: opencv, index_or_path: 0, ...}}'` */
function cameraArg(p) {
const cams = (p.cameras || []).filter((c) => c.name && String(c.path).length);
if (cams.length === 0) return null;
const body = cams
.map((c) => {
const numeric = /^\d+$/.test(String(c.path).trim());
const path = numeric ? String(c.path).trim() : `"${c.path}"`;
return `${c.name}: {type: ${c.type}, index_or_path: ${path}, width: ${c.width}, height: ${c.height}, fps: ${c.fps}}`;
})
.join(", ");
return A("--robot.cameras", `'{${body}}'`, "raw");
}
/* ══════════════════════════ naming helpers ══════════════════════════ */
const userToken = (p) => (p.useHfVar ? "$HF_USER" : p.hfUser.trim() || "<HF_USER>");
const repoId = (p, name) => (name.includes("/") ? name : `${userToken(p)}/${name}`);
const datasetRepo = (p) => (p.datasetName.trim() ? repoId(p, p.datasetName.trim()) : "");
/* Which pip extras this particular robot + teleop + policy + camera mix needs.
* Surfaced as a prerequisite hint rather than an install step. */
function installExtras(p) {
const robot = byId(ROBOTS, p.robotType);
const teleop = byId(TELEOPS, p.teleopType);
const policy = byId(POLICIES, p.policyType);
const camExtras = (p.cameras || []).map((c) => byId(CAMERA_TYPES, c.type)?.extra);
const set = [robot?.extra, teleop?.extra, policy?.extra, ...camExtras].filter(Boolean);
return [...new Set(set)];
}
/* The run name that ties --output_dir, --job_name and the checkpoint path
* together. Derived from the dataset so two runs never collide by accident. */
function defaultJobName(p) {
if (p.outputDir.trim()) return p.outputDir.trim().split("/").filter(Boolean).pop();
const source = (p.trainDataset.trim() || p.datasetName.trim()).split("/").filter(Boolean).pop() || "run";
return `${p.policyType}_${source.replace(/[^\w.-]+/g, "_")}`;
}
/* ══════════════════════════ command builders ══════════════════════════ */
function buildBlocks(p) {
const robot = byId(ROBOTS, p.robotType);
const teleop = byId(TELEOPS, p.teleopType);
const cam = cameraArg(p);
const display = p.displayData ? [A("--display_data", "true")] : [];
const dataset = datasetRepo(p);
const blocks = {};
/* ── step 1: calibrate ── */
blocks.calibrate_robot = robot?.calibrate
? {
label: "Calibrate the follower",
lines: [{ prog: "lerobot-calibrate", args: deviceArgs("robot", robot, p) }],
}
: {
label: "Calibrate the follower",
note: `<code>${robot?.id ?? "This robot"}</code> does not need calibration through the LeRobot CLI.`,
lines: [],
};
blocks.calibrate_teleop = teleop?.calibrate
? {
label: "Calibrate the leader",
note: "<code>lerobot-calibrate</code> takes either a robot or a teleoperator, never both β€” run these as two separate commands.",
lines: [{ prog: "lerobot-calibrate", args: deviceArgs("teleop", teleop, p) }],
}
: {
label: "Calibrate the leader",
note: `<code>${teleop?.id ?? "This teleoperator"}</code> has nothing to calibrate.`,
lines: [],
};
/* ── step 2: teleoperate ── */
blocks.teleoperate = {
label: "Teleoperate β€” no data is recorded",
note: "Stop with Ctrl+C.",
lines: [
{
prog: "lerobot-teleoperate",
args: [
...deviceArgs("robot", robot, p),
...deviceArgs("teleop", teleop, p),
...(cam ? [cam] : []),
A("--fps", p.teleopFps),
...display,
],
},
],
};
/* ── step 3: record ── */
const recordArgs = [
...deviceArgs("robot", robot, p),
...deviceArgs("teleop", teleop, p),
...(cam ? [cam] : []),
A("--dataset.repo_id", dataset || "<HF_USER>/<DATASET_NAME>"),
A("--dataset.single_task", p.taskDesc || "<describe the task in one sentence>", "text"),
A("--dataset.num_episodes", p.numEpisodes),
A("--dataset.episode_time_s", p.episodeTime),
A("--dataset.reset_time_s", p.resetTime),
A("--dataset.fps", p.datasetFps),
];
if (!p.pushToHub) recordArgs.push(A("--dataset.push_to_hub", "false"));
if (p.pushToHub && p.privateDataset) recordArgs.push(A("--dataset.private", "true"));
if (p.resumeRecording) recordArgs.push(A("--resume", "true"));
recordArgs.push(...display);
blocks.record = {
label: "Record",
note: p.resumeRecording
? "Resuming: <code>--dataset.num_episodes</code> is the number of <strong>additional</strong> episodes, not the new total. You also need <code>--dataset.root=&lt;local path&gt;</code> pointing at the existing dataset on disk."
: "During recording: <strong>β†’</strong> save the episode and move on, <strong>←</strong> redo it, <strong>ESC</strong> stop early and upload what you have.",
lines: [{ prog: "lerobot-record", args: recordArgs }],
};
blocks.viz = {
label: "Inspect locally instead (Rerun)",
note: "Works on a local dataset before it is ever pushed.",
lines: [
{
prog: "lerobot-dataset-viz",
args: [A("--repo-id", dataset || "<HF_USER>/<DATASET_NAME>"), A("--episode-index", "0")],
},
],
};
blocks.replay = {
label: "Replay one episode on the robot",
note: "The follower re-executes a recorded episode. A good check that the dataset really holds what you think it does.",
lines: [
{
prog: "lerobot-replay",
args: [
...deviceArgs("robot", robot, p),
A("--dataset.repo_id", dataset || "<HF_USER>/<DATASET_NAME>"),
A("--dataset.episode", "0"),
],
},
],
};
/* ── step 4: train ── */
const trainDs = p.trainDataset.trim() ? repoId(p, p.trainDataset.trim()) : dataset || "<HF_USER>/<DATASET_NAME>";
const jobName = defaultJobName(p);
const trainArgs = [
A("--dataset.repo_id", trainDs),
A("--policy.type", p.policyType),
A("--policy.device", p.device),
A("--output_dir", p.outputDir.trim() || `outputs/train/${jobName}`),
A("--job_name", jobName),
A("--batch_size", p.batchSize),
A("--steps", p.steps),
];
if (p.wandb) trainArgs.push(A("--wandb.enable", "true"));
if (p.policyRepo.trim()) trainArgs.push(A("--policy.repo_id", repoId(p, p.policyRepo.trim())));
if (p.jobTarget.trim()) trainArgs.push(A("--job.target", p.jobTarget.trim()));
blocks.train = {
label: p.jobTarget.trim() ? `Train on Hugging Face Jobs (${p.jobTarget.trim()})` : "Train",
note: p.jobTarget.trim()
? "This submits the run to HF Jobs and streams the logs. Add <code>--job.detach=true</code> to submit and exit."
: "Long run? Prefix with <code>nohup</code> or run it inside <code>tmux</code> so an SSH drop doesn't kill it.",
lines: [{ prog: "lerobot-train", args: trainArgs }],
};
blocks.train_resume = p.resumeTraining
? {
label: "Resume from the last checkpoint",
note: "Point <code>--config_path</code> at the checkpoint's saved config; everything else comes from it.",
lines: [
{
prog: "lerobot-train",
args: [
A("--config_path", `${p.outputDir.trim() || `outputs/train/${jobName}`}/checkpoints/last/pretrained_model/train_config.json`),
A("--resume", "true"),
],
},
],
}
: { label: "", lines: [] };
/* ── step 5: rollout ── */
const strategy = byId(ROLLOUT_STRATEGIES, p.rolloutStrategy);
const policyPath =
p.policyPath.trim() ||
(p.policyRepo.trim()
? repoId(p, p.policyRepo.trim())
: `${p.outputDir.trim() || `outputs/train/${jobName}`}/checkpoints/last/pretrained_model`);
const rolloutArgs = [
A("--strategy.type", p.rolloutStrategy),
A("--policy.path", policyPath),
...deviceArgs("robot", robot, p),
];
// dagger hands control back to a human, so the leader has to be connected too
if (p.rolloutStrategy === "dagger") rolloutArgs.push(...deviceArgs("teleop", teleop, p));
if (cam) rolloutArgs.push(cam);
if (p.inferenceType !== "sync") rolloutArgs.push(A("--inference.type", p.inferenceType));
if (strategy?.records) {
const rd = p.rolloutDataset.trim() ? repoId(p, p.rolloutDataset.trim()) : "<HF_USER>/<ROLLOUT_DATASET>";
rolloutArgs.push(A("--dataset.repo_id", rd));
rolloutArgs.push(A("--dataset.single_task", p.taskDesc || "<describe the task in one sentence>", "text"));
rolloutArgs.push(A("--dataset.fps", p.datasetFps));
if (p.rolloutStrategy === "episodic" || p.rolloutStrategy === "dagger") {
rolloutArgs.push(A("--strategy.num_episodes", p.numEpisodes));
}
} else {
rolloutArgs.push(A("--task", p.taskDesc || "<describe the task in one sentence>", "text"));
}
rolloutArgs.push(A("--fps", p.rolloutFps));
if (!strategy?.records || p.rolloutStrategy === "sentry") rolloutArgs.push(A("--duration", p.rolloutDuration));
if (p.interactive) rolloutArgs.push(A("--interactive", "true"));
rolloutArgs.push(...display);
blocks.rollout = {
label: `Rollout β€” ${p.rolloutStrategy}`,
note:
p.rolloutStrategy === "highlight"
? "Press <strong>s</strong> to keep the last few seconds, <strong>h</strong> to push what you kept."
: p.rolloutStrategy === "dagger"
? "Grab the leader arm the moment the policy goes wrong β€” your correction is what gets recorded."
: "Keep a hand near the power switch the first time you run a fresh policy.",
lines: [{ prog: "lerobot-rollout", args: rolloutArgs }],
};
return blocks;
}
/* ══════════════════════════ command rendering ══════════════════════════ */
function renderBlock(container, block, multiline) {
container.textContent = "";
if (!block || block.lines.length === 0) {
if (block?.note) {
container.append(el("p", { className: "cmd__note", innerHTML: block.note }));
}
return;
}
const text = blockText(block, multiline);
const head = el("div", { className: "cmd__head" }, [
el("span", { className: "cmd__label", textContent: block.label }),
]);
const copy = el("button", { className: "cmd__copy", type: "button", textContent: "Copy" });
head.append(copy);
const pre = el("pre", { className: "cmd__pre" });
pre.innerHTML = blockHtml(block, multiline);
copy.addEventListener("click", () => copyToClipboard(text, copy, pre));
container.append(head, pre);
if (block.note) container.append(el("p", { className: "cmd__note", innerHTML: block.note }));
}
function renderCommands() {
const p = profile();
const blocks = buildBlocks(p);
for (const node of document.querySelectorAll(".cmd")) {
renderBlock(node, blocks[node.dataset.cmd], p.multiline);
}
renderWarnings(p);
renderContextNotes(p);
}
/* Three tiers, because the Hub embeds this page in a cross-origin iframe:
* 1. the async Clipboard API β€” needs a user gesture and, in an iframe, a
* delegated clipboard-write permission;
* 2. execCommand on an offscreen textarea, which still works where (1) is
* blocked by permissions policy;
* 3. select the command in place so ⌘/Ctrl+C does it by hand. Better than a
* a modal text prompt, which mangles a multi-line command into one row.
*/
async function copyToClipboard(text, button, selectFallback) {
let ok = false;
try {
await navigator.clipboard.writeText(text);
ok = true;
} catch {
const ta = el("textarea", { value: text });
ta.setAttribute("readonly", "");
Object.assign(ta.style, { position: "fixed", top: "-1000px", opacity: "0" });
document.body.append(ta);
ta.select();
try {
ok = document.execCommand("copy");
} catch {
ok = false;
}
ta.remove();
}
if (!ok && selectFallback) {
const range = document.createRange();
range.selectNodeContents(selectFallback);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
if (button) {
const original = button.textContent;
button.textContent = ok ? "Copied βœ“" : "Selected β€” press ⌘/Ctrl+C";
button.classList.toggle("is-copied", ok);
setTimeout(() => {
button.textContent = original;
button.classList.remove("is-copied");
}, ok ? 1600 : 3200);
}
return ok;
}
function toast(message) {
const node = $("#toast");
node.textContent = message;
node.hidden = false;
clearTimeout(node._t);
node._t = setTimeout(() => {
node.hidden = true;
}, 2200);
}
/* ══════════════════════════ warnings ══════════════════════════ */
function renderWarnings(p) {
const box = $("#warnings");
const robot = byId(ROBOTS, p.robotType);
const teleop = byId(TELEOPS, p.teleopType);
const msgs = [];
const missing = (label, value) => {
if (!String(value ?? "").trim()) msgs.push(`${label} is empty β€” the commands below have a placeholder in its place.`);
};
if (!p.hfUser.trim() && !p.useHfVar) missing("Hugging Face username", "");
for (const field of robot?.fields ?? []) {
if (field === "port") missing("Robot port", p.robotPort);
if (field === "biport") {
missing("Robot left-arm port", p.robotPortLeft);
missing("Robot right-arm port", p.robotPortRight);
}
if (field === "remote_ip") missing("LeKiwi host IP", p.robotRemoteIp);
}
for (const field of teleop?.fields ?? []) {
if (field === "port") missing("Teleoperator port", p.teleopPort);
if (field === "biport") {
missing("Teleoperator left-arm port", p.teleopPortLeft);
missing("Teleoperator right-arm port", p.teleopPortRight);
}
}
if (!p.datasetName.trim()) missing("Dataset name", "");
if (!p.taskDesc.trim()) missing("Task description", "");
if ((p.cameras || []).length === 0) {
msgs.push("No cameras configured β€” a policy trained without camera observations can only ever replay joint trajectories.");
}
if (teleop && robot && teleop.pairs?.length && !teleop.pairs.includes(robot.id)) {
msgs.push(`<code>${teleop.id}</code> is not a usual pairing for <code>${robot.id}</code>. Fine if you know why.`);
}
if (p.rolloutStrategy === "dagger" && teleop && !teleop.calibrate && teleop.fields.length === 0) {
msgs.push("DAgger needs a teleoperator you can grab mid-episode β€” a keyboard or gamepad will not give useful corrections for arm tasks.");
}
if (p.device === "cpu") {
msgs.push("Training on CPU is realistically only viable for a smoke test. Consider <code>--job.target</code> to run on HF Jobs instead.");
}
box.hidden = msgs.length === 0;
box.innerHTML = msgs.length ? `<ul>${msgs.map((m) => `<li>${m}</li>`).join("")}</ul>` : "";
}
/* Aside notes that depend on the current selection. */
function renderContextNotes(p) {
const robot = byId(ROBOTS, p.robotType);
$("#robotNote").textContent = robot?.note ?? "";
const policy = byId(POLICIES, p.policyType);
$("#policyNote").textContent = policy?.note ?? "";
$("#policyNoteHead").textContent = policy ? `${policy.label}` : "Policy notes";
/* Documentation links follow the current selection: pointing someone at the
* SO-101 page while they have a LeKiwi selected is worse than no link. */
const robotDoc = docUrl(robot?.doc);
// "LeKiwi Client (run on your laptop)" is a helpful dropdown label but a poor
// link label, so the parenthetical is dropped here.
const shortLabel = (label) => label.replace(/\s*\([^)]*\)\s*$/, "");
for (const id of ["#docRobot", "#docRobotAside"]) {
const link = $(id);
if (!link) continue;
link.hidden = !robotDoc;
if (robotDoc) {
link.href = robotDoc;
link.textContent = `${shortLabel(robot.label)} setup β†—`;
}
}
const policyDoc = docUrl(policy?.doc);
const policyLink = $("#docPolicy");
if (policyLink) {
policyLink.hidden = !policyDoc;
if (policyDoc) {
policyLink.href = policyDoc;
policyLink.textContent = `${policy.label} guide β†—`;
}
}
const extras = installExtras(p);
$("#prereqExtra").innerHTML = extras.length
? `This setup needs the <code>${extras.join(", ")}</code> extra${extras.length > 1 ? "s" : ""} β€” <code>pip install 'lerobot[${extras.join(",")}]'</code>`
: "";
const strategy = byId(ROLLOUT_STRATEGIES, p.rolloutStrategy);
$("#strategyNote").textContent = strategy?.note ?? "";
const inference = byId(INFERENCE_BACKENDS, p.inferenceType);
$("#inferenceNote").textContent = inference?.note ?? "";
const jobName = defaultJobName(p);
$("#ckptPath").textContent = `${p.outputDir.trim() || `outputs/train/${jobName}`}/checkpoints/`;
const prefix = `${userToken(p)}/`;
$("#datasetPrefix").textContent = prefix;
$("#datasetPrefix").title = prefix;
$("#rolloutPrefix").textContent = prefix;
$("#rolloutPrefix").title = prefix;
// dataset viewer link
const ds = datasetRepo(p);
const link = $("#vizLink");
const usable = ds && !ds.includes("<") && !ds.includes("$");
link.href = usable
? `https://huggingface.co/datasets/${ds}`
: "https://huggingface.co/spaces/lerobot/visualize_dataset";
$("#vizNote").textContent = usable
? `Opens huggingface.co/datasets/${ds} β€” the embedded viewer plays every episode.`
: "Fill in your username and dataset name to get a direct link.";
// rollout dataset field only matters for recording strategies
$("#rolloutDatasetField").hidden = !strategy?.records;
}
/* ══════════════════════════ select population ══════════════════════════ */
function fillGrouped(select, items) {
select.textContent = "";
for (const group of groupsOf(items)) {
const og = el("optgroup", { label: group });
for (const item of items.filter((i) => i.group === group)) {
og.append(el("option", { value: item.id, textContent: `${item.label} β€” ${item.id}` }));
}
select.append(og);
}
}
function fillFlat(select, items, labelFn = (i) => i.label) {
select.textContent = "";
for (const item of items) select.append(el("option", { value: item.id, textContent: labelFn(item) }));
}
/* ══════════════════════════ dynamic connection fields ══════════════════════════ */
const PORT_HINT = "Serial device or CAN interface";
function textField(label, key, value, placeholder, hint) {
const input = el("input", { type: "text", value: value ?? "", placeholder: placeholder ?? "", autocomplete: "off", spellcheck: false });
input.dataset.key = key;
input.addEventListener("input", () => {
profile()[key] = input.value;
persist();
renderCommands();
});
const parts = [el("span", { className: "field__label", textContent: label }), input];
if (hint) parts.push(el("span", { className: "field__hint", textContent: hint }));
return el("label", { className: "field" }, parts);
}
function selectField(label, key, value, options) {
const select = el("select");
for (const o of options) select.append(el("option", { value: o.id, textContent: o.label }));
select.value = value;
select.dataset.key = key;
select.addEventListener("change", () => {
profile()[key] = select.value;
persist();
renderCommands();
});
return el("label", { className: "field" }, [el("span", { className: "field__label", textContent: label }), select]);
}
function renderConnFields(ns) {
const p = profile();
const K = ns === "robot" ? "robot" : "teleop";
const entry = ns === "robot" ? byId(ROBOTS, p.robotType) : byId(TELEOPS, p.teleopType);
const box = $(ns === "robot" ? "#robotConnFields" : "#teleopConnFields");
box.textContent = "";
if (!entry) return;
const hint = entry.portHint || "/dev/ttyACM0";
for (const field of entry.fields) {
switch (field) {
case "port":
box.append(textField("Port", `${K}Port`, p[`${K}Port`], hint, PORT_HINT));
break;
case "biport":
box.append(textField("Left arm port", `${K}PortLeft`, p[`${K}PortLeft`], hint));
box.append(textField("Right arm port", `${K}PortRight`, p[`${K}PortRight`], hint.replace(/(\d)$/, (d) => Number(d) + 1)));
break;
case "side":
box.append(
selectField("Arm side", `${K}Side`, p[`${K}Side`] || "", [
{ id: "", label: "not set (safe default limits)" },
{ id: "left", label: "left" },
{ id: "right", label: "right" },
]),
);
break;
case "family":
case "bifamily":
box.append(selectField("Motor family", `${K}Family`, p[`${K}Family`], MOTOR_FAMILIES));
break;
case "ip":
box.append(textField("IP address", `${K}Ip`, p[`${K}Ip`], "localhost"));
break;
case "robot_ip":
box.append(textField("Robot IP", `${K}RobotIp`, p[`${K}RobotIp`], "192.168.123.164"));
break;
case "remote_ip":
box.append(textField("Host IP (the robot's Pi)", `${K}RemoteIp`, p[`${K}RemoteIp`], "192.168.1.42"));
break;
case "sdk_url":
box.append(textField("SDK URL", `${K}SdkUrl`, p[`${K}SdkUrl`], "http://localhost:8000"));
break;
case "phone_os":
box.append(
selectField("Phone OS", `${K}PhoneOs`, p[`${K}PhoneOs`], [
{ id: "ios", label: "iOS" },
{ id: "android", label: "Android" },
]),
);
break;
case "biside":
break; // emitted automatically as left/right
}
}
if (entry.fields.length === 0) {
box.append(el("p", { className: "type-note", textContent: "No connection settings β€” this device needs no port or address." }));
}
}
/* ══════════════════════════ cameras ══════════════════════════ */
function renderCameras() {
const p = profile();
const list = $("#cameraList");
list.textContent = "";
if (!p.cameras) p.cameras = [];
if (p.cameras.length === 0) {
list.append(el("p", { className: "camera-empty", textContent: "No cameras. Add at least one before recording." }));
return;
}
p.cameras.forEach((camera, index) => {
const card = el("div", { className: "camera" });
const nameInput = el("input", { type: "text", value: camera.name, placeholder: "front" });
nameInput.addEventListener("input", () => {
camera.name = nameInput.value.trim().replace(/\s+/g, "_");
persist();
renderCommands();
});
const remove = el("button", { className: "camera__x", type: "button", innerHTML: "&times;", title: "Remove camera" });
remove.addEventListener("click", () => {
p.cameras.splice(index, 1);
persist();
renderCameras();
renderCommands();
});
card.append(el("div", { className: "camera__top" }, [nameInput, remove]));
const grid = el("div", { className: "camera__grid" });
const typeSelect = el("select");
for (const t of CAMERA_TYPES) typeSelect.append(el("option", { value: t.id, textContent: t.label }));
typeSelect.value = camera.type;
typeSelect.addEventListener("change", () => {
camera.type = typeSelect.value;
persist();
renderCommands();
});
grid.append(typeSelect);
const numField = (key, placeholder, title) => {
const input = el("input", { type: "text", value: camera[key], placeholder, title });
input.addEventListener("input", () => {
camera[key] = input.value.trim();
persist();
renderCommands();
});
return input;
};
grid.append(numField("path", "0 or /dev/video0", "Camera index or device path"));
grid.append(numField("fps", "30", "FPS"));
grid.append(numField("width", "640", "Width"));
grid.append(numField("height", "480", "Height"));
card.append(grid);
list.append(card);
});
}
/* ══════════════════════════ form binding ══════════════════════════ */
/* [element id, profile key, kind] */
const BINDINGS = [
["hfUser", "hfUser", "text"],
["robotId", "robotId", "text"],
["teleopId", "teleopId", "text"],
["multiline", "multiline", "bool"],
["useHfVar", "useHfVar", "bool"],
["displayData", "displayData", "bool"],
["teleopFps", "teleopFps", "num"],
["datasetName", "datasetName", "text"],
["taskDesc", "taskDesc", "text"],
["numEpisodes", "numEpisodes", "num"],
["episodeTime", "episodeTime", "num"],
["resetTime", "resetTime", "num"],
["datasetFps", "datasetFps", "num"],
["pushToHub", "pushToHub", "bool"],
["privateDataset", "privateDataset", "bool"],
["resumeRecording", "resumeRecording", "bool"],
["trainDataset", "trainDataset", "text"],
["policyType", "policyType", "text"],
["device", "device", "text"],
["batchSize", "batchSize", "num"],
["steps", "steps", "num"],
["outputDir", "outputDir", "text"],
["policyRepo", "policyRepo", "text"],
["jobTarget", "jobTarget", "text"],
["wandb", "wandb", "bool"],
["resumeTraining", "resumeTraining", "bool"],
["policyPath", "policyPath", "text"],
["rolloutStrategy", "rolloutStrategy", "text"],
["inferenceType", "inferenceType", "text"],
["rolloutDuration", "rolloutDuration", "num"],
["rolloutFps", "rolloutFps", "num"],
["rolloutDataset", "rolloutDataset", "text"],
["interactive", "interactive", "bool"],
];
function syncFormFromProfile() {
const p = profile();
for (const [id, key, kind] of BINDINGS) {
const node = document.getElementById(id);
if (!node) continue;
if (kind === "bool") node.checked = Boolean(p[key]);
else node.value = p[key] ?? "";
}
$("#profileName").value = p.name;
$("#robotType").value = p.robotType;
$("#teleopType").value = p.teleopType;
renderConnFields("robot");
renderConnFields("teleop");
renderCameras();
renderCommands();
}
function wireForm() {
for (const [id, key, kind] of BINDINGS) {
const node = document.getElementById(id);
if (!node) continue;
const event = kind === "bool" || node.tagName === "SELECT" ? "change" : "input";
node.addEventListener(event, () => {
const p = profile();
if (kind === "bool") p[key] = node.checked;
else if (kind === "num") p[key] = node.value === "" ? "" : Number(node.value);
else p[key] = node.value;
persist();
renderCommands();
});
}
$("#robotType").addEventListener("change", (e) => {
const p = profile();
p.robotType = e.target.value;
// suggest the matching leader when the current one makes no sense
const teleop = byId(TELEOPS, p.teleopType);
if (teleop?.pairs?.length && !teleop.pairs.includes(p.robotType)) {
const match = TELEOPS.find((t) => t.pairs?.includes(p.robotType));
if (match) {
p.teleopType = match.id;
$("#teleopType").value = match.id;
renderConnFields("teleop");
}
}
persist();
renderConnFields("robot");
renderCommands();
});
$("#teleopType").addEventListener("change", (e) => {
profile().teleopType = e.target.value;
persist();
renderConnFields("teleop");
renderCommands();
});
$("#cameraAdd").addEventListener("click", () => {
const p = profile();
const names = ["front", "wrist", "top", "side", "overhead"];
const used = new Set(p.cameras.map((c) => c.name));
const name = names.find((n) => !used.has(n)) || `cam${p.cameras.length}`;
const nextIndex = String(p.cameras.length);
p.cameras.push({ name, type: "opencv", path: nextIndex, width: 640, height: 480, fps: 30 });
persist();
renderCameras();
renderCommands();
});
$("#useRecordedDataset").addEventListener("click", () => {
const p = profile();
if (!p.datasetName.trim()) return toast("Set a dataset name in step 3 first.");
p.trainDataset = p.datasetName.trim();
$("#trainDataset").value = p.trainDataset;
persist();
renderCommands();
toast("Using the dataset from step 3.");
});
$("#useTrainedPolicy").addEventListener("click", () => {
const p = profile();
const jobName = defaultJobName(p);
p.policyPath = p.policyRepo.trim()
? repoId(p, p.policyRepo.trim())
: `${p.outputDir.trim() || `outputs/train/${jobName}`}/checkpoints/last/pretrained_model`;
$("#policyPath").value = p.policyPath;
persist();
renderCommands();
toast("Using the policy from step 4.");
});
}
/* ══════════════════════════ profiles ══════════════════════════ */
/* Share links carry only what differs from the defaults β€” a full profile is
* ~1.8 kB of JSON, most of it values the receiving page would have anyway. */
function encodeProfile(p) {
const base = defaultProfile(p.name);
const diff = { name: p.name };
for (const [key, value] of Object.entries(p)) {
if (JSON.stringify(value) !== JSON.stringify(base[key])) diff[key] = value;
}
return btoa(unescape(encodeURIComponent(JSON.stringify(diff))));
}
function decodeProfile(encoded) {
const raw = JSON.parse(decodeURIComponent(escape(atob(encoded))));
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
return { ...defaultProfile("Shared setup"), ...raw };
}
function renderProfileSelect() {
const select = $("#profileSelect");
select.textContent = "";
for (const p of store.profiles) select.append(el("option", { value: p.name, textContent: p.name }));
select.value = store.active;
$("#profileDelete").disabled = store.profiles.length <= 1;
}
function uniqueName(base) {
let name = base;
let n = 2;
const taken = new Set(store.profiles.map((p) => p.name));
while (taken.has(name)) name = `${base} ${n++}`;
return name;
}
function switchProfile(name) {
store.active = name;
persist();
renderProfileSelect();
syncFormFromProfile();
}
/* No modal dialogs in here. Both prompt() and confirm() can be unavailable in
* an embedded context (the Hub renders this page in a cross-origin iframe),
* where a blocked modal would make New and Delete silently do nothing. The
* name field renames in place, and Delete asks for a second click instead. */
function wireProfiles() {
const nameInput = $("#profileName");
nameInput.addEventListener("input", () => {
const current = profile();
const typed = nameInput.value.trim();
if (!typed || typed === current.name) return;
// Only claim a unique variant once the typed name actually collides.
const collides = store.profiles.some((x) => x !== current && x.name === typed);
const next = collides ? uniqueName(typed) : typed;
current.name = next;
store.active = next;
persist();
renderProfileSelect();
});
nameInput.addEventListener("blur", () => {
// An emptied field would leave an unnameable profile behind.
if (!nameInput.value.trim()) nameInput.value = profile().name;
});
$("#profileNew").addEventListener("click", () => {
const created = defaultProfile(uniqueName("New setup"));
store.profiles.push(created);
switchProfile(created.name);
nameInput.focus();
nameInput.select();
toast("New profile \u2014 type a name to rename it.");
});
$("#profileDuplicate").addEventListener("click", () => {
const copy = structuredClone(profile());
copy.name = uniqueName(`${copy.name} copy`);
store.profiles.push(copy);
switchProfile(copy.name);
toast(`Duplicated to \u201c${copy.name}\u201d.`);
});
/* Two-click delete: the button becomes its own confirmation. */
const deleteBtn = $("#profileDelete");
let armedFor = null;
const disarm = () => {
armedFor = null;
deleteBtn.textContent = "Delete";
deleteBtn.classList.remove("is-armed");
};
deleteBtn.addEventListener("click", () => {
if (store.profiles.length <= 1) return;
const current = profile();
if (armedFor !== current.name) {
armedFor = current.name;
deleteBtn.textContent = "Click again";
deleteBtn.classList.add("is-armed");
setTimeout(() => {
if (armedFor === current.name) disarm();
}, 4000);
return;
}
store.profiles = store.profiles.filter((x) => x.name !== current.name);
disarm();
switchProfile(store.profiles[0].name);
toast(`Deleted \u201c${current.name}\u201d.`);
});
$("#profileSelect").addEventListener("change", (e) => {
disarm();
switchProfile(e.target.value);
});
$("#profileExport").addEventListener("click", () => {
const blob = new Blob([JSON.stringify(store, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = el("a", { href: url, download: "lerobot-command-builder-profiles.json" });
document.body.append(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
});
$("#profileImport").addEventListener("click", () => $("#profileFile").click());
$("#profileFile").addEventListener("change", async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const data = JSON.parse(await file.text());
const incoming = Array.isArray(data?.profiles) ? data.profiles : [data];
let added = 0;
for (const raw of incoming) {
if (!raw || typeof raw !== "object") continue;
const name = uniqueName(String(raw.name || "Imported"));
store.profiles.push({ ...defaultProfile(name), ...raw, name });
added++;
}
if (added === 0) {
toast("Nothing importable in that file.");
} else {
switchProfile(store.profiles[store.profiles.length - 1].name);
toast(`Imported ${added} profile${added > 1 ? "s" : ""}.`);
}
} catch {
toast("That file is not valid JSON.");
}
e.target.value = "";
});
$("#profileShare").addEventListener("click", async () => {
const url = `${location.origin}${location.pathname}#p=${encodeProfile(profile())}`;
const ok = await copyToClipboard(url, null);
toast(ok ? "Share link copied \u2014 it carries this profile, not your whole list." : "Could not copy the link.");
});
}
/* A profile can arrive in the URL hash, which is how you move a setup between
* machines or browsers without any backend. Pasting a share link while the page
* is already open only changes the fragment β€” a same-document navigation that
* never re-runs this module β€” so the hashchange listener below matters as much
* as the call at boot. */
function importFromHash() {
const match = location.hash.match(/^#p=(.+)$/);
if (!match) return false;
try {
const merged = decodeProfile(match[1]);
if (!merged) return false;
merged.name = uniqueName(String(merged.name || "Shared setup"));
store.profiles.push(merged);
store.active = merged.name;
history.replaceState(null, "", location.pathname);
return true;
} catch {
return false;
}
}
/* ══════════════════════════ theme + nav ══════════════════════════ */
function wireTheme() {
let saved = null;
try {
saved = localStorage.getItem(THEME_KEY);
} catch {
/* blocked β€” fall back to the system preference */
}
const systemDark = () => window.matchMedia("(prefers-color-scheme: dark)").matches;
const apply = (theme) => {
document.documentElement.dataset.theme = theme;
$("#themeIcon").textContent = theme === "dark" ? "β˜€" : "☾";
};
apply(saved || (systemDark() ? "dark" : "light"));
$("#themeToggle").addEventListener("click", () => {
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
apply(next);
try {
localStorage.setItem(THEME_KEY, next);
} catch {
/* nothing to do */
}
});
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
let stored = null;
try {
stored = localStorage.getItem(THEME_KEY);
} catch {
/* ignore */
}
if (!stored) apply(e.matches ? "dark" : "light");
});
}
/*
* Step navigation.
*
* The obvious implementation β€” an IntersectionObserver with a thin rootMargin
* band β€” is wrong here. During a smooth scroll every section between here and
* the destination crosses that band and fires, so the last one to fire wins the
* highlight rather than the one you clicked. Sections also vary from 450px to
* 1200px tall, so a fixed band suits some and not others.
*
* Instead: one reading line just below the two sticky bars, and the active step
* is the last section whose top has passed it. That is unambiguous at any
* scroll position and for any section height. A click owns the highlight until
* its animation settles, so nothing streaming past the line can steal it.
*/
function wireStepNav() {
const entries = [...document.querySelectorAll(".stepnav a")]
.map((link) => ({ link, section: document.querySelector(link.getAttribute("href")) }))
.filter((e) => e.section);
if (entries.length === 0) return;
const topbar = document.querySelector(".topbar");
const stepnav = document.querySelector(".stepnav");
/* Measured, not hardcoded: the bars' heights change with the viewport (the
* topbar drops its subtitle under 900px) and with any future CSS edit. */
const stickyOffset = () => topbar.offsetHeight + stepnav.offsetHeight + 10;
const setActive = (active) => {
for (const { link } of entries) link.classList.toggle("is-active", link === active);
};
let lockedUntil = 0;
const release = () => {
lockedUntil = 0;
};
const syncFromScroll = () => {
if (performance.now() < lockedUntil) return;
const line = stickyOffset();
let current = entries[0];
for (const entry of entries) {
if (entry.section.getBoundingClientRect().top <= line) current = entry;
}
/* The last section is often shorter than the remaining scroll, so its top
* never reaches the line β€” at the bottom of the page it is what you are
* reading regardless. */
const atBottom =
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 4;
setActive(atBottom ? entries[entries.length - 1].link : current.link);
};
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
for (const { link, section } of entries) {
link.addEventListener("click", (event) => {
event.preventDefault();
setActive(link);
// Deliberately no history entry: the URL fragment is reserved for shared
// profiles, and back-button steps through nav clicks are not wanted.
const top = section.getBoundingClientRect().top + window.scrollY - stickyOffset();
const behavior = reduceMotion.matches ? "auto" : "smooth";
lockedUntil = behavior === "smooth" ? performance.now() + 1000 : 0;
window.scrollTo({ top: Math.max(0, top), behavior });
});
}
window.addEventListener("scroll", syncFromScroll, { passive: true });
window.addEventListener("resize", syncFromScroll);
/* Let a real scroll gesture interrupt the animation and take the highlight
* back immediately, rather than waiting out the lock. */
for (const gesture of ["wheel", "touchstart", "keydown"]) {
window.addEventListener(gesture, release, { passive: true });
}
if ("onscrollend" in window) window.addEventListener("scrollend", release);
syncFromScroll();
}
/* ══════════════════════════ boot ══════════════════════════ */
fillGrouped($("#robotType"), ROBOTS);
fillGrouped($("#teleopType"), TELEOPS);
fillFlat($("#policyType"), POLICIES, (i) => `${i.label} β€” ${i.id}`);
fillFlat($("#device"), DEVICES);
fillFlat($("#rolloutStrategy"), ROLLOUT_STRATEGIES);
fillFlat($("#inferenceType"), INFERENCE_BACKENDS);
const shared = importFromHash();
window.addEventListener("hashchange", () => {
if (!importFromHash()) return;
renderProfileSelect();
syncFormFromProfile();
persist();
toast("Loaded a shared setup into a new profile.");
});
wireTheme();
wireForm();
wireProfiles();
wireStepNav();
renderProfileSelect();
syncFormFromProfile();
if (storageBlocked) {
const pill = $("#savedPill");
pill.textContent = "Not saved β€” storage blocked";
pill.dataset.state = "blocked";
pill.title =
"This browser is blocking localStorage for this page. Open the Space directly at its *.hf.space URL, or use Export JSON / Copy share link to keep your setup.";
} else {
persist();
}
if (shared) toast("Loaded a shared setup into a new profile.");