import { useEffect, useState, useRef, useCallback } from 'react'; import type { KeyboardEvent } from 'react'; import { Button } from '@extension/ui'; import { llmProviderStore, agentModelStore, AgentNameEnum, llmProviderModelNames, ProviderTypeEnum, getDefaultDisplayNameFromProviderId, getDefaultProviderConfig, getDefaultAgentModelParams, } from '@extension/storage'; interface ModelSettingsProps { isDarkMode?: boolean; } export const ModelSettings = ({ isDarkMode = false }: ModelSettingsProps) => { const [providers, setProviders] = useState< Record< string, { apiKey: string; baseUrl?: string; name?: string; modelNames?: string[]; type?: ProviderTypeEnum; createdAt?: number; } > >({}); const [modifiedProviders, setModifiedProviders] = useState>(new Set()); const [providersFromStorage, setProvidersFromStorage] = useState>(new Set()); const [selectedModels, setSelectedModels] = useState>({ [AgentNameEnum.Navigator]: '', [AgentNameEnum.Planner]: '', [AgentNameEnum.Validator]: '', }); const [modelParameters, setModelParameters] = useState>({ [AgentNameEnum.Navigator]: { temperature: 0, topP: 0 }, [AgentNameEnum.Planner]: { temperature: 0, topP: 0 }, [AgentNameEnum.Validator]: { temperature: 0, topP: 0 }, }); const [newModelInputs, setNewModelInputs] = useState>({}); const [isProviderSelectorOpen, setIsProviderSelectorOpen] = useState(false); const newlyAddedProviderRef = useRef(null); const [nameErrors, setNameErrors] = useState>({}); // Create a non-async wrapper for use in render functions const [availableModels, setAvailableModels] = useState< Array<{ provider: string; providerName: string; model: string }> >([]); useEffect(() => { const loadProviders = async () => { try { const allProviders = await llmProviderStore.getAllProviders(); console.log('allProviders', allProviders); // Track which providers are from storage const fromStorage = new Set(Object.keys(allProviders)); setProvidersFromStorage(fromStorage); // Only use providers from storage, don't add default ones setProviders(allProviders); } catch (error) { console.error('Error loading providers:', error); // Set empty providers on error setProviders({}); // No providers from storage on error setProvidersFromStorage(new Set()); } }; loadProviders(); }, []); // Load existing agent models and parameters on mount useEffect(() => { const loadAgentModels = async () => { try { const models: Record = { [AgentNameEnum.Planner]: '', [AgentNameEnum.Navigator]: '', [AgentNameEnum.Validator]: '', }; for (const agent of Object.values(AgentNameEnum)) { const config = await agentModelStore.getAgentModel(agent); if (config) { models[agent] = config.modelName; if (config.parameters?.temperature !== undefined || config.parameters?.topP !== undefined) { setModelParameters(prev => ({ ...prev, [agent]: { temperature: config.parameters?.temperature ?? prev[agent].temperature, topP: config.parameters?.topP ?? prev[agent].topP, }, })); } } } setSelectedModels(models); } catch (error) { console.error('Error loading agent models:', error); } }; loadAgentModels(); }, []); // Auto-focus the input field when a new provider is added useEffect(() => { // Only focus if we have a newly added provider reference if (newlyAddedProviderRef.current && providers[newlyAddedProviderRef.current]) { const providerId = newlyAddedProviderRef.current; const config = providers[providerId]; // For custom providers, focus on the name input if (config.type === ProviderTypeEnum.CustomOpenAI) { const nameInput = document.getElementById(`${providerId}-name`); if (nameInput) { nameInput.focus(); } } else { // For default providers, focus on the API key input const apiKeyInput = document.getElementById(`${providerId}-api-key`); if (apiKeyInput) { apiKeyInput.focus(); } } // Clear the ref after focusing newlyAddedProviderRef.current = null; } }, [providers]); // Add a click outside handler to close the dropdown useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as HTMLElement; if (isProviderSelectorOpen && !target.closest('.provider-selector-container')) { setIsProviderSelectorOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [isProviderSelectorOpen]); // Create a memoized version of getAvailableModels const getAvailableModelsCallback = useCallback(async () => { const models: Array<{ provider: string; providerName: string; model: string }> = []; try { // Load providers directly from storage const storedProviders = await llmProviderStore.getAllProviders(); // Only use providers that are actually in storage for (const [provider, config] of Object.entries(storedProviders)) { const providerModels = config.modelNames || llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || []; models.push( ...providerModels.map(model => ({ provider, providerName: config.name || provider, model, })), ); } } catch (error) { console.error('Error loading providers for model selection:', error); } return models; }, []); // Update available models whenever providers change useEffect(() => { const updateAvailableModels = async () => { const models = await getAvailableModelsCallback(); setAvailableModels(models); }; updateAvailableModels(); }, [getAvailableModelsCallback]); // Only depends on the callback const handleApiKeyChange = (provider: string, apiKey: string, baseUrl?: string) => { setModifiedProviders(prev => new Set(prev).add(provider)); setProviders(prev => ({ ...prev, [provider]: { ...prev[provider], apiKey: apiKey.trim(), baseUrl: baseUrl !== undefined ? baseUrl.trim() : prev[provider]?.baseUrl, }, })); }; const handleNameChange = (provider: string, name: string) => { setModifiedProviders(prev => new Set(prev).add(provider)); setProviders(prev => { const updated = { ...prev, [provider]: { ...prev[provider], name: name.trim(), }, }; return updated; }); }; const handleModelsChange = (provider: string, modelsString: string) => { setNewModelInputs(prev => ({ ...prev, [provider]: modelsString, })); }; const addModel = (provider: string, model: string) => { if (!model.trim()) return; setModifiedProviders(prev => new Set(prev).add(provider)); setProviders(prev => { const providerData = prev[provider] || {}; // Get current models - either from provider config or default models let currentModels = providerData.modelNames; if (currentModels === undefined) { currentModels = [...(llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [])]; } // Don't add duplicates if (currentModels.includes(model.trim())) return prev; return { ...prev, [provider]: { ...providerData, modelNames: [...currentModels, model.trim()], }, }; }); // Clear the input setNewModelInputs(prev => ({ ...prev, [provider]: '', })); }; const removeModel = (provider: string, modelToRemove: string) => { setModifiedProviders(prev => new Set(prev).add(provider)); setProviders(prev => { const providerData = prev[provider] || {}; // If modelNames doesn't exist in the provider data yet, we need to initialize it // with the default models from llmProviderModelNames first if (!providerData.modelNames) { const defaultModels = llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || []; const filteredModels = defaultModels.filter(model => model !== modelToRemove); return { ...prev, [provider]: { ...providerData, modelNames: filteredModels, }, }; } // If modelNames already exists, just filter out the model to remove return { ...prev, [provider]: { ...providerData, modelNames: providerData.modelNames.filter(model => model !== modelToRemove), }, }; }); }; const handleKeyDown = (e: KeyboardEvent, provider: string) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); const value = newModelInputs[provider] || ''; addModel(provider, value); } }; const getButtonProps = (provider: string) => { const isInStorage = providersFromStorage.has(provider); const isModified = modifiedProviders.has(provider); const isCustom = providers[provider]?.type === ProviderTypeEnum.CustomOpenAI; // For deletion, we only care if it's in storage and not modified if (isInStorage && !isModified) { return { theme: isDarkMode ? 'dark' : 'light', variant: 'danger' as const, children: 'Delete', disabled: false, }; } // For saving, we need to check if it has the required inputs // Only custom providers can be saved without an API key const hasInput = isCustom || Boolean(providers[provider]?.apiKey?.trim()); return { theme: isDarkMode ? 'dark' : 'light', variant: 'primary' as const, children: 'Save', disabled: !hasInput || !isModified, }; }; const handleSave = async (provider: string) => { try { // Check if name contains spaces for custom providers if (providers[provider].type === ProviderTypeEnum.CustomOpenAI && providers[provider].name?.includes(' ')) { setNameErrors(prev => ({ ...prev, [provider]: 'Spaces are not allowed in provider names. Please use underscores or other characters instead.', })); return; } // Check if base URL is required but missing for custom_openai if ( providers[provider].type === ProviderTypeEnum.CustomOpenAI && (!providers[provider].baseUrl || !providers[provider].baseUrl.trim()) ) { alert('Base URL is required for custom OpenAI-compatible API providers'); return; } // Check if API key is required but empty for built-in providers (except custom) const isCustom = providers[provider].type === ProviderTypeEnum.CustomOpenAI; if (!isCustom && (!providers[provider].apiKey || !providers[provider].apiKey.trim())) { alert('API key is required for this provider'); return; } // Ensure modelNames is provided let modelNames = providers[provider].modelNames; if (!modelNames) { // Use default model names if not explicitly set modelNames = [...(llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [])]; } // The provider store will handle filling in the missing fields await llmProviderStore.setProvider(provider, { apiKey: providers[provider].apiKey || '', baseUrl: providers[provider].baseUrl, name: providers[provider].name, modelNames: modelNames, type: providers[provider].type, createdAt: providers[provider].createdAt, }); // Clear any name errors on successful save setNameErrors(prev => { const newErrors = { ...prev }; delete newErrors[provider]; return newErrors; }); // Add to providersFromStorage since it's now saved setProvidersFromStorage(prev => new Set(prev).add(provider)); setModifiedProviders(prev => { const next = new Set(prev); next.delete(provider); return next; }); // Refresh available models const models = await getAvailableModelsCallback(); setAvailableModels(models); } catch (error) { console.error('Error saving API key:', error); } }; const handleDelete = async (provider: string) => { try { // Delete the provider from storage regardless of its API key value await llmProviderStore.removeProvider(provider); // Remove from providersFromStorage setProvidersFromStorage(prev => { const next = new Set(prev); next.delete(provider); return next; }); // Remove from providers state setProviders(prev => { const next = { ...prev }; delete next[provider]; return next; }); // Also remove from modifiedProviders if it's there setModifiedProviders(prev => { const next = new Set(prev); next.delete(provider); return next; }); // Refresh available models const models = await getAvailableModelsCallback(); setAvailableModels(models); } catch (error) { console.error('Error deleting provider:', error); } }; const handleCancelProvider = (providerId: string) => { // Remove the provider from the state setProviders(prev => { const next = { ...prev }; delete next[providerId]; return next; }); // Remove from modified providers setModifiedProviders(prev => { const next = new Set(prev); next.delete(providerId); return next; }); }; const handleModelChange = async (agentName: AgentNameEnum, modelValue: string) => { // modelValue will be in format "provider>model" const [provider, model] = modelValue.split('>'); // Set parameters based on provider type const newParameters = getDefaultAgentModelParams(provider, agentName); setModelParameters(prev => ({ ...prev, [agentName]: newParameters, })); setSelectedModels(prev => ({ ...prev, [agentName]: model, })); try { if (model) { await agentModelStore.setAgentModel(agentName, { provider, modelName: model, parameters: newParameters, }); } else { // Reset storage if no model is selected await agentModelStore.resetAgentModel(agentName); } } catch (error) { console.error('Error saving agent model:', error); } }; const handleParameterChange = async (agentName: AgentNameEnum, paramName: 'temperature' | 'topP', value: number) => { const newParameters = { ...modelParameters[agentName], [paramName]: value, }; setModelParameters(prev => ({ ...prev, [agentName]: newParameters, })); // Only update if we have a selected model if (selectedModels[agentName]) { try { // Find provider let provider: string | undefined; for (const [providerKey, providerConfig] of Object.entries(providers)) { const modelNames = providerConfig.modelNames || llmProviderModelNames[providerKey as keyof typeof llmProviderModelNames] || []; if (modelNames.includes(selectedModels[agentName])) { provider = providerKey; break; } } if (provider) { await agentModelStore.setAgentModel(agentName, { provider, modelName: selectedModels[agentName], parameters: newParameters, }); } } catch (error) { console.error('Error saving agent parameters:', error); } } }; const renderModelSelect = (agentName: AgentNameEnum) => (

{agentName.charAt(0).toUpperCase() + agentName.slice(1)}

{getAgentDescription(agentName)}

{/* Model Selection */}
{/* Temperature Slider */}
handleParameterChange(agentName, 'temperature', Number.parseFloat(e.target.value))} style={{ background: `linear-gradient(to right, ${isDarkMode ? '#3b82f6' : '#60a5fa'} 0%, ${isDarkMode ? '#3b82f6' : '#60a5fa'} ${(modelParameters[agentName].temperature / 2) * 100}%, ${isDarkMode ? '#475569' : '#cbd5e1'} ${(modelParameters[agentName].temperature / 2) * 100}%, ${isDarkMode ? '#475569' : '#cbd5e1'} 100%)`, }} className={`flex-1 ${isDarkMode ? 'accent-blue-500' : 'accent-blue-400'} h-1 appearance-none rounded-full`} />
{modelParameters[agentName].temperature.toFixed(2)} { const value = Number.parseFloat(e.target.value); if (!Number.isNaN(value) && value >= 0 && value <= 2) { handleParameterChange(agentName, 'temperature', value); } }} className={`w-20 rounded-md border ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-800' : 'border-gray-300 bg-white text-gray-700 focus:border-blue-400 focus:ring-2 focus:ring-blue-200'} px-2 py-1 text-sm`} aria-label={`${agentName} temperature number input`} />
{/* Top P Slider */}
handleParameterChange(agentName, 'topP', Number.parseFloat(e.target.value))} style={{ background: `linear-gradient(to right, ${isDarkMode ? '#3b82f6' : '#60a5fa'} 0%, ${isDarkMode ? '#3b82f6' : '#60a5fa'} ${modelParameters[agentName].topP * 100}%, ${isDarkMode ? '#475569' : '#cbd5e1'} ${modelParameters[agentName].topP * 100}%, ${isDarkMode ? '#475569' : '#cbd5e1'} 100%)`, }} className={`flex-1 ${isDarkMode ? 'accent-blue-500' : 'accent-blue-400'} h-1 appearance-none rounded-full`} />
{modelParameters[agentName].topP.toFixed(3)} { const value = Number.parseFloat(e.target.value); if (!Number.isNaN(value) && value >= 0 && value <= 1) { handleParameterChange(agentName, 'topP', value); } }} className={`w-20 rounded-md border ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-800' : 'border-gray-300 bg-white text-gray-700 focus:border-blue-400 focus:ring-2 focus:ring-blue-200'} px-2 py-1 text-sm`} aria-label={`${agentName} top P number input`} />
); const getAgentDescription = (agentName: AgentNameEnum) => { switch (agentName) { case AgentNameEnum.Navigator: return 'Navigates websites and performs actions'; case AgentNameEnum.Planner: return 'Develops and refines strategies to complete tasks'; case AgentNameEnum.Validator: return 'Checks if tasks are completed successfully'; default: return ''; } }; const getMaxCustomProviderNumber = () => { let maxNumber = 0; for (const providerId of Object.keys(providers)) { if (providerId.startsWith('custom_openai_')) { const match = providerId.match(/custom_openai_(\d+)/); if (match) { const number = Number.parseInt(match[1], 10); maxNumber = Math.max(maxNumber, number); } } } return maxNumber; }; const addCustomProvider = () => { const nextNumber = getMaxCustomProviderNumber() + 1; const providerId = `custom_openai_${nextNumber}`; setProviders(prev => ({ ...prev, [providerId]: { apiKey: '', name: `CustomProvider${nextNumber}`, type: ProviderTypeEnum.CustomOpenAI, baseUrl: '', modelNames: [], createdAt: Date.now(), }, })); setModifiedProviders(prev => new Set(prev).add(providerId)); // Set the newly added provider ref newlyAddedProviderRef.current = providerId; // Scroll to the newly added provider after render setTimeout(() => { const providerElement = document.getElementById(`provider-${providerId}`); if (providerElement) { providerElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, 100); }; const addBuiltInProvider = (provider: string) => { // Get the default provider configuration const config = getDefaultProviderConfig(provider); // Add the provider to the state setProviders(prev => ({ ...prev, [provider]: config, })); // Mark as modified so it shows up in the UI setModifiedProviders(prev => new Set(prev).add(provider)); // Set the newly added provider ref newlyAddedProviderRef.current = provider; // Scroll to the newly added provider after render setTimeout(() => { const providerElement = document.getElementById(`provider-${provider}`); if (providerElement) { providerElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, 100); }; // Sort providers to ensure newly added providers appear at the bottom const getSortedProviders = () => { // Filter providers to only include those from storage and newly added providers const filteredProviders = Object.entries(providers).filter(([providerId]) => { // Include if it's from storage if (providersFromStorage.has(providerId)) { return true; } // Include if it's a newly added provider (has been modified) if (modifiedProviders.has(providerId)) { return true; } // Exclude providers that aren't from storage and haven't been modified return false; }); // Sort the filtered providers return filteredProviders.sort(([keyA, configA], [keyB, configB]) => { // First, separate newly added providers from stored providers const isNewA = !providersFromStorage.has(keyA) && modifiedProviders.has(keyA); const isNewB = !providersFromStorage.has(keyB) && modifiedProviders.has(keyB); // If one is new and one is stored, new ones go to the end if (isNewA && !isNewB) return 1; if (!isNewA && isNewB) return -1; // If both are new or both are stored, sort by createdAt if (configA.createdAt && configB.createdAt) { return configA.createdAt - configB.createdAt; // Sort in ascending order (oldest first) } // If only one has createdAt, put the one without createdAt at the end if (configA.createdAt) return -1; if (configB.createdAt) return 1; // If neither has createdAt, sort by type and then name const isCustomA = configA.type === ProviderTypeEnum.CustomOpenAI; const isCustomB = configB.type === ProviderTypeEnum.CustomOpenAI; if (isCustomA && !isCustomB) { return 1; // Custom providers come after non-custom } if (!isCustomA && isCustomB) { return -1; // Non-custom providers come before custom } // Sort alphabetically by name within each group return (configA.name || keyA).localeCompare(configB.name || keyB); }); }; const handleProviderSelection = (providerType: string) => { // Close the dropdown immediately setIsProviderSelectorOpen(false); // Handle custom provider if (providerType === ProviderTypeEnum.CustomOpenAI) { addCustomProvider(); return; } // Handle built-in supported providers addBuiltInProvider(providerType); }; const getProviderForModel = (modelName: string): string => { for (const [provider, config] of Object.entries(providers)) { const modelNames = config.modelNames || llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || []; if (modelNames.includes(modelName)) { return provider; } } return ''; }; return (
{/* LLM Providers Section */}

LLM Providers

{getSortedProviders().length === 0 ? (

No providers configured yet. Add a provider to get started.

) : ( getSortedProviders().map(([providerId, providerConfig]) => (

{providerConfig.name || providerId}

{/* Show Cancel button for newly added providers */} {modifiedProviders.has(providerId) && !providersFromStorage.has(providerId) && ( )}
{/* Show message for newly added providers */} {modifiedProviders.has(providerId) && !providersFromStorage.has(providerId) && (

This provider is newly added. Enter your API key and click Save to configure it.

)}
{/* Name input (only for custom_openai) - moved to top for prominence */} {providerConfig.type === ProviderTypeEnum.CustomOpenAI && (
{ console.log('Name input changed:', e.target.value); handleNameChange(providerId, e.target.value); }} className={`flex-1 rounded-md border p-2 text-sm ${ nameErrors[providerId] ? isDarkMode ? 'border-red-700 bg-slate-700 text-gray-200 focus:border-red-600 focus:ring-2 focus:ring-red-900' : 'border-red-300 bg-gray-50 focus:border-red-400 focus:ring-2 focus:ring-red-200' : isDarkMode ? 'border-blue-700 bg-slate-700 text-gray-200 focus:border-blue-600 focus:ring-2 focus:ring-blue-900' : 'border-blue-300 bg-gray-50 focus:border-blue-400 focus:ring-2 focus:ring-blue-200' } outline-none`} />
{nameErrors[providerId] ? (

{nameErrors[providerId]}

) : (

Provider name (spaces are not allowed when saving)

)}
)} {/* API Key input with label */}
handleApiKeyChange(providerId, e.target.value, providerConfig.baseUrl)} className={`flex-1 rounded-md border text-sm ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-800' : 'border-gray-300 bg-white text-gray-700 focus:border-blue-400 focus:ring-2 focus:ring-blue-200'} p-2 outline-none`} />
{/* Display API key for newly added providers */} {modifiedProviders.has(providerId) && !providersFromStorage.has(providerId) && providerConfig.apiKey && (

{providerConfig.apiKey}

)} {/* Base URL input (for custom_openai and ollama) */} {(providerConfig.type === ProviderTypeEnum.CustomOpenAI || providerConfig.type === ProviderTypeEnum.Ollama) && (
handleApiKeyChange(providerId, providerConfig.apiKey || '', e.target.value)} className={`flex-1 rounded-md border text-sm ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-800' : 'border-gray-300 bg-white text-gray-700 focus:border-blue-400 focus:ring-2 focus:ring-blue-200'} p-2 outline-none`} />
)} {/* Models input field with tags */}
{/* Display existing models as tags */} {(() => { // Get models from provider config or default models const models = providerConfig.modelNames !== undefined ? providerConfig.modelNames : llmProviderModelNames[providerId as keyof typeof llmProviderModelNames] || []; return models.map(model => (
{model}
)); })()} {/* Input for new models */} handleModelsChange(providerId, e.target.value)} onKeyDown={e => handleKeyDown(e, providerId)} className={`min-w-[150px] flex-1 border-none text-sm ${isDarkMode ? 'bg-transparent text-gray-200' : 'bg-transparent text-gray-700'} p-1 outline-none`} />

Type and Press Enter or Space to add a model

{/* Ollama reminder at the bottom of the section */} {providerConfig.type === ProviderTypeEnum.Ollama && (

Remember: Add{' '} OLLAMA_ORIGINS=chrome-extension://* {' '} environment variable for the Ollama server. Learn more

)}
{/* Add divider except for the last item */} {Object.keys(providers).indexOf(providerId) < Object.keys(providers).length - 1 && (
)}
)) )} {/* Add Provider button and dropdown */}
{isProviderSelectorOpen && (
{/* Map through provider types to create buttons */} {Object.values(ProviderTypeEnum) // Filter out CustomOpenAI and already added providers .filter( type => type !== ProviderTypeEnum.CustomOpenAI && !providersFromStorage.has(type) && !modifiedProviders.has(type), ) .map(type => ( ))} {/* Custom provider button (always shown) */}
)}
{/* Updated Agent Models Section */}

Model Selection

{[AgentNameEnum.Planner, AgentNameEnum.Navigator, AgentNameEnum.Validator].map(agentName => (
{renderModelSelect(agentName)}
))}
); };