Studio: add Export to GGUF button on finished training runs (#6475)

* Studio: add Export to GGUF button on finished training runs

A completed run's 'Current Run' tab greys out, so it was unclear how to
export it: GGUF export lives on the separate Export page and there was no
link to it from a run. Add an 'Export to GGUF' button to the run progress
card (shown for completed/stopped runs) that deep-links to the Export page
with that run preselected via a new ?run= search param. The Export page
reads the param, selects the run, defaults to GGUF, and picks the run's
main checkpoint. No retraining is required to export a finished run.

* Studio: fix export deep-link checkpoint preselect and edge cases

Address review feedback on the Export to GGUF deep link:
- Move the main-checkpoint auto-select effect after the model-change reset
  effect so it runs last; previously the reset cleared the checkpoint back to
  null in the same commit, leaving the field empty on a deep link.
- Reset the applied-run ref when the ?run= param clears (e.g. navigating to
  /export via the sidebar) so a later manual reselect of the same run is not
  treated as a deep link.
- Trim trailing slashes before taking the run output-dir basename so a path
  like /outputs/run/ still yields a name (the button no longer disappears).

* Studio: hide Export to GGUF on runs superseded by a resume

A stopped run whose output_dir was later reused by a resumed run is marked
resumed_later by the backend; its on-disk contents no longer match the older
run's metrics. Since the export deep link selects by output-dir basename,
showing the button on such a run would export the newer continuation instead
of the run being viewed. Carry resumed_later into the view data and hide the
button when set.

---------

Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
This commit is contained in:
Daniel Han 2026-06-22 05:03:41 -07:00 committed by GitHub
commit b92123a5f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 88 additions and 12 deletions

View file

@ -12,10 +12,19 @@ const ExportPage = lazy(() =>
})),
);
export type ExportSearch = {
// Preselect a training run on the Export page (its output-dir basename, which
// equals the checkpoint scan's model name). Set when arriving from a run view.
run?: string;
};
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/export",
staticData: { title: "Export" },
beforeLoad: () => requireAuth(),
validateSearch: (search: Record<string, unknown>): ExportSearch => ({
run: typeof search.run === "string" ? search.run : undefined,
}),
component: ExportPage,
});

View file

@ -49,6 +49,7 @@ import {
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";
@ -211,6 +212,29 @@ export function ExportPage() {
};
}, []);
// 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();
@ -362,6 +386,15 @@ export function ExportPage() {
setCheckpoint(null);
}, [selectedModelIdx]);
// For a ?run= deep link, default to the run's main checkpoint. Declared after
// the reset effect above so it runs last and isn't clobbered back to null.
useEffect(() => {
if (appliedRunRef.current == null) return;
if (appliedRunRef.current !== selectedModelIdx) return;
if (checkpoint != null || checkpointsForModel.length === 0) return;
setCheckpoint(checkpointsForModel[0].display_name);
}, [selectedModelIdx, checkpoint, checkpointsForModel]);
// Auto-reset export method if incompatible with the selected model type
useEffect(() => {
if (!isAdapter && (exportMethod === "merged" || exportMethod === "lora")) {

View file

@ -59,6 +59,7 @@ function mapToViewData(
currentEpoch: metrics.final_epoch,
currentNumTokens: metrics.final_num_tokens ?? null,
outputDir: run.output_dir ?? null,
resumedLater: run.resumed_later ?? false,
progressPercent:
run.total_steps && run.final_step
? (run.final_step / run.total_steps) * 100

View file

@ -33,6 +33,7 @@ import { cn } from "@/lib/utils";
import {
ChartAverageIcon,
DashboardSpeed01Icon,
FolderExportIcon,
Notebook01Icon,
RamMemoryIcon,
StopIcon,
@ -153,6 +154,21 @@ export function ProgressSection({
await navigate({ to: "/chat" });
};
// A finished run can be exported to GGUF: deep-link to the Export page with
// this run preselected (its output-dir basename is the export model name).
const exportRunName = data.outputDir
? (data.outputDir.replace(/[/\\]+$/, "").split(/[/\\]/).pop() || null)
: null;
const canExportGguf =
!data.isTrainingRunning &&
!!exportRunName &&
!data.resumedLater &&
(data.phase === "completed" || data.phase === "stopped");
const handleExportGguf = () => {
if (!exportRunName) return;
void navigate({ to: "/export", search: { run: exportRunName } });
};
const stoppedLoss = getDisplayMetric(
data.isTrainingRunning,
data.currentLoss,
@ -219,18 +235,31 @@ export function ProgressSection({
accent="emerald"
className="shadow-border border border-border/60 bg-card/90 ring-0 backdrop-blur-sm"
headerAction={
isHistorical ? (
<ConfigPopoverButton configItems={configItems} />
) : (
<LiveTrainingHeaderActions
configItems={configItems}
isTrainingRunning={data.isTrainingRunning}
onOpenStopDialog={setStopDialogOpen}
stopDialogOpen={stopDialogOpen}
stopRequested={stopRequested}
onSetStopRequested={setStopRequestedLocal}
/>
)
<div className="flex items-center gap-2">
{canExportGguf && (
<Button
size="sm"
variant="outline"
className="h-8 gap-1.5 text-xs"
onClick={handleExportGguf}
>
<HugeiconsIcon icon={FolderExportIcon} className="size-3.5" />
{t("studio.progress.exportGguf")}
</Button>
)}
{isHistorical ? (
<ConfigPopoverButton configItems={configItems} />
) : (
<LiveTrainingHeaderActions
configItems={configItems}
isTrainingRunning={data.isTrainingRunning}
onOpenStopDialog={setStopDialogOpen}
stopDialogOpen={stopDialogOpen}
stopRequested={stopRequested}
onSetStopRequested={setStopRequestedLocal}
/>
)}
</div>
}
>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1.2fr)_minmax(18rem,0.8fr)]">

View file

@ -147,6 +147,9 @@ export interface TrainingViewData {
currentEpoch: number | null;
currentNumTokens: number | null;
outputDir: string | null;
// True when a newer run reused this run's output_dir (resume), so its
// on-disk contents no longer match this (older) run's metrics.
resumedLater?: boolean;
progressPercent: number;
elapsedSeconds: number | null;
etaSeconds: number | null;

View file

@ -785,6 +785,7 @@ export const en = {
progress: {
title: "Training Progress",
liveMetrics: "Live training metrics",
exportGguf: "Export to GGUF",
openConfig: "Open training config",
configLabel: "Training Config",
hyperparams: "Hyperparams",