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 <cursoragent@cursor.com>
This commit is contained in:
Douglas 2026-05-01 16:49:56 +01:00
parent 6f93127592
commit 81742ce48d
5 changed files with 282 additions and 421 deletions

View file

@ -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<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(() => {
@ -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<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. */

View file

@ -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<HTMLDivElement>(null);
const bottomBoxOverlayRef = useRef<HTMLDivElement>(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 <div>Loading...</div>;
@ -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
@ -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,
@ -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'
@ -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>
);

View file

@ -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 (
<div className="relative flex h-full min-h-0 w-full flex-col">
<div className="relative z-50 flex h-[44px] w-full shrink-0 flex-row items-center justify-start px-3">
<div className="min-h-0 relative flex h-full w-full flex-col">
<div className="px-3 relative z-50 flex h-[44px] w-full shrink-0 flex-row items-center justify-start">
<TooltipSimple content={workWithPanelToggleLabel} delayDuration={300}>
<Button
type="button"
@ -285,21 +288,21 @@ export default function Workspace() {
onClick={() => setWorkspaceWorkWithPanelOpen((open) => !open)}
aria-expanded={workspaceWorkWithPanelOpen}
aria-controls="workspace-work-with-panel"
className="no-drag shrink-0 text-ds-text-neutral-muted-default hover:bg-ds-bg-neutral-strong-default"
className="no-drag text-ds-text-neutral-muted-default hover:bg-ds-bg-neutral-strong-default shrink-0"
aria-label={workWithPanelToggleLabel}
>
<Cast className="h-4 w-4" aria-hidden />
</Button>
</TooltipSimple>
</div>
<div className="relative z-0 flex min-h-0 w-full flex-1 flex-col items-stretch overflow-hidden">
<div className="flex min-h-0 w-full flex-1 flex-col px-3">
<div className="min-h-0 relative z-0 flex w-full flex-1 flex-col items-stretch overflow-hidden">
<div className="min-h-0 px-3 flex w-full flex-1 flex-col">
<div className="mx-auto flex w-full max-w-[600px] shrink-0 flex-col">
<div className="flex min-h-[50vh] w-full min-w-0 flex-col justify-end">
<div className="min-w-0 flex min-h-[50vh] w-full flex-col justify-end">
<div className="mb-8 flex w-full justify-center">
<WorkspaceProjectPicker />
</div>
<span className="mb-8 w-full text-center text-heading-lg font-bold text-ds-text-neutral-default-default">
<span className="mb-8 text-heading-lg font-bold text-ds-text-neutral-default-default w-full text-center">
{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',
})}
</span>
<div className="mb-8 flex w-full justify-center px-5">
<div className="mb-8 px-5 flex w-full justify-center">
{sessionSidePanelMode === SessionMode.SINGLE_AGENT ? (
<SingleAgentList />
) : (
@ -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() {
</div>
<div
className="flex min-h-0 w-full flex-1 flex-col overflow-y-auto pt-6"
className="min-h-0 pt-6 flex w-full flex-1 flex-col overflow-y-auto"
id="workspace-bottom-group"
>
{showWorkspaceExamplePrompts ? (
@ -395,7 +398,7 @@ export default function Workspace() {
<>
<button
type="button"
className="absolute inset-0 z-40 cursor-default bg-transparent backdrop-blur-[1px]"
className="inset-0 absolute z-40 cursor-default bg-transparent backdrop-blur-[1px]"
aria-label={t('layout.workspace-work-with-dismiss-overlay', {
defaultValue: 'Dismiss',
})}
@ -406,10 +409,10 @@ export default function Workspace() {
role="dialog"
aria-modal="true"
aria-labelledby="workspace-work-with-heading"
className="absolute left-0 top-8 z-50 flex max-h-[calc(100%-2.75rem)] w-[300px] flex-col overflow-y-auto duration-200 ease-out animate-in fade-in-0 slide-in-from-left-2"
className="left-0 top-8 ease-out animate-in fade-in-0 slide-in-from-left-2 absolute z-50 flex max-h-[calc(100%-2.75rem)] w-[300px] flex-col overflow-y-auto duration-200"
>
<div className="flex flex-col gap-3 p-3">
<div className="flex min-w-0 flex-col rounded-xl border border-solid border-ds-border-neutral-subtle-default bg-ds-bg-neutral-subtle-default p-3">
<div className="gap-3 p-3 flex flex-col">
<div className="min-w-0 rounded-xl border-ds-border-neutral-subtle-default bg-ds-bg-neutral-subtle-default p-3 flex flex-col border border-solid">
<span
id="workspace-work-with-heading"
className="text-body-sm font-semibold text-ds-text-neutral-default-default"
@ -418,7 +421,7 @@ export default function Workspace() {
defaultValue: 'Work with',
})}
</span>
<div className="mt-3 flex flex-col gap-1">
<div className="mt-3 gap-1 flex flex-col">
<Button
type="button"
variant="ghost"
@ -426,10 +429,10 @@ export default function Workspace() {
emphasis="default"
size="sm"
buttonContent="text"
className="no-drag justify-start gap-2"
className="no-drag gap-2 justify-start"
>
<MonitorSmartphone
className="h-4 w-4 shrink-0 text-ds-text-neutral-muted-default"
className="h-4 w-4 text-ds-text-neutral-muted-default shrink-0"
aria-hidden
/>
{t('layout.workspace-work-with-remote-control', {
@ -443,7 +446,7 @@ export default function Workspace() {
emphasis="default"
size="sm"
buttonContent="text"
className="no-drag justify-start gap-2"
className="no-drag gap-2 justify-start"
aria-label={t('layout.channels-telegram', {
defaultValue: 'Telegram',
})}
@ -465,7 +468,7 @@ export default function Workspace() {
emphasis="default"
size="sm"
buttonContent="text"
className="no-drag justify-start gap-2"
className="no-drag gap-2 justify-start"
aria-label={t('layout.channels-lark', {
defaultValue: 'Lark',
})}
@ -473,7 +476,7 @@ export default function Workspace() {
<img
src={larkIcon}
alt=""
className="h-4 w-4 shrink-0 rounded-lg object-contain"
className="h-4 w-4 rounded-lg shrink-0 object-contain"
aria-hidden
/>
{t('layout.channels-lark', { defaultValue: 'Lark' })}
@ -485,7 +488,7 @@ export default function Workspace() {
emphasis="default"
size="sm"
buttonContent="text"
className="no-drag justify-start gap-2"
className="no-drag gap-2 justify-start"
aria-label={t('layout.channels-whatsapp', {
defaultValue: 'WhatsApp',
})}

View file

@ -39,7 +39,9 @@ import {
} from '@/components/ui/select';
import { SITE_URL } from '@/lib';
import { INIT_PROVODERS } from '@/lib/llm';
import { useAuthStore } from '@/store/authStore';
import type { ProvidersCatalogFormRow } from '@/lib/mergeProvidersCatalog';
import { isCloudModelType, useAuthStore } from '@/store/authStore';
import { useProvidersCatalogStore } from '@/store/providersCatalogStore';
import { Provider } from '@/types';
import {
Check,
@ -54,7 +56,7 @@ import {
Server,
Settings,
} from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
@ -115,7 +117,7 @@ export default function SettingModels() {
const [items, _setItems] = useState<Provider[]>(
INIT_PROVODERS.filter((p) => p.id !== 'local')
);
const [form, setForm] = useState(() =>
const [form, setForm] = useState<ProvidersCatalogFormRow[]>(() =>
INIT_PROVODERS.filter((p) => p.id !== 'local').map((p) => ({
apiKey: p.apiKey,
apiHost: p.apiHost,
@ -162,8 +164,7 @@ export default function SettingModels() {
// Local Model independent state - per platform
const [localEnabled, setLocalEnabled] = useState(true);
const [localPlatform, setLocalPlatform] =
useState<string>(OLLAMA_PROVIDER_ID);
const [localPlatform, setLocalPlatform] = useState<string>('');
const [localEndpoints, setLocalEndpoints] = useState<Record<string, string>>(
{}
);
@ -176,6 +177,63 @@ export default function SettingModels() {
const [localInputError, setLocalInputError] = useState(false);
const [localPrefer, setLocalPrefer] = useState(false); // Local model prefer state (for current platform)
// Per-entity dirty tracking: refs (not state) so onChange handlers don't
// cause re-renders, and so the catalog-pull effect runs whenever the
// catalog refreshes — only the matching dirty row/platform is preserved.
const dirtyFormIndicesRef = useRef<Set<number>>(new Set());
const dirtyLocalPlatformsRef = useRef<Set<string>>(new Set());
const markFormRowDirty = useCallback((idx: number) => {
dirtyFormIndicesRef.current.add(idx);
}, []);
const clearFormRowDirty = useCallback((idx: number) => {
dirtyFormIndicesRef.current.delete(idx);
}, []);
const markLocalPlatformDirty = useCallback((platform: string) => {
dirtyLocalPlatformsRef.current.add(platform);
}, []);
const clearLocalPlatformDirty = useCallback((platform: string) => {
dirtyLocalPlatformsRef.current.delete(platform);
}, []);
const pullProvidersCatalogIntoModelsState = useCallback(() => {
const s = useProvidersCatalogStore.getState();
const dirtyForm = dirtyFormIndicesRef.current;
const dirtyLocal = dirtyLocalPlatformsRef.current;
setForm((prev) =>
s.form.map((row, idx) => (dirtyForm.has(idx) ? prev[idx] : row))
);
setLocalEndpoints((prev) => {
if (dirtyLocal.size === 0) return s.localEndpoints;
const next: Record<string, string> = { ...s.localEndpoints };
for (const p of dirtyLocal) {
if (prev[p] !== undefined) next[p] = prev[p];
}
return next;
});
setLocalTypes((prev) => {
if (dirtyLocal.size === 0) return s.localTypes;
const next: Record<string, string> = { ...s.localTypes };
for (const p of dirtyLocal) {
if (prev[p] !== undefined) next[p] = prev[p];
}
return next;
});
setLocalProviderIds(s.localProviderIds);
setCloudPrefer(s.cloudPrefer);
setLocalPrefer(s.localPrefer);
if (!dirtyLocal.has(s.localPlatform)) {
setLocalPlatform(s.localPlatform);
}
if (modelType === 'local') {
setLocalEnabled(true);
}
}, [modelType]);
const catalogHydrated = useProvidersCatalogStore((s) => s.hydrated);
const catalogLastFetchedAt = useProvidersCatalogStore((s) => s.lastFetchedAt);
// Per-platform model list state: { models, loading, error } keyed by platform ID.
const [platformModelState, setPlatformModelState] = useState<
Record<string, { models: string[]; loading: boolean; error: string | null }>
@ -251,115 +309,20 @@ export default function SettingModels() {
modelId: string;
} | null>(null);
// Load provider list and populate form
useEffect(() => {
(async () => {
try {
const res = await proxyFetchGet('/api/v1/providers');
const providerList = Array.isArray(res) ? res : res.items || [];
// Handle custom models
setForm((f) =>
f.map((fi, idx) => {
const item = items[idx];
const found = providerList.find(
(p: any) => p.provider_name === item.id
);
if (found) {
return {
...fi,
provider_id: found.id,
apiKey: found.api_key || '',
// Fall back to provider's default API host if endpoint_url is empty
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;
})
);
// Handle local models - load all local providers per platform
const localProviders = providerList.filter((p: any) =>
LOCAL_MODEL_OPTIONS.some((model) => model.id === p.provider_name)
);
const endpoints: Record<string, string> = {};
const types: Record<string, string> = {};
const providerIds: Record<string, number | undefined> = {};
localProviders.forEach((local: any) => {
const platform =
local.encrypted_config?.model_platform || local.provider_name;
// Auto-populate platform default endpoint if not set
endpoints[platform] =
local.endpoint_url || getDefaultLocalEndpoint(platform);
types[platform] = local.encrypted_config?.model_type || '';
providerIds[platform] = local.id;
// Set prefer state if any local model is preferred
if (local.prefer) {
setLocalPrefer(true);
setLocalPlatform(platform);
}
});
setLocalEndpoints(endpoints);
setLocalTypes(types);
setLocalProviderIds(providerIds);
// Fetch model lists for all providers that support it
LOCAL_MODEL_OPTIONS.filter((m) => m.fetchPath).forEach((m) => {
const ep = endpoints[m.id] || m.defaultEndpoint;
fetchModelsForPlatform(m.id, ep);
});
// If no local providers found, initialize empty state with Ollama default
if (localProviders.length === 0) {
LOCAL_MODEL_OPTIONS.forEach((model) => {
endpoints[model.id] = getDefaultLocalEndpoint(model.id);
types[model.id] = '';
providerIds[model.id] = undefined;
});
setLocalEndpoints(endpoints);
setLocalTypes(types);
setLocalProviderIds(providerIds);
}
if (modelType === 'cloud') {
setCloudPrefer(true);
setForm((f) => f.map((fi) => ({ ...fi, prefer: false })));
setLocalPrefer(false);
} else if (modelType === 'local') {
setLocalEnabled(true);
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);
// ignore error
}
})();
if (import.meta.env.VITE_USE_LOCAL_PROXY !== 'true') {
fetchSubscription();
updateCredits();
}
}, [items, modelType, fetchModelsForPlatform]);
if (!catalogHydrated || catalogLastFetchedAt == null) return;
pullProvidersCatalogIntoModelsState();
const s = useProvidersCatalogStore.getState();
LOCAL_MODEL_OPTIONS.filter((m) => m.fetchPath).forEach((m) => {
const ep = s.localEndpoints[m.id] || m.defaultEndpoint;
fetchModelsForPlatform(m.id, ep);
});
}, [
catalogHydrated,
catalogLastFetchedAt,
fetchModelsForPlatform,
pullProvidersCatalogIntoModelsState,
]);
// Get current default model display text
const getDefaultModelDisplayText = (): string => {
@ -452,8 +415,8 @@ export default function SettingModels() {
setForm((f) => f.map((fi) => ({ ...fi, prefer: false })));
setCloudPrefer(true);
setModelType('cloud');
if (modelId !== 'cloud') {
setCloudModelType(modelId as any);
if (modelId !== 'cloud' && isCloudModelType(modelId)) {
setCloudModelType(modelId);
}
} else if (category === 'custom') {
const idx = items.findIndex((item) => item.id === modelId);
@ -588,39 +551,10 @@ export default function SettingModels() {
await proxyFetchPost('/api/v1/provider', data);
}
// add: refresh provider list after saving, update form and switch editable status
const res = await proxyFetchGet('/api/v1/providers');
const providerList = Array.isArray(res) ? res : res.items || [];
setForm((f) =>
f.map((fi, i) => {
const item = items[i];
const found = providerList.find(
(p: any) => p.provider_name === item.id
);
if (found) {
return {
...fi,
provider_id: found.id,
apiKey: found.api_key || '',
// Fall back to provider's default API host if endpoint_url is empty
apiHost: found.endpoint_url || item.apiHost,
is_valid: !!found.is_valid,
prefer: found.prefer ?? false,
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 res = await proxyFetchGet('/api/v1/providers', { prefer: true });
useProvidersCatalogStore.getState().applyFromApiResponse(res, modelType);
clearFormRowDirty(idx);
pullProvidersCatalogIntoModelsState();
// Check if this was a pending default model selection
if (
@ -786,25 +720,23 @@ export default function SettingModels() {
setLocalError(null);
setLocalInputError(false);
// add: refresh provider list after saving, update localProviderIds and localPrefer
const res = await proxyFetchGet('/api/v1/providers');
const providerList = Array.isArray(res) ? res : res.items || [];
const local = providerList.find(
(p: any) => p.provider_name === localPlatform
);
if (local) {
setLocalProviderIds((prev) => ({ ...prev, [localPlatform]: local.id }));
setLocalPrefer(local.prefer ?? false);
const res = await proxyFetchGet('/api/v1/providers', { prefer: true });
useProvidersCatalogStore.getState().applyFromApiResponse(res, modelType);
clearLocalPlatformDirty(localPlatform);
pullProvidersCatalogIntoModelsState();
const resolvedId =
useProvidersCatalogStore.getState().localProviderIds[localPlatform];
if (resolvedId != null) {
// Check if this was a pending default model selection
if (
pendingDefaultModel &&
pendingDefaultModel.category === 'local' &&
pendingDefaultModel.modelId === localPlatform
) {
await handleLocalSwitch(true, local.id);
await handleLocalSwitch(true, resolvedId);
setPendingDefaultModel(null);
} else {
await handleLocalSwitch(true, local.id);
await handleLocalSwitch(true, resolvedId);
}
}
@ -871,6 +803,10 @@ export default function SettingModels() {
setCloudPrefer(false);
setForm((f) => f.map((fi, i) => ({ ...fi, prefer: i === idx }))); // Only one prefer allowed
setLocalPrefer(false);
// Refresh shared catalog so chat-input dropdown reflects the new prefer
// immediately rather than waiting for the next focus/sync event.
const res = await proxyFetchGet('/api/v1/providers', { prefer: true });
useProvidersCatalogStore.getState().applyFromApiResponse(res, 'custom');
} catch (e) {
console.error('Error switching model:', e);
// Optional: add error message
@ -905,6 +841,10 @@ export default function SettingModels() {
setForm((f) => f.map((fi) => ({ ...fi, prefer: false }))); // Set all others' prefer to false
setLocalPrefer(true);
setCloudPrefer(false);
// Refresh shared catalog so chat-input dropdown reflects the new prefer
// immediately rather than waiting for the next focus/sync event.
const res = await proxyFetchGet('/api/v1/providers', { prefer: true });
useProvidersCatalogStore.getState().applyFromApiResponse(res, 'local');
} catch (e) {
console.error('Error switching local model:', e);
// Optional: add error message
@ -937,6 +877,11 @@ export default function SettingModels() {
}
clearPlatformModelsError(localPlatform);
await fetchModelsForPlatform(localPlatform);
clearLocalPlatformDirty(localPlatform);
// Refresh shared catalog so chat-input dropdown stops listing the
// deleted local provider as configured.
const res = await proxyFetchGet('/api/v1/providers', { prefer: true });
useProvidersCatalogStore.getState().applyFromApiResponse(res, modelType);
toast.success(t('setting.reset-success'));
} catch (e) {
console.error('Error resetting local model:', e);
@ -977,6 +922,11 @@ export default function SettingModels() {
setActiveModelIdx(null);
setLocalEnabled(true);
}
clearFormRowDirty(idx);
// Refresh shared catalog so chat-input dropdown stops listing the
// deleted custom provider as configured.
const res = await proxyFetchGet('/api/v1/providers', { prefer: true });
useProvidersCatalogStore.getState().applyFromApiResponse(res, modelType);
toast.success(t('setting.reset-success'));
} catch (e) {
console.error('Error deleting model:', e);
@ -1022,6 +972,13 @@ export default function SettingModels() {
}
};
useEffect(() => {
if (import.meta.env.VITE_USE_LOCAL_PROXY !== 'true') {
void fetchSubscription();
void updateCredits();
}
}, [modelType]);
const needsInvert = (modelId: string | null): boolean =>
needsInvertModelImage(modelId, appearance);
@ -1048,13 +1005,13 @@ export default function SettingModels() {
<button
key={tabId}
onClick={() => setSelectedTab(tabId)}
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 transition-all duration-200 ${isSubItem ? 'pl-3' : ''} ${
className={`rounded-xl px-3 py-2 flex w-full items-center justify-between transition-all duration-200 ${isSubItem ? 'pl-3' : ''} ${
isActive
? 'bg-ds-bg-neutral-subtle-default hover:bg-ds-bg-neutral-subtle-default'
: 'bg-fill-fill-transparent hover:bg-fill-fill-transparent-hover'
} `}
>
<div className="flex items-center justify-center gap-3">
<div className="gap-3 flex items-center justify-center">
{modelImage ? (
<img
src={modelImage}
@ -1080,7 +1037,7 @@ export default function SettingModels() {
</span>
</div>
{isConfigured && (
<div className="m-1 h-2 w-2 shrink-0 rounded-full bg-ds-text-success-default-default" />
<div className="m-1 h-2 w-2 bg-ds-text-success-default-default shrink-0 rounded-full" />
)}
</button>
);
@ -1092,16 +1049,16 @@ export default function SettingModels() {
if (selectedTab === 'cloud') {
if (import.meta.env.VITE_USE_LOCAL_PROXY === 'true') {
return (
<div className="flex h-64 items-center justify-center text-ds-text-neutral-muted-default">
<div className="h-64 text-ds-text-neutral-muted-default flex items-center justify-center">
{t('setting.cloud-not-available-in-local-proxy')}
</div>
);
}
return (
<div className="flex w-full flex-col rounded-2xl bg-ds-bg-neutral-subtle-default">
<div className="mx-6 mb-4 flex flex-col justify-start self-stretch border-x-0 border-b-[0.5px] border-t-0 border-solid border-ds-border-neutral-default-default pb-4 pt-2">
<div className="inline-flex items-center justify-start gap-2 self-stretch">
<div className="text-body-base my-2 flex-1 justify-center font-bold text-ds-text-neutral-default-default">
<div className="rounded-2xl bg-ds-bg-neutral-subtle-default flex w-full flex-col">
<div className="mx-6 mb-4 border-ds-border-neutral-default-default pb-4 pt-2 flex flex-col justify-start self-stretch border-x-0 border-t-0 border-b-[0.5px] border-solid">
<div className="gap-2 inline-flex items-center justify-start self-stretch">
<div className="text-body-base my-2 font-bold text-ds-text-neutral-default-default flex-1 justify-center">
{t('setting.eigent-cloud')}
</div>
{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')}
</span>
@ -1162,7 +1119,7 @@ export default function SettingModels() {
</div>
</div>
{/*Content Area*/}
<div className="flex w-full flex-row items-center justify-between gap-4 px-6 pb-4">
<div className="gap-4 px-6 pb-4 flex w-full flex-row items-center justify-between">
<div className="text-body-sm text-ds-text-neutral-default-default">
{t('setting.credits')}:{' '}
{loadingCredits ? (
@ -1191,9 +1148,9 @@ export default function SettingModels() {
<Settings />
</Button>
</div>
<div className="flex w-full flex-1 items-center justify-between px-6 pb-4">
<div className="flex min-w-0 flex-1 items-center">
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-body-sm">
<div className="px-6 pb-4 flex w-full flex-1 items-center justify-between">
<div className="min-w-0 flex flex-1 items-center">
<span className="text-body-sm overflow-hidden text-ellipsis whitespace-nowrap">
{t('setting.select-model-type')}
</span>
</div>
@ -1263,13 +1220,13 @@ export default function SettingModels() {
const canSwitch = !!form[idx].provider_id;
return (
<div className="flex w-full flex-col rounded-2xl bg-ds-bg-neutral-subtle-default">
<div className="mx-6 mb-4 flex flex-col items-start justify-between border-x-0 border-b-[0.5px] border-t-0 border-solid border-ds-border-neutral-default-default pb-4 pt-2">
<div className="inline-flex items-center justify-between gap-2 self-stretch">
<div className="rounded-2xl bg-ds-bg-neutral-subtle-default flex w-full flex-col">
<div className="mx-6 mb-4 border-ds-border-neutral-default-default pb-4 pt-2 flex flex-col items-start justify-between border-x-0 border-t-0 border-b-[0.5px] border-solid">
<div className="gap-2 inline-flex items-center justify-between self-stretch">
<div className="text-body-base my-2 font-bold text-ds-text-neutral-default-default">
{item.name}
</div>
<div className="flex items-center gap-2">
<div className="gap-2 flex items-center">
{form[idx].prefer ? (
<Button
variant="primary"
@ -1309,9 +1266,9 @@ export default function SettingModels() {
</Button>
)}
{form[idx].provider_id ? (
<div className="h-2 w-2 shrink-0 rounded-full bg-ds-text-success-default-default" />
<div className="h-2 w-2 bg-ds-text-success-default-default shrink-0 rounded-full" />
) : (
<div className="h-2 w-2 shrink-0 rounded-full bg-ds-text-neutral-default-default opacity-10" />
<div className="h-2 w-2 bg-ds-text-neutral-default-default shrink-0 rounded-full opacity-10" />
)}
</div>
</div>
@ -1319,7 +1276,7 @@ export default function SettingModels() {
{item.description}
</div>
</div>
<div className="flex w-full flex-col items-center gap-4 px-6">
<div className="gap-4 px-6 flex w-full flex-col items-center">
{/* API Key Setting */}
<Input
id={`apiKey-${item.id}`}
@ -1344,6 +1301,7 @@ export default function SettingModels() {
value={form[idx].apiKey}
onChange={(e) => {
const v = e.target.value;
markFormRowDirty(idx);
setForm((f) =>
f.map((fi, i) => (i === idx ? { ...fi, apiKey: v } : fi))
);
@ -1365,6 +1323,7 @@ export default function SettingModels() {
value={form[idx].apiHost}
onChange={(e) => {
const v = e.target.value;
markFormRowDirty(idx);
setForm((f) =>
f.map((fi, i) => (i === idx ? { ...fi, apiHost: v } : fi))
);
@ -1386,6 +1345,7 @@ export default function SettingModels() {
value={form[idx].model_type}
onChange={(e) => {
const v = e.target.value;
markFormRowDirty(idx);
setForm((f) =>
f.map((fi, i) => (i === idx ? { ...fi, model_type: v } : fi))
);
@ -1400,11 +1360,12 @@ export default function SettingModels() {
{item.externalConfig &&
form[idx].externalConfig &&
form[idx].externalConfig.map((ec, ecIdx) => (
<div key={ec.key} className="flex h-full w-full flex-col gap-4">
<div key={ec.key} className="gap-4 flex h-full w-full flex-col">
{ec.options && ec.options.length > 0 ? (
<Select
value={ec.value}
onValueChange={(v) => {
markFormRowDirty(idx);
setForm((f) =>
f.map((fi, i) =>
i === idx
@ -1471,6 +1432,7 @@ export default function SettingModels() {
value={ec.value}
onChange={(e) => {
const v = e.target.value;
markFormRowDirty(idx);
setForm((f) =>
f.map((fi, i) =>
i === idx
@ -1491,7 +1453,7 @@ export default function SettingModels() {
))}
</div>
{/* Action Button */}
<div className="flex justify-end gap-2 px-6 py-4">
<div className="gap-2 px-6 py-4 flex justify-end">
<Button
variant="ghost"
tone="neutral"
@ -1536,10 +1498,10 @@ export default function SettingModels() {
const platformModelsError = platformState?.error || null;
return (
<div className="flex w-full flex-col rounded-2xl bg-ds-bg-neutral-subtle-default">
<div className="mx-6 mb-4 flex flex-col items-start justify-between border-x-0 border-b-[0.5px] border-t-0 border-solid border-ds-border-neutral-default-default pb-4 pt-2">
<div className="inline-flex items-center justify-between gap-2 self-stretch">
<div className="flex items-center gap-2">
<div className="rounded-2xl bg-ds-bg-neutral-subtle-default flex w-full flex-col">
<div className="mx-6 mb-4 border-ds-border-neutral-default-default pb-4 pt-2 flex flex-col items-start justify-between border-x-0 border-t-0 border-b-[0.5px] border-solid">
<div className="gap-2 inline-flex items-center justify-between self-stretch">
<div className="gap-2 flex items-center">
<div className="text-body-base my-2 font-bold text-ds-text-neutral-default-default">
{getLocalPlatformName(platform)}
</div>
@ -1569,7 +1531,7 @@ export default function SettingModels() {
onClick={() => handleLocalSwitch(true)}
className={
isConfigured
? 'bg-ds-bg-neutral-default-hover !text-ds-text-neutral-muted-default shadow-none hover:bg-ds-bg-neutral-default-active'
? 'bg-ds-bg-neutral-default-hover !text-ds-text-neutral-muted-default hover:bg-ds-bg-neutral-default-active shadow-none'
: ''
}
>
@ -1580,20 +1542,21 @@ export default function SettingModels() {
)}
</div>
{isConfigured ? (
<div className="h-2 w-2 rounded-full bg-text-success" />
<div className="h-2 w-2 bg-text-success rounded-full" />
) : (
<div className="h-2 w-2 rounded-full bg-text-label opacity-10" />
<div className="h-2 w-2 bg-text-label rounded-full opacity-10" />
)}
</div>
</div>
{/* Model Endpoint URL Setting */}
<div className="flex w-full flex-col items-center gap-4 px-6">
<div className="gap-4 px-6 flex w-full flex-col items-center">
<Input
size="default"
title={t('setting.model-endpoint-url')}
state={localInputError ? 'error' : 'default'}
value={currentEndpoint}
onChange={(e) => {
markLocalPlatformDirty(platform);
setLocalEndpoints((prev) => ({
...prev,
[platform]: e.target.value,
@ -1629,17 +1592,18 @@ export default function SettingModels() {
note={localError ?? undefined}
/>
{isModelListPlatform ? (
<div className="flex w-full flex-col gap-1">
<div className="flex w-full items-end gap-2">
<div className="gap-1 flex w-full flex-col">
<div className="gap-2 flex w-full items-end">
<div className="flex-1">
<Select
value={currentType}
onValueChange={(v) =>
onValueChange={(v) => {
markLocalPlatformDirty(platform);
setLocalTypes((prev) => ({
...prev,
[platform]: v,
}))
}
}));
}}
disabled={!localEnabled || platformModelsLoading}
>
<SelectTrigger
@ -1717,18 +1681,19 @@ export default function SettingModels() {
state={localInputError ? 'error' : 'default'}
placeholder={t('setting.enter-your-local-model-type')}
value={currentType}
onChange={(e) =>
onChange={(e) => {
markLocalPlatformDirty(platform);
setLocalTypes((prev) => ({
...prev,
[platform]: e.target.value,
}))
}
}));
}}
disabled={!localEnabled}
/>
)}
</div>
{/* Action Button */}
<div className="flex justify-end gap-2 px-6 py-4">
<div className="gap-2 px-6 py-4 flex justify-end">
<Button
variant="ghost"
tone="neutral"
@ -1763,8 +1728,8 @@ export default function SettingModels() {
return (
<div className="m-auto flex h-auto w-full flex-1 flex-col">
{/* Header Section */}
<div className="z-10 flex w-full items-center justify-between px-6 pb-6 pt-8">
<div className="flex w-full flex-col items-start justify-between gap-4">
<div className="px-6 pb-6 pt-8 z-10 flex w-full items-center justify-between">
<div className="gap-4 flex w-full flex-col items-start justify-between">
<div className="flex flex-col">
<div className="text-heading-sm font-bold text-ds-text-neutral-default-default">
{t('setting.models')}
@ -1773,10 +1738,10 @@ export default function SettingModels() {
</div>
</div>
{/* Content Section */}
<div className="mb-8 flex flex-col gap-6">
<div className="mb-8 gap-6 flex flex-col">
{/* Default Model Cascading Dropdown */}
<div className="flex w-full flex-col items-end justify-between gap-4 rounded-2xl bg-ds-bg-neutral-default-default px-6 py-4">
<div className="flex w-full flex-col items-start justify-center gap-1">
<div className="gap-4 rounded-2xl bg-ds-bg-neutral-default-default px-6 py-4 flex w-full flex-col items-end justify-between">
<div className="gap-1 flex w-full flex-col items-start justify-center">
<div className="text-body-base font-bold text-ds-text-neutral-default-default">
{t('setting.models-default-setting-title')}
</div>
@ -1786,11 +1751,11 @@ export default function SettingModels() {
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex w-fit items-center justify-between gap-2 rounded-lg border-[0.5px] border-solid border-ds-border-success-default-default bg-ds-bg-success-subtle-default px-3 py-1 font-semibold text-ds-text-success-default-default transition-colors hover:opacity-70 active:opacity-90">
<span className="whitespace-nowrap text-body-sm">
<button className="gap-2 rounded-lg border-ds-border-success-default-default bg-ds-bg-success-subtle-default px-3 py-1 font-semibold text-ds-text-success-default-default flex w-fit items-center justify-between border-[0.5px] border-solid transition-colors hover:opacity-70 active:opacity-90">
<span className="text-body-sm whitespace-nowrap">
{getDefaultModelDisplayText()}
</span>
<ChevronDown className="h-4 w-4 flex-shrink-0 !text-ds-text-success-default-default" />
<ChevronDown className="h-4 w-4 !text-ds-text-success-default-default flex-shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[180px]">
@ -1844,7 +1809,7 @@ export default function SettingModels() {
}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<div className="gap-2 flex items-center">
{modelImage ? (
<img
src={modelImage}
@ -1865,15 +1830,15 @@ export default function SettingModels() {
{item.name}
</span>
</div>
<div className="flex items-center gap-1">
<div className="gap-1 flex items-center">
{!isConfigured && (
<div className="h-2 w-2 rounded-full bg-text-label opacity-10" />
<div className="h-2 w-2 bg-text-label rounded-full opacity-10" />
)}
{isPreferred && (
<Check className="h-4 w-4 text-ds-text-status-completed-strong-default" />
)}
{isConfigured && !isPreferred && (
<div className="h-2 w-2 rounded-full bg-text-success" />
<div className="h-2 w-2 bg-text-success rounded-full" />
)}
</div>
</DropdownMenuItem>
@ -1905,7 +1870,7 @@ export default function SettingModels() {
}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<div className="gap-2 flex items-center">
{modelImage ? (
<img
src={modelImage}
@ -1926,15 +1891,15 @@ export default function SettingModels() {
{model.name}
</span>
</div>
<div className="flex items-center gap-1">
<div className="gap-1 flex items-center">
{!isConfigured && (
<div className="h-2 w-2 rounded-full bg-text-label opacity-10" />
<div className="h-2 w-2 bg-text-label rounded-full opacity-10" />
)}
{isPreferred && (
<Check className="h-4 w-4 text-ds-text-status-completed-strong-default" />
)}
{isConfigured && !isPreferred && (
<div className="h-2 w-2 rounded-full bg-text-success" />
<div className="h-2 w-2 bg-text-success rounded-full" />
)}
</div>
</DropdownMenuItem>
@ -1947,17 +1912,17 @@ export default function SettingModels() {
</div>
{/* Content Section with Sidebar */}
<div className="flex w-full flex-col items-start justify-between rounded-2xl bg-ds-bg-neutral-default-default px-3 py-2">
<div className="text-body-base sticky top-[48px] z-10 mb-4 w-full border-x-0 border-b-[0.5px] border-t-0 border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default px-3 py-2 pb-2 font-bold text-ds-text-neutral-default-default">
<div className="rounded-2xl bg-ds-bg-neutral-default-default px-3 py-2 flex w-full flex-col items-start justify-between">
<div className="text-body-base mb-4 border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default px-3 py-2 pb-2 font-bold text-ds-text-neutral-default-default sticky top-[48px] z-10 w-full border-x-0 border-t-0 border-b-[0.5px] border-solid">
{t('setting.models-configuration')}
</div>
<div className="flex w-full flex-row items-start justify-between px-3">
<div className="px-3 flex w-full flex-row items-start justify-between">
{/* Sidebar */}
<div className="-ml-2 mr-4 h-full w-[240px] rounded-2xl bg-ds-bg-neutral-default-default">
<div className="flex flex-col gap-4">
<div className="-ml-2 mr-4 rounded-2xl bg-ds-bg-neutral-default-default h-full w-[240px]">
<div className="gap-4 flex flex-col">
{/* Eigent Cloud Section */}
<div className="flex flex-col gap-1">
<div className="gap-1 flex flex-col">
<div className="px-3 py-2 text-body-sm font-bold text-ds-text-neutral-default-default">
{t('setting.eigent-cloud')}
</div>
@ -1972,10 +1937,10 @@ export default function SettingModels() {
)}
</div>
{/* Bring Your Own Key Section */}
<div className="flex flex-col gap-1">
<div className="gap-1 flex flex-col">
<button
onClick={() => setByokCollapsed(!byokCollapsed)}
className="flex items-center justify-between rounded-lg bg-transparent px-3 py-2 transition-colors hover:bg-ds-bg-neutral-default-default"
className="rounded-lg px-3 py-2 hover:bg-ds-bg-neutral-default-default flex items-center justify-between bg-transparent transition-colors"
>
<div className="text-body-sm font-bold text-ds-text-neutral-default-default">
{t('setting.custom-model')}
@ -1987,7 +1952,7 @@ export default function SettingModels() {
)}
</button>
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
className={`ease-in-out overflow-hidden transition-all duration-300 ${
byokCollapsed
? 'max-h-0 opacity-0'
: 'max-h-[2000px] opacity-100'
@ -2007,10 +1972,10 @@ export default function SettingModels() {
</div>
{/* Local Model Section */}
<div className="flex flex-col gap-1">
<div className="gap-1 flex flex-col">
<button
onClick={() => setLocalCollapsed(!localCollapsed)}
className="flex items-center justify-between rounded-lg bg-transparent px-3 py-2 transition-colors hover:bg-ds-bg-neutral-default-default"
className="rounded-lg px-3 py-2 hover:bg-ds-bg-neutral-default-default flex items-center justify-between bg-transparent transition-colors"
>
<div className="text-body-sm font-bold text-ds-text-neutral-default-default">
{t('setting.local-model')}
@ -2022,7 +1987,7 @@ export default function SettingModels() {
)}
</button>
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
className={`ease-in-out overflow-hidden transition-all duration-300 ${
localCollapsed
? 'max-h-0 opacity-0'
: 'max-h-[2000px] opacity-100'
@ -2073,7 +2038,7 @@ export default function SettingModels() {
</div>
</div>
{/* Main Content */}
<div className="sticky top-[136px] z-10 min-w-0 flex-1">
<div className="min-w-0 sticky top-[136px] z-10 flex-1">
{renderContent()}
</div>
</div>

View file

@ -31,6 +31,7 @@ import TriggerPanel from '@/components/Trigger';
import UpdateElectron from '@/components/update';
import Workspace from '@/components/Workspace';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { useModelConfigCheck } from '@/hooks/useModelConfigCheck';
import { useHost } from '@/host';
import { cn } from '@/lib/utils';
import { ChatTaskStatus } from '@/types/constants';
@ -98,6 +99,7 @@ export default function Home() {
const electronAPI = host?.electronAPI;
//Get Chatstore for the active project's task
const { chatStore, projectStore } = useChatStoreAdapter();
useModelConfigCheck();
const { activeWorkspaceTab, setHasAgentFiles, setActiveWorkspaceTab } =
usePageTabStore();