From b76d90d1b38d80b444d4200df1f232310c2a565b Mon Sep 17 00:00:00 2001 From: wasimysaid Date: Sat, 30 May 2026 20:21:21 +0200 Subject: [PATCH] feat(chat): align compare composer --- .../frontend/src/features/chat/chat-page.tsx | 103 +++--- .../src/features/chat/shared-composer.tsx | 307 ++++++++++++------ 2 files changed, 264 insertions(+), 146 deletions(-) diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index d78297824e..056e7bed90 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -314,7 +314,7 @@ function CompareShell({ {children}
-
{composer}
+
{composer}

LLMs can make mistakes. Double-check responses.

@@ -335,15 +335,17 @@ const LoraCompareContent = memo(function LoraCompareContent({ useEffect(() => { let isActive = true; - listStoredChatThreads({ pairId }).then((threads) => { - if (!isActive) return; - setBaseThreadId(threads.find((t) => t.modelType === "base")?.id); - setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id); - }).catch((error) => { - if (!isExpectedBackgroundChatStorageError(error)) { - throw error; - } - }); + listStoredChatThreads({ pairId }) + .then((threads) => { + if (!isActive) return; + setBaseThreadId(threads.find((t) => t.modelType === "base")?.id); + setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id); + }) + .catch((error) => { + if (!isExpectedBackgroundChatStorageError(error)) { + throw error; + } + }); return () => { isActive = false; }; @@ -353,10 +355,7 @@ const LoraCompareContent = memo(function LoraCompareContent({ + } > <> @@ -491,21 +490,25 @@ const GeneralCompareContent = memo(function GeneralCompareContent({ useEffect(() => { let isActive = true; - listStoredChatThreads({ pairId }).then((threads) => { - if (!isActive) return; - setModel1ThreadId( - threads.find((t) => t.modelType === "model1" || t.modelType === "base") - ?.id, - ); - setModel2ThreadId( - threads.find((t) => t.modelType === "model2" || t.modelType === "lora") - ?.id, - ); - }).catch((error) => { - if (!isExpectedBackgroundChatStorageError(error)) { - throw error; - } - }); + listStoredChatThreads({ pairId }) + .then((threads) => { + if (!isActive) return; + setModel1ThreadId( + threads.find( + (t) => t.modelType === "model1" || t.modelType === "base", + )?.id, + ); + setModel2ThreadId( + threads.find( + (t) => t.modelType === "model2" || t.modelType === "lora", + )?.id, + ); + }) + .catch((error) => { + if (!isExpectedBackgroundChatStorageError(error)) { + throw error; + } + }); return () => { isActive = false; }; @@ -659,8 +662,7 @@ export function ChatPage(): ReactElement { } = useChatModelRuntime(); const prevConnectionsEnabledRef = useRef(connectionsEnabled); useEffect(() => { - const turnedOff = - prevConnectionsEnabledRef.current && !connectionsEnabled; + const turnedOff = prevConnectionsEnabledRef.current && !connectionsEnabled; if (!connectionsEnabled && isExternalModelId(inferenceParams.checkpoint)) { clearCheckpoint(); if (turnedOff) { @@ -670,11 +672,7 @@ export function ChatPage(): ReactElement { } } prevConnectionsEnabledRef.current = connectionsEnabled; - }, [ - clearCheckpoint, - connectionsEnabled, - inferenceParams.checkpoint, - ]); + }, [clearCheckpoint, connectionsEnabled, inferenceParams.checkpoint]); const pendingNativeModelIntent = useNativeIntentStore( (state) => state.pendingModelIntent, ); @@ -693,17 +691,19 @@ export function ChatPage(): ReactElement { const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled); const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle); const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort); - const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff); + const supportsReasoningOff = useChatRuntimeStore( + (s) => s.supportsReasoningOff, + ); const activeExternalProvider = useMemo(() => { const selection = parseExternalModelId(inferenceParams.checkpoint); if (!selection) return null; return ( - externalProvidersForChat.find( - (p) => p.id === selection.providerId, - ) ?? null + externalProvidersForChat.find((p) => p.id === selection.providerId) ?? + null ); }, [externalProvidersForChat, inferenceParams.checkpoint]); - const activeExternalProviderType = activeExternalProvider?.providerType ?? null; + const activeExternalProviderType = + activeExternalProvider?.providerType ?? null; const activeProviderCapabilities = useMemo(() => { const selection = parseExternalModelId(inferenceParams.checkpoint); if (!selection) return null; @@ -819,7 +819,9 @@ export function ChatPage(): ReactElement { (provider?.providerType === "anthropic" || provider?.providerType === "openai"); const storedToolsEnabled = loadOptionalBool(CHAT_TOOLS_ENABLED_KEY); - const storedCodeToolsEnabled = loadOptionalBool(CHAT_CODE_TOOLS_ENABLED_KEY); + const storedCodeToolsEnabled = loadOptionalBool( + CHAT_CODE_TOOLS_ENABLED_KEY, + ); const storedImageToolsEnabled = loadOptionalBool( CHAT_IMAGE_TOOLS_ENABLED_KEY, ); @@ -983,8 +985,7 @@ export function ChatPage(): ReactElement { selectedProvider?.providerType, selectedExternal?.modelId, { - isReasoningProvider: - selectedProvider?.isReasoningModel === true, + isReasoningProvider: selectedProvider?.isReasoningModel === true, baseUrl: selectedProvider?.baseUrl ?? null, }, ); @@ -1030,11 +1031,12 @@ export function ChatPage(): ReactElement { selectedExternal?.modelId, selectedProvider?.baseUrl, ); - const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution( - selectedProvider?.providerType, - selectedExternal?.modelId, - selectedProvider?.baseUrl, - ); + const supportsBuiltinCodeExecution = + providerSupportsBuiltinCodeExecution( + selectedProvider?.providerType, + selectedExternal?.modelId, + selectedProvider?.baseUrl, + ); const supportsBuiltinImageGeneration = providerSupportsBuiltinImageGeneration( selectedProvider?.providerType, @@ -1231,8 +1233,7 @@ export function ChatPage(): ReactElement { if (!usage) return; const store = useChatRuntimeStore.getState(); const activeCheckpoint = store.params.checkpoint; - const usageModelId = - (usage as { modelId?: unknown }).modelId; + const usageModelId = (usage as { modelId?: unknown }).modelId; // Scope by modelId when present; reject if no active checkpoint // (model-scoped usage cannot be attributed to "nothing"). if (typeof usageModelId === "string" && usageModelId) { diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index c6b36c6f9e..e9cf589f32 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -21,7 +21,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { applyQwenThinkingParams } from "@/features/chat/utils/qwen-params"; +import { applyQwenThinkingParams } from "@/features/chat"; import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils"; import { isTauri } from "@/lib/api-base"; import { isMultimodalResponse } from "./types/api"; @@ -55,7 +55,10 @@ import { useNavigate } from "@tanstack/react-router"; import { HugeiconsIcon } from "@hugeicons/react"; import { toast } from "@/lib/toast"; import { loadModel, validateModel } from "./api/chat-api"; -import { parseExternalModelId, providerTypeSupportsVision } from "./external-providers"; +import { + parseExternalModelId, + providerTypeSupportsVision, +} from "./external-providers"; import { useExternalProvidersStore } from "./stores/external-providers-store"; import { type ReasoningEffort, @@ -78,6 +81,7 @@ import { useCallback, useContext, useEffect, + useMemo, useRef, useState, } from "react"; @@ -166,7 +170,10 @@ function fileToBase64DataURL(file: File): Promise { }); } -function formatReasoningEffortLabel(level: ReasoningEffort, modelId?: string): string { +function formatReasoningEffortLabel( + level: ReasoningEffort, + modelId?: string, +): string { if (level === "max") return "Max"; if (level === "xhigh") { const normalized = modelId?.trim().toLowerCase() ?? ""; @@ -202,7 +209,12 @@ function useDictation( const start = useCallback(() => { const SpeechRecognitionAPI = typeof window !== "undefined" && - (window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: typeof SpeechRecognition }).webkitSpeechRecognition); + (window.SpeechRecognition ?? + ( + window as unknown as { + webkitSpeechRecognition?: typeof SpeechRecognition; + } + ).webkitSpeechRecognition); if (!SpeechRecognitionAPI) { return; } @@ -248,7 +260,11 @@ function useDictation( const supported = typeof window !== "undefined" && - !!(window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition); + !!( + window.SpeechRecognition ?? + (window as unknown as { webkitSpeechRecognition?: unknown }) + .webkitSpeechRecognition + ); return { isDictating, start, stop, supported }; } @@ -287,9 +303,16 @@ export function RegisterCompareHandle({ currentHandles[name] = { // fixes occasional reorder on reload. append: (content) => - aui.thread().append({ role: "user", content, createdAt: new Date() } as never), + 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), + 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; @@ -327,13 +350,10 @@ function PendingImageThumb({ file: File; onRemove: () => void; }): ReactElement { - const [src, setSrc] = useState(null); + const src = useMemo(() => URL.createObjectURL(file), [file]); useEffect(() => { - const url = URL.createObjectURL(file); - setSrc(url); - return () => URL.revokeObjectURL(url); - }, [file]); - if (!src) return
; + return () => URL.revokeObjectURL(src); + }, [src]); return (
{file.name} @@ -383,7 +403,10 @@ export function SharedComposer({ 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 [pendingAudio, setPendingAudio] = useState<{ + name: string; + base64: string; + } | null>(null); const [dragging, setDragging] = useState(false); const [isComposing, setIsComposing] = useState(false); const textareaRef = useRef(null); @@ -412,10 +435,16 @@ export function SharedComposer({ 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 supportsReasoningOff = useChatRuntimeStore( + (s) => s.supportsReasoningOff, + ); + const reasoningEffortLevels = useChatRuntimeStore( + (s) => s.reasoningEffortLevels, + ); const setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort); - const supportsPreserveThinking = useChatRuntimeStore((s) => s.supportsPreserveThinking); + const supportsPreserveThinking = useChatRuntimeStore( + (s) => s.supportsPreserveThinking, + ); const preserveThinking = useChatRuntimeStore((s) => s.preserveThinking); const setPreserveThinking = useChatRuntimeStore((s) => s.setPreserveThinking); const supportsTools = useChatRuntimeStore((s) => s.supportsTools); @@ -510,7 +539,8 @@ export function SharedComposer({ effectiveSupportsReasoning || effectiveReasoningAlwaysOn; const isEffort = effectiveReasoningStyle === "reasoning_effort"; const thinkingActiveLook = isEffort - ? reasoningLockedOn || (effectiveReasoningVisualEnabled && !reasoningDisabled) + ? reasoningLockedOn || + (effectiveReasoningVisualEnabled && !reasoningDisabled) : reasoningLockedOn || (effectiveReasoningEnabled && !reasoningDisabled); // Two-pill gating: Search pill lights up when the runtime has either // a local tool runtime (supportsTools, gives us our Code/python + local @@ -540,7 +570,7 @@ export function SharedComposer({ // allowance, so we only disable Code unconditionally in Gemini // image mode. const isExternalGemini = selectedExternalProvider?.providerType === "gemini"; - const imageDisabled = !modelLoaded || !supportsBuiltinImageGeneration; + const imageDisabled = modelLoaded && !supportsBuiltinImageGeneration; const imageModeDisablesCode = isExternalGemini && imageToolsEnabled && !imageDisabled; // Image-tier Gemini models always reject codeExecution and reject @@ -549,8 +579,7 @@ export function SharedComposer({ // 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 isGeminiImageTier = isExternalGemini && supportsBuiltinImageGeneration; // Disable only when a loaded model lacks the capability; with no model the // tool can still be pre-selected and reflected, matching the + menu. const searchDisabled = @@ -564,22 +593,24 @@ export function SharedComposer({ ? 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; + // Images pill is only visible when selected. Its menu item appears when the + // provider supports it or when a persisted selected state needs to be shown/toggled. + const showImageMenuItem = supportsBuiltinImageGeneration || imageToolsEnabled; // 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 webFetchDisabled = modelLoaded && !supportsBuiltinWebFetch; + const showFetchMenuItem = supportsBuiltinWebFetch || webFetchToolsEnabled; const setPendingAudioStore = useChatRuntimeStore((s) => s.setPendingAudio); - const clearPendingAudioStore = useChatRuntimeStore((s) => s.clearPendingAudio); - - const { isDictating, start: startDictation, stop: stopDictation, supported: dictationSupported } = useDictation( - setText, + const clearPendingAudioStore = useChatRuntimeStore( + (s) => s.clearPendingAudio, ); + const { + isDictating, + start: startDictation, + stop: stopDictation, + supported: dictationSupported, + } = useDictation(setText); + useEffect(() => { const id = setInterval(() => { const handles = handlesRef.current; @@ -596,15 +627,17 @@ export function SharedComposer({ 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 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 addFiles = useCallback((files: FileList | null) => { + function addFiles(files: FileList | null) { if (!files?.length) return; const next: PendingImage[] = []; let droppedImageForUnavailable = false; @@ -632,7 +665,7 @@ export function SharedComposer({ toast.error(attachUnavailableReason); } setPendingImages((prev) => [...prev, ...next]); - }, [setPendingAudioStore, attachUnavailableReason]); + } const removePendingImage = useCallback((id: string) => { setPendingImages((prev) => prev.filter((p) => p.id !== id)); @@ -690,12 +723,17 @@ export function SharedComposer({ // 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.", + description: + "Use the model dropdown above each pane, then send your prompt.", }); return; } - if (pendingImages.length > 0 && !isGeneralizedCompare && imageUnavailableReason) { + 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 @@ -733,8 +771,9 @@ export function SharedComposer({ const maxSeqLength = store.params.maxSeqLength; const trustRemoteCode = store.params.trustRemoteCode ?? false; const chatTemplateOverride = store.chatTemplateOverride; - const effectiveChatTemplateOverride = - chatTemplateOverride?.trim() ? chatTemplateOverride : null; + const effectiveChatTemplateOverride = chatTemplateOverride?.trim() + ? chatTemplateOverride + : null; function modelDisplayName(id: string): string { const parts = id.split("/"); @@ -742,11 +781,14 @@ export function SharedComposer({ } // Helper: load a model and update store checkpoint - async function ensureModelLoaded(sel: CompareModelSelection): Promise { + async function ensureModelLoaded( + sel: CompareModelSelection, + ): Promise { const currentStore = useChatRuntimeStore.getState(); const isAlreadyActive = currentStore.params.checkpoint === sel.id && - (currentStore.activeGgufVariant ?? null) === (sel.ggufVariant ?? null); + (currentStore.activeGgufVariant ?? null) === + (sel.ggufVariant ?? null); if (!isAlreadyActive) { const validation = await validateModel({ model_path: sel.id, @@ -836,9 +878,17 @@ export function SharedComposer({ try { // Side 1: load → generate → wait if (handle1 && model1?.id) { - toast("Loading Model 1…", { id: toastId, description: name1, duration: Infinity }); + 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 }); + toast("Generating with Model 1…", { + id: toastId, + description: `${name1} (${status1})`, + duration: Infinity, + }); const done = handle1.waitForRunEnd(); handle1.startRun(); await done; @@ -846,13 +896,22 @@ export function SharedComposer({ // Side 2: load → generate → wait if (handle2 && model2?.id) { - const needsLoad = model2.id.toLowerCase() !== (model1?.id || "").toLowerCase() - || (model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? ""); + const needsLoad = + model2.id.toLowerCase() !== (model1?.id || "").toLowerCase() || + (model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? ""); if (needsLoad) { - toast("Loading Model 2…", { id: toastId, description: name2, duration: Infinity }); + 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 }); + toast("Generating with Model 2…", { + id: toastId, + description: `${name2} (${status2})`, + duration: Infinity, + }); const done = handle2.waitForRunEnd(); handle2.startRun(); await done; @@ -906,7 +965,9 @@ export function SharedComposer({ } } - const canSend = (text.trim().length > 0 || pendingImages.length > 0 || pendingAudio !== null) && !busy && !isComposing; + const hasComposerContent = + text.trim().length > 0 || pendingImages.length > 0 || pendingAudio !== null; + const canSend = hasComposerContent && !busy && !isComposing; return (
{pendingAudio.name} setToolsEnabled(!toolsEnabled)} > @@ -1074,6 +1140,38 @@ export function SharedComposer({ Code {codeToolsEnabled ? : null} + {showImageMenuItem ? ( + setImageToolsEnabled(!imageToolsEnabled)} + > + + Images + {imageToolsEnabled ? : null} + + ) : null} + {showFetchMenuItem ? ( + + setWebFetchToolsEnabled(!webFetchToolsEnabled) + } + > + + Fetch + {webFetchToolsEnabled ? ( + + ) : null} + + ) : null} setSettingsPanelOpen(true)}> MCP @@ -1123,38 +1221,50 @@ export function SharedComposer({ - - + > + + Search + + + ) : null} + {codeToolsEnabled ? ( + + ) : null} {/* Active in compare mode; click to exit back to single chat. */} - {showImagePill && ( + {imageToolsEnabled ? ( - )} - {showWebFetchPill && ( + ) : null} + {webFetchToolsEnabled ? ( - )} + ) : null}
{showReasoningControl ? ( @@ -1392,7 +1509,7 @@ export function SharedComposer({ onClick={startDictation} aria-label="Dictate" > - + ) : ( - ) : ( + ) : hasComposerContent ? ( - + - )} + ) : null}