This commit is contained in:
Richard Roberson 2025-01-21 19:43:01 -07:00
parent 44d26acb8a
commit 8f16b87044
4 changed files with 96 additions and 58 deletions

View file

@ -1,12 +1,14 @@
'use client'; 'use client';
import { createContext, useContext, useEffect, useState } from 'react'; import { createContext, useContext, useEffect, useState } from 'react';
import { getItem, setItem } from '@/services/indexedDB'; import { getItem, indexedDBService, setItem } from '@/services/indexedDB';
interface ConfigContextType { interface ConfigContextType {
apiKey: string; apiKey: string;
baseUrl: string; baseUrl: string;
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => Promise<void>; updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => Promise<void>;
isLoading: boolean;
isDBReady: boolean;
} }
const ConfigContext = createContext<ConfigContextType | undefined>(undefined); const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
@ -14,14 +16,27 @@ const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
export function ConfigProvider({ children }: { children: React.ReactNode }) { export function ConfigProvider({ children }: { children: React.ReactNode }) {
const [apiKey, setApiKey] = useState<string>(''); const [apiKey, setApiKey] = useState<string>('');
const [baseUrl, setBaseUrl] = useState<string>(''); const [baseUrl, setBaseUrl] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [isDBReady, setIsDBReady] = useState(false);
useEffect(() => { useEffect(() => {
const loadConfig = async () => { const initializeDB = async () => {
try { try {
// Try to load from IndexedDB first setIsLoading(true);
await indexedDBService.init();
setIsDBReady(true);
// Now load config
const cachedApiKey = await getItem('apiKey'); const cachedApiKey = await getItem('apiKey');
const cachedBaseUrl = await getItem('baseUrl'); 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 // If not in cache, use env variables
const defaultApiKey = process.env.NEXT_PUBLIC_OPENAI_API_KEY || ''; const defaultApiKey = process.env.NEXT_PUBLIC_OPENAI_API_KEY || '';
const defaultBaseUrl = process.env.NEXT_PUBLIC_OPENAI_API_BASE || ''; const defaultBaseUrl = process.env.NEXT_PUBLIC_OPENAI_API_BASE || '';
@ -30,19 +45,22 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) {
setApiKey(cachedApiKey || defaultApiKey); setApiKey(cachedApiKey || defaultApiKey);
setBaseUrl(cachedBaseUrl || defaultBaseUrl); setBaseUrl(cachedBaseUrl || defaultBaseUrl);
// If we used default values and they're not empty, store them in IndexedDB // If not in cache, save to cache
if (!cachedApiKey && defaultApiKey) { if (!cachedApiKey) {
await setItem('apiKey', defaultApiKey); await setItem('apiKey', defaultApiKey);
} }
if (!cachedBaseUrl && defaultBaseUrl) { if (!cachedBaseUrl) {
await setItem('baseUrl', defaultBaseUrl); await setItem('baseUrl', defaultBaseUrl);
} }
} catch (error) { } catch (error) {
console.error('Error loading config:', error); console.error('Error initializing:', error);
} finally {
setIsLoading(false);
} }
}; };
loadConfig(); initializeDB();
}, []); }, []);
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => { const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => {
@ -62,7 +80,7 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) {
}; };
return ( return (
<ConfigContext.Provider value={{ apiKey, baseUrl, updateConfig }}> <ConfigContext.Provider value={{ apiKey, baseUrl, updateConfig, isLoading, isDBReady }}>
{children} {children}
</ConfigContext.Provider> </ConfigContext.Provider>
); );

View file

@ -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> => {

View file

@ -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[]>([]);
@ -67,19 +74,41 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
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}

View file

@ -1,5 +1,5 @@
const DB_NAME = 'openreader-db'; const DB_NAME = 'openreader-db';
const DB_VERSION = 2; const DB_VERSION = 1;
const PDF_STORE_NAME = 'pdf-documents'; const PDF_STORE_NAME = 'pdf-documents';
const CONFIG_STORE_NAME = 'config'; const CONFIG_STORE_NAME = 'config';
@ -18,13 +18,18 @@ export interface Config {
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);
@ -55,6 +60,8 @@ class IndexedDBService {
} }
}; };
}); });
return this.initPromise;
} }
// PDF Document Methods // PDF Document Methods
@ -298,7 +305,9 @@ class IndexedDBService {
} }
} }
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 // Helper functions for the ConfigContext
export async function getItem(key: string): Promise<string | null> { export async function getItem(key: string): Promise<string | null> {