From ff458a36e944b936853f0f6ab5173218e1e6b210 Mon Sep 17 00:00:00 2001 From: Shine1i Date: Tue, 17 Feb 2026 20:08:02 +0100 Subject: [PATCH 1/4] feat: enhance training flow with new runtime hints, adjustable steps/epochs - Added halfway/completed training hints with actionable links. - Introduced sliders for adjusting max steps and epochs dynamically. - Refined tooltip explanations for configuration parameters. - Enabled custom overlay styling for `AlertDialogContent`. --- .../src/components/ui/alert-dialog.tsx | 28 +++---- .../components/steps/hyperparameters-step.tsx | 78 ++++++++++++++++--- .../studio/sections/params-section.tsx | 50 ++++++------ .../studio/sections/progress-section.tsx | 26 ++++++- 4 files changed, 132 insertions(+), 50 deletions(-) diff --git a/studio/frontend/src/components/ui/alert-dialog.tsx b/studio/frontend/src/components/ui/alert-dialog.tsx index b39a310c49..61327e525e 100644 --- a/studio/frontend/src/components/ui/alert-dialog.tsx +++ b/studio/frontend/src/components/ui/alert-dialog.tsx @@ -42,19 +42,21 @@ function AlertDialogOverlay({ ); } -function AlertDialogContent({ - className, - size = "default", - ...props -}: React.ComponentProps & { - size?: "default" | "sm"; -}) { - return ( - - - & { + size?: "default" | "sm"; + overlayClassName?: string; +}) { + return ( + + + ({ trainingMethod: s.trainingMethod, + maxSteps: s.maxSteps, + setMaxSteps: s.setMaxSteps, epochs: s.epochs, setEpochs: s.setEpochs, contextLength: s.contextLength, @@ -60,6 +64,8 @@ export function HyperparametersStep() { const showLoraParams = trainingMethod === "lora" || trainingMethod === "qlora"; + const maxStepsSliderMax = Math.max(500, maxSteps, 30); + const epochsSliderMax = Math.max(10, epochs, 1); return ( @@ -68,7 +74,7 @@ export function HyperparametersStep() {
- Epochs + Max Steps
@@ -196,6 +202,56 @@ export function HyperparametersStep() { className="w-32 font-mono" />
+ +
+ + Epochs + + + + + + Number of full passes over the dataset. Set 0 to run by max + steps.{" "} + + Read more + + + + +
+ setEpochs(v)} + min={0} + max={epochsSliderMax} + step={1} + className="w-40" + /> + setEpochs(Number(e.target.value))} + min={0} + max={epochsSliderMax} + step={1} + className="w-12 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary/30 [&::-webkit-inner-spin-button]:appearance-none" + /> +
+
diff --git a/studio/frontend/src/features/studio/sections/params-section.tsx b/studio/frontend/src/features/studio/sections/params-section.tsx index 141a38286d..2d7968e6c5 100644 --- a/studio/frontend/src/features/studio/sections/params-section.tsx +++ b/studio/frontend/src/features/studio/sections/params-section.tsx @@ -112,6 +112,8 @@ export function ParamsSection(): ReactElement { const showVisionLora = store.isVisionModel && store.isDatasetMultimodal === true; const [loraOpen, setLoraOpen] = useState(false); const [hyperOpen, setHyperOpen] = useState(false); + const maxStepsSliderMax = Math.max(500, store.maxSteps, 30); + const epochsSliderMax = Math.max(20, store.epochs, 1); return (
@@ -123,11 +125,11 @@ export function ParamsSection(): ReactElement { className="min-h-[450px]" >
- {/* Epochs */} + {/* Max Steps */}
store.setEpochs(v)} - min={1} - max={20} + value={[Math.min(maxStepsSliderMax, Math.max(0, store.maxSteps))]} + onValueChange={([v]) => store.setMaxSteps(v)} + min={0} + max={maxStepsSliderMax} step={1} />

- Number of full passes over the training dataset + Total optimizer steps. Use 0 to run by epochs.

@@ -602,11 +603,12 @@ export function ParamsSection(): ReactElement { max={100} step={1} /> - - Override total steps. 0 means use epochs instead.{" "} + Number of full passes over the dataset. Set 0 to run by + max steps.{" "} } - > - store.setMaxSteps(Number(e.target.value))} - className="w-28 font-mono" - /> - + value={store.epochs} + onChange={store.setEpochs} + min={0} + max={epochsSliderMax} + step={1} + /> 0 ? runtime.currentStep / elapsed : null; + const showHalfwayHint = + runtime.phase === "training" && pct >= 50 && pct < 100; + const showCompletedHint = runtime.phase === "completed"; const stoppedLoss = getDisplayMetric( runtime.isTrainingRunning, @@ -198,7 +202,7 @@ export function ProgressSection(): ReactElement { > Stop - + Stop Training @@ -252,6 +256,26 @@ export function ProgressSection(): ReactElement {
+ {(showHalfwayHint || showCompletedHint) && ( +
+

+ {showCompletedHint + ? "Training done. Next step: compare base vs fine-tuned outputs." + : "Halfway done. Training is past 50%."} +

+ {showCompletedHint && ( +
+ + +
+ )} +
+ )} + {runtime.error && (

{runtime.error}

)} From a0235025af42c7b03237e78e30868c77527778a4 Mon Sep 17 00:00:00 2001 From: Shine1i Date: Tue, 17 Feb 2026 20:42:43 +0100 Subject: [PATCH 2/4] fix chat compare handoff: auto-load trained lora, stop refresh loop, add debug logs --- .../frontend/src/features/chat/chat-page.tsx | 132 ++++++++++++++++-- studio/frontend/src/features/chat/index.ts | 1 + .../studio/sections/progress-section.tsx | 12 +- 3 files changed, 131 insertions(+), 14 deletions(-) diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index c79f4b387c..5c1e61b432 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -29,6 +29,10 @@ import { GuidedTour, useGuidedTourController } from "@/features/tour"; import { ChatSettingsPanel } from "./chat-settings-sheet"; import { db } from "./db"; import { useChatModelRuntime } from "./hooks/use-chat-model-runtime"; +import { + clearTrainingCompareHandoff, + getTrainingCompareHandoff, +} from "./lib/training-compare-handoff"; import { ChatRuntimeProvider } from "./runtime-provider"; import { useChatRuntimeStore } from "./stores/chat-runtime-store"; import { @@ -41,6 +45,43 @@ import { ThreadSidebar } from "./thread-sidebar"; import type { ChatView } from "./types"; import { buildChatTourSteps } from "./tour"; +type LoraCandidate = { + id: string; + baseModel: string; + updatedAt?: number; +}; + +function normalizeModelRef(value: string | null | undefined): string { + return value?.trim().toLowerCase() ?? ""; +} + +function pickBestLoraForBase( + loras: LoraCandidate[], + baseModel: string | null, +): LoraCandidate | null { + if (loras.length === 0) return null; + const sorted = [...loras].sort( + (a, b) => (b.updatedAt ?? -1) - (a.updatedAt ?? -1), + ); + const normalizedBase = normalizeModelRef(baseModel); + if (!normalizedBase) return sorted[0]; + + const exact = sorted.find( + (lora) => normalizeModelRef(lora.baseModel) === normalizedBase, + ); + if (exact) return exact; + + const partial = sorted.find((lora) => { + const normalizedLoraBase = normalizeModelRef(lora.baseModel); + if (!normalizedLoraBase) return false; + return ( + normalizedLoraBase.includes(normalizedBase) || + normalizedBase.includes(normalizedLoraBase) + ); + }); + return partial ?? sorted[0]; +} + const SingleContent = memo(function SingleContent({ threadId, newThreadNonce, @@ -207,7 +248,9 @@ export function ChatPage(): ReactElement { const [modelSelectorOpen, setModelSelectorOpen] = useState(false); const [modelSelectorLocked, setModelSelectorLocked] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(true); - const viewBeforeCompareRef = useRef(null); + const [viewBeforeCompare, setViewBeforeCompare] = useState( + null, + ); const inferenceParams = useChatRuntimeStore((state) => state.params); const setInferenceParams = useChatRuntimeStore((state) => state.setParams); const autoTitle = useChatRuntimeStore((state) => state.autoTitle); @@ -216,6 +259,13 @@ export function ChatPage(): ReactElement { const lorasFromStore = useChatRuntimeStore((state) => state.loras); const modelsError = useChatRuntimeStore((state) => state.modelsError); const { refresh, selectModel, ejectModel } = useChatModelRuntime(); + const refreshRef = useRef(refresh); + const selectModelRef = useRef(selectModel); + + useEffect(() => { + refreshRef.current = refresh; + selectModelRef.current = selectModel; + }, [refresh, selectModel]); const canCompare = useMemo(() => { const selected = inferenceParams.checkpoint; if (!selected) return false; @@ -262,18 +312,15 @@ export function ChatPage(): ReactElement { const openSidebar = useCallback(() => setSidebarOpen(true), []); const enterCompare = useCallback(() => { - if (viewBeforeCompareRef.current == null) { - viewBeforeCompareRef.current = view; - } + setViewBeforeCompare((prev) => prev ?? view); setView({ mode: "compare", pairId: crypto.randomUUID() }); }, [view]); const exitCompare = useCallback(() => { - const prev = viewBeforeCompareRef.current; - if (!prev) return; - viewBeforeCompareRef.current = null; - setView(prev); - }, []); + if (!viewBeforeCompare) return; + setView(viewBeforeCompare); + setViewBeforeCompare(null); + }, [viewBeforeCompare]); const models = useMemo( () => @@ -297,9 +344,69 @@ export function ChatPage(): ReactElement { ); useEffect(() => { + if (getTrainingCompareHandoff()) return; void refresh(); }, [refresh]); + useEffect(() => { + const handoff = getTrainingCompareHandoff(); + if (!handoff) return; + console.info("[chat-handoff] received", handoff); + function clearHandoff(): void { + clearTrainingCompareHandoff(); + } + + let canceled = false; + void (async () => { + try { + console.info("[chat-handoff] refreshing models+loras"); + await refreshRef.current(); + if (canceled) return; + + const state = useChatRuntimeStore.getState(); + const targetLora = pickBestLoraForBase(state.loras, handoff.baseModel); + if (targetLora) { + console.info("[chat-handoff] loading lora", { + id: targetLora.id, + baseModel: targetLora.baseModel, + }); + await selectModelRef.current({ id: targetLora.id, isLora: true }); + if (canceled) return; + setView({ mode: "compare", pairId: crypto.randomUUID() }); + clearHandoff(); + console.info("[chat-handoff] loaded lora + opened compare"); + return; + } + + if ( + handoff.baseModel && + state.models.some((model) => model.id === handoff.baseModel) + ) { + console.info("[chat-handoff] no lora match, loading base", { + id: handoff.baseModel, + }); + await selectModelRef.current({ id: handoff.baseModel, isLora: false }); + if (canceled) return; + } else { + console.warn("[chat-handoff] no lora/base match found", { + requestedBaseModel: handoff.baseModel, + loraCount: state.loras.length, + modelCount: state.models.length, + }); + } + clearHandoff(); + console.info("[chat-handoff] completed"); + } catch (error) { + console.error("[chat-handoff] failed", error); + clearHandoff(); + } + })(); + + return () => { + canceled = true; + }; + }, []); + const tourSteps = useMemo( () => buildChatTourSteps({ @@ -332,8 +439,11 @@ export function ChatPage(): ReactElement { useEffect(() => { if (tour.open) return; if (!modelSelectorLocked) return; - setModelSelectorLocked(false); - setModelSelectorOpen(false); + const timeoutId = window.setTimeout(() => { + setModelSelectorLocked(false); + setModelSelectorOpen(false); + }, 0); + return () => window.clearTimeout(timeoutId); }, [modelSelectorLocked, tour.open]); return ( diff --git a/studio/frontend/src/features/chat/index.ts b/studio/frontend/src/features/chat/index.ts index b7eaaf83ae..521a0fe27f 100644 --- a/studio/frontend/src/features/chat/index.ts +++ b/studio/frontend/src/features/chat/index.ts @@ -7,3 +7,4 @@ export { } from "./chat-settings-sheet"; export { useChatRuntimeStore } from "./stores/chat-runtime-store"; export { useChatModelRuntime } from "./hooks/use-chat-model-runtime"; +export { setTrainingCompareHandoff } from "./lib/training-compare-handoff"; diff --git a/studio/frontend/src/features/studio/sections/progress-section.tsx b/studio/frontend/src/features/studio/sections/progress-section.tsx index 2d075e870b..3752a0fb25 100644 --- a/studio/frontend/src/features/studio/sections/progress-section.tsx +++ b/studio/frontend/src/features/studio/sections/progress-section.tsx @@ -31,12 +31,14 @@ import { } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { useState, type ReactElement, type ReactNode } from "react"; -import { Link } from "@tanstack/react-router"; +import { Link, useNavigate } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useGpuUtilization } from "@/hooks"; +import { setTrainingCompareHandoff } from "@/features/chat"; import { formatDuration, formatNumber, phaseColors, phaseLabel } from "./progress-section-lib"; export function ProgressSection(): ReactElement { + const navigate = useNavigate(); const runtime = useTrainingRuntimeStore( useShallow((state) => ({ phase: state.phase, @@ -105,6 +107,10 @@ export function ProgressSection(): ReactElement { const showHalfwayHint = runtime.phase === "training" && pct >= 50 && pct < 100; const showCompletedHint = runtime.phase === "completed"; + const handleCompareInChat = () => { + setTrainingCompareHandoff(config.selectedModel); + void navigate({ to: "/chat" }); + }; const stoppedLoss = getDisplayMetric( runtime.isTrainingRunning, @@ -265,8 +271,8 @@ export function ProgressSection(): ReactElement {

{showCompletedHint && (
-
); } diff --git a/studio/frontend/src/features/auth/signup-page.tsx b/studio/frontend/src/features/auth/signup-page.tsx index 5b71fe52ee..3d9b09ff7b 100644 --- a/studio/frontend/src/features/auth/signup-page.tsx +++ b/studio/frontend/src/features/auth/signup-page.tsx @@ -1,4 +1,5 @@ import { LightRays } from "@/components/ui/light-rays"; +import { Card } from "@/components/ui/card"; import { AuthForm } from "./components/auth-form"; export function SignupPage() { @@ -12,9 +13,9 @@ export function SignupPage() { length="70vh" style={{ opacity: 0.4 }} /> -
+ -
+ ); } diff --git a/studio/frontend/src/features/export/export-page.tsx b/studio/frontend/src/features/export/export-page.tsx index 5c11a5914b..af670aae16 100644 --- a/studio/frontend/src/features/export/export-page.tsx +++ b/studio/frontend/src/features/export/export-page.tsx @@ -278,7 +278,7 @@ export function ExportPage() {
{/* Training run dropdown */} -
+
{/* Checkpoint dropdown */} -
+