Update API settings

This commit is contained in:
Richard Roberson 2025-03-13 21:00:34 -06:00
parent 12b46cc8e3
commit f464b059e4
7 changed files with 260 additions and 178 deletions

View file

@ -17,7 +17,7 @@ OpenReader WebUI is a document reader with Text-to-Speech capabilities, offering
- 💾 **Local-First Architecture**: Uses IndexedDB browser storage for documents - 💾 **Local-First Architecture**: Uses IndexedDB browser storage for documents
- 🛜 **Optional Server-side documents**: Manually upload documents to the next backend for all users to download - 🛜 **Optional Server-side documents**: Manually upload documents to the next backend for all users to download
- 📖 **Read Along Experience**: Follow along with highlighted text as the TTS narrates - 📖 **Read Along Experience**: Follow along with highlighted text as the TTS narrates
- 📄 **Document formats**: EPUB, PDF, DOCX - 📄 **Document formats**: EPUB, PDF, DOCX (with libreoffice installed)
- 🎧 **Audiobook Creation**: Create and export audiobooks from PDF and ePub files with m4b format - 🎧 **Audiobook Creation**: Create and export audiobooks from PDF and ePub files with m4b format
- 📲 **Mobile Support**: Works on mobile devices, and can be added as a PWA web app - 📲 **Mobile Support**: Works on mobile devices, and can be added as a PWA web app
- 🎨 **Customizable Experience**: - 🎨 **Customizable Experience**:
@ -33,7 +33,7 @@ OpenReader WebUI is a document reader with Text-to-Speech capabilities, offering
- [x] **Get PDFs on iOS 17 and below working 🤞** - [x] **Get PDFs on iOS 17 and below working 🤞**
- [ ] **End-to-end Testing**: More playwright tests (in progress) - [ ] **End-to-end Testing**: More playwright tests (in progress)
- [ ] **More document formats**: .txt, .md - [ ] **More document formats**: .txt, .md
- [ ] **Support more TTS APIs**: ElevenLabs, Ollama, etc. - [ ] **Support more TTS APIs**: ElevenLabs, etc.
- [ ] **Accessibility Improvements** - [ ] **Accessibility Improvements**
## 🐳 Docker Quick Start ## 🐳 Docker Quick Start

View file

@ -6,8 +6,8 @@ export async function POST(req: NextRequest) {
// Get API credentials from headers or fall back to environment variables // Get API credentials from headers or fall back to environment variables
const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none'; const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE; const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
const { text, voice, speed, format } = await req.json(); const { text, voice, speed, format, model } = await req.json();
console.log('Received TTS request:', text, voice, speed, format); console.log('Received TTS request:', text, voice, speed, format, model);
if (!openApiKey) { if (!openApiKey) {
return NextResponse.json({ error: 'Missing OpenAI API key' }, { status: 401 }); return NextResponse.json({ error: 'Missing OpenAI API key' }, { status: 401 });
@ -25,7 +25,7 @@ export async function POST(req: NextRequest) {
// Request audio from OpenAI and pass along the abort signal // Request audio from OpenAI and pass along the abort signal
const response = await openai.audio.speech.create({ const response = await openai.audio.speech.create({
model: 'tts-1', model: model || 'tts-1',
voice: voice as "alloy", voice: voice as "alloy",
input: text, input: text,
speed: speed, speed: speed,

View file

@ -40,7 +40,6 @@ export const metadata: Metadata = {
}, },
}, },
verification: { verification: {
// Add your verification codes if you have them
google: "MJXyTudn1kgQF8EtGD-tsnAWev7Iawso9hEvqeGHB3U", google: "MJXyTudn1kgQF8EtGD-tsnAWev7Iawso9hEvqeGHB3U",
}, },
}; };

View file

@ -1,7 +1,24 @@
'use client'; 'use client';
import { Fragment, useState, useEffect, useCallback } from 'react'; import { Fragment, useState, useEffect, useCallback } from 'react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button, Input } from '@headlessui/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 { useTheme } from '@/contexts/ThemeContext';
import { useConfig } from '@/contexts/ConfigContext'; import { useConfig } from '@/contexts/ConfigContext';
import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons'; import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons';
@ -22,10 +39,11 @@ export function SettingsModal() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { apiKey, baseUrl, updateConfig } = useConfig(); const { apiKey, baseUrl, ttsModel, updateConfig, updateConfigKey } = useConfig();
const { refreshPDFs, refreshEPUBs, clearPDFs, clearEPUBs } = useDocuments(); const { refreshPDFs, refreshEPUBs, clearPDFs, clearEPUBs } = useDocuments();
const [localApiKey, setLocalApiKey] = useState(apiKey); const [localApiKey, setLocalApiKey] = useState(apiKey);
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl); const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
const [localTTSModel, setLocalTTSModel] = useState(ttsModel);
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const selectedTheme = themes.find(t => t.id === theme) || themes[0]; const selectedTheme = themes.find(t => t.id === theme) || themes[0];
@ -46,7 +64,8 @@ export function SettingsModal() {
checkFirstVist(); checkFirstVist();
setLocalApiKey(apiKey); setLocalApiKey(apiKey);
setLocalBaseUrl(baseUrl); setLocalBaseUrl(baseUrl);
}, [apiKey, baseUrl, checkFirstVist]); setLocalTTSModel(ttsModel);
}, [apiKey, baseUrl, ttsModel, checkFirstVist]);
const handleSync = async () => { const handleSync = async () => {
try { try {
@ -91,14 +110,22 @@ export function SettingsModal() {
setShowClearServerConfirm(false); setShowClearServerConfirm(false);
}; };
const handleInputChange = (type: 'apiKey' | 'baseUrl', value: string) => { const handleInputChange = (type: 'apiKey' | 'baseUrl' | 'ttsModel', value: string) => {
if (type === 'apiKey') { if (type === 'apiKey') {
setLocalApiKey(value === '' ? '' : value); setLocalApiKey(value === '' ? '' : value);
} else { } else if (type === 'baseUrl') {
setLocalBaseUrl(value === '' ? '' : value); setLocalBaseUrl(value === '' ? '' : value);
} else if (type === 'ttsModel') {
setLocalTTSModel(value === '' ? 'tts-1' : value);
} }
}; };
const tabs = [
{ name: 'Appearance', icon: '✨' },
{ name: 'API', icon: '🔑' },
{ name: 'Documents', icon: '📄' }
];
return ( return (
<Button <Button
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
@ -140,173 +167,218 @@ export function SettingsModal() {
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all"> <DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle <DialogTitle
as="h3" as="h3"
className="text-lg font-semibold leading-6 text-foreground" className="text-lg font-semibold leading-6 text-foreground mb-4"
> >
Settings Settings
</DialogTitle> </DialogTitle>
<div className="mt-4">
<div className="relative space-y-4"> <TabGroup>
<div className="space-y-2"> <TabList className="flex space-x-1 rounded-xl bg-background p-1 mb-4">
<label className="block text-sm font-medium text-foreground">Theme</label> {tabs.map((tab) => (
<Listbox value={selectedTheme} onChange={(newTheme) => setTheme(newTheme.id)}> <Tab
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent"> key={tab.name}
<span className="block truncate">{selectedTheme.name}</span> className={({ selected }) =>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> `w-full rounded-lg py-1 text-sm font-medium
<ChevronUpDownIcon className="h-5 w-5 text-muted" /> ring-accent/60 ring-offset-2 ring-offset-base
</span> ${selected
</ListboxButton> ? 'bg-accent text-white shadow'
<Transition : 'text-foreground hover:bg-accent/[0.12] hover:text-accent'
as={Fragment} }`
leave="transition ease-in duration-100" }
leaveFrom="opacity-100" >
leaveTo="opacity-0" <span className="flex items-center justify-center gap-2">
> <span>{tab.icon}</span>
<ListboxOptions className="absolute mt-1 max-h-40 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none"> {tab.name}
{themes.map((theme) => ( </span>
<ListboxOption </Tab>
key={theme.id} ))}
className={({ active }) => </TabList>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-accent/10 text-accent' : 'text-foreground' <TabPanels className="mt-2">
}` <TabPanel className="space-y-4 pb-3">
} <div className="space-y-2">
value={theme} <label className="block text-sm font-medium text-foreground">Theme</label>
> <Listbox value={selectedTheme} onChange={(newTheme) => setTheme(newTheme.id)}>
{({ selected }) => ( <ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent">
<> <span className="block truncate">{selectedTheme.name}</span>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
{theme.name} <ChevronUpDownIcon className="h-5 w-5 text-muted" />
</span> </span>
{selected ? ( </ListboxButton>
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent"> <Transition
<CheckIcon className="h-5 w-5" /> as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions className="absolute mt-1 max-h-40 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none">
{themes.map((theme) => (
<ListboxOption
key={theme.id}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-accent/10 text-accent' : 'text-foreground'
}`
}
value={theme}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{theme.name}
</span> </span>
) : null} {selected ? (
</> <span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
)} <CheckIcon className="h-5 w-5" />
</ListboxOption> </span>
))} ) : null}
</ListboxOptions> </>
</Transition> )}
</Listbox> </ListboxOption>
</div> ))}
</ListboxOptions>
<div className="space-y-2"> </Transition>
<label className="block text-sm font-medium text-foreground"> </Listbox>
OpenAI API Key
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
</label>
<div className="flex gap-2">
<Input
type="password"
value={localApiKey}
onChange={(e) => handleInputChange('apiKey', e.target.value)}
placeholder="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"
/>
</div> </div>
</div> </TabPanel>
<div className="space-y-2"> <TabPanel className="space-y-4">
<label className="block text-sm font-medium text-foreground"> <div className="space-y-2">
OpenAI API Base URL <label className="block text-sm font-medium text-foreground">
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>} OpenAI API Key
</label> {localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
<div className="flex gap-2"> </label>
<Input <div className="flex gap-2">
type="text" <Input
value={localBaseUrl} type="password"
onChange={(e) => handleInputChange('baseUrl', e.target.value)} value={localApiKey}
placeholder="Using environment variable" onChange={(e) => handleInputChange('apiKey', e.target.value)}
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" placeholder="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"
/>
</div>
</div> </div>
</div>
{isDev && <div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-foreground">Document Sync</label> <label className="block text-sm font-medium text-foreground">
<div className="flex gap-2"> OpenAI API Base URL
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
</label>
<div className="flex gap-2">
<Input
type="text"
value={localBaseUrl}
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
placeholder="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"
/>
</div>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
OpenAI TTS Model
{localTTSModel !== 'tts-1' && <span className="ml-2 text-xs text-accent">(Custom model)</span>}
</label>
<div className="flex gap-2">
<Input
type="text"
value={localTTSModel}
onChange={(e) => handleInputChange('ttsModel', e.target.value)}
placeholder="tts-1"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button <Button
onClick={handleLoad} type="button"
disabled={isSyncing || isLoading} className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-background/90 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
>
{isLoading ? 'Loading...' : 'Load docs from Server'}
</Button>
<Button
onClick={handleSync}
disabled={isSyncing || isLoading}
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-background/90 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
>
{isSyncing ? 'Saving...' : 'Save local to Server'}
</Button>
</div>
</div>}
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">Bulk Delete</label>
<div className="flex gap-2">
<Button
onClick={() => setShowClearLocalConfirm(true)}
className="justify-center rounded-lg bg-red-600 px-3 py-1.5 text-sm
font-medium text-white hover:bg-red-700 focus:outline-none
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
>
Delete local docs
</Button>
{isDev && <Button
onClick={() => setShowClearServerConfirm(true)}
className="justify-center rounded-lg bg-red-600 px-3 py-1.5 text-sm
font-medium text-white hover:bg-red-700 focus:outline-none
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
>
Delete server docs
</Button>}
</div>
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-background/90 focus:outline-none font-medium text-foreground hover:bg-background/90 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
onClick={async () => { onClick={async () => {
setLocalApiKey(''); setLocalApiKey('');
setLocalBaseUrl(''); setLocalBaseUrl('');
}} setLocalTTSModel('tts-1');
> }}
Reset >
</Button> Reset
<Button </Button>
type="button" <Button
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm type="button"
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
font-medium text-white hover:bg-accent/90 focus:outline-none font-medium text-white hover:bg-accent/90 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background" transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
onClick={async () => { onClick={async () => {
await updateConfig({ await updateConfig({
apiKey: localApiKey || '', apiKey: localApiKey || '',
baseUrl: localBaseUrl || '', baseUrl: localBaseUrl || '',
}); });
setIsOpen(false); await updateConfigKey('ttsModel', localTTSModel || 'tts-1');
}} setIsOpen(false);
> }}
Done >
</Button> Done
</div> </Button>
</div>
</TabPanel>
<TabPanel className="space-y-4">
{isDev && <div className="space-y-2">
<label className="block text-sm font-medium text-foreground">Document Sync</label>
<div className="flex gap-2">
<Button
onClick={handleLoad}
disabled={isSyncing || isLoading}
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-background/90 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
>
{isLoading ? 'Loading...' : 'Load docs from Server'}
</Button>
<Button
onClick={handleSync}
disabled={isSyncing || isLoading}
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-background/90 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
>
{isSyncing ? 'Saving...' : 'Save local to Server'}
</Button>
</div>
</div>}
<div className="space-y-2 pb-3">
<label className="block text-sm font-medium text-foreground">Bulk Delete</label>
<div className="flex gap-2">
<Button
onClick={() => setShowClearLocalConfirm(true)}
className="justify-center rounded-lg bg-red-600 px-3 py-1.5 text-sm
font-medium text-white hover:bg-red-700 focus:outline-none
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
>
Delete local docs
</Button>
{isDev && <Button
onClick={() => setShowClearServerConfirm(true)}
className="justify-center rounded-lg bg-red-600 px-3 py-1.5 text-sm
font-medium text-white hover:bg-red-700 focus:outline-none
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
>
Delete server docs
</Button>}
</div>
</div>
</TabPanel>
</TabPanels>
</TabGroup>
</DialogPanel> </DialogPanel>
</TransitionChild> </TransitionChild>
</div> </div>

View file

@ -15,11 +15,11 @@ type ConfigValues = {
voice: string; voice: string;
skipBlank: boolean; skipBlank: boolean;
epubTheme: boolean; epubTheme: boolean;
textExtractionMargin: number;
headerMargin: number; headerMargin: number;
footerMargin: number; footerMargin: number;
leftMargin: number; leftMargin: number;
rightMargin: number; rightMargin: number;
ttsModel: string;
}; };
/** Interface defining the configuration context shape and functionality */ /** Interface defining the configuration context shape and functionality */
@ -31,11 +31,11 @@ interface ConfigContextType {
voice: string; voice: string;
skipBlank: boolean; skipBlank: boolean;
epubTheme: boolean; epubTheme: boolean;
textExtractionMargin: number;
headerMargin: number; headerMargin: number;
footerMargin: number; footerMargin: number;
leftMargin: number; leftMargin: number;
rightMargin: number; rightMargin: number;
ttsModel: string;
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise<void>; updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise<void>;
updateConfigKey: <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => Promise<void>; updateConfigKey: <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => Promise<void>;
isLoading: boolean; isLoading: boolean;
@ -59,11 +59,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const [voice, setVoice] = useState<string>('af_sarah'); const [voice, setVoice] = useState<string>('af_sarah');
const [skipBlank, setSkipBlank] = useState<boolean>(true); const [skipBlank, setSkipBlank] = useState<boolean>(true);
const [epubTheme, setEpubTheme] = useState<boolean>(false); const [epubTheme, setEpubTheme] = useState<boolean>(false);
const [textExtractionMargin, setTextExtractionMargin] = useState<number>(0.07);
const [headerMargin, setHeaderMargin] = useState<number>(0.07); const [headerMargin, setHeaderMargin] = useState<number>(0.07);
const [footerMargin, setFooterMargin] = useState<number>(0.07); const [footerMargin, setFooterMargin] = useState<number>(0.07);
const [leftMargin, setLeftMargin] = useState<number>(0.07); const [leftMargin, setLeftMargin] = useState<number>(0.07);
const [rightMargin, setRightMargin] = useState<number>(0.07); const [rightMargin, setRightMargin] = useState<number>(0.07);
const [ttsModel, setTTSModel] = useState<string>('tts-1');
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isDBReady, setIsDBReady] = useState(false); const [isDBReady, setIsDBReady] = useState(false);
@ -83,11 +83,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const cachedVoice = await getItem('voice'); const cachedVoice = await getItem('voice');
const cachedSkipBlank = await getItem('skipBlank'); const cachedSkipBlank = await getItem('skipBlank');
const cachedEpubTheme = await getItem('epubTheme'); const cachedEpubTheme = await getItem('epubTheme');
const cachedMargin = await getItem('textExtractionMargin');
const cachedHeaderMargin = await getItem('headerMargin'); const cachedHeaderMargin = await getItem('headerMargin');
const cachedFooterMargin = await getItem('footerMargin'); const cachedFooterMargin = await getItem('footerMargin');
const cachedLeftMargin = await getItem('leftMargin'); const cachedLeftMargin = await getItem('leftMargin');
const cachedRightMargin = await getItem('rightMargin'); const cachedRightMargin = await getItem('rightMargin');
const cachedTTSModel = await getItem('ttsModel');
// Only set API key and base URL if they were explicitly saved by the user // Only set API key and base URL if they were explicitly saved by the user
if (cachedApiKey) { if (cachedApiKey) {
@ -105,11 +105,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
setVoice(cachedVoice || 'af_sarah'); setVoice(cachedVoice || 'af_sarah');
setSkipBlank(cachedSkipBlank === 'false' ? false : true); setSkipBlank(cachedSkipBlank === 'false' ? false : true);
setEpubTheme(cachedEpubTheme === 'true'); setEpubTheme(cachedEpubTheme === 'true');
setTextExtractionMargin(parseFloat(cachedMargin || '0.07'));
setHeaderMargin(parseFloat(cachedHeaderMargin || '0.07')); setHeaderMargin(parseFloat(cachedHeaderMargin || '0.07'));
setFooterMargin(parseFloat(cachedFooterMargin || '0.07')); setFooterMargin(parseFloat(cachedFooterMargin || '0.07'));
setLeftMargin(parseFloat(cachedLeftMargin || '0.07')); setLeftMargin(parseFloat(cachedLeftMargin || '0.07'));
setRightMargin(parseFloat(cachedRightMargin || '0.07')); setRightMargin(parseFloat(cachedRightMargin || '0.07'));
setTTSModel(cachedTTSModel || 'tts-1');
// Only save non-sensitive settings by default // Only save non-sensitive settings by default
if (!cachedViewType) { if (!cachedViewType) {
@ -121,13 +121,13 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
if (cachedEpubTheme === null) { if (cachedEpubTheme === null) {
await setItem('epubTheme', 'false'); await setItem('epubTheme', 'false');
} }
if (cachedMargin === null) {
await setItem('textExtractionMargin', '0.07');
}
if (cachedHeaderMargin === null) await setItem('headerMargin', '0.07'); if (cachedHeaderMargin === null) await setItem('headerMargin', '0.07');
if (cachedFooterMargin === null) await setItem('footerMargin', '0.07'); if (cachedFooterMargin === null) await setItem('footerMargin', '0.07');
if (cachedLeftMargin === null) await setItem('leftMargin', '0.0'); if (cachedLeftMargin === null) await setItem('leftMargin', '0.0');
if (cachedRightMargin === null) await setItem('rightMargin', '0.0'); if (cachedRightMargin === null) await setItem('rightMargin', '0.0');
if (cachedTTSModel === null) {
await setItem('ttsModel', 'tts-1');
}
} catch (error) { } catch (error) {
console.error('Error initializing:', error); console.error('Error initializing:', error);
@ -145,6 +145,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
*/ */
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => { const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => {
try { try {
setIsLoading(true);
if (newConfig.apiKey !== undefined || newConfig.apiKey !== '') { if (newConfig.apiKey !== undefined || newConfig.apiKey !== '') {
// Only save API key to IndexedDB if it's different from env default // Only save API key to IndexedDB if it's different from env default
await setItem('apiKey', newConfig.apiKey!); await setItem('apiKey', newConfig.apiKey!);
@ -169,6 +170,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
} catch (error) { } catch (error) {
console.error('Error updating config:', error); console.error('Error updating config:', error);
throw error; throw error;
} finally {
setIsLoading(false);
} }
}; };
@ -179,6 +182,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
*/ */
const updateConfigKey = async <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => { const updateConfigKey = async <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => {
try { try {
setIsLoading(true);
await setItem(key, value.toString()); await setItem(key, value.toString());
switch (key) { switch (key) {
case 'apiKey': case 'apiKey':
@ -202,9 +206,6 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
case 'epubTheme': case 'epubTheme':
setEpubTheme(value as boolean); setEpubTheme(value as boolean);
break; break;
case 'textExtractionMargin':
setTextExtractionMargin(value as number);
break;
case 'headerMargin': case 'headerMargin':
setHeaderMargin(value as number); setHeaderMargin(value as number);
break; break;
@ -217,10 +218,15 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
case 'rightMargin': case 'rightMargin':
setRightMargin(value as number); setRightMargin(value as number);
break; break;
case 'ttsModel':
setTTSModel(value as string);
break;
} }
} catch (error) { } catch (error) {
console.error(`Error updating config key ${key}:`, error); console.error(`Error updating config key ${key}:`, error);
throw error; throw error;
} finally {
setIsLoading(false);
} }
}; };
@ -233,11 +239,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
voice, voice,
skipBlank, skipBlank,
epubTheme, epubTheme,
textExtractionMargin,
headerMargin, headerMargin,
footerMargin, footerMargin,
leftMargin, leftMargin,
rightMargin, rightMargin,
ttsModel,
updateConfig, updateConfig,
updateConfigKey, updateConfigKey,
isLoading, isLoading,

View file

@ -97,6 +97,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
isLoading: configIsLoading, isLoading: configIsLoading,
voiceSpeed, voiceSpeed,
voice: configVoice, voice: configVoice,
ttsModel: configTTSModel,
updateConfigKey, updateConfigKey,
skipBlank, skipBlank,
} = useConfig(); } = useConfig();
@ -138,6 +139,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
const [activeHowl, setActiveHowl] = useState<Howl | null>(null); const [activeHowl, setActiveHowl] = useState<Howl | null>(null);
const [speed, setSpeed] = useState(voiceSpeed); const [speed, setSpeed] = useState(voiceSpeed);
const [voice, setVoice] = useState(configVoice); const [voice, setVoice] = useState(configVoice);
const [ttsModel, setTTSModel] = useState(configTTSModel);
// Track pending preload requests // Track pending preload requests
const preloadRequests = useRef<Map<string, Promise<string>>>(new Map()); const preloadRequests = useRef<Map<string, Promise<string>>>(new Map());
@ -386,8 +388,9 @@ export function TTSProvider({ children }: { children: ReactNode }) {
if (!configIsLoading) { if (!configIsLoading) {
fetchVoices(); fetchVoices();
updateVoiceAndSpeed(); updateVoiceAndSpeed();
setTTSModel(configTTSModel);
} }
}, [configIsLoading, openApiKey, openApiBaseUrl, updateVoiceAndSpeed, fetchVoices]); }, [configIsLoading, openApiKey, openApiBaseUrl, updateVoiceAndSpeed, fetchVoices, configTTSModel]);
/** /**
* Generates and plays audio for the current sentence * Generates and plays audio for the current sentence
@ -423,6 +426,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
text: sentence, text: sentence,
voice: voice, voice: voice,
speed: speed, speed: speed,
model: ttsModel
}), }),
signal: controller.signal, signal: controller.signal,
}); });
@ -466,7 +470,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
}); });
throw error; throw error;
} }
}, [voice, speed, audioCache, openApiKey, openApiBaseUrl]); }, [voice, speed, ttsModel, audioCache, openApiKey, openApiBaseUrl]);
/** /**
* Processes and plays the current sentence * Processes and plays the current sentence

View file

@ -75,5 +75,6 @@ export async function setupTest(page: Page) {
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
// Click the "done" button to dismiss the welcome message // Click the "done" button to dismiss the welcome message
await page.getByText('Done').click(); await page.getByRole('tab', { name: '🔑 API' }).click();
await page.getByRole('button', { name: 'Done' }).click();
} }