Merge branch 'feature/canvas-lab' of https://github.com/unslothai/new-ui-prototype into feature/canvas-lab

# Conflicts:
#	studio/frontend/bun.lock
This commit is contained in:
Shine1i 2026-02-25 00:19:19 +01:00
commit e70bb621ec
34 changed files with 1376 additions and 910 deletions

View file

@ -410,6 +410,8 @@ export function ChatPage(): ReactElement {
name: lora.name,
baseModel: lora.baseModel,
updatedAt: lora.updatedAt,
source: lora.source,
exportType: lora.exportType,
})),
[lorasFromStore],
);

View file

@ -68,6 +68,8 @@ function toLoraSummary(lora: {
display_name: string;
adapter_path: string;
base_model?: string | null;
source?: "training" | "exported" | null;
export_type?: "lora" | "merged" | null;
}): ChatLoraSummary {
const idTail = lora.adapter_path.split("/").filter(Boolean).at(-1) ?? "";
const updatedAt =
@ -78,6 +80,8 @@ function toLoraSummary(lora: {
name: stripTrailingEpoch(lora.display_name),
baseModel: lora.base_model || "Unknown base model",
updatedAt,
source: lora.source ?? undefined,
exportType: lora.export_type ?? undefined,
};
}

View file

@ -14,6 +14,8 @@ export interface BackendLoraInfo {
display_name: string;
adapter_path: string;
base_model?: string | null;
source?: "training" | "exported" | null;
export_type?: "lora" | "merged" | null;
}
export interface ListLorasResponse {

View file

@ -33,4 +33,6 @@ export interface ChatLoraSummary {
name: string;
baseModel: string;
updatedAt?: number;
source?: "training" | "exported";
exportType?: "lora" | "merged";
}

File diff suppressed because it is too large Load diff

View file

@ -237,6 +237,7 @@ export function ProgressSection(): ReactElement {
onClick={() => {
setStopRequested(true);
setStopDialogOpen(false);
useTrainingRuntimeStore.getState().setStopRequested(true);
void stopTrainingRun(false).then((ok) => {
if (!ok) setStopRequested(false);
});
@ -248,6 +249,7 @@ export function ProgressSection(): ReactElement {
onClick={() => {
setStopRequested(true);
setStopDialogOpen(false);
useTrainingRuntimeStore.getState().setStopRequested(true);
void stopTrainingRun(true).then((ok) => {
if (!ok) setStopRequested(false);
});

View file

@ -2,23 +2,27 @@ import { SectionCard } from "@/components/section-card";
import { Button } from "@/components/ui/button";
import { ChartContainer } from "@/components/ui/chart";
import type { ChartConfig } from "@/components/ui/chart";
import { Checkbox } from "@/components/ui/checkbox";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { useTrainingActions, useTrainingConfigStore } from "@/features/training";
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
parseYamlConfig,
serializeConfigToYaml,
useTrainingActions,
useTrainingConfigStore,
} from "@/features/training";
import {
Archive04Icon,
ArrowDown01Icon,
ChartAverageIcon,
CleanIcon,
CloudUploadIcon,
Rocket01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useState } from "react";
import { useRef } from "react";
import { toast } from "sonner";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
const chartConfig = {
@ -37,10 +41,55 @@ const placeholderData = [
export function TrainingSection() {
const store = useTrainingConfigStore();
const { isStarting, startError, startTrainingRun } = useTrainingActions();
const [logOpen, setLogOpen] = useState(false);
const isIncompatible =
!store.isVisionModel && store.isDatasetMultimodal === true;
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
const reader = new FileReader();
reader.onload = () => {
try {
const config = parseYamlConfig(reader.result as string);
store.applyConfigPatch(config);
toast.success("Config loaded", { description: file.name });
} catch (err) {
toast.error("Failed to load config", {
description:
err instanceof Error ? err.message : "Invalid YAML file",
});
}
};
reader.onerror = () => {
toast.error("Failed to read file");
};
reader.readAsText(file);
};
const handleSaveConfig = () => {
const yamlStr = serializeConfigToYaml(store, store.isVisionModel);
const blob = new Blob([yamlStr], { type: "text/yaml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const model = (store.selectedModel ?? "model").split("/").pop();
const method = store.trainingMethod ?? "qlora";
const dataset = (store.dataset ?? "dataset").split("/").pop();
const timestamp = new Date().toISOString().replace(/[:T]/g, "-").slice(0, 19);
a.download = `${model}_${method}_${dataset}_${timestamp}.yaml`;
a.click();
URL.revokeObjectURL(url);
};
const handleResetConfig = () => {
store.resetToModelDefaults();
toast.success("Parameters reset to model defaults");
};
return (
<div data-tour="studio-training" className="col-span-1 xl:col-span-4">
@ -115,100 +164,61 @@ export function TrainingSection() {
</p>
)}
{/* Save / Clear */}
<div className="grid grid-cols-2 gap-2">
<Button
data-tour="studio-save"
variant="outline"
size="sm"
className="cursor-pointer"
>
<HugeiconsIcon icon={Archive04Icon} className="size-3.5" /> Save
Config
</Button>
<Button variant="outline" size="sm" className="cursor-pointer">
<HugeiconsIcon icon={CleanIcon} className="size-3.5" /> Clear
</Button>
{/* Upload / Save / Reset */}
<p className="text-xs text-muted-foreground">Training Config</p>
<div className="grid grid-cols-3 gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => fileInputRef.current?.click()}
>
<HugeiconsIcon icon={CloudUploadIcon} className="size-3.5" />
Upload
</Button>
</TooltipTrigger>
<TooltipContent>Load a saved YAML config</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
data-tour="studio-save"
variant="outline"
size="sm"
className="cursor-pointer"
onClick={handleSaveConfig}
>
<HugeiconsIcon icon={Archive04Icon} className="size-3.5" />
Save
</Button>
</TooltipTrigger>
<TooltipContent>Download current config as YAML</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={handleResetConfig}
disabled={!store.selectedModel}
>
<HugeiconsIcon icon={CleanIcon} className="size-3.5" />
Reset
</Button>
</TooltipTrigger>
<TooltipContent>Reset to model defaults</TooltipContent>
</Tooltip>
</div>
{/* Logging */}
<Collapsible open={logOpen} onOpenChange={setLogOpen}>
<CollapsibleTrigger className="flex w-full cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<HugeiconsIcon
icon={ArrowDown01Icon}
className={`size-3.5 transition-transform ${logOpen ? "rotate-180" : ""}`}
/>
Logging
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 flex flex-col gap-3">
{/* W&B */}
<div className="flex items-center gap-2">
<Checkbox
id="wandb"
checked={store.enableWandb}
onCheckedChange={(v) => store.setEnableWandb(!!v)}
/>
<label
htmlFor="wandb"
className="text-xs cursor-pointer text-muted-foreground"
>
Weights & Biases
</label>
</div>
{store.enableWandb && (
<div className="flex flex-col gap-2 pl-6">
<Input
placeholder="W&B API Token"
type="password"
value={store.wandbToken}
onChange={(e) => store.setWandbToken(e.target.value)}
/>
<Input
placeholder="Project name"
value={store.wandbProject}
onChange={(e) => store.setWandbProject(e.target.value)}
/>
</div>
)}
{/* TensorBoard */}
<div className="flex items-center gap-2">
<Checkbox
id="tensorboard"
checked={store.enableTensorboard}
onCheckedChange={(v) => store.setEnableTensorboard(!!v)}
/>
<label
htmlFor="tensorboard"
className="text-xs cursor-pointer text-muted-foreground"
>
TensorBoard
</label>
</div>
{store.enableTensorboard && (
<div className="flex flex-col gap-2 pl-6">
<Input
placeholder="Log directory"
value={store.tensorboardDir}
onChange={(e) => store.setTensorboardDir(e.target.value)}
/>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
Log frequency
</span>
<Input
type="number"
value={store.logFrequency}
onChange={(e) =>
store.setLogFrequency(Number(e.target.value))
}
className="w-24"
/>
</div>
</div>
)}
</CollapsibleContent>
</Collapsible>
<input
ref={fileInputRef}
type="file"
accept=".yaml,.yml"
className="hidden"
onChange={handleFileUpload}
/>
</div>
</SectionCard>
</div>

View file

@ -44,11 +44,16 @@ export function StudioPage(): ReactElement {
const dialogInitial = useDatasetPreviewDialogStore((s) => s.initialData);
const closeDialog = useDatasetPreviewDialogStore((s) => s.close);
const stopRequested = useTrainingRuntimeStore((state) => state.stopRequested);
const canGoBack =
showTrainingView &&
!isTrainingRunning &&
!isHydratingRuntime &&
(runtimePhase === "stopped" || runtimePhase === "error" || runtimePhase === "completed" || runtimePhase === "idle");
(stopRequested ||
(!isTrainingRunning &&
(runtimePhase === "stopped" ||
runtimePhase === "error" ||
runtimePhase === "completed" ||
runtimePhase === "idle")));
const tourEnabled = hasHydratedRuntime && !isHydratingRuntime;
const isConfigTour = !showTrainingView;
const tourSteps = showTrainingView ? studioTrainingTourSteps : studioTourSteps;

View file

@ -6,8 +6,8 @@ export const studioSaveStep: TourStep = {
title: "Save config",
body: (
<>
Save configs that worked. Re-running the same baseline makes it obvious
if a change helped (or if you just got lucky).
Save your training config as a YAML file. Re-running the same baseline
makes it obvious if a change helped (or if you just got lucky).
</>
),
};

View file

@ -1,9 +1,23 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
AnimatedSpan,
Terminal,
TypingAnimation,
} from "@/components/ui/terminal"
import type { ReactElement } from "react"
} from "@/components/ui/terminal";
import { useTrainingActions, useTrainingRuntimeStore } from "@/features/training";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useState, type ReactElement } from "react";
type TrainingStartOverlayProps = {
message: string
@ -14,18 +28,65 @@ export function TrainingStartOverlay({
message,
currentStep,
}: TrainingStartOverlayProps): ReactElement {
const { stopTrainingRun } = useTrainingActions();
const isStarting = useTrainingRuntimeStore((s) => s.isStarting);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelRequested, setCancelRequested] = useState(false);
useEffect(() => {
if (!isStarting) {
setCancelRequested(false);
}
}, [isStarting]);
return (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center rounded-2xl bg-background/45 backdrop-blur-[1px]">
<div className="flex w-[860px] max-w-[calc(100%-2rem)] flex-col items-center gap-4">
<div className="pointer-events-auto relative flex w-[860px] max-w-[calc(100%-2rem)] flex-col items-center gap-4">
<img
src="/Sloth emojis/large sloth wave.png"
alt="Unsloth mascot"
className="size-24 animate-bounce object-contain"
/>
<Terminal
className="w-full min-h-[390px] rounded-2xl px-7 py-6 text-left"
startOnView={false}
>
<div className="relative w-full">
<AlertDialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
<Button
variant="ghost"
size="icon"
className="absolute right-3 top-3 z-10 size-7 cursor-pointer rounded-full text-muted-foreground/60 hover:bg-destructive/10 hover:text-destructive"
onClick={() => setCancelDialogOpen(true)}
disabled={cancelRequested}
>
<HugeiconsIcon icon={Cancel01Icon} className="size-3.5" />
</Button>
<AlertDialogContent overlayClassName="bg-background/40 supports-backdrop-filter:backdrop-blur-[1px]">
<AlertDialogHeader>
<AlertDialogTitle>Cancel Training</AlertDialogTitle>
<AlertDialogDescription>
Do you want to cancel the current training run?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Continue Training</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setCancelRequested(true);
setCancelDialogOpen(false);
useTrainingRuntimeStore.getState().setStopRequested(true);
void stopTrainingRun(false).then((ok) => {
if (!ok) setCancelRequested(false);
});
}}
>
Cancel Training
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Terminal
className="w-full min-h-[390px] rounded-2xl px-7 py-6 text-left"
startOnView={false}
>
<TypingAnimation
duration={36}
className="bg-gradient-to-r from-emerald-300 via-lime-300 to-teal-300 bg-clip-text font-semibold text-transparent"
@ -51,7 +112,8 @@ O^O/ \\_/ \\
<AnimatedSpan className="mt-2 text-muted-foreground">
{`> ${message || "starting training..."} | waiting for first step... (${currentStep})`}
</AnimatedSpan>
</Terminal>
</Terminal>
</div>
</div>
</div>
)

View file

@ -10,3 +10,4 @@ export { useDatasetPreviewDialogStore } from "./stores/dataset-preview-dialog-st
export { listLocalModels } from "./api/models-api";
export type { LocalModelInfo } from "./api/models-api";
export type { TrainingPhase } from "./types/runtime";
export { parseYamlConfig, serializeConfigToYaml } from "./lib/yaml-config";

View file

@ -0,0 +1,81 @@
import * as yaml from "js-yaml";
import type { BackendModelConfig } from "../api/models-api";
import type { TrainingConfigState } from "../types/config";
const EXPECTED_TOP_KEYS = new Set(["training", "lora", "logging", "inference"]);
/**
* Parse a YAML string into a BackendModelConfig suitable for
* `mapBackendModelConfigToTrainingPatch`. Throws on invalid input.
*/
export function parseYamlConfig(text: string): BackendModelConfig {
const parsed = yaml.load(text);
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(
"Invalid config: expected a YAML mapping with training/lora/logging sections",
);
}
const raw = parsed as Record<string, unknown>;
const unknownKeys = Object.keys(raw).filter(
(k) => !EXPECTED_TOP_KEYS.has(k),
);
if (unknownKeys.length > 0) {
console.warn("Ignored unknown YAML keys:", unknownKeys.join(", "));
}
return {
training: (raw.training ?? undefined) as BackendModelConfig["training"],
lora: (raw.lora ?? undefined) as BackendModelConfig["lora"],
logging: (raw.logging ?? undefined) as BackendModelConfig["logging"],
};
}
/**
* Serialize the current training config state to a YAML string matching the
* backend model-defaults schema.
*/
export function serializeConfigToYaml(
state: TrainingConfigState,
includeVisionFields: boolean,
): string {
const lora: Record<string, unknown> = {
lora_r: state.loraRank,
lora_alpha: state.loraAlpha,
lora_dropout: state.loraDropout,
target_modules: state.targetModules,
use_rslora: state.loraVariant === "rslora",
use_loftq: state.loraVariant === "loftq",
};
if (includeVisionFields) {
lora.finetune_vision_layers = state.finetuneVisionLayers;
lora.finetune_language_layers = state.finetuneLanguageLayers;
lora.finetune_attention_modules = state.finetuneAttentionModules;
lora.finetune_mlp_modules = state.finetuneMLPModules;
}
const config = {
training: {
max_seq_length: state.contextLength,
num_epochs: state.epochs,
learning_rate: state.learningRate,
batch_size: state.batchSize,
gradient_accumulation_steps: state.gradientAccumulation,
warmup_steps: state.warmupSteps,
max_steps: state.maxSteps,
save_steps: state.saveSteps,
eval_steps: state.evalSteps,
weight_decay: state.weightDecay,
random_seed: state.randomSeed,
packing: state.packing,
train_on_completions: state.trainOnCompletions,
gradient_checkpointing: state.gradientCheckpointing,
optim: state.optimizerType,
lr_scheduler_type: state.lrSchedulerType,
},
lora,
};
return yaml.dump(config, { lineWidth: -1, noRefs: true });
}

View file

@ -5,6 +5,7 @@ import { persist } from "zustand/middleware";
import { checkDatasetFormat } from "../api/datasets-api";
import { checkVisionModel, getModelConfig } from "../api/models-api";
import { mapBackendModelConfigToTrainingPatch } from "../lib/model-defaults";
import type { BackendModelConfig } from "../api/models-api";
import type { TrainingConfigState, TrainingConfigStore } from "../types/config";
const MIN_STEP: StepNumber = 1;
@ -344,6 +345,16 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
setTargetModules: (targetModules) => set({ targetModules }),
canProceed: () => canProceedForStep(get()),
reset: () => set(initialState),
resetToModelDefaults: () => {
const { selectedModel } = get();
if (!selectedModel) return;
set({ modelDefaultsAppliedFor: null });
loadAndApplyModelDefaults(selectedModel);
},
applyConfigPatch: (config: BackendModelConfig) => {
const patch = mapBackendModelConfigToTrainingPatch(config);
set(patch);
},
};
},
{

View file

@ -37,6 +37,7 @@ const initialState: TrainingRuntimeState = {
gradNormHistory: [],
evalLossHistory: [],
resetGeneration: 0,
stopRequested: false,
};
function sortSeries(points: TrainingSeriesPoint[]): TrainingSeriesPoint[] {
@ -110,6 +111,7 @@ function applyMetricHistoryFromStatus(payload: TrainingStatusResponse): {
export const useTrainingRuntimeStore = create<TrainingRuntimeStore>()((set) => ({
...initialState,
setStopRequested: (value) => set({ stopRequested: value }),
setHydrating: (value) => set({ isHydrating: value }),
setHasHydrated: (value) => set({ hasHydrated: value }),
setStarting: (value) => set({ isStarting: value }),
@ -173,12 +175,15 @@ export const useTrainingRuntimeStore = create<TrainingRuntimeStore>()((set) => (
const detailLoss = payload.details?.loss;
const detailLr = payload.details?.learning_rate;
const detailEpoch = payload.details?.epoch;
const stopRequested =
payload.is_training_running ? state.stopRequested : false;
return {
...state,
jobId: payload.job_id || state.jobId,
phase: payload.phase,
isTrainingRunning: payload.is_training_running,
stopRequested,
evalEnabled: payload.eval_enabled ?? state.evalEnabled,
message: payload.message,
error: payload.error,

View file

@ -6,6 +6,7 @@ import type {
StepNumber,
TrainingMethod,
} from "@/types/training";
import type { BackendModelConfig } from "../api/models-api";
export type LoraVariant = "lora" | "rslora" | "loftq";
@ -117,6 +118,8 @@ export interface TrainingConfigActions {
setTargetModules: (value: string[]) => void;
canProceed: () => boolean;
reset: () => void;
resetToModelDefaults: () => void;
applyConfigPatch: (config: BackendModelConfig) => void;
}
export type TrainingConfigStore = TrainingConfigState & TrainingConfigActions;

View file

@ -95,9 +95,11 @@ export interface TrainingRuntimeState {
gradNormHistory: TrainingSeriesPoint[];
evalLossHistory: TrainingSeriesPoint[];
resetGeneration: number;
stopRequested: boolean;
}
export interface TrainingRuntimeActions {
setStopRequested: (value: boolean) => void;
setHydrating: (value: boolean) => void;
setHasHydrated: (value: boolean) => void;
setStarting: (value: boolean) => void;