Integration of the api with the EXPORT page with UI changes
This commit is contained in:
parent
c7b7ecab4f
commit
6f9dd90d56
3 changed files with 597 additions and 180 deletions
127
studio/frontend/src/features/export/api/export-api.ts
Normal file
127
studio/frontend/src/features/export/api/export-api.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { authFetch } from "@/features/auth";
|
||||
|
||||
async function readError(response: Response): Promise<string> {
|
||||
try {
|
||||
const payload = (await response.json()) as { detail?: string; message?: string };
|
||||
return payload.detail || payload.message || `Request failed (${response.status})`;
|
||||
} catch {
|
||||
return `Request failed (${response.status})`;
|
||||
}
|
||||
}
|
||||
|
||||
async function parseJson<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response));
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export interface CheckpointInfo {
|
||||
display_name: string;
|
||||
path: string;
|
||||
loss?: number | null;
|
||||
}
|
||||
|
||||
export interface ModelCheckpoints {
|
||||
name: string;
|
||||
checkpoints: CheckpointInfo[];
|
||||
base_model?: string | null;
|
||||
peft_type?: string | null;
|
||||
lora_rank?: number | null;
|
||||
}
|
||||
|
||||
export interface CheckpointListResponse {
|
||||
outputs_dir: string;
|
||||
models: ModelCheckpoints[];
|
||||
}
|
||||
|
||||
export interface ExportOperationResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
details?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export async function fetchCheckpoints(): Promise<CheckpointListResponse> {
|
||||
const response = await authFetch("/api/models/checkpoints");
|
||||
return parseJson<CheckpointListResponse>(response);
|
||||
}
|
||||
|
||||
export async function loadCheckpoint(params: {
|
||||
checkpoint_path: string;
|
||||
max_seq_length?: number;
|
||||
load_in_4bit?: boolean;
|
||||
}): Promise<ExportOperationResponse> {
|
||||
const response = await authFetch("/api/export/load-checkpoint", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
return parseJson<ExportOperationResponse>(response);
|
||||
}
|
||||
|
||||
export async function exportMerged(params: {
|
||||
save_directory: string;
|
||||
format_type?: string;
|
||||
push_to_hub?: boolean;
|
||||
repo_id?: string | null;
|
||||
hf_token?: string | null;
|
||||
private?: boolean;
|
||||
}): Promise<ExportOperationResponse> {
|
||||
const response = await authFetch("/api/export/export/merged", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
return parseJson<ExportOperationResponse>(response);
|
||||
}
|
||||
|
||||
export async function exportBase(params: {
|
||||
save_directory: string;
|
||||
push_to_hub?: boolean;
|
||||
repo_id?: string | null;
|
||||
hf_token?: string | null;
|
||||
private?: boolean;
|
||||
base_model_id?: string | null;
|
||||
}): Promise<ExportOperationResponse> {
|
||||
const response = await authFetch("/api/export/export/base", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
return parseJson<ExportOperationResponse>(response);
|
||||
}
|
||||
|
||||
export async function exportGGUF(params: {
|
||||
save_directory: string;
|
||||
quantization_method: string;
|
||||
push_to_hub?: boolean;
|
||||
repo_id?: string | null;
|
||||
hf_token?: string | null;
|
||||
}): Promise<ExportOperationResponse> {
|
||||
const response = await authFetch("/api/export/export/gguf", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
return parseJson<ExportOperationResponse>(response);
|
||||
}
|
||||
|
||||
export async function exportLoRA(params: {
|
||||
save_directory: string;
|
||||
push_to_hub?: boolean;
|
||||
repo_id?: string | null;
|
||||
hf_token?: string | null;
|
||||
private?: boolean;
|
||||
}): Promise<ExportOperationResponse> {
|
||||
const response = await authFetch("/api/export/export/lora", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
return parseJson<ExportOperationResponse>(response);
|
||||
}
|
||||
|
||||
export async function cleanupExport(): Promise<ExportOperationResponse> {
|
||||
const response = await authFetch("/api/export/cleanup", { method: "POST" });
|
||||
return parseJson<ExportOperationResponse>(response);
|
||||
}
|
||||
|
|
@ -13,8 +13,9 @@ import {
|
|||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ArrowRight01Icon, Key01Icon } from "@hugeicons/core-free-icons";
|
||||
import { AlertCircleIcon, ArrowRight01Icon, CheckmarkCircle02Icon, Key01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { collapseAnim } from "../anim";
|
||||
|
|
@ -41,6 +42,10 @@ interface ExportDialogProps {
|
|||
onHfTokenChange: (v: string) => void;
|
||||
privateRepo: boolean;
|
||||
onPrivateRepoChange: (v: boolean) => void;
|
||||
onExport: () => void;
|
||||
exporting: boolean;
|
||||
exportError: string | null;
|
||||
exportSuccess: boolean;
|
||||
}
|
||||
|
||||
export function ExportDialog({
|
||||
|
|
@ -62,10 +67,41 @@ export function ExportDialog({
|
|||
onHfTokenChange,
|
||||
privateRepo,
|
||||
onPrivateRepoChange,
|
||||
onExport,
|
||||
exporting,
|
||||
exportError,
|
||||
exportSuccess,
|
||||
}: ExportDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (exporting) return;
|
||||
onOpenChange(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg" onInteractOutside={(e) => { if (exporting) e.preventDefault(); }}>
|
||||
{exportSuccess ? (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="size-6 text-emerald-500" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold">Export Complete</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{destination === "hub"
|
||||
? "Model successfully pushed to Hugging Face Hub."
|
||||
: "Model saved locally."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>Done</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export Model</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
@ -77,6 +113,7 @@ export function ExportDialog({
|
|||
<Button
|
||||
variant={destination === "local" ? "dark" : "outline"}
|
||||
onClick={() => onDestinationChange("local")}
|
||||
disabled={exporting}
|
||||
className="flex-1"
|
||||
>
|
||||
Save Locally
|
||||
|
|
@ -84,6 +121,7 @@ export function ExportDialog({
|
|||
<Button
|
||||
variant={destination === "hub" ? "dark" : "outline"}
|
||||
onClick={() => onDestinationChange("hub")}
|
||||
disabled={exporting}
|
||||
className="flex-1"
|
||||
>
|
||||
Push to Hub
|
||||
|
|
@ -103,6 +141,7 @@ export function ExportDialog({
|
|||
placeholder="your-username"
|
||||
value={hfUsername}
|
||||
onChange={(e) => onHfUsernameChange(e.target.value)}
|
||||
disabled={exporting}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
|
@ -113,6 +152,7 @@ export function ExportDialog({
|
|||
placeholder="my-model-gguf"
|
||||
value={modelName}
|
||||
onChange={(e) => onModelNameChange(e.target.value)}
|
||||
disabled={exporting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -144,6 +184,7 @@ export function ExportDialog({
|
|||
placeholder="hf_..."
|
||||
value={hfToken}
|
||||
onChange={(e) => onHfTokenChange(e.target.value)}
|
||||
disabled={exporting}
|
||||
/>
|
||||
</InputGroup>
|
||||
<p className="text-[11px] text-muted-foreground/70">
|
||||
|
|
@ -157,6 +198,7 @@ export function ExportDialog({
|
|||
size="sm"
|
||||
checked={privateRepo}
|
||||
onCheckedChange={onPrivateRepoChange}
|
||||
disabled={exporting}
|
||||
/>
|
||||
<label
|
||||
htmlFor="private-repo"
|
||||
|
|
@ -170,6 +212,14 @@ export function ExportDialog({
|
|||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Error banner */}
|
||||
{exportError && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<HugeiconsIcon icon={AlertCircleIcon} className="size-4 mt-0.5 shrink-0" />
|
||||
<span>{exportError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<div className="rounded-xl bg-muted/50 p-3 text-xs text-muted-foreground flex flex-col gap-1">
|
||||
<div className="flex justify-between">
|
||||
|
|
@ -194,18 +244,34 @@ export function ExportDialog({
|
|||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
{/* TODO: unhide once estimated size comes from the backend API */}
|
||||
{/* <div className="flex justify-between">
|
||||
<span>Est. size</span>
|
||||
<span className="font-medium text-foreground">{estimatedSize}</span>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={exporting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>Start Export</Button>
|
||||
<Button onClick={onExport} disabled={exporting}>
|
||||
{exporting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
Exporting…
|
||||
</span>
|
||||
) : (
|
||||
"Start Export"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,80 +8,56 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useTrainingRuntimeStore } from "@/features/training";
|
||||
import { useTrainingConfigStore } from "@/features/training";
|
||||
import { isAdapterMethod } from "@/types/training";
|
||||
import { InformationCircleIcon, PackageIcon } from "@hugeicons/core-free-icons";
|
||||
import { AlertCircleIcon, InformationCircleIcon, PackageIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { collapseAnim } from "./anim";
|
||||
import type { ModelCheckpoints } from "./api/export-api";
|
||||
import {
|
||||
cleanupExport,
|
||||
exportBase,
|
||||
exportGGUF,
|
||||
exportLoRA,
|
||||
exportMerged,
|
||||
fetchCheckpoints,
|
||||
loadCheckpoint,
|
||||
} from "./api/export-api";
|
||||
import { ExportDialog } from "./components/export-dialog";
|
||||
import { MethodPicker } from "./components/method-picker";
|
||||
import { QuantPicker } from "./components/quant-picker";
|
||||
import {
|
||||
type ExportMethod,
|
||||
GUIDE_STEPS,
|
||||
METHOD_LABELS,
|
||||
getEstimatedSize,
|
||||
} from "./constants";
|
||||
import { GuidedTour, useGuidedTourController } from "@/features/tour";
|
||||
import { exportTourSteps } from "./tour";
|
||||
|
||||
export function ExportPage() {
|
||||
const {
|
||||
trainingMethod,
|
||||
selectedModel,
|
||||
saveSteps,
|
||||
epochs,
|
||||
loraRank,
|
||||
hfToken,
|
||||
setHfToken,
|
||||
} = useTrainingConfigStore(
|
||||
const { hfToken, setHfToken } = useTrainingConfigStore(
|
||||
useShallow((s) => ({
|
||||
trainingMethod: s.trainingMethod,
|
||||
selectedModel: s.selectedModel,
|
||||
saveSteps: s.saveSteps,
|
||||
epochs: s.epochs,
|
||||
loraRank: s.loraRank,
|
||||
hfToken: s.hfToken,
|
||||
setHfToken: s.setHfToken,
|
||||
})),
|
||||
);
|
||||
const totalSteps = useTrainingRuntimeStore((state) => state.totalSteps);
|
||||
const isAdapter = isAdapterMethod(trainingMethod);
|
||||
|
||||
const checkpoints = useMemo(() => {
|
||||
if (isAdapter) {
|
||||
const interval = saveSteps > 0 ? saveSteps : 100;
|
||||
const total = totalSteps > 0 ? 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).toFixed(2);
|
||||
entries.push({
|
||||
value: `checkpoint-${step}`,
|
||||
label: `checkpoint-${step}`,
|
||||
detail: step === total ? `Best Loss: ${loss}` : `Loss: ${loss}`,
|
||||
});
|
||||
}
|
||||
return entries.reverse();
|
||||
}
|
||||
return [
|
||||
{
|
||||
value: "final-model",
|
||||
label: "Final Model",
|
||||
detail: "Full fine-tuned weights",
|
||||
},
|
||||
];
|
||||
}, [isAdapter, saveSteps, totalSteps]);
|
||||
// ---- API-driven checkpoint state ----
|
||||
const [models, setModels] = useState<ModelCheckpoints[]>([]);
|
||||
const [loadingCheckpoints, setLoadingCheckpoints] = useState(true);
|
||||
const [checkpointError, setCheckpointError] = useState<string | null>(null);
|
||||
|
||||
const [selectedModelIdx, setSelectedModelIdx] = useState<string | null>(null);
|
||||
const [checkpoint, setCheckpoint] = useState<string | null>(null);
|
||||
|
||||
const [exportMethod, setExportMethod] = useState<ExportMethod | null>(null);
|
||||
const [quantLevels, setQuantLevels] = useState<string[]>([]);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
|
@ -91,11 +67,68 @@ export function ExportPage() {
|
|||
const [modelName, setModelName] = useState("");
|
||||
const [privateRepo, setPrivateRepo] = useState(false);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [exportSuccess, setExportSuccess] = useState(false);
|
||||
|
||||
const tour = useGuidedTourController({
|
||||
id: "export",
|
||||
steps: exportTourSteps,
|
||||
});
|
||||
|
||||
// ---- Fetch checkpoints on mount ----
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingCheckpoints(true);
|
||||
setCheckpointError(null);
|
||||
fetchCheckpoints()
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setModels(data.models);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setCheckpointError(
|
||||
err instanceof Error ? err.message : "Failed to load checkpoints",
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingCheckpoints(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ---- Derived state ----
|
||||
const selectedModelData = useMemo(
|
||||
() =>
|
||||
selectedModelIdx != null
|
||||
? models.find((m) => m.name === selectedModelIdx) ?? null
|
||||
: null,
|
||||
[models, selectedModelIdx],
|
||||
);
|
||||
|
||||
const checkpointsForModel = useMemo(
|
||||
() => selectedModelData?.checkpoints ?? [],
|
||||
[selectedModelData],
|
||||
);
|
||||
|
||||
// Derive training info from selected model's API metadata
|
||||
const baseModelName = selectedModelData?.base_model ?? "—";
|
||||
const isAdapter = !!selectedModelData?.peft_type;
|
||||
const loraRank = selectedModelData?.lora_rank ?? null;
|
||||
const trainingMethodLabel = selectedModelData?.peft_type
|
||||
? "LoRA / QLoRA"
|
||||
: "Full Fine-tune";
|
||||
|
||||
// Reset checkpoint when the selected model changes
|
||||
useEffect(() => {
|
||||
setCheckpoint(null);
|
||||
}, [selectedModelIdx]);
|
||||
|
||||
const handleMethodChange = (method: ExportMethod) => {
|
||||
setExportMethod(method);
|
||||
if (method !== "gguf") {
|
||||
|
|
@ -108,8 +141,100 @@ export function ExportPage() {
|
|||
checkpoint &&
|
||||
exportMethod &&
|
||||
(exportMethod !== "gguf" || quantLevels.length > 0);
|
||||
const baseModelName = selectedModel ?? "—";
|
||||
|
||||
// ---- Export handler ----
|
||||
const handleExport = useCallback(async () => {
|
||||
if (!checkpoint) return;
|
||||
|
||||
const selectedCp = checkpointsForModel.find(
|
||||
(cp) => cp.display_name === checkpoint,
|
||||
);
|
||||
if (!selectedCp) return;
|
||||
|
||||
setExporting(true);
|
||||
setExportError(null);
|
||||
setExportSuccess(false);
|
||||
|
||||
const saveDir = `./exports/${selectedModelIdx ?? "model"}/${checkpoint}`;
|
||||
const pushToHub = destination === "hub";
|
||||
const repoId = pushToHub && hfUsername && modelName
|
||||
? `${hfUsername}/${modelName}`
|
||||
: undefined;
|
||||
const token = pushToHub && hfToken ? hfToken : undefined;
|
||||
|
||||
try {
|
||||
// 1. Load checkpoint
|
||||
await loadCheckpoint({ checkpoint_path: selectedCp.path });
|
||||
|
||||
// 2. Run export based on method
|
||||
if (exportMethod === "merged") {
|
||||
if (isAdapter) {
|
||||
await exportMerged({
|
||||
save_directory: saveDir,
|
||||
push_to_hub: pushToHub,
|
||||
repo_id: repoId,
|
||||
hf_token: token,
|
||||
private: privateRepo,
|
||||
});
|
||||
} else {
|
||||
await exportBase({
|
||||
save_directory: saveDir,
|
||||
push_to_hub: pushToHub,
|
||||
repo_id: repoId,
|
||||
hf_token: token,
|
||||
private: privateRepo,
|
||||
base_model_id: selectedModelData?.base_model,
|
||||
});
|
||||
}
|
||||
} else if (exportMethod === "gguf") {
|
||||
for (const quant of quantLevels) {
|
||||
await exportGGUF({
|
||||
save_directory: saveDir,
|
||||
quantization_method: quant,
|
||||
push_to_hub: pushToHub,
|
||||
repo_id: repoId,
|
||||
hf_token: token,
|
||||
});
|
||||
}
|
||||
} else if (exportMethod === "lora") {
|
||||
await exportLoRA({
|
||||
save_directory: saveDir,
|
||||
push_to_hub: pushToHub,
|
||||
repo_id: repoId,
|
||||
hf_token: token,
|
||||
private: privateRepo,
|
||||
});
|
||||
}
|
||||
|
||||
setExportSuccess(true);
|
||||
} catch (err) {
|
||||
setExportError(
|
||||
err instanceof Error ? err.message : "Export failed",
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await cleanupExport();
|
||||
} catch {
|
||||
// cleanup is best-effort
|
||||
}
|
||||
setExporting(false);
|
||||
}
|
||||
}, [
|
||||
checkpoint,
|
||||
checkpointsForModel,
|
||||
selectedModelIdx,
|
||||
selectedModelData,
|
||||
exportMethod,
|
||||
isAdapter,
|
||||
quantLevels,
|
||||
destination,
|
||||
hfUsername,
|
||||
modelName,
|
||||
hfToken,
|
||||
privateRepo,
|
||||
]);
|
||||
|
||||
// ---- Render ----
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<main className="mx-auto max-w-7xl px-6 py-4">
|
||||
|
|
@ -132,141 +257,236 @@ export function ExportPage() {
|
|||
featured={true}
|
||||
className="shadow-border ring-1 ring-border"
|
||||
>
|
||||
{/* Top row: Checkpoint + metadata | Guide */}
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div className="flex flex-col gap-4 ">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
{isAdapter ? "Checkpoint" : "Model"}
|
||||
<Tooltip>
|
||||
<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>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Select value={checkpoint ?? ""} onValueChange={setCheckpoint}>
|
||||
<SelectTrigger data-tour="export-checkpoint" className="w-full">
|
||||
<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>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Loading / error states */}
|
||||
{loadingCheckpoints && (
|
||||
<div className="flex items-center gap-2 py-6 justify-center text-sm text-muted-foreground">
|
||||
<Spinner className="size-4" />
|
||||
Loading checkpoints…
|
||||
</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>
|
||||
<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>
|
||||
<span className="font-medium">{baseModelName}</span>
|
||||
{checkpointError && (
|
||||
<div className="flex items-center gap-2 py-6 justify-center text-sm text-destructive">
|
||||
<HugeiconsIcon icon={AlertCircleIcon} className="size-4" />
|
||||
{checkpointError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingCheckpoints && !checkpointError && (
|
||||
<>
|
||||
{/* Top row: Dropdowns + metadata | Guide */}
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Training run dropdown */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Training Run
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-foreground/70 hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={InformationCircleIcon}
|
||||
className="size-3"
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Select the training run that produced the checkpoints
|
||||
you want to export.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Select
|
||||
value={selectedModelIdx ?? ""}
|
||||
onValueChange={setSelectedModelIdx}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
models.length === 0
|
||||
? "No training runs found"
|
||||
: "Select a training run…"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((m) => {
|
||||
const tsMatch = m.name.match(/_(\d{10,})$/);
|
||||
const displayName = tsMatch ? m.name.slice(0, tsMatch.index) : m.name;
|
||||
const timeStr = tsMatch
|
||||
? new Date(Number(tsMatch[1]) * 1000).toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})
|
||||
: null;
|
||||
return (
|
||||
<SelectItem key={m.name} value={m.name}>
|
||||
<span className="flex items-center gap-2">
|
||||
{displayName}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{m.checkpoints.length} checkpoint
|
||||
{m.checkpoints.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{timeStr && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
· {timeStr}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Method</span>
|
||||
<span className="font-medium">
|
||||
{METHOD_LABELS[trainingMethod] ?? trainingMethod}
|
||||
|
||||
{/* Checkpoint dropdown */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Checkpoint
|
||||
<Tooltip>
|
||||
<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>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Select
|
||||
value={checkpoint ?? ""}
|
||||
onValueChange={setCheckpoint}
|
||||
disabled={!selectedModelIdx}
|
||||
>
|
||||
<SelectTrigger data-tour="export-checkpoint" className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!selectedModelIdx
|
||||
? "Select a training run first"
|
||||
: checkpointsForModel.length === 0
|
||||
? "No checkpoints found"
|
||||
: "Select a checkpoint…"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{checkpointsForModel.map((cp) => (
|
||||
<SelectItem key={cp.path} value={cp.display_name}>
|
||||
<span className="flex items-center gap-2">
|
||||
{cp.display_name}
|
||||
{cp.loss != null && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
loss: {cp.loss.toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Checkpoints</span>
|
||||
<span className="font-medium">{checkpoints.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">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">{loraRank}</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>
|
||||
<span className="font-medium">{baseModelName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Method</span>
|
||||
<span className="font-medium">
|
||||
{trainingMethodLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Checkpoints</span>
|
||||
<span className="font-medium">
|
||||
{checkpointsForModel.length}
|
||||
</span>
|
||||
</div>
|
||||
{isAdapter && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">LoRA Rank</span>
|
||||
<span className="font-medium">{loraRank}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<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"
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-semibold">
|
||||
{i + 1}
|
||||
</span>
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<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"
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-semibold">
|
||||
{i + 1}
|
||||
</span>
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<MethodPicker value={exportMethod} onChange={handleMethodChange} />
|
||||
|
||||
<MethodPicker value={exportMethod} onChange={handleMethodChange} />
|
||||
<AnimatePresence>
|
||||
{exportMethod === "gguf" && (
|
||||
<motion.div {...collapseAnim} className="overflow-hidden">
|
||||
<QuantPicker value={quantLevels} onChange={setQuantLevels} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{exportMethod === "gguf" && (
|
||||
<motion.div {...collapseAnim} className="overflow-hidden">
|
||||
<QuantPicker value={quantLevels} onChange={setQuantLevels} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<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"
|
||||
/>
|
||||
<span>Est. size: {estimatedSize} · Free disk space: 120 GB</span>
|
||||
</div>
|
||||
<Button
|
||||
data-tour="export-cta"
|
||||
disabled={!canExport}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
Export Model
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-end">
|
||||
{/* TODO: unhide once estimated size comes from the backend API */}
|
||||
{/* <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<HugeiconsIcon
|
||||
icon={InformationCircleIcon}
|
||||
className="size-3.5"
|
||||
/>
|
||||
<span>Est. size: {estimatedSize} · Free disk space: 120 GB</span>
|
||||
</div> */}
|
||||
<Button
|
||||
data-tour="export-cta"
|
||||
disabled={!canExport}
|
||||
onClick={() => { setExportSuccess(false); setExportError(null); setDialogOpen(true); }}
|
||||
>
|
||||
Export Model
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
</main>
|
||||
|
||||
|
|
@ -289,6 +509,10 @@ export function ExportPage() {
|
|||
onHfTokenChange={setHfToken}
|
||||
privateRepo={privateRepo}
|
||||
onPrivateRepoChange={setPrivateRepo}
|
||||
onExport={handleExport}
|
||||
exporting={exporting}
|
||||
exportError={exportError}
|
||||
exportSuccess={exportSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue