mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
feat: configure providers grid (#709)
This commit is contained in:
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
onClick={() => 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 && (
|
||||
<div className="absolute top-2 right-2 flex gap-1.5">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center justify-center w-4 h-4 rounded-full bg-green-100 dark:bg-green-900/30 shrink-0">
|
||||
<Check className="h-2.5 w-2.5 text-green-600 dark:text-green-500" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<Portal>
|
||||
<TooltipContent side="top" align="center" className="z-[9999]">
|
||||
<p>
|
||||
{hasRequiredKeys
|
||||
? `You have ${getArticle(name)} ${name} API Key set in your environment`
|
||||
: `${name} has no required API keys`}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Portal>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{showDelete && hasRequiredKeys && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.();
|
||||
}}
|
||||
>
|
||||
<X className="h-2.5 w-2.5 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<Portal>
|
||||
<TooltipContent side="top" align="center" className="z-[9999]">
|
||||
<p>Remove {name} API Key</p>
|
||||
</TooltipContent>
|
||||
</Portal>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1 mt-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-gray-600 dark:text-gray-400 mt-1.5 mb-3 leading-normal overflow-y-auto max-h-[48px] pr-1">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="absolute bottom-2 right-3">
|
||||
{!isConfigured && onAddKeys && hasRequiredKeys && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddKeys();
|
||||
}}
|
||||
className="rounded-full h-6 w-6 p-0 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<Portal>
|
||||
<TooltipContent side="top" align="center" className="z-[9999]">
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Portal>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{isConfigured && showSettings && hasRequiredKeys && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-full h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConfigure?.();
|
||||
}}
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<Portal>
|
||||
<TooltipContent side="top" align="center" className="z-[9999]">
|
||||
<p>Configure {name} settings</p>
|
||||
</TooltipContent>
|
||||
</Portal>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-3 auto-rows-fr max-w-full [&_*]:z-20">
|
||||
{providers.map((provider) => {
|
||||
const hasRequiredKeys = required_keys[provider.name]?.length > 0;
|
||||
return (
|
||||
<BaseProviderCard
|
||||
key={provider.id}
|
||||
name={provider.name}
|
||||
description={provider.description}
|
||||
isConfigured={provider.isConfigured}
|
||||
isSelected={selectedId === provider.id}
|
||||
isSelectable={isSelectable}
|
||||
onSelect={() => onSelect?.(provider.id)}
|
||||
onAddKeys={() => onAddKeys?.(provider)}
|
||||
onConfigure={() => onConfigure?.(provider)}
|
||||
onDelete={() => onDelete?.(provider)}
|
||||
showSettings={showSettings}
|
||||
showDelete={showDelete}
|
||||
hasRequiredKeys={hasRequiredKeys}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<div className="h-full w-full bg-white dark:bg-gray-800 overflow-hidden p-2 pt-0">
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="flex min-h-full">
|
||||
{/* Left Navigation */}
|
||||
<div className="w-48 border-r border-gray-100 dark:border-gray-700 px-2 pt-2">
|
||||
{/* Left Navigation - further reduced width */}
|
||||
<div className="w-24 border-r border-gray-100 dark:border-gray-700 px-2 pt-2">
|
||||
<div className="sticky top-8">
|
||||
<BackButton className="mb-4" />
|
||||
</div>
|
||||
@@ -18,11 +19,13 @@ export default function ConfigureProviders() {
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 px-16 py-8 pt-[20px]">
|
||||
<div className="max-w-3xl space-y-12">
|
||||
<div className="max-w-5xl space-y-12">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Configure Providers</h1>
|
||||
</div>
|
||||
<Providers />
|
||||
<div className="relative z-10">
|
||||
<ConfigureProvidersGrid />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm z-[9999]">
|
||||
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Confirm Delete
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-6">{message}</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={onCancel} className="rounded-full px-4">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
className="rounded-full px-4 bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Settings version - non-selectable cards with settings gear
|
||||
export function ConfigureProvidersGrid() {
|
||||
const { activeKeys, setActiveKeys } = useActiveKeys();
|
||||
const [showSetupModal, setShowSetupModal] = useState(false);
|
||||
const [selectedForSetup, setSelectedForSetup] = useState<string | null>(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 (
|
||||
<div className="space-y-4 max-w-[1400px] mx-auto">
|
||||
<BaseProviderGrid
|
||||
providers={providers}
|
||||
showSettings={true}
|
||||
showDelete={true}
|
||||
onAddKeys={handleAddKeys}
|
||||
onConfigure={handleConfigure}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{showSetupModal && selectedForSetup && (
|
||||
<div className="relative z-[9999]">
|
||||
<ProviderSetupModal
|
||||
provider={providers.find((p) => 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);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConfirmationOpen && providerToDelete && (
|
||||
<ConfirmationModal
|
||||
message={`Are you sure you want to delete the API key for ${providerToDelete.name}? This action cannot be undone.`}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setIsConfirmationOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-3 auto-rows-fr max-w-full">
|
||||
{providers.map((provider) => (
|
||||
<ProviderCard
|
||||
key={provider.id}
|
||||
name={provider.name}
|
||||
description={provider.description}
|
||||
isConfigured={provider.isConfigured}
|
||||
isSelected={selectedId === provider.id}
|
||||
onSelect={() => handleSelect(provider.id)}
|
||||
onConfigure={() => handleConfigure(provider)}
|
||||
onAddKeys={() => handleAddKeys(provider)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<BaseProviderGrid
|
||||
providers={providers}
|
||||
isSelectable={true}
|
||||
selectedId={selectedId}
|
||||
onSelect={handleSelect}
|
||||
onAddKeys={handleAddKeys}
|
||||
/>
|
||||
|
||||
{showSetupModal && selectedId && (
|
||||
<ProviderSetupModal
|
||||
provider={providers.find((p) => p.id === selectedId)?.name}
|
||||
model="Example Model"
|
||||
endpoint="Example Endpoint"
|
||||
onSubmit={handleModalSubmit}
|
||||
onCancel={() => {
|
||||
setShowSetupModal(false);
|
||||
setSelectedId(null);
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-[9999]">
|
||||
<ProviderSetupModal
|
||||
provider={providers.find((p) => p.id === selectedId)?.name}
|
||||
model="Example Model"
|
||||
endpoint="Example Endpoint"
|
||||
onSubmit={handleModalSubmit}
|
||||
onCancel={() => {
|
||||
setShowSetupModal(false);
|
||||
setSelectedId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div key={provider.id} className="border dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="flex-1 flex items-center justify-between"
|
||||
onClick={() => isSupported && toggleProvider(provider.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||
<FaKey className={`${isSupported ? 'text-gray-500' : 'text-red-500'}`} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium dark:text-white">{provider.name}</h3>
|
||||
{provider.id.toLowerCase() ===
|
||||
localStorage.getItem('GOOSE_PROVIDER')?.toLowerCase() && (
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded-full">
|
||||
Selected Provider
|
||||
</span>
|
||||
)}
|
||||
{!isSupported && (
|
||||
<span className="text-xs px-2 py-0.5 bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 rounded-full">
|
||||
Not Supported
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
{isSupported ? provider.description : 'Provider not supported'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isSupported && hasUnsetKeys && <FaExclamationCircle className="text-yellow-500" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isSupported && isExpanded && (
|
||||
<div className="mt-4 pl-11">
|
||||
{provider.keys.map((key) => {
|
||||
const secret = secrets.find((s) => s.key === key);
|
||||
return (
|
||||
<div key={key} className="py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-mono dark:text-gray-300">{key}</p>
|
||||
<p className="text-xs text-gray-500">Source: {secret?.location || 'none'}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs ${
|
||||
secret?.is_set
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
}`}
|
||||
>
|
||||
{secret?.is_set ? 'Key set' : 'Missing'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleAddOrEditKey(key, provider.name)}
|
||||
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title={secret?.is_set ? 'Edit key' : 'Add key'}
|
||||
>
|
||||
{secret?.is_set ? <FaPencilAlt size={14} /> : <FaPlus size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteKey(provider.id, key)}
|
||||
className={`p-1.5 rounded-full ${
|
||||
secret?.is_set
|
||||
? 'text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 hover:bg-red-100 dark:hover:bg-red-900'
|
||||
: 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
|
||||
}`}
|
||||
title={
|
||||
secret?.is_set
|
||||
? 'Delete key from keychain'
|
||||
: 'No key to delete - Add a key first before deleting'
|
||||
}
|
||||
disabled={!secret?.is_set}
|
||||
aria-disabled={!secret?.is_set}
|
||||
>
|
||||
<FaTrash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{provider.id.toLowerCase() !== localStorage.getItem('GOOSE_PROVIDER')?.toLowerCase() && (
|
||||
<button
|
||||
onClick={() => handleSelectProvider(provider.id)}
|
||||
disabled={isChangingProvider}
|
||||
className="mt-4 text-sm px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
Set as Active
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export const ProviderList = ({ providers, onProviderSelect }) => (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{providers.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
onClick={() => onProviderSelect(provider)}
|
||||
className="p-4 pt-3 border rounded-lg hover:border-blue-500 transition-colors text-left dark:border-gray-700 dark:hover:border-blue-400"
|
||||
>
|
||||
<h3 className="text-lg font-regular mb-1 dark:text-gray-200">{provider.name}</h3>
|
||||
<p className="font-light text-gray-600 dark:text-gray-400">{provider.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -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<string, SecretDetails>;
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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<Record<string, ProviderResponse>> {
|
||||
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<string, ProviderResponse>;
|
||||
return data;
|
||||
}
|
||||
|
||||
export function transformProviderSecretsResponse(
|
||||
data: Record<string, ProviderResponse>
|
||||
): 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<string, ProviderResponse>): 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`
|
||||
}))
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user