From c726fbc379301beef5727c376b8352642e4c68cf Mon Sep 17 00:00:00 2001 From: moxi Date: Wed, 11 Feb 2026 23:31:43 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(ai-providers):=20=E4=BC=98=E5=8C=96=20?= =?UTF-8?q?OpenAI=20=E7=BC=96=E8=BE=91=E9=A1=B5=20UI=20=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E4=B8=8E=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/HeaderInputList.tsx | 10 +- src/components/ui/ModelInputList.tsx | 50 +- src/i18n/locales/en.json | 13 +- src/i18n/locales/ru.json | 13 +- src/i18n/locales/zh-CN.json | 13 +- src/pages/AiProvidersAmpcodeEditPage.tsx | 2 + src/pages/AiProvidersClaudeEditPage.tsx | 6 +- src/pages/AiProvidersCodexEditPage.tsx | 4 +- src/pages/AiProvidersGeminiEditPage.tsx | 4 +- src/pages/AiProvidersOpenAIEditLayout.tsx | 26 + src/pages/AiProvidersOpenAIEditPage.tsx | 593 +++++++++++++++------- src/pages/AiProvidersPage.module.scss | 319 +++++++++++- src/pages/AiProvidersVertexEditPage.tsx | 6 +- src/stores/useOpenAIEditDraftStore.ts | 41 ++ src/styles/components.scss | 7 +- 15 files changed, 869 insertions(+), 238 deletions(-) diff --git a/src/components/ui/HeaderInputList.tsx b/src/components/ui/HeaderInputList.tsx index f85db15..6aea347 100644 --- a/src/components/ui/HeaderInputList.tsx +++ b/src/components/ui/HeaderInputList.tsx @@ -10,6 +10,8 @@ interface HeaderInputListProps { disabled?: boolean; keyPlaceholder?: string; valuePlaceholder?: string; + removeButtonTitle?: string; + removeButtonAriaLabel?: string; } export function HeaderInputList({ @@ -18,7 +20,9 @@ export function HeaderInputList({ addLabel, disabled = false, keyPlaceholder = 'X-Custom-Header', - valuePlaceholder = 'value' + valuePlaceholder = 'value', + removeButtonTitle = 'Remove', + removeButtonAriaLabel = 'Remove', }: HeaderInputListProps) { const currentEntries = entries.length ? entries : [{ key: '', value: '' }]; @@ -61,8 +65,8 @@ export function HeaderInputList({ size="sm" onClick={() => removeEntry(index)} disabled={disabled || currentEntries.length <= 1} - title="Remove" - aria-label="Remove" + title={removeButtonTitle} + aria-label={removeButtonAriaLabel} > diff --git a/src/components/ui/ModelInputList.tsx b/src/components/ui/ModelInputList.tsx index 7616330..a2b0a2c 100644 --- a/src/components/ui/ModelInputList.tsx +++ b/src/components/ui/ModelInputList.tsx @@ -6,10 +6,18 @@ import type { ModelEntry } from './modelInputListUtils'; interface ModelInputListProps { entries: ModelEntry[]; onChange: (entries: ModelEntry[]) => void; - addLabel: string; + addLabel?: string; disabled?: boolean; namePlaceholder?: string; aliasPlaceholder?: string; + hideAddButton?: boolean; + onAdd?: () => void; + className?: string; + rowClassName?: string; + inputClassName?: string; + removeButtonClassName?: string; + removeButtonTitle?: string; + removeButtonAriaLabel?: string; } export function ModelInputList({ @@ -18,9 +26,20 @@ export function ModelInputList({ addLabel, disabled = false, namePlaceholder = 'model-name', - aliasPlaceholder = 'alias (optional)' + aliasPlaceholder = 'alias (optional)', + hideAddButton = false, + onAdd, + className = '', + rowClassName = '', + inputClassName = '', + removeButtonClassName = '', + removeButtonTitle = 'Remove', + removeButtonAriaLabel = 'Remove', }: ModelInputListProps) { const currentEntries = entries.length ? entries : [{ name: '', alias: '' }]; + const containerClassName = ['header-input-list', className].filter(Boolean).join(' '); + const inputClassNames = ['input', inputClassName].filter(Boolean).join(' '); + const rowClassNames = ['header-input-row', rowClassName].filter(Boolean).join(' '); const updateEntry = (index: number, field: 'name' | 'alias', value: string) => { const next = currentEntries.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry)); @@ -28,7 +47,11 @@ export function ModelInputList({ }; const addEntry = () => { - onChange([...currentEntries, { name: '', alias: '' }]); + if (onAdd) { + onAdd(); + } else { + onChange([...currentEntries, { name: '', alias: '' }]); + } }; const removeEntry = (index: number) => { @@ -37,12 +60,12 @@ export function ModelInputList({ }; return ( -
+
{currentEntries.map((entry, index) => ( -
+
updateEntry(index, 'name', e.target.value)} @@ -50,7 +73,7 @@ export function ModelInputList({ /> updateEntry(index, 'alias', e.target.value)} @@ -61,17 +84,20 @@ export function ModelInputList({ size="sm" onClick={() => removeEntry(index)} disabled={disabled || currentEntries.length <= 1} - title="Remove" - aria-label="Remove" + className={removeButtonClassName} + title={removeButtonTitle} + aria-label={removeButtonAriaLabel} >
))} - + {!hideAddButton && addLabel && ( + + )}
); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a98ff94..43e7b66 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -38,13 +38,16 @@ "quota_update_required": "Please update the CPA version or check for updates", "quota_check_credential": "Please check the credential status", "copy": "Copy", + "status": "Status", + "action": "Action", "custom_headers_label": "Custom Headers", "custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.", "custom_headers_add": "Add Header", "custom_headers_key_placeholder": "Header name, e.g. X-Custom-Header", "custom_headers_value_placeholder": "Header value", "model_name_placeholder": "Model name, e.g. claude-3-5-sonnet-20241022", - "model_alias_placeholder": "Model alias (optional)" + "model_alias_placeholder": "Model alias (optional)", + "invalid_provider_index": "Invalid provider index." }, "title": { "main": "CLI Proxy API Management Center", @@ -333,7 +336,13 @@ "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", + "openai_test_single_action": "Test", + "openai_test_all_action": "Test All Keys", + "openai_test_all_hint": "Test connection status for all keys", + "openai_test_all_success": "All {{count}} keys passed the test", + "openai_test_all_failed": "All {{count}} keys failed the test", + "openai_test_all_partial": "Test completed: {{success}} passed, {{failed}} failed" }, "auth_files": { "title": "Auth Files Management", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index f657976..ee2c87e 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -38,13 +38,16 @@ "quota_update_required": "Пожалуйста, обновите CPA или проверьте наличие обновлений", "quota_check_credential": "Пожалуйста, проверьте статус учётных данных", "copy": "Копировать", + "status": "Статус", + "action": "Действие", "custom_headers_label": "Пользовательские заголовки", "custom_headers_hint": "Необязательно — HTTP-заголовки для отправки с запросом. Оставьте пустым для удаления.", "custom_headers_add": "Добавить заголовок", "custom_headers_key_placeholder": "Имя заголовка, например X-Custom-Header", "custom_headers_value_placeholder": "Значение заголовка", "model_name_placeholder": "Имя модели, напр. claude-3-5-sonnet-20241022", - "model_alias_placeholder": "Псевдоним модели (необязательно)" + "model_alias_placeholder": "Псевдоним модели (необязательно)", + "invalid_provider_index": "Неверный индекс провайдера." }, "title": { "main": "Центр управления CLI Proxy API", @@ -333,7 +336,13 @@ "openai_test_success": "Тест выполнен успешно. Модель ответила.", "openai_test_failed": "Тест не выполнен", "openai_test_select_placeholder": "Выберите из текущих моделей", - "openai_test_select_empty": "Модели не настроены. Сначала добавьте модели" + "openai_test_select_empty": "Модели не настроены. Сначала добавьте модели", + "openai_test_single_action": "Тест", + "openai_test_all_action": "Тестировать все ключи", + "openai_test_all_hint": "Проверить состояние подключения для всех ключей", + "openai_test_all_success": "Все {{count}} ключей прошли тест", + "openai_test_all_failed": "Все {{count}} ключей не прошли тест", + "openai_test_all_partial": "Тест завершен: {{success}} прошло, {{failed}} не прошло" }, "auth_files": { "title": "Управление файлами авторизации", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6bf2332..134d654 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -38,13 +38,16 @@ "quota_update_required": "请更新 CPA 版本或检查更新", "quota_check_credential": "请检查凭证状态", "copy": "复制", + "status": "状态", + "action": "操作", "custom_headers_label": "自定义请求头", "custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。", "custom_headers_add": "添加请求头", "custom_headers_key_placeholder": "Header 名称,例如 X-Custom-Header", "custom_headers_value_placeholder": "Header 值", "model_name_placeholder": "模型名称,例如 claude-3-5-sonnet-20241022", - "model_alias_placeholder": "模型别名 (可选)" + "model_alias_placeholder": "模型别名 (可选)", + "invalid_provider_index": "无效的提供商索引。" }, "title": { "main": "CLI Proxy API Management Center", @@ -333,7 +336,13 @@ "openai_test_success": "测试成功,模型可用。", "openai_test_failed": "测试失败", "openai_test_select_placeholder": "从当前模型列表选择", - "openai_test_select_empty": "当前未配置模型,请先添加模型" + "openai_test_select_empty": "当前未配置模型,请先添加模型", + "openai_test_single_action": "测试", + "openai_test_all_action": "一键测试全部密钥", + "openai_test_all_hint": "测试所有密钥的连接状态", + "openai_test_all_success": "所有 {{count}} 个密钥测试通过", + "openai_test_all_failed": "所有 {{count}} 个密钥测试失败", + "openai_test_all_partial": "测试完成:{{success}} 个通过,{{failed}} 个失败" }, "auth_files": { "title": "认证文件管理", diff --git a/src/pages/AiProvidersAmpcodeEditPage.tsx b/src/pages/AiProvidersAmpcodeEditPage.tsx index 516a3b5..4145292 100644 --- a/src/pages/AiProvidersAmpcodeEditPage.tsx +++ b/src/pages/AiProvidersAmpcodeEditPage.tsx @@ -302,6 +302,8 @@ export function AiProvidersAmpcodeEditPage() { addLabel={t('ai_providers.ampcode_model_mappings_add_btn')} namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')} aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')} + removeButtonTitle={t('common.delete')} + removeButtonAriaLabel={t('common.delete')} disabled={loading || saving || disableControls} />
{t('ai_providers.ampcode_model_mappings_hint')}
diff --git a/src/pages/AiProvidersClaudeEditPage.tsx b/src/pages/AiProvidersClaudeEditPage.tsx index bccf7d6..9d9c1fe 100644 --- a/src/pages/AiProvidersClaudeEditPage.tsx +++ b/src/pages/AiProvidersClaudeEditPage.tsx @@ -210,7 +210,7 @@ export function AiProvidersClaudeEditPage() { {error &&
{error}
} {invalidIndexParam || invalidIndex ? ( -
Invalid provider index.
+
{t('common.invalid_provider_index')}
) : ( <>
@@ -255,6 +257,8 @@ export function AiProvidersClaudeEditPage() { addLabel={t('ai_providers.claude_models_add_btn')} namePlaceholder={t('common.model_name_placeholder')} aliasPlaceholder={t('common.model_alias_placeholder')} + removeButtonTitle={t('common.delete')} + removeButtonAriaLabel={t('common.delete')} disabled={disableControls || saving} />
diff --git a/src/pages/AiProvidersCodexEditPage.tsx b/src/pages/AiProvidersCodexEditPage.tsx index 5685682..4e1db25 100644 --- a/src/pages/AiProvidersCodexEditPage.tsx +++ b/src/pages/AiProvidersCodexEditPage.tsx @@ -210,7 +210,7 @@ export function AiProvidersCodexEditPage() { {error &&
{error}
} {invalidIndexParam || invalidIndex ? ( -
Invalid provider index.
+
{t('common.invalid_provider_index')}
) : ( <>
diff --git a/src/pages/AiProvidersGeminiEditPage.tsx b/src/pages/AiProvidersGeminiEditPage.tsx index 0a7c620..441cad3 100644 --- a/src/pages/AiProvidersGeminiEditPage.tsx +++ b/src/pages/AiProvidersGeminiEditPage.tsx @@ -193,7 +193,7 @@ export function AiProvidersGeminiEditPage() { {error &&
{error}
} {invalidIndexParam || invalidIndex ? ( -
Invalid provider index.
+
{t('common.invalid_provider_index')}
) : ( <>
diff --git a/src/pages/AiProvidersOpenAIEditLayout.tsx b/src/pages/AiProvidersOpenAIEditLayout.tsx index 05fc91e..22614b7 100644 --- a/src/pages/AiProvidersOpenAIEditLayout.tsx +++ b/src/pages/AiProvidersOpenAIEditLayout.tsx @@ -10,6 +10,7 @@ import type { ModelInfo } from '@/utils/models'; import { buildHeaderObject, headersToEntries } from '@/utils/headers'; import { buildApiKeyEntry } from '@/components/providers/utils'; import type { ModelEntry, OpenAIFormState } from '@/components/providers/types'; +import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore'; type LocationState = { fromAiProviders?: boolean } | null; @@ -29,6 +30,9 @@ export type OpenAIEditOutletContext = { setTestStatus: Dispatch>; testMessage: string; setTestMessage: Dispatch>; + keyTestStatuses: KeyTestStatus[]; + setDraftKeyTestStatus: (keyIndex: number, status: KeyTestStatus) => void; + resetDraftKeyTestStatuses: (count: number) => void; availableModels: string[]; handleBack: () => void; handleSave: () => Promise; @@ -99,11 +103,14 @@ export function AiProvidersOpenAIEditLayout() { const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel); const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus); const setDraftTestMessage = useOpenAIEditDraftStore((state) => state.setDraftTestMessage); + const setDraftKeyTestStatus = useOpenAIEditDraftStore((state) => state.setDraftKeyTestStatus); + const resetDraftKeyTestStatuses = useOpenAIEditDraftStore((state) => state.resetDraftKeyTestStatuses); const form = draft?.form ?? buildEmptyForm(); const testModel = draft?.testModel ?? ''; const testStatus = draft?.testStatus ?? 'idle'; const testMessage = draft?.testMessage ?? ''; + const keyTestStatuses = draft?.keyTestStatuses ?? []; const setForm: Dispatch> = useCallback( (action) => { @@ -134,6 +141,20 @@ export function AiProvidersOpenAIEditLayout() { [draftKey, setDraftTestMessage] ); + const handleSetDraftKeyTestStatus = useCallback( + (keyIndex: number, status: KeyTestStatus) => { + setDraftKeyTestStatus(draftKey, keyIndex, status); + }, + [draftKey, setDraftKeyTestStatus] + ); + + const handleResetDraftKeyTestStatuses = useCallback( + (count: number) => { + resetDraftKeyTestStatuses(draftKey, count); + }, + [draftKey, resetDraftKeyTestStatuses] + ); + const initialData = useMemo(() => { if (editIndex === null) return undefined; return providers[editIndex]; @@ -215,6 +236,7 @@ export function AiProvidersOpenAIEditLayout() { testModel: initialTestModel, testStatus: 'idle', testMessage: '', + keyTestStatuses: [], }); } else { initDraft(draftKey, { @@ -222,6 +244,7 @@ export function AiProvidersOpenAIEditLayout() { testModel: '', testStatus: 'idle', testMessage: '', + keyTestStatuses: [], }); } }, [draft?.initialized, draftKey, initDraft, initialData, loading]); @@ -359,6 +382,9 @@ export function AiProvidersOpenAIEditLayout() { setTestStatus, testMessage, setTestMessage, + keyTestStatuses, + setDraftKeyTestStatus: handleSetDraftKeyTestStatus, + resetDraftKeyTestStatuses: handleResetDraftKeyTestStatuses, availableModels, handleBack, handleSave, diff --git a/src/pages/AiProvidersOpenAIEditPage.tsx b/src/pages/AiProvidersOpenAIEditPage.tsx index 4abd996..f83be72 100644 --- a/src/pages/AiProvidersOpenAIEditPage.tsx +++ b/src/pages/AiProvidersOpenAIEditPage.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useCallback } from 'react'; import { useNavigate, useOutletContext } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; @@ -14,6 +14,7 @@ import type { ApiKeyEntry } from '@/types'; import { buildHeaderObject } from '@/utils/headers'; import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '@/components/providers/utils'; import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout'; +import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore'; import styles from './AiProvidersPage.module.scss'; import layoutStyles from './AiProvidersEditLayout.module.scss'; @@ -25,6 +26,72 @@ const getErrorMessage = (err: unknown) => { return ''; }; +// Status icon components +function StatusLoadingIcon() { + return ( + + + + + ); +} + +function StatusSuccessIcon() { + return ( + + + + + ); +} + +function StatusErrorIcon() { + return ( + + + + + ); +} + +function StatusIdleIcon() { + return ( + + + + ); +} + +function StatusIcon({ status }: { status: KeyTestStatus['status'] }) { + switch (status) { + case 'loading': + return ; + case 'success': + return ; + case 'error': + return ; + default: + return ; + } +} + export function AiProvidersOpenAIEditPage() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -44,6 +111,9 @@ export function AiProvidersOpenAIEditPage() { setTestStatus, testMessage, setTestMessage, + keyTestStatuses, + setDraftKeyTestStatus, + resetDraftKeyTestStatuses, availableModels, handleBack, handleSave, @@ -66,6 +136,144 @@ export function AiProvidersOpenAIEditPage() { }, [handleBack]); const canSave = !disableControls && !loading && !saving && !invalidIndexParam && !invalidIndex; + const hasConfiguredModels = form.modelEntries.some((entry) => entry.name.trim()); + const hasTestableKeys = form.apiKeyEntries.some((entry) => entry.apiKey?.trim()); + + // Test a single key by index + const testSingleKey = useCallback( + async (keyIndex: number): Promise => { + const baseUrl = form.baseUrl.trim(); + if (!baseUrl) { + showNotification(t('notification.openai_test_url_required'), 'error'); + return false; + } + + const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl); + if (!endpoint) { + showNotification(t('notification.openai_test_url_required'), 'error'); + return false; + } + + const keyEntry = form.apiKeyEntries[keyIndex]; + if (!keyEntry?.apiKey?.trim()) { + setDraftKeyTestStatus(keyIndex, { status: 'error', message: t('notification.openai_test_key_required') }); + return false; + } + + const modelName = testModel.trim() || availableModels[0] || ''; + if (!modelName) { + showNotification(t('notification.openai_test_model_required'), 'error'); + return false; + } + + const customHeaders = buildHeaderObject(form.headers); + const headers: Record = { + 'Content-Type': 'application/json', + ...customHeaders, + }; + if (!headers.Authorization && !headers['authorization']) { + headers.Authorization = `Bearer ${keyEntry.apiKey.trim()}`; + } + + // Set loading state for this key + setDraftKeyTestStatus(keyIndex, { status: 'loading', message: '' }); + + try { + const result = await apiCallApi.request( + { + method: 'POST', + url: endpoint, + header: Object.keys(headers).length ? headers : undefined, + data: JSON.stringify({ + model: modelName, + messages: [{ role: 'user', content: 'Hi' }], + stream: false, + max_tokens: 5, + }), + }, + { timeout: OPENAI_TEST_TIMEOUT_MS } + ); + + if (result.statusCode < 200 || result.statusCode >= 300) { + throw new Error(getApiCallErrorMessage(result)); + } + + setDraftKeyTestStatus(keyIndex, { status: 'success', message: '' }); + return true; + } catch (err: unknown) { + const message = getErrorMessage(err); + const errorCode = + typeof err === 'object' && err !== null && 'code' in err + ? String((err as { code?: string }).code) + : ''; + const isTimeout = errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout'); + const errorMessage = isTimeout + ? t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 }) + : message; + setDraftKeyTestStatus(keyIndex, { status: 'error', message: errorMessage }); + return false; + } + }, + [form.baseUrl, form.apiKeyEntries, form.headers, testModel, availableModels, t, setDraftKeyTestStatus, showNotification] + ); + + // Test all keys + const testAllKeys = useCallback(async () => { + const baseUrl = form.baseUrl.trim(); + if (!baseUrl) { + showNotification(t('notification.openai_test_url_required'), 'error'); + return; + } + + const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl); + if (!endpoint) { + showNotification(t('notification.openai_test_url_required'), 'error'); + return; + } + + const modelName = testModel.trim() || availableModels[0] || ''; + if (!modelName) { + showNotification(t('notification.openai_test_model_required'), 'error'); + return; + } + + // Initialize statuses for all keys + const validKeyEntries = form.apiKeyEntries.filter((entry) => entry.apiKey?.trim()); + if (validKeyEntries.length === 0) { + showNotification(t('notification.openai_test_key_required'), 'error'); + return; + } + + resetDraftKeyTestStatuses(form.apiKeyEntries.length); + + // Test all keys in parallel + const results = await Promise.all( + form.apiKeyEntries.map((_, index) => testSingleKey(index)) + ); + + const successCount = results.filter(Boolean).length; + const failCount = results.length - successCount; + + if (failCount === 0) { + showNotification(t('ai_providers.openai_test_all_success', { count: successCount }), 'success'); + } else if (successCount === 0) { + showNotification(t('ai_providers.openai_test_all_failed', { count: failCount }), 'error'); + } else { + showNotification( + t('ai_providers.openai_test_all_partial', { success: successCount, failed: failCount }), + 'warning' + ); + } + }, [form.baseUrl, form.apiKeyEntries, testModel, availableModels, t, resetDraftKeyTestStatuses, testSingleKey, showNotification]); + + const openOpenaiModelDiscovery = () => { + const baseUrl = form.baseUrl.trim(); + if (!baseUrl) { + showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error'); + return; + } + navigate('models'); + }; const renderKeyEntries = (entries: ApiKeyEntry[]) => { const list = entries.length ? entries : [buildApiKeyEntry()]; @@ -88,145 +296,101 @@ export function AiProvidersOpenAIEditPage() { }; return ( -
- {list.map((entry, index) => ( -
-
- updateEntry(index, 'apiKey', e.target.value)} - disabled={saving || disableControls} - /> - updateEntry(index, 'proxyUrl', e.target.value)} - disabled={saving || disableControls} - /> -
-
- -
+
+
+ + {t('ai_providers.openai_keys_count')}: {list.length} + + +
+
+ {/* 表头 */} +
+
#
+
{t('common.status')}
+
{t('common.api_key')}
+
{t('common.proxy_url')}
+
{t('common.action')}
- ))} - + + {/* 数据行 */} + {list.map((entry, index) => { + const keyStatus = keyTestStatuses[index]?.status ?? 'idle'; + const canTestKey = Boolean(entry.apiKey?.trim()) && hasConfiguredModels; + + return ( +
+ {/* 序号 */} +
{index + 1}
+ + {/* 状态指示灯 */} +
+ +
+ + {/* Key 输入框 */} +
+ updateEntry(index, 'apiKey', e.target.value)} + disabled={saving || disableControls} + className={`input ${styles.keyTableInput}`} + placeholder={t('ai_providers.openai_key_placeholder')} + /> +
+ + {/* Proxy 输入框 */} +
+ updateEntry(index, 'proxyUrl', e.target.value)} + disabled={saving || disableControls} + className={`input ${styles.keyTableInput}`} + placeholder={t('ai_providers.openai_proxy_placeholder')} + /> +
+ + {/* 操作按钮 */} +
+ + +
+
+ ); + })} +
); }; - const openOpenaiModelDiscovery = () => { - const baseUrl = form.baseUrl.trim(); - if (!baseUrl) { - showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error'); - return; - } - navigate('models'); - }; - - const testOpenaiProviderConnection = async () => { - const baseUrl = form.baseUrl.trim(); - if (!baseUrl) { - const message = t('notification.openai_test_url_required'); - setTestStatus('error'); - setTestMessage(message); - showNotification(message, 'error'); - return; - } - - const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl); - if (!endpoint) { - const message = t('notification.openai_test_url_required'); - setTestStatus('error'); - setTestMessage(message); - showNotification(message, 'error'); - return; - } - - const firstKeyEntry = form.apiKeyEntries.find((entry) => entry.apiKey?.trim()); - if (!firstKeyEntry) { - const message = t('notification.openai_test_key_required'); - setTestStatus('error'); - setTestMessage(message); - showNotification(message, 'error'); - return; - } - - const modelName = testModel.trim() || availableModels[0] || ''; - if (!modelName) { - const message = t('notification.openai_test_model_required'); - setTestStatus('error'); - setTestMessage(message); - showNotification(message, 'error'); - return; - } - - const customHeaders = buildHeaderObject(form.headers); - const headers: Record = { - 'Content-Type': 'application/json', - ...customHeaders, - }; - if (!headers.Authorization && !headers['authorization']) { - headers.Authorization = `Bearer ${firstKeyEntry.apiKey.trim()}`; - } - - setTestStatus('loading'); - setTestMessage(t('ai_providers.openai_test_running')); - - try { - const result = await apiCallApi.request( - { - method: 'POST', - url: endpoint, - header: Object.keys(headers).length ? headers : undefined, - data: JSON.stringify({ - model: modelName, - messages: [{ role: 'user', content: 'Hi' }], - stream: false, - max_tokens: 5, - }), - }, - { timeout: OPENAI_TEST_TIMEOUT_MS } - ); - - if (result.statusCode < 200 || result.statusCode >= 300) { - throw new Error(getApiCallErrorMessage(result)); - } - - setTestStatus('success'); - setTestMessage(t('ai_providers.openai_test_success')); - } catch (err: unknown) { - setTestStatus('error'); - const message = getErrorMessage(err); - const errorCode = - typeof err === 'object' && err !== null && 'code' in err - ? String((err as { code?: string }).code) - : ''; - const isTimeout = errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout'); - if (isTimeout) { - setTestMessage( - t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 }) - ); - } else { - setTestMessage(`${t('ai_providers.openai_test_failed')}: ${message}`); - } - } - }; - return ( {invalidIndexParam || invalidIndex ? ( -
Invalid provider index.
+
{t('common.invalid_provider_index')}
) : ( <> -
- + {/* 模型配置区域 - 统一布局 */} +
+ {/* 标题行 */} +
+ +
+ + +
+
+ + {/* 提示文本 */}
{t('ai_providers.openai_models_hint')}
+ + {/* 模型列表 */} setForm((prev) => ({ ...prev, modelEntries: entries }))} - addLabel={t('ai_providers.openai_models_add_btn')} namePlaceholder={t('common.model_name_placeholder')} aliasPlaceholder={t('common.model_alias_placeholder')} disabled={saving || disableControls} + hideAddButton + className={styles.modelInputList} + rowClassName={styles.modelInputRow} + inputClassName={styles.modelInputField} + removeButtonClassName={styles.modelRowRemoveButton} + removeButtonTitle={t('common.delete')} + removeButtonAriaLabel={t('common.delete')} /> - -
-
- -
{t('ai_providers.openai_test_hint')}
-
- - + {/* 测试区域 */} +
+
+ + {t('ai_providers.openai_test_hint')} +
+
+ + +
{testMessage && (
-
- +
+
+ + {t('ai_providers.openai_keys_hint')} +
{renderKeyEntries(form.apiKeyEntries)}
diff --git a/src/pages/AiProvidersPage.module.scss b/src/pages/AiProvidersPage.module.scss index a1be474..4e807cd 100644 --- a/src/pages/AiProvidersPage.module.scss +++ b/src/pages/AiProvidersPage.module.scss @@ -387,19 +387,6 @@ } } -// 连通性测试按钮高度对齐 -.openaiTestSelect { - flex: 1 1 0; - min-width: 0; -} - -.openaiTestButton { - flex: 1 1 0; - padding: 8px 12px; - font-size: 14px; - line-height: 1.5; -} - // 状态监测栏 .statusBar { display: flex; @@ -473,6 +460,312 @@ background: var(--failure-badge-bg, #fee2e2); } +// ============================================ +// Model Config Section - Unified Layout +// ============================================ + +.modelConfigSection { + margin-bottom: $spacing-md; + display: flex; + flex-direction: column; + gap: $spacing-sm; +} + +.modelConfigHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-md; + flex-wrap: wrap; + + @include mobile { + align-items: flex-start; + } +} + +.modelConfigTitle { + font-weight: 600; + color: var(--text-primary); + font-size: 14px; + line-height: 1.4; +} + +.modelConfigToolbar { + display: flex; + align-items: center; + gap: $spacing-xs; + flex-wrap: wrap; + justify-content: flex-end; + + @include mobile { + width: 100%; + justify-content: flex-start; + } + + :global(.btn) { + white-space: nowrap; + } +} + +.modelInputList { + gap: $spacing-xs; +} + +.modelInputRow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto; + gap: $spacing-sm; + align-items: center; + + @include mobile { + grid-template-columns: minmax(0, 1fr) auto; + row-gap: $spacing-xs; + + > :nth-child(2) { + display: none; + } + + > :nth-child(3) { + grid-column: 1 / 3; + } + + > :nth-child(4) { + grid-column: 2 / 3; + grid-row: 1 / 2; + } + } +} + +.modelInputField { + min-width: 0; +} + +.modelRowRemoveButton { + justify-self: center; +} + +.modelTestPanel { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: $spacing-md; + margin-top: $spacing-sm; + padding: $spacing-sm $spacing-md; + border: 1px solid var(--border-color); + border-radius: $radius-md; + background: var(--bg-secondary); + + @include mobile { + flex-direction: column; + align-items: stretch; + } +} + +.modelTestMeta { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.modelTestLabel { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + line-height: 1.4; +} + +.modelTestHint { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.4; +} + +.modelTestControls { + display: flex; + align-items: center; + justify-content: flex-end; + gap: $spacing-xs; + flex: 1; + min-width: 0; + + @include mobile { + justify-content: flex-start; + } +} + +// ============================================ +// Key Entry Styles - Table Design +// ============================================ + +.keyEntriesSection { + margin-bottom: 0; +} + +.keyEntriesHeader { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: $spacing-sm; + + label { + margin: 0; + } +} + +.keyEntriesHint { + font-size: 13px; + line-height: 1.4; + color: var(--text-secondary); +} + +.keyEntriesList { + display: flex; + flex-direction: column; + gap: $spacing-sm; +} + +.keyEntriesToolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-sm; + flex-wrap: wrap; +} + +.keyEntriesCount { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} + +.keyTableShell { + overflow-x: auto; + border-radius: $radius-md; +} + +// 表头 +.keyTableHeader { + display: grid; + grid-template-columns: 46px 56px minmax(220px, 1.4fr) minmax(200px, 1.1fr) auto; + gap: $spacing-sm; + min-width: 760px; + padding: 10px $spacing-md; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-bottom: none; + border-radius: $radius-md $radius-md 0 0; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: none; +} + +// 数据行 +.keyTableRow { + display: grid; + grid-template-columns: 46px 56px minmax(220px, 1.4fr) minmax(200px, 1.1fr) auto; + gap: $spacing-sm; + min-width: 760px; + padding: 10px $spacing-md; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-top: none; + align-items: center; + + &:last-child { + border-radius: 0 0 $radius-md $radius-md; + } + + &:hover { + background: var(--bg-tertiary); + } +} + +// 列定义 +.keyTableColIndex { + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + color: var(--text-tertiary); +} + +.keyTableColStatus { + display: flex; + align-items: center; + justify-content: center; + + svg { + display: block; + } +} + +.keyTableColKey, +.keyTableColProxy { + min-width: 0; +} + +.keyTableColAction { + display: flex; + align-items: center; + justify-content: flex-end; + gap: $spacing-xs; + flex-shrink: 0; + white-space: nowrap; +} + +.keyTableInput { + width: 100%; + padding: 8px 10px; + font-size: 14px; + min-height: 38px; +} + +.addKeyButton { + align-self: auto; + margin-top: 0; +} + +.openaiTestSelect { + flex: 1 1 260px; + min-width: 180px; + max-width: 380px; + + @include mobile { + min-width: 0; + max-width: none; + } +} + +.modelTestAllButton { + white-space: nowrap; + flex-shrink: 0; +} + +.statusIconWrapper { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + color: var(--text-secondary); + flex-shrink: 0; +} + +.statusIconSpin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + // 暗色主题适配 :global([data-theme='dark']) { .headerBadge { diff --git a/src/pages/AiProvidersVertexEditPage.tsx b/src/pages/AiProvidersVertexEditPage.tsx index f02792c..a47af94 100644 --- a/src/pages/AiProvidersVertexEditPage.tsx +++ b/src/pages/AiProvidersVertexEditPage.tsx @@ -218,7 +218,7 @@ export function AiProvidersVertexEditPage() { {error &&
{error}
} {invalidIndexParam || invalidIndex ? ( -
Invalid provider index.
+
{t('common.invalid_provider_index')}
) : ( <>
@@ -266,6 +268,8 @@ export function AiProvidersVertexEditPage() { addLabel={t('ai_providers.vertex_models_add_btn')} namePlaceholder={t('common.model_name_placeholder')} aliasPlaceholder={t('common.model_alias_placeholder')} + removeButtonTitle={t('common.delete')} + removeButtonAriaLabel={t('common.delete')} disabled={disableControls || saving} />
{t('ai_providers.vertex_models_hint')}
diff --git a/src/stores/useOpenAIEditDraftStore.ts b/src/stores/useOpenAIEditDraftStore.ts index bab02d4..4c8566c 100644 --- a/src/stores/useOpenAIEditDraftStore.ts +++ b/src/stores/useOpenAIEditDraftStore.ts @@ -15,12 +15,18 @@ import { buildApiKeyEntry } from '@/components/providers/utils'; export type OpenAITestStatus = 'idle' | 'loading' | 'success' | 'error'; +export type KeyTestStatus = { + status: OpenAITestStatus; + message: string; +}; + export type OpenAIEditDraft = { initialized: boolean; form: OpenAIFormState; testModel: string; testStatus: OpenAITestStatus; testMessage: string; + keyTestStatuses: KeyTestStatus[]; }; interface OpenAIEditDraftState { @@ -31,6 +37,8 @@ interface OpenAIEditDraftState { setDraftTestModel: (key: string, action: SetStateAction) => void; setDraftTestStatus: (key: string, action: SetStateAction) => void; setDraftTestMessage: (key: string, action: SetStateAction) => void; + setDraftKeyTestStatus: (draftKey: string, keyIndex: number, status: KeyTestStatus) => void; + resetDraftKeyTestStatuses: (draftKey: string, count: number) => void; clearDraft: (key: string) => void; } @@ -53,6 +61,7 @@ const buildEmptyDraft = (): OpenAIEditDraft => ({ testModel: '', testStatus: 'idle', testMessage: '', + keyTestStatuses: [], }); export const useOpenAIEditDraftStore = create((set, get) => ({ @@ -135,6 +144,38 @@ export const useOpenAIEditDraftStore = create((set, get) = }); }, + setDraftKeyTestStatus: (draftKey, keyIndex, status) => { + if (!draftKey) return; + set((state) => { + const existing = state.drafts[draftKey] ?? buildEmptyDraft(); + const nextStatuses = [...existing.keyTestStatuses]; + nextStatuses[keyIndex] = status; + return { + drafts: { + ...state.drafts, + [draftKey]: { ...existing, initialized: true, keyTestStatuses: nextStatuses }, + }, + }; + }); + }, + + resetDraftKeyTestStatuses: (draftKey, count) => { + if (!draftKey) return; + set((state) => { + const existing = state.drafts[draftKey] ?? buildEmptyDraft(); + return { + drafts: { + ...state.drafts, + [draftKey]: { + ...existing, + initialized: true, + keyTestStatuses: Array.from({ length: count }, () => ({ status: 'idle', message: '' })), + }, + }, + }; + }); + }, + clearDraft: (key) => { if (!key) return; set((state) => { diff --git a/src/styles/components.scss b/src/styles/components.scss index 76e59f5..1ce5e65 100644 --- a/src/styles/components.scss +++ b/src/styles/components.scss @@ -581,15 +581,16 @@ textarea { padding: $spacing-md; background: var(--bg-primary); display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; gap: $spacing-md; - flex-wrap: wrap; .item-meta { display: flex; flex-direction: column; - gap: 6px; + gap: $spacing-sm; + flex: 1; + min-width: 0; } .item-title { From 0089d4a7054502a1a37c6416f1535fa53707c743 Mon Sep 17 00:00:00 2001 From: moxi Date: Wed, 11 Feb 2026 23:34:45 +0800 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20=E5=90=8C=E6=AD=A5=20package-lock?= =?UTF-8?q?=20=E4=BB=A5=E5=8C=B9=E9=85=8D=E4=BE=9D=E8=B5=96=E5=8F=98?= =?UTF-8?q?=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 38 +++++--------------------------------- 1 file changed, 5 insertions(+), 33 deletions(-) diff --git a/package-lock.json b/package-lock.json index cc39202..63c4c2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,7 +72,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -467,7 +466,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz", "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1243,18 +1241,6 @@ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "license": "MIT" }, - "node_modules/@openai/codex": { - "version": "0.98.0", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.98.0.tgz", - "integrity": "sha512-CKjrhAmzTvWn7Vbsi27iZRKBAJw9a7ZTTkWQDbLgQZP1weGbDIBk1r6wiLEp1ZmDO7w0fHPLYgnVspiOrYgcxg==", - "license": "Apache-2.0", - "bin": { - "codex": "bin/codex.js" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -1945,7 +1931,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2033,7 +2018,6 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -2351,7 +2335,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2401,13 +2384,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -2563,7 +2546,6 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -2828,7 +2810,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3305,7 +3286,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -3635,7 +3615,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3742,7 +3721,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3760,7 +3738,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3869,7 +3846,6 @@ "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -4052,7 +4028,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4129,7 +4104,6 @@ "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4259,7 +4233,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -4287,7 +4260,6 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 2cf1e2335101d69aa4ef5ad425f6b9d2a6dc4c8a Mon Sep 17 00:00:00 2001 From: moxi Date: Wed, 11 Feb 2026 23:51:53 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(ai-providers):=20=E4=BF=AE=E5=A4=8D=20O?= =?UTF-8?q?penAI=20=E5=AF=86=E9=92=A5=E6=B5=8B=E8=AF=95=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E4=B8=8E=E5=85=B1=E4=BA=AB=E6=A0=B7=E5=BC=8F=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/AiProvidersOpenAIEditPage.tsx | 91 ++++++++++++++++++------- src/styles/components.scss | 7 +- 2 files changed, 68 insertions(+), 30 deletions(-) diff --git a/src/pages/AiProvidersOpenAIEditPage.tsx b/src/pages/AiProvidersOpenAIEditPage.tsx index f83be72..6e9e80f 100644 --- a/src/pages/AiProvidersOpenAIEditPage.tsx +++ b/src/pages/AiProvidersOpenAIEditPage.tsx @@ -221,50 +221,79 @@ export function AiProvidersOpenAIEditPage() { const testAllKeys = useCallback(async () => { const baseUrl = form.baseUrl.trim(); if (!baseUrl) { - showNotification(t('notification.openai_test_url_required'), 'error'); + const message = t('notification.openai_test_url_required'); + setTestStatus('error'); + setTestMessage(message); + showNotification(message, 'error'); return; } const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl); if (!endpoint) { - showNotification(t('notification.openai_test_url_required'), 'error'); + const message = t('notification.openai_test_url_required'); + setTestStatus('error'); + setTestMessage(message); + showNotification(message, 'error'); return; } const modelName = testModel.trim() || availableModels[0] || ''; if (!modelName) { - showNotification(t('notification.openai_test_model_required'), 'error'); + const message = t('notification.openai_test_model_required'); + setTestStatus('error'); + setTestMessage(message); + showNotification(message, 'error'); return; } - // Initialize statuses for all keys - const validKeyEntries = form.apiKeyEntries.filter((entry) => entry.apiKey?.trim()); - if (validKeyEntries.length === 0) { - showNotification(t('notification.openai_test_key_required'), 'error'); + const validKeyIndexes = form.apiKeyEntries + .map((entry, index) => (entry.apiKey?.trim() ? index : -1)) + .filter((index) => index >= 0); + if (validKeyIndexes.length === 0) { + const message = t('notification.openai_test_key_required'); + setTestStatus('error'); + setTestMessage(message); + showNotification(message, 'error'); return; } + setTestStatus('loading'); + setTestMessage(t('ai_providers.openai_test_running')); resetDraftKeyTestStatuses(form.apiKeyEntries.length); - // Test all keys in parallel - const results = await Promise.all( - form.apiKeyEntries.map((_, index) => testSingleKey(index)) - ); + const results = await Promise.all(validKeyIndexes.map((index) => testSingleKey(index))); const successCount = results.filter(Boolean).length; - const failCount = results.length - successCount; + const failCount = validKeyIndexes.length - successCount; if (failCount === 0) { - showNotification(t('ai_providers.openai_test_all_success', { count: successCount }), 'success'); + const message = t('ai_providers.openai_test_all_success', { count: successCount }); + setTestStatus('success'); + setTestMessage(message); + showNotification(message, 'success'); } else if (successCount === 0) { - showNotification(t('ai_providers.openai_test_all_failed', { count: failCount }), 'error'); + const message = t('ai_providers.openai_test_all_failed', { count: failCount }); + setTestStatus('error'); + setTestMessage(message); + showNotification(message, 'error'); } else { - showNotification( - t('ai_providers.openai_test_all_partial', { success: successCount, failed: failCount }), - 'warning' - ); + const message = t('ai_providers.openai_test_all_partial', { success: successCount, failed: failCount }); + setTestStatus('error'); + setTestMessage(message); + showNotification(message, 'warning'); } - }, [form.baseUrl, form.apiKeyEntries, testModel, availableModels, t, resetDraftKeyTestStatuses, testSingleKey, showNotification]); + }, [ + form.baseUrl, + form.apiKeyEntries, + testModel, + availableModels, + t, + setTestStatus, + setTestMessage, + resetDraftKeyTestStatuses, + testSingleKey, + showNotification, + ]); const openOpenaiModelDiscovery = () => { const baseUrl = form.baseUrl.trim(); @@ -281,18 +310,28 @@ export function AiProvidersOpenAIEditPage() { const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => { const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry)); setForm((prev) => ({ ...prev, apiKeyEntries: next })); + setDraftKeyTestStatus(idx, { status: 'idle', message: '' }); + setTestStatus('idle'); + setTestMessage(''); }; const removeEntry = (idx: number) => { const next = list.filter((_, i) => i !== idx); + const nextLength = next.length ? next.length : 1; setForm((prev) => ({ ...prev, apiKeyEntries: next.length ? next : [buildApiKeyEntry()], })); + resetDraftKeyTestStatuses(nextLength); + setTestStatus('idle'); + setTestMessage(''); }; const addEntry = () => { setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] })); + resetDraftKeyTestStatuses(list.length + 1); + setTestStatus('idle'); + setTestMessage(''); }; return ( @@ -305,7 +344,7 @@ export function AiProvidersOpenAIEditPage() { variant="secondary" size="sm" onClick={addEntry} - disabled={saving || disableControls} + disabled={saving || disableControls || testStatus === 'loading'} className={styles.addKeyButton} > {t('ai_providers.openai_keys_add_btn')} @@ -345,7 +384,7 @@ export function AiProvidersOpenAIEditPage() { type="text" value={entry.apiKey} onChange={(e) => updateEntry(index, 'apiKey', e.target.value)} - disabled={saving || disableControls} + disabled={saving || disableControls || testStatus === 'loading'} className={`input ${styles.keyTableInput}`} placeholder={t('ai_providers.openai_key_placeholder')} /> @@ -357,7 +396,7 @@ export function AiProvidersOpenAIEditPage() { type="text" value={entry.proxyUrl ?? ''} onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)} - disabled={saving || disableControls} + disabled={saving || disableControls || testStatus === 'loading'} className={`input ${styles.keyTableInput}`} placeholder={t('ai_providers.openai_proxy_placeholder')} /> @@ -369,7 +408,7 @@ export function AiProvidersOpenAIEditPage() { variant="secondary" size="sm" onClick={() => void testSingleKey(index)} - disabled={saving || disableControls || !canTestKey} + disabled={saving || disableControls || testStatus === 'loading' || !canTestKey} loading={keyStatus === 'loading'} > {t('ai_providers.openai_test_single_action')} @@ -378,7 +417,7 @@ export function AiProvidersOpenAIEditPage() { variant="ghost" size="sm" onClick={() => removeEntry(index)} - disabled={saving || disableControls || list.length <= 1} + disabled={saving || disableControls || testStatus === 'loading' || list.length <= 1} > {t('common.delete')} @@ -510,7 +549,7 @@ export function AiProvidersOpenAIEditPage() { setTestStatus('idle'); setTestMessage(''); }} - disabled={saving || disableControls || availableModels.length === 0} + disabled={saving || disableControls || testStatus === 'loading' || availableModels.length === 0} >