Spaces:
Sleeping
Sleeping
Sync from enhance: d795216 feat: implement patch response parser for search and replace functionality
9bd4242 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | |
| import type { AIProvider, AIProviderType, SettingsConfig } from '../types/api'; | |
| import { loadSettings, saveSettings } from '../lib/settings'; | |
| import { | |
| GOOGLE_OPENAI_COMPAT_URL, | |
| OPENAI_DEFAULT_URL, | |
| normalizeProviderApiKeys, | |
| providerToApiCredentials, | |
| providerToCustomApiConfig | |
| } 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; | |
| keys?: string | string[]; | |
| apiKey?: string; | |
| apiKeys?: string | string[]; | |
| apiKeyPool?: string | 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 normalizeProviderType(type: unknown): AIProviderType { | |
| return type === 'google' ? 'google' : 'openai'; | |
| } | |
| function resolveMetadataApiKeys(value: ProviderMetadataJson): string[] { | |
| return normalizeProviderApiKeys( | |
| value.apiKeyPool ?? value.apiKeys ?? value.keys ?? value.apiKey ?? value.key ?? '' | |
| ); | |
| } | |
| export function ProviderConfigModal({ isOpen, onClose, onSave }: ProviderConfigModalProps) { | |
| const { t } = useI18n(); | |
| const { shouldRender, isExiting } = useModalTransition(isOpen); | |
| const [config, setConfig] = useState<SettingsConfig>(() => loadSettings()); | |
| const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null); | |
| const [metadataText, setMetadataText] = useState(''); | |
| const [metadataError, setMetadataError] = useState<string | null>(null); | |
| const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); | |
| const [testDialogOpen, setTestDialogOpen] = useState(false); | |
| const [testResult, setTestResult] = useState<TestResult>({ status: 'idle', message: '' }); | |
| const [modelsByProviderId, setModelsByProviderId] = useState<Record<string, string[]>>({}); | |
| const [fetchingModels, setFetchingModels] = useState(false); | |
| const autoSaveTimerRef = useRef<number | null>(null); | |
| const metadataTimerRef = useRef<number | null>(null); | |
| const modelFetchTimerRef = useRef<number | null>(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<AIProvider>) => { | |
| 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 = resolveMetadataApiKeys(value).join(','); | |
| 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 credentials = providerToApiCredentials(provider); | |
| if (!credentials) { | |
| 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: credentials }), | |
| }); | |
| 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 = providerToCustomApiConfig(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 ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center p-4"> | |
| {/* 沉浸式背景:保留毛玻璃 */} | |
| <div | |
| className={`absolute inset-0 bg-bg-primary/60 backdrop-blur-md transition-opacity duration-300 ${ | |
| isExiting ? 'opacity-0' : 'animate-overlay-wash-in' | |
| }`} | |
| onClick={onClose} | |
| /> | |
| {/* 模态框主体:应用进出动画 */} | |
| <div className={`relative w-full max-w-3xl lg:max-w-4xl max-h-[86vh] flex flex-col overflow-hidden bg-bg-secondary rounded-2xl shadow-xl border border-bg-tertiary/30 ${ | |
| isExiting ? 'animate-fade-out-soft' : 'animate-fade-in-soft' | |
| }`}> | |
| <div className="h-16 bg-bg-secondary/50 border-b border-bg-tertiary/30 flex items-center justify-between px-6"> | |
| <div className="flex items-center gap-3"> | |
| <span className="text-base text-text-primary font-medium">{t('providers.title')}</span> | |
| </div> | |
| <button | |
| onClick={onClose} | |
| className="p-2 text-text-secondary/70 hover:text-text-primary hover:bg-bg-tertiary/50 rounded-lg transition-colors" | |
| title={t('common.close')} | |
| aria-label={t('common.close')} | |
| > | |
| <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> | |
| </svg> | |
| </button> | |
| </div> | |
| <div className="flex-1 overflow-y-auto px-6 py-6 space-y-5"> | |
| <div className="flex items-center gap-2 overflow-x-auto pb-1"> | |
| {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 ( | |
| <button | |
| key={provider.id} | |
| type="button" | |
| onClick={() => setActiveProvider(provider.id)} | |
| className={cls} | |
| > | |
| {isActive ? ( | |
| <span className="w-2 h-2 bg-green-400 rounded-full animate-pulse ring-2 ring-green-400/20" /> | |
| ) : null} | |
| <span>{provider.name || t('providers.unnamed')}</span> | |
| </button> | |
| ); | |
| })} | |
| <button | |
| type="button" | |
| onClick={addProvider} | |
| className="px-3 py-2 rounded-xl text-sm whitespace-nowrap cursor-pointer text-accent border border-dashed border-accent/70 hover:border-accent hover:bg-bg-secondary/25 transition-colors" | |
| title={t('providers.add')} | |
| > | |
| + | |
| </button> | |
| </div> | |
| <div className="h-px bg-bg-tertiary/30" /> | |
| <div className="rounded-2xl bg-bg-secondary/25 border border-bg-tertiary/30 p-5 space-y-5"> | |
| {selectedProvider ? ( | |
| <> | |
| <div className="grid grid-cols-2 gap-3"> | |
| <FloatingInput | |
| id="providerNameCompact" | |
| type="text" | |
| label={t('providers.name')} | |
| value={selectedProvider.name} | |
| placeholder={t('providers.namePlaceholder')} | |
| onChange={(value) => updateSelectedProvider({ name: value })} | |
| inputClassName="text-base py-5" | |
| /> | |
| <FloatingInput | |
| id="providerModelCompact" | |
| type="text" | |
| label={t('providers.model')} | |
| value={selectedProvider.model} | |
| placeholder={fetchingModels ? t('providers.modelLoading') : t('providers.modelPlaceholder')} | |
| onChange={(value) => updateSelectedProvider({ model: value })} | |
| suggestions={modelSuggestions} | |
| inputClassName="text-base py-5" | |
| /> | |
| </div> | |
| <div className="h-px bg-bg-tertiary/25" /> | |
| <div> | |
| <div className="text-[11px] font-medium text-text-secondary uppercase tracking-[0.12em] px-1"> | |
| {t('providers.type')} | |
| </div> | |
| <div className="flex gap-2 mt-2"> | |
| {(['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 ( | |
| <button | |
| key={type} | |
| type="button" | |
| className={cls} | |
| onClick={() => { | |
| const nextType = normalizeProviderType(type); | |
| const shouldAutofillUrl = | |
| !selectedProvider.apiUrl.trim() || !selectedProvider.apiKey.trim(); | |
| const updates: Partial<AIProvider> = { type: nextType }; | |
| if (nextType === 'google' && shouldAutofillUrl) { | |
| updates.apiUrl = GOOGLE_OPENAI_COMPAT_URL; | |
| } | |
| if (nextType === 'openai' && shouldAutofillUrl) { | |
| updates.apiUrl = OPENAI_DEFAULT_URL; | |
| } | |
| updateSelectedProvider(updates); | |
| }} | |
| > | |
| {label} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| <div className="h-px bg-bg-tertiary/25" /> | |
| <div className="bg-bg-secondary/40 rounded-xl p-4 border border-bg-tertiary/30"> | |
| <div className="flex items-center justify-between mb-2"> | |
| <span className="text-sm font-semibold text-text-primary/90">{t('providers.metadataTitle')}</span> | |
| <button | |
| type="button" | |
| onClick={handleTest} | |
| disabled={testResult.status === 'testing'} | |
| className="text-sm font-medium text-accent hover:text-accent-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| {t('settings.test')} | |
| </button> | |
| </div> | |
| <textarea | |
| value={metadataText} | |
| onChange={(e) => onMetadataTextChange(e.target.value)} | |
| spellCheck={false} | |
| className="w-full h-36 bg-bg-secondary/30 border border-bg-tertiary/30 rounded-lg font-mono text-[12px] text-text-secondary/80 px-4 py-3 focus:outline-none focus:border-accent/30 focus:ring-1 focus:ring-accent/20 transition-colors" | |
| placeholder={t('providers.metadataPlaceholder')} | |
| /> | |
| {metadataError ? ( | |
| <div className="mt-2 text-xs text-red-600 dark:text-red-400">{metadataError}</div> | |
| ) : null} | |
| </div> | |
| </> | |
| ) : ( | |
| <div className="text-sm text-text-secondary">{t('providers.empty')}</div> | |
| )} | |
| </div> | |
| </div> | |
| <div className="px-6 py-4 border-t border-bg-tertiary/30 flex items-center justify-between"> | |
| <button | |
| type="button" | |
| onClick={() => setDeleteDialogOpen(true)} | |
| disabled={!selectedProvider} | |
| className="text-base text-red-600 dark:text-red-400 hover:text-red-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed" | |
| > | |
| {t('providers.delete')} | |
| </button> | |
| <div className="flex gap-3"> | |
| <button | |
| type="button" | |
| onClick={onClose} | |
| className="px-6 py-3 text-sm font-medium text-text-secondary hover:text-text-primary bg-bg-secondary/40 hover:bg-bg-secondary/60 rounded-2xl transition-all focus:outline-none focus:ring-2 focus:ring-accent/20" | |
| > | |
| {t('common.close')} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {deleteDialogOpen && ( | |
| <div className="fixed inset-0 z-[60] flex items-center justify-center p-4 animate-fade-in"> | |
| <div className="absolute inset-0 bg-black/40" onClick={() => setDeleteDialogOpen(false)} /> | |
| <div className="relative bg-bg-secondary rounded-2xl p-6 max-w-sm w-full shadow-xl border border-bg-tertiary/30"> | |
| <h3 className="text-base font-medium text-text-primary">{t('providers.delete')}</h3> | |
| <p className="text-sm text-text-secondary/80 mt-2 leading-relaxed">{t('providers.deleteConfirm')}</p> | |
| <div className="mt-6 flex gap-3"> | |
| <button | |
| type="button" | |
| onClick={() => setDeleteDialogOpen(false)} | |
| className="flex-1 px-4 py-2.5 text-sm text-text-secondary hover:text-text-primary bg-bg-primary rounded-xl transition-all" | |
| > | |
| {t('common.cancel')} | |
| </button> | |
| <button | |
| type="button" | |
| onClick={deleteSelectedProvider} | |
| className="flex-1 px-4 py-2.5 text-sm text-white bg-red-600 hover:bg-red-700 rounded-xl transition-all" | |
| > | |
| {t('common.confirm')} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {testDialogOpen && ( | |
| <div className="fixed inset-0 z-[60] flex items-center justify-center p-4 animate-fade-in"> | |
| <div className="absolute inset-0 bg-black/40" onClick={() => setTestDialogOpen(false)} /> | |
| <div className="relative bg-bg-secondary rounded-2xl p-6 max-w-sm w-full shadow-xl border border-bg-tertiary/30"> | |
| <div className="flex items-center justify-between"> | |
| <h3 className="text-base font-medium text-text-primary">{t('settings.test')}</h3> | |
| <button | |
| type="button" | |
| onClick={() => setTestDialogOpen(false)} | |
| className="p-2 text-text-secondary/70 hover:text-text-primary hover:bg-bg-tertiary/50 rounded-lg transition-colors" | |
| aria-label={t('common.close')} | |
| > | |
| <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> | |
| </svg> | |
| </button> | |
| </div> | |
| <div className="mt-4"> | |
| <TestResultBanner testResult={testResult} /> | |
| </div> | |
| {testResult.details ? ( | |
| <details className="mt-4"> | |
| <summary className="text-xs text-text-secondary/70 cursor-pointer select-none"> | |
| Details | |
| </summary> | |
| <pre className="mt-2 max-h-40 overflow-auto text-[11px] rounded-xl bg-bg-secondary/30 border border-bg-tertiary/30 p-3 text-text-secondary/80"> | |
| {JSON.stringify(testResult.details, null, 2)} | |
| </pre> | |
| </details> | |
| ) : null} | |
| <div className="mt-6 flex gap-3"> | |
| <button | |
| type="button" | |
| onClick={() => setTestDialogOpen(false)} | |
| className="flex-1 px-4 py-2.5 text-sm text-text-secondary hover:text-text-primary bg-bg-secondary/40 hover:bg-bg-secondary/60 rounded-xl transition-all" | |
| > | |
| {t('common.close')} | |
| </button> | |
| <button | |
| type="button" | |
| onClick={handleTest} | |
| disabled={testResult.status === 'testing'} | |
| className="flex-1 px-4 py-2.5 text-sm text-bg-primary bg-accent hover:bg-accent-hover rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| {t('settings.test')} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |