Merge pull request #140 from unslothai/feature/uxui-heuristics
ux: improve training-to-chat flow, param defaults UX, and guided onboarding/export polish
This commit is contained in:
commit
d9869dece1
15 changed files with 412 additions and 121 deletions
|
|
@ -42,19 +42,21 @@ function AlertDialogOverlay({
|
|||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||
size?: "default" | "sm";
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
overlayClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||
size?: "default" | "sm";
|
||||
overlayClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay className={overlayClassName} />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/5 gap-6 rounded-4xl p-6 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-md group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -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 LoginPage() {
|
||||
|
|
@ -12,9 +13,9 @@ export function LoginPage() {
|
|||
length="70vh"
|
||||
style={{ opacity: 0.4 }}
|
||||
/>
|
||||
<div className="relative z-10 w-full max-w-sm">
|
||||
<Card className="relative z-10 w-full max-w-sm px-6 py-8 shadow-border ring-1 ring-border">
|
||||
<AuthForm mode="login" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
/>
|
||||
<div className="relative z-10 w-full max-w-sm">
|
||||
<Card className="relative z-10 w-full max-w-sm px-6 py-8 shadow-border ring-1 ring-border">
|
||||
<AuthForm mode="signup" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ChatView | null>(null);
|
||||
const [viewBeforeCompare, setViewBeforeCompare] = useState<ChatView | null>(
|
||||
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<ModelOption[]>(
|
||||
() =>
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
const TRAINING_COMPARE_HANDOFF_KEY = "chat:training-compare-handoff:v1";
|
||||
const HANDOFF_MAX_AGE_MS = 15 * 60 * 1000;
|
||||
|
||||
export type TrainingCompareHandoff = {
|
||||
intent: "compare";
|
||||
baseModel: string | null;
|
||||
requestedAt: number;
|
||||
};
|
||||
|
||||
export function setTrainingCompareHandoff(baseModel: string | null): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const payload: TrainingCompareHandoff = {
|
||||
intent: "compare",
|
||||
baseModel,
|
||||
requestedAt: Date.now(),
|
||||
};
|
||||
window.sessionStorage.setItem(
|
||||
TRAINING_COMPARE_HANDOFF_KEY,
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
}
|
||||
|
||||
export function getTrainingCompareHandoff(): TrainingCompareHandoff | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
const raw = window.sessionStorage.getItem(TRAINING_COMPARE_HANDOFF_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<TrainingCompareHandoff>;
|
||||
if (parsed.intent !== "compare") return null;
|
||||
if (typeof parsed.requestedAt !== "number") return null;
|
||||
if (Date.now() - parsed.requestedAt > HANDOFF_MAX_AGE_MS) {
|
||||
clearTrainingCompareHandoff();
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
intent: "compare",
|
||||
baseModel:
|
||||
typeof parsed.baseModel === "string" ? parsed.baseModel : null,
|
||||
requestedAt: parsed.requestedAt,
|
||||
};
|
||||
} catch {
|
||||
clearTrainingCompareHandoff();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTrainingCompareHandoff(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.sessionStorage.removeItem(TRAINING_COMPARE_HANDOFF_KEY);
|
||||
}
|
||||
|
|
@ -278,7 +278,7 @@ export function ExportPage() {
|
|||
<div className="grid grid-cols-2 gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Training run dropdown */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div data-tour="export-training-run" className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Training Run
|
||||
<Tooltip>
|
||||
|
|
@ -344,7 +344,7 @@ export function ExportPage() {
|
|||
</div>
|
||||
|
||||
{/* Checkpoint dropdown */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<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>
|
||||
|
|
@ -378,7 +378,7 @@ export function ExportPage() {
|
|||
onValueChange={setCheckpoint}
|
||||
disabled={!selectedModelIdx}
|
||||
>
|
||||
<SelectTrigger data-tour="export-checkpoint" className="w-full">
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!selectedModelIdx
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
import type { TourStep } from "@/features/tour";
|
||||
|
||||
export const exportTourSteps: TourStep[] = [
|
||||
{
|
||||
id: "training-run",
|
||||
target: "export-training-run",
|
||||
title: "Pick training run",
|
||||
body: (
|
||||
<>
|
||||
Start by selecting the training run. Each run groups the checkpoints
|
||||
produced by that specific fine-tuning job.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "checkpoint",
|
||||
target: "export-checkpoint",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
interface SplashScreenProps {
|
||||
|
|
@ -11,57 +12,61 @@ export function SplashScreen({
|
|||
onGoToStudio,
|
||||
}: SplashScreenProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-gradient-to-b from-background via-background to-primary/5">
|
||||
{/* Mascot */}
|
||||
<motion.img
|
||||
src="/Sloth emojis/Sloth loca pc.png"
|
||||
alt="Sloth mascot"
|
||||
className="size-30"
|
||||
initial={{ opacity: 0, y: 40, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
duration: 0.7,
|
||||
bounce: 0.3,
|
||||
delay: 0.1,
|
||||
}}
|
||||
/>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gradient-to-b from-background via-background to-primary/5 p-6">
|
||||
<Card className="w-full max-w-md px-8 py-8 shadow-border ring-1 ring-border">
|
||||
{/* Mascot */}
|
||||
<div className="flex justify-center">
|
||||
<motion.img
|
||||
src="/Sloth emojis/Sloth loca pc.png"
|
||||
alt="Sloth mascot"
|
||||
className="size-30"
|
||||
initial={{ opacity: 0, y: 40, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
duration: 0.7,
|
||||
bounce: 0.3,
|
||||
delay: 0.1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Brand text */}
|
||||
<motion.div
|
||||
className="flex flex-col items-center gap-1 mt-4"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
ease: [0.165, 0.84, 0.44, 1],
|
||||
delay: 0.4,
|
||||
}}
|
||||
>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Unsloth Studio
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">Fine-tune LLMs faster</p>
|
||||
</motion.div>
|
||||
{/* Brand text */}
|
||||
<motion.div
|
||||
className="mt-4 flex flex-col items-center gap-1"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
ease: [0.165, 0.84, 0.44, 1],
|
||||
delay: 0.4,
|
||||
}}
|
||||
>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Unsloth Studio
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">Fine-tune LLMs faster</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Buttons */}
|
||||
<motion.div
|
||||
className="flex flex-col gap-3 mt-8"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
ease: [0.165, 0.84, 0.44, 1],
|
||||
delay: 0.8,
|
||||
}}
|
||||
>
|
||||
<Button size="lg" onClick={onStartOnboarding}>
|
||||
Start Onboarding
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" onClick={onGoToStudio}>
|
||||
Skip Onboarding
|
||||
</Button>
|
||||
</motion.div>
|
||||
{/* Buttons */}
|
||||
<motion.div
|
||||
className="mt-8 flex flex-col gap-3"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
ease: [0.165, 0.84, 0.44, 1],
|
||||
delay: 0.8,
|
||||
}}
|
||||
>
|
||||
<Button size="lg" onClick={onStartOnboarding}>
|
||||
Start Onboarding
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" onClick={onGoToStudio}>
|
||||
Skip Onboarding
|
||||
</Button>
|
||||
</motion.div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import { useShallow } from "zustand/react/shallow";
|
|||
export function HyperparametersStep() {
|
||||
const {
|
||||
trainingMethod,
|
||||
maxSteps,
|
||||
setMaxSteps,
|
||||
epochs,
|
||||
setEpochs,
|
||||
contextLength,
|
||||
|
|
@ -43,6 +45,8 @@ export function HyperparametersStep() {
|
|||
} = useTrainingConfigStore(
|
||||
useShallow((s) => ({
|
||||
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 (
|
||||
<FieldGroup>
|
||||
|
|
@ -68,7 +74,7 @@ export function HyperparametersStep() {
|
|||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel className="flex items-center gap-1.5 !text-sm text-muted-foreground">
|
||||
Epochs
|
||||
Max Steps
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -82,7 +88,7 @@ export function HyperparametersStep() {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Number of times to iterate over the entire dataset.{" "}
|
||||
Override total steps. Set 0 to use epochs instead.{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
|
|
@ -96,21 +102,21 @@ export function HyperparametersStep() {
|
|||
</FieldLabel>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[epochs]}
|
||||
onValueChange={([v]) => setEpochs(v)}
|
||||
min={1}
|
||||
max={10}
|
||||
value={[Math.min(maxStepsSliderMax, Math.max(0, maxSteps))]}
|
||||
onValueChange={([v]) => setMaxSteps(v)}
|
||||
min={0}
|
||||
max={maxStepsSliderMax}
|
||||
step={1}
|
||||
className="w-40"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={epochs}
|
||||
onChange={(e) => setEpochs(Number(e.target.value))}
|
||||
min={1}
|
||||
max={10}
|
||||
value={maxSteps}
|
||||
onChange={(e) => setMaxSteps(Number(e.target.value))}
|
||||
min={0}
|
||||
max={maxStepsSliderMax}
|
||||
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"
|
||||
className="w-16 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -196,6 +202,56 @@ export function HyperparametersStep() {
|
|||
className="w-32 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel className="flex items-center gap-1.5 !text-sm text-muted-foreground">
|
||||
Epochs
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground/50 hover:text-muted-foreground"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={InformationCircleIcon}
|
||||
className="size-3.5"
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Number of full passes over the dataset. Set 0 to run by max
|
||||
steps.{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FieldLabel>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[Math.min(epochsSliderMax, Math.max(0, epochs))]}
|
||||
onValueChange={([v]) => setEpochs(v)}
|
||||
min={0}
|
||||
max={epochsSliderMax}
|
||||
step={1}
|
||||
className="w-40"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={epochs}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export function WizardLayout() {
|
|||
ease: [0.165, 0.84, 0.44, 1],
|
||||
}}
|
||||
>
|
||||
<Card className="relative z-10 w-full !gap-0 h-[640px] flex flex-row overflow-hidden !p-0 !m-0 shadow-border ring-1 ring-border">
|
||||
<Card className="relative z-10 h-[660px] w-full !gap-0 !m-0 !p-0 flex flex-row overflow-hidden shadow-border ring-1 ring-border">
|
||||
<WizardSidebar />
|
||||
<div className="flex-1 flex flex-col">
|
||||
<WizardContent />
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div data-tour="studio-params" className="lg:col-span-4">
|
||||
|
|
@ -123,11 +125,11 @@ export function ParamsSection(): ReactElement {
|
|||
className="min-h-[450px]"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Epochs */}
|
||||
{/* Max Steps */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Epochs
|
||||
Max Steps
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -141,8 +143,7 @@ export function ParamsSection(): ReactElement {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
How many times the model sees the entire dataset during
|
||||
training.{" "}
|
||||
Override total steps. Set 0 to use epochs instead.{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
|
|
@ -156,23 +157,23 @@ export function ParamsSection(): ReactElement {
|
|||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={store.epochs}
|
||||
onChange={(e) => store.setEpochs(Number(e.target.value))}
|
||||
min={1}
|
||||
max={20}
|
||||
value={store.maxSteps}
|
||||
onChange={(e) => store.setMaxSteps(Number(e.target.value))}
|
||||
min={0}
|
||||
max={maxStepsSliderMax}
|
||||
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"
|
||||
className="w-16 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"
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
value={[store.epochs]}
|
||||
onValueChange={([v]) => 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}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Number of full passes over the training dataset
|
||||
Total optimizer steps. Use 0 to run by epochs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -602,11 +603,12 @@ export function ParamsSection(): ReactElement {
|
|||
max={100}
|
||||
step={1}
|
||||
/>
|
||||
<Row
|
||||
label="Max Steps"
|
||||
<SliderRow
|
||||
label="Epochs"
|
||||
tooltip={
|
||||
<>
|
||||
Override total steps. 0 means use epochs instead.{" "}
|
||||
Number of full passes over the dataset. Set 0 to run by
|
||||
max steps.{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
|
|
@ -617,14 +619,12 @@ export function ParamsSection(): ReactElement {
|
|||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={store.maxSteps}
|
||||
onChange={(e) => store.setMaxSteps(Number(e.target.value))}
|
||||
className="w-28 font-mono"
|
||||
/>
|
||||
</Row>
|
||||
value={store.epochs}
|
||||
onChange={store.setEpochs}
|
||||
min={0}
|
||||
max={epochsSliderMax}
|
||||
step={1}
|
||||
/>
|
||||
<Row
|
||||
label="Save Steps"
|
||||
tooltip={
|
||||
|
|
|
|||
|
|
@ -31,11 +31,14 @@ import {
|
|||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useState, type ReactElement, type ReactNode } from "react";
|
||||
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,
|
||||
|
|
@ -101,6 +104,13 @@ export function ProgressSection(): ReactElement {
|
|||
elapsed != null && elapsed > 0
|
||||
? runtime.currentStep / elapsed
|
||||
: null;
|
||||
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,
|
||||
|
|
@ -198,7 +208,7 @@ export function ProgressSection(): ReactElement {
|
|||
>
|
||||
<HugeiconsIcon icon={StopIcon} className="size-3" /> Stop
|
||||
</Button>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogContent overlayClassName="bg-background/40 supports-backdrop-filter:backdrop-blur-[1px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Stop Training</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
|
|
@ -252,6 +262,26 @@ export function ProgressSection(): ReactElement {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{(showHalfwayHint || showCompletedHint) && (
|
||||
<div className="rounded-xl border border-emerald-500/25 bg-emerald-500/8 p-3">
|
||||
<p className="text-xs font-medium text-emerald-900 dark:text-emerald-200">
|
||||
{showCompletedHint
|
||||
? "Training done. Next step: compare base vs fine-tuned outputs."
|
||||
: "Halfway done. Training is past 50%."}
|
||||
</p>
|
||||
{showCompletedHint && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button size="xs" onClick={handleCompareInChat}>
|
||||
Compare in Chat
|
||||
</Button>
|
||||
<Button asChild={true} size="xs" variant="outline">
|
||||
<Link to="/export">Export Model</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{runtime.error && (
|
||||
<p className="text-xs text-red-500 leading-relaxed">{runtime.error}</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { LightRays } from "@/components/ui/light-rays";
|
||||
import {
|
||||
shouldShowTrainingView,
|
||||
useDatasetPreviewDialogStore,
|
||||
|
|
@ -62,8 +63,16 @@ export function StudioPage(): ReactElement {
|
|||
}, [selectedModel, ensureModelDefaultsLoaded]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<main className="mx-auto max-w-7xl px-6 py-4">
|
||||
<div className="relative min-h-screen overflow-hidden bg-background">
|
||||
<LightRays
|
||||
count={6}
|
||||
color="rgba(34, 197, 94, 0.14)"
|
||||
blur={30}
|
||||
speed={18}
|
||||
length="62vh"
|
||||
style={{ opacity: 0.45 }}
|
||||
/>
|
||||
<main className="relative z-10 mx-auto max-w-7xl px-6 py-4">
|
||||
<GuidedTour {...tour.tourProps} celebrate={isConfigTour} />
|
||||
|
||||
<DatasetPreviewDialog
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { LightRays } from "@/components/ui/light-rays";
|
||||
import { useTrainingRuntimeStore } from "@/features/training";
|
||||
import type { ReactElement } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
|
@ -30,8 +31,19 @@ export function TrainingView(): ReactElement {
|
|||
|
||||
return (
|
||||
<div className={cn("relative", showOverlay && "min-h-[72vh]")}>
|
||||
<LightRays
|
||||
count={4}
|
||||
color="rgba(16, 185, 129, 0.13)"
|
||||
blur={28}
|
||||
speed={20}
|
||||
length="52vh"
|
||||
style={{ opacity: 0.35 }}
|
||||
/>
|
||||
<div
|
||||
className={cn("flex flex-col gap-6 transition-[filter]", showOverlay && "blur")}
|
||||
className={cn(
|
||||
"relative z-10 flex flex-col gap-6 transition-[filter]",
|
||||
showOverlay && "blur",
|
||||
)}
|
||||
>
|
||||
<div data-tour="studio-training-progress">
|
||||
<ProgressSection />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue