diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cc3cf52c..429724d9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import UpdateNotice from "@/components/UpdateNotice"; import { TooltipProvider } from "@radix-ui/react-tooltip"; import { ApiProvider } from "./contexts/ApiContext"; import { HfAuthProvider } from "./contexts/HfAuthContext"; +import { OnboardingProvider } from "./contexts/OnboardingContext"; const queryClient = new QueryClient(); @@ -33,24 +34,26 @@ function App() { - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> - } /> - - - + } /> + + + + diff --git a/frontend/src/components/jobs/JobsSection.tsx b/frontend/src/components/jobs/JobsSection.tsx index 1ae635b9..04244647 100644 --- a/frontend/src/components/jobs/JobsSection.tsx +++ b/frontend/src/components/jobs/JobsSection.tsx @@ -245,7 +245,7 @@ const JobsSection: React.FC = () => { untrackedHubInactive.length; return ( -
+

Jobs

diff --git a/frontend/src/components/landing/LandingTopBar.tsx b/frontend/src/components/landing/LandingTopBar.tsx index c8646927..a36321f6 100644 --- a/frontend/src/components/landing/LandingTopBar.tsx +++ b/frontend/src/components/landing/LandingTopBar.tsx @@ -1,5 +1,6 @@ import React from "react"; import HfAuthChip from "./HfAuthChip"; +import TourLauncher from "@/components/onboarding/TourLauncher"; const LandingTopBar: React.FC = () => { return ( @@ -15,7 +16,10 @@ const LandingTopBar: React.FC = () => { LeLab
- +
+ + +
); diff --git a/frontend/src/components/landing/RobotTile.tsx b/frontend/src/components/landing/RobotTile.tsx index f2ffa55f..f8790c98 100644 --- a/frontend/src/components/landing/RobotTile.tsx +++ b/frontend/src/components/landing/RobotTile.tsx @@ -47,7 +47,7 @@ const RobotTile: React.FC = ({ return (
-
+
= ({ className="h-8 w-8 text-gray-300 hover:text-white" onClick={() => onConfigure(robot.name)} aria-label="Configure" + data-tour="robot-configure" > @@ -102,7 +103,7 @@ const RobotTile: React.FC = ({ {robot && ( -
+
+ +
+
+

+ Step {stepIndex + 1} of {totalSteps} +

+ {currentComplete && ( + + + Done + + )} +
+

+ {currentStep.title} +

+

+ {currentStep.body} +

+ + {currentGated && !currentComplete && ( +

+ + Finish the earlier step to unlock this, or continue with Next + whenever you like. +

+ )} + +
+ +
+ {currentStep.optional && !isLast && ( + + )} + +
+
+
+
+ ); +}; + +export default TourCard; diff --git a/frontend/src/components/onboarding/TourLauncher.tsx b/frontend/src/components/onboarding/TourLauncher.tsx new file mode 100644 index 00000000..041d059e --- /dev/null +++ b/frontend/src/components/onboarding/TourLauncher.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { HelpCircle } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useOnboarding } from "@/contexts/OnboardingContext"; + +interface TourLauncherProps { + /** "floating" pins a round button to the corner on every page; "inline" is + * a compact button for the landing top bar. */ + variant?: "floating" | "inline"; + className?: string; +} + +const TourLauncher: React.FC = ({ + variant = "inline", + className, +}) => { + const { start, isActive } = useOnboarding(); + + // The floating button would only get in the way while the tour is running. + if (variant === "floating" && isActive) return null; + + if (variant === "floating") { + return ( + + ); + } + + return ( + + ); +}; + +export default TourLauncher; diff --git a/frontend/src/components/onboarding/WelcomeDialog.tsx b/frontend/src/components/onboarding/WelcomeDialog.tsx new file mode 100644 index 00000000..17c839f4 --- /dev/null +++ b/frontend/src/components/onboarding/WelcomeDialog.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Compass, Route } from "lucide-react"; + +interface WelcomeDialogProps { + open: boolean; + onStart: () => void; + onDismiss: () => void; +} + +// The stages a newcomer will walk through, shown as a quick preview so the +// tour's value is clear before they commit two minutes to it. +const STAGES = ["Calibrate", "Record", "Train", "Run"]; + +const WelcomeDialog: React.FC = ({ + open, + onStart, + onDismiss, +}) => { + return ( + (next ? undefined : onDismiss())}> + + + + + Welcome to LeLab + + + New to teaching a robot arm? A two-minute guided tour walks you + through the whole loop, no jargon required. + + + +
+
+ {STAGES.map((stage, i) => ( + + + {stage} + + {i < STAGES.length - 1 && ( + + )} + + ))} +
+
+ +
+ + +
+
+
+ ); +}; + +export default WelcomeDialog; diff --git a/frontend/src/contexts/OnboardingContext.tsx b/frontend/src/contexts/OnboardingContext.tsx new file mode 100644 index 00000000..88baa7a3 --- /dev/null +++ b/frontend/src/contexts/OnboardingContext.tsx @@ -0,0 +1,251 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { ONBOARDING_STEPS, TourStep } from "@/lib/onboardingSteps"; +import { useOnboardingProgress } from "@/hooks/useOnboardingProgress"; +import { isHostedSpace } from "@/lib/isHostedSpace"; +import SpotlightOverlay from "@/components/onboarding/SpotlightOverlay"; +import TourLauncher from "@/components/onboarding/TourLauncher"; +import WelcomeDialog from "@/components/onboarding/WelcomeDialog"; + +// Persisted across sessions, mirroring the useUpdateCheck pattern. The `-v1` +// suffix lets a future revamp re-offer the tour to everyone by bumping it. +const STORAGE_KEY = "lelab:onboarding-v1"; +// Delay before auto-advancing once a step's goal is met, so the user sees the +// "done" state register before moving on. +const AUTO_ADVANCE_MS = 1100; + +type OnboardingStatus = "dismissed" | "completed"; + +const readStatus = (): OnboardingStatus | null => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw === "dismissed" || raw === "completed" ? raw : null; + } catch { + return null; + } +}; + +const writeStatus = (status: OnboardingStatus) => { + try { + localStorage.setItem(STORAGE_KEY, status); + } catch { + // Storage may be unavailable (private mode, quota); nothing to persist. + } +}; + +interface OnboardingContextValue { + isActive: boolean; + currentStep: TourStep | null; + /** 0-based index of the current step among the steps visible to this user. */ + stepIndex: number; + totalSteps: number; + /** True when the current step's goal has been achieved. */ + currentComplete: boolean; + /** True when the current step's prerequisite is not met yet (info only). */ + currentGated: boolean; + /** True once the user has finished or dismissed the tour at least once. */ + hasSeen: boolean; + start: () => void; + next: () => void; + back: () => void; + /** Advance past an optional step without performing its action. */ + skipStep: () => void; + /** Leave the tour. `completed` records how it ended for the persisted flag. */ + stop: (completed: boolean) => void; +} + +const OnboardingContext = createContext(null); + +export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const navigate = useNavigate(); + const location = useLocation(); + + const [isActive, setIsActive] = useState(false); + const [currentStepId, setCurrentStepId] = useState(null); + const [hasSeen, setHasSeen] = useState(() => readStatus() !== null); + const [showWelcome, setShowWelcome] = useState(false); + + const progress = useOnboardingProgress(isActive); + + // Steps whose `show` predicate excludes this user drop out — but the current + // step is always kept so it can never vanish mid-view and strand the tour. + const visibleSteps = useMemo( + () => + ONBOARDING_STEPS.filter( + (s) => s.id === currentStepId || !s.show || s.show(progress) + ), + [progress, currentStepId] + ); + + const stepIndex = currentStepId + ? visibleSteps.findIndex((s) => s.id === currentStepId) + : -1; + const currentStep = + isActive && stepIndex >= 0 ? visibleSteps[stepIndex] : null; + + const currentComplete = currentStep?.isComplete + ? currentStep.isComplete(progress) + : false; + const currentGated = currentStep?.gate ? !currentStep.gate(progress) : false; + + const stop = useCallback((completed: boolean) => { + setIsActive(false); + setCurrentStepId(null); + writeStatus(completed ? "completed" : "dismissed"); + setHasSeen(true); + }, []); + + const start = useCallback(() => { + setShowWelcome(false); + setCurrentStepId(ONBOARDING_STEPS[0]?.id ?? null); + setIsActive(true); + }, []); + + const dismissWelcome = useCallback(() => { + setShowWelcome(false); + writeStatus("dismissed"); + setHasSeen(true); + }, []); + + // Offer the tour once, on the first visit only. Suppressed on the hosted HF + // Space, where UsageInstructionsModal owns the first-run moment (a + // non-dismissible install prompt) — the two must never stack. The corner + // launcher stays available everywhere regardless. + useEffect(() => { + if (readStatus() === null && !isHostedSpace()) { + setShowWelcome(true); + } + }, []); + + const next = useCallback(() => { + const idx = visibleSteps.findIndex((s) => s.id === currentStepId); + if (idx < 0) return; + if (idx >= visibleSteps.length - 1) { + stop(true); + return; + } + setCurrentStepId(visibleSteps[idx + 1].id); + }, [visibleSteps, currentStepId, stop]); + + const back = useCallback(() => { + const idx = visibleSteps.findIndex((s) => s.id === currentStepId); + if (idx > 0) setCurrentStepId(visibleSteps[idx - 1].id); + }, [visibleSteps, currentStepId]); + + const skipStep = next; + + // Navigate to a step's route once, when that step is entered — never during + // render. We deliberately do NOT re-navigate on later location changes, so a + // user who clicks away mid-step isn't yanked back (the spotlight just falls + // back to a centered card). The calibration page needs a robot_name in + // navigation state to show the right robot's controls, so we forward the + // currently-selected robot (persisted by useRobots). + const lastNavStepId = useRef(null); + useEffect(() => { + if (!isActive || !currentStep) { + lastNavStepId.current = null; + return; + } + if (lastNavStepId.current === currentStep.id) return; + lastNavStepId.current = currentStep.id; + if (location.pathname === currentStep.route) return; + if (currentStep.route === "/calibration") { + let robotName: string | null = null; + try { + robotName = localStorage.getItem("lelab.selectedRobot"); + } catch { + // Storage unavailable — fall through to a plain navigation. + } + navigate( + "/calibration", + robotName ? { state: { robot_name: robotName } } : undefined + ); + return; + } + navigate(currentStep.route); + }, [isActive, currentStep, location.pathname, navigate]); + + // Auto-advance when a step's goal is met — but only on a false->true + // transition while the step is shown, so returning to an already-complete + // step via Back doesn't immediately bounce forward again. + const autoRef = useRef<{ id: string | null; done: boolean }>({ + id: null, + done: false, + }); + useEffect(() => { + if (!isActive || !currentStep) return; + if (autoRef.current.id !== currentStep.id) { + // Entering a (possibly already-complete) step: set the baseline and + // never auto-advance out of a step that was already done on arrival. + autoRef.current = { id: currentStep.id, done: currentComplete }; + return; + } + if (!autoRef.current.done && currentComplete) { + autoRef.current.done = true; + const t = setTimeout(() => next(), AUTO_ADVANCE_MS); + return () => clearTimeout(t); + } + }, [isActive, currentStep, currentComplete, next]); + + const value = useMemo( + () => ({ + isActive, + currentStep, + stepIndex, + totalSteps: visibleSteps.length, + currentComplete, + currentGated, + hasSeen, + start, + next, + back, + skipStep, + stop, + }), + [ + isActive, + currentStep, + stepIndex, + visibleSteps.length, + currentComplete, + currentGated, + hasSeen, + start, + next, + back, + skipStep, + stop, + ] + ); + + return ( + + {children} + + + + + ); +}; + +export const useOnboarding = (): OnboardingContextValue => { + const ctx = useContext(OnboardingContext); + if (!ctx) { + throw new Error("useOnboarding must be used within an OnboardingProvider"); + } + return ctx; +}; diff --git a/frontend/src/hooks/useOnboardingProgress.ts b/frontend/src/hooks/useOnboardingProgress.ts new file mode 100644 index 00000000..ed996262 --- /dev/null +++ b/frontend/src/hooks/useOnboardingProgress.ts @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useApi } from "@/contexts/ApiContext"; +import { useHfAuth } from "@/contexts/HfAuthContext"; +import { listLocalDatasets } from "@/lib/replayApi"; +import { listJobs } from "@/lib/jobsApi"; +import type { RobotRecord } from "@/hooks/useRobots"; +import type { ProgressSnapshot } from "@/lib/onboardingSteps"; + +// Matches the key useRobots persists the selected robot under. +const SELECTED_KEY = "lelab.selectedRobot"; +// The tour only needs to notice progress within a few seconds; a gentle cadence +// keeps load off the backend, and polling pauses entirely while the tab is +// hidden. +const POLL_MS = 3000; + +const EMPTY: ProgressSnapshot = { + hasSelectedRobot: false, + robotIsClean: false, + hasLocalDataset: false, + hasTrainedModel: false, + isAuthenticated: false, +}; + +/** + * A read-only snapshot of how far the user has actually gotten, derived from + * existing endpoints. It only fetches while the tour is active, so it adds + * zero cost when the tour is closed. Deliberately does NOT reuse useRobots + * (which refetches per route and owns selection) — a second instance would + * double traffic and race the selection state. + * + * Only cheap local endpoints are polled — /robots, /datasets?scope=local (a + * filesystem scan, no Hub call), and /jobs — so the tour never adds Hugging + * Face Hub API load. Tracked cloud jobs already surface in /jobs, and auth + * comes from the existing HfAuth context. + */ +export function useOnboardingProgress(active: boolean): ProgressSnapshot { + const { baseUrl, fetchWithHeaders } = useApi(); + const { auth } = useHfAuth(); + const [snapshot, setSnapshot] = useState(EMPTY); + const isAuthenticated = auth.status === "authenticated"; + + const refresh = useCallback(async () => { + let selectedName: string | null = null; + try { + selectedName = localStorage.getItem(SELECTED_KEY); + } catch { + // Storage unavailable — treat as no selection. + } + + const [robotsBody, datasets, jobs] = await Promise.all([ + fetchWithHeaders(`${baseUrl}/robots`) + .then((r) => (r.ok ? r.json() : null)) + .catch(() => null), + listLocalDatasets(baseUrl, fetchWithHeaders).catch(() => []), + listJobs(baseUrl, fetchWithHeaders, 20).catch(() => []), + ]); + + const records: RobotRecord[] = robotsBody?.robots ?? []; + const selected = selectedName + ? records.find((r) => r.name === selectedName) ?? null + : null; + + setSnapshot({ + hasSelectedRobot: !!selected, + robotIsClean: !!selected?.is_clean, + hasLocalDataset: datasets.some( + (d) => d.source === "local" || d.source === "both" + ), + hasTrainedModel: jobs.some( + (j) => j.checkpoint_count > 0 || j.state === "done" + ), + isAuthenticated, + }); + }, [baseUrl, fetchWithHeaders, isAuthenticated]); + + // Keep the latest refresh in a ref so the polling interval never tears down. + const refreshRef = useRef(refresh); + refreshRef.current = refresh; + + useEffect(() => { + if (!active) { + setSnapshot(EMPTY); + return; + } + let cancelled = false; + let inFlight = false; + const tick = async () => { + // Skip while backgrounded, and never let ticks pile up if one is slow. + if (cancelled || inFlight || document.visibilityState === "hidden") return; + inFlight = true; + try { + await refreshRef.current(); + } finally { + inFlight = false; + } + }; + tick(); + const id = setInterval(tick, POLL_MS); + // Refresh promptly when the user returns to the tab. + const onVisible = () => { + if (document.visibilityState === "visible") tick(); + }; + document.addEventListener("visibilitychange", onVisible); + return () => { + cancelled = true; + clearInterval(id); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [active]); + + return snapshot; +} diff --git a/frontend/src/lib/onboardingSteps.ts b/frontend/src/lib/onboardingSteps.ts new file mode 100644 index 00000000..88d8f75d --- /dev/null +++ b/frontend/src/lib/onboardingSteps.ts @@ -0,0 +1,141 @@ +// Declarative model for the beginner onboarding tour. Each step points the +// spotlight at a real element (by its `data-tour` attribute) on a given route +// and explains, in plain language, one stage of the record -> train -> run loop. +// +// `gate` / `isComplete` read a live snapshot of how far the user has actually +// gotten (see useOnboardingProgress) so the tour can adapt and auto-advance. +// Both are optional: a step without them is a simple click-through. + +export type Placement = "top" | "bottom" | "left" | "right" | "center"; + +// A read-only snapshot of the user's real progress, derived entirely from +// existing endpoints (/robots, /datasets, /jobs, /hf-auth-status). +export interface ProgressSnapshot { + hasSelectedRobot: boolean; + robotIsClean: boolean; + hasLocalDataset: boolean; + hasTrainedModel: boolean; + isAuthenticated: boolean; +} + +export interface TourStep { + /** Stable id, also used as the log/debug label. */ + id: string; + /** Route this step lives on; the tour navigates here before showing it. */ + route: string; + /** `data-tour` value of the element to spotlight. Omit for a centered card. */ + target?: string; + title: string; + body: string; + placement?: Placement; + /** Optional steps can be skipped without doing the action (e.g. no hardware). */ + optional?: boolean; + /** When false, the step's action isn't possible yet (informational only). */ + gate?: (s: ProgressSnapshot) => boolean; + /** When true, the user has done this step's action; enables auto-advance. */ + isComplete?: (s: ProgressSnapshot) => boolean; + /** When present and false, the step is skipped entirely for this user. */ + show?: (s: ProgressSnapshot) => boolean; +} + +export const ONBOARDING_STEPS: TourStep[] = [ + { + id: "welcome", + route: "/", + placement: "center", + title: "Welcome to LeLab", + body: "This quick tour walks you from a fresh setup to a robot that runs a policy you trained yourself: calibrate, record, train, run. It takes about two minutes, and you can leave anytime with Esc.", + }, + { + id: "pick-robot", + route: "/", + target: "robot-selector", + placement: "bottom", + title: "Pick or name your arm", + body: "Start here. Choose an existing robot, or type a new name to create one. Everything else in the tour hangs off the arm you select.", + isComplete: (s) => s.hasSelectedRobot, + }, + { + id: "calibrate", + route: "/calibration", + target: "calibration-start", + placement: "bottom", + title: "Calibrate the arms", + body: "Calibration teaches the app each joint's range of motion so movements map correctly. Run it once for the leader arm, then the follower. Press Start and follow the on-screen steps.", + optional: true, + isComplete: (s) => s.robotIsClean, + }, + { + id: "cameras", + route: "/calibration", + target: "calibration-cameras", + placement: "top", + title: "Add cameras (if your task needs sight)", + body: "If the robot has to see what it's doing, add one or more cameras here. They're saved with this robot and reused when you record. Skip this if your task doesn't need vision.", + optional: true, + }, + { + id: "teleop", + route: "/", + target: "robot-teleop", + placement: "top", + title: "Try teleoperation (optional)", + body: "Drive the follower arm by moving the leader. It's a good way to confirm calibration feels right before you record anything. This button unlocks once the arm is calibrated.", + optional: true, + gate: (s) => s.robotIsClean, + }, + { + id: "record", + route: "/", + target: "dataset-picker", + placement: "bottom", + title: "Record a dataset", + body: "A dataset is a set of episodes: one attempt at your task each. Episode time is how long each attempt runs; reset time is the pause to reposition objects between attempts. Aim for 10 to 50 clean episodes to start.", + gate: (s) => s.robotIsClean, + isComplete: (s) => s.hasLocalDataset, + }, + { + id: "train", + route: "/", + target: "training-entry", + placement: "bottom", + title: "Train a policy", + body: "Turn your recorded episodes into a policy the robot can run. ACT is fast and a great first choice; SmolVLA is a larger vision-language model. You can train on your own GPU, or rent one in the cloud with a Hugging Face login.", + gate: (s) => s.hasLocalDataset, + isComplete: (s) => s.hasTrainedModel, + }, + { + id: "watch-training", + route: "/", + target: "jobs-section", + placement: "top", + title: "Watch it train", + body: "Your training job shows up here with live progress. Once it has a usable checkpoint, a green play button appears on the model so you can run it.", + }, + { + id: "inference", + route: "/", + target: "jobs-section", + placement: "top", + title: "Run your policy", + body: "Press the play button on a finished model to let the robot attempt the task on its own, using what it learned from your episodes. This is the payoff of the whole loop.", + optional: true, + }, + { + id: "upload", + route: "/", + target: "training-entry", + placement: "bottom", + title: "Share on the Hub (optional)", + body: "Log in to Hugging Face to push your datasets and trained models to the Hub, so you can back them up, share them, or train in the cloud.", + optional: true, + show: (s) => !s.isAuthenticated, + }, + { + id: "done", + route: "/", + placement: "center", + title: "That's the whole loop", + body: "Calibrate, record, train, run. You can reopen this tour anytime from the ? button in the corner. Happy building.", + }, +]; diff --git a/frontend/src/lib/replayApi.ts b/frontend/src/lib/replayApi.ts index 44b29c91..dadcf8ae 100644 --- a/frontend/src/lib/replayApi.ts +++ b/frontend/src/lib/replayApi.ts @@ -19,3 +19,19 @@ export async function listDatasets( action: "List datasets", }); } + +/** + * Local-cache datasets only — a pure filesystem scan on the backend, with no + * Hugging Face Hub call. For lightweight callers (e.g. the onboarding poll) + * that only need to know what exists on disk. + */ +export async function listLocalDatasets( + baseUrl: string, + fetcher: Fetcher, + signal?: AbortSignal, +): Promise { + return apiRequest(baseUrl, fetcher, "/datasets?scope=local", { + signal, + action: "List local datasets", + }); +} diff --git a/frontend/src/pages/Calibration.tsx b/frontend/src/pages/Calibration.tsx index acf285bc..2be17b8c 100644 --- a/frontend/src/pages/Calibration.tsx +++ b/frontend/src/pages/Calibration.tsx @@ -623,6 +623,7 @@ const Calibration = () => { onClick={handleStartCalibration} className="w-full bg-blue-600 hover:bg-blue-700 text-white rounded-full py-6 text-lg" disabled={!robotName || !deviceType || !port} + data-tour="calibration-start" > Start Calibration @@ -904,7 +905,10 @@ const Calibration = () => {
{robotName && ( - + diff --git a/frontend/src/pages/Landing.tsx b/frontend/src/pages/Landing.tsx index 33832569..5b047647 100644 --- a/frontend/src/pages/Landing.tsx +++ b/frontend/src/pages/Landing.tsx @@ -241,7 +241,10 @@ const Landing = () => { deleteRobot={deleteRobot} />
-
+

Dataset

@@ -266,7 +269,10 @@ const Landing = () => {
-
+

Create a model

diff --git a/lelab/datasets.py b/lelab/datasets.py index b34c85c3..a93fd20e 100644 --- a/lelab/datasets.py +++ b/lelab/datasets.py @@ -133,6 +133,16 @@ def list_user_datasets() -> list[dict[str, Any]]: return out +def list_local_datasets_with_source() -> list[dict[str, Any]]: + """Local-cache datasets tagged with source="local" — a pure filesystem scan. + + Same entry shape as `list_all_datasets`, but never contacts the Hub. For + lightweight callers that only need to know what exists on disk (e.g. a + poller) and must not add Hub API load. + """ + return [{**d, "source": "local"} for d in list_local_datasets()] + + def list_all_datasets() -> list[dict[str, Any]]: """Merged listing: Hub datasets + local cache, with `source` field. diff --git a/lelab/server.py b/lelab/server.py index 519e0c72..5b5fb4d3 100644 --- a/lelab/server.py +++ b/lelab/server.py @@ -379,11 +379,15 @@ def hf_auth_login(body: HfLoginBody): @app.get("/datasets") -def datasets_list(): +def datasets_list(scope: str = "all"): """List datasets available to the user — Hub-owned + local cache. - Each entry carries a `source` field: "local", "hub", or "both". + Each entry carries a `source` field: "local", "hub", or "both". Pass + `scope=local` for a local-only listing (a pure filesystem scan, no Hub + call) — for lightweight pollers that only need what exists on disk. """ + if scope == "local": + return dataset_browser.list_local_datasets_with_source() return dataset_browser.list_all_datasets() diff --git a/tests/test_datasets.py b/tests/test_datasets.py index 2b7cf7f0..c4e20b18 100644 --- a/tests/test_datasets.py +++ b/tests/test_datasets.py @@ -86,6 +86,22 @@ def test_list_user_datasets_returns_empty_when_not_logged_in( assert list_user_datasets() == [] +def test_list_local_datasets_with_source_tags_local_without_hub( + tmp_lerobot_home: Path, +) -> None: + from lelab.datasets import list_local_datasets_with_source + + _make_dataset(tmp_lerobot_home, "pusht") + + # The local-only listing must never consult the Hub. + with patch("lelab.datasets.list_user_datasets") as hub: + result = list_local_datasets_with_source() + hub.assert_not_called() + + by_id = {d["repo_id"]: d for d in result} + assert by_id["pusht"]["source"] == "local" + + def test_list_all_datasets_merges_hub_and_local( tmp_lerobot_home: Path, ) -> None: diff --git a/tests/test_server.py b/tests/test_server.py index 8b10033f..cd2a8d45 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -248,3 +248,32 @@ def boom(source, name=None): resp = client.post("/jobs/import", json={"source": "/tmp/x"}) assert resp.status_code == 400 assert "No usable model" in resp.json()["detail"] + + +def test_datasets_local_scope_skips_hub_merge(client, monkeypatch) -> None: + """`?scope=local` returns the local-only listing and never runs the + Hub-merging path (which would issue a Hugging Face API call).""" + from lelab import datasets as datasets_mod + + local = [{"repo_id": "pusht", "last_modified": None, "private": False, "source": "local"}] + + def boom() -> list: + raise AssertionError("list_all_datasets must not run for scope=local") + + monkeypatch.setattr(datasets_mod, "list_local_datasets_with_source", lambda: local) + monkeypatch.setattr(datasets_mod, "list_all_datasets", boom) + + resp = client.get("/datasets?scope=local") + assert resp.status_code == 200 + assert resp.json() == local + + +def test_datasets_default_scope_uses_merged_listing(client, monkeypatch) -> None: + from lelab import datasets as datasets_mod + + merged = [{"repo_id": "x", "last_modified": None, "private": False, "source": "both"}] + monkeypatch.setattr(datasets_mod, "list_all_datasets", lambda: merged) + + resp = client.get("/datasets") + assert resp.status_code == 200 + assert resp.json() == merged