From c726fbc379301beef5727c376b8352642e4c68cf Mon Sep 17 00:00:00 2001 From: moxi Date: Wed, 11 Feb 2026 23:31:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai-providers):=20=E4=BC=98=E5=8C=96=20Open?= =?UTF-8?q?AI=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 {