feat: add Hugging Face search integration for datasets and models, extend infinite scroll support, and improve UI components with animations and tooltips
This commit is contained in:
parent
e9857dab0f
commit
e705230499
18 changed files with 791 additions and 338 deletions
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(tree:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(bun run typecheck:*)",
|
||||
"mcp__plugin_serena_serena__list_dir",
|
||||
"Bash(bun x tsc:*)",
|
||||
"mcp__plugin_perplexity_perplexity__perplexity_ask",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:www.npmjs.com)",
|
||||
"WebFetch(domain:github.com)"
|
||||
]
|
||||
}
|
||||
}
|
||||
11
studio/frontend/src/components/ui/spinner.tsx
Normal file
11
studio/frontend/src/components/ui/spinner.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { Loading03Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
function Spinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<HugeiconsIcon icon={Loading03Icon} strokeWidth={2} role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
|
|
@ -4,6 +4,7 @@ import type {
|
|||
ModelType,
|
||||
StepConfig,
|
||||
} from "@/types/training";
|
||||
import type { PipelineType } from "@huggingface/hub";
|
||||
|
||||
export const STEPS: StepConfig[] = [
|
||||
{
|
||||
|
|
@ -257,7 +258,12 @@ export const DEFAULT_HYPERPARAMS = {
|
|||
targetModules: TARGET_MODULES,
|
||||
};
|
||||
|
||||
export const MODEL_TYPE_TO_HF_TASK: Record<ModelType, string> = {
|
||||
export function findModelById(id: string | null): ModelOption | undefined {
|
||||
if (!id) return undefined;
|
||||
return MODELS.find((m) => m.id === id || m.hfRepo === id);
|
||||
}
|
||||
|
||||
export const MODEL_TYPE_TO_HF_TASK: Record<ModelType, PipelineType> = {
|
||||
text: "text-generation",
|
||||
vision: "image-text-to-text",
|
||||
tts: "text-to-speech",
|
||||
|
|
|
|||
6
studio/frontend/src/features/export/anim.ts
Normal file
6
studio/frontend/src/features/export/anim.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export const collapseAnim = {
|
||||
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 },
|
||||
};
|
||||
|
|
@ -17,17 +17,11 @@ import { Switch } from "@/components/ui/switch";
|
|||
import { ArrowRight01Icon, Key01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { collapseAnim } from "../anim";
|
||||
import { EXPORT_METHODS, type ExportMethod } from "../constants";
|
||||
|
||||
type Destination = "local" | "hub";
|
||||
|
||||
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 },
|
||||
};
|
||||
|
||||
interface ExportDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
|
|
@ -96,7 +90,7 @@ export function ExportDialog({
|
|||
|
||||
<AnimatePresence>
|
||||
{destination === "hub" && (
|
||||
<motion.div {...anim} className="overflow-hidden">
|
||||
<motion.div {...collapseAnim} className="overflow-hidden">
|
||||
<div className="flex flex-col gap-4 px-0.5">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [exportMethod, setExportMethod] = useState<ExportMethod | null>(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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
|
|
@ -141,7 +146,7 @@ export function ExportPage() {
|
|||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Method</span>
|
||||
<span className="font-medium">{METHOD_LABELS[store.trainingMethod] ?? store.trainingMethod}</span>
|
||||
<span className="font-medium">{METHOD_LABELS[trainingMethod] ?? trainingMethod}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Checkpoints</span>
|
||||
|
|
@ -149,12 +154,12 @@ export function ExportPage() {
|
|||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Epochs</span>
|
||||
<span className="font-medium">{store.epochs}</span>
|
||||
<span className="font-medium">{epochs}</span>
|
||||
</div>
|
||||
{isAdapter && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">LoRA Rank</span>
|
||||
<span className="font-medium">{store.loraRank}</span>
|
||||
<span className="font-medium">{loraRank}</span>
|
||||
</div>
|
||||
)}
|
||||
{modelInfo?.params && (
|
||||
|
|
@ -186,7 +191,7 @@ export function ExportPage() {
|
|||
|
||||
<AnimatePresence>
|
||||
{exportMethod === "gguf" && (
|
||||
<motion.div {...anim} className="overflow-hidden">
|
||||
<motion.div {...collapseAnim} className="overflow-hidden">
|
||||
<QuantPicker value={quantLevels} onChange={setQuantLevels} />
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<string, { label: string; description?: string; size?: string; totalExamples?: number; sizeCategory?: string; downloads?: number; recommended?: boolean }>();
|
||||
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<HTMLDivElement>(null);
|
||||
const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore);
|
||||
|
||||
const handleFileUpload = () => {
|
||||
// Mock file upload
|
||||
setUploadedFile("my_dataset.jsonl");
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldGroup>
|
||||
{/* Source Toggle */}
|
||||
<Field>
|
||||
<FieldLabel>Source</FieldLabel>
|
||||
<div className="flex gap-2">
|
||||
|
|
@ -163,48 +195,71 @@ export function DatasetStep() {
|
|||
<FieldLabel>Search datasets</FieldLabel>
|
||||
<div ref={comboboxAnchorRef}>
|
||||
<Combobox
|
||||
items={sortedDatasets.map((d) => 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}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Search by name..."
|
||||
className="w-full"
|
||||
>
|
||||
<ComboboxInput placeholder="Search datasets..." className="w-full">
|
||||
<InputGroupAddon>
|
||||
<HugeiconsIcon icon={Search01Icon} className="size-4" />
|
||||
</InputGroupAddon>
|
||||
</ComboboxInput>
|
||||
<ComboboxContent anchor={comboboxAnchorRef}>
|
||||
<ComboboxEmpty>No datasets found</ComboboxEmpty>
|
||||
<ComboboxList className="p-1">
|
||||
{(name: string) => {
|
||||
const ds = sortedDatasets.find((d) => d.name === name);
|
||||
return (
|
||||
<ComboboxItem key={name} value={name}>
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
<span>{name}</span>
|
||||
{ds && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{ds.description}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground"><Spinner className="size-4" /> Searching…</div>
|
||||
) : (
|
||||
<ComboboxEmpty>No datasets found</ComboboxEmpty>
|
||||
)}
|
||||
<div ref={scrollRef} className="max-h-64 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
|
||||
<ComboboxList className="p-1 !max-h-none !overflow-visible">
|
||||
{(id: string) => {
|
||||
const meta = datasetMap.get(id);
|
||||
const label = meta?.label ?? id;
|
||||
const rowLabel = meta?.size ?? (meta?.totalExamples ? `${formatCompact(meta.totalExamples)} rows` : null);
|
||||
return (
|
||||
<ComboboxItem key={id} value={id} className="justify-between">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<span className="truncate">{label}</span>
|
||||
{meta?.description && (
|
||||
<span className="text-xs text-muted-foreground truncate">{meta.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs break-all">
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{rowLabel ? (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{rowLabel}
|
||||
</Badge>
|
||||
) : meta?.sizeCategory ? (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
{meta.sizeCategory}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{ds && (
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{ds.size}
|
||||
</Badge>
|
||||
)}
|
||||
</ComboboxItem>
|
||||
);
|
||||
}}
|
||||
</ComboboxList>
|
||||
) : meta?.downloads != null ? (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
↓{formatCompact(meta.downloads)}
|
||||
</span>
|
||||
) : null}
|
||||
</ComboboxItem>
|
||||
);
|
||||
}}
|
||||
</ComboboxList>
|
||||
{hasMore && <div ref={sentinelRef} className="h-px" />}
|
||||
{isLoadingMore && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Spinner className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
|
@ -250,7 +305,6 @@ export function DatasetStep() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Format Selection */}
|
||||
<Field>
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel className="flex items-center gap-1.5">
|
||||
|
|
|
|||
|
|
@ -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<string, { label: string; params?: string; totalParams?: number; downloads?: number; recommended?: boolean }>();
|
||||
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<HTMLDivElement>(null);
|
||||
const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore);
|
||||
|
||||
return (
|
||||
<FieldGroup>
|
||||
|
|
@ -123,7 +159,7 @@ export function ModelSelectionStep() {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Search from our curated list of optimized models.{" "}
|
||||
Search Hugging Face models or pick from our recommended list.{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/what-model-should-i-use"
|
||||
target="_blank"
|
||||
|
|
@ -137,44 +173,71 @@ export function ModelSelectionStep() {
|
|||
</FieldLabel>
|
||||
<div ref={comboboxAnchorRef}>
|
||||
<Combobox
|
||||
items={filteredModels.map((m) => 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}
|
||||
>
|
||||
<ComboboxInput placeholder="Search by name..." className="w-full">
|
||||
<ComboboxInput placeholder="Search models..." className="w-full">
|
||||
<InputGroupAddon>
|
||||
<HugeiconsIcon icon={Search01Icon} className="size-4" />
|
||||
</InputGroupAddon>
|
||||
</ComboboxInput>
|
||||
<ComboboxContent anchor={comboboxAnchorRef}>
|
||||
<ComboboxEmpty>No models found</ComboboxEmpty>
|
||||
<ComboboxList className="p-1">
|
||||
{(name: string) => {
|
||||
const model = filteredModels.find((m) => m.name === name);
|
||||
return (
|
||||
<ComboboxItem key={name} value={name}>
|
||||
<span className="flex-1">{name}</span>
|
||||
{model && (
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{model.params}
|
||||
</Badge>
|
||||
)}
|
||||
</ComboboxItem>
|
||||
);
|
||||
}}
|
||||
</ComboboxList>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground"><Spinner className="size-4" /> Searching…</div>
|
||||
) : (
|
||||
<ComboboxEmpty>No models found</ComboboxEmpty>
|
||||
)}
|
||||
<div ref={scrollRef} className="max-h-64 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
|
||||
<ComboboxList className="p-1 !max-h-none !overflow-visible">
|
||||
{(id: string) => {
|
||||
const meta = modelMap.get(id);
|
||||
const label = meta?.label ?? id;
|
||||
const sizeLabel = meta?.params ?? (meta?.totalParams ? formatCompact(meta.totalParams) : null);
|
||||
return (
|
||||
<ComboboxItem key={id} value={id} className="justify-between">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs break-all">
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="flex items-center gap-1.5 shrink-0">
|
||||
{meta?.recommended && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-emerald-600 border-emerald-200 dark:border-emerald-800 dark:text-emerald-400">
|
||||
Recommended
|
||||
</Badge>
|
||||
)}
|
||||
{sizeLabel ? (
|
||||
<Badge variant="outline">{sizeLabel}</Badge>
|
||||
) : meta?.downloads != null ? (
|
||||
<span className="text-[10px] text-muted-foreground">↓{formatCompact(meta.downloads)}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</ComboboxItem>
|
||||
);
|
||||
}}
|
||||
</ComboboxList>
|
||||
{hasMore && <div ref={sentinelRef} className="h-px" />}
|
||||
{isLoadingMore && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Spinner className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{selectedModelData && (
|
||||
{(selectedModelData || selectedModel) && (
|
||||
<Field>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
|
@ -208,7 +271,7 @@ export function ModelSelectionStep() {
|
|||
</Tooltip>
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
Choose how to fine-tune {selectedModelData.name}
|
||||
Choose how to fine-tune {selectedModelData?.name ?? selectedModel}
|
||||
</FieldDescription>
|
||||
</div>
|
||||
<Select
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DATASETS, MODELS } from "@/config/training";
|
||||
import { DATASETS, findModelById } from "@/config/training";
|
||||
import { isAdapterMethod } from "@/types/training";
|
||||
import { useWizardStore } from "@/stores/training";
|
||||
import { GpuIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
|
@ -49,10 +50,9 @@ export function SummaryStep() {
|
|||
})),
|
||||
);
|
||||
|
||||
const modelData = MODELS.find((m) => m.id === selectedModel);
|
||||
const modelData = findModelById(selectedModel);
|
||||
const datasetData = DATASETS.find((d) => d.id === dataset);
|
||||
const showLoraParams =
|
||||
trainingMethod === "lora" || trainingMethod === "qlora";
|
||||
const showLoraParams = isAdapterMethod(trainingMethod);
|
||||
const datasetName =
|
||||
datasetSource === "upload" ? uploadedFile : datasetData?.name;
|
||||
const datasetDesc =
|
||||
|
|
|
|||
|
|
@ -6,10 +6,17 @@ import {
|
|||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
InputGroup,
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import {
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -23,6 +30,8 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { DATASETS } from "@/config/training";
|
||||
import { useDebouncedValue, useHfDatasetSearch, useInfiniteScroll } from "@/hooks";
|
||||
import { formatCompact } from "@/lib/utils";
|
||||
import { useWizardStore } from "@/stores/training";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
|
|
@ -34,21 +43,66 @@ import {
|
|||
ViewIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
export function DatasetSection() {
|
||||
const { dataset, setDataset, datasetFormat, setDatasetFormat } =
|
||||
const { dataset, setDataset, datasetFormat, setDatasetFormat, hfToken } =
|
||||
useWizardStore(
|
||||
useShallow((s) => ({
|
||||
dataset: s.dataset,
|
||||
setDataset: s.setDataset,
|
||||
datasetFormat: s.datasetFormat,
|
||||
setDatasetFormat: s.setDatasetFormat,
|
||||
hfToken: s.hfToken,
|
||||
})),
|
||||
);
|
||||
const [recOpen, setRecOpen] = useState(false);
|
||||
|
||||
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 datasetMap = useMemo(() => {
|
||||
const map = new Map<string, { label: string; description?: string; size?: string; totalExamples?: number; sizeCategory?: string; downloads?: number }>();
|
||||
for (const d of curatedDatasets) {
|
||||
map.set(d.id, { label: d.name, description: d.description, size: d.size });
|
||||
}
|
||||
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<HTMLDivElement>(null);
|
||||
const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={<HugeiconsIcon icon={Database02Icon} className="size-5" />}
|
||||
|
|
@ -75,7 +129,7 @@ export function DatasetSection() {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Enter a Hugging Face dataset path like 'username/dataset-name'.{" "}
|
||||
Search Hugging Face datasets or enter a path like 'username/dataset-name'.{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/datasets-guide"
|
||||
target="_blank"
|
||||
|
|
@ -87,17 +141,71 @@ export function DatasetSection() {
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<HugeiconsIcon icon={Search01Icon} className="size-4" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="yahma/alpaca-cleaned"
|
||||
value={dataset ?? ""}
|
||||
className=""
|
||||
onChange={(e) => setDataset(e.target.value || null)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<div ref={comboboxAnchorRef}>
|
||||
<Combobox
|
||||
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}
|
||||
>
|
||||
<ComboboxInput placeholder="Search datasets..." className="w-full">
|
||||
<InputGroupAddon>
|
||||
<HugeiconsIcon icon={Search01Icon} className="size-4" />
|
||||
</InputGroupAddon>
|
||||
</ComboboxInput>
|
||||
<ComboboxContent anchor={comboboxAnchorRef}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground"><Spinner className="size-4" /> Searching…</div>
|
||||
) : (
|
||||
<ComboboxEmpty>No datasets found</ComboboxEmpty>
|
||||
)}
|
||||
<div ref={scrollRef} className="max-h-64 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
|
||||
<ComboboxList className="p-1 !max-h-none !overflow-visible">
|
||||
{(id: string) => {
|
||||
const meta = datasetMap.get(id);
|
||||
const label = meta?.label ?? id;
|
||||
const rowLabel = meta?.size ?? (meta?.totalExamples ? `${formatCompact(meta.totalExamples)} rows` : null);
|
||||
return (
|
||||
<ComboboxItem key={id} value={id} className="justify-between">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs break-all">
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{rowLabel ? (
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{rowLabel}
|
||||
</span>
|
||||
) : meta?.sizeCategory ? (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
{meta.sizeCategory}
|
||||
</span>
|
||||
) : meta?.downloads != null ? (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
↓{formatCompact(meta.downloads)}
|
||||
</span>
|
||||
) : null}
|
||||
</ComboboxItem>
|
||||
);
|
||||
}}
|
||||
</ComboboxList>
|
||||
{hasMore && <div ref={sentinelRef} className="h-px" />}
|
||||
{isLoadingMore && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Spinner className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format */}
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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<string, { label: string; params?: string; totalParams?: number; downloads?: number; recommended?: boolean }>();
|
||||
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<HTMLDivElement>(null);
|
||||
const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={<HugeiconsIcon icon={ChipIcon} className="size-5" />}
|
||||
|
|
@ -100,7 +132,44 @@ export function ModelSection() {
|
|||
className="col-span-12 shadow-border ring-1 ring-border"
|
||||
>
|
||||
<div className="grid gap-4 lg:grid-cols-4">
|
||||
{/* Base Model */}
|
||||
{/* Local Model */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Local Model
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-foreground/70 hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={InformationCircleIcon}
|
||||
className="size-3"
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Path to a locally downloaded model or a custom HF repo.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<InputGroup className="bg-foreground text-background [&_input]:text-background [&_input]:placeholder:text-background/40 [&_svg]:text-background/50 hover:bg-foreground/90">
|
||||
<InputGroupAddon>
|
||||
<HugeiconsIcon icon={FolderSearchIcon} className="size-4" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="./models/my-model"
|
||||
value={
|
||||
selectedModel
|
||||
? (MODELS.find((m) => m.id === selectedModel || m.hfRepo === selectedModel)?.hfRepo ?? selectedModel)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => setSelectedModel(e.target.value || null)}
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
|
||||
{/* Base Model Search */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Base Model
|
||||
|
|
@ -117,7 +186,7 @@ export function ModelSection() {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Search from curated optimized models.{" "}
|
||||
Search Hugging Face models or pick from our recommended list.{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/what-model-should-i-use"
|
||||
target="_blank"
|
||||
|
|
@ -129,50 +198,69 @@ export function ModelSection() {
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<Select value={selectedModel ?? ""} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className={DARK_TRIGGER}>
|
||||
<SelectValue placeholder="Select model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
className={`${DARK_CONTENT} max-h-64 overflow-y-auto w-[var(--radix-select-trigger-width)]`}
|
||||
<div ref={comboboxAnchorRef}>
|
||||
<Combobox
|
||||
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}
|
||||
>
|
||||
{filteredModels.map((m, i) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${DOT_COLORS[i % DOT_COLORS.length]}`}
|
||||
/>
|
||||
{m.name}
|
||||
<span className="text-background/40 ml-auto text-xs">
|
||||
{m.params}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* HF Repo */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Hugging Face Repo
|
||||
</span>
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<HugeiconsIcon icon={Search01Icon} className="size-4" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="unsloth/gemma-3-27b"
|
||||
value={
|
||||
selectedModel
|
||||
? (MODELS.find((m) => m.id === selectedModel)?.hfRepo ?? selectedModel)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => setSelectedModel(e.target.value || null)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<ComboboxInput placeholder="Search models..." className="w-full">
|
||||
<InputGroupAddon>
|
||||
<HugeiconsIcon icon={Search01Icon} className="size-4" />
|
||||
</InputGroupAddon>
|
||||
</ComboboxInput>
|
||||
<ComboboxContent anchor={comboboxAnchorRef}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground">
|
||||
<Spinner className="size-4" /> Searching…
|
||||
</div>
|
||||
) : (
|
||||
<ComboboxEmpty>No models found</ComboboxEmpty>
|
||||
)}
|
||||
<div ref={scrollRef} className="max-h-64 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
|
||||
<ComboboxList className="p-1 !max-h-none !overflow-visible">
|
||||
{(id: string) => {
|
||||
const meta = modelMap.get(id);
|
||||
const label = meta?.label ?? id;
|
||||
const sizeLabel = meta?.params ?? (meta?.totalParams ? formatCompact(meta.totalParams) : null);
|
||||
return (
|
||||
<ComboboxItem key={id} value={id} className="justify-between">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs break-all">
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{sizeLabel ? (
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{sizeLabel}
|
||||
</span>
|
||||
) : meta?.downloads != null ? (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
↓{formatCompact(meta.downloads)}
|
||||
</span>
|
||||
) : null}
|
||||
</ComboboxItem>
|
||||
);
|
||||
}}
|
||||
</ComboboxList>
|
||||
{hasMore && <div ref={sentinelRef} className="h-px" />}
|
||||
{isLoadingMore && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Spinner className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Training Method */}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<HfSearchState>({
|
||||
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<unknown>,
|
||||
[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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HfSearchState>({
|
||||
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<unknown>,
|
||||
[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);
|
||||
}
|
||||
|
|
|
|||
116
studio/frontend/src/hooks/use-hf-paginated-search.ts
Normal file
116
studio/frontend/src/hooks/use-hf-paginated-search.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface HfPaginatedState<T> {
|
||||
results: T[];
|
||||
isLoading: boolean;
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const INITIAL: HfPaginatedState<never> = {
|
||||
results: [],
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
hasMore: false,
|
||||
error: null,
|
||||
};
|
||||
const BATCH = 20;
|
||||
|
||||
async function pullBatch<T>(
|
||||
iter: AsyncGenerator<unknown>,
|
||||
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<T>(
|
||||
query: string,
|
||||
createIter: () => AsyncGenerator<unknown>,
|
||||
mapItem: (raw: unknown) => T,
|
||||
): HfPaginatedState<T> & { fetchMore: () => void } {
|
||||
const [state, setState] = useState<HfPaginatedState<T>>(
|
||||
INITIAL as HfPaginatedState<T>,
|
||||
);
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
|
||||
const iterRef = useRef<AsyncGenerator<unknown> | null>(null);
|
||||
const versionRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const v = ++versionRef.current;
|
||||
iterRef.current = null;
|
||||
|
||||
if (!query.trim()) {
|
||||
setState(INITIAL as HfPaginatedState<T>);
|
||||
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 };
|
||||
}
|
||||
21
studio/frontend/src/hooks/use-infinite-scroll.ts
Normal file
21
studio/frontend/src/hooks/use-infinite-scroll.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useInfiniteScroll(fetchMore: () => void) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue