import { useEffect, useCallback, useMemo, useRef, useState } from 'react'; import { useNavigate, useOutletContext } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; import { Card } from '@/components/ui/Card'; import { HeaderInputList } from '@/components/ui/HeaderInputList'; import { Input } from '@/components/ui/Input'; import { ModelInputList } from '@/components/ui/ModelInputList'; import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell'; import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack'; import { useNotificationStore } from '@/stores'; import { apiCallApi, getApiCallErrorMessage } from '@/services/api'; 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'; const OPENAI_TEST_TIMEOUT_MS = 30_000; const getErrorMessage = (err: unknown) => { if (err instanceof Error) return err.message; if (typeof err === 'string') return err; 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(); const { showNotification } = useNotificationStore(); const { hasIndexParam, invalidIndexParam, invalidIndex, disableControls, loading, saving, form, setForm, testModel, setTestModel, testStatus, setTestStatus, testMessage, setTestMessage, keyTestStatuses, setDraftKeyTestStatus, resetDraftKeyTestStatuses, availableModels, handleBack, handleSave, } = useOutletContext(); const title = hasIndexParam ? t('ai_providers.openai_edit_modal_title') : t('ai_providers.openai_add_modal_title'); const swipeRef = useEdgeSwipeBack({ onBack: handleBack }); const [isTestingKeys, setIsTestingKeys] = useState(false); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { handleBack(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [handleBack]); const canSave = !disableControls && !loading && !saving && !invalidIndexParam && !invalidIndex && !isTestingKeys; const hasConfiguredModels = form.modelEntries.some((entry) => entry.name.trim()); const hasTestableKeys = form.apiKeyEntries.some((entry) => entry.apiKey?.trim()); const connectivityConfigSignature = useMemo(() => { const headersSignature = form.headers .map((entry) => `${entry.key.trim()}:${entry.value.trim()}`) .join('|'); const modelsSignature = form.modelEntries .map((entry) => `${entry.name.trim()}:${entry.alias.trim()}`) .join('|'); return [form.baseUrl.trim(), testModel.trim(), headersSignature, modelsSignature].join('||'); }, [form.baseUrl, form.headers, form.modelEntries, testModel]); const previousConnectivityConfigRef = useRef(connectivityConfigSignature); useEffect(() => { if (previousConnectivityConfigRef.current === connectivityConfigSignature) { return; } previousConnectivityConfigRef.current = connectivityConfigSignature; resetDraftKeyTestStatuses(form.apiKeyEntries.length); setTestStatus('idle'); setTestMessage(''); }, [ connectivityConfigSignature, form.apiKeyEntries.length, resetDraftKeyTestStatuses, setTestStatus, setTestMessage, ]); // Test a single key by index const runSingleKeyTest = 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] ); const testSingleKey = useCallback( async (keyIndex: number): Promise => { if (isTestingKeys) return false; setIsTestingKeys(true); try { return await runSingleKeyTest(keyIndex); } finally { setIsTestingKeys(false); } }, [isTestingKeys, runSingleKeyTest] ); // Test all keys const testAllKeys = useCallback(async () => { if (isTestingKeys) return; 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 modelName = testModel.trim() || availableModels[0] || ''; if (!modelName) { const message = t('notification.openai_test_model_required'); setTestStatus('error'); setTestMessage(message); showNotification(message, 'error'); return; } const validKeyIndexes = form.apiKeyEntries .map((entry, index) => (entry.apiKey?.trim() ? index : -1)) .filter((index) => index >= 0); if (validKeyIndexes.length === 0) { const message = t('notification.openai_test_key_required'); setTestStatus('error'); setTestMessage(message); showNotification(message, 'error'); return; } setIsTestingKeys(true); setTestStatus('loading'); setTestMessage(t('ai_providers.openai_test_running')); resetDraftKeyTestStatuses(form.apiKeyEntries.length); try { const results = await Promise.all(validKeyIndexes.map((index) => runSingleKeyTest(index))); const successCount = results.filter(Boolean).length; const failCount = validKeyIndexes.length - successCount; if (failCount === 0) { const message = t('ai_providers.openai_test_all_success', { count: successCount }); setTestStatus('success'); setTestMessage(message); showNotification(message, 'success'); } else if (successCount === 0) { const message = t('ai_providers.openai_test_all_failed', { count: failCount }); setTestStatus('error'); setTestMessage(message); showNotification(message, 'error'); } else { const message = t('ai_providers.openai_test_all_partial', { success: successCount, failed: failCount }); setTestStatus('error'); setTestMessage(message); showNotification(message, 'warning'); } } finally { setIsTestingKeys(false); } }, [ isTestingKeys, form.baseUrl, form.apiKeyEntries, testModel, availableModels, t, setTestStatus, setTestMessage, resetDraftKeyTestStatuses, runSingleKeyTest, 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()]; const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => { const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry)); setForm((prev) => ({ ...prev, apiKeyEntries: next })); setDraftKeyTestStatus(idx, { status: 'idle', message: '' }); setTestStatus('idle'); setTestMessage(''); }; const removeEntry = (idx: number) => { const next = list.filter((_, i) => i !== idx); const nextLength = next.length ? next.length : 1; setForm((prev) => ({ ...prev, apiKeyEntries: next.length ? next : [buildApiKeyEntry()], })); resetDraftKeyTestStatuses(nextLength); setTestStatus('idle'); setTestMessage(''); }; const addEntry = () => { setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] })); resetDraftKeyTestStatuses(list.length + 1); setTestStatus('idle'); setTestMessage(''); }; return (
{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 || isTestingKeys} className={`input ${styles.keyTableInput}`} placeholder={t('ai_providers.openai_key_placeholder')} />
{/* Proxy 输入框 */}
updateEntry(index, 'proxyUrl', e.target.value)} disabled={saving || disableControls || isTestingKeys} className={`input ${styles.keyTableInput}`} placeholder={t('ai_providers.openai_proxy_placeholder')} />
{/* 操作按钮 */}
); })}
); }; return ( void handleSave()} loading={saving} disabled={!canSave}> {t('common.save')} } isLoading={loading} loadingLabel={t('common.loading')} > {invalidIndexParam || invalidIndex ? (
{t('common.invalid_provider_index')}
) : ( <> setForm((prev) => ({ ...prev, name: e.target.value }))} disabled={saving || disableControls || isTestingKeys} /> setForm((prev) => ({ ...prev, prefix: e.target.value }))} hint={t('ai_providers.prefix_hint')} disabled={saving || disableControls || isTestingKeys} /> setForm((prev) => ({ ...prev, baseUrl: e.target.value }))} disabled={saving || disableControls || isTestingKeys} /> setForm((prev) => ({ ...prev, headers: entries }))} addLabel={t('common.custom_headers_add')} keyPlaceholder={t('common.custom_headers_key_placeholder')} valuePlaceholder={t('common.custom_headers_value_placeholder')} removeButtonTitle={t('common.delete')} removeButtonAriaLabel={t('common.delete')} disabled={saving || disableControls || isTestingKeys} /> {/* 模型配置区域 - 统一布局 */}
{/* 标题行 */}
{/* 提示文本 */}
{t('ai_providers.openai_models_hint')}
{/* 模型列表 */} setForm((prev) => ({ ...prev, modelEntries: entries }))} namePlaceholder={t('common.model_name_placeholder')} aliasPlaceholder={t('common.model_alias_placeholder')} disabled={saving || disableControls || isTestingKeys} 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')}
{testMessage && (
{testMessage}
)}
{t('ai_providers.openai_keys_hint')}
{renderKeyEntries(form.apiKeyEntries)}
)}
); }