diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 615bd5ef4..7619dc66f 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -1,4 +1,6 @@ import React, { useEffect, useState } from 'react'; +import { addExtensionFromDeepLink } from './extensions'; +import { useNavigate } from 'react-router-dom'; import LauncherWindow from './LauncherWindow'; import ChatWindow from './ChatWindow'; import ErrorScreen from './components/ErrorScreen'; @@ -6,12 +8,19 @@ import 'react-toastify/dist/ReactToastify.css'; import { ToastContainer } from 'react-toastify'; import { ModelProvider } from './components/settings/models/ModelContext'; import { ActiveKeysProvider } from './components/settings/api_keys/ActiveKeysContext'; -import { loadStoredExtensionConfigs } from './extensions'; export default function App() { const [fatalError, setFatalError] = useState(null); const searchParams = new URLSearchParams(window.location.search); const isLauncher = searchParams.get('window') === 'launcher'; + const navigate = useNavigate(); + + useEffect(() => { + window.electron.on('add-extension', (_, link) => { + window.electron.logInfo(`Adding extension from deep link ${link}`); + addExtensionFromDeepLink(link, navigate); + }); + }, [navigate]); useEffect(() => { const handleFatalError = (_: any, errorMessage: string) => { @@ -21,16 +30,6 @@ export default function App() { // Listen for fatal errors from main process window.electron.on('fatal-error', handleFatalError); - // Load stored extension configs when the app starts - // delay this by a few seconds - setTimeout(() => { - window.electron.logInfo('App.tsx: Loading stored extension configs'); - loadStoredExtensionConfigs().catch((error) => { - console.error('Failed to load stored extension configs:', error); - window.electron.logInfo('App.tsx: Failed to load stored extension configs ' + error); - }); - }, 5000); - return () => { window.electron.off('fatal-error', handleFatalError); }; diff --git a/ui/desktop/src/ChatWindow.tsx b/ui/desktop/src/ChatWindow.tsx index 883db05a3..ca2dba726 100644 --- a/ui/desktop/src/ChatWindow.tsx +++ b/ui/desktop/src/ChatWindow.tsx @@ -1,8 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { Message, useChat } from './ai-sdk-fork/useChat'; import { getApiUrl, getSecretKey } from './config'; -import { extendGoosedFromUrl } from './extensions'; -import { useNavigate } from 'react-router-dom'; import BottomMenu from './components/BottomMenu'; import FlappyGoose from './components/FlappyGoose'; import GooseMessage from './components/GooseMessage'; @@ -24,6 +22,7 @@ import { useRecentModels } from './components/settings/models/RecentModels'; import { createSelectedModel } from './components/settings/models/utils'; import { getDefaultModel } from './components/settings/models/hardcoded_stuff'; import Splash from './components/Splash'; +import { loadAndAddStoredExtensions } from './extensions'; declare global { interface Window { @@ -337,18 +336,6 @@ export default function ChatWindow() { }; }, []); - const navigate = useNavigate(); - - useEffect(() => { - // Listen for goose:// deep links - window.electron.on('add-extension', (_, link) => { - window.electron.logInfo('Received message for add-extension: ' + link); - console.log('Received message for add-extension:', link); - extendGoosedFromUrl(link, navigate); - window.electron.logInfo('extended called: ' + link); - }); - }, [navigate]); - // Get initial query and history from URL parameters const searchParams = new URLSearchParams(window.location.search); const initialQuery = searchParams.get('initialQuery'); diff --git a/ui/desktop/src/components/settings/Settings.tsx b/ui/desktop/src/components/settings/Settings.tsx index cef572b5c..2e8cfe693 100644 --- a/ui/desktop/src/components/settings/Settings.tsx +++ b/ui/desktop/src/components/settings/Settings.tsx @@ -3,18 +3,23 @@ import { ScrollArea } from '../ui/scroll-area'; import { useNavigate, useLocation } from 'react-router-dom'; import { Plus } from 'lucide-react'; import { Settings as SettingsType } from './types'; -import { FullExtensionConfig, replaceWithShims, extendGoosed } from '../../extensions'; +import { + FullExtensionConfig, + addExtension, + removeExtension, + BUILT_IN_EXTENSIONS, +} from '../../extensions'; import { ConfigureExtensionModal } from './extensions/ConfigureExtensionModal'; import { ManualExtensionModal } from './extensions/ManualExtensionModal'; -import { showToast } from '../ui/toast'; import BackButton from '../ui/BackButton'; import { RecentModelsRadio } from './models/RecentModels'; import { ExtensionItem } from './extensions/ExtensionItem'; -import { getApiUrl, getSecretKey } from '../../config'; const EXTENSIONS_DESCRIPTION = 'The Model Context Protocol (MCP) is a system that allows AI models to securely connect with local or remote resources using standard server setups. It works like a client-server setup and expands AI capabilities using three main components: Prompts, Resources, and Tools.'; +const EXTENSIONS_SITE_LINK = 'https://block.github.io/goose/v1/extensions/'; + const DEFAULT_SETTINGS: SettingsType = { models: [ { @@ -36,42 +41,10 @@ const DEFAULT_SETTINGS: SettingsType = { enabled: true, }, ], - extensions: [], + // @ts-expect-error "we actually do always have all the properties required for builtins, but tsc cannot tell for some reason" + extensions: BUILT_IN_EXTENSIONS, }; -const BUILT_IN_EXTENSIONS = [ - { - id: 'jetbrains', - name: 'Jetbrains', - type: 'stdio', - cmd: 'goosed', - args: ['mcp', 'jetbrains'], - description: 'Integration with any Jetbrains IDE', - enabled: false, - env_keys: [], - }, - { - id: 'nondeveloper', - name: 'Non-Developer assistant', - type: 'stdio', - cmd: 'goosed', - args: ['mcp', 'nondeveloper'], - description: "General assisant tools that don't require you to be a developer or engineer.", - enabled: false, - env_keys: [], - }, - { - id: 'memory', - name: 'Memory', - type: 'stdio', - cmd: 'goosed', - args: ['mcp', 'memory'], - description: 'Teach goose your preferences as you go.', - enabled: false, - env_keys: [], - }, -]; - export default function Settings() { const navigate = useNavigate(); const location = useLocation(); @@ -96,6 +69,7 @@ export default function Settings() { const [extensionBeingConfigured, setExtensionBeingConfigured] = useState(null); + const [isManualModalOpen, setIsManualModalOpen] = useState(false); // Persist settings changes @@ -142,80 +116,32 @@ export default function Settings() { ), })); - try { - const endpoint = newEnabled ? '/extensions/add' : '/extensions/remove'; - - // Full config for adding - only "name" as a string for removing - const body = newEnabled - ? { - type: extension.type, - ...(extension.type === 'stdio' && { - cmd: await replaceWithShims(extension.cmd), - args: extension.args || [], - }), - ...(extension.type === 'sse' && { - uri: extension.uri, - }), - ...(extension.type === 'builtin' && { - name: extension.name, - }), - env_keys: extension.env_keys, - } - : extension.name; - - const response = await fetch(getApiUrl(endpoint), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify(body), - }); + let response: Response; - if (!response.ok) { - throw new Error(`Failed to ${newEnabled ? 'enable' : 'disable'} extension`); - } + if (newEnabled) { + response = await addExtension(extension); + } else { + response = await removeExtension(extension.name); + } - showToast(`Successfully ${newEnabled ? 'enabled' : 'disabled'} extension`, 'success'); - } catch (error) { + if (!response.ok) { setSettings(originalSettings); - showToast(`Error ${newEnabled ? 'enabling' : 'disabling'} extension`, 'error'); - console.error('Error toggling extension:', error); } }; const handleExtensionRemove = async () => { - if (!extensionBeingConfigured) return; - - try { - // First disable the extension if it's enabled - if (extensionBeingConfigured.enabled) { - const response = await fetch(getApiUrl('/extensions/remove'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify(extensionBeingConfigured.name), - }); - - if (!response.ok) { - throw new Error('Failed to remove extension from backend'); - } - } + if (!extensionBeingConfigured || !extensionBeingConfigured.enabled) return; - // Then remove it from the local settings + const response = await removeExtension(extensionBeingConfigured.name); + + if (response.ok) { + // Remove from localstorage setSettings((prev) => ({ ...prev, extensions: prev.extensions.filter((ext) => ext.id !== extensionBeingConfigured.id), })); - - showToast(`Successfully removed ${extensionBeingConfigured.name} extension`, 'success'); setExtensionBeingConfigured(null); navigate('/settings', { replace: true }); - } catch (error) { - console.error('Error removing extension:', error); - showToast('Failed to remove extension', 'error'); } }; @@ -297,11 +223,7 @@ export default function Settings() { {' '} | - {envKeys.length > 0 && ( + {envVars.length > 0 && (
- {envKeys.map((key) => ( + {envVars.map((envVar) => (
- {key} +
+ {envVar.key} + + = {envVar.value} + +
diff --git a/ui/desktop/src/components/ui/toast.tsx b/ui/desktop/src/components/ui/toast.tsx deleted file mode 100644 index 65fac8bf9..000000000 --- a/ui/desktop/src/components/ui/toast.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React, { useState } from 'react'; - -export function showToast(message: string, type: 'success' | 'error') { - const toast = document.createElement('div'); - toast.className = ` - fixed bottom-4 right-4 p-4 - rounded-lg shadow-lg - ${ - type === 'success' - ? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 border border-green-200 dark:border-green-800' - : 'bg-white dark:bg-gray-800 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800' - } - transform transition-all duration-300 ease-in-out - animate-in fade-in slide-in-from-bottom-5 - `; - toast.textContent = message; - document.body.appendChild(toast); - - // Animate out after 5 seconds instead of 2 - setTimeout(() => { - toast.style.opacity = '0'; - toast.style.transform = 'translateY(1rem)'; - setTimeout(() => toast.remove(), 300); - }, 5000); -} - -type ToastMessage = { - title: string; - description: string; -}; - -export function useToast() { - const [message, setMessage] = useState(null); - - function toast(newMessage: ToastMessage) { - setMessage(newMessage); - setTimeout(() => setMessage(null), 3000); // Auto-hide after 3 seconds - } - - return { message, toast }; -} diff --git a/ui/desktop/src/extensions.ts b/ui/desktop/src/extensions.ts index f38562bb1..8b87eefff 100644 --- a/ui/desktop/src/extensions.ts +++ b/ui/desktop/src/extensions.ts @@ -1,15 +1,19 @@ import { getApiUrl, getSecretKey } from './config'; import { NavigateFunction } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { getStoredProvider } from './utils/providerUtils'; // ExtensionConfig type matching the Rust version export type ExtensionConfig = | { type: 'sse'; + name: string; uri: string; env_keys?: string[]; } | { type: 'stdio'; + name: string; cmd: string; args: string[]; env_keys?: string[]; @@ -23,13 +27,151 @@ export type ExtensionConfig = // FullExtensionConfig type matching all the fields that come in deep links and are stored in local storage export type FullExtensionConfig = ExtensionConfig & { id: string; - name: string; description: string; enabled: boolean; }; +export interface ExtensionPayload { + name?: string; + type?: string; + cmd?: string; + args?: string[]; + uri?: string; + env_keys?: string[]; +} + +export const BUILT_IN_EXTENSIONS = [ + { + id: 'developer', + name: 'Developer', + description: 'General development tools useful for software engineering.', + enabled: true, + type: 'builtin', + env_keys: [], + }, + { + id: 'nondeveloper', + name: 'Non-Developer', + description: "General assisant tools that don't require you to be a developer or engineer.", + enabled: false, + type: 'builtin', + env_keys: [], + }, + { + id: 'memory', + name: 'Memory', + description: 'Teach goose your preferences as you go.', + enabled: false, + type: 'builtin', + env_keys: [], + }, + { + id: 'jetbrains', + name: 'Jetbrains', + description: 'Integration with any Jetbrains IDE', + enabled: false, + type: 'builtin', + env_keys: [], + }, + { + id: 'google_drive', + name: 'Google Drive', + description: 'Built-in Google Drive integration for file management and access', + enabled: false, + type: 'builtin', + env_keys: [ + 'GOOGLE_DRIVE_OAUTH_PATH', + 'GOOGLE_DRIVE_CREDENTIALS_PATH', + 'GOOGLE_DRIVE_OAUTH_CONFIG', + ], + }, +]; + +export async function addExtension( + extension: FullExtensionConfig, + silent: boolean = false +): Promise { + try { + // Create the config based on the extension type + const config = { + type: extension.type, + ...(extension.type === 'stdio' && { + name: extension.name, + cmd: await replaceWithShims(extension.cmd), + args: extension.args || [], + }), + ...(extension.type === 'sse' && { + name: extension.name, + uri: extension.uri, + }), + ...(extension.type === 'builtin' && { + name: extension.name.toLowerCase().replace(/-/g, '').replace(/\s/g, '_'), + }), + env_keys: extension.env_keys, + }; + + const response = await fetch(getApiUrl('/extensions/add'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + body: JSON.stringify(config), + }); + + const data = await response.json(); + + if (!data.error) { + if (!silent) { + toast.success(`Successfully added extension`); + } + return response; + } + + const errorMessage = `Error adding ${extension.name} extension ${data.message ? `. ${data.message}` : ''}`; + console.error(errorMessage); + toast.error(errorMessage); + return response; + } catch (error) { + const errorMessage = `Failed to add ${extension.name} extension: ${error instanceof Error ? error.message : 'Unknown error'}`; + console.error(errorMessage); + toast.error(errorMessage); + throw error; + } +} + +export async function removeExtension(name: string): Promise { + try { + const response = await fetch(getApiUrl('/extensions/remove'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + body: JSON.stringify(name), + }); + + const data = await response.json(); + + if (!data.error) { + toast.success(`Successfully removed ${name} extension`); + return response; + } + + const errorMessage = `Error removing ${name} extension${data.message ? `. ${data.message}` : ''}`; + console.error(errorMessage); + toast.error(errorMessage); + return response; + } catch (error) { + const errorMessage = `Failed to remove ${name} extension: ${error instanceof Error ? error.message : 'Unknown error'}`; + console.error(errorMessage); + toast.error(errorMessage); + throw error; + } +} + // Store extension config in user_settings -const storeExtensionConfig = (config: FullExtensionConfig) => { +function storeExtensionConfig(config: FullExtensionConfig) { try { const userSettingsStr = localStorage.getItem('user_settings'); const userSettings = userSettingsStr @@ -51,47 +193,37 @@ const storeExtensionConfig = (config: FullExtensionConfig) => { } catch (error) { console.error('Error storing extension config:', error); } -}; +} -// Load stored extension configs from user_settings -export const loadStoredExtensionConfigs = async (): Promise => { +export async function loadAndAddStoredExtensions() { try { const userSettingsStr = localStorage.getItem('user_settings'); - //console.log('Loading stored extension configs from user_settings', userSettingsStr); - if (userSettingsStr) { const userSettings = JSON.parse(userSettingsStr); const enabledExtensions = userSettings.extensions.filter((ext: any) => ext.enabled); - //console.log('Enabled extensions:', enabledExtensions); - + console.log('Adding extensions from localStorage: ', enabledExtensions); for (const ext of enabledExtensions) { - // Convert extension format back to ExtensionConfig - console.log('Loading extension:', ext); - - const config: ExtensionConfig = { - type: 'stdio', // Assuming all stored extensions are stdio type for now - cmd: ext.cmd, - args: ext.args || [], - env_keys: ext.env_keys || [], - }; - - console.log('ext config', config); - - await extendGoosed(config); + await addExtension(ext, true); } - - console.log( - 'Loaded stored extension configs from user_settings and activated extensions with agent' - ); + } else { + console.log('Saving default builtin extensions to localStorage'); + // TODO - Revisit + // @ts-expect-error "we actually do always have all the properties required for builtins, but tsc cannot tell for some reason" + BUILT_IN_EXTENSIONS.forEach(async (extension: FullExtensionConfig) => { + storeExtensionConfig(extension); + if (extension.enabled) { + await addExtension(extension, true); + } + }); } } catch (error) { - console.error('Error loading stored extension configs:', error); + console.error('Error loading and activating extensions from localStorage: ', error); } -}; +} // Update the path to the binary based on the command -export const replaceWithShims = async (cmd: string): Promise => { +export async function replaceWithShims(cmd: string) { const binaryPathMap: Record = { goosed: await window.electron.getBinaryPath('goosed'), npx: await window.electron.getBinaryPath('npx'), @@ -104,54 +236,18 @@ export const replaceWithShims = async (cmd: string): Promise => { } return cmd; -}; - -// Extend Goosed with a new system configuration -export const extendGoosed = async (config: ExtensionConfig) => { - console.log('extendGoosed', config); - // allowlist the CMD for stdio type - if (config.type === 'stdio') { - config.cmd = await replaceWithShims(config.cmd); - } - - try { - const response = await fetch(getApiUrl('/extensions/add'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify(config), - }); - const data = await response.json(); - - if (response.ok && !data.error) { - console.log(`Successfully added system config: ${JSON.stringify(config)}`); - } else { - throw new Error(data.message || `Failed to add system config: ${response.statusText}`); - } - console.log(`Successfully added system config: ${JSON.stringify(config)}`); - return true; - } catch (error) { - console.log(`Error adding system config:`, error); - return false; - } -}; +} -// Check if extension requires env vars -const envVarsRequired = (config: ExtensionConfig): boolean => { +function envVarsRequired(config: ExtensionConfig) { return config.env_keys?.length > 0; -}; +} -// Extend Goosed from a goose://extension URL -export const extendGoosedFromUrl = async (url: string, navigate: NavigateFunction) => { +export async function addExtensionFromDeepLink(url: string, navigate: NavigateFunction) { if (!url.startsWith('goose://extension')) { console.log('Invalid URL: URL must use the goose://extension scheme'); return; } - console.log('extendGoosedFromUrl', url); - const parsedUrl = new URL(url); if (parsedUrl.protocol !== 'goose:') { @@ -203,9 +299,5 @@ export const extendGoosedFromUrl = async (url: string, navigate: NavigateFunctio } // If no env vars are required, proceed with extending Goosed - const success = await extendGoosed(config); - - if (!success) { - console.log('Error installing extension from url', url); - } -}; + await addExtension(config); +} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 64d7e5c5a..e44223020 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -110,6 +110,7 @@ const createLauncher = () => { webPreferences: { preload: path.join(__dirname, 'preload.js'), additionalArguments: [JSON.stringify(appConfig)], + partition: 'persist:goose', }, skipTaskbar: true, alwaysOnTop: true, @@ -175,6 +176,7 @@ const createChat = async (app, query?: string, dir?: string, version?: string) = REQUEST_DIR: dir, }), ], + partition: 'persist:goose', // Add this line to ensure persistence }, }); diff --git a/ui/desktop/src/preload.js b/ui/desktop/src/preload.js index 8a3adab4a..734d1ac79 100644 --- a/ui/desktop/src/preload.js +++ b/ui/desktop/src/preload.js @@ -22,14 +22,6 @@ contextBridge.exposeInMainWorld('electron', { startPowerSaveBlocker: () => ipcRenderer.invoke('start-power-save-blocker'), stopPowerSaveBlocker: () => ipcRenderer.invoke('stop-power-save-blocker'), getBinaryPath: (binaryName) => ipcRenderer.invoke('get-binary-path', binaryName), - on: (channel, callback) => { - if (channel === 'fatal-error' || channel === 'add-extension') { - ipcRenderer.on(channel, callback); - } - }, - off: (channel, callback) => { - if (channel === 'fatal-error') { - ipcRenderer.removeListener(channel, callback); - } - } + on: (channel, callback) => ipcRenderer.on(channel, callback), + off: (channel, callback) => ipcRenderer.off(channel, callback) }); diff --git a/ui/desktop/src/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts index 4e2efe4df..700d39716 100644 --- a/ui/desktop/src/utils/providerUtils.ts +++ b/ui/desktop/src/utils/providerUtils.ts @@ -1,5 +1,5 @@ import { getApiUrl, getSecretKey } from '../config'; -import { extendGoosed } from '../extensions'; +import { loadAndAddStoredExtensions } from '../extensions'; import { GOOSE_PROVIDER } from '../env_vars'; import { Model } from '../components/settings/models/ModelContext'; @@ -75,14 +75,19 @@ const addAgent = async (provider: string, model: string) => { export const initializeSystem = async (provider: string, model: string) => { try { - console.log('initializing with provider', provider, 'model', model); + console.log('initializing agent with provider', provider, 'model', model); await addAgent(provider.toLowerCase(), model); - await extendGoosed({ - type: 'builtin', - name: 'developer', - }); + + // TODO - Needs to be replaced with something which can interface + // with the agent to tell when it is ready to add extensions + setTimeout(() => { + window.electron.logInfo('Loading and adding stored extension configs'); + loadAndAddStoredExtensions().catch((error) => { + console.error('Failed to load and add stored extension configs:', error); + }); + }, 5000); } catch (error) { - console.error('Failed to initialize system:', error); + console.error('Failed to initialize agent:', error); throw error; } };