diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index ff5d1d271e39..5148b29f3c08 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -390,6 +390,8 @@ 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, + super::routes::recipe::parse_recipe, super::routes::setup::start_openrouter_setup, super::routes::setup::start_tetrate_setup, ), @@ -469,6 +471,10 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::RecipeManifestResponse, super::routes::recipe::ListRecipeResponse, super::routes::recipe::DeleteRecipeRequest, + super::routes::recipe::SaveRecipeRequest, + super::routes::errors::ErrorResponse, + 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/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 96268a9bdf4e..ce829e1ff4a6 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -5,12 +5,14 @@ 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; 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; @@ -72,11 +74,25 @@ pub struct ScanRecipeResponse { has_security_warnings: bool, } +#[derive(Debug, Deserialize, ToSchema)] +pub struct SaveRecipeRequest { + recipe: Recipe, + id: Option, + is_global: Option, +} +#[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, - #[serde(rename = "isGlobal")] - is_global: bool, recipe: Recipe, #[serde(rename = "lastModified")] last_modified: String, @@ -235,7 +251,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(), @@ -278,6 +293,57 @@ async fn delete_recipe( StatusCode::NO_CONTENT } +#[utoipa::path( + post, + path = "/recipes/save", + request_body = SaveRecipeRequest, + 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 = ErrorResponse) + ), + tag = "Recipe Management" +)] +async fn save_recipe( + State(state): State>, + Json(request): Json, +) -> Result { + let file_path = match request.id { + Some(id) => state.recipe_file_hash_map.lock().await.get(&id).cloned(), + None => None, + }; + + match recipe_library::save_recipe_to_file(request.recipe, request.is_global, file_path) { + Ok(_) => Ok(StatusCode::NO_CONTENT), + Err(e) => Err(ErrorResponse { + message: e.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR, + }), + } +} + +#[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 = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse) + ), + tag = "Recipe Management" +)] +async fn parse_recipe( + Json(request): Json, +) -> 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 })) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/recipes/create", post(create_recipe)) @@ -286,6 +352,8 @@ 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", post(save_recipe)) + .route("/recipes/parse", post(parse_recipe)) .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..c6b7827c7cc2 100644 --- a/crates/goose-server/src/routes/recipe_utils.rs +++ b/crates/goose-server/src/routes/recipe_utils.rs @@ -4,10 +4,8 @@ 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::list_all_recipes_from_library; use goose::recipe::Recipe; use std::path::Path; @@ -18,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, @@ -31,59 +28,31 @@ fn short_id_from_path(path: &str) -> String { format!("{:016x}", h) } -fn load_recipes_from_path(path: &PathBuf, is_global: bool) -> 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(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 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 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(&local_recipe_path, false)?); - recipe_manifests_with_path.extend(load_recipes_from_path(&global_recipe_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) @@ -93,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 { @@ -129,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/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..8a615957b0e7 --- /dev/null +++ b/crates/goose/src/recipe/recipe_library.rs @@ -0,0 +1,104 @@ +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}; +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") + } +} + +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(); + let extension = path.extension(); + + if extension == Some("yaml".as_ref()) || extension == Some("json".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() + .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, +) -> anyhow::Result { + let is_global_value = is_global.unwrap_or(true); + + let default_file_path = + get_recipe_library_dir(is_global_value).join(generate_recipe_filename(&recipe.title)); + + let file_path_value = match file_path { + Some(path) => path, + None => { + if default_file_path.exists() { + return Err(anyhow::anyhow!( + "Recipe file 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!( + "Recipe with title '{}' already exists", + recipe.title + )); + } + } + + 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..708077b2c4c6 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1090,6 +1090,92 @@ } } }, + "/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/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/recipes/save": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "save_recipe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Recipe saved to file successfully" + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/recipes/scan": { "post": { "tags": [ @@ -2980,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": [ @@ -3272,7 +3380,6 @@ "type": "object", "required": [ "name", - "isGlobal", "recipe", "lastModified", "id" @@ -3281,9 +3388,6 @@ "id": { "type": "string" }, - "isGlobal": { - "type": "boolean" - }, "lastModified": { "type": "string" }, @@ -3489,6 +3593,25 @@ } } }, + "SaveRecipeRequest": { + "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..4c265e88ada6 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, ParseRecipeData, ParseRecipeResponses, ParseRecipeErrors, SaveRecipeData, SaveRecipeResponses, SaveRecipeErrors, 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,28 @@ 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 saveRecipe = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/recipes/save', + ...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..6f14b7e7152c 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; @@ -605,7 +613,6 @@ export type Recipe = { export type RecipeManifestResponse = { id: string; - isGlobal: boolean; lastModified: string; name: string; recipe: Recipe; @@ -684,6 +691,12 @@ export type RunNowResponse = { session_id: string; }; +export type SaveRecipeRequest = { + id?: string | null; + is_global?: boolean | null; + recipe: Recipe; +}; + export type ScanRecipeRequest = { recipe: Recipe; }; @@ -1771,6 +1784,64 @@ 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: ErrorResponse; + /** + * Internal server error + */ + 500: ErrorResponse; +}; + +export type ParseRecipeError = ParseRecipeErrors[keyof ParseRecipeErrors]; + +export type ParseRecipeResponses = { + /** + * Recipe parsed successfully + */ + 200: ParseRecipeResponse; +}; + +export type ParseRecipeResponse2 = ParseRecipeResponses[keyof ParseRecipeResponses]; + +export type SaveRecipeData = { + body: SaveRecipeRequest; + path?: never; + query?: never; + url: '/recipes/save'; +}; + +export type SaveRecipeErrors = { + /** + * Unauthorized - Invalid or missing API key + */ + 401: unknown; + /** + * Internal server error + */ + 500: ErrorResponse; +}; + +export type SaveRecipeError = SaveRecipeErrors[keyof SaveRecipeErrors]; + +export type SaveRecipeResponses = { + /** + * Recipe saved to file successfully + */ + 204: void; +}; + +export type SaveRecipeResponse = SaveRecipeResponses[keyof SaveRecipeResponses]; + export type ScanRecipeData = { body: ScanRecipeRequest; path?: never; 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); }