diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 6156a00031..139615a9ba 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -46,6 +46,7 @@ import { } from "@/components/ui/dropdown-menu"; import { applyQwenThinkingParams, + createCompareId, deleteThreadMessage, getExternalReasoningCapabilities, parseExternalModelId, @@ -1463,9 +1464,29 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({ const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled); const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled); const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled); + const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint); const modelLoaded = useChatRuntimeStore( (s) => !!s.params.checkpoint && !s.modelLoading, ); + const supportsTools = useChatRuntimeStore((s) => s.supportsTools); + const supportsBuiltinWebSearch = useChatRuntimeStore( + (s) => s.supportsBuiltinWebSearch, + ); + const supportsBuiltinCodeExecution = useChatRuntimeStore( + (s) => s.supportsBuiltinCodeExecution, + ); + const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled); + const connectionsEnabled = useExternalProvidersStore( + (s) => s.connectionsEnabled, + ); + const externalProvidersAll = useExternalProvidersStore((s) => s.providers); + const externalProviders = connectionsEnabled ? externalProvidersAll : []; + const externalSelection = parseExternalModelId(checkpoint); + const selectedExternalProvider = + externalSelection != null + ? externalProviders.find((p) => p.id === externalSelection.providerId) + : undefined; + const isKimiExternal = selectedExternalProvider?.providerType === "kimi"; const supportsBuiltinImageGeneration = useChatRuntimeStore( (s) => s.supportsBuiltinImageGeneration, ); @@ -1486,17 +1507,39 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({ const showFetchMenuItem = supportsBuiltinWebFetch || webFetchToolsEnabled; const imageMenuDisabled = modelLoaded && !supportsBuiltinImageGeneration; const fetchMenuDisabled = modelLoaded && !supportsBuiltinWebFetch; + const searchMenuDisabled = + modelLoaded && !(supportsTools || supportsBuiltinWebSearch); + const codeMenuDisabled = + modelLoaded && !(supportsTools || supportsBuiltinCodeExecution); + const searchMenuActive = toolsEnabled && !searchMenuDisabled; + const codeMenuActive = codeToolsEnabled && !codeMenuDisabled; + + const toggleSearchTools = useCallback(() => { + if (searchMenuDisabled) return; + const next = !toolsEnabled; + setToolsEnabled(next); + if (isKimiExternal) { + setReasoningEnabled(!next); + applyQwenThinkingParams(!next); + } + }, [ + isKimiExternal, + searchMenuDisabled, + setReasoningEnabled, + setToolsEnabled, + toolsEnabled, + ]); + + const toggleCodeTools = useCallback(() => { + if (codeMenuDisabled) return; + setCodeToolsEnabled(!codeToolsEnabled); + }, [codeMenuDisabled, codeToolsEnabled, setCodeToolsEnabled]); const startCompare = useCallback(() => { const store = useChatRuntimeStore.getState(); store.setActiveThreadId(null); store.setContextUsage(null); - // crypto.randomUUID is undefined in non-secure contexts (HTTP over a LAN IP). - const compareId = - typeof globalThis.crypto?.randomUUID === "function" - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - navigate({ to: "/chat", search: { compare: compareId } }); + navigate({ to: "/chat", search: { compare: createCompareId() } }); }, [navigate]); return ( @@ -1545,20 +1588,22 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({ setToolsEnabled(!toolsEnabled)} + disabled={searchMenuDisabled} + className={searchMenuActive ? "text-primary font-medium" : undefined} + onSelect={toggleSearchTools} > Web search - {toolsEnabled ? : null} + {searchMenuActive ? : null} setCodeToolsEnabled(!codeToolsEnabled)} + disabled={codeMenuDisabled} + className={codeMenuActive ? "text-primary font-medium" : undefined} + onSelect={toggleCodeTools} > Code - {codeToolsEnabled ? : null} + {codeMenuActive ? : null} {showImageMenuItem ? ( (null); + const [viewBeforeCompare, setViewBeforeCompare] = useState( + null, + ); const inferenceParams = useChatRuntimeStore((state) => state.params); const setInferenceParams = useChatRuntimeStore((state) => state.setParams); const activeGgufVariant = useChatRuntimeStore( @@ -1199,20 +1204,20 @@ export function ChatPage(): ReactElement { const openSidebar = useCallback(() => setPinned(true), [setPinned]); const enterCompare = useCallback(() => { - viewBeforeCompareRef.current = { ...search }; + setViewBeforeCompare({ ...search }); useChatRuntimeStore.getState().setActiveThreadId(null); useChatRuntimeStore.getState().setContextUsage(null); - navigate({ to: "/chat", search: { compare: crypto.randomUUID() } }); + navigate({ to: "/chat", search: { compare: createCompareId() } }); }, [navigate, search]); const exitCompare = useCallback(() => { - const saved = viewBeforeCompareRef.current; + const saved = viewBeforeCompare; // No saved view (compare opened by direct URL); fall back to a fresh chat. if (!saved) { navigate({ to: "/chat" }); return; } - viewBeforeCompareRef.current = null; + setViewBeforeCompare(null); navigate({ to: "/chat", search: saved }); // Restore usage from the last assistant message, but only if it // matches the currently active checkpoint. Without this guard the @@ -1259,7 +1264,7 @@ export function ChatPage(): ReactElement { } }); } - }, [navigate]); + }, [navigate, viewBeforeCompare]); const models = useMemo( () => @@ -1415,7 +1420,7 @@ export function ChatPage(): ReactElement { if (canceled) return; useChatRuntimeStore.getState().setActiveThreadId(null); useChatRuntimeStore.getState().setContextUsage(null); - navigate({ to: "/chat", search: { compare: crypto.randomUUID() } }); + navigate({ to: "/chat", search: { compare: createCompareId() } }); clearHandoff(); console.info("[chat-handoff] loaded lora + opened compare"); return; diff --git a/studio/frontend/src/features/chat/index.ts b/studio/frontend/src/features/chat/index.ts index 61ff727667..25a126fcb6 100644 --- a/studio/frontend/src/features/chat/index.ts +++ b/studio/frontend/src/features/chat/index.ts @@ -7,6 +7,7 @@ export { parseExternalModelId } from "./external-providers"; export { getExternalReasoningCapabilities } from "./provider-capabilities"; export { useExternalProvidersStore } from "./stores/external-providers-store"; export { deleteThreadMessage } from "./utils/delete-thread-message"; +export { createCompareId } from "./utils/compare-id"; export { applyQwenThinkingParams } from "./utils/qwen-params"; export { getInferenceStatus, diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 3e45fb8c70..40748d29b0 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -610,6 +610,30 @@ export function SharedComposer({ // Fetch pill: Anthropic-only (web_fetch_20250910 / web_fetch_20260209). const webFetchDisabled = modelLoaded && !supportsBuiltinWebFetch; const showFetchMenuItem = supportsBuiltinWebFetch || webFetchToolsEnabled; + const searchToolsActive = toolsEnabled && !searchDisabled; + const codeToolsActive = codeToolsEnabled && !codeDisabled; + const toggleSearchTools = useCallback(() => { + if (searchDisabled) return; + const next = !toolsEnabled; + setToolsEnabled(next); + // Kimi's $web_search builtin requires thinking=disabled + // (https://platform.kimi.ai/docs/guide/use-web-search). Toggle + // the Think pill off when Search is on, mirroring the backend. + if (isKimiExternal) { + setReasoningEnabled(!next, { persist: false }); + applyQwenThinkingParams(!next); + } + }, [ + isKimiExternal, + searchDisabled, + setReasoningEnabled, + setToolsEnabled, + toolsEnabled, + ]); + const toggleCodeTools = useCallback(() => { + if (codeDisabled) return; + setCodeToolsEnabled(!codeToolsEnabled); + }, [codeDisabled, codeToolsEnabled, setCodeToolsEnabled]); const setPendingAudioStore = useChatRuntimeStore((s) => s.setPendingAudio); const clearPendingAudioStore = useChatRuntimeStore( (s) => s.clearPendingAudio, @@ -657,10 +681,18 @@ export function SharedComposer({ 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); - }); + void fileToBase64(file) + .then((base64) => { + setPendingAudio({ name: file.name, base64 }); + setPendingAudioStore(base64, file.name); + }) + .catch(() => { + setPendingAudio(null); + clearPendingAudioStore(); + toast.error("Failed to read audio file", { + description: "Try a different audio file or add it again.", + }); + }); continue; } // Handle image files @@ -877,10 +909,6 @@ export function SharedComposer({ 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 }); @@ -900,6 +928,7 @@ export function SharedComposer({ description: `${name1} (${status1})`, duration: Infinity, }); + handle1.appendMessage(content); const done = handle1.waitForRunEnd(); handle1.startRun(); await done; @@ -923,6 +952,7 @@ export function SharedComposer({ description: `${name2} (${status2})`, duration: Infinity, }); + handle2.appendMessage(content); const done = handle2.waitForRunEnd(); handle2.startRun(); await done; @@ -1132,24 +1162,26 @@ export function SharedComposer({ )} setToolsEnabled(!toolsEnabled)} + onSelect={toggleSearchTools} > Web search - {toolsEnabled ? : null} + {searchToolsActive ? : null} setCodeToolsEnabled(!codeToolsEnabled)} + onSelect={toggleCodeTools} > Code - {codeToolsEnabled ? : null} + {codeToolsActive ? : null} {showImageMenuItem ? ( { - const next = !toolsEnabled; - setToolsEnabled(next); - // Kimi's $web_search builtin requires thinking=disabled - // (https://platform.kimi.ai/docs/guide/use-web-search). Toggle - // the Think pill off when Search is on, mirroring the backend. - if (isKimiExternal) { - setReasoningEnabled(!next, { persist: false }); - applyQwenThinkingParams(!next); - } - }} + onClick={toggleSearchTools} className="composer-pill-btn" - data-active={toolsEnabled && !searchDisabled ? "true" : "false"} + data-active={searchToolsActive ? "true" : "false"} aria-label={ toolsEnabled ? "Disable web search" : "Enable web search" } @@ -1262,9 +1284,9 @@ export function SharedComposer({