mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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(" ")
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -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(' ');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user