|
@@ -1,11 +1,15 @@
|
|
|
-import { useEffect, useState } from 'react';
|
|
|
+import { useEffect, useState, useRef, useCallback } from 'react';
|
|
|
+import type { KeyboardEvent } from 'react';
|
|
|
import { Button } from '@extension/ui';
|
|
|
import {
|
|
|
llmProviderStore,
|
|
|
agentModelStore,
|
|
|
AgentNameEnum,
|
|
|
- LLMProviderEnum,
|
|
|
llmProviderModelNames,
|
|
|
+ ProviderTypeEnum,
|
|
|
+ getDefaultDisplayNameFromProviderId,
|
|
|
+ getDefaultProviderConfig,
|
|
|
+ getDefaultAgentModelParams,
|
|
|
} from '@extension/storage';
|
|
|
|
|
|
interface ModelSettingsProps {
|
|
@@ -13,323 +17,1099 @@ interface ModelSettingsProps {
|
|
|
}
|
|
|
|
|
|
export const ModelSettings = ({ isDarkMode = false }: ModelSettingsProps) => {
|
|
|
- const [apiKeys, setApiKeys] = useState<Record<LLMProviderEnum, { apiKey: string; baseUrl?: string }>>(
|
|
|
- {} as Record<LLMProviderEnum, { apiKey: string; baseUrl?: string }>,
|
|
|
- );
|
|
|
- const [modifiedProviders, setModifiedProviders] = useState<Set<LLMProviderEnum>>(new Set());
|
|
|
+ const [providers, setProviders] = useState<
|
|
|
+ Record<
|
|
|
+ string,
|
|
|
+ {
|
|
|
+ apiKey: string;
|
|
|
+ baseUrl?: string;
|
|
|
+ name?: string;
|
|
|
+ modelNames?: string[];
|
|
|
+ type?: ProviderTypeEnum;
|
|
|
+ createdAt?: number;
|
|
|
+ }
|
|
|
+ >
|
|
|
+ >({});
|
|
|
+ const [modifiedProviders, setModifiedProviders] = useState<Set<string>>(new Set());
|
|
|
+ const [providersFromStorage, setProvidersFromStorage] = useState<Set<string>>(new Set());
|
|
|
const [selectedModels, setSelectedModels] = useState<Record<AgentNameEnum, string>>({
|
|
|
[AgentNameEnum.Navigator]: '',
|
|
|
[AgentNameEnum.Planner]: '',
|
|
|
[AgentNameEnum.Validator]: '',
|
|
|
});
|
|
|
+ const [modelParameters, setModelParameters] = useState<Record<AgentNameEnum, { temperature: number; topP: number }>>({
|
|
|
+ [AgentNameEnum.Navigator]: { temperature: 0, topP: 0 },
|
|
|
+ [AgentNameEnum.Planner]: { temperature: 0, topP: 0 },
|
|
|
+ [AgentNameEnum.Validator]: { temperature: 0, topP: 0 },
|
|
|
+ });
|
|
|
+ const [newModelInputs, setNewModelInputs] = useState<Record<string, string>>({});
|
|
|
+ const [isProviderSelectorOpen, setIsProviderSelectorOpen] = useState(false);
|
|
|
+ const newlyAddedProviderRef = useRef<string | null>(null);
|
|
|
+ const [nameErrors, setNameErrors] = useState<Record<string, string>>({});
|
|
|
+ // Create a non-async wrapper for use in render functions
|
|
|
+ const [availableModels, setAvailableModels] = useState<
|
|
|
+ Array<{ provider: string; providerName: string; model: string }>
|
|
|
+ >([]);
|
|
|
|
|
|
useEffect(() => {
|
|
|
- const loadApiKeys = async () => {
|
|
|
+ const loadProviders = async () => {
|
|
|
try {
|
|
|
- const providers = await llmProviderStore.getConfiguredProviders();
|
|
|
-
|
|
|
- const keys: Record<LLMProviderEnum, { apiKey: string; baseUrl?: string }> = {} as Record<
|
|
|
- LLMProviderEnum,
|
|
|
- { apiKey: string; baseUrl?: string }
|
|
|
- >;
|
|
|
+ const allProviders = await llmProviderStore.getAllProviders();
|
|
|
+ console.log('allProviders', allProviders);
|
|
|
|
|
|
- for (const provider of providers) {
|
|
|
- const config = await llmProviderStore.getProvider(provider);
|
|
|
- if (config) {
|
|
|
- keys[provider] = {
|
|
|
- apiKey: config.apiKey || '',
|
|
|
- baseUrl: config.baseUrl,
|
|
|
- };
|
|
|
- }
|
|
|
- }
|
|
|
+ // Track which providers are from storage
|
|
|
+ const fromStorage = new Set(Object.keys(allProviders));
|
|
|
+ setProvidersFromStorage(fromStorage);
|
|
|
|
|
|
- setApiKeys(keys);
|
|
|
+ // Only use providers from storage, don't add default ones
|
|
|
+ setProviders(allProviders);
|
|
|
} catch (error) {
|
|
|
- console.error('Failed to load API keys:', 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, string> = {
|
|
|
- [AgentNameEnum.Navigator]: '',
|
|
|
[AgentNameEnum.Planner]: '',
|
|
|
+ [AgentNameEnum.Navigator]: '',
|
|
|
[AgentNameEnum.Validator]: '',
|
|
|
};
|
|
|
|
|
|
for (const agent of Object.values(AgentNameEnum)) {
|
|
|
- const model = await agentModelStore.getAgentModel(agent);
|
|
|
- if (model) {
|
|
|
- models[agent] = model.modelName;
|
|
|
+ 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('Failed to load agent models:', error);
|
|
|
+ console.error('Error loading agent models:', error);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- loadApiKeys();
|
|
|
loadAgentModels();
|
|
|
}, []);
|
|
|
|
|
|
- const handleApiKeyChange = (provider: LLMProviderEnum, apiKey: string, baseUrl?: string) => {
|
|
|
- setApiKeys(prev => ({
|
|
|
+ // 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]: { apiKey, baseUrl },
|
|
|
+ [provider]: {
|
|
|
+ ...prev[provider],
|
|
|
+ apiKey: apiKey.trim(),
|
|
|
+ baseUrl: baseUrl !== undefined ? baseUrl.trim() : prev[provider]?.baseUrl,
|
|
|
+ },
|
|
|
}));
|
|
|
+ };
|
|
|
|
|
|
- setModifiedProviders(prev => {
|
|
|
- const newSet = new Set(prev);
|
|
|
- newSet.add(provider);
|
|
|
- return newSet;
|
|
|
+ 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 handleSave = async (provider: LLMProviderEnum) => {
|
|
|
+ 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<HTMLInputElement>, 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 {
|
|
|
- await llmProviderStore.setProvider(provider, apiKeys[provider]);
|
|
|
+ // 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 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 newSet = new Set(prev);
|
|
|
- newSet.delete(provider);
|
|
|
- return newSet;
|
|
|
+ const next = new Set(prev);
|
|
|
+ next.delete(provider);
|
|
|
+ return next;
|
|
|
});
|
|
|
+
|
|
|
+ // Refresh available models
|
|
|
+ const models = await getAvailableModelsCallback();
|
|
|
+ setAvailableModels(models);
|
|
|
} catch (error) {
|
|
|
- console.error('Failed to save API key:', error);
|
|
|
+ console.error('Error saving API key:', error);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- const handleDelete = async (provider: LLMProviderEnum) => {
|
|
|
+ const handleDelete = async (provider: string) => {
|
|
|
try {
|
|
|
+ // Delete the provider from storage regardless of its API key value
|
|
|
await llmProviderStore.removeProvider(provider);
|
|
|
- setApiKeys(prev => {
|
|
|
- const newKeys = { ...prev };
|
|
|
- delete newKeys[provider];
|
|
|
- return newKeys;
|
|
|
+
|
|
|
+ // 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 newSet = new Set(prev);
|
|
|
- newSet.delete(provider);
|
|
|
- return newSet;
|
|
|
+ const next = new Set(prev);
|
|
|
+ next.delete(provider);
|
|
|
+ return next;
|
|
|
});
|
|
|
+
|
|
|
+ // Refresh available models
|
|
|
+ const models = await getAvailableModelsCallback();
|
|
|
+ setAvailableModels(models);
|
|
|
} catch (error) {
|
|
|
- console.error('Failed to delete API key:', error);
|
|
|
+ console.error('Error deleting provider:', error);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- const getButtonProps = (provider: LLMProviderEnum) => {
|
|
|
- const isModified = modifiedProviders.has(provider);
|
|
|
- const hasApiKey = apiKeys[provider]?.apiKey;
|
|
|
-
|
|
|
- return {
|
|
|
- saveButton: {
|
|
|
- disabled: !isModified || !hasApiKey,
|
|
|
- className: `rounded-md px-3 py-1 text-sm font-medium ${
|
|
|
- !isModified || !hasApiKey
|
|
|
- ? `${isDarkMode ? 'bg-slate-700 text-gray-400' : 'bg-gray-200 text-gray-500'}`
|
|
|
- : `${isDarkMode ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-blue-500 text-white hover:bg-blue-600'}`
|
|
|
- }`,
|
|
|
- },
|
|
|
- deleteButton: {
|
|
|
- disabled: !hasApiKey,
|
|
|
- className: `ml-2 rounded-md px-3 py-1 text-sm font-medium ${
|
|
|
- !hasApiKey
|
|
|
- ? `${isDarkMode ? 'bg-slate-700 text-gray-400' : 'bg-gray-200 text-gray-500'}`
|
|
|
- : `${isDarkMode ? 'bg-red-600 text-white hover:bg-red-700' : 'bg-red-500 text-white hover:bg-red-600'}`
|
|
|
- }`,
|
|
|
- },
|
|
|
- };
|
|
|
- };
|
|
|
+ const handleCancelProvider = (providerId: string) => {
|
|
|
+ // Remove the provider from the state
|
|
|
+ setProviders(prev => {
|
|
|
+ const next = { ...prev };
|
|
|
+ delete next[providerId];
|
|
|
+ return next;
|
|
|
+ });
|
|
|
|
|
|
- const getAvailableModels = () => {
|
|
|
- const models: string[] = [];
|
|
|
- Object.values(LLMProviderEnum).forEach(provider => {
|
|
|
- if (apiKeys[provider]?.apiKey) {
|
|
|
- models.push(...(llmProviderModelNames[provider] || []));
|
|
|
- }
|
|
|
+ // Remove from modified providers
|
|
|
+ setModifiedProviders(prev => {
|
|
|
+ const next = new Set(prev);
|
|
|
+ next.delete(providerId);
|
|
|
+ return next;
|
|
|
});
|
|
|
- return models;
|
|
|
};
|
|
|
|
|
|
- const handleModelChange = async (agentName: AgentNameEnum, model: string) => {
|
|
|
- try {
|
|
|
- // Determine provider from model name
|
|
|
- let provider: LLMProviderEnum | undefined;
|
|
|
- for (const [providerKey, models] of Object.entries(llmProviderModelNames)) {
|
|
|
- if (models.includes(model)) {
|
|
|
- provider = providerKey as LLMProviderEnum;
|
|
|
- break;
|
|
|
- }
|
|
|
- }
|
|
|
+ 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);
|
|
|
|
|
|
- if (provider) {
|
|
|
+ setModelParameters(prev => ({
|
|
|
+ ...prev,
|
|
|
+ [agentName]: newParameters,
|
|
|
+ }));
|
|
|
+
|
|
|
+ setSelectedModels(prev => ({
|
|
|
+ ...prev,
|
|
|
+ [agentName]: model,
|
|
|
+ }));
|
|
|
+
|
|
|
+ try {
|
|
|
+ if (model) {
|
|
|
await agentModelStore.setAgentModel(agentName, {
|
|
|
provider,
|
|
|
modelName: model,
|
|
|
+ parameters: newParameters,
|
|
|
});
|
|
|
- setSelectedModels(prev => ({
|
|
|
- ...prev,
|
|
|
- [agentName]: model,
|
|
|
- }));
|
|
|
+ } else {
|
|
|
+ // Reset storage if no model is selected
|
|
|
+ await agentModelStore.resetAgentModel(agentName);
|
|
|
}
|
|
|
} catch (error) {
|
|
|
- console.error(`Failed to set model for ${agentName}:`, error);
|
|
|
+ console.error('Error saving agent model:', error);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- const renderApiKeyInput = (provider: LLMProviderEnum) => {
|
|
|
- const buttonProps = getButtonProps(provider);
|
|
|
- const needsBaseUrl =
|
|
|
- provider === LLMProviderEnum.OpenAI ||
|
|
|
- provider === LLMProviderEnum.Anthropic ||
|
|
|
- provider === LLMProviderEnum.Groq ||
|
|
|
- provider === LLMProviderEnum.Grok;
|
|
|
-
|
|
|
- return (
|
|
|
- <div key={provider} className="mb-6">
|
|
|
- <div className="mb-2 flex items-center justify-between">
|
|
|
- <h3 className={`text-lg font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
|
|
- {provider === LLMProviderEnum.OpenAI
|
|
|
- ? 'OpenAI'
|
|
|
- : provider === LLMProviderEnum.Anthropic
|
|
|
- ? 'Anthropic'
|
|
|
- : provider === LLMProviderEnum.Gemini
|
|
|
- ? 'Gemini'
|
|
|
- : provider === LLMProviderEnum.Groq
|
|
|
- ? 'Groq AI'
|
|
|
- : provider === LLMProviderEnum.Grok
|
|
|
- ? 'Grok AI'
|
|
|
- : provider}
|
|
|
- </h3>
|
|
|
- <div>
|
|
|
- <Button
|
|
|
- onClick={() => handleSave(provider)}
|
|
|
- disabled={buttonProps.saveButton.disabled}
|
|
|
- className={buttonProps.saveButton.className}>
|
|
|
- Save
|
|
|
- </Button>
|
|
|
- <Button
|
|
|
- onClick={() => handleDelete(provider)}
|
|
|
- disabled={buttonProps.deleteButton.disabled}
|
|
|
- className={buttonProps.deleteButton.className}>
|
|
|
- Delete
|
|
|
- </Button>
|
|
|
- </div>
|
|
|
+ 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) => (
|
|
|
+ <div
|
|
|
+ className={`rounded-lg border ${isDarkMode ? 'border-gray-700 bg-slate-800' : 'border-gray-200 bg-gray-50'} p-4`}>
|
|
|
+ <h3 className={`mb-2 text-lg font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
|
|
+ {agentName.charAt(0).toUpperCase() + agentName.slice(1)}
|
|
|
+ </h3>
|
|
|
+ <p className={`mb-4 text-sm font-normal ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
|
|
+ {getAgentDescription(agentName)}
|
|
|
+ </p>
|
|
|
+
|
|
|
+ <div className="space-y-4">
|
|
|
+ {/* Model Selection */}
|
|
|
+ <div className="flex items-center">
|
|
|
+ <label
|
|
|
+ htmlFor={`${agentName}-model`}
|
|
|
+ className={`w-24 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
|
|
+ Model
|
|
|
+ </label>
|
|
|
+ <select
|
|
|
+ id={`${agentName}-model`}
|
|
|
+ className={`flex-1 rounded-md border text-sm ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200' : 'border-gray-300 bg-white text-gray-700'} px-3 py-2`}
|
|
|
+ disabled={availableModels.length <= 1}
|
|
|
+ value={
|
|
|
+ selectedModels[agentName]
|
|
|
+ ? `${getProviderForModel(selectedModels[agentName])}>${selectedModels[agentName]}`
|
|
|
+ : ''
|
|
|
+ }
|
|
|
+ onChange={e => handleModelChange(agentName, e.target.value)}>
|
|
|
+ <option key="default" value="">
|
|
|
+ Choose model
|
|
|
+ </option>
|
|
|
+ {availableModels.map(({ provider, providerName, model }) => (
|
|
|
+ <option key={`${provider}>${model}`} value={`${provider}>${model}`}>
|
|
|
+ {`${providerName} > ${model}`}
|
|
|
+ </option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
</div>
|
|
|
|
|
|
- <div className="space-y-2">
|
|
|
- <div>
|
|
|
- <label
|
|
|
- htmlFor={`${provider}-api-key`}
|
|
|
- className={`mb-1 block text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
|
|
- API Key
|
|
|
- </label>
|
|
|
+ {/* Temperature Slider */}
|
|
|
+ <div className="flex items-center">
|
|
|
+ <label
|
|
|
+ htmlFor={`${agentName}-temperature`}
|
|
|
+ className={`w-24 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
|
|
+ Temperature
|
|
|
+ </label>
|
|
|
+ <div className="flex flex-1 items-center space-x-2">
|
|
|
<input
|
|
|
- id={`${provider}-api-key`}
|
|
|
- type="password"
|
|
|
- value={apiKeys[provider]?.apiKey || ''}
|
|
|
- onChange={e => handleApiKeyChange(provider, e.target.value, apiKeys[provider]?.baseUrl)}
|
|
|
- className={`w-full rounded-md border ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200' : 'border-gray-300 bg-white text-gray-700'} px-3 py-2`}
|
|
|
- placeholder={`Enter your ${provider} API key`}
|
|
|
+ id={`${agentName}-temperature`}
|
|
|
+ type="range"
|
|
|
+ min="0"
|
|
|
+ max="2"
|
|
|
+ step="0.01"
|
|
|
+ value={modelParameters[agentName].temperature}
|
|
|
+ onChange={e => 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`}
|
|
|
/>
|
|
|
+ <div className="flex items-center space-x-2">
|
|
|
+ <span className={`w-12 text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
|
|
+ {modelParameters[agentName].temperature.toFixed(2)}
|
|
|
+ </span>
|
|
|
+ <input
|
|
|
+ type="number"
|
|
|
+ min="0"
|
|
|
+ max="2"
|
|
|
+ step="0.01"
|
|
|
+ value={modelParameters[agentName].temperature}
|
|
|
+ onChange={e => {
|
|
|
+ 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`}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
</div>
|
|
|
+ </div>
|
|
|
|
|
|
- {needsBaseUrl && (
|
|
|
- <div>
|
|
|
- <label
|
|
|
- htmlFor={`${provider}-base-url`}
|
|
|
- className={`mb-1 block text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
|
|
- Base URL (Optional)
|
|
|
- </label>
|
|
|
+ {/* Top P Slider */}
|
|
|
+ <div className="flex items-center">
|
|
|
+ <label
|
|
|
+ htmlFor={`${agentName}-topP`}
|
|
|
+ className={`w-24 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
|
|
+ Top P
|
|
|
+ </label>
|
|
|
+ <div className="flex flex-1 items-center space-x-2">
|
|
|
+ <input
|
|
|
+ id={`${agentName}-topP`}
|
|
|
+ type="range"
|
|
|
+ min="0"
|
|
|
+ max="1"
|
|
|
+ step="0.001"
|
|
|
+ value={modelParameters[agentName].topP}
|
|
|
+ onChange={e => 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`}
|
|
|
+ />
|
|
|
+ <div className="flex items-center space-x-2">
|
|
|
+ <span className={`w-12 text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
|
|
+ {modelParameters[agentName].topP.toFixed(3)}
|
|
|
+ </span>
|
|
|
<input
|
|
|
- id={`${provider}-base-url`}
|
|
|
- type="text"
|
|
|
- value={apiKeys[provider]?.baseUrl || ''}
|
|
|
- onChange={e => handleApiKeyChange(provider, apiKeys[provider]?.apiKey || '', e.target.value)}
|
|
|
- className={`w-full rounded-md border ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200' : 'border-gray-300 bg-white text-gray-700'} px-3 py-2`}
|
|
|
- placeholder={
|
|
|
- provider === LLMProviderEnum.Groq
|
|
|
- ? 'https://api.groq.com/v1'
|
|
|
- : provider === LLMProviderEnum.Grok
|
|
|
- ? 'https://api.grok.x.ai/v1'
|
|
|
- : `Enter custom base URL for ${provider} (optional)`
|
|
|
- }
|
|
|
+ type="number"
|
|
|
+ min="0"
|
|
|
+ max="1"
|
|
|
+ step="0.001"
|
|
|
+ value={modelParameters[agentName].topP}
|
|
|
+ onChange={e => {
|
|
|
+ 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`}
|
|
|
/>
|
|
|
</div>
|
|
|
- )}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
- );
|
|
|
- };
|
|
|
-
|
|
|
- const renderModelSelect = (agentName: AgentNameEnum) => (
|
|
|
- <div key={agentName} className="mb-6">
|
|
|
- <div className="mb-2">
|
|
|
- <h3 className={`text-lg font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>{agentName}</h3>
|
|
|
- <p className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>{getAgentDescription(agentName)}</p>
|
|
|
- </div>
|
|
|
-
|
|
|
- <select
|
|
|
- id={`${agentName}-model`}
|
|
|
- value={selectedModels[agentName] || ''}
|
|
|
- onChange={e => handleModelChange(agentName, e.target.value)}
|
|
|
- className={`w-full rounded-md border ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200' : 'border-gray-300 bg-white text-gray-700'} px-3 py-2`}
|
|
|
- disabled={getAvailableModels().length === 0}>
|
|
|
- <option value="">Select a model</option>
|
|
|
- {getAvailableModels().map(model => (
|
|
|
- <option key={model} value={model}>
|
|
|
- {model}
|
|
|
- </option>
|
|
|
- ))}
|
|
|
- </select>
|
|
|
</div>
|
|
|
);
|
|
|
|
|
|
const getAgentDescription = (agentName: AgentNameEnum) => {
|
|
|
switch (agentName) {
|
|
|
case AgentNameEnum.Navigator:
|
|
|
- return 'Handles browsing and interacting with web pages';
|
|
|
+ return 'Navigates websites and performs actions';
|
|
|
case AgentNameEnum.Planner:
|
|
|
- return 'Creates and updates the plan for completing tasks';
|
|
|
+ return 'Develops and refines strategies to complete tasks';
|
|
|
case AgentNameEnum.Validator:
|
|
|
- return 'Validates the results of actions and task completion';
|
|
|
+ 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 (
|
|
|
- <div className="space-y-8">
|
|
|
+ <section className="space-y-6">
|
|
|
+ {/* LLM Providers Section */}
|
|
|
<div
|
|
|
- className={`rounded-lg border ${isDarkMode ? 'border-slate-700 bg-slate-800' : 'border-blue-100 bg-white'} p-6 text-left shadow-sm`}>
|
|
|
+ className={`rounded-lg border ${isDarkMode ? 'border-slate-700 bg-slate-800' : 'border-blue-100 bg-gray-50'} p-6 text-left shadow-sm`}>
|
|
|
<h2 className={`mb-4 text-xl font-semibold ${isDarkMode ? 'text-gray-200' : 'text-gray-800'}`}>
|
|
|
- LLM Provider API Keys
|
|
|
+ LLM Providers
|
|
|
</h2>
|
|
|
- <p className={`mb-4 text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
|
|
- Configure your API keys for the LLM providers you want to use. Your keys are stored locally and never sent to
|
|
|
- our servers.
|
|
|
- </p>
|
|
|
+ <div className="space-y-6">
|
|
|
+ {getSortedProviders().length === 0 ? (
|
|
|
+ <div className="py-8 text-center text-gray-500">
|
|
|
+ <p className="mb-4">No providers configured yet. Add a provider to get started.</p>
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ getSortedProviders().map(([providerId, providerConfig]) => (
|
|
|
+ <div
|
|
|
+ key={providerId}
|
|
|
+ id={`provider-${providerId}`}
|
|
|
+ className={`space-y-4 ${modifiedProviders.has(providerId) && !providersFromStorage.has(providerId) ? `rounded-lg border p-4 ${isDarkMode ? 'border-blue-700 bg-slate-700' : 'border-blue-200 bg-blue-50/70'}` : ''}`}>
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
+ <h3 className={`text-lg font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
|
|
+ {providerConfig.name || providerId}
|
|
|
+ </h3>
|
|
|
+ <div className="flex space-x-2">
|
|
|
+ {/* Show Cancel button for newly added providers */}
|
|
|
+ {modifiedProviders.has(providerId) && !providersFromStorage.has(providerId) && (
|
|
|
+ <Button variant="secondary" onClick={() => handleCancelProvider(providerId)}>
|
|
|
+ Cancel
|
|
|
+ </Button>
|
|
|
+ )}
|
|
|
+ <Button
|
|
|
+ variant={getButtonProps(providerId).variant}
|
|
|
+ disabled={getButtonProps(providerId).disabled}
|
|
|
+ onClick={() =>
|
|
|
+ providersFromStorage.has(providerId) && !modifiedProviders.has(providerId)
|
|
|
+ ? handleDelete(providerId)
|
|
|
+ : handleSave(providerId)
|
|
|
+ }>
|
|
|
+ {getButtonProps(providerId).children}
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
|
|
|
- {Object.values(LLMProviderEnum).map(provider => renderApiKeyInput(provider))}
|
|
|
- </div>
|
|
|
+ {/* Show message for newly added providers */}
|
|
|
+ {modifiedProviders.has(providerId) && !providersFromStorage.has(providerId) && (
|
|
|
+ <div className={`mb-2 text-sm ${isDarkMode ? 'text-teal-300' : 'text-teal-700'}`}>
|
|
|
+ <p>This provider is newly added. Enter your API key and click Save to configure it.</p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
|
|
|
- <div
|
|
|
- className={`rounded-lg border ${isDarkMode ? 'border-slate-700 bg-slate-800' : 'border-blue-100 bg-white'} p-6 text-left shadow-sm`}>
|
|
|
- <h2 className={`mb-4 text-xl font-semibold ${isDarkMode ? 'text-gray-200' : 'text-gray-800'}`}>Agent Models</h2>
|
|
|
- <p className={`mb-4 text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
|
|
- Select which models to use for each agent. You must configure at least one LLM provider above.
|
|
|
- </p>
|
|
|
-
|
|
|
- {getAvailableModels().length === 0 && (
|
|
|
- <div
|
|
|
- className={`mb-4 rounded-md ${isDarkMode ? 'bg-slate-700 text-gray-300' : 'bg-yellow-50 text-yellow-800'} p-3`}>
|
|
|
- <p className="text-sm">Please configure at least one LLM provider API key to select models.</p>
|
|
|
+ <div className="space-y-3">
|
|
|
+ {/* Name input (only for custom_openai) - moved to top for prominence */}
|
|
|
+ {providerConfig.type === ProviderTypeEnum.CustomOpenAI && (
|
|
|
+ <div className="flex flex-col">
|
|
|
+ <div className="flex items-center">
|
|
|
+ <label
|
|
|
+ htmlFor={`${providerId}-name`}
|
|
|
+ className={`w-20 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
|
|
+ Name
|
|
|
+ </label>
|
|
|
+ <input
|
|
|
+ id={`${providerId}-name`}
|
|
|
+ type="text"
|
|
|
+ placeholder="Provider name"
|
|
|
+ value={providerConfig.name || ''}
|
|
|
+ onChange={e => {
|
|
|
+ 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`}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ {nameErrors[providerId] ? (
|
|
|
+ <p className={`ml-20 mt-1 text-xs ${isDarkMode ? 'text-red-400' : 'text-red-500'}`}>
|
|
|
+ {nameErrors[providerId]}
|
|
|
+ </p>
|
|
|
+ ) : (
|
|
|
+ <p className={`ml-20 mt-1 text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
|
|
+ Provider name (spaces are not allowed when saving)
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* API Key input with label */}
|
|
|
+ <div className="flex items-center">
|
|
|
+ <label
|
|
|
+ htmlFor={`${providerId}-api-key`}
|
|
|
+ className={`w-20 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
|
|
+ Key{providerConfig.type !== ProviderTypeEnum.CustomOpenAI ? '*' : ''}
|
|
|
+ </label>
|
|
|
+ <input
|
|
|
+ id={`${providerId}-api-key`}
|
|
|
+ type="password"
|
|
|
+ placeholder={
|
|
|
+ providerConfig.type === ProviderTypeEnum.CustomOpenAI
|
|
|
+ ? `${providerConfig.name || providerId} API key (optional)`
|
|
|
+ : `${providerConfig.name || providerId} API key (required)`
|
|
|
+ }
|
|
|
+ value={providerConfig.apiKey || ''}
|
|
|
+ onChange={e => 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`}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Display API key for newly added providers */}
|
|
|
+ {modifiedProviders.has(providerId) &&
|
|
|
+ !providersFromStorage.has(providerId) &&
|
|
|
+ providerConfig.apiKey && (
|
|
|
+ <div className="ml-20 mt-1">
|
|
|
+ <p className={`font-mono text-sm ${isDarkMode ? 'text-emerald-400' : 'text-emerald-600'}`}>
|
|
|
+ {providerConfig.apiKey}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Base URL input (for custom_openai and ollama) */}
|
|
|
+ {(providerConfig.type === ProviderTypeEnum.CustomOpenAI ||
|
|
|
+ providerConfig.type === ProviderTypeEnum.Ollama) && (
|
|
|
+ <div className="flex flex-col">
|
|
|
+ <div className="flex items-center">
|
|
|
+ <label
|
|
|
+ htmlFor={`${providerId}-base-url`}
|
|
|
+ className={`w-20 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
|
|
+ Base URL
|
|
|
+ {providerConfig.type === ProviderTypeEnum.CustomOpenAI ||
|
|
|
+ providerConfig.type === ProviderTypeEnum.Ollama
|
|
|
+ ? '*'
|
|
|
+ : ''}
|
|
|
+ </label>
|
|
|
+ <input
|
|
|
+ id={`${providerId}-base-url`}
|
|
|
+ type="text"
|
|
|
+ placeholder={
|
|
|
+ providerConfig.type === ProviderTypeEnum.CustomOpenAI
|
|
|
+ ? 'Required for custom OpenAI providers'
|
|
|
+ : 'Ollama base URL'
|
|
|
+ }
|
|
|
+ value={providerConfig.baseUrl || ''}
|
|
|
+ onChange={e => 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`}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Models input field with tags */}
|
|
|
+ <div className="flex items-start">
|
|
|
+ <label
|
|
|
+ htmlFor={`${providerId}-models`}
|
|
|
+ className={`w-20 pt-2 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
|
|
+ Models
|
|
|
+ </label>
|
|
|
+ <div className="flex-1">
|
|
|
+ <div
|
|
|
+ className={`flex min-h-[42px] flex-wrap items-center gap-2 rounded-md border ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200' : 'border-gray-300 bg-white text-gray-700'} p-2`}>
|
|
|
+ {/* 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 => (
|
|
|
+ <div
|
|
|
+ key={model}
|
|
|
+ className={`flex items-center rounded-full ${isDarkMode ? 'bg-blue-900 text-blue-100' : 'bg-blue-100 text-blue-800'} px-2 py-1 text-sm`}>
|
|
|
+ <span>{model}</span>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => removeModel(providerId, model)}
|
|
|
+ className={`ml-1 font-bold ${isDarkMode ? 'text-blue-300 hover:text-blue-100' : 'text-blue-600 hover:text-blue-800'}`}
|
|
|
+ aria-label={`Remove ${model}`}>
|
|
|
+ ×
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ ));
|
|
|
+ })()}
|
|
|
+
|
|
|
+ {/* Input for new models */}
|
|
|
+ <input
|
|
|
+ id={`${providerId}-models`}
|
|
|
+ type="text"
|
|
|
+ placeholder=""
|
|
|
+ value={newModelInputs[providerId] || ''}
|
|
|
+ onChange={e => 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`}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <p className={`mt-1 text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
|
|
+ Type and Press Enter or Space to add a model
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Ollama reminder at the bottom of the section */}
|
|
|
+ {providerConfig.type === ProviderTypeEnum.Ollama && (
|
|
|
+ <div
|
|
|
+ className={`mt-4 rounded-md border ${isDarkMode ? 'border-slate-600 bg-slate-700' : 'border-blue-100 bg-blue-50'} p-3`}>
|
|
|
+ <p className={`text-sm ${isDarkMode ? 'text-gray-200' : 'text-gray-700'}`}>
|
|
|
+ <strong>Remember:</strong> Add{' '}
|
|
|
+ <code
|
|
|
+ className={`rounded italic ${isDarkMode ? 'bg-slate-600 px-1 py-0.5' : 'bg-blue-100 px-1 py-0.5'}`}>
|
|
|
+ OLLAMA_ORIGINS=chrome-extension://*
|
|
|
+ </code>{' '}
|
|
|
+ environment variable for the Ollama server.
|
|
|
+ <a
|
|
|
+ href="https://github.com/ollama/ollama/issues/6489"
|
|
|
+ target="_blank"
|
|
|
+ rel="noopener noreferrer"
|
|
|
+ className={`ml-1 ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-800'}`}>
|
|
|
+ Learn more
|
|
|
+ </a>
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Add divider except for the last item */}
|
|
|
+ {Object.keys(providers).indexOf(providerId) < Object.keys(providers).length - 1 && (
|
|
|
+ <div className={`mt-4 border-t ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`} />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ ))
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Add Provider button and dropdown */}
|
|
|
+ <div className="provider-selector-container relative pt-4">
|
|
|
+ <Button
|
|
|
+ variant="secondary"
|
|
|
+ onClick={() => setIsProviderSelectorOpen(prev => !prev)}
|
|
|
+ className={`flex w-full items-center justify-center font-medium ${
|
|
|
+ isDarkMode
|
|
|
+ ? 'border-blue-700 bg-blue-600 text-white hover:bg-blue-500'
|
|
|
+ : 'border-blue-200 bg-blue-100 text-blue-800 hover:bg-blue-200'
|
|
|
+ }`}>
|
|
|
+ <span className="mr-2 text-sm">+</span> <span className="text-sm">Add New Provider</span>
|
|
|
+ </Button>
|
|
|
+
|
|
|
+ {isProviderSelectorOpen && (
|
|
|
+ <div
|
|
|
+ className={`absolute z-10 mt-2 w-full overflow-hidden rounded-md border ${
|
|
|
+ isDarkMode
|
|
|
+ ? 'border-blue-600 bg-slate-700 shadow-lg shadow-slate-900/50'
|
|
|
+ : 'border-blue-200 bg-white shadow-xl shadow-blue-100/50'
|
|
|
+ }`}>
|
|
|
+ <div className="py-1">
|
|
|
+ {/* 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 => (
|
|
|
+ <button
|
|
|
+ key={type}
|
|
|
+ type="button"
|
|
|
+ className={`flex w-full items-center px-4 py-3 text-left text-sm ${
|
|
|
+ isDarkMode
|
|
|
+ ? 'text-blue-200 hover:bg-blue-600/30 hover:text-white'
|
|
|
+ : 'text-blue-700 hover:bg-blue-100 hover:text-blue-800'
|
|
|
+ } transition-colors duration-150`}
|
|
|
+ onClick={() => handleProviderSelection(type)}>
|
|
|
+ <span className="font-medium">{getDefaultDisplayNameFromProviderId(type)}</span>
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+
|
|
|
+ {/* Custom provider button (always shown) */}
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={`flex w-full items-center px-4 py-3 text-left text-sm ${
|
|
|
+ isDarkMode
|
|
|
+ ? 'text-blue-200 hover:bg-blue-600/30 hover:text-white'
|
|
|
+ : 'text-blue-700 hover:bg-blue-100 hover:text-blue-800'
|
|
|
+ } transition-colors duration-150`}
|
|
|
+ onClick={() => handleProviderSelection(ProviderTypeEnum.CustomOpenAI)}>
|
|
|
+ <span className="font-medium">Custom OpenAI-compatible</span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
- )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
|
|
|
- {Object.values(AgentNameEnum).map(agentName => renderModelSelect(agentName))}
|
|
|
+ {/* Updated Agent Models Section */}
|
|
|
+ <div
|
|
|
+ className={`rounded-lg border ${isDarkMode ? 'border-slate-700 bg-slate-800' : 'border-blue-100 bg-gray-50'} p-6 text-left shadow-sm`}>
|
|
|
+ <h2 className={`mb-4 text-left text-xl font-semibold ${isDarkMode ? 'text-gray-200' : 'text-gray-800'}`}>
|
|
|
+ Model Selection
|
|
|
+ </h2>
|
|
|
+ <div className="space-y-4">
|
|
|
+ {[AgentNameEnum.Planner, AgentNameEnum.Navigator, AgentNameEnum.Validator].map(agentName => (
|
|
|
+ <div key={agentName}>{renderModelSelect(agentName)}</div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
+ </section>
|
|
|
);
|
|
|
};
|