feat: hook extensions up in settings-v2 (#1447)

This commit is contained in:
Alex Hancock
2025-02-28 21:27:55 -05:00
committed by GitHub
parent 7439b8199e
commit 92e39acece
13 changed files with 236 additions and 100 deletions
+1
View File
@@ -9,6 +9,7 @@ use utoipa::OpenApi;
super::routes::config_management::read_config, super::routes::config_management::read_config,
super::routes::config_management::add_extension, super::routes::config_management::add_extension,
super::routes::config_management::remove_extension, super::routes::config_management::remove_extension,
super::routes::config_management::update_extension,
super::routes::config_management::read_all_config super::routes::config_management::read_all_config
), ),
components(schemas( components(schemas(
@@ -1,3 +1,4 @@
use axum::routing::put;
use axum::{ use axum::{
extract::State, extract::State,
routing::{delete, get, post}, routing::{delete, get, post},
@@ -194,6 +195,46 @@ pub async fn read_all_config(
Ok(Json(ConfigResponse { config: values })) 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<Arc<Mutex<HashMap<String, Value>>>>,
Json(extension): Json<ExtensionQuery>,
) -> Result<Json<String>, StatusCode> {
let config = Config::global();
// Get current extensions
let mut extensions: HashMap<String, Value> = 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 { pub fn routes(state: AppState) -> Router {
Router::new() Router::new()
.route("/config", get(read_all_config)) .route("/config", get(read_all_config))
@@ -201,6 +242,7 @@ pub fn routes(state: AppState) -> Router {
.route("/config/remove", post(remove_config)) .route("/config/remove", post(remove_config))
.route("/config/read", post(read_config)) .route("/config/read", post(read_config))
.route("/config/extension", post(add_extension)) .route("/config/extension", post(add_extension))
.route("/config/extension", put(update_extension))
.route("/config/extension", delete(remove_extension)) .route("/config/extension", delete(remove_extension))
.with_state(state.config) .with_state(state.config)
} }
+1 -1
View File
@@ -1,5 +1,5 @@
node_modules node_modules
dist dist
out out
src/api/generated src/api
*.lock *.lock
+1 -1
View File
@@ -37,7 +37,7 @@ const noWindowLocationHref = {
module.exports = [ module.exports = [
js.configs.recommended, js.configs.recommended,
{ {
ignores: ['src/api/generated/**'], ignores: ['src/api/**'],
files: ['**/*.{ts,tsx}'], files: ['**/*.{ts,tsx}'],
languageOptions: { languageOptions: {
parser: tsParser, parser: tsParser,
+1 -1
View File
@@ -2,6 +2,6 @@ import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({ export default defineConfig({
input: './openapi.json', input: './openapi.json',
output: './src/api/generated', output: './src/api',
plugins: ['@hey-api/client-fetch'], plugins: ['@hey-api/client-fetch'],
}); });
+34
View File
@@ -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": { "delete": {
"tags": [ "tags": [
"super::routes::config_management" "super::routes::config_management"
-59
View File
@@ -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 },
});
}
}
@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch'; 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'; import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
@@ -47,6 +47,17 @@ export const addExtension = <ThrowOnError extends boolean = false>(options: Opti
}); });
}; };
export const updateExtension = <ThrowOnError extends boolean = false>(options: Options<UpdateExtensionData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).put<UpdateExtensionResponse, unknown, ThrowOnError>({
url: '/config/extension',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const readConfig = <ThrowOnError extends boolean = false>(options: Options<ReadConfigData, ThrowOnError>) => { export const readConfig = <ThrowOnError extends boolean = false>(options: Options<ReadConfigData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<unknown, unknown, ThrowOnError>({ return (options.client ?? _heyApiClient).get<unknown, unknown, ThrowOnError>({
url: '/config/read', url: '/config/read',
@@ -89,6 +89,33 @@ export type AddExtensionResponses = {
export type AddExtensionResponse = AddExtensionResponses[keyof 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 = { export type ReadConfigData = {
body: ConfigKeyQuery; body: ConfigKeyQuery;
path?: never; path?: never;
+53 -11
View File
@@ -1,5 +1,23 @@
import React, { createContext, useContext, useState, useEffect } from 'react'; 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 { interface ConfigContextType {
config: Record<string, any>; config: Record<string, any>;
@@ -7,6 +25,7 @@ interface ConfigContextType {
read: (key: string) => Promise<any>; read: (key: string) => Promise<any>;
remove: (key: string) => Promise<void>; remove: (key: string) => Promise<void>;
addExtension: (name: string, config: any) => Promise<void>; addExtension: (name: string, config: any) => Promise<void>;
updateExtension: (name: string, config: any) => Promise<void>;
removeExtension: (name: string) => Promise<void>; removeExtension: (name: string) => Promise<void>;
} }
@@ -22,42 +41,65 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
useEffect(() => { useEffect(() => {
// Load all configuration data on mount // Load all configuration data on mount
(async () => { (async () => {
const initialConfig = await Config.readAll(); const response = await readAllConfig();
setConfig(initialConfig || {}); setConfig(response.data.config || {});
})(); })();
}, []); }, []);
const reloadConfig = async () => { const reloadConfig = async () => {
const newConfig = await Config.readAll(); const response = await readAllConfig();
setConfig(newConfig || {}); setConfig(response.data.config || {});
}; };
const upsert = async (key: string, value: any, isSecret?: boolean) => { 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(); await reloadConfig();
}; };
const read = async (key: string) => { const read = async (key: string) => {
return Config.read(key); return await readConfig({
body: { key },
});
}; };
const remove = async (key: string) => { const remove = async (key: string) => {
await Config.remove(key); await removeConfig({
body: { key },
});
await reloadConfig(); await reloadConfig();
}; };
const addExtension = async (name: string, config: any) => { const addExtension = async (name: string, config: any) => {
await Config.addExtension(name, config); await apiAddExtension({
body: { name, config },
});
await reloadConfig(); await reloadConfig();
}; };
const removeExtension = async (name: string) => { 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(); await reloadConfig();
}; };
return ( return (
<ConfigContext.Provider value={{ config, upsert, read, remove, addExtension, removeExtension }}> <ConfigContext.Provider
value={{ config, upsert, read, remove, addExtension, updateExtension, removeExtension }}
>
{children} {children}
</ConfigContext.Provider> </ConfigContext.Provider>
); );
@@ -1,45 +1,83 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Switch } from '../ui/switch'; import { Switch } from '../ui/switch';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { Gear } from '../icons/Gear'; import { Gear } from '../icons/Gear';
import { GPSIcon } from '../ui/icons'; import { GPSIcon } from '../ui/icons';
import { useConfig } from '../ConfigContext';
interface ExtensionConfig {
args?: string[];
cmd?: string;
enabled: boolean;
envs?: Record<string, string>;
name: string;
type: 'stdio' | 'builtin';
}
interface ExtensionItem { interface ExtensionItem {
id: string; id: string;
title: string; title: string;
subtitle: string; subtitle: string;
enabled: boolean; enabled: boolean;
canConfigure?: boolean; canConfigure: boolean;
config: ExtensionConfig;
} }
const extensionItems: ExtensionItem[] = [ // Helper function to get a friendly title from extension name
{ const getFriendlyTitle = (name: string): string => {
id: 'dev', return name
title: 'Developer Tools', .split('-')
subtitle: 'Code editing and shell access', .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
enabled: true, .join(' ');
canConfigure: true, };
},
{ // Helper function to get a subtitle based on extension type and configuration
id: 'browser', const getSubtitle = (config: ExtensionConfig): string => {
title: 'Web Browser', if (config.type === 'builtin') {
subtitle: 'Internet access and web automation', return 'Built-in extension';
enabled: false, }
canConfigure: true, return `${config.type.toUpperCase()} extension${config.cmd ? ` (${config.cmd})` : ''}`;
}, };
];
export default function ExtensionsSection() { export default function ExtensionsSection() {
const [extensions, setExtensions] = useState<ExtensionItem[]>(extensionItems); const { config, updateExtension } = useConfig();
const [extensions, setExtensions] = useState<ExtensionItem[]>([]);
const handleExtensionToggle = (id: string) => { useEffect(() => {
setExtensions( if (config.extensions) {
extensions.map((extension) => ({ const extensionItems: ExtensionItem[] = Object.entries(config.extensions).map(
...extension, ([name, ext]) => {
enabled: extension.id === id ? !extension.enabled : extension.enabled, 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 ( return (