Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 45 additions & 158 deletions src/components/ChatBox/BottomBox/ChatInputModelDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
* Configured models switch inline; unconfigured options open Agents → Models.
*/

import { proxyFetchGet } from '@/api/http';
import folderIcon from '@/assets/Folder.svg';
import {
DropdownMenu,
Expand All @@ -34,7 +33,6 @@ import {
isDefaultModelConfigured,
type DefaultModelCategory,
} from '@/lib/applyDefaultModelSelection';
import { INIT_PROVODERS } from '@/lib/llm';
import { cn } from '@/lib/utils';
import {
getLocalPlatformName,
Expand All @@ -44,8 +42,13 @@ import {
getModelImage,
needsInvertModelImage,
} from '@/shared/modelProviderImages';
import { useAuthStore } from '@/store/authStore';
import { isCloudModelType, useAuthStore } from '@/store/authStore';
import {
CATALOG_ITEMS,
useProvidersCatalogStore,
} from '@/store/providersCatalogStore';
import type { Provider } from '@/types';
import { useShallow } from 'zustand/react/shallow';

import {
Check,
Expand All @@ -56,34 +59,24 @@ import {
Server,
Sparkles,
} from 'lucide-react';
import type { Dispatch, SetStateAction } from 'react';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';

const cloudModelOptions = [
{ id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro Preview' },
{ id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro Preview' },
{ id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash Preview' },
{ id: 'gpt-4.1-mini', name: 'GPT-4.1 Mini' },
{ id: 'gpt-4.1', name: 'GPT-4.1' },
{ id: 'gpt-5', name: 'GPT-5' },
{ id: 'gpt-5.1', name: 'GPT-5.1' },
{ id: 'gpt-5.2', name: 'GPT-5.2' },
{ id: 'gpt-5.4', name: 'GPT-5.4' },
{ id: 'gpt-5.5', name: 'GPT-5.5' },
{ id: 'gpt-5-mini', name: 'GPT-5 Mini' },
{ id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' },
{ id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' },
{ id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
{ id: 'claude-opus-4-6', name: 'Claude Opus 4.6' },
{ id: 'minimax_m2_5', name: 'Minimax M2.5' },
{ id: 'claude-opus-4-7', name: 'Claude Opus 4.7' },
{ id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
{ id: 'minimax_m2_7', name: 'Minimax M2.7' },
] as const;

export interface ChatInputModelDropdownProps {
Expand All @@ -102,136 +95,36 @@ const modelTriggerShellClass = cn(
'bg-ds-bg-neutral-default-default text-ds-text-neutral-default-default'
);

const DROPDOWN_ITEMS: Provider[] = CATALOG_ITEMS;

export function ChatInputModelDropdown({
disabled,
readOnly = false,
}: ChatInputModelDropdownProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const {
modelType,
cloud_model_type,
appearance,
setModelType,
setCloudModelType,
} = useAuthStore();
const { cloud_model_type, appearance, setModelType, setCloudModelType } =
useAuthStore();

const [items] = useState<Provider[]>(
INIT_PROVODERS.filter((p) => p.id !== 'local')
);
const [form, setForm] = useState(() =>
INIT_PROVODERS.filter((p) => p.id !== 'local').map((p) => ({
apiKey: p.apiKey,
apiHost: p.apiHost,
is_valid: p.is_valid ?? false,
model_type: p.model_type ?? '',
externalConfig: p.externalConfig
? p.externalConfig.map((ec) => ({ ...ec }))
: undefined,
provider_id: p.provider_id ?? undefined,
prefer: p.prefer ?? false,
const {
form,
cloudPrefer,
localPrefer,
localPlatform,
localTypes,
localProviderIds,
} = useProvidersCatalogStore(
useShallow((s) => ({
form: s.form,
cloudPrefer: s.cloudPrefer,
localPrefer: s.localPrefer,
localPlatform: s.localPlatform,
localTypes: s.localTypes,
localProviderIds: s.localProviderIds,
}))
);
const [cloudPrefer, setCloudPrefer] = useState(false);
const [localPrefer, setLocalPrefer] = useState(false);
const [localPlatform, setLocalPlatform] = useState<string>('ollama');
const [localTypes, setLocalTypes] = useState<Record<string, string>>({});
const [localProviderIds, setLocalProviderIds] = useState<
Record<string, number | undefined>
>({});

useEffect(() => {
(async () => {
try {
const res = await proxyFetchGet('/api/v1/providers');
const providerList = Array.isArray(res) ? res : res.items || [];

setForm((f) =>
f.map((fi, idx) => {
const item = items[idx];
const found = providerList.find(
(p: { provider_name: string }) => p.provider_name === item.id
);
if (found) {
return {
...fi,
provider_id: found.id,
apiKey: found.api_key || '',
apiHost: found.endpoint_url || item.apiHost,
is_valid: !!found?.is_valid,
prefer: found.prefer ?? false,
model_type: found.model_type ?? '',
externalConfig: fi.externalConfig
? fi.externalConfig.map((ec) => {
if (
found.encrypted_config &&
found.encrypted_config[ec.key] !== undefined
) {
return { ...ec, value: found.encrypted_config[ec.key] };
}
return ec;
})
: undefined,
};
}
return fi;
})
);

const localProviders = providerList.filter(
(p: { provider_name: string }) =>
LOCAL_MODEL_OPTIONS.some((model) => model.id === p.provider_name)
);

const types: Record<string, string> = {};
const providerIds: Record<string, number | undefined> = {};

localProviders.forEach((local: Record<string, unknown>) => {
const platform =
(local.encrypted_config as { model_platform?: string } | undefined)
?.model_platform || (local.provider_name as string);
types[platform] =
(local.encrypted_config as { model_type?: string } | undefined)
?.model_type || '';
providerIds[platform] = local.id as number;

if (local.prefer) {
setLocalPrefer(true);
setLocalPlatform(platform);
}
});

setLocalTypes(types);
setLocalProviderIds(providerIds);

if (localProviders.length === 0) {
const nextTypes: Record<string, string> = {};
const nextIds: Record<string, number | undefined> = {};
LOCAL_MODEL_OPTIONS.forEach((model) => {
nextTypes[model.id] = '';
nextIds[model.id] = undefined;
});
setLocalTypes(nextTypes);
setLocalProviderIds(nextIds);
}

if (modelType === 'cloud') {
setCloudPrefer(true);
setForm((f) => f.map((fi) => ({ ...fi, prefer: false })));
setLocalPrefer(false);
} else if (modelType === 'local') {
setForm((f) => f.map((fi) => ({ ...fi, prefer: false })));
setLocalPrefer(true);
setCloudPrefer(false);
} else {
setLocalPrefer(false);
setCloudPrefer(false);
}
} catch (e) {
console.error('Error fetching providers:', e);
}
})();
}, [items, modelType]);
const items = DROPDOWN_ITEMS;

/** Model name only in the trigger (e.g. "Gemini 3.1 Pro Preview", no cloud/source prefix). */
const triggerModelName = useMemo(() => {
Expand Down Expand Up @@ -276,11 +169,12 @@ export function ChatInputModelDropdown({

const handleDefaultModelSelect = useCallback(
async (category: DefaultModelCategory, modelId: string) => {
const catalog = useProvidersCatalogStore.getState();
if (
!isDefaultModelConfigured(category, modelId, {
items,
form,
localProviderIds,
form: catalog.form,
localProviderIds: catalog.localProviderIds,
})
) {
navigate(DEFAULT_MODEL_CONFIGURE_PATH);
Expand All @@ -290,30 +184,23 @@ export function ChatInputModelDropdown({
category,
modelId,
items,
form,
setForm: setForm as Dispatch<SetStateAction<unknown[]>>,
setCloudPrefer,
setLocalPrefer,
setLocalPlatform,
localProviderIds,
localPlatform,
form: catalog.form,
setForm: catalog.setForm,
setCloudPrefer: catalog.setCloudPrefer,
setLocalPrefer: catalog.setLocalPrefer,
setLocalPlatform: catalog.setLocalPlatform,
localProviderIds: catalog.localProviderIds,
localPlatform: catalog.localPlatform,
setModelType,
setCloudModelType: (id: string) => {
setCloudModelType(id as never);
if (isCloudModelType(id)) {
setCloudModelType(id);
}
},
t,
});
},
[
items,
form,
localProviderIds,
localPlatform,
navigate,
setModelType,
setCloudModelType,
t,
]
[items, navigate, setModelType, setCloudModelType, t]
);

/** Radix submenu forces align=start (tops align); use alignOffset so sub bottom aligns with the SubTrigger row bottom. */
Expand Down
30 changes: 17 additions & 13 deletions src/components/ChatBox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import {
} from '@/api/http';
import { isWeb } from '@/client/platform';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { useModelConfigCheck } from '@/hooks/useModelConfigCheck';
import { useHost } from '@/host';
import { generateUniqueId } from '@/lib';
import { proxyUpdateTriggerExecution } from '@/service/triggerApi';
Expand Down Expand Up @@ -63,7 +62,10 @@ export default function ChatBox(): JSX.Element {
const sessionSidePanelMode = usePageTabStore(
(s) => s.sessionSidePanelMode ?? SessionMode.WORKFORCE
);
const { hasModel, isConfigLoaded } = useModelConfigCheck();
const hasModel = useAuthStore((s) => s.hasModelConfigured);
const modelConfigCheckCompleted = useAuthStore(
(s) => s.modelConfigCheckCompleted
);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const bottomBoxOverlayRef = useRef<HTMLDivElement>(null);
const [scrollBottomInsetPx, setScrollBottomInsetPx] = useState(
Expand Down Expand Up @@ -228,6 +230,8 @@ export default function ChatBox(): JSX.Element {
);
}, [chatStore?.activeTaskId, chatStore?.tasks]);

const showNoModelOverlay = !hasModel && modelConfigCheckCompleted;

const isInputDisabled = useMemo(() => {
if (!chatStore?.activeTaskId || !chatStore.tasks[chatStore.activeTaskId])
return true;
Expand Down Expand Up @@ -613,10 +617,10 @@ export default function ChatBox(): JSX.Element {
}, [projectStore]);

useEffect(() => {
if (share_token && isConfigLoaded) {
if (share_token && modelConfigCheckCompleted) {
handleSendShare(share_token);
}
}, [share_token, isConfigLoaded, handleSendShare]);
}, [share_token, modelConfigCheckCompleted, handleSendShare]);

if (!chatStore) {
return <div>Loading...</div>;
Expand Down Expand Up @@ -941,10 +945,10 @@ export default function ChatBox(): JSX.Element {
const chatColumn = (
<>
{/* Main: scroll (scrollbar on panel edge) + BottomBox overlay when chatting */}
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="min-h-0 min-w-0 relative flex flex-1 flex-col overflow-hidden">
<div
ref={scrollContainerRef}
className="scrollbar-always-visible min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden"
className="scrollbar-always-visible min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto"
>
{hasAnyMessages ? (
<ProjectChatContainer
Expand All @@ -954,15 +958,15 @@ export default function ChatBox(): JSX.Element {
isPauseResumeLoading={isPauseResumeLoading}
/>
) : (
<div className="mx-auto flex min-h-full w-full max-w-[600px] flex-col pl-4 pr-2">
<div className="flex flex-1 flex-col items-center justify-end gap-1 pb-4"></div>
<div className="pl-4 pr-2 mx-auto flex min-h-full w-full max-w-[600px] flex-col">
<div className="gap-1 pb-4 flex flex-1 flex-col items-center justify-end"></div>

{chatStore.activeTaskId && (
<BottomBox
state="input"
queuedMessages={queuedMessages}
onRemoveQueuedMessage={(id) => handleRemoveTaskQueue(id)}
noModelOverlay={!hasModel}
noModelOverlay={showNoModelOverlay}
onSelectModel={handleSelectModel}
inputProps={{
value: message,
Expand Down Expand Up @@ -997,14 +1001,14 @@ export default function ChatBox(): JSX.Element {
{chatStore.activeTaskId && hasAnyMessages && (
<div
ref={bottomBoxOverlayRef}
className="pointer-events-none absolute inset-x-0 bottom-0 z-30 flex justify-center"
className="inset-x-0 bottom-0 pointer-events-none absolute z-30 flex justify-center"
>
<div className="pointer-events-auto w-full max-w-[600px] px-sm">
<div className="px-sm pointer-events-auto w-full max-w-[600px]">
<BottomBox
state={getBottomBoxState()}
queuedMessages={queuedMessages}
onRemoveQueuedMessage={(id) => handleRemoveTaskQueue(id)}
noModelOverlay={!hasModel}
noModelOverlay={showNoModelOverlay}
onSelectModel={handleSelectModel}
subtitle={
getBottomBoxState() === 'confirm'
Expand Down Expand Up @@ -1064,7 +1068,7 @@ export default function ChatBox(): JSX.Element {
);

return (
<div className="relative flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden">
<div className="min-h-0 relative flex h-full w-full flex-1 flex-col overflow-hidden">
{chatColumn}
</div>
);
Expand Down
Loading