ui: auto update card upon config (#1610)

This commit is contained in:
Lily Delalande
2025-03-11 17:02:32 -07:00
committed by GitHub
parent abf44e2197
commit 4aafc49707
9 changed files with 173 additions and 154 deletions
@@ -11,9 +11,9 @@ use http::{HeaderMap, StatusCode};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use std::env;
use utoipa::ToSchema; use utoipa::ToSchema;
use crate::routes::utils::check_provider_configured;
use crate::state::AppState; use crate::state::AppState;
fn verify_secret_key(headers: &HeaderMap, state: &AppState) -> Result<StatusCode, StatusCode> { fn verify_secret_key(headers: &HeaderMap, state: &AppState) -> Result<StatusCode, StatusCode> {
@@ -123,7 +123,7 @@ pub async fn remove_config(
} }
#[utoipa::path( #[utoipa::path(
post, // Change from get to post post,
path = "/config/read", path = "/config/read",
request_body = ConfigKeyQuery, // Switch back to request_body request_body = ConfigKeyQuery, // Switch back to request_body
responses( responses(
@@ -335,31 +335,6 @@ pub async fn providers(
Ok(Json(providers_response)) Ok(Json(providers_response))
} }
fn check_provider_configured(metadata: &ProviderMetadata) -> bool {
let config = Config::global();
// Check all required keys for the provider
for key in &metadata.config_keys {
if key.required {
let key_name = &key.name;
// First, check if the key is set in the environment
let is_set_in_env = env::var(key_name).is_ok();
// If not set in environment, check the config file based on whether it's a secret or not
let is_set_in_config = config.get(key_name, key.secret).is_ok();
// If the key is neither in the environment nor in the config, the provider is not configured
if !is_set_in_env && !is_set_in_config {
return false;
}
}
}
// If all required keys are accounted for, the provider is considered configured
true
}
pub fn routes(state: AppState) -> Router { pub fn routes(state: AppState) -> Router {
Router::new() Router::new()
.route("/config", get(read_all_config)) .route("/config", get(read_all_config))
+1 -1
View File
@@ -6,7 +6,7 @@ pub mod extension;
pub mod health; pub mod health;
pub mod reply; pub mod reply;
pub mod session; pub mod session;
pub mod utils;
use axum::Router; use axum::Router;
// Function to configure all routes // Function to configure all routes
+66 -18
View File
@@ -1,13 +1,15 @@
use serde::{Deserialize, Serialize};
use std::error::Error;
use goose::config::Config; use goose::config::Config;
use goose::providers::base::{ConfigKey, ProviderMetadata};
use serde::{Deserialize, Serialize};
use std::env;
use std::error::Error;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum KeyLocation { pub enum KeyLocation {
Environment, Environment,
ConfigFile, ConfigFile,
Keychain, Keychain,
NotFound NotFound,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -20,10 +22,8 @@ pub struct KeyInfo {
} }
/// Inspects a configuration key to determine if it's set, its location, and value (for non-secret keys) /// Inspects a configuration key to determine if it's set, its location, and value (for non-secret keys)
pub fn inspect_key( #[allow(dead_code)]
key_name: &str, pub fn inspect_key(key_name: &str, is_secret: bool) -> Result<KeyInfo, Box<dyn Error>> {
is_secret: bool,
) -> Result<KeyInfo, Box<dyn Error>> {
let config = Config::global(); let config = Config::global();
// Check environment variable first // Check environment variable first
@@ -44,7 +44,7 @@ pub fn inspect_key(
let config_result = if is_secret { let config_result = if is_secret {
config.get_secret(key_name).map(|v| (v, true)) config.get_secret(key_name).map(|v| (v, true))
} else { } else {
config.get(key_name).map(|v| (v, false)) config.get_param(key_name).map(|v| (v, false))
}; };
match config_result { match config_result {
@@ -64,20 +64,19 @@ pub fn inspect_key(
// Only include value for non-secret keys // Only include value for non-secret keys
value: if !is_secret_actual { Some(value) } else { None }, value: if !is_secret_actual { Some(value) } else { None },
}) })
},
Err(_) => {
Ok(KeyInfo {
name: key_name.to_string(),
is_set: false,
location: KeyLocation::NotFound,
is_secret,
value: None,
})
} }
Err(_) => Ok(KeyInfo {
name: key_name.to_string(),
is_set: false,
location: KeyLocation::NotFound,
is_secret,
value: None,
}),
} }
} }
/// Inspects multiple keys at once /// Inspects multiple keys at once
#[allow(dead_code)]
pub fn inspect_keys( pub fn inspect_keys(
keys: &[(String, bool)], // (name, is_secret) pairs keys: &[(String, bool)], // (name, is_secret) pairs
) -> Result<Vec<KeyInfo>, Box<dyn Error>> { ) -> Result<Vec<KeyInfo>, Box<dyn Error>> {
@@ -89,4 +88,53 @@ pub fn inspect_keys(
} }
Ok(results) Ok(results)
} }
pub fn check_provider_configured(metadata: &ProviderMetadata) -> bool {
let config = Config::global();
// Get all required keys
let required_keys: Vec<&ConfigKey> = metadata
.config_keys
.iter()
.filter(|key| key.required)
.collect();
// Special case: If a provider has exactly one required key and that key
// has a default value, check if it's explicitly set
if required_keys.len() == 1 && required_keys[0].default.is_some() {
let key = &required_keys[0];
// Check if the key is explicitly set (either in env or config)
let is_set_in_env = env::var(&key.name).is_ok();
let is_set_in_config = config.get(&key.name, key.secret).is_ok();
return is_set_in_env || is_set_in_config;
}
// For providers with multiple keys or keys without defaults:
// Find required keys that don't have default values
let required_non_default_keys: Vec<&ConfigKey> = required_keys
.iter()
.filter(|key| key.default.is_none())
.cloned()
.collect();
// If there are no non-default keys, this provider needs at least one key explicitly set
if required_non_default_keys.is_empty() {
return required_keys.iter().any(|key| {
let is_set_in_env = env::var(&key.name).is_ok();
let is_set_in_config = config.get(&key.name, key.secret).is_ok();
is_set_in_env || is_set_in_config
});
}
// Otherwise, all non-default keys must be set
required_non_default_keys.iter().all(|key| {
let is_set_in_env = env::var(&key.name).is_ok();
let is_set_in_config = config.get(&key.name, key.secret).is_ok();
is_set_in_env || is_set_in_config
})
}
@@ -17,9 +17,11 @@ const GridLayout = memo(function GridLayout({ children }: { children: React.Reac
const ProviderCards = memo(function ProviderCards({ const ProviderCards = memo(function ProviderCards({
providers, providers,
isOnboarding, isOnboarding,
refreshProviders,
}: { }: {
providers: ProviderDetails[]; providers: ProviderDetails[];
isOnboarding: boolean; isOnboarding: boolean;
refreshProviders?: () => void;
}) { }) {
const { openModal } = useProviderModal(); const { openModal } = useProviderModal();
@@ -27,13 +29,16 @@ const ProviderCards = memo(function ProviderCards({
const configureProviderViaModal = useCallback( const configureProviderViaModal = useCallback(
(provider: ProviderDetails) => { (provider: ProviderDetails) => {
openModal(provider, { openModal(provider, {
onSubmit: (values: any) => { onSubmit: () => {
// Your logic to save the configuration // Only refresh if the function is provided
if (refreshProviders) {
refreshProviders();
}
}, },
formProps: {}, formProps: {},
}); });
}, },
[openModal] [openModal, refreshProviders]
); );
const handleLaunch = useCallback(() => { const handleLaunch = useCallback(() => {
@@ -56,48 +61,28 @@ const ProviderCards = memo(function ProviderCards({
return <>{providerCards}</>; return <>{providerCards}</>;
}); });
// Fix the ProviderModalProvider
export const OptimizedProviderModalProvider = memo(function OptimizedProviderModalProvider({
children,
}: {
children: React.ReactNode;
}) {
const contextValue = useMemo(
() => ({
isOpen: false,
currentProvider: null,
modalProps: {},
openModal: (provider, additionalProps = {}) => {
// Implementation
},
closeModal: () => {
// Implementation
},
}),
[]
);
return <ProviderModalProvider>{children}</ProviderModalProvider>;
});
export default memo(function ProviderGrid({ export default memo(function ProviderGrid({
providers, providers,
isOnboarding, isOnboarding,
refreshProviders,
}: { }: {
providers: ProviderDetails[]; providers: ProviderDetails[];
isOnboarding: boolean; isOnboarding: boolean;
refreshProviders?: () => void;
}) { }) {
// Remove the console.log
console.log('provider grid');
// Memoize the modal provider and its children to avoid recreating on every render // Memoize the modal provider and its children to avoid recreating on every render
const modalProviderContent = useMemo( const modalProviderContent = useMemo(
() => ( () => (
<ProviderModalProvider> <ProviderModalProvider>
<ProviderCards providers={providers} isOnboarding={isOnboarding} /> <ProviderCards
providers={providers}
isOnboarding={isOnboarding}
refreshProviders={refreshProviders}
/>
<ProviderConfigurationModal /> <ProviderConfigurationModal />
</ProviderModalProvider> </ProviderModalProvider>
), ),
[providers, isOnboarding] [providers, isOnboarding, refreshProviders]
); );
return <GridLayout>{modalProviderContent}</GridLayout>; return <GridLayout>{modalProviderContent}</GridLayout>;
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import { ScrollArea } from '../../ui/scroll-area'; import { ScrollArea } from '../../ui/scroll-area';
import BackButton from '../../ui/BackButton'; import BackButton from '../../ui/BackButton';
import ProviderGrid from './ProviderGrid'; import ProviderGrid from './ProviderGrid';
@@ -9,37 +9,40 @@ export default function ProviderSettings({ onClose }: { onClose: () => void }) {
const { getProviders } = useConfig(); const { getProviders } = useConfig();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [providers, setProviders] = useState<ProviderDetails[]>([]); const [providers, setProviders] = useState<ProviderDetails[]>([]);
const initialLoadDone = useRef(false);
// Create a function to load providers that can be called multiple times
const loadProviders = useCallback(async () => {
setLoading(true);
try {
// Only force refresh when explicitly requested, not on initial load
const result = await getProviders(!initialLoadDone.current);
if (result) {
setProviders(result);
initialLoadDone.current = true;
}
} catch (error) {
console.error('Failed to load providers:', error);
} finally {
setLoading(false);
}
}, [getProviders]);
// Load providers only once when component mounts // Load providers only once when component mounts
useEffect(() => { useEffect(() => {
let isMounted = true;
const loadProviders = async () => {
try {
// Force refresh to ensure we have the latest data
const result = await getProviders(true);
// Only update state if component is still mounted
if (isMounted && result) {
setProviders(result);
}
} catch (error) {
console.error('Failed to load providers:', error);
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadProviders(); loadProviders();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Intentionally not including loadProviders in deps to prevent reloading
// Cleanup function to prevent state updates on unmounted component // This function will be passed to ProviderGrid for manual refreshes after config changes
return () => { const refreshProviders = useCallback(() => {
isMounted = false; if (initialLoadDone.current) {
}; getProviders(true).then((result) => {
}, []); // Empty dependency array ensures this only runs once if (result) setProviders(result);
});
}
}, [getProviders]);
console.log(providers);
return ( return (
<div className="h-screen w-full"> <div className="h-screen w-full">
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div> <div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
@@ -61,7 +64,11 @@ export default function ProviderSettings({ onClose }: { onClose: () => void }) {
{loading ? ( {loading ? (
<div>Loading providers...</div> <div>Loading providers...</div>
) : ( ) : (
<ProviderGrid providers={providers} isOnboarding={false} /> <ProviderGrid
providers={providers}
isOnboarding={false}
refreshProviders={refreshProviders}
/>
)} )}
</div> </div>
</div> </div>
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import Modal from '../../../../components/Modal'; import Modal from '../../../../components/Modal';
import ProviderSetupHeader from './subcomponents/ProviderSetupHeader'; import ProviderSetupHeader from './subcomponents/ProviderSetupHeader';
import DefaultProviderSetupForm from './subcomponents/forms/DefaultProviderSetupForm'; import DefaultProviderSetupForm from './subcomponents/forms/DefaultProviderSetupForm';
@@ -20,7 +20,7 @@ const customFormsMap = {
}; };
export default function ProviderConfigurationModal() { export default function ProviderConfigurationModal() {
const { upsert } = useConfig(); const { upsert, getProviders } = useConfig();
const { isOpen, currentProvider, modalProps, closeModal } = useProviderModal(); const { isOpen, currentProvider, modalProps, closeModal } = useProviderModal();
const [configValues, setConfigValues] = useState({}); const [configValues, setConfigValues] = useState({});
@@ -32,15 +32,25 @@ export default function ProviderConfigurationModal() {
const SubmitHandler = customSubmitHandlerMap[currentProvider.name] || DefaultSubmitHandler; const SubmitHandler = customSubmitHandlerMap[currentProvider.name] || DefaultSubmitHandler;
const FormComponent = customFormsMap[currentProvider.name] || DefaultProviderSetupForm; const FormComponent = customFormsMap[currentProvider.name] || DefaultProviderSetupForm;
const handleSubmitForm = (e) => { const handleSubmitForm = async (e) => {
e.preventDefault(); e.preventDefault();
console.log('Form submitted for:', currentProvider.name); console.log('Form submitted for:', currentProvider.name);
SubmitHandler(upsert, currentProvider, configValues); try {
// Wait for the submission to complete
await SubmitHandler(upsert, currentProvider, configValues);
// Close the modal unless the custom handler explicitly returns false // Close the modal before triggering refreshes to avoid UI issues
// This gives custom handlers the ability to keep the modal open if needed closeModal();
closeModal();
// Call onSubmit callback if provided (from modal props)
if (modalProps.onSubmit) {
modalProps.onSubmit(configValues);
}
} catch (error) {
console.error('Failed to save configuration:', error);
// Keep modal open if there's an error
}
}; };
const handleCancel = () => { const handleCancel = () => {
@@ -1,60 +1,56 @@
import React, { createContext, useContext, useState, useMemo, useCallback } from 'react'; import React, { createContext, useContext, useState } from 'react';
import { ProviderDetails } from '../../../../api'; import { ProviderDetails } from '../../../../api/types.gen';
interface ModalProps {
onSubmit?: (values: any) => void;
onCancel?: () => void;
formProps?: any;
}
interface ProviderModalContextType { interface ProviderModalContextType {
isOpen: boolean; isOpen: boolean;
currentProvider: ProviderDetails | null; currentProvider: ProviderDetails | null;
modalProps: any; modalProps: ModalProps;
openModal: (provider: ProviderDetails, additionalProps: any) => void; openModal: (provider: ProviderDetails, additionalProps?: ModalProps) => void;
closeModal: () => void; closeModal: () => void;
} }
const defaultContext: ProviderModalContextType = { const ProviderModalContext = createContext<ProviderModalContextType | undefined>(undefined);
isOpen: false,
currentProvider: null,
modalProps: {},
openModal: () => {},
closeModal: () => {},
};
const ProviderModalContext = createContext<ProviderModalContextType>(defaultContext);
export const useProviderModal = () => useContext<ProviderModalContextType>(ProviderModalContext);
export const ProviderModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const ProviderModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [currentProvider, setCurrentProvider] = useState<ProviderDetails | null>(null); const [currentProvider, setCurrentProvider] = useState<ProviderDetails | null>(null);
const [modalProps, setModalProps] = useState({}); const [modalProps, setModalProps] = useState<ModalProps>({});
// Use useCallback to prevent function recreation on each render const openModal = (provider: ProviderDetails, additionalProps: ModalProps = {}) => {
const openModal = useCallback((provider: ProviderDetails, additionalProps = {}) => {
setCurrentProvider(provider); setCurrentProvider(provider);
setModalProps(additionalProps); setModalProps(additionalProps);
setIsOpen(true); setIsOpen(true);
}, []); };
const closeModal = useCallback(() => { const closeModal = () => {
setIsOpen(false); setIsOpen(false);
// Use a small timeout to prevent UI flicker };
setTimeout(() => {
setCurrentProvider(null);
setModalProps({});
}, 200);
}, []);
// Memoize the context value to prevent unnecessary re-renders
const contextValue = useMemo(
() => ({
isOpen,
currentProvider,
modalProps,
openModal,
closeModal,
}),
[isOpen, currentProvider, modalProps, openModal, closeModal]
);
return ( return (
<ProviderModalContext.Provider value={contextValue}>{children}</ProviderModalContext.Provider> <ProviderModalContext.Provider
value={{
isOpen,
currentProvider,
modalProps,
openModal,
closeModal,
}}
>
{children}
</ProviderModalContext.Provider>
); );
}; };
export const useProviderModal = () => {
const context = useContext(ProviderModalContext);
if (context === undefined) {
throw new Error('useProviderModal must be used within a ProviderModalProvider');
}
return context;
};
@@ -25,12 +25,11 @@ export default function DefaultProviderSetupForm({
// Try to load actual values from config for each parameter that is not secret // Try to load actual values from config for each parameter that is not secret
for (const parameter of parameters) { for (const parameter of parameters) {
if (parameter.required && !parameter.secret) { if (parameter.required) {
try { try {
// Check if there's a stored value in the config system // Check if there's a stored value in the config system
const configKey = `${parameter.name}`; const configKey = `${parameter.name}`;
const configResponse = await read(configKey, parameter.secret || false); const configResponse = await read(configKey, parameter.secret || false);
console.log('configResponse', configResponse);
if (configResponse) { if (configResponse) {
// Use the value from the config provider // Use the value from the config provider
@@ -101,8 +101,7 @@ export const DefaultSubmitHandler = async (upsertFn, provider, configValues) =>
// Create the provider-specific config key // Create the provider-specific config key
const configKey = `${parameter.name}`; const configKey = `${parameter.name}`;
// Explicitly define is_secret as a boolean (true/false) or null // Explicitly define is_secret as a boolean (true/false)
// This is critical for Rust's Option<bool> type
const isSecret = parameter.secret === true; const isSecret = parameter.secret === true;
// Pass the is_secret flag from the parameter definition // Pass the is_secret flag from the parameter definition