From e7d32a6461cc03dcdce7ad0cc8e517615d4c92eb Mon Sep 17 00:00:00 2001 From: samit Date: Fri, 20 Feb 2026 22:45:41 -0800 Subject: [PATCH 01/16] updated stop button to unavailable during cancel training --- .../studio/sections/progress-section.tsx | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/studio/frontend/src/features/studio/sections/progress-section.tsx b/studio/frontend/src/features/studio/sections/progress-section.tsx index 6e88c4c05a..a04273f568 100644 --- a/studio/frontend/src/features/studio/sections/progress-section.tsx +++ b/studio/frontend/src/features/studio/sections/progress-section.tsx @@ -30,7 +30,7 @@ import { ZapIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { useState, type ReactElement, type ReactNode } from "react"; +import { useEffect, useState, type ReactElement, type ReactNode } from "react"; import { Link, useNavigate } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useGpuUtilization } from "@/hooks"; @@ -83,6 +83,13 @@ export function ProgressSection(): ReactElement { const { stopTrainingRun } = useTrainingActions(); const gpu = useGpuUtilization(runtime.isTrainingRunning); const [stopDialogOpen, setStopDialogOpen] = useState(false); + const [stopRequested, setStopRequested] = useState(false); + + useEffect(() => { + if (!runtime.isTrainingRunning) { + setStopRequested(false); + } + }, [runtime.isTrainingRunning]); const pct = runtime.totalSteps > 0 @@ -209,11 +216,12 @@ export function ProgressSection(): ReactElement { data-tour="studio-training-stop" variant="destructive" size="sm" - className="h-7 cursor-pointer px-3 text-xs" + className={`h-7 px-3 text-xs ${stopRequested ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`} onClick={() => setStopDialogOpen(true)} - disabled={!runtime.isTrainingRunning} + disabled={!runtime.isTrainingRunning || stopRequested} > - Stop + + {stopRequested ? "Stopping…" : "Stop"} @@ -226,12 +234,24 @@ export function ProgressSection(): ReactElement { Continue Training void stopTrainingRun(false)} + onClick={() => { + setStopRequested(true); + setStopDialogOpen(false); + void stopTrainingRun(false).then((ok) => { + if (!ok) setStopRequested(false); + }); + }} > Cancel Training void stopTrainingRun(true)} + onClick={() => { + setStopRequested(true); + setStopDialogOpen(false); + void stopTrainingRun(true).then((ok) => { + if (!ok) setStopRequested(false); + }); + }} > Stop and Save From c815fc045db4374c58918db5978226c6f38750d7 Mon Sep 17 00:00:00 2001 From: imagineer99 Date: Sun, 22 Feb 2026 01:45:51 +0000 Subject: [PATCH 02/16] feat: sort unsloth models first in HF search dropdowns --- studio/frontend/src/hooks/use-hf-model-search.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/studio/frontend/src/hooks/use-hf-model-search.ts b/studio/frontend/src/hooks/use-hf-model-search.ts index 4745b4dc7b..2ec100b1bd 100644 --- a/studio/frontend/src/hooks/use-hf-model-search.ts +++ b/studio/frontend/src/hooks/use-hf-model-search.ts @@ -1,6 +1,6 @@ import type { PipelineType } from "@huggingface/hub"; import { listModels } from "@huggingface/hub"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { useHfPaginatedSearch } from "./use-hf-paginated-search"; export interface HfModelResult { @@ -84,5 +84,17 @@ export function useHfModelSearch( [query, task, accessToken], ); - return useHfPaginatedSearch(createIter, mapModel); + const search = useHfPaginatedSearch(createIter, mapModel); + + const results = useMemo( + () => + [...search.results].sort((a, b) => { + const aFirst = a.id.startsWith("unsloth/") ? 0 : 1; + const bFirst = b.id.startsWith("unsloth/") ? 0 : 1; + return aFirst - bFirst; + }), + [search.results], + ); + + return { ...search, results }; } From 52a2bfe016a3c9b9926ddbb3591929e3c8fac876 Mon Sep 17 00:00:00 2001 From: imagineer99 Date: Sun, 22 Feb 2026 02:39:56 +0000 Subject: [PATCH 03/16] fix: resolve image preview thumbnail not rendering before send --- .../components/assistant-ui/attachment.tsx | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/attachment.tsx b/studio/frontend/src/components/assistant-ui/attachment.tsx index 06c7d90d07..c53c134ea2 100644 --- a/studio/frontend/src/components/assistant-ui/attachment.tsx +++ b/studio/frontend/src/components/assistant-ui/attachment.tsx @@ -26,25 +26,22 @@ import { type FC, type PropsWithChildren, useEffect, - useMemo, useState, } from "react"; import { useShallow } from "zustand/shallow"; const useFileSrc = (file: File | undefined): string | undefined => { - const objectUrl = useMemo( - () => (file ? URL.createObjectURL(file) : undefined), - [file], - ); + const [objectUrl, setObjectUrl] = useState(undefined); useEffect(() => { - if (!objectUrl) { - return undefined; + if (!file) { + setObjectUrl(undefined); + return; } - return () => { - URL.revokeObjectURL(objectUrl); - }; - }, [objectUrl]); + const url = URL.createObjectURL(file); + setObjectUrl(url); + return () => URL.revokeObjectURL(url); + }, [file]); return objectUrl; }; From c726cff4c8e8d634231c6799a05f32dae239630c Mon Sep 17 00:00:00 2001 From: Shine1i Date: Sun, 22 Feb 2026 07:08:18 +0100 Subject: [PATCH 04/16] feat: add utils for deep cloning content and attachments in thread messages --- .../src/features/chat/runtime-provider.tsx | 54 +++++++++++++------ studio/frontend/src/features/chat/types.ts | 1 + 2 files changed, 40 insertions(+), 15 deletions(-) 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/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; } From ac83e8b6686567b99458a4a6d3096ed666d3e612 Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Sun, 22 Feb 2026 10:15:24 +0400 Subject: [PATCH 05/16] Revert "fix(chat): persist + hydrate user attachments in IndexedDB history" --- studio/backend/core/inference/inference.py | 169 +++++------ .../assistant-ui/model-selector/pickers.tsx | 173 ++--------- studio/frontend/src/components/navbar.tsx | 2 +- .../src/features/chat/api/chat-adapter.ts | 9 - .../frontend/src/features/chat/chat-page.tsx | 15 +- .../chat/hooks/use-chat-model-runtime.ts | 24 +- .../src/features/chat/runtime-provider.tsx | 54 +--- .../src/features/chat/shared-composer.tsx | 282 ++---------------- studio/frontend/src/features/chat/types.ts | 1 - studio/frontend/src/hooks/index.ts | 1 - .../src/hooks/use-recommended-model-vram.ts | 57 ---- studio/frontend/src/speech-recognition.d.ts | 49 --- 12 files changed, 146 insertions(+), 690 deletions(-) delete mode 100644 studio/frontend/src/hooks/use-recommended-model-vram.ts delete mode 100644 studio/frontend/src/speech-recognition.d.ts diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py index 6423e7a128..99a7c485e7 100644 --- a/studio/backend/core/inference/inference.py +++ b/studio/backend/core/inference/inference.py @@ -107,23 +107,6 @@ class InferenceBackend: # Apply inference optimization FastVisionModel.for_inference(model) - # FastVisionModel may return a raw tokenizer (e.g. GemmaTokenizerFast) - # instead of a proper Processor for some models (e.g. Gemma-3). - # In that case, load the real processor from the base model. - from transformers import ProcessorMixin - if not (isinstance(processor, ProcessorMixin) or hasattr(processor, "image_processor")): - processor_source = config.base_model if config.is_lora else config.identifier - logger.warning( - f"FastVisionModel returned {type(processor).__name__} (no image_processor) " - f"for '{model_name}' — loading proper processor from '{processor_source}'" - ) - from transformers import AutoProcessor - processor = AutoProcessor.from_pretrained( - processor_source, - token=hf_token if hf_token and hf_token.strip() else None, - ) - logger.info(f"Loaded {type(processor).__name__} from {processor_source}") - self.models[model_name]["model"] = model self.models[model_name]["tokenizer"] = processor self.models[model_name]["processor"] = processor @@ -591,78 +574,59 @@ class InferenceBackend: model_info = self.models[self.active_model_name] is_vision = model_info.get("is_vision", False) tokenizer = model_info.get("tokenizer") or model_info.get("processor") - # Unwrap processor → raw tokenizer for VLMs on the text path - tokenizer = getattr(tokenizer, "tokenizer", tokenizer) top_k = self._normalize_top_k(top_k) - if is_vision and image: - # Vision model generation (only when an image is actually provided) - # Check that the stored processor can actually handle images. - # FastVisionModel may return a raw tokenizer (e.g. GemmaTokenizerFast) - # instead of a proper ProcessorMixin for some models (e.g. Gemma-3). - from transformers import ProcessorMixin - processor = model_info.get("processor") - has_image_processing = ( - processor is not None - and (isinstance(processor, ProcessorMixin) or hasattr(processor, "image_processor")) + if is_vision: + # Vision model generation + yield from self._generate_vision_response( + messages, system_prompt, image, + temperature, top_p, top_k, min_p, max_new_tokens, repetition_penalty, + cancel_event=cancel_event, ) - if has_image_processing: - yield from self._generate_vision_response( - messages, system_prompt, image, - temperature, top_p, top_k, min_p, max_new_tokens, repetition_penalty, - cancel_event=cancel_event, - ) - return - else: - logger.warning( - f"Model '{self.active_model_name}' is marked as vision but its processor " - f"({type(processor).__name__}) has no image_processor — " - f"falling back to text-only generation (image will be ignored)." + else: + # Text model: Use training pipeline approach + # Messages are already in ChatML format from eval.py + + # Step 1: Apply get_chat_template if model is in mapper + try: + from utils.datasets import MODEL_TO_TEMPLATE_MAPPER, get_tokenizer_chat_template + + model_name_lower = self.active_model_name.lower() + + # Check if model has a registered template + if model_name_lower in MODEL_TO_TEMPLATE_MAPPER: + template_name = MODEL_TO_TEMPLATE_MAPPER[model_name_lower] + logger.info(f"Applying chat template '{template_name}' for {self.active_model_name}") + + # This modifies the tokenizer with the correct template + tokenizer = get_chat_template( + tokenizer, + self.active_model_name + ) + else: + logger.info(f"No registered template for {self.active_model_name}, using tokenizer default") + except Exception as e: + logger.warning(f"Could not apply get_chat_template: {e}") + + # Step 2: Format with tokenizer.apply_chat_template() + try: + formatted_prompt = tokenizer.apply_chat_template( + messages, + tokenize=False, + add_generation_prompt=True ) + logger.debug(f"Formatted prompt: {formatted_prompt[:200]}...") + except Exception as e: + logger.error(f"Error applying chat template: {e}") + # Fallback to manual formatting + formatted_prompt = self.format_chat_prompt(messages, system_prompt) - # Text path: Use training pipeline approach - # Messages are already in ChatML format from eval.py - - # Step 1: Apply get_chat_template if model is in mapper - try: - from utils.datasets import MODEL_TO_TEMPLATE_MAPPER, get_tokenizer_chat_template - - model_name_lower = self.active_model_name.lower() - - # Check if model has a registered template - if model_name_lower in MODEL_TO_TEMPLATE_MAPPER: - template_name = MODEL_TO_TEMPLATE_MAPPER[model_name_lower] - logger.info(f"Applying chat template '{template_name}' for {self.active_model_name}") - - # This modifies the tokenizer with the correct template - tokenizer = get_chat_template( - tokenizer, - chat_template=template_name, - ) - else: - logger.info(f"No registered template for {self.active_model_name}, using tokenizer default") - except Exception as e: - logger.warning(f"Could not apply get_chat_template: {e}") - - # Step 2: Format with tokenizer.apply_chat_template() - try: - formatted_prompt = tokenizer.apply_chat_template( - messages, - tokenize=False, - add_generation_prompt=True + # Step 3: Generate + yield from self.generate_stream( + formatted_prompt, temperature, top_p, top_k, min_p, max_new_tokens, repetition_penalty, + cancel_event=cancel_event, + _adapter_state=_adapter_state, ) - logger.debug(f"Formatted prompt: {formatted_prompt[:200]}...") - except Exception as e: - logger.error(f"Error applying chat template: {e}") - # Fallback to manual formatting - formatted_prompt = self.format_chat_prompt(messages, system_prompt) - - # Step 3: Generate - yield from self.generate_stream( - formatted_prompt, temperature, top_p, top_k, min_p, max_new_tokens, repetition_penalty, - cancel_event=cancel_event, - _adapter_state=_adapter_state, - ) def _generate_vision_response(self, messages, system_prompt, image, temperature, top_p, top_k, min_p, max_new_tokens, @@ -671,9 +635,6 @@ class InferenceBackend: model_info = self.models[self.active_model_name] model = model_info["model"] processor = model_info["processor"] - # FastVisionModel may return a raw tokenizer (e.g. GemmaTokenizerFast) - # instead of a Processor for some models. Safe unwrap for tokenize-only ops. - raw_tokenizer = getattr(processor, "tokenizer", processor) # Extract user message user_message = "" @@ -697,7 +658,7 @@ class InferenceBackend: } ] - input_text = processor.apply_chat_template(vision_messages, add_generation_prompt=True, tokenize=False) + input_text = processor.apply_chat_template(vision_messages, add_generation_prompt=True) inputs = processor( image, input_text, @@ -707,7 +668,7 @@ class InferenceBackend: else: # Text-only for vision model formatted_prompt = self.format_chat_prompt(messages, system_prompt) - inputs = raw_tokenizer(formatted_prompt, return_tensors="pt").to(self.device) + inputs = processor.tokenizer(formatted_prompt, return_tensors="pt").to(self.device) # Stream with TextIteratorStreamer + background thread try: @@ -715,7 +676,7 @@ class InferenceBackend: import threading streamer = TextIteratorStreamer( - raw_tokenizer, + processor.tokenizer, skip_prompt=True, skip_special_tokens=True, timeout=0.2, @@ -805,11 +766,7 @@ class InferenceBackend: model_info = self.models[self.active_model_name] model = model_info["model"] - # For VLMs the stored "tokenizer" is actually the processor. - # Unwrap to get the real tokenizer so TextIteratorStreamer's - # skip_prompt / skip_special_tokens work correctly. tokenizer = model_info["tokenizer"] - tokenizer = getattr(tokenizer, "tokenizer", tokenizer) try: inputs = tokenizer(prompt, return_tensors="pt").to(model.device) @@ -919,7 +876,6 @@ class InferenceBackend: chat_template_info = self.models[self.active_model_name].get("chat_template_info", {}) tokenizer = self.models[self.active_model_name]["tokenizer"] - tokenizer = getattr(tokenizer, "tokenizer", tokenizer) chat_messages = [] @@ -1160,13 +1116,24 @@ class InferenceBackend: return img def _clean_generated_text(self, text: str) -> str: - """Strip leaked special tokens using the tokenizer's own token list.""" - tokenizer = self.models.get(self.active_model_name, {}).get("tokenizer") - if tokenizer: - for token in getattr(tokenizer, "all_special_tokens", []): - if token in text: - text = text.replace(token, "") - return text.strip() + import re + + text = re.sub(r'<\|start_header_id\|>.*?<\|end_header_id\|>', '', text) + text = re.sub(r'<\|eot_id\|>', '', text) + text = re.sub(r'<\|begin_of_text\|>', '', text) + + text = re.sub(r'\[INST\].*?\[/INST\]', '', text) + text = re.sub(r'|', '', text) + + # Clean ChatML tokens (used by Qwen2-VL and similar models) + text = re.sub(r'<\|im_start\|>.*?<\|im_end\|>', '', text) + text = re.sub(r'<\|im_end\|>', '', text) + text = re.sub(r'<\|im_start\|>', '', text) + + text = re.sub(r'^\s*(assistant|user|system):\s*', '', text, flags=re.IGNORECASE) + text = text.strip() + + return text def _load_chat_template_info(self, model_name: str): if model_name not in self.models or not self.models[model_name].get("tokenizer"): diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index fc579dd32a..b90cdd0e3b 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -1,20 +1,7 @@ import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { - useDebouncedValue, - useGpuInfo, - useHfModelSearch, - useInfiniteScroll, - useRecommendedModelVram, -} from "@/hooks"; +import { useDebouncedValue, useHfModelSearch, useInfiniteScroll } from "@/hooks"; import { cn, formatCompact } from "@/lib/utils"; -import type { VramFitStatus } from "@/lib/vram"; -import { checkVramFit, estimateLoadingVram } from "@/lib/vram"; import { Search01Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { useMemo, useState, type ReactNode } from "react"; @@ -41,77 +28,27 @@ function ModelRow({ meta, selected, onClick, - vramStatus, - vramEst, - gpuGb, }: { label: string; meta?: string; selected?: boolean; onClick: () => void; - vramStatus?: VramFitStatus | null; - vramEst?: number; - gpuGb?: number; }) { - const exceeds = vramStatus === "exceeds"; - const showVramTooltip = - vramEst != null && vramEst > 0 && gpuGb != null && gpuGb > 0; - const vramTooltipText = - showVramTooltip && vramStatus - ? exceeds - ? `Needs ~${vramEst}GB VRAM (GPU: ${gpuGb}GB)` - : vramStatus === "tight" - ? `~${vramEst}GB VRAM (tight fit on ${gpuGb}GB)` - : `~${vramEst}GB VRAM` - : null; - - const content = ( + return ( ); - - if (vramTooltipText) { - return ( - - {content} - - {label} - {vramTooltipText} - - - ); - } - return content; } export function HubModelPicker({ @@ -123,7 +60,6 @@ export function HubModelPicker({ value?: string; onSelect: (id: string, meta: ModelSelectorChangeMeta) => void; }) { - const gpu = useGpuInfo(); const [query, setQuery] = useState(""); const debouncedQuery = useDebouncedValue(query); const { results, isLoading, isLoadingMore, fetchMore } = useHfModelSearch( @@ -135,9 +71,6 @@ export function HubModelPicker({ [models, value], ); - const { paramCountById: recommendedParamCountById } = - useRecommendedModelVram(recommendedIds); - const showHfSection = debouncedQuery.trim().length > 0; const recommendedSet = useMemo(() => new Set(recommendedIds), [recommendedIds]); @@ -161,49 +94,6 @@ export function HubModelPicker({ [results], ); - const vramMap = useMemo(() => { - const map = new Map< - string, - { est: number; status: VramFitStatus | null; detail: string | null } - >(); - for (const r of results) { - const detail = r.totalParams - ? formatCompact(r.totalParams) - : r.downloads != null - ? `↓${formatCompact(r.downloads)}` - : null; - if (r.totalParams) { - const est = estimateLoadingVram(r.totalParams, "qlora"); - const status = gpu.available - ? checkVramFit(est, gpu.memoryTotalGb) - : null; - map.set(r.id, { est, status, detail }); - } else { - map.set(r.id, { est: 0, status: null, detail }); - } - } - return map; - }, [results, gpu]); - - const recommendedVramMap = useMemo(() => { - const map = new Map< - string, - { est: number; status: VramFitStatus | null; detail: string | null } - >(); - for (const id of recommendedIds) { - const totalParams = recommendedParamCountById.get(id); - if (totalParams) { - const est = estimateLoadingVram(totalParams, "qlora"); - const status = gpu.available - ? checkVramFit(est, gpu.memoryTotalGb) - : null; - const detail = formatCompact(totalParams); - map.set(id, { est, status, detail }); - } - } - return map; - }, [recommendedIds, recommendedParamCountById, gpu]); - const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore, results.length); return ( @@ -234,23 +124,14 @@ export function HubModelPicker({ No default models. ) : ( - recommendedIds.map((id) => { - const vram = recommendedVramMap.get(id); - return ( - - onSelect(id, { source: "hub", isLora: false }) - } - vramStatus={vram?.status ?? null} - vramEst={vram?.est} - gpuGb={gpu.available ? gpu.memoryTotalGb : undefined} - /> - ); - }) + recommendedIds.map((id) => ( + onSelect(id, { source: "hub", isLora: false })} + /> + )) )} ) : null} @@ -263,23 +144,15 @@ export function HubModelPicker({ No matching models. ) : ( - hfIds.map((id) => { - const vram = vramMap.get(id); - return ( - - onSelect(id, { source: "hub", isLora: false }) - } - vramStatus={vram?.status ?? null} - vramEst={vram?.est} - gpuGb={gpu.available ? gpu.memoryTotalGb : undefined} - /> - ); - }) + hfIds.map((id) => ( + onSelect(id, { source: "hub", isLora: false })} + /> + )) )}
{isLoadingMore ? ( diff --git a/studio/frontend/src/components/navbar.tsx b/studio/frontend/src/components/navbar.tsx index d30622f95d..2828cb61f8 100644 --- a/studio/frontend/src/components/navbar.tsx +++ b/studio/frontend/src/components/navbar.tsx @@ -57,7 +57,7 @@ export function Navbar() { }; return ( -
+
{/* Left: logo */}
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}
{modelsError && (
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 ( -
- {file.name} - -
- ); -} - 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)} - /> - ))} -
- )} +