0) {
for (const attachment of message.attachments ?? []) {
for (const part of attachment.content ?? []) {
diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx
index 1184183088..34443ec87f 100644
--- a/studio/frontend/src/features/chat/chat-page.tsx
+++ b/studio/frontend/src/features/chat/chat-page.tsx
@@ -5,7 +5,6 @@ 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,
@@ -284,8 +283,7 @@ 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, loadingModel } =
- useChatModelRuntime();
+ const { refresh, selectModel, ejectModel } = useChatModelRuntime();
const refreshRef = useRef(refresh);
const selectModelRef = useRef(selectModel);
@@ -520,17 +518,6 @@ 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 961967cc9c..03aba01736 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, useState } from "react";
+import { useCallback } from "react";
import { toast } from "sonner";
import {
getInferenceStatus,
@@ -116,11 +116,6 @@ 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 {
@@ -162,7 +157,6 @@ export function useChatModelRuntime() {
const displayName = model?.name || lora?.name || modelId;
setModelsError(null);
- setLoadingModel({ id: modelId, displayName });
try {
async function performLoad(): Promise
{
if (params.checkpoint) {
@@ -182,20 +176,19 @@ export function useChatModelRuntime() {
await refresh();
}
- const loadPromise = performLoad().finally(() => {
- setLoadingModel(null);
- });
+ let description = "Base model selected.";
+ if (isLora) {
+ description = "Fine-tuned (LoRA) selected.";
+ }
- await toast.promise(loadPromise, {
- loading: "Loading model…",
+ await toast.promise(performLoad(), {
+ loading: `Loading ${displayName}`,
success: `${displayName} loaded`,
error: (err) =>
err instanceof Error ? err.message : "Failed to load model",
- description:
- "This may include downloading. Large models can take a while.",
+ description,
});
} catch (error) {
- setLoadingModel(null);
const message =
error instanceof Error ? error.message : "Failed to load model";
setModelsError(message);
@@ -234,6 +227,5 @@ export function useChatModelRuntime() {
refresh,
selectModel,
ejectModel,
- loadingModel,
};
}
diff --git a/studio/frontend/src/features/chat/runtime-provider.tsx b/studio/frontend/src/features/chat/runtime-provider.tsx
index 9f99d309c7..28bcf83421 100644
--- a/studio/frontend/src/features/chat/runtime-provider.tsx
+++ b/studio/frontend/src/features/chat/runtime-provider.tsx
@@ -35,14 +35,6 @@ const DEFAULT_SUGGESTIONS = [
"Format a comparison of 3 databases as a markdown table with pros and cons",
];
-type TitleResponse = {
- choices?: Array<{
- message?: {
- content?: string;
- };
- }>;
-};
-
class VisionImageAdapter implements AttachmentAdapter {
accept = "image/jpeg,image/png,image/webp,image/gif";
@@ -224,7 +216,7 @@ async function generateTitleWithModel(payload: {
}),
});
- const body = (await response.json().catch(() => null)) as TitleResponse | null;
+ const body = (await response.json().catch(() => null)) as any;
if (!response.ok) return null;
const raw: string | undefined = body?.choices?.[0]?.message?.content;
if (!raw) return null;
@@ -241,42 +233,27 @@ function fallbackTitleFromUserText(userText: string): string {
return cleaned.slice(0, max) + (cleaned.length > max ? "..." : "");
}
-function cloneContent(content: ThreadMessage["content"]): ThreadMessage["content"] {
- return Array.isArray(content)
- ? JSON.parse(JSON.stringify(content))
- : [];
-}
-
-function cloneAttachments(
- attachments: readonly CompleteAttachment[] | undefined,
-): readonly CompleteAttachment[] {
- if (!Array.isArray(attachments)) {
- return [];
- }
- return JSON.parse(JSON.stringify(attachments));
-}
-
function toThreadMessage(m: MessageRecord): ThreadMessage {
- const content =
- Array.isArray(m.content) && m.content.length > 0
- ? cloneContent(m.content)
- : [{ type: "text" as const, text: "" }];
+ const base = {
+ id: m.id,
+ createdAt: new Date(m.createdAt),
+ content:
+ Array.isArray(m.content) && m.content.length > 0
+ ? m.content
+ : [{ type: "text" as const, text: "" }],
+ };
if (m.role === "user") {
return {
- id: m.id,
- createdAt: new Date(m.createdAt),
+ ...base,
role: "user" as const,
- content: content as Extract["content"],
- attachments: cloneAttachments(m.attachments),
+ attachments: [],
metadata: { custom: {} },
};
}
return {
- id: m.id,
- createdAt: new Date(m.createdAt),
+ ...base,
role: "assistant" as const,
- content: content as Extract["content"],
status: { type: "complete" as const, reason: "unknown" as const },
metadata: {
custom: (m.metadata as Record) ?? {},
@@ -464,9 +441,9 @@ function ThreadHistoryProvider({
async append({ message }: ExportedMessageRepositoryItem) {
const { remoteId } = await aui.threadListItem().initialize();
- const content = cloneContent(message.content);
- const attachments =
- message.role === "user" ? cloneAttachments(message.attachments) : [];
+ const content = Array.isArray(message.content)
+ ? JSON.parse(JSON.stringify(message.content))
+ : [];
const custom = message.metadata?.custom;
const existing = await db.messages.get(message.id);
const createdAt =
@@ -478,7 +455,6 @@ function ThreadHistoryProvider({
threadId: remoteId,
role: message.role,
content,
- ...(attachments.length > 0 && { attachments }),
...(custom && Object.keys(custom).length > 0 && { metadata: custom }),
createdAt,
});
diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx
index 6b3fc29d9e..f849d3f5b7 100644
--- a/studio/frontend/src/features/chat/shared-composer.tsx
+++ b/studio/frontend/src/features/chat/shared-composer.tsx
@@ -1,102 +1,25 @@
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import { useAui } from "@assistant-ui/react";
-import { ArrowUpIcon, MicIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
+import { ArrowUpIcon, SquareIcon } 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: CompareMessagePart[]) => void;
+ append: (content: { type: "text"; text: string }[]) => 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);
@@ -143,37 +66,6 @@ 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,
}: {
@@ -181,14 +73,7 @@ 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(() => {
@@ -199,50 +84,23 @@ export function SharedComposer({
return () => clearInterval(id);
}, [handlesRef]);
- 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() {
+ 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) {
+ return;
}
- 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();
}
@@ -257,33 +115,8 @@ 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)}
- />
- ))}
-
- )}
+
-
-
-
- {dictationSupported && (
- <>
- {!isDictating ? (
-
-
-
- ) : (
-
-
-
- )}
- >
- )}
- {running ? (
-
- ) : (
-
-
-
- )}
-
+ )}
);
diff --git a/studio/frontend/src/features/chat/types.ts b/studio/frontend/src/features/chat/types.ts
index 01fa4fe200..b0dccab307 100644
--- a/studio/frontend/src/features/chat/types.ts
+++ b/studio/frontend/src/features/chat/types.ts
@@ -18,7 +18,6 @@ export interface MessageRecord {
threadId: string;
role: import("@assistant-ui/react").ThreadMessage["role"];
content: import("@assistant-ui/react").ThreadMessage["content"];
- attachments?: import("@assistant-ui/react").ThreadMessage["attachments"];
metadata?: Record
;
createdAt: number;
}
diff --git a/studio/frontend/src/hooks/index.ts b/studio/frontend/src/hooks/index.ts
index 3932baf088..b6c4fcad14 100644
--- a/studio/frontend/src/hooks/index.ts
+++ b/studio/frontend/src/hooks/index.ts
@@ -3,7 +3,6 @@ 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
deleted file mode 100644
index 69692ff416..0000000000
--- a/studio/frontend/src/hooks/use-recommended-model-vram.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-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/speech-recognition.d.ts b/studio/frontend/src/speech-recognition.d.ts
deleted file mode 100644
index c583b200a6..0000000000
--- a/studio/frontend/src/speech-recognition.d.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-/**
- * 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;
From 3015916d26f412f682d77457baedd8765d839d62 Mon Sep 17 00:00:00 2001
From: Roland Tannous
Date: Mon, 23 Feb 2026 12:21:06 +0000
Subject: [PATCH 2/2] fix: error on >30% sample drop after
train_on_responses_only instead of silent DataLoader crash
---
studio/backend/core/training/trainer.py | 37 ++++++++++++++++++++++++-
1 file changed, 36 insertions(+), 1 deletion(-)
diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py
index e996603ae6..063d87ead6 100644
--- a/studio/backend/core/training/trainer.py
+++ b/studio/backend/core/training/trainer.py
@@ -874,6 +874,41 @@ class UnslothTrainer:
num_proc=config_args.get("dataset_num_proc", max(1, os.cpu_count() // 4)),
)
print("Train on responses only configured successfully\n")
+
+ # ── Safety net: check if all samples were filtered out ──
+ # Unsloth's train_on_responses_only masks non-response
+ # tokens with -100. If max_seq_length is too short and the
+ # response portion gets truncated away, EVERY sample ends
+ # up with all labels == -100 and Unsloth removes them,
+ # leaving 0 usable training samples.
+ filtered_len = len(self.trainer.train_dataset)
+ original_len = len(dataset["dataset"])
+ dropped = original_len - filtered_len
+ drop_pct = round(100 * dropped / original_len, 1) if original_len > 0 else 0
+
+ if filtered_len == 0 or drop_pct > 30:
+ max_seq = training_args.get('max_seq_length', 2048)
+ error_msg = (
+ f"{dropped}/{original_len} samples ({drop_pct}%) "
+ f"were dropped after applying 'train on responses "
+ f"only' — only {filtered_len} remain. This usually "
+ f"means max_seq_length ({max_seq}) is too short "
+ f"and the response portion is being truncated "
+ f"away. Try increasing max_seq_length (e.g. 8192) "
+ f"or disabling 'Train on completions'."
+ )
+ logger.error(error_msg)
+ self._update_progress(error=error_msg, is_training=False)
+ return
+
+ if dropped > 0:
+ print(
+ f"⚠️ {dropped}/{original_len} samples "
+ f"({drop_pct}%) were dropped (all labels "
+ f"masked). {filtered_len} samples remain.\n"
+ )
+ print(f"Post-filter dataset size: {filtered_len} samples\n")
+
except Exception as e:
logger.warning(f"Failed to apply train on responses only: {e}")
train_on_responses_enabled = False
@@ -955,7 +990,7 @@ class UnslothTrainer:
progress_callback = ProgressCallback(self)
self.trainer.add_callback(progress_callback)
- num_samples = len(dataset["dataset"])
+ num_samples = len(self.trainer.train_dataset)
batch_size = training_args.get('batch_size', 2)
grad_accum = training_args.get('gradient_accumulation_steps', 4)
num_epochs = training_args.get('num_epochs', 3)