mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
feat: add delete button to apps UI
Implement delete functionality for custom apps in the Apps view. Adds:
- DELETE /agent/delete_app/{name} endpoint to delete apps from cache
- McpAppCache.delete_app() method to remove cached app files
- UI delete button with confirmation modal on each custom app card
- OpenAPI schema generation for the new endpoint
Resolves #7965
This commit is contained in:
@@ -428,6 +428,7 @@ derive_utoipa!(Icon as IconSchema);
|
||||
super::routes::agent::list_apps,
|
||||
super::routes::agent::export_app,
|
||||
super::routes::agent::import_app,
|
||||
super::routes::agent::delete_app,
|
||||
super::routes::agent::update_from_session,
|
||||
super::routes::agent::agent_add_extension,
|
||||
super::routes::agent::agent_remove_extension,
|
||||
@@ -645,6 +646,7 @@ derive_utoipa!(Icon as IconSchema);
|
||||
super::routes::agent::ListAppsResponse,
|
||||
super::routes::agent::ImportAppRequest,
|
||||
super::routes::agent::ImportAppResponse,
|
||||
super::routes::agent::DeleteAppResponse,
|
||||
super::routes::agent::StartAgentRequest,
|
||||
super::routes::agent::ResumeAgentRequest,
|
||||
super::routes::agent::StopAgentRequest,
|
||||
|
||||
@@ -7,7 +7,7 @@ use axum::response::IntoResponse;
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use goose::agents::{Container, ExtensionLoadResult};
|
||||
@@ -1284,6 +1284,65 @@ async fn import_app(
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteAppResponse {
|
||||
pub name: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/agent/delete_app/{name}",
|
||||
params(
|
||||
("name" = String, Path, description = "Name of the app to delete")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "App deleted successfully", body = DeleteAppResponse),
|
||||
(status = 404, description = "App not found", body = ErrorResponse),
|
||||
(status = 500, description = "Internal server error", body = ErrorResponse),
|
||||
),
|
||||
security(
|
||||
("api_key" = [])
|
||||
),
|
||||
tag = "Agent"
|
||||
)]
|
||||
async fn delete_app(
|
||||
axum::extract::Path(name): axum::extract::Path<String>,
|
||||
) -> Result<Json<DeleteAppResponse>, 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,
|
||||
})?;
|
||||
|
||||
for extension_name in &app.mcp_servers {
|
||||
cache
|
||||
.delete_app(extension_name, &app.resource.uri)
|
||||
.map_err(|e| ErrorResponse {
|
||||
message: format!("Failed to delete app: {}", e),
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(Json(DeleteAppResponse {
|
||||
name: name.clone(),
|
||||
message: format!("App '{}' deleted successfully", name),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn routes(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/agent/start", post(start_agent))
|
||||
@@ -1296,6 +1355,7 @@ pub fn routes(state: Arc<AppState>) -> Router {
|
||||
.route("/agent/list_apps", get(list_apps))
|
||||
.route("/agent/export_app/{name}", get(export_app))
|
||||
.route("/agent/import_app", post(import_app))
|
||||
.route("/agent/delete_app/{name}", delete(delete_app))
|
||||
.route("/agent/update_provider", post(update_agent_provider))
|
||||
.route("/agent/update_session", post(update_session))
|
||||
.route("/agent/update_from_session", post(update_from_session))
|
||||
|
||||
@@ -92,6 +92,20 @@ impl McpAppCache {
|
||||
.and_then(|content| serde_json::from_str::<GooseApp>(&content).ok())
|
||||
}
|
||||
|
||||
pub fn delete_app(&self, extension_name: &str, resource_uri: &str) -> Result<(), std::io::Error> {
|
||||
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 Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("App not found in cache: {}::{}", extension_name, resource_uri),
|
||||
));
|
||||
}
|
||||
|
||||
fs::remove_file(app_path)
|
||||
}
|
||||
|
||||
pub fn delete_extension_apps(&self, extension_name: &str) -> Result<usize, std::io::Error> {
|
||||
let mut deleted_count = 0;
|
||||
|
||||
|
||||
@@ -128,6 +128,62 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/agent/delete_app/{name}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Agent"
|
||||
],
|
||||
"operationId": "delete_app",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"description": "Name of the app to delete",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "App deleted successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DeleteAppResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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/export_app/{name}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -4667,6 +4723,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DeleteAppResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"DeleteRecipeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
Generated
+20714
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -229,6 +229,11 @@ export type DecodeRecipeResponse = {
|
||||
recipe: Recipe;
|
||||
};
|
||||
|
||||
export type DeleteAppResponse = {
|
||||
message: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type DeleteRecipeRequest = {
|
||||
id: string;
|
||||
};
|
||||
@@ -1723,6 +1728,40 @@ export type CallToolResponses = {
|
||||
|
||||
export type CallToolResponse2 = CallToolResponses[keyof CallToolResponses];
|
||||
|
||||
export type DeleteAppData = {
|
||||
body?: never;
|
||||
path: {
|
||||
/**
|
||||
* Name of the app to delete
|
||||
*/
|
||||
name: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/agent/delete_app/{name}';
|
||||
};
|
||||
|
||||
export type DeleteAppErrors = {
|
||||
/**
|
||||
* App not found
|
||||
*/
|
||||
404: ErrorResponse;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: ErrorResponse;
|
||||
};
|
||||
|
||||
export type DeleteAppError = DeleteAppErrors[keyof DeleteAppErrors];
|
||||
|
||||
export type DeleteAppResponses = {
|
||||
/**
|
||||
* App deleted successfully
|
||||
*/
|
||||
200: DeleteAppResponse;
|
||||
};
|
||||
|
||||
export type DeleteAppResponse2 = DeleteAppResponses[keyof DeleteAppResponses];
|
||||
|
||||
export type ExportAppData = {
|
||||
body?: never;
|
||||
path: {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { MainPanelLayout } from '../Layout/MainPanelLayout';
|
||||
import { Button } from '../ui/button';
|
||||
import { Download, Play, Upload } from 'lucide-react';
|
||||
import { exportApp, GooseApp, importApp, listApps } from '../../api';
|
||||
import { Download, Play, Trash2, Upload } from 'lucide-react';
|
||||
import { deleteApp, exportApp, GooseApp, importApp, listApps } from '../../api';
|
||||
import { ConfirmationModal } from '../ui/ConfirmationModal';
|
||||
import { useChatContext } from '../../contexts/ChatContext';
|
||||
import { formatAppName } from '../../utils/conversionUtils';
|
||||
import { errorMessage } from '../../utils/conversionUtils';
|
||||
@@ -25,6 +26,7 @@ export default function AppsView() {
|
||||
const [apps, setApps] = useState<GooseApp[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [appToDelete, setAppToDelete] = useState<GooseApp | null>(null);
|
||||
const chatContext = useChatContext();
|
||||
const sessionId = chatContext?.chat.sessionId;
|
||||
|
||||
@@ -159,6 +161,28 @@ export default function AppsView() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteApp = async (app: GooseApp) => {
|
||||
try {
|
||||
await deleteApp({
|
||||
throwOnError: true,
|
||||
path: { name: app.name },
|
||||
});
|
||||
|
||||
// Refresh apps list
|
||||
const response = await listApps({
|
||||
throwOnError: true,
|
||||
});
|
||||
const cachedApps = response.data?.apps || [];
|
||||
setApps(cachedApps.filter((a) => a.mcpServers?.includes('apps')));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete app:', err);
|
||||
setError(errorMessage(err, 'Failed to delete app'));
|
||||
} finally {
|
||||
setAppToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleImportClick = () => {
|
||||
@@ -284,14 +308,24 @@ export default function AppsView() {
|
||||
Launch
|
||||
</Button>
|
||||
{isCustomApp && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadApp(app)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadApp(app)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAppToDelete(app)}
|
||||
className="flex items-center gap-2 text-red-500 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -301,6 +335,17 @@ export default function AppsView() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={appToDelete !== null}
|
||||
title="Delete App"
|
||||
message={`Are you sure you want to delete "${appToDelete ? formatAppName(appToDelete.name) : ''}"? This action cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
confirmVariant="destructive"
|
||||
onConfirm={() => appToDelete && handleDeleteApp(appToDelete)}
|
||||
onCancel={() => setAppToDelete(null)}
|
||||
/>
|
||||
</MainPanelLayout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user