refactor: format and clean up imports, hooks, and UI components for consistent structure and readability across models and datasets sections
This commit is contained in:
parent
e705230499
commit
af3e8c20ee
16 changed files with 670 additions and 216 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -25,6 +25,7 @@ models/
|
|||
# IDE / Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
|
|
|
|||
|
|
@ -98,8 +98,21 @@ export const MODELS: ModelOption[] = [
|
|||
hfRepo: "Qwen/Qwen-VL-Chat",
|
||||
},
|
||||
// TTS models
|
||||
{ id: "bark", name: "Bark", type: "tts", params: "1B", hfRepo: "suno/bark", recommended: true },
|
||||
{ id: "xtts-v2", name: "XTTS v2", type: "tts", params: "500M", hfRepo: "coqui/XTTS-v2" },
|
||||
{
|
||||
id: "bark",
|
||||
name: "Bark",
|
||||
type: "tts",
|
||||
params: "1B",
|
||||
hfRepo: "suno/bark",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "xtts-v2",
|
||||
name: "XTTS v2",
|
||||
type: "tts",
|
||||
params: "500M",
|
||||
hfRepo: "coqui/XTTS-v2",
|
||||
},
|
||||
// Embedding models
|
||||
{
|
||||
id: "bge-large",
|
||||
|
|
@ -111,24 +124,6 @@ export const MODELS: ModelOption[] = [
|
|||
{ id: "e5-large", name: "E5 Large", type: "embeddings", params: "335M" },
|
||||
{ id: "gte-large", name: "GTE Large", type: "embeddings", params: "335M" },
|
||||
// Text models
|
||||
{
|
||||
id: "llama-3.1-8b",
|
||||
name: "Llama 3.1 8B",
|
||||
type: "text",
|
||||
params: "8B",
|
||||
vram: "~6GB",
|
||||
context: "128K",
|
||||
hfRepo: "unsloth/Llama-3.1-8B",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "llama-3.1-70b",
|
||||
name: "Llama 3.1 70B",
|
||||
type: "text",
|
||||
params: "70B",
|
||||
vram: "~40GB",
|
||||
context: "128K",
|
||||
},
|
||||
{
|
||||
id: "mistral-7b",
|
||||
name: "Mistral 7B",
|
||||
|
|
@ -154,24 +149,6 @@ export const MODELS: ModelOption[] = [
|
|||
vram: "~3GB",
|
||||
context: "128K",
|
||||
},
|
||||
{
|
||||
id: "gemma-2-9b",
|
||||
name: "Gemma 2 9B",
|
||||
type: "text",
|
||||
params: "9B",
|
||||
vram: "~7GB",
|
||||
context: "8K",
|
||||
},
|
||||
{
|
||||
id: "gemma-3-27b",
|
||||
name: "Gemma 3 27B",
|
||||
type: "text",
|
||||
params: "27B",
|
||||
vram: "~18GB",
|
||||
context: "128K",
|
||||
hfRepo: "unsloth/gemma-3-27b",
|
||||
recommended: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const DATASETS: DatasetOption[] = [
|
||||
|
|
@ -259,7 +236,9 @@ export const DEFAULT_HYPERPARAMS = {
|
|||
};
|
||||
|
||||
export function findModelById(id: string | null): ModelOption | undefined {
|
||||
if (!id) return undefined;
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
return MODELS.find((m) => m.id === id || m.hfRepo === id);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,9 @@ export function ExportDialog({
|
|||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export Model</DialogTitle>
|
||||
<DialogDescription>Choose where to save your exported model.</DialogDescription>
|
||||
<DialogDescription>
|
||||
Choose where to save your exported model.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-2">
|
||||
|
|
@ -94,18 +96,32 @@ export function ExportDialog({
|
|||
<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">
|
||||
<label className="text-xs font-medium text-muted-foreground">Username / Org</label>
|
||||
<Input placeholder="your-username" value={hfUsername} onChange={(e) => onHfUsernameChange(e.target.value)} />
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Username / Org
|
||||
</label>
|
||||
<Input
|
||||
placeholder="your-username"
|
||||
value={hfUsername}
|
||||
onChange={(e) => onHfUsernameChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Model Name</label>
|
||||
<Input placeholder="my-model-gguf" value={modelName} onChange={(e) => onModelNameChange(e.target.value)} />
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Model Name
|
||||
</label>
|
||||
<Input
|
||||
placeholder="my-model-gguf"
|
||||
value={modelName}
|
||||
onChange={(e) => onModelNameChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-muted-foreground">HF Write Token</label>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
HF Write Token
|
||||
</label>
|
||||
<a
|
||||
href="https://huggingface.co/settings/tokens"
|
||||
target="_blank"
|
||||
|
|
@ -113,21 +129,41 @@ export function ExportDialog({
|
|||
className="flex items-center gap-1 text-[11px] text-emerald-600 hover:text-emerald-700 transition-colors"
|
||||
>
|
||||
Get token
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} className="size-3" />
|
||||
<HugeiconsIcon
|
||||
icon={ArrowRight01Icon}
|
||||
className="size-3"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<HugeiconsIcon icon={Key01Icon} className="size-4" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput type="password" placeholder="hf_..." value={hfToken} onChange={(e) => onHfTokenChange(e.target.value)} />
|
||||
<InputGroupInput
|
||||
type="password"
|
||||
placeholder="hf_..."
|
||||
value={hfToken}
|
||||
onChange={(e) => onHfTokenChange(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<p className="text-[11px] text-muted-foreground/70">Leave empty if already logged in via CLI.</p>
|
||||
<p className="text-[11px] text-muted-foreground/70">
|
||||
Leave empty if already logged in via CLI.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="private-repo" size="sm" checked={privateRepo} onCheckedChange={onPrivateRepoChange} />
|
||||
<label htmlFor="private-repo" className="text-xs font-medium cursor-pointer">Private Repository</label>
|
||||
<Switch
|
||||
id="private-repo"
|
||||
size="sm"
|
||||
checked={privateRepo}
|
||||
onCheckedChange={onPrivateRepoChange}
|
||||
/>
|
||||
<label
|
||||
htmlFor="private-repo"
|
||||
className="text-xs font-medium cursor-pointer"
|
||||
>
|
||||
Private Repository
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
@ -153,7 +189,9 @@ export function ExportDialog({
|
|||
{exportMethod === "gguf" && quantLevels.length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span>Quantizations</span>
|
||||
<span className="font-medium text-foreground">{quantLevels.join(", ")}</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{quantLevels.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
|
|
@ -163,7 +201,9 @@ export function ExportDialog({
|
|||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>Start Export</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckmarkCircle01Icon, InformationCircleIcon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
CheckmarkCircle01Icon,
|
||||
InformationCircleIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { EXPORT_METHODS, type ExportMethod } from "../constants";
|
||||
|
||||
|
|
@ -20,14 +23,24 @@ export function MethodPicker({ value, onChange }: MethodPickerProps) {
|
|||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Export Method
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" className="text-foreground/70 hover:text-foreground">
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-foreground/70 hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={InformationCircleIcon} className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
How your model is packaged for deployment.{" "}
|
||||
<a href="https://unsloth.ai/docs/basics/inference-and-deployment" target="_blank" rel="noopener noreferrer" className="text-primary underline">Read more</a>
|
||||
<a
|
||||
href="https://unsloth.ai/docs/basics/inference-and-deployment"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
|
|
@ -49,34 +62,61 @@ export function MethodPicker({ value, onChange }: MethodPickerProps) {
|
|||
<div
|
||||
className={cn(
|
||||
"mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors",
|
||||
selected ? "border-primary bg-primary" : "border-muted-foreground/30",
|
||||
selected
|
||||
? "border-primary bg-primary"
|
||||
: "border-muted-foreground/30",
|
||||
)}
|
||||
>
|
||||
{selected && (
|
||||
<HugeiconsIcon icon={CheckmarkCircle01Icon} className="size-3 text-primary-foreground" />
|
||||
<HugeiconsIcon
|
||||
icon={CheckmarkCircle01Icon}
|
||||
className="size-3 text-primary-foreground"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{m.title}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="shrink-0 text-foreground/50 hover:text-foreground cursor-help" onClick={(e) => e.stopPropagation()}>
|
||||
<HugeiconsIcon icon={InformationCircleIcon} className="size-3" />
|
||||
<TooltipTrigger asChild={true}>
|
||||
<span
|
||||
className="shrink-0 text-foreground/50 hover:text-foreground cursor-help"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={InformationCircleIcon}
|
||||
className="size-3"
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
{m.tooltip}{" "}
|
||||
<a href={m.value === "gguf" ? "https://unsloth.ai/docs/basics/inference-and-deployment/saving-to-gguf" : "https://unsloth.ai/docs/basics/inference-and-deployment"} target="_blank" rel="noopener noreferrer" className="text-primary underline">Read more</a>
|
||||
<a
|
||||
href={
|
||||
m.value === "gguf"
|
||||
? "https://unsloth.ai/docs/basics/inference-and-deployment/saving-to-gguf"
|
||||
: "https://unsloth.ai/docs/basics/inference-and-deployment"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{m.badge && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{m.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{m.description}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{m.description}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckmarkCircle01Icon, InformationCircleIcon, LayersIcon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
CheckmarkCircle01Icon,
|
||||
InformationCircleIcon,
|
||||
LayersIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { QUANT_OPTIONS } from "../constants";
|
||||
|
||||
|
|
@ -23,20 +27,38 @@ export function QuantPicker({ value, onChange }: QuantPickerProps) {
|
|||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<HugeiconsIcon icon={LayersIcon} className="size-4 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground">Quantization Levels</span>
|
||||
<HugeiconsIcon
|
||||
icon={LayersIcon}
|
||||
className="size-4 text-muted-foreground"
|
||||
/>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Quantization Levels
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" className="text-foreground/70 hover:text-foreground">
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-foreground/70 hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={InformationCircleIcon} className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
Lower quantization (Q2, Q3) = smaller files but reduced quality. Q4–Q5 is a good balance.{" "}
|
||||
<a href="https://unsloth.ai/docs/basics/inference-and-deployment/saving-to-gguf" target="_blank" rel="noopener noreferrer" className="text-primary underline">Read more</a>
|
||||
Lower quantization (Q2, Q3) = smaller files but reduced quality.
|
||||
Q4–Q5 is a good balance.{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/basics/inference-and-deployment/saving-to-gguf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-[11px] text-muted-foreground/70">— select one or more</span>
|
||||
<span className="text-[11px] text-muted-foreground/70">
|
||||
— select one or more
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 py-1 pl-1">
|
||||
{QUANT_OPTIONS.map((q) => {
|
||||
|
|
@ -53,7 +75,12 @@ export function QuantPicker({ value, onChange }: QuantPickerProps) {
|
|||
: "ring-border text-muted-foreground hover:text-foreground hover:ring-foreground/20",
|
||||
)}
|
||||
>
|
||||
{active && <HugeiconsIcon icon={CheckmarkCircle01Icon} className="size-3" />}
|
||||
{active && (
|
||||
<HugeiconsIcon
|
||||
icon={CheckmarkCircle01Icon}
|
||||
className="size-3"
|
||||
/>
|
||||
)}
|
||||
{q.label}
|
||||
<span className="text-[10px] opacity-60">{q.size}</span>
|
||||
{q.recommended && !active && (
|
||||
|
|
|
|||
|
|
@ -9,9 +9,27 @@ export const EXPORT_METHODS: {
|
|||
tooltip: string;
|
||||
badge?: string;
|
||||
}[] = [
|
||||
{ value: "merged", title: "Merged Model", description: "Full 16-bit model ready for inference.", tooltip: "Merges adapter weights into the base model. Best for direct deployment with vLLM or TGI." },
|
||||
{ value: "lora", title: "LoRA Only", description: "Lightweight adapter files (~100 MB). Needs base model.", tooltip: "Exports only the trained adapter. Pair with the base model at inference time to save storage." },
|
||||
{ value: "gguf", title: "GGUF / Llama.cpp", description: "Quantized formats for local AI runners.", tooltip: "Converts to GGUF for llama.cpp, Ollama, and other local runners. Pick a quantization level below." },
|
||||
{
|
||||
value: "merged",
|
||||
title: "Merged Model",
|
||||
description: "Full 16-bit model ready for inference.",
|
||||
tooltip:
|
||||
"Merges adapter weights into the base model. Best for direct deployment with vLLM or TGI.",
|
||||
},
|
||||
{
|
||||
value: "lora",
|
||||
title: "LoRA Only",
|
||||
description: "Lightweight adapter files (~100 MB). Needs base model.",
|
||||
tooltip:
|
||||
"Exports only the trained adapter. Pair with the base model at inference time to save storage.",
|
||||
},
|
||||
{
|
||||
value: "gguf",
|
||||
title: "GGUF / Llama.cpp",
|
||||
description: "Quantized formats for local AI runners.",
|
||||
tooltip:
|
||||
"Converts to GGUF for llama.cpp, Ollama, and other local runners. Pick a quantization level below.",
|
||||
},
|
||||
];
|
||||
|
||||
export const QUANT_OPTIONS = [
|
||||
|
|
@ -27,17 +45,27 @@ export const QUANT_OPTIONS = [
|
|||
{ value: "f16", label: "F16", size: "~14.2 GB" },
|
||||
];
|
||||
|
||||
export function getEstimatedSize(method: ExportMethod | null, quantLevels: string[]) {
|
||||
const sizeOf = (v: string) => QUANT_OPTIONS.find((q) => q.value === v)?.size ?? "—";
|
||||
export function getEstimatedSize(
|
||||
method: ExportMethod | null,
|
||||
quantLevels: string[],
|
||||
) {
|
||||
const sizeOf = (v: string) =>
|
||||
QUANT_OPTIONS.find((q) => q.value === v)?.size ?? "—";
|
||||
if (method === "gguf" && quantLevels.length > 0) {
|
||||
if (quantLevels.length === 1) return sizeOf(quantLevels[0]);
|
||||
if (quantLevels.length === 1) {
|
||||
return sizeOf(quantLevels[0]);
|
||||
}
|
||||
const total = quantLevels
|
||||
.map((q) => Number.parseFloat(sizeOf(q).replace(/[^0-9.]/g, "")))
|
||||
.reduce((a, b) => a + b, 0);
|
||||
return `~${total.toFixed(1)} GB (${quantLevels.length} files)`;
|
||||
}
|
||||
if (method === "merged") return "~14.2 GB";
|
||||
if (method === "lora") return "~100 MB";
|
||||
if (method === "merged") {
|
||||
return "~14.2 GB";
|
||||
}
|
||||
if (method === "lora") {
|
||||
return "~100 MB";
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { SectionCard } from "@/components/section-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -7,15 +8,14 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import { findModelById } from "@/config/training";
|
||||
import { useWizardStore } from "@/stores/training";
|
||||
import { isAdapterMethod } from "@/types/training";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { findModelById } from "@/config/training";
|
||||
import { useWizardStore } from "@/stores/training";
|
||||
import { isAdapterMethod } from "@/types/training";
|
||||
import { InformationCircleIcon, PackageIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
|
@ -33,21 +33,32 @@ import {
|
|||
} from "./constants";
|
||||
|
||||
export function ExportPage() {
|
||||
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 {
|
||||
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 modelInfo = useMemo(
|
||||
() => findModelById(selectedModel),
|
||||
[selectedModel],
|
||||
);
|
||||
|
||||
const checkpoints = useMemo(() => {
|
||||
if (isAdapter) {
|
||||
|
|
@ -55,7 +66,11 @@ export function ExportPage() {
|
|||
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);
|
||||
const loss = (
|
||||
1.5 -
|
||||
(step / total) * 0.7 +
|
||||
Math.random() * 0.05
|
||||
).toFixed(2);
|
||||
entries.push({
|
||||
value: `checkpoint-${step}`,
|
||||
label: `checkpoint-${step}`,
|
||||
|
|
@ -64,7 +79,13 @@ export function ExportPage() {
|
|||
}
|
||||
return entries.reverse();
|
||||
}
|
||||
return [{ value: "final-model", label: "Final Model", detail: "Full fine-tuned weights" }];
|
||||
return [
|
||||
{
|
||||
value: "final-model",
|
||||
label: "Final Model",
|
||||
detail: "Full fine-tuned weights",
|
||||
},
|
||||
];
|
||||
}, [isAdapter, saveSteps, trainingMetrics?.totalSteps]);
|
||||
|
||||
const [checkpoint, setCheckpoint] = useState<string | null>(null);
|
||||
|
|
@ -79,19 +100,28 @@ export function ExportPage() {
|
|||
|
||||
const handleMethodChange = (method: ExportMethod) => {
|
||||
setExportMethod(method);
|
||||
if (method !== "gguf") setQuantLevels([]);
|
||||
if (method !== "gguf") {
|
||||
setQuantLevels([]);
|
||||
}
|
||||
};
|
||||
|
||||
const estimatedSize = getEstimatedSize(exportMethod, quantLevels);
|
||||
const canExport = checkpoint && exportMethod && (exportMethod !== "gguf" || quantLevels.length > 0);
|
||||
const canExport =
|
||||
checkpoint &&
|
||||
exportMethod &&
|
||||
(exportMethod !== "gguf" || quantLevels.length > 0);
|
||||
const baseModelName = modelInfo?.name ?? selectedModel ?? "—";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<main className="mx-auto max-w-7xl px-6 py-8">
|
||||
<div className="mb-8 flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Export Model</h1>
|
||||
<p className="text-sm text-muted-foreground">Export your fine-tuned model for deployment</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Export Model
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Export your fine-tuned model for deployment
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SectionCard
|
||||
|
|
@ -99,7 +129,7 @@ export function ExportPage() {
|
|||
title="Export Configuration"
|
||||
description="Select checkpoint, method, and quantization"
|
||||
accent="emerald"
|
||||
featured
|
||||
featured={true}
|
||||
className="shadow-border ring-1 ring-border"
|
||||
>
|
||||
{/* Top row: Checkpoint + metadata | Guide */}
|
||||
|
|
@ -109,27 +139,47 @@ export function ExportPage() {
|
|||
<label className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
{isAdapter ? "Checkpoint" : "Model"}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" className="text-foreground/70 hover:text-foreground">
|
||||
<HugeiconsIcon icon={InformationCircleIcon} className="size-3" />
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-foreground/70 hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={InformationCircleIcon}
|
||||
className="size-3"
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Choose a saved checkpoint to export. Lower loss generally means better quality.{" "}
|
||||
<a href="https://unsloth.ai/docs/basics/inference-and-deployment" target="_blank" rel="noopener noreferrer" className="text-primary underline">Read more</a>
|
||||
Choose a saved checkpoint to export. Lower loss generally
|
||||
means better quality.{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/basics/inference-and-deployment"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Select value={checkpoint ?? ""} onValueChange={setCheckpoint}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={isAdapter ? "Select a checkpoint…" : "Select model…"} />
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isAdapter ? "Select a checkpoint…" : "Select model…"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{checkpoints.map((cp) => (
|
||||
<SelectItem key={cp.value} value={cp.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
{cp.label}
|
||||
<span className="text-muted-foreground text-xs">{cp.detail}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{cp.detail}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
@ -138,7 +188,9 @@ export function ExportPage() {
|
|||
</div>
|
||||
|
||||
<div className="rounded-xl bg-muted/50 p-3 flex flex-col gap-2">
|
||||
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">Training Info</span>
|
||||
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Training Info
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Base Model</span>
|
||||
|
|
@ -146,7 +198,9 @@ export function ExportPage() {
|
|||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Method</span>
|
||||
<span className="font-medium">{METHOD_LABELS[trainingMethod] ?? trainingMethod}</span>
|
||||
<span className="font-medium">
|
||||
{METHOD_LABELS[trainingMethod] ?? trainingMethod}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Checkpoints</span>
|
||||
|
|
@ -173,10 +227,15 @@ export function ExportPage() {
|
|||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Quick Guide</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Quick Guide
|
||||
</span>
|
||||
<ol className="flex flex-col gap-3">
|
||||
{GUIDE_STEPS.map((step, i) => (
|
||||
<li key={step} className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<li
|
||||
key={step}
|
||||
className="flex items-start gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-semibold">
|
||||
{i + 1}
|
||||
</span>
|
||||
|
|
@ -200,7 +259,10 @@ export function ExportPage() {
|
|||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<HugeiconsIcon icon={InformationCircleIcon} className="size-3.5" />
|
||||
<HugeiconsIcon
|
||||
icon={InformationCircleIcon}
|
||||
className="size-3.5"
|
||||
/>
|
||||
<span>Est. size: {estimatedSize} · Free disk space: 120 GB</span>
|
||||
</div>
|
||||
<Button disabled={!canExport} onClick={() => setDialogOpen(true)}>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,11 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { DATASETS } from "@/config/training";
|
||||
import { useDebouncedValue, useHfDatasetSearch, useInfiniteScroll } from "@/hooks";
|
||||
import {
|
||||
useDebouncedValue,
|
||||
useHfDatasetSearch,
|
||||
useInfiniteScroll,
|
||||
} from "@/hooks";
|
||||
import { cn, formatCompact } from "@/lib/utils";
|
||||
import { useWizardStore } from "@/stores/training";
|
||||
import type { DatasetFormat } from "@/types/training";
|
||||
|
|
@ -83,23 +87,53 @@ export function DatasetStep() {
|
|||
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const debouncedQuery = useDebouncedValue(inputValue);
|
||||
const { results: hfResults, isLoading, isLoadingMore, hasMore, fetchMore } = useHfDatasetSearch(debouncedQuery, {
|
||||
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)),
|
||||
() =>
|
||||
[...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; recommended?: boolean }>();
|
||||
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 });
|
||||
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 });
|
||||
map.set(r.id, {
|
||||
label: r.id,
|
||||
downloads: r.downloads,
|
||||
totalExamples: r.totalExamples,
|
||||
sizeCategory: r.sizeCategory,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
|
|
@ -111,14 +145,24 @@ export function DatasetStep() {
|
|||
}
|
||||
const q = debouncedQuery.toLowerCase();
|
||||
const curatedIds = curatedDatasets
|
||||
.filter((d) => d.name.toLowerCase().includes(q) || d.id.toLowerCase().includes(q))
|
||||
.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));
|
||||
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)])],
|
||||
() => [
|
||||
...new Set([
|
||||
...curatedDatasets.map((d) => d.id),
|
||||
...hfResults.map((r) => r.id),
|
||||
]),
|
||||
],
|
||||
[curatedDatasets, hfResults],
|
||||
);
|
||||
|
||||
|
|
@ -204,35 +248,56 @@ export function DatasetStep() {
|
|||
itemToStringValue={(id) => datasetMap.get(id)?.label ?? id}
|
||||
autoHighlight={true}
|
||||
>
|
||||
<ComboboxInput placeholder="Search datasets..." className="w-full">
|
||||
<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>
|
||||
<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]">
|
||||
<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);
|
||||
const rowLabel =
|
||||
meta?.size ??
|
||||
(meta?.totalExamples
|
||||
? `${formatCompact(meta.totalExamples)} rows`
|
||||
: null);
|
||||
return (
|
||||
<ComboboxItem key={id} value={id} className="justify-between">
|
||||
<ComboboxItem
|
||||
key={id}
|
||||
value={id}
|
||||
className="justify-between"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<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>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{meta.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs break-all">
|
||||
<TooltipContent
|
||||
side="left"
|
||||
className="max-w-xs break-all"
|
||||
>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -31,8 +31,12 @@ import {
|
|||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { MODEL_TYPE_TO_HF_TASK, MODELS } from "@/config/training";
|
||||
import { useDebouncedValue, useHfModelSearch, useInfiniteScroll } from "@/hooks";
|
||||
import { MODELS, MODEL_TYPE_TO_HF_TASK } 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";
|
||||
|
|
@ -69,26 +73,51 @@ export function ModelSelectionStep() {
|
|||
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, {
|
||||
const {
|
||||
results: hfResults,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
fetchMore,
|
||||
} = useHfModelSearch(debouncedQuery, {
|
||||
task,
|
||||
accessToken: hfToken || undefined,
|
||||
});
|
||||
|
||||
const curatedModels = useMemo(() => {
|
||||
if (!modelType) return [];
|
||||
if (!modelType) {
|
||||
return [];
|
||||
}
|
||||
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 }>();
|
||||
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 });
|
||||
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 });
|
||||
map.set(r.id, {
|
||||
label: r.id,
|
||||
downloads: r.downloads,
|
||||
totalParams: r.totalParams,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
|
|
@ -100,15 +129,32 @@ export function ModelSelectionStep() {
|
|||
}
|
||||
const q = debouncedQuery.toLowerCase();
|
||||
const curatedIds = curatedModels
|
||||
.filter((m) => m.name.toLowerCase().includes(q) || m.id.toLowerCase().includes(q) || m.hfRepo?.toLowerCase().includes(q))
|
||||
.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));
|
||||
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 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 selectedModelData = MODELS.find(
|
||||
(m) => m.id === selectedModel || m.hfRepo === selectedModel,
|
||||
);
|
||||
const comboboxAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore);
|
||||
|
||||
|
|
@ -189,36 +235,59 @@ export function ModelSelectionStep() {
|
|||
</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>
|
||||
<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]">
|
||||
<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);
|
||||
const sizeLabel =
|
||||
meta?.params ??
|
||||
(meta?.totalParams
|
||||
? formatCompact(meta.totalParams)
|
||||
: null);
|
||||
return (
|
||||
<ComboboxItem key={id} value={id} className="justify-between">
|
||||
<ComboboxItem
|
||||
key={id}
|
||||
value={id}
|
||||
className="justify-between"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{label}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs break-all">
|
||||
<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">
|
||||
<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>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
↓{formatCompact(meta.downloads)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</ComboboxItem>
|
||||
|
|
@ -271,7 +340,8 @@ export function ModelSelectionStep() {
|
|||
</Tooltip>
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
Choose how to fine-tune {selectedModelData?.name ?? selectedModel}
|
||||
Choose how to fine-tune{" "}
|
||||
{selectedModelData?.name ?? selectedModel}
|
||||
</FieldDescription>
|
||||
</div>
|
||||
<Select
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DATASETS, findModelById } from "@/config/training";
|
||||
import { isAdapterMethod } from "@/types/training";
|
||||
import { useWizardStore } from "@/stores/training";
|
||||
import { isAdapterMethod } from "@/types/training";
|
||||
import { GpuIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
|
|
|||
|
|
@ -13,10 +13,7 @@ import {
|
|||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import {
|
||||
InputGroupAddon,
|
||||
} from "@/components/ui/input-group";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { InputGroupAddon } from "@/components/ui/input-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -24,13 +21,18 @@ 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 { useDebouncedValue, useHfDatasetSearch, useInfiniteScroll } from "@/hooks";
|
||||
import {
|
||||
useDebouncedValue,
|
||||
useHfDatasetSearch,
|
||||
useInfiniteScroll,
|
||||
} from "@/hooks";
|
||||
import { formatCompact } from "@/lib/utils";
|
||||
import { useWizardStore } from "@/stores/training";
|
||||
import {
|
||||
|
|
@ -61,23 +63,51 @@ export function DatasetSection() {
|
|||
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const debouncedQuery = useDebouncedValue(inputValue);
|
||||
const { results: hfResults, isLoading, isLoadingMore, hasMore, fetchMore } = useHfDatasetSearch(debouncedQuery, {
|
||||
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)),
|
||||
() =>
|
||||
[...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 }>();
|
||||
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 });
|
||||
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 });
|
||||
map.set(r.id, {
|
||||
label: r.id,
|
||||
downloads: r.downloads,
|
||||
totalExamples: r.totalExamples,
|
||||
sizeCategory: r.sizeCategory,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
|
|
@ -89,14 +119,24 @@ export function DatasetSection() {
|
|||
}
|
||||
const q = debouncedQuery.toLowerCase();
|
||||
const curatedIds = curatedDatasets
|
||||
.filter((d) => d.name.toLowerCase().includes(q) || d.id.toLowerCase().includes(q))
|
||||
.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));
|
||||
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)])],
|
||||
() => [
|
||||
...new Set([
|
||||
...curatedDatasets.map((d) => d.id),
|
||||
...hfResults.map((r) => r.id),
|
||||
]),
|
||||
],
|
||||
[curatedDatasets, hfResults],
|
||||
);
|
||||
|
||||
|
|
@ -129,7 +169,8 @@ export function DatasetSection() {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Search Hugging Face datasets or enter a 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"
|
||||
|
|
@ -152,30 +193,51 @@ export function DatasetSection() {
|
|||
itemToStringValue={(id) => datasetMap.get(id)?.label ?? id}
|
||||
autoHighlight={true}
|
||||
>
|
||||
<ComboboxInput placeholder="Search datasets..." className="w-full">
|
||||
<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>
|
||||
<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]">
|
||||
<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);
|
||||
const rowLabel =
|
||||
meta?.size ??
|
||||
(meta?.totalExamples
|
||||
? `${formatCompact(meta.totalExamples)} rows`
|
||||
: null);
|
||||
return (
|
||||
<ComboboxItem key={id} value={id} className="justify-between">
|
||||
<ComboboxItem
|
||||
key={id}
|
||||
value={id}
|
||||
className="justify-between"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{label}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs break-all">
|
||||
<TooltipContent
|
||||
side="left"
|
||||
className="max-w-xs break-all"
|
||||
>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,12 @@ import {
|
|||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { MODEL_TYPE_TO_HF_TASK, MODELS } from "@/config/training";
|
||||
import { useDebouncedValue, useHfModelSearch, useInfiniteScroll } from "@/hooks";
|
||||
import { MODELS, MODEL_TYPE_TO_HF_TASK } 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";
|
||||
|
|
@ -76,26 +80,51 @@ export function ModelSection() {
|
|||
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, {
|
||||
const {
|
||||
results: hfResults,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
fetchMore,
|
||||
} = useHfModelSearch(debouncedQuery, {
|
||||
task,
|
||||
accessToken: hfToken || undefined,
|
||||
});
|
||||
|
||||
const curatedModels = useMemo(() => {
|
||||
if (!modelType) return MODELS;
|
||||
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 }>();
|
||||
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 });
|
||||
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 });
|
||||
map.set(r.id, {
|
||||
label: r.id,
|
||||
downloads: r.downloads,
|
||||
totalParams: r.totalParams,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
|
|
@ -107,14 +136,26 @@ export function ModelSection() {
|
|||
}
|
||||
const q = debouncedQuery.toLowerCase();
|
||||
const curatedIds = curatedModels
|
||||
.filter((m) => m.name.toLowerCase().includes(q) || m.id.toLowerCase().includes(q) || m.hfRepo?.toLowerCase().includes(q))
|
||||
.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));
|
||||
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)])],
|
||||
() => [
|
||||
...new Set([
|
||||
...curatedModels.map((m) => m.hfRepo ?? m.id),
|
||||
...hfResults.map((r) => r.id),
|
||||
]),
|
||||
],
|
||||
[curatedModels, hfResults],
|
||||
);
|
||||
|
||||
|
|
@ -161,7 +202,10 @@ export function ModelSection() {
|
|||
placeholder="./models/my-model"
|
||||
value={
|
||||
selectedModel
|
||||
? (MODELS.find((m) => m.id === selectedModel || m.hfRepo === selectedModel)?.hfRepo ?? selectedModel)
|
||||
? (MODELS.find(
|
||||
(m) =>
|
||||
m.id === selectedModel || m.hfRepo === selectedModel,
|
||||
)?.hfRepo ?? selectedModel)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => setSelectedModel(e.target.value || null)}
|
||||
|
|
@ -222,19 +266,35 @@ export function ModelSection() {
|
|||
) : (
|
||||
<ComboboxEmpty>No models found</ComboboxEmpty>
|
||||
)}
|
||||
<div ref={scrollRef} className="max-h-64 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
|
||||
<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);
|
||||
const sizeLabel =
|
||||
meta?.params ??
|
||||
(meta?.totalParams
|
||||
? formatCompact(meta.totalParams)
|
||||
: null);
|
||||
return (
|
||||
<ComboboxItem key={id} value={id} className="justify-between">
|
||||
<ComboboxItem
|
||||
key={id}
|
||||
value={id}
|
||||
className="justify-between"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{label}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs break-all">
|
||||
<TooltipContent
|
||||
side="left"
|
||||
className="max-w-xs break-all"
|
||||
>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ interface CardDataWithInfo {
|
|||
function extractTotalExamples(
|
||||
cardData: CardDataWithInfo | undefined,
|
||||
): number | undefined {
|
||||
if (!cardData?.dataset_info) return undefined;
|
||||
if (!cardData?.dataset_info) {
|
||||
return undefined;
|
||||
}
|
||||
const infos = Array.isArray(cardData.dataset_info)
|
||||
? cardData.dataset_info
|
||||
: [cardData.dataset_info];
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ async function pullBatch<T>(
|
|||
const items: T[] = [];
|
||||
for (let i = 0; i < size; i++) {
|
||||
const result = await iter.next();
|
||||
if (result.done) return { items, done: true };
|
||||
if (result.done) {
|
||||
return { items, done: true };
|
||||
}
|
||||
items.push(mapItem(result.value));
|
||||
}
|
||||
return { items, done: false };
|
||||
|
|
@ -67,7 +69,9 @@ export function useHfPaginatedSearch<T>(
|
|||
|
||||
pullBatch(iter, mapItem, BATCH)
|
||||
.then(({ items, done }) => {
|
||||
if (versionRef.current !== v) return;
|
||||
if (versionRef.current !== v) {
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
results: items,
|
||||
isLoading: false,
|
||||
|
|
@ -77,7 +81,9 @@ export function useHfPaginatedSearch<T>(
|
|||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
if (versionRef.current !== v) return;
|
||||
if (versionRef.current !== v) {
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
results: [],
|
||||
isLoading: false,
|
||||
|
|
@ -91,14 +97,18 @@ export function useHfPaginatedSearch<T>(
|
|||
const fetchMore = useCallback(() => {
|
||||
const iter = iterRef.current;
|
||||
const { isLoading, isLoadingMore, hasMore } = stateRef.current;
|
||||
if (!iter || isLoading || isLoadingMore || !hasMore) return;
|
||||
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;
|
||||
if (versionRef.current !== v) {
|
||||
return;
|
||||
}
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
results: [...prev.results, ...items],
|
||||
|
|
@ -107,7 +117,9 @@ export function useHfPaginatedSearch<T>(
|
|||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
if (versionRef.current !== v) return;
|
||||
if (versionRef.current !== v) {
|
||||
return;
|
||||
}
|
||||
setState((prev) => ({ ...prev, isLoadingMore: false, hasMore: false }));
|
||||
});
|
||||
}, [mapItem]);
|
||||
|
|
|
|||
|
|
@ -6,10 +6,14 @@ export function useInfiniteScroll(fetchMore: () => void) {
|
|||
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const obs = new IntersectionObserver(
|
||||
([e]) => {
|
||||
if (e.isIntersecting) fetchMore();
|
||||
if (e.isIntersecting) {
|
||||
fetchMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0, root: scrollRef.current },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile
|
||||
return !!isMobile;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue