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
- 🛜 **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
- 📄 **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
- 📲 **Mobile Support**: Works on mobile devices, and can be added as a PWA web app
- 🎨 **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 🤞**
- [ ] **End-to-end Testing**: More playwright tests (in progress)
- [ ] **More document formats**: .txt, .md
- [ ] **Support more TTS APIs**: ElevenLabs, Ollama, etc.
- [ ] **Support more TTS APIs**: ElevenLabs, etc.
- [ ] **Accessibility Improvements**
## 🐳 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
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 { text, voice, speed, format } = await req.json();
console.log('Received TTS request:', text, voice, speed, format);
const { text, voice, speed, format, model } = await req.json();
console.log('Received TTS request:', text, voice, speed, format, model);
if (!openApiKey) {
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
const response = await openai.audio.speech.create({
model: 'tts-1',
model: model || 'tts-1',
voice: voice as "alloy",
input: text,
speed: speed,

View file

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

View file

@ -1,7 +1,24 @@
'use client';
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 { useConfig } from '@/contexts/ConfigContext';
import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons';
@ -22,10 +39,11 @@ export function SettingsModal() {
const [isOpen, setIsOpen] = useState(false);
const { theme, setTheme } = useTheme();
const { apiKey, baseUrl, updateConfig } = useConfig();
const { apiKey, baseUrl, ttsModel, updateConfig, updateConfigKey } = useConfig();
const { refreshPDFs, refreshEPUBs, clearPDFs, clearEPUBs } = useDocuments();
const [localApiKey, setLocalApiKey] = useState(apiKey);
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
const [localTTSModel, setLocalTTSModel] = useState(ttsModel);
const [isSyncing, setIsSyncing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const selectedTheme = themes.find(t => t.id === theme) || themes[0];
@ -46,7 +64,8 @@ export function SettingsModal() {
checkFirstVist();
setLocalApiKey(apiKey);
setLocalBaseUrl(baseUrl);
}, [apiKey, baseUrl, checkFirstVist]);
setLocalTTSModel(ttsModel);
}, [apiKey, baseUrl, ttsModel, checkFirstVist]);
const handleSync = async () => {
try {
@ -91,14 +110,22 @@ export function SettingsModal() {
setShowClearServerConfirm(false);
};
const handleInputChange = (type: 'apiKey' | 'baseUrl', value: string) => {
const handleInputChange = (type: 'apiKey' | 'baseUrl' | 'ttsModel', value: string) => {
if (type === 'apiKey') {
setLocalApiKey(value === '' ? '' : value);
} else {
} else if (type === 'baseUrl') {
setLocalBaseUrl(value === '' ? '' : value);
} else if (type === 'ttsModel') {
setLocalTTSModel(value === '' ? 'tts-1' : value);
}
};
const tabs = [
{ name: 'Appearance', icon: '✨' },
{ name: 'API', icon: '🔑' },
{ name: 'Documents', icon: '📄' }
];
return (
<Button
onClick={() => setIsOpen(true)}
@ -140,12 +167,34 @@ 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">
<DialogTitle
as="h3"
className="text-lg font-semibold leading-6 text-foreground"
className="text-lg font-semibold leading-6 text-foreground mb-4"
>
Settings
</DialogTitle>
<div className="mt-4">
<div className="relative space-y-4">
<TabGroup>
<TabList className="flex space-x-1 rounded-xl bg-background p-1 mb-4">
{tabs.map((tab) => (
<Tab
key={tab.name}
className={({ selected }) =>
`w-full rounded-lg py-1 text-sm font-medium
ring-accent/60 ring-offset-2 ring-offset-base
${selected
? 'bg-accent text-white shadow'
: 'text-foreground hover:bg-accent/[0.12] hover:text-accent'
}`
}
>
<span className="flex items-center justify-center gap-2">
<span>{tab.icon}</span>
{tab.name}
</span>
</Tab>
))}
</TabList>
<TabPanels className="mt-2">
<TabPanel className="space-y-4 pb-3">
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">Theme</label>
<Listbox value={selectedTheme} onChange={(newTheme) => setTheme(newTheme.id)}>
@ -189,7 +238,9 @@ export function SettingsModal() {
</Transition>
</Listbox>
</div>
</TabPanel>
<TabPanel className="space-y-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
OpenAI API Key
@ -222,6 +273,58 @@ export function SettingsModal() {
</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
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
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"
onClick={async () => {
setLocalApiKey('');
setLocalBaseUrl('');
setLocalTTSModel('tts-1');
}}
>
Reset
</Button>
<Button
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
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"
onClick={async () => {
await updateConfig({
apiKey: localApiKey || '',
baseUrl: localBaseUrl || '',
});
await updateConfigKey('ttsModel', localTTSModel || 'tts-1');
setIsOpen(false);
}}
>
Done
</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">
@ -250,7 +353,7 @@ export function SettingsModal() {
</div>
</div>}
<div className="space-y-2">
<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
@ -273,40 +376,9 @@ export function SettingsModal() {
</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
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"
onClick={async () => {
setLocalApiKey('');
setLocalBaseUrl('');
}}
>
Reset
</Button>
<Button
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
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"
onClick={async () => {
await updateConfig({
apiKey: localApiKey || '',
baseUrl: localBaseUrl || '',
});
setIsOpen(false);
}}
>
Done
</Button>
</div>
</TabPanel>
</TabPanels>
</TabGroup>
</DialogPanel>
</TransitionChild>
</div>

View file

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

View file

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

View file

@ -75,5 +75,6 @@ export async function setupTest(page: Page) {
await page.waitForLoadState('networkidle');
// 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();
}