diff --git a/ui/desktop/src/components/settings/ProviderSetupModal.tsx b/ui/desktop/src/components/settings/ProviderSetupModal.tsx index 9c1fc06d06..3d9b16aaff 100644 --- a/ui/desktop/src/components/settings/ProviderSetupModal.tsx +++ b/ui/desktop/src/components/settings/ProviderSetupModal.tsx @@ -3,12 +3,14 @@ import { Card } from '../ui/card'; import { Lock } from 'lucide-react'; import { Input } from '../ui/input'; import { Button } from '../ui/button'; +import { required_keys } from './models/hardcoded_stuff'; // import UnionIcon from "../images/Union@2x.svg"; interface ProviderSetupModalProps { provider: string; model: string; endpoint: string; + title?: string; onSubmit: (apiKey: string) => void; onCancel: () => void; } @@ -17,10 +19,12 @@ export function ProviderSetupModal({ provider, model, endpoint, + title, onSubmit, onCancel, }: ProviderSetupModalProps) { const [apiKey, setApiKey] = React.useState(''); + const keyName = required_keys[provider]?.[0] || 'API Key'; const headerText = `Setup ${provider}`; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -48,7 +52,7 @@ export function ProviderSetupModal({ type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} - placeholder={`Enter API key`} + placeholder={keyName} className="w-full h-14 px-4 font-regular rounded-lg border shadow-none border-gray-300 bg-white text-lg placeholder:text-gray-400 font-regular text-gray-900" required /> diff --git a/ui/desktop/src/components/settings/providers/BaseProviderGrid.tsx b/ui/desktop/src/components/settings/providers/BaseProviderGrid.tsx new file mode 100644 index 0000000000..8236281520 --- /dev/null +++ b/ui/desktop/src/components/settings/providers/BaseProviderGrid.tsx @@ -0,0 +1,226 @@ +import React from 'react'; +import { Check, Plus, Settings, X } from 'lucide-react'; +import { Button } from '../../ui/button'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/Tooltip'; +import { Portal } from '@radix-ui/react-portal'; +import { required_keys } from '../models/hardcoded_stuff'; + +// Common interfaces and helper functions +interface Provider { + id: string; + name: string; + isConfigured: boolean; + description: string; +} + +interface BaseProviderCardProps { + name: string; + description: string; + isConfigured: boolean; + isSelected?: boolean; + isSelectable?: boolean; + onSelect?: () => void; + onAddKeys?: () => void; + onConfigure?: () => void; + showSettings?: boolean; + onDelete?: () => void; + showDelete?: boolean; + hasRequiredKeys?: boolean; +} + +function getArticle(word: string): string { + return 'aeiouAEIOU'.indexOf(word[0]) >= 0 ? 'an' : 'a'; +} + +function BaseProviderCard({ + name, + description, + isConfigured, + isSelected, + isSelectable, + onSelect, + onAddKeys, + onConfigure, + showSettings, + onDelete, + showDelete = false, + hasRequiredKeys = false, +}: BaseProviderCardProps) { + const numRequiredKeys = required_keys[name]?.length || 0; + const tooltipText = numRequiredKeys === 1 ? `Add ${name} API Key` : `Add ${name} API Keys`; + + return ( +
isSelectable && isConfigured && onSelect?.()} + className={`relative bg-white dark:bg-gray-800 rounded-lg border + ${ + isSelected + ? 'border-blue-500 dark:border-blue-400 shadow-[0_0_0_1px] shadow-blue-500/50' + : 'border-gray-200 dark:border-gray-700' + } + p-3 transition-all duration-200 h-[140px] + ${isSelectable && isConfigured ? 'cursor-pointer' : ''} + ${!isSelectable ? 'hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-md hover:scale-[1.01]' : ''} + ${isSelectable && isConfigured ? 'hover:border-blue-400 dark:hover:border-blue-300 hover:shadow-md' : ''} + `} + > + {isConfigured && ( +
+ + + +
+ +
+
+ + +

+ {hasRequiredKeys + ? `You have ${getArticle(name)} ${name} API Key set in your environment` + : `${name} has no required API keys`} +

+
+
+
+
+ + {showDelete && hasRequiredKeys && ( + + + + + + + +

Remove {name} API Key

+
+
+
+
+ )} +
+ )} + +
+

+ {name} +

+
+ +

+ {description} +

+ +
+ {!isConfigured && onAddKeys && hasRequiredKeys && ( + + + + + + + +

{tooltipText}

+
+
+
+
+ )} + {isConfigured && showSettings && hasRequiredKeys && ( + + + + + + + +

Configure {name} settings

+
+
+
+
+ )} +
+
+ ); +} + +interface BaseProviderGridProps { + providers: Provider[]; + isSelectable?: boolean; + showSettings?: boolean; + showDelete?: boolean; + selectedId?: string | null; + onSelect?: (providerId: string) => void; + onAddKeys?: (provider: Provider) => void; + onConfigure?: (provider: Provider) => void; + onDelete?: (provider: Provider) => void; +} + +export function BaseProviderGrid({ + providers, + isSelectable = false, + showSettings = false, + showDelete = false, + selectedId = null, + onSelect, + onAddKeys, + onConfigure, + onDelete, +}: BaseProviderGridProps) { + return ( +
+ {providers.map((provider) => { + const hasRequiredKeys = required_keys[provider.name]?.length > 0; + return ( + onSelect?.(provider.id)} + onAddKeys={() => onAddKeys?.(provider)} + onConfigure={() => onConfigure?.(provider)} + onDelete={() => onDelete?.(provider)} + showSettings={showSettings} + showDelete={showDelete} + hasRequiredKeys={hasRequiredKeys} + /> + ); + })} +
+ ); +} diff --git a/ui/desktop/src/components/settings/providers/ConfigureProviders.tsx b/ui/desktop/src/components/settings/providers/ConfigureProviders.tsx index 9cc0e3481d..9d2b7faeed 100644 --- a/ui/desktop/src/components/settings/providers/ConfigureProviders.tsx +++ b/ui/desktop/src/components/settings/providers/ConfigureProviders.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Providers } from './Provider'; import { ScrollArea } from '../../ui/scroll-area'; import BackButton from '../../ui/BackButton'; +import { ConfigureProvidersGrid } from './ConfigureProvidersGrid'; export default function ConfigureProviders() { return ( @@ -9,8 +10,8 @@ export default function ConfigureProviders() {
- {/* Left Navigation */} -
+ {/* Left Navigation - further reduced width */} +
@@ -18,11 +19,13 @@ export default function ConfigureProviders() { {/* Content Area */}
-
+

Configure Providers

- +
+ +
diff --git a/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx b/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx new file mode 100644 index 0000000000..12a68807c4 --- /dev/null +++ b/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx @@ -0,0 +1,231 @@ +import React, { useMemo, useState } from 'react'; +import { useActiveKeys } from '../api_keys/ActiveKeysContext'; +import { BaseProviderGrid } from './BaseProviderGrid'; +import { supported_providers, provider_aliases, required_keys } from '../models/hardcoded_stuff'; +import { getProviderDescription } from './Provider'; +import { ProviderSetupModal } from '../ProviderSetupModal'; +import { getApiUrl, getSecretKey } from '../../../config'; +import { toast } from 'react-toastify'; +import { getActiveProviders } from '../api_keys/utils'; +import { useModel } from '../models/ModelContext'; +import { Button } from '../../ui/button'; + +function ConfirmationModal({ message, onConfirm, onCancel }) { + return ( +
+
+
+

+ Confirm Delete +

+

{message}

+
+ + +
+
+
+
+ ); +} + +// Settings version - non-selectable cards with settings gear +export function ConfigureProvidersGrid() { + const { activeKeys, setActiveKeys } = useActiveKeys(); + const [showSetupModal, setShowSetupModal] = useState(false); + const [selectedForSetup, setSelectedForSetup] = useState(null); + const [modalMode, setModalMode] = useState<'edit' | 'setup'>('setup'); + const [isConfirmationOpen, setIsConfirmationOpen] = useState(false); + const [providerToDelete, setProviderToDelete] = useState(null); + const { currentModel } = useModel(); + + const providers = useMemo(() => { + return supported_providers.map((providerName) => { + const alias = + provider_aliases.find((p) => p.provider === providerName)?.alias || + providerName.toLowerCase(); + const isConfigured = activeKeys.includes(providerName); + + return { + id: alias, + name: providerName, + isConfigured, + description: getProviderDescription(providerName), + }; + }); + }, [activeKeys]); + + const handleAddKeys = (provider) => { + setSelectedForSetup(provider.id); + setModalMode('setup'); + setShowSetupModal(true); + }; + + const handleConfigure = (provider) => { + setSelectedForSetup(provider.id); + setModalMode('edit'); + setShowSetupModal(true); + }; + + const handleModalSubmit = async (apiKey: string) => { + if (!selectedForSetup) return; + + const provider = providers.find((p) => p.id === selectedForSetup)?.name; + if (!provider) return; + + const keyName = required_keys[provider]?.[0]; + if (!keyName) { + console.error(`No key found for provider ${provider}`); + return; + } + + try { + // Always delete the old key first when editing + const deleteResponse = await fetch(getApiUrl('/secrets/delete'), { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + body: JSON.stringify({ key: keyName }), + }); + + if (!deleteResponse.ok) { + const errorText = await deleteResponse.text(); + console.error('Delete response error:', errorText); + throw new Error('Failed to delete old key'); + } + + // Store new key + const storeResponse = await fetch(getApiUrl('/secrets/store'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + body: JSON.stringify({ + key: keyName, + value: apiKey.trim(), + }), + }); + + if (!storeResponse.ok) { + const errorText = await storeResponse.text(); + console.error('Store response error:', errorText); + throw new Error('Failed to store new key'); + } + + toast.success(`Successfully updated API key for ${provider}`); + + const updatedKeys = await getActiveProviders(); + setActiveKeys(updatedKeys); + + setShowSetupModal(false); + setSelectedForSetup(null); + setModalMode('setup'); // Reset modal mode + } catch (error) { + console.error('Error handling modal submit:', error); + toast.error(`Failed to update API key for ${provider}`); + } + }; + + const handleDelete = async (provider) => { + setProviderToDelete(provider); + setIsConfirmationOpen(true); + }; + + const confirmDelete = async () => { + if (!providerToDelete) return; + + const keyName = required_keys[providerToDelete.name]?.[0]; + if (!keyName) { + console.error(`No key found for provider ${providerToDelete.name}`); + return; + } + + try { + // Check if the selected provider is currently active + if (currentModel?.provider === providerToDelete.name) { + toast.error( + `Cannot delete the API key for ${providerToDelete.name} because it's the provider of the current model (${currentModel.name}). Please switch to a different model first.` + ); + setIsConfirmationOpen(false); + return; + } + + const deleteResponse = await fetch(getApiUrl('/secrets/delete'), { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + body: JSON.stringify({ key: keyName }), + }); + + if (!deleteResponse.ok) { + const errorText = await deleteResponse.text(); + console.error('Delete response error:', errorText); + throw new Error('Failed to delete key'); + } + + console.log('Key deleted successfully.'); + toast.success(`Successfully deleted API key for ${providerToDelete.name}`); + + const updatedKeys = await getActiveProviders(); + setActiveKeys(updatedKeys); + } catch (error) { + console.error('Error deleting key:', error); + toast.error(`Unable to delete API key for ${providerToDelete.name}`); + } + setIsConfirmationOpen(false); + }; + + return ( +
+ + + {showSetupModal && selectedForSetup && ( +
+ p.id === selectedForSetup)?.name} + model="Example Model" + endpoint="Example Endpoint" + title={ + modalMode === 'edit' + ? `Edit ${providers.find((p) => p.id === selectedForSetup)?.name} API Key` + : undefined + } + onSubmit={handleModalSubmit} + onCancel={() => { + setShowSetupModal(false); + setSelectedForSetup(null); + }} + /> +
+ )} + + {isConfirmationOpen && providerToDelete && ( + setIsConfirmationOpen(false)} + /> + )} +
+ ); +} diff --git a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx index 974e61ef2c..c985af7b11 100644 --- a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx +++ b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx @@ -15,6 +15,7 @@ import { toast } from 'react-toastify'; import { getActiveProviders } from '../api_keys/utils'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/Tooltip'; import { useNavigate } from 'react-router-dom'; +import { BaseProviderGrid } from './BaseProviderGrid'; interface ProviderCardProps { name: string; @@ -262,32 +263,27 @@ export function ProviderGrid({ onSubmit }: ProviderGridProps) { settings.
-
- {providers.map((provider) => ( - handleSelect(provider.id)} - onConfigure={() => handleConfigure(provider)} - onAddKeys={() => handleAddKeys(provider)} - /> - ))} -
+ {showSetupModal && selectedId && ( - p.id === selectedId)?.name} - model="Example Model" - endpoint="Example Endpoint" - onSubmit={handleModalSubmit} - onCancel={() => { - setShowSetupModal(false); - setSelectedId(null); - }} - /> +
+ p.id === selectedId)?.name} + model="Example Model" + endpoint="Example Endpoint" + onSubmit={handleModalSubmit} + onCancel={() => { + setShowSetupModal(false); + setSelectedId(null); + }} + /> +
)}
); diff --git a/ui/desktop/src/components/settings/providers/old_stuff/ProviderCard.tsx b/ui/desktop/src/components/settings/providers/old_stuff/ProviderCard.tsx deleted file mode 100644 index bd6f86fcd0..0000000000 --- a/ui/desktop/src/components/settings/providers/old_stuff/ProviderCard.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { FaKey, FaExclamationCircle, FaPencilAlt, FaTrash, FaPlus } from 'react-icons/fa'; -import React from 'react'; - -export const ProviderCard = ({ - provider, - secrets, - isExpanded, - isSupported, - isChangingProvider, - toggleProvider, - handleAddOrEditKey, - handleDeleteKey, - handleSelectProvider, - getProviderStatus, -}) => { - const hasUnsetKeys = getProviderStatus(provider); - - return ( -
-
- -
- - {isSupported && isExpanded && ( -
- {provider.keys.map((key) => { - const secret = secrets.find((s) => s.key === key); - return ( -
-
-

{key}

-

Source: {secret?.location || 'none'}

-
-
- - {secret?.is_set ? 'Key set' : 'Missing'} - - - -
-
- ); - })} - - {provider.id.toLowerCase() !== localStorage.getItem('GOOSE_PROVIDER')?.toLowerCase() && ( - - )} -
- )} -
- ); -}; diff --git a/ui/desktop/src/components/settings/providers/old_stuff/ProvidersList.tsx b/ui/desktop/src/components/settings/providers/old_stuff/ProvidersList.tsx deleted file mode 100644 index 67eee69d73..0000000000 --- a/ui/desktop/src/components/settings/providers/old_stuff/ProvidersList.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; - -export const ProviderList = ({ providers, onProviderSelect }) => ( -
- {providers.map((provider) => ( - - ))} -
-); diff --git a/ui/desktop/src/components/settings/providers/old_stuff/types.ts b/ui/desktop/src/components/settings/providers/old_stuff/types.ts deleted file mode 100644 index a6e190c2dc..0000000000 --- a/ui/desktop/src/components/settings/providers/old_stuff/types.ts +++ /dev/null @@ -1,38 +0,0 @@ -// transformation of the response provided by secrets/provider endpoint -export interface Provider { - id: string; - name: string; - keys: string[]; - description: string; - canDelete?: boolean; - supported: boolean; - order: number; -} - -export interface SecretDetails { - key: string; - is_set: boolean; - location?: string; -} - -// returned by the secrets/providers endpoint -export interface ProviderResponse { - supported: boolean; - name?: string; - description?: string; - models?: string[]; - secret_status: Record; -} - -// Represents the backend's secret structure for a single secret -export interface RawSecretStatus { - location: string; // Where the secret is stored (e.g., "keyring") - is_set: boolean; // Whether the secret is configured -} - -// Represents the transformed structure of a secret in the frontend -export interface TransformedSecret { - key: string; // The secret's key (e.g., "OPENAI_API_KEY") - location: string; // Where the secret is stored (e.g., "keyring") - is_set: boolean; // Whether the secret is set -} diff --git a/ui/desktop/src/components/settings/providers/old_stuff/utils.ts b/ui/desktop/src/components/settings/providers/old_stuff/utils.ts deleted file mode 100644 index 237f3e88a4..0000000000 --- a/ui/desktop/src/components/settings/providers/old_stuff/utils.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { ProviderResponse, Provider, TransformedSecret, RawSecretStatus } from './types'; -import { getProvidersList } from '../../../../utils/providerUtils'; -import { getApiUrl, getSecretKey } from '../../../../config'; - -export async function getSecretsSettings(): Promise> { - const providerList = await getProvidersList(); - // Extract the list of IDs - const providerIds = providerList.map((provider) => provider.id); - - // Fetch secrets state (set/unset) using the provider IDs - const response = await fetch(getApiUrl('/secrets/providers'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify({ - providers: providerIds, - }), - }); - - if (!response.ok) { - throw new Error('Failed to fetch secrets'); - } - - const data = (await response.json()) as Record; - return data; -} - -export function transformProviderSecretsResponse( - data: Record -): Provider[] { - // Transform the response into a list of ProviderWithSecrets objects - const providerOrder = ['openai', 'anthropic', 'databricks']; // maintains these three at top of resulting list - const transformedProviders: Provider[] = Object.entries(data) - .map(([id, status]: [string, any]) => ({ - id: id.toLowerCase(), - name: status.name ? status.name : id, - keys: status.secret_status ? Object.keys(status.secret_status) : [], - description: status.description ? status.description : 'Unsupported provider', - supported: status.supported, - canDelete: id.toLowerCase() !== 'openai' && id.toLowerCase() !== 'anthropic', - order: providerOrder.indexOf(id.toLowerCase()), - })) - .sort((a, b) => { - if (a.order !== -1 && b.order !== -1) { - return a.order - b.order; - } - if (a.order === -1 && b.order === -1) { - return a.name.localeCompare(b.name); - } - return a.order === -1 ? 1 : -1; - }); - - return transformedProviders; -} - -export function transformSecrets(data: Record): TransformedSecret[] { - return Object.entries(data) - .filter(([_, provider]) => provider.supported && provider.secret_status) - .flatMap(([_, provider]) => - Object.entries(provider.secret_status!).map(([key, rawStatus]) => ({ - key, // Secret key (e.g., "OPENAI_API_KEY") - location: rawStatus.location || 'none', // Default location if missing - is_set: rawStatus.is_set, // Renamed from `is_set` to `isSet` - })) - ); -}