'use client'; import { Fragment, useState, useEffect, useCallback, useMemo } from 'react'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button, Input, TabGroup, TabList, Tab, TabPanels, TabPanel, } from '@headlessui/react'; import { useTheme } from '@/contexts/ThemeContext'; import { useConfig } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons'; import { indexedDBService } from '@/utils/indexedDB'; import { useDocuments } from '@/contexts/DocumentContext'; import { setItem, getItem } from '@/utils/indexedDB'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ProgressPopup } from '@/components/ProgressPopup'; import { useTimeEstimation } from '@/hooks/useTimeEstimation'; import { THEMES } from '@/contexts/ThemeContext'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; const themes = THEMES.map(id => ({ id, name: id.charAt(0).toUpperCase() + id.slice(1) })); export function SettingsModal() { const [isOpen, setIsOpen] = useState(false); const { theme, setTheme } = useTheme(); const { apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig(); const { refreshPDFs, refreshEPUBs, clearPDFs, clearEPUBs } = useDocuments(); const [localApiKey, setLocalApiKey] = useState(apiKey); const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl); const [localTTSProvider, setLocalTTSProvider] = useState(ttsProvider); const [modelValue, setModelValue] = useState(ttsModel); const [customModelInput, setCustomModelInput] = useState(''); const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions); const [isSyncing, setIsSyncing] = useState(false); const [isLoading, setIsLoading] = useState(false); const [showProgress, setShowProgress] = useState(false); const [statusMessage, setStatusMessage] = useState(''); const [operationType, setOperationType] = useState<'sync' | 'load'>('sync'); const [abortController, setAbortController] = useState(null); const selectedTheme = themes.find(t => t.id === theme) || themes[0]; const [showClearLocalConfirm, setShowClearLocalConfirm] = useState(false); const [showClearServerConfirm, setShowClearServerConfirm] = useState(false); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const ttsProviders = useMemo(() => [ { id: 'custom-openai', name: 'Custom OpenAI-Like' }, { id: 'deepinfra', name: 'Deepinfra' }, { id: 'openai', name: 'OpenAI' } ], []); const ttsModels = useMemo(() => { switch (localTTSProvider) { case 'openai': return [ { id: 'tts-1', name: 'TTS-1' }, { id: 'tts-1-hd', name: 'TTS-1 HD' }, { id: 'gpt-4o-mini-tts', name: 'GPT-4o Mini TTS' } ]; case 'custom-openai': return [ { id: 'kokoro', name: 'Kokoro' }, { id: 'orpheus', name: 'Orpheus' }, { id: 'custom', name: 'Other' } ]; case 'deepinfra': // In production without an API key, limit to free tier model if (!isDev && !localApiKey) { return [ { id: 'hexgrad/Kokoro-82M', name: 'hexgrad/Kokoro-82M' } ]; } // In dev or with an API key, allow all models return [ { id: 'hexgrad/Kokoro-82M', name: 'hexgrad/Kokoro-82M' }, { id: 'canopylabs/orpheus-3b-0.1-ft', name: 'canopylabs/orpheus-3b-0.1-ft' }, { id: 'sesame/csm-1b', name: 'sesame/csm-1b' }, { id: 'ResembleAI/chatterbox', name: 'ResembleAI/chatterbox' }, { id: 'Zyphra/Zonos-v0.1-hybrid', name: 'Zyphra/Zonos-v0.1-hybrid' }, { id: 'Zyphra/Zonos-v0.1-transformer', name: 'Zyphra/Zonos-v0.1-transformer' }, { id: 'custom', name: 'Other' } ]; default: return [ { id: 'tts-1', name: 'TTS-1' } ]; } }, [localTTSProvider, localApiKey]); const supportsCustom = useMemo(() => localTTSProvider !== 'openai', [localTTSProvider]); const selectedModelId = useMemo( () => { const isPreset = ttsModels.some(m => m.id === modelValue); if (isPreset) return modelValue; return supportsCustom ? 'custom' : (ttsModels[0]?.id ?? ''); }, [ttsModels, modelValue, supportsCustom] ); const canSubmit = useMemo( () => selectedModelId !== 'custom' || (supportsCustom && customModelInput.trim().length > 0), [selectedModelId, supportsCustom, customModelInput] ); // set firstVisit on initial load const checkFirstVist = useCallback(async () => { if (!isDev) return; const firstVisit = await getItem('firstVisit'); if (firstVisit == null) { await setItem('firstVisit', 'true'); setIsOpen(true); } }, [setIsOpen]); useEffect(() => { checkFirstVist(); setLocalApiKey(apiKey); setLocalBaseUrl(baseUrl); setLocalTTSProvider(ttsProvider); setModelValue(ttsModel); setLocalTTSInstructions(ttsInstructions); }, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, checkFirstVist]); // Detect if current model is custom (not in presets) and mirror it in the input field useEffect(() => { if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') { setCustomModelInput(modelValue); } else { setCustomModelInput(''); } }, [modelValue, ttsModels]); const handleSync = async () => { const controller = new AbortController(); setAbortController(controller); try { setIsSyncing(true); setShowProgress(true); setProgress(0); setOperationType('sync'); setStatusMessage('Preparing documents...'); await indexedDBService.syncToServer((progress, status) => { if (controller.signal.aborted) return; setProgress(progress); if (status) setStatusMessage(status); }, controller.signal); } catch (error) { if (controller.signal.aborted) { console.log('Sync operation cancelled'); setStatusMessage('Operation cancelled'); } else { console.error('Sync failed:', error); setStatusMessage('Sync failed. Please try again.'); } } finally { setIsSyncing(false); setShowProgress(false); setProgress(0); setStatusMessage(''); setAbortController(null); } }; const handleLoad = async () => { const controller = new AbortController(); setAbortController(controller); try { setIsLoading(true); setShowProgress(true); setProgress(0); setOperationType('load'); setStatusMessage('Downloading documents from server...'); await indexedDBService.loadFromServer((progress, status) => { if (controller.signal.aborted) return; setProgress(progress); if (status) setStatusMessage(status); }, controller.signal); if (controller.signal.aborted) return; setStatusMessage('Refreshing document list...'); await Promise.all([refreshPDFs(), refreshEPUBs()]); } catch (error) { if (controller.signal.aborted) { console.log('Load operation cancelled'); setStatusMessage('Operation cancelled'); } else { console.error('Load failed:', error); setStatusMessage('Load failed. Please try again.'); } } finally { setIsLoading(false); setShowProgress(false); setProgress(0); setStatusMessage(''); setAbortController(null); } }; const handleClearLocal = async () => { await clearPDFs(); await clearEPUBs(); setShowClearLocalConfirm(false); }; const handleClearServer = async () => { try { const response = await fetch('/api/documents', { method: 'DELETE', }); if (!response.ok) { throw new Error('Failed to delete server documents'); } } catch (error) { console.error('Delete failed:', error); } setShowClearServerConfirm(false); }; const handleInputChange = (type: 'apiKey' | 'baseUrl', value: string) => { if (type === 'apiKey') { setLocalApiKey(value === '' ? '' : value); } else if (type === 'baseUrl') { setLocalBaseUrl(value === '' ? '' : value); } }; const resetToCurrent = useCallback(() => { setIsOpen(false); setLocalApiKey(apiKey); setLocalBaseUrl(baseUrl); setLocalTTSProvider(ttsProvider); setModelValue(ttsModel); setLocalTTSInstructions(ttsInstructions); if (!ttsModels.some(m => m.id === ttsModel) && ttsModel !== '') { setCustomModelInput(ttsModel); } else { setCustomModelInput(''); } }, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, ttsModels]); const tabs = [ { name: 'API', icon: '🔑' }, { name: 'Appearance', icon: '✨' }, { name: 'Documents', icon: '📄' } ]; return ( <>
Settings {tabs.map((tab) => ( `w-full rounded-lg py-1 text-sm font-medium ring-accent/60 ring-offset-2 ring-offset-base ${selected ? 'bg-accent text-background shadow' : 'text-foreground hover:text-accent' }` } > {tab.icon} {tab.name} ))}
p.id === localTTSProvider) || ttsProviders[0]} onChange={(provider) => { setLocalTTSProvider(provider.id); // Set default model and base_url for each provider if (provider.id === 'openai') { setModelValue('tts-1'); setLocalBaseUrl('https://api.openai.com/v1'); } else if (provider.id === 'custom-openai') { setModelValue('kokoro'); // Clear baseUrl for custom provider setLocalBaseUrl(''); } else if (provider.id === 'deepinfra') { setModelValue('hexgrad/Kokoro-82M'); setLocalBaseUrl('https://api.deepinfra.com/v1/openai'); } setCustomModelInput(''); }} > {ttsProviders.find(p => p.id === localTTSProvider)?.name || 'Select Provider'} {ttsProviders.map((provider) => ( `relative cursor-pointer select-none py-2 pl-10 pr-4 ${ active ? 'bg-accent/10 text-accent' : 'text-foreground' }` } value={provider} > {({ selected }) => ( <> {provider.name} {selected ? ( ) : null} )} ))}
handleInputChange('apiKey', e.target.value)} placeholder={!isDev && localTTSProvider === 'deepinfra' ? "Deepinfra free or override apikey" : "Using environment variable"} className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" />
m.id === selectedModelId) || ttsModels[0]} onChange={(model) => { if (model.id === 'custom') { // Switch to custom: keep the current custom input (or empty) setModelValue(customModelInput); } else { setModelValue(model.id); setCustomModelInput(''); } }} > {ttsModels.find(m => m.id === selectedModelId)?.name || 'Select Model'} {ttsModels.map((model) => ( `relative cursor-pointer select-none py-2 pl-10 pr-4 ${ active ? 'bg-accent/10 text-accent' : 'text-foreground' }` } value={model} > {({ selected }) => ( <> {model.name} {selected ? ( ) : null} )} ))} {supportsCustom && selectedModelId === 'custom' && ( { setCustomModelInput(e.target.value); setModelValue(e.target.value); }} placeholder="Enter custom model name" className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" /> )}
{modelValue === 'gpt-4o-mini-tts' && (