diff --git a/electron/shared/providers/registry.ts b/electron/shared/providers/registry.ts index d15b8ca..f936077 100644 --- a/electron/shared/providers/registry.ts +++ b/electron/shared/providers/registry.ts @@ -45,6 +45,7 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ id: 'google', name: 'Google', icon: '🔷', + invertIconInDark: false, placeholder: 'AIza...', model: 'Gemini', requiresApiKey: true, @@ -84,8 +85,9 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ }, { id: 'ark', - name: 'ByteDance Ark', + name: '火山方舟', icon: 'A', + invertIconInDark: false, placeholder: 'your-ark-api-key', model: 'Doubao', requiresApiKey: true, @@ -109,7 +111,7 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ }, { id: 'moonshot', - name: 'Moonshot (CN)', + name: 'Moonshot', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', @@ -140,8 +142,9 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ }, { id: 'siliconflow', - name: 'SiliconFlow (CN)', + name: 'SiliconFlow', icon: '🌊', + invertIconInDark: false, placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, @@ -165,6 +168,7 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ id: 'minimax-portal', name: 'MiniMax (Global)', icon: '☁️', + invertIconInDark: false, placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, @@ -185,8 +189,9 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ }, { id: 'minimax-portal-cn', - name: 'MiniMax (CN)', + name: 'MiniMax', icon: '☁️', + invertIconInDark: false, placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, @@ -209,6 +214,7 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ id: 'qwen-portal', name: 'Qwen', icon: '☁️', + invertIconInDark: false, placeholder: 'sk-...', model: 'Qwen', requiresApiKey: false, diff --git a/electron/shared/providers/types.ts b/electron/shared/providers/types.ts index 7d816f8..8bc302c 100644 --- a/electron/shared/providers/types.ts +++ b/electron/shared/providers/types.ts @@ -78,6 +78,7 @@ export interface ProviderTypeInfo { id: ProviderType; name: string; icon: string; + invertIconInDark?: boolean; placeholder: string; model?: string; requiresApiKey: boolean; diff --git a/electron/utils/device-oauth.ts b/electron/utils/device-oauth.ts index 6f797a5..9419d72 100644 --- a/electron/utils/device-oauth.ts +++ b/electron/utils/device-oauth.ts @@ -269,7 +269,7 @@ class DeviceOAuthManager extends EventEmitter { const existing = await providerService.getAccount(accountId); const nameMap: Record = { 'minimax-portal': 'MiniMax (Global)', - 'minimax-portal-cn': 'MiniMax (CN)', + 'minimax-portal-cn': 'MiniMax', 'qwen-portal': 'Qwen', }; const nextAccount: ProviderAccount = { diff --git a/resources/release-notes.md b/resources/release-notes.md index ee4a25e..94948e9 100644 --- a/resources/release-notes.md +++ b/resources/release-notes.md @@ -1,3 +1 @@ -# 更新说明 - - 问题修复&体验优化 \ No newline at end of file diff --git a/src/assets/providers/ark.svg b/src/assets/providers/ark.svg index f835a49..ecf6d75 100644 --- a/src/assets/providers/ark.svg +++ b/src/assets/providers/ark.svg @@ -1,5 +1 @@ - - ByteDance Ark - - - +Volcengine \ No newline at end of file diff --git a/src/assets/providers/google.svg b/src/assets/providers/google.svg index 80cd65a..f1cf357 100644 --- a/src/assets/providers/google.svg +++ b/src/assets/providers/google.svg @@ -1 +1 @@ - \ No newline at end of file +Gemini \ No newline at end of file diff --git a/src/assets/providers/minimax.svg b/src/assets/providers/minimax.svg index 2805eb5..2a60bd4 100644 --- a/src/assets/providers/minimax.svg +++ b/src/assets/providers/minimax.svg @@ -1 +1 @@ -Minimax \ No newline at end of file +Minimax \ No newline at end of file diff --git a/src/assets/providers/qwen.svg b/src/assets/providers/qwen.svg index 4a0c06b..33b3f64 100644 --- a/src/assets/providers/qwen.svg +++ b/src/assets/providers/qwen.svg @@ -1 +1 @@ -Qwen \ No newline at end of file +Qwen \ No newline at end of file diff --git a/src/assets/providers/siliconflow.svg b/src/assets/providers/siliconflow.svg index 044f430..6b5f6d8 100644 --- a/src/assets/providers/siliconflow.svg +++ b/src/assets/providers/siliconflow.svg @@ -1 +1 @@ -SiliconCloud \ No newline at end of file +SiliconCloud \ No newline at end of file diff --git a/src/components/settings/ModelsSettingsSection.tsx b/src/components/settings/ModelsSettingsSection.tsx index ec61e32..7f69033 100644 --- a/src/components/settings/ModelsSettingsSection.tsx +++ b/src/components/settings/ModelsSettingsSection.tsx @@ -190,7 +190,7 @@ export function ModelsSettingsSection() { }; return ( -
+
@@ -215,7 +215,7 @@ export function ModelsSettingsSection() {
- + {loading || !snapshot ? (
diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index ff454d8..7dc8eda 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -5,13 +5,12 @@ import React, { useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { - Plus, Eye, EyeOff, Check, + Star, X, Loader2, - Key, ExternalLink, Copy, XCircle, @@ -52,7 +51,7 @@ import { useSettingsStore } from '@/stores/settings'; import { hostApiFetch } from '@/lib/host-api'; import { subscribeHostEvent } from '@/lib/host-events'; import { normalizeOAuthFlowPayload, type OAuthFlowData } from '@/lib/oauth-flow'; -import { Delete02Icon, PinIcon } from '@hugeicons/core-free-icons'; +import { Delete02Icon } from '@hugeicons/core-free-icons'; import { HugeiconsIcon } from '@hugeicons/react'; function getProtocolBaseUrlPlaceholder( @@ -133,6 +132,28 @@ function getAuthModeLabel( } } +type ProviderSidebarEntry = + | { + key: string; + kind: 'account'; + vendorId: ProviderType; + vendor?: ProviderVendorInfo; + typeInfo?: ProviderTypeInfo; + item: ProviderListItem; + title: string; + subtitle: string; + isDefault: boolean; + } + | { + key: string; + kind: 'placeholder'; + vendorId: ProviderType; + vendor?: ProviderVendorInfo; + typeInfo?: ProviderTypeInfo; + title: string; + subtitle: string; + }; + export function ProvidersSettings() { const { t } = useTranslation('settings'); const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked); @@ -151,30 +172,115 @@ export function ProvidersSettings() { } = useProviderStore(); const [showAddDialog, setShowAddDialog] = useState(false); - const [selectedProviderId, setSelectedProviderId] = useState(null); - const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor])); - const existingVendorIds = new Set(accounts.map((account) => account.vendorId)); + const [addDialogInitialType, setAddDialogInitialType] = useState(null); + const [selectedProviderKey, setSelectedProviderKey] = useState(null); + const [pendingDeleteItem, setPendingDeleteItem] = useState(null); + const [deletingAccountId, setDeletingAccountId] = useState(null); + const [, setDefaultingAccountId] = useState(null); + const vendorMap = useMemo( + () => new Map(vendors.map((vendor) => [vendor.id, vendor])), + [vendors], + ); const displayProviders = useMemo( () => buildProviderListItems(accounts, statuses, vendors, defaultAccountId), [accounts, statuses, vendors, defaultAccountId], ); - const selectedProvider = useMemo( + const existingVendorIds = useMemo( + () => new Set(displayProviders.map((item) => item.account.vendorId)), + [displayProviders], + ); + const providerSidebarItems = useMemo( () => { - if (displayProviders.length === 0) { + const orderedVendorIds = [ + ...PROVIDER_TYPE_INFO.map((provider) => provider.id), + ...vendors + .map((vendor) => vendor.id) + .filter((vendorId) => !PROVIDER_TYPE_INFO.some((provider) => provider.id === vendorId)), + ]; + const itemsByVendor = new Map(); + for (const item of displayProviders) { + const next = itemsByVendor.get(item.account.vendorId) || []; + next.push(item); + itemsByVendor.set(item.account.vendorId, next); + } + const sidebarItems: ProviderSidebarEntry[] = []; + + for (const vendorId of orderedVendorIds) { + const vendor = vendorMap.get(vendorId); + const typeInfo = PROVIDER_TYPE_INFO.find((provider) => provider.id === vendorId); + const vendorName = vendorId === 'custom' + ? t('aiProviders.custom') + : (vendor?.name || typeInfo?.name || vendorId); + const vendorItems = [...(itemsByVendor.get(vendorId) || [])].sort((left, right) => { + if (left.account.id === defaultAccountId) return -1; + if (right.account.id === defaultAccountId) return 1; + return right.account.updatedAt.localeCompare(left.account.updatedAt); + }); + + if (vendorItems.length === 0) { + sidebarItems.push({ + key: `vendor:${vendorId}`, + kind: 'placeholder', + vendorId, + vendor, + typeInfo, + title: vendorName, + subtitle: t('aiProviders.list.enableHint'), + }); + continue; + } + + for (const item of vendorItems) { + const subtitleSegments = []; + if (item.account.label.trim() !== vendorName) { + subtitleSegments.push(vendorName); + } + subtitleSegments.push( + hasConfiguredCredentials(item.account, item.status) + ? t('aiProviders.card.configured') + : t('aiProviders.list.needsSetup'), + ); + + sidebarItems.push({ + key: item.account.id, + kind: 'account', + vendorId, + vendor: item.vendor || vendor, + typeInfo, + item, + title: vendorName, + subtitle: subtitleSegments.join(' · '), + isDefault: item.account.id === defaultAccountId, + }); + } + } + + return sidebarItems; + }, + [defaultAccountId, displayProviders, t, vendorMap, vendors], + ); + const selectedProviderEntry = useMemo( + () => { + if (providerSidebarItems.length === 0) { return null; } - if (selectedProviderId) { - const explicitlySelectedProvider = displayProviders.find((item) => item.account.id === selectedProviderId); + if (selectedProviderKey) { + const explicitlySelectedProvider = providerSidebarItems.find((item) => item.key === selectedProviderKey); if (explicitlySelectedProvider) { return explicitlySelectedProvider; } } - return displayProviders.find((item) => item.account.id === defaultAccountId) ?? displayProviders[0] ?? null; + return ( + providerSidebarItems.find((item) => item.kind === 'account' && item.isDefault) + || providerSidebarItems.find((item) => item.kind === 'account') + || providerSidebarItems[0] + ); }, - [defaultAccountId, displayProviders, selectedProviderId], + [providerSidebarItems, selectedProviderKey], ); + const selectedProvider = selectedProviderEntry?.kind === 'account' ? selectedProviderEntry.item : null; // Fetch providers on mount useEffect(() => { @@ -217,6 +323,8 @@ export function ProvidersSettings() { await setDefaultAccount(id); } + setSelectedProviderKey(id); + setAddDialogInitialType(null); setShowAddDialog(false); toast.success(t('aiProviders.toast.added')); } catch (error) { @@ -224,9 +332,13 @@ export function ProvidersSettings() { } }; - const handleDeleteProvider = async (providerId: string) => { + const handleDeleteProvider = async (providerId: string, nextSelectionKey?: string) => { + setDeletingAccountId(providerId); try { await removeAccount(providerId); + if (nextSelectionKey) { + setSelectedProviderKey(nextSelectionKey); + } toast.success(t('aiProviders.toast.deleted')); } catch (error) { const message = String(error); @@ -238,93 +350,113 @@ export function ProvidersSettings() { } else { toast.error(`${t('aiProviders.toast.failedDelete')}: ${error}`); } + } finally { + setDeletingAccountId(null); + setPendingDeleteItem(null); } }; - const handleSetDefault = async (providerId: string) => { + setDefaultingAccountId(providerId); try { await setDefaultAccount(providerId); + setSelectedProviderKey(providerId); toast.success(t('aiProviders.toast.defaultUpdated')); } catch (error) { toast.error(`${t('aiProviders.toast.failedDefault')}: ${error}`); + } finally { + setDefaultingAccountId(null); } }; return (
-
-

- {t('aiProviders.title', 'AI Providers')} -

- -
+
*/} {loading ? (
- ) : displayProviders.length === 0 ? ( -
- -

{t('aiProviders.empty.title')}

-

- {t('aiProviders.empty.desc')} -

- -
) : (
-
+
- {displayProviders.map((item) => { - const isSelected = item.account.id === selectedProvider?.account.id; - return ( - + + + {isEnabled ? t('aiProviders.list.enabled') : t('aiProviders.list.disabled')} - {item.account.id === defaultAccountId ? ( - - {t('aiProviders.card.default')} - - ) : null}
- - ); - })} + ); + })}
@@ -333,7 +465,7 @@ export function ProvidersSettings() { handleDeleteProvider(selectedProvider.account.id)} + onDelete={() => setPendingDeleteItem(selectedProvider)} onSetDefault={() => handleSetDefault(selectedProvider.account.id)} onSaveAccount={async (updates, newApiKey) => { const nextUpdates: Partial = { ...updates }; @@ -358,6 +490,17 @@ export function ProvidersSettings() { onValidateKey={(key, options) => validateAccountApiKey(selectedProvider.account.id, key, options)} devModeUnlocked={devModeUnlocked} /> + ) : selectedProviderEntry?.kind === 'placeholder' ? ( + { + setAddDialogInitialType(selectedProviderEntry.vendorId); + setShowAddDialog(true); + }} + /> ) : null}
@@ -367,14 +510,62 @@ export function ProvidersSettings() { {/* Add Provider Dialog */} {showAddDialog && ( setShowAddDialog(false)} + onClose={() => { + setShowAddDialog(false); + setAddDialogInitialType(null); + }} onAdd={handleAddProvider} onValidateKey={(type, key, options) => validateAccountApiKey(type, key, options)} devModeUnlocked={devModeUnlocked} /> )} + + {pendingDeleteItem && createPortal( +
+
+
+

{t('aiProviders.card.deleteConfirmTitle')}

+

+ {t('aiProviders.card.deleteConfirmDesc', { name: pendingDeleteItem.account.label })} +

+
+
+ + +
+
+
, + document.body, + )}
); } @@ -393,6 +584,118 @@ interface ProviderCardProps { devModeUnlocked: boolean; } +interface ProviderInactiveCardProps { + vendorId: ProviderType; + vendor?: ProviderVendorInfo; + typeInfo?: ProviderTypeInfo; + adding: boolean; + onAddAccount: () => void; +} + +function ProviderInactiveCard({ + vendorId, + vendor, + typeInfo, + adding, + onAddAccount, +}: ProviderInactiveCardProps) { + const { t, i18n } = useTranslation('settings'); + const providerDocsUrl = getProviderDocsUrl(typeInfo, i18n.language); + const providerName = vendorId === 'custom' + ? t('aiProviders.custom') + : (vendor?.name || typeInfo?.name || vendorId); + const authModes = vendor?.supportedAuthModes ?? []; + + return ( +
+
+
+
+
+ {getProviderIconUrl(vendorId) ? ( + {providerName} + ) : ( + {vendor?.icon || typeInfo?.icon || '⚙️'} + )} +
+
+
+

{providerName}

+ + {t('aiProviders.inactive.notEnabled')} + +
+
+
+ + {providerDocsUrl ? ( + + {t('aiProviders.dialog.customDoc')} + + + ) : null} +
+ +
+

+ {t('aiProviders.inactive.title')} +

+ +
+ +
+ {authModes.length > 0 ? ( +
+

+ {t('aiProviders.inactive.authModes')} +

+

+ {authModes.map((mode) => getAuthModeLabel(mode, t)).join(' / ')} +

+
+ ) : null} + {typeInfo?.defaultModelId ? ( +
+

+ {t('aiProviders.inactive.defaultModel')} +

+

+ {typeInfo.defaultModelId} +

+
+ ) : null} + {typeInfo?.defaultBaseUrl ? ( +
+

+ {t('aiProviders.inactive.baseUrl')} +

+

+ {typeInfo.defaultBaseUrl} +

+
+ ) : null} +
+
+
+ ); +} + function ProviderCard({ @@ -428,8 +731,6 @@ function ProviderCard({ const [showKey, setShowKey] = useState(false); const [validating, setValidating] = useState(false); const [saving, setSaving] = useState(false); - const [confirmingDelete, setConfirmingDelete] = useState(false); - const [deleting, setDeleting] = useState(false); const [arkMode, setArkMode] = useState(draftState.arkMode); const [oauthFlowing, setOauthFlowing] = useState(false); const [oauthData, setOauthData] = useState(null); @@ -696,9 +997,9 @@ function ProviderCard({
-
-
-
+
+
+
{getProviderIconUrl(account.vendorId) ? (
-

{account.label}

+

+ {vendor?.name || account.vendorId} +

{isDefault ? ( - - - {t('aiProviders.card.default')} + + ) : null}
-

- {vendor?.name || account.vendorId} + {account.label && account.label !== (vendor?.name || account.vendorId) && ( +

+ {account.label}

+ )}
@@ -737,23 +1044,22 @@ function ProviderCard({ )} - {!isDefault && ( + {!isDefault ? ( - )} + ) : null}
)}
- - {confirmingDelete && createPortal( -
-
-
-

{t('aiProviders.card.deleteConfirmTitle')}

-

- {t('aiProviders.card.deleteConfirmDesc', { name: account.label })} -

-
-
- - -
-
-
, - document.body, - )}
); } interface AddProviderDialogProps { + initialType?: ProviderType | null; existingVendorIds: Set; vendors: ProviderVendorInfo[]; onClose: () => void; @@ -1233,6 +1499,7 @@ interface AddProviderDialogProps { } function AddProviderDialog({ + initialType = null, existingVendorIds, vendors, onClose, @@ -1241,7 +1508,7 @@ function AddProviderDialog({ devModeUnlocked, }: AddProviderDialogProps) { const { t, i18n } = useTranslation('settings'); - const [selectedType, setSelectedType] = useState(null); + const [selectedType, setSelectedType] = useState(initialType); const [name, setName] = useState(''); const [apiKey, setApiKey] = useState(''); const [baseUrl, setBaseUrl] = useState(''); @@ -1285,6 +1552,22 @@ function AddProviderDialog({ // Effective OAuth mode: pure OAuth providers, or dual-mode with oauth selected const useOAuthFlow = isOAuth && (!supportsApiKey || authMode === 'oauth'); + useEffect(() => { + if (!initialType) { + return; + } + + const initialTypeInfo = PROVIDER_TYPE_INFO.find((provider) => provider.id === initialType); + setSelectedType(initialType); + setName(initialType === 'custom' ? t('aiProviders.custom') : (initialTypeInfo?.name || initialType)); + setApiKey(''); + setBaseUrl(initialTypeInfo?.defaultBaseUrl || ''); + setApiProtocol('openai-completions'); + setModelsText(initialTypeInfo?.defaultModelId || ''); + setArkMode('apikey'); + setValidationError(null); + }, [initialType, t]); + useEffect(() => { if (!selectedVendor || !isOAuth || !supportsApiKey) { return; diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index d39526a..c90f684 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -107,6 +107,7 @@ "fallback": "Fallback Settings" }, "add": "Add Provider", + "addAccount": "Add Account", "custom": "Custom", "notRequired": "Not required", "empty": { @@ -114,6 +115,21 @@ "desc": "Add an AI provider to start using GeeClaw", "cta": "Add Your First Provider" }, + "list": { + "enable": "Enable provider", + "disable": "Disable provider", + "enabled": "Enabled", + "disabled": "Disabled", + "needsSetup": "Needs setup" + }, + "inactive": { + "title": "This provider is not enabled yet", + "enable": "Enable and configure", + "notEnabled": "Not enabled", + "authModes": "Authentication", + "defaultModel": "Default model", + "baseUrl": "Default base URL" + }, "dialog": { "title": "Add AI Provider", "desc": "Configure a new AI model provider", @@ -502,4 +518,4 @@ "docs": "Website", "github": "GitHub" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index a777859..40695a0 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -107,6 +107,7 @@ "fallback": "回退配置" }, "add": "添加提供商", + "addAccount": "添加账户", "custom": "自定义", "notRequired": "非必填", "empty": { @@ -114,6 +115,21 @@ "desc": "添加 AI 提供商以开始使用 GeeClaw", "cta": "添加您的第一个提供商" }, + "list": { + "enable": "启用提供商", + "disable": "停用提供商", + "enabled": "已启用", + "disabled": "未启用", + "needsSetup": "待完成配置" + }, + "inactive": { + "title": "该服务提供商尚未启用", + "enable": "启用并配置", + "notEnabled": "未启用", + "authModes": "认证方式", + "defaultModel": "默认模型", + "baseUrl": "默认 Base URL" + }, "dialog": { "title": "添加 AI 模型提供商", "desc": "配置新的 AI 模型提供商", @@ -187,7 +203,7 @@ "oauth": { "loginMode": "OAuth 登录", "apikeyMode": "API 密钥", - "loginPrompt": "此提供商需要通过浏览器登录授权。", + "loginPrompt": "此提供商需要通过浏览器登录授权", "loginButton": "浏览器登录", "getApiKey": "获取 API 密钥", "waiting": "等待中...", diff --git a/src/lib/providers.ts b/src/lib/providers.ts index 736bed3..46a7df2 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -72,6 +72,7 @@ export interface ProviderTypeInfo { id: ProviderType; name: string; icon: string; + invertIconInDark?: boolean; placeholder: string; model?: string; requiresApiKey: boolean; @@ -165,6 +166,7 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [ id: 'google', name: 'Google', icon: '🔷', + invertIconInDark: false, placeholder: 'AIza...', model: 'Gemini', requiresApiKey: true, @@ -175,12 +177,12 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [ }, { id: 'geekai', name: 'GeekAI', icon: '🦞', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, showModelId: true, defaultModelId: 'qwen3.5-flash' }, { id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true, showModelId: true, modelIdPlaceholder: 'openai/gpt-5.4', defaultModelId: 'openai/gpt-5.4', docsUrl: 'https://openrouter.ai/models' }, - { id: 'minimax-portal-cn', name: 'MiniMax (CN)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimaxi.com/' }, - { id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5', docsUrl: 'https://platform.moonshot.cn/' }, - { id: 'siliconflow', name: 'SiliconFlow (CN)', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.cn/v1', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3', defaultModelId: 'deepseek-ai/DeepSeek-V3', docsUrl: 'https://docs.siliconflow.cn/cn/userguide/introduction' }, - { id: 'minimax-portal', name: 'MiniMax (Global)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimax.io' }, - { id: 'qwen-portal', name: 'Qwen (Global)', icon: '☁️', placeholder: 'sk-...', model: 'Qwen', requiresApiKey: false, isOAuth: true, defaultModelId: 'coder-model' }, - { id: 'ark', name: 'ByteDance Ark', icon: 'A', placeholder: 'your-ark-api-key', model: 'Doubao', requiresApiKey: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'ep-20260228000000-xxxxx', docsUrl: 'https://www.volcengine.com/', codePlanPresetBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', codePlanPresetModelId: 'ark-code-latest', codePlanDocsUrl: 'https://www.volcengine.com/docs/82379/1928261?lang=zh' }, + { id: 'minimax-portal-cn', name: 'MiniMax', icon: '☁️', invertIconInDark: false, placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimaxi.com/' }, + { id: 'moonshot', name: 'Moonshot', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5', docsUrl: 'https://platform.moonshot.cn/' }, + { id: 'siliconflow', name: 'SiliconFlow', icon: '🌊', invertIconInDark: false, placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.cn/v1', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3', defaultModelId: 'deepseek-ai/DeepSeek-V3', docsUrl: 'https://docs.siliconflow.cn/cn/userguide/introduction' }, + { id: 'minimax-portal', name: 'MiniMax (Global)', icon: '☁️', invertIconInDark: false, placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimax.io' }, + { id: 'qwen-portal', name: 'Qwen (Global)', icon: '☁️', invertIconInDark: false, placeholder: 'sk-...', model: 'Qwen', requiresApiKey: false, isOAuth: true, defaultModelId: 'coder-model' }, + { id: 'ark', name: '火山方舟', icon: 'A', invertIconInDark: false, placeholder: 'your-ark-api-key', model: 'Doubao', requiresApiKey: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'ep-20260228000000-xxxxx', docsUrl: 'https://www.volcengine.com/', codePlanPresetBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', codePlanPresetModelId: 'ark-code-latest', codePlanDocsUrl: 'https://www.volcengine.com/docs/82379/1928261?lang=zh' }, { id: 'ollama', name: 'Ollama', icon: '🦙', placeholder: 'Not required', requiresApiKey: false, defaultBaseUrl: 'http://localhost:11434/v1', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'qwen3:latest' }, { id: 'custom', @@ -201,9 +203,10 @@ export function getProviderIconUrl(type: ProviderType | string): string | undefi return providerIcons[type]; } -/** Whether a provider's logo needs CSS invert in dark mode (all logos are monochrome) */ -export function shouldInvertInDark(_type: ProviderType | string): boolean { - return true; +/** Whether a provider's logo needs CSS invert in dark mode. Defaults to true for monochrome icons. */ +export function shouldInvertInDark(type: ProviderType | string): boolean { + const provider = PROVIDER_TYPE_INFO.find((item) => item.id === type); + return provider?.invertIconInDark ?? true; } /** Provider list shown in the Setup wizard */ diff --git a/src/pages/Chat/ChatInput.tsx b/src/pages/Chat/ChatInput.tsx index 1797950..d2bf7cf 100644 --- a/src/pages/Chat/ChatInput.tsx +++ b/src/pages/Chat/ChatInput.tsx @@ -558,6 +558,20 @@ function getVisibleSlashItems( return rankSlashPickerItemsForQuery(scopedItems, slashQuery?.query ?? '', tChat); } +function scrollElementIntoScrollableView(container: HTMLElement, element: HTMLElement) { + const containerRect = container.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + + if (elementRect.top < containerRect.top) { + container.scrollTop -= containerRect.top - elementRect.top; + return; + } + + if (elementRect.bottom > containerRect.bottom) { + container.scrollTop += elementRect.bottom - containerRect.bottom; + } +} + function SkillTokenView(props: { node: { attrs: { label?: string | null; slug?: string | null; id?: string | null } } }) { const label = props.node.attrs.label || props.node.attrs.slug || props.node.attrs.id || 'Skill'; @@ -663,6 +677,8 @@ export const ChatInput = memo(function ChatInput({ const highlightedSkillIndexRef = useRef(0); const handleSendRef = useRef<() => void>(() => {}); const handleSlashItemSelectRef = useRef<(item: SlashPickerItem, queryOverride?: SlashSkillQuery | null) => void>(() => {}); + const skillPickerListRef = useRef(null); + const skillPickerItemRefs = useRef>([]); const agents = useAgentsStore((s) => s.agents); const fetchAgents = useAgentsStore((s) => s.fetchAgents); const skills = useSkillsStore((s) => s.skills); @@ -1094,6 +1110,22 @@ export const ChatInput = memo(function ChatInput({ setHighlightedSkillIndexState(Math.max(filteredSlashItems.length - 1, 0)); }, [filteredSlashItems.length, highlightedSkillIndex, setHighlightedSkillIndexState]); + useEffect(() => { + if (!skillPickerVisible || filteredSlashItems.length === 0) { + return; + } + + const container = skillPickerListRef.current; + const boundedHighlightedIndex = Math.min(highlightedSkillIndex, filteredSlashItems.length - 1); + const selectedItem = skillPickerItemRefs.current[boundedHighlightedIndex]; + + if (!container || !selectedItem) { + return; + } + + scrollElementIntoScrollableView(container, selectedItem); + }, [filteredSlashItems.length, highlightedSkillIndex, skillPickerVisible]); + useEffect(() => { if (activeSkillRecommendation) { return; @@ -1462,13 +1494,16 @@ export const ChatInput = memo(function ChatInput({
-
+
{filteredSlashItems.length > 0 ? ( filteredSlashItems.map((item, index) => ( { + skillPickerItemRefs.current[index] = node; + }} onSelect={() => handleSlashItemSelect(item)} /> )) @@ -1827,10 +1862,12 @@ function AgentPickerItem({ function SkillPickerItem({ item, selected, + itemRef, onSelect, }: { item: SlashPickerItem; selected: boolean; + itemRef?: (node: HTMLButtonElement | null) => void; onSelect: () => void; }) { const { t } = useTranslation('skills'); @@ -1859,6 +1896,7 @@ function SkillPickerItem({ return (