diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 07ece72..0a2a33c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -325,7 +325,10 @@ "openai_test_success": "Test succeeded. The model responded.", "openai_test_failed": "Test failed", "openai_test_select_placeholder": "Choose from current models", - "openai_test_select_empty": "No models configured. Add models first" + "openai_test_select_empty": "No models configured. Add models first", + "search_placeholder": "Search configs (keys, URLs, models...)", + "search_empty_title": "No matching configs", + "search_empty_desc": "Try a different keyword or clear the search box" }, "auth_files": { "title": "Auth Files Management", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 87a8c48..8dbc7f3 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -325,7 +325,10 @@ "openai_test_success": "测试成功,模型可用。", "openai_test_failed": "测试失败", "openai_test_select_placeholder": "从当前模型列表选择", - "openai_test_select_empty": "当前未配置模型,请先添加模型" + "openai_test_select_empty": "当前未配置模型,请先添加模型", + "search_placeholder": "搜索配置(密钥、地址、模型等)", + "search_empty_title": "没有匹配的配置", + "search_empty_desc": "请尝试更换关键字或清空搜索框" }, "auth_files": { "title": "认证文件管理", diff --git a/src/pages/AiProvidersPage.module.scss b/src/pages/AiProvidersPage.module.scss index eff7935..0bd8bdd 100644 --- a/src/pages/AiProvidersPage.module.scss +++ b/src/pages/AiProvidersPage.module.scss @@ -20,7 +20,53 @@ font-size: 28px; font-weight: 700; color: var(--text-primary); - margin: 0 0 $spacing-xl 0; + margin: 0; +} + +.pageHeader { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: $spacing-md; + margin-bottom: $spacing-xl; +} + +.searchBox { + flex: 0 1 320px; + min-width: 200px; + + @include mobile { + flex: 1 1 100%; + } + + :global(.form-group) { + margin-bottom: 0; + } +} + +.searchEmpty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: $spacing-xl * 2; + text-align: center; + background: var(--bg-secondary); + border: 1px dashed var(--border-primary); + border-radius: 12px; +} + +.searchEmptyTitle { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: $spacing-sm; +} + +.searchEmptyDesc { + font-size: 14px; + color: var(--text-tertiary); } .content { diff --git a/src/pages/AiProvidersPage.tsx b/src/pages/AiProvidersPage.tsx index d437b9b..b607784 100644 --- a/src/pages/AiProvidersPage.tsx +++ b/src/pages/AiProvidersPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { entriesToModels } from '@/components/ui/ModelInputList'; import { @@ -20,6 +20,7 @@ import { withDisableAllModelsRule, withoutDisableAllModelsRule, } from '@/components/providers/utils'; +import { Input } from '@/components/ui/Input'; import { ampcodeApi, providersApi } from '@/services/api'; import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores'; import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types'; @@ -50,10 +51,132 @@ export function AiProvidersPage() { const [configSwitchingKey, setConfigSwitchingKey] = useState(null); const [modal, setModal] = useState(null); const [ampcodeBusy, setAmpcodeBusy] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); const disableControls = connectionStatus !== 'connected'; const isSwitching = Boolean(configSwitchingKey); + const normalizedQuery = searchQuery.trim().toLowerCase(); + + const filteredGeminiKeys = useMemo(() => { + if (!normalizedQuery) return geminiKeys.map((item, index) => ({ item, originalIndex: index })); + return geminiKeys + .map((item, index) => ({ item, originalIndex: index })) + .filter(({ item }) => { + const searchFields = [ + item.apiKey, + item.prefix, + item.baseUrl, + ...(item.excludedModels || []), + ...Object.keys(item.headers || {}), + ...Object.values(item.headers || {}), + ]; + return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery)); + }); + }, [geminiKeys, normalizedQuery]); + + const filteredCodexConfigs = useMemo(() => { + if (!normalizedQuery) return codexConfigs.map((item, index) => ({ item, originalIndex: index })); + return codexConfigs + .map((item, index) => ({ item, originalIndex: index })) + .filter(({ item }) => { + const searchFields = [ + item.apiKey, + item.prefix, + item.baseUrl, + item.proxyUrl, + ...(item.excludedModels || []), + ...(item.models?.map((m) => m.name) || []), + ...(item.models?.map((m) => m.alias) || []), + ...Object.keys(item.headers || {}), + ...Object.values(item.headers || {}), + ]; + return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery)); + }); + }, [codexConfigs, normalizedQuery]); + + const filteredClaudeConfigs = useMemo(() => { + if (!normalizedQuery) return claudeConfigs.map((item, index) => ({ item, originalIndex: index })); + return claudeConfigs + .map((item, index) => ({ item, originalIndex: index })) + .filter(({ item }) => { + const searchFields = [ + item.apiKey, + item.prefix, + item.baseUrl, + item.proxyUrl, + ...(item.excludedModels || []), + ...(item.models?.map((m) => m.name) || []), + ...(item.models?.map((m) => m.alias) || []), + ...Object.keys(item.headers || {}), + ...Object.values(item.headers || {}), + ]; + return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery)); + }); + }, [claudeConfigs, normalizedQuery]); + + const filteredVertexConfigs = useMemo(() => { + if (!normalizedQuery) return vertexConfigs.map((item, index) => ({ item, originalIndex: index })); + return vertexConfigs + .map((item, index) => ({ item, originalIndex: index })) + .filter(({ item }) => { + const searchFields = [ + item.apiKey, + item.prefix, + item.baseUrl, + item.proxyUrl, + ...(item.models?.map((m) => m.name) || []), + ...(item.models?.map((m) => m.alias) || []), + ...Object.keys(item.headers || {}), + ...Object.values(item.headers || {}), + ]; + return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery)); + }); + }, [vertexConfigs, normalizedQuery]); + + const filteredOpenaiProviders = useMemo(() => { + if (!normalizedQuery) + return openaiProviders.map((item, index) => ({ item, originalIndex: index })); + return openaiProviders + .map((item, index) => ({ item, originalIndex: index })) + .filter(({ item }) => { + const searchFields = [ + item.name, + item.prefix, + item.baseUrl, + item.testModel, + ...(item.apiKeyEntries?.map((e) => e.apiKey) || []), + ...(item.apiKeyEntries?.map((e) => e.proxyUrl) || []), + ...(item.models?.map((m) => m.name) || []), + ...(item.models?.map((m) => m.alias) || []), + ...Object.keys(item.headers || {}), + ...Object.values(item.headers || {}), + ]; + return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery)); + }); + }, [openaiProviders, normalizedQuery]); + + const showAmpcode = useMemo(() => { + if (!normalizedQuery) return true; + const ampcode = config?.ampcode; + if (!ampcode) return false; + const searchFields = [ + ampcode.upstreamUrl, + ampcode.upstreamApiKey, + ...(ampcode.modelMappings?.map((m) => m.from) || []), + ...(ampcode.modelMappings?.map((m) => m.to) || []), + ]; + return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery)); + }, [config?.ampcode, normalizedQuery]); + + const hasSearchResults = + filteredGeminiKeys.length > 0 || + filteredCodexConfigs.length > 0 || + filteredClaudeConfigs.length > 0 || + filteredVertexConfigs.length > 0 || + filteredOpenaiProviders.length > 0 || + showAmpcode; + const { keyStats, usageDetails, loadKeyStats } = useProviderStats(); const getErrorMessage = (err: unknown) => { @@ -507,112 +630,171 @@ export function AiProvidersPage() { return (
-

{t('ai_providers.title')}

+
+

{t('ai_providers.title')}

+
+ setSearchQuery(e.target.value)} + /> +
+
{error &&
{error}
} - openGeminiModal(null)} - onEdit={(index) => openGeminiModal(index)} - onDelete={deleteGemini} - onToggle={(index, enabled) => void setConfigEnabled('gemini', index, enabled)} - onCloseModal={closeModal} - onSave={saveGemini} - /> + {normalizedQuery && !hasSearchResults && ( +
+
{t('ai_providers.search_empty_title')}
+
{t('ai_providers.search_empty_desc')}
+
+ )} - openProviderModal('codex', null)} - onEdit={(index) => openProviderModal('codex', index)} - onDelete={(index) => void deleteProviderEntry('codex', index)} - onToggle={(index, enabled) => void setConfigEnabled('codex', index, enabled)} - onCloseModal={closeModal} - onSave={(form, editIndex) => saveProvider('codex', form, editIndex)} - /> + {filteredGeminiKeys.length > 0 && ( + item)} + keyStats={keyStats} + usageDetails={usageDetails} + loading={loading} + disableControls={disableControls} + isSaving={saving} + isSwitching={isSwitching} + isModalOpen={modal?.type === 'gemini'} + modalIndex={ + geminiModalIndex !== null + ? filteredGeminiKeys.findIndex(({ originalIndex }) => originalIndex === geminiModalIndex) + : null + } + onAdd={() => openGeminiModal(null)} + onEdit={(index) => openGeminiModal(filteredGeminiKeys[index]?.originalIndex ?? index)} + onDelete={(index) => deleteGemini(filteredGeminiKeys[index]?.originalIndex ?? index)} + onToggle={(index, enabled) => + void setConfigEnabled('gemini', filteredGeminiKeys[index]?.originalIndex ?? index, enabled) + } + onCloseModal={closeModal} + onSave={saveGemini} + /> + )} - openProviderModal('claude', null)} - onEdit={(index) => openProviderModal('claude', index)} - onDelete={(index) => void deleteProviderEntry('claude', index)} - onToggle={(index, enabled) => void setConfigEnabled('claude', index, enabled)} - onCloseModal={closeModal} - onSave={(form, editIndex) => saveProvider('claude', form, editIndex)} - /> + {filteredCodexConfigs.length > 0 && ( + item)} + keyStats={keyStats} + usageDetails={usageDetails} + loading={loading} + disableControls={disableControls} + isSaving={saving} + isSwitching={isSwitching} + resolvedTheme={resolvedTheme} + isModalOpen={modal?.type === 'codex'} + modalIndex={ + codexModalIndex !== null + ? filteredCodexConfigs.findIndex(({ originalIndex }) => originalIndex === codexModalIndex) + : null + } + onAdd={() => openProviderModal('codex', null)} + onEdit={(index) => openProviderModal('codex', filteredCodexConfigs[index]?.originalIndex ?? index)} + onDelete={(index) => + void deleteProviderEntry('codex', filteredCodexConfigs[index]?.originalIndex ?? index) + } + onToggle={(index, enabled) => + void setConfigEnabled('codex', filteredCodexConfigs[index]?.originalIndex ?? index, enabled) + } + onCloseModal={closeModal} + onSave={(form, editIndex) => saveProvider('codex', form, editIndex)} + /> + )} - openVertexModal(null)} - onEdit={(index) => openVertexModal(index)} - onDelete={deleteVertex} - onCloseModal={closeModal} - onSave={saveVertex} - /> + {filteredClaudeConfigs.length > 0 && ( + item)} + keyStats={keyStats} + usageDetails={usageDetails} + loading={loading} + disableControls={disableControls} + isSaving={saving} + isSwitching={isSwitching} + isModalOpen={modal?.type === 'claude'} + modalIndex={ + claudeModalIndex !== null + ? filteredClaudeConfigs.findIndex(({ originalIndex }) => originalIndex === claudeModalIndex) + : null + } + onAdd={() => openProviderModal('claude', null)} + onEdit={(index) => openProviderModal('claude', filteredClaudeConfigs[index]?.originalIndex ?? index)} + onDelete={(index) => + void deleteProviderEntry('claude', filteredClaudeConfigs[index]?.originalIndex ?? index) + } + onToggle={(index, enabled) => + void setConfigEnabled('claude', filteredClaudeConfigs[index]?.originalIndex ?? index, enabled) + } + onCloseModal={closeModal} + onSave={(form, editIndex) => saveProvider('claude', form, editIndex)} + /> + )} - + {filteredVertexConfigs.length > 0 && ( + item)} + keyStats={keyStats} + usageDetails={usageDetails} + loading={loading} + disableControls={disableControls} + isSaving={saving} + isSwitching={isSwitching} + isModalOpen={modal?.type === 'vertex'} + modalIndex={ + vertexModalIndex !== null + ? filteredVertexConfigs.findIndex(({ originalIndex }) => originalIndex === vertexModalIndex) + : null + } + onAdd={() => openVertexModal(null)} + onEdit={(index) => openVertexModal(filteredVertexConfigs[index]?.originalIndex ?? index)} + onDelete={(index) => deleteVertex(filteredVertexConfigs[index]?.originalIndex ?? index)} + onCloseModal={closeModal} + onSave={saveVertex} + /> + )} - openOpenaiModal(null)} - onEdit={(index) => openOpenaiModal(index)} - onDelete={deleteOpenai} - onCloseModal={closeModal} - onSave={saveOpenai} - /> + {showAmpcode && ( + + )} + + {filteredOpenaiProviders.length > 0 && ( + item)} + keyStats={keyStats} + usageDetails={usageDetails} + loading={loading} + disableControls={disableControls} + isSaving={saving} + isSwitching={isSwitching} + resolvedTheme={resolvedTheme} + isModalOpen={modal?.type === 'openai'} + modalIndex={ + openaiModalIndex !== null + ? filteredOpenaiProviders.findIndex(({ originalIndex }) => originalIndex === openaiModalIndex) + : null + } + onAdd={() => openOpenaiModal(null)} + onEdit={(index) => openOpenaiModal(filteredOpenaiProviders[index]?.originalIndex ?? index)} + onDelete={(index) => deleteOpenai(filteredOpenaiProviders[index]?.originalIndex ?? index)} + onCloseModal={closeModal} + onSave={saveOpenai} + /> + )}
);