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() {
@@ -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 */}
-
+