Environment changes, set in Docker run
This commit is contained in:
parent
9ad1f5f117
commit
96376e2324
9 changed files with 119 additions and 73 deletions
|
|
@ -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
|
||||
16
README.md
16
README.md
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
Loading…
Reference in a new issue