diff --git a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx index 0c2570309a06..45bfd358ddab 100644 --- a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx @@ -9,10 +9,10 @@ import { createExtensionConfig, ExtensionFormData, extensionToFormData, + extractExtensionConfig, getDefaultFormData, } from './utils'; -import { useAgent } from '../../../agent/UpdateAgent'; -import { activateExtension } from '.'; +import { AddNewExtension, DeleteExtension, ToggleExtension, UpdateExtension } from './temp'; export default function ExtensionsSection() { const { toggleExtension, getExtensions, addExtension, removeExtension } = useConfig(); @@ -22,7 +22,6 @@ export default function ExtensionsSection() { 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 +43,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 +63,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 AddNewExtension({ 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/agent_stuff.tsx b/ui/desktop/src/components/settings_v2/extensions/agent_stuff.tsx new file mode 100644 index 000000000000..3998b427519d --- /dev/null +++ b/ui/desktop/src/components/settings_v2/extensions/agent_stuff.tsx @@ -0,0 +1,136 @@ +import { replaceWithShims } from '../../../agent/utils'; +import { ExtensionConfig } from '../../../api'; +import { toast } from 'react-toastify'; +import { getApiUrl, getSecretKey } from '../../../config'; +import React from 'react'; + +// Error message component +const ErrorMsg = ({ + name, + message, + closeToast, +}: { + name: string; + message?: string; + closeToast?: () => void; +}) => ( +
+
+ Error {message?.includes('adding') ? 'adding' : 'removing'} {name} extension +
+
+ +
+
+); + +// Core API call function +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 { + toastId = toast.loading(`${actionVerb} ${extensionName} extension...`, { + position: 'top-center', + }); + + if (actionType === 'adding') { + 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); + toast.error('Agent is not initialized. Please initialize the agent first.'); + return response; + } + + if (toastId) toast.dismiss(toastId); + toast.error( + `Failed to ${actionType === 'adding' ? 'add' : 'remove'} ${extensionName} extension: ${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); + toast.success( + `Successfully ${actionType === 'adding' ? 'enabled' : 'disabled'} ${extensionName} extension` + ); + return response; + } + + const errorMessage = `Error ${actionType} ${extensionName} extension${data.message ? `. ${data.message}` : ''}`; + console.error(errorMessage); + + if (toastId) toast.dismiss(toastId); + toast(, { + type: 'error', + autoClose: false, + }); + + return response; + } catch (error) { + console.log('Got some other error'); + const errorMessage = `Failed to ${actionType === 'adding' ? 'add' : 'remove'} ${extensionName} extension: ${error instanceof Error ? error.message : 'Unknown error'}`; + console.error(errorMessage); + if (toastId) toast.dismiss(toastId); + toast.error(errorMessage, { autoClose: false }); + throw 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/index.ts b/ui/desktop/src/components/settings_v2/extensions/index.ts index 4bb66c777aca..b0a87d0ee7f4 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 @@ -77,6 +78,11 @@ export async function activateExtension( // First add to the config system await addExtensionFn(nameToKey(name), config, true); + if (config.type != 'stdio') { + console.error('only stdio is supported'); + throw Error('Only STDIO extensions are currently supported'); + } + // Then call the API endpoint const response = await fetch(getApiUrl('/extensions/add'), { method: 'POST', 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/temp.tsx b/ui/desktop/src/components/settings_v2/extensions/temp.tsx new file mode 100644 index 000000000000..d59d8dc271f6 --- /dev/null +++ b/ui/desktop/src/components/settings_v2/extensions/temp.tsx @@ -0,0 +1,140 @@ +import { ExtensionConfig } from '../../../api'; +import { AddToAgent, RemoveFromAgent } from './agent_stuff'; + +interface UpdateExtensionProps { + enabled: boolean; + addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; + extensionConfig: ExtensionConfig; +} + +// 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; + } + + // 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); + } + } +} + +// Adding a net-new extension (not in config) +interface AddNewExtensionProps { + addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; + extensionConfig: ExtensionConfig; +} + +export async function AddNewExtension({ addToConfig, extensionConfig }: AddNewExtensionProps) { + try { + // 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; + } + + // 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); + } +} + +// TODO: handle errors in their respective functions +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) { + 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; + } +} 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; +}