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
NEXT_PUBLIC_OPENAI_API_KEY=your_openai_api_key_here
API_KEY=api_key_here_if_needed
# OpenAI API Base URL (default)
# 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
# Add other environment variables below as needed
NEXT_PUBLIC_NODE_ENV=development
API_BASE=https://api.openai.com/v1

View file

@ -42,11 +42,21 @@ docker run --name openreader-webui \
-v openreader_docstore:/app/docstore \
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.
> 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
```bash
@ -64,6 +74,8 @@ services:
openreader-webui:
container_name: openreader-webui
image: richardr1126/openreader-webui:latest
environment:
- API_BASE=http://host.docker.internal:8880/v1
ports:
- "3003:3003"
volumes:

View file

@ -3,14 +3,14 @@ import OpenAI from 'openai';
export async function POST(req: NextRequest) {
try {
// Get API credentials from headers
const openApiKey = req.headers.get('x-openai-key');
const openApiBaseUrl = req.headers.get('x-openai-base-url');
// 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 } = await req.json();
console.log('Received TTS request:', text, voice, speed);
if (!openApiKey || !openApiBaseUrl) {
return NextResponse.json({ error: 'Missing API credentials' }, { status: 401 });
if (!openApiKey) {
return NextResponse.json({ error: 'Missing OpenAI API key' }, { status: 401 });
}
if (!text || !voice || !speed) {
@ -20,7 +20,7 @@ export async function POST(req: NextRequest) {
// Initialize OpenAI client with abort signal
const openai = new OpenAI({
apiKey: openApiKey,
baseURL: openApiBaseUrl,
baseURL: openApiBaseUrl || 'https://api.openai.com/v1',
});
// 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) {
try {
// Get API credentials from headers
const openApiKey = req.headers.get('x-openai-key');
const openApiBaseUrl = req.headers.get('x-openai-base-url');
if (!openApiKey || !openApiBaseUrl) {
return NextResponse.json({ error: 'Missing API credentials' }, { status: 401 });
}
// 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;
// Request voices from OpenAI
const response = await fetch(`${openApiBaseUrl}/audio/voices`, {
const response = await fetch(`${openApiBaseUrl || 'https://api.openai.com/v1'}/audio/voices`, {
headers: {
'Authorization': `Bearer ${openApiKey}`,
'Content-Type': 'application/json',

View file

@ -91,6 +91,14 @@ export function SettingsModal() {
setShowClearServerConfirm(false);
};
const handleInputChange = (type: 'apiKey' | 'baseUrl', value: string) => {
if (type === 'apiKey') {
setLocalApiKey(value === '' ? '' : value);
} else {
setLocalBaseUrl(value === '' ? '' : value);
}
};
return (
<Button
onClick={() => setIsOpen(true)}
@ -183,23 +191,35 @@ export function SettingsModal() {
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">OpenAI API Key</label>
<Input
type="password"
value={localApiKey}
onChange={(e) => setLocalApiKey(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"
/>
<label className="block text-sm font-medium text-foreground">
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 className="space-y-2">
<label className="block text-sm font-medium text-foreground">OpenAI API Base URL</label>
<Input
type="text"
value={localBaseUrl}
onChange={(e) => setLocalBaseUrl(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"
/>
<label className="block text-sm font-medium text-foreground">
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>
{isDev && <div className="space-y-2">
@ -256,7 +276,20 @@ export function SettingsModal() {
</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
type="button"
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"
onClick={async () => {
await updateConfig({
apiKey: localApiKey,
baseUrl: localBaseUrl
apiKey: localApiKey || '',
baseUrl: localBaseUrl || '',
});
setIsOpen(false);
}}

View file

@ -1,7 +1,7 @@
'use client';
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 */
export type ViewType = 'single' | 'dual' | 'scroll';
@ -60,7 +60,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
await indexedDBService.init();
setIsDBReady(true);
// Now load config
// Load config from IndexedDB
const cachedApiKey = await getItem('apiKey');
const cachedBaseUrl = await getItem('baseUrl');
const cachedViewType = await getItem('viewType');
@ -69,34 +69,24 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const cachedSkipBlank = await getItem('skipBlank');
const cachedEpubTheme = await getItem('epubTheme');
if (cachedApiKey) console.log('Cached API key found:', cachedApiKey);
if (cachedBaseUrl) console.log('Cached base URL found:', cachedBaseUrl);
if (cachedViewType) console.log('Cached view type found:', cachedViewType);
if (cachedVoiceSpeed) console.log('Cached voice speed found:', cachedVoiceSpeed);
if (cachedVoice) console.log('Cached voice found:', cachedVoice);
if (cachedSkipBlank) console.log('Cached skip blank found:', cachedSkipBlank);
if (cachedEpubTheme) console.log('Cached EPUB theme found:', cachedEpubTheme);
// Only set API key and base URL if they were explicitly saved by the user
if (cachedApiKey) {
console.log('Using cached API key');
setApiKey(cachedApiKey);
}
if (cachedBaseUrl) {
console.log('Using cached base URL');
setBaseUrl(cachedBaseUrl);
}
// If not in cache, use env variables
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);
// Set the other values with defaults
setViewType((cachedViewType || 'single') as ViewType);
setVoiceSpeed(parseFloat(cachedVoiceSpeed || '1'));
setVoice(cachedVoice || 'af_sarah');
setSkipBlank(cachedSkipBlank === 'false' ? false : true);
setEpubTheme(cachedEpubTheme === 'true');
// If not in cache, save to cache
if (!cachedApiKey) {
await setItem('apiKey', defaultApiKey);
}
if (!cachedBaseUrl) {
await setItem('baseUrl', defaultBaseUrl);
}
// Only save non-sensitive settings by default
if (!cachedViewType) {
await setItem('viewType', 'single');
}
@ -119,18 +109,31 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
/**
* 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 }>) => {
try {
if (newConfig.apiKey !== undefined) {
await setItem('apiKey', newConfig.apiKey);
setApiKey(newConfig.apiKey);
if (newConfig.apiKey !== undefined || newConfig.apiKey !== '') {
// Only save API key to IndexedDB if it's different from env default
await setItem('apiKey', newConfig.apiKey!);
setApiKey(newConfig.apiKey!);
}
if (newConfig.baseUrl !== undefined) {
await setItem('baseUrl', newConfig.baseUrl);
setBaseUrl(newConfig.baseUrl);
if (newConfig.baseUrl !== undefined || newConfig.baseUrl !== '') {
// Only save base URL to IndexedDB if it's different from env default
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) {
console.error('Error updating config:', error);
throw error;

View file

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

View file

@ -14,13 +14,12 @@ export function useVoiceManagement(apiKey: string | undefined, baseUrl: string |
const [availableVoices, setAvailableVoices] = useState<string[]>([]);
const fetchVoices = useCallback(async () => {
if (!apiKey || !baseUrl) return;
try {
console.log('Fetching voices...');
const response = await fetch('/api/tts/voices', {
headers: {
'x-openai-key': apiKey,
'x-openai-base-url': baseUrl,
'x-openai-key': apiKey || '',
'x-openai-base-url': baseUrl || '',
'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);
}
export async function removeItem(key: string): Promise<void> {
return indexedDBService.removeConfigItem(key);
}
// Add these helper functions before the final export
export async function getLastDocumentLocation(docId: string): Promise<string | null> {
const key = `lastLocation_${docId}`;