Vibe mcp apps (#6569)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
Douwe Osinga
2026-01-22 13:03:44 -05:00
committed by GitHub
parent 13bdff4bb5
commit 01e607a12d
28 changed files with 2198 additions and 294 deletions
+4
View File
@@ -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,
+121 -3
View File
@@ -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<String> = apps
let active_extensions: HashSet<String> = 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<String>,
) -> Result<impl IntoResponse, ErrorResponse> {
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<ImportAppRequest>,
) -> Result<(StatusCode, Json<ImportAppResponse>), 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<String> = 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<AppState>) -> Router {
Router::new()
.route("/agent/start", post(start_agent))
@@ -1034,6 +1150,8 @@ pub fn routes(state: Arc<AppState>) -> 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))
+20
View File
@@ -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()
{
+669
View File
@@ -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 <!DOCTYPE html> to </html>
html: String,
/// Window width in pixels (recommended: 400-1600)
width: Option<u32>,
/// Window height in pixels (recommended: 300-1200)
height: Option<u32>,
/// Whether the window should be resizable
resizable: Option<bool>,
}
/// 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 <!DOCTYPE html> to </html>
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<u32>,
/// Updated window height in pixels (optional - only if size should change)
height: Option<u32>,
/// Updated resizable property (optional - only if it should change)
resizable: Option<bool>,
}
pub struct AppsManagerClient {
info: InitializeResult,
context: PlatformExtensionContext,
apps_dir: PathBuf,
}
impl AppsManagerClient {
pub fn new(context: PlatformExtensionContext) -> Result<Self, String> {
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<Vec<String>, 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<GooseApp, String> {
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<Arc<dyn Provider>, 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<T: JsonSchema>() -> 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::<CreateAppContentResponse>(),
)
}
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::<UpdateAppContentResponse>(),
)
}
async fn generate_new_app_content(
&self,
session_id: &str,
prd: &str,
) -> Result<CreateAppContentResponse, String> {
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<UpdateAppContentResponse, String> {
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<JsonObject>,
) -> Result<CallToolResult, String> {
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<JsonObject>,
) -> Result<CallToolResult, String> {
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<JsonObject>,
) -> Result<CallToolResult, String> {
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<JsonObject>,
) -> Result<CallToolResult, String> {
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<String>,
_cancel_token: CancellationToken,
) -> Result<ListToolsResult, Error> {
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::<ListAppsParams>(),
),
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::<CreateAppParams>(),
),
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::<IterateAppParams>(),
),
McpTool::new(
"delete_app".to_string(),
"Delete an app permanently".to_string(),
schema::<DeleteAppParams>(),
),
];
Ok(ListToolsResult {
tools,
next_cursor: None,
meta: None,
})
}
async fn call_tool(
&self,
session_id: &str,
name: &str,
arguments: Option<JsonObject>,
_cancel_token: CancellationToken,
) -> Result<CallToolResult, Error> {
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<String>,
_cancel_token: CancellationToken,
) -> Result<ListResourcesResult, Error> {
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<ReadResourceResult, Error> {
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<T: JsonSchema>() -> 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<String, String> {
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<T: serde::de::DeserializeOwned>(
response: &Message,
tool_name: &str,
) -> Result<T, String> {
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))
}
+44
View File
@@ -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<HashMap<&'static str, PlatformExtensionDef>
},
);
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<crate::session::SessionManager>,
}
impl PlatformExtensionContext {
pub fn result_with_platform_notification(
&self,
mut result: rmcp::model::CallToolResult,
extension_name: impl Into<String>,
event_type: impl Into<String>,
mut additional_params: serde_json::Map<String, serde_json::Value>,
) -> 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,
@@ -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()
+1
View File
@@ -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;
+29 -6
View File
@@ -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<serde_json::Value>,
}
#[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<S: Into<String>>(
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<S: Into<String>>(
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
}
+309
View File
@@ -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<String>,
#[serde(flatten, skip_serializing_if = "Option::is_none")]
pub window_props: Option<WindowProps>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prd: Option<String>,
}
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<Self, String> {
use regex::Regex;
let metadata_re = Regex::new(&format!(
r#"(?s)<script type="{}"[^>]*>\s*(.*?)\s*</script>"#,
regex::escape(Self::METADATA_SCRIPT_TYPE)
))
.map_err(|e| format!("Regex error: {}", e))?;
let prd_re = Regex::new(&format!(
r#"(?s)<script type="{}"[^>]*>\s*(.*?)\s*</script>"#,
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<String, String> {
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!(
" <script type=\"{}\">\n{}\n </script>",
Self::METADATA_SCRIPT_TYPE,
metadata_json
);
let prd_script = if let Some(ref prd) = self.prd {
if !prd.is_empty() {
format!(
" <script type=\"{}\">\n{}\n </script>",
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("</head>") {
let mut result = html.clone();
result.insert_str(head_pos, &scripts);
result
} else if let Some(html_pos) = html.find("<html") {
let after_html = html
.get(html_pos..)
.and_then(|s| s.find('>'))
.map(|p| html_pos + p + 1);
if let Some(pos) = after_html {
let mut result = html.clone();
result.insert_str(pos, &format!("\n<head>\n{}</head>", scripts));
result
} else {
format!("<head>\n{}</head>\n{}", scripts, html)
}
} else {
format!(
"<html>\n<head>\n{}</head>\n<body>\n{}\n</body>\n</html>",
scripts, html
)
};
Ok(result)
}
}
pub async fn fetch_mcp_apps(
extension_manager: &ExtensionManager,
session_id: &str,
) -> Result<Vec<GooseApp>, 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)
}
+118
View File
@@ -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<Self, std::io::Error> {
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<Vec<GooseApp>, 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::<GooseApp>(&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<GooseApp> {
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::<GooseApp>(&content).ok())
}
pub fn delete_extension_apps(&self, extension_name: &str) -> Result<usize, std::io::Error> {
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::<GooseApp>(&content) {
if app.mcp_servers.contains(&extension_name.to_string())
&& fs::remove_file(&path).is_ok()
{
deleted_count += 1;
}
}
}
}
}
Ok(deleted_count)
}
}
+250
View File
@@ -0,0 +1,250 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Clock</title>
<script type="application/ld+json">
{
"@context": "https://goose.ai/schema",
"@type": "GooseApp",
"name": "clock",
"description": "Swiss Railway Clock",
"width": 300,
"height": 300,
"resizable": false
}
</script>
<script type="application/x-goose-prd">
# Swiss Railway Clock Widget
## Overview
An analog clock widget inspired by the iconic Swiss railway clock (Hans Hilfiker design).
## Core Functionality
### Time Display
- Analog clock face with hour, minute, and second hands
- Second hand sweeps smoothly in small increments (10 updates/second)
- Distinctive "pause and jump" behavior: second hand pauses at 12 o'clock for ~1.5 seconds before jumping to the next minute
- Hour and minute hands update continuously based on current time
- Clean white face with simple black hour markers
### Swiss Clock Behavior
- Second hand: smooth sweep with characteristic pause at top
- During pause: hour and minute hands continue to move
- After pause: second hand jumps to correct position and resumes sweep
- Mimics the synchronization behavior of Swiss railway station clocks
### Visual Design
- Circular white clock face
- Black outline circle
- Simple black tick marks at hour positions
- Red lollipop-style second hand (round tip)
- Black tapered hour and minute hands
- Centered on widget canvas
## Default Dimensions
- Width: 300px
- Height: 300px
- Not resizable (maintains aspect ratio)
## Technical Requirements
- Uses HTML5 canvas for rendering
- Updates 10 times per second for smooth animation
- Properly implements Swiss railway clock timing behavior
- Cleans up animation loop when widget closes
</script>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
body {
display: flex;
align-items: center;
justify-content: center;
}
.clock-container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
#clockCanvas {
display: block;
max-width: 100%;
max-height: 100%;
}
</style>
</head>
<body>
<div class="clock-container">
<canvas id="clockCanvas" width="300" height="300"></canvas>
</div>
<script>
class ClockWidget {
constructor() {
this.animationFrame = null;
this.lastSecond = -1;
this.pauseUntil = 0;
}
onMount() {
this.canvas = document.getElementById('clockCanvas');
this.ctx = this.canvas.getContext('2d');
this.animate();
}
onClose() {
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
}
}
animate() {
this.drawClock();
this.animationFrame = requestAnimationFrame(() => this.animate());
}
drawClock() {
const now = Date.now();
const date = new Date(now);
const canvas = this.canvas;
const ctx = this.ctx;
const radius = canvas.width / 2;
const centerX = radius;
const centerY = radius;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.beginPath();
ctx.arc(centerX, centerY, radius - 5, 0, 2 * Math.PI);
ctx.fillStyle = 'white';
ctx.fill();
ctx.strokeStyle = 'black';
ctx.lineWidth = 2;
ctx.stroke();
for (let i = 0; i < 60; i++) {
const angle = (i * 6) * Math.PI / 180;
const isHourMark = i % 5 === 0;
if (isHourMark) {
const x1 = centerX + Math.sin(angle) * (radius - 15);
const y1 = centerY - Math.cos(angle) * (radius - 15);
const x2 = centerX + Math.sin(angle) * (radius - 40);
const y2 = centerY - Math.cos(angle) * (radius - 40);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = 'black';
ctx.lineWidth = 8;
ctx.lineCap = 'butt';
ctx.stroke();
} else {
const x1 = centerX + Math.sin(angle) * (radius - 15);
const y1 = centerY - Math.cos(angle) * (radius - 15);
const x2 = centerX + Math.sin(angle) * (radius - 25);
const y2 = centerY - Math.cos(angle) * (radius - 25);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = 'black';
ctx.lineWidth = 2;
ctx.lineCap = 'butt';
ctx.stroke();
}
}
ctx.font = '16px sans-serif';
ctx.fillStyle = 'black';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('GOOSE CLOCK', centerX, centerY - 65);
const seconds = date.getSeconds();
const milliseconds = date.getMilliseconds();
if (seconds === 0 && this.lastSecond === 59) {
this.pauseUntil = now + 1500;
}
this.lastSecond = seconds;
const hours = date.getHours() % 12;
const minutes = date.getMinutes();
const hourAngle = ((hours + minutes / 60) * 30) * Math.PI / 180;
this.drawHand(ctx, centerX, centerY, hourAngle, radius * 0.65, 11, 'black');
const minuteAngle = ((minutes + seconds / 60) * 6) * Math.PI / 180;
this.drawHand(ctx, centerX, centerY, minuteAngle, radius * 0.90, 8, 'black');
let secondAngle;
if (now < this.pauseUntil) {
secondAngle = 0;
} else {
secondAngle = ((seconds + milliseconds / 1000) * 6) * Math.PI / 180;
}
this.drawSecondHand(ctx, centerX, centerY, secondAngle, radius * 0.65);
ctx.beginPath();
ctx.arc(centerX, centerY, 6, 0, 2 * Math.PI);
ctx.fillStyle = '#d32f2f';
ctx.fill();
}
drawHand(ctx, x, y, angle, length, width, color) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.beginPath();
ctx.rect(-width/2, -length, width, length + 20);
ctx.fillStyle = color;
ctx.fill();
ctx.restore();
}
drawSecondHand(ctx, x, y, angle, length) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(0, length * 0.2);
ctx.lineTo(0, -length);
ctx.strokeStyle = '#d32f2f';
ctx.lineWidth = 2.5;
ctx.lineCap = 'butt';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -length, 10, 0, 2 * Math.PI);
ctx.fillStyle = '#d32f2f';
ctx.fill();
ctx.restore();
}
}
const widget = new ClockWidget();
widget.onMount();
window.addEventListener('beforeunload', () => {
widget.onClose();
});
</script>
</body>
</html>
+4 -206
View File
@@ -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<String>,
#[serde(flatten, skip_serializing_if = "Option::is_none")]
pub window_props: Option<WindowProps>,
}
pub struct McpAppCache {
cache_dir: PathBuf,
}
impl McpAppCache {
pub fn new() -> Result<Self, std::io::Error> {
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<Vec<GooseApp>, 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::<GooseApp>(&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<GooseApp> {
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::<GooseApp>(&content).ok())
}
pub fn delete_extension_apps(&self, extension_name: &str) -> Result<usize, std::io::Error> {
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::<GooseApp>(&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<Vec<GooseApp>, 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::<Vec<_>>()
.join(" ")
}
+8
View File
@@ -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",
+19
View File
@@ -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.
+25
View File
@@ -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.
+148 -3
View File
@@ -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"
},
+6
View File
@@ -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 <ErrorUI error={errorMessage(fatalError)} />;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+75 -4
View File
@@ -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<string>;
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;
@@ -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<SidebarProps> = ({ 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<Session[]>([]);
const [hasApps, setHasApps] = useState(false);
const activeSessionId = searchParams.get('resumeSessionId') ?? undefined;
const { getSessionStatus, clearUnread } = useSidebarSessionStatus(activeSessionId);
@@ -251,21 +255,6 @@ const AppSidebar: React.FC<SidebarProps> = ({ 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<typeof setTimeout>[] = [];
let isPolling = false;
@@ -477,8 +466,9 @@ const AppSidebar: React.FC<SidebarProps> = ({ 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<SidebarProps> = ({ currentPath }) => {
<SidebarSeparator />
{/* Other menu items - filter out Apps if no apps available */}
{visibleMenuItems.map((entry, index) => renderMenuItem(entry, index))}
</SidebarMenu>
</SidebarContent>
+154 -35
View File
@@ -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<HTMLInputElement>(null);
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleUploadApp = async (event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<MainPanelLayout>
<div className="flex-1 flex flex-col min-h-0">
<input
ref={fileInputRef}
type="file"
accept=".html"
onChange={handleUploadApp}
style={{ display: 'none' }}
/>
<div className="bg-background-default px-8 pb-8 pt-16">
<div className="flex flex-col page-transition">
<div className="flex justify-between items-center mb-1">
<h1 className="text-4xl font-light">Apps</h1>
<Button
variant="outline"
size="sm"
onClick={handleImportClick}
className="flex items-center gap-2"
>
<Upload className="h-4 w-4" />
Import App
</Button>
</div>
<div className="mb-4">
<p className="text-sm text-text-muted mb-2">
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.
</p>
<p className="text-xs text-amber-600 dark:text-amber-500">
Experimental feature - may change or be removed at any time
</p>
</div>
<p className="text-sm text-text-muted mb-4">
Applications from your MCP servers that can run in standalone windows.
</p>
</div>
</div>
@@ -140,41 +242,58 @@ export default function AppsView() {
<div className="text-center">
<h3 className="text-lg font-medium mb-2">No apps available</h3>
<p className="text-sm text-text-muted">
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.
</p>
</div>
</div>
) : (
<GridLayout>
{apps.map((app) => (
<div
key={`${app.uri}-${app.mcpServer}`}
className="flex flex-col p-4 border border-border-muted rounded-lg bg-background-panel hover:border-border-default transition-colors"
>
<div className="flex-1 mb-4">
<h3 className="font-medium text-text-default mb-2">{app.name}</h3>
{app.description && (
<p className="text-sm text-text-muted mb-2">{app.description}</p>
)}
{app.mcpServer && (
<span className="inline-block px-2 py-1 text-xs bg-background-subtle text-text-muted rounded">
{app.mcpServer}
</span>
)}
{apps.map((app) => {
const isCustomApp = app.mcpServers?.includes('apps') ?? false;
return (
<div
key={`${app.uri}-${app.mcpServers?.join(',')}`}
className="flex flex-col p-4 border border-border-muted rounded-lg bg-background-panel hover:border-border-default transition-colors"
>
<div className="flex-1 mb-4">
<h3 className="font-medium text-text-default mb-2">
{formatAppName(app.name)}
</h3>
{app.description && (
<p className="text-sm text-text-muted mb-2">{app.description}</p>
)}
{app.mcpServers && app.mcpServers.length > 0 && (
<span className="inline-block px-2 py-1 text-xs bg-background-subtle text-text-muted rounded">
{isCustomApp ? 'Custom app' : app.mcpServers.join(', ')}
</span>
)}
</div>
<div className="flex gap-2">
<Button
variant="default"
size="sm"
onClick={() => handleLaunchApp(app)}
className="flex items-center gap-2 flex-1"
>
<Play className="h-4 w-4" />
Launch
</Button>
{isCustomApp && (
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadApp(app)}
className="flex items-center gap-2"
>
<Download className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="flex gap-2">
<Button
variant="default"
size="sm"
onClick={() => handleLaunchApp(app)}
className="flex items-center gap-2 flex-1"
>
<Play className="h-4 w-4" />
Launch
</Button>
</div>
</div>
))}
);
})}
</GridLayout>
)}
</div>
@@ -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]);
+6 -3
View File
@@ -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<string, { messages: Message[]; session: Session }>();
@@ -197,7 +198,8 @@ async function streamFromResponse(
stream: AsyncIterable<MessageEvent>,
initialMessages: Message[],
dispatch: React.Dispatch<StreamAction>,
onFinish: (error?: string) => void
onFinish: (error?: string) => void,
sessionId: string
): Promise<void> {
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
+45 -3
View File
@@ -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<number, BrowserWindow>();
const goosedClients = new Map<number, Client>();
const appWindows = new Map<string, BrowserWindow>();
// Track power save blockers per window
const windowPowerSaveBlockers = new Map<number, number>(); // windowId -> blockerId
// Track pending initial messages per window
const pendingInitialMessages = new Map<number, string>(); // 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 () => {
+4
View File
@@ -138,6 +138,8 @@ type ElectronAPI = {
recordRecipeHash: (recipe: Recipe) => Promise<boolean>;
openDirectoryInExplorer: (directoryPath: string) => Promise<boolean>;
launchApp: (app: GooseApp) => Promise<void>;
refreshApp: (app: GooseApp) => Promise<void>;
closeApp: (appName: string) => Promise<void>;
addRecentDir: (dir: string) => Promise<boolean>;
};
@@ -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),
};
+8
View File
@@ -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(' ');
}
+95
View File
@@ -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<void>;
async function handleAppsEvent(eventType: string, eventData: PlatformEventData): Promise<void> {
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<string, PlatformEventHandler> = {
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);
}