fix(chat): harden composer tool and compare flows
This commit is contained in:
parent
19436f589a
commit
def605d5b6
6 changed files with 144 additions and 54 deletions
|
|
@ -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" }> = ({
|
|||
<ComposerAudioMenuItem />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className={toolsEnabled ? "text-primary font-medium" : undefined}
|
||||
onSelect={() => setToolsEnabled(!toolsEnabled)}
|
||||
disabled={searchMenuDisabled}
|
||||
className={searchMenuActive ? "text-primary font-medium" : undefined}
|
||||
onSelect={toggleSearchTools}
|
||||
>
|
||||
<GlobeIcon />
|
||||
Web search
|
||||
{toolsEnabled ? <CheckIcon className="ml-auto" /> : null}
|
||||
{searchMenuActive ? <CheckIcon className="ml-auto" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={codeToolsEnabled ? "text-primary font-medium" : undefined}
|
||||
onSelect={() => setCodeToolsEnabled(!codeToolsEnabled)}
|
||||
disabled={codeMenuDisabled}
|
||||
className={codeMenuActive ? "text-primary font-medium" : undefined}
|
||||
onSelect={toggleCodeTools}
|
||||
>
|
||||
<HugeiconsIcon icon={CodeIcon} strokeWidth={2} />
|
||||
Code
|
||||
{codeToolsEnabled ? <CheckIcon className="ml-auto" /> : null}
|
||||
{codeMenuActive ? <CheckIcon className="ml-auto" /> : null}
|
||||
</DropdownMenuItem>
|
||||
{showImageMenuItem ? (
|
||||
<DropdownMenuItem
|
||||
|
|
|
|||
|
|
@ -12,13 +12,15 @@ import {
|
|||
import { Thread } from "@/components/assistant-ui/thread";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { NativeModelChip } from "@/features/native-intents/components/native-model-chip";
|
||||
import { NativeModelDropOverlay } from "@/features/native-intents/components/native-model-drop-overlay";
|
||||
import { useNativeIntentStore } from "@/features/native-intents/store";
|
||||
import type { NativeIntent } from "@/features/native-intents/types";
|
||||
import { useChooseNativeModel } from "@/features/native-intents/use-native-dialogs";
|
||||
import { useNativeModelDrop } from "@/features/native-intents/use-native-drop";
|
||||
import { useNativePathLeasesSupported } from "@/features/native-intents/use-native-readiness";
|
||||
import {
|
||||
NativeModelChip,
|
||||
NativeModelDropOverlay,
|
||||
type NativeIntent,
|
||||
useChooseNativeModel,
|
||||
useNativeIntentStore,
|
||||
useNativeModelDrop,
|
||||
useNativePathLeasesSupported,
|
||||
} from "@/features/native-intents";
|
||||
import { GuidedTour, useGuidedTourController } from "@/features/tour";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -85,6 +87,7 @@ import {
|
|||
listStoredChatMessages,
|
||||
listStoredChatThreads,
|
||||
} from "./utils/chat-history-storage";
|
||||
import { createCompareId } from "./utils/compare-id";
|
||||
|
||||
type LoraCandidate = {
|
||||
id: string;
|
||||
|
|
@ -632,7 +635,9 @@ export function ChatPage(): ReactElement {
|
|||
|
||||
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
|
||||
const [modelSelectorLocked, setModelSelectorLocked] = useState(false);
|
||||
const viewBeforeCompareRef = useRef<ChatSearch | null>(null);
|
||||
const [viewBeforeCompare, setViewBeforeCompare] = useState<ChatSearch | null>(
|
||||
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<ModelOption[]>(
|
||||
() =>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={searchDisabled}
|
||||
className={
|
||||
toolsEnabled ? "text-primary font-medium" : undefined
|
||||
searchToolsActive ? "text-primary font-medium" : undefined
|
||||
}
|
||||
onSelect={() => setToolsEnabled(!toolsEnabled)}
|
||||
onSelect={toggleSearchTools}
|
||||
>
|
||||
<GlobeIcon />
|
||||
Web search
|
||||
{toolsEnabled ? <CheckIcon className="ml-auto" /> : null}
|
||||
{searchToolsActive ? <CheckIcon className="ml-auto" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={codeDisabled}
|
||||
className={
|
||||
codeToolsEnabled ? "text-primary font-medium" : undefined
|
||||
codeToolsActive ? "text-primary font-medium" : undefined
|
||||
}
|
||||
onSelect={() => setCodeToolsEnabled(!codeToolsEnabled)}
|
||||
onSelect={toggleCodeTools}
|
||||
>
|
||||
<HugeiconsIcon icon={CodeIcon} strokeWidth={2} />
|
||||
Code
|
||||
{codeToolsEnabled ? <CheckIcon className="ml-auto" /> : null}
|
||||
{codeToolsActive ? <CheckIcon className="ml-auto" /> : null}
|
||||
</DropdownMenuItem>
|
||||
{showImageMenuItem ? (
|
||||
<DropdownMenuItem
|
||||
|
|
@ -1236,19 +1268,9 @@ export function SharedComposer({
|
|||
<button
|
||||
type="button"
|
||||
disabled={searchDisabled}
|
||||
onClick={() => {
|
||||
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({
|
|||
<button
|
||||
type="button"
|
||||
disabled={codeDisabled}
|
||||
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
|
||||
onClick={toggleCodeTools}
|
||||
className="composer-pill-btn"
|
||||
data-active={codeToolsEnabled && !codeDisabled ? "true" : "false"}
|
||||
data-active={codeToolsActive ? "true" : "false"}
|
||||
aria-label={
|
||||
codeToolsEnabled
|
||||
? "Disable code execution"
|
||||
|
|
|
|||
9
studio/frontend/src/features/chat/utils/compare-id.ts
Normal file
9
studio/frontend/src/features/chat/utils/compare-id.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export function createCompareId(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
8
studio/frontend/src/features/native-intents/index.ts
Normal file
8
studio/frontend/src/features/native-intents/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export { NativeModelChip } from "./components/native-model-chip";
|
||||
export { NativeModelDropOverlay } from "./components/native-model-drop-overlay";
|
||||
export { useNativeIntentStore } from "./store";
|
||||
export type { NativeIntent } from "./types";
|
||||
export { useChooseNativeModel } from "./use-native-dialogs";
|
||||
export { useNativeModelDrop } from "./use-native-drop";
|
||||
export type { NativeModelDropState } from "./use-native-drop";
|
||||
export { useNativePathLeasesSupported } from "./use-native-readiness";
|
||||
Loading…
Add table
Add a link
Reference in a new issue