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 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/runtime-provider.tsx b/studio/frontend/src/features/chat/runtime-provider.tsx
index 28bcf83421..9f99d309c7 100644
--- a/studio/frontend/src/features/chat/runtime-provider.tsx
+++ b/studio/frontend/src/features/chat/runtime-provider.tsx
@@ -35,6 +35,14 @@ 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";
@@ -216,7 +224,7 @@ async function generateTitleWithModel(payload: {
}),
});
- const body = (await response.json().catch(() => null)) as any;
+ const body = (await response.json().catch(() => null)) as TitleResponse | null;
if (!response.ok) return null;
const raw: string | undefined = body?.choices?.[0]?.message?.content;
if (!raw) return null;
@@ -233,27 +241,42 @@ 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 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: "" }],
- };
+ const content =
+ Array.isArray(m.content) && m.content.length > 0
+ ? cloneContent(m.content)
+ : [{ type: "text" as const, text: "" }];
if (m.role === "user") {
return {
- ...base,
+ id: m.id,
+ createdAt: new Date(m.createdAt),
role: "user" as const,
- attachments: [],
+ content: content as Extract["content"],
+ attachments: cloneAttachments(m.attachments),
metadata: { custom: {} },
};
}
return {
- ...base,
+ id: m.id,
+ createdAt: new Date(m.createdAt),
role: "assistant" as const,
+ content: content as Extract["content"],
status: { type: "complete" as const, reason: "unknown" as const },
metadata: {
custom: (m.metadata as Record) ?? {},
@@ -441,9 +464,9 @@ function ThreadHistoryProvider({
async append({ message }: ExportedMessageRepositoryItem) {
const { remoteId } = await aui.threadListItem().initialize();
- const content = Array.isArray(message.content)
- ? JSON.parse(JSON.stringify(message.content))
- : [];
+ const content = cloneContent(message.content);
+ const attachments =
+ message.role === "user" ? cloneAttachments(message.attachments) : [];
const custom = message.metadata?.custom;
const existing = await db.messages.get(message.id);
const createdAt =
@@ -455,6 +478,7 @@ 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 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/chat/types.ts b/studio/frontend/src/features/chat/types.ts
index b0dccab307..01fa4fe200 100644
--- a/studio/frontend/src/features/chat/types.ts
+++ b/studio/frontend/src/features/chat/types.ts
@@ -18,6 +18,7 @@ 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 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/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;