= [
+ { value: "adamw_8bit", label: "AdamW 8-bit" },
+ { value: "paged_adamw_8bit", label: "Paged AdamW 8-bit" },
+ { value: "adamw_bnb_8bit", label: "AdamW BNB 8-bit" },
+ { value: "paged_adamw_32bit", label: "Paged AdamW 32-bit" },
+ { value: "adamw_torch", label: "AdamW (PyTorch)" },
+ { value: "adamw_torch_fused", label: "AdamW (PyTorch Fused)" },
+];
+
+export const LR_SCHEDULER_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
+ { value: "linear", label: "Linear" },
+ { value: "cosine", label: "Cosine" },
+];
+
export const DEFAULT_HYPERPARAMS = {
epochs: 3,
contextLength: 2048,
learningRate: 2e-4,
+ optimizerType: "adamw_8bit",
+ lrSchedulerType: "linear",
loraRank: 16,
loraAlpha: 32,
loraDropout: 0.05,
diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts
index 8997570271..af8f10156f 100644
--- a/studio/frontend/src/features/chat/api/chat-adapter.ts
+++ b/studio/frontend/src/features/chat/api/chat-adapter.ts
@@ -65,6 +65,15 @@ function findLatestUserImageBase64(messages: RunMessages): string | undefined {
continue;
}
+ // Image in message.content (e.g. compare view appends content with image parts)
+ for (const part of message.content ?? []) {
+ if (part.type === "image" && "image" in part) {
+ const encoded = extractImageBase64(part.image);
+ if (encoded) return encoded;
+ }
+ }
+
+ // Image in message.attachments (e.g. chat composer)
if ("attachments" in message && (message.attachments?.length ?? 0) > 0) {
for (const attachment of message.attachments ?? []) {
for (const part of attachment.content ?? []) {
@@ -159,7 +168,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
if (abortSignal.aborted) return;
warmupToastShown = true;
toast.promise(firstTokenPromise, {
- loading: "Warming up model",
+ loading: "Generating",
success: "Generating",
error: (err) =>
err instanceof Error && err.message ? err.message : "Generation failed",
diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx
index 34443ec87f..1184183088 100644
--- a/studio/frontend/src/features/chat/chat-page.tsx
+++ b/studio/frontend/src/features/chat/chat-page.tsx
@@ -5,6 +5,7 @@ import {
} from "@/components/assistant-ui/model-selector";
import { Thread } from "@/components/assistant-ui/thread";
import { Button } from "@/components/ui/button";
+import { Spinner } from "@/components/ui/spinner";
import { SidebarProvider, SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
import {
Sheet,
@@ -283,7 +284,8 @@ export function ChatPage(): ReactElement {
const modelsFromStore = useChatRuntimeStore((state) => state.models);
const lorasFromStore = useChatRuntimeStore((state) => state.loras);
const modelsError = useChatRuntimeStore((state) => state.modelsError);
- const { refresh, selectModel, ejectModel } = useChatModelRuntime();
+ const { refresh, selectModel, ejectModel, loadingModel } =
+ useChatModelRuntime();
const refreshRef = useRef(refresh);
const selectModelRef = useRef(selectModel);
@@ -518,6 +520,17 @@ export function ChatPage(): ReactElement {
contentDataTour="chat-model-selector-popover"
className="max-w-[62vw] sm:max-w-none"
/>
+ {loadingModel ? (
+
+
+
+ Downloading model…
+
+
+ ) : null}
diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts
index 03aba01736..961967cc9c 100644
--- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts
+++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts
@@ -1,4 +1,4 @@
-import { useCallback } from "react";
+import { useCallback, useState } from "react";
import { toast } from "sonner";
import {
getInferenceStatus,
@@ -116,6 +116,11 @@ export function useChatModelRuntime() {
const setCheckpoint = useChatRuntimeStore((state) => state.setCheckpoint);
const clearCheckpoint = useChatRuntimeStore((state) => state.clearCheckpoint);
+ const [loadingModel, setLoadingModel] = useState<{
+ id: string;
+ displayName: string;
+ } | null>(null);
+
const refresh = useCallback(async () => {
setModelsError(null);
try {
@@ -157,6 +162,7 @@ export function useChatModelRuntime() {
const displayName = model?.name || lora?.name || modelId;
setModelsError(null);
+ setLoadingModel({ id: modelId, displayName });
try {
async function performLoad(): Promise
{
if (params.checkpoint) {
@@ -176,19 +182,20 @@ export function useChatModelRuntime() {
await refresh();
}
- let description = "Base model selected.";
- if (isLora) {
- description = "Fine-tuned (LoRA) selected.";
- }
+ const loadPromise = performLoad().finally(() => {
+ setLoadingModel(null);
+ });
- await toast.promise(performLoad(), {
- loading: `Loading ${displayName}`,
+ await toast.promise(loadPromise, {
+ loading: "Loading model…",
success: `${displayName} loaded`,
error: (err) =>
err instanceof Error ? err.message : "Failed to load model",
- description,
+ description:
+ "This may include downloading. Large models can take a while.",
});
} catch (error) {
+ setLoadingModel(null);
const message =
error instanceof Error ? error.message : "Failed to load model";
setModelsError(message);
@@ -227,5 +234,6 @@ export function useChatModelRuntime() {
refresh,
selectModel,
ejectModel,
+ loadingModel,
};
}
diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx
index f849d3f5b7..6b3fc29d9e 100644
--- a/studio/frontend/src/features/chat/shared-composer.tsx
+++ b/studio/frontend/src/features/chat/shared-composer.tsx
@@ -1,25 +1,102 @@
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import { useAui } from "@assistant-ui/react";
-import { ArrowUpIcon, SquareIcon } from "lucide-react";
+import { ArrowUpIcon, MicIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
import {
type KeyboardEvent,
type MutableRefObject,
type ReactElement,
type ReactNode,
createContext,
+ useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
+export type CompareMessagePart =
+ | { type: "text"; text: string }
+ | { type: "image"; image: string };
+
export interface CompareHandle {
- append: (content: { type: "text"; text: string }[]) => void;
+ append: (content: CompareMessagePart[]) => void;
cancel: () => void;
isRunning: () => boolean;
}
+const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif";
+const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
+
+function fileToBase64DataURL(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = () => reject(new Error("Failed to read image file"));
+ reader.readAsDataURL(file);
+ });
+}
+
+function useDictation(
+ setText: (value: string | ((prev: string) => string)) => void,
+) {
+ const [isDictating, setIsDictating] = useState(false);
+ const recognitionRef = useRef(null);
+
+ const start = useCallback(() => {
+ const SpeechRecognitionAPI =
+ typeof window !== "undefined" &&
+ (window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: typeof SpeechRecognition }).webkitSpeechRecognition);
+ if (!SpeechRecognitionAPI) {
+ return;
+ }
+ const recognition = new SpeechRecognitionAPI() as SpeechRecognition;
+ recognition.continuous = true;
+ recognition.interimResults = true;
+ recognition.lang = "en-US";
+ recognition.onresult = (event: SpeechRecognitionEvent) => {
+ const last = event.resultIndex;
+ const result = event.results[last];
+ if (!result?.isFinal) return;
+ const transcript = result[0]?.transcript?.trim();
+ if (transcript) {
+ setText((prev) => (prev ? `${prev} ${transcript}` : transcript));
+ }
+ };
+ recognition.onerror = () => {
+ setIsDictating(false);
+ };
+ recognition.onend = () => {
+ setIsDictating(false);
+ };
+ recognition.start();
+ recognitionRef.current = recognition;
+ setIsDictating(true);
+ }, [setText]);
+
+ const stop = useCallback(() => {
+ if (recognitionRef.current) {
+ recognitionRef.current.stop();
+ recognitionRef.current = null;
+ }
+ setIsDictating(false);
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ if (recognitionRef.current) {
+ recognitionRef.current.abort();
+ }
+ };
+ }, []);
+
+ const supported =
+ typeof window !== "undefined" &&
+ !!(window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition);
+
+ return { isDictating, start, stop, supported };
+}
+
export type CompareHandles = MutableRefObject>;
const CompareHandlesContext = createContext(null);
@@ -66,6 +143,37 @@ export function RegisterCompareHandle({
return null;
}
+type PendingImage = { id: string; file: File };
+
+function PendingImageThumb({
+ file,
+ onRemove,
+}: {
+ file: File;
+ onRemove: () => void;
+}): ReactElement {
+ const [src, setSrc] = useState(null);
+ useEffect(() => {
+ const url = URL.createObjectURL(file);
+ setSrc(url);
+ return () => URL.revokeObjectURL(url);
+ }, [file]);
+ if (!src) return ;
+ return (
+
+

+
+
+ );
+}
+
export function SharedComposer({
handlesRef,
}: {
@@ -73,7 +181,14 @@ export function SharedComposer({
}): ReactElement {
const [text, setText] = useState("");
const [running, setRunning] = useState(false);
+ const [pendingImages, setPendingImages] = useState([]);
+ const [dragging, setDragging] = useState(false);
const textareaRef = useRef(null);
+ const fileInputRef = useRef(null);
+
+ const { isDictating, start: startDictation, stop: stopDictation, supported: dictationSupported } = useDictation(
+ setText,
+ );
useEffect(() => {
const id = setInterval(() => {
@@ -84,23 +199,50 @@ export function SharedComposer({
return () => clearInterval(id);
}, [handlesRef]);
- function send() {
- const msg = text.trim();
- if (!msg) {
- return;
+ const addFiles = useCallback((files: FileList | null) => {
+ if (!files?.length) return;
+ const next: PendingImage[] = [];
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+ if (!file?.type.match(/^image\/(jpeg|png|webp|gif)$/i)) continue;
+ if (file.size > MAX_IMAGE_SIZE) continue;
+ next.push({ id: crypto.randomUUID(), file });
}
+ setPendingImages((prev) => [...prev, ...next]);
+ }, []);
+
+ const removePendingImage = useCallback((id: string) => {
+ setPendingImages((prev) => prev.filter((p) => p.id !== id));
+ }, []);
+
+ async function send() {
+ const msg = text.trim();
+ if (!msg && pendingImages.length === 0) return;
+
+ const content: CompareMessagePart[] = [];
+ for (const { file } of pendingImages) {
+ try {
+ const image = await fileToBase64DataURL(file);
+ content.push({ type: "image", image });
+ } catch {
+ // skip failed image
+ }
+ }
+ if (msg) {
+ content.push({ type: "text", text: msg });
+ }
+ if (content.length === 0) return;
- const content: { type: "text"; text: string }[] = [
- { type: "text", text: msg },
- ];
for (const handle of Object.values(handlesRef.current)) {
handle.append(content);
}
setText("");
+ setPendingImages([]);
textareaRef.current?.focus();
}
function stop() {
+ if (isDictating) stopDictation();
for (const handle of Object.values(handlesRef.current)) {
handle.cancel();
}
@@ -115,8 +257,33 @@ export function SharedComposer({
}
}
+ const canSend = (text.trim().length > 0 || pendingImages.length > 0) && !running;
+
return (
-
+
{
+ e.preventDefault();
+ setDragging(true);
+ }}
+ onDragLeave={() => setDragging(false)}
+ onDrop={(e) => {
+ e.preventDefault();
+ setDragging(false);
+ addFiles(e.dataTransfer.files);
+ }}
+ >
+ {pendingImages.length > 0 && (
+
+ {pendingImages.map(({ id, file }) => (
+
removePendingImage(id)}
+ />
+ ))}
+
+ )}
-
- {running ? (
-
- ) : (
+
+
+
{
+ addFiles(e.target.files);
+ e.target.value = "";
+ }}
+ />
fileInputRef.current?.click()}
+ aria-label="Add attachment"
>
-
+
- )}
+
+
+ {dictationSupported && (
+ <>
+ {!isDictating ? (
+
+
+
+ ) : (
+
+
+
+ )}
+ >
+ )}
+ {running ? (
+
+ ) : (
+
+
+
+ )}
+
);
diff --git a/studio/frontend/src/features/studio/sections/dataset-preview-dialog.tsx b/studio/frontend/src/features/studio/sections/dataset-preview-dialog.tsx
index 648adfbc89..e135f301af 100644
--- a/studio/frontend/src/features/studio/sections/dataset-preview-dialog.tsx
+++ b/studio/frontend/src/features/studio/sections/dataset-preview-dialog.tsx
@@ -63,6 +63,7 @@ export function DatasetPreviewDialog({
const mappingOk = !!manualMapping.input && !!manualMapping.output;
const leftLabel = isVlm ? "Image" : "Input";
const rightLabel = isVlm ? "Text" : "Output";
+ const isHfDataset = !!datasetName && datasetName.includes("/");
useEffect(() => {
if (!manualMapping.input || !manualMapping.output) return;
@@ -266,8 +267,13 @@ export function DatasetPreviewDialog({
- Loading preview...
+ {isHfDataset ? "Fetching dataset preview from Hugging Face..." : "Loading preview..."}
+ {isHfDataset && (
+
+ This may take a moment for large datasets
+
+ )}
)}
diff --git a/studio/frontend/src/features/studio/sections/dataset-section.tsx b/studio/frontend/src/features/studio/sections/dataset-section.tsx
index d7b97c7ad0..d142ae5f54 100644
--- a/studio/frontend/src/features/studio/sections/dataset-section.tsx
+++ b/studio/frontend/src/features/studio/sections/dataset-section.tsx
@@ -114,7 +114,7 @@ export function DatasetSection() {
const resultIds = useMemo(() => {
const ids = hfResults.map((r) => r.id);
if (dataset && !ids.includes(dataset)) {
- ids.unshift(dataset);
+ ids.push(dataset);
}
return ids;
}, [hfResults, dataset]);
@@ -164,7 +164,20 @@ export function DatasetSection() {
-
+
{
+ if (event.key !== "Enter") return;
+ if (!(event.target instanceof HTMLInputElement)) return;
+ event.preventDefault();
+ if (hfResults.length > 0) {
+ handleDatasetSelect(hfResults[0].id);
+ } else {
+ const text = event.target.value.trim();
+ if (text) handleDatasetSelect(text);
+ }
+ }}
+ >
id}
autoHighlight={true}
>
-
+
diff --git a/studio/frontend/src/features/studio/sections/model-section.tsx b/studio/frontend/src/features/studio/sections/model-section.tsx
index 9c92755f60..2ba13d739c 100644
--- a/studio/frontend/src/features/studio/sections/model-section.tsx
+++ b/studio/frontend/src/features/studio/sections/model-section.tsx
@@ -165,7 +165,7 @@ export function ModelSection() {
const resultIds = useMemo(() => {
const ids = hfResults.map((r) => r.id);
if (selectedModel && !ids.includes(selectedModel)) {
- ids.unshift(selectedModel);
+ ids.push(selectedModel);
}
return ids;
}, [hfResults, selectedModel]);
@@ -380,7 +380,20 @@ export function ModelSection() {
-
+
{
+ if (event.key !== "Enter") return;
+ if (!(event.target instanceof HTMLInputElement)) return;
+ event.preventDefault();
+ if (hfResults.length > 0) {
+ handleModelSelect(hfResults[0].id);
+ } else {
+ const text = event.target.value.trim();
+ if (text) handleModelSelect(text);
+ }
+ }}
+ >
+
+ Optimization algorithm. 8-bit variants reduce memory usage.
+ Fused is recommended for vision models.{" "}
+
+ Read more
+
+ >
+ }
+ >
+
+
+
+ How the learning rate changes over training. Linear decays
+ steadily; cosine decays in a curve.{" "}
+
+ Read more
+
+ >
+ }
+ >
+
+
= {
idle: "Idle",
+ downloading_model: "Downloading model",
+ downloading_dataset: "Downloading dataset",
loading_model: "Loading model",
loading_dataset: "Loading dataset",
configuring: "Configuring",
@@ -13,6 +15,10 @@ export const phaseLabel: Record = {
export const phaseColors: Record = {
idle: "bg-muted text-muted-foreground",
+ downloading_model:
+ "bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300",
+ downloading_dataset:
+ "bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300",
loading_model:
"bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
loading_dataset:
diff --git a/studio/frontend/src/features/studio/sections/progress-section.tsx b/studio/frontend/src/features/studio/sections/progress-section.tsx
index 3752a0fb25..6e88c4c05a 100644
--- a/studio/frontend/src/features/studio/sections/progress-section.tsx
+++ b/studio/frontend/src/features/studio/sections/progress-section.tsx
@@ -35,6 +35,7 @@ import { Link, useNavigate } from "@tanstack/react-router";
import { useShallow } from "zustand/react/shallow";
import { useGpuUtilization } from "@/hooks";
import { setTrainingCompareHandoff } from "@/features/chat";
+import { OPTIMIZER_OPTIONS } from "@/config/training";
import { formatDuration, formatNumber, phaseColors, phaseLabel } from "./progress-section-lib";
export function ProgressSection(): ReactElement {
@@ -71,6 +72,7 @@ export function ProgressSection(): ReactElement {
maxSteps: state.maxSteps,
contextLength: state.contextLength,
warmupSteps: state.warmupSteps,
+ optimizerType: state.optimizerType,
loraRank: state.loraRank,
loraAlpha: state.loraAlpha,
loraDropout: state.loraDropout,
@@ -126,6 +128,10 @@ export function ProgressSection(): ReactElement {
? runtime.currentGradNorm
: lastNonZeroValue(runtime.gradNormHistory) ?? runtime.currentGradNorm;
+ const optimizerLabel =
+ OPTIMIZER_OPTIONS.find((o) => o.value === config.optimizerType)?.label ??
+ config.optimizerType;
+
const configItems = [
{
section: "Hyperparams",
@@ -133,6 +139,7 @@ export function ProgressSection(): ReactElement {
["Epochs", config.epochs],
["Batch size", config.batchSize],
["Learning rate", config.learningRate],
+ ["Optimizer", optimizerLabel],
["Max steps", config.maxSteps],
["Context length", config.contextLength],
["Warmup steps", config.warmupSteps],
diff --git a/studio/frontend/src/features/studio/training-view.tsx b/studio/frontend/src/features/studio/training-view.tsx
index 9e83a2f662..fe616ea88a 100644
--- a/studio/frontend/src/features/studio/training-view.tsx
+++ b/studio/frontend/src/features/studio/training-view.tsx
@@ -19,6 +19,8 @@ export function TrainingView(): ReactElement {
);
const isPreparingPhase =
+ runtime.phase === "downloading_model" ||
+ runtime.phase === "downloading_dataset" ||
runtime.phase === "loading_model" ||
runtime.phase === "loading_dataset" ||
runtime.phase === "configuring";
diff --git a/studio/frontend/src/features/training/api/mappers.ts b/studio/frontend/src/features/training/api/mappers.ts
index 510cc542b8..ac93761b62 100644
--- a/studio/frontend/src/features/training/api/mappers.ts
+++ b/studio/frontend/src/features/training/api/mappers.ts
@@ -40,8 +40,8 @@ export function buildTrainingStartPayload(
weight_decay: config.weightDecay,
random_seed: config.randomSeed,
packing: config.packing,
- optim: "adamw_8bit",
- lr_scheduler_type: "linear",
+ optim: config.optimizerType,
+ lr_scheduler_type: config.lrSchedulerType,
use_lora: adapterMethod,
lora_r: config.loraRank,
lora_alpha: config.loraAlpha,
diff --git a/studio/frontend/src/features/training/api/models-api.ts b/studio/frontend/src/features/training/api/models-api.ts
index 6e6e3ff762..e22de5f581 100644
--- a/studio/frontend/src/features/training/api/models-api.ts
+++ b/studio/frontend/src/features/training/api/models-api.ts
@@ -9,6 +9,8 @@ interface BackendTrainingDefaults {
max_seq_length?: number;
num_epochs?: number;
learning_rate?: number | string;
+ optim?: string;
+ lr_scheduler_type?: string;
batch_size?: number;
gradient_accumulation_steps?: number;
warmup_steps?: number;
diff --git a/studio/frontend/src/features/training/lib/model-defaults.ts b/studio/frontend/src/features/training/lib/model-defaults.ts
index 07ffb2a422..35ce562dbf 100644
--- a/studio/frontend/src/features/training/lib/model-defaults.ts
+++ b/studio/frontend/src/features/training/lib/model-defaults.ts
@@ -7,6 +7,8 @@ type ModelDefaultsPatch = Partial<
| "epochs"
| "contextLength"
| "learningRate"
+ | "optimizerType"
+ | "lrSchedulerType"
| "loraRank"
| "loraAlpha"
| "loraDropout"
@@ -86,6 +88,12 @@ export function mapBackendModelConfigToTrainingPatch(
const learningRate = toNumber(training?.learning_rate);
if (learningRate !== undefined) patch.learningRate = learningRate;
+ const optim = toStringValue(training?.optim);
+ if (optim !== undefined) patch.optimizerType = optim;
+
+ const lrSchedulerType = toStringValue(training?.lr_scheduler_type);
+ if (lrSchedulerType !== undefined) patch.lrSchedulerType = lrSchedulerType;
+
const batchSize = toNumber(training?.batch_size);
if (batchSize !== undefined) patch.batchSize = batchSize;
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 67317dbc09..b6f9c01c42 100644
--- a/studio/frontend/src/features/training/stores/training-config-store.ts
+++ b/studio/frontend/src/features/training/stores/training-config-store.ts
@@ -305,6 +305,8 @@ export const useTrainingConfigStore = create()(
setEpochs: (epochs) => set({ epochs }),
setContextLength: (contextLength) => set({ contextLength }),
setLearningRate: (learningRate) => set({ learningRate }),
+ setOptimizerType: (optimizerType) => set({ optimizerType }),
+ setLrSchedulerType: (lrSchedulerType) => set({ lrSchedulerType }),
setLoraRank: (loraRank) => set({ loraRank }),
setLoraAlpha: (loraAlpha) => set({ loraAlpha }),
setLoraDropout: (loraDropout) => set({ loraDropout }),
@@ -346,7 +348,7 @@ export const useTrainingConfigStore = create()(
},
{
name: "unsloth_training_config_v1",
- version: 3,
+ version: 5,
migrate: (persisted, version) => {
const s = persisted as Record;
if (version < 2 && s.datasetSubset == null && s.datasetConfig != null) {
@@ -356,6 +358,12 @@ export const useTrainingConfigStore = create()(
if (version < 3 && s.modelDefaultsAppliedFor == null) {
s.modelDefaultsAppliedFor = null;
}
+ if (version < 4 && s.optimizerType == null) {
+ s.optimizerType = DEFAULT_HYPERPARAMS.optimizerType;
+ }
+ if (version < 5 && s.lrSchedulerType == null) {
+ s.lrSchedulerType = DEFAULT_HYPERPARAMS.lrSchedulerType;
+ }
return s as unknown as TrainingConfigStore;
},
partialize: partializePersistedState,
diff --git a/studio/frontend/src/features/training/types/config.ts b/studio/frontend/src/features/training/types/config.ts
index 53beabd383..b268a08b79 100644
--- a/studio/frontend/src/features/training/types/config.ts
+++ b/studio/frontend/src/features/training/types/config.ts
@@ -30,6 +30,8 @@ export interface TrainingConfigState {
epochs: number;
contextLength: number;
learningRate: number;
+ optimizerType: string;
+ lrSchedulerType: string;
loraRank: number;
loraAlpha: number;
loraDropout: number;
@@ -85,6 +87,8 @@ export interface TrainingConfigActions {
setEpochs: (epochs: number) => void;
setContextLength: (length: number) => void;
setLearningRate: (rate: number) => void;
+ setOptimizerType: (value: string) => void;
+ setLrSchedulerType: (value: string) => void;
setLoraRank: (rank: number) => void;
setLoraAlpha: (alpha: number) => void;
setLoraDropout: (dropout: number) => void;
diff --git a/studio/frontend/src/features/training/types/runtime.ts b/studio/frontend/src/features/training/types/runtime.ts
index df24c020ca..7ebf09518d 100644
--- a/studio/frontend/src/features/training/types/runtime.ts
+++ b/studio/frontend/src/features/training/types/runtime.ts
@@ -1,5 +1,7 @@
export type TrainingPhase =
| "idle"
+ | "downloading_model"
+ | "downloading_dataset"
| "loading_model"
| "loading_dataset"
| "configuring"
diff --git a/studio/frontend/src/hooks/index.ts b/studio/frontend/src/hooks/index.ts
index b6c4fcad14..3932baf088 100644
--- a/studio/frontend/src/hooks/index.ts
+++ b/studio/frontend/src/hooks/index.ts
@@ -3,6 +3,7 @@ export { useGpuInfo } from "./use-gpu-info";
export { useGpuUtilization } from "./use-gpu-utilization";
export { useHardwareInfo } from "./use-hardware-info";
export { useHfModelSearch } from "./use-hf-model-search";
+export { useRecommendedModelVram } from "./use-recommended-model-vram";
export { useHfDatasetSearch } from "./use-hf-dataset-search";
export { useHfDatasetSplits } from "./use-hf-dataset-splits";
export { useHfTokenValidation } from "./use-hf-token-validation";
diff --git a/studio/frontend/src/hooks/use-recommended-model-vram.ts b/studio/frontend/src/hooks/use-recommended-model-vram.ts
new file mode 100644
index 0000000000..69692ff416
--- /dev/null
+++ b/studio/frontend/src/hooks/use-recommended-model-vram.ts
@@ -0,0 +1,57 @@
+import { modelInfo } from "@huggingface/hub";
+import { useEffect, useState } from "react";
+
+/**
+ * Fetches Hugging Face model info (safetensors total param count) for a list of
+ * model IDs. Used to show VRAM fit (FIT / TIGHT / OOM) for recommended/default
+ * models in the chat model dropdown.
+ */
+export function useRecommendedModelVram(ids: string[]) {
+ const [paramCountById, setParamCountById] = useState<
+ Map
+ >(new Map());
+ const [isLoading, setIsLoading] = useState(false);
+
+ const stableKey = [...ids].filter(Boolean).sort().join(",");
+
+ useEffect(() => {
+ const stableIds = stableKey ? stableKey.split(",") : [];
+ if (stableIds.length === 0) {
+ setParamCountById(new Map());
+ setIsLoading(false);
+ return;
+ }
+ let canceled = false;
+ void (async () => {
+ setIsLoading(true);
+ const next = new Map();
+ await Promise.all(
+ stableIds.map(async (id) => {
+ if (canceled) return;
+ try {
+ const info = await modelInfo({
+ name: id,
+ additionalFields: ["safetensors"],
+ });
+ const raw = info as { safetensors?: { total?: number } };
+ const total = raw.safetensors?.total;
+ if (typeof total === "number" && total > 0) {
+ next.set(id, total);
+ }
+ } catch {
+ // Model not on HF or no safetensors; skip
+ }
+ }),
+ );
+ if (!canceled) {
+ setParamCountById(next);
+ setIsLoading(false);
+ }
+ })();
+ return () => {
+ canceled = true;
+ };
+ }, [stableKey]);
+
+ return { paramCountById, isLoading };
+}
diff --git a/studio/frontend/src/lib/copy-to-clipboard.ts b/studio/frontend/src/lib/copy-to-clipboard.ts
new file mode 100644
index 0000000000..3ef3df1177
--- /dev/null
+++ b/studio/frontend/src/lib/copy-to-clipboard.ts
@@ -0,0 +1,44 @@
+/**
+ * Copy text to clipboard in a way that works on Mac/Safari.
+ * Uses a synchronous textarea + execCommand fallback so the copy runs in the
+ * same user gesture as the click (required by Safari's clipboard security).
+ */
+export function copyToClipboard(text: string): boolean {
+ if (typeof text !== "string" || text.length === 0) {
+ return false;
+ }
+
+ // Synchronous fallback: works in Safari/Mac when clipboard API fails
+ // because it runs entirely within the user gesture (click) stack.
+ if (document.queryCommandSupported?.("copy") !== false) {
+ const textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.style.position = "fixed";
+ textarea.style.top = "0";
+ textarea.style.left = "0";
+ textarea.style.opacity = "0";
+ textarea.setAttribute("aria-hidden", "true");
+ document.body.appendChild(textarea);
+ textarea.focus({ preventScroll: true });
+ textarea.select();
+ try {
+ const ok = document.execCommand("copy");
+ document.body.removeChild(textarea);
+ return ok;
+ } catch {
+ document.body.removeChild(textarea);
+ return false;
+ }
+ }
+
+ // Modern API only when fallback not available (e.g. non-browser)
+ if (typeof navigator?.clipboard?.writeText === "function") {
+ navigator.clipboard.writeText(text).then(
+ () => {},
+ () => {}
+ );
+ return true;
+ }
+
+ return false;
+}
diff --git a/studio/frontend/src/speech-recognition.d.ts b/studio/frontend/src/speech-recognition.d.ts
new file mode 100644
index 0000000000..c583b200a6
--- /dev/null
+++ b/studio/frontend/src/speech-recognition.d.ts
@@ -0,0 +1,49 @@
+/**
+ * Minimal Web Speech API (Speech Recognition) types for browsers that support it.
+ * Full types: @types/dom-speech-recognition
+ */
+interface SpeechRecognitionResultList {
+ readonly length: number;
+ item(index: number): SpeechRecognitionResult;
+ [index: number]: SpeechRecognitionResult;
+}
+
+interface SpeechRecognitionResult {
+ readonly length: number;
+ readonly isFinal: boolean;
+ item(index: number): SpeechRecognitionAlternative;
+ [index: number]: SpeechRecognitionAlternative;
+}
+
+interface SpeechRecognitionAlternative {
+ readonly transcript: string;
+ readonly confidence: number;
+}
+
+interface SpeechRecognitionEvent extends Event {
+ readonly resultIndex: number;
+ readonly results: SpeechRecognitionResultList;
+}
+
+interface SpeechRecognition extends EventTarget {
+ continuous: boolean;
+ interimResults: boolean;
+ lang: string;
+ onresult: ((event: SpeechRecognitionEvent) => void) | null;
+ onerror: ((event: Event) => void) | null;
+ onend: (() => void) | null;
+ start(): void;
+ stop(): void;
+ abort(): void;
+}
+
+interface SpeechRecognitionConstructor {
+ new (): SpeechRecognition;
+}
+
+interface Window {
+ SpeechRecognition?: SpeechRecognitionConstructor;
+ webkitSpeechRecognition?: SpeechRecognitionConstructor;
+}
+
+declare var SpeechRecognition: SpeechRecognitionConstructor | undefined;