mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-03 05:23:42 +00:00
793 lines
28 KiB
TypeScript
793 lines
28 KiB
TypeScript
// ========= 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. =========
|
|
|
|
/**
|
|
* Default model picker for the chat input bar — same structure as Agents → Models.
|
|
* Configured models switch inline; unconfigured options open Agents → Models.
|
|
*/
|
|
|
|
import { proxyFetchGet } from '@/api/http';
|
|
import folderIcon from '@/assets/logo/eigent_icon_rich.svg';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSub,
|
|
DropdownMenuSubContent,
|
|
DropdownMenuSubTrigger,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { createHost } from '@/host/createHost';
|
|
import {
|
|
applyDefaultModelSelection,
|
|
DEFAULT_MODEL_CONFIGURE_PATH,
|
|
isDefaultModelConfigured,
|
|
type DefaultModelCategory,
|
|
} from '@/lib/applyDefaultModelSelection';
|
|
import { INIT_PROVODERS } from '@/lib/llm';
|
|
import { getProviderValid } from '@/lib/providerStatus';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
getLocalPlatformName,
|
|
LOCAL_MODEL_OPTIONS,
|
|
} from '@/pages/Agents/localModels';
|
|
import {
|
|
getModelImage,
|
|
needsInvertModelImage,
|
|
} from '@/shared/modelProviderImages';
|
|
import { useAuthStore } from '@/store/authStore';
|
|
import { useCloudModelStore } from '@/store/cloudModelStore';
|
|
import { useProjectRuntimeStore } from '@/store/projectRuntimeStore';
|
|
import { useSpaceStore } from '@/store/spaceStore';
|
|
import type { Provider } from '@/types';
|
|
|
|
import {
|
|
Check,
|
|
ChevronDown,
|
|
HardDrive,
|
|
Key,
|
|
Layers,
|
|
Server,
|
|
} from 'lucide-react';
|
|
import type { Dispatch, SetStateAction } from 'react';
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
export interface ModelSelectProps {
|
|
disabled?: boolean;
|
|
/**
|
|
* Project whose pinned model this dropdown reads and writes. When set,
|
|
* selections update only that Project's captured model; the global
|
|
* default model is left untouched.
|
|
*/
|
|
projectId?: string | null;
|
|
/**
|
|
* When true, shows the current default model in the same shell as
|
|
* `ProjectModeToggle` (readOnly) — no chevron, not interactive,
|
|
* no filled background (session input bar).
|
|
* Used for session chat input where the model is fixed for the session.
|
|
*/
|
|
readOnly?: boolean;
|
|
}
|
|
|
|
const modelTriggerShellClass = cn(
|
|
'rounded-xl px-2 py-1 inline-flex max-w-[min(100%,320px)] shrink-0 items-center gap-1.5',
|
|
'bg-ds-bg-neutral-default-default text-ds-text-neutral-default-default'
|
|
);
|
|
|
|
export function ModelSelect({
|
|
disabled,
|
|
projectId,
|
|
readOnly = false,
|
|
}: ModelSelectProps) {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const {
|
|
modelType,
|
|
cloud_model_type,
|
|
codex_model_type,
|
|
email,
|
|
appearance,
|
|
setModelType,
|
|
setCloudModelType,
|
|
} = useAuthStore();
|
|
const cloudModels = useCloudModelStore((state) => state.models);
|
|
const fetchCloudModels = useCloudModelStore(
|
|
(state) => state.fetchCloudModels
|
|
);
|
|
const getCloudModelDisplayName = useCloudModelStore(
|
|
(state) => state.getModelDisplayName
|
|
);
|
|
const effectiveCloudModelId = useCloudModelStore((state) =>
|
|
state.getEffectiveModelId(cloud_model_type)
|
|
);
|
|
const setProjectModel = useProjectRuntimeStore(
|
|
(state) => state.setProjectModel
|
|
);
|
|
const runtimePinnedSelection = useProjectRuntimeStore((state) =>
|
|
projectId
|
|
? (state.projects[projectId]?.metadata?.modelSelection ?? null)
|
|
: null
|
|
);
|
|
const spacePinnedSelection = useSpaceStore((state) => {
|
|
if (!projectId) return null;
|
|
const spaceId = state.projectIdIndex[projectId];
|
|
if (!spaceId) return null;
|
|
return (
|
|
state.projectsBySpaceId[spaceId]?.[projectId]?.metadata?.modelSelection ??
|
|
null
|
|
);
|
|
});
|
|
const pinnedSelection = projectId
|
|
? (runtimePinnedSelection ?? spacePinnedSelection)
|
|
: null;
|
|
const cloudModelOptions = useMemo(
|
|
() =>
|
|
cloudModels.map((model) => ({
|
|
id: model.id,
|
|
name: model.display_name,
|
|
})),
|
|
[cloudModels]
|
|
);
|
|
|
|
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 [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>
|
|
>({});
|
|
const [codexStatus, setCodexStatus] = useState<{
|
|
connected: boolean;
|
|
status: string;
|
|
}>({ connected: false, status: 'not_connected' });
|
|
|
|
useEffect(() => {
|
|
if (import.meta.env.VITE_USE_LOCAL_PROXY === 'true') return;
|
|
void fetchCloudModels();
|
|
}, [fetchCloudModels]);
|
|
|
|
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: getProviderValid(found),
|
|
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 if (modelType === 'codex_subscription') {
|
|
setForm((f) => f.map((fi) => ({ ...fi, prefer: false })));
|
|
setLocalPrefer(false);
|
|
setCloudPrefer(false);
|
|
} else {
|
|
setLocalPrefer(false);
|
|
setCloudPrefer(false);
|
|
}
|
|
} catch (e) {
|
|
console.error('Error fetching providers:', e);
|
|
}
|
|
})();
|
|
}, [items, modelType]);
|
|
|
|
const refreshCodexStatus = useCallback(async () => {
|
|
if (!email) {
|
|
setCodexStatus({ connected: false, status: 'not_connected' });
|
|
return;
|
|
}
|
|
try {
|
|
const status =
|
|
await createHost().electronAPI?.codexSubscriptionStatus?.(email);
|
|
setCodexStatus(status || { connected: false, status: 'not_connected' });
|
|
} catch (error) {
|
|
console.error('Failed to load Codex subscription status:', error);
|
|
setCodexStatus({ connected: false, status: 'error' });
|
|
}
|
|
}, [email]);
|
|
|
|
useEffect(() => {
|
|
refreshCodexStatus();
|
|
}, [refreshCodexStatus]);
|
|
|
|
useEffect(() => {
|
|
const ipcRenderer = createHost().ipcRenderer;
|
|
if (!ipcRenderer?.on || !ipcRenderer?.off) return;
|
|
const listener = () => {
|
|
refreshCodexStatus();
|
|
};
|
|
ipcRenderer.on('subscription-auth:codex-status-changed', listener);
|
|
return () => {
|
|
ipcRenderer.off('subscription-auth:codex-status-changed', listener);
|
|
};
|
|
}, [refreshCodexStatus]);
|
|
|
|
const handleCodexSetDefault = useCallback(() => {
|
|
if (projectId) {
|
|
const codexModelId = codex_model_type || 'gpt-5.5';
|
|
setProjectModel(projectId, {
|
|
modelType: 'codex_subscription',
|
|
codex_model_type: codexModelId,
|
|
model_platform: 'openai',
|
|
model_type: codexModelId,
|
|
});
|
|
return;
|
|
}
|
|
setCloudPrefer(false);
|
|
setLocalPrefer(false);
|
|
setForm((f) => f.map((fi) => ({ ...fi, prefer: false })));
|
|
setModelType('codex_subscription');
|
|
}, [codex_model_type, projectId, setModelType, setProjectModel]);
|
|
|
|
/** Model name only in the trigger (e.g. "Gemini 3.1 Pro Preview", no cloud/source prefix). */
|
|
const triggerModelName = useMemo(() => {
|
|
if (pinnedSelection) {
|
|
if (pinnedSelection.modelType === 'codex_subscription') {
|
|
const pinnedCodexModelType = pinnedSelection.codex_model_type || '';
|
|
return `Codex Subscription${pinnedCodexModelType ? ` (${pinnedCodexModelType})` : ''}`;
|
|
}
|
|
if (pinnedSelection.modelType === 'cloud') {
|
|
return getCloudModelDisplayName(
|
|
pinnedSelection.cloud_model_type || cloud_model_type
|
|
);
|
|
}
|
|
if (pinnedSelection.modelType === 'custom') {
|
|
const idx =
|
|
pinnedSelection.provider_id !== undefined
|
|
? form.findIndex(
|
|
(f) => f.provider_id === pinnedSelection.provider_id
|
|
)
|
|
: -1;
|
|
if (idx !== -1) {
|
|
const mt = form[idx].model_type || '';
|
|
return `${items[idx].name}${mt ? ` (${mt})` : ''}`;
|
|
}
|
|
}
|
|
if (pinnedSelection.modelType === 'local') {
|
|
const platform = Object.keys(localProviderIds).find(
|
|
(key) => localProviderIds[key] === pinnedSelection.provider_id
|
|
);
|
|
if (platform) {
|
|
const mt = localTypes[platform] || '';
|
|
return `${getLocalPlatformName(platform)}${mt ? ` (${mt})` : ''}`;
|
|
}
|
|
}
|
|
// Providers are still loading (or the pinned provider disappeared):
|
|
// fall back to the identifiers captured with the pin.
|
|
if (pinnedSelection.model_platform || pinnedSelection.model_type) {
|
|
const platformLabel = pinnedSelection.model_platform || '';
|
|
const mt = pinnedSelection.model_type || '';
|
|
return platformLabel ? `${platformLabel}${mt ? ` (${mt})` : ''}` : mt;
|
|
}
|
|
}
|
|
|
|
if (modelType === 'codex_subscription') {
|
|
return `Codex Subscription${codex_model_type ? ` (${codex_model_type})` : ''}`;
|
|
}
|
|
|
|
if (cloudPrefer) {
|
|
return getCloudModelDisplayName(cloud_model_type);
|
|
}
|
|
|
|
const preferredIdx = form.findIndex((f) => f.prefer);
|
|
if (preferredIdx !== -1) {
|
|
const item = items[preferredIdx];
|
|
const mt = form[preferredIdx].model_type || '';
|
|
return `${item.name}${mt ? ` (${mt})` : ''}`;
|
|
}
|
|
|
|
if (localPrefer && localPlatform) {
|
|
const platformName = getLocalPlatformName(localPlatform);
|
|
const mt = localTypes[localPlatform] || '';
|
|
return `${platformName}${mt ? ` (${mt})` : ''}`;
|
|
}
|
|
|
|
return t('setting.select-default-model');
|
|
}, [
|
|
cloudPrefer,
|
|
cloud_model_type,
|
|
codex_model_type,
|
|
form,
|
|
getCloudModelDisplayName,
|
|
items,
|
|
localPrefer,
|
|
localPlatform,
|
|
localProviderIds,
|
|
localTypes,
|
|
modelType,
|
|
pinnedSelection,
|
|
t,
|
|
]);
|
|
|
|
const needsInvert = (modelId: string | null): boolean =>
|
|
needsInvertModelImage(modelId, appearance);
|
|
|
|
const handleDefaultModelSelect = useCallback(
|
|
async (category: DefaultModelCategory, modelId: string) => {
|
|
if (
|
|
!isDefaultModelConfigured(category, modelId, {
|
|
items,
|
|
form,
|
|
localProviderIds,
|
|
})
|
|
) {
|
|
navigate(DEFAULT_MODEL_CONFIGURE_PATH);
|
|
return;
|
|
}
|
|
if (projectId) {
|
|
// Pin the choice to this Project only; the global default model
|
|
// (and the server-side preferred provider) stays unchanged.
|
|
if (category === 'cloud') {
|
|
setProjectModel(projectId, {
|
|
modelType: 'cloud',
|
|
cloud_model_type: modelId,
|
|
});
|
|
return;
|
|
}
|
|
if (category === 'custom') {
|
|
const idx = items.findIndex((item) => item.id === modelId);
|
|
const providerId = idx !== -1 ? form[idx]?.provider_id : undefined;
|
|
if (providerId === undefined) return;
|
|
setProjectModel(projectId, {
|
|
modelType: 'custom',
|
|
provider_id: providerId,
|
|
model_platform: modelId,
|
|
model_type: form[idx]?.model_type || undefined,
|
|
});
|
|
return;
|
|
}
|
|
if (category === 'local') {
|
|
const providerId = localProviderIds[modelId];
|
|
if (providerId === undefined) return;
|
|
setProjectModel(projectId, {
|
|
modelType: 'local',
|
|
provider_id: providerId,
|
|
model_platform: modelId,
|
|
model_type: localTypes[modelId] || undefined,
|
|
});
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
await applyDefaultModelSelection({
|
|
category,
|
|
modelId,
|
|
items,
|
|
form,
|
|
setForm: setForm as Dispatch<SetStateAction<unknown[]>>,
|
|
setCloudPrefer,
|
|
setLocalPrefer,
|
|
setLocalPlatform,
|
|
localProviderIds,
|
|
localPlatform,
|
|
setModelType,
|
|
setCloudModelType: (id: string) => {
|
|
setCloudModelType(id);
|
|
},
|
|
t,
|
|
});
|
|
},
|
|
[
|
|
items,
|
|
form,
|
|
localProviderIds,
|
|
localPlatform,
|
|
localTypes,
|
|
navigate,
|
|
projectId,
|
|
setProjectModel,
|
|
setModelType,
|
|
setCloudModelType,
|
|
t,
|
|
]
|
|
);
|
|
|
|
// Grow the trigger to match the open dropdown's content width (never shrink
|
|
// below its own natural content width). Keep in sync with the `w-[180px]`
|
|
// on `DropdownMenuContent` below.
|
|
const [open, setOpen] = useState(false);
|
|
|
|
const activeSubTriggerRef = useRef<HTMLElement | null>(null);
|
|
|
|
// Bottom-align the sub content with the trigger row purely imperatively:
|
|
// shift the content up by (subHeight - triggerHeight) via marginTop.
|
|
// No React state is touched, so this can never cause a re-render loop.
|
|
const subContentCallbackRef = useCallback((el: HTMLDivElement | null) => {
|
|
if (!el) return;
|
|
const trigger = activeSubTriggerRef.current;
|
|
if (!trigger) return;
|
|
const subH = el.offsetHeight;
|
|
const trigH = trigger.offsetHeight;
|
|
if (subH <= 0 || trigH <= 0) return;
|
|
el.style.marginTop = `${trigH - subH}px`;
|
|
}, []);
|
|
|
|
if (readOnly) {
|
|
return (
|
|
<div
|
|
role="status"
|
|
title={triggerModelName}
|
|
aria-label={triggerModelName}
|
|
className={cn(
|
|
modelTriggerShellClass,
|
|
'pointer-events-none bg-transparent',
|
|
{
|
|
'opacity-50': disabled,
|
|
}
|
|
)}
|
|
>
|
|
<span className="inline-flex min-h-[1.25rem] min-w-0 items-center gap-1.5 overflow-hidden">
|
|
<span className="min-w-0 truncate !text-label-xs font-semibold">
|
|
{triggerModelName}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<DropdownMenu
|
|
onOpenChange={(next) => {
|
|
setOpen(next);
|
|
if (next) void fetchCloudModels();
|
|
}}
|
|
>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
title={triggerModelName}
|
|
aria-label={triggerModelName}
|
|
aria-haspopup="menu"
|
|
className={cn(
|
|
modelTriggerShellClass,
|
|
'min-w-0 cursor-pointer border-0 text-left',
|
|
'justify-between font-semibold transition-all',
|
|
'hover:bg-ds-bg-neutral-subtle-default active:bg-ds-bg-neutral-subtle-default data-[state=open]:bg-ds-bg-neutral-subtle-default',
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-border-neutral-strong-default focus-visible:ring-offset-2 focus-visible:ring-offset-ds-bg-neutral-default-default',
|
|
'disabled:pointer-events-none disabled:opacity-50',
|
|
// While open, only the trigger's content grows to the menu width;
|
|
// the chevron stays pinned at the right edge.
|
|
open && 'min-w-[180px]'
|
|
)}
|
|
>
|
|
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
|
<span className="min-w-0 flex-1 truncate text-center !text-label-xs text-ds-text-neutral-default-default">
|
|
{triggerModelName}
|
|
</span>
|
|
</span>
|
|
<ChevronDown
|
|
className="h-3.5 w-3.5 shrink-0 opacity-80"
|
|
aria-hidden
|
|
strokeWidth={2}
|
|
/>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
side="top"
|
|
sideOffset={4}
|
|
collisionPadding={12}
|
|
avoidCollisions
|
|
className="w-[180px]"
|
|
>
|
|
{import.meta.env.VITE_USE_LOCAL_PROXY !== 'true' && (
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger
|
|
className="flex w-full min-w-0 items-center justify-start gap-2 [&>svg:first-child]:!h-4 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-w-4"
|
|
onPointerEnter={(e) => {
|
|
activeSubTriggerRef.current = e.currentTarget;
|
|
}}
|
|
>
|
|
<img
|
|
src={folderIcon}
|
|
alt=""
|
|
className="mt-0.5 h-4 w-4 shrink-0"
|
|
aria-hidden
|
|
/>
|
|
<span className="min-w-0 flex-1 text-left text-body-sm">
|
|
{t('setting.eigent-cloud')}
|
|
</span>
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent
|
|
ref={subContentCallbackRef}
|
|
className="max-h-[300px] w-[200px] overflow-y-auto"
|
|
>
|
|
{cloudModelOptions.map((model) => (
|
|
<DropdownMenuItem
|
|
key={model.id}
|
|
onSelect={() => {
|
|
void handleDefaultModelSelect('cloud', model.id);
|
|
}}
|
|
className="flex items-center justify-between"
|
|
>
|
|
<span className="text-body-sm">{model.name}</span>
|
|
{(pinnedSelection
|
|
? pinnedSelection.modelType === 'cloud' &&
|
|
(pinnedSelection.cloud_model_type ||
|
|
effectiveCloudModelId) === model.id
|
|
: cloudPrefer && effectiveCloudModelId === model.id) && (
|
|
<Check className="h-4 w-4 text-ds-text-success-default-default" />
|
|
)}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
)}
|
|
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger
|
|
className="flex w-full min-w-0 items-center justify-start gap-2 [&>svg:first-child]:!h-5 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-w-4"
|
|
onPointerEnter={(e) => {
|
|
activeSubTriggerRef.current = e.currentTarget;
|
|
}}
|
|
>
|
|
<Layers
|
|
className="shrink-0 text-ds-icon-neutral-default-default"
|
|
aria-hidden
|
|
/>
|
|
<span className="min-w-0 flex-1 text-left text-body-sm">
|
|
{t('setting.custom-model')}
|
|
</span>
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent
|
|
ref={subContentCallbackRef}
|
|
className="max-h-[440px] w-[220px] overflow-y-auto"
|
|
>
|
|
{items
|
|
.map((item, idx) => ({ item, idx }))
|
|
.sort((a, b) => {
|
|
// Subscription (OAuth) providers first, original order otherwise.
|
|
const aSub = a.item.authMode === 'oauth_subscription' ? 0 : 1;
|
|
const bSub = b.item.authMode === 'oauth_subscription' ? 0 : 1;
|
|
return aSub - bSub;
|
|
})
|
|
.map(({ item, idx }) => {
|
|
const isSubscriptionAuth =
|
|
item.authMode === 'oauth_subscription';
|
|
const isConfigured = isSubscriptionAuth
|
|
? codexStatus.connected
|
|
: !!form[idx]?.provider_id;
|
|
const isPreferred = pinnedSelection
|
|
? isSubscriptionAuth
|
|
? pinnedSelection.modelType === 'codex_subscription'
|
|
: pinnedSelection.modelType === 'custom' &&
|
|
pinnedSelection.provider_id !== undefined &&
|
|
form[idx]?.provider_id === pinnedSelection.provider_id
|
|
: isSubscriptionAuth
|
|
? modelType === 'codex_subscription'
|
|
: form[idx]?.prefer;
|
|
const modelImage = getModelImage(item.id);
|
|
|
|
return (
|
|
<DropdownMenuItem
|
|
key={item.id}
|
|
onSelect={() => {
|
|
if (isSubscriptionAuth) {
|
|
if (isConfigured) {
|
|
handleCodexSetDefault();
|
|
} else {
|
|
navigate(DEFAULT_MODEL_CONFIGURE_PATH);
|
|
}
|
|
return;
|
|
}
|
|
void handleDefaultModelSelect('custom', item.id);
|
|
}}
|
|
className="flex items-center justify-between"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{modelImage ? (
|
|
<img
|
|
src={modelImage}
|
|
alt={item.name}
|
|
className="h-4 w-4"
|
|
style={
|
|
needsInvert(item.id)
|
|
? { filter: 'invert(1)' }
|
|
: undefined
|
|
}
|
|
/>
|
|
) : (
|
|
<Key className="h-3 w-3 text-ds-icon-neutral-muted-default" />
|
|
)}
|
|
<span
|
|
className={`text-body-sm ${isConfigured ? 'text-ds-text-neutral-default-default' : 'text-ds-text-neutral-subtle-default'}`}
|
|
>
|
|
{item.name}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{!isConfigured && (
|
|
<div className="h-2 w-2 rounded-full bg-ds-text-neutral-subtle-default opacity-10" />
|
|
)}
|
|
{isPreferred && (
|
|
<Check className="h-4 w-4 text-ds-text-success-default-default" />
|
|
)}
|
|
{isConfigured && !isPreferred && (
|
|
<div className="h-2 w-2 rounded-full bg-ds-text-success-default-default" />
|
|
)}
|
|
</div>
|
|
</DropdownMenuItem>
|
|
);
|
|
})}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger
|
|
className="flex w-full min-w-0 items-center justify-start gap-2 [&>svg:first-child]:!h-4 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-w-4"
|
|
onPointerEnter={(e) => {
|
|
activeSubTriggerRef.current = e.currentTarget;
|
|
}}
|
|
>
|
|
<HardDrive
|
|
className="shrink-0 text-ds-icon-neutral-default-default"
|
|
aria-hidden
|
|
/>
|
|
<span className="min-w-0 flex-1 text-left text-body-sm">
|
|
{t('setting.local-model')}
|
|
</span>
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent
|
|
ref={subContentCallbackRef}
|
|
className="w-[200px]"
|
|
>
|
|
{LOCAL_MODEL_OPTIONS.map((model) => {
|
|
const isConfigured = !!localProviderIds[model.id];
|
|
const isPreferred = pinnedSelection
|
|
? pinnedSelection.modelType === 'local' &&
|
|
pinnedSelection.provider_id !== undefined &&
|
|
localProviderIds[model.id] === pinnedSelection.provider_id
|
|
: localPrefer && localPlatform === model.id;
|
|
const modelImage = getModelImage(`local-${model.id}`);
|
|
|
|
return (
|
|
<DropdownMenuItem
|
|
key={model.id}
|
|
onSelect={() => {
|
|
void handleDefaultModelSelect('local', model.id);
|
|
}}
|
|
className="flex items-center justify-between"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{modelImage ? (
|
|
<img
|
|
src={modelImage}
|
|
alt={model.name}
|
|
className="h-4 w-4"
|
|
style={
|
|
needsInvert(`local-${model.id}`)
|
|
? { filter: 'invert(1)' }
|
|
: undefined
|
|
}
|
|
/>
|
|
) : (
|
|
<Server className="h-4 w-4 text-ds-icon-neutral-muted-default" />
|
|
)}
|
|
<span
|
|
className={`text-body-sm ${isConfigured ? 'text-ds-text-neutral-default-default' : 'text-ds-text-neutral-subtle-default'}`}
|
|
>
|
|
{model.name}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{!isConfigured && (
|
|
<div className="h-2 w-2 rounded-full bg-ds-text-neutral-subtle-default opacity-10" />
|
|
)}
|
|
{isPreferred && (
|
|
<Check className="h-4 w-4 text-ds-text-success-default-default" />
|
|
)}
|
|
{isConfigured && !isPreferred && (
|
|
<div className="h-2 w-2 rounded-full bg-ds-text-success-default-default" />
|
|
)}
|
|
</div>
|
|
</DropdownMenuItem>
|
|
);
|
|
})}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|