import { useState, useRef, useEffect, useCallback } from 'react'; import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { X, Trash2, Box, Settings, CheckCircle2, XCircle, FileText, Image as ImageIcon, Film, Search, Volume2, Mic, Plus, } from 'lucide-react'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useSettingsStore } from '@/lib/store/settings'; import { toast } from 'sonner'; import { type ProviderId } from '@/lib/ai/providers'; import { PROVIDERS, MONO_LOGO_PROVIDERS } from '@/lib/ai/providers'; import { cn } from '@/lib/utils'; import { createCustomProviderSettings, getProviderTypeLabel } from './utils'; import { ProviderList } from './provider-list'; import { ProviderConfigPanel } from './provider-config-panel'; import { PDFSettings } from './pdf-settings'; import { PDF_PROVIDERS } from '@/lib/pdf/constants'; import type { PDFProviderId } from '@/lib/pdf/types'; import { ImageSettings } from './image-settings'; import { IMAGE_PROVIDERS } from '@/lib/media/image-providers'; import type { ImageProviderId } from '@/lib/media/types'; import { VideoSettings } from './video-settings'; import { VIDEO_PROVIDERS } from '@/lib/media/video-providers'; import type { VideoProviderId } from '@/lib/media/types'; import { TTSSettings } from './tts-settings'; import { TTS_PROVIDERS } from '@/lib/audio/constants'; import type { TTSProviderId } from '@/lib/audio/types'; import { ASRSettings } from './asr-settings'; import { ASR_PROVIDERS } from '@/lib/audio/constants'; import type { ASRProviderId } from '@/lib/audio/types'; import { WebSearchSettings } from './web-search-settings'; import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants'; import type { WebSearchProviderId } from '@/lib/web-search/types'; import { GeneralSettings } from './general-settings'; import { ModelEditDialog } from './model-edit-dialog'; import { AddProviderDialog, type NewProviderData } from './add-provider-dialog'; import { AddAudioProviderDialog, type NewAudioProviderData } from './add-audio-provider-dialog'; import { isCustomTTSProvider, isCustomASRProvider } from '@/lib/audio/types'; import type { SettingsSection, EditingModel } from '@/lib/types/settings'; // ─── Provider List Column (reusable) ─── function ProviderListColumn({ providers, configs, selectedId, onSelect, width, t, onAdd, }: { providers: Array<{ id: T; name: string; icon?: string }>; configs: Record; selectedId: T; onSelect: (id: T) => void; width: number; t: (key: string) => string; onAdd?: () => void; }) { return (
{providers.map((provider) => ( ))}
{onAdd && (
)}
); } // ─── Helper: get TTS/ASR provider display name ─── function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => string): string { if (isCustomTTSProvider(providerId)) { const cfg = useSettingsStore.getState().ttsProvidersConfig[providerId]; return cfg?.customName || providerId; } const names: Record = { 'openai-tts': t('settings.providerOpenAITTS'), 'azure-tts': t('settings.providerAzureTTS'), 'glm-tts': t('settings.providerGLMTTS'), 'qwen-tts': t('settings.providerQwenTTS'), 'voxcpm-tts': t('settings.providerVoxCPMTTS'), 'doubao-tts': t('settings.providerDoubaoTTS'), 'elevenlabs-tts': t('settings.providerElevenLabsTTS'), 'minimax-tts': t('settings.providerMiniMaxTTS'), 'browser-native-tts': t('settings.providerBrowserNativeTTS'), }; return names[providerId] || providerId; } function getASRProviderName(providerId: ASRProviderId, t: (key: string) => string): string { if (isCustomASRProvider(providerId)) { const cfg = useSettingsStore.getState().asrProvidersConfig[providerId]; return cfg?.customName || providerId; } const names: Record = { 'openai-whisper': t('settings.providerOpenAIWhisper'), 'browser-native': t('settings.providerBrowserNative'), 'qwen-asr': t('settings.providerQwenASR'), }; return names[providerId] || providerId; } // ─── Image/Video provider name helpers ─── const IMAGE_PROVIDER_NAMES: Record = { seedream: 'providerSeedream', 'openai-image': 'providerOpenAIImage', 'qwen-image': 'providerQwenImage', 'nano-banana': 'providerNanoBanana', 'minimax-image': 'providerMiniMaxImage', 'grok-image': 'providerGrokImage', }; const IMAGE_PROVIDER_ICONS: Record = { seedream: '/logos/doubao.svg', 'openai-image': '/logos/openai.svg', 'qwen-image': '/logos/bailian.svg', 'nano-banana': '/logos/gemini.svg', 'minimax-image': '/logos/minimax.svg', 'grok-image': '/logos/grok.svg', }; const VIDEO_PROVIDER_NAMES: Record = { seedance: 'providerSeedance', kling: 'providerKling', veo: 'providerVeo', sora: 'providerSora', 'minimax-video': 'providerMiniMaxVideo', 'grok-video': 'providerGrokVideo', }; const VIDEO_PROVIDER_ICONS: Record = { seedance: '/logos/doubao.svg', kling: '/logos/kling.svg', veo: '/logos/gemini.svg', sora: '/logos/openai.svg', 'minimax-video': '/logos/minimax.svg', 'grok-video': '/logos/grok.svg', }; interface SettingsDialogProps { open: boolean; onOpenChange: (open: boolean) => void; initialSection?: SettingsSection; } export function SettingsDialog({ open, onOpenChange, initialSection }: SettingsDialogProps) { const { t } = useI18n(); // Get settings from store const providerId = useSettingsStore((state) => state.providerId); const _modelId = useSettingsStore((state) => state.modelId); const providersConfig = useSettingsStore((state) => state.providersConfig); const pdfProviderId = useSettingsStore((state) => state.pdfProviderId); const pdfProvidersConfig = useSettingsStore((state) => state.pdfProvidersConfig); const webSearchProviderId = useSettingsStore((state) => state.webSearchProviderId); const webSearchProvidersConfig = useSettingsStore((state) => state.webSearchProvidersConfig); const imageProviderId = useSettingsStore((state) => state.imageProviderId); const imageProvidersConfig = useSettingsStore((state) => state.imageProvidersConfig); const videoProviderId = useSettingsStore((state) => state.videoProviderId); const videoProvidersConfig = useSettingsStore((state) => state.videoProvidersConfig); const ttsProviderId = useSettingsStore((state) => state.ttsProviderId); const ttsProvidersConfig = useSettingsStore((state) => state.ttsProvidersConfig); const asrProviderId = useSettingsStore((state) => state.asrProviderId); const asrProvidersConfig = useSettingsStore((state) => state.asrProvidersConfig); // Store actions const setModel = useSettingsStore((state) => state.setModel); const setProviderConfig = useSettingsStore((state) => state.setProviderConfig); const setProvidersConfig = useSettingsStore((state) => state.setProvidersConfig); const setTTSProvider = useSettingsStore((state) => state.setTTSProvider); const setASRProvider = useSettingsStore((state) => state.setASRProvider); // Navigation const [activeSection, setActiveSection] = useState('providers'); const [selectedProviderId, setSelectedProviderId] = useState(providerId); const [selectedPdfProviderId, setSelectedPdfProviderId] = useState(pdfProviderId); const [selectedWebSearchProviderId, setSelectedWebSearchProviderId] = useState(webSearchProviderId); const [selectedImageProviderId, setSelectedImageProviderId] = useState(imageProviderId); const [selectedVideoProviderId, setSelectedVideoProviderId] = useState(videoProviderId); // Navigate to initialSection when dialog opens useEffect(() => { if (open && initialSection) { // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync section from prop when dialog opens setActiveSection(initialSection); } }, [open, initialSection]); // Model editing state const [editingModel, setEditingModel] = useState(null); const [showModelDialog, setShowModelDialog] = useState(false); // Provider deletion confirmation const [providerToDelete, setProviderToDelete] = useState(null); // Add provider dialog const [showAddProviderDialog, setShowAddProviderDialog] = useState(false); const [showAddTTSProviderDialog, setShowAddTTSProviderDialog] = useState(false); const [showAddASRProviderDialog, setShowAddASRProviderDialog] = useState(false); const addCustomTTSProvider = useSettingsStore((state) => state.addCustomTTSProvider); const addCustomASRProvider = useSettingsStore((state) => state.addCustomASRProvider); const handleAddTTSProvider = (data: NewAudioProviderData) => { const id = `custom-tts-${Date.now()}` as TTSProviderId; addCustomTTSProvider(id, data.name, data.baseUrl, data.requiresApiKey, data.defaultModel); }; const handleAddASRProvider = (data: NewAudioProviderData) => { const id = `custom-asr-${Date.now()}` as ASRProviderId; addCustomASRProvider(id, data.name, data.baseUrl, data.requiresApiKey); }; // Save status indicator const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle'); // Resizable column widths const [sidebarWidth, setSidebarWidth] = useState(192); const [providerListWidth, setProviderListWidth] = useState(192); const [isResizing, setIsResizing] = useState(false); const resizeRef = useRef<{ target: 'sidebar' | 'providerList'; startX: number; startWidth: number; } | null>(null); const handleResizeStart = useCallback( (e: React.MouseEvent, target: 'sidebar' | 'providerList') => { e.preventDefault(); const startWidth = target === 'sidebar' ? sidebarWidth : providerListWidth; resizeRef.current = { target, startX: e.clientX, startWidth }; setIsResizing(true); }, [sidebarWidth, providerListWidth], ); useEffect(() => { if (!isResizing) return; const handleMouseMove = (e: MouseEvent) => { if (!resizeRef.current) return; const { target, startX, startWidth } = resizeRef.current; const delta = e.clientX - startX; const newWidth = Math.max(120, Math.min(360, startWidth + delta)); if (target === 'sidebar') { setSidebarWidth(newWidth); } else { setProviderListWidth(newWidth); } }; const handleMouseUp = () => { resizeRef.current = null; setIsResizing(false); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); document.body.style.userSelect = 'none'; document.body.style.cursor = 'col-resize'; return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); document.body.style.userSelect = ''; document.body.style.cursor = ''; }; }, [isResizing]); const handleSave = () => { onOpenChange(false); }; const handleProviderSelect = (pid: ProviderId) => { setSelectedProviderId(pid); }; const handleProviderConfigChange = ( pid: ProviderId, apiKey: string, baseUrl: string, requiresApiKey: boolean, ) => { setProviderConfig(pid, { apiKey, baseUrl, requiresApiKey, }); }; const handleProviderConfigSave = () => { setSaveStatus('saved'); setTimeout(() => setSaveStatus('idle'), 2000); }; const selectedProvider = providersConfig[selectedProviderId] ? { id: selectedProviderId, name: providersConfig[selectedProviderId].name, type: providersConfig[selectedProviderId].type, defaultBaseUrl: providersConfig[selectedProviderId].defaultBaseUrl, alternateBaseUrls: PROVIDERS[selectedProviderId]?.alternateBaseUrls, icon: providersConfig[selectedProviderId].icon, requiresApiKey: providersConfig[selectedProviderId].requiresApiKey, models: providersConfig[selectedProviderId].models, } : undefined; // Handle model editing const handleEditModel = (pid: ProviderId, modelIndex: number) => { const allModels = providersConfig[pid]?.models || []; setEditingModel({ providerId: pid, modelIndex, model: { ...allModels[modelIndex] }, }); setShowModelDialog(true); }; const handleAddModel = () => { setEditingModel({ providerId: selectedProviderId, modelIndex: null, model: { id: '', name: '', capabilities: { streaming: true, tools: true, vision: false, }, }, }); setShowModelDialog(true); }; const handleDeleteModel = (pid: ProviderId, modelIndex: number) => { const currentModels = providersConfig[pid]?.models || []; const newModels = currentModels.filter((_, i) => i !== modelIndex); setProviderConfig(pid, { models: newModels }); }; const handleAutoSaveModel = () => { if (!editingModel) return; const { providerId: pid, modelIndex, model } = editingModel; if (!model.id.trim()) return; const currentModels = providersConfig[pid]?.models || []; let newModels: typeof currentModels; let newModelIndex = modelIndex; if (modelIndex === null) { const existingIndex = currentModels.findIndex((m) => m.id === model.id); if (existingIndex >= 0) { newModels = [...currentModels]; newModels[existingIndex] = model; newModelIndex = existingIndex; } else { newModels = [...currentModels, model]; newModelIndex = newModels.length - 1; } setProviderConfig(pid, { models: newModels }); setEditingModel({ ...editingModel, modelIndex: newModelIndex }); } else { newModels = [...currentModels]; newModels[modelIndex] = model; setProviderConfig(pid, { models: newModels }); } }; const handleSaveModel = () => { if (!editingModel) return; const { providerId: pid, modelIndex, model } = editingModel; if (!model.id.trim()) { toast.error(t('settings.modelIdRequired')); return; } const currentModels = providersConfig[pid]?.models || []; let newModels: typeof currentModels; if (modelIndex === null) { newModels = [...currentModels, model]; } else { newModels = [...currentModels]; newModels[modelIndex] = model; } setProviderConfig(pid, { models: newModels }); setShowModelDialog(false); setEditingModel(null); }; // Handle provider management const handleAddProvider = (providerData: NewProviderData) => { if (!providerData.name.trim()) { toast.error(t('settings.providerNameRequired')); return; } const newProviderId = `custom-${Date.now()}` as ProviderId; const updatedConfig = { ...providersConfig, [newProviderId]: createCustomProviderSettings(providerData), }; setProvidersConfig(updatedConfig); setShowAddProviderDialog(false); setSelectedProviderId(newProviderId); }; const handleDeleteProvider = (pid: ProviderId) => { if (providersConfig[pid]?.isBuiltIn) { toast.error(t('settings.cannotDeleteBuiltIn')); return; } setProviderToDelete(pid); }; const confirmDeleteProvider = () => { if (!providerToDelete) return; const pid = providerToDelete; const updatedConfig = { ...providersConfig }; delete updatedConfig[pid]; setProvidersConfig(updatedConfig); if (selectedProviderId === pid) { const firstRemainingPid = Object.keys(updatedConfig)[0] as ProviderId | undefined; setSelectedProviderId(firstRemainingPid || 'openai'); } if (providerId === pid) { const firstRemainingPid = Object.keys(updatedConfig)[0] as ProviderId | undefined; const firstModel = firstRemainingPid ? updatedConfig[firstRemainingPid]?.serverModels?.[0] || updatedConfig[firstRemainingPid]?.models?.[0]?.id : undefined; if (firstRemainingPid && firstModel) { setModel(firstRemainingPid, firstModel); } else { setModel('openai' as ProviderId, 'gpt-5.4-mini'); } } setProviderToDelete(null); }; const handleResetProvider = (pid: ProviderId) => { const provider = PROVIDERS[pid]; if (!provider) return; setProviderConfig(pid, { models: [...provider.models] }); toast.success(t('settings.resetSuccess')); }; // Get all providers from providersConfig const allProviders = Object.entries(providersConfig).map(([id, config]) => ({ id: id as ProviderId, name: config.name, type: config.type, defaultBaseUrl: config.defaultBaseUrl, icon: config.icon, requiresApiKey: config.requiresApiKey, models: config.models, isServerConfigured: config.isServerConfigured, })); // Sections that show a provider list column const _hasProviderList = [ 'providers', 'pdf', 'web-search', 'image', 'video', 'tts', 'asr', ].includes(activeSection); // Get header content based on section const getHeaderContent = () => { switch (activeSection) { case 'general': return

{t('settings.systemSettings')}

; case 'providers': if (selectedProvider) { return ( <> {selectedProvider.icon ? ( {selectedProvider.name} { (e.target as HTMLImageElement).style.display = 'none'; }} /> ) : ( )}

{t(`settings.providerNames.${selectedProvider.id}`) !== `settings.providerNames.${selectedProvider.id}` ? t(`settings.providerNames.${selectedProvider.id}`) : selectedProvider.name}

{getProviderTypeLabel(selectedProvider.type, t)}

); } return null; case 'pdf': { const pdfProvider = PDF_PROVIDERS[selectedPdfProviderId]; if (!pdfProvider) return null; return ( <> {pdfProvider.icon ? ( {pdfProvider.name} { (e.target as HTMLImageElement).style.display = 'none'; }} /> ) : ( )}

{pdfProvider.name}

); } case 'web-search': { const wsProvider = WEB_SEARCH_PROVIDERS[selectedWebSearchProviderId]; if (!wsProvider) return null; return ( <> {wsProvider.icon ? ( {wsProvider.name} { (e.target as HTMLImageElement).style.display = 'none'; }} /> ) : ( )}

{wsProvider.name}

); } case 'image': { const imgProvider = IMAGE_PROVIDERS[selectedImageProviderId]; const imgIcon = IMAGE_PROVIDER_ICONS[selectedImageProviderId]; return ( <> {imgIcon ? ( {imgProvider?.name} { (e.target as HTMLImageElement).style.display = 'none'; }} /> ) : ( )}

{t(`settings.${IMAGE_PROVIDER_NAMES[selectedImageProviderId]}`) || imgProvider?.name}

); } case 'video': { const vidProvider = VIDEO_PROVIDERS[selectedVideoProviderId]; const vidIcon = VIDEO_PROVIDER_ICONS[selectedVideoProviderId]; return ( <> {vidIcon ? ( {vidProvider?.name} { (e.target as HTMLImageElement).style.display = 'none'; }} /> ) : ( )}

{t(`settings.${VIDEO_PROVIDER_NAMES[selectedVideoProviderId]}`) || vidProvider?.name}

); } case 'tts': { const ttsIcon = TTS_PROVIDERS[ttsProviderId as keyof typeof TTS_PROVIDERS]?.icon; return ( <> {ttsIcon ? ( { (e.target as HTMLImageElement).style.display = 'none'; }} /> ) : ( )}

{getTTSProviderName(ttsProviderId, t)}

); } case 'asr': { const asrIcon = ASR_PROVIDERS[asrProviderId as keyof typeof ASR_PROVIDERS]?.icon; return ( <> {asrIcon ? ( { (e.target as HTMLImageElement).style.display = 'none'; }} /> ) : ( )}

{getASRProviderName(asrProviderId, t)}

); } default: return null; } }; return ( {t('settings.title')} {t('settings.description')}
{/* Left Sidebar - Navigation */}
{/* Sidebar resize handle */}
handleResizeStart(e, 'sidebar')} className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center" >
{/* Middle - Provider List (only shown for provider-based sections) */} {activeSection === 'providers' && ( <> setShowAddProviderDialog(true)} width={providerListWidth} />
handleResizeStart(e, 'providerList')} className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center" >
)} {activeSection === 'pdf' && ( <>
handleResizeStart(e, 'providerList')} className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center" >
)} {activeSection === 'web-search' && ( <>
handleResizeStart(e, 'providerList')} className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center" >
)} {activeSection === 'image' && ( <> ({ id: p.id, name: t(`settings.${IMAGE_PROVIDER_NAMES[p.id]}`) || p.name, icon: IMAGE_PROVIDER_ICONS[p.id], }))} configs={imageProvidersConfig} selectedId={selectedImageProviderId} onSelect={setSelectedImageProviderId} width={providerListWidth} t={t} />
handleResizeStart(e, 'providerList')} className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center" >
)} {activeSection === 'video' && ( <> ({ id: p.id, name: t(`settings.${VIDEO_PROVIDER_NAMES[p.id]}`) || p.name, icon: VIDEO_PROVIDER_ICONS[p.id], }))} configs={videoProvidersConfig} selectedId={selectedVideoProviderId} onSelect={setSelectedVideoProviderId} width={providerListWidth} t={t} />
handleResizeStart(e, 'providerList')} className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center" >
)} {activeSection === 'tts' && ( <> ({ id: p.id, name: getTTSProviderName(p.id, t), icon: p.icon, })), ...Object.entries(ttsProvidersConfig) .filter(([id]) => isCustomTTSProvider(id)) .map(([id, cfg]) => ({ id: id as TTSProviderId, name: cfg.customName || id, icon: undefined, })), ]} configs={ttsProvidersConfig} selectedId={ttsProviderId} onSelect={setTTSProvider} width={providerListWidth} t={t} onAdd={() => setShowAddTTSProviderDialog(true)} />
handleResizeStart(e, 'providerList')} className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center" >
)} {activeSection === 'asr' && ( <> ({ id: p.id, name: getASRProviderName(p.id, t), icon: p.icon, })), ...Object.entries(asrProvidersConfig) .filter(([id]) => isCustomASRProvider(id)) .map(([id, cfg]) => ({ id: id as ASRProviderId, name: cfg.customName || id, icon: undefined, })), ]} configs={asrProvidersConfig} selectedId={asrProviderId} onSelect={setASRProvider} width={providerListWidth} t={t} onAdd={() => setShowAddASRProviderDialog(true)} />
handleResizeStart(e, 'providerList')} className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center" >
)} {/* Right - Configuration Panel */}
{/* Header */}
{getHeaderContent()}
{activeSection === 'providers' && !providersConfig[selectedProviderId]?.isBuiltIn && ( )}
{/* Content */}
{activeSection === 'general' && } {activeSection === 'providers' && selectedProvider && ( handleProviderConfigChange(selectedProviderId, apiKey, baseUrl, requiresApiKey) } onSave={handleProviderConfigSave} onEditModel={(index) => handleEditModel(selectedProviderId, index)} onDeleteModel={(index) => handleDeleteModel(selectedProviderId, index)} onAddModel={handleAddModel} onResetToDefault={() => handleResetProvider(selectedProviderId)} isBuiltIn={providersConfig[selectedProviderId]?.isBuiltIn ?? true} /> )} {activeSection === 'pdf' && ( )} {activeSection === 'web-search' && ( )} {activeSection === 'image' && ( )} {activeSection === 'video' && ( )} {activeSection === 'tts' && } {activeSection === 'asr' && }
{/* Footer */}
{saveStatus === 'saved' && (
{t('settings.saveSuccess')}
)} {saveStatus === 'error' && (
{t('settings.saveFailed')}
)}
{/* Edit Model Dialog */} {/* Add Provider Dialog */} {/* Add TTS Provider Dialog */} {/* Add ASR Provider Dialog */} {/* Delete Provider Confirmation */} !open && setProviderToDelete(null)} > {t('settings.deleteProvider')} {t('settings.deleteProviderConfirm')} {t('settings.cancelEdit')} {t('settings.deleteProvider')}
); }