diff --git a/app/components/chat/APIKeyManager.tsx b/app/components/chat/APIKeyManager.tsx index 28847bc19..eb9bd5fde 100644 --- a/app/components/chat/APIKeyManager.tsx +++ b/app/components/chat/APIKeyManager.tsx @@ -1,6 +1,8 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { IconButton } from '~/components/ui/IconButton'; import type { ProviderInfo } from '~/types/model'; +import { apiSettingsStore } from '~/lib/stores/settings'; +import { useStore } from '@nanostores/react'; interface APIKeyManagerProps { provider: ProviderInfo; @@ -14,6 +16,17 @@ interface APIKeyManagerProps { export const APIKeyManager: React.FC = ({ provider, apiKey, setApiKey }) => { const [isEditing, setIsEditing] = useState(false); const [tempKey, setTempKey] = useState(apiKey); + const storedSettings = useStore(apiSettingsStore); + + useEffect(() => { + // Update the API key if it exists in the store + const storedKey = storedSettings.apiKeys[provider.name]; + + if (storedKey) { + setApiKey(storedKey); + setTempKey(storedKey); + } + }, [storedSettings, provider.name, setApiKey]); const handleSave = () => { setApiKey(tempKey); diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index 8c7589a68..42b8d0acf 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -12,8 +12,6 @@ import { classNames } from '~/utils/classNames'; import { MODEL_LIST, PROVIDER_LIST, initializeModelList } from '~/utils/constants'; import { Messages } from './Messages.client'; import { SendButton } from './SendButton.client'; -import { APIKeyManager } from './APIKeyManager'; -import Cookies from 'js-cookie'; import * as Tooltip from '@radix-ui/react-tooltip'; import styles from './BaseChat.module.scss'; @@ -54,6 +52,7 @@ interface BaseChatProps { setUploadedFiles?: (files: File[]) => void; imageDataList?: string[]; setImageDataList?: (dataList: string[]) => void; + availableProviders?: ProviderInfo[]; } export const BaseChat = React.forwardRef( @@ -83,11 +82,11 @@ export const BaseChat = React.forwardRef( imageDataList = [], setImageDataList, messages, + availableProviders = PROVIDER_LIST, }, ref, ) => { const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; - const [apiKeys, setApiKeys] = useState>({}); const [modelList, setModelList] = useState(MODEL_LIST); const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false); const [isListening, setIsListening] = useState(false); @@ -96,24 +95,6 @@ export const BaseChat = React.forwardRef( console.log(transcript); useEffect(() => { - // Load API keys from cookies on component mount - try { - const storedApiKeys = Cookies.get('apiKeys'); - - if (storedApiKeys) { - const parsedKeys = JSON.parse(storedApiKeys); - - if (typeof parsedKeys === 'object' && parsedKeys !== null) { - setApiKeys(parsedKeys); - } - } - } catch (error) { - console.error('Error loading API keys from cookies:', error); - - // Clear invalid cookie data - Cookies.remove('apiKeys'); - } - initializeModelList().then((modelList) => { setModelList(modelList); }); @@ -183,23 +164,6 @@ export const BaseChat = React.forwardRef( } }; - const updateApiKey = (provider: string, key: string) => { - try { - const updatedApiKeys = { ...apiKeys, [provider]: key }; - setApiKeys(updatedApiKeys); - - // Save updated API keys to cookies with 30 day expiry and secure settings - Cookies.set('apiKeys', JSON.stringify(updatedApiKeys), { - expires: 30, // 30 days - secure: true, // Only send over HTTPS - sameSite: 'strict', // Protect against CSRF - path: '/', // Accessible across the site - }); - } catch (error) { - console.error('Error saving API keys to cookies:', error); - } - }; - const handleFileUpload = () => { const input = document.createElement('input'); input.type = 'file'; @@ -349,23 +313,17 @@ export const BaseChat = React.forwardRef(
- - {provider && ( - + updateApiKey(provider.name, key)} + setProvider={setProvider} + providerList={availableProviders} /> - )} +
{ const savedProvider = Cookies.get('selectedProvider'); - return PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER; + const savedActiveProviders = Cookies.get('activeProviders'); + const activeProviders = savedActiveProviders ? JSON.parse(savedActiveProviders) : {}; + + // Filter PROVIDER_LIST to only include active providers and Ollama (which is always available) + const availableProviders = PROVIDER_LIST.filter( + (p) => + p.name === 'Ollama' || // Ollama is always available + activeProviders[p.name], // Provider is active in settings + ); + + // If no providers are available, default to Ollama + if (availableProviders.length === 0) { + return PROVIDER_LIST.find((p) => p.name === 'Ollama') || DEFAULT_PROVIDER; + } + + // Try to find the saved provider in available providers + const savedProviderObj = availableProviders.find((p) => p.name === savedProvider); + + return savedProviderObj || availableProviders[0] || DEFAULT_PROVIDER; }); const { showChat } = useStore(chatStore); @@ -107,6 +125,15 @@ export const ChatImpl = memo( const [apiKeys, setApiKeys] = useState>({}); + // Load API keys from cookies when component mounts + useEffect(() => { + const savedApiKeys = Cookies.get('apiKeys'); + + if (savedApiKeys) { + setApiKeys(JSON.parse(savedApiKeys)); + } + }, []); + const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({ api: '/api/chat', body: { @@ -283,14 +310,6 @@ export const ChatImpl = memo( const [messageRef, scrollRef] = useSnapScroll(); - useEffect(() => { - const storedApiKeys = Cookies.get('apiKeys'); - - if (storedApiKeys) { - setApiKeys(JSON.parse(storedApiKeys)); - } - }, []); - const handleModelChange = (newModel: string) => { setModel(newModel); Cookies.set('selectedModel', newModel, { expires: 30 }); @@ -352,6 +371,11 @@ export const ChatImpl = memo( setUploadedFiles={setUploadedFiles} imageDataList={imageDataList} setImageDataList={setImageDataList} + availableProviders={PROVIDER_LIST.filter( + (p) => + p.name === 'Ollama' || // Ollama is always available + JSON.parse(Cookies.get('activeProviders') || '{}')[p.name], // Provider is active in settings + )} /> ); }, diff --git a/app/components/chat/ModelSelector.tsx b/app/components/chat/ModelSelector.tsx index 1bc7a6691..7700d79cc 100644 --- a/app/components/chat/ModelSelector.tsx +++ b/app/components/chat/ModelSelector.tsx @@ -1,5 +1,6 @@ import type { ProviderInfo } from '~/types/model'; import type { ModelInfo } from '~/utils/types'; +import { classNames } from '~/utils/classNames'; interface ModelSelectorProps { model?: string; @@ -8,7 +9,6 @@ interface ModelSelectorProps { setProvider?: (provider: ProviderInfo) => void; modelList: ModelInfo[]; providerList: ProviderInfo[]; - apiKeys: Record; } export const ModelSelector = ({ @@ -20,44 +20,61 @@ export const ModelSelector = ({ providerList, }: ModelSelectorProps) => { return ( -
- { + const newProvider = providerList.find((p: ProviderInfo) => p.name === e.target.value); - if (newProvider && setProvider) { - setProvider(newProvider); - } + if (newProvider && setProvider) { + setProvider(newProvider); + } - const firstModel = [...modelList].find((m) => m.provider === e.target.value); + const firstModel = [...modelList].find((m) => m.provider === e.target.value); - if (firstModel && setModel) { - setModel(firstModel.name); - } - }} - className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all" - > - {providerList.map((provider: ProviderInfo) => ( - - ))} - - + +
+ +
+ + +
); }; diff --git a/app/components/sidebar/Menu.client.tsx b/app/components/sidebar/Menu.client.tsx index b2af5e156..11e4d4e0f 100644 --- a/app/components/sidebar/Menu.client.tsx +++ b/app/components/sidebar/Menu.client.tsx @@ -2,7 +2,7 @@ import { motion, type Variants } from 'framer-motion'; import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'react-toastify'; import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog'; -import { ThemeSwitch } from '~/components/ui/ThemeSwitch'; +import { ThemeAndSettings } from '~/components/ui/ThemeAndSettings'; import { db, deleteById, getAll, chatId, type ChatHistoryItem, useChatHistory } from '~/lib/persistence'; import { cubicEasingFn } from '~/utils/easings'; import { logger } from '~/utils/logger'; @@ -201,7 +201,7 @@ export const Menu = () => {
- +
diff --git a/app/components/ui/SettingsDialog.tsx b/app/components/ui/SettingsDialog.tsx new file mode 100644 index 000000000..e421d15ea --- /dev/null +++ b/app/components/ui/SettingsDialog.tsx @@ -0,0 +1,682 @@ +import React, { useState, useEffect } from 'react'; +import { Dialog, DialogRoot, DialogTitle } from './Dialog'; +import { APIKeyManager } from '~/components/chat/APIKeyManager'; +import type { ProviderInfo } from '~/types/model'; +import { IconButton } from './IconButton'; +import { apiSettingsStore, saveApiSettings } from '~/lib/stores/settings'; +import { useStore } from '@nanostores/react'; +import { useChatHistory } from '~/lib/persistence'; +import Cookies from 'js-cookie'; + +interface ApiSettings { + [key: string]: { + apiKey?: string; + baseUrl?: string; + getApiKeyLink?: string; + labelForGetApiKey?: string; + }; +} + +// Add debug settings interface +interface DebugSettings { + enabled: boolean; +} + +const initialApiSettings: ApiSettings = { + Anthropic: { + apiKey: '', + getApiKeyLink: 'https://console.anthropic.com/account/keys', + labelForGetApiKey: 'Get Anthropic API Key', + }, + Cohere: { + apiKey: '', + getApiKeyLink: 'https://dashboard.cohere.ai/api-keys', + labelForGetApiKey: 'Get Cohere API Key', + }, + DeepSeek: { + apiKey: '', + getApiKeyLink: 'https://platform.deepseek.com/api', + labelForGetApiKey: 'Get DeepSeek API Key', + }, + GoogleGenerativeAI: { + apiKey: '', + getApiKeyLink: 'https://makersuite.google.com/app/apikey', + labelForGetApiKey: 'Get Google AI API Key', + }, + GROQ: { + apiKey: '', + getApiKeyLink: 'https://console.groq.com/keys', + labelForGetApiKey: 'Get Groq API Key', + }, + HuggingFace: { + apiKey: '', + getApiKeyLink: 'https://huggingface.co/settings/tokens', + labelForGetApiKey: 'Get HuggingFace API Key', + }, + LMStudio: { + baseUrl: '', + getApiKeyLink: 'https://lmstudio.ai/', + labelForGetApiKey: 'Download LM Studio', + }, + Mistral: { + apiKey: '', + getApiKeyLink: 'https://console.mistral.ai/api-keys/', + labelForGetApiKey: 'Get Mistral API Key', + }, + Ollama: { + baseUrl: '', + getApiKeyLink: 'https://ollama.ai/', + labelForGetApiKey: 'Download Ollama', + }, + OpenAI: { + apiKey: '', + getApiKeyLink: 'https://platform.openai.com/api-keys', + labelForGetApiKey: 'Get OpenAI API Key', + }, + OpenAILike: { + apiKey: '', + baseUrl: '', + getApiKeyLink: 'https://github.com/BoltzmannEntropy/privateGPT#readme', + labelForGetApiKey: 'Learn about OpenAI-compatible APIs', + }, + OpenRouter: { + apiKey: '', + getApiKeyLink: 'https://openrouter.ai/keys', + labelForGetApiKey: 'Get OpenRouter API Key', + }, + TogetherAI: { + apiKey: '', + baseUrl: '', + getApiKeyLink: 'https://api.together.xyz/settings/api-keys', + labelForGetApiKey: 'Get Together AI API Key', + }, + xAI: { + apiKey: '', + getApiKeyLink: 'https://x.ai/', + labelForGetApiKey: 'Get xAI Access', + }, +}; + +const ENV_API_KEYS = { + Anthropic: process.env.ANTHROPIC_API_KEY, + OpenAI: process.env.OPENAI_API_KEY, + GoogleGenerativeAI: process.env.GOOGLE_GENERATIVE_AI_API_KEY, + Groq: process.env.GROQ_API_KEY, + HuggingFace: process.env.HuggingFace_API_KEY, + OpenRouter: process.env.OPEN_ROUTER_API_KEY, + Deepseek: process.env.DEEPSEEK_API_KEY, + Mistral: process.env.MISTRAL_API_KEY, + OpenAILike: process.env.OPENAI_LIKE_API_KEY, + Together: process.env.TOGETHER_API_KEY, + xAI: process.env.XAI_API_KEY, + Cohere: process.env.COHERE_API_KEY, + AzureOpenAI: process.env.AZURE_OPENAI_API_KEY, +}; + +const ENV_BASE_URLS = { + Together: process.env.TOGETHER_API_BASE_URL, + OpenAILike: process.env.OPENAI_LIKE_API_BASE_URL, + LMStudio: process.env.LMSTUDIO_API_BASE_URL, + Ollama: process.env.OLLAMA_API_BASE_URL, +}; + +interface SettingsDialogProps { + isOpen: boolean; + onClose: () => void; + provider?: ProviderInfo; + apiKey?: string; + setApiKey?: (key: string) => void; +} + +// Add type for active tab +type ActiveTab = 'api-settings' | 'features' | 'debug' | 'chat-history'; + +export function SettingsDialog({ isOpen, onClose, provider, apiKey = '', setApiKey }: SettingsDialogProps) { + const [activeTab, setActiveTab] = useState('api-settings'); + const [apiSettings, setApiSettings] = useState(initialApiSettings); + const [activeProviders, setActiveProviders] = useState<{ [key: string]: boolean }>({}); + + const [debugSettings, setDebugSettings] = useState({ enabled: false }); + const [showChatHistory, setShowChatHistory] = useState(() => { + const savedValue = Cookies.get('showChatHistory'); + return savedValue === undefined ? true : savedValue === 'true'; + }); + const storedSettings = useStore(apiSettingsStore); + const { deleteAllChatHistory, deleteAllChatHistoryExceptToday, exportAllChats } = useChatHistory(); + + // Add function to format debug info + const getFormattedDebugInfo = () => { + const systemInfo = { + 'Node.js Version': process.version, + Environment: process.env.NODE_ENV || 'development', + Runtime: process.env.DOCKER_CONTAINER ? 'Docker' : 'Local', + Platform: window.navigator.platform, + }; + + const activeProvidersInfo = Object.entries(activeProviders) + .filter(([_, isActive]) => isActive) + .map(([provider]) => { + const settings = apiSettings[provider]; + const showBaseUrl = ['OpenAILike', 'Ollama', 'LMStudio'].includes(provider); + + if (showBaseUrl && settings.baseUrl) { + return { + name: provider, + baseUrl: settings.baseUrl, + }; + } + + return { + name: provider, + }; + }); + + const debugInfo = { + 'System Information': systemInfo, + 'Active API Providers': activeProvidersInfo, + }; + + return JSON.stringify(debugInfo, null, 2); + }; + + const handleCopyDebugInfo = async () => { + try { + await navigator.clipboard.writeText(getFormattedDebugInfo()); + + // You might want to add a toast notification here + console.log('Debug info copied to clipboard'); + } catch (err) { + console.error('Failed to copy debug info:', err); + } + }; + + useEffect(() => { + // Load settings from the store and environment + const newSettings = { ...initialApiSettings }; + + // Load environment variables first + Object.entries(ENV_API_KEYS).forEach(([provider, key]) => { + if (key && newSettings[provider]) { + newSettings[provider] = { + ...newSettings[provider], + apiKey: key, + }; + } + }); + + Object.entries(ENV_BASE_URLS).forEach(([provider, url]) => { + if (url && newSettings[provider]) { + newSettings[provider] = { + ...newSettings[provider], + baseUrl: url, + }; + } + }); + + // Then merge with stored settings + Object.entries(storedSettings.apiKeys).forEach(([provider, key]) => { + if (newSettings[provider]) { + newSettings[provider] = { + ...newSettings[provider], + apiKey: key, + }; + } + }); + + Object.entries(storedSettings.baseUrls).forEach(([provider, url]) => { + if (newSettings[provider]) { + newSettings[provider] = { + ...newSettings[provider], + baseUrl: url, + }; + } + }); + + setApiSettings(newSettings); + + // Set active providers for any provider with env vars or stored settings + const newActiveProviders = { ...storedSettings.activeProviders }; + Object.entries(newSettings).forEach(([provider, settings]) => { + if (settings.apiKey || settings.baseUrl) { + newActiveProviders[provider] = true; + } + }); + setActiveProviders(newActiveProviders); + + // Load debug mode + setDebugSettings((prev) => ({ ...prev, enabled: storedSettings.debugMode || false })); + }, [storedSettings]); + + const handleApiSettingChange = (provider: string, field: 'apiKey' | 'baseUrl', value: string) => { + setApiSettings((prev) => ({ + ...prev, + [provider]: { + ...prev[provider], + [field]: value, + }, + })); + setActiveProviders((prev) => ({ + ...prev, + [provider]: true, + })); + }; + + const handleProviderToggle = (provider: string) => { + setActiveProviders((prev) => ({ + ...prev, + [provider]: !prev[provider], + })); + + if (activeProviders[provider]) { + setApiSettings((prev) => ({ + ...prev, + [provider]: { + ...prev[provider], + apiKey: '', + }, + })); + } + }; + + const handleClearProvider = (provider: string) => { + setApiSettings((prev) => ({ + ...prev, + [provider]: { + ...prev[provider], + apiKey: '', + }, + })); + setActiveProviders((prev) => ({ + ...prev, + [provider]: false, + })); + }; + + const handleSaveSettings = () => { + // Create an object with just the API keys + const apiKeysToSave = Object.entries(apiSettings).reduce( + (acc, [provider, settings]) => { + if (settings.apiKey) { + acc[provider] = settings.apiKey; + } + + return acc; + }, + {} as Record, + ); + + // Create an object with base URLs for providers that have them + const baseUrlsToSave = Object.entries(apiSettings).reduce( + (acc, [provider, settings]) => { + if (settings.baseUrl) { + acc[provider] = settings.baseUrl; + } + + return acc; + }, + {} as Record, + ); + + // Save settings to the store + saveApiSettings({ + apiKeys: apiKeysToSave, + baseUrls: baseUrlsToSave, + activeProviders, + debugMode: debugSettings.enabled, + }); + + console.log('Saving settings:', { apiSettings, activeProviders, debugSettings }); + onClose(); + }; + + // Add a list of providers that don't need API keys + const NO_API_KEY_PROVIDERS = ['Ollama', 'LMStudio']; + + const isKeySetInEnv = (providerName: string) => { + return !!ENV_API_KEYS[providerName as keyof typeof ENV_API_KEYS]; + }; + + const isBaseUrlSetInEnv = (providerName: string) => { + return !!ENV_BASE_URLS[providerName as keyof typeof ENV_BASE_URLS]; + }; + + const handleToggleChatHistory = () => { + const newValue = !showChatHistory; + setShowChatHistory(newValue); + Cookies.set('showChatHistory', String(newValue)); + + if (activeTab === 'chat-history') { + setActiveTab('features'); + } + }; + + return ( + + + Settings +
+
+
    +
  • + +
  • +
  • + +
  • + {debugSettings.enabled && ( +
  • + +
  • + )} + {showChatHistory && ( +
  • + +
  • + )} +
+
+
+ {activeTab === 'api-settings' && ( +
+

API Settings

+

Manage your API keys and URLs

+ {provider && setApiKey && } + {Object.entries(apiSettings) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([providerName, settings]) => ( +
+
+
+

{providerName}

+ {settings.getApiKeyLink && ( + window.open(settings.getApiKeyLink, '_blank')} + /> + )} +
+
+ + {activeProviders[providerName] && ( + handleClearProvider(providerName)} + /> + )} +
+
+ {activeProviders[providerName] && ( +
+ {!NO_API_KEY_PROVIDERS.includes(providerName) && ( +
+ + handleApiSettingChange(providerName, 'apiKey', e.target.value)} + className="w-full px-3 py-2 rounded border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary" + placeholder={ + isKeySetInEnv(providerName) ? 'Using environment variable' : 'Enter API key' + } + /> +
+ )} + {settings.baseUrl !== undefined && ( +
+ + handleApiSettingChange(providerName, 'baseUrl', e.target.value)} + className="w-full px-3 py-2 rounded border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary" + placeholder={ + isBaseUrlSetInEnv(providerName) ? 'Using environment variable' : 'Enter base URL' + } + /> +
+ )} +
+ )} +
+ ))} +
+ )} + {activeTab === 'features' && ( +
+

Features

+
+
+
+

Enable Chat History Tab

+

Show chat history management features

+
+ +
+
+
+ )} + {activeTab === 'chat-history' && showChatHistory && ( +
+

Chat History

+
+
+
+

Export All Chats

+

+ Download all your chats as a single JSON file +

+
+ +
+
+
+
+

Delete All Chat History

+

+ This will permanently delete all your chat history +

+
+ +
+
+
+

Delete Old Chat History

+

+ This will delete all chat history except today's chats +

+
+ +
+ +
+
+

Debug Mode

+

Enable detailed debugging information

+
+ +
+
+
+ )} + {activeTab === 'debug' && debugSettings.enabled && ( +
+

Debug Information

+
+
+

System Information

+
+
+ Node.js Version: + {process.version} +
+
+ Environment: + {process.env.NODE_ENV || 'development'} +
+
+ Runtime: + {process.env.DOCKER_CONTAINER ? 'Docker' : 'Local'} +
+
+ Platform: + {window.navigator.platform} +
+
+
+ +
+

Active API Providers

+
+ {Object.entries(activeProviders) + .filter(([_, isActive]) => isActive) + .map(([provider]) => { + const settings = apiSettings[provider]; + const showBaseUrl = ['OpenAILike', 'Ollama', 'LMStudio'].includes(provider); + + return ( +
+
+ {provider} + ✓ Active +
+ {showBaseUrl && settings.baseUrl && ( +
+ Base URL: {settings.baseUrl} +
+ )} +
+ ); + })} +
+
+ +
+ +
+
+
+ )} +
+
+
+ +
+
+
+ ); +} diff --git a/app/components/ui/ThemeAndSettings.tsx b/app/components/ui/ThemeAndSettings.tsx new file mode 100644 index 000000000..81a4a0892 --- /dev/null +++ b/app/components/ui/ThemeAndSettings.tsx @@ -0,0 +1,20 @@ +import React, { useState } from 'react'; +import { ThemeSwitch } from './ThemeSwitch'; +import { SettingsDialog } from './SettingsDialog'; +import { IconButton } from './IconButton'; + +interface ThemeAndSettingsProps { + className?: string; +} + +export function ThemeAndSettings({ className }: ThemeAndSettingsProps) { + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + + return ( +
+ setIsSettingsOpen(true)} /> + + setIsSettingsOpen(false)} /> +
+ ); +} diff --git a/app/lib/persistence/db.ts b/app/lib/persistence/db.ts index 64aea1cf7..e6e64b72c 100644 --- a/app/lib/persistence/db.ts +++ b/app/lib/persistence/db.ts @@ -232,3 +232,64 @@ export async function updateChatDescription(db: IDBDatabase, id: string, descrip await setMessages(db, id, chat.messages, chat.urlId, description, chat.timestamp); } + +export async function deleteAllChats(db: IDBDatabase): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction('chats', 'readwrite'); + const store = transaction.objectStore('chats'); + const request = store.clear(); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +} + +export async function deleteAllChatsExceptToday(db: IDBDatabase): Promise { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const allChats = await getAll(db); + const chatsToDelete = allChats.filter((chat) => { + const chatDate = new Date(chat.timestamp); + chatDate.setHours(0, 0, 0, 0); + + return chatDate < today; + }); + + const transaction = db.transaction('chats', 'readwrite'); + const store = transaction.objectStore('chats'); + + return new Promise((resolve, reject) => { + let completed = 0; + let errors = 0; + + if (chatsToDelete.length === 0) { + resolve(); + return; + } + + chatsToDelete.forEach((chat) => { + const request = store.delete(chat.id); + + request.onsuccess = () => { + completed++; + + if (completed + errors === chatsToDelete.length) { + if (errors > 0) { + reject(new Error(`Failed to delete ${errors} chats`)); + } else { + resolve(); + } + } + }; + + request.onerror = () => { + errors++; + + if (completed + errors === chatsToDelete.length) { + reject(new Error(`Failed to delete ${errors} chats`)); + } + }; + }); + }); +} diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts index ca44b4bdf..c702be46e 100644 --- a/app/lib/persistence/useChatHistory.ts +++ b/app/lib/persistence/useChatHistory.ts @@ -12,6 +12,9 @@ import { setMessages, duplicateChat, createChatFromMessages, + deleteAllChats, + deleteAllChatsExceptToday, + getAll, } from './db'; export interface ChatHistoryItem { @@ -160,6 +163,64 @@ export function useChatHistory() { document.body.removeChild(a); URL.revokeObjectURL(url); }, + exportAllChats: async () => { + if (!db) { + return; + } + + try { + const allChats = await getAll(db); + const exportData = { + chats: allChats.map((chat) => ({ + messages: chat.messages, + description: chat.description, + timestamp: chat.timestamp, + })), + exportDate: new Date().toISOString(), + }; + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `all-chats-${new Date().toISOString()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + toast.success('All chats exported successfully'); + } catch (error) { + toast.error('Failed to export chats'); + console.error(error); + } + }, + deleteAllChatHistory: async () => { + if (!db) { + return; + } + + try { + await deleteAllChats(db); + window.location.href = '/'; + toast.success('All chat history deleted successfully'); + } catch (error) { + toast.error('Failed to delete chat history'); + console.error(error); + } + }, + deleteAllChatHistoryExceptToday: async () => { + if (!db) { + return; + } + + try { + await deleteAllChatsExceptToday(db); + toast.success('Old chat history deleted successfully'); + } catch (error) { + toast.error('Failed to delete chat history'); + console.error(error); + } + }, }; } diff --git a/app/lib/stores/settings.ts b/app/lib/stores/settings.ts index 5e48bfe4f..01df36fd0 100644 --- a/app/lib/stores/settings.ts +++ b/app/lib/stores/settings.ts @@ -1,5 +1,36 @@ import { map } from 'nanostores'; import { workbenchStore } from './workbench'; +import Cookies from 'js-cookie'; + +const ENV_API_KEYS = { + Anthropic: process.env.ANTHROPIC_API_KEY, + OpenAI: process.env.OPENAI_API_KEY, + GoogleGenerativeAI: process.env.GOOGLE_GENERATIVE_AI_API_KEY, + Groq: process.env.GROQ_API_KEY, + HuggingFace: process.env.HuggingFace_API_KEY, + OpenRouter: process.env.OPEN_ROUTER_API_KEY, + Deepseek: process.env.DEEPSEEK_API_KEY, + Mistral: process.env.MISTRAL_API_KEY, + OpenAILike: process.env.OPENAI_LIKE_API_KEY, + Together: process.env.TOGETHER_API_KEY, + xAI: process.env.XAI_API_KEY, + Cohere: process.env.COHERE_API_KEY, + AzureOpenAI: process.env.AZURE_OPENAI_API_KEY, +}; + +const ENV_BASE_URLS = { + Together: process.env.TOGETHER_API_BASE_URL, + OpenAILike: process.env.OPENAI_LIKE_API_BASE_URL, + LMStudio: process.env.LMSTUDIO_API_BASE_URL || 'http://localhost:1234', + Ollama: process.env.OLLAMA_API_BASE_URL || 'http://localhost:11434', +}; + +export interface ApiSettings { + apiKeys: Record; + baseUrls: Record; + activeProviders: Record; + debugMode?: boolean; +} export interface Shortcut { key: string; @@ -17,6 +48,7 @@ export interface Shortcuts { export interface Settings { shortcuts: Shortcuts; + apiSettings: ApiSettings; } export const shortcutsStore = map({ @@ -27,13 +59,131 @@ export const shortcutsStore = map({ }, }); +export const apiSettingsStore = map({ + apiKeys: {}, + baseUrls: {}, + activeProviders: {}, +}); + +// Initialize API settings from cookies and environment variables +const loadApiSettings = () => { + try { + const savedApiKeys = Cookies.get('apiKeys'); + const savedBaseUrls = Cookies.get('baseUrls'); + const savedActiveProviders = Cookies.get('activeProviders'); + const savedDebugMode = Cookies.get('debugMode'); + + // Start with environment variables + const apiKeys: Record = {}; + const baseUrls: Record = {}; + const activeProviders: Record = {}; + let debugMode = false; + + // Load environment variables first + Object.entries(ENV_API_KEYS).forEach(([provider, key]) => { + if (key) { + apiKeys[provider] = key; + activeProviders[provider] = true; + } + }); + + Object.entries(ENV_BASE_URLS).forEach(([provider, url]) => { + if (url) { + baseUrls[provider] = url; + activeProviders[provider] = true; + } + }); + + // Only merge cookie values if they exist and are not empty + if (savedApiKeys) { + const cookieApiKeys = JSON.parse(savedApiKeys); + Object.entries(cookieApiKeys).forEach(([provider, key]) => { + if (key && typeof key === 'string' && key.trim() !== '') { + apiKeys[provider] = key; + } + }); + } + + if (savedBaseUrls) { + const cookieBaseUrls = JSON.parse(savedBaseUrls); + Object.entries(cookieBaseUrls).forEach(([provider, url]) => { + if (url && typeof url === 'string' && url.trim() !== '') { + baseUrls[provider] = url; + } + }); + } + + if (savedActiveProviders) { + const cookieActiveProviders = JSON.parse(savedActiveProviders); + Object.assign(activeProviders, cookieActiveProviders); + } + + if (savedDebugMode) { + debugMode = JSON.parse(savedDebugMode); + } + + // Ensure providers with env vars are always active + Object.entries(ENV_API_KEYS).forEach(([provider, key]) => { + if (key) { + activeProviders[provider] = true; + } + }); + + Object.entries(ENV_BASE_URLS).forEach(([provider, url]) => { + if (url) { + activeProviders[provider] = true; + } + }); + + apiSettingsStore.set({ + apiKeys, + baseUrls, + activeProviders, + debugMode, + }); + + console.log('Loaded settings:', { apiKeys, baseUrls, activeProviders, debugMode }); + } catch (error) { + console.error('Error loading API settings:', error); + } +}; + +// Save API settings to cookies +export const saveApiSettings = (settings: ApiSettings) => { + try { + Cookies.set('apiKeys', JSON.stringify(settings.apiKeys), { expires: 30 }); + Cookies.set('baseUrls', JSON.stringify(settings.baseUrls), { expires: 30 }); + Cookies.set('activeProviders', JSON.stringify(settings.activeProviders), { expires: 30 }); + + if (settings.debugMode !== undefined) { + Cookies.set('debugMode', JSON.stringify(settings.debugMode), { expires: 30 }); + } + + apiSettingsStore.set(settings); + } catch (error) { + console.error('Error saving API settings to cookies:', error); + } +}; + export const settingsStore = map({ shortcuts: shortcutsStore.get(), + apiSettings: apiSettingsStore.get(), }); +// Subscribe to changes in shortcuts and API settings shortcutsStore.subscribe((shortcuts) => { settingsStore.set({ ...settingsStore.get(), shortcuts, }); }); + +apiSettingsStore.subscribe((apiSettings) => { + settingsStore.set({ + ...settingsStore.get(), + apiSettings, + }); +}); + +// Load API settings when the module is imported +loadApiSettings();