Add api settings to settings modal

This commit is contained in:
openhands 2025-01-22 01:57:05 +00:00
parent b978e8c58a
commit 44d26acb8a
4 changed files with 295 additions and 25 deletions

View file

@ -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>
); );
} }

View file

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

View file

@ -0,0 +1,77 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { getItem, setItem } from '@/services/indexedDB';
interface ConfigContextType {
apiKey: string;
baseUrl: string;
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => Promise<void>;
}
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
export function ConfigProvider({ children }: { children: React.ReactNode }) {
const [apiKey, setApiKey] = useState<string>('');
const [baseUrl, setBaseUrl] = useState<string>('');
useEffect(() => {
const loadConfig = async () => {
try {
// Try to load from IndexedDB first
const cachedApiKey = await getItem('apiKey');
const cachedBaseUrl = await getItem('baseUrl');
// 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 we used default values and they're not empty, store them in IndexedDB
if (!cachedApiKey && defaultApiKey) {
await setItem('apiKey', defaultApiKey);
}
if (!cachedBaseUrl && defaultBaseUrl) {
await setItem('baseUrl', defaultBaseUrl);
}
} catch (error) {
console.error('Error loading config:', error);
}
};
loadConfig();
}, []);
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 }}>
{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;
}

View file

@ -1,6 +1,7 @@
const DB_NAME = 'openreader-db'; const DB_NAME = 'openreader-db';
const DB_VERSION = 1; const DB_VERSION = 2;
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,6 +11,11 @@ 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;
@ -37,25 +43,31 @@ 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' });
} }
}; };
}); });
} }
// 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 +89,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 +118,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 +147,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 +173,138 @@ 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(); export const indexedDBService = new IndexedDBService();
// 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);
}