From 92e39acecee71c17432434f2e2a6a215f46159f8 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Fri, 28 Feb 2025 21:27:55 -0500 Subject: [PATCH] feat: hook extensions up in settings-v2 (#1447) --- crates/goose-server/src/openapi.rs | 1 + .../src/routes/config_management.rs | 42 +++++++++ ui/desktop/.prettierignore | 2 +- ui/desktop/eslint.config.js | 2 +- ui/desktop/openapi-ts.config.ts | 2 +- ui/desktop/openapi.json | 34 +++++++ .../src/api/{generated => }/client.gen.ts | 0 ui/desktop/src/api/config.ts | 59 ------------ ui/desktop/src/api/{generated => }/index.ts | 0 ui/desktop/src/api/{generated => }/sdk.gen.ts | 13 ++- .../src/api/{generated => }/types.gen.ts | 27 ++++++ ui/desktop/src/components/ConfigContext.tsx | 64 ++++++++++--- .../settings_v2/ExtensionsSection.tsx | 90 +++++++++++++------ 13 files changed, 236 insertions(+), 100 deletions(-) rename ui/desktop/src/api/{generated => }/client.gen.ts (100%) delete mode 100644 ui/desktop/src/api/config.ts rename ui/desktop/src/api/{generated => }/index.ts (100%) rename ui/desktop/src/api/{generated => }/sdk.gen.ts (84%) rename ui/desktop/src/api/{generated => }/types.gen.ts (85%) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index f90fee8f..6ea5536a 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -9,6 +9,7 @@ use utoipa::OpenApi; super::routes::config_management::read_config, super::routes::config_management::add_extension, super::routes::config_management::remove_extension, + super::routes::config_management::update_extension, super::routes::config_management::read_all_config ), components(schemas( diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index 8713cfc9..76d0eddf 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -1,3 +1,4 @@ +use axum::routing::put; use axum::{ extract::State, routing::{delete, get, post}, @@ -194,6 +195,46 @@ pub async fn read_all_config( Ok(Json(ConfigResponse { config: values })) } +#[utoipa::path( + put, + path = "/config/extension", + request_body = ExtensionQuery, + responses( + (status = 200, description = "Extension configuration updated successfully", body = String), + (status = 404, description = "Extension not found"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn update_extension( + State(_state): State>>>, + Json(extension): Json, +) -> Result, StatusCode> { + let config = Config::global(); + + // Get current extensions + let mut extensions: HashMap = match config.get("extensions") { + Ok(exts) => exts, + Err(_) => return Err(StatusCode::NOT_FOUND), + }; + + // Check if extension exists + if !extensions.contains_key(&extension.name) { + return Err(StatusCode::NOT_FOUND); + } + + // Update extension configuration + extensions.insert(extension.name.clone(), extension.config); + + // Save updated extensions + match config.set( + "extensions", + Value::Object(extensions.into_iter().collect()), + ) { + Ok(_) => Ok(Json(format!("Updated extension {}", extension.name))), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + pub fn routes(state: AppState) -> Router { Router::new() .route("/config", get(read_all_config)) @@ -201,6 +242,7 @@ pub fn routes(state: AppState) -> Router { .route("/config/remove", post(remove_config)) .route("/config/read", post(read_config)) .route("/config/extension", post(add_extension)) + .route("/config/extension", put(update_extension)) .route("/config/extension", delete(remove_extension)) .with_state(state.config) } diff --git a/ui/desktop/.prettierignore b/ui/desktop/.prettierignore index da996a6f..16d6bd24 100644 --- a/ui/desktop/.prettierignore +++ b/ui/desktop/.prettierignore @@ -1,5 +1,5 @@ node_modules dist out -src/api/generated +src/api *.lock \ No newline at end of file diff --git a/ui/desktop/eslint.config.js b/ui/desktop/eslint.config.js index d03560e9..d1422cd7 100644 --- a/ui/desktop/eslint.config.js +++ b/ui/desktop/eslint.config.js @@ -37,7 +37,7 @@ const noWindowLocationHref = { module.exports = [ js.configs.recommended, { - ignores: ['src/api/generated/**'], + ignores: ['src/api/**'], files: ['**/*.{ts,tsx}'], languageOptions: { parser: tsParser, diff --git a/ui/desktop/openapi-ts.config.ts b/ui/desktop/openapi-ts.config.ts index 51fb695a..547099aa 100644 --- a/ui/desktop/openapi-ts.config.ts +++ b/ui/desktop/openapi-ts.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from '@hey-api/openapi-ts'; export default defineConfig({ input: './openapi.json', - output: './src/api/generated', + output: './src/api', plugins: ['@hey-api/client-fetch'], }); diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 0cd35edb..e1a349b4 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -68,6 +68,40 @@ } } }, + "put": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "update_extension", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtensionQuery" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Extension configuration updated successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Extension not found" + }, + "500": { + "description": "Internal server error" + } + } + }, "delete": { "tags": [ "super::routes::config_management" diff --git a/ui/desktop/src/api/generated/client.gen.ts b/ui/desktop/src/api/client.gen.ts similarity index 100% rename from ui/desktop/src/api/generated/client.gen.ts rename to ui/desktop/src/api/client.gen.ts diff --git a/ui/desktop/src/api/config.ts b/ui/desktop/src/api/config.ts deleted file mode 100644 index 3f0096df..00000000 --- a/ui/desktop/src/api/config.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - readAllConfig, - readConfig, - removeConfig, - upsertConfig, - addExtension, - removeExtension, -} from './generated'; -import { client } from './generated/client.gen'; - -// Initialize client configuration -client.setConfig({ - baseUrl: window.appConfig.get('GOOSE_API_HOST') + ':' + window.appConfig.get('GOOSE_PORT'), - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': window.appConfig.get('secretKey'), - }, -}); - -export class Config { - static async upsert(key: string, value: any, isSecret?: boolean) { - return await upsertConfig({ - body: { - key, - value, - is_secret: isSecret, - }, - }); - } - - static async read(key: string) { - return await readConfig({ - body: { key }, - }); - } - - static async remove(key: string) { - return await removeConfig({ - body: { key }, - }); - } - - static async readAll() { - const response = await readAllConfig(); - return response.data.config; - } - - static async addExtension(name: string, config: any) { - return await addExtension({ - body: { name, config }, - }); - } - - static async removeExtension(name: string) { - return await removeExtension({ - body: { key: name }, - }); - } -} diff --git a/ui/desktop/src/api/generated/index.ts b/ui/desktop/src/api/index.ts similarity index 100% rename from ui/desktop/src/api/generated/index.ts rename to ui/desktop/src/api/index.ts diff --git a/ui/desktop/src/api/generated/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts similarity index 84% rename from ui/desktop/src/api/generated/sdk.gen.ts rename to ui/desktop/src/api/sdk.gen.ts index 87bed977..ef87ee43 100644 --- a/ui/desktop/src/api/generated/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch'; -import type { ReadAllConfigData, ReadAllConfigResponse, RemoveExtensionData, RemoveExtensionResponse, AddExtensionData, AddExtensionResponse, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse } from './types.gen'; +import type { ReadAllConfigData, ReadAllConfigResponse, RemoveExtensionData, RemoveExtensionResponse, AddExtensionData, AddExtensionResponse, UpdateExtensionData, UpdateExtensionResponse, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -47,6 +47,17 @@ export const addExtension = (options: Opti }); }; +export const updateExtension = (options: Options) => { + return (options.client ?? _heyApiClient).put({ + url: '/config/extension', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + export const readConfig = (options: Options) => { return (options.client ?? _heyApiClient).get({ url: '/config/read', diff --git a/ui/desktop/src/api/generated/types.gen.ts b/ui/desktop/src/api/types.gen.ts similarity index 85% rename from ui/desktop/src/api/generated/types.gen.ts rename to ui/desktop/src/api/types.gen.ts index 0e4c7299..55297dab 100644 --- a/ui/desktop/src/api/generated/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -89,6 +89,33 @@ export type AddExtensionResponses = { export type AddExtensionResponse = AddExtensionResponses[keyof AddExtensionResponses]; +export type UpdateExtensionData = { + body: ExtensionQuery; + path?: never; + query?: never; + url: '/config/extension'; +}; + +export type UpdateExtensionErrors = { + /** + * Extension not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type UpdateExtensionResponses = { + /** + * Extension configuration updated successfully + */ + 200: string; +}; + +export type UpdateExtensionResponse = UpdateExtensionResponses[keyof UpdateExtensionResponses]; + export type ReadConfigData = { body: ConfigKeyQuery; path?: never; diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index f6a9638b..a20f1d3f 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -1,5 +1,23 @@ import React, { createContext, useContext, useState, useEffect } from 'react'; -import { Config } from '../api/config'; +import { + readAllConfig, + readConfig, + removeConfig, + upsertConfig, + addExtension as apiAddExtension, + removeExtension as apiRemoveExtension, + updateExtension as apiUpdateExtension, +} from '../api'; +import { client } from '../api/client.gen'; + +// Initialize client configuration +client.setConfig({ + baseUrl: window.appConfig.get('GOOSE_API_HOST') + ':' + window.appConfig.get('GOOSE_PORT'), + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': window.appConfig.get('secretKey'), + }, +}); interface ConfigContextType { config: Record; @@ -7,6 +25,7 @@ interface ConfigContextType { read: (key: string) => Promise; remove: (key: string) => Promise; addExtension: (name: string, config: any) => Promise; + updateExtension: (name: string, config: any) => Promise; removeExtension: (name: string) => Promise; } @@ -22,42 +41,65 @@ export const ConfigProvider: React.FC = ({ children }) => { useEffect(() => { // Load all configuration data on mount (async () => { - const initialConfig = await Config.readAll(); - setConfig(initialConfig || {}); + const response = await readAllConfig(); + setConfig(response.data.config || {}); })(); }, []); const reloadConfig = async () => { - const newConfig = await Config.readAll(); - setConfig(newConfig || {}); + const response = await readAllConfig(); + setConfig(response.data.config || {}); }; const upsert = async (key: string, value: any, isSecret?: boolean) => { - await Config.upsert(key, value, isSecret); + await upsertConfig({ + body: { + key, + value, + is_secret: isSecret, + }, + }); await reloadConfig(); }; const read = async (key: string) => { - return Config.read(key); + return await readConfig({ + body: { key }, + }); }; const remove = async (key: string) => { - await Config.remove(key); + await removeConfig({ + body: { key }, + }); await reloadConfig(); }; const addExtension = async (name: string, config: any) => { - await Config.addExtension(name, config); + await apiAddExtension({ + body: { name, config }, + }); await reloadConfig(); }; const removeExtension = async (name: string) => { - await Config.removeExtension(name); + await apiRemoveExtension({ + body: { key: name }, + }); + await reloadConfig(); + }; + + const updateExtension = async (name: string, config: any) => { + await apiUpdateExtension({ + body: { name, config }, + }); await reloadConfig(); }; return ( - + {children} ); diff --git a/ui/desktop/src/components/settings_v2/ExtensionsSection.tsx b/ui/desktop/src/components/settings_v2/ExtensionsSection.tsx index 27d906b9..7aaa74aa 100644 --- a/ui/desktop/src/components/settings_v2/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings_v2/ExtensionsSection.tsx @@ -1,45 +1,83 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Button } from '../ui/button'; import { Switch } from '../ui/switch'; import { Plus } from 'lucide-react'; import { Gear } from '../icons/Gear'; import { GPSIcon } from '../ui/icons'; +import { useConfig } from '../ConfigContext'; + +interface ExtensionConfig { + args?: string[]; + cmd?: string; + enabled: boolean; + envs?: Record; + name: string; + type: 'stdio' | 'builtin'; +} interface ExtensionItem { id: string; title: string; subtitle: string; enabled: boolean; - canConfigure?: boolean; + canConfigure: boolean; + config: ExtensionConfig; } -const extensionItems: ExtensionItem[] = [ - { - id: 'dev', - title: 'Developer Tools', - subtitle: 'Code editing and shell access', - enabled: true, - canConfigure: true, - }, - { - id: 'browser', - title: 'Web Browser', - subtitle: 'Internet access and web automation', - enabled: false, - canConfigure: true, - }, -]; +// Helper function to get a friendly title from extension name +const getFriendlyTitle = (name: string): string => { + return name + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +}; + +// Helper function to get a subtitle based on extension type and configuration +const getSubtitle = (config: ExtensionConfig): string => { + if (config.type === 'builtin') { + return 'Built-in extension'; + } + return `${config.type.toUpperCase()} extension${config.cmd ? ` (${config.cmd})` : ''}`; +}; export default function ExtensionsSection() { - const [extensions, setExtensions] = useState(extensionItems); + const { config, updateExtension } = useConfig(); + const [extensions, setExtensions] = useState([]); - const handleExtensionToggle = (id: string) => { - setExtensions( - extensions.map((extension) => ({ - ...extension, - enabled: extension.id === id ? !extension.enabled : extension.enabled, - })) - ); + useEffect(() => { + if (config.extensions) { + const extensionItems: ExtensionItem[] = Object.entries(config.extensions).map( + ([name, ext]) => { + const extensionConfig = ext as ExtensionConfig; + return { + id: name, + title: getFriendlyTitle(name), + subtitle: getSubtitle(extensionConfig), + enabled: extensionConfig.enabled, + canConfigure: extensionConfig.type === 'stdio' && !!extensionConfig.envs, + config: extensionConfig, + }; + } + ); + setExtensions(extensionItems); + } + }, [config.extensions]); + + const handleExtensionToggle = async (id: string) => { + const extension = extensions.find((ext) => ext.id === id); + if (extension) { + const updatedConfig = { + ...extension.config, + enabled: !extension.config.enabled, + }; + + try { + await updateExtension(id, updatedConfig); + } catch (error) { + console.error('Failed to update extension:', error); + // Here you might want to add a toast notification for error feedback + } + } }; return (