From bae7ff8752eaf90b9a37ba7439e0ec3f8e6c08c2 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Fri, 12 Dec 2025 18:53:51 +0800 Subject: [PATCH] feat: enhance AiProvidersPage with OpenAI model discovery functionality, improve localization for model selection messages, and update styles for better user experience --- src/i18n/locales/en.json | 4 +- src/i18n/locales/zh-CN.json | 4 +- src/pages/AiProvidersPage.module.scss | 72 +++++ src/pages/AiProvidersPage.tsx | 411 +++++++++++++++++++++++++- 4 files changed, 479 insertions(+), 12 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 35b6c2e..9c8f351 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -235,7 +235,7 @@ "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, enter manually" + "openai_test_select_empty": "No models configured. Add models first" }, "auth_files": { "title": "Auth Files Management", @@ -616,7 +616,7 @@ "openai_model_name_required": "Model name is required", "openai_test_url_required": "Please provide a valid Base URL before testing", "openai_test_key_required": "Please add at least one API key before testing", - "openai_test_model_required": "Please select or enter a model to test", + "openai_test_model_required": "Please select a model to test", "data_refreshed": "Data refreshed successfully", "connection_required": "Please establish connection first", "refresh_failed": "Refresh failed", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a0681f9..80b22a8 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -235,7 +235,7 @@ "openai_test_success": "测试成功,模型可用。", "openai_test_failed": "测试失败", "openai_test_select_placeholder": "从当前模型列表选择", - "openai_test_select_empty": "当前未配置模型,可直接输入" + "openai_test_select_empty": "当前未配置模型,请先添加模型" }, "auth_files": { "title": "认证文件管理", @@ -616,7 +616,7 @@ "openai_model_name_required": "请填写模型名称", "openai_test_url_required": "请先填写有效的 Base URL 以进行测试", "openai_test_key_required": "请至少填写一个 API 密钥以进行测试", - "openai_test_model_required": "请选择或输入要测试的模型", + "openai_test_model_required": "请选择要测试的模型", "data_refreshed": "数据刷新成功", "connection_required": "请先建立连接", "refresh_failed": "刷新失败", diff --git a/src/pages/AiProvidersPage.module.scss b/src/pages/AiProvidersPage.module.scss index bb3a78d..c0bfb8d 100644 --- a/src/pages/AiProvidersPage.module.scss +++ b/src/pages/AiProvidersPage.module.scss @@ -286,6 +286,78 @@ color: var(--failure-badge-text, #991b1b); } +// OpenAI 模型发现(二级界面) +.modelDiscoveryList { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 360px; + overflow-y: auto; + margin-top: 8px; + padding-right: 4px; +} + +.modelDiscoveryRow { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; + + input[type='checkbox'] { + margin-top: 2px; + cursor: pointer; + } + + &:hover { + border-color: var(--primary-color); + background: var(--bg-secondary); + } +} + +.modelDiscoveryRowSelected { + border-color: var(--primary-color); + background: var(--bg-tertiary); +} + +.modelDiscoveryMeta { + display: flex; + flex-direction: column; + gap: 2px; +} + +.modelDiscoveryName { + font-weight: 600; + color: var(--text-primary); +} + +.modelDiscoveryAlias { + margin-left: 6px; + color: var(--text-tertiary); + font-style: italic; +} + +.modelDiscoveryDesc { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; +} + +.openaiTestButtonSuccess { + background-color: var(--success-badge-bg, #d1fae5); + border-color: var(--success-badge-border, #6ee7b7); + color: var(--success-badge-text, #065f46); + + &:hover { + background-color: var(--success-badge-bg, #d1fae5); + border-color: var(--success-badge-border, #6ee7b7); + } +} + // 暗色主题适配 :global([data-theme='dark']) { .headerBadge { diff --git a/src/pages/AiProvidersPage.tsx b/src/pages/AiProvidersPage.tsx index f13b520..b02f3d7 100644 --- a/src/pages/AiProvidersPage.tsx +++ b/src/pages/AiProvidersPage.tsx @@ -8,7 +8,7 @@ import { EmptyState } from '@/components/ui/EmptyState'; import { HeaderInputList } from '@/components/ui/HeaderInputList'; import { ModelInputList, modelsToEntries, entriesToModels } from '@/components/ui/ModelInputList'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; -import { providersApi, usageApi } from '@/services/api'; +import { modelsApi, providersApi, usageApi } from '@/services/api'; import type { GeminiKeyConfig, ProviderKeyConfig, @@ -16,6 +16,7 @@ import type { ApiKeyEntry } from '@/types'; import type { KeyStats, KeyStatBucket } from '@/utils/usage'; +import type { ModelInfo } from '@/utils/models'; import { headersToEntries, buildHeaderObject, type HeaderEntry } from '@/utils/headers'; import { maskApiKey } from '@/utils/format'; import styles from './AiProvidersPage.module.scss'; @@ -48,6 +49,21 @@ const parseExcludedModels = (text: string): string[] => const excludedModelsToText = (models?: string[]) => (Array.isArray(models) ? models.join('\n') : ''); +const buildOpenAIModelsEndpoint = (baseUrl: string): string => { + const trimmed = String(baseUrl || '').trim().replace(/\/+$/g, ''); + if (!trimmed) return ''; + return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`; +}; + +const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => { + const trimmed = String(baseUrl || '').trim().replace(/\/+$/g, ''); + if (!trimmed) return ''; + if (trimmed.endsWith('/chat/completions')) { + return trimmed; + } + return trimmed.endsWith('/v1') ? `${trimmed}/chat/completions` : `${trimmed}/v1/chat/completions`; +}; + // 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致 const getStatsBySource = ( apiKey: string, @@ -130,9 +146,36 @@ export function AiProvidersPage() { apiKeyEntries: [buildApiKeyEntry()], modelEntries: [{ name: '', alias: '' }] }); + const [openaiDiscoveryOpen, setOpenaiDiscoveryOpen] = useState(false); + const [openaiDiscoveryEndpoint, setOpenaiDiscoveryEndpoint] = useState(''); + const [openaiDiscoveryModels, setOpenaiDiscoveryModels] = useState([]); + const [openaiDiscoveryLoading, setOpenaiDiscoveryLoading] = useState(false); + const [openaiDiscoveryError, setOpenaiDiscoveryError] = useState(''); + const [openaiDiscoverySearch, setOpenaiDiscoverySearch] = useState(''); + const [openaiDiscoverySelected, setOpenaiDiscoverySelected] = useState>(new Set()); + const [openaiTestModel, setOpenaiTestModel] = useState(''); + const [openaiTestStatus, setOpenaiTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); + const [openaiTestMessage, setOpenaiTestMessage] = useState(''); const [saving, setSaving] = useState(false); const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]); + const filteredOpenaiDiscoveryModels = useMemo(() => { + const filter = openaiDiscoverySearch.trim().toLowerCase(); + if (!filter) return openaiDiscoveryModels; + return openaiDiscoveryModels.filter((model) => { + const name = (model.name || '').toLowerCase(); + const alias = (model.alias || '').toLowerCase(); + const desc = (model.description || '').toLowerCase(); + return name.includes(filter) || alias.includes(filter) || desc.includes(filter); + }); + }, [openaiDiscoveryModels, openaiDiscoverySearch]); + const openaiAvailableModels = useMemo( + () => + openaiForm.modelEntries + .map((entry) => entry.name.trim()) + .filter(Boolean), + [openaiForm.modelEntries] + ); // 加载 key 统计 const loadKeyStats = useCallback(async () => { @@ -197,6 +240,15 @@ export function AiProvidersPage() { modelEntries: [{ name: '', alias: '' }], testModel: undefined }); + setOpenaiDiscoveryOpen(false); + setOpenaiDiscoveryModels([]); + setOpenaiDiscoverySelected(new Set()); + setOpenaiDiscoverySearch(''); + setOpenaiDiscoveryError(''); + setOpenaiDiscoveryEndpoint(''); + setOpenaiTestModel(''); + setOpenaiTestStatus('idle'); + setOpenaiTestMessage(''); }; const openGeminiModal = (index: number | null) => { @@ -225,18 +277,229 @@ export function AiProvidersPage() { const openOpenaiModal = (index: number | null) => { if (index !== null) { const entry = openaiProviders[index]; + const modelEntries = modelsToEntries(entry.models); setOpenaiForm({ name: entry.name, baseUrl: entry.baseUrl, headers: headersToEntries(entry.headers), testModel: entry.testModel, - modelEntries: modelsToEntries(entry.models), + modelEntries, apiKeyEntries: entry.apiKeyEntries?.length ? entry.apiKeyEntries : [buildApiKeyEntry()] }); + const available = modelEntries.map((m) => m.name.trim()).filter(Boolean); + const initialModel = + entry.testModel && available.includes(entry.testModel) ? entry.testModel : available[0] || ''; + setOpenaiTestModel(initialModel); + } else { + setOpenaiTestModel(''); } + setOpenaiTestStatus('idle'); + setOpenaiTestMessage(''); setModal({ type: 'openai', index }); }; + const closeOpenaiModelDiscovery = () => { + setOpenaiDiscoveryOpen(false); + setOpenaiDiscoveryModels([]); + setOpenaiDiscoverySelected(new Set()); + setOpenaiDiscoverySearch(''); + setOpenaiDiscoveryError(''); + }; + + const fetchOpenaiModelDiscovery = async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => { + const baseUrl = openaiForm.baseUrl.trim(); + if (!baseUrl) return; + + setOpenaiDiscoveryLoading(true); + setOpenaiDiscoveryError(''); + try { + const headers = buildHeaderObject(openaiForm.headers); + const firstKey = openaiForm.apiKeyEntries.find((entry) => entry.apiKey?.trim())?.apiKey?.trim(); + const hasAuthHeader = Boolean(headers.Authorization || headers['authorization']); + const list = await modelsApi.fetchModels(baseUrl, hasAuthHeader ? undefined : firstKey, headers); + setOpenaiDiscoveryModels(list); + } catch (err: any) { + if (allowFallback) { + try { + const list = await modelsApi.fetchModels(baseUrl); + setOpenaiDiscoveryModels(list); + return; + } catch (fallbackErr: any) { + const message = fallbackErr?.message || err?.message || ''; + setOpenaiDiscoveryModels([]); + setOpenaiDiscoveryError(`${t('ai_providers.openai_models_fetch_error')}: ${message}`); + } + } else { + setOpenaiDiscoveryModels([]); + setOpenaiDiscoveryError(`${t('ai_providers.openai_models_fetch_error')}: ${err?.message || ''}`); + } + } finally { + setOpenaiDiscoveryLoading(false); + } + }; + + const openOpenaiModelDiscovery = () => { + const baseUrl = openaiForm.baseUrl.trim(); + if (!baseUrl) { + showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error'); + return; + } + + setOpenaiDiscoveryEndpoint(buildOpenAIModelsEndpoint(baseUrl)); + setOpenaiDiscoveryModels([]); + setOpenaiDiscoverySearch(''); + setOpenaiDiscoverySelected(new Set()); + setOpenaiDiscoveryError(''); + setOpenaiDiscoveryOpen(true); + void fetchOpenaiModelDiscovery(); + }; + + const toggleOpenaiModelSelection = (name: string) => { + setOpenaiDiscoverySelected((prev) => { + const next = new Set(prev); + if (next.has(name)) { + next.delete(name); + } else { + next.add(name); + } + return next; + }); + }; + + const applyOpenaiModelDiscoverySelection = () => { + const selectedModels = openaiDiscoveryModels.filter((model) => openaiDiscoverySelected.has(model.name)); + if (!selectedModels.length) { + closeOpenaiModelDiscovery(); + return; + } + + const mergedMap = new Map(); + openaiForm.modelEntries.forEach((entry) => { + const name = entry.name.trim(); + if (!name) return; + mergedMap.set(name, { name, alias: entry.alias?.trim() || '' }); + }); + + let addedCount = 0; + selectedModels.forEach((model) => { + const name = model.name.trim(); + if (!name || mergedMap.has(name)) return; + mergedMap.set(name, { name, alias: model.alias ?? '' }); + addedCount += 1; + }); + + const mergedEntries = Array.from(mergedMap.values()); + setOpenaiForm((prev) => ({ + ...prev, + modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }] + })); + + closeOpenaiModelDiscovery(); + if (addedCount > 0) { + showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success'); + } + }; + + useEffect(() => { + if (modal?.type !== 'openai') return; + if (openaiAvailableModels.length === 0) { + if (openaiTestModel) { + setOpenaiTestModel(''); + setOpenaiTestStatus('idle'); + setOpenaiTestMessage(''); + } + return; + } + + if (!openaiTestModel || !openaiAvailableModels.includes(openaiTestModel)) { + setOpenaiTestModel(openaiAvailableModels[0]); + setOpenaiTestStatus('idle'); + setOpenaiTestMessage(''); + } + }, [modal?.type, openaiAvailableModels, openaiTestModel]); + + const testOpenaiProviderConnection = async () => { + const baseUrl = openaiForm.baseUrl.trim(); + if (!baseUrl) { + const message = t('notification.openai_test_url_required'); + setOpenaiTestStatus('error'); + setOpenaiTestMessage(message); + showNotification(message, 'error'); + return; + } + + const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl); + if (!endpoint) { + const message = t('notification.openai_test_url_required'); + setOpenaiTestStatus('error'); + setOpenaiTestMessage(message); + showNotification(message, 'error'); + return; + } + + const firstKeyEntry = openaiForm.apiKeyEntries.find((entry) => entry.apiKey?.trim()); + if (!firstKeyEntry) { + const message = t('notification.openai_test_key_required'); + setOpenaiTestStatus('error'); + setOpenaiTestMessage(message); + showNotification(message, 'error'); + return; + } + + const modelName = openaiTestModel.trim() || openaiAvailableModels[0] || ''; + if (!modelName) { + const message = t('notification.openai_test_model_required'); + setOpenaiTestStatus('error'); + setOpenaiTestMessage(message); + showNotification(message, 'error'); + return; + } + + const customHeaders = buildHeaderObject(openaiForm.headers); + const headers: Record = { + 'Content-Type': 'application/json', + ...customHeaders + }; + if (!headers.Authorization && !headers['authorization']) { + headers.Authorization = `Bearer ${firstKeyEntry.apiKey.trim()}`; + } + + setOpenaiTestStatus('loading'); + setOpenaiTestMessage(t('ai_providers.openai_test_running')); + try { + const response = await fetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify({ + model: modelName, + messages: [{ role: 'user', content: 'Hi' }], + stream: false, + max_tokens: 5 + }) + }); + const rawText = await response.text(); + + if (!response.ok) { + let errorMessage = `${response.status} ${response.statusText}`; + try { + const parsed = rawText ? JSON.parse(rawText) : null; + errorMessage = parsed?.error?.message || parsed?.message || errorMessage; + } catch { + if (rawText) { + errorMessage = rawText; + } + } + throw new Error(errorMessage); + } + + setOpenaiTestStatus('success'); + setOpenaiTestMessage(t('ai_providers.openai_test_success')); + } catch (err: any) { + setOpenaiTestStatus('error'); + setOpenaiTestMessage(`${t('ai_providers.openai_test_failed')}: ${err?.message || ''}`); + } + }; + const saveGemini = async () => { setSaving(true); try { @@ -979,11 +1242,6 @@ export function AiProvidersPage() { value={openaiForm.baseUrl} onChange={(e) => setOpenaiForm((prev) => ({ ...prev, baseUrl: e.target.value }))} /> - setOpenaiForm((prev) => ({ ...prev, testModel: e.target.value }))} - />
- + +
{t('ai_providers.openai_models_hint')}
setOpenaiForm((prev) => ({ ...prev, modelEntries: entries }))} @@ -1003,6 +1266,63 @@ export function AiProvidersPage() { aliasPlaceholder={t('common.model_alias_placeholder')} disabled={saving} /> + +
+ +
+ +
{t('ai_providers.openai_test_hint')}
+
+ + +
+ {openaiTestMessage && ( +
+ {openaiTestMessage} +
+ )}
@@ -1010,6 +1330,81 @@ export function AiProvidersPage() { {renderKeyEntries(openaiForm.apiKeyEntries)}
+ + {/* OpenAI Models Discovery Modal */} + + + + + } + > +
+ {t('ai_providers.openai_models_fetch_hint')} +
+
+ +
+ + +
+
+ setOpenaiDiscoverySearch(e.target.value)} + /> + {openaiDiscoveryError &&
{openaiDiscoveryError}
} + {openaiDiscoveryLoading ? ( +
{t('ai_providers.openai_models_fetch_loading')}
+ ) : openaiDiscoveryModels.length === 0 ? ( +
{t('ai_providers.openai_models_fetch_empty')}
+ ) : filteredOpenaiDiscoveryModels.length === 0 ? ( +
{t('ai_providers.openai_models_search_empty')}
+ ) : ( +
+ {filteredOpenaiDiscoveryModels.map((model) => { + const checked = openaiDiscoverySelected.has(model.name); + return ( + + ); + })} +
+ )} +
); }