diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 3cc8cac406e7..5c74974c37a1 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -366,6 +366,8 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::read_resource, super::routes::agent::call_tool, super::routes::agent::list_apps, + super::routes::agent::export_app, + super::routes::agent::import_app, super::routes::agent::update_from_session, super::routes::agent::agent_add_extension, super::routes::agent::agent_remove_extension, @@ -544,6 +546,8 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::CallToolResponse, super::routes::agent::ListAppsRequest, super::routes::agent::ListAppsResponse, + super::routes::agent::ImportAppRequest, + super::routes::agent::ImportAppResponse, super::routes::agent::StartAgentRequest, super::routes::agent::ResumeAgentRequest, super::routes::agent::StopAgentRequest, diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index ee0eafddaf6b..b16d6f506b03 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -32,7 +32,7 @@ use goose::{ use rmcp::model::{CallToolRequestParam, Content}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; use tokio_util::sync::CancellationToken; @@ -1000,9 +1000,9 @@ async fn list_apps( })?; if let Some(cache) = cache.as_ref() { - let active_extensions: std::collections::HashSet = apps + let active_extensions: HashSet = apps .iter() - .filter_map(|app| app.mcp_server.clone()) + .flat_map(|app| app.mcp_servers.iter().cloned()) .collect(); for extension_name in active_extensions { @@ -1024,6 +1024,122 @@ async fn list_apps( Ok(Json(ListAppsResponse { apps })) } +#[utoipa::path( + get, + path = "/agent/export_app/{name}", + params( + ("name" = String, Path, description = "Name of the app to export") + ), + responses( + (status = 200, description = "App HTML exported successfully", body = String), + (status = 404, description = "App not found", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ), + security( + ("api_key" = []) + ), + tag = "Agent" +)] +async fn export_app( + axum::extract::Path(name): axum::extract::Path, +) -> Result { + let cache = McpAppCache::new().map_err(|e| ErrorResponse { + message: format!("Failed to access app cache: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + let apps = cache.list_apps().map_err(|e| ErrorResponse { + message: format!("Failed to list apps: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + let app = apps + .into_iter() + .find(|a| a.resource.name == name) + .ok_or_else(|| ErrorResponse { + message: format!("App '{}' not found", name), + status: StatusCode::NOT_FOUND, + })?; + + let html = app.to_html().map_err(|e| ErrorResponse { + message: format!("Failed to generate HTML: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + Ok(html) +} + +#[derive(Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ImportAppRequest { + pub html: String, +} + +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ImportAppResponse { + pub name: String, + pub message: String, +} + +#[utoipa::path( + post, + path = "/agent/import_app", + request_body = ImportAppRequest, + responses( + (status = 201, description = "App imported successfully", body = ImportAppResponse), + (status = 400, description = "Bad request - Invalid HTML", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ), + security( + ("api_key" = []) + ), + tag = "Agent" +)] +async fn import_app( + Json(body): Json, +) -> Result<(StatusCode, Json), ErrorResponse> { + let cache = McpAppCache::new().map_err(|e| ErrorResponse { + message: format!("Failed to access app cache: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + let mut app = GooseApp::from_html(&body.html).map_err(|e| ErrorResponse { + message: format!("Invalid Goose App HTML: {}", e), + status: StatusCode::BAD_REQUEST, + })?; + + let original_name = app.resource.name.clone(); + let mut counter = 1; + + let existing_apps = cache.list_apps().unwrap_or_default(); + let existing_names: HashSet = existing_apps + .iter() + .map(|a| a.resource.name.clone()) + .collect(); + + while existing_names.contains(&app.resource.name) { + app.resource.name = format!("{}_{}", original_name, counter); + app.resource.uri = format!("ui://apps/{}", app.resource.name); + counter += 1; + } + + app.mcp_servers = vec!["apps".to_string()]; + + cache.store_app(&app).map_err(|e| ErrorResponse { + message: format!("Failed to store app: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + Ok(( + StatusCode::CREATED, + Json(ImportAppResponse { + name: app.resource.name.clone(), + message: format!("App '{}' imported successfully", app.resource.name), + }), + )) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/agent/start", post(start_agent)) @@ -1034,6 +1150,8 @@ pub fn routes(state: Arc) -> Router { .route("/agent/read_resource", post(read_resource)) .route("/agent/call_tool", post(call_tool)) .route("/agent/list_apps", get(list_apps)) + .route("/agent/export_app/{name}", get(export_app)) + .route("/agent/import_app", post(import_app)) .route("/agent/update_provider", post(update_agent_provider)) .route("/agent/update_from_session", post(update_from_session)) .route("/agent/add_extension", post(agent_add_extension)) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index e62c5c33dbf8..715f982a5132 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1301,6 +1301,26 @@ impl Agent { ToolStreamItem::Result(output) => { let output = call_tool_result::validate(output); + // Platform extensions use meta as a way to publish notifications. Ideally we'd + // send the notifications directly, but the current plumbing doesn't support that + // well: + if let Ok(ref call_result) = output { + if let Some(ref meta) = call_result.meta { + if let Some(notification_data) = meta.0.get("platform_notification") { + if let Some(method) = notification_data.get("method").and_then(|v| v.as_str()) { + let params = notification_data.get("params").cloned(); + let custom_notification = rmcp::model::CustomNotification::new( + method.to_string(), + params, + ); + + let server_notification = rmcp::model::ServerNotification::CustomNotification(custom_notification); + yield AgentEvent::McpNotification((request_id.clone(), server_notification)); + } + } + } + } + if enable_extension_request_ids.contains(&request_id) && output.is_err() { diff --git a/crates/goose/src/agents/apps_extension.rs b/crates/goose/src/agents/apps_extension.rs new file mode 100644 index 000000000000..76a6ba776fcf --- /dev/null +++ b/crates/goose/src/agents/apps_extension.rs @@ -0,0 +1,669 @@ +use crate::agents::extension::PlatformExtensionContext; +use crate::agents::mcp_client::{Error, McpClientTrait}; +use crate::config::paths::Paths; +use crate::conversation::message::Message; +use crate::goose_apps::McpAppResource; +use crate::goose_apps::{GooseApp, WindowProps}; +use crate::prompt_template::render_template; +use crate::providers::base::Provider; +use async_trait::async_trait; +use rmcp::model::{ + CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListResourcesResult, + ListToolsResult, Meta, ProtocolVersion, RawResource, ReadResourceResult, Resource, + ResourceContents, ResourcesCapability, ServerCapabilities, Tool as McpTool, ToolsCapability, +}; +use schemars::{schema_for, JsonSchema}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; + +pub static EXTENSION_NAME: &str = "apps"; + +const DEFAULT_WINDOW_PROPS: WindowProps = WindowProps { + width: 800, + height: 600, + resizable: true, +}; + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +struct CreateAppParams { + /// What the app should do - a description or PRD that will be used to generate the app + prd: String, +} + +/// Parameters for iterate_app tool +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +struct IterateAppParams { + /// Name of the app to iterate on + name: String, + /// Feedback or requested changes to improve the app + feedback: String, +} + +/// Parameters for delete_app tool +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +struct DeleteAppParams { + /// Name of the app to delete + name: String, +} + +/// Parameters for list_apps tool +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +struct ListAppsParams { + // No parameters needed - lists all apps +} + +/// Response from create_app_content tool +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +struct CreateAppContentResponse { + /// App name (lowercase, hyphens allowed, no spaces) + name: String, + /// Brief description of what the app does (1-2 sentences, max 100 chars) + description: String, + /// Complete HTML code for the app, from to + html: String, + /// Window width in pixels (recommended: 400-1600) + width: Option, + /// Window height in pixels (recommended: 300-1200) + height: Option, + /// Whether the window should be resizable + resizable: Option, +} + +/// Response from update_app_content tool +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +struct UpdateAppContentResponse { + /// Updated description of what the app does (1-2 sentences, max 100 chars) + description: String, + /// Complete updated HTML code for the app, from to + html: String, + /// Updated PRD reflecting the current state of the app after this iteration + prd: String, + /// Updated window width in pixels (optional - only if size should change) + width: Option, + /// Updated window height in pixels (optional - only if size should change) + height: Option, + /// Updated resizable property (optional - only if it should change) + resizable: Option, +} + +pub struct AppsManagerClient { + info: InitializeResult, + context: PlatformExtensionContext, + apps_dir: PathBuf, +} + +impl AppsManagerClient { + pub fn new(context: PlatformExtensionContext) -> Result { + let apps_dir = Paths::in_data_dir(EXTENSION_NAME); + + fs::create_dir_all(&apps_dir) + .map_err(|e| format!("Failed to create apps directory: {}", e))?; + + let client = Self { + info: Self::create_info(), + context, + apps_dir, + }; + + client.ensure_default_apps()?; + + Ok(client) + } + + fn create_info() -> InitializeResult { + InitializeResult { + protocol_version: ProtocolVersion::V_2025_03_26, + capabilities: ServerCapabilities { + tools: Some(ToolsCapability { + list_changed: Some(false), + }), + resources: Some(ResourcesCapability { + subscribe: Some(false), + list_changed: Some(false), + }), + prompts: None, + completions: None, + experimental: None, + tasks: None, + logging: None, + }, + server_info: Implementation { + name: EXTENSION_NAME.to_string(), + title: Some("Apps Manager".to_string()), + version: "1.0.0".to_string(), + icons: None, + website_url: None, + }, + instructions: Some( + "Use this extension to create, manage, and iterate on custom HTML/CSS/JavaScript apps." + .to_string(), + ), + } + } + + fn ensure_default_apps(&self) -> Result<(), String> { + // TODO(Douwe): we have the same check in cache, consider unfiying that + const CLOCK_HTML: &str = include_str!("../goose_apps/clock.html"); + + // Check if clock app exists + let clock_path = self.apps_dir.join("clock.html"); + if !clock_path.exists() { + // Parse and save the default clock app + let clock_app = GooseApp::from_html(CLOCK_HTML)?; + self.save_app(&clock_app)?; + } + + Ok(()) + } + + fn list_stored_apps(&self) -> Result, String> { + let mut apps = Vec::new(); + + let entries = fs::read_dir(&self.apps_dir) + .map_err(|e| format!("Failed to read apps directory: {}", e))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) == Some("html") { + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + apps.push(stem.to_string()); + } + } + } + + apps.sort(); + Ok(apps) + } + + fn load_app(&self, name: &str) -> Result { + let path = self.apps_dir.join(format!("{}.html", name)); + + let html = + fs::read_to_string(&path).map_err(|e| format!("Failed to read app file: {}", e))?; + + GooseApp::from_html(&html) + } + + fn save_app(&self, app: &GooseApp) -> Result<(), String> { + let path = self.apps_dir.join(format!("{}.html", app.resource.name)); + + let html_content = app.to_html()?; + + fs::write(&path, html_content).map_err(|e| format!("Failed to write app file: {}", e))?; + + Ok(()) + } + + fn delete_app(&self, name: &str) -> Result<(), String> { + let path = self.apps_dir.join(format!("{}.html", name)); + + fs::remove_file(&path).map_err(|e| format!("Failed to delete app file: {}", e))?; + + Ok(()) + } + + fn with_platform_notification( + &self, + result: CallToolResult, + event_type: &str, + app_name: &str, + ) -> CallToolResult { + let mut params = serde_json::Map::new(); + params.insert("app_name".to_string(), json!(app_name)); + self.context + .result_with_platform_notification(result, EXTENSION_NAME, event_type, params) + } + + async fn get_provider(&self) -> Result, String> { + let extension_manager = self + .context + .extension_manager + .as_ref() + .and_then(|weak| weak.upgrade()) + .ok_or("Extension manager not available")?; + + let provider_guard = extension_manager.get_provider().lock().await; + + let provider = provider_guard + .as_ref() + .ok_or("Provider not available")? + .clone(); + + Ok(provider) + } + + fn schema() -> JsonObject { + serde_json::to_value(schema_for!(T)) + .map(|v| { + v.as_object() + .expect("schema_for!(T) must serialize to a JSON object") + .clone() + }) + .expect("Schema serialization must succeed") + } + + fn create_app_content_tool() -> rmcp::model::Tool { + rmcp::model::Tool::new( + "create_app_content".to_string(), + "Generate content for a new Goose app. Returns the HTML code, app name, description, and window properties.".to_string(), + Self::schema::(), + ) + } + + fn update_app_content_tool() -> rmcp::model::Tool { + rmcp::model::Tool::new( + "update_app_content".to_string(), + "Generate updated content for an existing Goose app. Returns the improved HTML code, updated description, and optionally updated window properties.".to_string(), + Self::schema::(), + ) + } + + async fn generate_new_app_content( + &self, + session_id: &str, + prd: &str, + ) -> Result { + let provider = self.get_provider().await?; + + let existing_apps = self.list_stored_apps().unwrap_or_default(); + let existing_names = existing_apps.join(", "); + + let context: HashMap<&str, &str> = HashMap::new(); + let system_prompt = render_template("apps_create.md", &context) + .map_err(|e| format!("Failed to render template: {}", e))?; + + let user_prompt = format!( + "REQUESTED APP:\n{}\n\nEXISTING APPS: {}\n\nGenerate a unique name (lowercase with hyphens, not in existing apps), a brief description, complete HTML, and appropriate window size for this app.", + prd, + if existing_names.is_empty() { "none" } else { &existing_names } + ); + + let messages = vec![Message::user().with_text(&user_prompt)]; + let tools = vec![Self::create_app_content_tool()]; + + let (response, _usage) = provider + .complete(session_id, &system_prompt, &messages, &tools) + .await + .map_err(|e| format!("LLM call failed: {}", e))?; + + extract_tool_response(&response, "create_app_content") + } + + async fn generate_updated_app_content( + &self, + session_id: &str, + existing_html: &str, + existing_prd: &str, + feedback: &str, + ) -> Result { + let provider = self.get_provider().await?; + + let context: HashMap<&str, &str> = HashMap::new(); + let system_prompt = render_template("apps_iterate.md", &context) + .map_err(|e| format!("Failed to render template: {}", e))?; + + let user_prompt = format!( + "ORIGINAL PRD:\n{}\n\nCURRENT APP:\n```html\n{}\n```\n\nFEEDBACK: {}\n\nImplement the requested changes and return:\n1. Updated description\n2. Updated HTML implementing the feedback\n3. Updated PRD reflecting the current state of the app\n4. Optionally updated window size if appropriate", + existing_prd, + existing_html, + feedback + ); + + let messages = vec![Message::user().with_text(&user_prompt)]; + let tools = vec![Self::update_app_content_tool()]; + + let (response, _usage) = provider + .complete(session_id, &system_prompt, &messages, &tools) + .await + .map_err(|e| format!("LLM call failed: {}", e))?; + + extract_tool_response(&response, "update_app_content") + } + + async fn handle_list_apps( + &self, + _arguments: Option, + ) -> Result { + let app_names = self.list_stored_apps()?; + + if app_names.is_empty() { + return Ok(CallToolResult::success(vec![Content::text( + "No apps found. Create your first app with the create_app tool!".to_string(), + )])); + } + + let mut apps_info = vec![format!("Found {} app(s):\n", app_names.len())]; + + for name in app_names { + match self.load_app(&name) { + Ok(app) => { + let description = app + .resource + .description + .as_deref() + .unwrap_or("No description"); + + let size = if let Some(ref props) = app.window_props { + format!(" ({}x{})", props.width, props.height) + } else { + String::new() + }; + + apps_info.push(format!("- {}{}: {}", name, size, description)); + } + Err(e) => { + apps_info.push(format!("- {}: (error loading: {})", name, e)); + } + } + } + + Ok(CallToolResult::success(vec![Content::text( + apps_info.join("\n"), + )])) + } + + async fn handle_create_app( + &self, + session_id: &str, + arguments: Option, + ) -> Result { + let args = arguments.ok_or("Missing arguments")?; + let prd = extract_string(&args, "prd")?; + + let content = self.generate_new_app_content(session_id, &prd).await?; + + if self.load_app(&content.name).is_ok() { + return Err(format!( + "App '{}' already exists (generated name conflicts with existing app).", + content.name + )); + } + + let app = GooseApp { + resource: McpAppResource { + uri: format!("ui://apps/{}", content.name), + name: content.name.clone(), + description: Some(content.description), + mime_type: "text/html;profile=mcp-app".to_string(), + text: Some(content.html), + blob: None, + meta: None, + }, + mcp_servers: vec![EXTENSION_NAME.to_string()], + window_props: Some(WindowProps { + width: content.width.unwrap_or(DEFAULT_WINDOW_PROPS.width), + height: content.height.unwrap_or(DEFAULT_WINDOW_PROPS.height), + resizable: content.resizable.unwrap_or(DEFAULT_WINDOW_PROPS.resizable), + }), + prd: Some(prd), + }; + + self.save_app(&app)?; + + let result = CallToolResult::success(vec![Content::text(format!( + "Created app '{}'! It should have automatically opened in a new window. You can always find it again in the [Apps] tab.", + content.name + ))]); + + Ok(self.with_platform_notification(result, "app_created", &content.name)) + } + + async fn handle_iterate_app( + &self, + session_id: &str, + arguments: Option, + ) -> Result { + let args = arguments.ok_or("Missing arguments")?; + + let name = extract_string(&args, "name")?; + let feedback = extract_string(&args, "feedback")?; + + let mut app = self.load_app(&name)?; + + let existing_html = app + .resource + .text + .as_deref() + .ok_or("App has no HTML content")?; + + let existing_prd = app.prd.as_deref().unwrap_or(""); + + let content = self + .generate_updated_app_content(session_id, existing_html, existing_prd, &feedback) + .await?; + + app.resource.text = Some(content.html); + app.resource.description = Some(content.description); + app.prd = Some(content.prd); + if content.width.is_some() || content.height.is_some() || content.resizable.is_some() { + let current_props = app.window_props.as_ref(); + let default_width = current_props + .map(|p| p.width) + .unwrap_or(DEFAULT_WINDOW_PROPS.width); + let default_height = current_props + .map(|p| p.height) + .unwrap_or(DEFAULT_WINDOW_PROPS.height); + let default_resizable = current_props + .map(|p| p.resizable) + .unwrap_or(DEFAULT_WINDOW_PROPS.resizable); + + app.window_props = Some(WindowProps { + width: content.width.unwrap_or(default_width), + height: content.height.unwrap_or(default_height), + resizable: content.resizable.unwrap_or(default_resizable), + }); + } + + self.save_app(&app)?; + + let result = CallToolResult::success(vec![Content::text(format!( + "Updated app '{}' based on your feedback", + name + ))]); + + Ok(self.with_platform_notification(result, "app_updated", &name)) + } + + async fn handle_delete_app( + &self, + arguments: Option, + ) -> Result { + let args = arguments.ok_or("Missing arguments")?; + + let name = extract_string(&args, "name")?; + + self.delete_app(&name)?; + + let result = + CallToolResult::success(vec![Content::text(format!("Deleted app '{}'", name))]); + + Ok(self.with_platform_notification(result, "app_deleted", &name)) + } +} + +#[async_trait] +impl McpClientTrait for AppsManagerClient { + async fn list_tools( + &self, + _session_id: &str, + _next_cursor: Option, + _cancel_token: CancellationToken, + ) -> Result { + let tools = vec![ + McpTool::new( + "list_apps".to_string(), + "List all available Goose apps with their names and descriptions. Use this to see what apps exist before creating or modifying apps.".to_string(), + schema::(), + ), + McpTool::new( + "create_app".to_string(), + "Create a new Goose app based on a description or PRD. The extension will use an LLM to generate the HTML/CSS/JavaScript. Apps are sandboxed and run in standalone windows.".to_string(), + schema::(), + ), + McpTool::new( + "iterate_app".to_string(), + "Improve an existing app based on feedback. The extension will use an LLM to update the HTML while preserving the app's intent.".to_string(), + schema::(), + ), + McpTool::new( + "delete_app".to_string(), + "Delete an app permanently".to_string(), + schema::(), + ), + ]; + + Ok(ListToolsResult { + tools, + next_cursor: None, + meta: None, + }) + } + + async fn call_tool( + &self, + session_id: &str, + name: &str, + arguments: Option, + _cancel_token: CancellationToken, + ) -> Result { + let result = match name { + "list_apps" => self.handle_list_apps(arguments).await, + "create_app" => self.handle_create_app(session_id, arguments).await, + "iterate_app" => self.handle_iterate_app(session_id, arguments).await, + "delete_app" => self.handle_delete_app(arguments).await, + _ => Err(format!("Unknown tool: {}", name)), + }; + + match result { + Ok(result) => Ok(result), + Err(error) => Ok(CallToolResult::error(vec![Content::text(format!( + "Error: {}", + error + ))])), + } + } + + async fn list_resources( + &self, + _session_id: &str, + _next_cursor: Option, + _cancel_token: CancellationToken, + ) -> Result { + let app_names = self + .list_stored_apps() + .map_err(|_| Error::TransportClosed)?; + + let mut resources = Vec::new(); + + for name in app_names { + if let Ok(app) = self.load_app(&name) { + let meta = if let Some(ref window_props) = app.window_props { + let mut meta_obj = Meta::new(); + meta_obj.insert( + "window".to_string(), + json!({ + "width": window_props.width, + "height": window_props.height, + "resizable": window_props.resizable, + }), + ); + Some(meta_obj) + } else { + None + }; + + let raw_resource = RawResource { + uri: app.resource.uri.clone(), + name: app.resource.name.clone(), + title: None, + description: app.resource.description.clone(), + mime_type: Some(app.resource.mime_type.clone()), + size: None, + icons: None, + meta, + }; + resources.push(Resource { + raw: raw_resource, + annotations: None, + }); + } + } + + Ok(ListResourcesResult { + resources, + next_cursor: None, + meta: None, + }) + } + + async fn read_resource( + &self, + _session_id: &str, + uri: &str, + _cancel_token: CancellationToken, + ) -> Result { + let app_name = uri + .strip_prefix("ui://apps/") + .ok_or(Error::TransportClosed)?; + + let app = self + .load_app(app_name) + .map_err(|_| Error::TransportClosed)?; + + let html = app + .resource + .text + .unwrap_or_else(|| String::from("No content")); + + Ok(ReadResourceResult { + contents: vec![ResourceContents::text(html, uri)], + }) + } + + fn get_info(&self) -> Option<&InitializeResult> { + Some(&self.info) + } +} + +fn schema() -> JsonObject { + serde_json::to_value(schema_for!(T)) + .map(|v| v.as_object().unwrap().clone()) + .expect("valid schema") +} + +fn extract_string(args: &JsonObject, key: &str) -> Result { + args.get(key) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| format!("Missing or invalid '{}'", key)) +} + +fn extract_tool_response( + response: &Message, + tool_name: &str, +) -> Result { + for content in &response.content { + if let crate::conversation::message::MessageContent::ToolRequest(tool_req) = content { + if let Ok(tool_call) = &tool_req.tool_call { + if tool_call.name == tool_name { + let params = tool_call + .arguments + .as_ref() + .ok_or("Missing tool call parameters")?; + + return serde_json::from_value(serde_json::Value::Object(params.clone())) + .map_err(|e| format!("Failed to parse tool response: {}", e)); + } + } + } + } + + Err(format!("LLM did not call the required tool: {}", tool_name)) +} diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index 8e046924902a..aebb4fddae39 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -1,3 +1,4 @@ +use crate::agents::apps_extension; use crate::agents::chatrecall_extension; use crate::agents::code_execution_extension; use crate::agents::extension_manager_extension; @@ -54,6 +55,17 @@ pub static PLATFORM_EXTENSIONS: Lazy }, ); + map.insert( + apps_extension::EXTENSION_NAME, + PlatformExtensionDef { + name: apps_extension::EXTENSION_NAME, + description: + "Create and manage custom Goose apps through chat. Apps are HTML/CSS/JavaScript and run in sandboxed windows.", + default_enabled: true, + client_factory: |ctx| Box::new(apps_extension::AppsManagerClient::new(ctx).unwrap()), + }, + ); + map.insert( chatrecall_extension::EXTENSION_NAME, PlatformExtensionDef { @@ -111,6 +123,38 @@ pub struct PlatformExtensionContext { pub session_manager: std::sync::Arc, } +impl PlatformExtensionContext { + pub fn result_with_platform_notification( + &self, + mut result: rmcp::model::CallToolResult, + extension_name: impl Into, + event_type: impl Into, + mut additional_params: serde_json::Map, + ) -> rmcp::model::CallToolResult { + additional_params.insert("extension".to_string(), extension_name.into().into()); + additional_params.insert("event_type".to_string(), event_type.into().into()); + + let meta_value = serde_json::json!({ + "platform_notification": { + "method": "platform_event", + "params": additional_params + } + }); + + if let Some(ref mut meta) = result.meta { + if let Some(obj) = meta_value.as_object() { + for (k, v) in obj { + meta.0.insert(k.clone(), v.clone()); + } + } + } else { + result.meta = Some(rmcp::model::Meta(meta_value.as_object().unwrap().clone())); + } + + result + } +} + #[derive(Debug, Clone)] pub struct PlatformExtensionDef { pub name: &'static str, diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index be1a8f88b6aa..50c00146e687 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -466,6 +466,10 @@ impl ExtensionManager { &self.context } + pub fn get_provider(&self) -> &SharedProvider { + &self.provider + } + pub async fn supports_resources(&self) -> bool { self.extensions .lock() diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index c6bf9706ee09..8298e87e91fa 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -1,4 +1,5 @@ mod agent; +pub(crate) mod apps_extension; pub(crate) mod chatrecall_extension; pub(crate) mod code_execution_extension; pub mod execute_commands; diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index a188fe5faa48..5e9de889834e 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -171,6 +171,8 @@ pub enum SystemNotificationType { pub struct SystemNotificationContent { pub notification_type: SystemNotificationType, pub msg: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] @@ -369,6 +371,19 @@ impl MessageContent { MessageContent::SystemNotification(SystemNotificationContent { notification_type, msg: msg.into(), + data: None, + }) + } + + pub fn system_notification_with_data>( + notification_type: SystemNotificationType, + msg: S, + data: serde_json::Value, + ) -> Self { + MessageContent::SystemNotification(SystemNotificationContent { + notification_type, + msg: msg.into(), + data: Some(data), }) } @@ -816,39 +831,47 @@ impl Message { .with_metadata(MessageMetadata::user_only()) } - /// Set the visibility metadata for the message + pub fn with_system_notification_with_data>( + self, + notification_type: SystemNotificationType, + msg: S, + data: serde_json::Value, + ) -> Self { + self.with_content(MessageContent::system_notification_with_data( + notification_type, + msg, + data, + )) + .with_metadata(MessageMetadata::user_only()) + } + pub fn with_visibility(mut self, user_visible: bool, agent_visible: bool) -> Self { self.metadata.user_visible = user_visible; self.metadata.agent_visible = agent_visible; self } - /// Set the entire metadata for the message pub fn with_metadata(mut self, metadata: MessageMetadata) -> Self { self.metadata = metadata; self } - /// Mark the message as only visible to the user (not the agent) pub fn user_only(mut self) -> Self { self.metadata.user_visible = true; self.metadata.agent_visible = false; self } - /// Mark the message as only visible to the agent (not the user) pub fn agent_only(mut self) -> Self { self.metadata.user_visible = false; self.metadata.agent_visible = true; self } - /// Check if the message is visible to the user pub fn is_user_visible(&self) -> bool { self.metadata.user_visible } - /// Check if the message is visible to the agent pub fn is_agent_visible(&self) -> bool { self.metadata.agent_visible } diff --git a/crates/goose/src/goose_apps/app.rs b/crates/goose/src/goose_apps/app.rs new file mode 100644 index 000000000000..adef080fc7b2 --- /dev/null +++ b/crates/goose/src/goose_apps/app.rs @@ -0,0 +1,309 @@ +use crate::agents::ExtensionManager; +use rmcp::model::ErrorData; +use serde::{Deserialize, Serialize}; +use tokio_util::sync::CancellationToken; +use tracing::warn; +use utoipa::ToSchema; + +use super::resource::McpAppResource; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct WindowProps { + pub width: u32, + pub height: u32, + pub resizable: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct GooseApp { + #[serde(flatten)] + pub resource: McpAppResource, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mcp_servers: Vec, + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub window_props: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prd: Option, +} + +impl GooseApp { + const METADATA_SCRIPT_TYPE: &'static str = "application/ld+json"; + const PRD_SCRIPT_TYPE: &'static str = "application/x-goose-prd"; + const GOOSE_APP_TYPE: &'static str = "GooseApp"; + const GOOSE_SCHEMA_CONTEXT: &'static str = "urn:goose.ai:schema"; + + pub fn from_html(html: &str) -> Result { + use regex::Regex; + + let metadata_re = Regex::new(&format!( + r#"(?s)"#, + regex::escape(Self::METADATA_SCRIPT_TYPE) + )) + .map_err(|e| format!("Regex error: {}", e))?; + + let prd_re = Regex::new(&format!( + r#"(?s)"#, + regex::escape(Self::PRD_SCRIPT_TYPE) + )) + .map_err(|e| format!("Regex error: {}", e))?; + + let json_str = metadata_re + .captures(html) + .and_then(|cap| cap.get(1)) + .ok_or_else(|| "No GooseApp JSON-LD metadata found in HTML".to_string())? + .as_str(); + + let metadata: serde_json::Value = serde_json::from_str(json_str) + .map_err(|e| format!("Failed to parse metadata JSON: {}", e))?; + + let name = metadata + .get("name") + .and_then(|v| v.as_str()) + .ok_or("Missing 'name' in metadata")? + .to_string(); + + let description = metadata + .get("description") + .and_then(|v| v.as_str()) + .map(String::from); + + let width = metadata + .get("width") + .and_then(|v| v.as_u64()) + .map(|v| v as u32); + let height = metadata + .get("height") + .and_then(|v| v.as_u64()) + .map(|v| v as u32); + let resizable = metadata.get("resizable").and_then(|v| v.as_bool()); + + let window_props = if width.is_some() || height.is_some() || resizable.is_some() { + Some(WindowProps { + width: width.unwrap_or(800), + height: height.unwrap_or(600), + resizable: resizable.unwrap_or(true), + }) + } else { + None + }; + + let mcp_servers = metadata + .get("mcpServers") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + let prd = prd_re + .captures(html) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().trim().to_string()); + + let clean_html = metadata_re.replace(html, ""); + let clean_html = prd_re.replace(&clean_html, "").to_string(); + + Ok(GooseApp { + resource: McpAppResource { + uri: format!("ui://apps/{}", name), + name, + description, + mime_type: "text/html;profile=mcp-app".to_string(), + text: Some(clean_html), + blob: None, + meta: None, + }, + mcp_servers, + window_props, + prd, + }) + } + + pub fn to_html(&self) -> Result { + let html = self + .resource + .text + .as_ref() + .ok_or("App has no HTML content")?; + + let mut metadata = serde_json::json!({ + "@context": Self::GOOSE_SCHEMA_CONTEXT, + "@type": Self::GOOSE_APP_TYPE, + "name": self.resource.name, + }); + + if let Some(ref desc) = self.resource.description { + metadata["description"] = serde_json::json!(desc); + } + + if let Some(ref props) = self.window_props { + metadata["width"] = serde_json::json!(props.width); + metadata["height"] = serde_json::json!(props.height); + metadata["resizable"] = serde_json::json!(props.resizable); + } + + if !self.mcp_servers.is_empty() { + metadata["mcpServers"] = serde_json::json!(self.mcp_servers); + } + + let metadata_json = serde_json::to_string_pretty(&metadata) + .map_err(|e| format!("Failed to serialize metadata: {}", e))?; + + let metadata_script = format!( + " ", + Self::METADATA_SCRIPT_TYPE, + metadata_json + ); + + let prd_script = if let Some(ref prd) = self.prd { + if !prd.is_empty() { + format!( + " ", + Self::PRD_SCRIPT_TYPE, + prd + ) + } else { + String::new() + } + } else { + String::new() + }; + + let scripts = if prd_script.is_empty() { + format!("{}\n", metadata_script) + } else { + format!("{}\n{}\n", metadata_script, prd_script) + }; + + let result = if let Some(head_pos) = html.find("") { + let mut result = html.clone(); + result.insert_str(head_pos, &scripts); + result + } else if let Some(html_pos) = html.find("')) + .map(|p| html_pos + p + 1); + if let Some(pos) = after_html { + let mut result = html.clone(); + result.insert_str(pos, &format!("\n\n{}", scripts)); + result + } else { + format!("\n{}\n{}", scripts, html) + } + } else { + format!( + "\n\n{}\n\n{}\n\n", + scripts, html + ) + }; + + Ok(result) + } +} + +pub async fn fetch_mcp_apps( + extension_manager: &ExtensionManager, + session_id: &str, +) -> Result, ErrorData> { + let mut apps = Vec::new(); + + let ui_resources = extension_manager.get_ui_resources(session_id).await?; + + for (extension_name, resource) in ui_resources { + match extension_manager + .read_resource( + session_id, + &resource.uri, + &extension_name, + CancellationToken::default(), + ) + .await + { + Ok(read_result) => { + let mut html = String::new(); + for content in read_result.contents { + if let rmcp::model::ResourceContents::TextResourceContents { text, .. } = + content + { + html = text; + break; + } + } + + if !html.is_empty() { + let mcp_resource = McpAppResource { + uri: resource.uri.clone(), + name: resource.name.clone(), + description: resource.description.clone(), + mime_type: "text/html;profile=mcp-app".to_string(), + text: Some(html), + blob: None, + meta: None, + }; + + let window_props = if let Some(ref meta) = resource.meta { + if let Some(window_obj) = meta.get("window").and_then(|v| v.as_object()) { + if let (Some(width), Some(height), Some(resizable)) = ( + window_obj + .get("width") + .and_then(|v| v.as_u64()) + .map(|v| v as u32), + window_obj + .get("height") + .and_then(|v| v.as_u64()) + .map(|v| v as u32), + window_obj.get("resizable").and_then(|v| v.as_bool()), + ) { + Some(WindowProps { + width, + height, + resizable, + }) + } else { + Some(WindowProps { + width: 800, + height: 600, + resizable: true, + }) + } + } else { + Some(WindowProps { + width: 800, + height: 600, + resizable: true, + }) + } + } else { + Some(WindowProps { + width: 800, + height: 600, + resizable: true, + }) + }; + + let app = GooseApp { + resource: mcp_resource, + mcp_servers: vec![extension_name], + window_props, + prd: None, + }; + + apps.push(app); + } + } + Err(e) => { + warn!( + "Failed to read resource {} from {}: {}", + resource.uri, extension_name, e + ); + } + } + } + + Ok(apps) +} diff --git a/crates/goose/src/goose_apps/cache.rs b/crates/goose/src/goose_apps/cache.rs new file mode 100644 index 000000000000..633701ce6350 --- /dev/null +++ b/crates/goose/src/goose_apps/cache.rs @@ -0,0 +1,118 @@ +use crate::config::paths::Paths; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::PathBuf; +use tracing::warn; + +use super::app::GooseApp; + +static CLOCK_HTML: &str = include_str!("../goose_apps/clock.html"); +const APPS_EXTENSION_NAME: &str = "apps"; + +pub struct McpAppCache { + cache_dir: PathBuf, +} + +impl McpAppCache { + pub fn new() -> Result { + let config_dir = Paths::config_dir(); + let cache_dir = config_dir.join("mcp-apps-cache"); + let cache = Self { cache_dir }; + cache.ensure_default_apps(); + Ok(cache) + } + + fn ensure_default_apps(&self) { + if self.get_app(APPS_EXTENSION_NAME, "apps://clock").is_none() { + if let Ok(mut clock_app) = GooseApp::from_html(CLOCK_HTML) { + clock_app.mcp_servers = vec![APPS_EXTENSION_NAME.to_string()]; + let _ = self.store_app(&clock_app); + } + } + } + + fn cache_key(extension_name: &str, resource_uri: &str) -> String { + let input = format!("{}::{}", extension_name, resource_uri); + let hash = Sha256::digest(input.as_bytes()); + format!("{}_{:x}", extension_name, hash) + } + + pub fn list_apps(&self) -> Result, std::io::Error> { + let mut apps = Vec::new(); + + if !self.cache_dir.exists() { + return Ok(apps); + } + + for entry in fs::read_dir(&self.cache_dir)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) == Some("json") { + match fs::read_to_string(&path) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(app) => apps.push(app), + Err(e) => warn!("Failed to parse cached app from {:?}: {}", path, e), + }, + Err(e) => warn!("Failed to read cached app from {:?}: {}", path, e), + } + } + } + + Ok(apps) + } + + pub fn store_app(&self, app: &GooseApp) -> Result<(), std::io::Error> { + fs::create_dir_all(&self.cache_dir)?; + + // Store the app once for each MCP server it's associated with + for extension_name in &app.mcp_servers { + let cache_key = Self::cache_key(extension_name, &app.resource.uri); + let app_path = self.cache_dir.join(format!("{}.json", cache_key)); + let json = serde_json::to_string_pretty(app).map_err(std::io::Error::other)?; + fs::write(app_path, json)?; + } + + Ok(()) + } + + pub fn get_app(&self, extension_name: &str, resource_uri: &str) -> Option { + let cache_key = Self::cache_key(extension_name, resource_uri); + let app_path = self.cache_dir.join(format!("{}.json", cache_key)); + + if !app_path.exists() { + return None; + } + + fs::read_to_string(&app_path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + } + + pub fn delete_extension_apps(&self, extension_name: &str) -> Result { + let mut deleted_count = 0; + + if !self.cache_dir.exists() { + return Ok(0); + } + + for entry in fs::read_dir(&self.cache_dir)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) == Some("json") { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(app) = serde_json::from_str::(&content) { + if app.mcp_servers.contains(&extension_name.to_string()) + && fs::remove_file(&path).is_ok() + { + deleted_count += 1; + } + } + } + } + } + + Ok(deleted_count) + } +} diff --git a/crates/goose/src/goose_apps/clock.html b/crates/goose/src/goose_apps/clock.html new file mode 100644 index 000000000000..4da4f19a73d5 --- /dev/null +++ b/crates/goose/src/goose_apps/clock.html @@ -0,0 +1,250 @@ + + + + + Clock + + + + + +
+ +
+ + + + diff --git a/crates/goose/src/goose_apps/mod.rs b/crates/goose/src/goose_apps/mod.rs index 298e5f4b4dd8..327b0e1bf93e 100644 --- a/crates/goose/src/goose_apps/mod.rs +++ b/crates/goose/src/goose_apps/mod.rs @@ -1,209 +1,7 @@ +pub mod app; +pub mod cache; pub mod resource; -use crate::agents::ExtensionManager; -use crate::config::paths::Paths; -use rmcp::model::ErrorData; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::fs; -use std::path::PathBuf; -use tokio_util::sync::CancellationToken; -use tracing::warn; -use utoipa::ToSchema; - +pub use app::{fetch_mcp_apps, GooseApp, WindowProps}; +pub use cache::McpAppCache; pub use resource::{CspMetadata, McpAppResource, ResourceMetadata, UiMetadata}; - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct WindowProps { - pub width: u32, - pub height: u32, - pub resizable: bool, -} - -/// A Goose App combining MCP resource data with Goose-specific metadata -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct GooseApp { - #[serde(flatten)] - pub resource: McpAppResource, - #[serde(skip_serializing_if = "Option::is_none")] - pub mcp_server: Option, - #[serde(flatten, skip_serializing_if = "Option::is_none")] - pub window_props: Option, -} - -pub struct McpAppCache { - cache_dir: PathBuf, -} - -impl McpAppCache { - pub fn new() -> Result { - let config_dir = Paths::config_dir(); - let cache_dir = config_dir.join("mcp-apps-cache"); - Ok(Self { cache_dir }) - } - - fn cache_key(extension_name: &str, resource_uri: &str) -> String { - let input = format!("{}::{}", extension_name, resource_uri); - let hash = Sha256::digest(input.as_bytes()); - format!("{}_{:x}", extension_name, hash) - } - - pub fn list_apps(&self) -> Result, std::io::Error> { - let mut apps = Vec::new(); - - if !self.cache_dir.exists() { - return Ok(apps); - } - - for entry in fs::read_dir(&self.cache_dir)? { - let entry = entry?; - let path = entry.path(); - - if path.extension().and_then(|s| s.to_str()) == Some("json") { - match fs::read_to_string(&path) { - Ok(content) => match serde_json::from_str::(&content) { - Ok(app) => apps.push(app), - Err(e) => warn!("Failed to parse cached app from {:?}: {}", path, e), - }, - Err(e) => warn!("Failed to read cached app from {:?}: {}", path, e), - } - } - } - - Ok(apps) - } - - pub fn store_app(&self, app: &GooseApp) -> Result<(), std::io::Error> { - fs::create_dir_all(&self.cache_dir)?; - - if let Some(ref extension_name) = app.mcp_server { - let cache_key = Self::cache_key(extension_name, &app.resource.uri); - let app_path = self.cache_dir.join(format!("{}.json", cache_key)); - let json = serde_json::to_string_pretty(app).map_err(std::io::Error::other)?; - fs::write(app_path, json)?; - } - - Ok(()) - } - - pub fn get_app(&self, extension_name: &str, resource_uri: &str) -> Option { - let cache_key = Self::cache_key(extension_name, resource_uri); - let app_path = self.cache_dir.join(format!("{}.json", cache_key)); - - if !app_path.exists() { - return None; - } - - fs::read_to_string(&app_path) - .ok() - .and_then(|content| serde_json::from_str::(&content).ok()) - } - - pub fn delete_extension_apps(&self, extension_name: &str) -> Result { - let mut deleted_count = 0; - - if !self.cache_dir.exists() { - return Ok(0); - } - - for entry in fs::read_dir(&self.cache_dir)? { - let entry = entry?; - let path = entry.path(); - - if path.extension().and_then(|s| s.to_str()) == Some("json") { - if let Ok(content) = fs::read_to_string(&path) { - if let Ok(app) = serde_json::from_str::(&content) { - if app.mcp_server.as_deref() == Some(extension_name) - && fs::remove_file(&path).is_ok() - { - deleted_count += 1; - } - } - } - } - } - - Ok(deleted_count) - } -} - -pub async fn fetch_mcp_apps( - extension_manager: &ExtensionManager, - session_id: &str, -) -> Result, ErrorData> { - let mut apps = Vec::new(); - - let ui_resources = extension_manager.get_ui_resources(session_id).await?; - - for (extension_name, resource) in ui_resources { - match extension_manager - .read_resource( - session_id, - &resource.uri, - &extension_name, - CancellationToken::default(), - ) - .await - { - Ok(read_result) => { - let mut html = String::new(); - for content in read_result.contents { - if let rmcp::model::ResourceContents::TextResourceContents { text, .. } = - content - { - html = text; - break; - } - } - - if !html.is_empty() { - let mcp_resource = McpAppResource { - uri: resource.uri.clone(), - name: format_resource_name(resource.name.clone()), - description: resource.description.clone(), - mime_type: "text/html;profile=mcp-app".to_string(), - text: Some(html), - blob: None, - meta: None, - }; - - let app = GooseApp { - resource: mcp_resource, - mcp_server: Some(extension_name), - window_props: Some(WindowProps { - width: 800, - height: 600, - resizable: true, - }), - }; - - apps.push(app); - } - } - Err(e) => { - warn!( - "Failed to read resource {} from {}: {}", - resource.uri, extension_name, e - ); - } - } - } - - Ok(apps) -} - -fn format_resource_name(name: String) -> String { - name.replace('_', " ") - .split_whitespace() - .map(|word| { - let mut chars = word.chars(); - match chars.next() { - None => String::new(), - Some(first) => first.to_uppercase().chain(chars).collect(), - } - }) - .collect::>() - .join(" ") -} diff --git a/crates/goose/src/prompt_template.rs b/crates/goose/src/prompt_template.rs index 9215c4509de6..1231f4c6da56 100644 --- a/crates/goose/src/prompt_template.rs +++ b/crates/goose/src/prompt_template.rs @@ -23,6 +23,14 @@ static TEMPLATE_REGISTRY: &[(&str, &str)] = &[ "recipe.md", "Prompt for generating recipe files from conversations", ), + ( + "apps_create.md", + "Prompt for generating new Goose apps based on the user instructions", + ), + ( + "apps_iterate.md", + "Prompt for updating existing Goose apps based on feedback", + ), ( "permission_judge.md", "Prompt for analyzing tool operations for read-only detection", diff --git a/crates/goose/src/prompts/apps_create.md b/crates/goose/src/prompts/apps_create.md new file mode 100644 index 000000000000..ec553f9c3268 --- /dev/null +++ b/crates/goose/src/prompts/apps_create.md @@ -0,0 +1,19 @@ +You are an expert HTML/CSS/JavaScript developer. Generate standalone, single-file HTML applications. + +REQUIREMENTS: +- Create a complete, self-contained HTML file with embedded CSS and JavaScript +- Use modern, clean design with good UX +- Make it responsive and work well in different window sizes +- Use semantic HTML5 +- Add appropriate error handling +- Make the app interactive and functional +- Use vanilla JavaScript; do not load external JavaScript libraries (no JS dependencies from CDNs or packages) +- If you need external resources (fonts, icons, or CSS only), use CDN links from well-known, trusted providers +- The app will be sandboxed with strict CSP, so all JavaScript must be inline; only non-script assets (fonts, icons, CSS) may be loaded from trusted CDNs + +WINDOW SIZING: +- Choose appropriate width and height based on the app's content and layout +- Typical sizes: small utilities (400x300), standard apps (800x600), large apps (1200x800) +- Set resizable to false for fixed-size apps, true for flexible layouts + +You must call the create_app_content tool to return the app name, description, HTML, and window properties. diff --git a/crates/goose/src/prompts/apps_iterate.md b/crates/goose/src/prompts/apps_iterate.md new file mode 100644 index 000000000000..f248b3eee0de --- /dev/null +++ b/crates/goose/src/prompts/apps_iterate.md @@ -0,0 +1,25 @@ +You are an expert HTML/CSS/JavaScript developer. Generate standalone, single-file HTML applications. + +REQUIREMENTS: +- Create a complete, self-contained HTML file with embedded CSS and JavaScript +- Use modern, clean design with good UX +- Make it responsive and work well in different window sizes +- Use semantic HTML5 +- Add appropriate error handling +- Make the app interactive and functional +- Use vanilla JavaScript; do not load external JavaScript libraries (no JS dependencies from CDNs or packages) +- If you need external resources (fonts, icons, or CSS only), use CDN links from well-known, trusted providers +- The app will be sandboxed with strict CSP, so all JavaScript must be inline; only non-script assets (fonts, icons, CSS) may be loaded from trusted CDNs + +WINDOW SIZING: +- Optionally update width/height if the changes warrant a different window size +- Only include size properties if they should change +- Set resizable to false for fixed-size apps, true for flexible layouts + +PRD UPDATE: +- Update the PRD to reflect the current state of the app after implementing the feedback +- Keep the core requirements but add/update sections based on what was actually changed +- Document new features, changed behavior, or updated requirements +- Keep the PRD concise and focused on what the app should do, not implementation details + +You must call the update_app_content tool to return the updated description, HTML, updated PRD, and optionally updated window properties. diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index de6e90d72a7c..a7dbbeedeb51 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -128,6 +128,117 @@ } } }, + "/agent/export_app/{name}": { + "get": { + "tags": [ + "Agent" + ], + "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", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "App not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, + "/agent/import_app": { + "post": { + "tags": [ + "Agent" + ], + "operationId": "import_app", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportAppRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "App imported successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportAppResponse" + } + } + } + }, + "400": { + "description": "Bad request - Invalid HTML", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, "/agent/list_apps": { "get": { "tags": [ @@ -4005,14 +4116,19 @@ { "type": "object", "properties": { - "mcpServer": { + "mcpServers": { + "type": "array", + "items": { + "type": "string" + } + }, + "prd": { "type": "string", "nullable": true } } } - ], - "description": "A Goose App combining MCP resource data with Goose-specific metadata" + ] }, "Icon": { "type": "object", @@ -4063,6 +4179,32 @@ } } }, + "ImportAppRequest": { + "type": "object", + "required": [ + "html" + ], + "properties": { + "html": { + "type": "string" + } + } + }, + "ImportAppResponse": { + "type": "object", + "required": [ + "name", + "message" + ], + "properties": { + "message": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "ImportSessionRequest": { "type": "object", "required": [ @@ -6120,6 +6262,9 @@ "msg" ], "properties": { + "data": { + "nullable": true + }, "msg": { "type": "string" }, diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 5c1f50f31c0e..84dea4be434a 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -52,6 +52,7 @@ import { getInitialWorkingDir } from './utils/workingDir'; import { usePageViewTracking } from './hooks/useAnalytics'; import { trackOnboardingCompleted, trackErrorWithContext } from './utils/analytics'; import { AppEvents } from './constants/events'; +import { registerPlatformEventHandlers } from './utils/platform_events'; function PageViewTracker() { usePageViewTracking(); @@ -603,6 +604,11 @@ export function AppInner() { }; }, [navigate]); + // Register platform event handlers for app lifecycle management + useEffect(() => { + return registerPlatformEventHandlers(); + }, []); + if (fatalError) { return ; } diff --git a/ui/desktop/src/api/index.ts b/ui/desktop/src/api/index.ts index e1118ac1a3cb..8809485b8c68 100644 --- a/ui/desktop/src/api/index.ts +++ b/ui/desktop/src/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { addExtension, agentAddExtension, agentRemoveExtension, backupConfig, callTool, checkProvider, confirmToolAction, createCustomProvider, createRecipe, createSchedule, decodeRecipe, deleteRecipe, deleteSchedule, deleteSession, detectProvider, diagnostics, encodeRecipe, exportSession, forkSession, getCustomProvider, getExtensions, getPricing, getPrompt, getPrompts, getProviderModels, getSession, getSessionExtensions, getSessionInsights, getSlashCommands, getTools, getTunnelStatus, importSession, initConfig, inspectRunningJob, killRunningJob, listApps, listRecipes, listSchedules, listSessions, mcpUiProxy, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, readResource, recipeToYaml, recoverConfig, removeConfig, removeCustomProvider, removeExtension, reply, resetPrompt, restartAgent, resumeAgent, runNowHandler, savePrompt, saveRecipe, scanRecipe, scheduleRecipe, sendTelemetryEvent, sessionsHandler, setConfigProvider, setRecipeSlashCommand, startAgent, startOpenrouterSetup, startTetrateSetup, startTunnel, status, stopAgent, stopTunnel, systemInfo, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateSchedule, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, upsertPermissions, validateConfig } from './sdk.gen'; -export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, AuthorRequest, BackupConfigData, BackupConfigErrors, BackupConfigResponse, BackupConfigResponses, CallToolData, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, ChatRequest, CheckProviderData, CheckProviderRequest, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeRequest, CreateRecipeResponse, CreateRecipeResponse2, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderRequest, DetectProviderResponse, DetectProviderResponse2, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponse, DiagnosticsResponses, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, ErrorResponse, ExportSessionData, ExportSessionErrors, ExportSessionResponse, ExportSessionResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, ForkRequest, ForkResponse, ForkSessionData, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, FrontendToolRequest, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetPricingData, GetPricingResponse, GetPricingResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponse, GetSessionInsightsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GooseApp, Icon, ImageContent, ImportSessionData, ImportSessionErrors, ImportSessionRequest, ImportSessionResponse, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponse, InitConfigResponses, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsError, ListAppsErrors, ListAppsRequest, ListAppsResponse, ListAppsResponse2, ListAppsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, LoadedProvider, McpAppResource, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, Message, MessageContent, MessageEvent, MessageMetadata, ModelConfig, ModelInfo, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, PermissionLevel, PricingData, PricingQuery, PricingResponse, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderDetails, ProviderEngine, ProviderMetadata, ProvidersData, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponse, RecoverConfigResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionDisplayInfo, SessionExtensionsResponse, SessionInsights, SessionListResponse, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SetupResponse, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StartTunnelData, StartTunnelError, StartTunnelErrors, StartTunnelResponse, StartTunnelResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, StopTunnelData, StopTunnelError, StopTunnelErrors, StopTunnelResponses, SubRecipe, SuccessCheck, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TelemetryEventRequest, Template, TextContent, ThinkingContent, TokenState, Tool, ToolAnnotations, ToolConfirmationRequest, ToolInfo, ToolPermission, ToolRequest, ToolResponse, TunnelInfo, TunnelState, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WindowProps } from './types.gen'; +export { addExtension, agentAddExtension, agentRemoveExtension, backupConfig, callTool, checkProvider, confirmToolAction, createCustomProvider, createRecipe, createSchedule, decodeRecipe, deleteRecipe, deleteSchedule, deleteSession, detectProvider, diagnostics, encodeRecipe, exportApp, exportSession, forkSession, getCustomProvider, getExtensions, getPricing, getPrompt, getPrompts, getProviderModels, getSession, getSessionExtensions, getSessionInsights, getSlashCommands, getTools, getTunnelStatus, importApp, importSession, initConfig, inspectRunningJob, killRunningJob, listApps, listRecipes, listSchedules, listSessions, mcpUiProxy, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, readResource, recipeToYaml, recoverConfig, removeConfig, removeCustomProvider, removeExtension, reply, resetPrompt, restartAgent, resumeAgent, runNowHandler, savePrompt, saveRecipe, scanRecipe, scheduleRecipe, sendTelemetryEvent, sessionsHandler, setConfigProvider, setRecipeSlashCommand, startAgent, startOpenrouterSetup, startTetrateSetup, startTunnel, status, stopAgent, stopTunnel, systemInfo, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateSchedule, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, upsertPermissions, validateConfig } from './sdk.gen'; +export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, AuthorRequest, BackupConfigData, BackupConfigErrors, BackupConfigResponse, BackupConfigResponses, CallToolData, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, ChatRequest, CheckProviderData, CheckProviderRequest, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeRequest, CreateRecipeResponse, CreateRecipeResponse2, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderRequest, DetectProviderResponse, DetectProviderResponse2, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponse, DiagnosticsResponses, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, ErrorResponse, ExportAppData, ExportAppError, ExportAppErrors, ExportAppResponse, ExportAppResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponse, ExportSessionResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, ForkRequest, ForkResponse, ForkSessionData, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, FrontendToolRequest, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetPricingData, GetPricingResponse, GetPricingResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponse, GetSessionInsightsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GooseApp, Icon, ImageContent, ImportAppData, ImportAppError, ImportAppErrors, ImportAppRequest, ImportAppResponse, ImportAppResponse2, ImportAppResponses, ImportSessionData, ImportSessionErrors, ImportSessionRequest, ImportSessionResponse, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponse, InitConfigResponses, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsError, ListAppsErrors, ListAppsRequest, ListAppsResponse, ListAppsResponse2, ListAppsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, LoadedProvider, McpAppResource, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, Message, MessageContent, MessageEvent, MessageMetadata, ModelConfig, ModelInfo, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, PermissionLevel, PricingData, PricingQuery, PricingResponse, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderDetails, ProviderEngine, ProviderMetadata, ProvidersData, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponse, RecoverConfigResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionDisplayInfo, SessionExtensionsResponse, SessionInsights, SessionListResponse, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SetupResponse, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StartTunnelData, StartTunnelError, StartTunnelErrors, StartTunnelResponse, StartTunnelResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, StopTunnelData, StopTunnelError, StopTunnelErrors, StopTunnelResponses, SubRecipe, SuccessCheck, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TelemetryEventRequest, Template, TextContent, ThinkingContent, TokenState, Tool, ToolAnnotations, ToolConfirmationRequest, ToolInfo, ToolPermission, ToolRequest, ToolResponse, TunnelInfo, TunnelState, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WindowProps } from './types.gen'; diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index db9f8d725097..b84db5a80cb3 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, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPricingData, GetPricingResponses, GetPromptData, GetPromptErrors, GetPromptResponses, GetPromptsData, GetPromptsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsErrors, ListAppsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SavePromptData, SavePromptErrors, SavePromptResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, SystemInfoData, SystemInfoResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, 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, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportAppData, ExportAppErrors, ExportAppResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPricingData, GetPricingResponses, GetPromptData, GetPromptErrors, GetPromptResponses, GetPromptsData, GetPromptsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportAppData, ImportAppErrors, ImportAppResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsErrors, ListAppsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SavePromptData, SavePromptErrors, SavePromptResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, SystemInfoData, SystemInfoResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -45,6 +45,17 @@ export const callTool = (options: Options< } }); +export const exportApp = (options: Options) => (options.client ?? client).get({ url: '/agent/export_app/{name}', ...options }); + +export const importApp = (options: Options) => (options.client ?? client).post({ + url: '/agent/import_app', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + export const listApps = (options?: Options) => (options?.client ?? client).get({ url: '/agent/list_apps', ...options }); export const readResource = (options: Options) => (options.client ?? client).post({ diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index bcef9d17ec09..075f4194ee95 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -363,11 +363,9 @@ export type GetToolsQuery = { session_id: string; }; -/** - * A Goose App combining MCP resource data with Goose-specific metadata - */ export type GooseApp = McpAppResource & (WindowProps | null) & { - mcpServer?: string | null; + mcpServers?: Array; + prd?: string | null; }; export type Icon = { @@ -387,6 +385,15 @@ export type ImageContent = { mimeType: string; }; +export type ImportAppRequest = { + html: string; +}; + +export type ImportAppResponse = { + message: string; + name: string; +}; + export type ImportSessionRequest = { json: string; }; @@ -1042,6 +1049,7 @@ export type SystemInfo = { }; export type SystemNotificationContent = { + data?: unknown; msg: string; notificationType: SystemNotificationType; }; @@ -1348,6 +1356,69 @@ export type CallToolResponses = { export type CallToolResponse2 = CallToolResponses[keyof CallToolResponses]; +export type ExportAppData = { + body?: never; + path: { + /** + * Name of the app to export + */ + name: string; + }; + query?: never; + url: '/agent/export_app/{name}'; +}; + +export type ExportAppErrors = { + /** + * App not found + */ + 404: ErrorResponse; + /** + * Internal server error + */ + 500: ErrorResponse; +}; + +export type ExportAppError = ExportAppErrors[keyof ExportAppErrors]; + +export type ExportAppResponses = { + /** + * App HTML exported successfully + */ + 200: string; +}; + +export type ExportAppResponse = ExportAppResponses[keyof ExportAppResponses]; + +export type ImportAppData = { + body: ImportAppRequest; + path?: never; + query?: never; + url: '/agent/import_app'; +}; + +export type ImportAppErrors = { + /** + * Bad request - Invalid HTML + */ + 400: ErrorResponse; + /** + * Internal server error + */ + 500: ErrorResponse; +}; + +export type ImportAppError = ImportAppErrors[keyof ImportAppErrors]; + +export type ImportAppResponses = { + /** + * App imported successfully + */ + 201: ImportAppResponse; +}; + +export type ImportAppResponse2 = ImportAppResponses[keyof ImportAppResponses]; + export type ListAppsData = { body?: never; path?: never; diff --git a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx index cb7521d10e00..123db12ae7a0 100644 --- a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx +++ b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx @@ -23,12 +23,13 @@ import { import { Gear } from '../icons'; import { View, ViewOptions } from '../../utils/navigationUtils'; import { DEFAULT_CHAT_TITLE, useChatContext } from '../../contexts/ChatContext'; -import { listSessions, listApps, Session } from '../../api'; +import { listSessions, Session } from '../../api'; import { resumeSession, startNewSession, shouldShowNewChatTitle } from '../../sessions'; import { useNavigation } from '../../hooks/useNavigation'; import { SessionIndicators } from '../SessionIndicators'; import { useSidebarSessionStatus } from '../../hooks/useSidebarSessionStatus'; import { getInitialWorkingDir } from '../../utils/workingDir'; +import { useConfig } from '../ConfigContext'; interface SidebarProps { onSelectSession: (sessionId: string) => void; @@ -60,6 +61,13 @@ const menuItems: NavigationEntry[] = [ icon: FileText, tooltip: 'Browse your saved recipes', }, + { + type: 'item', + path: '/apps', + label: 'Apps', + icon: AppWindow, + tooltip: 'MCP and custom apps', + }, { type: 'item', path: '/schedules', @@ -74,13 +82,6 @@ const menuItems: NavigationEntry[] = [ icon: Puzzle, tooltip: 'Manage your extensions', }, - { - type: 'item', - path: '/apps', - label: 'Apps', - icon: AppWindow, - tooltip: 'Browse and launch MCP apps', - }, { type: 'separator' }, { type: 'item', @@ -195,10 +196,13 @@ SessionList.displayName = 'SessionList'; const AppSidebar: React.FC = ({ currentPath }) => { const navigate = useNavigate(); const chatContext = useChatContext(); + const configContext = useConfig(); const setView = useNavigation(); + + const appsExtensionEnabled = !!configContext.extensionsList?.find((ext) => ext.name === 'apps') + ?.enabled; const [searchParams] = useSearchParams(); const [recentSessions, setRecentSessions] = useState([]); - const [hasApps, setHasApps] = useState(false); const activeSessionId = searchParams.get('resumeSessionId') ?? undefined; const { getSessionStatus, clearUnread } = useSidebarSessionStatus(activeSessionId); @@ -251,21 +255,6 @@ const AppSidebar: React.FC = ({ currentPath }) => { loadRecentSessions(); }, []); - useEffect(() => { - const checkApps = async () => { - try { - const response = await listApps({ - throwOnError: true, - }); - setHasApps((response.data?.apps || []).length > 0); - } catch (err) { - console.warn('Failed to check for apps:', err); - } - }; - - checkApps(); - }, [currentPath]); - useEffect(() => { let pollingTimeouts: ReturnType[] = []; let isPolling = false; @@ -477,8 +466,9 @@ const AppSidebar: React.FC = ({ currentPath }) => { }; const visibleMenuItems = menuItems.filter((entry) => { + // Filter out Apps if extension is not enabled if (entry.type === 'item' && entry.path === '/apps') { - return hasApps; + return appsExtensionEnabled; } return true; }); @@ -544,7 +534,6 @@ const AppSidebar: React.FC = ({ currentPath }) => { - {/* Other menu items - filter out Apps if no apps available */} {visibleMenuItems.map((entry, index) => renderMenuItem(entry, index))} diff --git a/ui/desktop/src/components/apps/AppsView.tsx b/ui/desktop/src/components/apps/AppsView.tsx index b40487c7d641..69863b649364 100644 --- a/ui/desktop/src/components/apps/AppsView.tsx +++ b/ui/desktop/src/components/apps/AppsView.tsx @@ -1,9 +1,10 @@ -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 } from 'lucide-react'; -import { GooseApp, listApps } from '../../api'; +import { Download, Play, Upload } from 'lucide-react'; +import { exportApp, GooseApp, importApp, listApps } from '../../api'; import { useChatContext } from '../../contexts/ChatContext'; +import { formatAppName } from '../../utils/conversionUtils'; const GridLayout = ({ children }: { children: React.ReactNode }) => { return ( @@ -73,6 +74,32 @@ export default function AppsView() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId]); + useEffect(() => { + const handlePlatformEvent = (event: Event) => { + const customEvent = event as CustomEvent; + const eventData = customEvent.detail; + + if (eventData?.extension === 'apps') { + const eventSessionId = eventData.sessionId || sessionId; + + // Refresh apps list to get latest state + if (eventSessionId) { + listApps({ + throwOnError: false, + query: { session_id: eventSessionId }, + }).then((response) => { + if (response.data?.apps) { + setApps(response.data.apps); + } + }); + } + } + }; + + window.addEventListener('platform-event', handlePlatformEvent); + return () => window.removeEventListener('platform-event', handlePlatformEvent); + }, [sessionId]); + const loadApps = useCallback(async () => { if (!sessionId) return; @@ -104,6 +131,59 @@ export default function AppsView() { } }; + 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) { + console.error('Failed to export app:', 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: { html: text }, + }); + + const response = await listApps({ + throwOnError: true, + }); + setApps(response.data?.apps || []); + setError(null); + } catch (err) { + console.error('Failed to import app:', err); + setError(err instanceof Error ? err.message : 'Failed to import app'); + } + event.target.value = ''; + }; + // Only show error-only UI if we have no apps to display if (error && apps.length === 0) { return ( @@ -119,14 +199,36 @@ export default function AppsView() { return (
+

Apps

+ +
+
+

+ Applications from your MCP servers and Apps build by goose itself. You can ask it to + create new apps through the chat interface and they will appear here. +

+

+ ⚠️ Experimental feature - may change or be removed at any time +

-

- Applications from your MCP servers that can run in standalone windows. -

@@ -140,41 +242,58 @@ export default function AppsView() {

No apps available

- Install MCP servers that provide UI resources to see apps here. + Open a chat and ask goose for the app you want to have. It can build one for you + and that will appear here. Or if somebody shared an app, you can import it using + the button above.

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

{app.name}

- {app.description && ( -

{app.description}

- )} - {app.mcpServer && ( - - {app.mcpServer} - - )} -
-
- + {apps.map((app) => { + const isCustomApp = app.mcpServers?.includes('apps') ?? false; + return ( +
+
+

+ {formatAppName(app.name)} +

+ {app.description && ( +

{app.description}

+ )} + {app.mcpServers && app.mcpServers.length > 0 && ( + + {isCustomApp ? 'Custom app' : app.mcpServers.join(', ')} + + )} +
+
+ + {isCustomApp && ( + + )} +
-
- ))} + ); + })} )}
diff --git a/ui/desktop/src/components/apps/StandaloneAppView.tsx b/ui/desktop/src/components/apps/StandaloneAppView.tsx index c5acd3d115cf..77ba0120369e 100644 --- a/ui/desktop/src/components/apps/StandaloneAppView.tsx +++ b/ui/desktop/src/components/apps/StandaloneAppView.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import McpAppRenderer from '../McpApps/McpAppRenderer'; import { startAgent, resumeAgent, listApps, stopAgent } from '../../api'; +import { formatAppName } from '../../utils/conversionUtils'; export default function StandaloneAppView() { const [searchParams] = useSearchParams(); @@ -35,7 +36,7 @@ export default function StandaloneAppView() { const apps = response.data?.apps || []; const cachedApp = apps.find( - (app) => app.uri === resourceUri && app.mcpServer === extensionName + (app) => app.uri === resourceUri && app.mcpServers?.includes(extensionName) ); if (cachedApp?.text) { @@ -88,7 +89,7 @@ export default function StandaloneAppView() { useEffect(() => { if (appName) { - document.title = appName; + document.title = formatAppName(appName); } }, [appName]); diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index c73e21938845..d146be51bf5f 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -24,6 +24,7 @@ import { } from '../types/message'; import { errorMessage } from '../utils/conversionUtils'; import { showExtensionLoadResults } from '../utils/extensionErrorUtils'; +import { maybeHandlePlatformEvent } from '../utils/platform_events'; const resultsCache = new Map(); @@ -197,7 +198,8 @@ async function streamFromResponse( stream: AsyncIterable, initialMessages: Message[], dispatch: React.Dispatch, - onFinish: (error?: string) => void + onFinish: (error?: string) => void, + sessionId: string ): Promise { let currentMessages = initialMessages; @@ -249,6 +251,7 @@ async function streamFromResponse( } case 'Notification': { dispatch({ type: 'ADD_NOTIFICATION', payload: event as NotificationEvent }); + maybeHandlePlatformEvent(event.message, sessionId); break; } case 'Ping': @@ -540,7 +543,7 @@ export function useChatStream({ signal: abortControllerRef.current.signal, }); - await streamFromResponse(stream, currentMessages, dispatch, onFinish); + await streamFromResponse(stream, currentMessages, dispatch, onFinish, sessionId); } catch (error) { // AbortError is expected when user stops streaming if (error instanceof Error && error.name === 'AbortError') { @@ -581,7 +584,7 @@ export function useChatStream({ signal: abortControllerRef.current.signal, }); - await streamFromResponse(stream, currentMessages, dispatch, onFinish); + await streamFromResponse(stream, currentMessages, dispatch, onFinish, sessionId); } catch (error) { if (error instanceof Error && error.name === 'AbortError') { // Silently handle abort diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 835bd595f59a..1d7e399dfdb8 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -29,6 +29,7 @@ import { expandTilde } from './utils/pathUtils'; import log from './utils/logger'; import { ensureWinShims } from './utils/winShims'; import { addRecentDir, loadRecentDirs } from './utils/recentDirs'; +import { formatAppName } from './utils/conversionUtils'; import { EnvToggles, loadSettings, @@ -516,8 +517,8 @@ let appConfig = { const windowMap = new Map(); const goosedClients = new Map(); +const appWindows = new Map(); -// Track power save blockers per window const windowPowerSaveBlockers = new Map(); // windowId -> blockerId // Track pending initial messages per window const pendingInitialMessages = new Map(); // windowId -> initialMessage @@ -2484,10 +2485,11 @@ async function appMain() { const baseUrl = new URL(currentUrl).origin; const appWindow = new BrowserWindow({ - title: gooseApp.name, + title: formatAppName(gooseApp.name), width: gooseApp.width ?? 800, height: gooseApp.height ?? 600, resizable: gooseApp.resizable ?? true, + useContentSize: true, webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, @@ -2498,13 +2500,15 @@ async function appMain() { }); goosedClients.set(appWindow.id, launchingClient); + appWindows.set(gooseApp.name, appWindow); appWindow.on('close', () => { goosedClients.delete(appWindow.id); + appWindows.delete(gooseApp.name); }); const workingDir = app.getPath('home'); - const extensionName = gooseApp.mcpServer ?? ''; + const extensionName = gooseApp.mcpServers?.[0] ?? ''; const standaloneUrl = `${baseUrl}/#/standalone-app?` + `resourceUri=${encodeURIComponent(gooseApp.uri)}` + @@ -2519,6 +2523,44 @@ async function appMain() { throw error; } }); + + ipcMain.handle('refresh-app', async (_event, gooseApp: GooseApp) => { + try { + const appWindow = appWindows.get(gooseApp.name); + if (!appWindow || appWindow.isDestroyed()) { + console.log(`App window for '${gooseApp.name}' not found or destroyed, skipping refresh`); + return; + } + + // Bring to front first + if (appWindow.isMinimized()) { + appWindow.restore(); + } + appWindow.show(); + appWindow.focus(); + + // Then reload + await appWindow.webContents.reload(); + } catch (error) { + console.error('Failed to refresh app:', error); + throw error; + } + }); + + ipcMain.handle('close-app', async (_event, appName: string) => { + try { + const appWindow = appWindows.get(appName); + if (!appWindow || appWindow.isDestroyed()) { + console.log(`App window for '${appName}' not found or destroyed, skipping close`); + return; + } + + appWindow.close(); + } catch (error) { + console.error('Failed to close app:', error); + throw error; + } + }); } app.whenReady().then(async () => { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 3ac6ce0b843f..2f93efcad6f6 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -138,6 +138,8 @@ type ElectronAPI = { recordRecipeHash: (recipe: Recipe) => Promise; openDirectoryInExplorer: (directoryPath: string) => Promise; launchApp: (app: GooseApp) => Promise; + refreshApp: (app: GooseApp) => Promise; + closeApp: (appName: string) => Promise; addRecentDir: (dir: string) => Promise; }; @@ -278,6 +280,8 @@ const electronAPI: ElectronAPI = { openDirectoryInExplorer: (directoryPath: string) => ipcRenderer.invoke('open-directory-in-explorer', directoryPath), launchApp: (app: GooseApp) => ipcRenderer.invoke('launch-app', app), + refreshApp: (app: GooseApp) => ipcRenderer.invoke('refresh-app', app), + closeApp: (appName: string) => ipcRenderer.invoke('close-app', appName), addRecentDir: (dir: string) => ipcRenderer.invoke('add-recent-dir', dir), }; diff --git a/ui/desktop/src/utils/conversionUtils.ts b/ui/desktop/src/utils/conversionUtils.ts index 51b9cb30accf..792df32a4e88 100644 --- a/ui/desktop/src/utils/conversionUtils.ts +++ b/ui/desktop/src/utils/conversionUtils.ts @@ -21,3 +21,11 @@ export function errorMessage(err: Error | unknown, default_value?: string) { return default_value || String(err); } } + +export function formatAppName(name: string): string { + return name + .split(/[-_\s]+/) + .filter((word) => word.length > 0) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); +} diff --git a/ui/desktop/src/utils/platform_events.ts b/ui/desktop/src/utils/platform_events.ts new file mode 100644 index 000000000000..8cb5053e5a44 --- /dev/null +++ b/ui/desktop/src/utils/platform_events.ts @@ -0,0 +1,95 @@ +import { listApps, GooseApp } from '../api'; + +interface PlatformEventData { + extension: string; + sessionId?: string; + [key: string]: unknown; +} + +interface AppsEventData extends PlatformEventData { + app_name?: string; + sessionId: string; +} + +type PlatformEventHandler = (eventType: string, data: PlatformEventData) => Promise; + +async function handleAppsEvent(eventType: string, eventData: PlatformEventData): Promise { + const { app_name, sessionId } = eventData as AppsEventData; + + if (!sessionId) { + console.warn('No sessionId in apps platform event, skipping'); + return; + } + + const response = await listApps({ + throwOnError: false, + query: { session_id: sessionId }, + }); + + const apps = response.data?.apps || []; + + const targetApp = apps.find((app: GooseApp) => app.name === app_name); + + switch (eventType) { + case 'app_created': + if (targetApp) { + await window.electron.launchApp(targetApp).catch((err) => { + console.error('Failed to launch newly created app:', err); + }); + } + break; + + case 'app_updated': + if (targetApp) { + await window.electron.refreshApp(targetApp).catch((err) => { + console.error('Failed to refresh updated app:', err); + }); + } + break; + + case 'app_deleted': + if (app_name) { + await window.electron.closeApp(app_name).catch((err) => { + console.error('Failed to close deleted app:', err); + }); + } + break; + + default: + console.warn(`Unknown apps event type: ${eventType}`); + } +} + +const EXTENSION_HANDLERS: Record = { + apps: handleAppsEvent, +}; + +export function maybeHandlePlatformEvent(notification: unknown, sessionId: string): void { + if (notification && typeof notification === 'object' && 'method' in notification) { + const msg = notification as { method?: string; params?: unknown }; + if (msg.method === 'platform_event' && msg.params) { + window.dispatchEvent( + new CustomEvent('platform-event', { + detail: { ...msg.params, sessionId }, + }) + ); + } + } +} + +export function registerPlatformEventHandlers(): () => void { + const handler = (event: Event) => { + const customEvent = event as CustomEvent; + const { extension, event_type, ...data } = customEvent.detail; + + const extensionHandler = EXTENSION_HANDLERS[extension]; + if (extensionHandler) { + extensionHandler(event_type, { ...data, extension }).catch((err) => { + console.error(`Platform event handler failed for ${extension}:`, err); + }); + } + }; + + window.addEventListener('platform-event', handler); + return () => window.removeEventListener('platform-event', handler); +}