From f464b059e400f4af1e2dd6b0ebe010547953bdd6 Mon Sep 17 00:00:00 2001 From: Richard Roberson Date: Thu, 13 Mar 2025 21:00:34 -0600 Subject: [PATCH] Update API settings --- README.md | 4 +- src/app/api/tts/route.ts | 6 +- src/app/layout.tsx | 1 - src/components/SettingsModal.tsx | 386 ++++++++++++++++++------------- src/contexts/ConfigContext.tsx | 30 ++- src/contexts/TTSContext.tsx | 8 +- tests/helpers.ts | 3 +- 7 files changed, 260 insertions(+), 178 deletions(-) diff --git a/README.md b/README.md index 19e72e7..b7c0261 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index 255748a..ee5be18 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -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, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 116bb5e..062c636 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -40,7 +40,6 @@ export const metadata: Metadata = { }, }, verification: { - // Add your verification codes if you have them google: "MJXyTudn1kgQF8EtGD-tsnAWev7Iawso9hEvqeGHB3U", }, }; diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 5066d11..de6e67e 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -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 ( - - - } - -
- -
- - {isDev && } -
-
- - - -
- - -
+ onClick={async () => { + await updateConfig({ + apiKey: localApiKey || '', + baseUrl: localBaseUrl || '', + }); + await updateConfigKey('ttsModel', localTTSModel || 'tts-1'); + setIsOpen(false); + }} + > + Done + + + + + + {isDev &&
+ +
+ + +
+
} + +
+ +
+ + {isDev && } +
+
+
+ + diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index 0086a1f..7b23ff0 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -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; updateConfigKey: (key: K, value: ConfigValues[K]) => Promise; isLoading: boolean; @@ -59,11 +59,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const [voice, setVoice] = useState('af_sarah'); const [skipBlank, setSkipBlank] = useState(true); const [epubTheme, setEpubTheme] = useState(false); - const [textExtractionMargin, setTextExtractionMargin] = useState(0.07); const [headerMargin, setHeaderMargin] = useState(0.07); const [footerMargin, setFooterMargin] = useState(0.07); const [leftMargin, setLeftMargin] = useState(0.07); const [rightMargin, setRightMargin] = useState(0.07); + const [ttsModel, setTTSModel] = useState('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 (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, diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 35cb2bb..572ea39 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -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(null); const [speed, setSpeed] = useState(voiceSpeed); const [voice, setVoice] = useState(configVoice); + const [ttsModel, setTTSModel] = useState(configTTSModel); // Track pending preload requests const preloadRequests = useRef>>(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 diff --git a/tests/helpers.ts b/tests/helpers.ts index 6b07ef8..01b7b96 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -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(); } \ No newline at end of file