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:
shine1i 2026-02-02 12:14:18 +01:00
commit e705230499
18 changed files with 791 additions and 338 deletions

View file

@ -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)"
]
}
}

View 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 }

View file

@ -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",

View 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 },
};

View file

@ -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">

View file

@ -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}
/>

View file

@ -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">

View file

@ -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

View file

@ -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 =

View file

@ -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 */}

View file

@ -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 */}

View file

@ -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";

View file

@ -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);
}

View file

@ -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);
}

View 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 };
}

View 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 };
}

View file

@ -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);
}

View file

@ -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";