From 4f9d087a088047c5ed965e9126885ba0596a033b Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 30 Sep 2025 12:31:38 +1000 Subject: [PATCH 1/7] added save recipe to file route --- crates/goose-server/src/openapi.rs | 2 + crates/goose-server/src/routes/recipe.rs | 34 ++++++++++++ .../goose-server/src/routes/recipe_utils.rs | 18 ++----- crates/goose/src/recipe/mod.rs | 1 + crates/goose/src/recipe/recipe_library.rs | 53 +++++++++++++++++++ ui/desktop/openapi.json | 48 +++++++++++++++++ ui/desktop/src/api/sdk.gen.ts | 13 ++++- ui/desktop/src/api/types.gen.ts | 33 ++++++++++++ ui/desktop/src/recipe/recipe_management.ts | 21 ++++++++ 9 files changed, 209 insertions(+), 14 deletions(-) create mode 100644 crates/goose/src/recipe/recipe_library.rs create mode 100644 ui/desktop/src/recipe/recipe_management.ts diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index ff5d1d271e39..34c57fa0d705 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -390,6 +390,7 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::scan_recipe, super::routes::recipe::list_recipes, super::routes::recipe::delete_recipe, + super::routes::recipe::save_recipe_to_file, super::routes::setup::start_openrouter_setup, super::routes::setup::start_tetrate_setup, ), @@ -469,6 +470,7 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::RecipeManifestResponse, super::routes::recipe::ListRecipeResponse, super::routes::recipe::DeleteRecipeRequest, + super::routes::recipe::SaveRecipeToFileRequest, goose::recipe::Recipe, goose::recipe::Author, goose::recipe::Settings, diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index 96268a9bdf4e..50899cfecf4e 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -5,6 +5,7 @@ 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_library; use goose::recipe::Recipe; use goose::recipe_deeplink; @@ -72,6 +73,13 @@ pub struct ScanRecipeResponse { has_security_warnings: bool, } +#[derive(Debug, Deserialize, ToSchema)] +pub struct SaveRecipeToFileRequest { + recipe: Recipe, + id: Option, + is_global: Option, +} + #[derive(Debug, Serialize, ToSchema)] pub struct RecipeManifestResponse { name: String, @@ -278,6 +286,31 @@ async fn delete_recipe( StatusCode::NO_CONTENT } +#[utoipa::path( + post, + path = "/recipes/save_to_file", + request_body = SaveRecipeToFileRequest, + responses( + (status = 204, description = "Recipe saved to file successfully"), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 500, description = "Internal server error") + ), + tag = "Recipe Management" +)] +async fn save_recipe_to_file( + State(state): State>, + Json(request): Json, +) -> StatusCode { + let file_path = match request.id { + Some(id) => state.recipe_file_hash_map.lock().await.get(&id).cloned(), + None => None, + }; + if recipe_library::save_recipe_to_file(request.recipe, request.is_global, 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)) @@ -286,6 +319,7 @@ pub fn routes(state: Arc) -> Router { .route("/recipes/scan", post(scan_recipe)) .route("/recipes/list", get(list_recipes)) .route("/recipes/delete", post(delete_recipe)) + .route("/recipes/save_to_file", post(save_recipe_to_file)) .with_state(state) } diff --git a/crates/goose-server/src/routes/recipe_utils.rs b/crates/goose-server/src/routes/recipe_utils.rs index 6caa7fc8148a..efdbcf791b95 100644 --- a/crates/goose-server/src/routes/recipe_utils.rs +++ b/crates/goose-server/src/routes/recipe_utils.rs @@ -4,10 +4,9 @@ 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_library::get_recipe_library_dir; use goose::recipe::Recipe; use std::path::Path; @@ -31,7 +30,8 @@ fn short_id_from_path(path: &str) -> String { format!("{:016x}", h) } -fn load_recipes_from_path(path: &PathBuf, is_global: bool) -> Result> { +fn load_recipes_from_path(is_global: bool) -> Result> { + let path = get_recipe_library_dir(is_global); let mut recipe_manifests_with_path = Vec::new(); if path.exists() { for entry in fs::read_dir(path)? { @@ -72,18 +72,10 @@ fn load_recipes_from_path(path: &PathBuf, is_global: bool) -> Result 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, false)?); - recipe_manifests_with_path.extend(load_recipes_from_path(&global_recipe_path, true)?); + recipe_manifests_with_path.extend(load_recipes_from_path(false)?); + recipe_manifests_with_path.extend(load_recipes_from_path(true)?); recipe_manifests_with_path.sort_by(|a, b| b.last_modified.cmp(&a.last_modified)); Ok(recipe_manifests_with_path) diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index f6367fbd1d6e..6626e3d06adc 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -12,6 +12,7 @@ use utoipa::ToSchema; pub mod build_recipe; pub mod read_recipe_file_content; +pub mod recipe_library; pub mod template_recipe; pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir"; diff --git a/crates/goose/src/recipe/recipe_library.rs b/crates/goose/src/recipe/recipe_library.rs new file mode 100644 index 000000000000..990b96550f9d --- /dev/null +++ b/crates/goose/src/recipe/recipe_library.rs @@ -0,0 +1,53 @@ +use crate::config::APP_STRATEGY; +use crate::recipe::Recipe; +use anyhow::Result; +use etcetera::{choose_app_strategy, AppStrategy}; +use serde_yaml; +use std::fs; +use std::path::PathBuf; + +pub fn get_recipe_library_dir(is_global: bool) -> PathBuf { + if is_global { + choose_app_strategy(APP_STRATEGY.clone()) + .expect("goose requires a home dir") + .config_dir() + .join("recipes") + } else { + std::env::current_dir().unwrap().join(".goose/recipes") + } +} + +fn generate_recipe_filename(title: &str) -> String { + let base_name = title + .to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '-') + .collect::() + .split_whitespace() + .collect::>() + .join("-"); + + let filename = if base_name.is_empty() { + "untitled-recipe".to_string() + } else { + base_name + }; + format!("{}.yaml", filename) +} + +pub fn save_recipe_to_file( + recipe: Recipe, + is_global: Option, + file_path: Option, +) -> Result { + let is_global_value = is_global.unwrap_or(true); + // TODO: Lifei + // check whether there is any existing file with same name, if return bad request + // check whether there is any existing recipe has same title?, if yes, return bad request + let default_file_path = + get_recipe_library_dir(is_global_value).join(generate_recipe_filename(&recipe.title)); + let file_path_value = file_path.unwrap_or(default_file_path); + let yaml_content = serde_yaml::to_string(&recipe)?; + fs::write(&file_path_value, yaml_content)?; + Ok(file_path_value) +} diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 296ec589f14f..160ea4869b10 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1090,6 +1090,35 @@ } } }, + "/recipes/save_to_file": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "save_recipe_to_file", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveRecipeToFileRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Recipe saved to file successfully" + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/recipes/scan": { "post": { "tags": [ @@ -3489,6 +3518,25 @@ } } }, + "SaveRecipeToFileRequest": { + "type": "object", + "required": [ + "recipe" + ], + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "is_global": { + "type": "boolean", + "nullable": true + }, + "recipe": { + "$ref": "#/components/schemas/Recipe" + } + } + }, "ScanRecipeRequest": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 7be7d8b9dac3..5629e3cec9aa 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, ResumeAgentData, ResumeAgentResponses, ResumeAgentErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, StartAgentData, StartAgentResponses, StartAgentErrors, 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, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, 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, GetSessionInsightsData, GetSessionInsightsResponses, GetSessionInsightsErrors, DeleteSessionData, DeleteSessionResponses, DeleteSessionErrors, GetSessionData, GetSessionResponses, GetSessionErrors, UpdateSessionDescriptionData, UpdateSessionDescriptionResponses, UpdateSessionDescriptionErrors, StatusData, StatusResponses } from './types.gen'; +import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, ResumeAgentData, ResumeAgentResponses, ResumeAgentErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, StartAgentData, StartAgentResponses, StartAgentErrors, 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, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, SaveRecipeToFileData, SaveRecipeToFileResponses, SaveRecipeToFileErrors, 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, GetSessionInsightsData, GetSessionInsightsResponses, GetSessionInsightsErrors, DeleteSessionData, DeleteSessionResponses, DeleteSessionErrors, GetSessionData, GetSessionResponses, GetSessionErrors, UpdateSessionDescriptionData, UpdateSessionDescriptionResponses, UpdateSessionDescriptionErrors, StatusData, StatusResponses } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -328,6 +328,17 @@ export const listRecipes = (options?: Opti }); }; +export const saveRecipeToFile = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/recipes/save_to_file', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + 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 ec0fdcb247fa..dc37d91b73d5 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -684,6 +684,12 @@ export type RunNowResponse = { session_id: string; }; +export type SaveRecipeToFileRequest = { + id?: string | null; + is_global?: boolean | null; + recipe: Recipe; +}; + export type ScanRecipeRequest = { recipe: Recipe; }; @@ -1771,6 +1777,33 @@ export type ListRecipesResponses = { export type ListRecipesResponse = ListRecipesResponses[keyof ListRecipesResponses]; +export type SaveRecipeToFileData = { + body: SaveRecipeToFileRequest; + path?: never; + query?: never; + url: '/recipes/save_to_file'; +}; + +export type SaveRecipeToFileErrors = { + /** + * Unauthorized - Invalid or missing API key + */ + 401: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type SaveRecipeToFileResponses = { + /** + * Recipe saved to file successfully + */ + 204: void; +}; + +export type SaveRecipeToFileResponse = SaveRecipeToFileResponses[keyof SaveRecipeToFileResponses]; + export type ScanRecipeData = { body: ScanRecipeRequest; path?: never; diff --git a/ui/desktop/src/recipe/recipe_management.ts b/ui/desktop/src/recipe/recipe_management.ts new file mode 100644 index 000000000000..e08e27c5bd50 --- /dev/null +++ b/ui/desktop/src/recipe/recipe_management.ts @@ -0,0 +1,21 @@ +import { Recipe, saveRecipeToFile } from '../api'; + +export async function saveRecipe( + recipe: Recipe, + isGlobal: boolean | null, + recipeId: string | null +): Promise { + try { + await saveRecipeToFile({ + body: { + recipe, + id: recipeId, + is_global: isGlobal, + }, + }); + } catch (error) { + throw new Error( + `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} From ade8b790c251bb037a39f64bafedc6c18c0904e0 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 30 Sep 2025 13:12:14 +1000 Subject: [PATCH 2/7] extract list all recipes from library and removed is_global in api response --- crates/goose-server/src/routes/recipe.rs | 3 - .../goose-server/src/routes/recipe_utils.rs | 74 ++++++------------- crates/goose/src/recipe/recipe_library.rs | 56 +++++++++++++- ui/desktop/openapi.json | 4 - ui/desktop/src/api/types.gen.ts | 1 - .../components/recipes/ImportRecipeForm.tsx | 13 +--- 6 files changed, 79 insertions(+), 72 deletions(-) diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index 50899cfecf4e..c8325b5a8d85 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -83,8 +83,6 @@ pub struct SaveRecipeToFileRequest { #[derive(Debug, Serialize, ToSchema)] pub struct RecipeManifestResponse { name: String, - #[serde(rename = "isGlobal")] - is_global: bool, recipe: Recipe, #[serde(rename = "lastModified")] last_modified: String, @@ -243,7 +241,6 @@ async fn list_recipes( 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(), diff --git a/crates/goose-server/src/routes/recipe_utils.rs b/crates/goose-server/src/routes/recipe_utils.rs index efdbcf791b95..c6b7827c7cc2 100644 --- a/crates/goose-server/src/routes/recipe_utils.rs +++ b/crates/goose-server/src/routes/recipe_utils.rs @@ -5,8 +5,7 @@ use std::path::PathBuf; use anyhow::Result; -use goose::recipe::read_recipe_file_content::read_recipe_file; -use goose::recipe::recipe_library::get_recipe_library_dir; +use goose::recipe::recipe_library::list_all_recipes_from_library; use goose::recipe::Recipe; use std::path::Path; @@ -17,7 +16,6 @@ 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, @@ -30,52 +28,31 @@ fn short_id_from_path(path: &str) -> String { format!("{:016x}", h) } -fn load_recipes_from_path(is_global: bool) -> Result> { - let path = get_recipe_library_dir(is_global); - 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(last_modified) = fs::metadata(path.clone()).map(|m| { - chrono::DateTime::::from(m.modified().unwrap()).to_rfc3339() - }) else { - continue; - }; - let recipe_metadata = - RecipeManifestMetadata::from_yaml_file(&path).unwrap_or_else(|_| { - RecipeManifestMetadata { - name: recipe.title.clone(), - is_global, - } - }); - - 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 recipes_with_path = list_all_recipes_from_library()?; let mut recipe_manifests_with_path = Vec::new(); - - recipe_manifests_with_path.extend(load_recipes_from_path(false)?); - recipe_manifests_with_path.extend(load_recipes_from_path(true)?); + for (file_path, recipe) in recipes_with_path { + let Ok(last_modified) = fs::metadata(file_path.clone()) + .map(|m| chrono::DateTime::::from(m.modified().unwrap()).to_rfc3339()) + else { + continue; + }; + let recipe_metadata = + RecipeManifestMetadata::from_yaml_file(&file_path).unwrap_or_else(|_| { + RecipeManifestMetadata { + name: recipe.title.clone(), + } + }); + + let manifest_with_path = RecipeManifestWithPath { + id: short_id_from_path(file_path.to_string_lossy().as_ref()), + name: recipe_metadata.name, + recipe, + file_path, + last_modified, + }; + recipe_manifests_with_path.push(manifest_with_path); + } recipe_manifests_with_path.sort_by(|a, b| b.last_modified.cmp(&a.last_modified)); Ok(recipe_manifests_with_path) @@ -85,8 +62,6 @@ pub fn get_all_recipes_manifests() -> Result> { #[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] struct RecipeManifestMetadata { pub name: String, - #[serde(rename = "isGlobal")] - pub is_global: bool, } impl RecipeManifestMetadata { @@ -121,6 +96,5 @@ recipe: recipe_content let result = RecipeManifestMetadata::from_yaml_file(&file_path).unwrap(); assert_eq!(result.name, "Test Recipe"); - assert!(result.is_global); } } diff --git a/crates/goose/src/recipe/recipe_library.rs b/crates/goose/src/recipe/recipe_library.rs index 990b96550f9d..98ed8774950e 100644 --- a/crates/goose/src/recipe/recipe_library.rs +++ b/crates/goose/src/recipe/recipe_library.rs @@ -1,4 +1,5 @@ use crate::config::APP_STRATEGY; +use crate::recipe::read_recipe_file_content::read_recipe_file; use crate::recipe::Recipe; use anyhow::Result; use etcetera::{choose_app_strategy, AppStrategy}; @@ -17,6 +18,33 @@ pub fn get_recipe_library_dir(is_global: bool) -> PathBuf { } } +pub fn list_recipes_from_library(is_global: bool) -> Result> { + let path = get_recipe_library_dir(is_global); + let mut recipes_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; + }; + recipes_with_path.push((path, recipe)); + } + } + } + Ok(recipes_with_path) +} + +pub fn list_all_recipes_from_library() -> Result> { + let mut recipes_with_path = Vec::new(); + recipes_with_path.extend(list_recipes_from_library(true)?); + recipes_with_path.extend(list_recipes_from_library(false)?); + Ok(recipes_with_path) +} + fn generate_recipe_filename(title: &str) -> String { let base_name = title .to_lowercase() @@ -41,12 +69,32 @@ pub fn save_recipe_to_file( file_path: Option, ) -> Result { let is_global_value = is_global.unwrap_or(true); - // TODO: Lifei - // check whether there is any existing file with same name, if return bad request - // check whether there is any existing recipe has same title?, if yes, return bad request + let default_file_path = get_recipe_library_dir(is_global_value).join(generate_recipe_filename(&recipe.title)); - let file_path_value = file_path.unwrap_or(default_file_path); + + let file_path_value = match file_path { + Some(path) => path, + None => { + if default_file_path.exists() { + return Err(anyhow::anyhow!( + "Recipe with same file name already exists at: {:?}", + default_file_path + )); + } + default_file_path + } + }; + let all_recipes = list_all_recipes_from_library()?; + + for (existing_path, existing_recipe) in &all_recipes { + if existing_recipe.title == recipe.title && existing_path != &file_path_value { + return Err(anyhow::anyhow!( + "A recipe with the same title already exists" + )); + } + } + let yaml_content = serde_yaml::to_string(&recipe)?; fs::write(&file_path_value, yaml_content)?; Ok(file_path_value) diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 160ea4869b10..4fc93354575a 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3301,7 +3301,6 @@ "type": "object", "required": [ "name", - "isGlobal", "recipe", "lastModified", "id" @@ -3310,9 +3309,6 @@ "id": { "type": "string" }, - "isGlobal": { - "type": "boolean" - }, "lastModified": { "type": "string" }, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index dc37d91b73d5..54b567acf18a 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -605,7 +605,6 @@ export type Recipe = { export type RecipeManifestResponse = { id: string; - isGlobal: boolean; lastModified: string; name: string; recipe: Recipe; diff --git a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx index 6b68a1edc75c..cc30abaf94e6 100644 --- a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx +++ b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx @@ -119,17 +119,13 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR return recipe as Recipe; }; - const validateTitleUniqueness = async ( - title: string, - isGlobal: boolean - ): Promise => { + const validateTitleUniqueness = async (title: string): Promise => { if (!title.trim()) return undefined; try { const existingRecipes = await listSavedRecipes(); const titleExists = existingRecipes.some( - (recipe) => - recipe.recipe.title?.toLowerCase() === title.toLowerCase() && recipe.isGlobal === isGlobal + (recipe) => recipe.recipe.title?.toLowerCase() === title.toLowerCase() ); if (titleExists) { @@ -171,10 +167,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR recipe.title = value.recipeTitle.trim(); - const titleValidationError = await validateTitleUniqueness( - value.recipeTitle.trim(), - value.global - ); + const titleValidationError = await validateTitleUniqueness(value.recipeTitle.trim()); if (titleValidationError) { throw new Error(titleValidationError); } From 35764f6d629778f1732ecfb1350a7d1c0378dfea Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 30 Sep 2025 14:37:50 +1000 Subject: [PATCH 3/7] simplified error handling --- crates/goose-server/src/openapi.rs | 1 + crates/goose-server/src/routes/recipe.rs | 21 +++++++++--- crates/goose/src/recipe/recipe_library.rs | 7 ++-- ui/desktop/openapi.json | 20 ++++++++++- ui/desktop/src/api/types.gen.ts | 8 ++++- .../components/recipes/ImportRecipeForm.tsx | 34 +++++++++---------- ui/desktop/src/recipe/recipe_management.ts | 7 +++- 7 files changed, 70 insertions(+), 28 deletions(-) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 34c57fa0d705..6b1d03a46932 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -471,6 +471,7 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::ListRecipeResponse, super::routes::recipe::DeleteRecipeRequest, super::routes::recipe::SaveRecipeToFileRequest, + super::routes::recipe::SaveRecipeToFileErrorResponse, goose::recipe::Recipe, goose::recipe::Author, goose::recipe::Settings, diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index c8325b5a8d85..41aa89e47fbf 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -80,6 +80,11 @@ pub struct SaveRecipeToFileRequest { is_global: Option, } +#[derive(Debug, Serialize, ToSchema)] +pub struct SaveRecipeToFileErrorResponse { + pub message: String, +} + #[derive(Debug, Serialize, ToSchema)] pub struct RecipeManifestResponse { name: String, @@ -290,22 +295,28 @@ async fn delete_recipe( responses( (status = 204, description = "Recipe saved to file successfully"), (status = 401, description = "Unauthorized - Invalid or missing API key"), - (status = 500, description = "Internal server error") + (status = 500, description = "Internal server error", body = SaveRecipeToFileErrorResponse) ), tag = "Recipe Management" )] async fn save_recipe_to_file( State(state): State>, Json(request): Json, -) -> StatusCode { +) -> Result)> { let file_path = match request.id { Some(id) => state.recipe_file_hash_map.lock().await.get(&id).cloned(), None => None, }; - if recipe_library::save_recipe_to_file(request.recipe, request.is_global, file_path).is_err() { - return StatusCode::INTERNAL_SERVER_ERROR; + + match recipe_library::save_recipe_to_file(request.recipe, request.is_global, file_path) { + Ok(_) => Ok(StatusCode::NO_CONTENT), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(SaveRecipeToFileErrorResponse { + message: e.to_string(), + }), + )), } - StatusCode::NO_CONTENT } pub fn routes(state: Arc) -> Router { diff --git a/crates/goose/src/recipe/recipe_library.rs b/crates/goose/src/recipe/recipe_library.rs index 98ed8774950e..8a28f645f2f0 100644 --- a/crates/goose/src/recipe/recipe_library.rs +++ b/crates/goose/src/recipe/recipe_library.rs @@ -67,7 +67,7 @@ pub fn save_recipe_to_file( recipe: Recipe, is_global: Option, file_path: Option, -) -> Result { +) -> anyhow::Result { let is_global_value = is_global.unwrap_or(true); let default_file_path = @@ -78,7 +78,7 @@ pub fn save_recipe_to_file( None => { if default_file_path.exists() { return Err(anyhow::anyhow!( - "Recipe with same file name already exists at: {:?}", + "Recipe file already exists at: {:?}", default_file_path )); } @@ -90,7 +90,8 @@ pub fn save_recipe_to_file( for (existing_path, existing_recipe) in &all_recipes { if existing_recipe.title == recipe.title && existing_path != &file_path_value { return Err(anyhow::anyhow!( - "A recipe with the same title already exists" + "Recipe with title '{}' already exists", + recipe.title )); } } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 4fc93354575a..e9d7b5fbf3fc 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1114,7 +1114,14 @@ "description": "Unauthorized - Invalid or missing API key" }, "500": { - "description": "Internal server error" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveRecipeToFileErrorResponse" + } + } + } } } } @@ -3514,6 +3521,17 @@ } } }, + "SaveRecipeToFileErrorResponse": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, "SaveRecipeToFileRequest": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 54b567acf18a..a8517e17e6d1 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -683,6 +683,10 @@ export type RunNowResponse = { session_id: string; }; +export type SaveRecipeToFileErrorResponse = { + message: string; +}; + export type SaveRecipeToFileRequest = { id?: string | null; is_global?: boolean | null; @@ -1791,9 +1795,11 @@ export type SaveRecipeToFileErrors = { /** * Internal server error */ - 500: unknown; + 500: SaveRecipeToFileErrorResponse; }; +export type SaveRecipeToFileError = SaveRecipeToFileErrors[keyof SaveRecipeToFileErrors]; + export type SaveRecipeToFileResponses = { /** * Recipe saved to file successfully diff --git a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx index cc30abaf94e6..88a5f821e92f 100644 --- a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx +++ b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx @@ -5,17 +5,15 @@ import { Download } from 'lucide-react'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { Recipe, decodeRecipe } from '../../recipe'; -import { saveRecipe } from '../../recipe/recipeStorage'; import * as yaml from 'yaml'; import { toastSuccess, toastError } from '../../toasts'; import { useEscapeKey } from '../../hooks/useEscapeKey'; import { RecipeTitleField } from './shared/RecipeTitleField'; import { listSavedRecipes } from '../../recipe/recipeStorage'; import { - validateRecipe, - getValidationErrorMessages, getRecipeJsonSchema, } from '../../recipe/validation'; +import { saveRecipe } from '../../recipe/recipe_management'; interface ImportRecipeFormProps { isOpen: boolean; @@ -167,22 +165,24 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR recipe.title = value.recipeTitle.trim(); - const titleValidationError = await validateTitleUniqueness(value.recipeTitle.trim()); - if (titleValidationError) { - throw new Error(titleValidationError); - } + // const titleValidationError = await validateTitleUniqueness(value.recipeTitle.trim()); + // if (titleValidationError) { + // throw new Error(titleValidationError); + // } - const validationResult = validateRecipe(recipe); - if (!validationResult.success) { - const errorMessages = getValidationErrorMessages(validationResult.errors); - throw new Error(`Recipe validation failed: ${errorMessages.join(', ')}`); - } + // const validationResult = validateRecipe(recipe); + // if (!validationResult.success) { + // const errorMessages = getValidationErrorMessages(validationResult.errors); + // throw new Error(`Recipe validation failed: ${errorMessages.join(', ')}`); + // } - await saveRecipe(recipe, { - name: '', - title: value.recipeTitle.trim(), - global: value.global, - }); + // await saveRecipe(recipe, { + // name: '', + // title: value.recipeTitle.trim(), + // global: value.global, + // }); + + await saveRecipe(recipe, value.global, null); // Reset dialog state importRecipeForm.reset({ diff --git a/ui/desktop/src/recipe/recipe_management.ts b/ui/desktop/src/recipe/recipe_management.ts index e08e27c5bd50..7a27f6b123f0 100644 --- a/ui/desktop/src/recipe/recipe_management.ts +++ b/ui/desktop/src/recipe/recipe_management.ts @@ -12,10 +12,15 @@ export async function saveRecipe( id: recipeId, is_global: isGlobal, }, + throwOnError: true }); } catch (error) { + let error_message = "unknown error"; + if (typeof error === 'object' && error !== null && 'message' in error) { + error_message = error.message as string; + } throw new Error( - `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}` + `Failed to save recipe: ${error_message}` ); } } From 3db1468bb5d7b215825c1e7cd42fddb20286e2da Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 30 Sep 2025 15:31:11 +1000 Subject: [PATCH 4/7] added parse recipe endpoint to import recipe from file --- crates/goose-server/src/openapi.rs | 3 + crates/goose-server/src/routes/recipe.rs | 42 +++++++++++ ui/desktop/openapi.json | 72 +++++++++++++++++++ ui/desktop/src/api/sdk.gen.ts | 13 +++- ui/desktop/src/api/types.gen.ts | 37 ++++++++++ .../components/recipes/ImportRecipeForm.tsx | 5 +- ui/desktop/src/recipe/recipe_management.ts | 12 +++- 7 files changed, 180 insertions(+), 4 deletions(-) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 6b1d03a46932..6f5d9ff184a2 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -391,6 +391,7 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::list_recipes, super::routes::recipe::delete_recipe, super::routes::recipe::save_recipe_to_file, + super::routes::recipe::parse_recipe, super::routes::setup::start_openrouter_setup, super::routes::setup::start_tetrate_setup, ), @@ -472,6 +473,8 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::DeleteRecipeRequest, super::routes::recipe::SaveRecipeToFileRequest, super::routes::recipe::SaveRecipeToFileErrorResponse, + super::routes::recipe::ParseRecipeRequest, + super::routes::recipe::ParseRecipeResponse, goose::recipe::Recipe, goose::recipe::Author, goose::recipe::Settings, diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index 41aa89e47fbf..8bc2b5acc373 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -80,11 +80,26 @@ pub struct SaveRecipeToFileRequest { is_global: Option, } +#[derive(Debug, Serialize, ToSchema)] +pub struct SaveRecipeToFileResponse { + pub message: String, +} + #[derive(Debug, Serialize, ToSchema)] pub struct SaveRecipeToFileErrorResponse { pub message: String, } +#[derive(Debug, Deserialize, ToSchema)] +pub struct ParseRecipeRequest { + pub content: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ParseRecipeResponse { + pub recipe: Recipe, +} + #[derive(Debug, Serialize, ToSchema)] pub struct RecipeManifestResponse { name: String, @@ -319,6 +334,32 @@ async fn save_recipe_to_file( } } +#[utoipa::path( + post, + path = "/recipes/parse", + request_body = ParseRecipeRequest, + responses( + (status = 200, description = "Recipe parsed successfully", body = ParseRecipeResponse), + (status = 400, description = "Bad request - Invalid recipe format", body = SaveRecipeToFileErrorResponse), + (status = 500, description = "Internal server error", body = SaveRecipeToFileErrorResponse) + ), + tag = "Recipe Management" +)] +async fn parse_recipe( + Json(request): Json, +) -> Result, (StatusCode, Json)> { + let recipe = Recipe::from_content(&request.content).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + Json(SaveRecipeToFileErrorResponse { + message: format!("Invalid recipe format: {}", e), + }), + ) + })?; + + Ok(Json(ParseRecipeResponse { recipe })) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/recipes/create", post(create_recipe)) @@ -328,6 +369,7 @@ pub fn routes(state: Arc) -> Router { .route("/recipes/list", get(list_recipes)) .route("/recipes/delete", post(delete_recipe)) .route("/recipes/save_to_file", post(save_recipe_to_file)) + .route("/recipes/parse", post(parse_recipe)) .with_state(state) } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index e9d7b5fbf3fc..0a7173db0b33 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1090,6 +1090,56 @@ } } }, + "/recipes/parse": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "parse_recipe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ParseRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Recipe parsed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ParseRecipeResponse" + } + } + } + }, + "400": { + "description": "Bad request - Invalid recipe format", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveRecipeToFileErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveRecipeToFileErrorResponse" + } + } + } + } + } + } + }, "/recipes/save_to_file": { "post": { "tags": [ @@ -3016,6 +3066,28 @@ } } }, + "ParseRecipeRequest": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "content": { + "type": "string" + } + } + }, + "ParseRecipeResponse": { + "type": "object", + "required": [ + "recipe" + ], + "properties": { + "recipe": { + "$ref": "#/components/schemas/Recipe" + } + } + }, "PermissionConfirmationRequest": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 5629e3cec9aa..feae018e4c4d 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, ResumeAgentData, ResumeAgentResponses, ResumeAgentErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, StartAgentData, StartAgentResponses, StartAgentErrors, 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, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, SaveRecipeToFileData, SaveRecipeToFileResponses, SaveRecipeToFileErrors, 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, GetSessionInsightsData, GetSessionInsightsResponses, GetSessionInsightsErrors, DeleteSessionData, DeleteSessionResponses, DeleteSessionErrors, GetSessionData, GetSessionResponses, GetSessionErrors, UpdateSessionDescriptionData, UpdateSessionDescriptionResponses, UpdateSessionDescriptionErrors, StatusData, StatusResponses } from './types.gen'; +import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, ResumeAgentData, ResumeAgentResponses, ResumeAgentErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, StartAgentData, StartAgentResponses, StartAgentErrors, 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, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ParseRecipeData, ParseRecipeResponses, ParseRecipeErrors, SaveRecipeToFileData, SaveRecipeToFileResponses, SaveRecipeToFileErrors, 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, GetSessionInsightsData, GetSessionInsightsResponses, GetSessionInsightsErrors, DeleteSessionData, DeleteSessionResponses, DeleteSessionErrors, GetSessionData, GetSessionResponses, GetSessionErrors, UpdateSessionDescriptionData, UpdateSessionDescriptionResponses, UpdateSessionDescriptionErrors, StatusData, StatusResponses } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -328,6 +328,17 @@ export const listRecipes = (options?: Opti }); }; +export const parseRecipe = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/recipes/parse', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + export const saveRecipeToFile = (options: Options) => { return (options.client ?? _heyApiClient).post({ url: '/recipes/save_to_file', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index a8517e17e6d1..49c520ca4935 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -444,6 +444,14 @@ export type ModelInfo = { supports_cache_control?: boolean | null; }; +export type ParseRecipeRequest = { + content: string; +}; + +export type ParseRecipeResponse = { + recipe: Recipe; +}; + export type PermissionConfirmationRequest = { action: string; id: string; @@ -1780,6 +1788,35 @@ export type ListRecipesResponses = { export type ListRecipesResponse = ListRecipesResponses[keyof ListRecipesResponses]; +export type ParseRecipeData = { + body: ParseRecipeRequest; + path?: never; + query?: never; + url: '/recipes/parse'; +}; + +export type ParseRecipeErrors = { + /** + * Bad request - Invalid recipe format + */ + 400: SaveRecipeToFileErrorResponse; + /** + * Internal server error + */ + 500: SaveRecipeToFileErrorResponse; +}; + +export type ParseRecipeError = ParseRecipeErrors[keyof ParseRecipeErrors]; + +export type ParseRecipeResponses = { + /** + * Recipe parsed successfully + */ + 200: ParseRecipeResponse; +}; + +export type ParseRecipeResponse2 = ParseRecipeResponses[keyof ParseRecipeResponses]; + export type SaveRecipeToFileData = { body: SaveRecipeToFileRequest; path?: never; diff --git a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx index 88a5f821e92f..e74550b04dcd 100644 --- a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx +++ b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx @@ -13,7 +13,7 @@ import { listSavedRecipes } from '../../recipe/recipeStorage'; import { getRecipeJsonSchema, } from '../../recipe/validation'; -import { saveRecipe } from '../../recipe/recipe_management'; +import { parseRecipeFromFile, saveRecipe } from '../../recipe/recipe_management'; interface ImportRecipeFormProps { isOpen: boolean; @@ -266,7 +266,8 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR if (file) { try { const fileContent = await file.text(); - const recipe = await parseRecipeUploadFile(fileContent, file.name); + console.log('=======fileContent', fileContent); + const recipe = await parseRecipeFromFile(fileContent); if (recipe.title) { // Use the recipe title field's handleChange method if available if (recipeTitleFieldRef) { diff --git a/ui/desktop/src/recipe/recipe_management.ts b/ui/desktop/src/recipe/recipe_management.ts index 7a27f6b123f0..08793ec59b29 100644 --- a/ui/desktop/src/recipe/recipe_management.ts +++ b/ui/desktop/src/recipe/recipe_management.ts @@ -1,4 +1,4 @@ -import { Recipe, saveRecipeToFile } from '../api'; +import { parseRecipe, Recipe, saveRecipeToFile } from '../api'; export async function saveRecipe( recipe: Recipe, @@ -24,3 +24,13 @@ export async function saveRecipe( ); } } + +export async function parseRecipeFromFile(fileContent: string): Promise { + let response = await parseRecipe({ + body: { + content: fileContent + }, + throwOnError: true + }) + return response.data.recipe; +} \ No newline at end of file From e6fec0cbaeca82eabd41c343ad90483add0ebe5d Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 30 Sep 2025 17:49:57 +1000 Subject: [PATCH 5/7] introduced general error payload with message --- crates/goose-server/src/openapi.rs | 2 +- crates/goose-server/src/routes/errors.rs | 24 +++++++++++ crates/goose-server/src/routes/mod.rs | 1 + crates/goose-server/src/routes/recipe.rs | 42 ++++++------------- ui/desktop/openapi.json | 17 ++------ ui/desktop/src/api/types.gen.ts | 10 ++--- .../components/recipes/ImportRecipeForm.tsx | 25 +---------- ui/desktop/src/recipe/recipe_management.ts | 25 ++++++----- 8 files changed, 58 insertions(+), 88 deletions(-) create mode 100644 crates/goose-server/src/routes/errors.rs diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 6f5d9ff184a2..802eb07d2ac7 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -472,7 +472,7 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::ListRecipeResponse, super::routes::recipe::DeleteRecipeRequest, super::routes::recipe::SaveRecipeToFileRequest, - super::routes::recipe::SaveRecipeToFileErrorResponse, + super::routes::errors::ErrorResponse, super::routes::recipe::ParseRecipeRequest, super::routes::recipe::ParseRecipeResponse, goose::recipe::Recipe, diff --git a/crates/goose-server/src/routes/errors.rs b/crates/goose-server/src/routes/errors.rs new file mode 100644 index 000000000000..894f13e426e9 --- /dev/null +++ b/crates/goose-server/src/routes/errors.rs @@ -0,0 +1,24 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::Serialize; +use utoipa::ToSchema; + +#[derive(Debug, Serialize, ToSchema)] +pub struct ErrorResponse { + pub message: String, + #[serde(skip)] + pub status: StatusCode, +} + +impl IntoResponse for ErrorResponse { + fn into_response(self) -> Response { + let body = Json(serde_json::json!({ + "message": self.message, + })); + + (self.status, body).into_response() + } +} diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index 65764143d563..1d34dc29b610 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -2,6 +2,7 @@ pub mod agent; pub mod audio; pub mod config_management; pub mod context; +pub mod errors; pub mod extension; pub mod health; pub mod recipe; diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index 8bc2b5acc373..e3e8f317b5f2 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -12,6 +12,7 @@ use goose::recipe_deeplink; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use crate::routes::errors::ErrorResponse; use crate::routes::recipe_utils::get_all_recipes_manifests; use crate::state::AppState; @@ -79,17 +80,6 @@ pub struct SaveRecipeToFileRequest { id: Option, is_global: Option, } - -#[derive(Debug, Serialize, ToSchema)] -pub struct SaveRecipeToFileResponse { - pub message: String, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct SaveRecipeToFileErrorResponse { - pub message: String, -} - #[derive(Debug, Deserialize, ToSchema)] pub struct ParseRecipeRequest { pub content: String, @@ -310,14 +300,14 @@ async fn delete_recipe( responses( (status = 204, description = "Recipe saved to file successfully"), (status = 401, description = "Unauthorized - Invalid or missing API key"), - (status = 500, description = "Internal server error", body = SaveRecipeToFileErrorResponse) + (status = 500, description = "Internal server error", body = ErrorResponse) ), tag = "Recipe Management" )] async fn save_recipe_to_file( State(state): State>, Json(request): Json, -) -> Result)> { +) -> Result { let file_path = match request.id { Some(id) => state.recipe_file_hash_map.lock().await.get(&id).cloned(), None => None, @@ -325,12 +315,10 @@ async fn save_recipe_to_file( match recipe_library::save_recipe_to_file(request.recipe, request.is_global, file_path) { Ok(_) => Ok(StatusCode::NO_CONTENT), - Err(e) => Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(SaveRecipeToFileErrorResponse { - message: e.to_string(), - }), - )), + Err(e) => Err(ErrorResponse { + message: e.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR, + }), } } @@ -340,21 +328,17 @@ async fn save_recipe_to_file( request_body = ParseRecipeRequest, responses( (status = 200, description = "Recipe parsed successfully", body = ParseRecipeResponse), - (status = 400, description = "Bad request - Invalid recipe format", body = SaveRecipeToFileErrorResponse), - (status = 500, description = "Internal server error", body = SaveRecipeToFileErrorResponse) + (status = 400, description = "Bad request - Invalid recipe format", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse) ), tag = "Recipe Management" )] async fn parse_recipe( Json(request): Json, -) -> Result, (StatusCode, Json)> { - let recipe = Recipe::from_content(&request.content).map_err(|e| { - ( - StatusCode::BAD_REQUEST, - Json(SaveRecipeToFileErrorResponse { - message: format!("Invalid recipe format: {}", e), - }), - ) +) -> Result, ErrorResponse> { + let recipe = Recipe::from_content(&request.content).map_err(|e| ErrorResponse { + message: format!("Invalid recipe format: {}", e), + status: StatusCode::BAD_REQUEST, })?; Ok(Json(ParseRecipeResponse { recipe })) diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 0a7173db0b33..f6c1feffe649 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1122,7 +1122,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SaveRecipeToFileErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -1132,7 +1132,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SaveRecipeToFileErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -1168,7 +1168,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SaveRecipeToFileErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -3593,17 +3593,6 @@ } } }, - "SaveRecipeToFileErrorResponse": { - "type": "object", - "required": [ - "message" - ], - "properties": { - "message": { - "type": "string" - } - } - }, "SaveRecipeToFileRequest": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 49c520ca4935..4430dc878457 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -691,10 +691,6 @@ export type RunNowResponse = { session_id: string; }; -export type SaveRecipeToFileErrorResponse = { - message: string; -}; - export type SaveRecipeToFileRequest = { id?: string | null; is_global?: boolean | null; @@ -1799,11 +1795,11 @@ export type ParseRecipeErrors = { /** * Bad request - Invalid recipe format */ - 400: SaveRecipeToFileErrorResponse; + 400: ErrorResponse; /** * Internal server error */ - 500: SaveRecipeToFileErrorResponse; + 500: ErrorResponse; }; export type ParseRecipeError = ParseRecipeErrors[keyof ParseRecipeErrors]; @@ -1832,7 +1828,7 @@ export type SaveRecipeToFileErrors = { /** * Internal server error */ - 500: SaveRecipeToFileErrorResponse; + 500: ErrorResponse; }; export type SaveRecipeToFileError = SaveRecipeToFileErrors[keyof SaveRecipeToFileErrors]; diff --git a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx index e74550b04dcd..2b40006500f8 100644 --- a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx +++ b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx @@ -9,10 +9,7 @@ import * as yaml from 'yaml'; import { toastSuccess, toastError } from '../../toasts'; import { useEscapeKey } from '../../hooks/useEscapeKey'; import { RecipeTitleField } from './shared/RecipeTitleField'; -import { listSavedRecipes } from '../../recipe/recipeStorage'; -import { - getRecipeJsonSchema, -} from '../../recipe/validation'; +import { getRecipeJsonSchema } from '../../recipe/validation'; import { parseRecipeFromFile, saveRecipe } from '../../recipe/recipe_management'; interface ImportRecipeFormProps { @@ -117,25 +114,6 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR return recipe as Recipe; }; - const validateTitleUniqueness = async (title: string): Promise => { - if (!title.trim()) return undefined; - - try { - const existingRecipes = await listSavedRecipes(); - const titleExists = existingRecipes.some( - (recipe) => recipe.recipe.title?.toLowerCase() === title.toLowerCase() - ); - - if (titleExists) { - return `A recipe with the same title already exists`; - } - } catch (error) { - console.warn('Failed to validate title uniqueness:', error); - } - - return undefined; - }; - const importRecipeForm = useForm({ defaultValues: { deeplink: '', @@ -266,7 +244,6 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR if (file) { try { const fileContent = await file.text(); - console.log('=======fileContent', fileContent); const recipe = await parseRecipeFromFile(fileContent); if (recipe.title) { // Use the recipe title field's handleChange method if available diff --git a/ui/desktop/src/recipe/recipe_management.ts b/ui/desktop/src/recipe/recipe_management.ts index 08793ec59b29..7ecdd2f85ad0 100644 --- a/ui/desktop/src/recipe/recipe_management.ts +++ b/ui/desktop/src/recipe/recipe_management.ts @@ -12,25 +12,24 @@ export async function saveRecipe( id: recipeId, is_global: isGlobal, }, - throwOnError: true + throwOnError: true, }); } catch (error) { - let error_message = "unknown error"; + console.log('=======error', error); + let error_message = 'unknown error'; if (typeof error === 'object' && error !== null && 'message' in error) { error_message = error.message as string; } - throw new Error( - `Failed to save recipe: ${error_message}` - ); + throw new Error(`Failed to save recipe: ${error_message}`); } } export async function parseRecipeFromFile(fileContent: string): Promise { - let response = await parseRecipe({ - body: { - content: fileContent - }, - throwOnError: true - }) - return response.data.recipe; -} \ No newline at end of file + let response = await parseRecipe({ + body: { + content: fileContent, + }, + throwOnError: true, + }); + return response.data.recipe; +} From f9f9eef3785790db91c8987edd9197520f5c9487 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 30 Sep 2025 17:53:32 +1000 Subject: [PATCH 6/7] removed the logging --- ui/desktop/src/recipe/recipe_management.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/desktop/src/recipe/recipe_management.ts b/ui/desktop/src/recipe/recipe_management.ts index 7ecdd2f85ad0..83e9874bfe59 100644 --- a/ui/desktop/src/recipe/recipe_management.ts +++ b/ui/desktop/src/recipe/recipe_management.ts @@ -15,7 +15,6 @@ export async function saveRecipe( throwOnError: true, }); } catch (error) { - console.log('=======error', error); let error_message = 'unknown error'; if (typeof error === 'object' && error !== null && 'message' in error) { error_message = error.message as string; From be8cfcbfa6156f8b5d2a9ca473a839605e3329dc Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Fri, 3 Oct 2025 09:20:28 +1000 Subject: [PATCH 7/7] renamed route name --- crates/goose-server/src/openapi.rs | 4 +- crates/goose-server/src/routes/recipe.rs | 12 ++-- crates/goose/src/recipe/recipe_library.rs | 4 +- ui/desktop/openapi.json | 8 +-- ui/desktop/src/api/sdk.gen.ts | 8 +-- ui/desktop/src/api/types.gen.ts | 16 ++--- .../components/recipes/ImportRecipeForm.tsx | 60 +++++++++++++------ ui/desktop/src/recipe/recipe_management.ts | 34 ----------- 8 files changed, 68 insertions(+), 78 deletions(-) delete mode 100644 ui/desktop/src/recipe/recipe_management.ts diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 802eb07d2ac7..5148b29f3c08 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -390,7 +390,7 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::scan_recipe, super::routes::recipe::list_recipes, super::routes::recipe::delete_recipe, - super::routes::recipe::save_recipe_to_file, + super::routes::recipe::save_recipe, super::routes::recipe::parse_recipe, super::routes::setup::start_openrouter_setup, super::routes::setup::start_tetrate_setup, @@ -471,7 +471,7 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::RecipeManifestResponse, super::routes::recipe::ListRecipeResponse, super::routes::recipe::DeleteRecipeRequest, - super::routes::recipe::SaveRecipeToFileRequest, + super::routes::recipe::SaveRecipeRequest, super::routes::errors::ErrorResponse, super::routes::recipe::ParseRecipeRequest, super::routes::recipe::ParseRecipeResponse, diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index e3e8f317b5f2..ce829e1ff4a6 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -75,7 +75,7 @@ pub struct ScanRecipeResponse { } #[derive(Debug, Deserialize, ToSchema)] -pub struct SaveRecipeToFileRequest { +pub struct SaveRecipeRequest { recipe: Recipe, id: Option, is_global: Option, @@ -295,8 +295,8 @@ async fn delete_recipe( #[utoipa::path( post, - path = "/recipes/save_to_file", - request_body = SaveRecipeToFileRequest, + path = "/recipes/save", + request_body = SaveRecipeRequest, responses( (status = 204, description = "Recipe saved to file successfully"), (status = 401, description = "Unauthorized - Invalid or missing API key"), @@ -304,9 +304,9 @@ async fn delete_recipe( ), tag = "Recipe Management" )] -async fn save_recipe_to_file( +async fn save_recipe( State(state): State>, - Json(request): Json, + Json(request): Json, ) -> Result { let file_path = match request.id { Some(id) => state.recipe_file_hash_map.lock().await.get(&id).cloned(), @@ -352,7 +352,7 @@ pub fn routes(state: Arc) -> Router { .route("/recipes/scan", post(scan_recipe)) .route("/recipes/list", get(list_recipes)) .route("/recipes/delete", post(delete_recipe)) - .route("/recipes/save_to_file", post(save_recipe_to_file)) + .route("/recipes/save", post(save_recipe)) .route("/recipes/parse", post(parse_recipe)) .with_state(state) } diff --git a/crates/goose/src/recipe/recipe_library.rs b/crates/goose/src/recipe/recipe_library.rs index 8a28f645f2f0..8a615957b0e7 100644 --- a/crates/goose/src/recipe/recipe_library.rs +++ b/crates/goose/src/recipe/recipe_library.rs @@ -24,7 +24,9 @@ pub fn list_recipes_from_library(is_global: bool) -> Result = ClientOptions & { @@ -339,9 +339,9 @@ export const parseRecipe = (options: Optio }); }; -export const saveRecipeToFile = (options: Options) => { - return (options.client ?? _heyApiClient).post({ - url: '/recipes/save_to_file', +export const saveRecipe = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/recipes/save', ...options, headers: { 'Content-Type': 'application/json', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 4430dc878457..6f14b7e7152c 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -691,7 +691,7 @@ export type RunNowResponse = { session_id: string; }; -export type SaveRecipeToFileRequest = { +export type SaveRecipeRequest = { id?: string | null; is_global?: boolean | null; recipe: Recipe; @@ -1813,14 +1813,14 @@ export type ParseRecipeResponses = { export type ParseRecipeResponse2 = ParseRecipeResponses[keyof ParseRecipeResponses]; -export type SaveRecipeToFileData = { - body: SaveRecipeToFileRequest; +export type SaveRecipeData = { + body: SaveRecipeRequest; path?: never; query?: never; - url: '/recipes/save_to_file'; + url: '/recipes/save'; }; -export type SaveRecipeToFileErrors = { +export type SaveRecipeErrors = { /** * Unauthorized - Invalid or missing API key */ @@ -1831,16 +1831,16 @@ export type SaveRecipeToFileErrors = { 500: ErrorResponse; }; -export type SaveRecipeToFileError = SaveRecipeToFileErrors[keyof SaveRecipeToFileErrors]; +export type SaveRecipeError = SaveRecipeErrors[keyof SaveRecipeErrors]; -export type SaveRecipeToFileResponses = { +export type SaveRecipeResponses = { /** * Recipe saved to file successfully */ 204: void; }; -export type SaveRecipeToFileResponse = SaveRecipeToFileResponses[keyof SaveRecipeToFileResponses]; +export type SaveRecipeResponse = SaveRecipeResponses[keyof SaveRecipeResponses]; export type ScanRecipeData = { body: ScanRecipeRequest; diff --git a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx index 2b40006500f8..cc30abaf94e6 100644 --- a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx +++ b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx @@ -5,12 +5,17 @@ import { Download } from 'lucide-react'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { Recipe, decodeRecipe } from '../../recipe'; +import { saveRecipe } from '../../recipe/recipeStorage'; import * as yaml from 'yaml'; import { toastSuccess, toastError } from '../../toasts'; import { useEscapeKey } from '../../hooks/useEscapeKey'; import { RecipeTitleField } from './shared/RecipeTitleField'; -import { getRecipeJsonSchema } from '../../recipe/validation'; -import { parseRecipeFromFile, saveRecipe } from '../../recipe/recipe_management'; +import { listSavedRecipes } from '../../recipe/recipeStorage'; +import { + validateRecipe, + getValidationErrorMessages, + getRecipeJsonSchema, +} from '../../recipe/validation'; interface ImportRecipeFormProps { isOpen: boolean; @@ -114,6 +119,25 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR return recipe as Recipe; }; + const validateTitleUniqueness = async (title: string): Promise => { + if (!title.trim()) return undefined; + + try { + const existingRecipes = await listSavedRecipes(); + const titleExists = existingRecipes.some( + (recipe) => recipe.recipe.title?.toLowerCase() === title.toLowerCase() + ); + + if (titleExists) { + return `A recipe with the same title already exists`; + } + } catch (error) { + console.warn('Failed to validate title uniqueness:', error); + } + + return undefined; + }; + const importRecipeForm = useForm({ defaultValues: { deeplink: '', @@ -143,24 +167,22 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR recipe.title = value.recipeTitle.trim(); - // const titleValidationError = await validateTitleUniqueness(value.recipeTitle.trim()); - // if (titleValidationError) { - // throw new Error(titleValidationError); - // } - - // const validationResult = validateRecipe(recipe); - // if (!validationResult.success) { - // const errorMessages = getValidationErrorMessages(validationResult.errors); - // throw new Error(`Recipe validation failed: ${errorMessages.join(', ')}`); - // } + const titleValidationError = await validateTitleUniqueness(value.recipeTitle.trim()); + if (titleValidationError) { + throw new Error(titleValidationError); + } - // await saveRecipe(recipe, { - // name: '', - // title: value.recipeTitle.trim(), - // global: value.global, - // }); + const validationResult = validateRecipe(recipe); + if (!validationResult.success) { + const errorMessages = getValidationErrorMessages(validationResult.errors); + throw new Error(`Recipe validation failed: ${errorMessages.join(', ')}`); + } - await saveRecipe(recipe, value.global, null); + await saveRecipe(recipe, { + name: '', + title: value.recipeTitle.trim(), + global: value.global, + }); // Reset dialog state importRecipeForm.reset({ @@ -244,7 +266,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR if (file) { try { const fileContent = await file.text(); - const recipe = await parseRecipeFromFile(fileContent); + const recipe = await parseRecipeUploadFile(fileContent, file.name); if (recipe.title) { // Use the recipe title field's handleChange method if available if (recipeTitleFieldRef) { diff --git a/ui/desktop/src/recipe/recipe_management.ts b/ui/desktop/src/recipe/recipe_management.ts deleted file mode 100644 index 83e9874bfe59..000000000000 --- a/ui/desktop/src/recipe/recipe_management.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { parseRecipe, Recipe, saveRecipeToFile } from '../api'; - -export async function saveRecipe( - recipe: Recipe, - isGlobal: boolean | null, - recipeId: string | null -): Promise { - try { - await saveRecipeToFile({ - body: { - recipe, - id: recipeId, - is_global: isGlobal, - }, - throwOnError: true, - }); - } catch (error) { - let error_message = 'unknown error'; - if (typeof error === 'object' && error !== null && 'message' in error) { - error_message = error.message as string; - } - throw new Error(`Failed to save recipe: ${error_message}`); - } -} - -export async function parseRecipeFromFile(fileContent: string): Promise { - let response = await parseRecipe({ - body: { - content: fileContent, - }, - throwOnError: true, - }); - return response.data.recipe; -}