-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Lifei/create save recipe to file #4895
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4f9d087
ade8b79
35764f6
3db1468
e6fec0c
f9f9eef
be8cfcb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should we replace /decode with this too? and really make parse so that it also takes base64 etc |
||
| .with_state(state) | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(); | ||
|
|
||
| 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::<chrono::Utc>::from(m.modified().unwrap()).to_rfc3339()) | ||
| else { | ||
| continue; | ||
| }; | ||
| let recipe_metadata = | ||
| RecipeManifestMetadata::from_yaml_file(&file_path).unwrap_or_else(|_| { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current import UI also supports JSON so we may want to consider that too
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yaml is a superset of json so that should be alright |
||
| 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<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); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is cute and we certainly need something like this, but I would leave it out for now; the real refactor here is to have something that converts from our errors to a ErrorResponse globally and then apply it everywhere. would love to have it, but I don't think it is in scope /cc @jamadeo
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
axum is very permissive about what you can return: https://docs.rs/axum/latest/axum/response/index.html
You can already return a (StatusCode, String) and it will convert
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cool. we should do this for everything some time. maybe goose can