diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 62516f9f12..e5069d1630 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 e54abf40cb..e941ce89f4 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 550bd9ca5a..1d7684c1c5 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 d47c498747..6f4058469d 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 ca2c86adb7..92bc84d3cd 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 f2ecd03d2a..c6f91f1e65 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 818412e4b8..b5dd735bcc 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 37ab09819f..eafd7f180e 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() { > +