Lifei/create save recipe to file (#4895)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user