From b92123a5f0fd7a32df53213fc9a7bd74cf11ca6c Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 05:03:41 -0700 Subject: [PATCH] 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 --- studio/frontend/src/app/routes/export.tsx | 9 ++++ .../src/features/export/export-page.tsx | 33 ++++++++++++ .../studio/historical-training-view.tsx | 1 + .../studio/sections/progress-section.tsx | 53 ++++++++++++++----- .../src/features/training/types/runtime.ts | 3 ++ studio/frontend/src/i18n/locales/en.ts | 1 + 6 files changed, 88 insertions(+), 12 deletions(-) diff --git a/studio/frontend/src/app/routes/export.tsx b/studio/frontend/src/app/routes/export.tsx index c0356c823f..40118c6a92 100644 --- a/studio/frontend/src/app/routes/export.tsx +++ b/studio/frontend/src/app/routes/export.tsx @@ -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): ExportSearch => ({ + run: typeof search.run === "string" ? search.run : undefined, + }), component: ExportPage, }); diff --git a/studio/frontend/src/features/export/export-page.tsx b/studio/frontend/src/features/export/export-page.tsx index 64123bc2f5..e23b3d7262 100644 --- a/studio/frontend/src/features/export/export-page.tsx +++ b/studio/frontend/src/features/export/export-page.tsx @@ -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(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")) { diff --git a/studio/frontend/src/features/studio/historical-training-view.tsx b/studio/frontend/src/features/studio/historical-training-view.tsx index e958f2ba0d..0bca090413 100644 --- a/studio/frontend/src/features/studio/historical-training-view.tsx +++ b/studio/frontend/src/features/studio/historical-training-view.tsx @@ -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 diff --git a/studio/frontend/src/features/studio/sections/progress-section.tsx b/studio/frontend/src/features/studio/sections/progress-section.tsx index 67800df27b..de8605e021 100644 --- a/studio/frontend/src/features/studio/sections/progress-section.tsx +++ b/studio/frontend/src/features/studio/sections/progress-section.tsx @@ -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 ? ( - - ) : ( - - ) +
+ {canExportGguf && ( + + )} + {isHistorical ? ( + + ) : ( + + )} +
} >
diff --git a/studio/frontend/src/features/training/types/runtime.ts b/studio/frontend/src/features/training/types/runtime.ts index 5fd8286d22..c27a2f2bed 100644 --- a/studio/frontend/src/features/training/types/runtime.ts +++ b/studio/frontend/src/features/training/types/runtime.ts @@ -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; diff --git a/studio/frontend/src/i18n/locales/en.ts b/studio/frontend/src/i18n/locales/en.ts index 214767bec0..7bc86b57ec 100644 --- a/studio/frontend/src/i18n/locales/en.ts +++ b/studio/frontend/src/i18n/locales/en.ts @@ -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",