Compare commits
4 commits
main
...
feat/local
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3c37cf3d1 | ||
|
|
c6149c0499 | ||
|
|
c8d8ff79b1 | ||
|
|
b747b7f2a9 |
2 changed files with 613 additions and 156 deletions
|
|
@ -217,6 +217,7 @@ class ExportOrchestrator:
|
||||||
max_seq_length: int = 2048,
|
max_seq_length: int = 2048,
|
||||||
load_in_4bit: bool = True,
|
load_in_4bit: bool = True,
|
||||||
trust_remote_code: bool = False,
|
trust_remote_code: bool = False,
|
||||||
|
hf_token: Optional[str] = None,
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
"""Load a checkpoint for export.
|
"""Load a checkpoint for export.
|
||||||
|
|
||||||
|
|
@ -227,6 +228,7 @@ class ExportOrchestrator:
|
||||||
"max_seq_length": max_seq_length,
|
"max_seq_length": max_seq_length,
|
||||||
"load_in_4bit": load_in_4bit,
|
"load_in_4bit": load_in_4bit,
|
||||||
"trust_remote_code": trust_remote_code,
|
"trust_remote_code": trust_remote_code,
|
||||||
|
"hf_token": hf_token,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Always kill existing subprocess and spawn fresh.
|
# Always kill existing subprocess and spawn fresh.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,19 @@
|
||||||
|
|
||||||
import { SectionCard } from "@/components/section-card";
|
import { SectionCard } from "@/components/section-card";
|
||||||
import { Button } from "@/components/ui/button";
|
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 {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -11,17 +24,34 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { useTrainingConfigStore } from "@/features/training";
|
import {
|
||||||
import { AlertCircleIcon, InformationCircleIcon, PackageIcon } from "@hugeicons/core-free-icons";
|
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 { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import { AnimatePresence, motion } from "motion/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 { useShallow } from "zustand/react/shallow";
|
||||||
import { collapseAnim } from "./anim";
|
import { collapseAnim } from "./anim";
|
||||||
import type { ModelCheckpoints } from "./api/export-api";
|
import type { ModelCheckpoints } from "./api/export-api";
|
||||||
|
|
@ -60,6 +90,21 @@ export function ExportPage() {
|
||||||
|
|
||||||
const [selectedModelIdx, setSelectedModelIdx] = useState<string | null>(null);
|
const [selectedModelIdx, setSelectedModelIdx] = useState<string | null>(null);
|
||||||
const [checkpoint, setCheckpoint] = 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 [exportMethod, setExportMethod] = useState<ExportMethod | null>(null);
|
||||||
const [quantLevels, setQuantLevels] = useState<string[]>([]);
|
const [quantLevels, setQuantLevels] = useState<string[]>([]);
|
||||||
|
|
@ -74,6 +119,9 @@ export function ExportPage() {
|
||||||
const [exportError, setExportError] = useState<string | null>(null);
|
const [exportError, setExportError] = useState<string | null>(null);
|
||||||
const [exportSuccess, setExportSuccess] = useState(false);
|
const [exportSuccess, setExportSuccess] = useState(false);
|
||||||
|
|
||||||
|
const hfComboboxAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const localComboboxAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const tour = useGuidedTourController({
|
const tour = useGuidedTourController({
|
||||||
id: "export",
|
id: "export",
|
||||||
steps: exportTourSteps,
|
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 ----
|
// ---- Derived state ----
|
||||||
const selectedModelData = useMemo(
|
const selectedModelData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -127,6 +196,83 @@ export function ExportPage() {
|
||||||
const trainingMethodLabel = selectedModelData?.peft_type
|
const trainingMethodLabel = selectedModelData?.peft_type
|
||||||
? "LoRA / QLoRA"
|
? "LoRA / QLoRA"
|
||||||
: "Full Fine-tune";
|
: "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
|
// Reset checkpoint when the selected model changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -144,6 +290,25 @@ export function ExportPage() {
|
||||||
}
|
}
|
||||||
}, [isAdapter, isQuantized, exportMethod]);
|
}, [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) => {
|
const handleMethodChange = (method: ExportMethod) => {
|
||||||
setExportMethod(method);
|
setExportMethod(method);
|
||||||
if (method !== "gguf") {
|
if (method !== "gguf") {
|
||||||
|
|
@ -152,19 +317,24 @@ export function ExportPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const estimatedSize = getEstimatedSize(exportMethod, quantLevels);
|
const estimatedSize = getEstimatedSize(exportMethod, quantLevels);
|
||||||
const canExport =
|
const selectedExportSource =
|
||||||
checkpoint &&
|
sourceMode === "checkpoint" ? checkpoint : selectedSourceModel;
|
||||||
|
const canExport = !!(
|
||||||
|
selectedExportSource &&
|
||||||
exportMethod &&
|
exportMethod &&
|
||||||
(exportMethod !== "gguf" || quantLevels.length > 0);
|
(exportMethod !== "gguf" || quantLevels.length > 0)
|
||||||
|
);
|
||||||
|
|
||||||
// ---- Export handler ----
|
// ---- Export handler ----
|
||||||
const handleExport = useCallback(async () => {
|
const handleExport = useCallback(async () => {
|
||||||
if (!checkpoint) return;
|
const source = sourceMode === "checkpoint" ? checkpoint : selectedSourceModel;
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
const selectedCp = checkpointsForModel.find(
|
const selectedCp = sourceMode === "checkpoint"
|
||||||
(cp) => cp.display_name === checkpoint,
|
? checkpointsForModel.find((cp) => cp.display_name === checkpoint)
|
||||||
);
|
: null;
|
||||||
if (!selectedCp) return;
|
if (sourceMode === "checkpoint" && !selectedCp) return;
|
||||||
|
const checkpointPath = selectedCp?.path;
|
||||||
|
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
setExportError(null);
|
setExportError(null);
|
||||||
|
|
@ -174,7 +344,8 @@ export function ExportPage() {
|
||||||
// For other formats, nest under training-run/checkpoint
|
// For other formats, nest under training-run/checkpoint
|
||||||
const saveDir =
|
const saveDir =
|
||||||
exportMethod === "gguf"
|
exportMethod === "gguf"
|
||||||
? `${baseModelName.split("/").pop() ?? selectedModelIdx ?? "model"}-finetune-gguf`
|
? `${(sourceBaseModelName.split("/").pop() ?? selectedModelIdx ?? "model")
|
||||||
|
.replace(/[^a-zA-Z0-9._-]/g, "-")}-gguf`
|
||||||
: `${selectedModelIdx ?? "model"}/${checkpoint}`;
|
: `${selectedModelIdx ?? "model"}/${checkpoint}`;
|
||||||
const pushToHub = destination === "hub";
|
const pushToHub = destination === "hub";
|
||||||
const repoId = pushToHub && hfUsername && modelName
|
const repoId = pushToHub && hfUsername && modelName
|
||||||
|
|
@ -183,8 +354,18 @@ export function ExportPage() {
|
||||||
const token = pushToHub && hfToken ? hfToken : undefined;
|
const token = pushToHub && hfToken ? hfToken : undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Load checkpoint
|
// 1. Load model source
|
||||||
await loadCheckpoint({ checkpoint_path: selectedCp.path });
|
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
|
// 2. Run export based on method
|
||||||
if (exportMethod === "merged") {
|
if (exportMethod === "merged") {
|
||||||
|
|
@ -242,16 +423,21 @@ export function ExportPage() {
|
||||||
}, [
|
}, [
|
||||||
checkpoint,
|
checkpoint,
|
||||||
checkpointsForModel,
|
checkpointsForModel,
|
||||||
|
sourceMode,
|
||||||
|
selectedSourceModel,
|
||||||
selectedModelIdx,
|
selectedModelIdx,
|
||||||
selectedModelData,
|
selectedModelData,
|
||||||
exportMethod,
|
exportMethod,
|
||||||
isAdapter,
|
isAdapter,
|
||||||
|
sourceBaseModelName,
|
||||||
quantLevels,
|
quantLevels,
|
||||||
destination,
|
destination,
|
||||||
hfUsername,
|
hfUsername,
|
||||||
modelName,
|
modelName,
|
||||||
hfToken,
|
hfToken,
|
||||||
privateRepo,
|
privateRepo,
|
||||||
|
modelSource,
|
||||||
|
hfExportTrustRemoteCode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ---- Render ----
|
// ---- Render ----
|
||||||
|
|
@ -265,14 +451,14 @@ export function ExportPage() {
|
||||||
Export Model
|
Export Model
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Export your fine-tuned model for deployment
|
Export fine-tuned or base models for deployment
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<HugeiconsIcon icon={PackageIcon} className="size-5" />}
|
icon={<HugeiconsIcon icon={PackageIcon} className="size-5" />}
|
||||||
title="Export Configuration"
|
title="Export Configuration"
|
||||||
description="Select checkpoint, method, and quantization"
|
description="Select source, method, and quantization"
|
||||||
accent="emerald"
|
accent="emerald"
|
||||||
featured={true}
|
featured={true}
|
||||||
className="shadow-border ring-1 ring-border"
|
className="shadow-border ring-1 ring-border"
|
||||||
|
|
@ -296,11 +482,10 @@ export function ExportPage() {
|
||||||
<>
|
<>
|
||||||
{/* Top row: Dropdowns + metadata | Guide */}
|
{/* Top row: Dropdowns + metadata | Guide */}
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-8">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-8">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-2">
|
||||||
{/* Training run dropdown */}
|
<div className="flex items-end justify-between">
|
||||||
<div data-tour="export-training-run" className="flex flex-col gap-2">
|
|
||||||
<label className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
<label className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||||
Training Run
|
{sourceMode === "checkpoint" ? "Training Run" : "Model Source"}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild={true}>
|
<TooltipTrigger asChild={true}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -314,147 +499,415 @@ export function ExportPage() {
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
Select the training run that produced the checkpoints
|
{sourceMode === "checkpoint"
|
||||||
you want to export.
|
? "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>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<button
|
||||||
value={selectedModelIdx ?? ""}
|
type="button"
|
||||||
onValueChange={setSelectedModelIdx}
|
onClick={() =>
|
||||||
|
handleSourceModeSwitch(
|
||||||
|
sourceMode === "checkpoint" ? "model" : "checkpoint",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="text-xs text-primary underline cursor-pointer leading-none"
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
{sourceMode === "checkpoint"
|
||||||
<SelectValue
|
? "Use Hugging Face / Local Model"
|
||||||
placeholder={
|
: "Use Training Checkpoints"}
|
||||||
models.length === 0
|
</button>
|
||||||
? "No training runs found"
|
</div>
|
||||||
: "Select a training run…"
|
|
||||||
}
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
/>
|
{sourceMode === "checkpoint" ? (
|
||||||
</SelectTrigger>
|
<motion.div
|
||||||
<SelectContent>
|
key="checkpoint"
|
||||||
{models.map((m) => {
|
initial={{ height: 0, opacity: 0 }}
|
||||||
const tsMatch = m.name.match(/_(\d{10,})$/);
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
const displayName = tsMatch ? m.name.slice(0, tsMatch.index) : m.name;
|
exit={{ height: 0, opacity: 0 }}
|
||||||
const timeStr = tsMatch
|
transition={{ duration: 0.25, ease: [0.25, 0.1, 0.25, 1] }}
|
||||||
? new Date(Number(tsMatch[1]) * 1000).toLocaleString(undefined, {
|
className="flex flex-col gap-2 overflow-visible"
|
||||||
dateStyle: "medium",
|
>
|
||||||
timeStyle: "short",
|
<div data-tour="export-training-run" className="flex flex-col gap-2">
|
||||||
})
|
<Select
|
||||||
: null;
|
value={selectedModelIdx ?? ""}
|
||||||
return (
|
onValueChange={setSelectedModelIdx}
|
||||||
<SelectItem key={m.name} value={m.name}>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<SelectTrigger className="w-full">
|
||||||
{displayName}
|
<SelectValue
|
||||||
<span className="text-muted-foreground text-xs">
|
placeholder={
|
||||||
{m.checkpoints.length} checkpoint
|
models.length === 0
|
||||||
{m.checkpoints.length !== 1 ? "s" : ""}
|
? "No training runs found"
|
||||||
</span>
|
: "Select a training run…"
|
||||||
{timeStr && (
|
}
|
||||||
<span className="text-muted-foreground text-xs">
|
/>
|
||||||
· {timeStr}
|
</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>
|
||||||
</span>
|
);
|
||||||
</SelectItem>
|
})}
|
||||||
);
|
</SelectContent>
|
||||||
})}
|
</Select>
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Checkpoint dropdown */}
|
<div data-tour="export-checkpoint" className="flex flex-col gap-2">
|
||||||
<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">
|
||||||
<label className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
Checkpoint
|
||||||
Checkpoint
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild={true}>
|
||||||
<TooltipTrigger asChild={true}>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className="text-foreground/70 hover:text-foreground"
|
||||||
className="text-foreground/70 hover:text-foreground"
|
>
|
||||||
>
|
<HugeiconsIcon
|
||||||
<HugeiconsIcon
|
icon={InformationCircleIcon}
|
||||||
icon={InformationCircleIcon}
|
className="size-3"
|
||||||
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>
|
</SelectTrigger>
|
||||||
</TooltipTrigger>
|
<SelectContent>
|
||||||
<TooltipContent>
|
{checkpointsForModel.map((cp) => (
|
||||||
Choose a saved checkpoint to export. Lower loss
|
<SelectItem key={cp.path} value={cp.display_name}>
|
||||||
generally means better quality.{" "}
|
<span className="flex items-center gap-2">
|
||||||
<a
|
{cp.display_name}
|
||||||
href="https://unsloth.ai/docs/basics/inference-and-deployment"
|
{cp.loss != null && (
|
||||||
target="_blank"
|
<span className="text-muted-foreground text-xs">
|
||||||
rel="noopener noreferrer"
|
loss: {cp.loss.toFixed(4)}
|
||||||
className="text-primary underline"
|
</span>
|
||||||
>
|
)}
|
||||||
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)}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
</SelectItem>
|
||||||
</span>
|
))}
|
||||||
</SelectItem>
|
</SelectContent>
|
||||||
))}
|
</Select>
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
</motion.div>
|
||||||
</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">
|
{modelSource === "hf" ? (
|
||||||
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
<>
|
||||||
Training Info
|
<div className="flex flex-col gap-2">
|
||||||
</span>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
<div className="grid grid-cols-1 gap-x-6 gap-y-1.5 text-xs sm:grid-cols-2">
|
Hugging Face Model
|
||||||
<div className="flex justify-between">
|
</label>
|
||||||
<span className="text-muted-foreground">Base Model</span>
|
<div ref={hfComboboxAnchorRef}>
|
||||||
<span className="font-medium">{baseModelName}</span>
|
<Combobox
|
||||||
</div>
|
items={hfResultIds}
|
||||||
<div className="flex justify-between">
|
filteredItems={hfResultIds}
|
||||||
<span className="text-muted-foreground">Method</span>
|
filter={null}
|
||||||
<span className="font-medium">
|
value={selectedSourceModel}
|
||||||
{trainingMethodLabel}
|
onValueChange={setSelectedSourceModel}
|
||||||
</span>
|
onInputValueChange={(val) => {
|
||||||
</div>
|
setModelInput(val);
|
||||||
<div className="flex justify-between">
|
if (!val.trim()) setSelectedSourceModel(null);
|
||||||
<span className="text-muted-foreground">Checkpoints</span>
|
}}
|
||||||
<span className="font-medium">
|
itemToStringValue={(id) => id}
|
||||||
{checkpointsForModel.length}
|
autoHighlight={true}
|
||||||
</span>
|
>
|
||||||
</div>
|
<ComboboxInput placeholder="Search models..." className="w-full">
|
||||||
{isAdapter && (
|
<InputGroupAddon>
|
||||||
<div className="flex justify-between">
|
<HugeiconsIcon icon={Search01Icon} className="size-4" />
|
||||||
<span className="text-muted-foreground">LoRA Rank</span>
|
</InputGroupAddon>
|
||||||
<span className="font-medium">{loraRank}</span>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-2.5">
|
||||||
|
|
@ -462,7 +915,7 @@ export function ExportPage() {
|
||||||
Quick Guide
|
Quick Guide
|
||||||
</span>
|
</span>
|
||||||
<ol className="flex flex-col gap-3">
|
<ol className="flex flex-col gap-3">
|
||||||
{GUIDE_STEPS.map((step, i) => (
|
{exportGuideSteps.map((step, i) => (
|
||||||
<li
|
<li
|
||||||
key={step}
|
key={step}
|
||||||
className="flex items-start gap-2 text-xs text-muted-foreground"
|
className="flex items-start gap-2 text-xs text-muted-foreground"
|
||||||
|
|
@ -483,22 +936,24 @@ export function ExportPage() {
|
||||||
disabledMethods={
|
disabledMethods={
|
||||||
!isAdapter && isQuantized
|
!isAdapter && isQuantized
|
||||||
? ["merged", "lora", "gguf"]
|
? ["merged", "lora", "gguf"]
|
||||||
: !isAdapter
|
: !isAdapter || sourceMode === "model"
|
||||||
? ["merged", "lora"]
|
? ["merged", "lora"]
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
disabledReason={
|
disabledReason={
|
||||||
!isAdapter && isQuantized
|
!isAdapter && isQuantized
|
||||||
? "Pre-quantized (BNB 4-bit) models cannot be exported without LoRA adapters"
|
? "Pre-quantized (BNB 4-bit) models cannot be exported without LoRA adapters"
|
||||||
: !isAdapter
|
: sourceMode === "model"
|
||||||
? "Not available for full fine-tune checkpoints (no LoRA adapters)"
|
? "Only GGUF export is available for direct model export"
|
||||||
: undefined
|
: !isAdapter
|
||||||
|
? "Not available for full fine-tune checkpoints (no LoRA adapters)"
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{exportMethod === "gguf" && (
|
{exportMethod === "gguf" && (
|
||||||
<motion.div {...collapseAnim} className="overflow-hidden">
|
<motion.div {...collapseAnim} className="overflow-visible">
|
||||||
<QuantPicker value={quantLevels} onChange={setQuantLevels} />
|
<QuantPicker value={quantLevels} onChange={setQuantLevels} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -530,12 +985,12 @@ export function ExportPage() {
|
||||||
<ExportDialog
|
<ExportDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onOpenChange={setDialogOpen}
|
onOpenChange={setDialogOpen}
|
||||||
checkpoint={checkpoint}
|
|
||||||
exportMethod={exportMethod}
|
exportMethod={exportMethod}
|
||||||
quantLevels={quantLevels}
|
quantLevels={quantLevels}
|
||||||
estimatedSize={estimatedSize}
|
estimatedSize={estimatedSize}
|
||||||
baseModelName={baseModelName}
|
checkpoint={selectedExportSource}
|
||||||
isAdapter={isAdapter}
|
baseModelName={sourceBaseModelName}
|
||||||
|
isAdapter={sourceMode === "checkpoint" && isAdapter}
|
||||||
destination={destination}
|
destination={destination}
|
||||||
onDestinationChange={setDestination}
|
onDestinationChange={setDestination}
|
||||||
hfUsername={hfUsername}
|
hfUsername={hfUsername}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue