From ce69b396ce7ecf7396bf43ca10799195d9ba5abf Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Mon, 1 Sep 2025 11:28:28 +1000 Subject: [PATCH] chore: move list recipes and archive recipe to goose server (#4422) --- Cargo.lock | 1 + crates/goose-server/Cargo.toml | 1 + crates/goose-server/src/openapi.rs | 7 +- crates/goose-server/src/routes/mod.rs | 1 + crates/goose-server/src/routes/recipe.rs | 103 +++ .../goose-server/src/routes/recipe_utils.rs | 130 +++ crates/goose-server/src/state.rs | 9 + ui/desktop/openapi.json | 110 +++ ui/desktop/src/api/sdk.gen.ts | 20 +- ui/desktop/src/api/types.gen.ts | 74 ++ .../src/components/ProgressiveMessageList.tsx | 2 +- ui/desktop/src/components/RecipesView.tsx | 238 ++---- .../alerts/__tests__/AlertBox.test.tsx | 61 +- .../alerts/__tests__/useAlerts.test.tsx | 110 +-- .../components/sessions/SessionListView.tsx | 798 +++++++++--------- ui/desktop/src/recipe/recipeStorage.ts | 175 +--- 16 files changed, 1039 insertions(+), 801 deletions(-) create mode 100644 crates/goose-server/src/routes/recipe_utils.rs diff --git a/Cargo.lock b/Cargo.lock index f77b5dbff7..56f8038305 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2766,6 +2766,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "tempfile", "thiserror 1.0.69", "tokio", "tokio-stream", diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml index 7abfdd37f2..de4dbba76c 100644 --- a/crates/goose-server/Cargo.toml +++ b/crates/goose-server/Cargo.toml @@ -53,3 +53,4 @@ path = "src/bin/generate_schema.rs" [dev-dependencies] tower = "0.5" async-trait = "0.1" +tempfile = "3.15.0" diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index c2012ef4a8..f3745c410f 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -393,7 +393,9 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::create_recipe, super::routes::recipe::encode_recipe, super::routes::recipe::decode_recipe, - super::routes::recipe::scan_recipe + super::routes::recipe::scan_recipe, + super::routes::recipe::list_recipes, + super::routes::recipe::delete_recipe, ), components(schemas( super::routes::config_management::UpsertConfigQuery, @@ -464,6 +466,9 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::DecodeRecipeResponse, super::routes::recipe::ScanRecipeRequest, super::routes::recipe::ScanRecipeResponse, + super::routes::recipe::RecipeManifestResponse, + super::routes::recipe::ListRecipeResponse, + super::routes::recipe::DeleteRecipeRequest, goose::recipe::Recipe, goose::recipe::Author, goose::recipe::Settings, diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index dfc96b8c1b..bc86bbd4ef 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -6,6 +6,7 @@ pub mod context; pub mod extension; pub mod health; pub mod recipe; +pub mod recipe_utils; pub mod reply; pub mod schedule; pub mod session; diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index ad463aefed..93c8620de8 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -1,12 +1,19 @@ +use std::collections::HashMap; +use std::fs; use std::sync::Arc; +use axum::routing::get; use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; use goose::conversation::{message::Message, Conversation}; use goose::recipe::Recipe; use goose::recipe_deeplink; + +use http::HeaderMap; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use crate::routes::recipe_utils::get_all_recipes_manifests; +use crate::routes::utils::verify_secret_key; use crate::state::AppState; #[derive(Debug, Deserialize, ToSchema)] @@ -66,6 +73,27 @@ pub struct ScanRecipeResponse { has_security_warnings: bool, } +#[derive(Debug, Serialize, ToSchema)] +pub struct RecipeManifestResponse { + name: String, + #[serde(rename = "isGlobal")] + is_global: bool, + recipe: Recipe, + #[serde(rename = "lastModified")] + last_modified: String, + id: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct DeleteRecipeRequest { + id: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ListRecipeResponse { + recipe_manifest_responses: Vec, +} + #[utoipa::path( post, path = "/recipes/create", @@ -209,12 +237,87 @@ async fn scan_recipe( })) } +#[utoipa::path( + get, + path = "/recipes/list", + responses( + (status = 200, description = "Get recipe list successfully", body = ListRecipeResponse), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 500, description = "Internal server error") + ), + tag = "Recipe Management" +)] +async fn list_recipes( + State(state): State>, + headers: HeaderMap, +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; + + let recipe_manifest_with_paths = get_all_recipes_manifests().unwrap(); + let mut recipe_file_hash_map = HashMap::new(); + let recipe_manifest_responses = recipe_manifest_with_paths + .iter() + .map(|recipe_manifest_with_path| { + let id = &recipe_manifest_with_path.id; + let file_path = recipe_manifest_with_path.file_path.clone(); + recipe_file_hash_map.insert(id.clone(), file_path); + RecipeManifestResponse { + name: recipe_manifest_with_path.name.clone(), + is_global: recipe_manifest_with_path.is_global, + recipe: recipe_manifest_with_path.recipe.clone(), + id: id.clone(), + last_modified: recipe_manifest_with_path.last_modified.clone(), + } + }) + .collect::>(); + state.set_recipe_file_hash_map(recipe_file_hash_map).await; + + Ok(Json(ListRecipeResponse { + recipe_manifest_responses, + })) +} + +#[utoipa::path( + post, + path = "/recipes/delete", + request_body = DeleteRecipeRequest, + responses( + (status = 204, description = "Recipe deleted successfully"), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 404, description = "Recipe not found"), + (status = 500, description = "Internal server error") + ), + tag = "Recipe Management" +)] +async fn delete_recipe( + State(state): State>, + headers: HeaderMap, + Json(request): Json, +) -> StatusCode { + if verify_secret_key(&headers, &state).is_err() { + return StatusCode::UNAUTHORIZED; + } + let recipe_file_hash_map = state.recipe_file_hash_map.lock().await; + let file_path = match recipe_file_hash_map.get(&request.id) { + Some(path) => path, + None => return StatusCode::NOT_FOUND, + }; + + if fs::remove_file(file_path).is_err() { + return StatusCode::INTERNAL_SERVER_ERROR; + } + + StatusCode::NO_CONTENT +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/recipes/create", post(create_recipe)) .route("/recipes/encode", post(encode_recipe)) .route("/recipes/decode", post(decode_recipe)) .route("/recipes/scan", post(scan_recipe)) + .route("/recipes/list", get(list_recipes)) + .route("/recipes/delete", post(delete_recipe)) .with_state(state) } diff --git a/crates/goose-server/src/routes/recipe_utils.rs b/crates/goose-server/src/routes/recipe_utils.rs new file mode 100644 index 0000000000..8fba2fea68 --- /dev/null +++ b/crates/goose-server/src/routes/recipe_utils.rs @@ -0,0 +1,130 @@ +use std::fs; +use std::hash::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::path::PathBuf; + +use anyhow::Result; +use etcetera::{choose_app_strategy, AppStrategy}; + +use goose::config::APP_STRATEGY; +use goose::recipe::read_recipe_file_content::read_recipe_file; +use goose::recipe::Recipe; + +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +pub struct RecipeManifestWithPath { + pub id: String, + pub name: String, + pub is_global: bool, + pub recipe: Recipe, + pub file_path: PathBuf, + pub last_modified: String, +} + +fn short_id_from_path(path: &str) -> String { + let mut hasher = DefaultHasher::new(); + path.hash(&mut hasher); + let h = hasher.finish(); + format!("{:016x}", h) +} + +fn load_recipes_from_path(path: &PathBuf) -> Result> { + let mut recipe_manifests_with_path = Vec::new(); + if path.exists() { + for entry in fs::read_dir(path)? { + let path = entry?.path(); + if path.extension() == Some("yaml".as_ref()) { + let Ok(recipe_file) = read_recipe_file(path.clone()) else { + continue; + }; + let Ok(recipe) = Recipe::from_content(&recipe_file.content) else { + continue; + }; + let Ok(recipe_metadata) = RecipeManifestMetadata::from_yaml_file(&path) else { + continue; + }; + let Ok(last_modified) = fs::metadata(path.clone()).map(|m| { + chrono::DateTime::::from(m.modified().unwrap()).to_rfc3339() + }) else { + continue; + }; + + let manifest_with_path = RecipeManifestWithPath { + id: short_id_from_path(recipe_file.file_path.to_string_lossy().as_ref()), + name: recipe_metadata.name, + is_global: recipe_metadata.is_global, + recipe, + file_path: recipe_file.file_path, + last_modified, + }; + recipe_manifests_with_path.push(manifest_with_path); + } + } + } + Ok(recipe_manifests_with_path) +} + +pub fn get_all_recipes_manifests() -> Result> { + let current_dir = std::env::current_dir()?; + let local_recipe_path = current_dir.join(".goose/recipes"); + + let global_recipe_path = choose_app_strategy(APP_STRATEGY.clone()) + .expect("goose requires a home dir") + .config_dir() + .join("recipes"); + + let mut recipe_manifests_with_path = Vec::new(); + + recipe_manifests_with_path.extend(load_recipes_from_path(&local_recipe_path)?); + recipe_manifests_with_path.extend(load_recipes_from_path(&global_recipe_path)?); + recipe_manifests_with_path.sort_by(|a, b| b.last_modified.cmp(&a.last_modified)); + + Ok(recipe_manifests_with_path) +} + +// this is a temporary struct to deserilize the UI recipe files. should not be used for other purposes. +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +struct RecipeManifestMetadata { + pub name: String, + #[serde(rename = "isGlobal")] + pub is_global: bool, +} + +impl RecipeManifestMetadata { + pub fn from_yaml_file(path: &Path) -> Result { + let content = fs::read_to_string(path) + .map_err(|e| anyhow::anyhow!("Failed to read file {}: {}", path.display(), e))?; + let metadata = serde_yaml::from_str::(&content) + .map_err(|e| anyhow::anyhow!("Failed to parse YAML: {}", e))?; + Ok(metadata) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_from_yaml_file_success() { + let temp_dir = tempdir().unwrap(); + let file_path = temp_dir.path().join("test_recipe.yaml"); + + let yaml_content = r#" +name: "Test Recipe" +isGlobal: true +recipe: recipe_content +"#; + + fs::write(&file_path, yaml_content).unwrap(); + + let result = RecipeManifestMetadata::from_yaml_file(&file_path).unwrap(); + + assert_eq!(result.name, "Test Recipe"); + assert_eq!(result.is_global, true); + } +} diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index 720912b0c4..e72363f4be 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -1,5 +1,7 @@ use goose::agents::Agent; use goose::scheduler_trait::SchedulerTrait; +use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; @@ -10,6 +12,7 @@ pub struct AppState { agent: Option, pub secret_key: String, pub scheduler: Arc>>>, + pub recipe_file_hash_map: Arc>>, } impl AppState { @@ -18,6 +21,7 @@ impl AppState { agent: Some(agent.clone()), secret_key, scheduler: Arc::new(Mutex::new(None)), + recipe_file_hash_map: Arc::new(Mutex::new(HashMap::new())), }) } @@ -39,4 +43,9 @@ impl AppState { .clone() .ok_or_else(|| anyhow::anyhow!("Scheduler not initialized")) } + + pub async fn set_recipe_file_hash_map(&self, hash_map: HashMap) { + let mut map = self.recipe_file_hash_map.lock().await; + *map = hash_map; + } } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 4ee811a1ed..39dd635673 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -862,6 +862,38 @@ } } }, + "/recipes/delete": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "delete_recipe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Recipe deleted successfully" + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "404": { + "description": "Recipe not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/recipes/encode": { "post": { "tags": [ @@ -895,6 +927,32 @@ } } }, + "/recipes/list": { + "get": { + "tags": [ + "Recipe Management" + ], + "operationId": "list_recipes", + "responses": { + "200": { + "description": "Get recipe list successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListRecipeResponse" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/recipes/scan": { "post": { "tags": [ @@ -1717,6 +1775,17 @@ } } }, + "DeleteRecipeRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, "EmbeddedResource": { "type": "object", "required": [ @@ -2257,6 +2326,20 @@ } } }, + "ListRecipeResponse": { + "type": "object", + "required": [ + "recipe_manifest_responses" + ], + "properties": { + "recipe_manifest_responses": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecipeManifestResponse" + } + } + } + }, "ListSchedulesResponse": { "type": "object", "required": [ @@ -2804,6 +2887,33 @@ } } }, + "RecipeManifestResponse": { + "type": "object", + "required": [ + "name", + "isGlobal", + "recipe", + "lastModified", + "id" + ], + "properties": { + "id": { + "type": "string" + }, + "isGlobal": { + "type": "boolean" + }, + "lastModified": { + "type": "string" + }, + "name": { + "type": "string" + }, + "recipe": { + "$ref": "#/components/schemas/Recipe" + } + } + }, "RecipeParameter": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index a36b1ce97c..208017617c 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options as ClientOptions, TDataShape, Client } from './client'; -import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ScanRecipeData, ScanRecipeResponses, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionHistoryData, GetSessionHistoryResponses, GetSessionHistoryErrors } from './types.gen'; +import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ScanRecipeData, ScanRecipeResponses, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionHistoryData, GetSessionHistoryResponses, GetSessionHistoryErrors } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -259,6 +259,17 @@ export const decodeRecipe = (options: Opti }); }; +export const deleteRecipe = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/recipes/delete', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + export const encodeRecipe = (options: Options) => { return (options.client ?? _heyApiClient).post({ url: '/recipes/encode', @@ -270,6 +281,13 @@ export const encodeRecipe = (options: Opti }); }; +export const listRecipes = (options?: Options) => { + return (options?.client ?? _heyApiClient).get({ + url: '/recipes/list', + ...options + }); +}; + export const scanRecipe = (options: Options) => { return (options.client ?? _heyApiClient).post({ url: '/recipes/scan', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 9d2e462172..4edbb65c77 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -135,6 +135,10 @@ export type DecodeRecipeResponse = { recipe: Recipe; }; +export type DeleteRecipeRequest = { + id: string; +}; + export type EmbeddedResource = { annotations?: Annotations | { [key: string]: unknown; @@ -332,6 +336,10 @@ export type KillJobResponse = { message: string; }; +export type ListRecipeResponse = { + recipe_manifest_responses: Array; +}; + export type ListSchedulesResponse = { jobs: Array; }; @@ -542,6 +550,14 @@ export type Recipe = { version?: string; }; +export type RecipeManifestResponse = { + id: string; + isGlobal: boolean; + lastModified: string; + name: string; + recipe: Recipe; +}; + export type RecipeParameter = { default?: string | null; description: string; @@ -1529,6 +1545,37 @@ export type DecodeRecipeResponses = { export type DecodeRecipeResponse2 = DecodeRecipeResponses[keyof DecodeRecipeResponses]; +export type DeleteRecipeData = { + body: DeleteRecipeRequest; + path?: never; + query?: never; + url: '/recipes/delete'; +}; + +export type DeleteRecipeErrors = { + /** + * Unauthorized - Invalid or missing API key + */ + 401: unknown; + /** + * Recipe not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type DeleteRecipeResponses = { + /** + * Recipe deleted successfully + */ + 204: void; +}; + +export type DeleteRecipeResponse = DeleteRecipeResponses[keyof DeleteRecipeResponses]; + export type EncodeRecipeData = { body: EncodeRecipeRequest; path?: never; @@ -1552,6 +1599,33 @@ export type EncodeRecipeResponses = { export type EncodeRecipeResponse2 = EncodeRecipeResponses[keyof EncodeRecipeResponses]; +export type ListRecipesData = { + body?: never; + path?: never; + query?: never; + url: '/recipes/list'; +}; + +export type ListRecipesErrors = { + /** + * Unauthorized - Invalid or missing API key + */ + 401: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type ListRecipesResponses = { + /** + * Get recipe list successfully + */ + 200: ListRecipeResponse; +}; + +export type ListRecipesResponse = ListRecipesResponses[keyof ListRecipesResponses]; + export type ScanRecipeData = { body: ScanRecipeRequest; path?: never; diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index 37bdca8f4e..1e88121a75 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -227,7 +227,7 @@ export default function ProgressiveMessageList({ toolCallNotifications, isStreamingMessage, onMessageUpdate, - hasCompactionMarker + hasCompactionMarker, ]); return ( diff --git a/ui/desktop/src/components/RecipesView.tsx b/ui/desktop/src/components/RecipesView.tsx index 674b350cde..553914711b 100644 --- a/ui/desktop/src/components/RecipesView.tsx +++ b/ui/desktop/src/components/RecipesView.tsx @@ -1,21 +1,11 @@ import { useState, useEffect } from 'react'; import { listSavedRecipes, - archiveRecipe, - SavedRecipe, saveRecipe, generateRecipeFilename, + convertToLocaleDateString, } from '../recipe/recipeStorage'; -import { - FileText, - Trash2, - Bot, - Calendar, - Globe, - Folder, - AlertCircle, - Download, -} from 'lucide-react'; +import { FileText, Trash2, Bot, Calendar, AlertCircle, Download } from 'lucide-react'; import { ScrollArea } from './ui/scroll-area'; import { Card } from './ui/card'; import { Button } from './ui/button'; @@ -24,6 +14,7 @@ import { MainPanelLayout } from './Layout/MainPanelLayout'; import { Recipe, decodeRecipe, generateDeepLink } from '../recipe'; import { toastSuccess, toastError } from '../toasts'; import { useEscapeKey } from '../hooks/useEscapeKey'; +import { deleteRecipe, RecipeManifestResponse } from '../api'; interface RecipesViewProps { onLoadRecipe?: (recipe: Recipe) => void; @@ -31,11 +22,11 @@ interface RecipesViewProps { // @ts-expect-error until we make onLoadRecipe work for loading recipes in the same window export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { - const [savedRecipes, setSavedRecipes] = useState([]); + const [savedRecipes, setSavedRecipes] = useState([]); const [loading, setLoading] = useState(true); const [showSkeleton, setShowSkeleton] = useState(true); const [error, setError] = useState(null); - const [selectedRecipe, setSelectedRecipe] = useState(null); + const [selectedRecipe, setSelectedRecipe] = useState(null); const [showPreview, setShowPreview] = useState(false); const [showContent, setShowContent] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false); @@ -99,8 +90,8 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { setShowSkeleton(true); setShowContent(false); setError(null); - const recipes = await listSavedRecipes(); - setSavedRecipes(recipes); + const recipeManifestResponses = await listSavedRecipes(); + setSavedRecipes(recipeManifestResponses); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load recipes'); console.error('Failed to load saved recipes:', err); @@ -109,7 +100,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { } }; - const handleLoadRecipe = async (savedRecipe: SavedRecipe) => { + const handleLoadRecipe = async (recipe: Recipe) => { try { // onLoadRecipe is not working for loading recipes. It looks correct // but the instructions are not flowing through to the server. @@ -125,7 +116,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { undefined, // dir undefined, // version undefined, // resumeSessionId - savedRecipe.recipe, // recipe config + recipe, // recipe config undefined // view type ); // } @@ -135,15 +126,15 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { } }; - const handleDeleteRecipe = async (savedRecipe: SavedRecipe) => { + const handleDeleteRecipe = async (recipeManifest: RecipeManifestResponse) => { // TODO: Use Electron's dialog API for confirmation const result = await window.electron.showMessageBox({ type: 'warning', buttons: ['Cancel', 'Delete'], defaultId: 0, title: 'Delete Recipe', - message: `Are you sure you want to delete "${savedRecipe.name}"?`, - detail: 'Deleted recipes can be restored later.', + message: `Are you sure you want to delete "${recipeManifest.name}"?`, + detail: 'Recipe file will be deleted.', }); if (result.response !== 1) { @@ -151,22 +142,25 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { } try { - await archiveRecipe(savedRecipe.name, savedRecipe.isGlobal); - // Reload the recipes list + await deleteRecipe({ body: { id: recipeManifest.id } }); await loadSavedRecipes(); + toastSuccess({ + title: recipeManifest.name, + msg: 'Recipe deleted successfully', + }); } catch (err) { - console.error('Failed to archive recipe:', err); - setError(err instanceof Error ? err.message : 'Failed to archive recipe'); + console.error('Failed to delete recipe:', err); + setError(err instanceof Error ? err.message : 'Failed to delete recipe'); } }; - const handlePreviewRecipe = async (savedRecipe: SavedRecipe) => { - setSelectedRecipe(savedRecipe); + const handlePreviewRecipe = async (recipeManifest: RecipeManifestResponse) => { + setSelectedRecipe(recipeManifest); setShowPreview(true); // Generate deeplink for preview try { - const deeplink = await generateDeepLink(savedRecipe.recipe); + const deeplink = await generateDeepLink(recipeManifest.recipe); setPreviewDeeplink(deeplink); } catch (error) { console.error('Failed to generate deeplink for preview:', error); @@ -372,90 +366,65 @@ Parameters you can use: } }; - // Render a recipe item with error handling - const RecipeItem = ({ savedRecipe }: { savedRecipe: SavedRecipe }) => { - try { - return ( - -
-
-
-

{savedRecipe.recipe.title}

- {savedRecipe.isGlobal ? ( - - ) : ( - - )} -
-

- {savedRecipe.recipe.description} -

-
- - {savedRecipe.lastModified.toLocaleDateString()} -
-
+ // Render a recipe item + const RecipeItem = ({ + recipeManifestResponse, + recipeManifestResponse: { recipe, lastModified }, + }: { + recipeManifestResponse: RecipeManifestResponse; + }) => ( + +
+
+
+

{recipe.title}

+
+

{recipe.description}

+
+ + {convertToLocaleDateString(lastModified)} +
+
-
- - - -
-
-
- ); - } catch (error) { - // Error row showing failed to read file with filename and error details - return ( - -
-
-
- -

- Failed to read file: {savedRecipe.filename} -

-
-

- {error instanceof Error ? error.message : 'Unknown error'} -

-
-
-
- ); - } - }; +
+ + + +
+
+
+ ); // Render skeleton loader for recipe items const RecipeSkeleton = () => ( @@ -515,10 +484,10 @@ Parameters you can use: return (
- {savedRecipes.map((savedRecipe) => ( + {savedRecipes.map((recipeManifestResponse: RecipeManifestResponse) => ( ))}
@@ -584,9 +553,6 @@ Parameters you can use:

{selectedRecipe.recipe.title}

-

- {selectedRecipe.isGlobal ? 'Global recipe' : 'Project recipe'} -

+ -
-

- {session.metadata.description || session.id} -

+
+

+ {session.metadata.description || session.id} +

-
- - {formatMessageTimestamp(Date.parse(session.modified) / 1000)} -
-
- - {session.metadata.working_dir} -
-
- -
-
-
- - {session.metadata.message_count} +
+ + {formatMessageTimestamp(Date.parse(session.modified) / 1000)}
- {session.metadata.total_tokens !== null && ( +
+ + {session.metadata.working_dir} +
+
+ +
+
- - {session.metadata.total_tokens.toLocaleString()} + + {session.metadata.message_count}
- )} + {session.metadata.total_tokens !== null && ( +
+ + + {session.metadata.total_tokens.toLocaleString()} + +
+ )} +
-
- - ); - }); + + ); + }); - // Render skeleton loader for session items with variations - const SessionSkeleton = React.memo(({ variant = 0 }: { variant?: number }) => { - const titleWidths = ['w-3/4', 'w-2/3', 'w-4/5', 'w-1/2']; - const pathWidths = ['w-32', 'w-28', 'w-36', 'w-24']; - const tokenWidths = ['w-12', 'w-10', 'w-14', 'w-8']; + // Render skeleton loader for session items with variations + const SessionSkeleton = React.memo(({ variant = 0 }: { variant?: number }) => { + const titleWidths = ['w-3/4', 'w-2/3', 'w-4/5', 'w-1/2']; + const pathWidths = ['w-32', 'w-28', 'w-36', 'w-24']; + const tokenWidths = ['w-12', 'w-10', 'w-14', 'w-8']; + + return ( + +
+ +
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+
+
+ ); + }); + + SessionSkeleton.displayName = 'SessionSkeleton'; + + const renderActualContent = () => { + if (error) { + return ( +
+ +

Error Loading Sessions

+

{error}

+ +
+ ); + } + + if (sessions.length === 0) { + return ( +
+ +

No chat sessions found

+

Your chat history will appear here

+
+ ); + } + + if (dateGroups.length === 0 && searchResults !== null) { + return ( +
+ +

No matching sessions found

+

Try adjusting your search terms

+
+ ); + } + + // For regular rendering in grid layout + return ( +
+ {dateGroups.map((group) => ( +
+
+

{group.label}

+
+
+ {group.sessions.map((session) => ( + + ))} +
+
+ ))} +
+ ); + }; return ( - -
- -
- - -
-
- - -
-
- -
-
-
- - -
-
- - -
-
-
-
- ); - }); - - SessionSkeleton.displayName = 'SessionSkeleton'; - - const renderActualContent = () => { - if (error) { - return ( -
- -

Error Loading Sessions

-

{error}

- -
- ); - } - - if (sessions.length === 0) { - return ( -
- -

No chat sessions found

-

Your chat history will appear here

-
- ); - } - - if (dateGroups.length === 0 && searchResults !== null) { - return ( -
- -

No matching sessions found

-

Try adjusting your search terms

-
- ); - } - - // For regular rendering in grid layout - return ( -
- {dateGroups.map((group) => ( -
-
-

{group.label}

-
-
- {group.sessions.map((session) => ( - - ))} -
-
- ))} -
- ); - }; - - return ( - <> - -
-
-
-
-

Chat history

+ <> + +
+
+
+
+

Chat history

+
+

+ View and search your past conversations with Goose. +

-

- View and search your past conversations with Goose. -

-
-
- -
- - {/* Skeleton layer - always rendered but conditionally visible */} -
+ +
+ -
- {/* Today section */} -
- -
- - - - - + {/* Skeleton layer - always rendered but conditionally visible */} +
+
+ {/* Today section */} +
+ +
+ + + + + +
-
- {/* Yesterday section */} -
- -
- - - - - - + {/* Yesterday section */} +
+ +
+ + + + + + +
-
- {/* Additional section */} -
- -
- - - + {/* Additional section */} +
+ +
+ + + +
-
- {/* Content layer - always rendered but conditionally visible */} -
- {renderActualContent()} -
- -
- + {/* Content layer - always rendered but conditionally visible */} +
+ {renderActualContent()} +
+ +
+ +
-
- + - - - ); -}); + + + ); + } +); SessionListView.displayName = 'SessionListView'; diff --git a/ui/desktop/src/recipe/recipeStorage.ts b/ui/desktop/src/recipe/recipeStorage.ts index bd40aa051b..fde07f99eb 100644 --- a/ui/desktop/src/recipe/recipeStorage.ts +++ b/ui/desktop/src/recipe/recipeStorage.ts @@ -1,3 +1,4 @@ +import { listRecipes, RecipeManifestResponse } from '../api'; import { Recipe } from './index'; import * as yaml from 'yaml'; @@ -165,181 +166,21 @@ export async function loadRecipe(recipeName: string, isGlobal: boolean): Promise } } -/** - * List all saved recipes from the recipes directories. - * - * Uses the listFiles API to find available recipe files. - */ -export async function listSavedRecipes(includeArchived: boolean = false): Promise { +export async function listSavedRecipes(): Promise { try { - // Check for global and local recipe directories - const globalDir = getStorageDirectory(true); - const localDir = getStorageDirectory(false); - - // Ensure directories exist - await window.electron.ensureDirectory(globalDir); - await window.electron.ensureDirectory(localDir); - - // Get list of recipe files with .yaml extension - const globalFiles = await window.electron.listFiles(globalDir, 'yaml'); - const localFiles = await window.electron.listFiles(localDir, 'yaml'); - - // Process global recipes in parallel - const globalRecipePromises = globalFiles.map(async (file) => { - const recipeName = file.replace(/\.yaml$/, ''); - return await loadRecipeFromFile(recipeName, true); - }); - - // Process local recipes in parallel - const localRecipePromises = localFiles.map(async (file) => { - const recipeName = file.replace(/\.yaml$/, ''); - return await loadRecipeFromFile(recipeName, false); - }); - - // Wait for all recipes to load in parallel - const [globalRecipes, localRecipes] = await Promise.all([ - Promise.all(globalRecipePromises), - Promise.all(localRecipePromises), - ]); - - // Filter out null results and apply archived filter - const recipes: SavedRecipe[] = []; - - for (const recipe of globalRecipes) { - if (recipe && (includeArchived || !recipe.isArchived)) { - recipes.push(recipe); - } - } - - for (const recipe of localRecipes) { - if (recipe && (includeArchived || !recipe.isArchived)) { - recipes.push(recipe); - } - } - - // Sort by last modified (newest first) - return recipes.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + const listRecipeResponse = await listRecipes(); + return listRecipeResponse?.data?.recipe_manifest_responses ?? []; } catch (error) { console.warn('Failed to list saved recipes:', error); return []; } } -/** - * Restore an archived recipe. - * - * @param recipeName The name of the recipe to restore - * @param isGlobal Whether the recipe is in global or local storage - */ -export async function restoreRecipe(recipeName: string, isGlobal: boolean): Promise { - try { - const savedRecipe = await loadRecipeFromFile(recipeName, isGlobal); - - if (!savedRecipe) { - throw new Error('Archived recipe not found'); - } - - if (!savedRecipe.isArchived) { - throw new Error('Recipe is not archived'); - } - - // Mark as not archived - savedRecipe.isArchived = false; - savedRecipe.lastModified = new Date(); - - // Save back to file - const success = await saveRecipeToFile(savedRecipe); - - if (!success) { - throw new Error('Failed to save updated recipe'); - } - } catch (error) { - throw new Error( - `Failed to restore recipe: ${error instanceof Error ? error.message : 'Unknown error'}` - ); +export function convertToLocaleDateString(lastModified: string): string { + if (lastModified) { + return parseLastModified(lastModified).toLocaleDateString(); } -} - -/** - * Archive a recipe. - * - * @param recipeName The name of the recipe to archive - * @param isGlobal Whether the recipe is in global or local storage - */ -export async function archiveRecipe(recipeName: string, isGlobal: boolean): Promise { - try { - const savedRecipe = await loadRecipeFromFile(recipeName, isGlobal); - - if (!savedRecipe) { - throw new Error('Recipe not found'); - } - - if (savedRecipe.isArchived) { - throw new Error('Recipe is already archived'); - } - - // Mark as archived - savedRecipe.isArchived = true; - savedRecipe.lastModified = new Date(); - - // Save back to file - const success = await saveRecipeToFile(savedRecipe); - - if (!success) { - throw new Error('Failed to save updated recipe'); - } - } catch (error) { - throw new Error( - `Failed to archive recipe: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Permanently delete a recipe file. - * - * @param recipeName The name of the recipe to permanently delete - * @param isGlobal Whether the recipe is in global or local storage - */ -export async function permanentlyDeleteRecipe( - recipeName: string, - isGlobal: boolean -): Promise { - try { - // TODO: Implement file deletion when available in the API - // For now, we'll just mark it as archived as a fallback - const savedRecipe = await loadRecipeFromFile(recipeName, isGlobal); - - if (!savedRecipe) { - throw new Error('Recipe not found'); - } - - // Mark as archived with special flag - savedRecipe.isArchived = true; - savedRecipe.lastModified = new Date(); - - // Save back to file - const success = await saveRecipeToFile(savedRecipe); - - if (!success) { - throw new Error('Failed to mark recipe as deleted'); - } - } catch (error) { - throw new Error( - `Failed to delete recipe: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Delete a recipe (archives it by default for backward compatibility). - * - * @deprecated Use archiveRecipe instead - * @param recipeName The name of the recipe to delete/archive - * @param isGlobal Whether the recipe is in global or local storage - */ -export async function deleteRecipe(recipeName: string, isGlobal: boolean): Promise { - return archiveRecipe(recipeName, isGlobal); + return ''; } /**