From f14cdc27c344d594f92bde7617ded97fbcf19efa Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison Date: Mon, 21 Jul 2025 15:31:45 +1000 Subject: [PATCH] Use openapi for recipe endpoint types and in frontend --- crates/goose-server/src/openapi.rs | 20 +- crates/goose-server/src/routes/recipe.rs | 49 ++- crates/goose/src/recipe/mod.rs | 17 +- ui/desktop/openapi.json | 412 ++++++++++++++++++ ui/desktop/src/api/sdk.gen.ts | 38 +- ui/desktop/src/api/types.gen.ts | 219 ++++++++++ ui/desktop/src/components/RecipesView.tsx | 8 +- .../schedule/CreateScheduleModal.tsx | 16 +- ui/desktop/src/recipe/index.ts | 206 +++++---- ui/desktop/src/recipe/recipeStorage.ts | 18 +- 10 files changed, 867 insertions(+), 136 deletions(-) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index b49f7cd51038..bc43201dbce7 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -318,7 +318,10 @@ derive_utoipa!(Annotations as AnnotationsSchema); super::routes::schedule::unpause_schedule, super::routes::schedule::kill_running_job, super::routes::schedule::inspect_running_job, - super::routes::schedule::sessions_handler + super::routes::schedule::sessions_handler, + super::routes::recipe::create_recipe, + super::routes::recipe::encode_recipe, + super::routes::recipe::decode_recipe ), components(schemas( super::routes::config_management::UpsertConfigQuery, @@ -375,6 +378,21 @@ derive_utoipa!(Annotations as AnnotationsSchema); super::routes::schedule::ListSchedulesResponse, super::routes::schedule::SessionsQuery, super::routes::schedule::SessionDisplayInfo, + super::routes::recipe::CreateRecipeRequest, + super::routes::recipe::AuthorRequest, + super::routes::recipe::CreateRecipeResponse, + super::routes::recipe::EncodeRecipeRequest, + super::routes::recipe::EncodeRecipeResponse, + super::routes::recipe::DecodeRecipeRequest, + super::routes::recipe::DecodeRecipeResponse, + goose::recipe::Recipe, + goose::recipe::Author, + goose::recipe::Settings, + goose::recipe::RecipeParameter, + goose::recipe::RecipeParameterInputType, + goose::recipe::RecipeParameterRequirement, + goose::recipe::Response, + goose::recipe::SubRecipe, )) )] pub struct ApiDoc; diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index 19042cd2ef15..c2165fcfb9bf 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -5,10 +5,11 @@ use goose::message::Message; use goose::recipe::Recipe; use goose::recipe_deeplink; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use crate::state::AppState; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct CreateRecipeRequest { messages: Vec, // Required metadata @@ -21,7 +22,7 @@ pub struct CreateRecipeRequest { author: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct AuthorRequest { #[serde(default)] contact: Option, @@ -29,33 +30,45 @@ pub struct AuthorRequest { metadata: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct CreateRecipeResponse { recipe: Option, error: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct EncodeRecipeRequest { recipe: Recipe, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct EncodeRecipeResponse { deeplink: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct DecodeRecipeRequest { deeplink: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct DecodeRecipeResponse { recipe: Recipe, } -/// Create a Recipe configuration from the current state of an agent +#[utoipa::path( + post, + path = "/recipes/create", + request_body = CreateRecipeRequest, + responses( + (status = 200, description = "Recipe created successfully", body = CreateRecipeResponse), + (status = 400, description = "Bad request"), + (status = 412, description = "Precondition failed - Agent not available"), + (status = 500, description = "Internal server error") + ), + tag = "Recipe Management" +)] +/// Create a Recipe configuration from the current session async fn create_recipe( State(state): State>, Json(request): Json, @@ -105,6 +118,16 @@ async fn create_recipe( } } +#[utoipa::path( + post, + path = "/recipes/encode", + request_body = EncodeRecipeRequest, + responses( + (status = 200, description = "Recipe encoded successfully", body = EncodeRecipeResponse), + (status = 400, description = "Bad request") + ), + tag = "Recipe Management" +)] async fn encode_recipe( Json(request): Json, ) -> Result, StatusCode> { @@ -117,6 +140,16 @@ async fn encode_recipe( } } +#[utoipa::path( + post, + path = "/recipes/decode", + request_body = DecodeRecipeRequest, + responses( + (status = 200, description = "Recipe decoded successfully", body = DecodeRecipeResponse), + (status = 400, description = "Bad request") + ), + tag = "Recipe Management" +)] async fn decode_recipe( Json(request): Json, ) -> Result, StatusCode> { diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 2d753b20c9c9..2175129f9700 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -6,6 +6,7 @@ use std::fmt; use crate::agents::extension::ExtensionConfig; use serde::de::Deserializer; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; pub mod build_recipe; pub mod read_recipe_file_content; @@ -67,7 +68,7 @@ fn default_version() -> String { /// sub_recipes: None, /// }; /// -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] pub struct Recipe { // Required fields #[serde(default = "default_version")] @@ -110,7 +111,7 @@ pub struct Recipe { pub sub_recipes: Option>, // sub-recipes for the recipe } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] pub struct Author { #[serde(skip_serializing_if = "Option::is_none")] pub contact: Option, // creator/contact information of the recipe @@ -119,7 +120,7 @@ pub struct Author { pub metadata: Option, // any additional metadata for the author } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] pub struct Settings { #[serde(skip_serializing_if = "Option::is_none")] pub goose_provider: Option, @@ -131,13 +132,13 @@ pub struct Settings { pub temperature: Option, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] pub struct Response { #[serde(skip_serializing_if = "Option::is_none")] pub json_schema: Option, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] pub struct SubRecipe { pub name: String, pub path: String, @@ -172,7 +173,7 @@ where } } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] #[serde(rename_all = "snake_case")] pub enum RecipeParameterRequirement { Required, @@ -190,7 +191,7 @@ impl fmt::Display for RecipeParameterRequirement { } } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] #[serde(rename_all = "snake_case")] pub enum RecipeParameterInputType { String, @@ -210,7 +211,7 @@ impl fmt::Display for RecipeParameterInputType { } } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] pub struct RecipeParameter { pub key: String, pub input_type: RecipeParameterInputType, diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 30ac193e3d0e..332ca65dea97 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -499,6 +499,112 @@ ] } }, + "/recipes/create": { + "post": { + "tags": [ + "Recipe Management" + ], + "summary": "Create a Recipe configuration from the current session", + "operationId": "create_recipe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Recipe created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRecipeResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "412": { + "description": "Precondition failed - Agent not available" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/recipes/decode": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "decode_recipe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DecodeRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Recipe decoded successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DecodeRecipeResponse" + } + } + } + }, + "400": { + "description": "Bad request" + } + } + } + }, + "/recipes/encode": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "encode_recipe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EncodeRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Recipe encoded successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EncodeRecipeResponse" + } + } + } + }, + "400": { + "description": "Bad request" + } + } + } + }, "/schedule/create": { "post": { "tags": [ @@ -945,6 +1051,32 @@ } } }, + "Author": { + "type": "object", + "properties": { + "contact": { + "type": "string", + "nullable": true + }, + "metadata": { + "type": "string", + "nullable": true + } + } + }, + "AuthorRequest": { + "type": "object", + "properties": { + "contact": { + "type": "string", + "nullable": true + }, + "metadata": { + "type": "string", + "nullable": true + } + } + }, "ConfigKey": { "type": "object", "required": [ @@ -1131,6 +1263,60 @@ } } }, + "CreateRecipeRequest": { + "type": "object", + "required": [ + "messages", + "title", + "description" + ], + "properties": { + "activities": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "author": { + "allOf": [ + { + "$ref": "#/components/schemas/AuthorRequest" + } + ], + "nullable": true + }, + "description": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Message" + } + }, + "title": { + "type": "string" + } + } + }, + "CreateRecipeResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "nullable": true + }, + "recipe": { + "allOf": [ + { + "$ref": "#/components/schemas/Recipe" + } + ], + "nullable": true + } + } + }, "CreateScheduleRequest": { "type": "object", "required": [ @@ -1154,6 +1340,28 @@ } } }, + "DecodeRecipeRequest": { + "type": "object", + "required": [ + "deeplink" + ], + "properties": { + "deeplink": { + "type": "string" + } + } + }, + "DecodeRecipeResponse": { + "type": "object", + "required": [ + "recipe" + ], + "properties": { + "recipe": { + "$ref": "#/components/schemas/Recipe" + } + } + }, "EmbeddedResource": { "type": "object", "required": [ @@ -1172,6 +1380,28 @@ } } }, + "EncodeRecipeRequest": { + "type": "object", + "required": [ + "recipe" + ], + "properties": { + "recipe": { + "$ref": "#/components/schemas/Recipe" + } + } + }, + "EncodeRecipeResponse": { + "type": "object", + "required": [ + "deeplink" + ], + "properties": { + "deeplink": { + "type": "string" + } + } + }, "Envs": { "type": "object", "additionalProperties": { @@ -1951,6 +2181,137 @@ } } }, + "Recipe": { + "type": "object", + "description": "A Recipe represents a personalized, user-generated agent configuration that defines\nspecific behaviors and capabilities within the Goose system.\n\n# Fields\n\n## Required Fields\n* `version` - Semantic version of the Recipe file format (defaults to \"1.0.0\")\n* `title` - Short, descriptive name of the Recipe\n* `description` - Detailed description explaining the Recipe's purpose and functionality\n* `Instructions` - Instructions that defines the Recipe's behavior\n\n## Optional Fields\n* `prompt` - the initial prompt to the session to start with\n* `extensions` - List of extension configurations required by the Recipe\n* `context` - Supplementary context information for the Recipe\n* `activities` - Activity labels that appear when loading the Recipe\n* `author` - Information about the Recipe's creator and metadata\n* `parameters` - Additional parameters for the Recipe\n* `response` - Response configuration including JSON schema validation\n\n# Example\n\n\nuse goose::recipe::Recipe;\n\n// Using the builder pattern\nlet recipe = Recipe::builder()\n.title(\"Example Agent\")\n.description(\"An example Recipe configuration\")\n.instructions(\"Act as a helpful assistant\")\n.build()\n.expect(\"Missing required fields\");\n\n// Or using struct initialization\nlet recipe = Recipe {\nversion: \"1.0.0\".to_string(),\ntitle: \"Example Agent\".to_string(),\ndescription: \"An example Recipe configuration\".to_string(),\ninstructions: Some(\"Act as a helpful assistant\".to_string()),\nprompt: None,\nextensions: None,\ncontext: None,\nactivities: None,\nauthor: None,\nsettings: None,\nparameters: None,\nresponse: None,\nsub_recipes: None,\n};\n", + "required": [ + "title", + "description" + ], + "properties": { + "activities": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "author": { + "allOf": [ + { + "$ref": "#/components/schemas/Author" + } + ], + "nullable": true + }, + "context": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "description": { + "type": "string" + }, + "extensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionConfig" + }, + "nullable": true + }, + "instructions": { + "type": "string", + "nullable": true + }, + "parameters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecipeParameter" + }, + "nullable": true + }, + "prompt": { + "type": "string", + "nullable": true + }, + "response": { + "allOf": [ + { + "$ref": "#/components/schemas/Response" + } + ], + "nullable": true + }, + "settings": { + "allOf": [ + { + "$ref": "#/components/schemas/Settings" + } + ], + "nullable": true + }, + "sub_recipes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SubRecipe" + }, + "nullable": true + }, + "title": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "RecipeParameter": { + "type": "object", + "required": [ + "key", + "input_type", + "requirement", + "description" + ], + "properties": { + "default": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string" + }, + "input_type": { + "$ref": "#/components/schemas/RecipeParameterInputType" + }, + "key": { + "type": "string" + }, + "requirement": { + "$ref": "#/components/schemas/RecipeParameterRequirement" + } + } + }, + "RecipeParameterInputType": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "date", + "file" + ] + }, + "RecipeParameterRequirement": { + "type": "string", + "enum": [ + "required", + "optional", + "user_prompt" + ] + }, "RedactedThinkingContent": { "type": "object", "required": [ @@ -2004,6 +2365,14 @@ } ] }, + "Response": { + "type": "object", + "properties": { + "json_schema": { + "nullable": true + } + } + }, "Role": { "oneOf": [ { @@ -2273,6 +2642,49 @@ } } }, + "Settings": { + "type": "object", + "properties": { + "goose_model": { + "type": "string", + "nullable": true + }, + "goose_provider": { + "type": "string", + "nullable": true + }, + "temperature": { + "type": "number", + "format": "float", + "nullable": true + } + } + }, + "SubRecipe": { + "type": "object", + "required": [ + "name", + "path" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "sequential_when_repeated": { + "type": "boolean" + }, + "values": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + } + } + }, "SummarizationRequested": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index c223297e172d..c772a1b0048b 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 '@hey-api/client-fetch'; -import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RecoverConfigData, RecoverConfigResponse, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ValidateConfigData, ValidateConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, CreateScheduleData, CreateScheduleResponse, DeleteScheduleData, DeleteScheduleResponse, ListSchedulesData, ListSchedulesResponse2, UpdateScheduleData, UpdateScheduleResponse, InspectRunningJobData, InspectRunningJobResponse, KillRunningJobData, PauseScheduleData, PauseScheduleResponse, RunNowHandlerData, RunNowHandlerResponse, SessionsHandlerData, SessionsHandlerResponse, UnpauseScheduleData, UnpauseScheduleResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen'; +import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RecoverConfigData, RecoverConfigResponse, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ValidateConfigData, ValidateConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, CreateRecipeData, CreateRecipeResponse2, DecodeRecipeData, DecodeRecipeResponse2, EncodeRecipeData, EncodeRecipeResponse2, CreateScheduleData, CreateScheduleResponse, DeleteScheduleData, DeleteScheduleResponse, ListSchedulesData, ListSchedulesResponse2, UpdateScheduleData, UpdateScheduleResponse, InspectRunningJobData, InspectRunningJobResponse, KillRunningJobData, PauseScheduleData, PauseScheduleResponse, RunNowHandlerData, RunNowHandlerResponse, SessionsHandlerData, SessionsHandlerResponse, UnpauseScheduleData, UnpauseScheduleResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -158,6 +158,42 @@ export const manageContext = (options: Opt }); }; +/** + * Create a Recipe configuration from the current session + */ +export const createRecipe = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/recipes/create', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + +export const decodeRecipe = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/recipes/decode', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + +export const encodeRecipe = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/recipes/encode', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + export const createSchedule = (options: Options) => { return (options.client ?? _heyApiClient).post({ url: '/schedule/create', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 071c7bfd557e..dbb28b820f2d 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -6,6 +6,16 @@ export type Annotations = { timestamp?: string; }; +export type Author = { + contact?: string | null; + metadata?: string | null; +}; + +export type AuthorRequest = { + contact?: string | null; + metadata?: string | null; +}; + export type ConfigKey = { default?: string | null; name: string; @@ -71,6 +81,19 @@ export type ContextManageResponse = { tokenCounts: Array; }; +export type CreateRecipeRequest = { + activities?: Array | null; + author?: AuthorRequest | null; + description: string; + messages: Array; + title: string; +}; + +export type CreateRecipeResponse = { + error?: string | null; + recipe?: Recipe | null; +}; + export type CreateScheduleRequest = { cron: string; execution_mode?: string | null; @@ -78,11 +101,27 @@ export type CreateScheduleRequest = { recipe_source: string; }; +export type DecodeRecipeRequest = { + deeplink: string; +}; + +export type DecodeRecipeResponse = { + recipe: Recipe; +}; + export type EmbeddedResource = { annotations?: Annotations; resource: ResourceContents; }; +export type EncodeRecipeRequest = { + recipe: Recipe; +}; + +export type EncodeRecipeResponse = { + deeplink: string; +}; + export type Envs = { [key: string]: string; }; @@ -273,6 +312,10 @@ export type ModelInfo = { * Cost per token for output (optional) */ output_token_cost?: number | null; + /** + * Whether this model supports cache control + */ + supports_cache_control?: boolean | null; }; export type PermissionConfirmationRequest = { @@ -333,6 +376,86 @@ export type ProvidersResponse = { providers: Array; }; +/** + * A Recipe represents a personalized, user-generated agent configuration that defines + * specific behaviors and capabilities within the Goose system. + * + * # Fields + * + * ## Required Fields + * * `version` - Semantic version of the Recipe file format (defaults to "1.0.0") + * * `title` - Short, descriptive name of the Recipe + * * `description` - Detailed description explaining the Recipe's purpose and functionality + * * `Instructions` - Instructions that defines the Recipe's behavior + * + * ## Optional Fields + * * `prompt` - the initial prompt to the session to start with + * * `extensions` - List of extension configurations required by the Recipe + * * `context` - Supplementary context information for the Recipe + * * `activities` - Activity labels that appear when loading the Recipe + * * `author` - Information about the Recipe's creator and metadata + * * `parameters` - Additional parameters for the Recipe + * * `response` - Response configuration including JSON schema validation + * + * # Example + * + * + * use goose::recipe::Recipe; + * + * // Using the builder pattern + * let recipe = Recipe::builder() + * .title("Example Agent") + * .description("An example Recipe configuration") + * .instructions("Act as a helpful assistant") + * .build() + * .expect("Missing required fields"); + * + * // Or using struct initialization + * let recipe = Recipe { + * version: "1.0.0".to_string(), + * title: "Example Agent".to_string(), + * description: "An example Recipe configuration".to_string(), + * instructions: Some("Act as a helpful assistant".to_string()), + * prompt: None, + * extensions: None, + * context: None, + * activities: None, + * author: None, + * settings: None, + * parameters: None, + * response: None, + * sub_recipes: None, + * }; + * + */ +export type Recipe = { + activities?: Array | null; + author?: Author | null; + context?: Array | null; + description: string; + extensions?: Array | null; + instructions?: string | null; + parameters?: Array | null; + prompt?: string | null; + response?: Response | null; + settings?: Settings | null; + sub_recipes?: Array | null; + title: string; + version?: string; +}; + +export type RecipeParameter = { + default?: string | null; + description: string; + input_type: RecipeParameterInputType; + key: string; + requirement: RecipeParameterRequirement; +}; + +export type RecipeParameterInputType = 'string' | 'number' | 'boolean' | 'date' | 'file'; + +export type RecipeParameterRequirement = 'required' | 'optional' | 'user_prompt'; + export type RedactedThinkingContent = { data: string; }; @@ -347,6 +470,10 @@ export type ResourceContents = { uri: string; }; +export type Response = { + json_schema?: unknown; +}; + export type Role = string; export type RunNowResponse = { @@ -460,6 +587,21 @@ export type SessionsQuery = { limit?: number; }; +export type Settings = { + goose_model?: string | null; + goose_provider?: string | null; + temperature?: number | null; +}; + +export type SubRecipe = { + name: string; + path: string; + sequential_when_repeated?: boolean; + values?: { + [key: string]: string; + } | null; +}; + export type SummarizationRequested = { msg: string; }; @@ -994,6 +1136,83 @@ export type ManageContextResponses = { export type ManageContextResponse = ManageContextResponses[keyof ManageContextResponses]; +export type CreateRecipeData = { + body: CreateRecipeRequest; + path?: never; + query?: never; + url: '/recipes/create'; +}; + +export type CreateRecipeErrors = { + /** + * Bad request + */ + 400: unknown; + /** + * Precondition failed - Agent not available + */ + 412: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type CreateRecipeResponses = { + /** + * Recipe created successfully + */ + 200: CreateRecipeResponse; +}; + +export type CreateRecipeResponse2 = CreateRecipeResponses[keyof CreateRecipeResponses]; + +export type DecodeRecipeData = { + body: DecodeRecipeRequest; + path?: never; + query?: never; + url: '/recipes/decode'; +}; + +export type DecodeRecipeErrors = { + /** + * Bad request + */ + 400: unknown; +}; + +export type DecodeRecipeResponses = { + /** + * Recipe decoded successfully + */ + 200: DecodeRecipeResponse; +}; + +export type DecodeRecipeResponse2 = DecodeRecipeResponses[keyof DecodeRecipeResponses]; + +export type EncodeRecipeData = { + body: EncodeRecipeRequest; + path?: never; + query?: never; + url: '/recipes/encode'; +}; + +export type EncodeRecipeErrors = { + /** + * Bad request + */ + 400: unknown; +}; + +export type EncodeRecipeResponses = { + /** + * Recipe encoded successfully + */ + 200: EncodeRecipeResponse; +}; + +export type EncodeRecipeResponse2 = EncodeRecipeResponses[keyof EncodeRecipeResponses]; + export type CreateScheduleData = { body: CreateScheduleRequest; path?: never; diff --git a/ui/desktop/src/components/RecipesView.tsx b/ui/desktop/src/components/RecipesView.tsx index 1c6d46ec7a36..d22cf6b3e315 100644 --- a/ui/desktop/src/components/RecipesView.tsx +++ b/ui/desktop/src/components/RecipesView.tsx @@ -163,8 +163,12 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { } const recipe = await decodeRecipe(recipeEncoded); - if (!recipe.title || !recipe.description || !recipe.instructions) { - throw new Error('Recipe is missing required fields (title, description, instructions)'); + if (!recipe.title || !recipe.description) { + throw new Error('Recipe is missing required fields (title, description)'); + } + + if (!recipe.instructions && !recipe.prompt) { + throw new Error('Recipe must have either instructions or prompt'); } return recipe; diff --git a/ui/desktop/src/components/schedule/CreateScheduleModal.tsx b/ui/desktop/src/components/schedule/CreateScheduleModal.tsx index d83ea9b23046..e73d9dd21ae4 100644 --- a/ui/desktop/src/components/schedule/CreateScheduleModal.tsx +++ b/ui/desktop/src/components/schedule/CreateScheduleModal.tsx @@ -48,11 +48,13 @@ interface CleanExtension { bundled?: boolean; } +// TODO: This 'Recipe' interface should be converted to match the OpenAPI spec for Recipe +// once we have separated the recipe from the schedule in the frontend. // Interface for clean recipe in YAML interface CleanRecipe { title: string; description: string; - instructions: string; + instructions?: string; prompt?: string; activities?: string[]; extensions?: CleanExtension[]; @@ -131,9 +133,12 @@ function recipeToYaml(recipe: Recipe, executionMode: ExecutionMode): string { const cleanRecipe: CleanRecipe = { title: recipe.title, description: recipe.description, - instructions: recipe.instructions, }; + if (recipe.instructions) { + cleanRecipe.instructions = recipe.instructions; + } + if (recipe.prompt) { cleanRecipe.prompt = recipe.prompt; } @@ -211,7 +216,7 @@ function recipeToYaml(recipe: Recipe, executionMode: ExecutionMode): string { } // Add common optional fields - if (ext.env_keys && ext.env_keys.length > 0) { + if ('env_keys' in ext && ext.env_keys && ext.env_keys.length > 0) { cleanExt.env_keys = ext.env_keys; } @@ -244,7 +249,10 @@ function recipeToYaml(recipe: Recipe, executionMode: ExecutionMode): string { } if (recipe.author) { - cleanRecipe.author = recipe.author; + cleanRecipe.author = { + contact: recipe.author.contact || undefined, + metadata: recipe.author.metadata || undefined, + }; } // Add schedule configuration based on execution mode diff --git a/ui/desktop/src/recipe/index.ts b/ui/desktop/src/recipe/index.ts index fd01fbe80b3b..1a51c95d90ce 100644 --- a/ui/desktop/src/recipe/index.ts +++ b/ui/desktop/src/recipe/index.ts @@ -1,39 +1,36 @@ -import { Message } from '../types/message'; -import { getApiUrl } from '../config'; -import { FullExtensionConfig } from '../extensions'; -import { safeJsonParse } from '../utils/jsonUtils'; - -export interface Parameter { - key: string; - description: string; - input_type: string; - default?: string; - requirement: 'required' | 'optional' | 'user_prompt'; -} - -export interface Recipe { - title: string; - description: string; - instructions: string; - prompt?: string; - activities?: string[]; - parameters?: Parameter[]; - author?: { - contact?: string; - metadata?: string; - }; - extensions?: FullExtensionConfig[]; - goosehints?: string; - context?: string[]; - profile?: string; - mcps?: number; +import { + createRecipe as apiCreateRecipe, + encodeRecipe as apiEncodeRecipe, + decodeRecipe as apiDecodeRecipe, +} from '../api'; +import type { + CreateRecipeRequest as ApiCreateRecipeRequest, + CreateRecipeResponse as ApiCreateRecipeResponse, + RecipeParameter, + Message as ApiMessage, + Role, + MessageContent, +} from '../api'; +import type { Message as FrontendMessage } from '../types/message'; + +// Re-export OpenAPI types with frontend-specific additions +export type Parameter = RecipeParameter; +export type Recipe = import('../api').Recipe & { + // TODO: Separate these from the raw recipe type // Properties added for scheduled execution scheduledJobId?: string; isScheduledExecution?: boolean; -} + // TODO: Separate these from the raw recipe type + // Legacy frontend properties (not in OpenAPI schema) + profile?: string; + goosehints?: string; + mcps?: number; +}; +// Create frontend-compatible type that accepts frontend Message until we can refactor. export interface CreateRecipeRequest { - messages: Message[]; + // TODO: Fix this type to match Message OpenAPI spec + messages: FrontendMessage[]; title: string; description: string; activities?: string[]; @@ -43,98 +40,97 @@ export interface CreateRecipeRequest { }; } -export interface CreateRecipeResponse { - recipe: Recipe | null; - error: string | null; +export type CreateRecipeResponse = ApiCreateRecipeResponse; + +function convertFrontendMessageToApiMessage(frontendMessage: FrontendMessage): ApiMessage { + // TODO: Fix this type to match Message OpenAPI spec + return { + id: frontendMessage.id, + role: frontendMessage.role as Role, + content: frontendMessage.content.map((content) => ({ + ...content, + // Convert toolCall to match API expectations + ...(content.type === 'toolRequest' && 'toolCall' in content + ? { + toolCall: content.toolCall as unknown as { [key: string]: unknown }, + } + : {}), + })) as MessageContent[], + created: frontendMessage.created, + }; } export async function createRecipe(request: CreateRecipeRequest): Promise { - const url = getApiUrl('/recipes/create'); - console.log('Creating recipe at:', url); - console.log('Request:', JSON.stringify(request, null, 2)); - - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(request), - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error('Failed to create recipe:', { - status: response.status, - statusText: response.statusText, - error: errorText, + console.log('Creating recipe with request:', JSON.stringify(request, null, 2)); + + try { + const apiRequest: ApiCreateRecipeRequest = { + messages: request.messages.map(convertFrontendMessageToApiMessage), + title: request.title, + description: request.description, + activities: request.activities || undefined, + author: request.author + ? { + contact: request.author.contact || undefined, + metadata: request.author.metadata || undefined, + } + : undefined, + }; + + const response = await apiCreateRecipe({ + body: apiRequest, }); - throw new Error(`Failed to create recipe: ${response.statusText} (${errorText})`); - } - return safeJsonParse(response, 'Server failed to create recipe:'); -} + if (!response.data) { + throw new Error('No data returned from API'); + } -export interface EncodeRecipeRequest { - recipe: Recipe; + return response.data; + } catch (error) { + console.error('Failed to create recipe:', error); + throw error; + } } -export interface EncodeRecipeResponse { - deeplink: string; -} +export async function encodeRecipe(recipe: Recipe): Promise { + try { + const response = await apiEncodeRecipe({ + body: { recipe }, + }); -export interface DecodeRecipeRequest { - deeplink: string; -} + if (!response.data) { + throw new Error('No data returned from API'); + } -export interface DecodeRecipeResponse { - recipe: Recipe; -} - -export async function encodeRecipe(recipe: Recipe): Promise { - const url = getApiUrl('/recipes/encode'); - - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ recipe } as EncodeRecipeRequest), - }); - - if (!response.ok) { - throw new Error(`Failed to encode recipe: ${response.status} ${response.statusText}`); + return response.data.deeplink; + } catch (error) { + console.error('Failed to encode recipe:', error); + throw error; } - - const data: EncodeRecipeResponse = await response.json(); - return data.deeplink; } export async function decodeRecipe(deeplink: string): Promise { - const url = getApiUrl('/recipes/decode'); - console.log('Decoding recipe from deeplink:', deeplink); - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ deeplink } as DecodeRecipeRequest), - }); - - if (!response.ok) { - console.error('Failed to decode deeplink:', { - status: response.status, - statusText: response.statusText, + + try { + const response = await apiDecodeRecipe({ + body: { deeplink }, }); - throw new Error(`Failed to decode deeplink: ${response.status} ${response.statusText}`); - } - const data: DecodeRecipeResponse = await response.json(); - if (!data.recipe) { - console.error('Decoded recipe is null:', data); - throw new Error('Decoded recipe is null'); + if (!response.data) { + throw new Error('No data returned from API'); + } + + if (!response.data.recipe) { + console.error('Decoded recipe is null:', response.data); + throw new Error('Decoded recipe is null'); + } + + return response.data.recipe as Recipe; + } catch (error) { + console.error('Failed to decode deeplink:', error); + throw error; } - return data.recipe; } export async function generateDeepLink(recipe: Recipe): Promise { diff --git a/ui/desktop/src/recipe/recipeStorage.ts b/ui/desktop/src/recipe/recipeStorage.ts index 0d92c6808982..6941598d5643 100644 --- a/ui/desktop/src/recipe/recipeStorage.ts +++ b/ui/desktop/src/recipe/recipeStorage.ts @@ -100,8 +100,12 @@ export async function saveRecipe(recipe: Recipe, options: SaveRecipeOptions): Pr } // Validate recipe has required fields - if (!recipe.title || !recipe.description || !recipe.instructions) { - throw new Error('Recipe is missing required fields (title, description, instructions)'); + if (!recipe.title || !recipe.description) { + throw new Error('Recipe is missing required fields (title, description)'); + } + + if (!recipe.instructions && !recipe.prompt) { + throw new Error('Recipe must have either instructions or prompt'); } try { @@ -142,14 +146,14 @@ export async function loadRecipe(recipeName: string, isGlobal: boolean): Promise } // Validate the loaded recipe has required fields - if ( - !savedRecipe.recipe.title || - !savedRecipe.recipe.description || - !savedRecipe.recipe.instructions - ) { + if (!savedRecipe.recipe.title || !savedRecipe.recipe.description) { throw new Error('Loaded recipe is missing required fields'); } + if (!savedRecipe.recipe.instructions && !savedRecipe.recipe.prompt) { + throw new Error('Loaded recipe must have either instructions or prompt'); + } + return savedRecipe.recipe; } catch (error) { throw new Error(