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:
commit
e70bb621ec
34 changed files with 1376 additions and 910 deletions
|
|
@ -62,7 +62,7 @@ function ModelSelectorTrigger({
|
|||
className={cn(
|
||||
"flex items-center gap-2 transition-colors",
|
||||
variant === "outline" &&
|
||||
"rounded-full border border-border/60 hover:bg-accent",
|
||||
"rounded-full border border-border/60 hover:bg-accent",
|
||||
variant === "ghost" && "rounded-md hover:bg-accent",
|
||||
variant === "muted" && "rounded-md bg-muted hover:bg-muted/80",
|
||||
size === "sm" && "h-8 px-3 text-xs",
|
||||
|
|
@ -183,9 +183,20 @@ export function ModelSelector({
|
|||
all.set(model.id, model);
|
||||
}
|
||||
for (const lora of loraModels) {
|
||||
// Strip "/ suffix" from display name (e.g. "foo_123/foo" → "foo_123")
|
||||
const displayName = lora.name.includes("/")
|
||||
? lora.name.split("/")[0].trim()
|
||||
: lora.name;
|
||||
// Show type tag instead of base model name
|
||||
const isExported = lora.source === "exported";
|
||||
const isMerged = lora.exportType === "merged";
|
||||
const tag = isExported
|
||||
? isMerged ? "Merged · Exported" : "LoRA"
|
||||
: "LoRA";
|
||||
all.set(lora.id, {
|
||||
...lora,
|
||||
description: lora.baseModel || lora.description,
|
||||
name: displayName,
|
||||
description: tag,
|
||||
});
|
||||
}
|
||||
return all;
|
||||
|
|
|
|||
|
|
@ -365,15 +365,26 @@ export function LoraModelPicker({
|
|||
<div key={baseModel}>
|
||||
{index > 0 ? <div className="my-1" /> : null}
|
||||
<ListLabel>{baseModel}</ListLabel>
|
||||
{adapters.map((adapter) => (
|
||||
<ModelRow
|
||||
key={adapter.id}
|
||||
label={adapter.name}
|
||||
meta="LoRA"
|
||||
selected={value === adapter.id}
|
||||
onClick={() => onSelect(adapter.id, { source: "lora", isLora: true })}
|
||||
/>
|
||||
))}
|
||||
{adapters.map((adapter) => {
|
||||
const isExported = adapter.source === "exported";
|
||||
const isMerged = adapter.exportType === "merged";
|
||||
const tag = isExported
|
||||
? isMerged ? "Merged" : "LoRA"
|
||||
: "LoRA";
|
||||
const meta = isExported ? `${tag} · Exported` : tag;
|
||||
return (
|
||||
<ModelRow
|
||||
key={adapter.id}
|
||||
label={adapter.name}
|
||||
meta={meta}
|
||||
selected={value === adapter.id}
|
||||
onClick={() => onSelect(adapter.id, {
|
||||
source: isExported ? "exported" : "lora",
|
||||
isLora: !isMerged,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ export interface ModelOption {
|
|||
export interface LoraModelOption extends ModelOption {
|
||||
baseModel?: string;
|
||||
updatedAt?: number;
|
||||
source?: "training" | "exported";
|
||||
exportType?: "lora" | "merged";
|
||||
}
|
||||
|
||||
export interface ModelSelectorChangeMeta {
|
||||
source: "hub" | "lora";
|
||||
source: "hub" | "lora" | "exported";
|
||||
isLora: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export const DEFAULT_HYPERPARAMS = {
|
|||
warmupSteps: 5,
|
||||
maxSteps: 0,
|
||||
saveSteps: 0,
|
||||
evalSteps: 0.01,
|
||||
evalSteps: 0.00,
|
||||
packing: false,
|
||||
trainOnCompletions: false,
|
||||
gradientCheckpointing: "unsloth" as const,
|
||||
|
|
|
|||
|
|
@ -410,6 +410,8 @@ export function ChatPage(): ReactElement {
|
|||
name: lora.name,
|
||||
baseModel: lora.baseModel,
|
||||
updatedAt: lora.updatedAt,
|
||||
source: lora.source,
|
||||
exportType: lora.exportType,
|
||||
})),
|
||||
[lorasFromStore],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
81
studio/frontend/src/features/training/lib/yaml-config.ts
Normal file
81
studio/frontend/src/features/training/lib/yaml-config.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue