From 2ed8a7107369db4c57111a787762b41207b7b4dc Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 1 May 2026 16:49:30 +0100 Subject: [PATCH 1/5] feat(catalog): add providers merge helpers and shared catalog store Introduce merge utilities for API/catalog form rows and a Zustand store so chat and Agents Models can share one source of truth for BYOK models. Co-authored-by: Cursor --- src/lib/mergeProvidersCatalog.ts | 208 +++++++++++++++++++++++++++++ src/store/providersCatalogStore.ts | 97 ++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 src/lib/mergeProvidersCatalog.ts create mode 100644 src/store/providersCatalogStore.ts diff --git a/src/lib/mergeProvidersCatalog.ts b/src/lib/mergeProvidersCatalog.ts new file mode 100644 index 000000000..aed81530d --- /dev/null +++ b/src/lib/mergeProvidersCatalog.ts @@ -0,0 +1,208 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +/** + * Single source of truth for merging GET /api/v1/providers into UI catalog + * state (chat dropdown + Agents → Models). Keep in sync with Models.tsx. + */ + +import { + getDefaultLocalEndpoint, + LOCAL_MODEL_OPTIONS, +} from '@/pages/Agents/localModels'; +import type { Provider } from '@/types'; + +export type ProvidersCatalogFormRow = { + apiKey: string; + apiHost: string; + is_valid: boolean; + model_type: string; + externalConfig?: + | Array<{ + key: string; + name: string; + value: string; + placeholder?: string; + secret?: boolean; + options?: { label: string; value: string }[]; + }> + | undefined; + provider_id?: number; + prefer: boolean; +}; + +export type ProvidersCatalogModelType = 'cloud' | 'local' | 'custom'; + +export type ProvidersCatalogSnapshot = { + form: ProvidersCatalogFormRow[]; + localEndpoints: Record; + localTypes: Record; + localProviderIds: Record; + cloudPrefer: boolean; + localPrefer: boolean; + localPlatform: string; + /** + * True iff the API response (`prefer=true`) contained at least one row. + * Mirrors the legacy `useModelConfigCheck` heuristic so consumers can derive + * `hasModelConfigured` for local/custom modes without a duplicate fetch. + */ + hasPreferredProvider: boolean; +}; + +export function createInitialCatalogForm( + items: Provider[] +): ProvidersCatalogFormRow[] { + return items.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, + })); +} + +/** Normalized provider row from API (subset used for merge). */ +export type ApiProviderRow = { + id?: number; + provider_name: string; + api_key?: string; + endpoint_url?: string; + is_valid?: boolean; + prefer?: boolean; + model_type?: string; + encrypted_config?: Record; +}; + +function parseProviderList(res: unknown): ApiProviderRow[] { + if (Array.isArray(res)) return res as ApiProviderRow[]; + const items = (res as { items?: ApiProviderRow[] })?.items; + return Array.isArray(items) ? items : []; +} + +/** + * Merge provider API response + auth modelType into catalog snapshot. + * Matches SettingModels load effect (superset: includes localEndpoints). + */ +export function buildProvidersCatalogSnapshot( + items: Provider[], + res: unknown, + modelType: ProvidersCatalogModelType +): ProvidersCatalogSnapshot { + const providerList = parseProviderList(res); + + let form: ProvidersCatalogFormRow[] = createInitialCatalogForm(items); + + form = form.map((fi, idx) => { + const item = items[idx]; + const found = providerList.find((p) => 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, + prefer: fi.prefer ?? false, + }; + }); + + const localProviders = providerList.filter((p) => + LOCAL_MODEL_OPTIONS.some((model) => model.id === p.provider_name) + ); + + // Always seed every known local platform with defaults so consumers can + // index by any LOCAL_MODEL_OPTIONS id without missing keys after a partial + // delete (e.g., user removes Ollama while LM Studio remains configured). + const endpoints: Record = {}; + const types: Record = {}; + const providerIds: Record = {}; + LOCAL_MODEL_OPTIONS.forEach((model) => { + endpoints[model.id] = getDefaultLocalEndpoint(model.id); + types[model.id] = ''; + providerIds[model.id] = undefined; + }); + + let preferredPlatform = ''; + let anyLocalPrefer = false; + + localProviders.forEach((local) => { + const platform = + local.encrypted_config?.model_platform || local.provider_name; + endpoints[platform] = + local.endpoint_url || getDefaultLocalEndpoint(platform); + types[platform] = local.encrypted_config?.model_type || ''; + providerIds[platform] = local.id; + + if (!preferredPlatform) { + preferredPlatform = platform; + } + + if (local.prefer) { + anyLocalPrefer = true; + preferredPlatform = platform; + } + }); + + let cloudPrefer = false; + let localPrefer = false; + let localPlatform = preferredPlatform; + + if (modelType === 'cloud') { + cloudPrefer = true; + form = form.map((fi) => ({ ...fi, prefer: false })); + localPrefer = false; + } else if (modelType === 'local') { + form = form.map((fi) => ({ ...fi, prefer: false })); + localPrefer = true; + cloudPrefer = false; + if (anyLocalPrefer) { + localPlatform = preferredPlatform; + } + } else { + localPrefer = false; + cloudPrefer = false; + } + + return { + form, + localEndpoints: endpoints, + localTypes: types, + localProviderIds: providerIds, + cloudPrefer, + localPrefer, + localPlatform, + hasPreferredProvider: providerList.length > 0, + }; +} diff --git a/src/store/providersCatalogStore.ts b/src/store/providersCatalogStore.ts new file mode 100644 index 000000000..2e8ca87d0 --- /dev/null +++ b/src/store/providersCatalogStore.ts @@ -0,0 +1,97 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { INIT_PROVODERS } from '@/lib/llm'; +import { + buildProvidersCatalogSnapshot, + createInitialCatalogForm, + type ProvidersCatalogFormRow, + type ProvidersCatalogModelType, +} from '@/lib/mergeProvidersCatalog'; +import type { Provider } from '@/types'; +import { create } from 'zustand'; + +export const CATALOG_ITEMS: Provider[] = INIT_PROVODERS.filter( + (p) => p.id !== 'local' +); + +const initialForm = (): ProvidersCatalogFormRow[] => + createInitialCatalogForm(CATALOG_ITEMS); + +export interface ProvidersCatalogState { + form: ProvidersCatalogFormRow[]; + localEndpoints: Record; + localTypes: Record; + localProviderIds: Record; + cloudPrefer: boolean; + localPrefer: boolean; + localPlatform: string; + hasPreferredProvider: boolean; + hydrated: boolean; + lastFetchedAt: number | null; + + applyFromApiResponse: ( + res: unknown, + modelType: ProvidersCatalogModelType + ) => void; + reset: () => void; + setForm: ( + updater: (prev: ProvidersCatalogFormRow[]) => ProvidersCatalogFormRow[] + ) => void; + setCloudPrefer: (v: boolean) => void; + setLocalPrefer: (v: boolean) => void; + setLocalPlatform: (p: string) => void; +} + +const baseState = () => ({ + form: initialForm(), + localEndpoints: {} as Record, + localTypes: {} as Record, + localProviderIds: {} as Record, + cloudPrefer: false, + localPrefer: false, + localPlatform: '', + hasPreferredProvider: false, + hydrated: false, + lastFetchedAt: null as number | null, +}); + +export const useProvidersCatalogStore = create()( + (set) => ({ + ...baseState(), + + applyFromApiResponse: (res, modelType) => { + const snap = buildProvidersCatalogSnapshot(CATALOG_ITEMS, res, modelType); + set({ + ...snap, + hydrated: true, + lastFetchedAt: Date.now(), + }); + }, + + reset: () => set(baseState()), + + setForm: (updater) => set((s) => ({ form: updater(s.form) })), + + setCloudPrefer: (cloudPrefer) => set({ cloudPrefer }), + + setLocalPrefer: (localPrefer) => set({ localPrefer }), + + setLocalPlatform: (localPlatform) => set({ localPlatform }), + }) +); + +export function getProvidersCatalogItems(): Provider[] { + return CATALOG_ITEMS; +} From 35bade7758db83d15915f046c3670f21a42ff429 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 1 May 2026 16:49:37 +0100 Subject: [PATCH 2/5] feat(auth): keep providers catalog in sync with session model config Update auth flows so the shared catalog store reflects login and model-type changes, with unit coverage for the new behavior. Co-authored-by: Cursor --- src/store/authStore.ts | 178 ++++++++++++++++++------------ test/unit/store/authStore.test.ts | 156 ++++++++++++++++++++++++++ 2 files changed, 262 insertions(+), 72 deletions(-) create mode 100644 test/unit/store/authStore.test.ts diff --git a/src/store/authStore.ts b/src/store/authStore.ts index a886c34eb..8c3955276 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -17,6 +17,7 @@ import { getRecommendedContrast, } from '@/lib/themeTokens/catalog'; import type { Mode, ThemeCatalog, ThemeSeed } from '@/lib/themeTokens/types'; +import { useProvidersCatalogStore } from '@/store/providersCatalogStore'; import { create } from 'zustand'; import { persist } from 'zustand/middleware'; @@ -34,20 +35,27 @@ export type WorkspaceMainBackground = | 'ruled' | 'dotted' | 'dashed'; -export type CloudModelType = - | 'gemini-3.1-pro-preview' - | 'gemini-3-pro-preview' - | 'gemini-3-flash-preview' - | 'claude-haiku-4-5' - | 'claude-sonnet-4-5' - | 'claude-sonnet-4-6' - | 'claude-opus-4-6' - | 'claude-opus-4-7' - | 'gpt-5.4' - | 'gpt-5.5' - | 'gpt-5-mini' - | 'deepseek-v4-pro' - | 'minimax_m2_7'; +export const CLOUD_MODEL_TYPES = [ + 'gemini-3.1-pro-preview', + 'gemini-3-pro-preview', + 'gemini-3-flash-preview', + 'claude-haiku-4-5', + 'claude-sonnet-4-5', + 'claude-sonnet-4-6', + 'claude-opus-4-6', + 'claude-opus-4-7', + 'gpt-5.4', + 'gpt-5.5', + 'gpt-5-mini', + 'deepseek-v4-pro', + 'minimax_m2_7', +] as const; +export type CloudModelType = (typeof CLOUD_MODEL_TYPES)[number]; + +/** Narrow an arbitrary string into the {@link CloudModelType} union. */ +export function isCloudModelType(id: string): id is CloudModelType { + return (CLOUD_MODEL_TYPES as readonly string[]).includes(id); +} // auth info interface interface AuthInfo { @@ -82,6 +90,12 @@ interface AuthState { * while the API request is still in flight. */ hasModelConfigured: boolean; + /** + * Session-only: true after the first model-config check finishes this app + * session. Not persisted so overlays / share-token handling do not reset on + * tab switches (see useModelConfigCheck in Home). + */ + modelConfigCheckCompleted: boolean; initState: InitState; // IDE preference @@ -121,6 +135,7 @@ interface AuthState { setModelType: (modelType: ModelType) => void; setCloudModelType: (cloud_model_type: CloudModelType) => void; setHasModelConfigured: (hasModelConfigured: boolean) => void; + setModelConfigCheckCompleted: (completed: boolean) => void; setIsFirstLaunch: (isFirstLaunch: boolean) => void; setPreferredIDE: (ide: PreferredIDE) => void; setWorkspaceMainBackground: (value: WorkspaceMainBackground) => void; @@ -136,6 +151,70 @@ const getRandomDefaultModel = (): CloudModelType => { return models[Math.floor(Math.random() * models.length)]; }; +export function migrateAuthPersistedState( + persistedState: unknown, + _fromVersion: number +): unknown { + const s = persistedState as + | { + appearance?: string; + appearanceMode?: AppearanceMode; + customThemeCatalog?: Partial; + workspaceMainBackground?: string; + hasModelConfigured?: boolean; + } + | undefined; + if (!s) return persistedState; + + const rawWmb = s.workspaceMainBackground; + let workspaceMainBackground: WorkspaceMainBackground = 'empty'; + if ( + rawWmb === 'dots' || + rawWmb === 'blocks' || + rawWmb === 'ruled' || + rawWmb === 'dotted' || + rawWmb === 'dashed' + ) { + workspaceMainBackground = rawWmb; + } else if (rawWmb === 'margin-ruled') { + workspaceMainBackground = 'ruled'; + } else if (rawWmb === 'empty' || rawWmb === 'none') { + workspaceMainBackground = 'empty'; + } + + const normalizedAppearance: Mode = s.appearance === 'dark' ? 'dark' : 'light'; + const normalizedAppearanceMode: AppearanceMode = + s.appearanceMode === 'system' || s.appearanceMode === 'dark' + ? s.appearanceMode + : normalizedAppearance; + const normalizedCustomCatalog: ThemeCatalog = { + light: s.customThemeCatalog?.light ?? {}, + dark: s.customThemeCatalog?.dark ?? {}, + }; + + const hasModelConfigured = + typeof s.hasModelConfigured === 'boolean' ? s.hasModelConfigured : false; + + if (s.appearance === 'transparent') { + return { + ...s, + appearance: 'light', + appearanceMode: 'light', + customThemeCatalog: normalizedCustomCatalog, + workspaceMainBackground, + hasModelConfigured, + }; + } + return { + ...s, + appearance: normalizedAppearance, + appearanceMode: normalizedAppearanceMode, + customThemeCatalog: normalizedCustomCatalog, + workspaceMainBackground, + hasModelConfigured, + }; +} + // create store const authStore = create()( persist( @@ -159,6 +238,7 @@ const authStore = create()( modelType: 'cloud', cloud_model_type: getRandomDefaultModel(), hasModelConfigured: false, + modelConfigCheckCompleted: false, preferredIDE: 'system', workspaceMainBackground: 'empty', initState: 'carousel', @@ -170,7 +250,8 @@ const authStore = create()( setAuth: ({ token, username, email, user_id }) => set({ token, username, email, user_id }), - logout: () => + logout: () => { + useProvidersCatalogStore.getState().reset(); set({ token: null, username: null, @@ -178,7 +259,10 @@ const authStore = create()( user_id: null, initState: 'carousel', localProxyValue: null, - }), + hasModelConfigured: false, + modelConfigCheckCompleted: false, + }); + }, // set related methods setAppearance: (appearance) => @@ -254,6 +338,9 @@ const authStore = create()( setHasModelConfigured: (hasModelConfigured) => set({ hasModelConfigured }), + setModelConfigCheckCompleted: (modelConfigCheckCompleted) => + set({ modelConfigCheckCompleted }), + setIsFirstLaunch: (isFirstLaunch) => set({ isFirstLaunch }), setPreferredIDE: (preferredIDE) => set({ preferredIDE }), @@ -306,62 +393,8 @@ const authStore = create()( }), { name: 'auth-storage', - version: 6, - migrate: (persistedState, _version) => { - const s = persistedState as - | { - appearance?: string; - appearanceMode?: AppearanceMode; - customThemeCatalog?: Partial; - workspaceMainBackground?: string; - } - | undefined; - if (!s) return persistedState as typeof persistedState; - - const rawWmb = s.workspaceMainBackground; - let workspaceMainBackground: WorkspaceMainBackground = 'empty'; - if ( - rawWmb === 'dots' || - rawWmb === 'blocks' || - rawWmb === 'ruled' || - rawWmb === 'dotted' || - rawWmb === 'dashed' - ) { - workspaceMainBackground = rawWmb; - } else if (rawWmb === 'margin-ruled') { - workspaceMainBackground = 'ruled'; - } else if (rawWmb === 'empty' || rawWmb === 'none') { - workspaceMainBackground = 'empty'; - } - - const normalizedAppearance: Mode = - s.appearance === 'dark' ? 'dark' : 'light'; - const normalizedAppearanceMode: AppearanceMode = - s.appearanceMode === 'system' || s.appearanceMode === 'dark' - ? s.appearanceMode - : normalizedAppearance; - const normalizedCustomCatalog: ThemeCatalog = { - light: s.customThemeCatalog?.light ?? {}, - dark: s.customThemeCatalog?.dark ?? {}, - }; - - if (s.appearance === 'transparent') { - return { - ...s, - appearance: 'light', - appearanceMode: 'light', - customThemeCatalog: normalizedCustomCatalog, - workspaceMainBackground, - }; - } - return { - ...s, - appearance: normalizedAppearance, - appearanceMode: normalizedAppearanceMode, - customThemeCatalog: normalizedCustomCatalog, - workspaceMainBackground, - } as typeof persistedState; - }, + version: 7, + migrate: migrateAuthPersistedState, partialize: (state) => ({ token: state.token, username: state.username, @@ -376,6 +409,7 @@ const authStore = create()( language: state.language, modelType: state.modelType, cloud_model_type: state.cloud_model_type, + hasModelConfigured: state.hasModelConfigured, initState: state.initState, isFirstLaunch: state.isFirstLaunch, preferredIDE: state.preferredIDE, diff --git a/test/unit/store/authStore.test.ts b/test/unit/store/authStore.test.ts new file mode 100644 index 000000000..64cf0d733 --- /dev/null +++ b/test/unit/store/authStore.test.ts @@ -0,0 +1,156 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { migrateAuthPersistedState } from '@/store/authStore'; +import { describe, expect, it } from 'vitest'; + +type MigratedState = { + token: string; + username: string; + email: string; + user_id: number; + appearance: string; + appearanceMode: string; + language: string; + modelType: string; + hasModelConfigured: boolean; + workspaceMainBackground: string; + customThemeCatalog: { + light: Record; + dark: Record; + }; + workerListData: Record; +}; + +describe('authStore migration', () => { + it('migrates v6 state to v7 without dropping existing fields', () => { + const v6State = { + token: 'eigent-token-1', + username: 'Eigent', + email: 'eigent@example.com', + user_id: 42, + appearance: 'dark', + appearanceMode: 'system', + customThemeCatalog: { + light: { custom: { id: 'custom' } }, + }, + workspaceMainBackground: 'margin-ruled', + language: 'en', + modelType: 'custom', + workerListData: { + 'eigent@example.com': [{ id: 'eigent-agent-1' }], + }, + }; + + const migrated = migrateAuthPersistedState(v6State, 6) as MigratedState; + + expect(migrated).toMatchObject({ + token: v6State.token, + username: v6State.username, + email: v6State.email, + user_id: v6State.user_id, + language: v6State.language, + modelType: v6State.modelType, + workerListData: v6State.workerListData, + appearance: 'dark', + appearanceMode: 'system', + workspaceMainBackground: 'ruled', + hasModelConfigured: false, + }); + expect(migrated.customThemeCatalog.light).toEqual( + v6State.customThemeCatalog.light + ); + expect(migrated.customThemeCatalog.dark).toEqual({}); + }); + + it('normalizes the same way regardless of source version (e.g. v5)', () => { + const v5State = { + token: 'eigent-token-2', + username: 'Eigent', + email: 'eigent@example.com', + user_id: 7, + appearance: 'light', + language: 'system', + modelType: 'cloud', + workerListData: {}, + }; + + const migrated = migrateAuthPersistedState(v5State, 5) as MigratedState; + + expect(migrated).toMatchObject({ + appearance: 'light', + appearanceMode: 'light', + workspaceMainBackground: 'empty', + hasModelConfigured: false, + }); + expect(migrated.customThemeCatalog).toEqual({ light: {}, dark: {} }); + }); + + it('rewrites the legacy "transparent" appearance to light', () => { + const legacyState = { + token: 'eigent-token-3', + username: 'Eigent', + email: 'eigent@example.com', + appearance: 'transparent', + appearanceMode: 'transparent', + modelType: 'cloud', + }; + + const migrated = migrateAuthPersistedState(legacyState, 4) as MigratedState; + + expect(migrated.appearance).toBe('light'); + expect(migrated.appearanceMode).toBe('light'); + }); + + it('coerces unknown workspaceMainBackground values to "empty"', () => { + const legacyState = { + token: 'eigent-token-4', + username: 'Eigent', + email: 'eigent@example.com', + appearance: 'light', + modelType: 'cloud', + workspaceMainBackground: 'none', + }; + const noneMigrated = migrateAuthPersistedState( + legacyState, + 6 + ) as MigratedState; + expect(noneMigrated.workspaceMainBackground).toBe('empty'); + + const garbageMigrated = migrateAuthPersistedState( + { ...legacyState, workspaceMainBackground: 'something-removed' }, + 6 + ) as MigratedState; + expect(garbageMigrated.workspaceMainBackground).toBe('empty'); + }); + + it('preserves an explicit hasModelConfigured=true flag', () => { + const v6State = { + token: 'eigent-token-5', + username: 'Eigent', + email: 'eigent@example.com', + appearance: 'light', + modelType: 'custom', + hasModelConfigured: true, + }; + + const migrated = migrateAuthPersistedState(v6State, 6) as MigratedState; + + expect(migrated.hasModelConfigured).toBe(true); + }); + + it('returns the input unchanged when the persisted state is undefined', () => { + expect(migrateAuthPersistedState(undefined, 6)).toBeUndefined(); + }); +}); From 7552063adc89df04f1e9ded7375c4c3fa48a4354 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 1 May 2026 16:49:44 +0100 Subject: [PATCH 3/5] feat(layout): background-sync providers catalog on auth routes Mount a shared hook from Layout so home and history keep the BYOK catalog fresh without duplicate fetches from nested views. Co-authored-by: Cursor --- src/components/Layout/index.tsx | 2 + src/hooks/useProvidersCatalogSync.ts | 79 ++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 src/hooks/useProvidersCatalogSync.ts diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 27ffaf3fe..eaf0b3373 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -18,6 +18,7 @@ import { AnimationJson } from '@/components/Layout/AnimationJson'; import TopBar from '@/components/TopBar'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import { useInstallationSetup } from '@/hooks/useInstallationSetup'; +import { useProvidersCatalogSync } from '@/hooks/useProvidersCatalogSync'; import { useHost } from '@/host'; import { useAuthStore } from '@/store/authStore'; import { useInstallationUI } from '@/store/installationStore'; @@ -53,6 +54,7 @@ const Layout = () => { } = useInstallationUI(); useInstallationSetup(); + useProvidersCatalogSync(); useEffect(() => { if (!host?.ipcRenderer || !host?.electronAPI) return; diff --git a/src/hooks/useProvidersCatalogSync.ts b/src/hooks/useProvidersCatalogSync.ts new file mode 100644 index 000000000..947ae487c --- /dev/null +++ b/src/hooks/useProvidersCatalogSync.ts @@ -0,0 +1,79 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { proxyFetchGet } from '@/api/http'; +import { useAuthStore } from '@/store/authStore'; +import { useProvidersCatalogStore } from '@/store/providersCatalogStore'; +import { useCallback, useEffect, useRef } from 'react'; + +/** + * Background sync for BYOK/local provider catalog (shared by chat model + * dropdown and Agents → Models). Mount from Layout so `/` and `/history` both + * stay fresh without per-input remount fetches. + */ +export function useProvidersCatalogSync(): void { + const token = useAuthStore((s) => s.token); + const modelType = useAuthStore((s) => s.modelType); + const applyFromApiResponse = useProvidersCatalogStore( + (s) => s.applyFromApiResponse + ); + const latestModelTypeRef = useRef(modelType); + const latestApplyFromApiResponseRef = useRef(applyFromApiResponse); + const inFlightRef = useRef(false); + const pendingSyncRef = useRef(false); + + const sync = useCallback(async () => { + if (!useAuthStore.getState().token) return; + + if (inFlightRef.current) { + pendingSyncRef.current = true; + return; + } + + inFlightRef.current = true; + try { + do { + pendingSyncRef.current = false; + if (!useAuthStore.getState().token) return; + + const res = await proxyFetchGet('/api/v1/providers', { prefer: true }); + if (!useAuthStore.getState().token) return; + + latestApplyFromApiResponseRef.current(res, latestModelTypeRef.current); + } while (pendingSyncRef.current); + } catch (e) { + console.error('Failed to sync providers catalog:', e); + } finally { + inFlightRef.current = false; + } + }, []); + + useEffect(() => { + latestApplyFromApiResponseRef.current = applyFromApiResponse; + }, [applyFromApiResponse]); + + useEffect(() => { + latestModelTypeRef.current = modelType; + if (!token) return; + sync(); + }, [token, modelType, sync]); + + useEffect(() => { + const onFocus = () => { + sync(); + }; + window.addEventListener('focus', onFocus); + return () => window.removeEventListener('focus', onFocus); + }, [sync]); +} From 6f9312759213c654775dbb7403241bb23f08bbce Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 1 May 2026 16:49:50 +0100 Subject: [PATCH 4/5] fix(models): align model config gate with shared catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rely on the catalog store for availability checks and tighten default-model selection so chat matches Agents → Models when switching in-app. Co-authored-by: Cursor --- src/hooks/useModelConfigCheck.ts | 69 +++++++++++++++++++-------- src/lib/applyDefaultModelSelection.ts | 25 ++++++---- 2 files changed, 63 insertions(+), 31 deletions(-) diff --git a/src/hooks/useModelConfigCheck.ts b/src/hooks/useModelConfigCheck.ts index 89d94a9f1..2e397de2d 100644 --- a/src/hooks/useModelConfigCheck.ts +++ b/src/hooks/useModelConfigCheck.ts @@ -14,50 +14,65 @@ import { proxyFetchGet } from '@/api/http'; import { useAuthStore } from '@/store/authStore'; -import { useCallback, useEffect, useState } from 'react'; +import { useProvidersCatalogStore } from '@/store/providersCatalogStore'; +import { useCallback, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; /** - * Centralized model-configuration check. + * Centralized model-configuration check. Mount once from Home (workspace + * shell) so tab switches do not reset session state or duplicate requests. * * Reads the last known result from the persisted auth store so returning * users get the correct UI on first paint (no overlay flash). Re-validates - * silently in the background on mount, when `modelType` changes, when the - * route returns to `/`, and when the window regains focus. On API failure - * the previous value is kept rather than reset to `false`, so a transient - * error doesn't briefly hide the input. + * silently in the background when `modelType` changes, when the route is + * `/`, and when the window regains focus. + * + * Cloud mode hits `/api/v1/user/key` directly (no catalog overlap). Local / + * custom modes derive `hasModelConfigured` from `useProvidersCatalogStore` + * (`hasPreferredProvider`), avoiding a duplicate `GET /api/v1/providers` + * — Layout's `useProvidersCatalogSync` already keeps the catalog fresh. + * + * On API failure the previous value is kept rather than reset to `false`, + * so a transient error doesn't briefly hide the input. + * `modelConfigCheckCompleted` in the store is set in `finally` for overlay + * / share-token guards without per-component local state. */ -export function useModelConfigCheck(): { - hasModel: boolean; - isConfigLoaded: boolean; -} { +export function useModelConfigCheck(): void { const modelType = useAuthStore((s) => s.modelType); - const hasModel = useAuthStore((s) => s.hasModelConfigured); const setHasModelConfigured = useAuthStore((s) => s.setHasModelConfigured); + const setModelConfigCheckCompleted = useAuthStore( + (s) => s.setModelConfigCheckCompleted + ); const location = useLocation(); - // Session-only: true once the first check has completed at least once, - // used by callers that need to wait for a fresh validation (e.g. share - // token handling) rather than trusting the persisted optimistic value. - const [isConfigLoaded, setIsConfigLoaded] = useState(false); const checkModelConfig = useCallback(async () => { + let shouldMarkCompleted = true; try { if (modelType === 'cloud') { const res = await proxyFetchGet('/api/v1/user/key'); setHasModelConfigured(!!res.value); } else if (modelType === 'local' || modelType === 'custom') { - const res = await proxyFetchGet('/api/v1/providers', { prefer: true }); - const providerList = res.items || []; - setHasModelConfigured(providerList.length > 0); + const catalog = useProvidersCatalogStore.getState(); + if (catalog.lastFetchedAt != null) { + setHasModelConfigured(catalog.hasPreferredProvider); + } else { + // Defer completion until catalog sync hydrates to avoid showing + // "no model configured" from stale/default state. + shouldMarkCompleted = false; + setModelConfigCheckCompleted(false); + } + // If the catalog hasn't loaded yet, the subscription effect below + // mirrors `hasPreferredProvider` once Layout's sync lands. } else { setHasModelConfigured(false); } } catch (err) { console.error('Failed to check model config:', err); } finally { - setIsConfigLoaded(true); + if (!shouldMarkCompleted) return; + setModelConfigCheckCompleted(true); } - }, [modelType, setHasModelConfigured]); + }, [modelType, setHasModelConfigured, setModelConfigCheckCompleted]); useEffect(() => { checkModelConfig(); @@ -79,5 +94,17 @@ export function useModelConfigCheck(): { }; }, [checkModelConfig]); - return { hasModel, isConfigLoaded }; + // For local/custom: subscribe to catalog updates so changes from the + // shared sync land in `hasModelConfigured` without re-fetching. + useEffect(() => { + if (modelType !== 'local' && modelType !== 'custom') return; + const apply = () => { + const state = useProvidersCatalogStore.getState(); + if (state.lastFetchedAt == null) return; + setHasModelConfigured(state.hasPreferredProvider); + setModelConfigCheckCompleted(true); + }; + apply(); + return useProvidersCatalogStore.subscribe(apply); + }, [modelType, setHasModelConfigured, setModelConfigCheckCompleted]); } diff --git a/src/lib/applyDefaultModelSelection.ts b/src/lib/applyDefaultModelSelection.ts index ee02b69fd..5e8202d27 100644 --- a/src/lib/applyDefaultModelSelection.ts +++ b/src/lib/applyDefaultModelSelection.ts @@ -20,7 +20,6 @@ import { proxyFetchGet, proxyFetchPost } from '@/api/http'; import type { Provider } from '@/types'; import type { TFunction } from 'i18next'; -import type { Dispatch, SetStateAction } from 'react'; import { toast } from 'sonner'; export type DefaultModelCategory = 'cloud' | 'custom' | 'local'; @@ -30,6 +29,10 @@ export type DefaultModelFormRow = { prefer?: boolean; }; +type SetDefaultModelFormUpdater = ( + updater: (prev: T[]) => T[] +) => void; + export function isDefaultModelConfigured( category: DefaultModelCategory, modelId: string, @@ -64,13 +67,15 @@ async function checkHasSearchKey(): Promise { return Boolean(hasApiKey && hasApiId); } -export interface ApplyDefaultModelSelectionParams { +export interface ApplyDefaultModelSelectionParams< + TFormRow extends DefaultModelFormRow = DefaultModelFormRow, +> { category: DefaultModelCategory; modelId: string; items: Provider[]; - form: DefaultModelFormRow[]; + form: TFormRow[]; /** Full provider form state from UIs (BYOK + chat); we only read/update `prefer`. */ - setForm: Dispatch>; + setForm: SetDefaultModelFormUpdater; setCloudPrefer: (v: boolean) => void; setLocalPrefer: (v: boolean) => void; setLocalPlatform: (p: string) => void; @@ -85,9 +90,9 @@ export interface ApplyDefaultModelSelectionParams { * Applies default model for an already-configured option. Call only when * {@link isDefaultModelConfigured} is true. */ -export async function applyDefaultModelSelection( - params: ApplyDefaultModelSelectionParams -): Promise { +export async function applyDefaultModelSelection< + TFormRow extends DefaultModelFormRow, +>(params: ApplyDefaultModelSelectionParams): Promise { const { category, modelId, @@ -106,7 +111,7 @@ export async function applyDefaultModelSelection( try { if (category === 'cloud') { - setForm((f) => (f as object[]).map((fi) => ({ ...fi, prefer: false }))); + setForm((f) => f.map((fi) => ({ ...fi, prefer: false }) as TFormRow)); setLocalPrefer(false); setCloudPrefer(true); setModelType('cloud'); @@ -138,7 +143,7 @@ export async function applyDefaultModelSelection( setCloudPrefer(false); setLocalPrefer(false); setForm((f) => - (f as object[]).map((fi, i) => ({ ...fi, prefer: i === idx })) + f.map((fi, i) => ({ ...fi, prefer: i === idx }) as TFormRow) ); return true; } @@ -163,7 +168,7 @@ export async function applyDefaultModelSelection( provider_id: targetProviderId, }); setModelType('local'); - setForm((f) => (f as object[]).map((fi) => ({ ...fi, prefer: false }))); + setForm((f) => f.map((fi) => ({ ...fi, prefer: false }) as TFormRow)); setLocalPrefer(true); setCloudPrefer(false); return true; From 81742ce48d63cd1069c4e103b71363d279506d2a Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 1 May 2026 16:49:56 +0100 Subject: [PATCH 5/5] 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 707393fb5..48ae17702 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 c2577b8e4..176152ca8 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 9ee7cdae0..dc3a90de1 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 ? (