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) => (
-
);
}
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 {