diff --git a/ui/desktop/src/components/ProviderGrid.tsx b/ui/desktop/src/components/ProviderGrid.tsx index 850c09a667fa..1b58662d872b 100644 --- a/ui/desktop/src/components/ProviderGrid.tsx +++ b/ui/desktop/src/components/ProviderGrid.tsx @@ -150,7 +150,7 @@ export function ProviderGrid({ onSubmit }: ProviderGridProps) { ToastError({ title: provider, msg: `Failed to ${providers.find((p) => p.id === selectedId)?.isConfigured ? 'update' : 'add'} configuration`, - errorMessage: error.message, + traceback: error.message, }); } }; diff --git a/ui/desktop/src/components/settings/extensions/ConfigureBuiltInExtensionModal.tsx b/ui/desktop/src/components/settings/extensions/ConfigureBuiltInExtensionModal.tsx index 7fd5f8bcad43..44d9706cfb2f 100644 --- a/ui/desktop/src/components/settings/extensions/ConfigureBuiltInExtensionModal.tsx +++ b/ui/desktop/src/components/settings/extensions/ConfigureBuiltInExtensionModal.tsx @@ -80,7 +80,7 @@ export function ConfigureBuiltInExtensionModal({ ToastError({ title: extension.name, msg: `Failed to configure the extension`, - errorMessage: error.message, + traceback: error.message, }); } finally { setIsSubmitting(false); diff --git a/ui/desktop/src/components/settings/extensions/ConfigureExtensionModal.tsx b/ui/desktop/src/components/settings/extensions/ConfigureExtensionModal.tsx index dc51bd9f4493..71d3fa872ff9 100644 --- a/ui/desktop/src/components/settings/extensions/ConfigureExtensionModal.tsx +++ b/ui/desktop/src/components/settings/extensions/ConfigureExtensionModal.tsx @@ -82,7 +82,7 @@ export function ConfigureExtensionModal({ ToastError({ title: extension.name, msg: `Failed to configure extension`, - errorMessage: error.message, + traceback: error.message, }); } finally { setIsSubmitting(false); diff --git a/ui/desktop/src/components/settings/extensions/ManualExtensionModal.tsx b/ui/desktop/src/components/settings/extensions/ManualExtensionModal.tsx index ca747687645e..0df7cdd5adf6 100644 --- a/ui/desktop/src/components/settings/extensions/ManualExtensionModal.tsx +++ b/ui/desktop/src/components/settings/extensions/ManualExtensionModal.tsx @@ -99,7 +99,7 @@ export function ManualExtensionModal({ isOpen, onClose, onSubmit }: ManualExtens resetForm(); } catch (error) { console.error('Error configuring extension:', error); - ToastError({ title: 'Failed to configure extension', errorMessage: error.message }); + ToastError({ title: 'Failed to configure extension', traceback: error.message }); } }; diff --git a/ui/desktop/src/components/settings/models/toasts.tsx b/ui/desktop/src/components/settings/models/toasts.tsx index bbabbaf8ba96..307c6c82ad15 100644 --- a/ui/desktop/src/components/settings/models/toasts.tsx +++ b/ui/desktop/src/components/settings/models/toasts.tsx @@ -24,10 +24,10 @@ export function ToastSuccess({ title, msg, toastOptions = {} }: ToastSuccessProp type ToastErrorProps = { title?: string; msg?: string; - errorMessage?: string; + traceback?: string; toastOptions?: ToastOptions; }; -export function ToastError({ title, msg, errorMessage, toastOptions }: ToastErrorProps) { +export function ToastError({ title, msg, traceback, toastOptions }: ToastErrorProps) { return toast.error(
@@ -35,17 +35,17 @@ export function ToastError({ title, msg, errorMessage, toastOptions }: ToastErro {msg ?
{msg}
: null}
- {errorMessage ? ( + {traceback ? ( ) : null}
, - { ...commonToastOptions, autoClose: errorMessage ? false : 5000, ...toastOptions } + { ...commonToastOptions, autoClose: traceback ? false : 5000, ...toastOptions } ); } diff --git a/ui/desktop/src/components/settings/models/utils.tsx b/ui/desktop/src/components/settings/models/utils.tsx index 3f0b22590675..5af28772cdc2 100644 --- a/ui/desktop/src/components/settings/models/utils.tsx +++ b/ui/desktop/src/components/settings/models/utils.tsx @@ -43,7 +43,7 @@ export function useHandleModelSelection() { ToastError({ title: model.name, msg: `Failed to switch to model`, - errorMessage: error.message, + traceback: error.message, }); } }; diff --git a/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx b/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx index 0968de2a0b7b..9e190a8920d3 100644 --- a/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx +++ b/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx @@ -158,7 +158,7 @@ export function ConfigureProvidersGrid() { ToastError({ title: provider, msg: `Failed to ${providers.find((p) => p.id === selectedForSetup)?.isConfigured ? 'update' : 'add'} configuration`, - errorMessage: error.message, + traceback: error.message, }); } }; @@ -181,7 +181,7 @@ export function ConfigureProvidersGrid() { // Check if the selected provider is currently active if (currentModel?.provider === providerToDelete.name) { const msg = `Cannot delete the configuration because it's the provider of the current model (${currentModel.name}). Please switch to a different model first.`; - ToastError({ title: providerToDelete.name, msg, errorMessage: msg }); + ToastError({ title: providerToDelete.name, msg, traceback: msg }); setIsConfirmationOpen(false); return; } @@ -221,7 +221,7 @@ export function ConfigureProvidersGrid() { ToastError({ title: providerToDelete.name, msg: 'Failed to delete configuration', - errorMessage: error.message, + traceback: error.message, }); } setIsConfirmationOpen(false); diff --git a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx index 0c2570309a06..34c007da0ad7 100644 --- a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx @@ -9,20 +9,20 @@ import { createExtensionConfig, ExtensionFormData, extensionToFormData, + extractExtensionConfig, getDefaultFormData, } from './utils'; -import { useAgent } from '../../../agent/UpdateAgent'; -import { activateExtension } from '.'; + +import { activateExtension, deleteExtension, toggleExtension, updateExtension } from './index'; export default function ExtensionsSection() { - const { toggleExtension, getExtensions, addExtension, removeExtension } = useConfig(); + const { getExtensions, addExtension, removeExtension } = useConfig(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [extensions, setExtensions] = useState([]); const [selectedExtension, setSelectedExtension] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false); - const { updateAgent, addExtensionToAgent } = useAgent(); const fetchExtensions = async () => { setLoading(true); @@ -44,13 +44,17 @@ export default function ExtensionsSection() { fetchExtensions(); }, []); - const handleExtensionToggle = async (name: string) => { - try { - await toggleExtension(name); - fetchExtensions(); // Refresh the list after toggling - } catch (error) { - console.error('Failed to toggle extension:', error); - } + const handleExtensionToggle = async (extension: FixedExtensionEntry) => { + // If extension is enabled, we are trying to toggle if off, otherwise on + const toggleDirection = extension.enabled ? 'toggleOff' : 'toggleOn'; + const extensionConfig = extractExtensionConfig(extension); + await toggleExtension({ + toggle: toggleDirection, + extensionConfig: extensionConfig, + addToConfig: addExtension, + removeFromConfig: removeExtension, + }); + await fetchExtensions(); // Refresh the list after toggling }; const handleConfigureClick = (extension: FixedExtensionEntry) => { @@ -60,38 +64,29 @@ export default function ExtensionsSection() { const handleAddExtension = async (formData: ExtensionFormData) => { const extensionConfig = createExtensionConfig(formData); - - try { - await activateExtension(formData.name, extensionConfig, addExtension); - console.log('attempting to add extension'); - await updateAgent(extensionConfig); - handleModalClose(); - await fetchExtensions(); // Refresh the list after adding - } catch (error) { - console.error('Failed to add extension:', error); - } + // TODO: replace activateExtension in index + // TODO: make sure error handling works + await activateExtension({ addToConfig: addExtension, extensionConfig: extensionConfig }); + handleModalClose(); + await fetchExtensions(); }; const handleUpdateExtension = async (formData: ExtensionFormData) => { const extensionConfig = createExtensionConfig(formData); - try { - await activateExtension(formData.name, extensionConfig, addExtension); - handleModalClose(); - fetchExtensions(); // Refresh the list after updating - } catch (error) { - console.error('Failed to update extension configuration:', error); - } + await updateExtension({ + enabled: formData.enabled, + extensionConfig: extensionConfig, + addToConfig: addExtension, + }); + handleModalClose(); + await fetchExtensions(); }; const handleDeleteExtension = async (name: string) => { - try { - await removeExtension(name); - handleModalClose(); - fetchExtensions(); // Refresh the list after deleting - } catch (error) { - console.error('Failed to delete extension:', error); - } + await deleteExtension({ name, removeFromConfig: removeExtension }); + handleModalClose(); + await fetchExtensions(); }; const handleModalClose = () => { diff --git a/ui/desktop/src/components/settings_v2/extensions/index.ts b/ui/desktop/src/components/settings_v2/extensions/index.ts index 4bb66c777aca..508afaac0ddf 100644 --- a/ui/desktop/src/components/settings_v2/extensions/index.ts +++ b/ui/desktop/src/components/settings_v2/extensions/index.ts @@ -6,6 +6,7 @@ import { toast } from 'react-toastify'; import { ToastError, ToastLoading, ToastSuccess } from '../../settings/models/toasts'; // Default extension timeout in seconds +// TODO: keep in sync with rust better export const DEFAULT_EXTENSION_TIMEOUT = 300; // Type definition for built-in extensions from JSON @@ -33,7 +34,7 @@ function handleError(message: string, shouldThrow = false): void { ToastError({ title: 'Error', msg: message, - errorMessage: message, + traceback: message, }); console.error(message); if (shouldThrow) { @@ -57,6 +58,11 @@ async function replaceWithShims(cmd: string) { return cmd; } +interface activateExtensionProps { + addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; + extensionConfig: ExtensionConfig; +} + /** * Activates an extension by adding it to both the config system and the API. * @param name The extension name @@ -64,67 +70,151 @@ async function replaceWithShims(cmd: string) { * @param addExtensionFn Function to add extension to config * @returns Promise that resolves when activation is complete */ -export async function activateExtension( - name: string, - config: ExtensionConfig, - addExtensionFn: (name: string, config: ExtensionConfig, enabled: boolean) => Promise -): Promise { - let toastId; +export async function activateExtension({ + addToConfig, + extensionConfig, +}: activateExtensionProps): Promise { try { - // Show loading toast - toastId = ToastLoading({ title: name, msg: 'Adding extension...' }); + // AddToAgent + await AddToAgent(extensionConfig); + } catch (error) { + // add to config with enabled = false + await addToConfig(extensionConfig.name, extensionConfig, false); + // show user the error, return + console.log('error', error); + return; + } - // First add to the config system - await addExtensionFn(nameToKey(name), config, true); + // Then add to config + try { + await addToConfig(extensionConfig.name, extensionConfig, true); + } catch (error) { + // remove from Agent + await RemoveFromAgent(extensionConfig.name); + // config error workflow + console.log('error', error); + } +} - // Then call the API endpoint - const response = await fetch(getApiUrl('/extensions/add'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify({ - type: config.type, - name: nameToKey(name), - cmd: await replaceWithShims(config.cmd), - args: config.args || [], - env_keys: config.envs ? Object.keys(config.envs) : undefined, - timeout: config.timeout, - }), - }); +interface updateExtensionProps { + enabled: boolean; + addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; + extensionConfig: ExtensionConfig; +} - const data = await response.json(); +// updating -- no change to enabled state +export async function updateExtension({ + enabled, + addToConfig, + extensionConfig, +}: updateExtensionProps) { + if (enabled) { + try { + // AddToAgent + await AddToAgent(extensionConfig); + } catch (error) { + // i think only error that gets thrown here is when it's not from the response... rest are handled by agent + console.log('error', error); + // failed to add to agent -- show that error to user and do not update the config file + return; + } - if (!data.error) { - if (toastId) toast.dismiss(toastId); - ToastSuccess({ title: name, msg: 'Successfully enabled extension' }); - } else { - const errorMessage = `Error adding extension`; - console.error(errorMessage); - if (toastId) toast.dismiss(toastId); - ToastError({ - title: name, - msg: errorMessage, - errorMessage: data.message, - }); + // Then add to config + try { + await addToConfig(extensionConfig.name, extensionConfig, enabled); + } catch (error) { + // config error workflow + console.log('error', error); + } + } else { + try { + await addToConfig(extensionConfig.name, extensionConfig, enabled); + } catch (error) { + // TODO: Add to agent with previous configuration and raise error + // for now just log error + console.log('error', error); + } + } +} + +interface toggleExtensionProps { + toggle: 'toggleOn' | 'toggleOff'; + extensionConfig: ExtensionConfig; + addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; + removeFromConfig: (name: string) => Promise; +} + +export async function toggleExtension({ + toggle, + extensionConfig, + addToConfig, +}: toggleExtensionProps) { + // disabled to enabled + if (toggle == 'toggleOn') { + try { + // add to agent + await AddToAgent(extensionConfig); + } catch (error) { + // do nothing raise error + // show user error + console.log('Error adding extension to agent. Error:', error); + return; + } + + // update the config + try { + await addToConfig(extensionConfig.name, extensionConfig, true); + } catch (error) { + // remove from agent? + await RemoveFromAgent(extensionConfig.name); + } + } else if (toggle == 'toggleOff') { + // enabled to disabled + try { + await RemoveFromAgent(extensionConfig.name); + } catch (error) { + // note there was an error, but remove from config anyway + console.error('Error removing extension from agent', extensionConfig.name, error); + } + // update the config + try { + await addToConfig(extensionConfig.name, extensionConfig, false); + } catch (error) { + // TODO: Add to agent with previous configuration + console.log('Error removing extension from config', extensionConfig.name, 'Error:', error); } + } +} + +interface deleteExtensionProps { + name: string; + removeFromConfig: (name: string) => Promise; +} + +export async function deleteExtension({ name, removeFromConfig }: deleteExtensionProps) { + // remove from agent + await RemoveFromAgent(name); + + try { + await removeFromConfig(name); } catch (error) { - const errorMessage = `Failed to add ${name} extension: ${error instanceof Error ? error.message : 'Unknown error'}`; - console.error(errorMessage); - if (toastId) toast.dismiss(toastId); - ToastError({ - title: name, - msg: 'Failed to add extension', - errorMessage: error.message, - }); + console.log('Failed to remove extension from config after removing from agent. Error:', error); + // TODO: tell user to restart goose and try again to remove (will still be present in settings but not on agent until restart) throw error; } } +{ + /*Deeplinks*/ +} + export async function addExtensionFromDeepLink( url: string, - addExtensionFn: (name: string, config: ExtensionConfig, enabled: boolean) => Promise, + addExtensionFn: ( + name: string, + extensionConfig: ExtensionConfig, + enabled: boolean + ) => Promise, setView: (view: string, options: { extensionId: string; showEnvVars: boolean }) => void ) { const parsedUrl = new URL(url); @@ -202,7 +292,11 @@ export async function addExtensionFromDeepLink( } // If no env vars are required, proceed with adding the extension - await activateExtension(name, config, addExtensionFn); + await activateExtension({ extensionConfig: config, addToConfig: addExtensionFn }); +} + +{ + /*Built ins*/ } /** @@ -272,3 +366,109 @@ export async function initializeBuiltInExtensions( // Call with an empty list to ensure all built-ins are added await syncBuiltInExtensions([], addExtensionFn); } + +{ + /* Agent-related helper functions */ +} +async function extensionApiCall( + endpoint: string, + payload: any, + actionType: 'adding' | 'removing', + extensionName: string +): Promise { + let toastId; + const actionVerb = actionType === 'adding' ? 'Adding' : 'Removing'; + const pastVerb = actionType === 'adding' ? 'added' : 'removed'; + + try { + if (actionType === 'adding') { + // Show loading toast + toastId = ToastLoading({ + title: extensionName, + msg: `${actionVerb} ${extensionName} extension...`, + }); + // FIXME: this also shows when toggling -- should only show when you have modal up (fix: diff message for toggling) + toast.info( + 'Press the ESC key on your keyboard to continue using goose while extension loads' + ); + } + + const response = await fetch(getApiUrl(endpoint), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + body: JSON.stringify(payload), + }); + + // Handle non-OK responses + if (!response.ok) { + const errorMsg = `Server returned ${response.status}: ${response.statusText}`; + console.error(errorMsg); + + // Special handling for 428 Precondition Required (agent not initialized) + if (response.status === 428 && actionType === 'adding') { + if (toastId) toast.dismiss(toastId); + ToastError({ + title: extensionName, + msg: 'Agent is not initialized. Please initialize the agent first.', + traceback: errorMsg, + }); + return response; + } + + const msg = `Failed to ${actionType === 'adding' ? 'add' : 'remove'} ${extensionName} extension: ${errorMsg}`; + console.error(msg); + + if (toastId) toast.dismiss(toastId); + ToastError({ + title: extensionName, + msg: msg, + traceback: errorMsg, + }); + return response; + } + + // Parse response JSON safely + let data; + try { + const text = await response.text(); + data = text ? JSON.parse(text) : { error: false }; + } catch (error) { + console.warn('Could not parse response as JSON, assuming success', error); + data = { error: false }; + } + + if (!data.error) { + if (toastId) toast.dismiss(toastId); + ToastSuccess({ title: extensionName, msg: 'Successfully enabled extension' }); + } else { + const errorMessage = `Error adding extension -- parsing data`; + console.error(errorMessage); + if (toastId) toast.dismiss(toastId); + ToastError({ + title: extensionName, + msg: errorMessage, + traceback: data.message, // why data.message not data.error? + }); + } + } catch (error) { + // + } +} + +// Public functions +export async function AddToAgent(extension: ExtensionConfig): Promise { + if (extension.type === 'stdio') { + console.log('extension command', extension.cmd); + extension.cmd = await replaceWithShims(extension.cmd); + console.log('next ext command', extension.cmd); + } + + return extensionApiCall('/extensions/add', extension, 'adding', extension.name); +} + +export async function RemoveFromAgent(name: string): Promise { + return extensionApiCall('/extensions/remove', name, 'removing', name); +} diff --git a/ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionItem.tsx b/ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionItem.tsx index 7d17a01c3d31..b5e286e59fc2 100644 --- a/ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionItem.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionItem.tsx @@ -7,7 +7,7 @@ import { getSubtitle, getFriendlyTitle } from './ExtensionList'; interface ExtensionItemProps { extension: FixedExtensionEntry; - onToggle: (name: string) => void; + onToggle: (extension: FixedExtensionEntry) => void; onConfigure: (extension: FixedExtensionEntry) => void; } @@ -37,7 +37,7 @@ export default function ExtensionItem({ extension, onToggle, onConfigure }: Exte )} onToggle(extension.name)} + onCheckedChange={() => onToggle(extension)} variant="mono" /> diff --git a/ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionList.tsx b/ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionList.tsx index 587a8c957568..79cbcfcf0f23 100644 --- a/ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionList.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionList.tsx @@ -7,7 +7,7 @@ import { combineCmdAndArgs } from '../utils'; interface ExtensionListProps { extensions: FixedExtensionEntry[]; - onToggle: (name: string) => void; + onToggle: (extension: FixedExtensionEntry) => void; onConfigure: (extension: FixedExtensionEntry) => void; } diff --git a/ui/desktop/src/components/settings_v2/extensions/utils.ts b/ui/desktop/src/components/settings_v2/extensions/utils.ts index 000f84f011be..e68afefcabfe 100644 --- a/ui/desktop/src/components/settings_v2/extensions/utils.ts +++ b/ui/desktop/src/components/settings_v2/extensions/utils.ts @@ -95,3 +95,13 @@ export function splitCmdAndArgs(str: string): { cmd: string; args: string[] } { export function combineCmdAndArgs(cmd: string, args: string[]): string { return [cmd, ...args].join(' '); } + +/** + * Extracts the ExtensionConfig from a FixedExtensionEntry object + * @param fixedEntry - The FixedExtensionEntry object + * @returns The ExtensionConfig portion of the object + */ +export function extractExtensionConfig(fixedEntry: FixedExtensionEntry): ExtensionConfig { + const { enabled, ...extensionConfig } = fixedEntry; + return extensionConfig; +} diff --git a/ui/desktop/src/components/settings_v2/models/AddModelModal.tsx b/ui/desktop/src/components/settings_v2/models/AddModelModal.tsx index 1f2b420a0a4c..f717f123920d 100644 --- a/ui/desktop/src/components/settings_v2/models/AddModelModal.tsx +++ b/ui/desktop/src/components/settings_v2/models/AddModelModal.tsx @@ -51,7 +51,7 @@ export const AddModelModal = ({ onClose }: AddModelModalProps) => { } catch (e) { ToastError({ title: 'Failed to add model', - errorMessage: e.message, + traceback: e.message, }); } };