Environment changes, set in Docker run

This commit is contained in:
Richard Roberson 2025-02-23 13:20:42 -07:00
parent 9ad1f5f117
commit 96376e2324
9 changed files with 119 additions and 73 deletions

View file

@ -1,9 +1,8 @@
NEXT_PUBLIC_NODE_ENV=development
# OpenAI API Key for Text-to-Speech functionality # OpenAI API Key for Text-to-Speech functionality
NEXT_PUBLIC_OPENAI_API_KEY=your_openai_api_key_here API_KEY=api_key_here_if_needed
# OpenAI API Base URL (default) # OpenAI API Base URL (default)
# To use a local TTS model server, I suggest using https://github.com/remsky/Kokoro-FastAPI # To use a local TTS model server, I suggest using https://github.com/remsky/Kokoro-FastAPI
NEXT_PUBLIC_OPENAI_API_BASE=https://api.openai.com/v1 API_BASE=https://api.openai.com/v1
# Add other environment variables below as needed
NEXT_PUBLIC_NODE_ENV=development

View file

@ -42,11 +42,21 @@ docker run --name openreader-webui \
-v openreader_docstore:/app/docstore \ -v openreader_docstore:/app/docstore \
richardr1126/openreader-webui:latest richardr1126/openreader-webui:latest
``` ```
> **Note:** The `openreader_docstore` volume is used to store server-side documents. You can mount a local directory instead. Or remove it if you don't need server-side documents.
(Optionally): Set the TTS `API_BASE` URL and/or `API_KEY` to be default for all devices
```bash
docker run --name openreader-webui \
-e API_BASE=http://host.docker.internal:8880/v1 \
-p 3003:3003 \
-v openreader_docstore:/app/docstore \
richardr1126/openreader-webui:latest
```
> Requesting audio from the TTS API happens on the Next.js server not the client. So the base URL for the TTS API should be accessible and relative to the Next.js server. If it is in a Docker you may need to use `host.docker.internal` to access the host machine, instead of `localhost`.
Visit [http://localhost:3003](http://localhost:3003) to run the app and set your settings. Visit [http://localhost:3003](http://localhost:3003) to run the app and set your settings.
> Requesting audio from the TTS API happens on the Next.js server not the client. So the base URL for the TTS API should be accessible and relative to the Next.js server. > **Note:** The `openreader_docstore` volume is used to store server-side documents. You can mount a local directory instead. Or remove it if you don't need server-side documents.
### ⬆️ Update Docker Image ### ⬆️ Update Docker Image
```bash ```bash
@ -64,6 +74,8 @@ services:
openreader-webui: openreader-webui:
container_name: openreader-webui container_name: openreader-webui
image: richardr1126/openreader-webui:latest image: richardr1126/openreader-webui:latest
environment:
- API_BASE=http://host.docker.internal:8880/v1
ports: ports:
- "3003:3003" - "3003:3003"
volumes: volumes:

View file

@ -3,14 +3,14 @@ import OpenAI from 'openai';
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
// Get API credentials from headers // Get API credentials from headers or fall back to environment variables
const openApiKey = req.headers.get('x-openai-key'); const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
const openApiBaseUrl = req.headers.get('x-openai-base-url'); const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
const { text, voice, speed } = await req.json(); const { text, voice, speed } = await req.json();
console.log('Received TTS request:', text, voice, speed); console.log('Received TTS request:', text, voice, speed);
if (!openApiKey || !openApiBaseUrl) { if (!openApiKey) {
return NextResponse.json({ error: 'Missing API credentials' }, { status: 401 }); return NextResponse.json({ error: 'Missing OpenAI API key' }, { status: 401 });
} }
if (!text || !voice || !speed) { if (!text || !voice || !speed) {
@ -20,7 +20,7 @@ export async function POST(req: NextRequest) {
// Initialize OpenAI client with abort signal // Initialize OpenAI client with abort signal
const openai = new OpenAI({ const openai = new OpenAI({
apiKey: openApiKey, apiKey: openApiKey,
baseURL: openApiBaseUrl, baseURL: openApiBaseUrl || 'https://api.openai.com/v1',
}); });
// Request audio from OpenAI and pass along the abort signal // Request audio from OpenAI and pass along the abort signal

View file

@ -4,16 +4,12 @@ const DEFAULT_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova'
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
// Get API credentials from headers // Get API credentials from headers or fall back to environment variables
const openApiKey = req.headers.get('x-openai-key'); const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
const openApiBaseUrl = req.headers.get('x-openai-base-url'); const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
if (!openApiKey || !openApiBaseUrl) {
return NextResponse.json({ error: 'Missing API credentials' }, { status: 401 });
}
// Request voices from OpenAI // Request voices from OpenAI
const response = await fetch(`${openApiBaseUrl}/audio/voices`, { const response = await fetch(`${openApiBaseUrl || 'https://api.openai.com/v1'}/audio/voices`, {
headers: { headers: {
'Authorization': `Bearer ${openApiKey}`, 'Authorization': `Bearer ${openApiKey}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View file

@ -91,6 +91,14 @@ export function SettingsModal() {
setShowClearServerConfirm(false); setShowClearServerConfirm(false);
}; };
const handleInputChange = (type: 'apiKey' | 'baseUrl', value: string) => {
if (type === 'apiKey') {
setLocalApiKey(value === '' ? '' : value);
} else {
setLocalBaseUrl(value === '' ? '' : value);
}
};
return ( return (
<Button <Button
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
@ -183,23 +191,35 @@ export function SettingsModal() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-foreground">OpenAI API Key</label> <label className="block text-sm font-medium text-foreground">
<Input OpenAI API Key
type="password" {localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
value={localApiKey} </label>
onChange={(e) => setLocalApiKey(e.target.value)} <div className="flex gap-2">
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" <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>
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-foreground">OpenAI API Base URL</label> <label className="block text-sm font-medium text-foreground">
<Input OpenAI API Base URL
type="text" {localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
value={localBaseUrl} </label>
onChange={(e) => setLocalBaseUrl(e.target.value)} <div className="flex gap-2">
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" <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>
{isDev && <div className="space-y-2"> {isDev && <div className="space-y-2">
@ -256,7 +276,20 @@ export function SettingsModal() {
</div> </div>
</div> </div>
<div className="mt-6 flex justify-end"> <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 <Button
type="button" type="button"
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
@ -265,8 +298,8 @@ export function SettingsModal() {
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); setIsOpen(false);
}} }}

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { getItem, indexedDBService, setItem } from '@/utils/indexedDB'; import { getItem, indexedDBService, setItem, removeItem } from '@/utils/indexedDB';
/** Represents the possible view types for document display */ /** Represents the possible view types for document display */
export type ViewType = 'single' | 'dual' | 'scroll'; export type ViewType = 'single' | 'dual' | 'scroll';
@ -60,7 +60,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
await indexedDBService.init(); await indexedDBService.init();
setIsDBReady(true); setIsDBReady(true);
// Now load config // Load config from IndexedDB
const cachedApiKey = await getItem('apiKey'); const cachedApiKey = await getItem('apiKey');
const cachedBaseUrl = await getItem('baseUrl'); const cachedBaseUrl = await getItem('baseUrl');
const cachedViewType = await getItem('viewType'); const cachedViewType = await getItem('viewType');
@ -69,34 +69,24 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const cachedSkipBlank = await getItem('skipBlank'); const cachedSkipBlank = await getItem('skipBlank');
const cachedEpubTheme = await getItem('epubTheme'); const cachedEpubTheme = await getItem('epubTheme');
if (cachedApiKey) console.log('Cached API key found:', cachedApiKey); // Only set API key and base URL if they were explicitly saved by the user
if (cachedBaseUrl) console.log('Cached base URL found:', cachedBaseUrl); if (cachedApiKey) {
if (cachedViewType) console.log('Cached view type found:', cachedViewType); console.log('Using cached API key');
if (cachedVoiceSpeed) console.log('Cached voice speed found:', cachedVoiceSpeed); setApiKey(cachedApiKey);
if (cachedVoice) console.log('Cached voice found:', cachedVoice); }
if (cachedSkipBlank) console.log('Cached skip blank found:', cachedSkipBlank); if (cachedBaseUrl) {
if (cachedEpubTheme) console.log('Cached EPUB theme found:', cachedEpubTheme); console.log('Using cached base URL');
setBaseUrl(cachedBaseUrl);
}
// If not in cache, use env variables // Set the other values with defaults
const defaultApiKey = process.env.NEXT_PUBLIC_OPENAI_API_KEY || '1234567890';
const defaultBaseUrl = process.env.NEXT_PUBLIC_OPENAI_API_BASE || 'https://api.openai.com/v1';
// Set the values
setApiKey(cachedApiKey || defaultApiKey);
setBaseUrl(cachedBaseUrl || defaultBaseUrl);
setViewType((cachedViewType || 'single') as ViewType); setViewType((cachedViewType || 'single') as ViewType);
setVoiceSpeed(parseFloat(cachedVoiceSpeed || '1')); setVoiceSpeed(parseFloat(cachedVoiceSpeed || '1'));
setVoice(cachedVoice || 'af_sarah'); setVoice(cachedVoice || 'af_sarah');
setSkipBlank(cachedSkipBlank === 'false' ? false : true); setSkipBlank(cachedSkipBlank === 'false' ? false : true);
setEpubTheme(cachedEpubTheme === 'true'); setEpubTheme(cachedEpubTheme === 'true');
// If not in cache, save to cache // Only save non-sensitive settings by default
if (!cachedApiKey) {
await setItem('apiKey', defaultApiKey);
}
if (!cachedBaseUrl) {
await setItem('baseUrl', defaultBaseUrl);
}
if (!cachedViewType) { if (!cachedViewType) {
await setItem('viewType', 'single'); await setItem('viewType', 'single');
} }
@ -119,18 +109,31 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
/** /**
* Updates multiple configuration values simultaneously * Updates multiple configuration values simultaneously
* @param {Partial<{apiKey: string; baseUrl: string}>} newConfig - Object containing new config values * Only saves API credentials if they are explicitly set
*/ */
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => { const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => {
try { try {
if (newConfig.apiKey !== undefined) { if (newConfig.apiKey !== undefined || newConfig.apiKey !== '') {
await setItem('apiKey', newConfig.apiKey); // Only save API key to IndexedDB if it's different from env default
setApiKey(newConfig.apiKey); await setItem('apiKey', newConfig.apiKey!);
setApiKey(newConfig.apiKey!);
} }
if (newConfig.baseUrl !== undefined) { if (newConfig.baseUrl !== undefined || newConfig.baseUrl !== '') {
await setItem('baseUrl', newConfig.baseUrl); // Only save base URL to IndexedDB if it's different from env default
setBaseUrl(newConfig.baseUrl); await setItem('baseUrl', newConfig.baseUrl!);
setBaseUrl(newConfig.baseUrl!);
} }
// Delete completely if '' is passed
if (newConfig.apiKey === '') {
await removeItem('apiKey');
setApiKey('');
}
if (newConfig.baseUrl === '') {
await removeItem('baseUrl');
setBaseUrl('');
}
} catch (error) { } catch (error) {
console.error('Error updating config:', error); console.error('Error updating config:', error);
throw error; throw error;

View file

@ -383,7 +383,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
* Initializes configuration and fetches available voices * Initializes configuration and fetches available voices
*/ */
useEffect(() => { useEffect(() => {
if (!configIsLoading && openApiKey && openApiBaseUrl) { if (!configIsLoading) {
fetchVoices(); fetchVoices();
updateVoiceAndSpeed(); updateVoiceAndSpeed();
} }

View file

@ -14,13 +14,12 @@ export function useVoiceManagement(apiKey: string | undefined, baseUrl: string |
const [availableVoices, setAvailableVoices] = useState<string[]>([]); const [availableVoices, setAvailableVoices] = useState<string[]>([]);
const fetchVoices = useCallback(async () => { const fetchVoices = useCallback(async () => {
if (!apiKey || !baseUrl) return;
try { try {
console.log('Fetching voices...');
const response = await fetch('/api/tts/voices', { const response = await fetch('/api/tts/voices', {
headers: { headers: {
'x-openai-key': apiKey, 'x-openai-key': apiKey || '',
'x-openai-base-url': baseUrl, 'x-openai-base-url': baseUrl || '',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });

View file

@ -638,6 +638,10 @@ export async function setItem(key: string, value: string): Promise<void> {
return indexedDBService.setConfigItem(key, value); return indexedDBService.setConfigItem(key, value);
} }
export async function removeItem(key: string): Promise<void> {
return indexedDBService.removeConfigItem(key);
}
// Add these helper functions before the final export // Add these helper functions before the final export
export async function getLastDocumentLocation(docId: string): Promise<string | null> { export async function getLastDocumentLocation(docId: string): Promise<string | null> {
const key = `lastLocation_${docId}`; const key = `lastLocation_${docId}`;