Integration of the api with the EXPORT page with UI changes

This commit is contained in:
Manan17 2026-02-17 00:34:48 +00:00
commit 6f9dd90d56
3 changed files with 597 additions and 180 deletions

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

View file

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

View file

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