From a3ec5b8ca99cb05560ca6aaa27ed8fdee92fe7fd Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Thu, 20 Mar 2025 12:34:37 -0400 Subject: [PATCH 1/5] pause --- ui/desktop/src/agent/UpdateAgent.tsx | 99 +++++++++++++- .../extensions/ExtensionsSection.tsx | 27 ++-- .../settings_v2/extensions/extensionUtils.ts | 128 ++++++++++++++++++ 3 files changed, 243 insertions(+), 11 deletions(-) create mode 100644 ui/desktop/src/components/settings_v2/extensions/extensionUtils.ts diff --git a/ui/desktop/src/agent/UpdateAgent.tsx b/ui/desktop/src/agent/UpdateAgent.tsx index 09ad1ce1d8eb..1176fff49d9a 100644 --- a/ui/desktop/src/agent/UpdateAgent.tsx +++ b/ui/desktop/src/agent/UpdateAgent.tsx @@ -11,6 +11,10 @@ import { ToastSuccess, } from '../components/settings/models/toasts'; +export interface ExtensionUpdate { + extension: ExtensionConfig + type: 'add' | 'remove' +} // extensionUpdate = an extension was newly added or updated so we should attempt to add it export const useAgent = () => { @@ -39,7 +43,7 @@ export const useAgent = () => { } }; - const updateAgent = async (extensionUpdate?: ExtensionConfig) => { + const updateAgent = async (extensionUpdate?: ExtensionUpdate) => { setIsUpdating(true); try { @@ -62,7 +66,13 @@ export const useAgent = () => { } if (extensionUpdate) { - await addExtensionToAgent(extensionUpdate); + if (extensionUpdate.type == 'remove') { + // If explicitly set to false, remove the extension -- only need name + await removeExtensionFromAgent(extensionUpdate.extension.name); + } else { + // Otherwise, add or update the extension -- need full config + await addExtensionToAgent(extensionUpdate.extension); + } } return true; @@ -184,6 +194,91 @@ export const useAgent = () => { } }; + const removeExtensionFromAgent = async ( + name: string, + silent: boolean = false + ): Promise => { + try { + let toastId; + if (!silent) { + toastId = toast.loading(`Removing ${name} extension...`, { + position: 'top-center', + }); + } + + const response = await fetch(getApiUrl('/extensions/remove'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + body: JSON.stringify(name), + }); + + // Handle non-OK responses + if (!response.ok) { + const errorMsg = `Server returned ${response.status}: ${response.statusText}`; + console.error(errorMsg); + + if (!silent) { + if (toastId) toast.dismiss(toastId); + toast.error(`Failed to remove ${name} 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 }; + } + + console.log('Response data:', data); + + if (!data.error) { + if (!silent) { + if (toastId) toast.dismiss(toastId); + toast.success(`Successfully disabled ${name} extension`); + } + return response; + } + + const errorMessage = `Error removing ${name} extension${data.message ? `. ${data.message}` : ''}`; + const ErrorMsg = ({ closeToast }: { closeToast?: () => void }) => ( +
+
Error removing {name} extension
+
+ +
+
+ ); + + console.error(errorMessage); + if (toastId) toast.dismiss(toastId); + toast(ErrorMsg, { type: 'error', autoClose: false }); + + return response; + } catch (error) { + console.log('Got some other error'); + const errorMessage = `Failed to remove ${name} extension: ${error instanceof Error ? error.message : 'Unknown error'}`; + console.error(errorMessage); + toast.error(errorMessage, { autoClose: false }); + throw error; + } + }; + return { updateAgent, addExtensionToAgent, diff --git a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx index 0c2570309a06..ee3c514e581e 100644 --- a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx @@ -13,16 +13,17 @@ import { } from './utils'; import { useAgent } from '../../../agent/UpdateAgent'; import { activateExtension } from '.'; +import {useExtensionUpdater, handleExtensionError} from './extensionUtils'; export default function ExtensionsSection() { const { toggleExtension, getExtensions, addExtension, removeExtension } = useConfig(); + const performExtensionAction = useExtensionUpdater() 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); @@ -45,11 +46,15 @@ export default function ExtensionsSection() { }, []); const handleExtensionToggle = async (name: string) => { + // TODO: how to handle updating agent based on if we are toggling extension on vs off? try { - await toggleExtension(name); - fetchExtensions(); // Refresh the list after toggling + await performExtensionAction(toggleExtension, [name]) } catch (error) { - console.error('Failed to toggle extension:', error); + // TODO: move to separate function + // TODO: handle error for configuration problems (make sure it's set to not enabled) -- do we handle that configcontext side or via a callback here? + handleExtensionError(error) + } finally { + await fetchExtensions(); // Refresh the list after toggling } }; @@ -65,10 +70,12 @@ export default function ExtensionsSection() { await activateExtension(formData.name, extensionConfig, addExtension); console.log('attempting to add extension'); await updateAgent(extensionConfig); + await performExtensionAction(addExtension, [formData.name, extensionConfig, formData.enabled]) handleModalClose(); - await fetchExtensions(); // Refresh the list after adding } catch (error) { - console.error('Failed to add extension:', error); + handleExtensionError(error) + } finally { + await fetchExtensions(); // Refresh the list after adding } }; @@ -82,15 +89,17 @@ export default function ExtensionsSection() { } catch (error) { console.error('Failed to update extension configuration:', error); } + return handleAddExtension(formData) }; const handleDeleteExtension = async (name: string) => { try { - await removeExtension(name); + await performExtensionAction(removeExtension, [name]) handleModalClose(); - fetchExtensions(); // Refresh the list after deleting } catch (error) { - console.error('Failed to delete extension:', error); + handleExtensionError(error) + } finally { + await fetchExtensions(); // Refresh the list after deleting } }; diff --git a/ui/desktop/src/components/settings_v2/extensions/extensionUtils.ts b/ui/desktop/src/components/settings_v2/extensions/extensionUtils.ts new file mode 100644 index 000000000000..830fbba98729 --- /dev/null +++ b/ui/desktop/src/components/settings_v2/extensions/extensionUtils.ts @@ -0,0 +1,128 @@ +import { useAgent, ExtensionUpdate } from "../../../agent/UpdateAgent"; +import {ExtensionConfig} from "../../../api"; + +type ExtensionUpdateType = 'add' | 'remove' | 'update' | 'toggle'; + +// Define error types for better error handling +interface ConfigError { + type: 'CONFIG_ERROR'; + message: string; + originalError: unknown; +} + +interface AgentUpdateError { + type: 'AGENT_UPDATE_ERROR'; + message: string; + originalError: unknown; +} + +type ExtensionActionError = ConfigError | AgentUpdateError; + + +// TODO: make this a backend endpoint we can call that handles the config update part and the agent update part all in one + +/** + * This is a custom hook that handles updates to extensions that should require updates to both the config + * and the agent. + * + * (1) We update the config + * (2) We update the agent + * + * We throw an error if either (1) or (2) fails + */ +export function useExtensionUpdater() { + const { updateAgent } = useAgent(); + + // Return a strictly typed function that can be called from components + const updateConfigAndAgent = async function( + extensionUpdateType: ExtensionUpdateType, + params: any[], // TODO: some type checking + ): Promise { + let configSuccess = false; + + // First try-catch for the config operation + try { + // Execute the main extension action with appropriate arguments + // Type safety is ensured by the function overloads + await actionFn(...actionParams); + configSuccess = true; + } catch (error) { + console.error('Extension config operation failed:', error); + const configError: ConfigError = { + type: 'CONFIG_ERROR', + message: 'Failed to update extension configuration', + originalError: error + }; + throw configError; + } + + // If config update succeeded, update the agent + if (configSuccess && extensionName) { + try { + // Determine if we're enabling or disabling based on the action function + let isEnabling = false; + + if (actionFn === toggleExtension) { + // For toggle, we need to know the current state to determine the new state + // This might require additional context or a different approach + // For simplicity, we'll assume we're toggling ON here, but you might need to adjust + isEnabling = true; + } else if (actionFn === addExtension) { + // For add operation, the third param is the enabled state + isEnabling = actionParams[2] as boolean; + } else if (actionFn === removeExtension) { + // For remove, we're definitely disabling + isEnabling = false; + } + + // Update the agent with the extension name and whether it's being added or removed from the agent + const extensionUpdate = ExtensionUpdate{extension} + await updateAgent(extensionName); + } catch (error) { + console.error('Agent update operation failed:', error); + const agentError: AgentUpdateError = { + type: 'AGENT_UPDATE_ERROR', + message: 'Failed to update agent with extension changes', + originalError: error + }; + throw agentError; + } + } + }; + + return performExtensionAction; +} + +/** + * Handles extension operation errors with appropriate logging and actions + * @param error The error caught from extension operations + * @param callbacks Optional callback functions for different error types + */ +export function handleExtensionError( + error: any, + callbacks?: { + onConfigError?: (error: ExtensionActionError) => void; + onAgentError?: (error: ExtensionActionError) => void; + onUnknownError?: (error: any) => void; + } +) { + if (error?.type === 'CONFIG_ERROR') { + console.error('Configuration error:', error.message); + callbacks?.onConfigError?.(error as ExtensionActionError); + } else if (error?.type === 'AGENT_ERROR') { + console.error('Agent update error:', error.message); + callbacks?.onAgentError?.(error as ExtensionActionError); + } else { + // Handle unexpected errors + console.error('Unknown error:', error); + callbacks?.onUnknownError?.(error); + } +} + + +// For TypeScript to recognize these functions in comparison checks +// You would need to export these from your config context +// This is just a placeholder for the actual implementation +export const toggleExtension: ToggleExtensionFn = async (name) => { /* implementation */ }; +export const addExtension: AddExtensionFn = async (name, config, enabled) => { /* implementation */ }; +export const removeExtension: RemoveExtensionFn = async (name) => { /* implementation */ }; \ No newline at end of file From 407d57a02ad48f700da3158dca7cdc647868a843 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Thu, 20 Mar 2025 21:59:40 -0400 Subject: [PATCH 2/5] hooks --- .../settings_v2/extensions/extensionUtils.ts | 267 +++++++++++------- 1 file changed, 171 insertions(+), 96 deletions(-) diff --git a/ui/desktop/src/components/settings_v2/extensions/extensionUtils.ts b/ui/desktop/src/components/settings_v2/extensions/extensionUtils.ts index 830fbba98729..05eb9d65ed0e 100644 --- a/ui/desktop/src/components/settings_v2/extensions/extensionUtils.ts +++ b/ui/desktop/src/components/settings_v2/extensions/extensionUtils.ts @@ -1,23 +1,46 @@ -import { useAgent, ExtensionUpdate } from "../../../agent/UpdateAgent"; -import {ExtensionConfig} from "../../../api"; +import { ExtensionUpdate, useAgent } from '../../../agent/UpdateAgent'; +import { ExtensionConfig } from '../../../api'; +import { FixedExtensionEntry, useConfig } from '@/src/components/ConfigContext'; -type ExtensionUpdateType = 'add' | 'remove' | 'update' | 'toggle'; +type ExtensionUpdateType = 'add' | 'remove' | 'toggle'; + +interface addExtensionParams { + name: string; + extensionConfig: ExtensionConfig; + enabled: boolean; +} + +interface removeExtensionParams { + name: string; +} + +interface toggleExtensionParams { + name: string; +} + +type ExtensionUpdateParams = addExtensionParams | removeExtensionParams | toggleExtensionParams; // Define error types for better error handling + +interface LookupError { + type: 'LOOKUP_ERROR'; + message: string; + originalError: unknown; +} + interface ConfigError { - type: 'CONFIG_ERROR'; - message: string; - originalError: unknown; + type: 'CONFIG_ERROR'; + message: string; + originalError: unknown; } interface AgentUpdateError { - type: 'AGENT_UPDATE_ERROR'; - message: string; - originalError: unknown; + type: 'AGENT_UPDATE_ERROR'; + message: string; + originalError: unknown; } -type ExtensionActionError = ConfigError | AgentUpdateError; - +type ExtensionUpdateError = LookupError | ConfigError | AgentUpdateError; // TODO: make this a backend endpoint we can call that handles the config update part and the agent update part all in one @@ -31,66 +54,121 @@ type ExtensionActionError = ConfigError | AgentUpdateError; * We throw an error if either (1) or (2) fails */ export function useExtensionUpdater() { - const { updateAgent } = useAgent(); - - // Return a strictly typed function that can be called from components - const updateConfigAndAgent = async function( - extensionUpdateType: ExtensionUpdateType, - params: any[], // TODO: some type checking - ): Promise { - let configSuccess = false; - - // First try-catch for the config operation - try { - // Execute the main extension action with appropriate arguments - // Type safety is ensured by the function overloads - await actionFn(...actionParams); - configSuccess = true; - } catch (error) { - console.error('Extension config operation failed:', error); - const configError: ConfigError = { - type: 'CONFIG_ERROR', - message: 'Failed to update extension configuration', - originalError: error - }; - throw configError; - } - - // If config update succeeded, update the agent - if (configSuccess && extensionName) { - try { - // Determine if we're enabling or disabling based on the action function - let isEnabling = false; - - if (actionFn === toggleExtension) { - // For toggle, we need to know the current state to determine the new state - // This might require additional context or a different approach - // For simplicity, we'll assume we're toggling ON here, but you might need to adjust - isEnabling = true; - } else if (actionFn === addExtension) { - // For add operation, the third param is the enabled state - isEnabling = actionParams[2] as boolean; - } else if (actionFn === removeExtension) { - // For remove, we're definitely disabling - isEnabling = false; - } - - // Update the agent with the extension name and whether it's being added or removed from the agent - const extensionUpdate = ExtensionUpdate{extension} - await updateAgent(extensionName); - } catch (error) { - console.error('Agent update operation failed:', error); - const agentError: AgentUpdateError = { - type: 'AGENT_UPDATE_ERROR', - message: 'Failed to update agent with extension changes', - originalError: error - }; - throw agentError; - } - } - }; - - return performExtensionAction; + const { updateAgent } = useAgent(); + const { addExtension, removeExtension, toggleExtension, getExtensions } = useConfig(); + + async function getExtensionEntryFromName(name: string) { + const extensions = await getExtensions(true); + const filteredExtensions = extensions.filter((extension) => extension.name === name); + if (filteredExtensions.length > 1) { + throw Error(`Multiple extensions with the same name: ${name}`); + } + return filteredExtensions[0]; + } + + // Extract just the ExtensionConfig part (omitting the 'enabled' property) + const extractConfig = (entry: FixedExtensionEntry): ExtensionConfig => { + const { enabled, ...config } = entry; + return config; + }; + + // TODO: variable naming + // Return a strictly typed function that can be called from components + const updateConfigAndAgent = async function ( + extensionUpdateType: ExtensionUpdateType, + params: ExtensionUpdateParams + ): Promise { + let configSuccess = false; + let lookupSuccess = false; + + let extensionUpdate = { extension: null, type: null }; + let args = null; + let extensionToUpdate = null; + let extEntry = null; + + // get relevant config information + try { + switch (extensionUpdateType) { + case 'add': + args = params as addExtensionParams; + extensionUpdate = { extension: args.extensionConfig, type: 'add' }; + break; + case 'remove': + args = params as removeExtensionParams; + extEntry = await getExtensionEntryFromName(args.name); + extensionToUpdate = extractConfig(extEntry); + extensionUpdate = { extension: extractConfig(extEntry), type: 'remove' }; + break; + case 'toggle': + args = params as toggleExtensionParams; + extEntry = await getExtensionEntryFromName(params.name); + extensionUpdate = { extension: extractConfig(extEntry), type: 'add' }; + break; + default: + throw Error( + "trying to perform an operation other other than 'add', 'remove' or 'toggle'" + ); + } + lookupSuccess = true; + } catch (error) { + const lookupError: LookupError = { + type: 'LOOKUP_ERROR', + message: 'Failed to find extension configuration values', + originalError: error, + }; + throw lookupError; + } + + // First try-catch for the config operation + try { + switch (extensionUpdateType) { + case 'add': + await addExtension(args.name, args.extensionConfig, args.enabled); + break; + case 'remove': + await removeExtension(args.name); + break; + case 'toggle': + await toggleExtension(args.name); + break; + default: + break; + } + configSuccess = true; + } catch (error) { + console.error('Extension config operation failed:', error); + const configError: ConfigError = { + type: 'CONFIG_ERROR', + message: 'Failed to update extension configuration', + originalError: error, + }; + throw configError; + } + + // If config update succeeded, update the agent + if (configSuccess && lookupSuccess) { + try { + await updateAgent(extensionUpdate); + } catch (error) { + console.error('Agent update operation failed:', error); + + // update the config with enabled set to false for this extension + // TODO: if we tried to remove ext with enabled = true, we should re-try removing ? + await addExtension(args.name, extensionUpdate.extension, false); + + // TODO: handle retries / config updates with enabled=false with the handleExtensionError callback? + + const agentError: AgentUpdateError = { + type: 'AGENT_UPDATE_ERROR', + message: 'Failed to update agent with extension changes', + originalError: error, + }; + throw agentError; + } + } + }; + + return updateConfigAndAgent; } /** @@ -99,30 +177,27 @@ export function useExtensionUpdater() { * @param callbacks Optional callback functions for different error types */ export function handleExtensionError( - error: any, - callbacks?: { - onConfigError?: (error: ExtensionActionError) => void; - onAgentError?: (error: ExtensionActionError) => void; - onUnknownError?: (error: any) => void; - } + error: any, + callbacks?: { + onLookupError?: (error: ExtensionUpdateError) => void; + onConfigError?: (error: ExtensionUpdateError) => void; + onAgentError?: (error: ExtensionUpdateError) => void; + onUnknownError?: (error: any) => void; + } ) { - if (error?.type === 'CONFIG_ERROR') { - console.error('Configuration error:', error.message); - callbacks?.onConfigError?.(error as ExtensionActionError); - } else if (error?.type === 'AGENT_ERROR') { - console.error('Agent update error:', error.message); - callbacks?.onAgentError?.(error as ExtensionActionError); - } else { - // Handle unexpected errors - console.error('Unknown error:', error); - callbacks?.onUnknownError?.(error); - } + if (error?.type === 'LOOKUP_ERROR') { + console.error('Lookup error:', error.message); + callbacks?.onLookupError?.(error as ExtensionUpdateError); + } + if (error?.type === 'CONFIG_ERROR') { + console.error('Configuration error:', error.message); + callbacks?.onConfigError?.(error as ExtensionUpdateError); + } else if (error?.type === 'AGENT_ERROR') { + console.error('Agent update error:', error.message); + callbacks?.onAgentError?.(error as ExtensionUpdateError); + } else { + // Handle unexpected errors + console.error('Unknown error:', error); + callbacks?.onUnknownError?.(error); + } } - - -// For TypeScript to recognize these functions in comparison checks -// You would need to export these from your config context -// This is just a placeholder for the actual implementation -export const toggleExtension: ToggleExtensionFn = async (name) => { /* implementation */ }; -export const addExtension: AddExtensionFn = async (name, config, enabled) => { /* implementation */ }; -export const removeExtension: RemoveExtensionFn = async (name) => { /* implementation */ }; \ No newline at end of file From f7c5661900b187d030a4ea2857ec1ee11d12bf94 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Mon, 24 Mar 2025 19:48:42 -0400 Subject: [PATCH 3/5] add the flows --- .../extensions/ExtensionsSection.tsx | 71 +++--- .../settings_v2/extensions/agent_stuff.tsx | 136 ++++++++++++ .../settings_v2/extensions/extensionUtils.ts | 203 ------------------ .../settings_v2/extensions/index.ts | 6 + .../subcomponents/ExtensionItem.tsx | 4 +- .../subcomponents/ExtensionList.tsx | 2 +- .../settings_v2/extensions/temp.tsx | 140 ++++++++++++ .../settings_v2/extensions/utils.ts | 10 + 8 files changed, 323 insertions(+), 249 deletions(-) create mode 100644 ui/desktop/src/components/settings_v2/extensions/agent_stuff.tsx delete mode 100644 ui/desktop/src/components/settings_v2/extensions/extensionUtils.ts create mode 100644 ui/desktop/src/components/settings_v2/extensions/temp.tsx diff --git a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx index ee3c514e581e..45bfd358ddab 100644 --- a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx @@ -9,15 +9,13 @@ import { createExtensionConfig, ExtensionFormData, extensionToFormData, + extractExtensionConfig, getDefaultFormData, } from './utils'; -import { useAgent } from '../../../agent/UpdateAgent'; -import { activateExtension } from '.'; -import {useExtensionUpdater, handleExtensionError} from './extensionUtils'; +import { AddNewExtension, DeleteExtension, ToggleExtension, UpdateExtension } from './temp'; export default function ExtensionsSection() { const { toggleExtension, getExtensions, addExtension, removeExtension } = useConfig(); - const performExtensionAction = useExtensionUpdater() const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [extensions, setExtensions] = useState([]); @@ -45,17 +43,17 @@ export default function ExtensionsSection() { fetchExtensions(); }, []); - const handleExtensionToggle = async (name: string) => { - // TODO: how to handle updating agent based on if we are toggling extension on vs off? - try { - await performExtensionAction(toggleExtension, [name]) - } catch (error) { - // TODO: move to separate function - // TODO: handle error for configuration problems (make sure it's set to not enabled) -- do we handle that configcontext side or via a callback here? - handleExtensionError(error) - } finally { - await fetchExtensions(); // Refresh the list after toggling - } + 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) => { @@ -65,42 +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); - await performExtensionAction(addExtension, [formData.name, extensionConfig, formData.enabled]) - handleModalClose(); - } catch (error) { - handleExtensionError(error) - } finally { - await fetchExtensions(); // Refresh the list after adding - } + // 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); - } - return handleAddExtension(formData) + await UpdateExtension({ + enabled: formData.enabled, + extensionConfig: extensionConfig, + addToConfig: addExtension, + }); + handleModalClose(); + await fetchExtensions(); }; const handleDeleteExtension = async (name: string) => { - try { - await performExtensionAction(removeExtension, [name]) - handleModalClose(); - } catch (error) { - handleExtensionError(error) - } finally { - await fetchExtensions(); // Refresh the list after deleting - } + 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/extensionUtils.ts b/ui/desktop/src/components/settings_v2/extensions/extensionUtils.ts deleted file mode 100644 index 05eb9d65ed0e..000000000000 --- a/ui/desktop/src/components/settings_v2/extensions/extensionUtils.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { ExtensionUpdate, useAgent } from '../../../agent/UpdateAgent'; -import { ExtensionConfig } from '../../../api'; -import { FixedExtensionEntry, useConfig } from '@/src/components/ConfigContext'; - -type ExtensionUpdateType = 'add' | 'remove' | 'toggle'; - -interface addExtensionParams { - name: string; - extensionConfig: ExtensionConfig; - enabled: boolean; -} - -interface removeExtensionParams { - name: string; -} - -interface toggleExtensionParams { - name: string; -} - -type ExtensionUpdateParams = addExtensionParams | removeExtensionParams | toggleExtensionParams; - -// Define error types for better error handling - -interface LookupError { - type: 'LOOKUP_ERROR'; - message: string; - originalError: unknown; -} - -interface ConfigError { - type: 'CONFIG_ERROR'; - message: string; - originalError: unknown; -} - -interface AgentUpdateError { - type: 'AGENT_UPDATE_ERROR'; - message: string; - originalError: unknown; -} - -type ExtensionUpdateError = LookupError | ConfigError | AgentUpdateError; - -// TODO: make this a backend endpoint we can call that handles the config update part and the agent update part all in one - -/** - * This is a custom hook that handles updates to extensions that should require updates to both the config - * and the agent. - * - * (1) We update the config - * (2) We update the agent - * - * We throw an error if either (1) or (2) fails - */ -export function useExtensionUpdater() { - const { updateAgent } = useAgent(); - const { addExtension, removeExtension, toggleExtension, getExtensions } = useConfig(); - - async function getExtensionEntryFromName(name: string) { - const extensions = await getExtensions(true); - const filteredExtensions = extensions.filter((extension) => extension.name === name); - if (filteredExtensions.length > 1) { - throw Error(`Multiple extensions with the same name: ${name}`); - } - return filteredExtensions[0]; - } - - // Extract just the ExtensionConfig part (omitting the 'enabled' property) - const extractConfig = (entry: FixedExtensionEntry): ExtensionConfig => { - const { enabled, ...config } = entry; - return config; - }; - - // TODO: variable naming - // Return a strictly typed function that can be called from components - const updateConfigAndAgent = async function ( - extensionUpdateType: ExtensionUpdateType, - params: ExtensionUpdateParams - ): Promise { - let configSuccess = false; - let lookupSuccess = false; - - let extensionUpdate = { extension: null, type: null }; - let args = null; - let extensionToUpdate = null; - let extEntry = null; - - // get relevant config information - try { - switch (extensionUpdateType) { - case 'add': - args = params as addExtensionParams; - extensionUpdate = { extension: args.extensionConfig, type: 'add' }; - break; - case 'remove': - args = params as removeExtensionParams; - extEntry = await getExtensionEntryFromName(args.name); - extensionToUpdate = extractConfig(extEntry); - extensionUpdate = { extension: extractConfig(extEntry), type: 'remove' }; - break; - case 'toggle': - args = params as toggleExtensionParams; - extEntry = await getExtensionEntryFromName(params.name); - extensionUpdate = { extension: extractConfig(extEntry), type: 'add' }; - break; - default: - throw Error( - "trying to perform an operation other other than 'add', 'remove' or 'toggle'" - ); - } - lookupSuccess = true; - } catch (error) { - const lookupError: LookupError = { - type: 'LOOKUP_ERROR', - message: 'Failed to find extension configuration values', - originalError: error, - }; - throw lookupError; - } - - // First try-catch for the config operation - try { - switch (extensionUpdateType) { - case 'add': - await addExtension(args.name, args.extensionConfig, args.enabled); - break; - case 'remove': - await removeExtension(args.name); - break; - case 'toggle': - await toggleExtension(args.name); - break; - default: - break; - } - configSuccess = true; - } catch (error) { - console.error('Extension config operation failed:', error); - const configError: ConfigError = { - type: 'CONFIG_ERROR', - message: 'Failed to update extension configuration', - originalError: error, - }; - throw configError; - } - - // If config update succeeded, update the agent - if (configSuccess && lookupSuccess) { - try { - await updateAgent(extensionUpdate); - } catch (error) { - console.error('Agent update operation failed:', error); - - // update the config with enabled set to false for this extension - // TODO: if we tried to remove ext with enabled = true, we should re-try removing ? - await addExtension(args.name, extensionUpdate.extension, false); - - // TODO: handle retries / config updates with enabled=false with the handleExtensionError callback? - - const agentError: AgentUpdateError = { - type: 'AGENT_UPDATE_ERROR', - message: 'Failed to update agent with extension changes', - originalError: error, - }; - throw agentError; - } - } - }; - - return updateConfigAndAgent; -} - -/** - * Handles extension operation errors with appropriate logging and actions - * @param error The error caught from extension operations - * @param callbacks Optional callback functions for different error types - */ -export function handleExtensionError( - error: any, - callbacks?: { - onLookupError?: (error: ExtensionUpdateError) => void; - onConfigError?: (error: ExtensionUpdateError) => void; - onAgentError?: (error: ExtensionUpdateError) => void; - onUnknownError?: (error: any) => void; - } -) { - if (error?.type === 'LOOKUP_ERROR') { - console.error('Lookup error:', error.message); - callbacks?.onLookupError?.(error as ExtensionUpdateError); - } - if (error?.type === 'CONFIG_ERROR') { - console.error('Configuration error:', error.message); - callbacks?.onConfigError?.(error as ExtensionUpdateError); - } else if (error?.type === 'AGENT_ERROR') { - console.error('Agent update error:', error.message); - callbacks?.onAgentError?.(error as ExtensionUpdateError); - } else { - // Handle unexpected errors - console.error('Unknown error:', error); - callbacks?.onUnknownError?.(error); - } -} 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; +} From 773a524ffdf5b340ac842d6261e0c379da365daf Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Mon, 24 Mar 2025 20:11:27 -0400 Subject: [PATCH 4/5] unused changes --- ui/desktop/src/agent/UpdateAgent.tsx | 99 +--------------------------- 1 file changed, 2 insertions(+), 97 deletions(-) diff --git a/ui/desktop/src/agent/UpdateAgent.tsx b/ui/desktop/src/agent/UpdateAgent.tsx index 1176fff49d9a..09ad1ce1d8eb 100644 --- a/ui/desktop/src/agent/UpdateAgent.tsx +++ b/ui/desktop/src/agent/UpdateAgent.tsx @@ -11,10 +11,6 @@ import { ToastSuccess, } from '../components/settings/models/toasts'; -export interface ExtensionUpdate { - extension: ExtensionConfig - type: 'add' | 'remove' -} // extensionUpdate = an extension was newly added or updated so we should attempt to add it export const useAgent = () => { @@ -43,7 +39,7 @@ export const useAgent = () => { } }; - const updateAgent = async (extensionUpdate?: ExtensionUpdate) => { + const updateAgent = async (extensionUpdate?: ExtensionConfig) => { setIsUpdating(true); try { @@ -66,13 +62,7 @@ export const useAgent = () => { } if (extensionUpdate) { - if (extensionUpdate.type == 'remove') { - // If explicitly set to false, remove the extension -- only need name - await removeExtensionFromAgent(extensionUpdate.extension.name); - } else { - // Otherwise, add or update the extension -- need full config - await addExtensionToAgent(extensionUpdate.extension); - } + await addExtensionToAgent(extensionUpdate); } return true; @@ -194,91 +184,6 @@ export const useAgent = () => { } }; - const removeExtensionFromAgent = async ( - name: string, - silent: boolean = false - ): Promise => { - try { - let toastId; - if (!silent) { - toastId = toast.loading(`Removing ${name} extension...`, { - position: 'top-center', - }); - } - - const response = await fetch(getApiUrl('/extensions/remove'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify(name), - }); - - // Handle non-OK responses - if (!response.ok) { - const errorMsg = `Server returned ${response.status}: ${response.statusText}`; - console.error(errorMsg); - - if (!silent) { - if (toastId) toast.dismiss(toastId); - toast.error(`Failed to remove ${name} 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 }; - } - - console.log('Response data:', data); - - if (!data.error) { - if (!silent) { - if (toastId) toast.dismiss(toastId); - toast.success(`Successfully disabled ${name} extension`); - } - return response; - } - - const errorMessage = `Error removing ${name} extension${data.message ? `. ${data.message}` : ''}`; - const ErrorMsg = ({ closeToast }: { closeToast?: () => void }) => ( -
-
Error removing {name} extension
-
- -
-
- ); - - console.error(errorMessage); - if (toastId) toast.dismiss(toastId); - toast(ErrorMsg, { type: 'error', autoClose: false }); - - return response; - } catch (error) { - console.log('Got some other error'); - const errorMessage = `Failed to remove ${name} extension: ${error instanceof Error ? error.message : 'Unknown error'}`; - console.error(errorMessage); - toast.error(errorMessage, { autoClose: false }); - throw error; - } - }; - return { updateAgent, addExtensionToAgent, From c75df2dd6354198b9685b9d543fc306ccee97779 Mon Sep 17 00:00:00 2001 From: Lily Delalande Date: Mon, 24 Mar 2025 22:38:22 -0400 Subject: [PATCH 5/5] merge with index --- ui/desktop/src/components/ProviderGrid.tsx | 2 +- .../ConfigureBuiltInExtensionModal.tsx | 2 +- .../extensions/ConfigureExtensionModal.tsx | 2 +- .../extensions/ManualExtensionModal.tsx | 2 +- .../src/components/settings/models/toasts.tsx | 10 +- .../src/components/settings/models/utils.tsx | 2 +- .../providers/ConfigureProvidersGrid.tsx | 6 +- .../extensions/ExtensionsSection.tsx | 13 +- .../settings_v2/extensions/agent_stuff.tsx | 136 -------- .../settings_v2/extensions/index.ts | 300 ++++++++++++++---- .../settings_v2/extensions/temp.tsx | 140 -------- .../settings_v2/models/AddModelModal.tsx | 2 +- 12 files changed, 268 insertions(+), 349 deletions(-) delete mode 100644 ui/desktop/src/components/settings_v2/extensions/agent_stuff.tsx delete mode 100644 ui/desktop/src/components/settings_v2/extensions/temp.tsx 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 45bfd358ddab..34c007da0ad7 100644 --- a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx @@ -12,10 +12,11 @@ import { extractExtensionConfig, getDefaultFormData, } from './utils'; -import { AddNewExtension, DeleteExtension, ToggleExtension, UpdateExtension } from './temp'; + +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([]); @@ -47,7 +48,7 @@ export default function ExtensionsSection() { // 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({ + await toggleExtension({ toggle: toggleDirection, extensionConfig: extensionConfig, addToConfig: addExtension, @@ -65,7 +66,7 @@ export default function ExtensionsSection() { const extensionConfig = createExtensionConfig(formData); // TODO: replace activateExtension in index // TODO: make sure error handling works - await AddNewExtension({ addToConfig: addExtension, extensionConfig: extensionConfig }); + await activateExtension({ addToConfig: addExtension, extensionConfig: extensionConfig }); handleModalClose(); await fetchExtensions(); }; @@ -73,7 +74,7 @@ export default function ExtensionsSection() { const handleUpdateExtension = async (formData: ExtensionFormData) => { const extensionConfig = createExtensionConfig(formData); - await UpdateExtension({ + await updateExtension({ enabled: formData.enabled, extensionConfig: extensionConfig, addToConfig: addExtension, @@ -83,7 +84,7 @@ export default function ExtensionsSection() { }; const handleDeleteExtension = async (name: string) => { - await DeleteExtension({ name, removeFromConfig: removeExtension }); + await deleteExtension({ name, removeFromConfig: removeExtension }); handleModalClose(); await fetchExtensions(); }; diff --git a/ui/desktop/src/components/settings_v2/extensions/agent_stuff.tsx b/ui/desktop/src/components/settings_v2/extensions/agent_stuff.tsx deleted file mode 100644 index 3998b427519d..000000000000 --- a/ui/desktop/src/components/settings_v2/extensions/agent_stuff.tsx +++ /dev/null @@ -1,136 +0,0 @@ -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 b0a87d0ee7f4..508afaac0ddf 100644 --- a/ui/desktop/src/components/settings_v2/extensions/index.ts +++ b/ui/desktop/src/components/settings_v2/extensions/index.ts @@ -34,7 +34,7 @@ function handleError(message: string, shouldThrow = false): void { ToastError({ title: 'Error', msg: message, - errorMessage: message, + traceback: message, }); console.error(message); if (shouldThrow) { @@ -58,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 @@ -65,72 +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 { + // 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 { - // Show loading toast - toastId = ToastLoading({ title: name, msg: 'Adding extension...' }); + await addToConfig(extensionConfig.name, extensionConfig, true); + } catch (error) { + // remove from Agent + await RemoveFromAgent(extensionConfig.name); + // config error workflow + console.log('error', error); + } +} - // First add to the config system - await addExtensionFn(nameToKey(name), config, true); +interface updateExtensionProps { + enabled: boolean; + addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; + extensionConfig: ExtensionConfig; +} - if (config.type != 'stdio') { - console.error('only stdio is supported'); - throw Error('Only STDIO extensions are currently supported'); +// 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 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, - }), - }); + // 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); + } + } +} - const data = await response.json(); +interface toggleExtensionProps { + toggle: 'toggleOn' | 'toggleOff'; + extensionConfig: ExtensionConfig; + addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; + removeFromConfig: (name: string) => Promise; +} - 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, - }); +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); @@ -208,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*/ } /** @@ -278,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/temp.tsx b/ui/desktop/src/components/settings_v2/extensions/temp.tsx deleted file mode 100644 index d59d8dc271f6..000000000000 --- a/ui/desktop/src/components/settings_v2/extensions/temp.tsx +++ /dev/null @@ -1,140 +0,0 @@ -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/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, }); } };