mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 18:50:49 +08:00
feat(ai-providers): 优化 OpenAI 编辑页 UI 交互与对齐
This commit is contained in:
@@ -10,6 +10,8 @@ interface HeaderInputListProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
keyPlaceholder?: string;
|
keyPlaceholder?: string;
|
||||||
valuePlaceholder?: string;
|
valuePlaceholder?: string;
|
||||||
|
removeButtonTitle?: string;
|
||||||
|
removeButtonAriaLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeaderInputList({
|
export function HeaderInputList({
|
||||||
@@ -18,7 +20,9 @@ export function HeaderInputList({
|
|||||||
addLabel,
|
addLabel,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
keyPlaceholder = 'X-Custom-Header',
|
keyPlaceholder = 'X-Custom-Header',
|
||||||
valuePlaceholder = 'value'
|
valuePlaceholder = 'value',
|
||||||
|
removeButtonTitle = 'Remove',
|
||||||
|
removeButtonAriaLabel = 'Remove',
|
||||||
}: HeaderInputListProps) {
|
}: HeaderInputListProps) {
|
||||||
const currentEntries = entries.length ? entries : [{ key: '', value: '' }];
|
const currentEntries = entries.length ? entries : [{ key: '', value: '' }];
|
||||||
|
|
||||||
@@ -61,8 +65,8 @@ export function HeaderInputList({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => removeEntry(index)}
|
onClick={() => removeEntry(index)}
|
||||||
disabled={disabled || currentEntries.length <= 1}
|
disabled={disabled || currentEntries.length <= 1}
|
||||||
title="Remove"
|
title={removeButtonTitle}
|
||||||
aria-label="Remove"
|
aria-label={removeButtonAriaLabel}
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -6,10 +6,18 @@ import type { ModelEntry } from './modelInputListUtils';
|
|||||||
interface ModelInputListProps {
|
interface ModelInputListProps {
|
||||||
entries: ModelEntry[];
|
entries: ModelEntry[];
|
||||||
onChange: (entries: ModelEntry[]) => void;
|
onChange: (entries: ModelEntry[]) => void;
|
||||||
addLabel: string;
|
addLabel?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
namePlaceholder?: string;
|
namePlaceholder?: string;
|
||||||
aliasPlaceholder?: string;
|
aliasPlaceholder?: string;
|
||||||
|
hideAddButton?: boolean;
|
||||||
|
onAdd?: () => void;
|
||||||
|
className?: string;
|
||||||
|
rowClassName?: string;
|
||||||
|
inputClassName?: string;
|
||||||
|
removeButtonClassName?: string;
|
||||||
|
removeButtonTitle?: string;
|
||||||
|
removeButtonAriaLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelInputList({
|
export function ModelInputList({
|
||||||
@@ -18,9 +26,20 @@ export function ModelInputList({
|
|||||||
addLabel,
|
addLabel,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
namePlaceholder = 'model-name',
|
namePlaceholder = 'model-name',
|
||||||
aliasPlaceholder = 'alias (optional)'
|
aliasPlaceholder = 'alias (optional)',
|
||||||
|
hideAddButton = false,
|
||||||
|
onAdd,
|
||||||
|
className = '',
|
||||||
|
rowClassName = '',
|
||||||
|
inputClassName = '',
|
||||||
|
removeButtonClassName = '',
|
||||||
|
removeButtonTitle = 'Remove',
|
||||||
|
removeButtonAriaLabel = 'Remove',
|
||||||
}: ModelInputListProps) {
|
}: ModelInputListProps) {
|
||||||
const currentEntries = entries.length ? entries : [{ name: '', alias: '' }];
|
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 updateEntry = (index: number, field: 'name' | 'alias', value: string) => {
|
||||||
const next = currentEntries.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry));
|
const next = currentEntries.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry));
|
||||||
@@ -28,7 +47,11 @@ export function ModelInputList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addEntry = () => {
|
const addEntry = () => {
|
||||||
onChange([...currentEntries, { name: '', alias: '' }]);
|
if (onAdd) {
|
||||||
|
onAdd();
|
||||||
|
} else {
|
||||||
|
onChange([...currentEntries, { name: '', alias: '' }]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeEntry = (index: number) => {
|
const removeEntry = (index: number) => {
|
||||||
@@ -37,12 +60,12 @@ export function ModelInputList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="header-input-list">
|
<div className={containerClassName}>
|
||||||
{currentEntries.map((entry, index) => (
|
{currentEntries.map((entry, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
<div className="header-input-row">
|
<div className={rowClassNames}>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className={inputClassNames}
|
||||||
placeholder={namePlaceholder}
|
placeholder={namePlaceholder}
|
||||||
value={entry.name}
|
value={entry.name}
|
||||||
onChange={(e) => updateEntry(index, 'name', e.target.value)}
|
onChange={(e) => updateEntry(index, 'name', e.target.value)}
|
||||||
@@ -50,7 +73,7 @@ export function ModelInputList({
|
|||||||
/>
|
/>
|
||||||
<span className="header-separator">→</span>
|
<span className="header-separator">→</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className={inputClassNames}
|
||||||
placeholder={aliasPlaceholder}
|
placeholder={aliasPlaceholder}
|
||||||
value={entry.alias}
|
value={entry.alias}
|
||||||
onChange={(e) => updateEntry(index, 'alias', e.target.value)}
|
onChange={(e) => updateEntry(index, 'alias', e.target.value)}
|
||||||
@@ -61,17 +84,20 @@ export function ModelInputList({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => removeEntry(index)}
|
onClick={() => removeEntry(index)}
|
||||||
disabled={disabled || currentEntries.length <= 1}
|
disabled={disabled || currentEntries.length <= 1}
|
||||||
title="Remove"
|
className={removeButtonClassName}
|
||||||
aria-label="Remove"
|
title={removeButtonTitle}
|
||||||
|
aria-label={removeButtonAriaLabel}
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
<Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start">
|
{!hideAddButton && addLabel && (
|
||||||
{addLabel}
|
<Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start">
|
||||||
</Button>
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,13 +38,16 @@
|
|||||||
"quota_update_required": "Please update the CPA version or check for updates",
|
"quota_update_required": "Please update the CPA version or check for updates",
|
||||||
"quota_check_credential": "Please check the credential status",
|
"quota_check_credential": "Please check the credential status",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
|
"status": "Status",
|
||||||
|
"action": "Action",
|
||||||
"custom_headers_label": "Custom Headers",
|
"custom_headers_label": "Custom Headers",
|
||||||
"custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.",
|
"custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.",
|
||||||
"custom_headers_add": "Add Header",
|
"custom_headers_add": "Add Header",
|
||||||
"custom_headers_key_placeholder": "Header name, e.g. X-Custom-Header",
|
"custom_headers_key_placeholder": "Header name, e.g. X-Custom-Header",
|
||||||
"custom_headers_value_placeholder": "Header value",
|
"custom_headers_value_placeholder": "Header value",
|
||||||
"model_name_placeholder": "Model name, e.g. claude-3-5-sonnet-20241022",
|
"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": {
|
"title": {
|
||||||
"main": "CLI Proxy API Management Center",
|
"main": "CLI Proxy API Management Center",
|
||||||
@@ -333,7 +336,13 @@
|
|||||||
"openai_test_success": "Test succeeded. The model responded.",
|
"openai_test_success": "Test succeeded. The model responded.",
|
||||||
"openai_test_failed": "Test failed",
|
"openai_test_failed": "Test failed",
|
||||||
"openai_test_select_placeholder": "Choose from current models",
|
"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": {
|
"auth_files": {
|
||||||
"title": "Auth Files Management",
|
"title": "Auth Files Management",
|
||||||
|
|||||||
@@ -38,13 +38,16 @@
|
|||||||
"quota_update_required": "Пожалуйста, обновите CPA или проверьте наличие обновлений",
|
"quota_update_required": "Пожалуйста, обновите CPA или проверьте наличие обновлений",
|
||||||
"quota_check_credential": "Пожалуйста, проверьте статус учётных данных",
|
"quota_check_credential": "Пожалуйста, проверьте статус учётных данных",
|
||||||
"copy": "Копировать",
|
"copy": "Копировать",
|
||||||
|
"status": "Статус",
|
||||||
|
"action": "Действие",
|
||||||
"custom_headers_label": "Пользовательские заголовки",
|
"custom_headers_label": "Пользовательские заголовки",
|
||||||
"custom_headers_hint": "Необязательно — HTTP-заголовки для отправки с запросом. Оставьте пустым для удаления.",
|
"custom_headers_hint": "Необязательно — HTTP-заголовки для отправки с запросом. Оставьте пустым для удаления.",
|
||||||
"custom_headers_add": "Добавить заголовок",
|
"custom_headers_add": "Добавить заголовок",
|
||||||
"custom_headers_key_placeholder": "Имя заголовка, например X-Custom-Header",
|
"custom_headers_key_placeholder": "Имя заголовка, например X-Custom-Header",
|
||||||
"custom_headers_value_placeholder": "Значение заголовка",
|
"custom_headers_value_placeholder": "Значение заголовка",
|
||||||
"model_name_placeholder": "Имя модели, напр. claude-3-5-sonnet-20241022",
|
"model_name_placeholder": "Имя модели, напр. claude-3-5-sonnet-20241022",
|
||||||
"model_alias_placeholder": "Псевдоним модели (необязательно)"
|
"model_alias_placeholder": "Псевдоним модели (необязательно)",
|
||||||
|
"invalid_provider_index": "Неверный индекс провайдера."
|
||||||
},
|
},
|
||||||
"title": {
|
"title": {
|
||||||
"main": "Центр управления CLI Proxy API",
|
"main": "Центр управления CLI Proxy API",
|
||||||
@@ -333,7 +336,13 @@
|
|||||||
"openai_test_success": "Тест выполнен успешно. Модель ответила.",
|
"openai_test_success": "Тест выполнен успешно. Модель ответила.",
|
||||||
"openai_test_failed": "Тест не выполнен",
|
"openai_test_failed": "Тест не выполнен",
|
||||||
"openai_test_select_placeholder": "Выберите из текущих моделей",
|
"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": {
|
"auth_files": {
|
||||||
"title": "Управление файлами авторизации",
|
"title": "Управление файлами авторизации",
|
||||||
|
|||||||
@@ -38,13 +38,16 @@
|
|||||||
"quota_update_required": "请更新 CPA 版本或检查更新",
|
"quota_update_required": "请更新 CPA 版本或检查更新",
|
||||||
"quota_check_credential": "请检查凭证状态",
|
"quota_check_credential": "请检查凭证状态",
|
||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
|
"status": "状态",
|
||||||
|
"action": "操作",
|
||||||
"custom_headers_label": "自定义请求头",
|
"custom_headers_label": "自定义请求头",
|
||||||
"custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。",
|
"custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。",
|
||||||
"custom_headers_add": "添加请求头",
|
"custom_headers_add": "添加请求头",
|
||||||
"custom_headers_key_placeholder": "Header 名称,例如 X-Custom-Header",
|
"custom_headers_key_placeholder": "Header 名称,例如 X-Custom-Header",
|
||||||
"custom_headers_value_placeholder": "Header 值",
|
"custom_headers_value_placeholder": "Header 值",
|
||||||
"model_name_placeholder": "模型名称,例如 claude-3-5-sonnet-20241022",
|
"model_name_placeholder": "模型名称,例如 claude-3-5-sonnet-20241022",
|
||||||
"model_alias_placeholder": "模型别名 (可选)"
|
"model_alias_placeholder": "模型别名 (可选)",
|
||||||
|
"invalid_provider_index": "无效的提供商索引。"
|
||||||
},
|
},
|
||||||
"title": {
|
"title": {
|
||||||
"main": "CLI Proxy API Management Center",
|
"main": "CLI Proxy API Management Center",
|
||||||
@@ -333,7 +336,13 @@
|
|||||||
"openai_test_success": "测试成功,模型可用。",
|
"openai_test_success": "测试成功,模型可用。",
|
||||||
"openai_test_failed": "测试失败",
|
"openai_test_failed": "测试失败",
|
||||||
"openai_test_select_placeholder": "从当前模型列表选择",
|
"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": {
|
"auth_files": {
|
||||||
"title": "认证文件管理",
|
"title": "认证文件管理",
|
||||||
|
|||||||
@@ -302,6 +302,8 @@ export function AiProvidersAmpcodeEditPage() {
|
|||||||
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
|
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
|
||||||
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
|
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
|
||||||
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
|
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
|
||||||
|
removeButtonTitle={t('common.delete')}
|
||||||
|
removeButtonAriaLabel={t('common.delete')}
|
||||||
disabled={loading || saving || disableControls}
|
disabled={loading || saving || disableControls}
|
||||||
/>
|
/>
|
||||||
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
|
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function AiProvidersClaudeEditPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
{error && <div className="error-box">{error}</div>}
|
{error && <div className="error-box">{error}</div>}
|
||||||
{invalidIndexParam || invalidIndex ? (
|
{invalidIndexParam || invalidIndex ? (
|
||||||
<div className="hint">Invalid provider index.</div>
|
<div className="hint">{t('common.invalid_provider_index')}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
@@ -245,6 +245,8 @@ export function AiProvidersClaudeEditPage() {
|
|||||||
addLabel={t('common.custom_headers_add')}
|
addLabel={t('common.custom_headers_add')}
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||||
|
removeButtonTitle={t('common.delete')}
|
||||||
|
removeButtonAriaLabel={t('common.delete')}
|
||||||
disabled={disableControls || saving}
|
disabled={disableControls || saving}
|
||||||
/>
|
/>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -255,6 +257,8 @@ export function AiProvidersClaudeEditPage() {
|
|||||||
addLabel={t('ai_providers.claude_models_add_btn')}
|
addLabel={t('ai_providers.claude_models_add_btn')}
|
||||||
namePlaceholder={t('common.model_name_placeholder')}
|
namePlaceholder={t('common.model_name_placeholder')}
|
||||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||||
|
removeButtonTitle={t('common.delete')}
|
||||||
|
removeButtonAriaLabel={t('common.delete')}
|
||||||
disabled={disableControls || saving}
|
disabled={disableControls || saving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function AiProvidersCodexEditPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
{error && <div className="error-box">{error}</div>}
|
{error && <div className="error-box">{error}</div>}
|
||||||
{invalidIndexParam || invalidIndex ? (
|
{invalidIndexParam || invalidIndex ? (
|
||||||
<div className="hint">Invalid provider index.</div>
|
<div className="hint">{t('common.invalid_provider_index')}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
@@ -245,6 +245,8 @@ export function AiProvidersCodexEditPage() {
|
|||||||
addLabel={t('common.custom_headers_add')}
|
addLabel={t('common.custom_headers_add')}
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||||
|
removeButtonTitle={t('common.delete')}
|
||||||
|
removeButtonAriaLabel={t('common.delete')}
|
||||||
disabled={disableControls || saving}
|
disabled={disableControls || saving}
|
||||||
/>
|
/>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export function AiProvidersGeminiEditPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
{error && <div className="error-box">{error}</div>}
|
{error && <div className="error-box">{error}</div>}
|
||||||
{invalidIndexParam || invalidIndex ? (
|
{invalidIndexParam || invalidIndex ? (
|
||||||
<div className="hint">Invalid provider index.</div>
|
<div className="hint">{t('common.invalid_provider_index')}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
@@ -224,6 +224,8 @@ export function AiProvidersGeminiEditPage() {
|
|||||||
addLabel={t('common.custom_headers_add')}
|
addLabel={t('common.custom_headers_add')}
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||||
|
removeButtonTitle={t('common.delete')}
|
||||||
|
removeButtonAriaLabel={t('common.delete')}
|
||||||
disabled={disableControls || saving}
|
disabled={disableControls || saving}
|
||||||
/>
|
/>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { ModelInfo } from '@/utils/models';
|
|||||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||||
import { buildApiKeyEntry } from '@/components/providers/utils';
|
import { buildApiKeyEntry } from '@/components/providers/utils';
|
||||||
import type { ModelEntry, OpenAIFormState } from '@/components/providers/types';
|
import type { ModelEntry, OpenAIFormState } from '@/components/providers/types';
|
||||||
|
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
|
||||||
|
|
||||||
type LocationState = { fromAiProviders?: boolean } | null;
|
type LocationState = { fromAiProviders?: boolean } | null;
|
||||||
|
|
||||||
@@ -29,6 +30,9 @@ export type OpenAIEditOutletContext = {
|
|||||||
setTestStatus: Dispatch<SetStateAction<'idle' | 'loading' | 'success' | 'error'>>;
|
setTestStatus: Dispatch<SetStateAction<'idle' | 'loading' | 'success' | 'error'>>;
|
||||||
testMessage: string;
|
testMessage: string;
|
||||||
setTestMessage: Dispatch<SetStateAction<string>>;
|
setTestMessage: Dispatch<SetStateAction<string>>;
|
||||||
|
keyTestStatuses: KeyTestStatus[];
|
||||||
|
setDraftKeyTestStatus: (keyIndex: number, status: KeyTestStatus) => void;
|
||||||
|
resetDraftKeyTestStatuses: (count: number) => void;
|
||||||
availableModels: string[];
|
availableModels: string[];
|
||||||
handleBack: () => void;
|
handleBack: () => void;
|
||||||
handleSave: () => Promise<void>;
|
handleSave: () => Promise<void>;
|
||||||
@@ -99,11 +103,14 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel);
|
const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel);
|
||||||
const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus);
|
const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus);
|
||||||
const setDraftTestMessage = useOpenAIEditDraftStore((state) => state.setDraftTestMessage);
|
const setDraftTestMessage = useOpenAIEditDraftStore((state) => state.setDraftTestMessage);
|
||||||
|
const setDraftKeyTestStatus = useOpenAIEditDraftStore((state) => state.setDraftKeyTestStatus);
|
||||||
|
const resetDraftKeyTestStatuses = useOpenAIEditDraftStore((state) => state.resetDraftKeyTestStatuses);
|
||||||
|
|
||||||
const form = draft?.form ?? buildEmptyForm();
|
const form = draft?.form ?? buildEmptyForm();
|
||||||
const testModel = draft?.testModel ?? '';
|
const testModel = draft?.testModel ?? '';
|
||||||
const testStatus = draft?.testStatus ?? 'idle';
|
const testStatus = draft?.testStatus ?? 'idle';
|
||||||
const testMessage = draft?.testMessage ?? '';
|
const testMessage = draft?.testMessage ?? '';
|
||||||
|
const keyTestStatuses = draft?.keyTestStatuses ?? [];
|
||||||
|
|
||||||
const setForm: Dispatch<SetStateAction<OpenAIFormState>> = useCallback(
|
const setForm: Dispatch<SetStateAction<OpenAIFormState>> = useCallback(
|
||||||
(action) => {
|
(action) => {
|
||||||
@@ -134,6 +141,20 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
[draftKey, setDraftTestMessage]
|
[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(() => {
|
const initialData = useMemo(() => {
|
||||||
if (editIndex === null) return undefined;
|
if (editIndex === null) return undefined;
|
||||||
return providers[editIndex];
|
return providers[editIndex];
|
||||||
@@ -215,6 +236,7 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
testModel: initialTestModel,
|
testModel: initialTestModel,
|
||||||
testStatus: 'idle',
|
testStatus: 'idle',
|
||||||
testMessage: '',
|
testMessage: '',
|
||||||
|
keyTestStatuses: [],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
initDraft(draftKey, {
|
initDraft(draftKey, {
|
||||||
@@ -222,6 +244,7 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
testModel: '',
|
testModel: '',
|
||||||
testStatus: 'idle',
|
testStatus: 'idle',
|
||||||
testMessage: '',
|
testMessage: '',
|
||||||
|
keyTestStatuses: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [draft?.initialized, draftKey, initDraft, initialData, loading]);
|
}, [draft?.initialized, draftKey, initDraft, initialData, loading]);
|
||||||
@@ -359,6 +382,9 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
setTestStatus,
|
setTestStatus,
|
||||||
testMessage,
|
testMessage,
|
||||||
setTestMessage,
|
setTestMessage,
|
||||||
|
keyTestStatuses,
|
||||||
|
setDraftKeyTestStatus: handleSetDraftKeyTestStatus,
|
||||||
|
resetDraftKeyTestStatuses: handleResetDraftKeyTestStatuses,
|
||||||
availableModels,
|
availableModels,
|
||||||
handleBack,
|
handleBack,
|
||||||
handleSave,
|
handleSave,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useCallback } from 'react';
|
||||||
import { useNavigate, useOutletContext } from 'react-router-dom';
|
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
@@ -14,6 +14,7 @@ import type { ApiKeyEntry } from '@/types';
|
|||||||
import { buildHeaderObject } from '@/utils/headers';
|
import { buildHeaderObject } from '@/utils/headers';
|
||||||
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '@/components/providers/utils';
|
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '@/components/providers/utils';
|
||||||
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
|
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
|
||||||
|
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
|
||||||
import styles from './AiProvidersPage.module.scss';
|
import styles from './AiProvidersPage.module.scss';
|
||||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||||
|
|
||||||
@@ -25,6 +26,72 @@ const getErrorMessage = (err: unknown) => {
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Status icon components
|
||||||
|
function StatusLoadingIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className={styles.statusIconSpin}>
|
||||||
|
<circle cx="8" cy="8" r="7" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
|
||||||
|
<path
|
||||||
|
d="M8 1A7 7 0 0 1 8 15"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusSuccessIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<circle cx="8" cy="8" r="8" fill="var(--success-color, #22c55e)" />
|
||||||
|
<path
|
||||||
|
d="M4.5 8L7 10.5L11.5 6"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusErrorIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<circle cx="8" cy="8" r="8" fill="var(--danger-color, #ef4444)" />
|
||||||
|
<path
|
||||||
|
d="M5 5L11 11M11 5L5 11"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusIdleIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<circle cx="8" cy="8" r="7" stroke="var(--text-tertiary, #9ca3af)" strokeWidth="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusIcon({ status }: { status: KeyTestStatus['status'] }) {
|
||||||
|
switch (status) {
|
||||||
|
case 'loading':
|
||||||
|
return <StatusLoadingIcon />;
|
||||||
|
case 'success':
|
||||||
|
return <StatusSuccessIcon />;
|
||||||
|
case 'error':
|
||||||
|
return <StatusErrorIcon />;
|
||||||
|
default:
|
||||||
|
return <StatusIdleIcon />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AiProvidersOpenAIEditPage() {
|
export function AiProvidersOpenAIEditPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -44,6 +111,9 @@ export function AiProvidersOpenAIEditPage() {
|
|||||||
setTestStatus,
|
setTestStatus,
|
||||||
testMessage,
|
testMessage,
|
||||||
setTestMessage,
|
setTestMessage,
|
||||||
|
keyTestStatuses,
|
||||||
|
setDraftKeyTestStatus,
|
||||||
|
resetDraftKeyTestStatuses,
|
||||||
availableModels,
|
availableModels,
|
||||||
handleBack,
|
handleBack,
|
||||||
handleSave,
|
handleSave,
|
||||||
@@ -66,6 +136,144 @@ export function AiProvidersOpenAIEditPage() {
|
|||||||
}, [handleBack]);
|
}, [handleBack]);
|
||||||
|
|
||||||
const canSave = !disableControls && !loading && !saving && !invalidIndexParam && !invalidIndex;
|
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<boolean> => {
|
||||||
|
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<string, string> = {
|
||||||
|
'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 renderKeyEntries = (entries: ApiKeyEntry[]) => {
|
||||||
const list = entries.length ? entries : [buildApiKeyEntry()];
|
const list = entries.length ? entries : [buildApiKeyEntry()];
|
||||||
@@ -88,145 +296,101 @@ export function AiProvidersOpenAIEditPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className={styles.keyEntriesList}>
|
||||||
{list.map((entry, index) => (
|
<div className={styles.keyEntriesToolbar}>
|
||||||
<div key={index} className="item-row">
|
<span className={styles.keyEntriesCount}>
|
||||||
<div className="item-meta">
|
{t('ai_providers.openai_keys_count')}: {list.length}
|
||||||
<Input
|
</span>
|
||||||
label={`${t('common.api_key')} #${index + 1}`}
|
<Button
|
||||||
value={entry.apiKey}
|
variant="secondary"
|
||||||
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
|
size="sm"
|
||||||
disabled={saving || disableControls}
|
onClick={addEntry}
|
||||||
/>
|
disabled={saving || disableControls}
|
||||||
<Input
|
className={styles.addKeyButton}
|
||||||
label={t('common.proxy_url')}
|
>
|
||||||
value={entry.proxyUrl ?? ''}
|
{t('ai_providers.openai_keys_add_btn')}
|
||||||
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
|
</Button>
|
||||||
disabled={saving || disableControls}
|
</div>
|
||||||
/>
|
<div className={styles.keyTableShell}>
|
||||||
</div>
|
{/* 表头 */}
|
||||||
<div className="item-actions">
|
<div className={styles.keyTableHeader}>
|
||||||
<Button
|
<div className={styles.keyTableColIndex}>#</div>
|
||||||
variant="ghost"
|
<div className={styles.keyTableColStatus}>{t('common.status')}</div>
|
||||||
size="sm"
|
<div className={styles.keyTableColKey}>{t('common.api_key')}</div>
|
||||||
onClick={() => removeEntry(index)}
|
<div className={styles.keyTableColProxy}>{t('common.proxy_url')}</div>
|
||||||
disabled={saving || disableControls || list.length <= 1}
|
<div className={styles.keyTableColAction}>{t('common.action')}</div>
|
||||||
>
|
|
||||||
{t('common.delete')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
<Button
|
{/* 数据行 */}
|
||||||
variant="secondary"
|
{list.map((entry, index) => {
|
||||||
size="sm"
|
const keyStatus = keyTestStatuses[index]?.status ?? 'idle';
|
||||||
onClick={addEntry}
|
const canTestKey = Boolean(entry.apiKey?.trim()) && hasConfiguredModels;
|
||||||
disabled={saving || disableControls}
|
|
||||||
>
|
return (
|
||||||
{t('ai_providers.openai_keys_add_btn')}
|
<div key={index} className={styles.keyTableRow}>
|
||||||
</Button>
|
{/* 序号 */}
|
||||||
|
<div className={styles.keyTableColIndex}>{index + 1}</div>
|
||||||
|
|
||||||
|
{/* 状态指示灯 */}
|
||||||
|
<div
|
||||||
|
className={styles.keyTableColStatus}
|
||||||
|
title={keyTestStatuses[index]?.message || ''}
|
||||||
|
>
|
||||||
|
<StatusIcon status={keyStatus} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key 输入框 */}
|
||||||
|
<div className={styles.keyTableColKey}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={entry.apiKey}
|
||||||
|
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
|
||||||
|
disabled={saving || disableControls}
|
||||||
|
className={`input ${styles.keyTableInput}`}
|
||||||
|
placeholder={t('ai_providers.openai_key_placeholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Proxy 输入框 */}
|
||||||
|
<div className={styles.keyTableColProxy}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={entry.proxyUrl ?? ''}
|
||||||
|
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
|
||||||
|
disabled={saving || disableControls}
|
||||||
|
className={`input ${styles.keyTableInput}`}
|
||||||
|
placeholder={t('ai_providers.openai_proxy_placeholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className={styles.keyTableColAction}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void testSingleKey(index)}
|
||||||
|
disabled={saving || disableControls || !canTestKey}
|
||||||
|
loading={keyStatus === 'loading'}
|
||||||
|
>
|
||||||
|
{t('ai_providers.openai_test_single_action')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeEntry(index)}
|
||||||
|
disabled={saving || disableControls || list.length <= 1}
|
||||||
|
>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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<string, string> = {
|
|
||||||
'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 (
|
return (
|
||||||
<SecondaryScreenShell
|
<SecondaryScreenShell
|
||||||
ref={swipeRef}
|
ref={swipeRef}
|
||||||
@@ -245,7 +409,7 @@ export function AiProvidersOpenAIEditPage() {
|
|||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
{invalidIndexParam || invalidIndex ? (
|
{invalidIndexParam || invalidIndex ? (
|
||||||
<div className="hint">Invalid provider index.</div>
|
<div className="hint">{t('common.invalid_provider_index')}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
@@ -275,77 +439,109 @@ export function AiProvidersOpenAIEditPage() {
|
|||||||
addLabel={t('common.custom_headers_add')}
|
addLabel={t('common.custom_headers_add')}
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||||
|
removeButtonTitle={t('common.delete')}
|
||||||
|
removeButtonAriaLabel={t('common.delete')}
|
||||||
disabled={saving || disableControls}
|
disabled={saving || disableControls}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="form-group">
|
{/* 模型配置区域 - 统一布局 */}
|
||||||
<label>
|
<div className={styles.modelConfigSection}>
|
||||||
{hasIndexParam
|
{/* 标题行 */}
|
||||||
? t('ai_providers.openai_edit_modal_models_label')
|
<div className={styles.modelConfigHeader}>
|
||||||
: t('ai_providers.openai_add_modal_models_label')}
|
<label className={styles.modelConfigTitle}>
|
||||||
</label>
|
{hasIndexParam
|
||||||
|
? t('ai_providers.openai_edit_modal_models_label')
|
||||||
|
: t('ai_providers.openai_add_modal_models_label')}
|
||||||
|
</label>
|
||||||
|
<div className={styles.modelConfigToolbar}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
modelEntries: [...prev.modelEntries, { name: '', alias: '' }]
|
||||||
|
}))}
|
||||||
|
disabled={saving || disableControls}
|
||||||
|
>
|
||||||
|
{t('ai_providers.openai_models_add_btn')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={openOpenaiModelDiscovery}
|
||||||
|
disabled={saving || disableControls}
|
||||||
|
>
|
||||||
|
{t('ai_providers.openai_models_fetch_button')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提示文本 */}
|
||||||
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
|
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
|
||||||
|
|
||||||
|
{/* 模型列表 */}
|
||||||
<ModelInputList
|
<ModelInputList
|
||||||
entries={form.modelEntries}
|
entries={form.modelEntries}
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
||||||
addLabel={t('ai_providers.openai_models_add_btn')}
|
|
||||||
namePlaceholder={t('common.model_name_placeholder')}
|
namePlaceholder={t('common.model_name_placeholder')}
|
||||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||||
disabled={saving || disableControls}
|
disabled={saving || disableControls}
|
||||||
|
hideAddButton
|
||||||
|
className={styles.modelInputList}
|
||||||
|
rowClassName={styles.modelInputRow}
|
||||||
|
inputClassName={styles.modelInputField}
|
||||||
|
removeButtonClassName={styles.modelRowRemoveButton}
|
||||||
|
removeButtonTitle={t('common.delete')}
|
||||||
|
removeButtonAriaLabel={t('common.delete')}
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={openOpenaiModelDiscovery}
|
|
||||||
disabled={saving || disableControls}
|
|
||||||
>
|
|
||||||
{t('ai_providers.openai_models_fetch_button')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
{/* 测试区域 */}
|
||||||
<label>{t('ai_providers.openai_test_title')}</label>
|
<div className={styles.modelTestPanel}>
|
||||||
<div className="hint">{t('ai_providers.openai_test_hint')}</div>
|
<div className={styles.modelTestMeta}>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
<label className={styles.modelTestLabel}>{t('ai_providers.openai_test_title')}</label>
|
||||||
<select
|
<span className={styles.modelTestHint}>{t('ai_providers.openai_test_hint')}</span>
|
||||||
className={`input ${styles.openaiTestSelect}`}
|
</div>
|
||||||
value={testModel}
|
<div className={styles.modelTestControls}>
|
||||||
onChange={(e) => {
|
<select
|
||||||
setTestModel(e.target.value);
|
className={`input ${styles.openaiTestSelect}`}
|
||||||
setTestStatus('idle');
|
value={testModel}
|
||||||
setTestMessage('');
|
onChange={(e) => {
|
||||||
}}
|
setTestModel(e.target.value);
|
||||||
disabled={saving || disableControls || availableModels.length === 0}
|
setTestStatus('idle');
|
||||||
>
|
setTestMessage('');
|
||||||
<option value="">
|
}}
|
||||||
{availableModels.length
|
disabled={saving || disableControls || availableModels.length === 0}
|
||||||
? t('ai_providers.openai_test_select_placeholder')
|
>
|
||||||
: t('ai_providers.openai_test_select_empty')}
|
<option value="">
|
||||||
</option>
|
{availableModels.length
|
||||||
{form.modelEntries
|
? t('ai_providers.openai_test_select_placeholder')
|
||||||
.filter((entry) => entry.name.trim())
|
: t('ai_providers.openai_test_select_empty')}
|
||||||
.map((entry, idx) => {
|
</option>
|
||||||
const name = entry.name.trim();
|
{form.modelEntries
|
||||||
const alias = entry.alias.trim();
|
.filter((entry) => entry.name.trim())
|
||||||
const label = alias && alias !== name ? `${name} (${alias})` : name;
|
.map((entry, idx) => {
|
||||||
return (
|
const name = entry.name.trim();
|
||||||
<option key={`${name}-${idx}`} value={name}>
|
const alias = entry.alias.trim();
|
||||||
{label}
|
const label = alias && alias !== name ? `${name} (${alias})` : name;
|
||||||
</option>
|
return (
|
||||||
);
|
<option key={`${name}-${idx}`} value={name}>
|
||||||
})}
|
{label}
|
||||||
</select>
|
</option>
|
||||||
<Button
|
);
|
||||||
variant={testStatus === 'error' ? 'danger' : 'secondary'}
|
})}
|
||||||
className={`${styles.openaiTestButton} ${
|
</select>
|
||||||
testStatus === 'success' ? styles.openaiTestButtonSuccess : ''
|
<Button
|
||||||
}`}
|
variant={testStatus === 'error' ? 'danger' : 'secondary'}
|
||||||
onClick={() => void testOpenaiProviderConnection()}
|
size="sm"
|
||||||
loading={testStatus === 'loading'}
|
onClick={() => void testAllKeys()}
|
||||||
disabled={saving || disableControls || availableModels.length === 0}
|
loading={testStatus === 'loading'}
|
||||||
>
|
disabled={saving || disableControls || !hasConfiguredModels || !hasTestableKeys}
|
||||||
{t('ai_providers.openai_test_action')}
|
title={t('ai_providers.openai_test_all_hint')}
|
||||||
</Button>
|
className={styles.modelTestAllButton}
|
||||||
|
>
|
||||||
|
{t('ai_providers.openai_test_all_action')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{testMessage && (
|
{testMessage && (
|
||||||
<div
|
<div
|
||||||
@@ -362,8 +558,11 @@ export function AiProvidersOpenAIEditPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className={`form-group ${styles.keyEntriesSection}`}>
|
||||||
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
|
<div className={styles.keyEntriesHeader}>
|
||||||
|
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
|
||||||
|
<span className={styles.keyEntriesHint}>{t('ai_providers.openai_keys_hint')}</span>
|
||||||
|
</div>
|
||||||
{renderKeyEntries(form.apiKeyEntries)}
|
{renderKeyEntries(form.apiKeyEntries)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 {
|
.statusBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -473,6 +460,312 @@
|
|||||||
background: var(--failure-badge-bg, #fee2e2);
|
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']) {
|
:global([data-theme='dark']) {
|
||||||
.headerBadge {
|
.headerBadge {
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ export function AiProvidersVertexEditPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
{error && <div className="error-box">{error}</div>}
|
{error && <div className="error-box">{error}</div>}
|
||||||
{invalidIndexParam || invalidIndex ? (
|
{invalidIndexParam || invalidIndex ? (
|
||||||
<div className="hint">Invalid provider index.</div>
|
<div className="hint">{t('common.invalid_provider_index')}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
@@ -256,6 +256,8 @@ export function AiProvidersVertexEditPage() {
|
|||||||
addLabel={t('common.custom_headers_add')}
|
addLabel={t('common.custom_headers_add')}
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||||
|
removeButtonTitle={t('common.delete')}
|
||||||
|
removeButtonAriaLabel={t('common.delete')}
|
||||||
disabled={disableControls || saving}
|
disabled={disableControls || saving}
|
||||||
/>
|
/>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -266,6 +268,8 @@ export function AiProvidersVertexEditPage() {
|
|||||||
addLabel={t('ai_providers.vertex_models_add_btn')}
|
addLabel={t('ai_providers.vertex_models_add_btn')}
|
||||||
namePlaceholder={t('common.model_name_placeholder')}
|
namePlaceholder={t('common.model_name_placeholder')}
|
||||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||||
|
removeButtonTitle={t('common.delete')}
|
||||||
|
removeButtonAriaLabel={t('common.delete')}
|
||||||
disabled={disableControls || saving}
|
disabled={disableControls || saving}
|
||||||
/>
|
/>
|
||||||
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
|
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
|
||||||
|
|||||||
@@ -15,12 +15,18 @@ import { buildApiKeyEntry } from '@/components/providers/utils';
|
|||||||
|
|
||||||
export type OpenAITestStatus = 'idle' | 'loading' | 'success' | 'error';
|
export type OpenAITestStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
export type KeyTestStatus = {
|
||||||
|
status: OpenAITestStatus;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type OpenAIEditDraft = {
|
export type OpenAIEditDraft = {
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
form: OpenAIFormState;
|
form: OpenAIFormState;
|
||||||
testModel: string;
|
testModel: string;
|
||||||
testStatus: OpenAITestStatus;
|
testStatus: OpenAITestStatus;
|
||||||
testMessage: string;
|
testMessage: string;
|
||||||
|
keyTestStatuses: KeyTestStatus[];
|
||||||
};
|
};
|
||||||
|
|
||||||
interface OpenAIEditDraftState {
|
interface OpenAIEditDraftState {
|
||||||
@@ -31,6 +37,8 @@ interface OpenAIEditDraftState {
|
|||||||
setDraftTestModel: (key: string, action: SetStateAction<string>) => void;
|
setDraftTestModel: (key: string, action: SetStateAction<string>) => void;
|
||||||
setDraftTestStatus: (key: string, action: SetStateAction<OpenAITestStatus>) => void;
|
setDraftTestStatus: (key: string, action: SetStateAction<OpenAITestStatus>) => void;
|
||||||
setDraftTestMessage: (key: string, action: SetStateAction<string>) => void;
|
setDraftTestMessage: (key: string, action: SetStateAction<string>) => void;
|
||||||
|
setDraftKeyTestStatus: (draftKey: string, keyIndex: number, status: KeyTestStatus) => void;
|
||||||
|
resetDraftKeyTestStatuses: (draftKey: string, count: number) => void;
|
||||||
clearDraft: (key: string) => void;
|
clearDraft: (key: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +61,7 @@ const buildEmptyDraft = (): OpenAIEditDraft => ({
|
|||||||
testModel: '',
|
testModel: '',
|
||||||
testStatus: 'idle',
|
testStatus: 'idle',
|
||||||
testMessage: '',
|
testMessage: '',
|
||||||
|
keyTestStatuses: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) => ({
|
export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) => ({
|
||||||
@@ -135,6 +144,38 @@ export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((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) => {
|
clearDraft: (key) => {
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
|||||||
@@ -581,15 +581,16 @@ textarea {
|
|||||||
padding: $spacing-md;
|
padding: $spacing-md;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: $spacing-md;
|
gap: $spacing-md;
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
.item-meta {
|
.item-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: $spacing-sm;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-title {
|
.item-title {
|
||||||
|
|||||||
Reference in New Issue
Block a user