From 6c97adf0073337d2b141ea76dd632adcf4c26452 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Tue, 5 Aug 2025 18:27:08 +0200 Subject: [PATCH 01/31] First post --- crates/goose-server/src/openapi.rs | 5 +- crates/goose-server/src/routes/goose_apps.rs | 79 +++++++++++++ crates/goose-server/src/routes/mod.rs | 3 + ui/desktop/openapi.json | 65 +++++++++++ ui/desktop/src/App.tsx | 20 +++- ui/desktop/src/api/sdk.gen.ts | 9 +- ui/desktop/src/api/types.gen.ts | 40 +++++++ .../components/GooseSidebar/AppSidebar.tsx | 28 ++++- ui/desktop/src/components/apps/AppsView.tsx | 106 ++++++++++++++++++ 9 files changed, 350 insertions(+), 5 deletions(-) create mode 100644 crates/goose-server/src/routes/goose_apps.rs create mode 100644 ui/desktop/src/components/apps/AppsView.tsx diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 38ea0e343e5c..87c17d82bae9 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -385,7 +385,8 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::schedule::sessions_handler, super::routes::recipe::create_recipe, super::routes::recipe::encode_recipe, - super::routes::recipe::decode_recipe + super::routes::recipe::decode_recipe, + super::routes::goose_apps::list_apps ), components(schemas( super::routes::config_management::UpsertConfigQuery, @@ -464,6 +465,8 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { goose::agents::types::SuccessCheck, super::routes::agent::AddSubRecipesRequest, super::routes::agent::AddSubRecipesResponse, + super::routes::goose_apps::GooseApp, + super::routes::goose_apps::AppListResponse, )) )] pub struct ApiDoc; diff --git a/crates/goose-server/src/routes/goose_apps.rs b/crates/goose-server/src/routes/goose_apps.rs new file mode 100644 index 000000000000..ea444de7dd1d --- /dev/null +++ b/crates/goose-server/src/routes/goose_apps.rs @@ -0,0 +1,79 @@ +use super::utils::verify_secret_key; +use std::sync::Arc; + +use crate::state::AppState; +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + routing::get, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct GooseApp { + pub name: String, + pub description: Option, + pub js_implementation: String, +} + +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AppListResponse { + /// List of installed Goose apps + pub apps: Vec, +} + +#[utoipa::path( + get, + path = "/apps", + responses( + (status = 200, description = "List of installed apps retrieved successfully", body = AppListResponse), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 500, description = "Internal server error") + ), + security( + ("api_key" = []) + ), + tag = "App Management" +)] +async fn list_apps( + State(state): State>, + headers: HeaderMap, +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; + + let clock_app = GooseApp { + name: "Clock".to_string(), + description: Some("Digital clock".to_string()), + js_implementation: r#" +class ClockWidget extends GooseWidget { + getName() { + return 'Clock'; + } + + render() { + return `
+ ${new Date().toLocaleTimeString()} +
`; + } + + onMount() { + setInterval(() => this.api.update(), 1000); + } +} +"#.to_string(), + }; + + Ok(Json(AppListResponse { + apps: vec![clock_app], + })) +} + +pub fn routes(state: Arc) -> Router { + Router::new() + .route("/list_apps", get(list_apps)) + .with_state(state) +} diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index 0c14880bde0b..468cad22d537 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -4,6 +4,7 @@ pub mod audio; pub mod config_management; pub mod context; pub mod extension; +pub mod goose_apps; pub mod health; pub mod project; pub mod recipe; @@ -12,6 +13,7 @@ pub mod schedule; pub mod session; pub mod setup; pub mod utils; + use std::sync::Arc; use axum::Router; @@ -25,6 +27,7 @@ pub fn configure(state: Arc) -> Router { .merge(audio::routes(state.clone())) .merge(context::routes(state.clone())) .merge(extension::routes(state.clone())) + .merge(goose_apps::routes(state.clone())) .merge(config_management::routes(state.clone())) .merge(recipe::routes(state.clone())) .merge(session::routes(state.clone())) diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index f032e086b789..dbc38acbfb73 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -90,6 +90,37 @@ } } }, + "/apps": { + "get": { + "tags": [ + "App Management" + ], + "operationId": "list_apps", + "responses": { + "200": { + "description": "List of installed apps retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, "/config": { "get": { "tags": [ @@ -1122,6 +1153,21 @@ } } }, + "AppListResponse": { + "type": "object", + "required": [ + "apps" + ], + "properties": { + "apps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GooseApp" + }, + "description": "List of installed Goose apps" + } + } + }, "Author": { "type": "object", "properties": { @@ -1789,6 +1835,25 @@ } } }, + "GooseApp": { + "type": "object", + "required": [ + "name", + "jsImplementation" + ], + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "jsImplementation": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "ImageContent": { "type": "object", "required": [ diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 1b08dc5b2513..5a0b41238916 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -48,6 +48,7 @@ import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/E import { Recipe } from './recipe'; import RecipesView from './components/RecipesView'; import RecipeEditor from './components/RecipeEditor'; +import AppsView from './components/apps/AppsView'; export type View = | 'welcome' @@ -66,7 +67,8 @@ export type View = | 'loading' | 'recipeEditor' | 'recipes' - | 'permission'; + | 'permission' + | 'apps'; // | 'projects'; export type ViewOptions = { @@ -150,6 +152,9 @@ const HubRouteWrapper = ({ case 'recipeEditor': navigate('/recipe-editor', { state: options }); break; + case 'apps': + navigate('/apps'); + break; case 'welcome': navigate('/welcome'); break; @@ -463,6 +468,10 @@ const RecipeEditorRoute = () => { return ; }; +const AppsRoute = () => { + return ; +}; + const PermissionRoute = () => { const location = useLocation(); const navigate = useNavigate(); @@ -973,6 +982,7 @@ export default function App() { '#/shared-session', '#/recipe-editor', '#/extensions', + '#/apps', ]; if (!validRoutes.includes(currentHash)) { @@ -1482,6 +1492,14 @@ export default function App() { } /> + + + + } + /> = ClientOptions & { @@ -36,6 +36,13 @@ export const getTools = (options?: Options }); }; +export const listApps = (options?: Options) => { + return (options?.client ?? _heyApiClient).get({ + url: '/apps', + ...options + }); +}; + export const readAllConfig = (options?: Options) => { return (options?.client ?? _heyApiClient).get({ url: '/config', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 442ac0d247f0..8b0d8943a8bb 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -16,6 +16,13 @@ export type Annotations = { timestamp?: string; }; +export type AppListResponse = { + /** + * List of installed Goose apps + */ + apps: Array; +}; + export type Author = { contact?: string | null; metadata?: string | null; @@ -253,6 +260,12 @@ export type FrontendToolRequest = { }; }; +export type GooseApp = { + description?: string | null; + jsImplementation: string; + name: string; +}; + export type ImageContent = { annotations?: Annotations | { [key: string]: unknown; @@ -822,6 +835,33 @@ export type GetToolsResponses = { export type GetToolsResponse = GetToolsResponses[keyof GetToolsResponses]; +export type ListAppsData = { + body?: never; + path?: never; + query?: never; + url: '/apps'; +}; + +export type ListAppsErrors = { + /** + * Unauthorized - Invalid or missing API key + */ + 401: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type ListAppsResponses = { + /** + * List of installed apps retrieved successfully + */ + 200: AppListResponse; +}; + +export type ListAppsResponse = ListAppsResponses[keyof ListAppsResponses]; + export type ReadAllConfigData = { body?: never; path?: never; diff --git a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx index 43edfed8951d..f7bed84c4cca 100644 --- a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx +++ b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx @@ -1,5 +1,5 @@ -import React, { useEffect } from 'react'; -import { FileText, Clock, Home, Puzzle, History } from 'lucide-react'; +import React, { useEffect, useState } from 'react'; +import { FileText, Clock, Home, Puzzle, History, AppWindow } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { SidebarContent, @@ -13,6 +13,7 @@ import { } from '../ui/sidebar'; import { ChatSmart, Gear } from '../icons'; import { ViewOptions, View } from '../../App'; +import { listApps } from '../../api'; interface SidebarProps { onSelectSession: (sessionId: string) => void; @@ -41,6 +42,13 @@ const AppSidebar: React.FC = ({ currentPath }) => { return currentPath === path; }; + const [hasApps, setHasApps] = useState(false); + useEffect(() => { + listApps() + .then((response) => setHasApps(!!response.data && response.data.apps.length > 0)) + .catch(() => {}); + }, []); + return ( <> @@ -162,6 +170,22 @@ const AppSidebar: React.FC = ({ currentPath }) => { + + {hasApps && ( +
+ + navigate('/apps')} + isActive={isActivePath('/apps')} + tooltip="Run Goose Apps" + className="w-full justify-start px-3 rounded-lg h-fit hover:bg-background-medium/50 transition-all duration-200 data-[active=true]:bg-background-medium" + > + + Goose Apps + + +
+ )} diff --git a/ui/desktop/src/components/apps/AppsView.tsx b/ui/desktop/src/components/apps/AppsView.tsx new file mode 100644 index 000000000000..b9173523343d --- /dev/null +++ b/ui/desktop/src/components/apps/AppsView.tsx @@ -0,0 +1,106 @@ +import { useState, useEffect } from 'react'; +import { MainPanelLayout } from '../Layout/MainPanelLayout'; +import { Button } from '../ui/button'; +import { Play } from 'lucide-react'; +import { listApps, GooseApp } from '../../api'; + +export default function AppsView() { + const [apps, setApps] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadApps = async () => { + try { + setLoading(true); + const response = await listApps(); + setApps(response.data?.apps || []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load apps'); + } finally { + setLoading(false); + } + }; + + loadApps(); + }, []); + + const handleLaunchApp = (app: GooseApp) => { + // TODO: Implement app launching + console.log('Launching app:', app.name); + }; + + if (loading) { + return ( + +
+
+
+
+ ); + } + + if (error) { + return ( + +
+

Error loading apps: {error}

+ +
+
+ ); + } + + return ( + +
+
+
+
+

Apps

+
+

+ Self-contained JavaScript applications that run within Goose. +

+
+
+ +
+ {apps.length === 0 ? ( +
+

No apps installed

+
+ ) : ( +
+ {apps.map((app, index) => ( +
+
+

{app.name}

+ {app.description && ( +

{app.description}

+ )} +
+ +
+ ))} +
+ )} +
+ + {/* Bottom padding space */} +
+
+ + ); +} From 7191f750a0b64ca7a51e980ae28a05c5fad993d5 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Thu, 7 Aug 2025 23:52:10 +0200 Subject: [PATCH 02/31] What --- ui/desktop/src/components/apps/AppsView.tsx | 16 +- ui/desktop/src/main.ts | 166 ++++++++++++++++++++ ui/desktop/src/preload.ts | 3 + 3 files changed, 182 insertions(+), 3 deletions(-) diff --git a/ui/desktop/src/components/apps/AppsView.tsx b/ui/desktop/src/components/apps/AppsView.tsx index b9173523343d..3d94b6b518c7 100644 --- a/ui/desktop/src/components/apps/AppsView.tsx +++ b/ui/desktop/src/components/apps/AppsView.tsx @@ -25,9 +25,19 @@ export default function AppsView() { loadApps(); }, []); - const handleLaunchApp = (app: GooseApp) => { - // TODO: Implement app launching - console.log('Launching app:', app.name); + const handleLaunchApp = async (app: GooseApp) => { + try { + console.log('Launching app:', app.name); + const result = await window.electron.launchGooseApp(app.name, app.jsImplementation); + + if (!result.success) { + console.error('Failed to launch app:', result.error); + // Could add a toast notification here in the future + } + } catch (error) { + console.error('Error launching app:', error); + // Could add a toast notification here in the future + } }; if (loading) { diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index ea9721056f6c..a52d6e0531f3 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -2036,6 +2036,172 @@ app.whenReady().then(async () => { createChat(app, query, dir, version, resumeSessionId, recipe, viewType); }); + // Handler for launching goose apps + ipcMain.handle('launch-goose-app', async (_, appName: string, jsImplementation: string) => { + console.log('Launching goose app:', appName); + + try { + // Create a new window for the goose app + const appWindow = new BrowserWindow({ + title: `Goose App - ${appName}`, + width: 1200, + height: 800, + minWidth: 800, + minHeight: 600, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + webSecurity: true, + }, + }); + + // Create the HTML content with the app injected + const htmlContent = ` + + + + + Goose App - ${appName} + + + +
+ + + +`; + + // Load the HTML content + appWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`); + + // Show the window + appWindow.show(); + + return { success: true }; + } catch (error) { + console.error('Error launching goose app:', error); + return { success: false, error: error.message }; + } + }); + ipcMain.on('notify', (_event, data) => { try { // Validate notification data diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 8de1613fce1d..679e574ba517 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -55,6 +55,7 @@ type ElectronAPI = { recipe?: Recipe, viewType?: string ) => void; + launchGooseApp: (appName: string, jsImplementation: string) => Promise<{ success: boolean; error?: string }>; logInfo: (txt: string) => void; showNotification: (data: NotificationData) => void; showMessageBox: (options: MessageBoxOptions) => Promise; @@ -150,6 +151,8 @@ const electronAPI: ElectronAPI = { viewType?: string ) => ipcRenderer.send('create-chat-window', query, dir, version, resumeSessionId, recipe, viewType), + launchGooseApp: (appName: string, jsImplementation: string) => + ipcRenderer.invoke('launch-goose-app', appName, jsImplementation), logInfo: (txt: string) => ipcRenderer.send('logInfo', txt), showNotification: (data: NotificationData) => ipcRenderer.send('notify', data), showMessageBox: (options: MessageBoxOptions) => ipcRenderer.invoke('show-message-box', options), From a63775c883bd017c3e0d359016df7a097ed52ab8 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Sun, 17 Aug 2025 11:57:24 -0400 Subject: [PATCH 03/31] Make them launchable --- ui/desktop/forge.config.ts | 24 ++- ui/desktop/src/components/apps/AppsView.tsx | 13 +- .../src/goose_apps/assets/container.html | 33 ++++ ui/desktop/src/goose_apps/assets/container.js | 157 +++++++++++++++++ .../src/goose_apps/assets/goose-widget.js | 103 +++++++++++ ui/desktop/src/goose_apps/index.ts | 35 ++++ ui/desktop/src/main.ts | 165 +----------------- 7 files changed, 342 insertions(+), 188 deletions(-) create mode 100644 ui/desktop/src/goose_apps/assets/container.html create mode 100644 ui/desktop/src/goose_apps/assets/container.js create mode 100644 ui/desktop/src/goose_apps/assets/goose-widget.js create mode 100644 ui/desktop/src/goose_apps/index.ts diff --git a/ui/desktop/forge.config.ts b/ui/desktop/forge.config.ts index 156fc183b56c..227893dbb5f0 100644 --- a/ui/desktop/forge.config.ts +++ b/ui/desktop/forge.config.ts @@ -4,9 +4,8 @@ const { resolve } = require('path'); let cfg = { asar: true, - extraResource: ['src/bin', 'src/images'], + extraResource: ['src/bin', 'src/images', 'src/goose_apps/assets'], icon: 'src/images/icon', - // Windows specific configuration win32: { icon: 'src/images/icon.ico', certificateFile: process.env.WINDOWS_CERTIFICATE_FILE, @@ -14,7 +13,6 @@ let cfg = { rfc3161TimeStampServer: 'http://timestamp.digicert.com', signWithParams: '/fd sha256 /tr http://timestamp.digicert.com /td sha256', }, - // Protocol registration protocols: [ { name: 'GooseProtocol', @@ -26,12 +24,12 @@ let cfg = { // Document types for drag-and-drop support onto dock icon CFBundleDocumentTypes: [ { - CFBundleTypeName: "Folders", - CFBundleTypeRole: "Viewer", - LSHandlerRank: "Alternate", - LSItemContentTypes: ["public.directory", "public.folder"] - } - ] + CFBundleTypeName: 'Folders', + CFBundleTypeRole: 'Viewer', + LSHandlerRank: 'Alternate', + LSItemContentTypes: ['public.directory', 'public.folder'], + }, + ], }, }; @@ -72,8 +70,8 @@ module.exports = { categories: ['Development'], mimeType: ['x-scheme-handler/goose'], options: { - icon: 'src/images/icon.png' - } + icon: 'src/images/icon.png', + }, }, }, { @@ -85,8 +83,8 @@ module.exports = { homepage: 'https://block.github.io/goose/', categories: ['Development'], options: { - icon: 'src/images/icon.png' - } + icon: 'src/images/icon.png', + }, }, }, ], diff --git a/ui/desktop/src/components/apps/AppsView.tsx b/ui/desktop/src/components/apps/AppsView.tsx index 3d94b6b518c7..37743353a525 100644 --- a/ui/desktop/src/components/apps/AppsView.tsx +++ b/ui/desktop/src/components/apps/AppsView.tsx @@ -26,18 +26,7 @@ export default function AppsView() { }, []); const handleLaunchApp = async (app: GooseApp) => { - try { - console.log('Launching app:', app.name); - const result = await window.electron.launchGooseApp(app.name, app.jsImplementation); - - if (!result.success) { - console.error('Failed to launch app:', result.error); - // Could add a toast notification here in the future - } - } catch (error) { - console.error('Error launching app:', error); - // Could add a toast notification here in the future - } + await window.electron.launchGooseApp(app.name, app.jsImplementation); }; if (loading) { diff --git a/ui/desktop/src/goose_apps/assets/container.html b/ui/desktop/src/goose_apps/assets/container.html new file mode 100644 index 000000000000..7ac120094c05 --- /dev/null +++ b/ui/desktop/src/goose_apps/assets/container.html @@ -0,0 +1,33 @@ + + + + + Goose App Container + + + +
+ + + + + \ No newline at end of file diff --git a/ui/desktop/src/goose_apps/assets/container.js b/ui/desktop/src/goose_apps/assets/container.js new file mode 100644 index 000000000000..c713f53bc6d2 --- /dev/null +++ b/ui/desktop/src/goose_apps/assets/container.js @@ -0,0 +1,157 @@ +/** + * Concrete implementation of WidgetAPI for the Goose App environment + */ +class GooseAppWidgetAPI extends window.WidgetAPI { + constructor(widget) { + super(); + this.widget = widget; + this.properties = new Map(); + } + + async setProperty(key, value) { + this.properties.set(key, value); + // In a real implementation, this might persist to storage + return true; + } + + getProperty(key, defaultValue = null) { + return this.properties.get(key) ?? defaultValue; + } + + async LLMCall(prompt) { + // Mock implementation - in real app this would call the Goose LLM + console.log('LLM Call:', prompt); + return `Mock response to: ${prompt}`; + } + + async requestGoogleClient() { + // Mock implementation - in real app this would return a Google client + throw new Error('Google client not implemented in mock environment'); + } + + update() { + if (this.widget && this.widget.element) { + const newContent = this.widget.render(); + this.widget.element.innerHTML = newContent; + // Rebind events after update + this.widget.bindEvents(); + } + } + + bindEvent(selector, eventType, handler) { + if (this.widget && this.widget.element) { + const elements = this.widget.element.querySelectorAll(selector); + elements.forEach((element) => { + element.addEventListener(eventType, handler); + }); + } + } +} + +/** + * Main app initialization and widget management + */ +class GooseAppManager { + constructor() { + this.widget = null; + this.container = null; + } + + showError(message) { + if (this.container) { + this.container.innerHTML = ` +
+

Error

+

${message}

+
+ `; + } + } + + getQueryParams() { + const params = new URLSearchParams(window.location.search); + return { + appName: params.get('appName'), + implementation: params.get('implementation'), + }; + } + + loadWidget() { + const { appName, implementation } = this.getQueryParams(); + + if (!appName || !implementation) { + this.showError('Missing app parameters'); + return; + } + + this.container = document.getElementById('app-container'); + if (!this.container) { + throw new Error('App container not found'); + } + + const jsImplementation = atob(implementation); + + // Extract class name with regex + const classMatch = jsImplementation.match(/class\s+(\w+)\s+extends\s+GooseWidget/); + if (!classMatch) { + throw new Error('No class extending GooseWidget found in implementation'); + } + + const className = classMatch[1]; + + // Execute the script and manually assign to global + const script = document.createElement('script'); + script.textContent = `${jsImplementation}\nwindow.${className} = ${className};`; + document.head.appendChild(script); + + const WidgetClass = window[className]; + if (!WidgetClass) { + throw new Error(`Class ${className} not found after script execution`); + } + + // Create the widget with its API + const api = new GooseAppWidgetAPI(); + this.widget = new WidgetClass(api); + api.widget = this.widget; // Set the widget reference in the API + + // Update document title + document.title = `Goose App - ${this.widget.getName() || appName}`; + + // Inject widget CSS if provided + const css = this.widget.css(); + if (css) { + const style = document.createElement('style'); + style.textContent = css; + document.head.appendChild(style); + } + + // Create widget element and render + this.widget.element = document.createElement('div'); + this.widget.element.className = 'widget-container'; + this.widget.element.innerHTML = this.widget.render(); + + this.container.appendChild(this.widget.element); + + // Bind events + this.widget.bindEvents(); + + // Call onMount + this.widget.onMount(); + + console.log(`Widget ${this.widget.getName()} loaded successfully`); + } +} + +let appManager; + +function initializeApp() { + appManager = new GooseAppManager(); + appManager.loadWidget(); +} + +// Handle page unload +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeApp); +} else { + initializeApp(); +} diff --git a/ui/desktop/src/goose_apps/assets/goose-widget.js b/ui/desktop/src/goose_apps/assets/goose-widget.js new file mode 100644 index 000000000000..6f565266d70b --- /dev/null +++ b/ui/desktop/src/goose_apps/assets/goose-widget.js @@ -0,0 +1,103 @@ +/** + * Abstract API interface that widgets can use to interact with the system. + */ +window.WidgetAPI = class WidgetAPI { + constructor() { + if (new.target === WidgetAPI) { + throw new TypeError('Cannot construct WidgetAPI instances directly'); + } + } + + async setProperty(key, value) { + throw new Error('setProperty must be implemented'); + } + + getProperty(key, defaultValue = null) { + throw new Error('getProperty must be implemented'); + } + + /** Call an LLM - provide a prompt and get the response as text **/ + async LLMCall(prompt) { + throw new Error('LLMCall must be implemented'); + } + + /** Get a GoogleClient for API use; calendar, drive, docs & sheets are supported */ + async requestGoogleClient() { + throw new Error('requestGoogleClient must be implemented'); + } + + /** Update the widget's DOM with new content **/ + update() { + throw new Error('update must be implemented'); + } + + /** + * Helper to bind an event listener to an element within the widget + * selector: CSS selector for the element to bind to, like .clock-settings + * eventType: The event type to listen for, like click + * handler: The function to call when the event occurs + */ + bindEvent(selector, eventType, handler) { + throw new Error('bindEvent must be implemented'); + } +}; + +/** + * Base widget class that all widgets should extend. + */ +window.GooseWidget = class GooseWidget { + constructor(api) { + if (!(api instanceof window.WidgetAPI)) { + throw new TypeError('GooseWidget must be constructed with a WidgetAPI instance'); + } + this.api = api; + this.element = null; + } + + /** + * Get the display name of the widget. Can be overridden by subclasses. + * @returns {string} The name to display in the widget header + */ + getName() { + return ''; // Default implementation returns empty string + } + + /** + * Get the default size for this widget type. Can be overridden by subclasses. + * @returns {{width: number, height: number}} The default dimensions + */ + getDefaultSize() { + return { width: 300, height: 200 }; + } + + /** + * Get CSS styles needed for this widget. All widget css lives in a shared namespace + * so to avoid conflicts, all widget css should be prefixed with the widget name. + */ + css() { + return ''; + } + + /** + * Render the widget content. Must be implemented by subclasses. + * @returns {string} HTML string for the widget content + */ + render() { + throw new Error('render() must be implemented by subclass'); + } + + /** + * Call bindEvent() for any events needed. Don't call explicitly from onMount + */ + bindEvents() {} + + /** + * Called when the widget is mounted to the DOM. + */ + onMount() {} + + /** + * Called when the widget is about to be removed + */ + onClose() {} +}; diff --git a/ui/desktop/src/goose_apps/index.ts b/ui/desktop/src/goose_apps/index.ts new file mode 100644 index 000000000000..c0633f4f4d4a --- /dev/null +++ b/ui/desktop/src/goose_apps/index.ts @@ -0,0 +1,35 @@ +import { app, BrowserWindow } from 'electron'; +import path from 'node:path'; +import { Buffer } from 'node:buffer'; + +export async function launchGooseApp(appName: string, jsImplementation: string): Promise { + console.log(`Launching Goose app: ${appName}`); + const appWindow = new BrowserWindow({ + title: `Goose App - ${appName}`, + width: 1200, + height: 800, + minWidth: 800, + minHeight: 600, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + webSecurity: true, + }, + }); + + const appHtmlPath = app.isPackaged + ? path.join(process.resourcesPath, 'assets/container.html') + : path.join(__dirname, '../../src/goose_apps/assets/container.html'); + + const encodedImplementation = Buffer.from(jsImplementation).toString('base64'); + const queryParams = new URLSearchParams({ + appName, + implementation: encodedImplementation, + }); + + await appWindow.loadFile(appHtmlPath, { + search: queryParams.toString(), + }); + + appWindow.show(); +} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 198ff7c138e1..72942489fc89 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -52,6 +52,7 @@ import { import { UPDATES_ENABLED } from './updates'; import { Recipe } from './recipe'; import './utils/recipeHash'; +import { launchGooseApp } from './goose_apps'; // API URL constructor for main process before window is ready function getApiUrlMain(endpoint: string, dynamicPort: number): string { @@ -2032,170 +2033,8 @@ app.whenReady().then(async () => { createChat(app, query, dir, version, resumeSessionId, recipe, viewType); }); - // Handler for launching goose apps ipcMain.handle('launch-goose-app', async (_, appName: string, jsImplementation: string) => { - console.log('Launching goose app:', appName); - - try { - // Create a new window for the goose app - const appWindow = new BrowserWindow({ - title: `Goose App - ${appName}`, - width: 1200, - height: 800, - minWidth: 800, - minHeight: 600, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - webSecurity: true, - }, - }); - - // Create the HTML content with the app injected - const htmlContent = ` - - - - - Goose App - ${appName} - - - -
- - - -`; - - // Load the HTML content - appWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`); - - // Show the window - appWindow.show(); - - return { success: true }; - } catch (error) { - console.error('Error launching goose app:', error); - return { success: false, error: error.message }; - } + await launchGooseApp(appName, jsImplementation); }); ipcMain.on('notify', (_event, data) => { From fd93127af8135ef454839eeb7256e8dc7d98bed4 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Mon, 18 Aug 2025 09:55:13 -0400 Subject: [PATCH 04/31] At least it compiles --- crates/goose-server/src/openapi.rs | 7 +- crates/goose-server/src/routes/goose_apps.rs | 383 ++++++++++++++++--- crates/goose/src/agents/extension_manager.rs | 9 +- crates/goose/src/config/extensions.rs | 26 +- crates/goose/src/goose_apps/app.rs | 56 +++ crates/goose/src/goose_apps/manager.rs | 84 ++++ crates/goose/src/goose_apps/mcp_server.rs | 285 ++++++++++++++ crates/goose/src/goose_apps/mod.rs | 10 + crates/goose/src/goose_apps/service.rs | 261 +++++++++++++ crates/goose/src/lib.rs | 1 + ui/desktop/openapi.json | 22 +- ui/desktop/src/api/types.gen.ts | 8 +- ui/desktop/src/components/apps/AppsView.tsx | 2 +- ui/desktop/src/goose_apps/index.ts | 18 +- ui/desktop/src/main.ts | 5 +- ui/desktop/src/preload.ts | 6 +- 16 files changed, 1100 insertions(+), 83 deletions(-) create mode 100644 crates/goose/src/goose_apps/app.rs create mode 100644 crates/goose/src/goose_apps/manager.rs create mode 100644 crates/goose/src/goose_apps/mcp_server.rs create mode 100644 crates/goose/src/goose_apps/mod.rs create mode 100644 crates/goose/src/goose_apps/service.rs diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index e1af591b7958..b5797945ea46 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -3,6 +3,7 @@ use goose::agents::extension::ToolInfo; use goose::agents::ExtensionConfig; use goose::config::permission::PermissionLevel; use goose::config::ExtensionEntry; +use goose::goose_apps::GooseApp; use goose::permission::permission_confirmation::PrincipalType; use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata}; use goose::session::info::SessionInfo; @@ -391,6 +392,10 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::encode_recipe, super::routes::recipe::decode_recipe, super::routes::goose_apps::list_apps, + super::routes::goose_apps::get_app, + super::routes::goose_apps::create_app, + super::routes::goose_apps::update_app, + super::routes::goose_apps::delete_app, ), components(schemas( super::routes::config_management::UpsertConfigQuery, @@ -407,6 +412,7 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::context::ContextManageResponse, super::routes::session::SessionListResponse, super::routes::session::SessionHistoryResponse, + GooseApp, Message, MessageContent, ContentSchema, @@ -476,7 +482,6 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::agent::GetToolsQuery, super::routes::agent::ErrorResponse, super::routes::goose_apps::AppListResponse, - super::routes::goose_apps::GooseApp )) )] pub struct ApiDoc; diff --git a/crates/goose-server/src/routes/goose_apps.rs b/crates/goose-server/src/routes/goose_apps.rs index a616f6e3d4be..652471b6e174 100644 --- a/crates/goose-server/src/routes/goose_apps.rs +++ b/crates/goose-server/src/routes/goose_apps.rs @@ -1,95 +1,370 @@ use super::utils::verify_secret_key; -use std::sync::Arc; - use crate::state::AppState; use axum::{ - extract::State, + extract::{Path, State}, http::{HeaderMap, StatusCode}, - routing::get, + routing::{delete, get, post, put}, Json, Router, }; +use goose::goose_apps::{GooseApp, GooseAppsManager}; use serde::{Deserialize, Serialize}; +use std::sync::Arc; use utoipa::ToSchema; -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Serialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct GooseApp { - pub name: String, - pub description: Option, - pub js_implementation: String, +pub struct AppListResponse { + pub apps: Vec, } #[derive(Serialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct AppListResponse { - /// List of installed Goose apps - pub apps: Vec, +pub struct AppResponse { + pub app: GooseApp, +} + +#[derive(Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateAppRequest { + pub app: GooseApp, +} + +#[derive(Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateAppRequest { + pub app: GooseApp, +} + +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SuccessResponse { + pub message: String, +} + +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ErrorResponse { + pub error: String, } #[utoipa::path( get, path = "/apps/list_apps", responses( - (status = 200, description = "List of installed apps retrieved successfully", body = AppListResponse), - (status = 401, description = "Unauthorized - Invalid or missing API key", body = ErrorResponse), - (status = 500, description = "Internal server error", body = ErrorResponse), + (status = 200, description = "List of installed apps retrieved successfully", body = AppListResponse), + (status = 401, description = "Unauthorized - Invalid or missing API key", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), ), security( - ("api_key" = []) + ("api_key" = []) ), tag = "App Management" )] async fn list_apps( State(state): State>, headers: HeaderMap, -) -> Result, StatusCode> { - verify_secret_key(&headers, &state)?; - - let clock_app = GooseApp { - name: "Clock".to_string(), - description: Some("Digital clock".to_string()), - js_implementation: r#" -class ClockWidget extends GooseWidget { - getName() { - return 'Clock'; - } - - render() { - return `
- ${new Date().toLocaleTimeString()} -
`; - } - - onMount() { - setInterval(() => this.api.update(), 1000); - } +) -> Result, (StatusCode, Json)> { + verify_secret_key(&headers, &state).map_err(|_| { + ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: "Unauthorized".to_string(), + }), + ) + })?; + + let manager = GooseAppsManager::new().map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to initialize apps manager: {}", e), + }), + ) + })?; + + let apps = manager.list_apps().map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to list apps: {}", e), + }), + ) + })?; + + Ok(Json(AppListResponse { apps })) } -"#.to_string(), - }; - Ok(Json(AppListResponse { - apps: vec![clock_app], - })) +#[utoipa::path( + get, + path = "/apps/{name}", + responses( + (status = 200, description = "App retrieved successfully", body = AppResponse), + (status = 401, description = "Unauthorized - Invalid or missing API key", body = ErrorResponse), + (status = 404, description = "App not found", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ), + params( + ("name" = String, Path, description = "Name of the app") + ), + security( + ("api_key" = []) + ), + tag = "App Management" +)] +async fn get_app( + State(state): State>, + Path(name): Path, + headers: HeaderMap, +) -> Result, (StatusCode, Json)> { + verify_secret_key(&headers, &state).map_err(|_| { + ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: "Unauthorized".to_string(), + }), + ) + })?; + + let manager = GooseAppsManager::new().map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to initialize apps manager: {}", e), + }), + ) + })?; + + let app = manager.get_app(&name).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to get app: {}", e), + }), + ) + })?; + + match app { + Some(app) => Ok(Json(AppResponse { app })), + None => Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("App '{}' not found", name), + }), + )), + } } -#[derive(Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct LaunchAppRequest { - /// Name of the app to launch - pub app_name: String, +#[utoipa::path( + post, + path = "/apps", + request_body = CreateAppRequest, + responses( + (status = 201, description = "App created successfully", body = SuccessResponse), + (status = 400, description = "Bad request - Invalid app data", body = ErrorResponse), + (status = 401, description = "Unauthorized - Invalid or missing API key", body = ErrorResponse), + (status = 409, description = "Conflict - App already exists", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ), + security( + ("api_key" = []) + ), + tag = "App Management" +)] +async fn create_app( + State(state): State>, + headers: HeaderMap, + Json(request): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + verify_secret_key(&headers, &state).map_err(|_| { + ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: "Unauthorized".to_string(), + }), + ) + })?; + + let manager = GooseAppsManager::new().map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to initialize apps manager: {}", e), + }), + ) + })?; + + // Check if app already exists + if manager.app_exists(&request.app.name) { + return Err(( + StatusCode::CONFLICT, + Json(ErrorResponse { + error: format!("App '{}' already exists", request.app.name), + }), + )); + } + + manager.update_app(&request.app).map_err(|e| { + let error_msg = e.to_string(); + if error_msg.contains("extends GooseWidget") { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: error_msg }), + ) + } else { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: error_msg }), + ) + } + })?; + + Ok(( + StatusCode::CREATED, + Json(SuccessResponse { + message: format!("App '{}' created successfully", request.app.name), + }), + )) } -#[derive(Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct LaunchAppResponse { - /// Success message - pub message: String, - /// Port on which the app is running (if applicable) - pub port: Option, +#[utoipa::path( + put, + path = "/apps/{name}", + request_body = UpdateAppRequest, + responses( + (status = 200, description = "App updated successfully", body = SuccessResponse), + (status = 400, description = "Bad request - Invalid app data", body = ErrorResponse), + (status = 401, description = "Unauthorized - Invalid or missing API key", body = ErrorResponse), + (status = 404, description = "App not found", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ), + params( + ("name" = String, Path, description = "Name of the app to update") + ), + security( + ("api_key" = []) + ), + tag = "App Management" +)] +async fn update_app( + State(state): State>, + Path(name): Path, + headers: HeaderMap, + Json(request): Json, +) -> Result, (StatusCode, Json)> { + verify_secret_key(&headers, &state).map_err(|_| { + ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: "Unauthorized".to_string(), + }), + ) + })?; + + let manager = GooseAppsManager::new().map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to initialize apps manager: {}", e), + }), + ) + })?; + + // Check if app exists + if !manager.app_exists(&name) { + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("App '{}' not found", name), + }), + )); + } + + manager.update_app(&request.app).map_err(|e| { + let error_msg = e.to_string(); + if error_msg.contains("extends GooseWidget") { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: error_msg }), + ) + } else { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: error_msg }), + ) + } + })?; + + Ok(Json(SuccessResponse { + message: format!("App '{}' updated successfully", name), + })) +} + +#[utoipa::path( + delete, + path = "/apps/{name}", + responses( + (status = 200, description = "App deleted successfully", body = SuccessResponse), + (status = 401, description = "Unauthorized - Invalid or missing API key", body = ErrorResponse), + (status = 404, description = "App not found", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ), + params( + ("name" = String, Path, description = "Name of the app to delete") + ), + security( + ("api_key" = []) + ), + tag = "App Management" +)] +async fn delete_app( + State(state): State>, + Path(name): Path, + headers: HeaderMap, +) -> Result, (StatusCode, Json)> { + verify_secret_key(&headers, &state).map_err(|_| { + ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: "Unauthorized".to_string(), + }), + ) + })?; + + let manager = GooseAppsManager::new().map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to initialize apps manager: {}", e), + }), + ) + })?; + + manager.delete_app(&name).map_err(|e| { + let error_msg = e.to_string(); + if error_msg.contains("not found") { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { error: error_msg }), + ) + } else { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: error_msg }), + ) + } + })?; + + Ok(Json(SuccessResponse { + message: format!("App '{}' deleted successfully", name), + })) } pub fn routes(state: Arc) -> Router { Router::new() .route("/apps/list_apps", get(list_apps)) + .route("/apps/{name}", get(get_app)) + .route("/apps", post(create_app)) + .route("/apps/{name}", put(update_app)) + .route("/apps/{name}", delete(delete_app)) .with_state(state) } diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index c8a2b671a820..532ac1d6b325 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -371,7 +371,14 @@ impl ExtensionManager { Box::new(client) } - _ => unreachable!(), + ExtensionConfig::Frontend { .. } => { + // For frontend extensions (including goose_apps), use the GooseAppsClient directly + use crate::goose_apps::GooseAppsClient; + Box::new( + GooseAppsClient::new() + .map_err(|e| ExtensionError::SetupError(e.to_string()))?, + ) + } //_ => unreachable!(), }; let info = client.get_info(); diff --git a/crates/goose/src/config/extensions.rs b/crates/goose/src/config/extensions.rs index 7a075714c5e4..3d89cce25f17 100644 --- a/crates/goose/src/config/extensions.rs +++ b/crates/goose/src/config/extensions.rs @@ -36,9 +36,12 @@ impl ExtensionConfigManager { let extensions: HashMap = match config.get_param("extensions") { Ok(exts) => exts, Err(super::ConfigError::NotFound(_)) => { - // Initialize with default developer extension - let defaults = HashMap::from([( - name_to_key(DEFAULT_EXTENSION), // Use key format for top-level key in config + // Initialize with default developer extension and goose_apps + let mut defaults = HashMap::new(); + + // Default developer extension + defaults.insert( + name_to_key(DEFAULT_EXTENSION), ExtensionEntry { enabled: true, config: ExtensionConfig::Builtin { @@ -49,7 +52,22 @@ impl ExtensionConfigManager { description: Some(DEFAULT_EXTENSION_DESCRIPTION.to_string()), }, }, - )]); + ); + + // Default goose_apps extension - DISABLED by default + defaults.insert( + name_to_key("goose_apps"), + ExtensionEntry { + enabled: false, // Disabled by default + config: ExtensionConfig::Frontend { + name: "goose_apps".to_string(), + tools: vec![], // Will be populated when loaded + instructions: Some("Manage Goose Apps - create, update, list JavaScript apps that extend Goose functionality.".to_string()), + bundled: Some(true), + }, + }, + ); + config.set_param("extensions", serde_json::to_value(&defaults)?)?; defaults } diff --git a/crates/goose/src/goose_apps/app.rs b/crates/goose/src/goose_apps/app.rs new file mode 100644 index 000000000000..14075430edff --- /dev/null +++ b/crates/goose/src/goose_apps/app.rs @@ -0,0 +1,56 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct GooseApp { + pub name: String, + pub description: Option, + pub width: Option, + pub height: Option, + pub resizable: Option, + // we leave the implemenation as default to "" so we can manipulate where it lands + // in the file when reading/writing + #[serde(default)] + pub js_implementation: String, +} + +impl GooseApp { + // goose aps are stored in frontmatter format, with the delimiter being "---" + // name: GooseApp + // description: Optional description of the app + // --- + // JavaScript implementation of the app + const FRONTMATTER_DELIMITER: &'static str = "\n---\n"; + + pub fn from_file>(path: P) -> Result { + let content = fs::read_to_string(path)?; + let parts: Vec<&str> = content.splitn(2, Self::FRONTMATTER_DELIMITER).collect(); + + if parts.len() != 2 { + return Err(anyhow::anyhow!( + "Invalid app file format - missing frontmatter delimiter" + )); + } + + let mut app: GooseApp = serde_yaml::from_str(parts[0])?; + app.js_implementation = parts[1].to_string(); + + Ok(app) + } + + pub fn to_file_content(&self) -> Result { + let mut metadata = self.clone(); + metadata.js_implementation = String::new(); + let yaml_content = serde_yaml::to_string(&metadata)?; + Ok(format!( + "{}{}{}", + yaml_content, + Self::FRONTMATTER_DELIMITER, + self.js_implementation + )) + } +} diff --git a/crates/goose/src/goose_apps/manager.rs b/crates/goose/src/goose_apps/manager.rs new file mode 100644 index 000000000000..8c60df2cb15e --- /dev/null +++ b/crates/goose/src/goose_apps/manager.rs @@ -0,0 +1,84 @@ +use crate::config::APP_STRATEGY; +use crate::goose_apps::GooseApp; +use anyhow::Result; +use etcetera::{choose_app_strategy, AppStrategy}; +use std::fs; +use std::path::PathBuf; + +pub struct GooseAppsManager { + apps_dir: PathBuf, +} + +impl GooseAppsManager { + pub fn new() -> Result { + let config_dir = choose_app_strategy(APP_STRATEGY.clone()) + .expect("goose requires a home dir") + .data_dir(); + + let apps_dir = config_dir.join("apps"); + + Ok(Self { apps_dir }) + } + + pub fn list_apps(&self) -> Result> { + let mut apps = Vec::new(); + + if !self.apps_dir.exists() { + return Ok(apps); + } + + for entry in fs::read_dir(&self.apps_dir)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) == Some("gapp") { + match GooseApp::from_file(&path) { + Ok(app) => apps.push(app), + Err(e) => eprintln!("Failed to load app from {:?}: {}", path, e), + } + } + } + + Ok(apps) + } + + pub fn get_app(&self, name: &str) -> Result> { + let app_path = self.apps_dir.join(format!("{}.gapp", name)); + + if !app_path.exists() { + return Ok(None); + } + + Ok(Some(GooseApp::from_file(app_path)?)) + } + + pub fn update_app(&self, app: &GooseApp) -> Result<()> { + if !app.js_implementation.contains("extends GooseWidget") { + return Err(anyhow::anyhow!( + "Implementation must contain a class extending GooseWidget" + )); + } + + let app_path = self.apps_dir.join(format!("{}.gapp", app.name)); + + let file_content = app.to_file_content()?; + fs::write(app_path, file_content)?; + + Ok(()) + } + + pub fn delete_app(&self, name: &str) -> Result<()> { + let app_path = self.apps_dir.join(format!("{}.gapp", name)); + + if !app_path.exists() { + return Err(anyhow::anyhow!("App '{}' not found", name)); + } + + fs::remove_file(app_path)?; + Ok(()) + } + + pub fn app_exists(&self, name: &str) -> bool { + self.apps_dir.join(format!("{}.gapp", name)).exists() + } +} diff --git a/crates/goose/src/goose_apps/mcp_server.rs b/crates/goose/src/goose_apps/mcp_server.rs new file mode 100644 index 000000000000..f2d5a5a9cb0b --- /dev/null +++ b/crates/goose/src/goose_apps/mcp_server.rs @@ -0,0 +1,285 @@ +use anyhow::Result; +use async_trait::async_trait; +use mcp_client::client::{Error, McpClientTrait}; +use rmcp::model::{ + CallToolResult, Content, GetPromptResult, Implementation, InitializeResult, ListPromptsResult, + ListResourcesResult, ListToolsResult, ProtocolVersion, ReadResourceResult, ServerCapabilities, + ServerNotification, Tool, ToolsCapability, +}; +use serde_json::{json, Map, Value}; +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +use super::service::{goose_app_from_json, GooseAppUpdates, GooseAppsService}; + +pub struct GooseAppsClient { + service: GooseAppsService, + info: InitializeResult, +} + +impl GooseAppsClient { + pub fn new() -> Result { + let service = GooseAppsService::new()?; + + let info = InitializeResult { + protocol_version: ProtocolVersion::V_2025_03_26, + capabilities: ServerCapabilities { + tools: Some(ToolsCapability { + list_changed: Some(false), + }), + resources: None, + prompts: None, + completions: None, + experimental: None, + logging: None, + }, + server_info: Implementation { + name: "goose-apps".to_string(), + version: "1.0.0".to_string(), + }, + instructions: Some("Manage Goose Apps - create, update, list JavaScript apps that extend Goose functionality.".to_string()), + }; + + Ok(Self { service, info }) + } + + async fn handle_create_app(&self, arguments: Value) -> Result, String> { + let app = goose_app_from_json(&arguments).map_err(|e| e.to_string())?; + let result = self + .service + .create_app(&app) + .await + .map_err(|e| e.to_string())?; + Ok(vec![Content::text(result)]) + } + + async fn handle_update_app(&self, arguments: Value) -> Result, String> { + let name = arguments + .get("name") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: name")?; + + let updates = GooseAppUpdates::from_json(&arguments); + let result = self + .service + .update_app(name, &updates) + .await + .map_err(|e| e.to_string())?; + Ok(vec![Content::text(result)]) + } + + async fn handle_list_apps(&self) -> Result, String> { + let apps = self.service.list_apps().await.map_err(|e| e.to_string())?; + let formatted = GooseAppsService::format_app_list(&apps); + Ok(vec![Content::text(formatted)]) + } + + async fn handle_get_app(&self, arguments: Value) -> Result, String> { + let name = arguments + .get("name") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: name")?; + + let app = self + .service + .get_app(name) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("App '{}' not found", name))?; + + let formatted = GooseAppsService::format_app_details(&app); + Ok(vec![Content::text(formatted)]) + } + + fn get_tools() -> Vec { + fn create_schema(json_value: Value) -> Arc> { + Arc::new(json_value.as_object().unwrap().clone()) + } + + vec![ + Tool { + name: "create_goose_app".into(), + description: Some("Create a new Goose App with JavaScript implementation".into()), + input_schema: create_schema(json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the Goose App" + }, + "js_implementation": { + "type": "string", + "description": "JavaScript implementation containing a class extending GooseWidget" + }, + "description": { + "type": "string", + "description": "Optional description of the app" + }, + "width": { + "type": "integer", + "description": "Optional window width in pixels" + }, + "height": { + "type": "integer", + "description": "Optional window height in pixels" + }, + "resizable": { + "type": "boolean", + "description": "Whether the window should be resizable" + } + }, + "required": ["name", "js_implementation"] + })), + annotations: None, + output_schema: None, + }, + Tool { + name: "update_goose_app".into(), + description: Some("Update an existing Goose App".into()), + input_schema: create_schema(json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the Goose App to update" + }, + "js_implementation": { + "type": "string", + "description": "New JavaScript implementation containing a class extending GooseWidget" + }, + "description": { + "type": "string", + "description": "Updated description of the app" + }, + "width": { + "type": "integer", + "description": "Updated window width in pixels" + }, + "height": { + "type": "integer", + "description": "Updated window height in pixels" + }, + "resizable": { + "type": "boolean", + "description": "Whether the window should be resizable" + } + }, + "required": ["name"] + })), + annotations: None, + output_schema: None, + }, + Tool { + name: "list_goose_apps".into(), + description: Some("List all available Goose Apps".into()), + input_schema: create_schema(json!({ + "type": "object", + "properties": {}, + "required": [] + })), + annotations: None, + output_schema: None, + }, + Tool { + name: "get_goose_app".into(), + description: Some("Get detailed information about a specific Goose App including its implementation".into()), + input_schema: create_schema(json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the Goose App" + } + }, + "required": ["name"] + })), + annotations: None, + output_schema: None, + } + ] + } +} + +#[async_trait] +impl McpClientTrait for GooseAppsClient { + fn get_info(&self) -> Option<&InitializeResult> { + Some(&self.info) + } + + async fn list_tools( + &self, + _next_cursor: Option, + _cancellation_token: CancellationToken, + ) -> Result { + Ok(ListToolsResult { + tools: Self::get_tools(), + next_cursor: None, + }) + } + + async fn call_tool( + &self, + name: &str, + arguments: Value, + _cancellation_token: CancellationToken, + ) -> Result { + let content = match name { + "create_goose_app" => self.handle_create_app(arguments).await, + "update_goose_app" => self.handle_update_app(arguments).await, + "list_goose_apps" => self.handle_list_apps().await, + "get_goose_app" => self.handle_get_app(arguments).await, + _ => Err(format!("Unknown tool: {}", name)), + }; + + match content { + Ok(content) => Ok(CallToolResult { + content: Some(content), + is_error: None, + structured_content: None, + }), + Err(error) => Ok(CallToolResult { + content: Some(vec![Content::text(format!("Error: {}", error))]), + is_error: Some(true), + structured_content: None, + }), + } + } + + async fn list_resources( + &self, + _next_cursor: Option, + _cancellation_token: CancellationToken, + ) -> Result { + Err(Error::TransportClosed) + } + + async fn read_resource( + &self, + _uri: &str, + _cancellation_token: CancellationToken, + ) -> Result { + Err(Error::TransportClosed) + } + + async fn list_prompts( + &self, + _next_cursor: Option, + _cancellation_token: CancellationToken, + ) -> Result { + Err(Error::TransportClosed) + } + + async fn get_prompt( + &self, + _name: &str, + _arguments: Value, + _cancellation_token: CancellationToken, + ) -> Result { + Err(Error::TransportClosed) + } + + async fn subscribe(&self) -> mpsc::Receiver { + mpsc::channel(1).1 + } +} diff --git a/crates/goose/src/goose_apps/mod.rs b/crates/goose/src/goose_apps/mod.rs new file mode 100644 index 000000000000..c2888d59e268 --- /dev/null +++ b/crates/goose/src/goose_apps/mod.rs @@ -0,0 +1,10 @@ +//! Goose Apps - JavaScript apps management system +pub mod app; +pub mod manager; +pub mod mcp_server; +pub mod service; + +pub use app::GooseApp; +pub use manager::GooseAppsManager; +pub use mcp_server::GooseAppsClient; +pub use service::{goose_app_from_json, GooseAppUpdates, GooseAppsError, GooseAppsService}; diff --git a/crates/goose/src/goose_apps/service.rs b/crates/goose/src/goose_apps/service.rs new file mode 100644 index 000000000000..e87a11691adf --- /dev/null +++ b/crates/goose/src/goose_apps/service.rs @@ -0,0 +1,261 @@ +use super::manager::GooseAppsManager; +use crate::goose_apps::GooseApp; +use anyhow::Result; +use serde_json::{json, Value}; +use std::sync::Arc; +use tokio::sync::Mutex; // Updated import + +/// Shared service layer for Goose Apps operations +/// Used by both HTTP routes and MCP client +pub struct GooseAppsService { + manager: Arc>, +} + +#[derive(Debug)] +pub enum GooseAppsError { + NotFound(String), + AlreadyExists(String), + InvalidImplementation(String), + InvalidParameter(String), + Internal(String), +} + +impl std::fmt::Display for GooseAppsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GooseAppsError::NotFound(msg) => write!(f, "Not found: {}", msg), + GooseAppsError::AlreadyExists(msg) => write!(f, "Already exists: {}", msg), + GooseAppsError::InvalidImplementation(msg) => { + write!(f, "Invalid implementation: {}", msg) + } + GooseAppsError::InvalidParameter(msg) => write!(f, "Invalid parameter: {}", msg), + GooseAppsError::Internal(msg) => write!(f, "Internal error: {}", msg), + } + } +} + +impl std::error::Error for GooseAppsError {} + +impl From for GooseAppsError { + fn from(err: anyhow::Error) -> Self { + let err_str = err.to_string(); + if err_str.contains("not found") { + GooseAppsError::NotFound(err_str) + } else if err_str.contains("already exists") { + GooseAppsError::AlreadyExists(err_str) + } else if err_str.contains("extends GooseWidget") { + GooseAppsError::InvalidImplementation(err_str) + } else { + GooseAppsError::Internal(err_str) + } + } +} + +impl GooseAppsService { + pub fn new() -> Result { + let manager = GooseAppsManager::new()?; + Ok(Self { + manager: Arc::new(Mutex::new(manager)), + }) + } + + pub async fn create_app(&self, app: &GooseApp) -> Result { + let manager = self.manager.lock().await; + manager.update_app(app)?; + Ok(format!("Successfully created Goose App: {}", app.name)) + } + + pub async fn update_app( + &self, + name: &str, + updates: &GooseAppUpdates, + ) -> Result { + let manager = self.manager.lock().await; + + // Get existing app to preserve fields not being updated + let mut existing_app = manager + .get_app(name)? + .ok_or_else(|| GooseAppsError::NotFound(format!("App '{}' not found", name)))?; + + // Apply updates + updates.apply_to(&mut existing_app); + + manager.update_app(&existing_app)?; + Ok(format!("Successfully updated Goose App: {}", name)) + } + + pub async fn list_apps(&self) -> Result, GooseAppsError> { + let manager = self.manager.lock().await; + Ok(manager.list_apps()?) + } + + pub async fn get_app(&self, name: &str) -> Result, GooseAppsError> { + let manager = self.manager.lock().await; + Ok(manager.get_app(name)?) + } + + pub async fn delete_app(&self, name: &str) -> Result { + let manager = self.manager.lock().await; + manager.delete_app(name)?; + Ok(format!("Successfully deleted Goose App: {}", name)) + } + + pub async fn app_exists(&self, name: &str) -> bool { + let manager = self.manager.lock().await; + manager.app_exists(name) + } + + // Formatting helpers + pub fn format_app_list(apps: &[GooseApp]) -> String { + if apps.is_empty() { + return "No Goose Apps found".to_string(); + } + + let mut result = vec!["Available Goose Apps:".to_string()]; + + for app in apps.iter() { + let description = app + .description + .as_ref() + .map(|d| format!(" - {}", d)) + .unwrap_or_default(); + + let dimensions = match (app.width, app.height) { + (Some(w), Some(h)) => format!(" ({}x{})", w, h), + _ => String::new(), + }; + + result.push(format!("• {}{}{}", app.name, dimensions, description)); + } + + result.join("\n") + } + + pub fn format_app_details(app: &GooseApp) -> String { + let info = json!({ + "name": app.name, + "description": app.description, + "width": app.width, + "height": app.height, + "resizable": app.resizable, + "js_implementation": app.js_implementation + }); + + serde_json::to_string_pretty(&info).unwrap() + } +} + +/// Struct for partial updates to avoid duplication in update logic +#[derive(Debug, Default)] +pub struct GooseAppUpdates { + pub js_implementation: Option, + pub description: Option, + pub width: Option, + pub height: Option, + pub resizable: Option, +} + +impl GooseAppUpdates { + pub fn from_json(arguments: &Value) -> Self { + Self { + js_implementation: arguments + .get("js_implementation") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + description: arguments + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + width: arguments + .get("width") + .and_then(|v| v.as_u64()) + .map(|v| v as u32), + height: arguments + .get("height") + .and_then(|v| v.as_u64()) + .map(|v| v as u32), + resizable: arguments.get("resizable").and_then(|v| v.as_bool()), + } + } + + pub fn from_request_fields( + js_implementation: Option, + description: Option, + width: Option, + height: Option, + resizable: Option, + ) -> Self { + Self { + js_implementation, + description, + width, + height, + resizable, + } + } + + fn apply_to(&self, app: &mut GooseApp) { + if let Some(ref js_implementation) = self.js_implementation { + app.js_implementation = js_implementation.clone(); + } + if let Some(ref description) = self.description { + app.description = Some(description.clone()); + } + if let Some(width) = self.width { + app.width = Some(width); + } + if let Some(height) = self.height { + app.height = Some(height); + } + if let Some(resizable) = self.resizable { + app.resizable = Some(resizable); + } + } +} + +/// Helper to extract GooseApp from JSON arguments +pub fn goose_app_from_json(arguments: &Value) -> Result { + let name = arguments + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + GooseAppsError::InvalidParameter("Missing required parameter: name".to_string()) + })? + .to_string(); + + let js_implementation = arguments + .get("js_implementation") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + GooseAppsError::InvalidParameter( + "Missing required parameter: js_implementation".to_string(), + ) + })? + .to_string(); + + let description = arguments + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let width = arguments + .get("width") + .and_then(|v| v.as_u64()) + .map(|v| v as u32); + + let height = arguments + .get("height") + .and_then(|v| v.as_u64()) + .map(|v| v as u32); + + let resizable = arguments.get("resizable").and_then(|v| v.as_bool()); + + Ok(GooseApp { + name, + description, + width, + height, + resizable, + js_implementation, + }) +} diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index d0046941f57a..4983e4579160 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -23,3 +23,4 @@ pub mod utils; mod cron_test; #[macro_use] mod macros; +pub mod goose_apps; diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 69ccb1075937..b585060b8b8d 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1319,8 +1319,7 @@ "type": "array", "items": { "$ref": "#/components/schemas/GooseApp" - }, - "description": "List of installed Goose apps" + } } } }, @@ -2046,19 +2045,34 @@ "GooseApp": { "type": "object", "required": [ - "name", - "jsImplementation" + "name" ], "properties": { "description": { "type": "string", "nullable": true }, + "height": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, "jsImplementation": { "type": "string" }, "name": { "type": "string" + }, + "resizable": { + "type": "boolean", + "nullable": true + }, + "width": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 } } }, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index f19b2f749456..500cb2275d9d 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -17,9 +17,6 @@ export type Annotations = { }; export type AppListResponse = { - /** - * List of installed Goose apps - */ apps: Array; }; @@ -298,8 +295,11 @@ export type GetToolsQuery = { export type GooseApp = { description?: string | null; - jsImplementation: string; + height?: number | null; + jsImplementation?: string; name: string; + resizable?: boolean | null; + width?: number | null; }; export type ImageContent = { diff --git a/ui/desktop/src/components/apps/AppsView.tsx b/ui/desktop/src/components/apps/AppsView.tsx index 37743353a525..a3069e131f32 100644 --- a/ui/desktop/src/components/apps/AppsView.tsx +++ b/ui/desktop/src/components/apps/AppsView.tsx @@ -26,7 +26,7 @@ export default function AppsView() { }, []); const handleLaunchApp = async (app: GooseApp) => { - await window.electron.launchGooseApp(app.name, app.jsImplementation); + await window.electron.launchGooseApp(app); }; if (loading) { diff --git a/ui/desktop/src/goose_apps/index.ts b/ui/desktop/src/goose_apps/index.ts index c0633f4f4d4a..beb20ef081a7 100644 --- a/ui/desktop/src/goose_apps/index.ts +++ b/ui/desktop/src/goose_apps/index.ts @@ -1,15 +1,15 @@ import { app, BrowserWindow } from 'electron'; import path from 'node:path'; import { Buffer } from 'node:buffer'; +import { GooseApp } from '../api'; -export async function launchGooseApp(appName: string, jsImplementation: string): Promise { - console.log(`Launching Goose app: ${appName}`); +export async function launchGooseApp(gapp: GooseApp): Promise { + console.log(`Launching Goose app: ${gapp.name}`); const appWindow = new BrowserWindow({ - title: `Goose App - ${appName}`, - width: 1200, - height: 800, - minWidth: 800, - minHeight: 600, + title: gapp.name, + width: gapp.width || 800, + height: gapp.height || 600, + resizable: gapp.resizable ?? true, webPreferences: { nodeIntegration: false, contextIsolation: true, @@ -21,9 +21,9 @@ export async function launchGooseApp(appName: string, jsImplementation: string): ? path.join(process.resourcesPath, 'assets/container.html') : path.join(__dirname, '../../src/goose_apps/assets/container.html'); - const encodedImplementation = Buffer.from(jsImplementation).toString('base64'); + const encodedImplementation = Buffer.from(gapp.jsImplementation!).toString('base64'); const queryParams = new URLSearchParams({ - appName, + appName: gapp.name, implementation: encodedImplementation, }); diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 72942489fc89..0cb67ac21b31 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -53,6 +53,7 @@ import { UPDATES_ENABLED } from './updates'; import { Recipe } from './recipe'; import './utils/recipeHash'; import { launchGooseApp } from './goose_apps'; +import { GooseApp } from './api'; // API URL constructor for main process before window is ready function getApiUrlMain(endpoint: string, dynamicPort: number): string { @@ -2033,8 +2034,8 @@ app.whenReady().then(async () => { createChat(app, query, dir, version, resumeSessionId, recipe, viewType); }); - ipcMain.handle('launch-goose-app', async (_, appName: string, jsImplementation: string) => { - await launchGooseApp(appName, jsImplementation); + ipcMain.handle('launch-goose-app', async (_, app: GooseApp) => { + await launchGooseApp(app); }); ipcMain.on('notify', (_event, data) => { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 679e574ba517..72f0d410f148 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -1,5 +1,6 @@ import Electron, { contextBridge, ipcRenderer, webUtils } from 'electron'; import { Recipe } from './recipe'; +import { GooseApp } from './api'; interface NotificationData { title: string; @@ -55,7 +56,7 @@ type ElectronAPI = { recipe?: Recipe, viewType?: string ) => void; - launchGooseApp: (appName: string, jsImplementation: string) => Promise<{ success: boolean; error?: string }>; + launchGooseApp: (app: GooseApp) => Promise<{ success: boolean; error?: string }>; logInfo: (txt: string) => void; showNotification: (data: NotificationData) => void; showMessageBox: (options: MessageBoxOptions) => Promise; @@ -151,8 +152,7 @@ const electronAPI: ElectronAPI = { viewType?: string ) => ipcRenderer.send('create-chat-window', query, dir, version, resumeSessionId, recipe, viewType), - launchGooseApp: (appName: string, jsImplementation: string) => - ipcRenderer.invoke('launch-goose-app', appName, jsImplementation), + launchGooseApp: (app: GooseApp) => ipcRenderer.invoke('launch-goose-app', app), logInfo: (txt: string) => ipcRenderer.send('logInfo', txt), showNotification: (data: NotificationData) => ipcRenderer.send('notify', data), showMessageBox: (options: MessageBoxOptions) => ipcRenderer.invoke('show-message-box', options), From 2d34098bed6ac44c5ecd8a47035bd94382a990bf Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Sat, 27 Sep 2025 09:00:17 -0400 Subject: [PATCH 05/31] Throw --- ui/desktop/src/components/apps/AppsView.tsx | 2 +- ui/desktop/src/goose_apps/index.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/desktop/src/components/apps/AppsView.tsx b/ui/desktop/src/components/apps/AppsView.tsx index a3069e131f32..35f8d6942516 100644 --- a/ui/desktop/src/components/apps/AppsView.tsx +++ b/ui/desktop/src/components/apps/AppsView.tsx @@ -13,7 +13,7 @@ export default function AppsView() { const loadApps = async () => { try { setLoading(true); - const response = await listApps(); + const response = await listApps({ throwOnError: true }); setApps(response.data?.apps || []); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load apps'); diff --git a/ui/desktop/src/goose_apps/index.ts b/ui/desktop/src/goose_apps/index.ts index beb20ef081a7..21bc20c251a7 100644 --- a/ui/desktop/src/goose_apps/index.ts +++ b/ui/desktop/src/goose_apps/index.ts @@ -4,7 +4,6 @@ import { Buffer } from 'node:buffer'; import { GooseApp } from '../api'; export async function launchGooseApp(gapp: GooseApp): Promise { - console.log(`Launching Goose app: ${gapp.name}`); const appWindow = new BrowserWindow({ title: gapp.name, width: gapp.width || 800, From 9266aca2475c36e1a8a0ec31b02950f1d102c90e Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Sat, 27 Sep 2025 09:21:30 -0400 Subject: [PATCH 06/31] One step --- ui/desktop/src/utils/navigationUtils.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/utils/navigationUtils.ts b/ui/desktop/src/utils/navigationUtils.ts index 7ab04ce1f58a..0d90396dc66c 100644 --- a/ui/desktop/src/utils/navigationUtils.ts +++ b/ui/desktop/src/utils/navigationUtils.ts @@ -17,7 +17,8 @@ export type View = | 'loading' | 'recipeEditor' | 'recipes' - | 'permission'; + | 'permission' + | 'apps'; // TODO(Douwe): check these for usage, especially key: string for resetChat export type ViewOptions = { @@ -75,6 +76,9 @@ export const createNavigationHandler = (navigate: NavigateFunction) => { case 'extensions': navigate('/extensions', { state: options }); break; + case 'apps': + navigate('/apps'); + break; default: navigate('/', { state: options }); } From 9922462e7a215638f9b1ce72acab348a735f4346 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Sat, 27 Sep 2025 12:56:49 -0400 Subject: [PATCH 07/31] Spawn a recipe --- ui/desktop/src/App.tsx | 3 + .../src/components/apps/GooseAppsView.tsx | 148 ++++++++++++++++++ ui/desktop/src/hooks/useRecipeManager.ts | 4 +- ui/desktop/src/recipe/index.ts | 1 + 4 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 ui/desktop/src/components/apps/GooseAppsView.tsx diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 12aa2fe37e32..64a151bf567f 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -44,6 +44,7 @@ import { NoProviderOrModelError, useAgent, } from './hooks/useAgent'; +import GooseAppsView from './components/apps/GooseAppsView'; // Route Components const HubRouteWrapper = ({ @@ -587,6 +588,8 @@ export function AppInner() { } /> } /> } /> + } /> + { + return ( +
+ {children} +
+ ); +}; + +const AddAppCard = ({ onClick }: { onClick: () => void }) => { + return ( +
+
+ +
+
Add App
+
+
+
+ ); +}; + +export default function GooseAppsView() { + const [apps, setApps] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const recipe = (for_app: GooseApp | null): Recipe => { + return { + description: '', + title: for_app ? `update ${for_app.name} app` : 'Create goose app', + internal: true, + }; + }; + + useEffect(() => { + const loadApps = async () => { + try { + setLoading(true); + const response = await listApps({ throwOnError: true }); + setApps(response.data?.apps || []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load apps'); + } finally { + setLoading(false); + } + }; + + loadApps(); + }, []); + + const handleLaunchApp = async (app: GooseApp) => { + await window.electron.launchGooseApp(app); + }; + + const handleAddApp = () => { + window.electron.createChatWindow(undefined, undefined, undefined, undefined, recipe(null)); + }; + + if (loading) { + return ( + +
+
+
+
+ ); + } + + if (error) { + return ( + +
+

Error loading apps: {error}

+ +
+
+ ); + } + + return ( + +
+
+
+
+

Apps

+
+

+ Self-contained JavaScript applications that run within Goose. +

+
+
+ +
+ {apps.length === 0 ? ( + + + + ) : ( + + {apps.map((app, index) => ( +
+
+

{app.name}

+ {app.description && ( +

{app.description}

+ )} +
+ +
+ ))} + +
+ )} +
+ +
+
+ + ); +} diff --git a/ui/desktop/src/hooks/useRecipeManager.ts b/ui/desktop/src/hooks/useRecipeManager.ts index ce182d383f65..33e355d78770 100644 --- a/ui/desktop/src/hooks/useRecipeManager.ts +++ b/ui/desktop/src/hooks/useRecipeManager.ts @@ -53,7 +53,9 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) = const checkRecipeAcceptance = async () => { if (finalRecipeConfig) { try { - const hasAccepted = await window.electron.hasAcceptedRecipeBefore(finalRecipeConfig); + const hasAccepted = + finalRecipeConfig.internal || + (await window.electron.hasAcceptedRecipeBefore(finalRecipeConfig)); if (!hasAccepted) { const securityScanResult = await scanRecipe(finalRecipeConfig); diff --git a/ui/desktop/src/recipe/index.ts b/ui/desktop/src/recipe/index.ts index b3004bb543c3..a8903e0d2e3d 100644 --- a/ui/desktop/src/recipe/index.ts +++ b/ui/desktop/src/recipe/index.ts @@ -17,6 +17,7 @@ import type { Message as FrontendMessage } from '../types/message'; // Re-export OpenAPI types with frontend-specific additions export type Parameter = RecipeParameter; export type Recipe = import('../api').Recipe & { + internal?: boolean; // TODO: Separate these from the raw recipe type // Properties added for scheduled execution scheduledJobId?: string; From b8c55ee0f2f01b5f8247fb74d8e713be2bef37fb Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Sat, 27 Sep 2025 16:14:45 -0400 Subject: [PATCH 08/31] Extension support -ish --- crates/goose-server/build.rs | 2 ++ crates/goose/src/config/extensions.rs | 27 ++++++++++++++++++++--- crates/goose/src/goose_apps/mcp_server.rs | 3 +++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/goose-server/build.rs b/crates/goose-server/build.rs index 23a0fa399a66..442f575164ba 100644 --- a/crates/goose-server/build.rs +++ b/crates/goose-server/build.rs @@ -1,3 +1,5 @@ +mod todo_extension; + // We'll generate the schema at runtime since we need access to the complete application context fn main() { println!("cargo:rerun-if-changed=src/"); diff --git a/crates/goose/src/config/extensions.rs b/crates/goose/src/config/extensions.rs index 3019f81ca537..b964beee95e3 100644 --- a/crates/goose/src/config/extensions.rs +++ b/crates/goose/src/config/extensions.rs @@ -1,5 +1,6 @@ use super::base::Config; use crate::agents::ExtensionConfig; +use crate::goose_apps; use anyhow::Result; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -29,10 +30,30 @@ pub struct ExtensionConfigManager; impl ExtensionConfigManager { fn get_extensions_map() -> Result> { - let config = Config::global(); - Ok(config + let mut extensions_map = Config::global() .get_param(EXTENSIONS_CONFIG_KEY) - .unwrap_or_else(|_| HashMap::new())) + .unwrap_or_else(|_| HashMap::new()); + + // undo this hack when we have the infra (Douwe): + if !extensions_map.contains_key(goose_apps::mcp_server::EXTENSION_NAME) { + extensions_map.insert( + goose_apps::mcp_server::EXTENSION_NAME.to_string(), + ExtensionEntry { + config: ExtensionConfig::Builtin { + name: goose_apps::mcp_server::EXTENSION_NAME.to_string(), + display_name: Some("Goose Apps".to_string()), + description: Some( + "Create and edit goose apps through the goose chat interface and share with your friends".to_string()), + timeout: Some(300), + bundled: Some(true), + available_tools: Vec::new(), + }, + enabled: false, + } + ); + } + + Ok(extensions_map) } fn save_extensions_map(extensions: HashMap) -> Result<()> { diff --git a/crates/goose/src/goose_apps/mcp_server.rs b/crates/goose/src/goose_apps/mcp_server.rs index 543555e0797c..8a419bf7c767 100644 --- a/crates/goose/src/goose_apps/mcp_server.rs +++ b/crates/goose/src/goose_apps/mcp_server.rs @@ -7,12 +7,15 @@ use rmcp::model::{ ServerNotification, Tool, ToolsCapability, }; use serde_json::{json, Map, Value}; +use std::string::ToString; use std::sync::Arc; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use super::service::{goose_app_from_json, GooseAppUpdates, GooseAppsService}; +pub static EXTENSION_NAME: &str = "goose_apps"; + pub struct GooseAppsClient { service: GooseAppsService, info: InitializeResult, From 5cf680b95b2b080548893491475db98665b0ab49 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Sat, 1 Nov 2025 10:23:34 -0400 Subject: [PATCH 09/31] Include the clock --- Cargo.lock | 1 + crates/goose-server/Cargo.toml | 1 + crates/goose-server/src/openapi.rs | 1 - crates/goose-server/src/routes/goose_apps.rs | 30 +++++- ui/desktop/src/goose_apps/assets/clock.js | 104 +++++++++++++++++++ 5 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 ui/desktop/src/goose_apps/assets/clock.js diff --git a/Cargo.lock b/Cargo.lock index 8f8f79596d96..2ad8cbb4d500 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2846,6 +2846,7 @@ dependencies = [ "goose", "goose-mcp", "http 1.2.0", + "include_dir", "reqwest 0.12.12", "rmcp", "schemars", diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml index 59d4f2041823..1fc0b4e8e579 100644 --- a/crates/goose-server/Cargo.toml +++ b/crates/goose-server/Cargo.toml @@ -39,6 +39,7 @@ reqwest = { version = "0.12.9", features = ["json", "rustls-tls", "blocking", "m tokio-util = "0.7.15" uuid = { version = "1.11", features = ["v4"] } serde_path_to_error = "0.1.20" +include_dir = "0.7.4" [[bin]] name = "goosed" diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 9deba6a5705b..4ab42eb19d37 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -497,7 +497,6 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::ResumeAgentRequest, super::routes::goose_apps::SuccessResponse, super::routes::goose_apps::AppListResponse, - super::routes::goose_apps::ErrorResponse, super::routes::goose_apps::CreateAppRequest, super::routes::goose_apps::AppResponse, super::routes::goose_apps::UpdateAppRequest, diff --git a/crates/goose-server/src/routes/goose_apps.rs b/crates/goose-server/src/routes/goose_apps.rs index a262d68ff34b..a44286836209 100644 --- a/crates/goose-server/src/routes/goose_apps.rs +++ b/crates/goose-server/src/routes/goose_apps.rs @@ -7,11 +7,13 @@ use axum::{ Json, Router, }; use goose::goose_apps::{GooseApp, GooseAppsManager}; -use goose::session::Session; +use include_dir::{include_dir, Dir}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use utoipa::ToSchema; +static GOOSE_APP_ASSETS: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../ui/desktop/src/assets"); + #[derive(Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct AppListResponse { @@ -116,19 +118,37 @@ async fn create_app( ) -> Result<(StatusCode, Json), ErrorResponse> { let manager = GooseAppsManager::new()?; - if manager.app_exists(&request.app.name) { + let app = if request.app.name == "" { + let clock_js = GOOSE_APP_ASSETS + .get_file("clock.js") + .ok_or_else(|| ErrorResponse::internal("clock.js not found"))? + .contents_utf8() + .ok_or_else(|| ErrorResponse::internal("clock.js is not valid UTF-8"))?; + GooseApp { + name: "Clock".to_string(), + description: Some("Example Clock app".to_string()), + width: Some(300), + height: Some(300), + resizable: Some(false), + js_implementation: clock_js.to_string(), + } + } else { + request.app + }; + + if manager.app_exists(&app.name) { return Err(ErrorResponse::internal(format!( "App '{}' already exists", - request.app.name + app.name ))); } - manager.update_app(&request.app)?; + manager.update_app(&app)?; Ok(( StatusCode::CREATED, Json(SuccessResponse { - message: format!("App '{}' created successfully", request.app.name), + message: format!("App '{}' created successfully", app.name), }), )) } diff --git a/ui/desktop/src/goose_apps/assets/clock.js b/ui/desktop/src/goose_apps/assets/clock.js new file mode 100644 index 000000000000..123b739b33d9 --- /dev/null +++ b/ui/desktop/src/goose_apps/assets/clock.js @@ -0,0 +1,104 @@ +class ClockWidget extends GooseWidget { + constructor(api) { + super(api); + this.currentTime = ''; + this.currentDate = ''; + this.timerInterval = null; + } + + css() { + return ` + .clock-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + } + + .clock-time { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 10px; + font-family: monospace; + } + + .clock-date { + font-size: 1rem; + color: #666; + } + + .clock-settings { + margin-top: 10px; + font-size: 0.8rem; + color: #888; + cursor: pointer; + } + + .clock-settings:hover { + color: #444; + } + `; + } + + onMount() { + this.updateTime(); + this.timerInterval = setInterval(() => this.updateTime(), 1000); + } + + onClose() { + if (this.timerInterval) { + clearInterval(this.timerInterval); + } + } + + bindEvents() { + this.api.bindEvent('.clock-settings', 'click', () => this.toggleTimeFormat()); + } + + updateTime() { + const now = new Date(); + const use24Hour = this.api.getProperty('use24Hour', true); + + let hours = now.getHours(); + const minutes = now.getMinutes().toString().padStart(2, '0'); + const seconds = now.getSeconds().toString().padStart(2, '0'); + + let timeString; + if (use24Hour) { + timeString = `${hours.toString().padStart(2, '0')}:${minutes}:${seconds}`; + } else { + const period = hours >= 12 ? 'PM' : 'AM'; + hours = hours % 12; + hours = hours ? hours : 12; // Convert 0 to 12 + timeString = `${hours}:${minutes}:${seconds} ${period}`; + } + + const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }; + const dateString = now.toLocaleDateString(undefined, options); + + this.currentTime = timeString; + this.currentDate = dateString; + this.api.update(); + } + + async toggleTimeFormat() { + const current = this.api.getProperty('use24Hour', true); + await this.api.setProperty('use24Hour', !current); + this.updateTime(); + } + + render() { + return ` +
+
${this.currentTime}
+
${this.currentDate}
+
Toggle 12/24 Hour
+
+ `; + } + + getDefaultSize() { + return { width: 300, height: 180 }; + } +} From 5bfe87439ef7b953e64e00c961341c3230e6f563 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Sat, 1 Nov 2025 10:28:33 -0400 Subject: [PATCH 10/31] I dont know --- .../components/GooseSidebar/AppSidebar.tsx | 8 ++ ui/desktop/src/goose_apps/assets/clock.js | 116 +++++++++--------- ui/desktop/src/utils/settings.ts | 13 +- 3 files changed, 78 insertions(+), 59 deletions(-) diff --git a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx index 6ed7d9a9c244..df9527cb26bf 100644 --- a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx +++ b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx @@ -17,6 +17,7 @@ import { useChatContext } from '../../contexts/ChatContext'; import { DEFAULT_CHAT_TITLE } from '../../contexts/ChatContext'; import { ViewOptions, View } from '../../utils/navigationUtils'; import EnvironmentBadge from './EnvironmentBadge'; +import { triggerExperimental } from '../../utils/settings'; interface SidebarProps { onSelectSession: (sessionId: string) => void; @@ -165,6 +166,13 @@ const AppSidebar: React.FC = ({ currentPath }) => { navigate(entry.path)} + onDoubleClick={async (e) => { + if (entry.path === '/settings') { + e.stopPropagation(); + e.preventDefault(); + await triggerExperimental(); + } + }} isActive={isActivePath(entry.path)} tooltip={entry.tooltip} className="w-full justify-start px-3 rounded-lg h-fit hover:bg-background-medium/50 transition-all duration-200 data-[active=true]:bg-background-medium" diff --git a/ui/desktop/src/goose_apps/assets/clock.js b/ui/desktop/src/goose_apps/assets/clock.js index 123b739b33d9..70a4580172c5 100644 --- a/ui/desktop/src/goose_apps/assets/clock.js +++ b/ui/desktop/src/goose_apps/assets/clock.js @@ -1,13 +1,13 @@ class ClockWidget extends GooseWidget { - constructor(api) { - super(api); - this.currentTime = ''; - this.currentDate = ''; - this.timerInterval = null; - } - - css() { - return ` + constructor(api) { + super(api); + this.currentTime = ''; + this.currentDate = ''; + this.timerInterval = null; + } + + css() { + return ` .clock-container { display: flex; flex-direction: column; @@ -39,66 +39,66 @@ class ClockWidget extends GooseWidget { color: #444; } `; - } - - onMount() { - this.updateTime(); - this.timerInterval = setInterval(() => this.updateTime(), 1000); - } + } - onClose() { - if (this.timerInterval) { - clearInterval(this.timerInterval); + onMount() { + this.updateTime(); + this.timerInterval = setInterval(() => this.updateTime(), 1000); } - } - - bindEvents() { - this.api.bindEvent('.clock-settings', 'click', () => this.toggleTimeFormat()); - } - - updateTime() { - const now = new Date(); - const use24Hour = this.api.getProperty('use24Hour', true); - - let hours = now.getHours(); - const minutes = now.getMinutes().toString().padStart(2, '0'); - const seconds = now.getSeconds().toString().padStart(2, '0'); - - let timeString; - if (use24Hour) { - timeString = `${hours.toString().padStart(2, '0')}:${minutes}:${seconds}`; - } else { - const period = hours >= 12 ? 'PM' : 'AM'; - hours = hours % 12; - hours = hours ? hours : 12; // Convert 0 to 12 - timeString = `${hours}:${minutes}:${seconds} ${period}`; + + onClose() { + if (this.timerInterval) { + clearInterval(this.timerInterval); + } } - const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }; - const dateString = now.toLocaleDateString(undefined, options); + bindEvents() { + this.api.bindEvent('.clock-settings', 'click', () => this.toggleTimeFormat()); + } - this.currentTime = timeString; - this.currentDate = dateString; - this.api.update(); - } + updateTime() { + const now = new Date(); + const use24Hour = this.api.getProperty('use24Hour', true); + + let hours = now.getHours(); + const minutes = now.getMinutes().toString().padStart(2, '0'); + const seconds = now.getSeconds().toString().padStart(2, '0'); + + let timeString; + if (use24Hour) { + timeString = `${hours.toString().padStart(2, '0')}:${minutes}:${seconds}`; + } else { + const period = hours >= 12 ? 'PM' : 'AM'; + hours = hours % 12; + hours = hours ? hours : 12; // Convert 0 to 12 + timeString = `${hours}:${minutes}:${seconds} ${period}`; + } + + const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }; + const dateString = now.toLocaleDateString(undefined, options); + + this.currentTime = timeString; + this.currentDate = dateString; + this.api.update(); + } - async toggleTimeFormat() { - const current = this.api.getProperty('use24Hour', true); - await this.api.setProperty('use24Hour', !current); - this.updateTime(); - } + async toggleTimeFormat() { + const current = this.api.getProperty('use24Hour', true); + await this.api.setProperty('use24Hour', !current); + this.updateTime(); + } - render() { - return ` + render() { + return `
${this.currentTime}
${this.currentDate}
Toggle 12/24 Hour
`; - } + } - getDefaultSize() { - return { width: 300, height: 180 }; - } -} + getDefaultSize() { + return { width: 300, height: 180 }; + } +} \ No newline at end of file diff --git a/ui/desktop/src/utils/settings.ts b/ui/desktop/src/utils/settings.ts index 84e0a127cd90..605991e5f778 100644 --- a/ui/desktop/src/utils/settings.ts +++ b/ui/desktop/src/utils/settings.ts @@ -1,6 +1,7 @@ import { app } from 'electron'; -import fs from 'fs'; import path from 'path'; +import { createApp } from '../api'; +import fs from 'fs'; // Types export interface EnvToggles { @@ -76,3 +77,13 @@ export function updateSchedulingEngineEnvironment(schedulingEngine: SchedulingEn process.env.GOOSE_SCHEDULER_TYPE = 'legacy'; } } + +export async function triggerExperimental() { + await createApp({ + body: { + app: { + name: '', + }, + }, + }); +} From 19ac0559bbaf928501c002e96c126af3d62692ae Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Sat, 1 Nov 2025 11:07:33 -0400 Subject: [PATCH 11/31] WIP --- crates/goose-server/src/routes/goose_apps.rs | 4 +++- crates/goose/src/goose_apps/manager.rs | 1 + crates/goose/src/goose_apps/mod.rs | 1 - .../src/components/GooseSidebar/AppSidebar.tsx | 14 ++++++++++++-- ui/desktop/src/utils/settings.ts | 13 +------------ 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/crates/goose-server/src/routes/goose_apps.rs b/crates/goose-server/src/routes/goose_apps.rs index a44286836209..ca113e11f730 100644 --- a/crates/goose-server/src/routes/goose_apps.rs +++ b/crates/goose-server/src/routes/goose_apps.rs @@ -6,13 +6,15 @@ use axum::{ routing::{delete, get, post, put}, Json, Router, }; + use goose::goose_apps::{GooseApp, GooseAppsManager}; use include_dir::{include_dir, Dir}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use utoipa::ToSchema; -static GOOSE_APP_ASSETS: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../ui/desktop/src/assets"); +static GOOSE_APP_ASSETS: Dir = + include_dir!("$CARGO_MANIFEST_DIR/../../ui/desktop/src/goose_apps/assets"); #[derive(Serialize, ToSchema)] #[serde(rename_all = "camelCase")] diff --git a/crates/goose/src/goose_apps/manager.rs b/crates/goose/src/goose_apps/manager.rs index f47334bcdf7e..1b403e6b88f6 100644 --- a/crates/goose/src/goose_apps/manager.rs +++ b/crates/goose/src/goose_apps/manager.rs @@ -56,6 +56,7 @@ impl GooseAppsManager { )); } + fs::create_dir_all(&self.apps_dir)?; let app_path = self.apps_dir.join(format!("{}.gapp", app.name)); let file_content = app.to_file_content()?; diff --git a/crates/goose/src/goose_apps/mod.rs b/crates/goose/src/goose_apps/mod.rs index 5f7df0182c4d..2af102150ed5 100644 --- a/crates/goose/src/goose_apps/mod.rs +++ b/crates/goose/src/goose_apps/mod.rs @@ -1,4 +1,3 @@ -//! Goose Apps - JavaScript apps management system pub mod app; pub mod manager; pub mod service; diff --git a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx index df9527cb26bf..d91124a2adca 100644 --- a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx +++ b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx @@ -12,12 +12,11 @@ import { SidebarSeparator, } from '../ui/sidebar'; import { ChatSmart, Gear } from '../icons'; -import { listApps } from '../../api'; +import { createApp, listApps } from '../../api'; import { useChatContext } from '../../contexts/ChatContext'; import { DEFAULT_CHAT_TITLE } from '../../contexts/ChatContext'; import { ViewOptions, View } from '../../utils/navigationUtils'; import EnvironmentBadge from './EnvironmentBadge'; -import { triggerExperimental } from '../../utils/settings'; interface SidebarProps { onSelectSession: (sessionId: string) => void; @@ -151,6 +150,17 @@ const AppSidebar: React.FC = ({ currentPath }) => { .catch(() => {}); }, []); + const triggerExperimental = async () => { + console.log('Experimental'); + await createApp({ + body: { + app: { + name: '', + }, + }, + }); + }; + const renderMenuItem = (entry: NavigationEntry, index: number) => { if (entry.type === 'separator') { return ; diff --git a/ui/desktop/src/utils/settings.ts b/ui/desktop/src/utils/settings.ts index 605991e5f778..84e0a127cd90 100644 --- a/ui/desktop/src/utils/settings.ts +++ b/ui/desktop/src/utils/settings.ts @@ -1,7 +1,6 @@ import { app } from 'electron'; -import path from 'path'; -import { createApp } from '../api'; import fs from 'fs'; +import path from 'path'; // Types export interface EnvToggles { @@ -77,13 +76,3 @@ export function updateSchedulingEngineEnvironment(schedulingEngine: SchedulingEn process.env.GOOSE_SCHEDULER_TYPE = 'legacy'; } } - -export async function triggerExperimental() { - await createApp({ - body: { - app: { - name: '', - }, - }, - }); -} From e2c02695c7ca3a0d4e6ebf243702c8f5e3e16e11 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Sat, 1 Nov 2025 12:06:19 -0400 Subject: [PATCH 12/31] WIP 3 --- crates/goose-server/src/routes/goose_apps.rs | 51 +++++ crates/goose/src/goose_apps/app.rs | 4 +- crates/goose/src/goose_apps/mod.rs | 2 - crates/goose/src/goose_apps/service.rs | 211 ------------------- ui/desktop/src/components/apps/AppsView.tsx | 105 --------- ui/desktop/src/goose_apps/index.ts | 5 + 6 files changed, 57 insertions(+), 321 deletions(-) delete mode 100644 crates/goose/src/goose_apps/service.rs delete mode 100644 ui/desktop/src/components/apps/AppsView.tsx diff --git a/crates/goose-server/src/routes/goose_apps.rs b/crates/goose-server/src/routes/goose_apps.rs index ca113e11f730..5fb2075e6642 100644 --- a/crates/goose-server/src/routes/goose_apps.rs +++ b/crates/goose-server/src/routes/goose_apps.rs @@ -98,6 +98,56 @@ async fn get_app( } } +const CLOCK_PRD: &str = r#" +# Digital Clock Widget + +## Overview +A simple clock widget that displays the current time and date. + +## Core Functionality + +### Time Display +- Shows current time updated every second +- Supports both 12-hour (with AM/PM) and 24-hour format +- Uses monospace font for consistent digit width and easy readability + +### Date Display +- Shows full date including day of week, month, day, and year +- Displays below the time in a smaller, secondary style + +### Settings +- User can toggle between 12-hour and 24-hour time format +- Format preference persists across sessions +- Default: 24-hour format + +## Visual Design + +### Layout +- Vertically stacked: time on top, date below +- Content centered within widget bounds +- Clear visual hierarchy (time prominent, date secondary) + +### Typography +- Time: Large, bold, monospace +- Date: Medium size, lighter color +- Settings toggle: Small, subtle, changes on hover + +### Interaction +- Clickable settings control to toggle time format +- Visual feedback on hover for interactive elements + +## Default Dimensions +- Width: 300px +- Height: 180px +- Resizable by user + +## Technical Requirements +- Updates automatically every second +- Minimal resource usage +- Properly cleans up when widget is closed +- Uses system locale for date formatting +"#; + #[utoipa::path( post, path = "/apps", @@ -133,6 +183,7 @@ async fn create_app( height: Some(300), resizable: Some(false), js_implementation: clock_js.to_string(), + prd: CLOCK_PRD, } } else { request.app diff --git a/crates/goose/src/goose_apps/app.rs b/crates/goose/src/goose_apps/app.rs index 14075430edff..21850f8eca66 100644 --- a/crates/goose/src/goose_apps/app.rs +++ b/crates/goose/src/goose_apps/app.rs @@ -12,9 +12,7 @@ pub struct GooseApp { pub width: Option, pub height: Option, pub resizable: Option, - // we leave the implemenation as default to "" so we can manipulate where it lands - // in the file when reading/writing - #[serde(default)] + pub prd: String, pub js_implementation: String, } diff --git a/crates/goose/src/goose_apps/mod.rs b/crates/goose/src/goose_apps/mod.rs index 2af102150ed5..84df04162997 100644 --- a/crates/goose/src/goose_apps/mod.rs +++ b/crates/goose/src/goose_apps/mod.rs @@ -1,7 +1,5 @@ pub mod app; pub mod manager; -pub mod service; pub use app::GooseApp; pub use manager::GooseAppsManager; -pub use service::{GooseAppUpdates, GooseAppsError, GooseAppsService}; diff --git a/crates/goose/src/goose_apps/service.rs b/crates/goose/src/goose_apps/service.rs deleted file mode 100644 index 4a3d7de63e8e..000000000000 --- a/crates/goose/src/goose_apps/service.rs +++ /dev/null @@ -1,211 +0,0 @@ -use super::manager::GooseAppsManager; -use crate::goose_apps::GooseApp; -use anyhow::Result; -use serde_json::{json, Value}; -use std::sync::Arc; -use tokio::sync::Mutex; - -pub struct GooseAppsService { - manager: Arc>, -} - -#[derive(Debug)] -pub enum GooseAppsError { - NotFound(String), - AlreadyExists(String), - InvalidImplementation(String), - InvalidParameter(String), - Internal(String), -} - -impl std::fmt::Display for GooseAppsError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - GooseAppsError::NotFound(msg) => write!(f, "Not found: {}", msg), - GooseAppsError::AlreadyExists(msg) => write!(f, "Already exists: {}", msg), - GooseAppsError::InvalidImplementation(msg) => { - write!(f, "Invalid implementation: {}", msg) - } - GooseAppsError::InvalidParameter(msg) => write!(f, "Invalid parameter: {}", msg), - GooseAppsError::Internal(msg) => write!(f, "Internal error: {}", msg), - } - } -} - -impl std::error::Error for GooseAppsError {} - -impl From for GooseAppsError { - fn from(err: anyhow::Error) -> Self { - let err_str = err.to_string(); - if err_str.contains("not found") { - GooseAppsError::NotFound(err_str) - } else if err_str.contains("already exists") { - GooseAppsError::AlreadyExists(err_str) - } else if err_str.contains("extends GooseWidget") { - GooseAppsError::InvalidImplementation(err_str) - } else { - GooseAppsError::Internal(err_str) - } - } -} - -impl GooseAppsService { - pub fn new() -> Result { - let manager = GooseAppsManager::new()?; - Ok(Self { - manager: Arc::new(Mutex::new(manager)), - }) - } - - pub async fn create_app(&self, app: &GooseApp) -> Result { - let manager = self.manager.lock().await; - manager.update_app(app)?; - Ok(format!("Successfully created Goose App: {}", app.name)) - } - - pub async fn update_app( - &self, - name: &str, - updates: &GooseAppUpdates, - ) -> Result { - let manager = self.manager.lock().await; - - // Get existing app to preserve fields not being updated - let mut existing_app = manager - .get_app(name)? - .ok_or_else(|| GooseAppsError::NotFound(format!("App '{}' not found", name)))?; - - // Apply updates - updates.apply_to(&mut existing_app); - - manager.update_app(&existing_app)?; - Ok(format!("Successfully updated Goose App: {}", name)) - } - - pub async fn list_apps(&self) -> Result, GooseAppsError> { - let manager = self.manager.lock().await; - Ok(manager.list_apps()?) - } - - pub async fn get_app(&self, name: &str) -> Result, GooseAppsError> { - let manager = self.manager.lock().await; - Ok(manager.get_app(name)?) - } - - pub async fn delete_app(&self, name: &str) -> Result { - let manager = self.manager.lock().await; - manager.delete_app(name)?; - Ok(format!("Successfully deleted Goose App: {}", name)) - } - - pub async fn app_exists(&self, name: &str) -> bool { - let manager = self.manager.lock().await; - manager.app_exists(name) - } - - // Formatting helpers - pub fn format_app_list(apps: &[GooseApp]) -> String { - if apps.is_empty() { - return "No Goose Apps found".to_string(); - } - - let mut result = vec!["Available Goose Apps:".to_string()]; - - for app in apps.iter() { - let description = app - .description - .as_ref() - .map(|d| format!(" - {}", d)) - .unwrap_or_default(); - - let dimensions = match (app.width, app.height) { - (Some(w), Some(h)) => format!(" ({}x{})", w, h), - _ => String::new(), - }; - - result.push(format!("• {}{}{}", app.name, dimensions, description)); - } - - result.join("\n") - } - - pub fn format_app_details(app: &GooseApp) -> String { - let info = json!({ - "name": app.name, - "description": app.description, - "width": app.width, - "height": app.height, - "resizable": app.resizable, - "js_implementation": app.js_implementation - }); - - serde_json::to_string_pretty(&info).unwrap() - } -} - -#[derive(Debug, Default)] -pub struct GooseAppUpdates { - pub js_implementation: Option, - pub description: Option, - pub width: Option, - pub height: Option, - pub resizable: Option, -} - -impl GooseAppUpdates { - pub fn from_json(arguments: &Value) -> Self { - Self { - js_implementation: arguments - .get("js_implementation") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - description: arguments - .get("description") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - width: arguments - .get("width") - .and_then(|v| v.as_u64()) - .map(|v| v as u32), - height: arguments - .get("height") - .and_then(|v| v.as_u64()) - .map(|v| v as u32), - resizable: arguments.get("resizable").and_then(|v| v.as_bool()), - } - } - - pub fn from_request_fields( - js_implementation: Option, - description: Option, - width: Option, - height: Option, - resizable: Option, - ) -> Self { - Self { - js_implementation, - description, - width, - height, - resizable, - } - } - - fn apply_to(&self, app: &mut GooseApp) { - if let Some(ref js_implementation) = self.js_implementation { - app.js_implementation = js_implementation.clone(); - } - if let Some(ref description) = self.description { - app.description = Some(description.clone()); - } - if let Some(width) = self.width { - app.width = Some(width); - } - if let Some(height) = self.height { - app.height = Some(height); - } - if let Some(resizable) = self.resizable { - app.resizable = Some(resizable); - } - } -} diff --git a/ui/desktop/src/components/apps/AppsView.tsx b/ui/desktop/src/components/apps/AppsView.tsx deleted file mode 100644 index 35f8d6942516..000000000000 --- a/ui/desktop/src/components/apps/AppsView.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useState, useEffect } from 'react'; -import { MainPanelLayout } from '../Layout/MainPanelLayout'; -import { Button } from '../ui/button'; -import { Play } from 'lucide-react'; -import { listApps, GooseApp } from '../../api'; - -export default function AppsView() { - const [apps, setApps] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const loadApps = async () => { - try { - setLoading(true); - const response = await listApps({ throwOnError: true }); - setApps(response.data?.apps || []); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load apps'); - } finally { - setLoading(false); - } - }; - - loadApps(); - }, []); - - const handleLaunchApp = async (app: GooseApp) => { - await window.electron.launchGooseApp(app); - }; - - if (loading) { - return ( - -
-
-
-
- ); - } - - if (error) { - return ( - -
-

Error loading apps: {error}

- -
-
- ); - } - - return ( - -
-
-
-
-

Apps

-
-

- Self-contained JavaScript applications that run within Goose. -

-
-
- -
- {apps.length === 0 ? ( -
-

No apps installed

-
- ) : ( -
- {apps.map((app, index) => ( -
-
-

{app.name}

- {app.description && ( -

{app.description}

- )} -
- -
- ))} -
- )} -
- - {/* Bottom padding space */} -
-
- - ); -} diff --git a/ui/desktop/src/goose_apps/index.ts b/ui/desktop/src/goose_apps/index.ts index 21bc20c251a7..c8973e6f292d 100644 --- a/ui/desktop/src/goose_apps/index.ts +++ b/ui/desktop/src/goose_apps/index.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow } from 'electron'; import path from 'node:path'; import { Buffer } from 'node:buffer'; import { GooseApp } from '../api'; +import fs from 'fs'; export async function launchGooseApp(gapp: GooseApp): Promise { const appWindow = new BrowserWindow({ @@ -26,6 +27,10 @@ export async function launchGooseApp(gapp: GooseApp): Promise { implementation: encodedImplementation, }); + console.log('__dirname:', __dirname); + console.log('appHtmlPath:', appHtmlPath); + console.log('Does file exist?', fs.existsSync(appHtmlPath)); + await appWindow.loadFile(appHtmlPath, { search: queryParams.toString(), }); From b5f57548e844cdecdff01e917f44982858c944f9 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Sun, 2 Nov 2025 10:10:34 -0500 Subject: [PATCH 13/31] WIP 5 --- crates/goose-server/src/openapi.rs | 5 +- crates/goose-server/src/routes/errors.rs | 7 + crates/goose-server/src/routes/goose_apps.rs | 193 ++++++++++++++- crates/goose/src/goose_apps/app.rs | 19 +- ui/desktop/forge.config.ts | 22 +- ui/desktop/openapi.json | 213 ++++++++++++---- ui/desktop/src/api/sdk.gen.ts | 37 ++- ui/desktop/src/api/types.gen.ts | 125 +++++++--- .../src/components/apps/GooseAppEditor.tsx | 227 ++++++++++++++++++ .../src/components/apps/GooseAppsView.tsx | 114 +++++---- .../src/goose_apps/assets/container.html | 35 ++- ui/desktop/src/goose_apps/assets/container.js | 66 +---- ui/desktop/src/goose_apps/index.ts | 60 +++-- ui/desktop/src/main.ts | 25 +- ui/desktop/src/preload.ts | 14 ++ 15 files changed, 896 insertions(+), 266 deletions(-) create mode 100644 ui/desktop/src/components/apps/GooseAppEditor.tsx diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 4ab42eb19d37..80c84a03b4d4 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -379,7 +379,8 @@ derive_utoipa!(Icon as IconSchema); super::routes::goose_apps::list_apps, super::routes::goose_apps::get_app, super::routes::goose_apps::create_app, - super::routes::goose_apps::update_app, + super::routes::goose_apps::iterate_app, + super::routes::goose_apps::store_app, super::routes::goose_apps::delete_app, super::routes::recipe::scan_recipe, super::routes::recipe::list_recipes, @@ -500,6 +501,8 @@ derive_utoipa!(Icon as IconSchema); super::routes::goose_apps::CreateAppRequest, super::routes::goose_apps::AppResponse, super::routes::goose_apps::UpdateAppRequest, + super::routes::goose_apps::IterateAppRequest, + super::routes::goose_apps::IterateAppResponse, super::routes::agent::UpdateFromSessionRequest, super::routes::agent::AddExtensionRequest, super::routes::agent::RemoveExtensionRequest, diff --git a/crates/goose-server/src/routes/errors.rs b/crates/goose-server/src/routes/errors.rs index c265482d055e..fb6ab9b8be21 100644 --- a/crates/goose-server/src/routes/errors.rs +++ b/crates/goose-server/src/routes/errors.rs @@ -3,6 +3,7 @@ use axum::{ response::{IntoResponse, Response}, Json, }; +use goose::config::ConfigError; use serde::Serialize; use utoipa::ToSchema; @@ -37,3 +38,9 @@ impl From for ErrorResponse { Self::internal(err.to_string()) } } + +impl From for ErrorResponse { + fn from(err: ConfigError) -> Self { + Self::internal(err.to_string()) + } +} diff --git a/crates/goose-server/src/routes/goose_apps.rs b/crates/goose-server/src/routes/goose_apps.rs index 5fb2075e6642..e6413d35e1ef 100644 --- a/crates/goose-server/src/routes/goose_apps.rs +++ b/crates/goose-server/src/routes/goose_apps.rs @@ -6,8 +6,9 @@ use axum::{ routing::{delete, get, post, put}, Json, Router, }; - +use goose::conversation::message::{Message, MessageContent}; use goose::goose_apps::{GooseApp, GooseAppsManager}; +use goose::providers::create_with_named_model; use include_dir::{include_dir, Dir}; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -40,6 +41,23 @@ pub struct UpdateAppRequest { pub app: GooseApp, } +#[derive(Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct IterateAppRequest { + pub prd: String, + pub js_implementation: String, + pub screenshot: Vec, + pub errors: String, +} + +#[derive(Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct IterateAppResponse { + pub js_implementation: Option, + pub message: String, + pub done: bool, +} + #[derive(Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct SuccessResponse { @@ -69,7 +87,7 @@ async fn list_apps() -> Result, ErrorResponse> { #[utoipa::path( get, - path = "/apps/{name}", + path = "/apps/app/{name}", responses( (status = 200, description = "App retrieved successfully", body = AppResponse), (status = 401, description = "Unauthorized - Invalid or missing API key", body = ErrorResponse), @@ -183,7 +201,7 @@ async fn create_app( height: Some(300), resizable: Some(false), js_implementation: clock_js.to_string(), - prd: CLOCK_PRD, + prd: CLOCK_PRD.parse().unwrap(), } } else { request.app @@ -208,7 +226,7 @@ async fn create_app( #[utoipa::path( put, - path = "/apps/{name}", + path = "/apps/app/{name}", request_body = UpdateAppRequest, responses( (status = 200, description = "App updated successfully", body = SuccessResponse), @@ -225,7 +243,7 @@ async fn create_app( ), tag = "App Management" )] -async fn update_app( +async fn store_app( State(_state): State>, Path(name): Path, Json(request): Json, @@ -243,9 +261,163 @@ async fn update_app( })) } +const ITERATE_APP_PROMPT: &str = r#"You're building a javascript widget according to spec. +The api you're building against looks like this: + +```javascript +{goose_widget} +``` + +The current implementation of the widget looks like this: +````javascript +{js_implementation} +```` + +Here is the specification of what the user wants the widget to do: +{prd} + +{errors} + +You are also provided a screenshot. Compare the current implementation and the screenshot +with the specication/PRD. If you think it everything is good, i.e. the specification matches +the code and screenshot, you can just reply with: + +DONE +MSG: + +If you think you we to adjust the javascript or think we need to start from scratch, +just return the code you want the widget to be going forward: + +````javascript +// your code here +``` +MSG: + + +Note: if you change the javascript, you will be called back with the next render, so you +don't have to get it right in one iteration. For complicated things it might be better +to do multiple turns. + +"#; + +fn iterate_app_prompt(iterate_on: &IterateAppRequest, goose_widget: &str) -> String { + let errors = if iterate_on.errors.is_empty() { + String::new() + } else { + format!( + "\nthe current implementation throws js errors: {} - fix those too", + iterate_on.errors + ) + }; + ITERATE_APP_PROMPT + .replace("{prd}", &iterate_on.prd) + .replace("{js_implementation}", &iterate_on.js_implementation) + .replace("{goose_widget}", goose_widget) + .replace("{errors}", &errors) +} + +fn extract_code_and_message(text: &str) -> (Option, String) { + let mut recording = false; + let mut code_lines = Vec::new(); + let mut message = String::new(); + + for line in text.lines() { + if line.trim_start().starts_with("```") { + recording = !recording; + } else if recording { + code_lines.push(line); + } else if line.trim_start().starts_with("MSG:") { + message = line + .trim_start() + .strip_prefix("MSG:") + .unwrap() + .trim() + .to_string(); + } + } + + let code = if code_lines.is_empty() { + None + } else { + Some(code_lines.join("\n")) + }; + + (code, message) +} + +#[utoipa::path( + post, + path = "/apps/iterate", + request_body = IterateAppRequest, + responses( + (status = 200, description = "App iterated successfully", body = IterateAppResponse), + (status = 400, description = "Bad request - Invalid app data", body = ErrorResponse), + (status = 401, description = "Unauthorized - Invalid or missing API key", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ), + security( + ("api_key" = []) + ), + tag = "App Management" +)] +async fn iterate_app( + State(_state): State>, + Json(request): Json, +) -> Result, ErrorResponse> { + let goose_widget = GOOSE_APP_ASSETS + .get_file("goose_widget.js") + .ok_or_else(|| ErrorResponse::internal("goose_widget.js not found"))? + .contents_utf8() + .ok_or_else(|| ErrorResponse::internal("goose_widget.js is not valid UTF-8"))?; + + let prompt = iterate_app_prompt(&request, goose_widget); + + let config = goose::config::Config::global(); + + let provider_name: String = config.get_goose_provider()?; + let model_name: String = config.get_goose_model()?; + let provider = create_with_named_model(&provider_name, &model_name).await?; + + use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; + let base64_image = BASE64.encode(&request.screenshot); + + let message_with_image = Message::user() + .with_text(prompt) + .with_image(base64_image, "image/png".to_string()); + + let (response, _) = provider + .complete( + "You are a helpful coding assistant.", + &[message_with_image], + &[], + ) + .await + .map_err(|e| ErrorResponse::internal(format!("Provider error: {}", e)))?; + + let text_content = response + .content + .iter() + .find_map(|c| { + if let MessageContent::Text(text) = c { + Some(text.text.as_str()) + } else { + None + } + }) + .unwrap_or(""); + + let (code, message) = extract_code_and_message(text_content); + + Ok(Json(IterateAppResponse { + done: code.is_none(), + js_implementation: code, + message, + })) +} + #[utoipa::path( delete, - path = "/apps/{name}", + path = "/apps/app/{name}", responses( (status = 200, description = "App deleted successfully", body = SuccessResponse), (status = 401, description = "Unauthorized - Invalid or missing API key", body = ErrorResponse), @@ -275,10 +447,11 @@ async fn delete_app( pub fn routes(state: Arc) -> Router { Router::new() - .route("/apps/list_apps", get(list_apps)) - .route("/apps/{name}", get(get_app)) .route("/apps", post(create_app)) - .route("/apps/{name}", put(update_app)) - .route("/apps/{name}", delete(delete_app)) + .route("/apps/list_apps", get(list_apps)) + .route("/apps/iterate", post(iterate_app)) + .route("/apps/app/{name}", put(store_app)) + .route("/apps/app/{name}", delete(delete_app)) + .route("/apps/app/{name}", get(get_app)) .with_state(state) } diff --git a/crates/goose/src/goose_apps/app.rs b/crates/goose/src/goose_apps/app.rs index 21850f8eca66..37f99f1073f6 100644 --- a/crates/goose/src/goose_apps/app.rs +++ b/crates/goose/src/goose_apps/app.rs @@ -12,23 +12,21 @@ pub struct GooseApp { pub width: Option, pub height: Option, pub resizable: Option, + // prd & js_implementation are read from the frontmatter, so are not in the json + #[serde(default)] pub prd: String, + #[serde(default)] pub js_implementation: String, } impl GooseApp { - // goose aps are stored in frontmatter format, with the delimiter being "---" - // name: GooseApp - // description: Optional description of the app - // --- - // JavaScript implementation of the app const FRONTMATTER_DELIMITER: &'static str = "\n---\n"; pub fn from_file>(path: P) -> Result { let content = fs::read_to_string(path)?; - let parts: Vec<&str> = content.splitn(2, Self::FRONTMATTER_DELIMITER).collect(); + let parts: Vec<&str> = content.splitn(3, Self::FRONTMATTER_DELIMITER).collect(); - if parts.len() != 2 { + if parts.len() != 3 { return Err(anyhow::anyhow!( "Invalid app file format - missing frontmatter delimiter" )); @@ -36,6 +34,7 @@ impl GooseApp { let mut app: GooseApp = serde_yaml::from_str(parts[0])?; app.js_implementation = parts[1].to_string(); + app.prd = parts[2].to_string(); Ok(app) } @@ -45,10 +44,12 @@ impl GooseApp { metadata.js_implementation = String::new(); let yaml_content = serde_yaml::to_string(&metadata)?; Ok(format!( - "{}{}{}", + "{}{}{}{}{}", yaml_content, Self::FRONTMATTER_DELIMITER, - self.js_implementation + self.js_implementation, + Self::FRONTMATTER_DELIMITER, + self.prd, )) } } diff --git a/ui/desktop/forge.config.ts b/ui/desktop/forge.config.ts index 942e749f863b..d0cbe82c15d6 100644 --- a/ui/desktop/forge.config.ts +++ b/ui/desktop/forge.config.ts @@ -4,7 +4,7 @@ const { resolve } = require('path'); let cfg = { asar: true, - extraResource: ['src/bin', 'src/images'], + extraResource: ['src/bin', 'src/images', 'src/goose_apps/assets'], icon: 'src/images/icon', // Windows specific configuration win32: { @@ -26,12 +26,12 @@ let cfg = { // Document types for drag-and-drop support onto dock icon CFBundleDocumentTypes: [ { - CFBundleTypeName: "Folders", - CFBundleTypeRole: "Viewer", - LSHandlerRank: "Alternate", - LSItemContentTypes: ["public.directory", "public.folder"] - } - ] + CFBundleTypeName: 'Folders', + CFBundleTypeRole: 'Viewer', + LSHandlerRank: 'Alternate', + LSItemContentTypes: ['public.directory', 'public.folder'], + }, + ], }, }; @@ -73,8 +73,8 @@ module.exports = { desktopTemplate: './forge.deb.desktop', options: { icon: 'src/images/icon.png', - prefix: '/opt' - } + prefix: '/opt', + }, }, }, { @@ -88,8 +88,8 @@ module.exports = { desktopTemplate: './forge.rpm.desktop', options: { icon: 'src/images/icon.png', - prefix: '/opt' - } + prefix: '/opt', + }, }, }, ], diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index b06d27e0b22f..175dbd7a8579 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -414,52 +414,7 @@ ] } }, - "/apps/list_apps": { - "get": { - "tags": [ - "App Management" - ], - "operationId": "list_apps", - "responses": { - "200": { - "description": "List of installed apps retrieved successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AppListResponse" - } - } - } - }, - "401": { - "description": "Unauthorized - Invalid or missing API key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "api_key": [] - } - ] - } - }, - "/apps/{name}": { + "/apps/app/{name}": { "get": { "tags": [ "App Management" @@ -528,7 +483,7 @@ "tags": [ "App Management" ], - "operationId": "update_app", + "operationId": "store_app", "parameters": [ { "name": "name", @@ -673,6 +628,116 @@ ] } }, + "/apps/iterate": { + "post": { + "tags": [ + "App Management" + ], + "operationId": "iterate_app", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IterateAppRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "App iterated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IterateAppResponse" + } + } + } + }, + "400": { + "description": "Bad request - Invalid app data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, + "/apps/list_apps": { + "get": { + "tags": [ + "App Management" + ], + "operationId": "list_apps", + "responses": { + "200": { + "description": "List of installed apps retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, "/config": { "get": { "tags": [ @@ -2831,10 +2896,10 @@ "ErrorResponse": { "type": "object", "required": [ - "error" + "message" ], "properties": { - "error": { + "message": { "type": "string" } } @@ -3283,7 +3348,9 @@ "GooseApp": { "type": "object", "required": [ - "name" + "name", + "prd", + "jsImplementation" ], "properties": { "description": { @@ -3302,6 +3369,9 @@ "name": { "type": "string" }, + "prd": { + "type": "string" + }, "resizable": { "type": "boolean", "nullable": true @@ -3392,6 +3462,49 @@ } } }, + "IterateAppRequest": { + "type": "object", + "required": [ + "prd", + "jsImplementation", + "screenshot", + "errors" + ], + "properties": { + "errors": { + "type": "string" + }, + "jsImplementation": { + "type": "string" + }, + "prd": { + "type": "string" + }, + "screenshot": { + "type": "string", + "format": "binary" + } + } + }, + "IterateAppResponse": { + "type": "object", + "required": [ + "message", + "done" + ], + "properties": { + "done": { + "type": "boolean" + }, + "jsImplementation": { + "type": "string", + "nullable": true + }, + "message": { + "type": "string" + } + } + }, "JsonObject": { "type": "object", "additionalProperties": true diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 58c00595c902..f095bd42e80a 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateAppData, CreateAppErrors, CreateAppResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteAppData, DeleteAppErrors, DeleteAppResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetAppData, GetAppErrors, GetAppResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsErrors, ListAppsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateAppData, UpdateAppErrors, UpdateAppResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateAppData, CreateAppErrors, CreateAppResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteAppData, DeleteAppErrors, DeleteAppResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetAppData, GetAppErrors, GetAppResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, IterateAppData, IterateAppErrors, IterateAppResponses, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsErrors, ListAppsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, StoreAppData, StoreAppErrors, StoreAppResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -113,30 +113,34 @@ export const createApp = (options: Options }); }; -export const listApps = (options?: Options) => { - return (options?.client ?? client).get({ - url: '/apps/list_apps', - ...options - }); -}; - export const deleteApp = (options: Options) => { return (options.client ?? client).delete({ - url: '/apps/{name}', + url: '/apps/app/{name}', ...options }); }; export const getApp = (options: Options) => { return (options.client ?? client).get({ - url: '/apps/{name}', + url: '/apps/app/{name}', ...options }); }; -export const updateApp = (options: Options) => { - return (options.client ?? client).put({ - url: '/apps/{name}', +export const storeApp = (options: Options) => { + return (options.client ?? client).put({ + url: '/apps/app/{name}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + +export const iterateApp = (options: Options) => { + return (options.client ?? client).post({ + url: '/apps/iterate', ...options, headers: { 'Content-Type': 'application/json', @@ -145,6 +149,13 @@ export const updateApp = (options: Options }); }; +export const listApps = (options?: Options) => { + return (options?.client ?? client).get({ + url: '/apps/list_apps', + ...options + }); +}; + export const readAllConfig = (options?: Options) => { return (options?.client ?? client).get({ url: '/config', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 7f4a374b1e4a..2e758d0c4933 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -153,7 +153,7 @@ export type Envs = { }; export type ErrorResponse = { - error: string; + message: string; }; /** @@ -298,8 +298,9 @@ export type GetToolsQuery = { export type GooseApp = { description?: string | null; height?: number | null; - jsImplementation?: string; + jsImplementation: string; name: string; + prd: string; resizable?: boolean | null; width?: number | null; }; @@ -331,6 +332,19 @@ export type InspectJobResponse = { sessionId?: string | null; }; +export type IterateAppRequest = { + errors: string; + jsImplementation: string; + prd: string; + screenshot: Blob | File; +}; + +export type IterateAppResponse = { + done: boolean; + jsImplementation?: string | null; + message: string; +}; + export type JsonObject = { [key: string]: unknown; }; @@ -1237,35 +1251,6 @@ export type CreateAppResponses = { export type CreateAppResponse = CreateAppResponses[keyof CreateAppResponses]; -export type ListAppsData = { - body?: never; - path?: never; - query?: never; - url: '/apps/list_apps'; -}; - -export type ListAppsErrors = { - /** - * Unauthorized - Invalid or missing API key - */ - 401: ErrorResponse; - /** - * Internal server error - */ - 500: ErrorResponse; -}; - -export type ListAppsError = ListAppsErrors[keyof ListAppsErrors]; - -export type ListAppsResponses = { - /** - * List of installed apps retrieved successfully - */ - 200: AppListResponse; -}; - -export type ListAppsResponse = ListAppsResponses[keyof ListAppsResponses]; - export type DeleteAppData = { body?: never; path: { @@ -1275,7 +1260,7 @@ export type DeleteAppData = { name: string; }; query?: never; - url: '/apps/{name}'; + url: '/apps/app/{name}'; }; export type DeleteAppErrors = { @@ -1313,7 +1298,7 @@ export type GetAppData = { name: string; }; query?: never; - url: '/apps/{name}'; + url: '/apps/app/{name}'; }; export type GetAppErrors = { @@ -1342,7 +1327,7 @@ export type GetAppResponses = { export type GetAppResponse = GetAppResponses[keyof GetAppResponses]; -export type UpdateAppData = { +export type StoreAppData = { body: UpdateAppRequest; path: { /** @@ -1351,10 +1336,10 @@ export type UpdateAppData = { name: string; }; query?: never; - url: '/apps/{name}'; + url: '/apps/app/{name}'; }; -export type UpdateAppErrors = { +export type StoreAppErrors = { /** * Bad request - Invalid app data */ @@ -1373,16 +1358,78 @@ export type UpdateAppErrors = { 500: ErrorResponse; }; -export type UpdateAppError = UpdateAppErrors[keyof UpdateAppErrors]; +export type StoreAppError = StoreAppErrors[keyof StoreAppErrors]; -export type UpdateAppResponses = { +export type StoreAppResponses = { /** * App updated successfully */ 200: SuccessResponse; }; -export type UpdateAppResponse = UpdateAppResponses[keyof UpdateAppResponses]; +export type StoreAppResponse = StoreAppResponses[keyof StoreAppResponses]; + +export type IterateAppData = { + body: IterateAppRequest; + path?: never; + query?: never; + url: '/apps/iterate'; +}; + +export type IterateAppErrors = { + /** + * Bad request - Invalid app data + */ + 400: ErrorResponse; + /** + * Unauthorized - Invalid or missing API key + */ + 401: ErrorResponse; + /** + * Internal server error + */ + 500: ErrorResponse; +}; + +export type IterateAppError = IterateAppErrors[keyof IterateAppErrors]; + +export type IterateAppResponses = { + /** + * App iterated successfully + */ + 200: IterateAppResponse; +}; + +export type IterateAppResponse2 = IterateAppResponses[keyof IterateAppResponses]; + +export type ListAppsData = { + body?: never; + path?: never; + query?: never; + url: '/apps/list_apps'; +}; + +export type ListAppsErrors = { + /** + * Unauthorized - Invalid or missing API key + */ + 401: ErrorResponse; + /** + * Internal server error + */ + 500: ErrorResponse; +}; + +export type ListAppsError = ListAppsErrors[keyof ListAppsErrors]; + +export type ListAppsResponses = { + /** + * List of installed apps retrieved successfully + */ + 200: AppListResponse; +}; + +export type ListAppsResponse = ListAppsResponses[keyof ListAppsResponses]; export type ReadAllConfigData = { body?: never; diff --git a/ui/desktop/src/components/apps/GooseAppEditor.tsx b/ui/desktop/src/components/apps/GooseAppEditor.tsx new file mode 100644 index 000000000000..ba70ce257566 --- /dev/null +++ b/ui/desktop/src/components/apps/GooseAppEditor.tsx @@ -0,0 +1,227 @@ +import { useEffect, useRef, useState } from 'react'; +import { MainPanelLayout } from '../Layout/MainPanelLayout'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { GooseApp, iterateApp, storeApp } from '../../api'; + +interface GooseAppEditorProps { + app?: GooseApp; + onReturn: () => void; +} + +const DEFAULT_JS = `class HelloWidget extends GooseWidget { + render() { + return \` +
+ Hello World +
+ \`; + } +}`; + +export default function GooseAppEditor({ app, onReturn }: GooseAppEditorProps) { + const [name, setName] = useState(app?.name || ''); + const [description, setDescription] = useState(app?.description || ''); + const [width, setWidth] = useState(app?.width?.toString() || '800'); + const [height, setHeight] = useState(app?.height?.toString() || '600'); + const [resizable, setResizable] = useState(app?.resizable ?? true); + const [prd, setPrd] = useState(app?.prd || ''); + const iframeRef = useRef>(null); + const [iframeErrors, setIframeErrors] = useState([]); + const [iframeReady, setIframeReady] = useState(false); + const [jsImplementation, setJsImplementation] = useState(app?.jsImplementation || DEFAULT_JS); + const [isIterating, setIsIterating] = useState(false); + const [iterationMessage, setIterationMessage] = useState(''); + const [iframeKey, setIframeKey] = useState(0); + const [containerHtml, setContainerHtml] = useState('

Loading ...

'); + + useEffect(() => { + const handleMessage = (event: globalThis.MessageEvent) => { + if (event.data.type === 'error') { + setIframeErrors((prev) => [...prev, event.data.message]); + } else if (event.data.type === 'ready') { + setIframeReady(true); + } + }; + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, []); + + useEffect(() => { + const loadHtml = async () => { + const gooseApp: GooseApp = { jsImplementation, name, prd }; + const html = await window.electron.previewGooseApp(gooseApp); + console.log(html); + setContainerHtml(html); + setIframeKey((prev) => prev + 1); + }; + loadHtml(); + }, [jsImplementation, name, prd]); + + const captureScreenshot = async (): Promise => { + if (!iframeRef.current) throw new Error('Iframe not ready'); + if (!iframeReady) throw new Error('Iframe not loaded yet'); + + const rect = iframeRef.current.getBoundingClientRect(); + const bounds = { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + + const pngBuffer = await window.electron.captureScreenShot(bounds); + return new Blob([pngBuffer.buffer as ArrayBuffer], { type: 'image/png' }); + }; + + const handleUpdate = async () => { + setIsIterating(true); + setIterationMessage('Starting iteration...'); + + let currentJs = jsImplementation; + let done = false; + + while (!done) { + const screenshot = await captureScreenshot(); + + const response = await iterateApp({ + body: { + jsImplementation: currentJs, + prd, + screenshot, + errors: iframeErrors.join('.'), + }, + throwOnError: true, + }); + + setIterationMessage(response.data.message); + + if (response.data.done) { + done = true; + setIterationMessage('Done! ' + response.data.message); + } else { + currentJs = response.data.jsImplementation!; + setJsImplementation(currentJs); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + + setIsIterating(false); + }; + + const handleSave = async () => { + try { + await storeApp({ + path: { name }, + body: { + app: { + name, + description: description || null, + width: width ? parseInt(width) : null, + height: height ? parseInt(height) : null, + resizable, + prd, + jsImplementation, + }, + }, + }); + } catch (_e) { + console.log(_e); + } + onReturn(); + }; + + return ( + +
+
+

{app ? 'Edit App' : 'Create App'}

+ +
+
+ setName(e.target.value)} + /> +
+
+ setDescription(e.target.value)} + /> +
+
+ setWidth(e.target.value)} + /> +
+
+ setHeight(e.target.value)} + /> +
+
+ setResizable(e.target.checked)} + className="w-4 h-4" + /> + +
+
+ +
+

Preview

+ + + + \ No newline at end of file diff --git a/ui/desktop/src/goose_apps/index.ts b/ui/desktop/src/goose_apps/index.ts index 1f7745fb24fd..e2664d7176b0 100644 --- a/ui/desktop/src/goose_apps/index.ts +++ b/ui/desktop/src/goose_apps/index.ts @@ -1,49 +1,63 @@ -import { app, BrowserWindow } from 'electron'; +import { app, BrowserWindow, ipcMain } from 'electron'; import path from 'node:path'; -import { GooseApp } from '../api'; -import fs from 'fs'; - -export function getContainerHtml(gapp: GooseApp): string { - const jsImplementation = gapp.jsImplementation!; - const appName = gapp.name; - - const assetsPath = app.isPackaged - ? path.join(process.resourcesPath, 'src/goose_apps/assets') - : path.join(__dirname, '../../src/goose_apps/assets'); - - console.log('__dirname', __dirname); - - let containerHtml = fs.readFileSync(path.join(assetsPath, 'container.html'), 'utf-8'); - const gooseWidgetJs = fs.readFileSync(path.join(assetsPath, 'goose-widget.js'), 'utf-8'); - const containerJs = fs.readFileSync(path.join(assetsPath, 'container.js'), 'utf-8'); - - const asScript = (src: string) => ``; - - const classMatch = jsImplementation.match(/class\s+(\w+)\s+extends\s+GooseWidget/); - if (!classMatch) { - throw new Error('No class extending GooseWidget found in implementation'); - } - const widgetClassName = classMatch[1]; - - const vars: [string, string][] = [ - ['TITLE', appName], - ['GOOSE_WIDGET_JS', asScript(gooseWidgetJs)], - ['CONTAINER_JS', asScript(containerJs)], - [ - 'WIDGET_JS', - asScript(jsImplementation + '\nwindow.' + widgetClassName + ' = ' + widgetClassName + ';'), - ], - ['WIDGET_CLASS_NAME', widgetClassName], - ]; - - for (const [key, val] of vars) { - containerHtml = containerHtml.replace(`{{ ${key} }}`, val); - } - - return containerHtml; +import { GooseApp, resumeAgent, startAgent } from '../api'; +import { handleMCPRequest } from './mcpRequests'; +import { injectMCPClient } from './injectMcpClient'; +import { Client } from '../api/client'; + +export interface InlineAppContext { + sessionId: string; + extensionName: string; } -export async function launchGooseApp(gapp: GooseApp): Promise { +const appContexts = new Map(); + +let handlersRegistered = false; +export function registerMCPAppHandlers(goosedClients :Map) { + if (handlersRegistered) return; + + ipcMain.handle('get-app-html', async (event) => { + const windowId = event.sender.id; + const context = appContexts.get(windowId); + if (!context) { + throw new Error('App context not found'); + } + return context.html; + }); + + ipcMain.handle('mcp-request', async (event, msg, inlineContext?: InlineAppContext) => { + const windowId = BrowserWindow.fromWebContents(event.sender)?.id; + if (!windowId) { + throw new Error('Window not found'); + } + + const client = goosedClients.get(windowId); + if (!client) { + throw new Error('Client not found for window'); + } + + if (inlineContext) { + const gapp: GooseApp = { + name: inlineContext.extensionName, + html: '', + width: null, + height: null, + resizable: true, + prd: '', + description: null, + }; + return handleMCPRequest(msg, gapp, inlineContext.sessionId, client); + } else { + const context = appContexts.get(windowId); + if (!context) { + throw Error('Context not found for windowId'); + } + return handleMCPRequest(msg, context.gapp, context.sessionId, client); + } + }); +} + +export async function launchGooseApp(gapp: GooseApp, client:Client): Promise { const desiredContentWidth = gapp.width || 800; const desiredContentHeight = gapp.height || 600; @@ -56,19 +70,52 @@ export async function launchGooseApp(gapp: GooseApp): Promise { nodeIntegration: false, contextIsolation: true, webSecurity: true, + preload: path.join(__dirname, 'preload.js'), }, }); - const [currentContentWidth, currentContentHeight] = appWindow.getContentSize(); - const [currentWindowWidth, currentWindowHeight] = appWindow.getSize(); + const appHtmlWithMCP = injectMCPClient(gapp); - const frameWidth = currentWindowWidth - currentContentWidth; - const frameHeight = currentWindowHeight - currentContentHeight; + const startResponse = await startAgent({ + client, + body: { + working_dir: app.getPath('home'), + }, + throwOnError: true, + }); - appWindow.setSize(desiredContentWidth + frameWidth, desiredContentHeight + frameHeight); + const sessionId = startResponse.data.id; - const html = getContainerHtml(gapp); - await appWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); + await resumeAgent({ + client, + body: { + session_id: sessionId, + load_model_and_extensions: true, + }, + throwOnError: true, + }); + + appContexts.set(appWindow.webContents.id, { + gapp, + html: appHtmlWithMCP, + sessionId, + }); + + appContexts.set(appWindow.webContents.id, { gapp, html: appHtmlWithMCP, sessionId }); + + appWindow.on('close', () => { + appContexts.delete(appWindow.webContents.id); + }); + + const containerPath = app.isPackaged + ? path.join(process.resourcesPath, 'goose_apps/container.html') + : path.join(__dirname, '../../src/goose_apps/container.html'); + + await appWindow.loadFile(containerPath); + appWindow.setTitle(gapp.name); + appWindow.setContentSize(gapp.width || 800, gapp.height || 600); appWindow.show(); + + return appWindow; } diff --git a/ui/desktop/src/goose_apps/injectMcpClient.ts b/ui/desktop/src/goose_apps/injectMcpClient.ts new file mode 100644 index 000000000000..e118bc833168 --- /dev/null +++ b/ui/desktop/src/goose_apps/injectMcpClient.ts @@ -0,0 +1,124 @@ +import { GooseApp } from '../api'; + +export function injectMCPClient(app: GooseApp): string { + const mcpClientScript = ` + +`; + + const html = app.html || ""; + + if (html.includes('')) { + return html.replace('', `${mcpClientScript}`); + } else if (html.includes(']*>/, (match) => `${match}${mcpClientScript}`); + } else { + return mcpClientScript + html; + } +} diff --git a/ui/desktop/src/goose_apps/mcpRequests.ts b/ui/desktop/src/goose_apps/mcpRequests.ts new file mode 100644 index 000000000000..0ddbc1bac755 --- /dev/null +++ b/ui/desktop/src/goose_apps/mcpRequests.ts @@ -0,0 +1,131 @@ +import { app, shell } from 'electron'; +import { callTool, GooseApp, readResource } from '../api'; +import { Client } from '../api/client'; + +interface JSONRPCRequest { + jsonrpc: '2.0'; + id?: string | number; + method: string; + params?: Record; +} + +interface JSONRPCResult { + [key: string]: unknown; +} + +interface HostContext { + theme?: 'light' | 'dark'; + displayMode?: 'inline' | 'fullscreen' | 'standalone'; + viewport?: { + width: number; + height: number; + }; +} + +interface InitializeResult { + protocolVersion: string; + hostCapabilities: Record; + hostInfo: { + name: string; + version: string; + }; + hostContext: HostContext; +} + +export async function handleMCPRequest( + msg: JSONRPCRequest, + gapp: GooseApp, + sessionId: string, + client: Client +): Promise { + const { method, params = {} } = msg; + + switch (method) { + case 'ui/initialize': + return { + protocolVersion: '2025-06-18', + hostCapabilities: {}, + hostInfo: { name: 'goose', version: app.getVersion() }, + hostContext: { + theme: 'dark', + displayMode: 'standalone', + viewport: { + width: gapp.width || 800, + height: gapp.height || 600, + }, + }, + } as InitializeResult; + + case 'tools/call': { + if (!params.name || typeof params.name !== 'string') { + throw new Error('Invalid tool name'); + } + if (!gapp.mcpServer) { + throw new Error('need an mcp server to call'); + } + + const fullToolName = `${gapp.mcpServer}__${params.name}`; + + const response = await callTool({ + client, + body: { + session_id: sessionId, + name: fullToolName, + arguments: (params.arguments as Record) || {}, + }, + throwOnError:true + }); + + if (!response.data) { + throw new Error('Tool call failed'); + } + + return { + content: response.data.content, + structuredContent: response.data.structured_content, + isError: response.data.is_error, + }; + } + + case 'resources/read': { + if (!params.uri || typeof params.uri !== 'string') { + throw new Error('Invalid resource URI'); + } + if (!sessionId) { + throw new Error('sessionId required for resource reads'); + } + + const response = await readResource({ + client, + body: { + session_id: sessionId, + uri: params.uri, + extension_name: gapp.name, + }, + throwOnError:true + }); + + if (!response.data) { + throw new Error('Resource read failed'); + } + + return { + content: response.data.html, + }; + } + + case 'ui/message': + console.log('ui/message not yet implemented:', params); + return {}; + + case 'ui/open-link': + if (!params.url || typeof params.url !== 'string') { + throw new Error('Invalid URL'); + } + await shell.openExternal(params.url); + return {}; + + default: + throw new Error(`Unknown method: ${method}`); + } +} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 866f74716487..885da9f7f3af 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -47,7 +47,7 @@ import { } from './utils/autoUpdater'; import { UPDATES_ENABLED } from './updates'; import './utils/recipeHash'; -import { getContainerHtml, launchGooseApp } from './goose_apps'; +import { launchGooseApp, registerMCPAppHandlers } from './goose_apps'; import { GooseApp } from './api'; import { Client, createClient, createConfig } from './api/client'; import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer'; @@ -553,7 +553,7 @@ const createChat = async ( scheduledJobId: scheduledJobId, }), ], - partition: 'persist:goose', // Add this line to ensure persistence + partition: 'persist:goose', }, }); @@ -1141,7 +1141,6 @@ ipcMain.on('react-ready', (event) => { log.info('React ready - window is prepared for deep links'); }); -// Handle external URL opening ipcMain.handle('open-external', async (_event, url: string) => { try { await shell.openExternal(url); @@ -1152,6 +1151,7 @@ ipcMain.handle('open-external', async (_event, url: string) => { } }); + // Handle directory chooser ipcMain.handle('directory-chooser', (_event) => { return openDirectoryDialog(); @@ -1728,6 +1728,8 @@ ipcMain.handle('get-allowed-extensions', async () => { return await getAllowList(); }); +registerMCPAppHandlers(goosedClients); + const createNewWindow = async (app: App, dir?: string | null) => { const recentDirs = loadRecentDirs(); const openDir = dir || (recentDirs.length > 0 ? recentDirs[0] : undefined); @@ -2110,12 +2112,24 @@ async function appMain() { } ); - ipcMain.handle('launch-goose-app', async (_event, app: GooseApp) => { - await launchGooseApp(app); - }); + ipcMain.handle('launch-goose-app', async (event, gapp: GooseApp) => { + const launchingWindowId = BrowserWindow.fromWebContents(event.sender)?.id; + if (!launchingWindowId) { + throw new Error('Could not find launching window'); + } - ipcMain.handle('preview-goose-app', async (_event, app: GooseApp) => { - return getContainerHtml(app); + const launchingClient = goosedClients.get(launchingWindowId); + if (!launchingClient) { + throw new Error('No client found for launching window'); + } + + const appWindow = await launchGooseApp(gapp, launchingClient); + + goosedClients.set(appWindow.id, launchingClient); + + appWindow.on('close', () => { + goosedClients.delete(appWindow.id); + }); }); ipcMain.on('close-window', (event) => { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 66a56fb9eeae..aba88dbdf219 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -1,6 +1,7 @@ import Electron, { contextBridge, ipcRenderer, webUtils } from 'electron'; import { Recipe } from './recipe'; import { GooseApp } from './api'; +import { InlineAppContext } from './goose_apps'; interface NotificationData { title: string; @@ -57,7 +58,6 @@ type ElectronAPI = { recipeId?: string ) => void; launchGooseApp: (app: GooseApp) => Promise<{ success: boolean; error?: string }>; - previewGooseApp: (app: GooseApp) => Promise; logInfo: (txt: string) => void; showNotification: (data: NotificationData) => void; showMessageBox: (options: MessageBoxOptions) => Promise; @@ -164,7 +164,6 @@ const electronAPI: ElectronAPI = { recipeId ), launchGooseApp: (app: GooseApp) => ipcRenderer.invoke('launch-goose-app', app), - previewGooseApp: (app: GooseApp) => ipcRenderer.invoke('preview-goose-app', app), logInfo: (txt: string) => ipcRenderer.send('logInfo', txt), showNotification: (data: NotificationData) => ipcRenderer.send('notify', data), showMessageBox: (options: MessageBoxOptions) => ipcRenderer.invoke('show-message-box', options), @@ -278,6 +277,11 @@ const appConfigAPI: AppConfigAPI = { // Expose the APIs contextBridge.exposeInMainWorld('electron', electronAPI); contextBridge.exposeInMainWorld('appConfig', appConfigAPI); +contextBridge.exposeInMainWorld('__gooseMCP', { + getAppHtml: () => ipcRenderer.invoke('get-app-html'), + handleRequest: (msg: unknown, inlineContext?: InlineAppContext) => + ipcRenderer.invoke('mcp-request', msg, inlineContext), +}); // Type declaration for TypeScript declare global { From 5941fe525068e709ddcc29980b013e7425379d96 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 10 Dec 2025 12:16:33 -0500 Subject: [PATCH 23/31] More --- crates/goose-server/src/openapi.rs | 2 + crates/goose-server/src/routes/goose_apps.rs | 125 +++++++++++++++--- crates/goose/src/agents/extension_manager.rs | 20 ++- ui/desktop/openapi.json | 102 +++++++++++++- ui/desktop/src/App.tsx | 6 +- ui/desktop/src/api/sdk.gen.ts | 14 +- ui/desktop/src/api/types.gen.ts | 57 +++++++- .../src/components/apps/GooseAppEditor.tsx | 3 +- .../src/components/apps/GooseAppsView.tsx | 111 +++++++++++++--- 9 files changed, 389 insertions(+), 51 deletions(-) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 62516f9f1233..e5069d1630f9 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -391,6 +391,8 @@ derive_utoipa!(Icon as IconSchema); super::routes::goose_apps::iterate_app, super::routes::goose_apps::store_app, super::routes::goose_apps::delete_app, + super::routes::goose_apps::import_app, + super::routes::goose_apps::export_app, super::routes::recipe::scan_recipe, super::routes::recipe::list_recipes, super::routes::recipe::delete_recipe, diff --git a/crates/goose-server/src/routes/goose_apps.rs b/crates/goose-server/src/routes/goose_apps.rs index e54abf40cb92..e941ce89f4c9 100644 --- a/crates/goose-server/src/routes/goose_apps.rs +++ b/crates/goose-server/src/routes/goose_apps.rs @@ -1,21 +1,21 @@ use crate::routes::errors::ErrorResponse; use crate::state::AppState; +use axum::extract::Query; use axum::{ extract::{Path, State}, http::StatusCode, routing::{delete, get, post, put}, Json, Router, }; +use goose::agents::ExtensionManager; use goose::conversation::message::{Message, MessageContent}; use goose::goose_apps::{GooseApp, GooseAppsManager}; use goose::providers::create_with_named_model; use serde::{Deserialize, Serialize}; use std::sync::Arc; -use axum::extract::Query; use tokio_util::sync::CancellationToken; use tracing::{error, warn}; use utoipa::ToSchema; -use goose::agents::ExtensionManager; #[derive(Deserialize, utoipa::IntoParams, ToSchema)] pub struct ListAppsRequest { @@ -53,6 +53,8 @@ pub struct IterateAppRequest { pub html: String, pub screenshot_base64: Option, pub errors: String, + pub width: u32, + pub height: u32, } #[derive(Serialize, Deserialize, ToSchema)] @@ -114,7 +116,10 @@ async fn list_mcp_apps( }); } Err(e) => { - warn!("Failed to read resource {} from {}: {}", resource.uri, extension_name, e); + warn!( + "Failed to read resource {} from {}: {}", + resource.uri, extension_name, e + ); } } } @@ -309,9 +314,11 @@ Here is the specification of what the user wants the app to do: {prd} {errors} +{screenshot_instruction} -You are also provided a screenshot. Compare the current implementation and the screenshot -with the specification/PRD. If everything looks good and matches the spec, reply with: +Make sure the app matches the desired size the user specified of {width} width and {height} height + +If everything looks good and matches the spec, reply with: DONE MSG: @@ -338,11 +345,7 @@ If you need to adjust the HTML/CSS/JavaScript, return the complete HTML: ```` MSG: -Note: if you change the HTML, you will be called back with the next render, so you -don't have to get it right in one iteration. For complicated things, use multiple turns. - -In the message, describe exactly what you see on the screenshot (or say there's no screenshot), -then explain the changes you made or need to make. +{screenshot_note} "#; fn iterate_app_prompt(iterate_on: &IterateAppRequest) -> String { @@ -354,10 +357,25 @@ fn iterate_app_prompt(iterate_on: &IterateAppRequest) -> String { iterate_on.errors ) }; + + let (screenshot_instruction, screenshot_note) = if iterate_on.screenshot_base64.is_some() { + ( + + "You are also provided a screenshot. Compare the current implementation and the screenshot with the specification/PRD.", + "Note: if you change the HTML, you will be called back with the next render, so you don't have to get it right in one iteration. For complicated things, use multiple turns.\n\nIn the message, describe exactly what you see on the screenshot, then explain the changes you made or need to make." + ) + } else { + ("", "") + }; + ITERATE_APP_PROMPT .replace("{prd}", &iterate_on.prd) .replace("{html}", &iterate_on.html) .replace("{errors}", &errors) + .replace("{screenshot_instruction}", screenshot_instruction) + .replace("{screenshot_note}", screenshot_note) + .replace("{width}", &iterate_on.width.to_string()) + .replace("{height}", &iterate_on.height.to_string()) } fn extract_code_and_message(text: &str) -> (Option, String) { @@ -415,16 +433,16 @@ async fn iterate_app( let model_name: String = config.get_goose_model()?; let provider = create_with_named_model(&provider_name, &model_name).await?; - let message_with_image = Message::user() - .with_text(prompt) - .with_image(&request.screenshot_base64, "image/png".to_string()); + let message = if let Some(ref screenshot) = request.screenshot_base64 { + Message::user() + .with_text(prompt) + .with_image(screenshot, "image/png".to_string()) + } else { + Message::user().with_text(prompt) + }; let (response, _) = provider - .complete( - "You are a helpful coding assistant.", - &[message_with_image], - &[], - ) + .complete("You are a helpful coding assistant.", &[message], &[]) .await .map_err(|e| ErrorResponse::internal(format!("Provider error: {}", e)))?; @@ -478,6 +496,75 @@ async fn delete_app( })) } +#[utoipa::path( + get, + path = "/apps/export/{name}", + responses( + (status = 200, description = "App HTML exported successfully"), + (status = 404, description = "App not found", body = ErrorResponse), + ), + params( + ("name" = String, Path, description = "Name of the app to export") + ), + security( + ("api_key" = []) + ), + tag = "App Management" +)] +async fn export_app( + State(_state): State>, + Path(name): Path, +) -> Result { + let manager = GooseAppsManager::new()?; + let app = manager.get_app(&name)?; + + match app { + Some(app) => app + .to_file_content() + .map_err(|e| ErrorResponse::internal(format!("Failed to generate HTML: {}", e))), + None => Err(ErrorResponse::internal("App not found")), + } +} + +#[utoipa::path( + post, + path = "/apps/import", + request_body = String, + responses( + (status = 201, description = "App imported successfully", body = SuccessResponse), + (status = 400, description = "Bad request - Invalid HTML", body = ErrorResponse), + ), + security( + ("api_key" = []) + ), + tag = "App Management" +)] +async fn import_app( + State(_state): State>, + body: String, +) -> Result<(StatusCode, Json), ErrorResponse> { + let manager = GooseAppsManager::new()?; + + let mut app = GooseApp::from_html(&body) + .map_err(|e| ErrorResponse::internal(format!("Invalid Goose App HTML: {}", e)))?; + + let original_name = app.name.clone(); + let mut counter = 1; + while manager.app_exists(&app.name) { + app.name = format!("{}_{}", original_name, counter); + counter += 1; + } + + manager.update_app(&app)?; + + Ok(( + StatusCode::CREATED, + Json(SuccessResponse { + message: format!("App '{}' imported successfully", app.name), + }), + )) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/apps", post(create_app)) @@ -486,5 +573,7 @@ pub fn routes(state: Arc) -> Router { .route("/apps/app/{name}", put(store_app)) .route("/apps/app/{name}", delete(delete_app)) .route("/apps/app/{name}", get(get_app)) + .route("/apps/import", post(import_app)) + .route("/apps/export/{name}", get(export_app)) .with_state(state) } diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 550bd9ca5a9c..1d7684c1c5a5 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -39,7 +39,10 @@ use crate::config::{get_all_extensions, Config}; use crate::oauth::oauth_flow; use crate::prompt_template; use crate::subprocess::configure_command_no_window; -use rmcp::model::{CallToolRequestParam, Content, ErrorCode, ErrorData, GetPromptResult, Prompt, RawContent, Resource, ResourceContents, ServerInfo, Tool}; +use rmcp::model::{ + CallToolRequestParam, Content, ErrorCode, ErrorData, GetPromptResult, Prompt, RawContent, + Resource, ResourceContents, ServerInfo, Tool, +}; use rmcp::transport::auth::AuthClient; use schemars::_private::NoSerialize; use serde_json::Value; @@ -843,9 +846,7 @@ impl ExtensionManager { Ok(result) } - pub async fn get_ui_resources( - &self, - ) -> Result, ErrorData> { + pub async fn get_ui_resources(&self) -> Result, ErrorData> { let mut ui_resources = Vec::new(); let extensions_to_check: Vec<(String, McpClientBox)> = { @@ -862,9 +863,16 @@ impl ExtensionManager { info!("Checking extension: {}", extension_name); let client_guard = client.lock().await; - match client_guard.list_resources(None, CancellationToken::default()).await { + match client_guard + .list_resources(None, CancellationToken::default()) + .await + { Ok(list_response) => { - info!("List resources for {}: {} resources found", extension_name, list_response.resources.len()); + info!( + "List resources for {}: {} resources found", + extension_name, + list_response.resources.len() + ); for resource in list_response.resources { if resource.uri.starts_with("ui://") { ui_resources.push((extension_name.clone(), resource)); diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index d47c498747d7..6f4058469d89 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -746,6 +746,90 @@ ] } }, + "/apps/export/{name}": { + "get": { + "tags": [ + "App Management" + ], + "operationId": "export_app", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Name of the app to export", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "App HTML exported successfully" + }, + "404": { + "description": "App not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, + "/apps/import": { + "post": { + "tags": [ + "App Management" + ], + "operationId": "import_app", + "requestBody": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "App imported successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "400": { + "description": "Bad request - Invalid HTML", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, "/apps/iterate": { "post": { "tags": [ @@ -4005,13 +4089,19 @@ "required": [ "prd", "html", - "screenshotBase64", - "errors" + "errors", + "width", + "height" ], "properties": { "errors": { "type": "string" }, + "height": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, "html": { "type": "string" }, @@ -4019,7 +4109,13 @@ "type": "string" }, "screenshotBase64": { - "type": "string" + "type": "string", + "nullable": true + }, + "width": { + "type": "integer", + "format": "int32", + "minimum": 0 } } }, diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index ca2c86adb743..92bc84d3cd8f 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -39,8 +39,8 @@ import GooseAppsView from './components/apps/GooseAppsView'; import { NoProviderOrModelError, useAgent } from './hooks/useAgent'; import { useNavigation } from './hooks/useNavigation'; import { errorMessage } from './utils/conversionUtils'; -import Hub from './components/hub'; -import Pair, { PairRouteState } from './components/pair'; +import Hub from './components/Hub'; +import Pair, { PairRouteState } from './components/Pair'; // Route Components const HubRouteWrapper = ({ isExtensionsLoading }: { isExtensionsLoading: boolean }) => { @@ -672,7 +672,7 @@ export function AppInner() { } /> } /> } /> - } /> + } /> = Options2 & { /** @@ -132,6 +132,18 @@ export const storeApp = (options: Options< } }); +export const exportApp = (options: Options) => (options.client ?? client).get({ url: '/apps/export/{name}', ...options }); + +export const importApp = (options: Options) => (options.client ?? client).post({ + bodySerializer: null, + url: '/apps/import', + ...options, + headers: { + 'Content-Type': 'text/plain', + ...options.headers + } +}); + export const iterateApp = (options: Options) => (options.client ?? client).post({ url: '/apps/iterate', ...options, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index f2ecd03d2a46..c6f91f1e6594 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -382,9 +382,11 @@ export type InspectJobResponse = { export type IterateAppRequest = { errors: string; + height: number; html: string; prd: string; - screenshotBase64: string; + screenshotBase64?: string | null; + width: number; }; export type IterateAppResponse = { @@ -1579,6 +1581,59 @@ export type StoreAppResponses = { export type StoreAppResponse = StoreAppResponses[keyof StoreAppResponses]; +export type ExportAppData = { + body?: never; + path: { + /** + * Name of the app to export + */ + name: string; + }; + query?: never; + url: '/apps/export/{name}'; +}; + +export type ExportAppErrors = { + /** + * App not found + */ + 404: ErrorResponse; +}; + +export type ExportAppError = ExportAppErrors[keyof ExportAppErrors]; + +export type ExportAppResponses = { + /** + * App HTML exported successfully + */ + 200: unknown; +}; + +export type ImportAppData = { + body: string; + path?: never; + query?: never; + url: '/apps/import'; +}; + +export type ImportAppErrors = { + /** + * Bad request - Invalid HTML + */ + 400: ErrorResponse; +}; + +export type ImportAppError = ImportAppErrors[keyof ImportAppErrors]; + +export type ImportAppResponses = { + /** + * App imported successfully + */ + 201: SuccessResponse; +}; + +export type ImportAppResponse = ImportAppResponses[keyof ImportAppResponses]; + export type IterateAppData = { body: IterateAppRequest; path?: never; diff --git a/ui/desktop/src/components/apps/GooseAppEditor.tsx b/ui/desktop/src/components/apps/GooseAppEditor.tsx index 818412e4b877..b5dd735bcc7a 100644 --- a/ui/desktop/src/components/apps/GooseAppEditor.tsx +++ b/ui/desktop/src/components/apps/GooseAppEditor.tsx @@ -105,13 +105,14 @@ export default function GooseAppEditor({ app, onReturn }: GooseAppEditorProps) { prd, screenshotBase64, errors: iframeErrors.join('\n'), + width: parseInt(width) || 240, height: parseInt(height) || 320 }, throwOnError: true, }); setIterationMessage(response.data.message); - if (response.data.done) { + if (response.data.done || !screenshotBase64) { done = true; setIterationMessage('Done! ' + response.data.message); } else { diff --git a/ui/desktop/src/components/apps/GooseAppsView.tsx b/ui/desktop/src/components/apps/GooseAppsView.tsx index 37ab09819fae..eafd7f180efc 100644 --- a/ui/desktop/src/components/apps/GooseAppsView.tsx +++ b/ui/desktop/src/components/apps/GooseAppsView.tsx @@ -1,8 +1,15 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { MainPanelLayout } from '../Layout/MainPanelLayout'; import { Button } from '../ui/button'; -import { Play, Plus, Trash, Pencil } from 'lucide-react'; -import { createApp, deleteApp, GooseApp, listApps, resumeAgent } from '../../api'; +import { Play, Plus, Trash, Pencil, Download, Upload } from 'lucide-react'; +import { + deleteApp, + exportApp, + GooseApp, + importApp, + listApps, + resumeAgent, +} from '../../api'; import GooseAppEditor from './GooseAppEditor'; import { createSession } from '../../sessions'; @@ -70,17 +77,17 @@ export default function GooseAppsView() { try { const response = await listApps({ throwOnError: true, query: { session_id: appsSessionId } }); const apps = response.data?.apps || []; - if (apps.length === 0) { - await createApp({ - throwOnError: true, - body: { - app: { - name: '', - }, - }, - }); - return await loadApps(); - } + // if (apps.length === 0) { + // await createApp({ + // throwOnError: true, + // body: { + // app: { + // name: '', + // }, + // }, + // }); + // return await loadApps(); + // } setApps(apps); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load apps'); @@ -91,6 +98,53 @@ export default function GooseAppsView() { loadApps(); }, [loadApps]); + const handleDownloadApp = async (app: GooseApp) => { + try { + const response = await exportApp({ + throwOnError: true, + path: { name: app.name } + }); + + if (response.data) { + const blob = new Blob([response.data as string], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${app.name}.html`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to export app'); + } + }; + + const fileInputRef = useRef(null); + + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + const handleUploadApp = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + try { + const text = await file.text(); + await importApp({ + throwOnError: true, + body: text, + }); + await loadApps(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to import app'); + } + + event.target.value = ''; + }; + const handleLaunchApp = async (app: GooseApp) => { await window.electron.launchGooseApp(app); }; @@ -140,14 +194,27 @@ export default function GooseAppsView() { return ( -
-
+
+

Apps

+
-

- Self-contained Html applications that run within Goose. +

+ Self-contained HTML applications that run within Goose.

@@ -188,6 +255,14 @@ export default function GooseAppsView() { > +