Comment-only: condense the shim header, the _c10d_functional/_dtensor schema notes, the is_win32_rocm parity note, and the export-page prune comment. No code changes.
1680 lines
71 KiB
TypeScript
1680 lines
71 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
import { SectionCard } from "@/components/section-card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import {
|
|
Combobox,
|
|
ComboboxContent,
|
|
ComboboxEmpty,
|
|
ComboboxInput,
|
|
ComboboxItem,
|
|
ComboboxList,
|
|
} from "@/components/ui/combobox";
|
|
import {
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupInput,
|
|
} from "@/components/ui/input-group";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuCheckboxItem,
|
|
DropdownMenuContent,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
Alert,
|
|
AlertDescription,
|
|
AlertTitle,
|
|
} from "@/components/ui/alert";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Spinner } from "@/components/ui/spinner";
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import {
|
|
listLocalModels,
|
|
type LocalModelInfo,
|
|
useTrainingConfigStore,
|
|
} from "@/features/training";
|
|
import { useHubModelSearch } from "@/features/hub/hooks/use-hub-model-search";
|
|
import { confirmRemoteCodeIfNeeded } from "@/features/security";
|
|
import { useDebouncedValue, useHfTokenValidation } from "@/hooks";
|
|
import {
|
|
AlertCircleIcon,
|
|
ArrowDown01Icon,
|
|
FolderSearchIcon,
|
|
InformationCircleIcon,
|
|
Key01Icon,
|
|
PackageIcon,
|
|
Search01Icon,
|
|
} from "@hugeicons/core-free-icons";
|
|
import { useSearch } from "@tanstack/react-router";
|
|
import { HugeiconsIcon } from "@hugeicons/react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useShallow } from "zustand/react/shallow";
|
|
import type { ModelCheckpoints } from "./api/export-api";
|
|
import { fetchCheckpoints } from "./api/export-api";
|
|
import { ExportRunPanel } from "./components/export-run-panel";
|
|
import { MethodPicker } from "./components/method-picker";
|
|
import { QuantPicker } from "./components/quant-picker";
|
|
import {
|
|
EXPORT_METHODS,
|
|
type ExportMethod,
|
|
GUIDE_STEPS,
|
|
MERGED_FORMATS,
|
|
type MergedFormatOption,
|
|
mergedFormatPayload,
|
|
QUANT_OPTIONS,
|
|
buildQuantSizeLabels,
|
|
getEstimatedSize,
|
|
} from "./constants";
|
|
import { useHardwareInfo } from "@/hooks/use-hardware-info";
|
|
import { usePlatformStore } from "@/config/env";
|
|
import {
|
|
isExportPanelActive,
|
|
useExportRuntimeStore,
|
|
} from "./stores/export-runtime-store";
|
|
import { useExportSizeEstimate } from "./hooks/use-export-size-estimate";
|
|
import { GuidedTour, useGuidedTourController } from "@/features/tour";
|
|
import { exportTourSteps } from "./tour";
|
|
|
|
const SEARCH_INPUT_REASONS = new Set(["input-change", "input-paste", "input-clear"]);
|
|
|
|
// GGUF LoRA output float types (Q8_0 first / default). Q8_0 falls back to F16 per tensor for dims
|
|
// not divisible by the block size (32); no "auto" - the choice is explicit.
|
|
const LORA_GGUF_OUTTYPES = ["q8_0", "f16", "bf16", "f32"] as const;
|
|
|
|
type SourceTab = "local" | "checkpoint" | "hf";
|
|
type SourceMode = "checkpoint" | "model";
|
|
|
|
|
|
function safePathSegment(
|
|
value: string | null | undefined,
|
|
fallback = "model",
|
|
maxLength = 250,
|
|
): string {
|
|
const safe = (value ?? "")
|
|
.replace(/[^a-zA-Z0-9._-]/g, "-")
|
|
.replace(/^[._-]+|[._-]+$/g, "")
|
|
.slice(0, maxLength)
|
|
.replace(/[._-]+$/g, "");
|
|
return safe || fallback;
|
|
}
|
|
|
|
function buildRelativeSaveDirectory(
|
|
exportMethod: ExportMethod | null,
|
|
sourceMode: SourceMode,
|
|
sourceBaseModelName: string,
|
|
selectedModelIdx: string | null,
|
|
checkpoint: string | null,
|
|
): string {
|
|
if (exportMethod === "gguf") {
|
|
const rawName =
|
|
sourceMode === "checkpoint"
|
|
? checkpoint ?? selectedModelIdx ?? sourceBaseModelName
|
|
: sourceBaseModelName;
|
|
return `${safePathSegment(rawName)}-GGUF`;
|
|
}
|
|
// Merged / LoRA: a checkpoint keeps the "<run>/<checkpoint>" layout under outputs.
|
|
if (sourceMode === "checkpoint" && selectedModelIdx && checkpoint) {
|
|
return `${selectedModelIdx}/${checkpoint}`;
|
|
}
|
|
// Local / HF source (no checkpoint): name from the model id to avoid "model/null".
|
|
const rawName =
|
|
sourceMode === "checkpoint"
|
|
? checkpoint ?? selectedModelIdx ?? sourceBaseModelName
|
|
: sourceBaseModelName;
|
|
return `${safePathSegment(rawName)}-${exportMethod === "lora" ? "adapter" : "merged"}`;
|
|
}
|
|
|
|
function siblingGgufDirectory(sourcePath: string): string | null {
|
|
const trimmed = sourcePath.trim().replace(/[\\/]+$/, "");
|
|
if (!trimmed) return null;
|
|
const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
|
|
// Lowercase `_gguf` matches the backend's intermediate dir (core/export/export.py);
|
|
// `_GGUF` would relocate+delete that sibling.
|
|
if (slash < 0) return `${trimmed}_gguf`;
|
|
const parent =
|
|
slash === 0 || (slash === 2 && /^[A-Za-z]:/.test(trimmed))
|
|
? trimmed.slice(0, slash + 1)
|
|
: trimmed.slice(0, slash);
|
|
const name = trimmed.slice(slash + 1);
|
|
if (!name) return null;
|
|
const sep = parent.endsWith("/") || parent.endsWith("\\")
|
|
? ""
|
|
: trimmed.includes("\\")
|
|
? "\\"
|
|
: "/";
|
|
return `${parent}${sep}${name}_gguf`;
|
|
}
|
|
|
|
export function ExportPage() {
|
|
const { hfToken, setHfToken } = useTrainingConfigStore(
|
|
useShallow((s) => ({
|
|
hfToken: s.hfToken,
|
|
setHfToken: s.setHfToken,
|
|
})),
|
|
);
|
|
|
|
// ---- 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 [sourceMode, setSourceMode] = useState<SourceMode>("checkpoint");
|
|
const [modelSource, setModelSource] = useState<"hf" | "local">("hf");
|
|
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 debouncedHfToken = useDebouncedValue(hfToken, 500);
|
|
|
|
// Seed the method + quants from a live run so that navigating away and back
|
|
// (which remounts this page) keeps the method card selected and the run panel
|
|
// showing its logs/progress. The run itself lives in the global store; only
|
|
// this form state is local and would otherwise reset to null on remount.
|
|
const [exportMethod, setExportMethod] = useState<ExportMethod | null>(() => {
|
|
const s = useExportRuntimeStore.getState();
|
|
return isExportPanelActive(s) && s.summary ? s.summary.method : null;
|
|
});
|
|
const [quantLevels, setQuantLevels] = useState<string[]>(() => {
|
|
const s = useExportRuntimeStore.getState();
|
|
return isExportPanelActive(s) && s.summary?.method === "gguf"
|
|
? s.summary.quantLevels
|
|
: [];
|
|
});
|
|
// GGUF importance matrix (required for the IQ quants) and merged-export precision.
|
|
const [useImatrix, setUseImatrix] = useState(false);
|
|
// Merged precision: one or more MERGED_FORMATS values, exported in one run. Seed from a live run
|
|
// so navigating away and back (which remounts this page) keeps the selection, like exportMethod.
|
|
const [selectedFormats, setSelectedFormats] = useState<string[]>(() => {
|
|
const s = useExportRuntimeStore.getState();
|
|
return isExportPanelActive(s) &&
|
|
s.summary?.method === "merged" &&
|
|
s.summary.mergedFormats.length > 0
|
|
? s.summary.mergedFormats
|
|
: ["16-bit"];
|
|
});
|
|
// LoRA-only export: optionally also emit a GGUF LoRA adapter, and its output float type.
|
|
const [loraAsGguf, setLoraAsGguf] = useState(false);
|
|
const [loraGgufOuttype, setLoraGgufOuttype] = useState<string>("q8_0");
|
|
// GGUF method: export the full model as GGUF quants, or (for an adapter checkpoint) a GGUF LoRA.
|
|
const [ggufTarget, setGgufTarget] = useState<"model" | "lora">("model");
|
|
|
|
const hardware = useHardwareInfo();
|
|
const deviceType = usePlatformStore((s) => s.deviceType);
|
|
// GGUF LoRA conversion is rejected on the macOS / MLX path, so gate it out on a Mac host.
|
|
const isMacHost = deviceType === "mac";
|
|
// Backend truth for the torchao gate (single source). Not re-derived from `rocm`: AMD SDK
|
|
// wheels leave torch.version.hip unset, so `rocm` alone would miss Windows ROCm.
|
|
const isWindowsRocm = hardware.win32Rocm;
|
|
// Real CUDA (not ROCm); gates the NVIDIA-only compressed-tensors formats.
|
|
const hasNvidia = hardware.cuda != null && hardware.rocm == null;
|
|
// Only gray out on an authoritative unsupported response; while unloaded the backend route guard
|
|
// stays authoritative. The backend supplies the precise reason; the fallback below is a backstop.
|
|
const exportUnsupported =
|
|
hardware.loaded && hardware.exportSupported === false;
|
|
const exportUnsupportedMessage =
|
|
hardware.exportUnsupportedMessage ??
|
|
"Export requires a supported accelerator (NVIDIA, AMD, or Intel GPU, or Apple Silicon) with PyTorch or MLX installed.";
|
|
const availableFormats = useMemo<MergedFormatOption[]>(
|
|
() =>
|
|
MERGED_FORMATS.filter((f) => {
|
|
// compressed-tensors (llm-compressor) is the NVIDIA path; shown only on an NVIDIA GPU.
|
|
if (f.backend === "compressed") return hasNvidia;
|
|
// Portable torchao: shown on non-NVIDIA hosts. Hidden on NVIDIA (use compressed-tensors),
|
|
// macOS/MLX (rejected), and Windows ROCm (torchao unavailable: no torch.distributed).
|
|
if (f.backend === "torchao") return !hasNvidia && !isMacHost && !isWindowsRocm;
|
|
// Plain 16-bit is available everywhere.
|
|
return true;
|
|
}),
|
|
[hasNvidia, isMacHost, isWindowsRocm],
|
|
);
|
|
const toggleFormat = useCallback((value: string) => {
|
|
setSelectedFormats((prev) =>
|
|
prev.includes(value)
|
|
? prev.filter((v) => v !== value)
|
|
: [...prev, value],
|
|
);
|
|
}, []);
|
|
// Drop a selected format the gate removed (e.g. torchao once win32Rocm resolves). Gate on
|
|
// hardware.loaded: before the authoritative response hasNvidia is false, so pruning would
|
|
// permanently drop a running NVIDIA FP8/NVFP4 pick that the later response can't restore.
|
|
useEffect(() => {
|
|
if (!hardware.loaded) return;
|
|
const allowed = new Set(availableFormats.map((f) => f.value));
|
|
setSelectedFormats((prev) => {
|
|
const next = prev.filter((v) => allowed.has(v));
|
|
return next.length === prev.length ? prev : next;
|
|
});
|
|
}, [availableFormats, hardware.loaded]);
|
|
// IQ quants are imatrix-only: force imatrix on when one is selected, else llama.cpp rejects it.
|
|
const requiresImatrix = quantLevels.some(
|
|
(q) => QUANT_OPTIONS.find((o) => o.value === q)?.imatrix,
|
|
);
|
|
const effectiveImatrix = useImatrix || requiresImatrix;
|
|
|
|
// Whether the inline export panel is expanded. The panel also shows itself
|
|
// whenever a run is active/terminal (see `panelActive`), so it survives
|
|
// navigation even though this local flag resets on remount.
|
|
const [panelOpen, setPanelOpen] = useState(false);
|
|
|
|
const [destination, setDestination] = useState<"local" | "hub">("local");
|
|
const [customSaveDirectory, setCustomSaveDirectory] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [hfUsername, setHfUsername] = useState("");
|
|
const [modelName, setModelName] = useState("");
|
|
const [privateRepo, setPrivateRepo] = useState(false);
|
|
|
|
// Export run state lives in the global runtime store so it keeps running and
|
|
// streaming in the background, in parallel with training and inference.
|
|
const runExport = useExportRuntimeStore((s) => s.runExport);
|
|
const resetExportRun = useExportRuntimeStore((s) => s.reset);
|
|
const isExporting = useExportRuntimeStore((s) => s.isExporting);
|
|
const panelActive = useExportRuntimeStore(isExportPanelActive);
|
|
|
|
const hfComboboxAnchorRef = useRef<HTMLDivElement>(null);
|
|
const localComboboxAnchorRef = useRef<HTMLDivElement>(null);
|
|
const selectingHfModelRef = useRef(false);
|
|
const hfModelInputRef = useRef("");
|
|
const localModelInputRef = useRef("");
|
|
|
|
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;
|
|
};
|
|
}, []);
|
|
|
|
// Apply the ?run= deep link (e.g. from a finished run's "Export to GGUF"
|
|
// button) once its run appears in the checkpoint list: select the run and
|
|
// default to GGUF. The main checkpoint is auto-selected further below, after
|
|
// the model-change effect that clears the checkpoint.
|
|
const { run: preselectRun } = useSearch({ from: "/export" });
|
|
const appliedRunRef = useRef<string | null>(null);
|
|
useEffect(() => {
|
|
if (!preselectRun) {
|
|
// Deep link cleared (e.g. navigated to /export via the sidebar): stop
|
|
// treating the previously preselected run specially.
|
|
appliedRunRef.current = null;
|
|
return;
|
|
}
|
|
if (models.length === 0) return;
|
|
if (appliedRunRef.current === preselectRun) return;
|
|
const match = models.find((m) => m.name === preselectRun);
|
|
if (!match) return;
|
|
appliedRunRef.current = preselectRun;
|
|
setSourceMode("checkpoint");
|
|
setSelectedModelIdx(match.name);
|
|
setExportMethod("gguf");
|
|
}, [preselectRun, models]);
|
|
|
|
// ---- 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(
|
|
() =>
|
|
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 isQuantized = !!selectedModelData?.is_quantized;
|
|
// isAdapter / isQuantized come from the checkpoint's metadata and are stale in "model" source
|
|
// mode (a direct base export), so treat both as false outside checkpoint mode to avoid wrongly
|
|
// gating the methods.
|
|
const effectiveIsAdapter = sourceMode === "checkpoint" && isAdapter;
|
|
const effectiveIsQuantized = sourceMode === "checkpoint" && isQuantized;
|
|
const loraRank = selectedModelData?.lora_rank ?? null;
|
|
const trainingMethodLabel = selectedModelData?.peft_type
|
|
? "LoRA / QLoRA"
|
|
: "Full Fine-tune";
|
|
const sourceBaseModelName = sourceMode === "model"
|
|
? selectedSourceModel ?? "—"
|
|
: baseModelName;
|
|
|
|
// For a full fine-tune checkpoint the weights live in the checkpoint dir
|
|
// itself (its base_model may be a local/custom path that can't be sized), so
|
|
// size that dir; for LoRA adapters the export merges into the base model.
|
|
const sizeTargetModel = useMemo(() => {
|
|
if (sourceMode === "checkpoint" && !isAdapter) {
|
|
const cp = checkpointsForModel.find((c) => c.display_name === checkpoint);
|
|
if (cp?.path) {
|
|
return cp.path;
|
|
}
|
|
}
|
|
return sourceBaseModelName;
|
|
}, [sourceMode, isAdapter, checkpointsForModel, checkpoint, sourceBaseModelName]);
|
|
|
|
// Real (MoE-aware) fp16 size, used to scale the GGUF quant estimates.
|
|
const { fp16Bytes } = useExportSizeEstimate(sizeTargetModel, debouncedHfToken);
|
|
const quantSizeLabels = useMemo(
|
|
() => buildQuantSizeLabels(fp16Bytes),
|
|
[fp16Bytes],
|
|
);
|
|
|
|
const {
|
|
results: hfResults,
|
|
isLoading: isLoadingHfModels,
|
|
error: hfSearchError,
|
|
} = useHubModelSearch(debouncedModelQuery, {
|
|
accessToken: debouncedHfToken || undefined,
|
|
excludeGguf: true,
|
|
// Curated unsloth listing by default, but a typed query searches the whole
|
|
// Hub (unsloth floated first) so non-unsloth base models stay selectable.
|
|
ownerScope: debouncedModelQuery.trim() ? "all" : "unsloth",
|
|
});
|
|
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],
|
|
);
|
|
const sourceTab: SourceTab =
|
|
sourceMode === "checkpoint" ? "checkpoint" : modelSource;
|
|
|
|
// Reset checkpoint when the selected model changes
|
|
useEffect(() => {
|
|
setCheckpoint(null);
|
|
}, [selectedModelIdx]);
|
|
|
|
// Default to the newest checkpoint when none is chosen (checkpoints are sorted newest-first).
|
|
// Declared after the reset effect above so it runs last and isn't clobbered back to null. Covers
|
|
// both a ?run= deep link and a plain finetune opened without an explicit checkpoint pick.
|
|
useEffect(() => {
|
|
if (sourceMode !== "checkpoint") return;
|
|
if (checkpoint != null || checkpointsForModel.length === 0) return;
|
|
setCheckpoint(checkpointsForModel[0].display_name);
|
|
}, [sourceMode, selectedModelIdx, checkpoint, checkpointsForModel]);
|
|
|
|
// Auto-reset export method if incompatible with the selected model type
|
|
useEffect(() => {
|
|
// Only LoRA needs a real adapter; Merged and GGUF work for non-PEFT base models too.
|
|
if (!effectiveIsAdapter && exportMethod === "lora") {
|
|
setExportMethod(null);
|
|
}
|
|
// Quantized non-PEFT models can't export to any format
|
|
if (!effectiveIsAdapter && effectiveIsQuantized && exportMethod !== null) {
|
|
setExportMethod(null);
|
|
}
|
|
// The GGUF LoRA target only applies to an adapter checkpoint on a non-Mac host.
|
|
if ((!effectiveIsAdapter || isMacHost) && ggufTarget !== "model") {
|
|
setGgufTarget("model");
|
|
}
|
|
}, [effectiveIsAdapter, effectiveIsQuantized, exportMethod, isMacHost, ggufTarget]);
|
|
|
|
const handleSourceTabChange = useCallback((next: string) => {
|
|
if (next === "checkpoint") {
|
|
setSourceMode("checkpoint");
|
|
} else if (next === "hf" || next === "local") {
|
|
setSourceMode("model");
|
|
setModelSource(next);
|
|
// Don't force GGUF: Local / HF sources can export Merged too; a stale LoRA pick auto-resets.
|
|
} else {
|
|
return;
|
|
}
|
|
setSelectedSourceModel(null);
|
|
setLocalModelInput("");
|
|
setModelInput("");
|
|
hfModelInputRef.current = "";
|
|
localModelInputRef.current = "";
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setSelectedSourceModel(null);
|
|
setLocalModelInput("");
|
|
setModelInput("");
|
|
}, [modelSource]);
|
|
|
|
useEffect(() => {
|
|
hfModelInputRef.current = modelInput;
|
|
}, [modelInput]);
|
|
|
|
useEffect(() => {
|
|
localModelInputRef.current = localModelInput;
|
|
}, [localModelInput]);
|
|
|
|
const handleMethodChange = (method: ExportMethod) => {
|
|
setExportMethod(method);
|
|
if (method !== "gguf") {
|
|
setQuantLevels([]);
|
|
}
|
|
};
|
|
|
|
const estimatedSize = getEstimatedSize(exportMethod, quantLevels, fp16Bytes);
|
|
const selectedExportSource =
|
|
sourceMode === "checkpoint" ? checkpoint : selectedSourceModel;
|
|
const defaultSaveDirectory = useMemo(() => {
|
|
const relative = buildRelativeSaveDirectory(
|
|
exportMethod,
|
|
sourceMode,
|
|
sourceBaseModelName,
|
|
selectedModelIdx,
|
|
checkpoint,
|
|
);
|
|
if (
|
|
exportMethod === "gguf" &&
|
|
sourceMode === "model" &&
|
|
modelSource === "local" &&
|
|
selectedSourceModel
|
|
) {
|
|
const localModel = localMetaById.get(selectedSourceModel);
|
|
if (localModel && (localModel.source === "models_dir" || localModel.source === "custom")) {
|
|
return siblingGgufDirectory(localModel.path) ?? relative;
|
|
}
|
|
}
|
|
return relative;
|
|
}, [
|
|
checkpoint,
|
|
exportMethod,
|
|
localMetaById,
|
|
modelSource,
|
|
selectedModelIdx,
|
|
selectedSourceModel,
|
|
sourceBaseModelName,
|
|
sourceMode,
|
|
]);
|
|
const saveDirectory = customSaveDirectory?.trim() || defaultSaveDirectory;
|
|
// Each merged format uploads a full model to the repo root, so several to one repo would collide.
|
|
// GGUF method exporting an adapter checkpoint as a GGUF LoRA (vs full-model quants). Reuses the
|
|
// LoRA-adapter export path; no quant list needed.
|
|
const ggufAsLora =
|
|
exportMethod === "gguf" &&
|
|
ggufTarget === "lora" &&
|
|
effectiveIsAdapter &&
|
|
!isMacHost;
|
|
|
|
// Restrict a Hub merged export to a single format; multi-format stays available for local export.
|
|
const hubMultiFormat =
|
|
destination === "hub" && exportMethod === "merged" && selectedFormats.length > 1;
|
|
|
|
const canExport = !!(
|
|
selectedExportSource &&
|
|
exportMethod &&
|
|
!exportUnsupported &&
|
|
!hubMultiFormat &&
|
|
(exportMethod !== "gguf" || ggufAsLora || quantLevels.length > 0) &&
|
|
(exportMethod !== "merged" || selectedFormats.length > 0)
|
|
);
|
|
|
|
const applyHfSourceModel = useCallback((value: string) => {
|
|
const next = value.trim();
|
|
setModelInput(next);
|
|
setSelectedSourceModel(next || null);
|
|
}, []);
|
|
|
|
const handleHfSourceModelSelect = useCallback((id: string | null) => {
|
|
selectingHfModelRef.current = true;
|
|
const next = id ?? "";
|
|
hfModelInputRef.current = next;
|
|
setModelInput(next);
|
|
setSelectedSourceModel(id);
|
|
}, []);
|
|
|
|
const handleHfSourceInputChange = useCallback(
|
|
(value: string, eventDetails?: { reason?: string }) => {
|
|
hfModelInputRef.current = value;
|
|
if (selectingHfModelRef.current) {
|
|
selectingHfModelRef.current = false;
|
|
return;
|
|
}
|
|
if (!SEARCH_INPUT_REASONS.has(eventDetails?.reason ?? "")) {
|
|
return;
|
|
}
|
|
setModelInput(value);
|
|
if (value.trim() === "") {
|
|
setSelectedSourceModel(null);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const applyLocalSourceModel = useCallback((value: string) => {
|
|
const next = value.trim();
|
|
setLocalModelInput(next);
|
|
setSelectedSourceModel(next || null);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setCustomSaveDirectory(null);
|
|
}, [checkpoint, exportMethod, modelSource, selectedModelIdx, selectedSourceModel, sourceMode]);
|
|
|
|
const handleLocalSourceInputChange = useCallback(
|
|
(value: string, eventDetails?: { reason?: string }) => {
|
|
localModelInputRef.current = value;
|
|
if (!SEARCH_INPUT_REASONS.has(eventDetails?.reason ?? "")) {
|
|
return;
|
|
}
|
|
setLocalModelInput(value);
|
|
if (value.trim() === "") {
|
|
setSelectedSourceModel(null);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
// ---- Export handlers ----
|
|
// Assemble the run params from the current form and hand off to the global
|
|
// runtime store, which drives load -> export -> cleanup in the background.
|
|
const handleStart = useCallback(async () => {
|
|
const source = sourceMode === "checkpoint" ? checkpoint : selectedSourceModel;
|
|
if (!source || !exportMethod) return;
|
|
// No supported accelerator (or PyTorch/MLX missing): the backend would reject anyway; don't submit.
|
|
if (exportUnsupported) return;
|
|
// GGUF with no quant, or merged with no format, would run an unintended/empty export; require
|
|
// at least one (mirrors canExport, in case the panel's Start button bypasses the outer one).
|
|
if (exportMethod === "gguf" && !ggufAsLora && quantLevels.length === 0) return;
|
|
if (exportMethod === "merged" && selectedFormats.length === 0) return;
|
|
// A Hub merged push writes each format to the repo root; several would collide (mirrors canExport).
|
|
if (hubMultiFormat) return;
|
|
|
|
const selectedCp = sourceMode === "checkpoint"
|
|
? checkpointsForModel.find((cp) => cp.display_name === checkpoint)
|
|
: null;
|
|
if (sourceMode === "checkpoint" && !selectedCp) return;
|
|
const checkpointPath = selectedCp?.path ?? null;
|
|
|
|
const pushToHub = destination === "hub";
|
|
const repoId = pushToHub && hfUsername && modelName
|
|
? `${hfUsername}/${modelName}`
|
|
: undefined;
|
|
const token = pushToHub && hfToken ? hfToken : undefined;
|
|
// The GGUF method with the LoRA target reuses the LoRA-adapter export path.
|
|
const effectiveMethod: ExportMethod = ggufAsLora ? "lora" : exportMethod;
|
|
const emitLoraGguf =
|
|
ggufAsLora || (effectiveMethod === "lora" && loraAsGguf && !isMacHost);
|
|
const methodLabel = ggufAsLora
|
|
? "GGUF LoRA adapter"
|
|
: (EXPORT_METHODS.find((m) => m.value === exportMethod)?.title ?? exportMethod);
|
|
const adapterExport = sourceMode === "checkpoint" && isAdapter;
|
|
|
|
// Consent gate for an HF source's custom (auto_map) code, run before we hand
|
|
// off to runExport (which performs the load in the background). A local
|
|
// checkpoint/model the user exported is trusted by default.
|
|
let trustRemoteCode = modelSource !== "hf";
|
|
let approvedRemoteCodeFingerprint: string | null = null;
|
|
if (sourceMode !== "checkpoint") {
|
|
const remoteCodeOk = await confirmRemoteCodeIfNeeded({
|
|
modelName: source,
|
|
hfToken: hfToken || null,
|
|
// An HF source can need trust_remote_code via its YAML default with no
|
|
// auto_map to review; signal it so a YAML-only model does not export
|
|
// with it false.
|
|
requiresTrustRemoteCode: modelSource === "hf",
|
|
onApprove: (fingerprint) => {
|
|
trustRemoteCode = true;
|
|
approvedRemoteCodeFingerprint = fingerprint;
|
|
},
|
|
});
|
|
if (!remoteCodeOk) return;
|
|
}
|
|
|
|
void runExport({
|
|
sourceMode,
|
|
checkpointPath,
|
|
source,
|
|
modelSource,
|
|
trustRemoteCode,
|
|
approvedRemoteCodeFingerprint,
|
|
loadToken: hfToken || null,
|
|
exportMethod: effectiveMethod,
|
|
isAdapter: adapterExport,
|
|
quantLevels,
|
|
useImatrix: effectiveImatrix,
|
|
mergedSelections: selectedFormats.map((v) => ({
|
|
...mergedFormatPayload(v),
|
|
label: MERGED_FORMATS.find((f) => f.value === v)?.label ?? v,
|
|
})),
|
|
loraGguf: emitLoraGguf,
|
|
loraGgufOuttype,
|
|
saveDirectory,
|
|
destination,
|
|
repoId,
|
|
token,
|
|
privateRepo,
|
|
baseModelId: selectedModelData?.base_model ?? undefined,
|
|
summary: {
|
|
baseModelName: sourceBaseModelName,
|
|
checkpointLabel: selectedExportSource,
|
|
methodLabel,
|
|
method: effectiveMethod,
|
|
quantLevels,
|
|
mergedFormats: exportMethod === "merged" ? selectedFormats : [],
|
|
destination,
|
|
},
|
|
});
|
|
}, [
|
|
checkpoint,
|
|
checkpointsForModel,
|
|
sourceMode,
|
|
selectedSourceModel,
|
|
selectedModelData,
|
|
selectedExportSource,
|
|
sourceBaseModelName,
|
|
exportMethod,
|
|
isAdapter,
|
|
quantLevels,
|
|
effectiveImatrix,
|
|
selectedFormats,
|
|
hubMultiFormat,
|
|
ggufAsLora,
|
|
loraAsGguf,
|
|
isMacHost,
|
|
loraGgufOuttype,
|
|
exportUnsupported,
|
|
destination,
|
|
saveDirectory,
|
|
hfUsername,
|
|
modelName,
|
|
hfToken,
|
|
privateRepo,
|
|
modelSource,
|
|
runExport,
|
|
]);
|
|
|
|
// Open the inline panel into a fresh config state. Clears any previous
|
|
// terminal run (a still-running export cannot be cleared, so it stays).
|
|
const handleOpenPanel = useCallback(() => {
|
|
if (!isExporting) {
|
|
resetExportRun();
|
|
}
|
|
setPanelOpen(true);
|
|
}, [isExporting, resetExportRun]);
|
|
|
|
// Collapse the panel. Only reachable from config / terminal states (never
|
|
// mid-run), so resetting the store back to idle is safe.
|
|
const handleClosePanel = useCallback(() => {
|
|
resetExportRun();
|
|
setPanelOpen(false);
|
|
}, [resetExportRun]);
|
|
|
|
const showPanel = panelOpen || panelActive;
|
|
|
|
// Bring the panel into view when it opens and offer a scroll-down affordance
|
|
// (like Chat) when its end is below the fold.
|
|
const panelEndRef = useRef<HTMLDivElement>(null);
|
|
const [panelEndVisible, setPanelEndVisible] = useState(true);
|
|
|
|
useEffect(() => {
|
|
if (!showPanel) return;
|
|
const id = window.setTimeout(() => {
|
|
panelEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
|
}, 60);
|
|
return () => window.clearTimeout(id);
|
|
}, [showPanel]);
|
|
|
|
useEffect(() => {
|
|
const el = panelEndRef.current;
|
|
if (!showPanel || !el) return;
|
|
// The scroll-down button is also gated on showPanel, so there is no need to
|
|
// reset visibility when the panel closes; the observer self-corrects on open.
|
|
const obs = new IntersectionObserver(
|
|
([entry]) => setPanelEndVisible(entry.isIntersecting),
|
|
{ rootMargin: "0px 0px -40px 0px" },
|
|
);
|
|
obs.observe(el);
|
|
return () => obs.disconnect();
|
|
}, [showPanel]);
|
|
|
|
// ---- Render ----
|
|
return (
|
|
<div className="min-h-[calc(100dvh-var(--studio-titlebar-height,0px))] bg-background">
|
|
<main className="mx-auto max-w-7xl px-5 py-8 sm:px-9">
|
|
<GuidedTour {...tour.tourProps} />
|
|
|
|
<div className="mb-8 flex flex-col gap-0.5">
|
|
<h1 className="text-[30px] font-semibold leading-[1.04] tracking-[-0.028em] text-foreground sm:text-[34px]">
|
|
Export Model
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Export fine-tuned or base models for deployment
|
|
</p>
|
|
</div>
|
|
|
|
<SectionCard
|
|
icon={<HugeiconsIcon icon={PackageIcon} className="size-5" />}
|
|
title="Export Configuration"
|
|
description="Select source, method, and quantization"
|
|
accent="emerald"
|
|
featured={true}
|
|
className="ring-0 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:shadow-none"
|
|
>
|
|
{/* 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>
|
|
)}
|
|
|
|
{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-1 gap-6 md:grid-cols-2 md:gap-8">
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex flex-col gap-2">
|
|
<label className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
|
Source
|
|
<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 local model, fine-tuned checkpoint, or
|
|
Hugging Face model to export.
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</label>
|
|
<Tabs
|
|
value={sourceTab}
|
|
onValueChange={handleSourceTabChange}
|
|
className="w-full"
|
|
>
|
|
<TabsList
|
|
unstyled={true}
|
|
className="hub-menu-trigger hub-tab-toggle relative inline-flex h-9 w-full items-center rounded-full"
|
|
>
|
|
<TabsTrigger
|
|
value="local"
|
|
indicatorClassName="hub-tab-toggle-pill rounded-full"
|
|
className="h-9 rounded-full border-0 px-3 text-[12.5px] text-muted-foreground hover:text-foreground data-active:text-foreground data-[state=active]:text-foreground"
|
|
>
|
|
Local Model
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="checkpoint"
|
|
indicatorClassName="hub-tab-toggle-pill rounded-full"
|
|
className="h-9 rounded-full border-0 px-3 text-[12.5px] text-muted-foreground hover:text-foreground data-active:text-foreground data-[state=active]:text-foreground"
|
|
>
|
|
Fine-tuned
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="hf"
|
|
indicatorClassName="hub-tab-toggle-pill rounded-full"
|
|
className="h-9 rounded-full border-0 px-3 text-[12.5px] text-muted-foreground hover:text-foreground data-active:text-foreground data-[state=active]:text-foreground"
|
|
>
|
|
Hugging Face
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
</div>
|
|
|
|
{sourceMode === "checkpoint" ? (
|
|
<div className="flex flex-col gap-2 overflow-visible">
|
|
<div data-tour="export-training-run" className="flex flex-col gap-2">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Training Run
|
|
</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 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…"
|
|
}
|
|
/>
|
|
</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>
|
|
) : (
|
|
<div className="flex flex-col gap-2 overflow-visible">
|
|
{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={modelInput || selectedSourceModel || null}
|
|
onValueChange={handleHfSourceModelSelect}
|
|
onInputValueChange={handleHfSourceInputChange}
|
|
itemToStringValue={(id) => id}
|
|
autoHighlight={true}
|
|
>
|
|
<ComboboxInput
|
|
placeholder="Search models..."
|
|
className="w-full"
|
|
onBlur={() =>
|
|
applyHfSourceModel(hfModelInputRef.current)
|
|
}
|
|
onKeyDown={(event) => {
|
|
if (event.key !== "Enter") return;
|
|
event.preventDefault();
|
|
applyHfSourceModel(hfModelInputRef.current);
|
|
}}
|
|
>
|
|
<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>
|
|
{/* No persistent "trust remote code" toggle: custom code is
|
|
consented per model via the load-time review dialog. */}
|
|
<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 ?? "";
|
|
localModelInputRef.current = next;
|
|
setLocalModelInput(next);
|
|
setSelectedSourceModel(next || null);
|
|
}}
|
|
onInputValueChange={handleLocalSourceInputChange}
|
|
itemToStringValue={(id) => id}
|
|
autoHighlight={true}
|
|
>
|
|
<ComboboxInput
|
|
placeholder={
|
|
isLoadingLocalModels
|
|
? "Scanning local and cached models..."
|
|
: "./models/my-model"
|
|
}
|
|
className="w-full"
|
|
onBlur={() => applyLocalSourceModel(localModelInputRef.current)}
|
|
onKeyDown={(event) => {
|
|
if (event.key !== "Enter") return;
|
|
event.preventDefault();
|
|
applyLocalSourceModel(localModelInputRef.current);
|
|
}}
|
|
>
|
|
<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"
|
|
: model?.source === "custom"
|
|
? "Custom Folders"
|
|
: "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-foreground/[0.04] p-3">
|
|
<p className="text-[11px] text-muted-foreground">
|
|
Direct model exports currently support GGUF only.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{sourceMode === "checkpoint" && (
|
|
<div className="rounded-xl bg-foreground/[0.04] 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 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">
|
|
{exportGuideSteps.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-foreground/10 text-[10px] font-semibold">
|
|
{i + 1}
|
|
</span>
|
|
{step}
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
|
|
{exportUnsupported && (
|
|
<Alert variant="destructive">
|
|
<HugeiconsIcon icon={AlertCircleIcon} className="size-4" />
|
|
<AlertTitle>Export unavailable</AlertTitle>
|
|
<AlertDescription>{exportUnsupportedMessage}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<MethodPicker
|
|
value={exportMethod}
|
|
onChange={handleMethodChange}
|
|
disabledMethods={
|
|
exportUnsupported
|
|
? ["merged", "lora", "gguf"]
|
|
: !effectiveIsAdapter && effectiveIsQuantized
|
|
? ["merged", "lora", "gguf"]
|
|
: !effectiveIsAdapter
|
|
? ["lora"]
|
|
: []
|
|
}
|
|
disabledReason={
|
|
exportUnsupported
|
|
? exportUnsupportedMessage
|
|
: !effectiveIsAdapter && effectiveIsQuantized
|
|
? "Pre-quantized (BNB 4-bit) models cannot be exported without LoRA adapters"
|
|
: !effectiveIsAdapter
|
|
? "LoRA-only export needs a LoRA adapter checkpoint"
|
|
: undefined
|
|
}
|
|
/>
|
|
|
|
{exportMethod === "merged" && !exportUnsupported && (
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm font-medium">Precision</div>
|
|
<span className="text-[11px] text-muted-foreground/70">
|
|
— select one or more
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{availableFormats
|
|
.filter((f) => f.common)
|
|
.map((f) => {
|
|
const active = selectedFormats.includes(f.value);
|
|
return (
|
|
<Button
|
|
key={f.value}
|
|
type="button"
|
|
variant={active ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => toggleFormat(f.value)}
|
|
title={f.hint}
|
|
>
|
|
{f.label}
|
|
{f.needsCalibration ? " *" : ""}
|
|
</Button>
|
|
);
|
|
})}
|
|
|
|
{availableFormats.some((f) => !f.common) && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild={true}>
|
|
<Button type="button" variant="outline" size="sm">
|
|
More formats
|
|
{selectedFormats.some((v) =>
|
|
availableFormats.find(
|
|
(f) => f.value === v && !f.common,
|
|
),
|
|
)
|
|
? " ✓"
|
|
: "…"}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-64">
|
|
<DropdownMenuLabel>
|
|
Additional formats
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
{availableFormats
|
|
.filter((f) => !f.common)
|
|
.map((f) => (
|
|
<DropdownMenuCheckboxItem
|
|
key={f.value}
|
|
checked={selectedFormats.includes(f.value)}
|
|
onCheckedChange={() => toggleFormat(f.value)}
|
|
onSelect={(e) => e.preventDefault()}
|
|
>
|
|
<span className="flex flex-col">
|
|
<span>
|
|
{f.label}
|
|
{f.needsCalibration ? " *" : ""}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{f.hint}
|
|
</span>
|
|
</span>
|
|
</DropdownMenuCheckboxItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
|
|
{selectedFormats.length > 0 && (
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
<span className="text-[11px] text-muted-foreground">
|
|
{selectedFormats.length} selected:{" "}
|
|
{selectedFormats
|
|
.map(
|
|
(v) =>
|
|
MERGED_FORMATS.find((f) => f.value === v)
|
|
?.label ?? v,
|
|
)
|
|
.join(", ")}
|
|
</span>
|
|
{selectedFormats.length > 1 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedFormats(["16-bit"])}
|
|
className="text-[11px] text-muted-foreground/70 hover:text-foreground transition-colors"
|
|
>
|
|
Reset to 16-bit
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{hubMultiFormat && (
|
|
<div className="text-[11px] text-amber-600 dark:text-amber-500">
|
|
Hub export supports one format at a time (each writes to
|
|
the repository root). Select a single format, or export
|
|
locally to produce several at once.
|
|
</div>
|
|
)}
|
|
|
|
{selectedFormats.some(
|
|
(v) =>
|
|
MERGED_FORMATS.find((f) => f.value === v)
|
|
?.needsCalibration,
|
|
) && (
|
|
<div className="text-[11px] text-muted-foreground">
|
|
* calibrates on data (uses a small calibration set).
|
|
</div>
|
|
)}
|
|
|
|
{!hasNvidia && !isWindowsRocm && (
|
|
<div className="text-[11px] text-muted-foreground">
|
|
No NVIDIA GPU detected: compressed-tensors formats are
|
|
hidden. 16-bit and portable FP8/INT8 (torchao) still
|
|
work here and load in vLLM.
|
|
</div>
|
|
)}
|
|
|
|
{isWindowsRocm && (
|
|
<div className="text-[11px] text-muted-foreground">
|
|
Windows ROCm: quantized FP8/INT8 (torchao) export is
|
|
unavailable (no torch.distributed). Use 16-bit or GGUF.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{exportMethod === "lora" && effectiveIsAdapter && !exportUnsupported && (
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<div className="text-sm font-medium">Adapter format</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
variant={!loraAsGguf ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setLoraAsGguf(false)}
|
|
title="Standard PEFT adapter (adapter_model.safetensors)."
|
|
>
|
|
Adapter (safetensors)
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant={loraAsGguf ? "default" : "outline"}
|
|
size="sm"
|
|
disabled={isMacHost}
|
|
onClick={() => setLoraAsGguf(true)}
|
|
title={
|
|
isMacHost
|
|
? "GGUF LoRA export is not available on macOS/MLX. Use the safetensors adapter."
|
|
: "llama.cpp GGUF LoRA, loadable with `llama-cli --lora`."
|
|
}
|
|
>
|
|
GGUF adapter
|
|
</Button>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{isMacHost
|
|
? "GGUF LoRA is not available on macOS/MLX; exporting the safetensors adapter."
|
|
: loraAsGguf
|
|
? "Converts the adapter to a GGUF LoRA (llama.cpp `--lora`). The base model stays separate."
|
|
: "Standard PEFT adapter files. Pair with the base model at inference."}
|
|
</div>
|
|
</div>
|
|
|
|
{loraAsGguf && (
|
|
<div className="space-y-1.5">
|
|
<div className="text-sm font-medium">Output type</div>
|
|
<Select
|
|
value={loraGgufOuttype}
|
|
onValueChange={(v) => setLoraGgufOuttype(v)}
|
|
>
|
|
<SelectTrigger className="w-full sm:w-56">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{LORA_GGUF_OUTTYPES.map((t) => (
|
|
<SelectItem key={t} value={t}>
|
|
{t.toUpperCase()}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{exportMethod === "gguf" && !exportUnsupported && (
|
|
<div className="space-y-3">
|
|
{effectiveIsAdapter && !isMacHost && (
|
|
<div className="space-y-2">
|
|
<div className="text-sm font-medium">Export target</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
variant={ggufTarget === "model" ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setGgufTarget("model")}
|
|
title="Merge the adapter into the base model, then quantize the full model to GGUF."
|
|
>
|
|
Full model
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant={ggufTarget === "lora" ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setGgufTarget("lora")}
|
|
title="Export just the adapter as a GGUF LoRA (llama.cpp `--lora`); the base model stays separate."
|
|
>
|
|
LoRA adapter
|
|
</Button>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{ggufTarget === "lora"
|
|
? "Converts the adapter to a GGUF LoRA (llama.cpp `--lora`). The base model stays separate."
|
|
: "Merges the adapter into the base model, then quantizes the full model to GGUF."}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{ggufAsLora ? (
|
|
<div className="space-y-1.5">
|
|
<div className="text-sm font-medium">Output type</div>
|
|
<Select
|
|
value={loraGgufOuttype}
|
|
onValueChange={(v) => setLoraGgufOuttype(v)}
|
|
>
|
|
<SelectTrigger className="w-full sm:w-56">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{LORA_GGUF_OUTTYPES.map((t) => (
|
|
<SelectItem key={t} value={t}>
|
|
{t.toUpperCase()}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<QuantPicker
|
|
value={quantLevels}
|
|
onChange={setQuantLevels}
|
|
sizes={quantSizeLabels}
|
|
/>
|
|
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
|
|
<div className="space-y-0.5">
|
|
<div className="text-sm font-medium">
|
|
Importance matrix (imatrix)
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{requiresImatrix
|
|
? "Required for the selected IQ low-bit quant. Auto-downloads the upstream Unsloth imatrix for the base model."
|
|
: "Improves quant quality and unlocks the IQ low-bit quants. Auto-downloads the upstream Unsloth imatrix for the base model."}
|
|
</div>
|
|
</div>
|
|
<Switch
|
|
checked={effectiveImatrix}
|
|
onCheckedChange={setUseImatrix}
|
|
disabled={requiresImatrix}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
{estimatedSize && (
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<HugeiconsIcon
|
|
icon={InformationCircleIcon}
|
|
className="size-3.5"
|
|
/>
|
|
<span>Est. size: {estimatedSize}</span>
|
|
</div>
|
|
)}
|
|
|
|
<Separator />
|
|
{showPanel && (
|
|
<ExportRunPanel
|
|
exportMethod={exportMethod}
|
|
quantLevels={quantLevels}
|
|
checkpoint={selectedExportSource}
|
|
baseModelName={sourceBaseModelName}
|
|
isAdapter={sourceMode === "checkpoint" && isAdapter}
|
|
destination={destination}
|
|
onDestinationChange={setDestination}
|
|
saveDirectory={saveDirectory}
|
|
defaultSaveDirectory={defaultSaveDirectory}
|
|
saveDirectoryOverridden={!!customSaveDirectory}
|
|
onSaveDirectoryChange={setCustomSaveDirectory}
|
|
hfUsername={hfUsername}
|
|
onHfUsernameChange={setHfUsername}
|
|
modelName={modelName}
|
|
onModelNameChange={setModelName}
|
|
hfToken={hfToken}
|
|
onHfTokenChange={setHfToken}
|
|
privateRepo={privateRepo}
|
|
onPrivateRepoChange={setPrivateRepo}
|
|
onStart={handleStart}
|
|
onClose={handleClosePanel}
|
|
/>
|
|
)}
|
|
{showPanel && (
|
|
<div ref={panelEndRef} aria-hidden="true" className="h-px w-full" />
|
|
)}
|
|
{showPanel && !panelEndVisible && (
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
panelEndRef.current?.scrollIntoView({
|
|
behavior: "smooth",
|
|
block: "end",
|
|
})
|
|
}
|
|
aria-label="Scroll to export output"
|
|
className="fixed bottom-6 right-6 z-30 flex size-10 items-center justify-center rounded-full border border-border/60 bg-background/90 text-foreground shadow-md backdrop-blur transition-colors hover:bg-muted"
|
|
>
|
|
<HugeiconsIcon icon={ArrowDown01Icon} className="size-5" />
|
|
</button>
|
|
)}
|
|
{!showPanel && (
|
|
<div className="flex items-center justify-end">
|
|
<Button
|
|
data-tour="export-cta"
|
|
disabled={!canExport}
|
|
onClick={handleOpenPanel}
|
|
>
|
|
Export Model
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</SectionCard>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|