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 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); function argHtml(arg) { if (arg.raw !== undefined) return `${escapeHtml(arg.raw)}`; return `${escapeHtml(arg.flag)}=${escapeHtml( quoteValue(arg.value, arg.quoting), )}`; } /* 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 `# ${escapeHtml(line.comment)}`; const parts = [`${escapeHtml(line.prog)}`, ...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`], ``)); args.push(or(`--${ns}.right_arm_config.port`, p[`${K}PortRight`], ``)); 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`], "")); break; case "robot_ip": args.push(or(`--${ns}.robot_ip`, p[`${K}RobotIp`], "")); break; case "remote_ip": args.push(or(`--${ns}.remote_ip`, p[`${K}RemoteIp`], "")); break; case "sdk_url": args.push(or(`--${ns}.sdk_url`, p[`${K}SdkUrl`], "")); break; case "phone_os": args.push(A(`--${ns}.phone_os`, p[`${K}PhoneOs`])); break; } } return args; } /** Ordered `--robot.type --robot.id ` 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() || ""); 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: `${robot?.id ?? "This robot"} does not need calibration through the LeRobot CLI.`, lines: [], }; blocks.calibrate_teleop = teleop?.calibrate ? { label: "Calibrate the leader", note: "lerobot-calibrate 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: `${teleop?.id ?? "This teleoperator"} 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 || "/"), A("--dataset.single_task", p.taskDesc || "", "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: --dataset.num_episodes is the number of additional episodes, not the new total. You also need --dataset.root=<local path> pointing at the existing dataset on disk." : "During recording: → save the episode and move on, ← redo it, ESC 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 || "/"), 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 || "/"), A("--dataset.episode", "0"), ], }, ], }; /* ── step 4: train ── */ const trainDs = p.trainDataset.trim() ? repoId(p, p.trainDataset.trim()) : dataset || "/"; 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 --job.detach=true to submit and exit." : "Long run? Prefix with nohup or run it inside tmux 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 --config_path 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()) : "/"; rolloutArgs.push(A("--dataset.repo_id", rd)); rolloutArgs.push(A("--dataset.single_task", p.taskDesc || "", "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 || "", "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 s to keep the last few seconds, h 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(`${teleop.id} is not a usual pairing for ${robot.id}. 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 --job.target to run on HF Jobs instead."); } box.hidden = msgs.length === 0; box.innerHTML = msgs.length ? `
    ${msgs.map((m) => `
  • ${m}
  • `).join("")}
` : ""; } /* 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 ${extras.join(", ")} extra${extras.length > 1 ? "s" : ""} — pip install 'lerobot[${extras.join(",")}]'` : ""; 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: "×", 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.");