@@ -469,6 +496,7 @@ function TrainingHeaderActions({
stopDialogOpen: boolean;
stopRequested: boolean;
}): ReactElement {
+ const t = useT();
return (
@@ -486,25 +514,25 @@ function TrainingHeaderActions({
disabled={!isTrainingRunning || stopRequested}
>
- {stopRequested ? "Stopping…" : "Stop"}
+ {stopRequested ? t("studio.training.stopping") : t("studio.training.stopAction")}
- Stop Training
+ {t("studio.training.stopTitle")}
- Choose how you want to stop the current training run.
+ {t("studio.training.stopDescription")}
- Continue Training
+ {t("studio.training.continueAction")}
onRequestStop(false)}
>
- Cancel Training
+ {t("studio.training.cancelAction")}
onRequestStop(true)}>
- Stop and Save
+ {t("studio.training.stopAndSave")}
@@ -522,6 +550,7 @@ function MilestoneCallout({
showHalfwayHint: boolean;
onCompareInChat: () => Promise
;
}): ReactElement | null {
+ const t = useT();
if (!(showHalfwayHint || showCompletedHint)) {
return null;
}
@@ -532,7 +561,7 @@ function MilestoneCallout({
{!showCompletedHint && (
- Milestone
+ {t("studio.training.milestone")}
)}
{showCompletedHint
- ? "Training done. Next step: compare base vs fine-tuned outputs."
- : "Halfway done. Training is past 50%."}
+ ? t("studio.training.doneNextStep")
+ : t("studio.training.halfwayDone")}
{!showCompletedHint && (
@@ -555,10 +584,10 @@ function MilestoneCallout({
{showCompletedHint && (
- Compare in Chat
+ {t("studio.training.compareInChat")}
- Export Model
+ {t("studio.training.exportModel")}
)}
diff --git a/studio/frontend/src/features/studio/sections/training-section.tsx b/studio/frontend/src/features/studio/sections/training-section.tsx
index 46f97c358f..adeb1a7b14 100644
--- a/studio/frontend/src/features/studio/sections/training-section.tsx
+++ b/studio/frontend/src/features/studio/sections/training-section.tsx
@@ -28,10 +28,7 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { useRef } from "react";
import { toast } from "@/lib/toast";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
-
-const chartConfig = {
- loss: { label: "Loss", color: "#3b82f6" },
-} satisfies ChartConfig;
+import { useT } from "@/i18n";
const placeholderData = [
{ step: 0, loss: 2.5 },
@@ -43,6 +40,10 @@ const placeholderData = [
];
export function TrainingSection() {
+ const t = useT();
+ const chartConfig = {
+ loss: { label: t("studio.charts.loss"), color: "#3b82f6" },
+ } satisfies ChartConfig;
const store = useTrainingConfigStore();
const { isStarting, startError, startTrainingRun } = useTrainingActions();
const isLoadingModel = store.isLoadingModelDefaults || store.isCheckingVision;
@@ -65,22 +66,39 @@ export function TrainingSection() {
try {
const config = parseYamlConfig(reader.result as string);
store.applyConfigPatch(config);
- toast.success("Config loaded", { description: file.name });
+ toast.success(t("studio.training.configLoaded"), { description: file.name });
} catch (err) {
- toast.error("Failed to load config", {
+ toast.error(t("studio.training.failedToLoadConfig"), {
description:
- err instanceof Error ? err.message : "Invalid YAML file",
+ err instanceof Error ? err.message : t("studio.training.invalidYamlFile"),
});
}
};
reader.onerror = () => {
- toast.error("Failed to read file");
+ toast.error(t("studio.training.failedToReadFile"));
};
reader.readAsText(file);
};
const handleSaveConfig = () => {
- const yamlStr = serializeConfigToYaml(store, store.isVisionModel);
+ // isDatasetImage is null in three windows: before a dataset check
+ // completes, after dataset edits, and on import. Treat all three as
+ // "save it" so the user's choice is never silently dropped while we
+ // wait to confirm the dataset type. Only a confirmed text-only dataset
+ // (=== false) suppresses the vision fields.
+ const includeVisionFields =
+ store.isVisionModel && store.isDatasetImage !== false;
+ // DeepSeek OCR ignores vision_image_size; don't emit it to YAML either,
+ // or a later import on a non-DeepSeek model would activate the stale value.
+ const selectedModelLower = (store.selectedModel ?? "").toLowerCase();
+ const isDeepseekOcr =
+ selectedModelLower.includes("deepseek") &&
+ selectedModelLower.includes("ocr");
+ const yamlStr = serializeConfigToYaml(
+ store,
+ includeVisionFields,
+ includeVisionFields && !isDeepseekOcr,
+ );
const blob = new Blob([yamlStr], { type: "text/yaml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -98,15 +116,15 @@ export function TrainingSection() {
const handleResetConfig = () => {
store.resetToModelDefaults();
- toast.success("Parameters reset to model defaults");
+ toast.success(t("studio.training.parametersReset"));
};
return (
}
- title="Training"
- description="Monitor and control training"
+ title={t("studio.training.title")}
+ description={t("studio.training.description")}
accent="blue"
className={hasMessage ? "min-h-studio-config-column" : "h-studio-config-column"}
>
@@ -147,10 +165,10 @@ export function TrainingSection() {
className="size-5 text-muted-foreground/50"
/>
- No training data yet
+ {t("studio.training.chartNoDataTitle")}
- Start training to see loss progress
+ {t("studio.training.chartNoDataDescription")}
@@ -163,7 +181,13 @@ export function TrainingSection() {
disabled={isStarting || isIncompatible || store.isCheckingDataset || isLoadingModel || !configValidation.ok}
>
- {isStarting ? "Starting..." : isLoadingModel ? "Loading model..." : store.isCheckingDataset ? "Checking dataset..." : "Start Training"}
+ {isStarting
+ ? t("studio.training.starting")
+ : isLoadingModel
+ ? t("studio.training.loadingModel")
+ : store.isCheckingDataset
+ ? t("studio.training.checkingDataset")
+ : t("studio.training.startTraining")}
{startError && (
{startError}
@@ -171,8 +195,8 @@ export function TrainingSection() {
{isIncompatible && (
{!store.isAudioModel && store.isDatasetAudio === true
- ? "This model does not support audio. Switch to an audio-capable model or choose a non-audio dataset."
- : "Text model is not compatible with a multimodal dataset. Switch to a vision model or choose a text-only dataset."}
+ ? t("studio.training.audioIncompatible")
+ : t("studio.training.visionIncompatible")}
)}
{!configValidation.ok && configValidation.message && !isIncompatible && (
@@ -180,7 +204,7 @@ export function TrainingSection() {
)}
{/* Upload / Save / Reset */}
-
Training Config
+
{t("studio.training.configLabel")}
@@ -191,10 +215,10 @@ export function TrainingSection() {
onClick={() => fileInputRef.current?.click()}
>
- Upload
+ {t("studio.training.upload")}
- Load a saved YAML config
+ {t("studio.training.uploadConfigTooltip")}
@@ -206,10 +230,10 @@ export function TrainingSection() {
onClick={handleSaveConfig}
>
- Save
+ {t("studio.training.save")}
- Download current config as YAML
+ {t("studio.training.saveConfigTooltip")}
@@ -221,10 +245,10 @@ export function TrainingSection() {
disabled={!store.selectedModel}
>
- Reset
+ {t("studio.training.reset")}
- Reset to model defaults
+ {t("studio.training.resetConfigTooltip")}
state.isTrainingRunning);
@@ -120,10 +122,13 @@ export function StudioPage(): ReactElement {
}
const subtitle = (() => {
- if (activeTab === "current-run") return runtimeMessage || "Training in progress";
+ if (activeTab === "current-run")
+ return runtimeMessage || t("studio.subtitles.trainingInProgress");
if (activeTab === "history")
- return selectedHistoryRunId ? "Viewing past run" : "View past training runs";
- return "Configure and start training";
+ return selectedHistoryRunId
+ ? t("studio.subtitles.viewingPastRun")
+ : t("studio.subtitles.viewPastRuns");
+ return t("studio.subtitles.configure");
})();
return (
@@ -150,14 +155,14 @@ export function StudioPage(): ReactElement {
- Fine-tuning Studio
+ {t("studio.title")}
{subtitle}
{!hasHydratedRuntime && isHydratingRuntime ? (
- Loading training runtime...
+ {t("studio.loadingRuntime")}
) : (
@@ -168,19 +173,19 @@ export function StudioPage(): ReactElement {
size="icon-sm"
className="rounded-full text-muted-foreground"
onClick={() => setSelectedHistoryRunId(null)}
- aria-label="Back to history"
+ aria-label={t("studio.backToHistory")}
>
)}
- Configure
+ {t("studio.tabs.configure")}
- Current Run
+ {t("studio.tabs.currentRun")}
- History
+ {t("studio.tabs.history")}
diff --git a/studio/frontend/src/features/studio/training-start-overlay.tsx b/studio/frontend/src/features/studio/training-start-overlay.tsx
index 27c5a13163..8ecd3bb7e9 100644
--- a/studio/frontend/src/features/studio/training-start-overlay.tsx
+++ b/studio/frontend/src/features/studio/training-start-overlay.tsx
@@ -33,6 +33,7 @@ import {
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useState, type ReactElement } from "react";
+import { useT } from "@/i18n";
const HF_REPO_REGEX = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
@@ -171,6 +172,7 @@ type DownloadRowProps = {
};
function DownloadRow({ label, state }: DownloadRowProps): ReactElement | null {
+ const t = useT();
// Compute a rolling-window rate + ETA from the same cumulative-byte
// series the poll hook already produces, so we can show
// "5.2 / 20.7 GB • 85.3 MB/s • 3m 12s left" instead of just the pair.
@@ -179,22 +181,25 @@ function DownloadRow({ label, state }: DownloadRowProps): ReactElement | null {
if (state.downloadedBytes <= 0 && !state.cachePath) return null;
const isComplete = state.totalBytes > 0 && state.percent >= 100;
const statusLabel = isComplete
- ? "Ready"
+ ? t("studio.trainingStart.ready")
: state.totalBytes > 0
- ? "Downloading"
+ ? t("studio.trainingStart.downloading")
: state.downloadedBytes === 0
- ? "Preparing"
+ ? t("studio.trainingStart.preparing")
: null;
const showRate = stats.stable && !isComplete;
const rateSuffix = showRate ? ` • ${formatRate(stats.rateBytesPerSecond)}` : "";
const etaStr =
showRate && state.totalBytes > 0 ? formatEta(stats.etaSeconds) : "--";
- const etaSuffix = etaStr !== "--" ? ` • ${etaStr} left` : "";
+ const etaSuffix =
+ etaStr !== "--" ? ` • ${t("studio.trainingStart.left", { eta: etaStr })}` : "";
const sizeLabel =
state.totalBytes > 0
? `${formatBytes(state.downloadedBytes)} / ${formatBytes(state.totalBytes)}${rateSuffix}${etaSuffix}`
: state.downloadedBytes > 0
- ? `${formatBytes(state.downloadedBytes)} downloaded${rateSuffix}`
+ ? `${t("studio.trainingStart.downloaded", {
+ size: formatBytes(state.downloadedBytes),
+ })}${rateSuffix}`
: null;
return (
@@ -245,6 +250,7 @@ export function TrainingStartOverlay({
message,
currentStep,
}: TrainingStartOverlayProps): ReactElement {
+ const t = useT();
const { stopTrainingRun, dismissTrainingRun } = useTrainingActions();
const isStarting = useTrainingRuntimeStore((s) => s.isStarting);
const phase = useTrainingRuntimeStore((s) => s.phase);
@@ -273,8 +279,8 @@ export function TrainingStartOverlay({
: null;
const displayMessage =
startFromResume && !isDownloadPhase && /^download/i.test(message)
- ? "Resuming training..."
- : message || "starting training...";
+ ? t("studio.trainingStart.resumingTraining")
+ : message || t("studio.trainingStart.startingTraining");
const rawModelDownload = useModelDownloadProgress(modelName);
const rawDatasetDownload = useDatasetDownloadProgress(datasetName);
const modelDownload = isDownloadPhase
@@ -297,7 +303,7 @@ export function TrainingStartOverlay({
@@ -313,13 +319,13 @@ export function TrainingStartOverlay({
- Cancel Training
+ {t("studio.training.cancelTitle")}
- Do you want to cancel the current training run?
+ {t("studio.training.cancelDescription")}
- Continue Training
+ {t("studio.training.continueAction")}
{
@@ -335,7 +341,7 @@ export function TrainingStartOverlay({
});
}}
>
- Cancel Training
+ {t("studio.training.cancelAction")}
@@ -348,24 +354,27 @@ export function TrainingStartOverlay({
duration={36}
className="bg-gradient-to-r from-emerald-300 via-lime-300 to-teal-300 bg-clip-text font-semibold text-transparent"
>
- {"> unsloth training starts..."}
+ {t("studio.trainingStart.terminalStart")}
{`==((====))==\n \\\\ /|\nO^O/ \\_/ \\\n\\ /\n "-____-"`}
- {"> Preparing model and dataset..."}
+ {t("studio.trainingStart.preparingResources")}
- {"> We are getting everything ready for your run..."}
+ {t("studio.trainingStart.gettingReady")}
- {`> ${displayMessage} | waiting for first step... (${currentStep})`}
+ {t("studio.trainingStart.waitingForFirstStep", {
+ message: displayMessage,
+ step: currentStep,
+ })}
{datasetDownload.downloadedBytes > 0 || datasetDownload.cachePath ? (
@@ -373,7 +382,7 @@ export function TrainingStartOverlay({
{modelDownload.downloadedBytes > 0 || modelDownload.cachePath ? (
diff --git a/studio/frontend/src/features/training/api/mappers.ts b/studio/frontend/src/features/training/api/mappers.ts
index 5d81f0df9c..1223888b11 100644
--- a/studio/frontend/src/features/training/api/mappers.ts
+++ b/studio/frontend/src/features/training/api/mappers.ts
@@ -23,7 +23,12 @@ export function buildTrainingStartPayload(
const isCpt = config.trainingMethod === "cpt";
const adapterMethod = config.trainingMethod !== "full";
const isQloraMethod = config.trainingMethod === "qlora";
- const isFourBitModel = (config.selectedModel ?? "").toLowerCase().includes("4bit");
+ const _selectedModelLower = (config.selectedModel ?? "").toLowerCase();
+ const isFourBitModel = _selectedModelLower.includes("4bit");
+ // DeepSeek OCR ignores user-selected image size; do not send it.
+ const isDeepseekOcr =
+ _selectedModelLower.includes("deepseek") &&
+ _selectedModelLower.includes("ocr");
const isEmbedding = config.isEmbeddingModel;
const isRawText = isRawTextDatasetFormat(config.datasetFormat);
const hfDataset = config.datasetSource === "huggingface" ? config.dataset : null;
@@ -55,6 +60,10 @@ export function buildTrainingStartPayload(
hf_token: config.hfToken.trim() || null,
load_in_4bit: (adapterMethod && isQloraMethod) || (isCpt && isFourBitModel),
max_seq_length: config.contextLength,
+ vision_image_size:
+ config.isVisionModel && config.isDatasetImage === true && !isDeepseekOcr
+ ? config.visionImageSize
+ : null,
trust_remote_code: config.trustRemoteCode ?? false,
hf_dataset: hfDataset,
subset: hfDataset ? config.datasetSubset : null,
diff --git a/studio/frontend/src/features/training/api/models-api.ts b/studio/frontend/src/features/training/api/models-api.ts
index e512b9e28d..8fecfaf7b8 100644
--- a/studio/frontend/src/features/training/api/models-api.ts
+++ b/studio/frontend/src/features/training/api/models-api.ts
@@ -27,6 +27,7 @@ interface BackendTrainingDefaults {
eval_steps?: number;
weight_decay?: number;
random_seed?: number;
+ vision_image_size?: number | string | null;
packing?: boolean;
train_on_completions?: boolean;
gradient_checkpointing?: "none" | "true" | "unsloth";
diff --git a/studio/frontend/src/features/training/index.ts b/studio/frontend/src/features/training/index.ts
index 83d8edba75..654ddedd03 100644
--- a/studio/frontend/src/features/training/index.ts
+++ b/studio/frontend/src/features/training/index.ts
@@ -9,7 +9,10 @@ export {
export { useTrainingActions } from "./hooks/use-training-actions";
export { useTrainingHistorySidebarItems } from "./hooks/use-training-history-sidebar";
export { useTrainingRuntimeLifecycle } from "./hooks/use-training-runtime-lifecycle";
-export { removeTrainingUnloadGuard } from "./hooks/use-training-unload-guard";
+export {
+ removeTrainingUnloadGuard,
+ useTrainingUnloadGuard,
+} from "./hooks/use-training-unload-guard";
export { useMaxStepsEpochsToggle } from "./hooks/use-max-steps-epochs-toggle";
export { HfDatasetSubsetSplitSelectors } from "./components/hf-dataset-subset-split-selectors";
export { useDatasetPreviewDialogStore } from "./stores/dataset-preview-dialog-store";
diff --git a/studio/frontend/src/features/training/lib/model-defaults.ts b/studio/frontend/src/features/training/lib/model-defaults.ts
index 8bc9c4e064..3a0a14b150 100644
--- a/studio/frontend/src/features/training/lib/model-defaults.ts
+++ b/studio/frontend/src/features/training/lib/model-defaults.ts
@@ -28,6 +28,7 @@ type ModelDefaultsPatch = Partial<
| "trainOnCompletions"
| "gradientCheckpointing"
| "randomSeed"
+ | "visionImageSize"
| "enableWandb"
| "wandbProject"
| "enableTensorboard"
@@ -129,6 +130,25 @@ export function mapBackendModelConfigToTrainingPatch(
const randomSeed = toNumber(training?.random_seed);
if (randomSeed !== undefined) patch.randomSeed = randomSeed;
+ // Only patch when the config carries the key; model-switch reset lives in
+ // setSelectedModel so same-model reloads don't wipe a user's choice.
+ if (Object.hasOwn(training ?? {}, "vision_image_size")) {
+ const raw = training?.vision_image_size;
+ if (raw == null) {
+ patch.visionImageSize = null;
+ } else {
+ // Mirror studio/backend/models/training.py:_check_vision_image_size:
+ // drop anything outside [_MIN_VISION_IMAGE_SIZE, _MAX_VISION_IMAGE_SIZE]
+ // so the store/UI never show a value the backend would reject.
+ const n = toNumber(raw);
+ if (n !== undefined && Number.isInteger(n) && n >= 256 && n <= 2048) {
+ patch.visionImageSize = n;
+ } else {
+ patch.visionImageSize = null;
+ }
+ }
+ }
+
const packing = toBoolean(training?.packing);
if (packing !== undefined) patch.packing = packing;
diff --git a/studio/frontend/src/features/training/lib/yaml-config.ts b/studio/frontend/src/features/training/lib/yaml-config.ts
index d168da5347..1deabf3547 100644
--- a/studio/frontend/src/features/training/lib/yaml-config.ts
+++ b/studio/frontend/src/features/training/lib/yaml-config.ts
@@ -27,8 +27,27 @@ export function parseYamlConfig(text: string): BackendModelConfig {
console.warn("Ignored unknown YAML keys:", unknownKeys.join(", "));
}
+ // File import is authoritative: forge vision_image_size = null when the
+ // training section is missing, malformed, or missing the key, so a stale
+ // store value cannot survive an import. (Same-model defaults reloads
+ // preserve user choice via Object.hasOwn in model-defaults.ts.)
+ const rawTraining = raw.training;
+ const isPlainTrainingObject =
+ rawTraining != null &&
+ typeof rawTraining === "object" &&
+ !Array.isArray(rawTraining);
+ let trainingObj: Record
;
+ if (!isPlainTrainingObject) {
+ trainingObj = { vision_image_size: null };
+ } else {
+ trainingObj = { ...(rawTraining as Record) };
+ if (!Object.hasOwn(trainingObj, "vision_image_size")) {
+ trainingObj.vision_image_size = null;
+ }
+ }
+
return {
- training: (raw.training ?? undefined) as BackendModelConfig["training"],
+ training: trainingObj as BackendModelConfig["training"],
lora: (raw.lora ?? undefined) as BackendModelConfig["lora"],
logging: (raw.logging ?? undefined) as BackendModelConfig["logging"],
};
@@ -41,6 +60,7 @@ export function parseYamlConfig(text: string): BackendModelConfig {
export function serializeConfigToYaml(
state: TrainingConfigState,
includeVisionFields: boolean,
+ includeVisionImageSize: boolean = includeVisionFields,
): string {
const lora: Record = {
lora_r: state.loraRank,
@@ -58,25 +78,31 @@ export function serializeConfigToYaml(
lora.finetune_mlp_modules = state.finetuneMLPModules;
}
+ const training: Record = {
+ 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,
+ };
+
+ if (includeVisionImageSize) {
+ training.vision_image_size = state.visionImageSize;
+ }
+
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,
- },
+ training,
lora,
};
diff --git a/studio/frontend/src/features/training/stores/training-config-store.ts b/studio/frontend/src/features/training/stores/training-config-store.ts
index ef16f641f5..137669f8b8 100644
--- a/studio/frontend/src/features/training/stores/training-config-store.ts
+++ b/studio/frontend/src/features/training/stores/training-config-store.ts
@@ -390,6 +390,8 @@ export const useTrainingConfigStore = create()(
error instanceof Error
? error.message
: "Failed to load model defaults",
+ // Defaults load failed; reset so no prior model's value lingers.
+ visionImageSize: DEFAULT_HYPERPARAMS.visionImageSize,
});
// Fallback vision check if config endpoint fails.
@@ -498,7 +500,16 @@ export const useTrainingConfigStore = create()(
},
setSelectedModel: (selectedModel) => {
const previousModel = get().selectedModel;
- set({ selectedModel, modelDefaultsError: null });
+ // Reset vision_image_size on a true switch only; same-model reloads
+ // go through the mapper, which preserves the user's choice.
+ const patch: { selectedModel: string | null; modelDefaultsError: null; visionImageSize?: number | null } = {
+ selectedModel,
+ modelDefaultsError: null,
+ };
+ if (selectedModel !== previousModel) {
+ patch.visionImageSize = DEFAULT_HYPERPARAMS.visionImageSize;
+ }
+ set(patch);
if (!selectedModel) {
_modelConfigController?.abort();
@@ -701,6 +712,7 @@ export const useTrainingConfigStore = create()(
}),
setEpochs: (epochs) => set({ epochs }),
setContextLength: (contextLength) => set({ contextLength }),
+ setVisionImageSize: (visionImageSize) => set({ visionImageSize }),
setLearningRate: (learningRate) => {
_learningRateManuallySet = true;
set({ learningRate });
@@ -755,7 +767,10 @@ export const useTrainingConfigStore = create()(
resetToModelDefaults: () => {
const { selectedModel } = get();
if (!selectedModel) return;
- set({ modelDefaultsAppliedFor: null });
+ set({
+ modelDefaultsAppliedFor: null,
+ visionImageSize: DEFAULT_HYPERPARAMS.visionImageSize,
+ });
loadAndApplyModelDefaults(selectedModel);
},
applyConfigPatch: (config: BackendModelConfig) => {
diff --git a/studio/frontend/src/features/training/types/api.ts b/studio/frontend/src/features/training/types/api.ts
index 0cb881e634..5c12e71186 100644
--- a/studio/frontend/src/features/training/types/api.ts
+++ b/studio/frontend/src/features/training/types/api.ts
@@ -7,6 +7,7 @@ export interface TrainingStartRequest {
hf_token: string | null;
load_in_4bit: boolean;
max_seq_length: number;
+ vision_image_size?: number | null;
/** Allow loading models with custom code. Only enable for repos you trust. */
trust_remote_code?: boolean;
hf_dataset: string | null;
diff --git a/studio/frontend/src/features/training/types/config.ts b/studio/frontend/src/features/training/types/config.ts
index 5b156316ca..f40f053e8b 100644
--- a/studio/frontend/src/features/training/types/config.ts
+++ b/studio/frontend/src/features/training/types/config.ts
@@ -82,6 +82,7 @@ export interface TrainingConfigState {
finetuneMLPModules: boolean;
targetModules: string[];
maxPositionEmbeddings: number | null;
+ visionImageSize: number | null;
}
export interface TrainingConfigActions {
@@ -115,6 +116,7 @@ export interface TrainingConfigActions {
setUploadedEvalFile: (file: string | null) => void;
setEpochs: (epochs: number) => void;
setContextLength: (length: number) => void;
+ setVisionImageSize: (size: number | null) => void;
setLearningRate: (rate: number) => void;
setEmbeddingLearningRate: (rate: number | null) => void;
setOptimizerType: (value: string) => void;
diff --git a/studio/frontend/src/i18n/AGENTS.md b/studio/frontend/src/i18n/AGENTS.md
new file mode 100644
index 0000000000..42d964acca
--- /dev/null
+++ b/studio/frontend/src/i18n/AGENTS.md
@@ -0,0 +1,11 @@
+# i18n Contribution Instructions
+
+- `locales/en.ts` is the complete baseline message file.
+- Non-English locale files may be partial. Missing keys must fall back to English at runtime.
+- Use BCP 47 locale tags for new languages, for example `zh-CN`, `ja-JP`, and `ko-KR`.
+- Do not change fallback logic to hide missing translations.
+- Do not add automatic DOM translation, MutationObserver text replacement, or runtime guess-based translation.
+- Preserve interpolation variables exactly, for example `{count}`, `{model}`, and `{provider}`.
+- Keep product and technical names unchanged unless there is an established localized name, for example `Unsloth Studio`, `LoRA`, `GGUF`, and `Hugging Face`.
+- Keep translation changes small and reviewable. Prefer separate commits for runtime changes, UI migration, and locale text.
+- When adding user-facing Studio UI text, add the English message key first and add non-English overrides only when the translation is clear.
diff --git a/studio/frontend/src/i18n/check-parity.ts b/studio/frontend/src/i18n/check-parity.ts
new file mode 100644
index 0000000000..9808fd0e36
--- /dev/null
+++ b/studio/frontend/src/i18n/check-parity.ts
@@ -0,0 +1,111 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+// Parity check between en.ts and every non-English locale.
+// - Locale files may be partial; missing keys must fall back to English.
+// - All zh-CN keys must exist in en (no extras).
+// - Placeholder set must match per leaf between en and the overlay.
+//
+// Run: npx tsx src/i18n/check-parity.ts
+
+import { en } from "./locales/en.ts";
+import { zhCN } from "./locales/zh-CN.ts";
+
+type Tree = { readonly [k: string]: string | Tree };
+
+function isTree(v: unknown): v is Tree {
+ return typeof v === "object" && v !== null && !Array.isArray(v);
+}
+
+function placeholders(s: string): string[] {
+ const out: string[] = [];
+ const re = /\{([a-zA-Z0-9_]+)\}/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(s))) out.push(m[1]);
+ return out.sort();
+}
+
+function checkOverlay(
+ enNode: Tree,
+ overlay: Tree | undefined,
+ path: string,
+ errors: string[],
+ missing: string[],
+): void {
+ for (const [k, v] of Object.entries(enNode)) {
+ const subPath = path ? `${path}.${k}` : k;
+ if (typeof v === "string") {
+ if (overlay === undefined) {
+ missing.push(subPath);
+ continue;
+ }
+ const overlayV = overlay[k];
+ if (overlayV === undefined) {
+ missing.push(subPath);
+ continue;
+ }
+ if (typeof overlayV !== "string") {
+ errors.push(`${subPath} should be string, got ${typeof overlayV}`);
+ continue;
+ }
+ const enP = placeholders(v);
+ const ovP = placeholders(overlayV);
+ if (JSON.stringify(enP) !== JSON.stringify(ovP)) {
+ errors.push(
+ `${subPath}: placeholder mismatch en={${enP.join(",")}} overlay={${ovP.join(",")}}`,
+ );
+ }
+ } else if (isTree(v)) {
+ const overlaySub = overlay === undefined ? undefined : overlay[k];
+ if (overlaySub !== undefined && !isTree(overlaySub)) {
+ errors.push(`${subPath} should be an object, got ${typeof overlaySub}`);
+ continue;
+ }
+ checkOverlay(v, overlaySub, subPath, errors, missing);
+ }
+ }
+}
+
+function checkExtras(
+ overlay: Tree,
+ enNode: Tree,
+ path: string,
+ errors: string[],
+): void {
+ for (const [k, v] of Object.entries(overlay)) {
+ const subPath = path ? `${path}.${k}` : k;
+ if (!(k in enNode)) {
+ errors.push(`${subPath} exists in overlay but not in en`);
+ continue;
+ }
+ const enV = enNode[k];
+ if (isTree(v) && isTree(enV)) {
+ checkExtras(v, enV, subPath, errors);
+ } else if (isTree(v) !== isTree(enV)) {
+ errors.push(`${subPath}: shape mismatch (en=${typeof enV}, overlay=${typeof v})`);
+ }
+ }
+}
+
+const overlays: Record = { "zh-CN": zhCN as unknown as Tree };
+let anyError = false;
+
+for (const [locale, overlay] of Object.entries(overlays)) {
+ const errors: string[] = [];
+ const missing: string[] = [];
+ checkOverlay(en as unknown as Tree, overlay, "", errors, missing);
+ checkExtras(overlay, en as unknown as Tree, "", errors);
+
+ console.log(`\n=== ${locale} ===`);
+ console.log(`Missing keys (will fall back to en): ${missing.length}`);
+ if (errors.length) {
+ anyError = true;
+ console.error(`Errors (${errors.length}):`);
+ for (const e of errors) console.error(` - ${e}`);
+ } else {
+ console.log("No errors.");
+ }
+}
+
+if (anyError) process.exit(1);
+console.log("\nAll locale overlays pass parity.");
diff --git a/studio/frontend/src/i18n/index.ts b/studio/frontend/src/i18n/index.ts
new file mode 100644
index 0000000000..6cd2fe230a
--- /dev/null
+++ b/studio/frontend/src/i18n/index.ts
@@ -0,0 +1,44 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+import { useCallback } from "react";
+import { useLocale } from "./locale-store";
+import { translate } from "./messages";
+import type { InterpolationValues } from "./types";
+import type { TranslationKey } from "./messages";
+
+export {
+ DEFAULT_LOCALE,
+ LOCALE_STORAGE_KEY,
+ getLocale,
+ initializeLocale,
+ setLocale,
+ subscribeLocale,
+ useLocale,
+} from "./locale-store";
+export {
+ LOCALES,
+ isSupportedLocale,
+ messages,
+ translate,
+} from "./messages";
+export type { Locale, TranslationKey } from "./messages";
+export type {
+ DeepPartialMessageTree,
+ InterpolationValues,
+ MessageKey,
+ MessageTree,
+} from "./types";
+
+export function useT(): (
+ key: TranslationKey,
+ values?: InterpolationValues,
+) => string {
+ const locale = useLocale();
+
+ return useCallback(
+ (key: TranslationKey, values?: InterpolationValues) =>
+ translate(key, values, locale),
+ [locale],
+ );
+}
diff --git a/studio/frontend/src/i18n/locale-store.ts b/studio/frontend/src/i18n/locale-store.ts
new file mode 100644
index 0000000000..8b4643f5a9
--- /dev/null
+++ b/studio/frontend/src/i18n/locale-store.ts
@@ -0,0 +1,130 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+import { useSyncExternalStore } from "react";
+import { isSupportedLocale, type Locale } from "./messages";
+
+export const DEFAULT_LOCALE: Locale = "en";
+export const LOCALE_STORAGE_KEY = "unsloth_locale";
+
+const subscribers = new Set<() => void>();
+
+let currentLocale: Locale = DEFAULT_LOCALE;
+let isStorageListenerActive = false;
+
+function normalizeLocale(value: unknown): Locale {
+ return isSupportedLocale(value) ? value : DEFAULT_LOCALE;
+}
+
+function readStoredLocale(): Locale {
+ try {
+ const stored = globalThis.localStorage?.getItem(LOCALE_STORAGE_KEY) ?? null;
+ return normalizeLocale(stored);
+ } catch {
+ return DEFAULT_LOCALE;
+ }
+}
+
+function writeStoredLocale(locale: Locale): void {
+ try {
+ globalThis.localStorage?.setItem(LOCALE_STORAGE_KEY, locale);
+ } catch {
+ // localStorage 可能被禁用;失败只影响持久化,不影响当前会话语言。
+ }
+}
+
+function syncDocumentLang(locale: Locale): void {
+ if (typeof document === "undefined") return;
+ document.documentElement.lang = locale;
+}
+
+function notifySubscribers(): void {
+ for (const subscriber of subscribers) subscriber();
+}
+
+function updateCurrentLocale(locale: Locale): void {
+ if (locale === currentLocale) return;
+ currentLocale = locale;
+ syncDocumentLang(locale);
+ notifySubscribers();
+}
+
+function isLocaleStorageEvent(event: StorageEvent): boolean {
+ if (event.key !== LOCALE_STORAGE_KEY && event.key !== null) return false;
+ if (!event.storageArea || typeof window === "undefined") return true;
+ // Accessing window.localStorage can throw in privacy-restricted contexts
+ // where storage is blocked; mirror the try/catch in readStoredLocale/
+ // writeStoredLocale so storage-event handling is just as resilient.
+ try {
+ return event.storageArea === window.localStorage;
+ } catch {
+ return false;
+ }
+}
+
+function handleStorageEvent(event: StorageEvent): void {
+ if (!isLocaleStorageEvent(event)) return;
+ const nextLocale =
+ event.key === null ? DEFAULT_LOCALE : normalizeLocale(event.newValue);
+ updateCurrentLocale(nextLocale);
+}
+
+function startStorageListener(): void {
+ if (isStorageListenerActive || typeof window === "undefined") return;
+ window.addEventListener("storage", handleStorageEvent);
+ isStorageListenerActive = true;
+}
+
+function stopStorageListener(): void {
+ if (!isStorageListenerActive || typeof window === "undefined") return;
+ window.removeEventListener("storage", handleStorageEvent);
+ isStorageListenerActive = false;
+}
+
+function getLocaleSnapshot(): Locale {
+ return currentLocale;
+}
+
+function getServerLocaleSnapshot(): Locale {
+ return DEFAULT_LOCALE;
+}
+
+export function subscribeLocale(listener: () => void): () => void {
+ const shouldStartStorageListener = subscribers.size === 0;
+ subscribers.add(listener);
+ if (shouldStartStorageListener) startStorageListener();
+
+ return () => {
+ subscribers.delete(listener);
+ if (subscribers.size === 0) stopStorageListener();
+ };
+}
+
+export function initializeLocale(): Locale {
+ const nextLocale = readStoredLocale();
+ currentLocale = nextLocale;
+ syncDocumentLang(nextLocale);
+ notifySubscribers();
+ return nextLocale;
+}
+
+export function getLocale(): Locale {
+ return currentLocale;
+}
+
+export function setLocale(locale: Locale): void {
+ const requestedLocale = normalizeLocale(locale);
+ writeStoredLocale(requestedLocale);
+
+ currentLocale = requestedLocale;
+ syncDocumentLang(requestedLocale);
+ notifySubscribers();
+}
+
+export function useLocale(): Locale {
+ return useSyncExternalStore(
+ subscribeLocale,
+ getLocaleSnapshot,
+ getServerLocaleSnapshot,
+ );
+}
diff --git a/studio/frontend/src/i18n/locales/en.ts b/studio/frontend/src/i18n/locales/en.ts
new file mode 100644
index 0000000000..b94f3aa105
--- /dev/null
+++ b/studio/frontend/src/i18n/locales/en.ts
@@ -0,0 +1,731 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+export const en = {
+ common: {
+ cancel: "Cancel",
+ close: "Close",
+ delete: "Delete",
+ done: "Done",
+ error: "Error",
+ export: "Export",
+ help: "Help",
+ loading: "Loading...",
+ new: "New",
+ rename: "Rename",
+ save: "Save",
+ search: "Search",
+ shutdown: "Shutdown",
+ },
+ shell: {
+ beta: "BETA",
+ brand: "unsloth",
+ product: "Unsloth Studio",
+ accountMenu: "{name} account menu",
+ aria: {
+ home: "Unsloth home",
+ closeSidebar: "Close sidebar",
+ openSidebar: "Open sidebar",
+ chatOptions: "Chat options",
+ runOptions: "Run options",
+ },
+ navigation: {
+ newChat: "New Chat",
+ compare: "Compare",
+ search: "Search",
+ train: "Train",
+ recipes: "Recipes",
+ export: "Export",
+ recents: "Recents",
+ settings: "Settings",
+ api: "API",
+ lightMode: "Light Mode",
+ darkMode: "Dark Mode",
+ guidedTour: "Guided Tour",
+ help: "Help",
+ logOut: "Log out",
+ shutdown: "Shutdown",
+ },
+ notFound: {
+ title: "Page not found",
+ description: "{path} does not exist.",
+ backToChat: "Back to chat",
+ },
+ dialog: {
+ deleteChat: {
+ title: "Delete chat",
+ description: "Are you sure you want to delete this chat \"{name}\"?",
+ },
+ deleteRun: {
+ title: "Delete training run",
+ description: "Are you sure you want to delete this run \"{name}\"?",
+ },
+ renameChat: {
+ title: "Rename chat",
+ placeholder: "Chat title",
+ },
+ renameRun: {
+ title: "Rename run",
+ placeholder: "Run name",
+ },
+ },
+ toast: {
+ cannotDeleteRunningRun: "Cannot delete a running training run",
+ failedToDeleteChat: "Failed to delete chat",
+ failedToDeleteRun: "Failed to delete run",
+ failedToRenameChat: "Failed to rename chat",
+ failedToRenameRun: "Failed to rename run",
+ },
+ },
+ settings: {
+ title: "Settings",
+ dialog: {
+ title: "Settings",
+ description: "Manage your Unsloth Studio preferences.",
+ closeAriaLabel: "Close settings",
+ },
+ tabs: {
+ general: "General",
+ profile: "Profile",
+ appearance: "Appearance",
+ chat: "Chat",
+ connections: "Connections",
+ apiKeys: "API",
+ about: "Help",
+ },
+ general: {
+ title: "General",
+ description: "Global preferences for Unsloth Studio.",
+ account: "Account",
+ huggingFaceToken: "Hugging Face token",
+ huggingFaceTokenDescription:
+ "Used to load gated models and push artifacts.",
+ hideToken: "Hide token",
+ showToken: "Show token",
+ chatDefaults: "Chat defaults",
+ autoTitleNewChats: "Auto-title new chats",
+ autoTitleNewChatsDescription:
+ "Generate a short title from the first message.",
+ gettingStarted: "Getting started",
+ startOnboarding: "Start onboarding",
+ startOnboardingDescription:
+ "Open the setup wizard again without changing your account.",
+ startOnboardingAction: "Start onboarding",
+ resetPreferences: {
+ sectionTitle: "Danger zone",
+ label: "Reset all local preferences",
+ description:
+ "Clears local-only preferences. Chats, API access, and DB-backed chat settings are not affected.",
+ action: "Reset preferences",
+ confirmTitle: "Reset all local preferences?",
+ confirmDescription:
+ "This clears local-only preferences, then reloads Studio. Chats, API access, and DB-backed chat settings are not affected.",
+ confirmAction: "Reset and reload",
+ },
+ },
+ profile: {
+ title: "Profile",
+ description: "Update how your profile appears in Studio.",
+ changePicture: "Change profile picture",
+ displayName: "Display name",
+ nameSaved: "Profile name saved",
+ namePersistErrorTitle: "Could not persist profile name",
+ namePersistErrorDescription:
+ "Name updated for this session, but may not persist after reload.",
+ photoUpdated: "Profile photo updated",
+ photoPersistErrorTitle: "Could not persist profile photo",
+ photoPersistErrorDescription:
+ "Photo updated for this session, but may not persist after reload.",
+ photoUpdateErrorTitle: "Could not update profile photo",
+ imageUseError: "Could not use this image.",
+ },
+ appearance: {
+ title: "Appearance",
+ description: "How Unsloth Studio looks on this device.",
+ theme: {
+ title: "Theme",
+ label: "Color scheme",
+ description: "Choose light, dark, or follow your system.",
+ system: "System",
+ light: "Light",
+ dark: "Dark",
+ },
+ language: {
+ title: "Language",
+ label: "Display language",
+ description: "Choose the language used by Studio.",
+ },
+ layout: {
+ title: "Layout",
+ compactSidebar: "Pin sidebar by default",
+ compactSidebarDescription:
+ "Keep the sidebar expanded instead of collapsing to icons.",
+ },
+ },
+ chat: {
+ title: "Chat",
+ description: "Manage your chat history stored on this device.",
+ data: "Data",
+ exportHistory: "Export chat history",
+ exportHistoryDescription:
+ "Download all chats and messages as a JSON file.",
+ exportAction: "Export",
+ exportingAction: "Exporting...",
+ clearHistory: "Clear chat history",
+ clearHistoryDescription: "Delete local chat history from this device.",
+ clearAction: "Clear",
+ clearAllChats: "Clear all chats",
+ clearAllChatsDescription:
+ "Permanently delete every chat on this device.",
+ noChatsToClear: "No chats to clear.",
+ clearOneChatDescription:
+ "Permanently delete the only chat on this device.",
+ clearChatCountDescription:
+ "Permanently delete all {count} chats on this device.",
+ clearChatsAction: "Clear chats",
+ clearOneChatTitle: "Clear 1 chat?",
+ clearChatsTitle: "Clear {count} chats?",
+ clearChatsConfirmDescription:
+ "This permanently deletes every chat and message stored on this device. This cannot be undone.",
+ clearingAction: "Clearing...",
+ clearOneChatAction: "Clear 1 chat",
+ clearChatCountAction: "Clear {count} chats",
+ clearedAllChats: "Cleared all chats",
+ clearedOneChat: "Cleared 1 chat",
+ clearedChatCount: "Cleared {count} chats",
+ someChatsCouldNotBeCleared: "Some chats could not be cleared",
+ chatsClearedRemainOne:
+ "{clearedCount} chats cleared; 1 chat remains. Please retry.",
+ chatsClearedRemain:
+ "{clearedCount} chats cleared; {remainingCount} chats remain. Please retry.",
+ oneChatClearedRemain:
+ "1 chat cleared; {remainingCount} chats remain. Please retry.",
+ oneChatClearedRemainOne:
+ "1 chat cleared; 1 chat remains. Please retry.",
+ storageClearFailedOne:
+ "A storage clear failed; 1 chat may remain. Please retry.",
+ storageClearFailed:
+ "A storage clear failed; {count} chats may remain. Please retry.",
+ failedToClearChats: "Failed to clear chats",
+ },
+ connections: {
+ title: "Connections",
+ description: "Manage providers and external service connections.",
+ },
+ apiKeys: {
+ title: "API",
+ description: "Access Unsloth programmatically via the OpenAI-compatible API.",
+ readDocs: "Read the API docs",
+ noAccess: "No API access yet.",
+ newBadge: "New",
+ accessTokens: "Access tokens",
+ loadError: "Couldn't load API access.",
+ createError: "Couldn't create access token.",
+ revokeError: "Couldn't revoke access token.",
+ never: "Never",
+ tokenNamePlaceholder: "Token name (e.g. production)",
+ newAccessTokenName: "New access token name",
+ createToken: "Create token",
+ creating: "Creating...",
+ newTokenCreated: "New access token created",
+ accessTokenCopied: "Access token copied",
+ copyAccessToken: "Copy access token",
+ copyNow: "Copy now - this won't be shown again.",
+ usageExamples: "Usage examples",
+ usageTools: "Tools",
+ copySnippet: "Copy snippet",
+ copy: "Copy",
+ copied: "Copied",
+ setupDocs: "Setup docs:",
+ relativeNever: "never",
+ relativeJustNow: "just now",
+ relativeHoursAgo: "{count}h ago",
+ relativeDaysAgo: "{count}d ago",
+ relativeMonthsAgo: "{count}mo ago",
+ relativeYearsAgo: "{count}y ago",
+ expired: "expired",
+ today: "today",
+ inDays: "in {count}d",
+ created: "Created {value}",
+ used: "Used {value}",
+ expires: "Expires {value}",
+ actionsFor: "Actions for {name}",
+ copyPrefix: "Copy prefix",
+ revokeToken: "Revoke token",
+ revokeTitle: "Revoke access token \"{name}\"?",
+ revokeDescription:
+ "Applications using this token will immediately lose access. This cannot be undone.",
+ revokeAction: "Revoke \"{name}\"",
+ revoking: "Revoking...",
+ },
+ about: {
+ title: "About",
+ description:
+ "Documentation, release notes, feedback, and Studio build info.",
+ studioVersion: "Studio Version",
+ packageVersion: "Package Version",
+ updates: "Updates",
+ help: "Help",
+ documentation: "Documentation",
+ releaseNotes: "Release notes",
+ whatsNew: "What's new",
+ feedback: "Feedback",
+ reportIssue: "Report an issue",
+ dangerZone: "Danger zone",
+ shutDownStudio: "Shut down Unsloth Studio",
+ shutDownStudioDescription:
+ "Stops the Studio server process and ends your session.",
+ shutDown: "Shut down",
+ update: {
+ title: "Update Unsloth Studio",
+ openPowerShell: "Open PowerShell and run:",
+ openTerminal: "Open Terminal and run:",
+ commandText: "{label} text",
+ copied: "Copied",
+ copyCommand: "Copy command",
+ commandCopied: "{label} copied",
+ copyNamedCommand: "Copy {label}",
+ checkingInstall: "Checking how Studio was installed...",
+ localInstallDetected:
+ "Source or local install detected. To avoid replacing it with PyPI, update from the checkout or source you originally installed from.",
+ pullThenUpdate:
+ "Pull latest changes from your Unsloth repo checkout, then update Studio locally:",
+ gitPullCommand: "git pull command",
+ localUpdateCommand: "local update command",
+ localInstallerFallback:
+ "If the Studio update command is unavailable, run the local installer from that checkout:",
+ localInstallerCommand: "local installer command",
+ sourceInstallDetected:
+ "This looks like a source or VCS package install. Reinstall from the original local path or Git URL you used.",
+ repoCheckoutFallback:
+ "If you still have the Unsloth repo checkout, run the local installer from that checkout:",
+ restartAfterUpdate:
+ "Restart Studio after updating for changes to take effect.",
+ unknownInstall:
+ "Studio could not detect how it was installed. Check how you installed Studio first, then choose the matching update path.",
+ curlOrPypi: "For curl or PyPI installs, run:",
+ updateCommand: "update command",
+ localCheckout:
+ "For local checkout installs, update from that checkout instead and use the local update command:",
+ fallbackInstruction:
+ "If that fails or unsloth studio update is unavailable, run:",
+ fallbackCommand: "fallback command",
+ },
+ },
+ },
+ studio: {
+ routeTitle: "Train",
+ title: "Fine-tuning Studio",
+ subtitles: {
+ configure: "Configure and start training",
+ trainingInProgress: "Training in progress",
+ viewPastRuns: "View past training runs",
+ viewingPastRun: "Viewing past run",
+ },
+ tabs: {
+ configure: "Configure",
+ currentRun: "Current Run",
+ history: "History",
+ },
+ loadingRuntime: "Loading training runtime...",
+ backToHistory: "Back to history",
+ sections: {
+ model: "Model",
+ dataset: "Dataset",
+ params: "Parameters",
+ training: "Training",
+ charts: "Charts",
+ progress: "Training Progress",
+ },
+ configure: {
+ title: "Configure",
+ description: "Choose a model, dataset, and training settings.",
+ startTraining: "Start Training",
+ starting: "Starting...",
+ loadingModel: "Loading model...",
+ checkingDataset: "Checking dataset...",
+ trainingConfig: "Training Config",
+ },
+ model: {
+ title: "Model",
+ description: "Select base model and training method",
+ fasterTrainingBadge: "2x Faster Training",
+ baseModel: "Base model",
+ localModel: "Local Model",
+ localModelTooltip: "Path to a locally downloaded model or a custom HF repo.",
+ scanningLocalAndCachedModels: "Scanning local and cached models...",
+ scanning: "Scanning...",
+ scanningLocalModels: "Scanning local models...",
+ noLocalModelsFound: "No local models found",
+ noLocalModelsFoundManual: "No local models found. Enter path manually.",
+ failedToLoadLocalModels: "Failed to load local models",
+ hfCache: "HF cache",
+ customFolders: "Custom Folders",
+ localDir: "Local dir",
+ huggingFaceModel: "Hugging Face Model",
+ huggingFaceModelTooltip:
+ "Search Hugging Face models or pick from our recommended list.",
+ searchModels: "Search models...",
+ searching: "Searching...",
+ noModelsFound: "No models found",
+ needsVram: "Needs ~{vram}GB VRAM (GPU: {gpu}GB)",
+ tightVram: "~{vram}GB VRAM (tight fit on {gpu}GB)",
+ vramEstimate: "~{vram}GB VRAM",
+ method: "Method",
+ methodTooltip:
+ "QLoRA uses 4-bit quantization for lowest VRAM. LoRA uses 16-bit. Full updates all weights. CPT (Continued Pretraining) trains on raw text to adapt the model to a new domain without chat formatting.",
+ readMore: "Read more",
+ fullFineTune: "Full Fine-tune",
+ checkingToken: "Checking token...",
+ getOrUpdateToken: "Get or update token",
+ huggingFaceTokenOptional: "Hugging Face Token (Optional)",
+ continuedPretraining: "Continued Pretraining",
+ localModels: "Local models",
+ localModelsFound: "{count} local/cached models found",
+ loadingLocalModels: "Loading local models...",
+ },
+ dataset: {
+ title: "Dataset",
+ description: "Select or upload training data",
+ source: "Dataset source",
+ chooseDataset: "Choose dataset",
+ chooseDatasetTooltip:
+ "Use the popup tabs to switch between Hugging Face and local recipe outputs.",
+ localTab: "Local",
+ searchHuggingFaceDatasets: "Search Hugging Face datasets...",
+ searchLocalDatasets: "Search local datasets...",
+ searching: "Searching...",
+ noDatasetsFound: "No datasets found",
+ loadingLocalDatasets: "Loading local datasets...",
+ failedToLoadLocalDatasets: "Failed to load local datasets.",
+ noLocalDatasetsYet: "No local datasets yet.",
+ noLocalDatasetsMatchSearch: "No local datasets match search.",
+ openDataRecipes: "Open Data Recipes",
+ browsingSource:
+ "Browsing {browsing}. Current selection stays {current}.",
+ localDatasets: "Local datasets",
+ localDataset: "Local dataset",
+ localDatasetRows: " / {count} rows",
+ huggingFaceDataset: "Hugging Face Dataset",
+ localDatasetMetadata: "Local dataset metadata",
+ dataRecipeOutput: "Data Recipe output.",
+ rows: "Rows",
+ columns: "Columns",
+ batches: "Batches",
+ updated: "Updated",
+ evalDataset: "Eval dataset",
+ uploading: "Uploading...",
+ upload: "Upload",
+ uploadEvalFile: "Upload eval file",
+ evalDatasetDescription:
+ "Optional. If not provided, a small portion will be split from the training data.",
+ advanced: "Advanced",
+ targetFormat: "Target Format",
+ targetFormatTooltip:
+ "Format of your training data. Auto-detect works for most datasets.",
+ auto: "Auto",
+ rawText: "Raw Text",
+ trainSplitStart: "Train Split Start",
+ trainSplitStartTooltip:
+ "Only train on a subset of your training split by specifying a start row index (inclusive, 0-based). Leave empty to start from the first row.",
+ trainSplitEnd: "Train Split End",
+ trainSplitEndTooltip:
+ "Last row index to include from the training split (inclusive, 0-based). For example, set Start to 0 and End to 99 to train on the first 100 rows. Leave empty to use all remaining rows.",
+ endPlaceholder: "End",
+ clear: "Clear",
+ dropFileOrClick: "Drop 1 file here or click to upload",
+ viewDataset: "View dataset",
+ uploadFailed: "Upload failed",
+ unknownError: "Unknown error",
+ unsupportedFileType: "Unsupported file type",
+ uploadOneFileType: "Upload one {types} file.",
+ datasetUploaded: "Dataset uploaded",
+ evalDatasetUploaded: "Eval dataset uploaded",
+ uploadOneFileAtATime: "Upload one file at a time",
+ uploadSingleFileDescription:
+ "Training dataset upload accepts a single file.",
+ checkingToken: "Checking token...",
+ getOrUpdateToken: "Get or update token",
+ preview: "Preview dataset",
+ split: "Split",
+ subset: "Subset",
+ },
+ params: {
+ title: "Parameters",
+ description: "Configure training hyperparameters",
+ loraSettings: "LoRA Settings",
+ trainingHyperparameters: "Training Hyperparameters",
+ maxSteps: "Max Steps",
+ epochs: "Epochs",
+ useMaxSteps: "Use Max Steps",
+ useEpochs: "Use Epochs",
+ maxStepsTooltip: "Override total optimizer steps.",
+ epochsTooltip: "Number of full passes over the dataset.",
+ epochsDescription: "Each epoch is one full pass over your dataset.",
+ maxStepsDescription: "Limits training to a fixed number of optimizer steps.",
+ contextLength: "Context Length",
+ contextLengthTooltip: "Maximum number of tokens per training sample.",
+ customContextLength: "Enter a custom value",
+ contextLengthDescription: "Max sequence length for training samples",
+ learningRate: "Learning Rate",
+ learningRateTooltip:
+ "Step size for weight updates. Lower values train slower but more stably.",
+ learningRateDescription:
+ "Recommended: 2e-4 for LoRA, 5e-5 for CPT, 2e-5 for full fine-tune",
+ embeddingLearningRate: "Embedding Learning Rate",
+ embeddingLearningRateTooltip:
+ "Only used when CPT is training embed_tokens. Embeddings are easier to destabilize than LoRA weights, so they usually need a smaller LR. Leave blank to use lr/10; typical working range is 2x-10x smaller than the main LR. Increase it only if vocabulary or domain-token adaptation is too slow.",
+ embeddingLearningRateDescription:
+ "Leave blank to use lr/10 (recommended). Typical range is 2x-10x smaller than the main learning rate.",
+ rank: "Rank",
+ rankTooltip: "Dimension of the low-rank matrices. Higher = more capacity.",
+ alpha: "Alpha",
+ alphaTooltip: "Scaling factor for LoRA updates. Usually 2x rank.",
+ dropout: "Dropout",
+ dropoutTooltip: "Dropout probability for LoRA layers to reduce overfitting.",
+ visionLayers: "Vision layers",
+ languageLayers: "Language layers",
+ attentionModules: "Attention modules",
+ mlpModules: "MLP modules",
+ targetModules: "Target Modules",
+ enableLora: "Enable LoRA",
+ trainWithLora: "Train with LoRA",
+ stableRank: "Stable Rank",
+ memoryEfficient: "Memory Efficient",
+ optimization: "Optimization",
+ schedule: "Schedule",
+ memory: "Memory",
+ optimizer: "Optimizer",
+ optimizerTooltip:
+ "Optimization algorithm. 8-bit variants reduce memory usage. Fused is recommended for vision models.",
+ lrScheduler: "LR scheduler",
+ lrSchedulerTooltip:
+ "How the learning rate changes over training. Linear decays steadily; cosine decays in a curve.",
+ optimizerOptions: {
+ adamw8bit: "AdamW 8-bit",
+ pagedAdamw8bit: "Paged AdamW 8-bit",
+ adamwBnb8bit: "AdamW BNB 8-bit",
+ pagedAdamw32bit: "Paged AdamW 32-bit",
+ adamwTorch: "AdamW (PyTorch)",
+ adamwTorchFused: "AdamW (PyTorch Fused)",
+ },
+ lrSchedulerOptions: {
+ linear: "Linear",
+ cosine: "Cosine",
+ },
+ batchSize: "Batch Size",
+ batchSizeTooltip: "Samples processed per step. Higher uses more VRAM.",
+ gradAccum: "Grad Accum",
+ gradAccumTooltip: "Simulates larger batch sizes without extra VRAM.",
+ weightDecay: "Weight Decay",
+ weightDecayTooltip: "L2 regularization to prevent overfitting.",
+ warmupSteps: "Warmup Steps",
+ warmupStepsTooltip: "Gradually increase LR at training start for stability.",
+ scheduleEpochsTooltip:
+ "Number of full passes over the dataset. Set 0 to run by max steps.",
+ saveSteps: "Save Steps",
+ saveStepsTooltip: "Save a checkpoint every N steps. 0 to disable.",
+ evalSteps: "Eval Steps",
+ evalStepsTooltip:
+ "Fraction of total training steps between evaluations (0-1). Set to 0 to disable evaluation. E.g. 0.01 = evaluate every 1% of steps.",
+ seed: "Seed",
+ seedTooltip: "Random seed for reproducibility.",
+ gradCheckpoint: "Grad Checkpoint",
+ gradCheckpointTooltip:
+ "Trade compute for memory by recomputing activations.",
+ none: "None",
+ standard: "Standard",
+ enablePacking: "Enable packing",
+ assistantCompletionsOnly: "Assistant completions only",
+ readMore: "Read more",
+ },
+ training: {
+ title: "Training",
+ description: "Monitor and control training",
+ chartNoDataTitle: "No training data yet",
+ chartNoDataDescription: "Start training to see loss progress",
+ startTraining: "Start Training",
+ starting: "Starting...",
+ loadingModel: "Loading model...",
+ checkingDataset: "Checking dataset...",
+ configLabel: "Training Config",
+ upload: "Upload",
+ uploadConfigTooltip: "Load a saved YAML config",
+ save: "Save",
+ saveConfigTooltip: "Download current config as YAML",
+ reset: "Reset",
+ resetConfigTooltip: "Reset to model defaults",
+ configLoaded: "Config loaded",
+ failedToLoadConfig: "Failed to load config",
+ invalidYamlFile: "Invalid YAML file",
+ failedToReadFile: "Failed to read file",
+ parametersReset: "Parameters reset to model defaults",
+ audioIncompatible:
+ "This model does not support audio. Switch to an audio-capable model or choose a non-audio dataset.",
+ visionIncompatible:
+ "Text model is not compatible with a multimodal dataset. Switch to a vision model or choose a text-only dataset.",
+ cancelTitle: "Cancel Training",
+ cancelDescription: "Do you want to cancel the current training run?",
+ continueAction: "Continue Training",
+ cancelAction: "Cancel Training",
+ stopTitle: "Stop Training",
+ stopDescription: "Choose how you want to stop the current training run.",
+ stopAction: "Stop",
+ stopping: "Stopping...",
+ stopAndSave: "Stop and Save",
+ compareInChat: "Compare in Chat",
+ exportModel: "Export Model",
+ milestone: "Milestone",
+ halfwayDone: "Halfway done. Training is past 50%.",
+ doneNextStep: "Training done. Next step: compare base vs fine-tuned outputs.",
+ },
+ history: {
+ title: "History",
+ emptyTitle: "No training runs yet",
+ emptyDescription:
+ "No training runs yet. Start your first training run in the Configure tab.",
+ loadError: "Failed to load training runs",
+ deleteError: "Failed to delete training run. Please try again.",
+ retry: "Retry",
+ loadMore: "Load more",
+ loading: "Loading...",
+ loadingRun: "Loading training run...",
+ runNotFound: "Run not found",
+ deleteTitle: "Delete training run?",
+ deleteDescription:
+ "This will permanently delete this training run and all its metrics. This action cannot be undone.",
+ runCount: "{count} runs",
+ oneRun: "1 run",
+ resume: "Resume",
+ resumeTraining: "Resume training",
+ resuming: "Resuming...",
+ deleteRun: "Delete run",
+ loss: "Loss",
+ steps: "Steps",
+ lossTrendSparkline: "Loss trend sparkline",
+ relativeJustNow: "just now",
+ relativeMinutesAgo: "{count}m ago",
+ relativeHoursAgo: "{count}h ago",
+ relativeDaysAgo: "{count}d ago",
+ status: {
+ completed: "Completed",
+ stopped: "Stopped",
+ error: "Error",
+ running: "Running",
+ continued: "Continued",
+ },
+ message: {
+ completed: "Training completed",
+ stopped: "Training stopped",
+ running: "Training in progress",
+ errored: "Training errored",
+ },
+ },
+ charts: {
+ settings: "Chart Settings",
+ settingsDescription: "Tune chart presentation while training keeps running.",
+ openSettings: "Open chart settings",
+ viewWindow: "View window",
+ viewWindowDescription: "Show latest steps only or the full history.",
+ window: "Window",
+ all: "All",
+ trainingLoss: "Training Loss",
+ trainingLossDescription: "Control overlays and EMA smoothing.",
+ smoothing: "Smoothing",
+ smoothingDescription: "Move right for more smoothing. `0` = raw.",
+ showRawLoss: "Show raw loss",
+ showSmoothedLoss: "Show smoothed loss",
+ showAverageLine: "Show average line",
+ scaleAndCleanup: "Scale and cleanup",
+ linear: "Linear",
+ log: "Log",
+ noClip: "No clip",
+ clipP99: "Clip p99",
+ clipP95: "Clip p95",
+ lossAxis: "Loss axis",
+ gradientNormAxis: "Gradient norm axis",
+ learningRateAxis: "Learning rate axis",
+ resetDefaults: "Reset defaults",
+ loss: "Loss",
+ smoothed: "Smoothed",
+ evalLoss: "Eval Loss",
+ learningRate: "Learning Rate",
+ lr: "LR",
+ gradNorm: "Grad Norm",
+ gradientNorm: "Gradient Norm",
+ step: "Step {step}",
+ averageValue: "avg {value}",
+ waitingForFirstEvaluationStep: "Waiting for first evaluation step...",
+ evaluationNotConfigured: "Evaluation not configured",
+ evalChartWillAppear: "Chart will appear once eval_steps is reached",
+ setEvalDatasetAndSteps: "Set eval dataset & eval_steps to track eval loss",
+ },
+ progress: {
+ title: "Training Progress",
+ liveMetrics: "Live training metrics",
+ openConfig: "Open training config",
+ configLabel: "Training Config",
+ hyperparams: "Hyperparams",
+ epochs: "Epochs",
+ batchSize: "Batch size",
+ learningRate: "Learning rate",
+ optimizer: "Optimizer",
+ maxSteps: "Max steps",
+ contextLength: "Context length",
+ warmupSteps: "Warmup steps",
+ rank: "Rank",
+ alpha: "Alpha",
+ dropout: "Dropout",
+ variant: "Variant",
+ epoch: "Epoch {value}",
+ percentComplete: "{percent}% complete",
+ stepProgress: "Step {current} / {total}",
+ loss: "Loss",
+ lr: "LR",
+ gradNorm: "Grad Norm",
+ model: "Model",
+ method: "Method",
+ elapsed: "Elapsed: {value}",
+ eta: "ETA: {value}",
+ stepsPerSecond: "{value} steps/s",
+ noStepsPerSecond: "-- steps/s",
+ tokens: "Tokens: {value}",
+ gpuMonitor: "GPU Monitor",
+ live: "Live",
+ utilization: "Utilization",
+ temperature: "Temperature",
+ vram: "VRAM",
+ power: "Power",
+ phase: {
+ idle: "Idle",
+ downloadingModel: "Downloading model",
+ downloadingDataset: "Downloading dataset",
+ loadingModel: "Loading model",
+ loadingDataset: "Loading dataset",
+ configuring: "Configuring",
+ training: "Training",
+ completed: "Completed",
+ error: "Error",
+ stopped: "Stopped",
+ },
+ },
+ trainingStart: {
+ ready: "Ready",
+ downloading: "Downloading",
+ preparing: "Preparing",
+ left: "{eta} left",
+ downloaded: "{size} downloaded",
+ terminalStart: "> unsloth training starts...",
+ preparingResources: "> Preparing model and dataset...",
+ gettingReady: "> We are getting everything ready for your run...",
+ waitingForFirstStep: "> {message} | waiting for first step... ({step})",
+ resumingTraining: "Resuming training...",
+ startingTraining: "starting training...",
+ dataset: "Dataset",
+ modelWeights: "Model weights",
+ },
+ tour: {
+ guidedTour: "Guided Tour",
+ },
+ },
+} as const;
diff --git a/studio/frontend/src/i18n/locales/zh-CN.ts b/studio/frontend/src/i18n/locales/zh-CN.ts
new file mode 100644
index 0000000000..4074a5760b
--- /dev/null
+++ b/studio/frontend/src/i18n/locales/zh-CN.ts
@@ -0,0 +1,712 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+import type { DeepPartialMessageTree } from "../types";
+import type { en } from "./en";
+
+export const zhCN = {
+ common: {
+ cancel: "取消",
+ close: "关闭",
+ delete: "删除",
+ done: "完成",
+ error: "错误",
+ export: "导出",
+ help: "帮助",
+ loading: "加载中...",
+ new: "新增",
+ rename: "重命名",
+ save: "保存",
+ search: "搜索",
+ shutdown: "关闭服务",
+ },
+ shell: {
+ accountMenu: "{name} 账号菜单",
+ aria: {
+ home: "Unsloth 首页",
+ closeSidebar: "关闭侧边栏",
+ openSidebar: "打开侧边栏",
+ chatOptions: "聊天选项",
+ runOptions: "训练选项",
+ },
+ navigation: {
+ newChat: "新聊天",
+ compare: "对比",
+ search: "搜索",
+ train: "训练",
+ recipes: "配方",
+ export: "导出",
+ recents: "最近",
+ settings: "设置",
+ api: "API",
+ lightMode: "浅色模式",
+ darkMode: "深色模式",
+ guidedTour: "引导教程",
+ help: "帮助",
+ logOut: "退出登录",
+ shutdown: "关闭服务",
+ },
+ notFound: {
+ title: "页面未找到",
+ description: "{path} 不存在。",
+ backToChat: "返回聊天",
+ },
+ dialog: {
+ deleteChat: {
+ title: "删除聊天",
+ description: "确定要删除聊天“{name}”吗?",
+ },
+ deleteRun: {
+ title: "删除训练运行",
+ description: "确定要删除运行“{name}”吗?",
+ },
+ renameChat: {
+ title: "重命名聊天",
+ placeholder: "聊天标题",
+ },
+ renameRun: {
+ title: "重命名运行",
+ placeholder: "运行名称",
+ },
+ },
+ toast: {
+ cannotDeleteRunningRun: "不能删除正在运行的训练",
+ failedToDeleteChat: "删除聊天失败",
+ failedToDeleteRun: "删除运行失败",
+ failedToRenameChat: "重命名聊天失败",
+ failedToRenameRun: "重命名运行失败",
+ },
+ },
+ settings: {
+ title: "设置",
+ dialog: {
+ title: "设置",
+ description: "管理你的 Unsloth Studio 偏好设置。",
+ closeAriaLabel: "关闭设置",
+ },
+ tabs: {
+ general: "通用",
+ profile: "个人资料",
+ appearance: "外观",
+ chat: "聊天",
+ connections: "连接",
+ apiKeys: "API",
+ about: "帮助",
+ },
+ general: {
+ title: "通用",
+ description: "Unsloth Studio 的全局偏好设置。",
+ account: "账号",
+ huggingFaceToken: "Hugging Face token",
+ huggingFaceTokenDescription: "用于加载受限模型和推送产物。",
+ hideToken: "隐藏 token",
+ showToken: "显示 token",
+ chatDefaults: "聊天默认设置",
+ autoTitleNewChats: "自动为新聊天命名",
+ autoTitleNewChatsDescription: "根据第一条消息生成简短标题。",
+ gettingStarted: "入门",
+ startOnboarding: "开始引导",
+ startOnboardingDescription: "重新打开设置向导,不会更改你的账号。",
+ startOnboardingAction: "开始引导",
+ resetPreferences: {
+ sectionTitle: "危险区域",
+ label: "重置所有本地偏好设置",
+ description:
+ "清除仅保存在本地的偏好设置。聊天、API 访问权限和数据库中的聊天设置不会受到影响。",
+ action: "重置偏好设置",
+ confirmTitle: "重置所有本地偏好设置?",
+ confirmDescription:
+ "这会清除仅保存在本地的偏好设置,然后重新加载 Studio。聊天、API 访问权限和数据库中的聊天设置不会受到影响。",
+ confirmAction: "重置并重新加载",
+ },
+ },
+ profile: {
+ title: "个人资料",
+ description: "更新你在 Studio 中显示的个人资料。",
+ changePicture: "更换头像",
+ displayName: "显示名称",
+ nameSaved: "个人资料名称已保存",
+ namePersistErrorTitle: "无法持久保存个人资料名称",
+ namePersistErrorDescription:
+ "名称已在本次会话中更新,但重新加载后可能不会保留。",
+ photoUpdated: "头像已更新",
+ photoPersistErrorTitle: "无法持久保存头像",
+ photoPersistErrorDescription:
+ "头像已在本次会话中更新,但重新加载后可能不会保留。",
+ photoUpdateErrorTitle: "无法更新头像",
+ imageUseError: "无法使用这张图片。",
+ },
+ appearance: {
+ title: "外观",
+ description: "调整 Unsloth Studio 在此设备上的显示方式。",
+ language: {
+ title: "语言",
+ label: "显示语言",
+ description: "选择 Studio 使用的语言。",
+ },
+ theme: {
+ title: "主题",
+ label: "颜色主题",
+ description: "选择浅色、深色,或跟随系统。",
+ system: "跟随系统",
+ light: "浅色",
+ dark: "深色",
+ },
+ layout: {
+ title: "布局",
+ compactSidebar: "默认固定侧边栏",
+ compactSidebarDescription: "保持侧边栏展开,而不是折叠为图标。",
+ },
+ },
+ chat: {
+ title: "聊天",
+ description: "管理此设备上保存的聊天记录。",
+ data: "数据",
+ exportHistory: "导出聊天记录",
+ exportHistoryDescription: "将所有聊天和消息下载为 JSON 文件。",
+ exportAction: "导出",
+ exportingAction: "导出中...",
+ clearHistory: "清除聊天记录",
+ clearHistoryDescription: "从此设备删除本地聊天记录。",
+ clearAction: "清除",
+ clearAllChats: "清除所有聊天",
+ clearAllChatsDescription: "永久删除此设备上的每个聊天。",
+ noChatsToClear: "没有可清除的聊天。",
+ clearOneChatDescription: "永久删除此设备上的唯一一个聊天。",
+ clearChatCountDescription: "永久删除此设备上的 {count} 个聊天。",
+ clearChatsAction: "清除聊天",
+ clearOneChatTitle: "清除 1 个聊天?",
+ clearChatsTitle: "清除 {count} 个聊天?",
+ clearChatsConfirmDescription:
+ "这会永久删除此设备上保存的每个聊天和消息。此操作无法撤销。",
+ clearingAction: "清除中...",
+ clearOneChatAction: "清除 1 个聊天",
+ clearChatCountAction: "清除 {count} 个聊天",
+ clearedAllChats: "已清除所有聊天",
+ clearedOneChat: "已清除 1 个聊天",
+ clearedChatCount: "已清除 {count} 个聊天",
+ someChatsCouldNotBeCleared: "部分聊天无法清除",
+ chatsClearedRemainOne:
+ "已清除 {clearedCount} 个聊天;仍有 1 个聊天保留。请重试。",
+ chatsClearedRemain:
+ "已清除 {clearedCount} 个聊天;仍有 {remainingCount} 个聊天保留。请重试。",
+ oneChatClearedRemain:
+ "已清除 1 个聊天;仍有 {remainingCount} 个聊天保留。请重试。",
+ oneChatClearedRemainOne: "已清除 1 个聊天;仍有 1 个聊天保留。请重试。",
+ storageClearFailedOne:
+ "某个存储位置清除失败;可能仍有 1 个聊天保留。请重试。",
+ storageClearFailed:
+ "某个存储位置清除失败;可能仍有 {count} 个聊天保留。请重试。",
+ failedToClearChats: "清除聊天失败",
+ },
+ connections: {
+ title: "连接",
+ description: "管理提供方和外部服务的连接。",
+ },
+ apiKeys: {
+ title: "API",
+ description: "通过兼容 OpenAI 的 API 以编程方式访问 Unsloth。",
+ readDocs: "阅读 API 文档",
+ noAccess: "还没有 API 访问权限。",
+ newBadge: "新",
+ accessTokens: "访问 token",
+ loadError: "无法加载 API 访问权限。",
+ createError: "无法创建访问 token。",
+ revokeError: "无法撤销访问 token。",
+ never: "永不过期",
+ tokenNamePlaceholder: "Token 名称(例如 production)",
+ newAccessTokenName: "新的访问 token 名称",
+ createToken: "创建 token",
+ creating: "创建中...",
+ newTokenCreated: "新的访问 token 已创建",
+ accessTokenCopied: "访问 token 已复制",
+ copyAccessToken: "复制访问 token",
+ copyNow: "现在复制 - 之后不会再次显示。",
+ usageExamples: "使用示例",
+ usageTools: "工具",
+ copySnippet: "复制代码片段",
+ copy: "复制",
+ copied: "已复制",
+ setupDocs: "设置文档:",
+ relativeNever: "从未",
+ relativeJustNow: "刚刚",
+ relativeHoursAgo: "{count} 小时前",
+ relativeDaysAgo: "{count} 天前",
+ relativeMonthsAgo: "{count} 个月前",
+ relativeYearsAgo: "{count} 年前",
+ expired: "已过期",
+ today: "今天",
+ inDays: "{count} 天后",
+ created: "创建于 {value}",
+ used: "使用于 {value}",
+ expires: "过期时间 {value}",
+ actionsFor: "{name} 的操作",
+ copyPrefix: "复制前缀",
+ revokeToken: "撤销 token",
+ revokeTitle: "撤销访问 token \"{name}\"?",
+ revokeDescription:
+ "使用此 token 的应用会立即失去访问权限。此操作无法撤销。",
+ revokeAction: "撤销 \"{name}\"",
+ revoking: "撤销中...",
+ },
+ about: {
+ title: "帮助",
+ description: "文档、发布说明、反馈和 Studio 构建信息。",
+ studioVersion: "Studio 版本",
+ packageVersion: "包版本",
+ updates: "更新",
+ help: "帮助",
+ documentation: "文档",
+ releaseNotes: "发布说明",
+ whatsNew: "最新内容",
+ feedback: "反馈",
+ reportIssue: "报告问题",
+ dangerZone: "危险区域",
+ shutDownStudio: "关闭 Unsloth Studio",
+ shutDownStudioDescription: "停止 Studio 服务进程并结束你的会话。",
+ shutDown: "关闭",
+ update: {
+ title: "更新 Unsloth Studio",
+ openPowerShell: "打开 PowerShell 并运行:",
+ openTerminal: "打开终端并运行:",
+ commandText: "{label} 文本",
+ copied: "已复制",
+ copyCommand: "复制命令",
+ commandCopied: "{label} 已复制",
+ copyNamedCommand: "复制 {label}",
+ checkingInstall: "正在检查 Studio 的安装方式...",
+ localInstallDetected:
+ "检测到源码或本地安装。为避免替换为 PyPI 版本,请从最初安装时使用的 checkout 或源码位置更新。",
+ pullThenUpdate:
+ "从你的 Unsloth 仓库 checkout 拉取最新变更,然后本地更新 Studio:",
+ gitPullCommand: "git pull 命令",
+ localUpdateCommand: "本地更新命令",
+ localInstallerFallback:
+ "如果 Studio 更新命令不可用,请从该 checkout 运行本地安装器:",
+ localInstallerCommand: "本地安装器命令",
+ sourceInstallDetected:
+ "这看起来是源码或 VCS 包安装。请从最初使用的本地路径或 Git URL 重新安装。",
+ repoCheckoutFallback:
+ "如果你仍保留 Unsloth 仓库 checkout,请从该 checkout 运行本地安装器:",
+ restartAfterUpdate: "更新后重启 Studio,使变更生效。",
+ unknownInstall:
+ "Studio 无法检测安装方式。请先确认你如何安装 Studio,然后选择匹配的更新方式。",
+ curlOrPypi: "对于 curl 或 PyPI 安装,请运行:",
+ updateCommand: "更新命令",
+ localCheckout:
+ "对于本地 checkout 安装,请改为从该 checkout 更新并使用本地更新命令:",
+ fallbackInstruction:
+ "如果失败,或 unsloth studio update 不可用,请运行:",
+ fallbackCommand: "备用命令",
+ },
+ },
+ },
+ studio: {
+ routeTitle: "训练",
+ title: "微调工作台",
+ subtitles: {
+ configure: "配置并开始训练",
+ trainingInProgress: "训练进行中",
+ viewPastRuns: "查看历史训练",
+ viewingPastRun: "正在查看历史训练",
+ },
+ tabs: {
+ configure: "配置",
+ currentRun: "当前训练",
+ history: "历史",
+ },
+ loadingRuntime: "正在加载训练运行时...",
+ backToHistory: "返回历史",
+ sections: {
+ model: "模型",
+ dataset: "数据集",
+ params: "参数",
+ training: "训练",
+ charts: "图表",
+ progress: "训练进度",
+ },
+ configure: {
+ title: "配置",
+ description: "选择模型、数据集和训练设置。",
+ startTraining: "开始训练",
+ starting: "启动中...",
+ loadingModel: "正在加载模型...",
+ checkingDataset: "正在检查数据集...",
+ trainingConfig: "训练配置",
+ },
+ model: {
+ title: "模型",
+ description: "选择基础模型和训练方法",
+ fasterTrainingBadge: "训练速度提升 2 倍",
+ baseModel: "基础模型",
+ localModel: "本地模型",
+ localModelTooltip: "本地已下载模型的路径,或自定义 HF 仓库。",
+ scanningLocalAndCachedModels: "正在扫描本地和缓存模型...",
+ scanning: "正在扫描...",
+ scanningLocalModels: "正在扫描本地模型...",
+ noLocalModelsFound: "未找到本地模型",
+ noLocalModelsFoundManual: "未找到本地模型。请手动输入路径。",
+ failedToLoadLocalModels: "加载本地模型失败",
+ hfCache: "HF 缓存",
+ customFolders: "自定义文件夹",
+ localDir: "本地目录",
+ huggingFaceModel: "Hugging Face 模型",
+ huggingFaceModelTooltip: "搜索 Hugging Face 模型,或从推荐列表中选择。",
+ searchModels: "搜索模型...",
+ searching: "搜索中...",
+ noModelsFound: "未找到模型",
+ needsVram: "约需 {vram}GB 显存(GPU:{gpu}GB)",
+ tightVram: "约 {vram}GB 显存(在 {gpu}GB 上偏紧)",
+ vramEstimate: "约 {vram}GB 显存",
+ method: "方法",
+ methodTooltip:
+ "QLoRA 使用 4 位量化以最大限度降低显存。LoRA 使用 16 位。Full 会更新所有权重。CPT(持续预训练)在原始文本上训练,使模型适配新领域,不使用聊天格式。",
+ readMore: "了解更多",
+ fullFineTune: "全量微调",
+ checkingToken: "正在检查 token...",
+ getOrUpdateToken: "获取或更新 token",
+ huggingFaceTokenOptional: "Hugging Face Token(可选)",
+ continuedPretraining: "持续预训练",
+ localModels: "本地模型",
+ localModelsFound: "找到 {count} 个本地/缓存模型",
+ loadingLocalModels: "正在加载本地模型...",
+ },
+ dataset: {
+ title: "数据集",
+ description: "选择或上传训练数据",
+ source: "数据集来源",
+ chooseDataset: "选择数据集",
+ chooseDatasetTooltip:
+ "通过弹出标签切换 Hugging Face 与本地数据配方输出。",
+ localTab: "本地",
+ searchHuggingFaceDatasets: "搜索 Hugging Face 数据集...",
+ searchLocalDatasets: "搜索本地数据集...",
+ searching: "搜索中...",
+ noDatasetsFound: "未找到数据集",
+ loadingLocalDatasets: "正在加载本地数据集...",
+ failedToLoadLocalDatasets: "加载本地数据集失败。",
+ noLocalDatasetsYet: "还没有本地数据集。",
+ noLocalDatasetsMatchSearch: "没有本地数据集匹配搜索。",
+ openDataRecipes: "打开数据配方",
+ browsingSource: "正在浏览 {browsing}。当前选择仍保持为 {current}。",
+ localDatasets: "本地数据集",
+ localDataset: "本地数据集",
+ localDatasetRows: " / {count} 行",
+ huggingFaceDataset: "Hugging Face 数据集",
+ localDatasetMetadata: "本地数据集元数据",
+ dataRecipeOutput: "数据配方输出。",
+ rows: "行",
+ columns: "列",
+ batches: "批次",
+ updated: "更新时间",
+ evalDataset: "评估数据集",
+ uploading: "上传中...",
+ upload: "上传",
+ uploadEvalFile: "上传评估文件",
+ evalDatasetDescription:
+ "可选。如果未提供,将从训练数据中切分出一小部分。",
+ advanced: "高级",
+ targetFormat: "目标格式",
+ targetFormatTooltip:
+ "训练数据的格式。自动检测对大多数数据集都有效。",
+ auto: "自动",
+ rawText: "原始文本",
+ trainSplitStart: "训练切分起始",
+ trainSplitStartTooltip:
+ "通过指定起始行索引(含,从 0 开始)仅在训练切分的子集上训练。留空则从第一行开始。",
+ trainSplitEnd: "训练切分结束",
+ trainSplitEndTooltip:
+ "训练切分中包含的最后一行索引(含,从 0 开始)。例如将起始设为 0、结束设为 99,可在前 100 行上训练。留空则使用所有剩余行。",
+ endPlaceholder: "结束",
+ clear: "清除",
+ dropFileOrClick: "拖放 1 个文件到此处,或点击上传",
+ viewDataset: "查看数据集",
+ uploadFailed: "上传失败",
+ unknownError: "未知错误",
+ unsupportedFileType: "不支持的文件类型",
+ uploadOneFileType: "上传一个 {types} 文件。",
+ datasetUploaded: "数据集已上传",
+ evalDatasetUploaded: "评估数据集已上传",
+ uploadOneFileAtATime: "一次只能上传一个文件",
+ uploadSingleFileDescription: "训练数据集上传只接受单个文件。",
+ checkingToken: "正在检查 token...",
+ getOrUpdateToken: "获取或更新 token",
+ preview: "预览数据集",
+ split: "切分",
+ subset: "子集",
+ },
+ params: {
+ title: "参数",
+ description: "配置训练超参数",
+ loraSettings: "LoRA 设置",
+ trainingHyperparameters: "训练超参数",
+ maxSteps: "最大步数",
+ epochs: "轮数",
+ useMaxSteps: "使用最大步数",
+ useEpochs: "使用轮数",
+ maxStepsTooltip: "覆盖优化器总步数。",
+ epochsTooltip: "完整遍历数据集的次数。",
+ epochsDescription: "每个 epoch 是对数据集的一次完整遍历。",
+ maxStepsDescription: "将训练限制为固定数量的优化器步数。",
+ contextLength: "上下文长度",
+ contextLengthTooltip: "每个训练样本的最大 token 数。",
+ customContextLength: "输入自定义值",
+ contextLengthDescription: "训练样本的最大序列长度",
+ learningRate: "学习率",
+ learningRateTooltip: "权重更新步长。较低的值训练更慢但更稳定。",
+ learningRateDescription:
+ "推荐值:LoRA 用 2e-4,CPT 用 5e-5,全量微调用 2e-5",
+ embeddingLearningRate: "Embedding 学习率",
+ embeddingLearningRateTooltip:
+ "仅在 CPT 训练 embed_tokens 时使用。Embedding 比 LoRA 权重更易失稳,通常需要更小的学习率。留空则使用 lr/10;常用区间是比主学习率小 2 至 10 倍。仅在词表或领域 token 适配过慢时才提高。",
+ embeddingLearningRateDescription:
+ "留空使用 lr/10(推荐)。常用区间是比主学习率小 2 至 10 倍。",
+ rank: "Rank",
+ rankTooltip: "低秩矩阵的维度。越高容量越大。",
+ alpha: "Alpha",
+ alphaTooltip: "LoRA 更新的缩放因子。通常为 Rank 的 2 倍。",
+ dropout: "Dropout",
+ dropoutTooltip: "LoRA 层的 dropout 概率,用于减少过拟合。",
+ visionLayers: "视觉层",
+ languageLayers: "语言层",
+ attentionModules: "注意力模块",
+ mlpModules: "MLP 模块",
+ targetModules: "目标模块",
+ enableLora: "启用 LoRA",
+ trainWithLora: "使用 LoRA 训练",
+ stableRank: "稳定 Rank",
+ memoryEfficient: "节省内存",
+ optimization: "优化",
+ schedule: "计划",
+ memory: "内存",
+ optimizer: "优化器",
+ optimizerTooltip:
+ "优化算法。8 位变体可降低内存占用。对视觉模型推荐 Fused。",
+ lrScheduler: "LR 调度器",
+ lrSchedulerTooltip:
+ "学习率随训练变化的方式。Linear 平稳衰减;Cosine 曲线衰减。",
+ optimizerOptions: {
+ adamw8bit: "AdamW 8-bit",
+ pagedAdamw8bit: "Paged AdamW 8-bit",
+ adamwBnb8bit: "AdamW BNB 8-bit",
+ pagedAdamw32bit: "Paged AdamW 32-bit",
+ adamwTorch: "AdamW(PyTorch)",
+ adamwTorchFused: "AdamW(PyTorch Fused)",
+ },
+ lrSchedulerOptions: {
+ linear: "线性",
+ cosine: "余弦",
+ },
+ batchSize: "批大小",
+ batchSizeTooltip: "每步处理的样本数。越高占用越多显存。",
+ gradAccum: "梯度累积",
+ gradAccumTooltip: "在不增加显存的情况下模拟更大的批大小。",
+ weightDecay: "权重衰减",
+ weightDecayTooltip: "L2 正则化,用于防止过拟合。",
+ warmupSteps: "预热步数",
+ warmupStepsTooltip: "在训练开始时逐步提高学习率,提升稳定性。",
+ scheduleEpochsTooltip:
+ "完整遍历数据集的次数。设为 0 则按最大步数运行。",
+ saveSteps: "保存步数",
+ saveStepsTooltip: "每 N 步保存一次检查点。0 表示禁用。",
+ evalSteps: "评估步数",
+ evalStepsTooltip:
+ "评估之间间隔占总训练步数的比例(0-1)。设为 0 则禁用评估。例如 0.01 = 每 1% 步评估一次。",
+ seed: "随机种子",
+ seedTooltip: "用于复现的随机种子。",
+ gradCheckpoint: "梯度检查点",
+ gradCheckpointTooltip: "通过重算激活以时间换显存。",
+ none: "无",
+ standard: "标准",
+ enablePacking: "启用 packing",
+ assistantCompletionsOnly: "仅助手回复",
+ readMore: "了解更多",
+ },
+ training: {
+ title: "训练",
+ description: "监控和控制训练",
+ chartNoDataTitle: "暂无训练数据",
+ chartNoDataDescription: "开始训练后可查看 loss 进度",
+ startTraining: "开始训练",
+ starting: "启动中...",
+ loadingModel: "正在加载模型...",
+ checkingDataset: "正在检查数据集...",
+ configLabel: "训练配置",
+ upload: "上传",
+ uploadConfigTooltip: "加载已保存的 YAML 配置",
+ save: "保存",
+ saveConfigTooltip: "将当前配置下载为 YAML",
+ reset: "重置",
+ resetConfigTooltip: "重置为模型默认值",
+ configLoaded: "配置已加载",
+ failedToLoadConfig: "加载配置失败",
+ invalidYamlFile: "无效的 YAML 文件",
+ failedToReadFile: "读取文件失败",
+ parametersReset: "参数已重置为模型默认值",
+ audioIncompatible:
+ "该模型不支持音频。请切换到支持音频的模型,或选择非音频数据集。",
+ visionIncompatible:
+ "文本模型与多模态数据集不兼容。请切换到视觉模型,或选择纯文本数据集。",
+ cancelTitle: "取消训练",
+ cancelDescription: "要取消当前训练运行吗?",
+ continueAction: "继续训练",
+ cancelAction: "取消训练",
+ stopTitle: "停止训练",
+ stopDescription: "选择如何停止当前训练运行。",
+ stopAction: "停止",
+ stopping: "停止中...",
+ stopAndSave: "停止并保存",
+ compareInChat: "在聊天中对比",
+ exportModel: "导出模型",
+ milestone: "里程碑",
+ halfwayDone: "已完成一半。训练进度超过 50%。",
+ doneNextStep: "训练完成。下一步:对比基础模型和微调模型的输出。",
+ },
+ history: {
+ title: "历史",
+ emptyTitle: "还没有训练运行",
+ emptyDescription: "还没有训练运行。请在配置标签页开始第一次训练。",
+ loadError: "加载训练运行失败",
+ deleteError: "删除训练运行失败。请重试。",
+ retry: "重试",
+ loadMore: "加载更多",
+ loading: "加载中...",
+ loadingRun: "正在加载训练运行...",
+ runNotFound: "未找到运行",
+ deleteTitle: "删除训练运行?",
+ deleteDescription: "这会永久删除该训练运行及其所有指标。此操作无法撤销。",
+ runCount: "{count} 次运行",
+ oneRun: "1 次运行",
+ resume: "继续",
+ resumeTraining: "继续训练",
+ resuming: "继续中...",
+ deleteRun: "删除运行",
+ loss: "Loss",
+ steps: "步数",
+ lossTrendSparkline: "Loss 趋势迷你图",
+ relativeJustNow: "刚刚",
+ relativeMinutesAgo: "{count} 分钟前",
+ relativeHoursAgo: "{count} 小时前",
+ relativeDaysAgo: "{count} 天前",
+ status: {
+ completed: "已完成",
+ stopped: "已停止",
+ error: "错误",
+ running: "运行中",
+ continued: "已继续",
+ },
+ message: {
+ completed: "训练已完成",
+ stopped: "训练已停止",
+ running: "训练进行中",
+ errored: "训练出错",
+ },
+ },
+ charts: {
+ settings: "图表设置",
+ settingsDescription: "训练运行时调整图表显示。",
+ openSettings: "打开图表设置",
+ viewWindow: "查看窗口",
+ viewWindowDescription: "只显示最新步数或完整历史。",
+ window: "窗口",
+ all: "全部",
+ trainingLoss: "训练损失",
+ trainingLossDescription: "控制覆盖线和 EMA 平滑。",
+ smoothing: "平滑",
+ smoothingDescription: "向右移动可增加平滑度。`0` = 原始值。",
+ showRawLoss: "显示原始 loss",
+ showSmoothedLoss: "显示平滑 loss",
+ showAverageLine: "显示平均线",
+ scaleAndCleanup: "比例和清理",
+ linear: "线性",
+ log: "对数",
+ noClip: "不裁剪",
+ clipP99: "裁剪 p99",
+ clipP95: "裁剪 p95",
+ lossAxis: "损失轴",
+ gradientNormAxis: "梯度范数轴",
+ learningRateAxis: "学习率轴",
+ resetDefaults: "恢复默认值",
+ loss: "Loss",
+ smoothed: "平滑",
+ evalLoss: "评估 Loss",
+ learningRate: "学习率",
+ lr: "LR",
+ gradNorm: "梯度范数",
+ gradientNorm: "梯度范数",
+ step: "步数 {step}",
+ averageValue: "平均 {value}",
+ waitingForFirstEvaluationStep: "等待首次评估步...",
+ evaluationNotConfigured: "未配置评估",
+ evalChartWillAppear: "达到 eval_steps 后会显示图表",
+ setEvalDatasetAndSteps: "设置评估数据集和 eval_steps 以追踪评估 loss",
+ },
+ progress: {
+ title: "训练进度",
+ liveMetrics: "实时训练指标",
+ openConfig: "打开训练配置",
+ configLabel: "训练配置",
+ hyperparams: "超参数",
+ epochs: "轮数",
+ batchSize: "批大小",
+ learningRate: "学习率",
+ optimizer: "优化器",
+ maxSteps: "最大步数",
+ contextLength: "上下文长度",
+ warmupSteps: "预热步数",
+ rank: "Rank",
+ alpha: "Alpha",
+ dropout: "Dropout",
+ variant: "变体",
+ epoch: "Epoch {value}",
+ percentComplete: "完成 {percent}%",
+ stepProgress: "步数 {current} / {total}",
+ loss: "Loss",
+ lr: "LR",
+ gradNorm: "梯度范数",
+ model: "模型",
+ method: "方法",
+ elapsed: "已用时间:{value}",
+ eta: "ETA:{value}",
+ stepsPerSecond: "{value} 步/秒",
+ noStepsPerSecond: "-- 步/秒",
+ tokens: "Tokens:{value}",
+ gpuMonitor: "GPU 监控",
+ live: "实时",
+ utilization: "利用率",
+ temperature: "温度",
+ vram: "VRAM",
+ power: "功耗",
+ phase: {
+ idle: "空闲",
+ downloadingModel: "正在下载模型",
+ downloadingDataset: "正在下载数据集",
+ loadingModel: "正在加载模型",
+ loadingDataset: "正在加载数据集",
+ configuring: "配置中",
+ training: "训练中",
+ completed: "已完成",
+ error: "错误",
+ stopped: "已停止",
+ },
+ },
+ trainingStart: {
+ ready: "就绪",
+ downloading: "下载中",
+ preparing: "准备中",
+ left: "剩余 {eta}",
+ downloaded: "已下载 {size}",
+ terminalStart: "> Unsloth 训练开始...",
+ preparingResources: "> 正在准备模型和数据集...",
+ gettingReady: "> 正在为本次运行做好准备...",
+ waitingForFirstStep: "> {message} | 等待第一步...({step})",
+ resumingTraining: "正在继续训练...",
+ startingTraining: "正在开始训练...",
+ dataset: "数据集",
+ modelWeights: "模型权重",
+ },
+ tour: {
+ guidedTour: "引导教程",
+ },
+ },
+} satisfies DeepPartialMessageTree;
diff --git a/studio/frontend/src/i18n/messages.ts b/studio/frontend/src/i18n/messages.ts
new file mode 100644
index 0000000000..e75faf697a
--- /dev/null
+++ b/studio/frontend/src/i18n/messages.ts
@@ -0,0 +1,76 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+import { getLocale } from "./locale-store";
+import { en } from "./locales/en";
+import { zhCN } from "./locales/zh-CN";
+import type { InterpolationValues, MessageKey } from "./types";
+
+export const LOCALES = {
+ en: { label: "English", nativeLabel: "English" },
+ "zh-CN": { label: "Chinese (Simplified)", nativeLabel: "简体中文" },
+} as const;
+
+export type Locale = keyof typeof LOCALES;
+export type TranslationKey = MessageKey;
+
+export const messages = { en, "zh-CN": zhCN } as const;
+
+const PLACEHOLDER_PATTERN = /\{([a-zA-Z0-9_]+)\}/g;
+
+function readMessage(tree: unknown, key: string): string | undefined {
+ let cursor = tree;
+ for (const segment of key.split(".")) {
+ if (
+ cursor === null ||
+ typeof cursor !== "object" ||
+ !Object.prototype.hasOwnProperty.call(cursor, segment)
+ ) {
+ return undefined;
+ }
+ cursor = (cursor as Record)[segment];
+ }
+ return typeof cursor === "string" ? cursor : undefined;
+}
+
+function interpolate(
+ template: string,
+ values: InterpolationValues | undefined,
+): string {
+ if (!values) return template;
+
+ return template.replace(PLACEHOLDER_PATTERN, (match, name: string) => {
+ if (!Object.prototype.hasOwnProperty.call(values, name)) return match;
+ const value = values[name];
+ return value === null || value === undefined ? "" : String(value);
+ });
+}
+
+function warnMissingEnglishMessage(key: string): void {
+ if (import.meta.env.DEV) {
+ console.warn(`[i18n] Missing English translation for key "${key}".`);
+ }
+}
+
+export function translate(
+ key: TranslationKey,
+ values?: InterpolationValues,
+ locale: Locale = getLocale(),
+): string {
+ const localized = readMessage(messages[locale], key);
+ const fallback = localized ?? readMessage(messages.en, key);
+
+ if (fallback === undefined) {
+ warnMissingEnglishMessage(key);
+ return key;
+ }
+
+ return interpolate(fallback, values);
+}
+
+export function isSupportedLocale(value: unknown): value is Locale {
+ return (
+ typeof value === "string" &&
+ Object.prototype.hasOwnProperty.call(LOCALES, value)
+ );
+}
diff --git a/studio/frontend/src/i18n/types.ts b/studio/frontend/src/i18n/types.ts
new file mode 100644
index 0000000000..706c3fb1d3
--- /dev/null
+++ b/studio/frontend/src/i18n/types.ts
@@ -0,0 +1,30 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+export type MessageTree = {
+ readonly [key: string]: string | MessageTree;
+};
+
+export type DeepPartialMessageTree = {
+ readonly [K in keyof T]?: T[K] extends string
+ ? string
+ : T[K] extends MessageTree
+ ? DeepPartialMessageTree
+ : never;
+};
+
+type Join =
+ Prefix extends "" ? Key : `${Prefix}.${Key}`;
+
+export type MessageKey = {
+ [K in Extract]: T[K] extends string
+ ? Join
+ : T[K] extends MessageTree
+ ? MessageKey>
+ : never;
+}[Extract];
+
+export type InterpolationValues = Record<
+ string,
+ string | number | boolean | null | undefined
+>;
diff --git a/studio/frontend/src/main.tsx b/studio/frontend/src/main.tsx
index 0db37fdb6e..a924c1652e 100644
--- a/studio/frontend/src/main.tsx
+++ b/studio/frontend/src/main.tsx
@@ -7,6 +7,7 @@ import { createRoot } from "react-dom/client";
import "./index.css";
import { fetchDeviceType } from "./config/env";
import { App } from "./app/app";
+import { initializeLocale } from "./i18n";
const globalCrypto = globalThis.crypto as Crypto | undefined;
@@ -33,6 +34,8 @@ if (!rootElement) {
throw new Error("Root element not found");
}
+initializeLocale();
+
fetchDeviceType().then(() => {
createRoot(rootElement).render(
diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py
index 7672af6630..394a1c9cd8 100644
--- a/studio/install_llama_prebuilt.py
+++ b/studio/install_llama_prebuilt.py
@@ -1349,6 +1349,24 @@ def direct_upstream_release_plan(
install_kind = "windows-cpu",
)
)
+ elif host.is_windows and host.is_arm64:
+ # Upstream ggml-org/llama.cpp ships llama-bNNNN-bin-win-cpu-arm64.zip
+ # (visible in the b9334 release manifest). Without this branch the
+ # selector returned 0 attempts and the installer fell back to a
+ # source build on every Windows ARM64 host.
+ cpu_asset = f"llama-{release_tag}-bin-win-cpu-arm64.zip"
+ cpu_url = assets.get(cpu_asset)
+ if cpu_url:
+ attempts.append(
+ AssetChoice(
+ repo = repo,
+ tag = release_tag,
+ name = cpu_asset,
+ url = cpu_url,
+ source_label = "upstream",
+ install_kind = "windows-arm64",
+ )
+ )
elif host.is_macos and host.is_arm64:
asset_name = f"llama-{release_tag}-bin-macos-arm64.tar.gz"
asset_url = assets.get(asset_name)
@@ -1391,6 +1409,25 @@ def direct_upstream_release_plan(
install_kind = "linux-cpu",
)
)
+ elif host.is_linux and host.is_arm64 and not host.has_usable_nvidia:
+ # Upstream ggml-org/llama.cpp ships llama-bNNNN-bin-ubuntu-arm64.tar.gz
+ # (visible in the b9334 release manifest). Without this branch the
+ # selector returned 0 attempts and the installer fell back to a
+ # source build on every Linux ARM64 host (DGX Spark, Ampere
+ # Altra, GitHub-hosted ubuntu-24.04-arm runners, etc.).
+ asset_name = f"llama-{release_tag}-bin-ubuntu-arm64.tar.gz"
+ asset_url = assets.get(asset_name)
+ if asset_url:
+ attempts.append(
+ AssetChoice(
+ repo = repo,
+ tag = release_tag,
+ name = asset_name,
+ url = asset_url,
+ source_label = "upstream",
+ install_kind = "linux-arm64",
+ )
+ )
if not attempts:
raise PrebuiltFallback("no compatible upstream prebuilt asset was found")
return InstallReleasePlan(
@@ -3833,11 +3870,16 @@ def runtime_patterns_for_choice(choice: AssetChoice) -> list[str]:
# libraries between b9279 and b9283) without us re-enumerating
# every new file. Studio only invokes llama-server and llama-quantize;
# other CLIs upstream ships (llama-cli, llama-bench, ...) are skipped.
- if choice.install_kind in {"linux-cpu", "linux-cuda", "linux-rocm"}:
+ if choice.install_kind in {"linux-cpu", "linux-cuda", "linux-rocm", "linux-arm64"}:
return ["llama-server", "llama-quantize", "lib*.so*"]
if choice.install_kind in {"macos-arm64", "macos-x64"}:
return ["llama-server", "llama-quantize", "lib*.dylib"]
- if choice.install_kind in {"windows-cpu", "windows-cuda", "windows-hip"}:
+ if choice.install_kind in {
+ "windows-cpu",
+ "windows-cuda",
+ "windows-hip",
+ "windows-arm64",
+ }:
return ["llama-server.exe", "llama-quantize.exe", "*.dll"]
raise PrebuiltFallback(
f"unsupported install kind for runtime overlay: {choice.install_kind}"
@@ -5188,7 +5230,7 @@ def load_prebuilt_metadata(install_dir: Path) -> dict[str, Any] | None:
def runtime_payload_health_groups(choice: AssetChoice) -> list[list[str]]:
- if choice.install_kind == "linux-cpu":
+ if choice.install_kind in {"linux-cpu", "linux-arm64"}:
return [
["libllama-common.so*"],
["libllama.so*"],
@@ -5223,7 +5265,7 @@ def runtime_payload_health_groups(choice: AssetChoice) -> list[list[str]]:
["libmtmd.so*"],
["libggml-hip.so*"],
]
- if choice.install_kind == "windows-cpu":
+ if choice.install_kind in {"windows-cpu", "windows-arm64"}:
return [["llama.dll"]]
if choice.install_kind == "windows-cuda":
groups = [["llama.dll"], ["ggml-cuda.dll"]]
@@ -5298,6 +5340,15 @@ def existing_install_matches_choice(
for binary in ("llama-server", "llama-quantize"):
if not (runtime_dir / f"{binary}{ext}").exists():
return False
+ if host.is_linux:
+ try:
+ preflight_linux_installed_binaries(
+ [runtime_dir / "llama-server", runtime_dir / "llama-quantize"],
+ install_dir,
+ host,
+ )
+ except Exception:
+ return False
expected_fingerprint = expected_install_fingerprint(
llama_tag = llama_tag,
release_tag = release_tag,
diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py
index ca7fe3f004..c4524b06f5 100644
--- a/studio/install_python_stack.py
+++ b/studio/install_python_stack.py
@@ -38,6 +38,18 @@ IS_WINDOWS = sys.platform == "win32"
IS_MACOS = sys.platform == "darwin"
IS_MAC_INTEL = IS_MACOS and platform.machine() == "x86_64"
IS_MAC_ARM = IS_MACOS and platform.machine() == "arm64"
+IS_LINUX = sys.platform.startswith("linux")
+# torchcodec ships wheels only for manylinux_2_28_x86_64,
+# macosx_12_0_arm64, and win_amd64 (visible in the 0.10.0 PyPI page).
+# Trying to install it on any other host fails the whole
+# extras-no-deps step. `unsloth studio update` does not have a
+# --no-torch flag, so on these hosts the audio extras must be
+# filtered out independent of the NO_TORCH env var.
+PLATFORM_LACKS_TORCHCODEC_WHEEL = (
+ (IS_LINUX and platform.machine() in {"aarch64", "arm64"})
+ or (IS_WINDOWS and platform.machine().lower() in {"arm64", "aarch64"})
+ or IS_MAC_INTEL
+)
# ── ROCm / AMD GPU support ─────────────────────────────────────────────────────
# Mapping from detected ROCm (major, minor) to the best PyTorch wheel tag on
@@ -604,7 +616,14 @@ WINDOWS_SKIP_PACKAGES = {"open_spiel", "triton_kernels"}
# Packages to skip when torch is unavailable (Intel Mac GGUF-only mode).
# These packages either *are* torch extensions or have unconditional
# ``Requires-Dist: torch`` in their published metadata, so installing
-# them would pull torch back into the environment.
+# them would pull torch back into the environment. ``librosa`` also
+# lives in this set even though it does not itself require torch:
+# upstream ``llvmlite`` dropped its macOS x86_64 wheel between 0.42.0
+# and 0.46.0+ (see https://pypi.org/project/llvmlite/0.47.0/#files --
+# only macosx_arm64 / manylinux / win_amd64 remain), so on Intel Mac
+# the librosa -> numba -> llvmlite chain triggers a from-source build
+# that fails inside CI and on the host without LLVM 14/15 headers.
+# Tracked separately in unslothai/unsloth#5046.
NO_TORCH_SKIP_PACKAGES = {
"torch-stoi",
"timm",
@@ -612,6 +631,7 @@ NO_TORCH_SKIP_PACKAGES = {
"torch-c-dlpack-ext",
"openai-whisper",
"transformers-cfg",
+ "librosa",
}
@@ -839,6 +859,14 @@ def pip_install(
if actual_req is not None and NO_TORCH and NO_TORCH_SKIP_PACKAGES:
actual_req = _filter_requirements(actual_req, NO_TORCH_SKIP_PACKAGES)
temp_reqs.append(actual_req)
+ if actual_req is not None and PLATFORM_LACKS_TORCHCODEC_WHEEL:
+ # Linux aarch64 / Windows ARM64 / Intel Mac have no torchcodec
+ # wheel. `unsloth studio update --local` does not pass
+ # --no-torch, so the NO_TORCH filter above does not fire; do
+ # the targeted skip independently so the audio extras step
+ # does not take down the whole update.
+ actual_req = _filter_requirements(actual_req, {"torchcodec"})
+ temp_reqs.append(actual_req)
req_args_pip: list[str] = []
req_args_uv: list[str] = []
if actual_req is not None:
diff --git a/studio/setup.sh b/studio/setup.sh
index c5beb7ebd3..e8fc8f6f13 100755
--- a/studio/setup.sh
+++ b/studio/setup.sh
@@ -671,6 +671,16 @@ elif [ "$_HOST_SYSTEM" = "Linux" ] \
&& [ "$_HOST_MACHINE" = "x86_64" ] \
&& [ "$_LINUX_HAS_GPU" = false ]; then
_HELPER_RELEASE_REPO="ggml-org/llama.cpp"
+elif [ "$_HOST_SYSTEM" = "Linux" ] \
+ && { [ "$_HOST_MACHINE" = "aarch64" ] || [ "$_HOST_MACHINE" = "arm64" ]; } \
+ && [ "$_LINUX_HAS_GPU" = false ]; then
+ # Linux ARM64 (Ampere Altra, Raspberry Pi 5, GitHub `ubuntu-24.04-arm`,
+ # CPU-only Jetson rescue mode, ...). unslothai/llama.cpp only ships
+ # the Linux CUDA bundles, so without this branch the prebuilt
+ # resolver returns 0 attempts on every release and the installer
+ # falls all the way back to a source build. Upstream ggml-org ships
+ # llama-bNNNN-bin-ubuntu-arm64.tar.gz from at least b9072 onward.
+ _HELPER_RELEASE_REPO="ggml-org/llama.cpp"
else
_HELPER_RELEASE_REPO="unslothai/llama.cpp"
fi
diff --git a/tests/studio/_playwright_robust.py b/tests/studio/_playwright_robust.py
index 3deeb38cda..b190b2b3e1 100644
--- a/tests/studio/_playwright_robust.py
+++ b/tests/studio/_playwright_robust.py
@@ -436,6 +436,8 @@ def evaluate_fetch(
headers: dict[str, str] | None = None,
body: Any = None,
timeout_ms: int = 20_000,
+ transport_retries: int = 2,
+ transport_backoff_ms: int = 250,
) -> dict[str, Any]:
"""Run `fetch(url, opts)` inside the page with an AbortSignal deadline.
@@ -482,16 +484,47 @@ def evaluate_fetch(
}
}
"""
- return page.evaluate(
- js,
- {
- "url": url,
- "method": method,
- "headers": headers or {},
- "body": body_arg,
- "timeoutMs": int(timeout_ms),
- },
- )
+ payload = {
+ "url": url,
+ "method": method,
+ "headers": headers or {},
+ "body": body_arg,
+ "timeoutMs": int(timeout_ms),
+ }
+ # Bounded retry on transport failures only.
+ # status != 0 -> real HTTP response (incl. 4xx/5xx); propagate.
+ # AbortError -> caller's deadline; propagate.
+ # else (==0) -> stale-keepalive / "TypeError: Failed to fetch"
+ # on macos-14 right after auth rotations close
+ # existing sessions. Retry after backoff so the
+ # browser pool evicts the dead socket.
+ last: dict[str, Any] | None = None
+ attempts = max(1, int(transport_retries) + 1)
+ for attempt in range(attempts):
+ result = page.evaluate(js, payload)
+ last = result
+ try:
+ status = int(result.get("status") or 0)
+ except (TypeError, ValueError):
+ status = 0
+ if status != 0:
+ return result
+ err = str(result.get("error") or "")
+ if "AbortError" in err:
+ return result
+ if attempt < attempts - 1:
+ wait_ms = transport_backoff_ms * (2**attempt)
+ try:
+ sys.stderr.write(
+ f"[evaluate_fetch] {method} {url}: transport failure "
+ f"({attempt + 1}/{attempts}, err={err!r}); "
+ f"retrying in {wait_ms}ms\n"
+ )
+ sys.stderr.flush()
+ except Exception:
+ pass
+ time.sleep(wait_ms / 1000.0)
+ return last or {"status": 0, "body": None, "error": "no attempt made"}
# ─────────────────────────────────────────────────────────────────────
diff --git a/tests/studio/install/test_install_llama_prebuilt_logic.py b/tests/studio/install/test_install_llama_prebuilt_logic.py
index ed988e081f..49ad6da177 100644
--- a/tests/studio/install/test_install_llama_prebuilt_logic.py
+++ b/tests/studio/install/test_install_llama_prebuilt_logic.py
@@ -2799,6 +2799,97 @@ def test_runtime_overlay_cannot_overwrite_main_archive_payload(
assert (release_dir / name).exists(), f"missing {name}"
+def test_linux_runtime_overlay_copies_llama_tool_impl_libraries(
+ tmp_path: Path,
+) -> None:
+ install_from_archives = INSTALL_LLAMA_PREBUILT.install_from_archives
+
+ work = tmp_path / "work"
+ install = tmp_path / "install"
+ archives = tmp_path / "archives"
+ work.mkdir()
+ install.mkdir()
+ archives.mkdir()
+
+ bundle = archives / "app-b9334-linux-x64-cuda13-newer.tar.gz"
+ with tarfile.open(bundle, "w:gz") as archive:
+ for name in (
+ "llama-cli",
+ "llama-server",
+ "llama-quantize",
+ "libllama-cli-impl.so",
+ "libllama-server-impl.so",
+ "libllama-quantize-impl.so",
+ "libllama-common.so",
+ "libllama.so",
+ "libggml.so",
+ "libggml-base.so",
+ "libmtmd.so",
+ "libggml-cpu-x64.so",
+ "libggml-cuda.so",
+ ):
+ payload = f"{name}\n".encode()
+ member = tarfile.TarInfo(name)
+ member.size = len(payload)
+ archive.addfile(member, io.BytesIO(payload))
+
+ import hashlib
+ import shutil as _shutil
+
+ bundle_sha = hashlib.sha256(bundle.read_bytes()).hexdigest()
+ choice = AssetChoice(
+ repo = "unslothai/llama.cpp",
+ tag = "b9334",
+ name = bundle.name,
+ url = f"https://example.com/{bundle.name}",
+ source_label = "published",
+ install_kind = "linux-cuda",
+ runtime_line = "cuda13",
+ expected_sha256 = bundle_sha,
+ )
+ host = HostInfo(
+ system = "Linux",
+ machine = "x86_64",
+ is_windows = False,
+ is_linux = True,
+ is_macos = False,
+ is_x86_64 = True,
+ is_arm64 = False,
+ nvidia_smi = None,
+ driver_cuda_version = (13, 0),
+ compute_caps = [],
+ visible_cuda_devices = None,
+ has_physical_nvidia = True,
+ has_usable_nvidia = True,
+ )
+
+ orig_download = INSTALL_LLAMA_PREBUILT.download_file_verified
+
+ def fake_download(url, target_path, *, expected_sha256 = None, label = None, **kw):
+ _shutil.copy2(bundle, target_path)
+ if expected_sha256:
+ actual = hashlib.sha256(Path(target_path).read_bytes()).hexdigest()
+ if actual != expected_sha256:
+ raise INSTALL_LLAMA_PREBUILT.PrebuiltFallback(
+ f"sha256 mismatch on {label}"
+ )
+
+ INSTALL_LLAMA_PREBUILT.download_file_verified = fake_download
+ try:
+ install_from_archives(choice, host, install, work)
+ finally:
+ INSTALL_LLAMA_PREBUILT.download_file_verified = orig_download
+
+ runtime_dir = install / "build" / "bin"
+ for name in (
+ "libllama-cli-impl.so",
+ "libllama-server-impl.so",
+ "libllama-quantize-impl.so",
+ ):
+ assert (runtime_dir / name).exists(), f"missing {name}"
+ assert not (runtime_dir / "llama-cli").exists()
+
+
def test_python_runtime_dirs_covers_cu13_and_library_bin(
monkeypatch, tmp_path: Path
) -> None:
diff --git a/tests/studio/run_real_mlx_smoke.py b/tests/studio/run_real_mlx_smoke.py
index 27f682ee4e..dc3001fd99 100644
--- a/tests/studio/run_real_mlx_smoke.py
+++ b/tests/studio/run_real_mlx_smoke.py
@@ -390,7 +390,11 @@ def cmd_train(args) -> int:
)
if k in train_result
}
- assert len(losses_per_step) == 7, f"expected 7 logged steps, got {losses_per_step}"
+ # logging_steps=1 + max_steps=N -> N callbacks; track config so the
+ # gate auto-follows if max_steps is bumped again.
+ assert (
+ len(losses_per_step) == config.max_steps
+ ), f"expected {config.max_steps} logged steps, got {losses_per_step}"
for i, l in enumerate(losses_per_step):
# Allow exact 0.0: fp16 per-step loss underflows to 0.0 after
# the LoRA reaches loss=0 around step ~10 with this fixture +
diff --git a/tests/test_tool_mask_zoo_compat.py b/tests/test_tool_mask_zoo_compat.py
new file mode 100644
index 0000000000..b6b68a7561
--- /dev/null
+++ b/tests/test_tool_mask_zoo_compat.py
@@ -0,0 +1,103 @@
+"""Compatibility checks for env/tool mask support with older unsloth_zoo."""
+
+from __future__ import annotations
+
+import ast
+import os
+import textwrap
+
+import pytest
+import torch
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
+RL_SOURCE_PATH = os.path.join(REPO_ROOT, "unsloth", "models", "rl.py")
+RL_REPLACEMENTS_SOURCE_PATH = os.path.join(
+ REPO_ROOT, "unsloth", "models", "rl_replacements.py"
+)
+
+
+def _read(path: str) -> str:
+ with open(path, "r") as fh:
+ return fh.read()
+
+
+def _load_local_align_completion_tool_mask():
+ src = _read(RL_SOURCE_PATH)
+ tree = ast.parse(src)
+ for node in tree.body:
+ if isinstance(node, ast.If):
+ for item in node.body:
+ if (
+ isinstance(item, ast.FunctionDef)
+ and item.name == "align_completion_tool_mask"
+ ):
+ function_src = ast.get_source_segment(src, item)
+ break
+ else:
+ continue
+ break
+ else:
+ raise AssertionError("local align_completion_tool_mask fallback is missing")
+
+ calls = []
+
+ def align_logprobs_with_mask(logprob_tensor, completion_mask, pad_value = None):
+ calls.append((logprob_tensor, completion_mask, pad_value))
+ return torch.tensor(
+ [[1, 0, 1], [0, 1, 1]],
+ device = completion_mask.device,
+ dtype = logprob_tensor.dtype,
+ )
+
+ namespace = {
+ "torch": torch,
+ "align_logprobs_with_mask": align_logprobs_with_mask,
+ }
+ exec(textwrap.dedent(function_src), namespace)
+ return namespace["align_completion_tool_mask"], calls
+
+
+def test_rl_uses_optional_zoo_tool_mask_helper():
+ src = _read(RL_SOURCE_PATH)
+ assert 'RL_REPLACEMENTS.get("align_completion_tool_mask")' in src
+ assert 'RL_REPLACEMENTS["align_completion_tool_mask"]' not in src
+
+
+def test_local_tool_mask_fallback_is_only_old_zoo_compat_shim():
+ align_completion_tool_mask, calls = _load_local_align_completion_tool_mask()
+ completion_mask = torch.tensor(
+ [[1, 1, 0], [1, 1, 1]],
+ dtype = torch.float32,
+ )
+
+ assert align_completion_tool_mask(None, completion_mask) is completion_mask
+ assert calls == []
+
+ same_shape_tool_mask = torch.tensor([[1, 0, 1], [0, 1, 1]], dtype = torch.bool)
+ with pytest.raises(RuntimeError, match = "Please upgrade unsloth_zoo"):
+ align_completion_tool_mask(same_shape_tool_mask, completion_mask)
+
+
+def test_grpo_accumulated_loss_omits_none_tool_mask_for_old_zoo():
+ src = _read(RL_REPLACEMENTS_SOURCE_PATH)
+ assert "_grpo_accumulated_loss_kwargs = {}" in src
+ assert (
+ 'if tool_mask is not None:\n _grpo_accumulated_loss_kwargs["tool_mask"] = tool_mask'
+ in src
+ )
+ assert src.count("**_grpo_accumulated_loss_kwargs") == 2
+
+ accelerated_loss_start = src.find('if hasattr(self.args, "loss_type"):')
+ assert accelerated_loss_start != -1
+ accelerated_loss_body = src[
+ accelerated_loss_start : src.find(
+ 'if "train" in self._metrics:', accelerated_loss_start
+ )
+ ]
+ assert "tool_mask = tool_mask" not in accelerated_loss_body
+
+
+def test_rollout_output_patch_requires_real_tool_mask_symbol():
+ src = _read(RL_REPLACEMENTS_SOURCE_PATH)
+ assert 're.search(r"\\btool_mask\\b", function)' in src
+ assert 'output["tool_mask"]' in src
diff --git a/unsloth/models/llama.py b/unsloth/models/llama.py
index b2e6a45aa0..7af2a7d136 100644
--- a/unsloth/models/llama.py
+++ b/unsloth/models/llama.py
@@ -2594,6 +2594,7 @@ class FastLlamaModel:
quant_state_dict, model_config, dtype, bnb_config
)
model.vllm_engine = llm
+ llm.shared_weights = True
model.fast_generate = model.vllm_engine.generate
model.fast_generate_batches = functools.partial(
generate_batches, model.vllm_engine
diff --git a/unsloth/models/rl.py b/unsloth/models/rl.py
index c82c8364b3..359716fda4 100644
--- a/unsloth/models/rl.py
+++ b/unsloth/models/rl.py
@@ -365,6 +365,22 @@ calculate_pad_tokens_in_prompt = RL_REPLACEMENTS["calculate_pad_tokens_in_prompt
create_completion_attention_mask = RL_REPLACEMENTS["create_completion_attention_mask"]
left_pack_padding = RL_REPLACEMENTS["left_pack_padding"]
align_logprobs_with_mask = RL_REPLACEMENTS["align_logprobs_with_mask"]
+align_completion_tool_mask = RL_REPLACEMENTS.get("align_completion_tool_mask")
+if align_completion_tool_mask is None:
+
+ def align_completion_tool_mask(
+ tool_mask: torch.Tensor,
+ completion_mask: torch.Tensor,
+ ) -> torch.Tensor:
+ if tool_mask is None:
+ return completion_mask
+ raise RuntimeError(
+ "env_mask/tool_mask GRPO requires an unsloth_zoo build whose "
+ "grpo_accumulated_loss handles tool_mask. Please upgrade "
+ "unsloth_zoo."
+ )
+
+
autotune_batch_and_chunks = RL_REPLACEMENTS["grpo_autotune_batch_and_chunks"]
sanitize_logprob = RL_REPLACEMENTS["sanitize_logprob"]
@@ -452,6 +468,7 @@ torch_compile_options = {{
{create_completion_attention_mask_code}
{left_pack_padding_code}
{align_logprobs_with_mask_code}
+{align_completion_tool_mask_code}
{autotune_batch_and_chunks_code}
{sanitize_logprob_code}
@@ -1577,6 +1594,7 @@ def _patch_trl_rl_trainers_impl(trainer_file = "grpo_trainer"):
)
left_pack_padding_code = inspect.getsource(left_pack_padding)
align_logprobs_with_mask_code = inspect.getsource(align_logprobs_with_mask)
+ align_completion_tool_mask_code = inspect.getsource(align_completion_tool_mask)
autotune_batch_and_chunks_code = inspect.getsource(autotune_batch_and_chunks)
sanitize_logprob_code = inspect.getsource(sanitize_logprob)
# Get final source code
@@ -1607,6 +1625,7 @@ def _patch_trl_rl_trainers_impl(trainer_file = "grpo_trainer"):
autotune_batch_and_chunks_code = autotune_batch_and_chunks_code,
left_pack_padding_code = left_pack_padding_code,
align_logprobs_with_mask_code = align_logprobs_with_mask_code,
+ align_completion_tool_mask_code = align_completion_tool_mask_code,
sanitize_logprob_code = sanitize_logprob_code,
)
@@ -1983,12 +2002,14 @@ def patch_functions(RLTrainer, trainer_file, RLTrainer_name, all_imports, import
'GuidedDecodingParams(backend="outlines", regex=args.vllm_guided_decoding_regex) '
'if getattr(args, "vllm_guided_decoding_regex", None) is not None else None,',
)
- # Replace with our vLLM engine
+ # Replace with our vLLM engine when sharing weights
sampling_params = (
" " * 12
- + "self.llm = model.vllm_engine; self._last_loaded_step = 0; "
+ + "if getattr(getattr(model, 'vllm_engine', None), 'shared_weights', False): "
+ + "self.llm = model.vllm_engine; self._last_loaded_step = 0\n"
+ + " " * 12
+ sampling_params
- ) # Add spaces
+ )
# count the indentation of last line of sampling_params.
splitted_sampling_params = sampling_params.split("\n")
@@ -2021,14 +2042,27 @@ def patch_functions(RLTrainer, trainer_file, RLTrainer_name, all_imports, import
)
if trl_version >= Version("0.18.0"):
- # Replace LLM init with already existing vLLM engine for colocate mode
- vllm_llm_init_pattern = r"self\.llm\s*=\s*LLM\(.*?\)*\)\s*?\n(?!,)"
- vllm_llm_replacement = "self.llm = model.vllm_engine\n"
+ # Guard LLM init - use existing vLLM engine when sharing weights,
+ # otherwise keep the original LLM() creation for sync/reload path
+ vllm_llm_init_pattern = (
+ r"(?P[ \t]*)self\.llm\s*=\s*LLM\(.*?\)*\)\s*?\n(?!,)"
+ )
+
+ def guard_llm_init(match):
+ indent = match.group("indent")
+ original = match.group(0)
+ return (
+ f"{indent}if getattr(getattr(model, 'vllm_engine', None), 'shared_weights', False):\n"
+ f"{indent} self.llm = model.vllm_engine\n"
+ f"{indent}else:\n"
+ f"{indent} {original.lstrip()}"
+ )
+
new_vllm_part = re.sub(
vllm_llm_init_pattern,
- vllm_llm_replacement,
+ guard_llm_init,
new_vllm_part,
- flags = re.DOTALL, # Ensure . matches newlines [[5]]
+ flags = re.DOTALL,
)
init = init.replace(vllm_part, new_vllm_part)
@@ -2097,7 +2131,7 @@ def patch_functions(RLTrainer, trainer_file, RLTrainer_name, all_imports, import
source,
)
- # Replace self.llm.generate and self.llm.chat
+ # Replace self.llm.generate and self.llm.chat with lora_request (only when sharing weights)
if "CUDA_VISIBLE_DEVICES" in os.environ:
lora_name = (
trainer_file
@@ -2110,7 +2144,9 @@ def patch_functions(RLTrainer, trainer_file, RLTrainer_name, all_imports, import
r"(self\.llm\.(?:generate|chat)\([^\)]{1,})\)",
r"\1, lora_request = self.model.load_lora('"
+ lora_name
- + r", load_tensors = True))",
+ + r", load_tensors = True)"
+ + r" if getattr(self.llm, 'shared_weights', False)"
+ + r" else None)",
source,
)
# All these are to fix multiple commas before lora_request (in case the original code ends with something like ",)")
diff --git a/unsloth/models/rl_replacements.py b/unsloth/models/rl_replacements.py
index 0f9a324d5b..51d20f9ff7 100644
--- a/unsloth/models/rl_replacements.py
+++ b/unsloth/models/rl_replacements.py
@@ -613,39 +613,48 @@ def grpo_trainer__prepare_inputs(function_name, function):
RL_FUNCTIONS["grpo_trainer"].append(grpo_trainer__prepare_inputs)
-# Remove collective RPC of reload weights from generate
-# trl added reload weights (potentially for quantized models), we don't need it for our use case (LoRA primarily)
+# Guard reload_weights and sync_weights - skip when fast inference LoRA shares weights with vLLM
# https://github.com/huggingface/trl/commit/7856d3b1f6518601732f489883b341bb6dd36434#diff-964e6fd373aa93037604064cb2b822d7f8e2735e33f791065acf2c4c3552d393R1168-R1169
-def grpo_trainer__generate_single_turn(function_name, function):
- if function_name != "_generate_single_turn":
- return function
-
- # Remove the reload_weights collective RPC call from the generate function's source
- # function = function.replace('self.llm.collective_rpc("reload_weights")', "")
- # The regex below does the same thing but is more flexible and can handle single or double quotes
- # This is for older versions.
- function = re.sub(
- r"self\.llm\.collective_rpc\(\s*(['\"])reload_weights\1\s*\)",
- "",
- function,
+def _guard_vllm_sync_reload_for_shared_weights(function):
+ # Guard reload_weights - only call when not sharing weights with vLLM
+ reload_weights_pattern = re.compile(
+ r"^(?P[ \t]*)self\.llm\.collective_rpc\(\s*(['\"])reload_weights\2\s*\)\s*$",
+ re.MULTILINE,
)
- # Current TRL versions call vllm_generation.sync_weights() every step.
- # When Unsloth fast inference LoRA is active, weights are already shared.
+ def replace_reload_weights_line(match):
+ indent = match.group("indent")
+ return (
+ f"{indent}if not getattr(self.llm, 'shared_weights', False):\n"
+ f'{indent} self.llm.collective_rpc("reload_weights")\n'
+ )
+
+ function = reload_weights_pattern.sub(replace_reload_weights_line, function)
+
+ # Guard sync_weights - skip when sharing weights with vLLM
sync_weights_block = re.compile(
r"(?P[ \t]*)with profiling_context\(self,\s*(['\"])sync_weights\2\s*\):\n"
r"(?P=indent)[ \t]+self\.vllm_generation\.sync_weights\(\)\n",
re.MULTILINE,
)
- def remove_sync_weights_block(match):
+ def guard_sync_weights_block(match):
indent = match.group("indent")
return (
- f"{indent}# Unsloth fast inference LoRA shares weights with vLLM already.\n"
- f"{indent}# Skipping per-step vLLM sync_weights().\n"
+ f"{indent}if not getattr(getattr(self.vllm_generation, 'llm', None), 'shared_weights', False):\n"
+ f"{indent} with profiling_context(self, 'sync_weights'):\n"
+ f"{indent} self.vllm_generation.sync_weights()\n"
)
- function = sync_weights_block.sub(remove_sync_weights_block, function)
+ function = sync_weights_block.sub(guard_sync_weights_block, function)
+ return function
+
+
+def grpo_trainer__generate_single_turn(function_name, function):
+ if function_name != "_generate_single_turn":
+ return function
+
+ function = _guard_vllm_sync_reload_for_shared_weights(function)
# TRL 0.24.0-0.25.1 truncation regression fix
#
@@ -693,6 +702,16 @@ def grpo_trainer__generate_single_turn(function_name, function):
RL_FUNCTIONS["grpo_trainer"].append(grpo_trainer__generate_single_turn)
+def grpo_trainer__generate(function_name, function):
+ if function_name != "_generate":
+ return function
+
+ return _guard_vllm_sync_reload_for_shared_weights(function)
+
+
+RL_FUNCTIONS["grpo_trainer"].append(grpo_trainer__generate)
+
+
# Fix incorrect special tokens handling and truncation in older TRL versions
def grpo_trainer__generate_and_score_completions(function_name, function):
if function_name != "_generate_and_score_completions":
@@ -946,6 +965,14 @@ def grpo_trainer__generate_and_score_completions(function_name, function):
)
function = function.replace(_save_search, _save_replace)
+ if re.search(r"\btool_mask\b", function) and 'output["tool_mask"]' not in function:
+ function = function.replace(
+ " return output",
+ " if tool_mask is not None:\n"
+ ' output["tool_mask"] = tool_mask\n'
+ " return output",
+ )
+
return function
@@ -1523,6 +1550,7 @@ def grpo_trainer_compute_loss(function_name, function):
mm_token_type_ids = inputs.get("mm_token_type_ids", None)
num_items_in_batch = inputs.get("num_items_in_batch", None)
sampling_per_token_logps = inputs.get("sampling_per_token_logps", None)
+ tool_mask = inputs.get("tool_mask", None)
current_gradient_accumulation_steps = self.current_gradient_accumulation_steps
num_processes = self.accelerator.num_processes
@@ -1598,6 +1626,16 @@ def grpo_trainer_compute_loss(function_name, function):
max_left_pad = inputs.get("max_left_pad", 0)
if per_token_logps is not None:
+ loss_mask = completion_mask
+ if tool_mask is not None:
+ if tool_mask.shape != completion_mask.shape:
+ raise ValueError(
+ "tool_mask/env_mask must have the same shape as completion_mask"
+ )
+ loss_mask = completion_mask * tool_mask.to(
+ device = completion_mask.device,
+ dtype = completion_mask.dtype,
+ )
(
loss,
completion_length,
@@ -1612,7 +1650,7 @@ def grpo_trainer_compute_loss(function_name, function):
old_logps,
sampling_per_token_logps,
input_ids,
- completion_mask,
+ loss_mask,
self.beta,
advantages,
pixel_values = pixel_values,
@@ -1662,6 +1700,28 @@ def grpo_trainer_compute_loss(function_name, function):
"unsloth_zoo (see https://github.com/unslothai/unsloth-zoo/pull/613)."
)
self._unsloth_grpo_zoo_checked = True
+ if tool_mask is not None and not getattr(
+ self, "_unsloth_grpo_tool_mask_zoo_checked", False
+ ):
+ _supports_tool_mask = (
+ "tool_mask" in inspect.signature(grpo_accumulated_loss).parameters
+ )
+ if not _supports_tool_mask:
+ try:
+ _zoo_src = inspect.getsource(grpo_accumulated_loss)
+ except (TypeError, OSError):
+ _zoo_src = ""
+ _supports_tool_mask = "tool_mask" in _zoo_src
+ if not _supports_tool_mask:
+ raise RuntimeError(
+ "env_mask/tool_mask GRPO requires an unsloth_zoo build whose "
+ "grpo_accumulated_loss handles tool_mask. Please upgrade "
+ "unsloth_zoo."
+ )
+ self._unsloth_grpo_tool_mask_zoo_checked = True
+ _grpo_accumulated_loss_kwargs = {}
+ if tool_mask is not None:
+ _grpo_accumulated_loss_kwargs["tool_mask"] = tool_mask
if hasattr(self.args, "loss_type"):
(
loss,
@@ -1703,6 +1763,7 @@ def grpo_trainer_compute_loss(function_name, function):
sampling_per_token_logps = sampling_per_token_logps,
token_type_ids = token_type_ids,
mm_token_type_ids = mm_token_type_ids,
+ **_grpo_accumulated_loss_kwargs,
)
else:
# to ensure backwards compatibility with trl 0.15.2 and maybe even 0.17
@@ -1728,6 +1789,7 @@ def grpo_trainer_compute_loss(function_name, function):
attention_mask = attention_mask,
token_type_ids = token_type_ids,
mm_token_type_ids = mm_token_type_ids,
+ **_grpo_accumulated_loss_kwargs,
)
)
if "train" in self._metrics:
@@ -1927,7 +1989,7 @@ RL_METRICS_CHANGES["grpo_trainer"].append(grpo_trainer_metrics)
def openenv_vllm_reload_weights():
# This function patches the trl openenv generate_rollout_completions function to:
- # 1. Remove the reload_weights call (unsloth handles weight reloading)
+ # 1. Guard the reload_weights call (skip when sharing weights with vLLM)
# 2. Fix wake_up call to be compatible with unsloth (remove tags to wake everything)
#
# The issue: TRL's wake_up(tags=["kv_cache"]) only wakes kv_cache, leaving is_sleeping=True
@@ -1984,8 +2046,20 @@ def openenv_vllm_reload_weights():
src = textwrap.dedent(src)
original_src = src
- # Remove the reload_weights call - unsloth handles this differently
- src = re.sub(r'.*\.collective_rpc\(\s*([\'"])reload_weights\1\s*\).*\n?', "", src)
+ reload_weights_pattern = re.compile(
+ r"^(?P[ \t]*)(?P\S+)\.collective_rpc\(\s*(['\"])reload_weights\3\s*\)\s*$",
+ re.MULTILINE,
+ )
+
+ def replace_reload_weights(match):
+ indent = match.group("indent")
+ obj = match.group("obj")
+ return (
+ f"{indent}if not getattr({obj}, 'shared_weights', False):\n"
+ f'{indent} {obj}.collective_rpc("reload_weights")\n'
+ )
+
+ src = reload_weights_pattern.sub(replace_reload_weights, src)
# Change wake_up(tags=["kv_cache"]) to wake_up() - wake everything to set is_sleeping=False
# This prevents double wake_up issues. Unsloth's allocator skips weights anyway.
@@ -2075,7 +2149,9 @@ def vllm_generation_init_patch():
f"{indent}if hasattr(model, 'vllm_engine'):\n"
f"{indent} # Unsloth already inits vLLM in fast inference mode. Do not redo :)\n"
f"{indent} self.llm = model.vllm_engine\n"
- f"{indent} self.unsloth_fast_inference_lora = True\n"
+ f"{indent} self.unsloth_fast_inference_lora = getattr(self.llm, 'shared_weights', False)\n"
+ f"{indent} if getattr(self.llm, 'shared_weights', False) and hasattr(model, 'load_lora'):\n"
+ f"{indent} self._unsloth_load_lora = model.load_lora\n"
f"{indent}else:\n" + textwrap.indent(llm_block, indent + " ")
)
@@ -2097,8 +2173,11 @@ def vllm_generation_init_patch():
def replace_sync_weights(match):
body = match.group("body")
+ # Chain getattr so server mode (where self.llm is not set) does
+ # not raise AttributeError before the default kicks in.
guard = (
- " if getattr(self, 'unsloth_fast_inference_lora', False):\n"
+ " if getattr(getattr(self, 'llm', None), 'shared_weights', False) or "
+ "getattr(self, 'unsloth_fast_inference_lora', False):\n"
" # Unsloth fast inference LoRA shares weights with vLLM already.\n"
" return\n\n"
)
@@ -2119,7 +2198,12 @@ def vllm_generation_init_patch():
def replace_reload_weights(match):
indent = match.group("indent")
- return f'{indent}pass # self.llm.collective_rpc("reload_weights")'
+ # Chain getattr so server mode (no self.llm) is safe here too.
+ return (
+ f"{indent}if not (getattr(getattr(self, 'llm', None), 'shared_weights', False) or "
+ f"getattr(self, 'unsloth_fast_inference_lora', False)):\n"
+ f'{indent} self.llm.collective_rpc("reload_weights")'
+ )
patched_src, num_replacements = pattern.subn(
replace_reload_weights, src, count = 1
@@ -2128,25 +2212,39 @@ def vllm_generation_init_patch():
raise RuntimeError(
"Unsloth: Warning - regex did not match, VLLMGeneration.generate patch may have failed"
)
+
+ # Inject lora_request when sharing weights (vLLM needs the adapter)
+ lora_generate_pattern = re.compile(
+ r"(self\.llm\.generate\([^\)]+)\)",
+ )
+
+ def inject_lora_request(match):
+ return (
+ f"{match.group(1)}, lora_request="
+ f"self._unsloth_load_lora('vllm_gen_lora', load_tensors=True) "
+ f"if hasattr(self, '_unsloth_load_lora') else None)"
+ )
+
+ patched_src = lora_generate_pattern.sub(inject_lora_request, patched_src)
return patched_src
try:
init_patched = patch_vllm_generation_method(
"_init_vllm",
patch_init_vllm,
- "self.unsloth_fast_inference_lora = True",
+ "self.unsloth_fast_inference_lora = getattr(self.llm, 'shared_weights', False)",
"init_vllm",
)
sync_patched = patch_vllm_generation_method(
"sync_weights",
patch_sync_weights,
- "if getattr(self, 'unsloth_fast_inference_lora', False):",
+ "if getattr(getattr(self, 'llm', None), 'shared_weights', False) or getattr(self, 'unsloth_fast_inference_lora', False):",
"sync_weights",
)
generate_patched = patch_vllm_generation_method(
"generate",
patch_generate,
- 'pass # self.llm.collective_rpc("reload_weights")',
+ "if not (getattr(getattr(self, 'llm', None), 'shared_weights', False) or getattr(self, 'unsloth_fast_inference_lora', False)):",
"generate",
)
except RuntimeError as e:
diff --git a/unsloth/models/vision.py b/unsloth/models/vision.py
index 73ef1db7e3..6c4842596f 100644
--- a/unsloth/models/vision.py
+++ b/unsloth/models/vision.py
@@ -1079,6 +1079,7 @@ class FastBaseModel:
is_vision_model = is_vlm,
)
model.vllm_engine = llm
+ llm.shared_weights = True
model.fast_generate = model.vllm_engine.generate
model.fast_generate_batches = functools.partial(
generate_batches, model.vllm_engine