import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { AIProvider, AIProviderType, CustomApiConfig, SettingsConfig } from '../types/api'; import { loadSettings, saveSettings } from '../lib/settings'; import { GOOGLE_OPENAI_COMPAT_URL, OPENAI_DEFAULT_URL } from '../lib/ai-providers'; import { FloatingInput } from './settings-modal/FloatingInput'; import type { TestResult } from './settings-modal/types'; import { TestResultBanner } from './settings-modal/test-result-banner'; import { useI18n } from '../i18n'; import { useModalTransition } from '../hooks/useModalTransition'; interface ProviderConfigModalProps { isOpen: boolean; onClose: () => void; onSave: (config: SettingsConfig) => void; } type ProviderMetadataJson = { url?: string; apiUrl?: string; key?: string; apiKey?: string; model?: string; }; function createProviderId(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } return `provider_${Date.now()}_${Math.random().toString(16).slice(2)}`; } function toProviderMetadataJson(provider: AIProvider): ProviderMetadataJson { return { url: provider.apiUrl || '', key: provider.apiKey || '', }; } function parseProviderMetadataJson(text: string): { ok: true; value: ProviderMetadataJson } | { ok: false; error: string } { const trimmed = text.trim(); if (!trimmed) { return { ok: true, value: {} }; } try { const parsed = JSON.parse(trimmed) as unknown; if (!parsed || typeof parsed !== 'object') { return { ok: false, error: 'Invalid JSON object' }; } return { ok: true, value: parsed as ProviderMetadataJson }; } catch { return { ok: false, error: 'Invalid JSON format' }; } } function resolveCustomApiConfig(provider: AIProvider): CustomApiConfig | null { const apiUrl = provider.apiUrl.trim(); const apiKey = provider.apiKey.trim(); const model = provider.model.trim(); const hasAny = Boolean(apiUrl || apiKey || model); if (!hasAny) { return null; } if (!apiUrl || !apiKey || !model) { return null; } return { apiUrl, apiKey, model }; } function normalizeProviderType(type: unknown): AIProviderType { return type === 'google' ? 'google' : 'openai'; } export function ProviderConfigModal({ isOpen, onClose, onSave }: ProviderConfigModalProps) { const { t } = useI18n(); const { shouldRender, isExiting } = useModalTransition(isOpen); const [config, setConfig] = useState(() => loadSettings()); const [selectedProviderId, setSelectedProviderId] = useState(null); const [metadataText, setMetadataText] = useState(''); const [metadataError, setMetadataError] = useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [testDialogOpen, setTestDialogOpen] = useState(false); const [testResult, setTestResult] = useState({ status: 'idle', message: '' }); const [modelsByProviderId, setModelsByProviderId] = useState>({}); const [fetchingModels, setFetchingModels] = useState(false); const autoSaveTimerRef = useRef(null); const metadataTimerRef = useRef(null); const modelFetchTimerRef = useRef(null); const metadataTouchedRef = useRef(false); useEffect(() => { if (!isOpen) { if (metadataTimerRef.current) { clearTimeout(metadataTimerRef.current); metadataTimerRef.current = null; } if (modelFetchTimerRef.current) { clearTimeout(modelFetchTimerRef.current); modelFetchTimerRef.current = null; } return; } const loaded = loadSettings(); setConfig(loaded); setSelectedProviderId(loaded.api.activeProviderId ?? loaded.api.providers[0]?.id ?? null); setTestResult({ status: 'idle', message: '' }); setModelsByProviderId({}); setFetchingModels(false); setMetadataError(null); setDeleteDialogOpen(false); setTestDialogOpen(false); }, [isOpen]); useEffect(() => { if (!isOpen) { return; } if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current); } autoSaveTimerRef.current = window.setTimeout(() => { saveSettings(config); onSave(config); }, 500); return () => { if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current); autoSaveTimerRef.current = null; } }; }, [config, isOpen, onSave]); const providers = config.api.providers; const selectedProvider = useMemo(() => { if (!selectedProviderId) { return null; } return providers.find((provider) => provider.id === selectedProviderId) || null; }, [providers, selectedProviderId]); useEffect(() => { if (!isOpen) { return; } if (!selectedProvider) { setMetadataText(''); return; } setMetadataText(JSON.stringify(toProviderMetadataJson(selectedProvider), null, 2)); metadataTouchedRef.current = false; setMetadataError(null); setDeleteDialogOpen(false); setTestDialogOpen(false); }, [isOpen, selectedProvider]); useEffect(() => { if (!isOpen || !selectedProvider) { return; } if (metadataTouchedRef.current) { return; } setMetadataText(JSON.stringify(toProviderMetadataJson(selectedProvider), null, 2)); }, [isOpen, selectedProvider]); const setActiveProvider = (id: string) => { const isAlreadyActive = config.api.activeProviderId === id; setConfig((prev) => ({ ...prev, api: { ...prev.api, activeProviderId: isAlreadyActive ? null : id } })); setSelectedProviderId(id); setDeleteDialogOpen(false); }; const addProvider = () => { const id = createProviderId(); const provider: AIProvider = { id, name: t('providers.newName'), type: 'openai', apiUrl: OPENAI_DEFAULT_URL, apiKey: '', model: '', }; setConfig((prev) => ({ ...prev, api: { ...prev.api, providers: [...prev.api.providers, provider], activeProviderId: id } })); setSelectedProviderId(id); setDeleteDialogOpen(false); }; const deleteSelectedProvider = () => { if (!selectedProvider) { return; } const nextProviders = providers.filter((provider) => provider.id !== selectedProvider.id); const nextActiveProviderId = config.api.activeProviderId === selectedProvider.id ? (nextProviders[0]?.id ?? null) : config.api.activeProviderId; setConfig((prev) => ({ ...prev, api: { ...prev.api, providers: nextProviders, activeProviderId: nextActiveProviderId } })); setSelectedProviderId(nextActiveProviderId); setModelsByProviderId((prev) => { const next = { ...prev }; delete next[selectedProvider.id]; return next; }); setDeleteDialogOpen(false); }; const updateSelectedProvider = (updates: Partial) => { if (!selectedProvider) { return; } setConfig((prev) => ({ ...prev, api: { ...prev.api, providers: prev.api.providers.map((provider) => (provider.id === selectedProvider.id ? { ...provider, ...updates } : provider)), }, })); }; const applyMetadataToProvider = (value: ProviderMetadataJson) => { if (!selectedProvider) { return; } const apiUrl = (value.apiUrl ?? value.url ?? '').toString(); const apiKey = (value.apiKey ?? value.key ?? '').toString(); const model = typeof value.model === 'string' ? value.model : ''; updateSelectedProvider({ apiUrl, apiKey, ...(model.trim() ? { model } : {}) }); }; const onMetadataTextChange = (text: string) => { setMetadataText(text); metadataTouchedRef.current = true; if (metadataTimerRef.current) { clearTimeout(metadataTimerRef.current); } metadataTimerRef.current = window.setTimeout(() => { const parsed = parseProviderMetadataJson(text); if (!parsed.ok) { setMetadataError(t('providers.metadata.invalidJson')); return; } setMetadataError(null); applyMetadataToProvider(parsed.value); }, 400); }; const fetchModelsForSelectedProvider = useCallback(async (provider: AIProvider) => { const manimcatKey = config.api.manimcatApiKey.trim(); if (!manimcatKey) { return; } const customApiConfig = resolveCustomApiConfig(provider); if (!customApiConfig) { return; } setFetchingModels(true); try { const response = await fetch('/api/ai/models', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${manimcatKey}`, }, body: JSON.stringify({ customApiConfig }), }); if (!response.ok) { return; } const data = (await response.json()) as { models?: unknown }; const models = Array.isArray(data.models) ? data.models.filter((item) => typeof item === 'string') : []; setModelsByProviderId((prev) => ({ ...prev, [provider.id]: models })); } finally { setFetchingModels(false); } }, [config.api.manimcatApiKey]); useEffect(() => { if (!isOpen || !selectedProvider) { return; } if (modelFetchTimerRef.current) { clearTimeout(modelFetchTimerRef.current); } modelFetchTimerRef.current = window.setTimeout(() => { void fetchModelsForSelectedProvider(selectedProvider); }, 500); return () => { if (modelFetchTimerRef.current) { clearTimeout(modelFetchTimerRef.current); modelFetchTimerRef.current = null; } }; }, [fetchModelsForSelectedProvider, isOpen, selectedProvider]); const handleTest = async () => { setTestDialogOpen(true); if (!selectedProvider) { setTestResult({ status: 'error', message: t('providers.empty') }); return; } const manimcatKey = config.api.manimcatApiKey.trim(); if (!manimcatKey) { setTestResult({ status: 'error', message: t('settings.test.needManimcatKey') }); return; } const customApiConfig = resolveCustomApiConfig(selectedProvider); if (!customApiConfig) { setTestResult({ status: 'error', message: t('settings.test.needUrlAndKey') }); return; } setTestResult({ status: 'testing', message: t('settings.test.testing'), details: {} }); const startTime = performance.now(); try { const response = await fetch('/api/ai/test', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${manimcatKey}`, }, body: JSON.stringify({ customApiConfig }), }); const duration = Math.round(performance.now() - startTime); if (response.ok) { setTestResult({ status: 'success', message: t('settings.test.success', { duration }), details: { duration }, }); return; } setTestResult({ status: 'error', message: `HTTP ${response.status}: ${response.statusText}`, details: { statusCode: response.status, statusText: response.statusText, duration }, }); } catch (error) { const duration = Math.round(performance.now() - startTime); setTestResult({ status: 'error', message: error instanceof Error ? error.message : t('settings.test.failed'), details: { error: error instanceof Error ? `${error.name}: ${error.message}` : String(error), duration }, }); } }; if (!shouldRender) { return null; } const activeProviderId = config.api.activeProviderId; const modelSuggestions = selectedProvider ? (modelsByProviderId[selectedProvider.id] || []) : []; return (
{/* 沉浸式背景:保留毛玻璃 */}
{/* 模态框主体:应用进出动画 */}
{t('providers.title')}
{providers.map((provider) => { const isActive = provider.id === activeProviderId; const isSelected = provider.id === selectedProviderId; const base = 'px-4 py-2 rounded-xl text-sm whitespace-nowrap transition-all cursor-pointer flex items-center gap-2 border'; const cls = isSelected ? `${base} bg-accent text-bg-primary border-transparent shadow-md shadow-accent/10` : `${base} bg-bg-secondary/20 text-text-secondary hover:text-text-primary hover:bg-bg-secondary/35 border-bg-tertiary/20`; return ( ); })}
{selectedProvider ? ( <>
updateSelectedProvider({ name: value })} inputClassName="text-base py-5" /> updateSelectedProvider({ model: value })} suggestions={modelSuggestions} inputClassName="text-base py-5" />
{t('providers.type')}
{(['openai', 'google'] as const).map((type) => { const selected = selectedProvider.type === type; const cls = selected ? 'text-sm px-4 py-2 rounded-lg bg-accent/15 text-accent border border-accent/20' : 'text-sm px-4 py-2 rounded-lg bg-bg-secondary/20 text-text-secondary border border-bg-tertiary/30 hover:text-text-primary hover:bg-bg-secondary/35'; const label = type === 'openai' ? t('providers.typeOpenAI') : t('providers.typeGoogle'); return ( ); })}
{t('providers.metadataTitle')}