1597 lines
60 KiB
TypeScript
1597 lines
60 KiB
TypeScript
// 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<void>;
|
|
}
|
|
|
|
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<string> {
|
|
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<SpeechRecognition | null>(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<Record<string, CompareHandle>>;
|
|
|
|
const CompareHandlesContext = createContext<CompareHandles | null>(null);
|
|
|
|
export function CompareHandlesProvider({
|
|
handlesRef,
|
|
children,
|
|
}: {
|
|
handlesRef: CompareHandles;
|
|
children: ReactNode;
|
|
}): ReactElement {
|
|
return (
|
|
<CompareHandlesContext.Provider value={handlesRef}>
|
|
{children}
|
|
</CompareHandlesContext.Provider>
|
|
);
|
|
}
|
|
|
|
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<void>((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<string | null>(null);
|
|
useEffect(() => {
|
|
const url = URL.createObjectURL(file);
|
|
setSrc(url);
|
|
return () => URL.revokeObjectURL(url);
|
|
}, [file]);
|
|
if (!src) return <div className="size-14 animate-pulse rounded-[14px] bg-muted" />;
|
|
return (
|
|
<div className="relative size-14 shrink-0 overflow-hidden rounded-[14px] border border-foreground/20 bg-muted">
|
|
<img src={src} alt={file.name} className="h-full w-full object-cover" />
|
|
<button
|
|
type="button"
|
|
onClick={onRemove}
|
|
className="absolute top-1 right-1 flex size-5 items-center justify-center rounded-full bg-white text-muted-foreground shadow-sm hover:bg-destructive hover:text-destructive-foreground"
|
|
aria-label="Remove attachment"
|
|
>
|
|
<XIcon className="size-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<PendingImage[]>([]);
|
|
const [pendingAudio, setPendingAudio] = useState<{ name: string; base64: string } | null>(null);
|
|
const [pendingDocs, setPendingDocs] = useState<PendingDoc[]>([]);
|
|
const [dragging, setDragging] = useState(false);
|
|
const [isComposing, setIsComposing] = useState(false);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const composingRef = useRef(false);
|
|
const stuckImeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const audioInputRef = useRef<HTMLInputElement>(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<string | null> => {
|
|
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<string> {
|
|
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 (
|
|
<div
|
|
className={`chat-composer-surface ${dragging ? "border-ring bg-accent/50" : ""}`}
|
|
onDragOver={(e) => {
|
|
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) && (
|
|
<div className="mb-2 flex w-full flex-row flex-wrap items-center gap-2 px-1.5 pt-0.5 pb-1">
|
|
{pendingImages.map(({ id, file }) => (
|
|
<PendingImageThumb
|
|
key={id}
|
|
file={file}
|
|
onRemove={() => 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 (
|
|
<div
|
|
key={doc.id}
|
|
className="flex items-center gap-2 rounded-lg border border-foreground/20 bg-muted px-3 py-1.5 text-xs"
|
|
>
|
|
<FileTextIcon className="size-3.5 text-muted-foreground" />
|
|
<div className="flex flex-col">
|
|
<span className="max-w-48 truncate">{doc.file.name}</span>
|
|
<span className={cn("text-[10px] leading-tight", statusClass)}>
|
|
{statusLabel}
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="text-muted-foreground hover:text-destructive"
|
|
onClick={() => removePendingDoc(doc.id)}
|
|
aria-label="Remove document"
|
|
>
|
|
<XIcon className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
{pendingAudio && (
|
|
<div className="flex items-center gap-2 rounded-lg border border-foreground/20 bg-muted px-3 py-1.5 text-xs">
|
|
<HeadphonesIcon className="size-3.5 text-muted-foreground" />
|
|
<span className="max-w-48 truncate">{pendingAudio.name}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => { setPendingAudio(null); clearPendingAudioStore(); }}
|
|
className="flex size-4 items-center justify-center rounded-full hover:bg-destructive hover:text-destructive-foreground"
|
|
aria-label="Remove audio"
|
|
>
|
|
<XIcon className="size-3" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={text}
|
|
onChange={(e) => {
|
|
// ALWAYS mirror the DOM value into React state, even during IME
|
|
// composition. The controlled `value` prop must match the DOM at
|
|
// all times, otherwise any unrelated parent re-render reconciles
|
|
// the textarea back to the stored value mid-composition — wiping
|
|
// the IME preedit AND prior committed text (e.g. Tab cycling
|
|
// candidates erases earlier words). Issue #5318.
|
|
setCompositionState(isNativeComposing(e.nativeEvent));
|
|
setText(e.target.value);
|
|
}}
|
|
onCompositionStart={() => {
|
|
setCompositionState(true);
|
|
}}
|
|
onCompositionUpdate={() => {
|
|
refreshStuckImeTimer();
|
|
}}
|
|
onCompositionEnd={(e: CompositionEvent<HTMLTextAreaElement>) => {
|
|
setCompositionState(false);
|
|
setText(e.currentTarget.value);
|
|
}}
|
|
onKeyDown={onKeyDown}
|
|
placeholder="Send to both models..."
|
|
className="composer-input"
|
|
rows={1}
|
|
// dir="auto" auto-detects RTL (Arabic / Hebrew / Persian / Urdu)
|
|
// from the first strong character; no effect on LTR scripts.
|
|
dir="auto"
|
|
/>
|
|
<div className="composer-action-wrapper">
|
|
<div className="flex items-center gap-0.5">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept={`${IMAGE_ACCEPT},${DOCUMENT_ACCEPT}`}
|
|
multiple
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
addFiles(e.target.files);
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
<TooltipIconButton
|
|
tooltip="Add Attachment"
|
|
side="bottom"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-8.5 rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
|
onClick={() => {
|
|
// The picker accepts both image and audio. Don't gate the
|
|
// button on image-availability — addFiles still filters
|
|
// image files per-file when the loaded model can't take
|
|
// them, while audio attach always works.
|
|
fileInputRef.current?.click();
|
|
}}
|
|
aria-label="Add Attachment"
|
|
>
|
|
<PlusIcon className="size-5 stroke-[1.5px]" />
|
|
</TooltipIconButton>
|
|
{activeModel?.hasAudioInput && (
|
|
<>
|
|
<input
|
|
ref={audioInputRef}
|
|
type="file"
|
|
accept={AUDIO_ACCEPT}
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
addFiles(e.target.files);
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
<TooltipIconButton
|
|
tooltip="Upload audio"
|
|
side="bottom"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-8.5 rounded-full p-1 text-muted-foreground hover:bg-muted-foreground/15"
|
|
onClick={() => audioInputRef.current?.click()}
|
|
aria-label="Upload audio"
|
|
>
|
|
<HeadphonesIcon className="size-4.5 stroke-[1.5px]" />
|
|
</TooltipIconButton>
|
|
</>
|
|
)}
|
|
{showReasoningControl ? (
|
|
effectiveReasoningStyle === "reasoning_effort" ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild={true}>
|
|
<button
|
|
type="button"
|
|
disabled={reasoningDisabled}
|
|
className={cn(
|
|
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
|
|
reasoningDisabled
|
|
? "cursor-not-allowed opacity-40"
|
|
: effectiveReasoningVisualEnabled
|
|
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
|
|
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
|
|
)}
|
|
aria-label={thinkEffortAriaLabel({
|
|
modelLoaded,
|
|
reasoningDisabled,
|
|
reasoningEffort,
|
|
})}
|
|
>
|
|
{effectiveReasoningVisualEnabled ? (
|
|
<LightbulbIcon className="size-3.5" />
|
|
) : (
|
|
<LightbulbOffIcon className="size-3.5" />
|
|
)}
|
|
<span>
|
|
Think:{" "}
|
|
{effectiveReasoningVisualEnabled
|
|
? formatReasoningEffortLabel(
|
|
reasoningEffort,
|
|
externalSelection?.modelId,
|
|
)
|
|
: formatReasoningDisabledLabel(
|
|
effectiveSupportsReasoningOff,
|
|
isExternalOpenAIReasoning,
|
|
checkpoint,
|
|
)}
|
|
</span>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
{effectiveSupportsReasoningOff && (
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
setReasoningEnabled(false);
|
|
applyQwenThinkingParams(false);
|
|
}}
|
|
>
|
|
{formatReasoningDisabledLabel(
|
|
effectiveSupportsReasoningOff,
|
|
isExternalOpenAIReasoning,
|
|
checkpoint,
|
|
)}
|
|
{!effectiveReasoningVisualEnabled ? " \u2713" : ""}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{effectiveReasoningEffortLevels
|
|
.filter((level) => level !== "none")
|
|
.map((level) => (
|
|
<DropdownMenuItem
|
|
key={level}
|
|
onSelect={() => {
|
|
setReasoningEffort(level);
|
|
setReasoningEnabled(true);
|
|
applyQwenThinkingParams(true);
|
|
// Mutual exclusion: turning thinking on for a
|
|
// Kimi model forces the web_search builtin off.
|
|
if (isKimiExternal && toolsEnabled) {
|
|
setToolsEnabled(false, { persist: false });
|
|
}
|
|
}}
|
|
>
|
|
{formatReasoningEffortLabel(level, externalSelection?.modelId)}
|
|
{effectiveReasoningVisualEnabled && reasoningEffort === level ? " \u2713" : ""}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
disabled={reasoningDisabled || reasoningLockedOn}
|
|
aria-disabled={reasoningDisabled || reasoningLockedOn}
|
|
title={
|
|
reasoningLockedOn
|
|
? "This model requires reasoning to stay on."
|
|
: undefined
|
|
}
|
|
onClick={() => {
|
|
if (reasoningLockedOn) return;
|
|
const next = !reasoningEnabled;
|
|
setReasoningEnabled(next);
|
|
applyQwenThinkingParams(next);
|
|
// Mutual exclusion: Kimi's $web_search builtin
|
|
// requires thinking off, so turning thinking on flips
|
|
// the Search pill off (and vice versa).
|
|
if (isKimiExternal && next && toolsEnabled) {
|
|
setToolsEnabled(false, { persist: false });
|
|
}
|
|
}}
|
|
className={cn(
|
|
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
|
|
reasoningLockedOn
|
|
? "cursor-not-allowed text-primary"
|
|
: reasoningDisabled
|
|
? "cursor-not-allowed opacity-40"
|
|
: effectiveReasoningEnabled
|
|
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
|
|
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
|
|
)}
|
|
aria-label={thinkToggleAriaLabel({
|
|
reasoningLockedOn,
|
|
modelLoaded,
|
|
reasoningDisabled,
|
|
effectiveReasoningEnabled,
|
|
})}
|
|
>
|
|
{reasoningLockedOn ||
|
|
(effectiveReasoningEnabled && !reasoningDisabled) ? (
|
|
<LightbulbIcon className="size-3.5" />
|
|
) : (
|
|
<LightbulbOffIcon className="size-3.5" />
|
|
)}
|
|
<span>Think</span>
|
|
</button>
|
|
)
|
|
) : null}
|
|
{supportsPreserveThinking && (
|
|
<button
|
|
type="button"
|
|
disabled={!modelLoaded}
|
|
onClick={() => setPreserveThinking(!preserveThinking)}
|
|
className={cn(
|
|
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
|
|
!modelLoaded
|
|
? "cursor-not-allowed opacity-40"
|
|
: preserveThinking
|
|
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
|
|
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
|
|
)}
|
|
aria-label={
|
|
preserveThinking ? "Disable preserve think" : "Enable preserve think"
|
|
}
|
|
>
|
|
{preserveThinking && modelLoaded ? (
|
|
<LightbulbIcon className="size-3.5" />
|
|
) : (
|
|
<LightbulbOffIcon className="size-3.5" />
|
|
)}
|
|
<span>Preserve Think</span>
|
|
</button>
|
|
)}
|
|
<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 comes on, and
|
|
// back on when Search goes off — mutual exclusion that
|
|
// mirrors what the backend enforces.
|
|
if (isKimiExternal) {
|
|
setReasoningEnabled(!next, { persist: false });
|
|
applyQwenThinkingParams(!next);
|
|
}
|
|
}}
|
|
className="composer-pill-btn"
|
|
data-active={toolsEnabled && !searchDisabled ? "true" : "false"}
|
|
aria-label={toolsEnabled ? "Disable web search" : "Enable web search"}
|
|
>
|
|
<GlobeIcon className="size-3.5" />
|
|
<span>Search</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={codeDisabled}
|
|
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
|
|
className="composer-pill-btn"
|
|
data-active={codeToolsEnabled && !codeDisabled ? "true" : "false"}
|
|
aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"}
|
|
>
|
|
<CodeToggleIcon className="size-3.5" />
|
|
<span>Code</span>
|
|
</button>
|
|
{showImagePill && (
|
|
<button
|
|
type="button"
|
|
disabled={imageDisabled}
|
|
onClick={() => setImageToolsEnabled(!imageToolsEnabled)}
|
|
className="composer-pill-btn"
|
|
data-active={imageToolsEnabled && !imageDisabled ? "true" : "false"}
|
|
aria-label={
|
|
imageToolsEnabled ? "Disable image generation" : "Enable image generation"
|
|
}
|
|
>
|
|
<HugeiconsIcon
|
|
icon={Image03Icon}
|
|
className="size-3.5"
|
|
strokeWidth={2}
|
|
/>
|
|
<span>Images</span>
|
|
</button>
|
|
)}
|
|
{/* Master RAG toggle; sidebar Retrieval section configures the rest. */}
|
|
<button
|
|
type="button"
|
|
disabled={ragDisabled}
|
|
onClick={() => {
|
|
const next = !ragToolEnabled;
|
|
setRagToolEnabled(next);
|
|
if (next && ragSource.kind === "off") {
|
|
setRagSource({ kind: "thread" });
|
|
}
|
|
}}
|
|
className="composer-pill-btn"
|
|
data-active={ragToolEnabled && !ragDisabled ? "true" : "false"}
|
|
aria-label={ragToolEnabled ? "Disable RAG" : "Enable RAG"}
|
|
title={
|
|
ragDisabled
|
|
? "RAG needs a model that supports tool calling"
|
|
: ragToolEnabled
|
|
? "RAG on — the model can search your attached documents"
|
|
: "Enable RAG — let the model search your documents"
|
|
}
|
|
>
|
|
<BookOpenIcon className="size-3.5" />
|
|
<span>RAG</span>
|
|
</button>
|
|
{showWebFetchPill && (
|
|
<button
|
|
type="button"
|
|
disabled={webFetchDisabled}
|
|
onClick={() => setWebFetchToolsEnabled(!webFetchToolsEnabled)}
|
|
className="composer-pill-btn"
|
|
data-active={
|
|
webFetchToolsEnabled && !webFetchDisabled ? "true" : "false"
|
|
}
|
|
aria-label={
|
|
webFetchToolsEnabled ? "Disable URL fetch" : "Enable URL fetch"
|
|
}
|
|
>
|
|
<DownloadIcon className="size-3.5" />
|
|
<span>Fetch</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{dictationSupported && (
|
|
<>
|
|
{!isDictating ? (
|
|
<TooltipIconButton
|
|
tooltip="Dictate"
|
|
side="bottom"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-8 rounded-full text-muted-foreground"
|
|
onClick={startDictation}
|
|
aria-label="Dictate"
|
|
>
|
|
<MicIcon className="size-4" />
|
|
</TooltipIconButton>
|
|
) : (
|
|
<TooltipIconButton
|
|
tooltip="Stop dictation"
|
|
side="bottom"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-8 rounded-full text-destructive"
|
|
onClick={stopDictation}
|
|
aria-label="Stop dictation"
|
|
>
|
|
<SquareIcon className="size-3 animate-pulse fill-current" />
|
|
</TooltipIconButton>
|
|
)}
|
|
</>
|
|
)}
|
|
{busy ? (
|
|
<Button
|
|
type="button"
|
|
variant="default"
|
|
size="icon"
|
|
className="size-8 rounded-full"
|
|
onClick={stop}
|
|
>
|
|
<SquareIcon className="size-3 fill-current" />
|
|
</Button>
|
|
) : (
|
|
<TooltipIconButton
|
|
tooltip="Send message"
|
|
side="bottom"
|
|
variant="default"
|
|
size="icon"
|
|
className="size-8 rounded-full"
|
|
onClick={send}
|
|
disabled={!canSend}
|
|
aria-label="Send message"
|
|
>
|
|
<ArrowUpIcon className="size-4" />
|
|
</TooltipIconButton>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|