ui: introduce form injection for provider modals (#1493)

This commit is contained in:
Lily Delalande
2025-03-04 12:33:28 -05:00
committed by GitHub
parent 5f98a111c3
commit 689890d577
22 changed files with 488 additions and 365 deletions

View File

@@ -22,32 +22,28 @@ function ProviderCards({
}) {
const { openModal } = useProviderModal();
// Define the callbacks for provider actions
const providerCallbacks = {
// Replace your OnShowSettings with the modal opener
onConfigure: (provider: ProviderState) => {
console.log('Configure button clicked for:', provider.name);
openModal(provider, {
onSubmit: (values: any) => {
console.log(`Configuring ${provider.name}:`, values);
// Your logic to save the configuration
},
formProps: {},
});
console.log('openModal called'); // Check if this executes
},
onLaunch: (provider: ProviderState) => {
OnRefresh();
},
const configureProviderViaModal = (provider: ProviderState) => {
openModal(provider, {
onSubmit: (values: any) => {
console.log(`Configuring ${provider.name}:`, values);
// Your logic to save the configuration
},
formProps: {},
});
};
const handleLaunch = () => {
OnRefresh();
};
return (
<>
{providers.map((provider) => (
<ProviderCard
key={provider.name} // helps React efficiently update and track components when rendering lists
key={provider.name}
provider={provider}
buttonCallbacks={providerCallbacks}
onConfigure={() => configureProviderViaModal(provider)}
onLaunch={handleLaunch}
isOnboarding={isOnboarding}
/>
))}
@@ -67,7 +63,7 @@ export default function ProviderGrid({
<GridLayout>
<ProviderModalProvider>
<ProviderCards providers={providers} isOnboarding={isOnboarding} />
<ProviderConfigurationModal /> {/* This is missing! */}
<ProviderConfigurationModal />
</ProviderModalProvider>
</GridLayout>
);

View File

@@ -1,28 +1,6 @@
import React from 'react';
import ProviderDetails from './interfaces/ProviderDetails';
import DefaultCardButtons from './subcomponents/buttons/DefaultCardButtons';
import ButtonCallbacks from '@/src/components/settings_v2/providers/interfaces/ButtonCallbacks';
import ProviderState from '@/src/components/settings_v2/providers/interfaces/ProviderState';
// Helper function to generate default actions for most providers
const getDefaultButtons = (
provider: ProviderState,
callbacks: ButtonCallbacks,
isOnboardingPage
) => {
return [
{
id: 'default-buttons',
renderButton: () => (
<DefaultCardButtons
provider={provider}
callbacks={callbacks}
isOnboardingPage={isOnboardingPage}
/>
),
},
];
};
import OllamaForm from './modal/subcomponents/forms/OllamaForm';
import OllamaSubmitHandler from './modal/subcomponents/handlers/OllamaSubmitHandler';
export interface ProviderRegistry {
name: string;
@@ -47,13 +25,9 @@ export interface ProviderRegistry {
* - Optional custom form component
* - Action buttons that appear on provider cards
*
* 2. Two-Level Configuration:
* a) Provider Card UI - What buttons appear on each provider card
* - Controlled by the provider's getActions() function
* - Most providers use default buttons (configure/launch)
* - Can be customized for special providers
* 2. Configuration submission:
*
* b) Modal Content - What form appears in the configuration modal
* Modal Content - What form appears in the configuration modal
* - A single modal component exists in the app
* - Content changes dynamically based on the provider being configured
* - If provider has CustomForm property, that component is rendered
@@ -70,14 +44,12 @@ export interface ProviderRegistry {
* ---------------------
*
* For a standard provider with simple configuration:
* - Define parameters array with all required fields
* - Use the default getActions function
* - Define parameters array with all required fields and any defaults that should be supplied
* - No need to specify a CustomForm
*
* For a provider needing custom configuration:
* - Define parameters array (even if just for documentation)
* - Define parameters array (if needed, otherwise leave as an empty list)
* - Create a custom form component and assign to CustomForm property
* - Use the default or custom getActions function
*
* This architecture centralizes provider definitions while allowing
* flexibility for special cases, keeping the codebase maintainable.
@@ -95,9 +67,17 @@ export const PROVIDER_REGISTRY: ProviderRegistry[] = [
name: 'OPENAI_API_KEY',
is_secret: true,
},
{
name: 'OPENAI_HOST',
is_secret: false,
default: 'https://api.openai.com',
},
{
name: 'OPENAI_BASE_PATH',
is_secret: false,
default: 'v1/chat/completions',
},
],
getActions: (provider, callbacks, isOnboardingPage) =>
getDefaultButtons(provider, callbacks, isOnboardingPage),
},
},
{
@@ -112,8 +92,6 @@ export const PROVIDER_REGISTRY: ProviderRegistry[] = [
is_secret: true,
},
],
getActions: (provider, callbacks, isOnboardingPage) =>
getDefaultButtons(provider, callbacks, isOnboardingPage),
},
},
{
@@ -128,8 +106,6 @@ export const PROVIDER_REGISTRY: ProviderRegistry[] = [
is_secret: true,
},
],
getActions: (provider, callbacks, isOnboardingPage) =>
getDefaultButtons(provider, callbacks, isOnboardingPage),
},
},
{
@@ -144,8 +120,6 @@ export const PROVIDER_REGISTRY: ProviderRegistry[] = [
is_secret: true,
},
],
getActions: (provider, callbacks, isOnboardingPage) =>
getDefaultButtons(provider, callbacks, isOnboardingPage),
},
},
{
@@ -160,8 +134,6 @@ export const PROVIDER_REGISTRY: ProviderRegistry[] = [
is_secret: false,
},
],
getActions: (provider, callbacks, isOnboardingPage) =>
getDefaultButtons(provider, callbacks, isOnboardingPage),
},
},
{
@@ -176,8 +148,6 @@ export const PROVIDER_REGISTRY: ProviderRegistry[] = [
is_secret: true,
},
],
getActions: (provider, callbacks, isOnboardingPage) =>
getDefaultButtons(provider, callbacks, isOnboardingPage),
},
},
{
@@ -190,10 +160,50 @@ export const PROVIDER_REGISTRY: ProviderRegistry[] = [
{
name: 'OLLAMA_HOST',
is_secret: false,
default: 'localhost',
},
],
},
},
{
name: 'Azure OpenAI',
details: {
id: 'azure_openai',
name: 'Azure OpenAI',
description: 'Access Azure OpenAI models',
parameters: [
{
name: 'AZURE_OPENAI_API_KEY',
is_secret: true,
},
{
name: 'AZURE_OPENAI_ENDPOINT',
is_secret: false,
},
{
name: 'AZURE_OPENAI_DEPLOYMENT_NAME',
is_secret: false,
},
],
},
},
{
name: 'GCP Vertex AI',
details: {
id: 'gcp_vertex_ai',
name: 'GCP Vertex AI',
description: 'GCP Vertex AI models',
parameters: [
{
name: 'GCP_PROJECT_ID',
is_secret: false,
},
{
name: 'GCP_LOCATION',
is_secret: false,
default: 'us-central1',
},
],
getActions: (provider, callbacks, isOnboardingPage) =>
getDefaultButtons(provider, callbacks, isOnboardingPage),
},
},
];

View File

@@ -68,7 +68,7 @@ export default function ProviderSettings({ onClose }: { onClose: () => void }) {
{/* Content Area */}
<div className="max-w-5xl pt-4 px-8">
<div className="relative z-10">
<ProviderGrid providers={fakeProviderState} isOnboarding={true} />
<ProviderGrid providers={fakeProviderState} isOnboarding={false} />
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
export default interface ParameterSchema {
name: string;
is_secret: boolean;
location?: string; // env, config.yaml, and/or keychain
required?: boolean;
default?: string; // optional default values
}

View File

@@ -1,8 +1,5 @@
// metadata and action builder
import ProviderState from './ProviderState';
import ConfigurationAction from './ConfigurationAction';
import ParameterSchema from '../interfaces/ParameterSchema';
import ButtonCallbacks from './ButtonCallbacks';
import ProviderSetupFormProps from '../modal/interfaces/ProviderSetupFormProps';
export default interface ProviderDetails {
id: string;
@@ -10,9 +7,6 @@ export default interface ProviderDetails {
description: string;
parameters: ParameterSchema[];
getTags?: (name: string) => string[];
getActions?: (
provider: ProviderState,
callbacks: ButtonCallbacks,
isOnboardingPage: boolean
) => ConfigurationAction[];
customForm?: React.ComponentType<ProviderSetupFormProps>;
customSubmit?: (e: any) => void;
}

View File

@@ -6,22 +6,24 @@ import ProviderSetupActions from './subcomponents/ProviderSetupActions';
import ProviderLogo from './subcomponents/ProviderLogo';
import { useProviderModal } from './ProviderModalProvider';
import { toast } from 'react-toastify';
import { PROVIDER_REGISTRY } from '../ProviderRegistry';
import { SecureStorageNotice } from './subcomponents/SecureStorageNotice';
import DefaultSubmitHandler from './subcomponents/handlers/DefaultSubmitHandler';
export default function ProviderConfigurationModal() {
const { isOpen, currentProvider, modalProps, closeModal } = useProviderModal();
console.log('currentProvider', currentProvider);
const [configValues, setConfigValues] = useState({});
// Reset form values when provider changes
useEffect(() => {
if (currentProvider) {
// Initialize form with default values
const initialValues = {};
if (currentProvider.parameters) {
currentProvider.parameters.forEach((param) => {
initialValues[param.name] = param.defaultValue || '';
});
}
// FIXME
// if (currentProvider.parameters) {
// currentProvider.parameters.forEach((param) => {
// initialValues[param.name] = param.default || '';
// });
// }
setConfigValues(initialValues);
} else {
setConfigValues({});
@@ -31,22 +33,31 @@ export default function ProviderConfigurationModal() {
if (!isOpen || !currentProvider) return null;
const headerText = `Configure ${currentProvider.name}`;
const descriptionText = `Add your generated api keys for this provider to integrate into Goose`;
const descriptionText = `Add your API key(s) for this provider to integrate into Goose`;
// Use custom form component if provider specifies one, otherwise use default
const FormComponent = currentProvider.CustomForm || DefaultProviderSetupForm;
// Find the provider in the registry to get the details with customForm
const providerEntry = PROVIDER_REGISTRY.find((p) => p.name === currentProvider.name);
// Get the custom submit handler from the provider details
const customSubmitHandler = providerEntry?.details?.customSubmit;
// Use custom submit handler otherwise use default
const SubmitHandler = customSubmitHandler || DefaultSubmitHandler;
// Get the custom form component from the provider details
const CustomForm = providerEntry?.details?.customForm;
// Use custom form component if available, otherwise use default
const FormComponent = CustomForm || DefaultProviderSetupForm;
const handleSubmitForm = (e) => {
e.preventDefault();
console.log('Form submitted for:', currentProvider.name);
// Use custom submit handler if provided in modalProps
if (modalProps.onSubmit) {
modalProps.onSubmit(configValues);
} else {
// Default submit behavior
toast('Submitted configuration!');
}
SubmitHandler(configValues);
// Close the modal unless the custom handler explicitly returns false
// This gives custom handlers the ability to keep the modal open if needed
closeModal();
};
@@ -72,12 +83,14 @@ export default function ProviderConfigurationModal() {
<FormComponent
configValues={configValues}
setConfigValues={setConfigValues}
onSubmit={handleSubmitForm}
provider={currentProvider}
{...(modalProps.formProps || {})} // Spread any custom form props
/>
<ProviderSetupActions onCancel={handleCancel} />
{providerEntry?.details?.parameters && providerEntry.details.parameters.length > 0 && (
<SecureStorageNotice />
)}
<ProviderSetupActions onCancel={handleCancel} onSubmit={handleSubmitForm} />
</Modal>
);
}

View File

@@ -1,4 +1,13 @@
import React, { createContext, useContext, useState } from 'react';
import ProviderState from '../interfaces/ProviderState';
interface ProviderModalContextType {
isOpen: boolean;
currentProvider: ProviderState | null;
modalProps: any;
openModal: (provider: ProviderState, additionalProps: any) => void;
closeModal: () => void;
}
const ProviderModalContext = createContext({
isOpen: false,
@@ -8,7 +17,7 @@ const ProviderModalContext = createContext({
closeModal: () => {},
});
export const useProviderModal = () => useContext(ProviderModalContext);
export const useProviderModal = () => useContext<ProviderModalContextType>(ProviderModalContext);
export const ProviderModalProvider = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);

View File

@@ -3,19 +3,21 @@ import { Button } from '../../../../ui/button';
interface ProviderSetupActionsProps {
onCancel: () => void;
onSubmit: (e: any) => void;
}
/**
* Renders the "Submit" and "Cancel" buttons at the bottom.
* Updated to match the design from screenshots.
*/
export default function ProviderSetupActions({ onCancel }: ProviderSetupActionsProps) {
export default function ProviderSetupActions({ onCancel, onSubmit }: ProviderSetupActionsProps) {
return (
<div className="mt-8 -ml-8 -mr-8">
{/* We rely on the <form> "onSubmit" for the actual Submit logic */}
<Button
type="submit"
variant="ghost"
onClick={onSubmit}
className="w-full h-[60px] rounded-none border-t border-borderSubtle text-md hover:bg-bgSubtle text-textProminent font-medium"
>
Submit

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Lock } from 'lucide-react';
/**
* SecureStorageNotice - A reusable component that displays a message about secure storage
*
* @param {Object} props - Component props
* @param {string} [props.className] - Optional additional CSS classes
* @param {string} [props.message] - Optional custom message (defaults to keys stored in .env)
* @returns {JSX.Element} - The secure storage notice component
*/
export function SecureStorageNotice({
className = '',
message = 'Keys are stored securely in the keychain',
}) {
return (
<div className={`flex items-start mt-2 text-gray-600 dark:text-gray-300 ${className}`}>
<Lock className="w-5 h-5 mt-1" />
<span className="text-sm font-light ml-2">{message}</span>
</div>
);
}

View File

@@ -1,45 +1,48 @@
import React from 'react';
import React, { useEffect } from 'react';
import { Input } from '../../../../../ui/input';
import { Lock } from 'lucide-react';
import ProviderSetupFormProps from '../../interfaces/ProviderSetupFormProps';
import ParameterSchema from '../../../interfaces/ParameterSchema';
import { PROVIDER_REGISTRY } from '../../../ProviderRegistry';
export default function DefaultProviderSetupForm({
configValues,
setConfigValues,
onSubmit,
provider,
}: ProviderSetupFormProps) {
export default function DefaultProviderSetupForm({ configValues, setConfigValues, provider }) {
const providerEntry = PROVIDER_REGISTRY.find((p) => p.name === provider.name);
const parameters: ParameterSchema[] = providerEntry.details.parameters;
const parameters = providerEntry?.details?.parameters || [];
// Initialize default values when the component mounts or provider changes
useEffect(() => {
const defaultValues = {};
parameters.forEach((parameter) => {
if (parameter.default !== undefined && !configValues[parameter.name]) {
defaultValues[parameter.name] = parameter.default;
}
});
// Only update if there are default values to add
if (Object.keys(defaultValues).length > 0) {
setConfigValues((prev) => ({
...prev,
...defaultValues,
}));
}
}, [provider.name, parameters, setConfigValues, configValues]);
return (
<form onSubmit={onSubmit}>
<div className="mt-4 space-y-4">
{parameters.map((parameter) => (
<div key={parameter.name}>
<Input
type={parameter.is_secret ? 'password' : 'text'}
value={configValues[parameter.name] || ''}
onChange={(e) =>
setConfigValues((prev) => ({
...prev,
[parameter.name]: e.target.value,
}))
}
placeholder={parameter.name.replace(/_/g, ' ')}
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
/>
</div>
))}
<div className="flex items-start mt-2 text-gray-600 dark:text-gray-300">
<Lock className="w-5 h-5 mt-1" />
<span className="text-sm font-light ml-2">Keys are stored in a secure .env file</span>
<div className="mt-4 space-y-4">
{parameters.map((parameter) => (
<div key={parameter.name}>
<Input
type={parameter.is_secret ? 'password' : 'text'}
value={configValues[parameter.name] || ''}
onChange={(e) =>
setConfigValues((prev) => ({
...prev,
[parameter.name]: e.target.value,
}))
}
placeholder={parameter.name}
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
/>
</div>
</div>
</form>
))}
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { PROVIDER_REGISTRY } from '../../../ProviderRegistry';
import { Input } from '../../../../../ui/input';
import React from 'react';
import { useState, useEffect } from 'react';
import { Lock, RefreshCw } from 'lucide-react';
import CustomRadio from '../../../../../ui/CustomRadio';
export default function OllamaForm({ configValues, setConfigValues, provider }) {
const providerEntry = PROVIDER_REGISTRY.find((p) => p.name === provider.name);
const parameters = providerEntry?.details?.parameters || [];
const [isCheckingLocal, setIsCheckingLocal] = useState(false);
const [isLocalAvailable, setIsLocalAvailable] = useState(false);
const handleConnectionTypeChange = (value) => {
setConfigValues((prev) => ({
...prev,
connection_type: value,
}));
};
// Function to handle input changes and auto-select/deselect the host radio
const handleInputChange = (paramName, value) => {
// Update the parameter value
setConfigValues((prev) => ({
...prev,
[paramName]: value,
}));
// If the user is typing, auto-select the host radio button
if (value && configValues.connection_type !== 'host') {
handleConnectionTypeChange('host');
}
// If the input becomes empty and the host radio is selected, switch to local if available
else if (!value && configValues.connection_type === 'host') {
if (isLocalAvailable) {
handleConnectionTypeChange('local');
}
// If local is not available, we keep the host selected but leave the input empty
}
};
const checkLocalAvailability = async () => {
setIsCheckingLocal(true);
// Dummy implementation - simulates checking local availability
try {
console.log('Checking for local Ollama instance...');
// Simulate a network request with a delay
await new Promise((resolve) => setTimeout(resolve, 800));
// Randomly determine if Ollama is available (for demo purposes)
const isAvailable = Math.random() > 0.3;
setIsLocalAvailable(isAvailable);
if (isAvailable) {
console.log('Local Ollama instance found');
// Enable local radio button
} else {
console.log('No local Ollama instance found');
// If current selection is local, switch to host
if (configValues.connection_type === 'local') {
handleConnectionTypeChange('host');
}
}
} catch (error) {
console.error('Error checking for local Ollama:', error);
setIsLocalAvailable(false);
} finally {
setIsCheckingLocal(false);
}
};
// Check local availability on initial load
useEffect(() => {
checkLocalAvailability();
}, []);
return (
<div className="mt-4 space-y-4">
<div className="font-medium text-gray-900 dark:text-gray-100 mb-2">Connection</div>
{/* Local Option */}
<div className="flex items-center mb-3 justify-between">
<div className="flex items-center">
<span className="text-gray-700 dark:text-gray-300">Background App</span>
<button
type="button"
className="ml-2 p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={checkLocalAvailability}
disabled={isCheckingLocal}
>
<RefreshCw
className={`w-4 h-4 ${isCheckingLocal ? 'animate-spin' : ''} text-gray-600 dark:text-gray-400`}
/>
</button>
</div>
<CustomRadio
id="connection-local"
name="connection_type"
value="local"
checked={configValues.connection_type === 'local'}
onChange={() => handleConnectionTypeChange('local')}
disabled={!isLocalAvailable}
/>
</div>
{/* Other Parameters */}
{parameters
.filter((param) => param.name !== 'host_url') // Skip host_url as we handle it above
.map((parameter) => (
<div key={parameter.name} className="flex items-center mb-4">
<div className="flex-grow">
<Input
type={parameter.is_secret ? 'password' : 'text'}
value={configValues[parameter.name] || ''}
onChange={(e) => handleInputChange(parameter.name, e.target.value)}
placeholder={
parameter.default ? parameter.default : parameter.name.replace(/_/g, ' ')
}
className="w-full h-14 px-4 font-regular rounded-lg border shadow-none border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-lg placeholder:text-gray-400 dark:placeholder:text-gray-500 font-regular text-gray-900 dark:text-gray-100"
required={parameter.default == null}
/>
</div>
<div className="ml-4">
<CustomRadio
id={`connection-host-${parameter.name}`}
name="connection_type"
value="host"
checked={configValues.connection_type === 'host'}
onChange={() => handleConnectionTypeChange('host')}
/>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,7 @@
export default function DefaultSubmitHandler(configValues) {
// Log each field value individually for clarity
console.log('Field values:');
Object.entries(configValues).forEach(([key, value]) => {
console.log(`${key}: ${value}`);
});
}

View File

@@ -0,0 +1,7 @@
export default function OllamaSubmitHandler(configValues) {
// Log each field value individually for clarity
console.log('Ollama field values:');
Object.entries(configValues).forEach(([key, value]) => {
console.log(`${key}: ${value}`);
});
}

View File

@@ -1,48 +0,0 @@
import React, { createContext, useContext, useState, ReactNode, useEffect } from 'react';
import { getActiveProviders } from './utils';
// Create a context for active keys
const ActiveKeysContext = createContext<
| {
activeKeys: string[];
setActiveKeys: (keys: string[]) => void;
}
| undefined
>(undefined);
export const ActiveKeysProvider = ({ children }: { children: ReactNode }) => {
const [activeKeys, setActiveKeys] = useState<string[]>([]); // Start with an empty list
const [isLoading, setIsLoading] = useState(true); // Track loading state
// Fetch active keys from the backend
useEffect(() => {
const fetchActiveProviders = async () => {
try {
const providers = await getActiveProviders(); // Fetch the active providers
setActiveKeys(providers); // Update state with fetched providers
} catch (error) {
console.error('Error fetching active providers:', error);
} finally {
setIsLoading(false); // Ensure loading is marked as complete
}
};
fetchActiveProviders(); // Call the async function
}, []);
// Provide active keys and ability to update them
return (
<ActiveKeysContext.Provider value={{ activeKeys, setActiveKeys }}>
{!isLoading ? children : <div>Loading...</div>} {/* Conditional rendering */}
</ActiveKeysContext.Provider>
);
};
// Custom hook to access active keys
export const useActiveKeys = () => {
const context = useContext(ActiveKeysContext);
if (!context) {
throw new Error('useActiveKeys must be used within an ActiveKeysProvider');
}
return context;
};

View File

@@ -1,21 +0,0 @@
export interface ProviderResponse {
supported: boolean;
name?: string;
description?: string;
models?: string[];
config_status: Record<string, ConfigDetails>;
}
export interface ConfigDetails {
key: string;
is_set: boolean;
location?: string;
}
export interface Provider {
id: string; // Lowercase key (e.g., "openai")
name: string; // Provider name (e.g., "OpenAI")
description: string; // Description of the provider
models: string[]; // List of supported models
requiredKeys: string[]; // List of required keys
}

View File

@@ -1,98 +0,0 @@
import { Provider, ProviderResponse } from './types';
import { getApiUrl, getSecretKey } from '../../../config';
import { special_provider_cases } from '../providers/utils';
export function isSecretKey(keyName: string): boolean {
// Endpoints and hosts should not be stored as secrets
const nonSecretKeys = [
'DATABRICKS_HOST',
'OLLAMA_HOST',
'AZURE_OPENAI_ENDPOINT',
'AZURE_OPENAI_DEPLOYMENT_NAME',
];
return !nonSecretKeys.includes(keyName);
}
export async function getActiveProviders(): Promise<string[]> {
try {
// Fetch the secrets settings
const configSettings = await getConfigSettings();
console.log('[getActiveProviders]:', configSettings);
// Check for special provider cases (e.g. ollama running locally)
const specialCasesResults = await Promise.all(
Object.entries(special_provider_cases).map(async ([providerName, checkFunction]) => {
const isActive = await checkFunction(); // Dynamically re-check status
console.log(`Special case result for ${providerName}:`, isActive);
return isActive ? providerName : null;
})
);
// Extract active providers based on `is_set` in `secret_status` or providers with no keys
const activeProviders = Object.values(configSettings) // Convert object to array
.filter((provider) => {
const apiKeyStatus = Object.values(provider.config_status || {}); // Get all key statuses
// Include providers if all required keys are set
return apiKeyStatus.length > 0 && apiKeyStatus.every((key) => key.is_set);
})
.map((provider) => provider.name || 'Unknown Provider'); // Extract provider name
// Combine active providers from secrets settings and special cases (avoiding repeats)
const allActiveProviders = activeProviders.concat(
specialCasesResults.filter(
(provider) => provider !== null && !activeProviders.includes(provider)
)
);
return allActiveProviders;
} catch (error) {
console.error('Failed to get active providers:', error);
return [];
}
}
export async function getConfigSettings(): Promise<Record<string, ProviderResponse>> {
const providerList = await getProvidersList();
// Extract the list of IDs
const providerIds = providerList.map((provider) => provider.id);
// Fetch configs state (set/unset) using the provider IDs
const response = await fetch(getApiUrl('/configs/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 async function getProvidersList(): Promise<Provider[]> {
const response = await fetch(getApiUrl('/agent/providers'), {
method: 'GET',
});
if (!response.ok) {
throw new Error(`Failed to fetch providers: ${response.statusText}`);
}
const data = await response.json();
// Format the response into an array of providers
return data.map((item: any) => ({
id: item.id, // Root-level ID
name: item.details?.name || 'Unknown Provider', // Nested name in details
description: item.details?.description || 'No description available.', // Nested description
models: item.details?.models || [], // Nested models array
requiredKeys: item.details?.required_keys || [], // Nested required keys array
}));
}

View File

@@ -3,13 +3,9 @@ import CardActions from './CardActions';
import ConfigurationAction from '../interfaces/ConfigurationAction';
interface CardBodyProps {
actions: ConfigurationAction[];
children: React.ReactNode;
}
export default function CardBody({ actions }: CardBodyProps) {
return (
<div className="flex items-center justify-start">
<CardActions actions={actions} />
</div>
);
export default function CardBody({ children }: CardBodyProps) {
return <div className="flex items-center justify-start">{children}</div>;
}

View File

@@ -25,7 +25,6 @@ interface ProviderNameAndStatusProps {
function ProviderNameAndStatus({ name, isConfigured }: ProviderNameAndStatusProps) {
console.log(`Provider Name: ${name}, Is Configured: ${isConfigured}`);
const ollamaNotConfigured = !isConfigured && name === 'Ollama';
return (
<div className="flex items-center justify-between w-full">
@@ -33,9 +32,6 @@ function ProviderNameAndStatus({ name, isConfigured }: ProviderNameAndStatusProp
{/* Configured state: Green check */}
{isConfigured && <GreenCheckButton tooltip={ConfiguredProviderTooltipMessage(name)} />}
{/* Not Configured + Ollama => Exclamation */}
{ollamaNotConfigured && <ExclamationButton tooltip={OllamaNotConfiguredTooltipMessage()} />}
</div>
);
}

View File

@@ -3,49 +3,84 @@ import CardContainer from './CardContainer';
import CardHeader from './CardHeader';
import ProviderState from '../interfaces/ProviderState';
import CardBody from './CardBody';
import ButtonCallbacks from '../interfaces/ButtonCallbacks';
import { PROVIDER_REGISTRY } from '../ProviderRegistry';
import DefaultCardButtons from './buttons/DefaultCardButtons';
interface ProviderCardProps {
type ProviderCardProps = {
provider: ProviderState;
buttonCallbacks: ButtonCallbacks;
onConfigure: () => void;
onLaunch: () => void;
isOnboarding: boolean;
}
};
export function ProviderCard({ provider, buttonCallbacks, isOnboarding }: ProviderCardProps) {
// export function ProviderCard({ provider, buttonCallbacks, isOnboarding }: ProviderCardProps) {
// const providerEntry = PROVIDER_REGISTRY.find((p) => p.name === provider.name);
//
// // Add safety check
// if (!providerEntry) {
// console.error(`Provider ${provider.name} not found in registry`);
// return null;
// }
//
// const providerDetails = providerEntry.details;
// // Add another safety check
// if (!providerDetails) {
// console.error(`Provider ${provider.name} has no details`);
// return null;
// }
// console.log('provider details', providerDetails);
//
// try {
// const actions = providerDetails.getActions(provider, buttonCallbacks, isOnboarding);
//
// return (
// <CardContainer
// header={
// <CardHeader
// name={providerDetails.name}
// description={providerDetails.description}
// isConfigured={provider.isConfigured}
// />
// }
// body={<CardBody actions={actions} />}
// />
// );
// } catch (error) {
// console.error(`Error rendering provider card for ${provider.name}:`, error);
// return null;
// }
// }
export function ProviderCard({ provider, onConfigure, onLaunch, isOnboarding }: ProviderCardProps) {
const providerEntry = PROVIDER_REGISTRY.find((p) => p.name === provider.name);
// Add safety check
if (!providerEntry) {
console.error(`Provider ${provider.name} not found in registry`);
if (!providerEntry?.details) {
console.error(`Provider ${provider.name} not found in registry or has no details`);
return null;
}
const providerDetails = providerEntry.details;
// Add another safety check
if (!providerDetails) {
console.error(`Provider ${provider.name} has no details`);
return null;
}
console.log('provider details', providerDetails);
try {
const actions = providerDetails.getActions(provider, buttonCallbacks, isOnboarding);
return (
<CardContainer
header={
<CardHeader
name={providerDetails.name}
description={providerDetails.description}
isConfigured={provider.isConfigured}
return (
<CardContainer
header={
<CardHeader
name={providerDetails.name}
description={providerDetails.description}
isConfigured={provider.isConfigured}
/>
}
body={
<CardBody>
<DefaultCardButtons
provider={provider}
onConfigure={onConfigure}
onLaunch={onLaunch}
isOnboardingPage={isOnboarding}
/>
}
body={<CardBody actions={actions} />}
/>
);
} catch (error) {
console.error(`Error rendering provider card for ${provider.name}:`, error);
return null;
}
</CardBody>
}
/>
);
}

View File

@@ -1,13 +1,13 @@
import React from 'react';
import { ConfigureSettingsButton, RocketButton } from './CardButtons';
import ButtonCallbacks from '../../interfaces/ButtonCallbacks';
import ProviderState from '@/src/components/settings_v2/providers/interfaces/ProviderState';
// can define other optional callbacks as needed
interface CardButtonsProps {
provider: ProviderState;
isOnboardingPage?: boolean;
callbacks: ButtonCallbacks; // things like onConfigure, onDelete
isOnboardingPage: boolean;
onConfigure: (provider: ProviderState) => void;
onLaunch: (provider: ProviderState) => void;
}
function getDefaultTooltipMessages(name: string, actionType: string) {
@@ -23,23 +23,11 @@ function getDefaultTooltipMessages(name: string, actionType: string) {
}
}
/// This defines a group of buttons that will appear on the card
/// Controlled by if a provider is configured and which version of the grid page we're on (onboarding vs settings page)
/// This is the default button group
///
/// Settings page:
/// - show configure button
/// Onboarding page:
/// - show configure button if NOT configured
/// - show rocket launch button if configured
///
/// We inject what will happen if we click on a button via on<Function>
/// - onConfigure: pop open a modal -- modal is configured dynamically
/// - onLaunch: continue to chat window
export default function DefaultCardButtons({
provider,
isOnboardingPage,
callbacks,
onLaunch,
onConfigure,
}: CardButtonsProps) {
return (
<>
@@ -49,7 +37,7 @@ export default function DefaultCardButtons({
tooltip={getDefaultTooltipMessages(provider.name, 'add')}
onClick={(e) => {
e.stopPropagation();
callbacks.onConfigure(provider);
onConfigure(provider);
}}
/>
)}
@@ -59,7 +47,7 @@ export default function DefaultCardButtons({
tooltip={getDefaultTooltipMessages(provider.name, 'edit')}
onClick={(e) => {
e.stopPropagation();
callbacks.onConfigure(provider);
onConfigure(provider);
}}
/>
)}
@@ -68,7 +56,7 @@ export default function DefaultCardButtons({
<RocketButton
onClick={(e) => {
e.stopPropagation();
callbacks.onLaunch(provider);
onLaunch(provider);
}}
/>
)}

View File

@@ -0,0 +1,72 @@
import React from 'react';
/**
* CustomRadio - A reusable radio button component with dark mode support
* @param {Object} props - Component props
* @param {string} props.id - Unique identifier for the radio input
* @param {string} props.name - Name attribute for the radio input
* @param {string} props.value - Value of the radio input
* @param {boolean} props.checked - Whether the radio is checked
* @param {function} props.onChange - Function to call when radio selection changes
* @param {boolean} [props.disabled] - Whether the radio is disabled
* @param {React.ReactNode} [props.label] - Primary label content
* @param {React.ReactNode} [props.secondaryLabel] - Secondary/subtitle label content
* @param {React.ReactNode} [props.rightContent] - Optional content to display on the right side
* @param {string} [props.className] - Additional CSS classes for the main container
* @returns {JSX.Element}
*/
const CustomRadio = ({
id,
name,
value,
checked,
onChange,
disabled = false,
label = null,
secondaryLabel = null,
rightContent = null,
className = '',
}) => {
return (
<label
htmlFor={id}
className={`flex justify-between items-center py-2 cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
>
<div className="relative flex items-center">
<input
type="radio"
id={id}
name={name}
value={value}
checked={checked}
onChange={onChange}
disabled={disabled}
className="peer sr-only"
/>
<div
className="h-4 w-4 rounded-full border border-gray-400 dark:border-gray-500 mr-4
peer-checked:border-[6px] peer-checked:border-black dark:peer-checked:border-white
peer-checked:bg-white dark:peer-checked:bg-black
transition-all duration-200 ease-in-out"
></div>
{(label || secondaryLabel) && (
<div>
{label && <p className="text-sm text-gray-900 dark:text-gray-100">{label}</p>}
{secondaryLabel && (
<p className="text-xs text-gray-500 dark:text-gray-400">{secondaryLabel}</p>
)}
</div>
)}
</div>
{rightContent && (
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400">
{rightContent}
</div>
)}
</label>
);
};
export default CustomRadio;