diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index 0d8933b..a77932c 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -17,7 +17,6 @@ import Cookies from 'js-cookie'; import * as Tooltip from '@radix-ui/react-tooltip'; import styles from './BaseChat.module.scss'; -import type { ProviderInfo } from '~/utils/types'; import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton'; import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons'; import { ExamplePrompts } from '~/components/chat/ExamplePrompts'; @@ -26,6 +25,7 @@ import GitCloneButton from './GitCloneButton'; import FilePreview from './FilePreview'; import { ModelSelector } from '~/components/chat/ModelSelector'; import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition'; +import type { IProviderSetting, ProviderInfo } from '~/types/model'; const TEXTAREA_MIN_HEIGHT = 76; @@ -45,6 +45,7 @@ interface BaseChatProps { setModel?: (model: string) => void; provider?: ProviderInfo; setProvider?: (provider: ProviderInfo) => void; + providerList?: ProviderInfo[]; handleStop?: () => void; sendMessage?: (event: React.UIEvent, messageInput?: string) => void; handleInputChange?: (event: React.ChangeEvent) => void; @@ -70,6 +71,7 @@ export const BaseChat = React.forwardRef( setModel, provider, setProvider, + providerList, input = '', enhancingPrompt, handleInputChange, @@ -108,48 +110,10 @@ export const BaseChat = React.forwardRef( const [recognition, setRecognition] = useState(null); const [transcript, setTranscript] = useState(''); - // Load enabled providers from cookies - const [enabledProviders, setEnabledProviders] = useState(() => { - const savedProviders = Cookies.get('providers'); - - if (savedProviders) { - try { - const parsedProviders = JSON.parse(savedProviders); - return PROVIDER_LIST.filter((p) => parsedProviders[p.name]); - } catch (error) { - console.error('Failed to parse providers from cookies:', error); - return PROVIDER_LIST; - } - } - - return PROVIDER_LIST; - }); - - // Update enabled providers when cookies change - useEffect(() => { - const updateProvidersFromCookies = () => { - const savedProviders = Cookies.get('providers'); - - if (savedProviders) { - try { - const parsedProviders = JSON.parse(savedProviders); - setEnabledProviders(PROVIDER_LIST.filter((p) => parsedProviders[p.name])); - } catch (error) { - console.error('Failed to parse providers from cookies:', error); - } - } - }; - - updateProvidersFromCookies(); - - const interval = setInterval(updateProvidersFromCookies, 1000); - - return () => clearInterval(interval); - }, [PROVIDER_LIST]); - useEffect(() => { console.log(transcript); }, [transcript]); + useEffect(() => { // Load API keys from cookies on component mount try { @@ -169,7 +133,26 @@ export const BaseChat = React.forwardRef( Cookies.remove('apiKeys'); } - initializeModelList().then((modelList) => { + let providerSettings: Record | undefined = undefined; + + try { + const savedProviderSettings = Cookies.get('providers'); + + if (savedProviderSettings) { + const parsedProviderSettings = JSON.parse(savedProviderSettings); + + if (typeof parsedProviderSettings === 'object' && parsedProviderSettings !== null) { + providerSettings = parsedProviderSettings; + } + } + } catch (error) { + console.error('Error loading Provider Settings from cookies:', error); + + // Clear invalid cookie data + Cookies.remove('providers'); + } + + initializeModelList(providerSettings).then((modelList) => { setModelList(modelList); }); @@ -369,10 +352,10 @@ export const BaseChat = React.forwardRef( modelList={modelList} provider={provider} setProvider={setProvider} - providerList={PROVIDER_LIST} + providerList={providerList || PROVIDER_LIST} apiKeys={apiKeys} /> - {enabledProviders.length > 0 && provider && ( + {(providerList || []).length > 0 && provider && ( ( 0 || isStreaming || uploadedFiles.length > 0} isStreaming={isStreaming} - disabled={enabledProviders.length === 0} + disabled={!providerList || providerList.length === 0} onClick={(event) => { if (isStreaming) { handleStop?.(); @@ -528,7 +511,7 @@ export const BaseChat = React.forwardRef( !isModelSettingsCollapsed, })} onClick={() => setIsModelSettingsCollapsed(!isModelSettingsCollapsed)} - disabled={enabledProviders.length === 0} + disabled={!providerList || providerList.length === 0} >
{isModelSettingsCollapsed ? {model} : } diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index c6a01ee..cd651cb 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -17,8 +17,9 @@ import { cubicEasingFn } from '~/utils/easings'; import { createScopedLogger, renderLogger } from '~/utils/logger'; import { BaseChat } from './BaseChat'; import Cookies from 'js-cookie'; -import type { ProviderInfo } from '~/utils/types'; import { debounce } from '~/utils/debounce'; +import { useSettings } from '~/lib/hooks/useSettings'; +import type { ProviderInfo } from '~/types/model'; const toastAnimation = cssTransition({ enter: 'animated fadeInRight', @@ -91,6 +92,7 @@ export const ChatImpl = memo( const [chatStarted, setChatStarted] = useState(initialMessages.length > 0); const [uploadedFiles, setUploadedFiles] = useState([]); // Move here const [imageDataList, setImageDataList] = useState([]); // Move here + const { activeProviders } = useSettings(); const [model, setModel] = useState(() => { const savedModel = Cookies.get('selectedModel'); @@ -316,6 +318,7 @@ export const ChatImpl = memo( setModel={handleModelChange} provider={provider} setProvider={handleProviderChange} + providerList={activeProviders} messageRef={messageRef} scrollRef={scrollRef} handleInputChange={(e) => { diff --git a/app/components/settings/Settings.module.scss b/app/components/settings/Settings.module.scss index 6da8288..639cbbc 100644 --- a/app/components/settings/Settings.module.scss +++ b/app/components/settings/Settings.module.scss @@ -46,7 +46,7 @@ padding: 1rem; margin-bottom: 1rem; border-style: solid; - border-color: var(--bolt-elements-button-danger-backgroundHover) ; + border-color: var(--bolt-elements-button-danger-backgroundHover); border-width: thin; button { @@ -60,4 +60,4 @@ background-color: var(--bolt-elements-button-danger-backgroundHover); } } -} \ No newline at end of file +} diff --git a/app/components/settings/SettingsWindow.tsx b/app/components/settings/SettingsWindow.tsx index 4391ff4..b7b368d 100644 --- a/app/components/settings/SettingsWindow.tsx +++ b/app/components/settings/SettingsWindow.tsx @@ -1,17 +1,16 @@ import * as RadixDialog from '@radix-ui/react-dialog'; import { motion } from 'framer-motion'; -import { useState } from 'react'; +import { useState, type ReactElement } from 'react'; import { classNames } from '~/utils/classNames'; import { DialogTitle, dialogVariants, dialogBackdropVariants } from '~/components/ui/Dialog'; import { IconButton } from '~/components/ui/IconButton'; -import { providersList } from '~/lib/stores/settings'; -import { db, getAll, deleteById } from '~/lib/persistence'; -import { toast } from 'react-toastify'; -import { useNavigate } from '@remix-run/react'; -import commit from '~/commit.json'; -import Cookies from 'js-cookie'; import styles from './Settings.module.scss'; -import { Switch } from '~/components/ui/Switch'; +import ChatHistoryTab from './chat-history/ChatHistoryTab'; +import ProvidersTab from './providers/ProvidersTab'; +import { useSettings } from '~/lib/hooks/useSettings'; +import FeaturesTab from './features/FeaturesTab'; +import DebugTab from './debug/DebugTab'; +import ConnectionsTab from './connections/ConnectionsTab'; interface SettingsProps { open: boolean; @@ -21,206 +20,27 @@ interface SettingsProps { type TabType = 'chat-history' | 'providers' | 'features' | 'debug' | 'connection'; // Providers that support base URL configuration -const URL_CONFIGURABLE_PROVIDERS = ['Ollama', 'LMStudio', 'OpenAILike']; - export const SettingsWindow = ({ open, onClose }: SettingsProps) => { - const navigate = useNavigate(); + const { debug } = useSettings(); const [activeTab, setActiveTab] = useState('chat-history'); - const [isDebugEnabled, setIsDebugEnabled] = useState(() => { - const savedDebugState = Cookies.get('isDebugEnabled'); - return savedDebugState === 'true'; - }); - const [searchTerm, setSearchTerm] = useState(''); - const [isDeleting, setIsDeleting] = useState(false); - const [githubUsername, setGithubUsername] = useState(Cookies.get('githubUsername') || ''); - const [githubToken, setGithubToken] = useState(Cookies.get('githubToken') || ''); - const [isLocalModelsEnabled, setIsLocalModelsEnabled] = useState(() => { - const savedLocalModelsState = Cookies.get('isLocalModelsEnabled'); - return savedLocalModelsState === 'true'; - }); - // Load base URLs from cookies - const [baseUrls, setBaseUrls] = useState(() => { - const savedUrls = Cookies.get('providerBaseUrls'); - - if (savedUrls) { - try { - return JSON.parse(savedUrls); - } catch (error) { - console.error('Failed to parse base URLs from cookies:', error); - return { - Ollama: 'http://localhost:11434', - LMStudio: 'http://localhost:1234', - OpenAILike: '', - }; - } - } - - return { - Ollama: 'http://localhost:11434', - LMStudio: 'http://localhost:1234', - OpenAILike: '', - }; - }); - - const handleBaseUrlChange = (provider: string, url: string) => { - setBaseUrls((prev: Record) => { - const newUrls = { ...prev, [provider]: url }; - Cookies.set('providerBaseUrls', JSON.stringify(newUrls)); - - return newUrls; - }); - }; - - const tabs: { id: TabType; label: string; icon: string }[] = [ - { id: 'chat-history', label: 'Chat History', icon: 'i-ph:book' }, - { id: 'providers', label: 'Providers', icon: 'i-ph:key' }, - { id: 'features', label: 'Features', icon: 'i-ph:star' }, - { id: 'connection', label: 'Connection', icon: 'i-ph:link' }, - ...(isDebugEnabled ? [{ id: 'debug' as TabType, label: 'Debug Tab', icon: 'i-ph:bug' }] : []), + const tabs: { id: TabType; label: string; icon: string; component?: ReactElement }[] = [ + { id: 'chat-history', label: 'Chat History', icon: 'i-ph:book', component: }, + { id: 'providers', label: 'Providers', icon: 'i-ph:key', component: }, + { id: 'features', label: 'Features', icon: 'i-ph:star', component: }, + { id: 'connection', label: 'Connection', icon: 'i-ph:link', component: }, + ...(debug + ? [ + { + id: 'debug' as TabType, + label: 'Debug Tab', + icon: 'i-ph:bug', + component: , + }, + ] + : []), ]; - // Load providers from cookies on mount - const [providers, setProviders] = useState(() => { - const savedProviders = Cookies.get('providers'); - - if (savedProviders) { - try { - const parsedProviders = JSON.parse(savedProviders); - - // Merge saved enabled states with the base provider list - return providersList.map((provider) => ({ - ...provider, - isEnabled: parsedProviders[provider.name] || false, - })); - } catch (error) { - console.error('Failed to parse providers from cookies:', error); - } - } - - return providersList; - }); - - const handleToggleProvider = (providerName: string, enabled: boolean) => { - setProviders((prevProviders) => { - const newProviders = prevProviders.map((provider) => - provider.name === providerName ? { ...provider, isEnabled: enabled } : provider, - ); - - // Save to cookies - const enabledStates = newProviders.reduce( - (acc, provider) => ({ - ...acc, - [provider.name]: provider.isEnabled, - }), - {}, - ); - Cookies.set('providers', JSON.stringify(enabledStates)); - - return newProviders; - }); - }; - - const filteredProviders = providers - .filter((provider) => { - const isLocalModelProvider = ['OpenAILike', 'LMStudio', 'Ollama'].includes(provider.name); - return isLocalModelsEnabled || !isLocalModelProvider; - }) - .filter((provider) => provider.name.toLowerCase().includes(searchTerm.toLowerCase())) - .sort((a, b) => a.name.localeCompare(b.name)); - - const handleCopyToClipboard = () => { - const debugInfo = { - OS: navigator.platform, - Browser: navigator.userAgent, - ActiveFeatures: providers.filter((provider) => provider.isEnabled).map((provider) => provider.name), - BaseURLs: { - Ollama: process.env.REACT_APP_OLLAMA_URL, - OpenAI: process.env.REACT_APP_OPENAI_URL, - LMStudio: process.env.REACT_APP_LM_STUDIO_URL, - }, - Version: versionHash, - }; - navigator.clipboard.writeText(JSON.stringify(debugInfo, null, 2)).then(() => { - alert('Debug information copied to clipboard!'); - }); - }; - - const downloadAsJson = (data: any, filename: string) => { - const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - }; - - const handleDeleteAllChats = async () => { - if (!db) { - toast.error('Database is not available'); - return; - } - - try { - setIsDeleting(true); - - const allChats = await getAll(db); - - // Delete all chats one by one - await Promise.all(allChats.map((chat) => deleteById(db!, chat.id))); - - toast.success('All chats deleted successfully'); - navigate('/', { replace: true }); - } catch (error) { - toast.error('Failed to delete chats'); - console.error(error); - } finally { - setIsDeleting(false); - } - }; - - const handleExportAllChats = async () => { - if (!db) { - toast.error('Database is not available'); - return; - } - - try { - const allChats = await getAll(db); - const exportData = { - chats: allChats, - exportDate: new Date().toISOString(), - }; - - downloadAsJson(exportData, `all-chats-${new Date().toISOString()}.json`); - toast.success('Chats exported successfully'); - } catch (error) { - toast.error('Failed to export chats'); - console.error(error); - } - }; - - const versionHash = commit.commit; // Get the version hash from commit.json - - const handleSaveConnection = () => { - Cookies.set('githubUsername', githubUsername); - Cookies.set('githubToken', githubToken); - toast.success('GitHub credentials saved successfully!'); - }; - - const handleToggleDebug = (enabled: boolean) => { - setIsDebugEnabled(enabled); - Cookies.set('isDebugEnabled', String(enabled)); - }; - - const handleToggleLocalModels = (enabled: boolean) => { - setIsLocalModelsEnabled(enabled); - Cookies.set('isLocalModelsEnabled', String(enabled)); - }; - return ( @@ -284,192 +104,7 @@ export const SettingsWindow = ({ open, onClose }: SettingsProps) => {
-
- {activeTab === 'chat-history' && ( -
-

Chat History

- - -
-

Danger Area

-

This action cannot be undone!

- -
-
- )} - {activeTab === 'providers' && ( -
-
- setSearchTerm(e.target.value)} - className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" - /> -
- {filteredProviders.map((provider) => ( -
-
- {provider.name} - handleToggleProvider(provider.name, enabled)} - /> -
- {/* Base URL input for configurable providers */} - {URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && provider.isEnabled && ( -
- - handleBaseUrlChange(provider.name, e.target.value)} - placeholder={`Enter ${provider.name} base URL`} - className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" - /> -
- )} -
- ))} -
- )} - {activeTab === 'features' && ( -
-
-

Optional Features

-
- Debug Info - -
-
- -
-

- Experimental Features -

-

- Disclaimer: Experimental features may be unstable and are subject to change. -

-
- Enable Local Models - -
-
-
- )} - {activeTab === 'debug' && isDebugEnabled && ( -
-

Debug Tab

- - -

System Information

-

OS: {navigator.platform}

-

Browser: {navigator.userAgent}

- -

Active Features

-
    - {providers - .filter((provider) => provider.isEnabled) - .map((provider) => ( -
  • - {provider.name} -
  • - ))} -
- -

Base URLs

-
    -
  • Ollama: {process.env.REACT_APP_OLLAMA_URL}
  • -
  • OpenAI: {process.env.REACT_APP_OPENAI_URL}
  • -
  • - LM Studio: {process.env.REACT_APP_LM_STUDIO_URL} -
  • -
- -

Version Information

-

Version Hash: {versionHash}

-
- )} - {activeTab === 'connection' && ( -
-

GitHub Connection

-
-
- - setGithubUsername(e.target.value)} - className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" - /> -
-
- - setGithubToken(e.target.value)} - className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" - /> -
-
-
- -
-
- )} -
+
{tabs.find((tab) => tab.id === activeTab)?.component}
diff --git a/app/components/settings/chat-history/ChatHistoryTab.tsx b/app/components/settings/chat-history/ChatHistoryTab.tsx new file mode 100644 index 0000000..e96f0d8 --- /dev/null +++ b/app/components/settings/chat-history/ChatHistoryTab.tsx @@ -0,0 +1,105 @@ +import { useNavigate } from '@remix-run/react'; +import React, { useState } from 'react'; +import { toast } from 'react-toastify'; +import { db, deleteById, getAll } from '~/lib/persistence'; +import { classNames } from '~/utils/classNames'; +import styles from '~/components/settings/Settings.module.scss'; + +export default function ChatHistoryTab() { + const navigate = useNavigate(); + const [isDeleting, setIsDeleting] = useState(false); + const downloadAsJson = (data: any, filename: string) => { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + const handleDeleteAllChats = async () => { + if (!db) { + toast.error('Database is not available'); + return; + } + + try { + setIsDeleting(true); + + const allChats = await getAll(db); + + // Delete all chats one by one + await Promise.all(allChats.map((chat) => deleteById(db!, chat.id))); + + toast.success('All chats deleted successfully'); + navigate('/', { replace: true }); + } catch (error) { + toast.error('Failed to delete chats'); + console.error(error); + } finally { + setIsDeleting(false); + } + }; + + const handleExportAllChats = async () => { + if (!db) { + toast.error('Database is not available'); + return; + } + + try { + const allChats = await getAll(db); + const exportData = { + chats: allChats, + exportDate: new Date().toISOString(), + }; + + downloadAsJson(exportData, `all-chats-${new Date().toISOString()}.json`); + toast.success('Chats exported successfully'); + } catch (error) { + toast.error('Failed to export chats'); + console.error(error); + } + }; + + return ( + <> +
+

Chat History

+ + +
+

Danger Area

+

This action cannot be undone!

+ +
+
+ + ); +} diff --git a/app/components/settings/connections/ConnectionsTab.tsx b/app/components/settings/connections/ConnectionsTab.tsx new file mode 100644 index 0000000..32d0fa0 --- /dev/null +++ b/app/components/settings/connections/ConnectionsTab.tsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react'; +import { toast } from 'react-toastify'; +import Cookies from 'js-cookie'; + +export default function ConnectionsTab() { + const [githubUsername, setGithubUsername] = useState(Cookies.get('githubUsername') || ''); + const [githubToken, setGithubToken] = useState(Cookies.get('githubToken') || ''); + + const handleSaveConnection = () => { + Cookies.set('githubUsername', githubUsername); + Cookies.set('githubToken', githubToken); + toast.success('GitHub credentials saved successfully!'); + }; + + return ( +
+

GitHub Connection

+
+
+ + setGithubUsername(e.target.value)} + className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" + /> +
+
+ + setGithubToken(e.target.value)} + className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" + /> +
+
+
+ +
+
+ ); +} diff --git a/app/components/settings/debug/DebugTab.tsx b/app/components/settings/debug/DebugTab.tsx new file mode 100644 index 0000000..7a84ec1 --- /dev/null +++ b/app/components/settings/debug/DebugTab.tsx @@ -0,0 +1,69 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useSettings } from '~/lib/hooks/useSettings'; +import commit from '~/commit.json'; + +const versionHash = commit.commit; // Get the version hash from commit.json + +export default function DebugTab() { + const { providers } = useSettings(); + const [activeProviders, setActiveProviders] = useState([]); + useEffect(() => { + setActiveProviders( + Object.entries(providers) + .filter(([_key, provider]) => provider.settings.enabled) + .map(([_key, provider]) => provider.name), + ); + }, [providers]); + + const handleCopyToClipboard = useCallback(() => { + const debugInfo = { + OS: navigator.platform, + Browser: navigator.userAgent, + ActiveFeatures: activeProviders, + BaseURLs: { + Ollama: process.env.REACT_APP_OLLAMA_URL, + OpenAI: process.env.REACT_APP_OPENAI_URL, + LMStudio: process.env.REACT_APP_LM_STUDIO_URL, + }, + Version: versionHash, + }; + navigator.clipboard.writeText(JSON.stringify(debugInfo, null, 2)).then(() => { + alert('Debug information copied to clipboard!'); + }); + }, [providers]); + + return ( +
+

Debug Tab

+ + +

System Information

+

OS: {navigator.platform}

+

Browser: {navigator.userAgent}

+ +

Active Features

+
    + {activeProviders.map((name) => ( +
  • + {name} +
  • + ))} +
+ +

Base URLs

+
    +
  • Ollama: {process.env.REACT_APP_OLLAMA_URL}
  • +
  • OpenAI: {process.env.REACT_APP_OPENAI_URL}
  • +
  • LM Studio: {process.env.REACT_APP_LM_STUDIO_URL}
  • +
+ +

Version Information

+

Version Hash: {versionHash}

+
+ ); +} diff --git a/app/components/settings/features/FeaturesTab.tsx b/app/components/settings/features/FeaturesTab.tsx new file mode 100644 index 0000000..0b4fa75 --- /dev/null +++ b/app/components/settings/features/FeaturesTab.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Switch } from '~/components/ui/Switch'; +import { useSettings } from '~/lib/hooks/useSettings'; + +export default function FeaturesTab() { + const { debug, enableDebugMode, isLocalModel, enableLocalModels } = useSettings(); + return ( +
+
+

Optional Features

+
+ Debug Info + +
+
+ +
+

Experimental Features

+

+ Disclaimer: Experimental features may be unstable and are subject to change. +

+
+ Enable Local Models + +
+
+
+ ); +} diff --git a/app/components/settings/providers/ProvidersTab.tsx b/app/components/settings/providers/ProvidersTab.tsx new file mode 100644 index 0000000..309afb8 --- /dev/null +++ b/app/components/settings/providers/ProvidersTab.tsx @@ -0,0 +1,78 @@ +import React, { useEffect, useState } from 'react'; +import { Switch } from '~/components/ui/Switch'; +import { useSettings } from '~/lib/hooks/useSettings'; +import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings'; +import type { IProviderConfig } from '~/types/model'; + +export default function ProvidersTab() { + const { providers, updateProviderSettings, isLocalModel } = useSettings(); + const [filteredProviders, setFilteredProviders] = useState([]); + + // Load base URLs from cookies + const [searchTerm, setSearchTerm] = useState(''); + + useEffect(() => { + let newFilteredProviders: IProviderConfig[] = Object.entries(providers).map(([key, value]) => ({ + ...value, + name: key, + })); + + if (searchTerm && searchTerm.length > 0) { + newFilteredProviders = newFilteredProviders.filter((provider) => + provider.name.toLowerCase().includes(searchTerm.toLowerCase()), + ); + } + + if (!isLocalModel) { + newFilteredProviders = newFilteredProviders.filter((provider) => !LOCAL_PROVIDERS.includes(provider.name)); + } + + newFilteredProviders.sort((a, b) => a.name.localeCompare(b.name)); + + setFilteredProviders(newFilteredProviders); + }, [providers, searchTerm, isLocalModel]); + + return ( +
+
+ setSearchTerm(e.target.value)} + className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" + /> +
+ {filteredProviders.map((provider) => ( +
+
+ {provider.name} + updateProviderSettings(provider.name, { ...provider.settings, enabled })} + /> +
+ {/* Base URL input for configurable providers */} + {URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && provider.settings.enabled && ( +
+ + + updateProviderSettings(provider.name, { ...provider.settings, baseUrl: e.target.value }) + } + placeholder={`Enter ${provider.name} base URL`} + className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" + /> +
+ )} +
+ ))} +
+ ); +} diff --git a/app/lib/.server/llm/model.ts b/app/lib/.server/llm/model.ts index ecbcd64..2588c2b 100644 --- a/app/lib/.server/llm/model.ts +++ b/app/lib/.server/llm/model.ts @@ -11,6 +11,7 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createMistral } from '@ai-sdk/mistral'; import { createCohere } from '@ai-sdk/cohere'; import type { LanguageModelV1 } from 'ai'; +import type { IProviderSetting } from '~/types/model'; export const DEFAULT_NUM_CTX = process.env.DEFAULT_NUM_CTX ? parseInt(process.env.DEFAULT_NUM_CTX, 10) : 32768; @@ -127,14 +128,20 @@ export function getXAIModel(apiKey: OptionalApiKey, model: string) { return openai(model); } -export function getModel(provider: string, model: string, env: Env, apiKeys?: Record) { +export function getModel( + provider: string, + model: string, + env: Env, + apiKeys?: Record, + providerSettings?: Record, +) { /* * let apiKey; // Declare first * let baseURL; */ const apiKey = getAPIKey(env, provider, apiKeys); // Then assign - const baseURL = getBaseURL(env, provider); + const baseURL = providerSettings?.[provider].baseUrl || getBaseURL(env, provider); switch (provider) { case 'Anthropic': diff --git a/app/lib/.server/llm/stream-text.ts b/app/lib/.server/llm/stream-text.ts index f408ba2..52271f0 100644 --- a/app/lib/.server/llm/stream-text.ts +++ b/app/lib/.server/llm/stream-text.ts @@ -3,6 +3,7 @@ import { getModel } from '~/lib/.server/llm/model'; import { MAX_TOKENS } from './constants'; import { getSystemPrompt } from './prompts'; import { DEFAULT_MODEL, DEFAULT_PROVIDER, getModelList, MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants'; +import type { IProviderSetting } from '~/types/model'; interface ToolResult { toolCallId: string; @@ -58,15 +59,17 @@ function extractPropertiesFromMessage(message: Message): { model: string; provid return { model, provider, content: cleanedContent }; } -export async function streamText( - messages: Messages, - env: Env, - options?: StreamingOptions, - apiKeys?: Record, -) { +export async function streamText(props: { + messages: Messages; + env: Env; + options?: StreamingOptions; + apiKeys?: Record; + providerSettings?: Record; +}) { + const { messages, env, options, apiKeys, providerSettings } = props; let currentModel = DEFAULT_MODEL; let currentProvider = DEFAULT_PROVIDER.name; - const MODEL_LIST = await getModelList(apiKeys || {}); + const MODEL_LIST = await getModelList(apiKeys || {}, providerSettings); const processedMessages = messages.map((message) => { if (message.role === 'user') { const { model, provider, content } = extractPropertiesFromMessage(message); @@ -88,7 +91,7 @@ export async function streamText( const dynamicMaxTokens = modelDetails && modelDetails.maxTokenAllowed ? modelDetails.maxTokenAllowed : MAX_TOKENS; return _streamText({ - model: getModel(currentProvider, currentModel, env, apiKeys) as any, + model: getModel(currentProvider, currentModel, env, apiKeys, providerSettings) as any, system: getSystemPrompt(), maxTokens: dynamicMaxTokens, messages: convertToCoreMessages(processedMessages as any), diff --git a/app/lib/hooks/useSettings.tsx b/app/lib/hooks/useSettings.tsx new file mode 100644 index 0000000..531e481 --- /dev/null +++ b/app/lib/hooks/useSettings.tsx @@ -0,0 +1,97 @@ +import { useStore } from '@nanostores/react'; +import { isDebugMode, isLocalModelsEnabled, LOCAL_PROVIDERS, providersStore } from '~/lib/stores/settings'; +import { useCallback, useEffect, useState } from 'react'; +import Cookies from 'js-cookie'; +import type { IProviderSetting, ProviderInfo } from '~/types/model'; + +export function useSettings() { + const providers = useStore(providersStore); + const debug = useStore(isDebugMode); + const isLocalModel = useStore(isLocalModelsEnabled); + const [activeProviders, setActiveProviders] = useState([]); + + // reading values from cookies on mount + useEffect(() => { + const savedProviders = Cookies.get('providers'); + + if (savedProviders) { + try { + const parsedProviders: Record = JSON.parse(savedProviders); + Object.keys(parsedProviders).forEach((provider) => { + const currentProvider = providers[provider]; + providersStore.setKey(provider, { + ...currentProvider, + settings: { + ...parsedProviders[provider], + enabled: parsedProviders[provider].enabled || true, + }, + }); + }); + } catch (error) { + console.error('Failed to parse providers from cookies:', error); + } + } + + // load debug mode from cookies + const savedDebugMode = Cookies.get('isDebugEnabled'); + + if (savedDebugMode) { + isDebugMode.set(savedDebugMode === 'true'); + } + + // load local models from cookies + const savedLocalModels = Cookies.get('isLocalModelsEnabled'); + + if (savedLocalModels) { + isLocalModelsEnabled.set(savedLocalModels === 'true'); + } + }, []); + + // writing values to cookies on change + useEffect(() => { + const providers = providersStore.get(); + const providerSetting: Record = {}; + Object.keys(providers).forEach((provider) => { + providerSetting[provider] = providers[provider].settings; + }); + Cookies.set('providers', JSON.stringify(providerSetting)); + }, [providers]); + + useEffect(() => { + let active = Object.entries(providers) + .filter(([_key, provider]) => provider.settings.enabled) + .map(([_k, p]) => p); + + if (!isLocalModel) { + active = active.filter((p) => !LOCAL_PROVIDERS.includes(p.name)); + } + + setActiveProviders(active); + }, [providers, isLocalModel]); + + // helper function to update settings + const updateProviderSettings = useCallback((provider: string, config: IProviderSetting) => { + const settings = providers[provider].settings; + providersStore.setKey(provider, { ...providers[provider], settings: { ...settings, ...config } }); + }, []); + + const enableDebugMode = useCallback((enabled: boolean) => { + isDebugMode.set(enabled); + Cookies.set('isDebugEnabled', String(enabled)); + }, []); + + const enableLocalModels = useCallback((enabled: boolean) => { + isLocalModelsEnabled.set(enabled); + Cookies.set('isLocalModelsEnabled', String(enabled)); + }, []); + + return { + providers, + activeProviders, + updateProviderSettings, + debug, + enableDebugMode, + isLocalModel, + enableLocalModels, + }; +} diff --git a/app/lib/stores/settings.ts b/app/lib/stores/settings.ts index 7106cfb..31564e6 100644 --- a/app/lib/stores/settings.ts +++ b/app/lib/stores/settings.ts @@ -1,5 +1,7 @@ -import { map } from 'nanostores'; +import { atom, map } from 'nanostores'; import { workbenchStore } from './workbench'; +import { PROVIDER_LIST } from '~/utils/constants'; +import type { IProviderConfig } from '~/types/model'; export interface Shortcut { key: string; @@ -15,32 +17,10 @@ export interface Shortcuts { toggleTerminal: Shortcut; } -export interface Provider { - name: string; - isEnabled: boolean; -} +export const URL_CONFIGURABLE_PROVIDERS = ['Ollama', 'LMStudio', 'OpenAILike']; +export const LOCAL_PROVIDERS = ['OpenAILike', 'LMStudio', 'Ollama']; -export interface Settings { - shortcuts: Shortcuts; - providers: Provider[]; -} - -export const providersList: Provider[] = [ - { name: 'Groq', isEnabled: false }, - { name: 'HuggingFace', isEnabled: false }, - { name: 'OpenAI', isEnabled: false }, - { name: 'Anthropic', isEnabled: false }, - { name: 'OpenRouter', isEnabled: false }, - { name: 'Google', isEnabled: false }, - { name: 'Ollama', isEnabled: false }, - { name: 'OpenAILike', isEnabled: false }, - { name: 'Together', isEnabled: false }, - { name: 'Deepseek', isEnabled: false }, - { name: 'Mistral', isEnabled: false }, - { name: 'Cohere', isEnabled: false }, - { name: 'LMStudio', isEnabled: false }, - { name: 'xAI', isEnabled: false }, -]; +export type ProviderSetting = Record; export const shortcutsStore = map({ toggleTerminal: { @@ -50,14 +30,17 @@ export const shortcutsStore = map({ }, }); -export const settingsStore = map({ - shortcuts: shortcutsStore.get(), - providers: providersList, +const initialProviderSettings: ProviderSetting = {}; +PROVIDER_LIST.forEach((provider) => { + initialProviderSettings[provider.name] = { + ...provider, + settings: { + enabled: false, + }, + }; }); +export const providersStore = map(initialProviderSettings); -shortcutsStore.subscribe((shortcuts) => { - settingsStore.set({ - ...settingsStore.get(), - shortcuts, - }); -}); +export const isDebugMode = atom(false); + +export const isLocalModelsEnabled = atom(true); diff --git a/app/routes/api.chat.ts b/app/routes/api.chat.ts index 0073274..9edf1af 100644 --- a/app/routes/api.chat.ts +++ b/app/routes/api.chat.ts @@ -3,6 +3,7 @@ import { MAX_RESPONSE_SEGMENTS, MAX_TOKENS } from '~/lib/.server/llm/constants'; import { CONTINUE_PROMPT } from '~/lib/.server/llm/prompts'; import { streamText, type Messages, type StreamingOptions } from '~/lib/.server/llm/stream-text'; import SwitchableStream from '~/lib/.server/llm/switchable-stream'; +import type { IProviderSetting } from '~/types/model'; export async function action(args: ActionFunctionArgs) { return chatAction(args); @@ -38,6 +39,9 @@ async function chatAction({ context, request }: ActionFunctionArgs) { // Parse the cookie's value (returns an object or null if no cookie exists) const apiKeys = JSON.parse(parseCookies(cookieHeader || '').apiKeys || '{}'); + const providerSettings: Record = JSON.parse( + parseCookies(cookieHeader || '').providers || '{}', + ); const stream = new SwitchableStream(); @@ -60,13 +64,13 @@ async function chatAction({ context, request }: ActionFunctionArgs) { messages.push({ role: 'assistant', content }); messages.push({ role: 'user', content: CONTINUE_PROMPT }); - const result = await streamText(messages, context.cloudflare.env, options, apiKeys); + const result = await streamText({ messages, env: context.cloudflare.env, options, apiKeys, providerSettings }); return stream.switchSource(result.toAIStream()); }, }; - const result = await streamText(messages, context.cloudflare.env, options, apiKeys); + const result = await streamText({ messages, env: context.cloudflare.env, options, apiKeys, providerSettings }); stream.switchSource(result.toAIStream()); diff --git a/app/routes/api.enhancer.ts b/app/routes/api.enhancer.ts index 0738ae4..cc51116 100644 --- a/app/routes/api.enhancer.ts +++ b/app/routes/api.enhancer.ts @@ -2,7 +2,7 @@ import { type ActionFunctionArgs } from '@remix-run/cloudflare'; import { StreamingTextResponse, parseStreamPart } from 'ai'; import { streamText } from '~/lib/.server/llm/stream-text'; import { stripIndents } from '~/utils/stripIndent'; -import type { ProviderInfo } from '~/types/model'; +import type { IProviderSetting, ProviderInfo } from '~/types/model'; const encoder = new TextEncoder(); const decoder = new TextDecoder(); @@ -11,8 +11,28 @@ export async function action(args: ActionFunctionArgs) { return enhancerAction(args); } +function parseCookies(cookieHeader: string) { + const cookies: any = {}; + + // Split the cookie string by semicolons and spaces + const items = cookieHeader.split(';').map((cookie) => cookie.trim()); + + items.forEach((item) => { + const [name, ...rest] = item.split('='); + + if (name && rest) { + // Decode the name and value, and join value parts in case it contains '=' + const decodedName = decodeURIComponent(name.trim()); + const decodedValue = decodeURIComponent(rest.join('=').trim()); + cookies[decodedName] = decodedValue; + } + }); + + return cookies; +} + async function enhancerAction({ context, request }: ActionFunctionArgs) { - const { message, model, provider, apiKeys } = await request.json<{ + const { message, model, provider } = await request.json<{ message: string; model: string; provider: ProviderInfo; @@ -36,9 +56,17 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) { }); } + const cookieHeader = request.headers.get('Cookie'); + + // Parse the cookie's value (returns an object or null if no cookie exists) + const apiKeys = JSON.parse(parseCookies(cookieHeader || '').apiKeys || '{}'); + const providerSettings: Record = JSON.parse( + parseCookies(cookieHeader || '').providers || '{}', + ); + try { - const result = await streamText( - [ + const result = await streamText({ + messages: [ { role: 'user', content: @@ -73,10 +101,10 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) { `, }, ], - context.cloudflare.env, - undefined, + env: context.cloudflare.env, apiKeys, - ); + providerSettings, + }); const transformStream = new TransformStream({ transform(chunk, controller) { diff --git a/app/types/model.ts b/app/types/model.ts index c6c58d7..3bfbfde 100644 --- a/app/types/model.ts +++ b/app/types/model.ts @@ -3,9 +3,17 @@ import type { ModelInfo } from '~/utils/types'; export type ProviderInfo = { staticModels: ModelInfo[]; name: string; - getDynamicModels?: (apiKeys?: Record) => Promise; + getDynamicModels?: (apiKeys?: Record, providerSettings?: IProviderSetting) => Promise; getApiKeyLink?: string; labelForGetApiKey?: string; icon?: string; - isEnabled?: boolean; +}; + +export interface IProviderSetting { + enabled?: boolean; + baseUrl?: string; +} + +export type IProviderConfig = ProviderInfo & { + settings: IProviderSetting; }; diff --git a/app/utils/constants.ts b/app/utils/constants.ts index 0cd0808..9d98774 100644 --- a/app/utils/constants.ts +++ b/app/utils/constants.ts @@ -1,6 +1,6 @@ import Cookies from 'js-cookie'; import type { ModelInfo, OllamaApiResponse, OllamaModel } from './types'; -import type { ProviderInfo } from '~/types/model'; +import type { ProviderInfo, IProviderSetting } from '~/types/model'; import { createScopedLogger } from './logger'; export const WORK_DIR_NAME = 'project'; @@ -298,13 +298,16 @@ const staticModels: ModelInfo[] = PROVIDER_LIST.map((p) => p.staticModels).flat( export let MODEL_LIST: ModelInfo[] = [...staticModels]; -export async function getModelList(apiKeys: Record) { +export async function getModelList( + apiKeys: Record, + providerSettings?: Record, +) { MODEL_LIST = [ ...( await Promise.all( PROVIDER_LIST.filter( (p): p is ProviderInfo & { getDynamicModels: () => Promise } => !!p.getDynamicModels, - ).map((p) => p.getDynamicModels(apiKeys)), + ).map((p) => p.getDynamicModels(apiKeys, providerSettings?.[p.name])), ) ).flat(), ...staticModels, @@ -312,9 +315,9 @@ export async function getModelList(apiKeys: Record) { return MODEL_LIST; } -async function getTogetherModels(apiKeys?: Record): Promise { +async function getTogetherModels(apiKeys?: Record, settings?: IProviderSetting): Promise { try { - const baseUrl = import.meta.env.TOGETHER_API_BASE_URL || ''; + const baseUrl = settings?.baseUrl || import.meta.env.TOGETHER_API_BASE_URL || ''; const provider = 'Together'; if (!baseUrl) { @@ -353,8 +356,8 @@ async function getTogetherModels(apiKeys?: Record): Promise { - const defaultBaseUrl = import.meta.env.OLLAMA_API_BASE_URL || 'http://localhost:11434'; +const getOllamaBaseUrl = (settings?: IProviderSetting) => { + const defaultBaseUrl = settings?.baseUrl || import.meta.env.OLLAMA_API_BASE_URL || 'http://localhost:11434'; // Check if we're in the browser if (typeof window !== 'undefined') { @@ -368,7 +371,7 @@ const getOllamaBaseUrl = () => { return isDocker ? defaultBaseUrl.replace('localhost', 'host.docker.internal') : defaultBaseUrl; }; -async function getOllamaModels(): Promise { +async function getOllamaModels(apiKeys?: Record, settings?: IProviderSetting): Promise { /* * if (typeof window === 'undefined') { * return []; @@ -376,7 +379,7 @@ async function getOllamaModels(): Promise { */ try { - const baseUrl = getOllamaBaseUrl(); + const baseUrl = getOllamaBaseUrl(settings); const response = await fetch(`${baseUrl}/api/tags`); const data = (await response.json()) as OllamaApiResponse; @@ -392,20 +395,21 @@ async function getOllamaModels(): Promise { } } -async function getOpenAILikeModels(): Promise { +async function getOpenAILikeModels( + apiKeys?: Record, + settings?: IProviderSetting, +): Promise { try { - const baseUrl = import.meta.env.OPENAI_LIKE_API_BASE_URL || ''; + const baseUrl = settings?.baseUrl || import.meta.env.OPENAI_LIKE_API_BASE_URL || ''; if (!baseUrl) { return []; } - let apiKey = import.meta.env.OPENAI_LIKE_API_KEY ?? ''; + let apiKey = ''; - const apikeys = JSON.parse(Cookies.get('apiKeys') || '{}'); - - if (apikeys && apikeys.OpenAILike) { - apiKey = apikeys.OpenAILike; + if (apiKeys && apiKeys.OpenAILike) { + apiKey = apiKeys.OpenAILike; } const response = await fetch(`${baseUrl}/models`, { @@ -459,13 +463,13 @@ async function getOpenRouterModels(): Promise { })); } -async function getLMStudioModels(): Promise { +async function getLMStudioModels(_apiKeys?: Record, settings?: IProviderSetting): Promise { if (typeof window === 'undefined') { return []; } try { - const baseUrl = import.meta.env.LMSTUDIO_API_BASE_URL || 'http://localhost:1234'; + const baseUrl = settings?.baseUrl || import.meta.env.LMSTUDIO_API_BASE_URL || 'http://localhost:1234'; const response = await fetch(`${baseUrl}/v1/models`); const data = (await response.json()) as any; @@ -480,7 +484,7 @@ async function getLMStudioModels(): Promise { } } -async function initializeModelList(): Promise { +async function initializeModelList(providerSettings?: Record): Promise { let apiKeys: Record = {}; try { @@ -501,7 +505,7 @@ async function initializeModelList(): Promise { await Promise.all( PROVIDER_LIST.filter( (p): p is ProviderInfo & { getDynamicModels: () => Promise } => !!p.getDynamicModels, - ).map((p) => p.getDynamicModels(apiKeys)), + ).map((p) => p.getDynamicModels(apiKeys, providerSettings?.[p.name])), ) ).flat(), ...staticModels, diff --git a/app/utils/types.ts b/app/utils/types.ts index 8742891..1fa253f 100644 --- a/app/utils/types.ts +++ b/app/utils/types.ts @@ -26,12 +26,3 @@ export interface ModelInfo { provider: string; maxTokenAllowed: number; } - -export interface ProviderInfo { - staticModels: ModelInfo[]; - name: string; - getDynamicModels?: () => Promise; - getApiKeyLink?: string; - labelForGetApiKey?: string; - icon?: string; -}