import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; import { HeaderInputList } from '@/components/ui/HeaderInputList'; import { Input } from '@/components/ui/Input'; import { Modal } from '@/components/ui/Modal'; import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList'; import { useNotificationStore } from '@/stores'; import { apiCallApi, getApiCallErrorMessage } from '@/services/api'; import type { OpenAIProviderConfig, ApiKeyEntry } from '@/types'; import { buildHeaderObject, headersToEntries } from '@/utils/headers'; import type { ModelInfo } from '@/utils/models'; import styles from '@/pages/AiProvidersPage.module.scss'; import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '../utils'; import type { ModelEntry, OpenAIFormState, ProviderModalProps } from '../types'; import { OpenAIDiscoveryModal } from './OpenAIDiscoveryModal'; const OPENAI_TEST_TIMEOUT_MS = 30_000; interface OpenAIModalProps extends ProviderModalProps { isSaving: boolean; } const buildEmptyForm = (): OpenAIFormState => ({ name: '', prefix: '', baseUrl: '', headers: [], apiKeyEntries: [buildApiKeyEntry()], modelEntries: [{ name: '', alias: '' }], testModel: undefined, }); export function OpenAIModal({ isOpen, editIndex, initialData, onClose, onSave, isSaving, }: OpenAIModalProps) { const { t } = useTranslation(); const { showNotification } = useNotificationStore(); const [form, setForm] = useState(buildEmptyForm); const [discoveryOpen, setDiscoveryOpen] = useState(false); const [testModel, setTestModel] = useState(''); const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); const [testMessage, setTestMessage] = useState(''); const getErrorMessage = (err: unknown) => { if (err instanceof Error) return err.message; if (typeof err === 'string') return err; return ''; }; const availableModels = useMemo( () => form.modelEntries.map((entry) => entry.name.trim()).filter(Boolean), [form.modelEntries] ); useEffect(() => { if (!isOpen) { setDiscoveryOpen(false); return; } if (initialData) { const modelEntries = modelsToEntries(initialData.models); setForm({ name: initialData.name, prefix: initialData.prefix ?? '', baseUrl: initialData.baseUrl, headers: headersToEntries(initialData.headers), testModel: initialData.testModel, modelEntries, apiKeyEntries: initialData.apiKeyEntries?.length ? initialData.apiKeyEntries : [buildApiKeyEntry()], }); const available = modelEntries.map((entry) => entry.name.trim()).filter(Boolean); const initialModel = initialData.testModel && available.includes(initialData.testModel) ? initialData.testModel : available[0] || ''; setTestModel(initialModel); } else { setForm(buildEmptyForm()); setTestModel(''); } setTestStatus('idle'); setTestMessage(''); setDiscoveryOpen(false); }, [initialData, isOpen]); useEffect(() => { if (!isOpen) return; if (availableModels.length === 0) { if (testModel) { setTestModel(''); setTestStatus('idle'); setTestMessage(''); } return; } if (!testModel || !availableModels.includes(testModel)) { setTestModel(availableModels[0]); setTestStatus('idle'); setTestMessage(''); } }, [availableModels, isOpen, testModel]); 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 })); }; const removeEntry = (idx: number) => { const next = list.filter((_, i) => i !== idx); setForm((prev) => ({ ...prev, apiKeyEntries: next.length ? next : [buildApiKeyEntry()], })); }; const addEntry = () => { setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] })); }; return (
{list.map((entry, index) => (
updateEntry(index, 'apiKey', e.target.value)} /> updateEntry(index, 'proxyUrl', e.target.value)} />
))}
); }; const openOpenaiModelDiscovery = () => { const baseUrl = form.baseUrl.trim(); if (!baseUrl) { showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error'); return; } setDiscoveryOpen(true); }; const applyOpenaiModelDiscoverySelection = (selectedModels: ModelInfo[]) => { if (!selectedModels.length) { setDiscoveryOpen(false); return; } const mergedMap = new Map(); form.modelEntries.forEach((entry) => { const name = entry.name.trim(); if (!name) return; mergedMap.set(name, { name, alias: entry.alias?.trim() || '' }); }); let addedCount = 0; selectedModels.forEach((model) => { const name = model.name.trim(); if (!name || mergedMap.has(name)) return; mergedMap.set(name, { name, alias: model.alias ?? '' }); addedCount += 1; }); const mergedEntries = Array.from(mergedMap.values()); setForm((prev) => ({ ...prev, modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }], })); setDiscoveryOpen(false); if (addedCount > 0) { showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success'); } }; 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 ( <> } > setForm((prev) => ({ ...prev, name: e.target.value }))} /> setForm((prev) => ({ ...prev, prefix: e.target.value }))} hint={t('ai_providers.prefix_hint')} /> setForm((prev) => ({ ...prev, baseUrl: e.target.value }))} /> 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')} />
{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={isSaving} />
{t('ai_providers.openai_test_hint')}
{testMessage && (
{testMessage}
)}
{renderKeyEntries(form.apiKeyEntries)}
setDiscoveryOpen(false)} onApply={applyOpenaiModelDiscoverySelection} /> ); }