// SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon"; import { thinkEffortAriaLabel, thinkToggleAriaLabel, } from "@/components/assistant-ui/think-aria-label"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { applyQwenThinkingParams } from "@/features/chat/utils/qwen-params"; import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils"; import { isTauri } from "@/lib/api-base"; import { isMultimodalResponse } from "./types/api"; import { getImageInputUnavailableReason } from "./utils/image-input-support"; import { useAui } from "@assistant-ui/react"; import { ArrowUpIcon, BookOpenIcon, DownloadIcon, FileTextIcon, GlobeIcon, HeadphonesIcon, LightbulbIcon, LightbulbOffIcon, MicIcon, PlusIcon, SquareIcon, XIcon, } from "lucide-react"; import { Image03Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { cancelJob, subscribeToJobEvents } from "@/features/rag/api/rag-api"; import { useIndexProgressStore } from "@/features/rag/stores/index-progress-store"; import { useRagStore } from "@/features/rag/stores/rag-store"; import { acquireIndexSlot, releaseIndexSlot } from "./utils/rag-index-queue"; import { toast } from "@/lib/toast"; import { loadModel, validateModel } from "./api/chat-api"; import { parseExternalModelId, providerTypeSupportsVision } from "./external-providers"; import { useExternalProvidersStore } from "./stores/external-providers-store"; import { type ReasoningEffort, useChatRuntimeStore, } from "./stores/chat-runtime-store"; import { getExternalReasoningCapabilities, providerSupportsBuiltinCodeExecution, providerSupportsBuiltinImageGeneration, providerSupportsBuiltinWebFetch, } from "./provider-capabilities"; import { type CompositionEvent, 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 } | { type: "audio"; audio: string }; export interface CompareHandle { append: (content: CompareMessagePart[]) => void; /** Append a user message without triggering generation. */ appendMessage: (content: CompareMessagePart[]) => void; /** Trigger generation on the current thread (after appendMessage). */ startRun: () => void; cancel: () => void; isRunning: () => boolean; /** Returns a promise that resolves when the current or next run finishes. */ waitForRunEnd: () => Promise; } const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif"; const DOCUMENT_ACCEPT = ".pdf,.txt,.md,.markdown,.docx,.html,.htm"; const DOCUMENT_EXTENSIONS = new Set([ ".pdf", ".txt", ".md", ".markdown", ".docx", ".html", ".htm", ]); function isDocumentFile(file: File): boolean { const lower = file.name.toLowerCase(); const dot = lower.lastIndexOf("."); if (dot < 0) return false; return DOCUMENT_EXTENSIONS.has(lower.slice(dot)); } type PendingDoc = { id: string; file: File; status: "uploading" | "ingesting" | "ready" | "error"; jobId?: string; documentId?: string; errorMessage?: string; }; const MAX_IMAGE_SIZE = 20 * 1024 * 1024; function isNativeComposing(event: Event) { return "isComposing" in event && (event as InputEvent).isComposing === true; } // Mirrors the threshold in thread.tsx — see the comment there. Chrome on // Windows-over-WSL (issue #5546) never fires `compositionend` after the // IME commit, so the compose flag would otherwise stay true forever. const IME_STUCK_TIMEOUT_MS = 2500; 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 formatReasoningEffortLabel(level: ReasoningEffort, modelId?: string): string { if (level === "max") return "Max"; if (level === "xhigh") { const normalized = modelId?.trim().toLowerCase() ?? ""; if ( normalized.startsWith("claude-opus-4-6") || normalized.startsWith("claude-sonnet-4-6") ) { return "Max"; } return "Extra High"; } return level.charAt(0).toUpperCase() + level.slice(1); } function formatReasoningDisabledLabel( supportsReasoningOff: boolean, isExternalOpenAIReasoning: boolean, modelId?: string, ): string { const normalized = modelId?.trim().toLowerCase() ?? ""; // Magistral keeps the "none" wire value, but UX should present this floor // as "Medium" rather than a disabled state label. if (normalized.includes("magistral-medium-latest")) return "Medium"; return supportsReasoningOff && isExternalOpenAIReasoning ? "None" : "Off"; } 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); export function CompareHandlesProvider({ handlesRef, children, }: { handlesRef: CompareHandles; children: ReactNode; }): ReactElement { return ( {children} ); } export function RegisterCompareHandle({ name, }: { name: string; }): ReactElement | null { const handlesRef = useContext(CompareHandlesContext); const aui = useAui(); useEffect(() => { if (!handlesRef) { return; } const currentHandles = handlesRef.current; currentHandles[name] = { // fixes occasional reorder on reload. append: (content) => aui.thread().append({ role: "user", content, createdAt: new Date() } as never), appendMessage: (content) => aui.thread().append({ role: "user", content, createdAt: new Date(), startRun: false } as never), startRun: () => { const msgs = aui.thread().getState().messages; const lastId = msgs.length > 0 ? msgs[msgs.length - 1].id : null; aui.thread().startRun({ parentId: lastId }); }, cancel: () => aui.thread().cancelRun(), isRunning: () => aui.thread().getState().isRunning, waitForRunEnd: () => new Promise((resolve) => { let wasRunning = false; const unsub = useChatRuntimeStore.subscribe((state) => { const anyRunning = Object.keys(state.runningByThreadId).length > 0; if (anyRunning) wasRunning = true; if (wasRunning && !anyRunning) { unsub(); resolve(); } }); }), }; return () => { delete currentHandles[name]; }; }, [handlesRef, name, aui]); 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}
); } type CompareModelSelection = { id: string; isLora: boolean; ggufVariant?: string; }; export function SharedComposer({ handlesRef, model1, model2, }: { handlesRef: CompareHandles; model1?: CompareModelSelection; model2?: CompareModelSelection; }): ReactElement { const [text, setText] = useState(""); const [running, setRunning] = useState(false); const [comparing, setComparing] = useState(false); const [pendingImages, setPendingImages] = useState([]); const [pendingAudio, setPendingAudio] = useState<{ name: string; base64: string } | null>(null); const [pendingDocs, setPendingDocs] = useState([]); const [dragging, setDragging] = useState(false); const [isComposing, setIsComposing] = useState(false); const textareaRef = useRef(null); const composingRef = useRef(false); const stuckImeTimerRef = useRef | null>(null); const fileInputRef = useRef(null); const audioInputRef = useRef(null); const activeModel = useChatRuntimeStore((s) => { const checkpoint = s.params.checkpoint; return s.models.find((m) => m.id === checkpoint); }); const aui = useAui(); const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId); const ragSource = useChatRuntimeStore((s) => s.ragSource); const setRagSource = useChatRuntimeStore((s) => s.setRagSource); const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint); const connectionsEnabled = useExternalProvidersStore( (s) => s.connectionsEnabled, ); const externalProvidersAll = useExternalProvidersStore((s) => s.providers); const externalProviders = connectionsEnabled ? externalProvidersAll : []; const modelLoaded = useChatRuntimeStore( (s) => !!s.params.checkpoint && !s.modelLoading, ); const loadedIsMultimodal = useChatRuntimeStore((s) => s.loadedIsMultimodal); const supportsReasoning = useChatRuntimeStore((s) => s.supportsReasoning); const reasoningAlwaysOn = useChatRuntimeStore((s) => s.reasoningAlwaysOn); const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled); const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled); const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle); const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort); const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff); const reasoningEffortLevels = useChatRuntimeStore((s) => s.reasoningEffortLevels); const setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort); const supportsPreserveThinking = useChatRuntimeStore((s) => s.supportsPreserveThinking); const preserveThinking = useChatRuntimeStore((s) => s.preserveThinking); const setPreserveThinking = useChatRuntimeStore((s) => s.setPreserveThinking); const supportsTools = useChatRuntimeStore((s) => s.supportsTools); const supportsBuiltinWebSearch = useChatRuntimeStore( (s) => s.supportsBuiltinWebSearch, ); const toolsEnabled = useChatRuntimeStore((s) => s.toolsEnabled); const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled); const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled); const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled); const imageToolsEnabled = useChatRuntimeStore((s) => s.imageToolsEnabled); const setImageToolsEnabled = useChatRuntimeStore( (s) => s.setImageToolsEnabled, ); const ragToolEnabled = useChatRuntimeStore((s) => s.ragToolEnabled); const setRagToolEnabled = useChatRuntimeStore((s) => s.setRagToolEnabled); const webFetchToolsEnabled = useChatRuntimeStore( (s) => s.webFetchToolsEnabled, ); const setWebFetchToolsEnabled = useChatRuntimeStore( (s) => s.setWebFetchToolsEnabled, ); const lastOpenRouterChosenModel = useChatRuntimeStore( (s) => s.lastOpenRouterChosenModel, ); const externalSelection = parseExternalModelId(checkpoint); const isExternalModel = externalSelection !== null; const selectedExternalProvider = externalSelection != null ? externalProviders.find((p) => p.id === externalSelection.providerId) : undefined; const imageUnavailableReason = getImageInputUnavailableReason({ activeModel, isExternalModel, externalSupportsVision: providerTypeSupportsVision( selectedExternalProvider?.providerType, ), externalModelLabel: externalSelection?.modelId ?? null, loadedIsMultimodal, modelLoaded, }); const isCompareMode = Boolean(model1?.id || model2?.id); // Attach-time gate. Compare mode defers to send: the catalog can lag // behind a model's real capabilities (e.g., a GGUF whose mmproj // arrives after the catalog snapshot), and we only sync the models[] // entry after ensureModelLoaded runs at send time. Single mode uses // the loaded model's runtime capability. const attachUnavailableReason = isCompareMode ? null : imageUnavailableReason; const effectiveExternalModelId = selectedExternalProvider?.providerType === "openrouter" && externalSelection?.modelId === "openrouter/free" && lastOpenRouterChosenModel ? lastOpenRouterChosenModel : externalSelection?.modelId; const externalReasoningCaps = externalSelection != null ? getExternalReasoningCapabilities( selectedExternalProvider?.providerType, effectiveExternalModelId, { isReasoningProvider: selectedExternalProvider?.isReasoningModel === true, baseUrl: selectedExternalProvider?.baseUrl ?? null, }, ) : null; const isExternalOpenAIReasoning = externalReasoningCaps?.supportsReasoning === true && externalReasoningCaps.reasoningStyle === "reasoning_effort"; const effectiveReasoningStyle = externalReasoningCaps?.reasoningStyle ?? reasoningStyle; const effectiveReasoningAlwaysOn = externalReasoningCaps?.reasoningAlwaysOn ?? reasoningAlwaysOn; const effectiveSupportsReasoningOff = externalReasoningCaps?.supportsReasoningOff ?? supportsReasoningOff; const effectiveReasoningEffortLevels = externalReasoningCaps?.reasoningEffortLevels ?? reasoningEffortLevels; const effectiveSupportsReasoning = externalReasoningCaps?.supportsReasoning ?? supportsReasoning; const reasoningLockedOn = effectiveSupportsReasoning && (effectiveReasoningAlwaysOn || !effectiveSupportsReasoningOff); // Kimi's $web_search builtin mandates thinking=disabled per the docs at // https://platform.kimi.ai/docs/guide/use-web-search. Both pills stay // clickable for Kimi, but turning one on flips the other off — the // click handlers below enforce this mutual exclusion so the visible // state always matches what the backend actually sends. const isKimiExternal = selectedExternalProvider?.providerType === "kimi"; const effectiveReasoningEnabled = reasoningLockedOn ? true : reasoningEnabled; const effectiveReasoningVisualEnabled = effectiveReasoningEnabled && reasoningEffort !== "none"; const reasoningDisabled = !modelLoaded || !effectiveSupportsReasoning; const showReasoningControl = effectiveSupportsReasoning || effectiveReasoningAlwaysOn; // Two-pill gating: Search pill lights up when the runtime has either // a local tool runtime (supportsTools, gives us our Code/python + local // web_search) OR a server-side web_search the provider runs for us // (supportsBuiltinWebSearch, currently OpenAI / Anthropic / OpenRouter // / Kimi). Code pill lights up on the local runtime OR when Anthropic // is selected with a model that accepts the server-side // code_execution_20250825 tool — see // providerSupportsBuiltinCodeExecution. Anthropic is the only external // provider that ships a code-execution tool today. const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution( selectedExternalProvider?.providerType, effectiveExternalModelId, selectedExternalProvider?.baseUrl, ); const supportsBuiltinImageGeneration = providerSupportsBuiltinImageGeneration( selectedExternalProvider?.providerType, effectiveExternalModelId, selectedExternalProvider?.baseUrl, ); const supportsBuiltinWebFetch = providerSupportsBuiltinWebFetch( selectedExternalProvider?.providerType, ); // Gemini rejects codeExecution alongside image modalities. Search is // blocked on older Gemini image ids but allowed on Gemini 3 image // models -- supportsBuiltinWebSearch already encodes the per-model // allowance, so we only disable Code unconditionally in Gemini // image mode. const isExternalGemini = selectedExternalProvider?.providerType === "gemini"; const imageDisabled = !modelLoaded || !supportsBuiltinImageGeneration; const imageModeDisablesCode = isExternalGemini && imageToolsEnabled && !imageDisabled; // Image-tier Gemini models always reject codeExecution and reject // web_search on older ids (Gemini 3.x Pro/Flash allow it -- encoded // in supportsBuiltinWebSearch). Don't let the local `supportsTools` // runtime flag re-enable a pill the Gemini backend will silently // drop. Detect "external provider is Gemini AND model is image-tier" // and gate strictly on the provider builtin support. const isGeminiImageTier = isExternalGemini && supportsBuiltinImageGeneration; const searchDisabled = !modelLoaded || (isGeminiImageTier ? !supportsBuiltinWebSearch : !(supportsTools || supportsBuiltinWebSearch)); const codeDisabled = !modelLoaded || (isGeminiImageTier ? true : !(supportsTools || supportsBuiltinCodeExecution)) || imageModeDisablesCode; // Images pill is only ever lit on OpenAI cloud's Responses-API models // and Gemini Nano Banana family. No local tool runtime fallback. const showImagePill = supportsBuiltinImageGeneration; // Local models run RAG through the search_knowledge_base tool loop, so // they need tool-calling. External providers use the prefetch path // (studio retrieves + injects, no tool loop), so RAG is allowed for them // regardless of the local supportsTools flag. const ragDisabled = !modelLoaded || (!supportsTools && !isExternalModel); // Fetch pill: Anthropic-only (web_fetch_20250910 / web_fetch_20260209). const webFetchDisabled = !modelLoaded || !supportsBuiltinWebFetch; const showWebFetchPill = supportsBuiltinWebFetch; // Backwards-compatible alias for any other call site that may still // reference `toolsDisabled` (rare; both pills used it before). const toolsDisabled = codeDisabled; const setPendingAudioStore = useChatRuntimeStore((s) => s.setPendingAudio); const clearPendingAudioStore = useChatRuntimeStore((s) => s.clearPendingAudio); const { isDictating, start: startDictation, stop: stopDictation, supported: dictationSupported } = useDictation( setText, ); useEffect(() => { const id = setInterval(() => { const handles = handlesRef.current; const any = Object.values(handles).some((h) => h.isRunning()); setRunning(any); }, 200); return () => clearInterval(id); }, [handlesRef]); // Auto-expand textarea up to 6 rows, then scroll (matches regular chat composer). useEffect(() => { const ta = textareaRef.current; if (!ta) return; ta.style.height = "auto"; const styles = window.getComputedStyle(ta); const lineHeight = parseFloat(styles.lineHeight) || 20; const paddingY = parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom); const borderY = parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth); const maxHeight = lineHeight * 6 + paddingY + borderY; const next = Math.min(ta.scrollHeight, maxHeight); ta.style.height = `${next}px`; ta.style.overflowY = ta.scrollHeight > maxHeight ? "auto" : "hidden"; }, [text]); const ensureThreadId = useCallback(async (): Promise => { const stored = useChatRuntimeStore.getState().activeThreadId; if (stored) return stored; try { const runtime = aui.threads().__internal_getAssistantRuntime?.(); if (!runtime) return null; const localId = runtime.threads.getState().mainThreadId; if (!localId) return null; const { remoteId } = await runtime.threads .getItemById(localId) .initialize(); useChatRuntimeStore.getState().setActiveThreadId(remoteId); return remoteId; } catch { return null; } }, [aui]); // Composer "+" upload — routes to whichever scope the Retrieval // dropdown currently points at (KB or thread). Keeps "what you see is // what you upload to" so users don't get silent thread-vs-KB mismatches. const addDoc = useCallback( (file: File) => { const localChipId = crypto.randomUUID(); // Lifecycle state shared between the upload flow and the cancel thunk; // the thunk closes over these `let`s so it sees the latest ids whenever // the user cancels. const abort = new AbortController(); let jobId: string | undefined; let documentId: string | undefined; let scopeKey: string | null = null; let unsubscribe: (() => void) | undefined; let slotAcquired = false; let slotReleased = false; let cleaned = false; const releaseSlot = () => { if (slotAcquired && !slotReleased) { slotReleased = true; releaseIndexSlot(); } }; const removeChip = () => { setPendingDocs((prev) => prev.filter((d) => d.id !== localChipId)); }; const cleanupBackend = async () => { if (cleaned) return; cleaned = true; if (jobId) await cancelJob(jobId); if (documentId && scopeKey) { try { await useRagStore.getState().deleteDocument(documentId, scopeKey); } catch {} } }; setPendingDocs((prev) => [ ...prev, { id: localChipId, file, status: "uploading" }, ]); // Register in the aggregate-progress store now (whole batch) so the // single toast counts queued files too. const indexProgress = useIndexProgressStore.getState(); indexProgress.add(localChipId, file.name); indexProgress.setCancel(localChipId, async () => { abort.abort(); unsubscribe?.(); releaseSlot(); await cleanupBackend(); removeChip(); }); void (async () => { // Hold an indexing slot for the document's whole lifecycle so bulk / // folder uploads drain at the configured concurrency. Released on // every terminal path below. await acquireIndexSlot(); slotAcquired = true; if (abort.signal.aborted) { releaseSlot(); return; } indexProgress.setIndexing(localChipId); const ragSource = useChatRuntimeStore.getState().ragSource; let scope: | { kind: "kb"; kbId: string } | { kind: "thread"; threadId: string } | null = null; if (ragSource.kind === "kb") { scope = { kind: "kb", kbId: ragSource.kbId }; scopeKey = `kb:${ragSource.kbId}`; } else { const threadId = await ensureThreadId(); if (abort.signal.aborted) { releaseSlot(); return; } if (!threadId) { setPendingDocs((prev) => prev.map((d) => d.id === localChipId ? { ...d, status: "error", errorMessage: "Could not create thread for upload", } : d, ), ); toast.error("Could not create thread for upload"); indexProgress.setError(localChipId); releaseSlot(); return; } scope = { kind: "thread", threadId }; scopeKey = `thread:${threadId}`; } const uploadDocument = useRagStore.getState().uploadDocument; try { const { documentId: did, jobId: jid, alreadyIndexed, } = await uploadDocument(scope, file); documentId = did; jobId = jid; if (abort.signal.aborted) { // Cancelled while uploading: the document now exists on the // backend, so tear it down here. releaseSlot(); await cleanupBackend(); removeChip(); return; } if (alreadyIndexed) { // Drop the just-added chip if this doc is already represented // so the composer never shows the same document twice. setPendingDocs((prev) => { const dupExists = prev.some( (d) => d.id !== localChipId && d.documentId === did, ); if (dupExists) { return prev.filter((d) => d.id !== localChipId); } return prev.map((d) => d.id === localChipId ? { ...d, status: "ready", documentId: did } : d, ); }); toast.info(`${file.name} is already indexed`); if ( scope?.kind === "thread" && useChatRuntimeStore.getState().ragSource.kind === "off" ) { useChatRuntimeStore.getState().setRagSource({ kind: "thread" }); } indexProgress.setReady(localChipId); releaseSlot(); return; } setPendingDocs((prev) => prev.map((d) => d.id === localChipId ? { ...d, status: "ingesting", jobId: jid, documentId: did } : d, ), ); unsubscribe = subscribeToJobEvents(jid, { onEvent: (event) => { if (event.type === "progress") { indexProgress.setProgress(localChipId, event.progress); } else if (event.type === "complete") { setPendingDocs((prev) => prev.map((d) => d.id === localChipId ? { ...d, status: "ready" } : d, ), ); if ( scope?.kind === "thread" && useChatRuntimeStore.getState().ragSource.kind === "off" ) { useChatRuntimeStore .getState() .setRagSource({ kind: "thread" }); } indexProgress.setReady(localChipId, event.num_chunks); releaseSlot(); } else if (event.type === "cancelled") { releaseSlot(); } else if (event.type === "error") { setPendingDocs((prev) => prev.map((d) => d.id === localChipId ? { ...d, status: "error", errorMessage: event.error } : d, ), ); indexProgress.setError(localChipId); releaseSlot(); } }, }); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Upload failed"; setPendingDocs((prev) => prev.map((d) => d.id === localChipId ? { ...d, status: "error", errorMessage: message } : d, ), ); toast.error(`Document upload failed: ${message}`); indexProgress.setError(localChipId); releaseSlot(); } })(); }, [ensureThreadId], ); const addFiles = useCallback((files: FileList | null) => { if (!files?.length) return; const next: PendingImage[] = []; let droppedImageForUnavailable = false; for (let i = 0; i < files.length; i++) { const file = files[i]; if (!file) continue; // Handle audio files if (file.type.match(/^audio\//i) && file.size <= MAX_AUDIO_SIZE) { fileToBase64(file).then((base64) => { setPendingAudio({ name: file.name, base64 }); setPendingAudioStore(base64, file.name); }); continue; } if (isDocumentFile(file)) { addDoc(file); continue; } if (!file.type.match(/^image\/(jpeg|png|webp|gif)$/i)) continue; if (file.size > MAX_IMAGE_SIZE) continue; if (attachUnavailableReason) { droppedImageForUnavailable = true; continue; } next.push({ id: crypto.randomUUID(), file }); } if (droppedImageForUnavailable && attachUnavailableReason) { toast.error(attachUnavailableReason); } setPendingImages((prev) => [...prev, ...next]); }, [setPendingAudioStore, attachUnavailableReason, addDoc]); const removePendingImage = useCallback((id: string) => { setPendingImages((prev) => prev.filter((p) => p.id !== id)); }, []); const removePendingDoc = useCallback((id: string) => { setPendingDocs((prev) => { const doc = prev.find((d) => d.id === id); if (doc?.documentId) { void useRagStore .getState() .deleteDocument( doc.documentId, `thread:${activeThreadId ?? ""}`, ) .catch(() => {}); } return prev.filter((d) => d.id !== id); }); }, [activeThreadId]); function clearStuckImeTimer() { if (stuckImeTimerRef.current) { clearTimeout(stuckImeTimerRef.current); stuckImeTimerRef.current = null; } } function setCompositionState(next: boolean) { composingRef.current = next; setIsComposing(next); clearStuckImeTimer(); if (next) { stuckImeTimerRef.current = setTimeout(() => { stuckImeTimerRef.current = null; composingRef.current = false; setIsComposing(false); }, IME_STUCK_TIMEOUT_MS); } } function refreshStuckImeTimer() { if (!composingRef.current) { return; } clearStuckImeTimer(); stuckImeTimerRef.current = setTimeout(() => { stuckImeTimerRef.current = null; composingRef.current = false; setIsComposing(false); }, IME_STUCK_TIMEOUT_MS); } useEffect(() => () => clearStuckImeTimer(), []); async function send() { if (composingRef.current) return; const msg = text.trim(); if (!msg && pendingImages.length === 0 && !pendingAudio) return; const hasCompareHandles = Boolean( handlesRef.current["model1"] || handlesRef.current["model2"], ); const isGeneralizedCompare = hasCompareHandles && Boolean(model1?.id && model2?.id); // Generalized compare requires both panes to have a model. A // half-selected send either races to an empty bubble with bogus // tok/s (#5569) or leaves the empty pane with a dangling prompt. // hasCompareHandles is true only in GeneralCompareContent, so // LoraCompare and single-pane chats are unaffected. if (hasCompareHandles && !isGeneralizedCompare) { toast.error("Pick a model in each pane to compare", { description: "Use the model dropdown above each pane, then send your prompt.", }); return; } if (pendingImages.length > 0 && !isGeneralizedCompare && imageUnavailableReason) { // Single mode: the loaded model's runtime capability is known // here. Compare mode defers — each ensureModelLoaded below sets // loadedIsMultimodal for its side, and the chat-adapter's // pre-stream gate runs per-side against that fresh state. toast.error(imageUnavailableReason); 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 (pendingAudio) { content.push({ type: "audio", audio: pendingAudio.base64 }); } if (msg) { content.push({ type: "text", text: msg }); } if (content.length === 0) return; setText(""); setPendingImages([]); setPendingAudio(null); clearPendingAudioStore(); // Docs stay in backend; drop chips only. setPendingDocs([]); textareaRef.current?.focus(); // Generalized compare: load each model before dispatching to its side if (isGeneralizedCompare) { const store = useChatRuntimeStore.getState(); const maxSeqLength = store.params.maxSeqLength; const trustRemoteCode = store.params.trustRemoteCode ?? false; const chatTemplateOverride = store.chatTemplateOverride; const effectiveChatTemplateOverride = chatTemplateOverride?.trim() ? chatTemplateOverride : null; function modelDisplayName(id: string): string { const parts = id.split("/"); return parts[parts.length - 1] || id; } // Helper: load a model and update store checkpoint async function ensureModelLoaded(sel: CompareModelSelection): Promise { const currentStore = useChatRuntimeStore.getState(); const isAlreadyActive = currentStore.params.checkpoint === sel.id && (currentStore.activeGgufVariant ?? null) === (sel.ggufVariant ?? null); if (!isAlreadyActive) { const validation = await validateModel({ model_path: sel.id, hf_token: currentStore.hfToken || null, max_seq_length: maxSeqLength, load_in_4bit: true, is_lora: sel.isLora, gguf_variant: sel.ggufVariant ?? null, trust_remote_code: trustRemoteCode, chat_template_override: effectiveChatTemplateOverride, }); if (validation.requires_trust_remote_code && !trustRemoteCode) { throw new Error( `${modelDisplayName(sel.id)} needs custom code enabled to load. Turn on "Enable custom code" in Chat Settings, then try again.`, ); } } const resp = await loadModel({ model_path: sel.id, hf_token: useChatRuntimeStore.getState().hfToken || null, max_seq_length: maxSeqLength, load_in_4bit: true, is_lora: sel.isLora, gguf_variant: sel.ggufVariant ?? null, trust_remote_code: trustRemoteCode, chat_template_override: effectiveChatTemplateOverride, }); const store = useChatRuntimeStore.getState(); store.setCheckpoint( resp.model, resp.is_gguf ? (sel.ggufVariant ?? undefined) : null, ); store.setModelRequiresTrustRemoteCode( resp.requires_trust_remote_code ?? false, ); useChatRuntimeStore.setState({ supportsReasoning: resp.supports_reasoning ?? false, reasoningAlwaysOn: resp.reasoning_always_on ?? false, reasoningStyle: resp.reasoning_style ?? "enable_thinking", supportsPreserveThinking: resp.supports_preserve_thinking ?? false, supportsTools: resp.supports_tools ?? false, loadedIsMultimodal: isMultimodalResponse(resp), }); // Sync the models[] entry with the load response so the // attach/send gates read fresh capabilities. /api/models/list // can lag behind a model's actual state (e.g., a GGUF whose // mmproj was downloaded after the catalog snapshot). const currentModels = useChatRuntimeStore.getState().models; const idx = currentModels.findIndex((m) => m.id === sel.id); const synced = { isVision: Boolean(resp.is_vision), isGguf: Boolean(resp.is_gguf), isAudio: Boolean(resp.is_audio), audioType: resp.audio_type ?? null, hasAudioInput: Boolean(resp.has_audio_input), }; if (idx === -1) { store.setModels([ ...currentModels, { id: sel.id, name: resp.display_name ?? sel.id, isLora: sel.isLora, ...synced, }, ]); } else { const next = [...currentModels]; next[idx] = { ...next[idx], ...synced }; store.setModels(next); } return resp.status; } const handle1 = handlesRef.current["model1"]; const handle2 = handlesRef.current["model2"]; // Show user messages immediately on both sides if (handle1) handle1.appendMessage(content); if (handle2) handle2.appendMessage(content); const name1 = model1?.id ? modelDisplayName(model1.id) : ""; const name2 = model2?.id ? modelDisplayName(model2.id) : ""; const toastId = toast("Comparing models…", { duration: Infinity }); setComparing(true); try { // Side 1: load → generate → wait if (handle1 && model1?.id) { toast("Loading Model 1…", { id: toastId, description: name1, duration: Infinity }); const status1 = await ensureModelLoaded(model1); toast("Generating with Model 1…", { id: toastId, description: `${name1} (${status1})`, duration: Infinity }); const done = handle1.waitForRunEnd(); handle1.startRun(); await done; } // Side 2: load → generate → wait if (handle2 && model2?.id) { const needsLoad = model2.id.toLowerCase() !== (model1?.id || "").toLowerCase() || (model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? ""); if (needsLoad) { toast("Loading Model 2…", { id: toastId, description: name2, duration: Infinity }); } const status2 = await ensureModelLoaded(model2); toast("Generating with Model 2…", { id: toastId, description: `${name2} (${status2})`, duration: Infinity }); const done = handle2.waitForRunEnd(); handle2.startRun(); await done; } toast.success("Compare complete", { id: toastId, duration: 2000 }); } catch (err) { toast.error("Compare failed", { id: toastId, description: err instanceof Error ? err.message : "Unknown error", duration: 4000, }); } finally { setComparing(false); } } else { // Original behavior: fire all handles simultaneously for (const handle of Object.values(handlesRef.current)) { handle.append(content); } } } function stop() { if (isDictating) stopDictation(); for (const handle of Object.values(handlesRef.current)) { handle.cancel(); } } const busy = running || comparing; function onKeyDown(e: KeyboardEvent) { // IME composition (Japanese/Chinese/Korean): Enter commits the candidate. // Don't hijack it. See issue #5318. Re-pin composingRef in case the stuck // watchdog (#5546) cleared it during a long candidate-window pause; this // keeps a follow-up click-Send from submitting preedit text. Re-arm the // watchdog on the same path — without it the WSL+Chrome no-compositionend // case would leave composingRef pinned forever after an IME keypress and // re-lock Send. if (e.nativeEvent.isComposing || e.keyCode === 229) { composingRef.current = true; refreshStuckImeTimer(); return; } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (!busy) { send(); } } } const docsIndexing = pendingDocs.some( (d) => d.status === "uploading" || d.status === "ingesting", ); const canSend = (text.trim().length > 0 || pendingImages.length > 0 || pendingAudio !== null || pendingDocs.length > 0) && !busy && !isComposing && !docsIndexing; return (
{ if (isTauri) return; e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={(e) => { // Phase 1 native model drops own Tauri local-path drops. Restore browser // attachment drops in Tauri when Phase 1d adds attachment-token bridging. if (isTauri) return; e.preventDefault(); setDragging(false); addFiles(e.dataTransfer.files); }} > {(pendingImages.length > 0 || pendingAudio || pendingDocs.length > 0) && (
{pendingImages.map(({ id, file }) => ( removePendingImage(id)} /> ))} {pendingDocs.map((doc) => { const statusLabel = doc.status === "uploading" ? "Uploading…" : doc.status === "ingesting" ? "Indexing…" : doc.status === "error" ? doc.errorMessage ?? "Failed" : "Ready"; const statusClass = doc.status === "error" ? "text-destructive" : doc.status === "ready" ? "text-muted-foreground" : "text-muted-foreground italic"; return (
{doc.file.name} {statusLabel}
); })} {pendingAudio && (
{pendingAudio.name}
)}
)}