Lifei/create save recipe to file (#4895)

This commit is contained in:
Lifei Zhou
2025-10-03 09:38:25 +10:00
committed by GitHub
parent 94a5905a15
commit a3fbeb04fc
11 changed files with 455 additions and 76 deletions
+6
View File
@@ -368,6 +368,8 @@ derive_utoipa!(Icon as IconSchema);
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,
),
@@ -448,6 +450,10 @@ derive_utoipa!(Icon as IconSchema);
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,
+24
View File
@@ -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()
}
}
+1
View File
@@ -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;
+71 -3
View File
@@ -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<String>,
is_global: Option<bool>,
}
#[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<Arc<AppState>>,
Json(request): Json<SaveRecipeRequest>,
) -> Result<StatusCode, ErrorResponse> {
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<ParseRecipeRequest>,
) -> Result<Json<ParseRecipeResponse>, 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<AppState>) -> Router {
Router::new()
.route("/recipes/create", post(create_recipe))
@@ -286,6 +352,8 @@ pub fn routes(state: Arc<AppState>) -> 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)
}
+23 -57
View File
@@ -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<Vec<RecipeManifestWithPath>> {
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::<chrono::Utc>::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<Vec<RecipeManifestWithPath>> {
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();
for (file_path, recipe) in recipes_with_path {
let Ok(last_modified) = fs::metadata(file_path.clone())
.map(|m| chrono::DateTime::<chrono::Utc>::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(),
}
});
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)?);
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<Vec<RecipeManifestWithPath>> {
#[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);
}
}
+1
View File
@@ -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";
+104
View File
@@ -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<Vec<(PathBuf, Recipe)>> {
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<Vec<(PathBuf, Recipe)>> {
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::<String>()
.split_whitespace()
.collect::<Vec<&str>>()
.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<bool>,
file_path: Option<PathBuf>,
) -> anyhow::Result<PathBuf> {
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)
}
+127 -4
View File
@@ -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": [
@@ -2984,6 +3070,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": [
@@ -3300,7 +3408,6 @@
"type": "object",
"required": [
"name",
"isGlobal",
"recipe",
"lastModified",
"id"
@@ -3309,9 +3416,6 @@
"id": {
"type": "string"
},
"isGlobal": {
"type": "boolean"
},
"lastModified": {
"type": "string"
},
@@ -3517,6 +3621,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": [
+23 -1
View File
@@ -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<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
@@ -328,6 +328,28 @@ export const listRecipes = <ThrowOnError extends boolean = false>(options?: Opti
});
};
export const parseRecipe = <ThrowOnError extends boolean = false>(options: Options<ParseRecipeData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<ParseRecipeResponses, ParseRecipeErrors, ThrowOnError>({
url: '/recipes/parse',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
export const saveRecipe = <ThrowOnError extends boolean = false>(options: Options<SaveRecipeData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<SaveRecipeResponses, SaveRecipeErrors, ThrowOnError>({
url: '/recipes/save',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
export const scanRecipe = <ThrowOnError extends boolean = false>(options: Options<ScanRecipeData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<ScanRecipeResponses, unknown, ThrowOnError>({
url: '/recipes/scan',
+72 -1
View File
@@ -448,6 +448,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;
@@ -616,7 +624,6 @@ export type Recipe = {
export type RecipeManifestResponse = {
id: string;
isGlobal: boolean;
lastModified: string;
name: string;
recipe: Recipe;
@@ -695,6 +702,12 @@ export type RunNowResponse = {
session_id: string;
};
export type SaveRecipeRequest = {
id?: string | null;
is_global?: boolean | null;
recipe: Recipe;
};
export type ScanRecipeRequest = {
recipe: Recipe;
};
@@ -1784,6 +1797,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;
@@ -119,17 +119,13 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
return recipe as Recipe;
};
const validateTitleUniqueness = async (
title: string,
isGlobal: boolean
): Promise<string | undefined> => {
const validateTitleUniqueness = async (title: string): Promise<string | undefined> => {
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);
}