feat: add Upload / Save / Reset training config from local YAML
This commit is contained in:
parent
77b0978d5f
commit
6cedc339c6
7 changed files with 191 additions and 11 deletions
|
|
@ -40,6 +40,7 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"dexie": "^4.2.1",
|
||||
"framer-motion": "^11.15.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"katex": "^0.16.22",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mammoth": "^1.11.0",
|
||||
|
|
@ -66,6 +67,7 @@
|
|||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
|
@ -757,6 +759,8 @@
|
|||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
|
||||
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="],
|
||||
|
|
@ -827,7 +831,7 @@
|
|||
|
||||
"ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
|
||||
|
||||
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
|
|
@ -2267,12 +2271,12 @@
|
|||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
|
||||
|
||||
"mammoth/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"dexie": "^4.2.1",
|
||||
"framer-motion": "^11.15.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"katex": "^0.16.22",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mammoth": "^1.11.0",
|
||||
|
|
@ -74,6 +75,7 @@
|
|||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
|
|
|||
|
|
@ -9,16 +9,23 @@ import {
|
|||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useTrainingActions, useTrainingConfigStore } from "@/features/training";
|
||||
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, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
|
||||
const chartConfig = {
|
||||
|
|
@ -40,7 +47,47 @@ export function TrainingSection() {
|
|||
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;
|
||||
a.download = "training-config.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,21 +162,45 @@ export function TrainingSection() {
|
|||
</p>
|
||||
)}
|
||||
|
||||
{/* Save / Clear */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* Upload / Save / Reset */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<HugeiconsIcon icon={CloudUploadIcon} className="size-3.5" />
|
||||
Upload
|
||||
</Button>
|
||||
<Button
|
||||
data-tour="studio-save"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={handleSaveConfig}
|
||||
>
|
||||
<HugeiconsIcon icon={Archive04Icon} className="size-3.5" /> Save
|
||||
Config
|
||||
<HugeiconsIcon icon={Archive04Icon} className="size-3.5" />
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="cursor-pointer">
|
||||
<HugeiconsIcon icon={CleanIcon} className="size-3.5" /> Clear
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={handleResetConfig}
|
||||
disabled={!store.selectedModel}
|
||||
>
|
||||
<HugeiconsIcon icon={CleanIcon} className="size-3.5" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".yaml,.yml"
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
|
||||
{/* Logging */}
|
||||
<Collapsible open={logOpen} onOpenChange={setLogOpen}>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
88
studio/frontend/src/features/training/lib/yaml-config.ts
Normal file
88
studio/frontend/src/features/training/lib/yaml-config.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
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,
|
||||
logging: {
|
||||
enable_wandb: state.enableWandb,
|
||||
wandb_project: state.wandbProject,
|
||||
enable_tensorboard: state.enableTensorboard,
|
||||
tensorboard_dir: state.tensorboardDir,
|
||||
log_frequency: state.logFrequency,
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue