Add api settings to settings modal + fix README.md
This commit is contained in:
parent
b978e8c58a
commit
23d7fae8c2
8 changed files with 384 additions and 79 deletions
|
|
@ -36,15 +36,13 @@ https://github.com/user-attachments/assets/7a3457ba-feda-4939-928a-cb587b1c0884
|
||||||
|
|
||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
```bash
|
```bash
|
||||||
git clone [repository-url]
|
git clone https://github.com/richardr1126/OpenReader-WebUI.git
|
||||||
cd openreader-webui
|
cd OpenReader-WebUI
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install dependencies:
|
2. Install dependencies:
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
# or
|
|
||||||
yarn install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Set up environment variables:
|
3. Set up environment variables:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { usePDF } from '@/contexts/PDFContext';
|
import { usePDF } from '@/contexts/PDFContext';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { PDFSkeleton } from '@/components/PDFSkeleton';
|
import { PDFSkeleton } from '@/components/PDFSkeleton';
|
||||||
|
|
@ -21,7 +21,6 @@ const PDFViewer = dynamic(
|
||||||
export default function PDFViewerPage() {
|
export default function PDFViewerPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { getDocument } = usePDF();
|
const { getDocument } = usePDF();
|
||||||
const router = useRouter();
|
|
||||||
const { setText, stop } = useTTS();
|
const { setText, stop } = useTTS();
|
||||||
const [document, setDocument] = useState<{ name: string; data: Blob } | null>(null);
|
const [document, setDocument] = useState<{ name: string; data: Blob } | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -86,7 +85,7 @@ export default function PDFViewerPage() {
|
||||||
</svg>
|
</svg>
|
||||||
Documents
|
Documents
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="mr-2 text-xl font-semibold text-foreground">
|
<h1 className="mr-2 text-md font-semibold text-foreground">
|
||||||
{isLoading ? 'Loading...' : document?.name}
|
{isLoading ? 'Loading...' : document?.name}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,19 @@
|
||||||
import { PDFProvider } from '@/contexts/PDFContext';
|
import { PDFProvider } from '@/contexts/PDFContext';
|
||||||
import { TTSProvider } from '@/contexts/TTSContext';
|
import { TTSProvider } from '@/contexts/TTSContext';
|
||||||
import { ThemeProvider } from '@/contexts/ThemeContext';
|
import { ThemeProvider } from '@/contexts/ThemeContext';
|
||||||
|
import { ConfigProvider } from '@/contexts/ConfigContext';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
export function Providers({ children }: { children: ReactNode }) {
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<TTSProvider>
|
<ConfigProvider>
|
||||||
<PDFProvider>
|
<TTSProvider>
|
||||||
{children}
|
<PDFProvider>
|
||||||
</PDFProvider>
|
{children}
|
||||||
</TTSProvider>
|
</PDFProvider>
|
||||||
|
</TTSProvider>
|
||||||
|
</ConfigProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment } from 'react';
|
import { Fragment, useState, useEffect } from 'react';
|
||||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react';
|
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react';
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -17,8 +18,16 @@ const themes = [
|
||||||
|
|
||||||
export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
|
export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
const { apiKey, baseUrl, updateConfig } = useConfig();
|
||||||
|
const [localApiKey, setLocalApiKey] = useState(apiKey);
|
||||||
|
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
|
||||||
const selectedTheme = themes.find(t => t.id === theme) || themes[0];
|
const selectedTheme = themes.find(t => t.id === theme) || themes[0];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalApiKey(apiKey);
|
||||||
|
setLocalBaseUrl(baseUrl);
|
||||||
|
}, [apiKey, baseUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
|
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
|
||||||
|
|
@ -122,19 +131,59 @@ export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
|
||||||
</div>
|
</div>
|
||||||
</Listbox>
|
</Listbox>
|
||||||
</div>
|
</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"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end">
|
<div className="mt-6 flex justify-end space-x-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
|
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
|
||||||
font-medium text-white hover:bg-accent/90 focus:outline-none
|
font-medium text-white hover:bg-accent/90 focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
transition-colors"
|
transition-colors"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={async () => {
|
||||||
|
await updateConfig({
|
||||||
|
apiKey: localApiKey,
|
||||||
|
baseUrl: localBaseUrl
|
||||||
|
});
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Close
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 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
|
||||||
|
transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setLocalApiKey(apiKey);
|
||||||
|
setLocalBaseUrl(baseUrl);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</DialogPanel>
|
</DialogPanel>
|
||||||
|
|
|
||||||
95
src/contexts/ConfigContext.tsx
Normal file
95
src/contexts/ConfigContext.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
import { getItem, indexedDBService, setItem } from '@/services/indexedDB';
|
||||||
|
|
||||||
|
interface ConfigContextType {
|
||||||
|
apiKey: string;
|
||||||
|
baseUrl: string;
|
||||||
|
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => Promise<void>;
|
||||||
|
isLoading: boolean;
|
||||||
|
isDBReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function ConfigProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [apiKey, setApiKey] = useState<string>('');
|
||||||
|
const [baseUrl, setBaseUrl] = useState<string>('');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isDBReady, setIsDBReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initializeDB = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await indexedDBService.init();
|
||||||
|
setIsDBReady(true);
|
||||||
|
|
||||||
|
// Now load config
|
||||||
|
const cachedApiKey = await getItem('apiKey');
|
||||||
|
const cachedBaseUrl = await getItem('baseUrl');
|
||||||
|
|
||||||
|
if (cachedApiKey) {
|
||||||
|
console.log('Cached API key found:', cachedApiKey);
|
||||||
|
}
|
||||||
|
if (cachedBaseUrl) {
|
||||||
|
console.log('Cached base URL found:', cachedBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not in cache, use env variables
|
||||||
|
const defaultApiKey = process.env.NEXT_PUBLIC_OPENAI_API_KEY || '';
|
||||||
|
const defaultBaseUrl = process.env.NEXT_PUBLIC_OPENAI_API_BASE || '';
|
||||||
|
|
||||||
|
// Set the values
|
||||||
|
setApiKey(cachedApiKey || defaultApiKey);
|
||||||
|
setBaseUrl(cachedBaseUrl || defaultBaseUrl);
|
||||||
|
|
||||||
|
// If not in cache, save to cache
|
||||||
|
if (!cachedApiKey) {
|
||||||
|
await setItem('apiKey', defaultApiKey);
|
||||||
|
}
|
||||||
|
if (!cachedBaseUrl) {
|
||||||
|
await setItem('baseUrl', defaultBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeDB();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => {
|
||||||
|
try {
|
||||||
|
if (newConfig.apiKey !== undefined) {
|
||||||
|
await setItem('apiKey', newConfig.apiKey);
|
||||||
|
setApiKey(newConfig.apiKey);
|
||||||
|
}
|
||||||
|
if (newConfig.baseUrl !== undefined) {
|
||||||
|
await setItem('baseUrl', newConfig.baseUrl);
|
||||||
|
setBaseUrl(newConfig.baseUrl);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating config:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigContext.Provider value={{ apiKey, baseUrl, updateConfig, isLoading, isDBReady }}>
|
||||||
|
{children}
|
||||||
|
</ConfigContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConfig() {
|
||||||
|
const context = useContext(ConfigContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useConfig must be used within a ConfigProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ import nlp from 'compromise';
|
||||||
|
|
||||||
// Add the correct type import
|
// Add the correct type import
|
||||||
import type { TextContent, TextItem } from 'pdfjs-dist/types/src/display/api';
|
import type { TextContent, TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||||
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
|
|
||||||
// Set worker from public directory
|
// Set worker from public directory
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs';
|
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs';
|
||||||
|
|
@ -43,28 +44,30 @@ interface PDFContextType {
|
||||||
const PDFContext = createContext<PDFContextType | undefined>(undefined);
|
const PDFContext = createContext<PDFContextType | undefined>(undefined);
|
||||||
|
|
||||||
export function PDFProvider({ children }: { children: ReactNode }) {
|
export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { isDBReady } = useConfig();
|
||||||
const [documents, setDocuments] = useState<PDFDocument[]>([]);
|
const [documents, setDocuments] = useState<PDFDocument[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Load documents from IndexedDB on mount
|
// Load documents from IndexedDB once DB is ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDocuments = async () => {
|
const loadDocuments = async () => {
|
||||||
|
if (!isDBReady) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
await indexedDBService.init();
|
|
||||||
const docs = await indexedDBService.getAllDocuments();
|
const docs = await indexedDBService.getAllDocuments();
|
||||||
setDocuments(docs);
|
setDocuments(docs);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load documents:', error);
|
console.error('Failed to load documents:', error);
|
||||||
setError('Failed to initialize document storage. Please check if your browser supports IndexedDB.');
|
setError('Failed to load documents. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadDocuments();
|
loadDocuments();
|
||||||
}, []);
|
}, [isDBReady]);
|
||||||
|
|
||||||
// Add a new document to IndexedDB
|
// Add a new document to IndexedDB
|
||||||
const addDocument = useCallback(async (file: File): Promise<string> => {
|
const addDocument = useCallback(async (file: File): Promise<string> => {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import React, {
|
||||||
import nlp from 'compromise';
|
import nlp from 'compromise';
|
||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
import { LRUCache } from 'lru-cache'; // Import LRUCache directly
|
import { LRUCache } from 'lru-cache'; // Import LRUCache directly
|
||||||
|
import { useConfig } from './ConfigContext';
|
||||||
|
|
||||||
// Add type declarations
|
// Add type declarations
|
||||||
declare global {
|
declare global {
|
||||||
|
|
@ -50,6 +51,12 @@ interface TTSContextType {
|
||||||
const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
||||||
|
|
||||||
export function TTSProvider({ children }: { children: React.ReactNode }) {
|
export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { apiKey: openApiKey, baseUrl: openApiBaseUrl, isLoading: configIsLoading } = useConfig();
|
||||||
|
|
||||||
|
// Move openai initialization to a ref to avoid breaking hooks rules
|
||||||
|
const openaiRef = useRef<OpenAI | null>(null);
|
||||||
|
|
||||||
|
// All existing state declarations and refs stay at the top
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [currentText, setCurrentText] = useState('');
|
const [currentText, setCurrentText] = useState('');
|
||||||
const [sentences, setSentences] = useState<string[]>([]);
|
const [sentences, setSentences] = useState<string[]>([]);
|
||||||
|
|
@ -66,20 +73,42 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [speed, setSpeed] = useState(1);
|
const [speed, setSpeed] = useState(1);
|
||||||
const [voice, setVoice] = useState('alloy');
|
const [voice, setVoice] = useState('alloy');
|
||||||
const [availableVoices, setAvailableVoices] = useState<string[]>([]);
|
const [availableVoices, setAvailableVoices] = useState<string[]>([]);
|
||||||
|
|
||||||
// Create OpenAI instance
|
|
||||||
const [openai] = useState(
|
|
||||||
() =>
|
|
||||||
new OpenAI({
|
|
||||||
apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY,
|
|
||||||
baseURL: process.env.NEXT_PUBLIC_OPENAI_API_BASE,
|
|
||||||
dangerouslyAllowBrowser: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Audio cache using LRUCache with a maximum size of 50 entries
|
// Audio cache using LRUCache with a maximum size of 50 entries
|
||||||
const audioCacheRef = useRef(new LRUCache<string, AudioBuffer>({ max: 50 }));
|
const audioCacheRef = useRef(new LRUCache<string, AudioBuffer>({ max: 50 }));
|
||||||
|
|
||||||
|
// Initialize OpenAI instance when config loads
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchVoices = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${openApiBaseUrl}/audio/voices`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${openApiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch voices');
|
||||||
|
const data = await response.json();
|
||||||
|
setAvailableVoices(data.voices || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching voices:', error);
|
||||||
|
|
||||||
|
// Set available voices to default openai voices
|
||||||
|
// Supported voices are alloy, ash, coral, echo, fable, onyx, nova, sage and shimmer
|
||||||
|
setAvailableVoices(['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer']);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!configIsLoading && openApiKey && openApiBaseUrl) {
|
||||||
|
openaiRef.current = new OpenAI({
|
||||||
|
apiKey: openApiKey,
|
||||||
|
baseURL: openApiBaseUrl,
|
||||||
|
dangerouslyAllowBrowser: true,
|
||||||
|
});
|
||||||
|
fetchVoices();
|
||||||
|
}
|
||||||
|
}, [configIsLoading, openApiKey, openApiBaseUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/*
|
/*
|
||||||
* Initializes the AudioContext for text-to-speech playback.
|
* Initializes the AudioContext for text-to-speech playback.
|
||||||
|
|
@ -101,31 +130,6 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
}, [audioContext]);
|
}, [audioContext]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Fetch available voices when component mounts
|
|
||||||
const fetchVoices = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${openai.baseURL}/audio/voices`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${process.env.NEXT_PUBLIC_OPENAI_API_KEY}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch voices');
|
|
||||||
const data = await response.json();
|
|
||||||
setAvailableVoices(data.voices || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching voices:', error);
|
|
||||||
|
|
||||||
// Set available voices to default openai voices
|
|
||||||
// Supported voices are alloy, ash, coral, echo, fable, onyx, nova, sage and shimmer
|
|
||||||
setAvailableVoices(['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer']);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchVoices();
|
|
||||||
}, [openai.baseURL]);
|
|
||||||
|
|
||||||
// Text preprocessing function to clean and normalize text
|
// Text preprocessing function to clean and normalize text
|
||||||
const preprocessText = (text: string): string => {
|
const preprocessText = (text: string): string => {
|
||||||
return text
|
return text
|
||||||
|
|
@ -165,7 +169,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
}, [isPlaying, currentIndex, sentences]);
|
}, [isPlaying, currentIndex, sentences]);
|
||||||
|
|
||||||
const processAndPlaySentence = async (sentence: string) => {
|
const processAndPlaySentence = async (sentence: string) => {
|
||||||
if (!audioContext || isProcessing) return;
|
if (!audioContext || isProcessing || !openaiRef.current) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Only set processing if we need to fetch from API
|
// Only set processing if we need to fetch from API
|
||||||
|
|
@ -193,7 +197,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
if (!audioBuffer) {
|
if (!audioBuffer) {
|
||||||
console.log(' Processing TTS for sentence:', cleanedSentence.substring(0, 50) + '...');
|
console.log(' Processing TTS for sentence:', cleanedSentence.substring(0, 50) + '...');
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const response = await openai.audio.speech.create({
|
const response = await openaiRef.current.audio.speech.create({
|
||||||
model: 'tts-1',
|
model: 'tts-1',
|
||||||
voice: voice as "alloy",
|
voice: voice as "alloy",
|
||||||
input: cleanedSentence,
|
input: cleanedSentence,
|
||||||
|
|
@ -414,13 +418,13 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
}, [isPlaying, currentIndex, sentences, isProcessing]);
|
}, [isPlaying, currentIndex, sentences, isProcessing]);
|
||||||
|
|
||||||
const preloadSentence = async (sentence: string) => {
|
const preloadSentence = async (sentence: string) => {
|
||||||
if (!audioContext) return;
|
if (!audioContext || !openaiRef.current) return;
|
||||||
if (audioCacheRef.current.has(sentence)) return; // Already cached
|
if (audioCacheRef.current.has(sentence)) return; // Already cached
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(' Preloading TTS for sentence:', sentence.substring(0, 50) + '...');
|
console.log(' Preloading TTS for sentence:', sentence.substring(0, 50) + '...');
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const response = await openai.audio.speech.create({
|
const response = await openaiRef.current.audio.speech.create({
|
||||||
model: 'tts-1',
|
model: 'tts-1',
|
||||||
voice: voice as "alloy",
|
voice: voice as "alloy",
|
||||||
input: sentence,
|
input: sentence,
|
||||||
|
|
@ -565,6 +569,10 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
availableVoices,
|
availableVoices,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (configIsLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TTSContext.Provider value={value}>
|
<TTSContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
const DB_NAME = 'openreader-db';
|
const DB_NAME = 'openreader-db';
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 1;
|
||||||
const STORE_NAME = 'pdf-documents';
|
const PDF_STORE_NAME = 'pdf-documents';
|
||||||
|
const CONFIG_STORE_NAME = 'config';
|
||||||
|
|
||||||
export interface PDFDocument {
|
export interface PDFDocument {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -10,15 +11,25 @@ export interface PDFDocument {
|
||||||
data: Blob;
|
data: Blob;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
class IndexedDBService {
|
class IndexedDBService {
|
||||||
private db: IDBDatabase | null = null;
|
private db: IDBDatabase | null = null;
|
||||||
|
private initPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
if (!window.indexedDB) {
|
if (this.initPromise) {
|
||||||
throw new Error('IndexedDB is not supported in this browser');
|
return this.initPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
if (this.db) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initPromise = new Promise((resolve, reject) => {
|
||||||
console.log('Initializing IndexedDB...');
|
console.log('Initializing IndexedDB...');
|
||||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
||||||
|
|
@ -37,25 +48,33 @@ class IndexedDBService {
|
||||||
request.onupgradeneeded = (event) => {
|
request.onupgradeneeded = (event) => {
|
||||||
console.log('Upgrading IndexedDB schema...');
|
console.log('Upgrading IndexedDB schema...');
|
||||||
const db = (event.target as IDBOpenDBRequest).result;
|
const db = (event.target as IDBOpenDBRequest).result;
|
||||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
||||||
|
if (!db.objectStoreNames.contains(PDF_STORE_NAME)) {
|
||||||
console.log('Creating PDF documents store...');
|
console.log('Creating PDF documents store...');
|
||||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
db.createObjectStore(PDF_STORE_NAME, { keyPath: 'id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains(CONFIG_STORE_NAME)) {
|
||||||
|
console.log('Creating config store...');
|
||||||
|
db.createObjectStore(CONFIG_STORE_NAME, { keyPath: 'key' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return this.initPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PDF Document Methods
|
||||||
async addDocument(document: PDFDocument): Promise<void> {
|
async addDocument(document: PDFDocument): Promise<void> {
|
||||||
if (!this.db) {
|
if (!this.db) {
|
||||||
console.log('Database not initialized, initializing now...');
|
|
||||||
await this.init();
|
await this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
console.log('Adding document to IndexedDB:', document.name);
|
console.log('Adding document to IndexedDB:', document.name);
|
||||||
const transaction = this.db!.transaction([STORE_NAME], 'readwrite');
|
const transaction = this.db!.transaction([PDF_STORE_NAME], 'readwrite');
|
||||||
const store = transaction.objectStore(STORE_NAME);
|
const store = transaction.objectStore(PDF_STORE_NAME);
|
||||||
const request = store.put(document);
|
const request = store.put(document);
|
||||||
|
|
||||||
request.onerror = (event) => {
|
request.onerror = (event) => {
|
||||||
|
|
@ -77,15 +96,14 @@ class IndexedDBService {
|
||||||
|
|
||||||
async getDocument(id: string): Promise<PDFDocument | undefined> {
|
async getDocument(id: string): Promise<PDFDocument | undefined> {
|
||||||
if (!this.db) {
|
if (!this.db) {
|
||||||
console.log('Database not initialized, initializing now...');
|
|
||||||
await this.init();
|
await this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
console.log('Fetching document:', id);
|
console.log('Fetching document:', id);
|
||||||
const transaction = this.db!.transaction([STORE_NAME], 'readonly');
|
const transaction = this.db!.transaction([PDF_STORE_NAME], 'readonly');
|
||||||
const store = transaction.objectStore(STORE_NAME);
|
const store = transaction.objectStore(PDF_STORE_NAME);
|
||||||
const request = store.get(id);
|
const request = store.get(id);
|
||||||
|
|
||||||
request.onerror = (event) => {
|
request.onerror = (event) => {
|
||||||
|
|
@ -107,15 +125,14 @@ class IndexedDBService {
|
||||||
|
|
||||||
async getAllDocuments(): Promise<PDFDocument[]> {
|
async getAllDocuments(): Promise<PDFDocument[]> {
|
||||||
if (!this.db) {
|
if (!this.db) {
|
||||||
console.log('Database not initialized, initializing now...');
|
|
||||||
await this.init();
|
await this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
console.log('Fetching all documents');
|
console.log('Fetching all documents');
|
||||||
const transaction = this.db!.transaction([STORE_NAME], 'readonly');
|
const transaction = this.db!.transaction([PDF_STORE_NAME], 'readonly');
|
||||||
const store = transaction.objectStore(STORE_NAME);
|
const store = transaction.objectStore(PDF_STORE_NAME);
|
||||||
const request = store.getAll();
|
const request = store.getAll();
|
||||||
|
|
||||||
request.onerror = (event) => {
|
request.onerror = (event) => {
|
||||||
|
|
@ -137,15 +154,14 @@ class IndexedDBService {
|
||||||
|
|
||||||
async removeDocument(id: string): Promise<void> {
|
async removeDocument(id: string): Promise<void> {
|
||||||
if (!this.db) {
|
if (!this.db) {
|
||||||
console.log('Database not initialized, initializing now...');
|
|
||||||
await this.init();
|
await this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
console.log('Removing document:', id);
|
console.log('Removing document:', id);
|
||||||
const transaction = this.db!.transaction([STORE_NAME], 'readwrite');
|
const transaction = this.db!.transaction([PDF_STORE_NAME], 'readwrite');
|
||||||
const store = transaction.objectStore(STORE_NAME);
|
const store = transaction.objectStore(PDF_STORE_NAME);
|
||||||
const request = store.delete(id);
|
const request = store.delete(id);
|
||||||
|
|
||||||
request.onerror = (event) => {
|
request.onerror = (event) => {
|
||||||
|
|
@ -164,6 +180,140 @@ class IndexedDBService {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Config Methods
|
||||||
|
async setConfigItem(key: string, value: string): Promise<void> {
|
||||||
|
if (!this.db) {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
console.log('Setting config item:', key);
|
||||||
|
const transaction = this.db!.transaction([CONFIG_STORE_NAME], 'readwrite');
|
||||||
|
const store = transaction.objectStore(CONFIG_STORE_NAME);
|
||||||
|
const request = store.put({ key, value });
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
const error = (event.target as IDBRequest).error;
|
||||||
|
console.error('Error setting config item:', error);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
transaction.oncomplete = () => {
|
||||||
|
console.log('Config item set successfully:', key);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in setConfigItem transaction:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConfigItem(key: string): Promise<string | null> {
|
||||||
|
if (!this.db) {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
console.log('Fetching config item:', key);
|
||||||
|
const transaction = this.db!.transaction([CONFIG_STORE_NAME], 'readonly');
|
||||||
|
const store = transaction.objectStore(CONFIG_STORE_NAME);
|
||||||
|
const request = store.get(key);
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
const error = (event.target as IDBRequest).error;
|
||||||
|
console.error('Error fetching config item:', error);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const result = request.result as Config | undefined;
|
||||||
|
console.log('Config item fetch result:', result ? 'found' : 'not found');
|
||||||
|
resolve(result ? result.value : null);
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getConfigItem transaction:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllConfig(): Promise<Record<string, string>> {
|
||||||
|
if (!this.db) {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
console.log('Fetching all config items');
|
||||||
|
const transaction = this.db!.transaction([CONFIG_STORE_NAME], 'readonly');
|
||||||
|
const store = transaction.objectStore(CONFIG_STORE_NAME);
|
||||||
|
const request = store.getAll();
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
const error = (event.target as IDBRequest).error;
|
||||||
|
console.error('Error fetching all config items:', error);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const result = request.result as Config[];
|
||||||
|
const config: Record<string, string> = {};
|
||||||
|
result.forEach((item) => {
|
||||||
|
config[item.key] = item.value;
|
||||||
|
});
|
||||||
|
console.log('Retrieved config items count:', result.length);
|
||||||
|
resolve(config);
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getAllConfig transaction:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeConfigItem(key: string): Promise<void> {
|
||||||
|
if (!this.db) {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
console.log('Removing config item:', key);
|
||||||
|
const transaction = this.db!.transaction([CONFIG_STORE_NAME], 'readwrite');
|
||||||
|
const store = transaction.objectStore(CONFIG_STORE_NAME);
|
||||||
|
const request = store.delete(key);
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
const error = (event.target as IDBRequest).error;
|
||||||
|
console.error('Error removing config item:', error);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
transaction.oncomplete = () => {
|
||||||
|
console.log('Config item removed successfully:', key);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in removeConfigItem transaction:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const indexedDBService = new IndexedDBService();
|
// Make sure we export a singleton instance
|
||||||
|
const indexedDBServiceInstance = new IndexedDBService();
|
||||||
|
export const indexedDBService = indexedDBServiceInstance;
|
||||||
|
|
||||||
|
// Helper functions for the ConfigContext
|
||||||
|
export async function getItem(key: string): Promise<string | null> {
|
||||||
|
return indexedDBService.getConfigItem(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setItem(key: string, value: string): Promise<void> {
|
||||||
|
return indexedDBService.setConfigItem(key, value);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue