From 81742ce48d63cd1069c4e103b71363d279506d2a Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 1 May 2026 16:49:56 +0100 Subject: [PATCH] feat(ui): consume shared providers catalog in chat and models Wire the model dropdown, workspace, home, and Agents Models page to the catalog store so lists and edits stay consistent across the app. Co-authored-by: Cursor --- .../BottomBox/ChatInputModelDropdown.tsx | 203 ++------- src/components/ChatBox/index.tsx | 30 +- src/components/Workspace/index.tsx | 49 +- src/pages/Agents/Models.tsx | 419 ++++++++---------- src/pages/Home.tsx | 2 + 5 files changed, 282 insertions(+), 421 deletions(-) diff --git a/src/components/ChatBox/BottomBox/ChatInputModelDropdown.tsx b/src/components/ChatBox/BottomBox/ChatInputModelDropdown.tsx index 707393fb..48ae1770 100644 --- a/src/components/ChatBox/BottomBox/ChatInputModelDropdown.tsx +++ b/src/components/ChatBox/BottomBox/ChatInputModelDropdown.tsx @@ -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, @@ -34,7 +33,6 @@ import { isDefaultModelConfigured, type DefaultModelCategory, } from '@/lib/applyDefaultModelSelection'; -import { INIT_PROVODERS } from '@/lib/llm'; import { cn } from '@/lib/utils'; import { getLocalPlatformName, @@ -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, @@ -56,15 +59,7 @@ 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'; @@ -72,18 +67,16 @@ 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 { @@ -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( - 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('ollama'); - const [localTypes, setLocalTypes] = useState>({}); - const [localProviderIds, setLocalProviderIds] = useState< - Record - >({}); - 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 = {}; - const providerIds: Record = {}; - - localProviders.forEach((local: Record) => { - 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 = {}; - const nextIds: Record = {}; - 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(() => { @@ -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); @@ -290,30 +184,23 @@ export function ChatInputModelDropdown({ category, modelId, items, - form, - setForm: setForm as Dispatch>, - 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. */ diff --git a/src/components/ChatBox/index.tsx b/src/components/ChatBox/index.tsx index c2577b8e..176152ca 100644 --- a/src/components/ChatBox/index.tsx +++ b/src/components/ChatBox/index.tsx @@ -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'; @@ -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(null); const bottomBoxOverlayRef = useRef(null); const [scrollBottomInsetPx, setScrollBottomInsetPx] = useState( @@ -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; @@ -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
Loading...
; @@ -941,10 +945,10 @@ export default function ChatBox(): JSX.Element { const chatColumn = ( <> {/* Main: scroll (scrollbar on panel edge) + BottomBox overlay when chatting */} -
+
{hasAnyMessages ? ( ) : ( -
-
+
+
{chatStore.activeTaskId && ( handleRemoveTaskQueue(id)} - noModelOverlay={!hasModel} + noModelOverlay={showNoModelOverlay} onSelectModel={handleSelectModel} inputProps={{ value: message, @@ -997,14 +1001,14 @@ export default function ChatBox(): JSX.Element { {chatStore.activeTaskId && hasAnyMessages && (
-
+
handleRemoveTaskQueue(id)} - noModelOverlay={!hasModel} + noModelOverlay={showNoModelOverlay} onSelectModel={handleSelectModel} subtitle={ getBottomBoxState() === 'confirm' @@ -1064,7 +1068,7 @@ export default function ChatBox(): JSX.Element { ); return ( -
+
{chatColumn}
); diff --git a/src/components/Workspace/index.tsx b/src/components/Workspace/index.tsx index 9ee7cdae..dc3a90de 100644 --- a/src/components/Workspace/index.tsx +++ b/src/components/Workspace/index.tsx @@ -27,7 +27,6 @@ import { WorkspaceExamplePrompts } from '@/components/Workspace/WorkspaceExample import { WorkspaceProjectPicker } from '@/components/Workspace/WorkspaceProjectPicker'; import { WorkspaceRecentSessions } from '@/components/Workspace/WorkspaceRecentSessions'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; -import { useModelConfigCheck } from '@/hooks/useModelConfigCheck'; import { useHost } from '@/host'; import { useAuthStore, useWorkerList } from '@/store/authStore'; import { usePageTabStore } from '@/store/pageTabStore'; @@ -79,7 +78,11 @@ export default function Workspace() { const { modelType, setWorkerList } = useAuthStore(); const [message, setMessage] = useState(''); - const { hasModel } = useModelConfigCheck(); + const hasModel = useAuthStore((s) => s.hasModelConfigured); + const modelConfigCheckCompleted = useAuthStore( + (s) => s.modelConfigCheckCompleted + ); + const showNoModelOverlay = !hasModel && modelConfigCheckCompleted; const [useCloudModelInDev, setUseCloudModelInDev] = useState(false); const [addWorkerDialogOpen, setAddWorkerDialogOpen] = useState(false); const [workspaceWorkWithPanelOpen, setWorkspaceWorkWithPanelOpen] = @@ -274,8 +277,8 @@ export default function Workspace() { }); return ( -
-
+
+
-
-
+
+
-
+
- + {sessionSidePanelMode === SessionMode.SINGLE_AGENT ? t('layout.workspace-cowork-single-agent', { defaultValue: 'Cowork with Single Agent', @@ -308,7 +311,7 @@ export default function Workspace() { defaultValue: 'Cowork with Workforce', })} -
+
{sessionSidePanelMode === SessionMode.SINGLE_AGENT ? ( ) : ( @@ -328,7 +331,7 @@ export default function Workspace() { state="input" queuedMessages={[]} onRemoveQueuedMessage={() => {}} - noModelOverlay={!hasModel} + noModelOverlay={showNoModelOverlay} onSelectModel={() => navigate('/history?tab=agents')} inputProps={{ value: message, @@ -368,7 +371,7 @@ export default function Workspace() {
{showWorkspaceExamplePrompts ? ( @@ -395,7 +398,7 @@ export default function Workspace() { <> ); @@ -1092,16 +1049,16 @@ export default function SettingModels() { if (selectedTab === 'cloud') { if (import.meta.env.VITE_USE_LOCAL_PROXY === 'true') { return ( -
+
{t('setting.cloud-not-available-in-local-proxy')}
); } return ( -
-
-
-
+
+
+
+
{t('setting.eigent-cloud')}
{cloudPrefer ? ( @@ -1152,7 +1109,7 @@ export default function SettingModels() { onClick={() => { window.location.href = `${SITE_URL}/pricing`; }} - className="cursor-pointer text-body-sm text-ds-text-neutral-muted-default underline" + className="text-body-sm text-ds-text-neutral-muted-default cursor-pointer underline" > {t('setting.pricing-options')} @@ -1162,7 +1119,7 @@ export default function SettingModels() {
{/*Content Area*/} -
+
{t('setting.credits')}:{' '} {loadingCredits ? ( @@ -1191,9 +1148,9 @@ export default function SettingModels() {
-
-
- +
+
+ {t('setting.select-model-type')}
@@ -1263,13 +1220,13 @@ export default function SettingModels() { const canSwitch = !!form[idx].provider_id; return ( -
-
-
+
+
+
{item.name}
-
+
{form[idx].prefer ? (