studio: add HF/local model selection UI for GGUF export (#4365)

* feat(studio): add HF/local model selection UI for GGUF export

* fix(studio):fix selector ring clipping

* fix(studio): export page trust_remote_code control and label styling

* fix(studio): accept hf_token in load_checkpoint orchestrator method

The route was passing hf_token to load_checkpoint() but the method
didn't accept it, causing a TypeError on every /api/export/load-checkpoint
request.

* fix(studio): clear HF model selection when input is edited

Previously selectedSourceModel was only cleared when the input became
empty, so editing to a different repo ID after selecting a model would
silently keep the old selection.

---------

Co-authored-by: Roland Tannous <rolandtannous@gravityq.ai>
This commit is contained in:
Lee Jackson 2026-03-28 18:18:25 +00:00 committed by GitHub
commit 5d2dca801c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 613 additions and 156 deletions

View file

@ -217,6 +217,7 @@ class ExportOrchestrator:
max_seq_length: int = 2048,
load_in_4bit: bool = True,
trust_remote_code: bool = False,
hf_token: Optional[str] = None,
) -> Tuple[bool, str]:
"""Load a checkpoint for export.
@ -227,6 +228,7 @@ class ExportOrchestrator:
"max_seq_length": max_seq_length,
"load_in_4bit": load_in_4bit,
"trust_remote_code": trust_remote_code,
"hf_token": hf_token,
}
# Always kill existing subprocess and spawn fresh.

View file

@ -3,6 +3,19 @@
import { SectionCard } from "@/components/section-card";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import {
Select,
SelectContent,
@ -11,17 +24,34 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useTrainingConfigStore } from "@/features/training";
import { AlertCircleIcon, InformationCircleIcon, PackageIcon } from "@hugeicons/core-free-icons";
import {
listLocalModels,
type LocalModelInfo,
useTrainingConfigStore,
} from "@/features/training";
import {
useDebouncedValue,
useHfModelSearch,
useHfTokenValidation,
} from "@/hooks";
import {
AlertCircleIcon,
FolderSearchIcon,
InformationCircleIcon,
Key01Icon,
PackageIcon,
Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { collapseAnim } from "./anim";
import type { ModelCheckpoints } from "./api/export-api";
@ -60,6 +90,21 @@ export function ExportPage() {
const [selectedModelIdx, setSelectedModelIdx] = useState<string | null>(null);
const [checkpoint, setCheckpoint] = useState<string | null>(null);
const [sourceMode, setSourceMode] = useState<"checkpoint" | "model">(
"checkpoint",
);
const [modelSource, setModelSource] = useState<"hf" | "local">("hf");
const [hfExportTrustRemoteCode, setHfExportTrustRemoteCode] =
useState(true);
const [modelInput, setModelInput] = useState("");
const [selectedSourceModel, setSelectedSourceModel] = useState<string | null>(
null,
);
const [localModelInput, setLocalModelInput] = useState("");
const [localModels, setLocalModels] = useState<LocalModelInfo[]>([]);
const [isLoadingLocalModels, setIsLoadingLocalModels] = useState(true);
const [localModelsError, setLocalModelsError] = useState<string | null>(null);
const debouncedModelQuery = useDebouncedValue(modelInput);
const [exportMethod, setExportMethod] = useState<ExportMethod | null>(null);
const [quantLevels, setQuantLevels] = useState<string[]>([]);
@ -74,6 +119,9 @@ export function ExportPage() {
const [exportError, setExportError] = useState<string | null>(null);
const [exportSuccess, setExportSuccess] = useState(false);
const hfComboboxAnchorRef = useRef<HTMLDivElement>(null);
const localComboboxAnchorRef = useRef<HTMLDivElement>(null);
const tour = useGuidedTourController({
id: "export",
steps: exportTourSteps,
@ -105,6 +153,27 @@ export function ExportPage() {
};
}, []);
// ---- Fetch local models for direct export ----
useEffect(() => {
const controller = new AbortController();
void listLocalModels(controller.signal)
.then((models) => {
if (controller.signal.aborted) return;
setLocalModels(models);
})
.catch((error) => {
if (controller.signal.aborted) return;
setLocalModelsError(
error instanceof Error ? error.message : "Failed to load local models",
);
})
.finally(() => {
if (controller.signal.aborted) return;
setIsLoadingLocalModels(false);
});
return () => controller.abort();
}, []);
// ---- Derived state ----
const selectedModelData = useMemo(
() =>
@ -127,6 +196,83 @@ export function ExportPage() {
const trainingMethodLabel = selectedModelData?.peft_type
? "LoRA / QLoRA"
: "Full Fine-tune";
const sourceBaseModelName = sourceMode === "model"
? selectedSourceModel ?? "—"
: baseModelName;
const {
results: hfResults,
isLoading: isLoadingHfModels,
error: hfSearchError,
} = useHfModelSearch(debouncedModelQuery, {
accessToken: hfToken || undefined,
excludeGguf: true,
});
const { error: tokenValidationError, isChecking: isCheckingToken } =
useHfTokenValidation(hfToken);
const hfResultIds = useMemo(() => {
const ids = hfResults.map((r) => r.id);
if (
selectedSourceModel &&
modelSource === "hf" &&
!ids.includes(selectedSourceModel)
) {
ids.push(selectedSourceModel);
}
return ids;
}, [hfResults, modelSource, selectedSourceModel]);
const exportableLocalModels = useMemo(
() =>
localModels.filter((m) => {
if (m.path.endsWith(".gguf")) return false;
if (m.id.toLowerCase().includes("-gguf")) return false;
return true;
}),
[localModels],
);
const localMetaById = useMemo(() => {
const map = new Map<string, LocalModelInfo>();
for (const model of exportableLocalModels) map.set(model.id, model);
return map;
}, [exportableLocalModels]);
const localResultIds = useMemo(() => {
const ids = exportableLocalModels.map((model) => model.id);
const manual = localModelInput.trim();
if (manual && !ids.includes(manual)) {
ids.unshift(manual);
}
return ids;
}, [exportableLocalModels, localModelInput]);
const localFilteredIds = useMemo(() => {
const q = localModelInput.trim().toLowerCase();
if (!q) return localResultIds;
return localResultIds.filter((id) => {
const meta = localMetaById.get(id);
if (id.toLowerCase().includes(q)) return true;
if (meta?.display_name.toLowerCase().includes(q)) return true;
if (meta?.path.toLowerCase().includes(q)) return true;
return false;
});
}, [localMetaById, localModelInput, localResultIds]);
const exportGuideSteps = useMemo(
() =>
sourceMode === "model"
? [
"Select a Hugging Face or local model to export from",
"GGUF is used for non-finetuned model exports",
"Pick one or more GGUF quantization levels",
"Click Export and choose your destination",
"Test your model and compare outputs in Chat",
]
: GUIDE_STEPS,
[sourceMode],
);
// Reset checkpoint when the selected model changes
useEffect(() => {
@ -144,6 +290,25 @@ export function ExportPage() {
}
}, [isAdapter, isQuantized, exportMethod]);
const handleSourceModeSwitch = useCallback(
(next: "checkpoint" | "model") => {
setSourceMode(next);
if (next === "model") {
setExportMethod("gguf");
}
setSelectedSourceModel(null);
setLocalModelInput("");
setModelInput("");
},
[],
);
useEffect(() => {
setSelectedSourceModel(null);
setLocalModelInput("");
setModelInput("");
}, [modelSource]);
const handleMethodChange = (method: ExportMethod) => {
setExportMethod(method);
if (method !== "gguf") {
@ -152,19 +317,24 @@ export function ExportPage() {
};
const estimatedSize = getEstimatedSize(exportMethod, quantLevels);
const canExport =
checkpoint &&
const selectedExportSource =
sourceMode === "checkpoint" ? checkpoint : selectedSourceModel;
const canExport = !!(
selectedExportSource &&
exportMethod &&
(exportMethod !== "gguf" || quantLevels.length > 0);
(exportMethod !== "gguf" || quantLevels.length > 0)
);
// ---- Export handler ----
const handleExport = useCallback(async () => {
if (!checkpoint) return;
const source = sourceMode === "checkpoint" ? checkpoint : selectedSourceModel;
if (!source) return;
const selectedCp = checkpointsForModel.find(
(cp) => cp.display_name === checkpoint,
);
if (!selectedCp) return;
const selectedCp = sourceMode === "checkpoint"
? checkpointsForModel.find((cp) => cp.display_name === checkpoint)
: null;
if (sourceMode === "checkpoint" && !selectedCp) return;
const checkpointPath = selectedCp?.path;
setExporting(true);
setExportError(null);
@ -174,7 +344,8 @@ export function ExportPage() {
// For other formats, nest under training-run/checkpoint
const saveDir =
exportMethod === "gguf"
? `${baseModelName.split("/").pop() ?? selectedModelIdx ?? "model"}-finetune-gguf`
? `${(sourceBaseModelName.split("/").pop() ?? selectedModelIdx ?? "model")
.replace(/[^a-zA-Z0-9._-]/g, "-")}-gguf`
: `${selectedModelIdx ?? "model"}/${checkpoint}`;
const pushToHub = destination === "hub";
const repoId = pushToHub && hfUsername && modelName
@ -183,8 +354,18 @@ export function ExportPage() {
const token = pushToHub && hfToken ? hfToken : undefined;
try {
// 1. Load checkpoint
await loadCheckpoint({ checkpoint_path: selectedCp.path });
// 1. Load model source
if (sourceMode === "checkpoint") {
if (!checkpointPath) return;
await loadCheckpoint({ checkpoint_path: checkpointPath });
} else {
await loadCheckpoint({
checkpoint_path: source,
load_in_4bit: false,
trust_remote_code:
modelSource === "hf" ? hfExportTrustRemoteCode : true,
});
}
// 2. Run export based on method
if (exportMethod === "merged") {
@ -242,16 +423,21 @@ export function ExportPage() {
}, [
checkpoint,
checkpointsForModel,
sourceMode,
selectedSourceModel,
selectedModelIdx,
selectedModelData,
exportMethod,
isAdapter,
sourceBaseModelName,
quantLevels,
destination,
hfUsername,
modelName,
hfToken,
privateRepo,
modelSource,
hfExportTrustRemoteCode,
]);
// ---- Render ----
@ -265,14 +451,14 @@ export function ExportPage() {
Export Model
</h1>
<p className="text-sm text-muted-foreground">
Export your fine-tuned model for deployment
Export fine-tuned or base models for deployment
</p>
</div>
<SectionCard
icon={<HugeiconsIcon icon={PackageIcon} className="size-5" />}
title="Export Configuration"
description="Select checkpoint, method, and quantization"
description="Select source, method, and quantization"
accent="emerald"
featured={true}
className="shadow-border ring-1 ring-border"
@ -296,11 +482,10 @@ export function ExportPage() {
<>
{/* Top row: Dropdowns + metadata | Guide */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-8">
<div className="flex flex-col gap-4">
{/* Training run dropdown */}
<div data-tour="export-training-run" className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<div className="flex items-end justify-between">
<label className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
Training Run
{sourceMode === "checkpoint" ? "Training Run" : "Model Source"}
<Tooltip>
<TooltipTrigger asChild={true}>
<button
@ -314,147 +499,415 @@ export function ExportPage() {
</button>
</TooltipTrigger>
<TooltipContent>
Select the training run that produced the checkpoints
you want to export.
{sourceMode === "checkpoint"
? "Select the training run that produced the checkpoints you want to export."
: "Select a Hugging Face model or local model path to export directly to GGUF."}
</TooltipContent>
</Tooltip>
</label>
<Select
value={selectedModelIdx ?? ""}
onValueChange={setSelectedModelIdx}
<button
type="button"
onClick={() =>
handleSourceModeSwitch(
sourceMode === "checkpoint" ? "model" : "checkpoint",
)
}
className="text-xs text-primary underline cursor-pointer leading-none"
>
<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}
{sourceMode === "checkpoint"
? "Use Hugging Face / Local Model"
: "Use Training Checkpoints"}
</button>
</div>
<AnimatePresence mode="wait" initial={false}>
{sourceMode === "checkpoint" ? (
<motion.div
key="checkpoint"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.25, 0.1, 0.25, 1] }}
className="flex flex-col gap-2 overflow-visible"
>
<div data-tour="export-training-run" className="flex flex-col gap-2">
<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>
)}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{/* Checkpoint dropdown */}
<div data-tour="export-checkpoint" 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"
<div data-tour="export-checkpoint" 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 className="w-full">
<SelectValue
placeholder={
!selectedModelIdx
? "Select a training run first"
: checkpointsForModel.length === 0
? "No checkpoints found"
: "Select a checkpoint…"
}
/>
</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 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)}
</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>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</motion.div>
) : (
<motion.div
key="model"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.25, 0.1, 0.25, 1] }}
className="flex flex-col gap-2 overflow-visible"
>
<div className="flex gap-2">
<Button
variant={modelSource === "hf" ? "dark" : "outline"}
className="flex-1"
onClick={() => setModelSource("hf")}
>
Hugging Face
</Button>
<Button
variant={modelSource === "local" ? "dark" : "outline"}
className="flex-1"
onClick={() => setModelSource("local")}
>
Local Model
</Button>
</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-1 gap-x-6 gap-y-1.5 text-xs sm:grid-cols-2">
<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>
{modelSource === "hf" ? (
<>
<div className="flex flex-col gap-2">
<label className="text-xs font-medium text-muted-foreground">
Hugging Face Model
</label>
<div ref={hfComboboxAnchorRef}>
<Combobox
items={hfResultIds}
filteredItems={hfResultIds}
filter={null}
value={selectedSourceModel}
onValueChange={setSelectedSourceModel}
onInputValueChange={(val) => {
setModelInput(val);
setSelectedSourceModel(null);
}}
itemToStringValue={(id) => id}
autoHighlight={true}
>
<ComboboxInput placeholder="Search models..." className="w-full">
<InputGroupAddon>
<HugeiconsIcon icon={Search01Icon} className="size-4" />
</InputGroupAddon>
</ComboboxInput>
<ComboboxContent anchor={hfComboboxAnchorRef}>
{isLoadingHfModels ? (
<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>
)}
<ComboboxList className="p-1 !max-h-none !overflow-visible">
{(id: string) => (
<ComboboxItem key={id} value={id} className="gap-2">
<span className="block min-w-0 flex-1 truncate">
{id}
</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
{(tokenValidationError ?? hfSearchError) && (
<p className="text-xs text-destructive">
{tokenValidationError ?? hfSearchError}
</p>
)}
</div>
<div className="flex items-center gap-2">
<Switch
id="hf-export-trust-remote-code"
size="sm"
checked={hfExportTrustRemoteCode}
onCheckedChange={setHfExportTrustRemoteCode}
disabled={exporting}
/>
<label
htmlFor="hf-export-trust-remote-code"
className="cursor-pointer text-xs font-medium text-muted-foreground hover:text-foreground"
>
Trust remote code
</label>
<Tooltip>
<TooltipTrigger asChild={true}>
<button
type="button"
className="text-muted-foreground hover:text-foreground -m-1 inline-flex rounded p-1"
aria-label="About trust remote code"
>
<HugeiconsIcon
icon={InformationCircleIcon}
className="size-3.5"
/>
</button>
</TooltipTrigger>
<TooltipContent
side="top"
className="max-w-[260px] text-xs"
>
Loads custom Python from the repo if the model
needs it. Turn off if you do not trust the
source.
</TooltipContent>
</Tooltip>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Hugging Face Token (Optional)
</label>
<InputGroup>
<InputGroupAddon>
<HugeiconsIcon icon={Key01Icon} className="size-4" />
</InputGroupAddon>
<InputGroupInput
type="password"
autoComplete="new-password"
name="hf-token-export-source"
placeholder="hf_..."
value={hfToken}
onChange={(e) => setHfToken(e.target.value)}
/>
</InputGroup>
{isCheckingToken && (
<p className="text-xs text-muted-foreground">Checking token</p>
)}
</div>
</>
) : (
<div className="flex flex-col gap-2">
<label className="text-xs font-medium text-muted-foreground">
Local Model Path
</label>
<div ref={localComboboxAnchorRef}>
<Combobox
items={localResultIds}
filteredItems={localFilteredIds}
filter={null}
value={localModelInput || null}
onValueChange={(id) => {
const next = id ?? "";
setLocalModelInput(next);
setSelectedSourceModel(next || null);
}}
onInputValueChange={setLocalModelInput}
itemToStringValue={(id) => id}
autoHighlight={true}
>
<ComboboxInput
placeholder={
isLoadingLocalModels
? "Scanning local and cached models..."
: "./models/my-model"
}
className="w-full"
onBlur={() =>
setSelectedSourceModel(localModelInput.trim() || null)
}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
setSelectedSourceModel(localModelInput.trim() || null);
}}
>
<InputGroupAddon>
<HugeiconsIcon icon={FolderSearchIcon} className="size-4" />
</InputGroupAddon>
</ComboboxInput>
<ComboboxContent anchor={localComboboxAnchorRef}>
{isLoadingLocalModels ? (
<div className="flex items-center justify-center gap-2 py-4 text-xs text-muted-foreground">
<Spinner className="size-4" /> Scanning...
</div>
) : localModelsError ? (
<div className="px-3 py-2 text-xs text-red-500">
{localModelsError}
</div>
) : (
<ComboboxEmpty>No local models found</ComboboxEmpty>
)}
<ComboboxList className="p-1 !max-h-none !overflow-visible">
{(id: string) => {
const model = localMetaById.get(id);
const source =
model?.source === "hf_cache"
? "HF cache"
: "Local dir";
return (
<ComboboxItem key={id} value={id} className="gap-2">
<span className="block min-w-0 flex-1 truncate">
{model?.display_name ?? id}
</span>
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">
{source}
</span>
</ComboboxItem>
);
}}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
{isLoadingLocalModels ? (
<p className="text-[10px] text-muted-foreground">
Scanning local models...
</p>
) : localModelsError ? (
<p className="text-[10px] text-red-500">{localModelsError}</p>
) : (
<p className="text-[10px] text-muted-foreground">
{exportableLocalModels.length > 0
? `${exportableLocalModels.length} local/cached models found`
: "No local models found. Enter path manually."}
</p>
)}
</div>
)}
<div className="rounded-xl bg-muted/50 p-3">
<p className="text-[11px] text-muted-foreground">
Direct model exports currently support GGUF only.
</p>
</div>
</motion.div>
)}
</AnimatePresence>
{sourceMode === "checkpoint" && (
<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-1 gap-x-6 gap-y-1.5 text-xs sm:grid-cols-2">
<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>
<div className="flex flex-col gap-2.5">
@ -462,7 +915,7 @@ export function ExportPage() {
Quick Guide
</span>
<ol className="flex flex-col gap-3">
{GUIDE_STEPS.map((step, i) => (
{exportGuideSteps.map((step, i) => (
<li
key={step}
className="flex items-start gap-2 text-xs text-muted-foreground"
@ -483,22 +936,24 @@ export function ExportPage() {
disabledMethods={
!isAdapter && isQuantized
? ["merged", "lora", "gguf"]
: !isAdapter
: !isAdapter || sourceMode === "model"
? ["merged", "lora"]
: []
}
disabledReason={
!isAdapter && isQuantized
? "Pre-quantized (BNB 4-bit) models cannot be exported without LoRA adapters"
: !isAdapter
? "Not available for full fine-tune checkpoints (no LoRA adapters)"
: undefined
: sourceMode === "model"
? "Only GGUF export is available for direct model export"
: !isAdapter
? "Not available for full fine-tune checkpoints (no LoRA adapters)"
: undefined
}
/>
<AnimatePresence>
{exportMethod === "gguf" && (
<motion.div {...collapseAnim} className="overflow-hidden">
<motion.div {...collapseAnim} className="overflow-visible">
<QuantPicker value={quantLevels} onChange={setQuantLevels} />
</motion.div>
)}
@ -530,12 +985,12 @@ export function ExportPage() {
<ExportDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
checkpoint={checkpoint}
exportMethod={exportMethod}
quantLevels={quantLevels}
estimatedSize={estimatedSize}
baseModelName={baseModelName}
isAdapter={isAdapter}
checkpoint={selectedExportSource}
baseModelName={sourceBaseModelName}
isAdapter={sourceMode === "checkpoint" && isAdapter}
destination={destination}
onDestinationChange={setDestination}
hfUsername={hfUsername}