diff --git a/studio/frontend/src/features/export/export-page.tsx b/studio/frontend/src/features/export/export-page.tsx
index 0ba0c817a8..09c6514b65 100644
--- a/studio/frontend/src/features/export/export-page.tsx
+++ b/studio/frontend/src/features/export/export-page.tsx
@@ -8,8 +8,9 @@ import {
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { SectionCard } from "@/components/section-card";
-import { MODELS } from "@/config/training";
+import { findModelById } from "@/config/training";
import { useWizardStore } from "@/stores/training";
+import { isAdapterMethod } from "@/types/training";
import {
Tooltip,
TooltipContent,
@@ -19,6 +20,8 @@ import { InformationCircleIcon, PackageIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion } from "motion/react";
import { useMemo, useState } from "react";
+import { useShallow } from "zustand/react/shallow";
+import { collapseAnim } from "./anim";
import { ExportDialog } from "./components/export-dialog";
import { MethodPicker } from "./components/method-picker";
import { QuantPicker } from "./components/quant-picker";
@@ -29,25 +32,27 @@ import {
getEstimatedSize,
} from "./constants";
-const anim = {
- initial: { height: 0, opacity: 0 },
- animate: { height: "auto" as const, opacity: 1 },
- exit: { height: 0, opacity: 0 },
- transition: { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] as const },
-};
-
export function ExportPage() {
- const store = useWizardStore();
- const isAdapter = store.trainingMethod === "lora" || store.trainingMethod === "qlora";
- const modelInfo = useMemo(
- () => MODELS.find((m) => m.id === store.selectedModel),
- [store.selectedModel],
- );
+ const { trainingMethod, selectedModel, saveSteps, trainingMetrics, epochs, loraRank, hfToken, setHfToken } =
+ useWizardStore(
+ useShallow((s) => ({
+ trainingMethod: s.trainingMethod,
+ selectedModel: s.selectedModel,
+ saveSteps: s.saveSteps,
+ trainingMetrics: s.trainingMetrics,
+ epochs: s.epochs,
+ loraRank: s.loraRank,
+ hfToken: s.hfToken,
+ setHfToken: s.setHfToken,
+ })),
+ );
+ const isAdapter = isAdapterMethod(trainingMethod);
+ const modelInfo = useMemo(() => findModelById(selectedModel), [selectedModel]);
const checkpoints = useMemo(() => {
if (isAdapter) {
- const interval = store.saveSteps > 0 ? store.saveSteps : 100;
- const total = store.trainingMetrics?.totalSteps ?? 500;
+ const interval = saveSteps > 0 ? saveSteps : 100;
+ const total = trainingMetrics?.totalSteps ?? 500;
const entries: { value: string; label: string; detail: string }[] = [];
for (let step = interval; step <= total; step += interval) {
const loss = (1.5 - (step / total) * 0.7 + Math.random() * 0.05).toFixed(2);
@@ -60,7 +65,7 @@ export function ExportPage() {
return entries.reverse();
}
return [{ value: "final-model", label: "Final Model", detail: "Full fine-tuned weights" }];
- }, [isAdapter, store.saveSteps, store.trainingMetrics?.totalSteps]);
+ }, [isAdapter, saveSteps, trainingMetrics?.totalSteps]);
const [checkpoint, setCheckpoint] = useState
(null);
const [exportMethod, setExportMethod] = useState(null);
@@ -79,7 +84,7 @@ export function ExportPage() {
const estimatedSize = getEstimatedSize(exportMethod, quantLevels);
const canExport = checkpoint && exportMethod && (exportMethod !== "gguf" || quantLevels.length > 0);
- const baseModelName = modelInfo?.name ?? store.selectedModel ?? "—";
+ const baseModelName = modelInfo?.name ?? selectedModel ?? "—";
return (
@@ -141,7 +146,7 @@ export function ExportPage() {
Method
- {METHOD_LABELS[store.trainingMethod] ?? store.trainingMethod}
+ {METHOD_LABELS[trainingMethod] ?? trainingMethod}
Checkpoints
@@ -149,12 +154,12 @@ export function ExportPage() {
Epochs
- {store.epochs}
+ {epochs}
{isAdapter && (
LoRA Rank
- {store.loraRank}
+ {loraRank}
)}
{modelInfo?.params && (
@@ -186,7 +191,7 @@ export function ExportPage() {
{exportMethod === "gguf" && (
-
+
)}
@@ -220,8 +225,8 @@ export function ExportPage() {
onHfUsernameChange={setHfUsername}
modelName={modelName}
onModelNameChange={setModelName}
- hfToken={store.hfToken}
- onHfTokenChange={store.setHfToken}
+ hfToken={hfToken}
+ onHfTokenChange={setHfToken}
privateRepo={privateRepo}
onPrivateRepoChange={setPrivateRepo}
/>
diff --git a/studio/frontend/src/features/onboarding/components/steps/dataset-step.tsx b/studio/frontend/src/features/onboarding/components/steps/dataset-step.tsx
index a5d07c638e..291e065249 100644
--- a/studio/frontend/src/features/onboarding/components/steps/dataset-step.tsx
+++ b/studio/frontend/src/features/onboarding/components/steps/dataset-step.tsx
@@ -26,13 +26,15 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DATASETS } from "@/config/training";
-import { cn } from "@/lib/utils";
+import { useDebouncedValue, useHfDatasetSearch, useInfiniteScroll } from "@/hooks";
+import { cn, formatCompact } from "@/lib/utils";
import { useWizardStore } from "@/stores/training";
import type { DatasetFormat } from "@/types/training";
import {
@@ -42,7 +44,7 @@ import {
Upload04Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import { useMemo, useRef } from "react";
+import { useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
const FORMAT_OPTIONS: { value: DatasetFormat; label: string }[] = [
@@ -79,26 +81,56 @@ export function DatasetStep() {
})),
);
- const sortedDatasets = useMemo(
- () =>
- // Sort recommended first
- [...DATASETS].sort(
- (a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0),
- ),
+ const [inputValue, setInputValue] = useState("");
+ const debouncedQuery = useDebouncedValue(inputValue);
+ const { results: hfResults, isLoading, isLoadingMore, hasMore, fetchMore } = useHfDatasetSearch(debouncedQuery, {
+ accessToken: hfToken || undefined,
+ });
+
+ const curatedDatasets = useMemo(
+ () => [...DATASETS].sort((a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0)),
[],
);
- const selectedDatasetData = DATASETS.find((d) => d.id === dataset);
+ const datasetMap = useMemo(() => {
+ const map = new Map();
+ for (const d of curatedDatasets) {
+ map.set(d.id, { label: d.name, description: d.description, size: d.size, recommended: d.recommended });
+ }
+ for (const r of hfResults) {
+ if (!map.has(r.id)) {
+ map.set(r.id, { label: r.id, downloads: r.downloads, totalExamples: r.totalExamples, sizeCategory: r.sizeCategory });
+ }
+ }
+ return map;
+ }, [curatedDatasets, hfResults]);
+
+ const displayIds = useMemo(() => {
+ if (!debouncedQuery.trim()) {
+ return curatedDatasets.map((d) => d.id);
+ }
+ const q = debouncedQuery.toLowerCase();
+ const curatedIds = curatedDatasets
+ .filter((d) => d.name.toLowerCase().includes(q) || d.id.toLowerCase().includes(q))
+ .map((d) => d.id);
+ const liveIds = hfResults.map((r) => r.id).filter((id) => !curatedIds.includes(id));
+ return [...curatedIds, ...liveIds];
+ }, [debouncedQuery, curatedDatasets, hfResults]);
+
+ const allIds = useMemo(
+ () => [...new Set([...curatedDatasets.map((d) => d.id), ...hfResults.map((r) => r.id)])],
+ [curatedDatasets, hfResults],
+ );
+
const comboboxAnchorRef = useRef(null);
+ const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore);
const handleFileUpload = () => {
- // Mock file upload
setUploadedFile("my_dataset.jsonl");
};
return (
- {/* Source Toggle */}
Source
@@ -163,48 +195,71 @@ export function DatasetStep() {
Search datasets
d.name)}
- value={selectedDatasetData?.name ?? null}
- onValueChange={(name) => {
- const ds = sortedDatasets.find((d) => d.name === name);
- if (ds) {
- setDataset(ds.id);
- }
- }}
+ items={allIds}
+ filteredItems={displayIds}
+ filter={null}
+ value={dataset}
+ onValueChange={(id) => setDataset(id)}
+ onInputValueChange={(val) => setInputValue(val)}
+ itemToStringValue={(id) => datasetMap.get(id)?.label ?? id}
autoHighlight={true}
>
-
+
- No datasets found
-
- {(name: string) => {
- const ds = sortedDatasets.find((d) => d.name === name);
- return (
-
-
-
{name}
- {ds && (
-
- {ds.description}
+ {isLoading ? (
+ Searching…
+ ) : (
+ No datasets found
+ )}
+
+
+ {(id: string) => {
+ const meta = datasetMap.get(id);
+ const label = meta?.label ?? id;
+ const rowLabel = meta?.size ?? (meta?.totalExamples ? `${formatCompact(meta.totalExamples)} rows` : null);
+ return (
+
+
+
+
+ {label}
+ {meta?.description && (
+ {meta.description}
+ )}
+
+
+
+ {label}
+
+
+ {rowLabel ? (
+
+ {rowLabel}
+
+ ) : meta?.sizeCategory ? (
+
+ {meta.sizeCategory}
- )}
-
- {ds && (
-
- {ds.size}
-
- )}
-
- );
- }}
-
+ ) : meta?.downloads != null ? (
+
+ ↓{formatCompact(meta.downloads)}
+
+ ) : null}
+
+ );
+ }}
+
+ {hasMore && }
+ {isLoadingMore && (
+
+
+
+ )}
+
@@ -250,7 +305,6 @@ export function DatasetStep() {
>
)}
- {/* Format Selection */}
diff --git a/studio/frontend/src/features/onboarding/components/steps/model-selection-step.tsx b/studio/frontend/src/features/onboarding/components/steps/model-selection-step.tsx
index 16a0c1e967..66217fb822 100644
--- a/studio/frontend/src/features/onboarding/components/steps/model-selection-step.tsx
+++ b/studio/frontend/src/features/onboarding/components/steps/model-selection-step.tsx
@@ -25,12 +25,15 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { MODELS } from "@/config/training";
+import { MODEL_TYPE_TO_HF_TASK, MODELS } from "@/config/training";
+import { useDebouncedValue, useHfModelSearch, useInfiniteScroll } from "@/hooks";
+import { formatCompact } from "@/lib/utils";
import { useWizardStore } from "@/stores/training";
import type { TrainingMethod } from "@/types/training";
import {
@@ -39,7 +42,7 @@ import {
Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import { useMemo, useRef } from "react";
+import { useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
export function ModelSelectionStep() {
@@ -63,18 +66,51 @@ export function ModelSelectionStep() {
})),
);
- const filteredModels = useMemo(() => {
- if (!modelType) {
- return [];
- }
- // Sort recommended first
+ const [inputValue, setInputValue] = useState("");
+ const debouncedQuery = useDebouncedValue(inputValue);
+ const task = modelType ? MODEL_TYPE_TO_HF_TASK[modelType] : undefined;
+ const { results: hfResults, isLoading, isLoadingMore, hasMore, fetchMore } = useHfModelSearch(debouncedQuery, {
+ task,
+ accessToken: hfToken || undefined,
+ });
+
+ const curatedModels = useMemo(() => {
+ if (!modelType) return [];
return MODELS.filter((m) => m.type === modelType).sort(
(a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0),
);
}, [modelType]);
- const selectedModelData = MODELS.find((m) => m.id === selectedModel);
+ const modelMap = useMemo(() => {
+ const map = new Map();
+ for (const m of curatedModels) {
+ map.set(m.hfRepo ?? m.id, { label: m.name, params: m.params, recommended: m.recommended });
+ }
+ for (const r of hfResults) {
+ if (!map.has(r.id)) {
+ map.set(r.id, { label: r.id, downloads: r.downloads, totalParams: r.totalParams });
+ }
+ }
+ return map;
+ }, [curatedModels, hfResults]);
+
+ const displayIds = useMemo(() => {
+ if (!debouncedQuery.trim()) {
+ return curatedModels.map((m) => m.hfRepo ?? m.id);
+ }
+ const q = debouncedQuery.toLowerCase();
+ const curatedIds = curatedModels
+ .filter((m) => m.name.toLowerCase().includes(q) || m.id.toLowerCase().includes(q) || m.hfRepo?.toLowerCase().includes(q))
+ .map((m) => m.hfRepo ?? m.id);
+ const liveIds = hfResults.map((r) => r.id).filter((id) => !curatedIds.includes(id));
+ return [...curatedIds, ...liveIds];
+ }, [debouncedQuery, curatedModels, hfResults]);
+
+ const allIds = useMemo(() => [...new Set([...curatedModels.map((m) => m.hfRepo ?? m.id), ...hfResults.map((r) => r.id)])], [curatedModels, hfResults]);
+
+ const selectedModelData = MODELS.find((m) => m.id === selectedModel || m.hfRepo === selectedModel);
const comboboxAnchorRef = useRef(null);
+ const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore);
return (
@@ -123,7 +159,7 @@ export function ModelSelectionStep() {
- Search from our curated list of optimized models.{" "}
+ Search Hugging Face models or pick from our recommended list.{" "}
m.name)}
- value={selectedModelData?.name ?? null}
- onValueChange={(name) => {
- const model = filteredModels.find((m) => m.name === name);
- if (model) {
- setSelectedModel(model.id);
- }
- }}
+ items={allIds}
+ filteredItems={displayIds}
+ filter={null}
+ value={selectedModel}
+ onValueChange={(id) => setSelectedModel(id)}
+ onInputValueChange={(val) => setInputValue(val)}
+ itemToStringValue={(id) => modelMap.get(id)?.label ?? id}
autoHighlight={true}
>
-
+
- No models found
-
- {(name: string) => {
- const model = filteredModels.find((m) => m.name === name);
- return (
-
- {name}
- {model && (
-
- {model.params}
-
- )}
-
- );
- }}
-
+ {isLoading ? (
+ Searching…
+ ) : (
+ No models found
+ )}
+
+
+ {(id: string) => {
+ const meta = modelMap.get(id);
+ const label = meta?.label ?? id;
+ const sizeLabel = meta?.params ?? (meta?.totalParams ? formatCompact(meta.totalParams) : null);
+ return (
+
+
+
+ {label}
+
+
+ {label}
+
+
+
+ {meta?.recommended && (
+
+ Recommended
+
+ )}
+ {sizeLabel ? (
+ {sizeLabel}
+ ) : meta?.downloads != null ? (
+ ↓{formatCompact(meta.downloads)}
+ ) : null}
+
+
+ );
+ }}
+
+ {hasMore &&
}
+ {isLoadingMore && (
+
+
+
+ )}
+
- {selectedModelData && (
+ {(selectedModelData || selectedModel) && (
@@ -208,7 +271,7 @@ export function ModelSelectionStep() {
- Choose how to fine-tune {selectedModelData.name}
+ Choose how to fine-tune {selectedModelData?.name ?? selectedModel}
{/* Format */}
diff --git a/studio/frontend/src/features/studio/sections/model-section.tsx b/studio/frontend/src/features/studio/sections/model-section.tsx
index f9e5018779..f8edb3e148 100644
--- a/studio/frontend/src/features/studio/sections/model-section.tsx
+++ b/studio/frontend/src/features/studio/sections/model-section.tsx
@@ -1,4 +1,12 @@
import { SectionCard } from "@/components/section-card";
+import {
+ Combobox,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxInput,
+ ComboboxItem,
+ ComboboxList,
+} from "@/components/ui/combobox";
import {
InputGroup,
InputGroupAddon,
@@ -11,43 +19,28 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { MODELS } from "@/config/training";
+import { MODEL_TYPE_TO_HF_TASK, MODELS } from "@/config/training";
+import { useDebouncedValue, useHfModelSearch, useInfiniteScroll } from "@/hooks";
+import { formatCompact } from "@/lib/utils";
import { useWizardStore } from "@/stores/training";
import type { TrainingMethod } from "@/types/training";
import {
ChipIcon,
+ FolderSearchIcon,
InformationCircleIcon,
Key01Icon,
Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import { useMemo } from "react";
+import { useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
-const DOT_COLORS = [
- "bg-amber-400",
- "bg-blue-400",
- "bg-emerald-400",
- "bg-rose-400",
- "bg-violet-400",
- "bg-cyan-400",
- "bg-orange-400",
- "bg-pink-400",
- "bg-teal-400",
- "bg-indigo-400",
- "bg-lime-400",
- "bg-fuchsia-400",
- "bg-sky-400",
- "bg-red-400",
- "bg-yellow-400",
- "bg-purple-400",
-];
-
const METHOD_DOTS: Record = {
qlora: "bg-emerald-400",
lora: "bg-blue-400",
@@ -80,15 +73,54 @@ export function ModelSection() {
})),
);
- const filteredModels = useMemo(() => {
- if (!modelType) {
- return MODELS;
- }
+ const [inputValue, setInputValue] = useState("");
+ const debouncedQuery = useDebouncedValue(inputValue);
+ const task = modelType ? MODEL_TYPE_TO_HF_TASK[modelType] : undefined;
+ const { results: hfResults, isLoading, isLoadingMore, hasMore, fetchMore } = useHfModelSearch(debouncedQuery, {
+ task,
+ accessToken: hfToken || undefined,
+ });
+
+ const curatedModels = useMemo(() => {
+ if (!modelType) return MODELS;
return MODELS.filter((m) => m.type === modelType).sort(
(a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0),
);
}, [modelType]);
+ const modelMap = useMemo(() => {
+ const map = new Map();
+ for (const m of curatedModels) {
+ map.set(m.hfRepo ?? m.id, { label: m.name, params: m.params, recommended: m.recommended });
+ }
+ for (const r of hfResults) {
+ if (!map.has(r.id)) {
+ map.set(r.id, { label: r.id, downloads: r.downloads, totalParams: r.totalParams });
+ }
+ }
+ return map;
+ }, [curatedModels, hfResults]);
+
+ const displayIds = useMemo(() => {
+ if (!debouncedQuery.trim()) {
+ return curatedModels.map((m) => m.hfRepo ?? m.id);
+ }
+ const q = debouncedQuery.toLowerCase();
+ const curatedIds = curatedModels
+ .filter((m) => m.name.toLowerCase().includes(q) || m.id.toLowerCase().includes(q) || m.hfRepo?.toLowerCase().includes(q))
+ .map((m) => m.hfRepo ?? m.id);
+ const liveIds = hfResults.map((r) => r.id).filter((id) => !curatedIds.includes(id));
+ return [...curatedIds, ...liveIds];
+ }, [debouncedQuery, curatedModels, hfResults]);
+
+ const allIds = useMemo(
+ () => [...new Set([...curatedModels.map((m) => m.hfRepo ?? m.id), ...hfResults.map((r) => r.id)])],
+ [curatedModels, hfResults],
+ );
+
+ const comboboxAnchorRef = useRef(null);
+ const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore);
+
return (
}
@@ -100,7 +132,44 @@ export function ModelSection() {
className="col-span-12 shadow-border ring-1 ring-border"
>
- {/* Base Model */}
+ {/* Local Model */}
+
+
+ Local Model
+
+
+
+
+
+ Path to a locally downloaded model or a custom HF repo.
+
+
+
+
+
+
+
+ m.id === selectedModel || m.hfRepo === selectedModel)?.hfRepo ?? selectedModel)
+ : ""
+ }
+ onChange={(e) => setSelectedModel(e.target.value || null)}
+ />
+
+
+
+ {/* Base Model Search */}
Base Model
@@ -117,7 +186,7 @@ export function ModelSection() {
- Search from curated optimized models.{" "}
+ Search Hugging Face models or pick from our recommended list.{" "}
-
-
-
- {/* HF Repo */}
-
-
- Hugging Face Repo
-
-
-
-
-
- m.id === selectedModel)?.hfRepo ?? selectedModel)
- : ""
- }
- onChange={(e) => setSelectedModel(e.target.value || null)}
- />
-
+
+
+
+
+
+
+ {isLoading ? (
+
+ Searching…
+
+ ) : (
+ No models found
+ )}
+
+
+ {(id: string) => {
+ const meta = modelMap.get(id);
+ const label = meta?.label ?? id;
+ const sizeLabel = meta?.params ?? (meta?.totalParams ? formatCompact(meta.totalParams) : null);
+ return (
+
+
+
+ {label}
+
+
+ {label}
+
+
+ {sizeLabel ? (
+
+ {sizeLabel}
+
+ ) : meta?.downloads != null ? (
+
+ ↓{formatCompact(meta.downloads)}
+
+ ) : null}
+
+ );
+ }}
+
+ {hasMore &&
}
+ {isLoadingMore && (
+
+
+
+ )}
+
+
+
+
{/* Training Method */}
diff --git a/studio/frontend/src/hooks/index.ts b/studio/frontend/src/hooks/index.ts
index 0e981fab4f..4e338169e4 100644
--- a/studio/frontend/src/hooks/index.ts
+++ b/studio/frontend/src/hooks/index.ts
@@ -1,3 +1,4 @@
export { useDebouncedValue } from "./use-debounced-value";
export { useHfModelSearch } from "./use-hf-model-search";
export { useHfDatasetSearch } from "./use-hf-dataset-search";
+export { useInfiniteScroll } from "./use-infinite-scroll";
diff --git a/studio/frontend/src/hooks/use-hf-dataset-search.ts b/studio/frontend/src/hooks/use-hf-dataset-search.ts
index 727b6c1442..748dff6ba2 100644
--- a/studio/frontend/src/hooks/use-hf-dataset-search.ts
+++ b/studio/frontend/src/hooks/use-hf-dataset-search.ts
@@ -1,72 +1,85 @@
import { listDatasets } from "@huggingface/hub";
-import { useEffect, useState } from "react";
+import { useCallback } from "react";
+import { useHfPaginatedSearch } from "./use-hf-paginated-search";
+
+interface DatasetInfoSplit {
+ name: string;
+ num_bytes: number;
+ num_examples: number;
+}
+
+interface CardDataWithInfo {
+ size_categories?: string[];
+ pretty_name?: string;
+ dataset_info?:
+ | {
+ splits?: DatasetInfoSplit[];
+ download_size?: number;
+ dataset_size?: number;
+ }
+ | Array<{ splits?: DatasetInfoSplit[] }>;
+}
+
+function extractTotalExamples(
+ cardData: CardDataWithInfo | undefined,
+): number | undefined {
+ if (!cardData?.dataset_info) return undefined;
+ const infos = Array.isArray(cardData.dataset_info)
+ ? cardData.dataset_info
+ : [cardData.dataset_info];
+ let total = 0;
+ let found = false;
+ for (const info of infos) {
+ for (const split of info.splits ?? []) {
+ if (typeof split.num_examples === "number") {
+ total += split.num_examples;
+ found = true;
+ }
+ }
+ }
+ return found ? total : undefined;
+}
export interface HfDatasetResult {
id: string;
downloads: number;
likes: number;
+ totalExamples?: number;
+ sizeCategory?: string;
}
-interface HfSearchState {
- results: HfDatasetResult[];
- isLoading: boolean;
- error: string | null;
+function mapDataset(raw: unknown): HfDatasetResult {
+ const ds = raw as {
+ name: string;
+ downloads: number;
+ likes: number;
+ cardData?: unknown;
+ };
+ const card = ds.cardData as CardDataWithInfo | undefined;
+ return {
+ id: ds.name,
+ downloads: ds.downloads,
+ likes: ds.likes,
+ totalExamples: extractTotalExamples(card),
+ sizeCategory: card?.size_categories?.[0],
+ };
}
export function useHfDatasetSearch(
query: string,
- options?: { limit?: number; accessToken?: string },
-): HfSearchState {
- const { limit = 20, accessToken } = options ?? {};
- const [state, setState] = useState({
- results: [],
- isLoading: false,
- error: null,
- });
+ options?: { accessToken?: string },
+) {
+ const { accessToken } = options ?? {};
- useEffect(() => {
- if (!query.trim()) {
- setState({ results: [], isLoading: false, error: null });
- return;
- }
+ const createIter = useCallback(
+ () =>
+ listDatasets({
+ search: { query },
+ additionalFields: ["cardData"],
+ ...(accessToken ? { credentials: { accessToken } } : {}),
+ }) as AsyncGenerator,
+ [query, accessToken],
+ );
- let cancelled = false;
- setState((prev) => ({ ...prev, isLoading: true, error: null }));
-
- (async () => {
- try {
- const results: HfDatasetResult[] = [];
- const iter = listDatasets({
- search: { query },
- limit,
- ...(accessToken ? { credentials: { accessToken } } : {}),
- });
- for await (const ds of iter) {
- if (cancelled) return;
- results.push({
- id: ds.id,
- downloads: ds.downloads,
- likes: ds.likes,
- });
- }
- if (!cancelled) {
- setState({ results, isLoading: false, error: null });
- }
- } catch (err) {
- if (!cancelled) {
- setState({
- results: [],
- isLoading: false,
- error: err instanceof Error ? err.message : "Search failed",
- });
- }
- }
- })();
-
- return () => {
- cancelled = true;
- };
- }, [query, limit, accessToken]);
-
- return state;
+ return useHfPaginatedSearch(query, createIter, mapDataset);
}
diff --git a/studio/frontend/src/hooks/use-hf-model-search.ts b/studio/frontend/src/hooks/use-hf-model-search.ts
index fe0cc358e7..7b1ef0ee14 100644
--- a/studio/frontend/src/hooks/use-hf-model-search.ts
+++ b/studio/frontend/src/hooks/use-hf-model-search.ts
@@ -1,74 +1,45 @@
+import type { PipelineType } from "@huggingface/hub";
import { listModels } from "@huggingface/hub";
-import { useEffect, useState } from "react";
+import { useCallback } from "react";
+import { useHfPaginatedSearch } from "./use-hf-paginated-search";
export interface HfModelResult {
id: string;
downloads: number;
likes: number;
- task?: string;
+ totalParams?: number;
}
-interface HfSearchState {
- results: HfModelResult[];
- isLoading: boolean;
- error: string | null;
+function mapModel(raw: unknown): HfModelResult {
+ const m = raw as {
+ name: string;
+ downloads: number;
+ likes: number;
+ safetensors?: { total: number };
+ };
+ return {
+ id: m.name,
+ downloads: m.downloads,
+ likes: m.likes,
+ totalParams: m.safetensors?.total,
+ };
}
export function useHfModelSearch(
query: string,
- options?: { task?: string; limit?: number; accessToken?: string },
-): HfSearchState {
- const { task, limit = 20, accessToken } = options ?? {};
- const [state, setState] = useState({
- results: [],
- isLoading: false,
- error: null,
- });
+ options?: { task?: PipelineType; accessToken?: string },
+) {
+ const { task, accessToken } = options ?? {};
- useEffect(() => {
- if (!query.trim()) {
- setState({ results: [], isLoading: false, error: null });
- return;
- }
+ const createIter = useCallback(
+ () =>
+ listModels({
+ search: { query, ...(task ? { task } : {}) },
+ additionalFields: ["safetensors"],
+ ...(accessToken ? { credentials: { accessToken } } : {}),
+ }) as AsyncGenerator,
+ [query, task, accessToken],
+ );
- let cancelled = false;
- setState((prev) => ({ ...prev, isLoading: true, error: null }));
-
- (async () => {
- try {
- const results: HfModelResult[] = [];
- const iter = listModels({
- search: { query, ...(task ? { task } : {}) },
- limit,
- ...(accessToken ? { credentials: { accessToken } } : {}),
- });
- for await (const model of iter) {
- if (cancelled) return;
- results.push({
- id: model.id,
- downloads: model.downloads,
- likes: model.likes,
- task: model.task,
- });
- }
- if (!cancelled) {
- setState({ results, isLoading: false, error: null });
- }
- } catch (err) {
- if (!cancelled) {
- setState({
- results: [],
- isLoading: false,
- error: err instanceof Error ? err.message : "Search failed",
- });
- }
- }
- })();
-
- return () => {
- cancelled = true;
- };
- }, [query, task, limit, accessToken]);
-
- return state;
+ return useHfPaginatedSearch(query, createIter, mapModel);
}
diff --git a/studio/frontend/src/hooks/use-hf-paginated-search.ts b/studio/frontend/src/hooks/use-hf-paginated-search.ts
new file mode 100644
index 0000000000..7beeb1d1b5
--- /dev/null
+++ b/studio/frontend/src/hooks/use-hf-paginated-search.ts
@@ -0,0 +1,116 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+interface HfPaginatedState {
+ results: T[];
+ isLoading: boolean;
+ isLoadingMore: boolean;
+ hasMore: boolean;
+ error: string | null;
+}
+
+const INITIAL: HfPaginatedState = {
+ results: [],
+ isLoading: false,
+ isLoadingMore: false,
+ hasMore: false,
+ error: null,
+};
+const BATCH = 20;
+
+async function pullBatch(
+ iter: AsyncGenerator,
+ mapItem: (raw: unknown) => T,
+ size: number,
+) {
+ const items: T[] = [];
+ for (let i = 0; i < size; i++) {
+ const result = await iter.next();
+ if (result.done) return { items, done: true };
+ items.push(mapItem(result.value));
+ }
+ return { items, done: false };
+}
+
+export function useHfPaginatedSearch(
+ query: string,
+ createIter: () => AsyncGenerator,
+ mapItem: (raw: unknown) => T,
+): HfPaginatedState & { fetchMore: () => void } {
+ const [state, setState] = useState>(
+ INITIAL as HfPaginatedState,
+ );
+ const stateRef = useRef(state);
+ stateRef.current = state;
+
+ const iterRef = useRef | null>(null);
+ const versionRef = useRef(0);
+
+ useEffect(() => {
+ const v = ++versionRef.current;
+ iterRef.current = null;
+
+ if (!query.trim()) {
+ setState(INITIAL as HfPaginatedState);
+ return;
+ }
+
+ setState((prev) => ({
+ ...prev,
+ results: [],
+ isLoading: true,
+ error: null,
+ hasMore: false,
+ }));
+
+ const iter = createIter();
+ iterRef.current = iter;
+
+ pullBatch(iter, mapItem, BATCH)
+ .then(({ items, done }) => {
+ if (versionRef.current !== v) return;
+ setState({
+ results: items,
+ isLoading: false,
+ isLoadingMore: false,
+ hasMore: !done,
+ error: null,
+ });
+ })
+ .catch((err) => {
+ if (versionRef.current !== v) return;
+ setState({
+ results: [],
+ isLoading: false,
+ isLoadingMore: false,
+ hasMore: false,
+ error: err instanceof Error ? err.message : "Search failed",
+ });
+ });
+ }, [query, createIter, mapItem]);
+
+ const fetchMore = useCallback(() => {
+ const iter = iterRef.current;
+ const { isLoading, isLoadingMore, hasMore } = stateRef.current;
+ if (!iter || isLoading || isLoadingMore || !hasMore) return;
+
+ const v = versionRef.current;
+ setState((prev) => ({ ...prev, isLoadingMore: true }));
+
+ pullBatch(iter, mapItem, BATCH)
+ .then(({ items, done }) => {
+ if (versionRef.current !== v) return;
+ setState((prev) => ({
+ ...prev,
+ results: [...prev.results, ...items],
+ isLoadingMore: false,
+ hasMore: !done,
+ }));
+ })
+ .catch(() => {
+ if (versionRef.current !== v) return;
+ setState((prev) => ({ ...prev, isLoadingMore: false, hasMore: false }));
+ });
+ }, [mapItem]);
+
+ return { ...state, fetchMore };
+}
diff --git a/studio/frontend/src/hooks/use-infinite-scroll.ts b/studio/frontend/src/hooks/use-infinite-scroll.ts
new file mode 100644
index 0000000000..488739604f
--- /dev/null
+++ b/studio/frontend/src/hooks/use-infinite-scroll.ts
@@ -0,0 +1,21 @@
+import { useEffect, useRef } from "react";
+
+export function useInfiniteScroll(fetchMore: () => void) {
+ const scrollRef = useRef(null);
+ const sentinelRef = useRef(null);
+
+ useEffect(() => {
+ const el = sentinelRef.current;
+ if (!el) return;
+ const obs = new IntersectionObserver(
+ ([e]) => {
+ if (e.isIntersecting) fetchMore();
+ },
+ { threshold: 0, root: scrollRef.current },
+ );
+ obs.observe(el);
+ return () => obs.disconnect();
+ }, [fetchMore]);
+
+ return { scrollRef, sentinelRef };
+}
diff --git a/studio/frontend/src/lib/utils.ts b/studio/frontend/src/lib/utils.ts
index a70ebb68c7..3f05e80d09 100644
--- a/studio/frontend/src/lib/utils.ts
+++ b/studio/frontend/src/lib/utils.ts
@@ -4,3 +4,10 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+
+export function formatCompact(n: number): string {
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
+ return String(n);
+}
diff --git a/studio/frontend/src/types/training.ts b/studio/frontend/src/types/training.ts
index 9688ec1ed4..0a286571e1 100644
--- a/studio/frontend/src/types/training.ts
+++ b/studio/frontend/src/types/training.ts
@@ -1,5 +1,9 @@
export type ModelType = "vision" | "tts" | "embeddings" | "text";
export type TrainingMethod = "qlora" | "lora" | "full";
+
+export function isAdapterMethod(method: TrainingMethod): boolean {
+ return method === "lora" || method === "qlora";
+}
export type StepNumber = 1 | 2 | 3 | 4 | 5;
export type DatasetSource = "huggingface" | "upload";
export type DatasetFormat = "auto" | "alpaca" | "chatml" | "sharegpt";