Download app.js from nikodembartnik/lerobot-command-builder: direct link, hf CLI and curl.
- Browser
- Download file 50.8 kB
-
https://huggingface.co/spaces/nikodembartnik/lerobot-command-builder/resolve/main/app.js
- Command line
-
hf download hf://spaces/nikodembartnik/lerobot-command-builder/app.js
-
curl -L -o app.js https://huggingface.co/spaces/nikodembartnik/lerobot-command-builder/resolve/main/app.js
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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=<local path></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: "×", 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."); | |