From e2e656c0124a4225ebd12dfabba99417d7bf80c6 Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison <72240382+jsibbison-square@users.noreply.github.com> Date: Sun, 20 Jul 2025 18:13:55 +1000 Subject: [PATCH] refactor: Centralise deeplink encode and decode into server (#3489) --- Cargo.lock | 3 +- crates/goose-cli/Cargo.toml | 1 - crates/goose-cli/src/commands/recipe.rs | 29 ++- crates/goose-mcp/Cargo.toml | 1 - crates/goose-server/src/routes/recipe.rs | 83 ++++++++ crates/goose/Cargo.toml | 1 + crates/goose/src/lib.rs | 1 + crates/goose/src/recipe_deeplink.rs | 108 ++++++++++ ui/desktop/src/App.tsx | 6 +- ui/desktop/src/components/RecipeEditor.tsx | 87 ++++++-- ui/desktop/src/components/RecipesView.tsx | 31 +-- ui/desktop/src/components/ViewRecipeModal.tsx | 85 ++++++-- .../schedule/CreateScheduleModal.tsx | 45 ++-- .../schedule/ScheduleFromRecipeModal.tsx | 44 ++-- .../src/components/ui/DeepLinkModal.tsx | 103 +++++---- ui/desktop/src/hooks/useRecipeManager.ts | 16 +- ui/desktop/src/main.ts | 197 ++++++++++-------- ui/desktop/src/preload.ts | 17 +- ui/desktop/src/recipe/index.ts | 68 ++++++ ui/desktop/src/utils/providerUtils.ts | 4 +- 20 files changed, 667 insertions(+), 263 deletions(-) create mode 100644 crates/goose/src/recipe_deeplink.rs diff --git a/Cargo.lock b/Cargo.lock index b6d989de..0e6c66c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3491,6 +3491,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "urlencoding", "utoipa", "uuid", "webbrowser 0.8.15", @@ -3571,7 +3572,6 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", - "urlencoding", "webbrowser 1.0.4", "winapi", ] @@ -3666,7 +3666,6 @@ dependencies = [ "tracing-subscriber", "umya-spreadsheet", "url", - "urlencoding", "utoipa", "webbrowser 0.8.15", "which 6.0.3", diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index ffbdde82..88aa0266 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -71,7 +71,6 @@ http = "1.0" webbrowser = "1.0" indicatif = "0.17.11" -urlencoding = "2" [target.'cfg(target_os = "windows")'.dependencies] winapi = { version = "0.3", features = ["wincred"] } diff --git a/crates/goose-cli/src/commands/recipe.rs b/crates/goose-cli/src/commands/recipe.rs index 5e37f0c8..3f1db9e2 100644 --- a/crates/goose-cli/src/commands/recipe.rs +++ b/crates/goose-cli/src/commands/recipe.rs @@ -1,11 +1,10 @@ use anyhow::Result; -use base64::Engine; use console::style; -use serde_json; use crate::recipes::github_recipe::RecipeSource; use crate::recipes::recipe::load_recipe_for_validation; use crate::recipes::search_recipe::list_available_recipes; +use goose::recipe_deeplink; /// Validates a recipe file /// @@ -42,21 +41,26 @@ pub fn handle_validate(recipe_name: &str) -> Result<()> { pub fn handle_deeplink(recipe_name: &str) -> Result { // Load the recipe file first to validate it match load_recipe_for_validation(recipe_name) { - Ok(recipe) => { - let mut full_url = String::new(); - if let Ok(recipe_json) = serde_json::to_string(&recipe) { - let deeplink = base64::engine::general_purpose::STANDARD.encode(recipe_json); + Ok(recipe) => match recipe_deeplink::encode(&recipe) { + Ok(encoded) => { println!( "{} Generated deeplink for: {}", style("✓").green().bold(), recipe.title ); - let url_safe = urlencoding::encode(&deeplink); - full_url = format!("goose://recipe?config={}", url_safe); + let full_url = format!("goose://recipe?config={}", encoded); println!("{}", full_url); + Ok(full_url) } - Ok(full_url) - } + Err(err) => { + println!( + "{} Failed to encode recipe: {}", + style("✗").red().bold(), + err + ); + Err(anyhow::anyhow!("Failed to encode recipe: {}", err)) + } + }, Err(err) => { println!("{} {}", style("✗").red().bold(), err); Err(err) @@ -185,7 +189,10 @@ response: let result = handle_deeplink(&recipe_path); assert!(result.is_ok()); - assert!(result.unwrap().contains("goose://recipe?config=eyJ2ZXJzaW9uIjoiMS4wLjAiLCJ0aXRsZSI6IlRlc3QgUmVjaXBlIHdpdGggVmFsaWQgSlNPTiBTY2hlbWEiLCJkZXNjcmlwdGlvbiI6IkEgdGVzdCByZWNpcGUgd2l0aCB2YWxpZCBKU09OIHNjaGVtYSIsImluc3RydWN0aW9ucyI6IlRlc3QgaW5zdHJ1Y3Rpb25zIiwicHJvbXB0IjoiVGVzdCBwcm9tcHQgY29udGVudCIsInJlc3BvbnNlIjp7Impzb25fc2NoZW1hIjp7InByb3BlcnRpZXMiOnsiY291bnQiOnsiZGVzY3JpcHRpb24iOiJBIGNvdW50IHZhbHVlIiwidHlwZSI6Im51bWJlciJ9LCJyZXN1bHQiOnsiZGVzY3JpcHRpb24iOiJUaGUgcmVzdWx0IiwidHlwZSI6InN0cmluZyJ9fSwicmVxdWlyZWQiOlsicmVzdWx0Il0sInR5cGUiOiJvYmplY3QifX19")); + let url = result.unwrap(); + assert!(url.starts_with("goose://recipe?config=")); + let encoded_part = url.strip_prefix("goose://recipe?config=").unwrap(); + assert!(encoded_part.len() > 0); } #[test] diff --git a/crates/goose-mcp/Cargo.toml b/crates/goose-mcp/Cargo.toml index 3c24ae2c..de8030fd 100644 --- a/crates/goose-mcp/Cargo.toml +++ b/crates/goose-mcp/Cargo.toml @@ -20,7 +20,6 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" url = "2.5" -urlencoding = "2.1.3" base64 = "0.21" thiserror = "1.0" serde = { version = "1.0", features = ["derive"] } diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index d2f2df7b..871df176 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; use goose::message::Message; use goose::recipe::Recipe; +use goose::recipe_deeplink; use serde::{Deserialize, Serialize}; use crate::state::AppState; @@ -34,6 +35,26 @@ pub struct CreateRecipeResponse { error: Option, } +#[derive(Debug, Deserialize)] +pub struct EncodeRecipeRequest { + recipe: Recipe, +} + +#[derive(Debug, Serialize)] +pub struct EncodeRecipeResponse { + deeplink: String, +} + +#[derive(Debug, Deserialize)] +pub struct DecodeRecipeRequest { + deeplink: String, +} + +#[derive(Debug, Serialize)] +pub struct DecodeRecipeResponse { + recipe: Recipe, +} + /// Create a Recipe configuration from the current state of an agent async fn create_recipe( State(state): State>, @@ -84,8 +105,70 @@ async fn create_recipe( } } +async fn encode_recipe( + Json(request): Json, +) -> Result, StatusCode> { + match recipe_deeplink::encode(&request.recipe) { + Ok(encoded) => Ok(Json(EncodeRecipeResponse { deeplink: encoded })), + Err(err) => { + tracing::error!("Failed to encode recipe: {}", err); + Err(StatusCode::BAD_REQUEST) + } + } +} + +async fn decode_recipe( + Json(request): Json, +) -> Result, StatusCode> { + match recipe_deeplink::decode(&request.deeplink) { + Ok(recipe) => Ok(Json(DecodeRecipeResponse { recipe })), + Err(err) => { + tracing::error!("Failed to decode deeplink: {}", err); + Err(StatusCode::BAD_REQUEST) + } + } +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/recipe/create", post(create_recipe)) + .route("/recipes/encode", post(encode_recipe)) + .route("/recipes/decode", post(decode_recipe)) .with_state(state) } + +#[cfg(test)] +mod tests { + use super::*; + use goose::recipe::Recipe; + + #[tokio::test] + async fn test_decode_and_encode_recipe() { + let original_recipe = Recipe::builder() + .title("Test Recipe") + .description("A test recipe") + .instructions("Test instructions") + .build() + .unwrap(); + let encoded = recipe_deeplink::encode(&original_recipe).unwrap(); + + let request = DecodeRecipeRequest { + deeplink: encoded.clone(), + }; + let response = decode_recipe(Json(request)).await; + + assert!(response.is_ok()); + let decoded = response.unwrap().0.recipe; + assert_eq!(decoded.title, original_recipe.title); + assert_eq!(decoded.description, original_recipe.description); + assert_eq!(decoded.instructions, original_recipe.instructions); + + let encode_request = EncodeRecipeRequest { recipe: decoded }; + let encode_response = encode_recipe(Json(encode_request)).await; + + assert!(encode_response.is_ok()); + let encoded_again = encode_response.unwrap().0.deeplink; + assert!(!encoded_again.is_empty()); + assert_eq!(encoded, encoded_again); + } +} diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index e96edb23..7b694cb1 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -53,6 +53,7 @@ nanoid = "0.4" sha2 = "0.10" base64 = "0.21" url = "2.5" +urlencoding = "2.1" axum = "0.8.1" webbrowser = "0.8" lazy_static = "1.5.0" diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index 1a8bf107..32b8da80 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -8,6 +8,7 @@ pub mod project; pub mod prompt_template; pub mod providers; pub mod recipe; +pub mod recipe_deeplink; pub mod scheduler; pub mod scheduler_factory; pub mod scheduler_trait; diff --git a/crates/goose/src/recipe_deeplink.rs b/crates/goose/src/recipe_deeplink.rs new file mode 100644 index 00000000..7123f4ef --- /dev/null +++ b/crates/goose/src/recipe_deeplink.rs @@ -0,0 +1,108 @@ +use anyhow::Result; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use thiserror::Error; + +use crate::recipe::Recipe; + +#[derive(Error, Debug)] +pub enum DecodeError { + #[error("All decoding methods failed")] + AllMethodsFailed, +} + +pub fn encode(recipe: &Recipe) -> Result { + let recipe_json = serde_json::to_string(recipe)?; + let encoded = URL_SAFE_NO_PAD.encode(recipe_json.as_bytes()); + Ok(encoded) +} + +pub fn decode(link: &str) -> Result { + // Handle the current format: URL-safe Base64 without padding. + if let Ok(decoded_bytes) = URL_SAFE_NO_PAD.decode(link) { + if let Ok(recipe_json) = String::from_utf8(decoded_bytes) { + if let Ok(recipe) = serde_json::from_str::(&recipe_json) { + return Ok(recipe); + } + } + } + + // Handle legacy formats of 'standard base64 encoded' and standard base64 encoded that was then url encoded. + if let Ok(url_decoded) = urlencoding::decode(link) { + if let Ok(decoded_bytes) = + base64::engine::general_purpose::STANDARD.decode(url_decoded.as_bytes()) + { + if let Ok(recipe_json) = String::from_utf8(decoded_bytes) { + if let Ok(recipe) = serde_json::from_str::(&recipe_json) { + return Ok(recipe); + } + } + } + } + + Err(DecodeError::AllMethodsFailed) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::recipe::Recipe; + + fn create_test_recipe() -> Recipe { + Recipe::builder() + .title("Test Recipe") + .description("A test recipe for deeplink encoding/decoding") + .instructions("Act as a helpful assistant") + .build() + .expect("Failed to build test recipe") + } + + #[test] + fn test_encode_decode_round_trip() { + let original_recipe = create_test_recipe(); + + let encoded = encode(&original_recipe).expect("Failed to encode recipe"); + assert!(!encoded.is_empty()); + + let decoded_recipe = decode(&encoded).expect("Failed to decode recipe"); + + assert_eq!(original_recipe.title, decoded_recipe.title); + assert_eq!(original_recipe.description, decoded_recipe.description); + assert_eq!(original_recipe.instructions, decoded_recipe.instructions); + assert_eq!(original_recipe.version, decoded_recipe.version); + } + + #[test] + fn test_decode_legacy_standard_base64() { + let recipe = create_test_recipe(); + let recipe_json = serde_json::to_string(&recipe).unwrap(); + let legacy_encoded = + base64::engine::general_purpose::STANDARD.encode(recipe_json.as_bytes()); + + let decoded_recipe = decode(&legacy_encoded).expect("Failed to decode legacy format"); + assert_eq!(recipe.title, decoded_recipe.title); + assert_eq!(recipe.description, decoded_recipe.description); + assert_eq!(recipe.instructions, decoded_recipe.instructions); + } + + #[test] + fn test_decode_legacy_url_encoded_base64() { + let recipe = create_test_recipe(); + let recipe_json = serde_json::to_string(&recipe).unwrap(); + let base64_encoded = + base64::engine::general_purpose::STANDARD.encode(recipe_json.as_bytes()); + let url_encoded = urlencoding::encode(&base64_encoded); + + let decoded_recipe = + decode(&url_encoded).expect("Failed to decode URL-encoded legacy format"); + assert_eq!(recipe.title, decoded_recipe.title); + assert_eq!(recipe.description, decoded_recipe.description); + assert_eq!(recipe.instructions, decoded_recipe.instructions); + } + + #[test] + fn test_decode_invalid_input() { + let result = decode("invalid_base64!"); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), DecodeError::AllMethodsFailed)); + } +} diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index ebd32050..e4b2881a 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -432,7 +432,7 @@ const RecipeEditorRoute = () => { if (!config) { const electronConfig = window.electron.getConfig(); - config = electronConfig.recipeConfig; + config = electronConfig.recipe; } return ; @@ -758,7 +758,7 @@ export default function App() { const urlParams = new URLSearchParams(window.location.search); const viewType = urlParams.get('view'); const resumeSessionId = urlParams.get('resumeSessionId'); - const recipeConfig = window.appConfig.get('recipeConfig'); + const recipeConfig = window.appConfig.get('recipe'); // Check for session resume first - this takes priority over other navigation if (resumeSessionId) { @@ -979,7 +979,7 @@ export default function App() { // Handle navigation to pair view for recipe deeplinks after router is ready useEffect(() => { - const recipeConfig = window.appConfig.get('recipeConfig'); + const recipeConfig = window.appConfig.get('recipe'); if ( recipeConfig && typeof recipeConfig === 'object' && diff --git a/ui/desktop/src/components/RecipeEditor.tsx b/ui/desktop/src/components/RecipeEditor.tsx index c3f1ce6d..52ab473e 100644 --- a/ui/desktop/src/components/RecipeEditor.tsx +++ b/ui/desktop/src/components/RecipeEditor.tsx @@ -1,9 +1,8 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Recipe } from '../recipe'; +import { Recipe, generateDeepLink } from '../recipe'; import { Parameter } from '../recipe/index'; -import { Buffer } from 'buffer'; import { FullExtensionConfig } from '../extensions'; import { Geese } from './icons/Geese'; import Copy from './icons/Copy'; @@ -23,13 +22,6 @@ interface RecipeEditorProps { config?: Recipe; } -// Function to generate a deep link from a recipe -function generateDeepLink(recipe: Recipe): string { - const configBase64 = Buffer.from(JSON.stringify(recipe)).toString('base64'); - const urlSafe = encodeURIComponent(configBase64); - return `goose://recipe?config=${urlSafe}`; -} - export default function RecipeEditor({ config }: RecipeEditorProps) { const { getExtensions } = useConfig(); const navigate = useNavigate(); @@ -58,6 +50,9 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { setValue: (value: string) => void; } | null>(null); + const [deeplink, setDeeplink] = useState(''); + const [isGeneratingDeeplink, setIsGeneratingDeeplink] = useState(false); + // Initialize selected extensions for the recipe from config or localStorage const [recipeExtensions] = useState(() => { // First try to get from localStorage @@ -134,7 +129,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { setParameters(allParams); }, [instructions, prompt]); - const getCurrentConfig = (): Recipe => { + const getCurrentConfig = useCallback((): Recipe => { // Transform the internal parameters state into the desired output format. const formattedParameters = parameters.map((param) => { const formattedParam: Parameter = { @@ -184,7 +179,62 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { console.log('Final config extensions:', config.extensions); return config; - }; + }, [ + recipeConfig, + title, + description, + instructions, + activities, + prompt, + parameters, + recipeExtensions, + extensionOptions, + ]); + + // Generate deeplink whenever recipe configuration changes + useEffect(() => { + let isCancelled = false; + + const generateLink = async () => { + if (!title.trim() || !description.trim() || !instructions.trim()) { + setDeeplink(''); + return; + } + + setIsGeneratingDeeplink(true); + try { + const currentConfig = getCurrentConfig(); + const link = await generateDeepLink(currentConfig); + if (!isCancelled) { + setDeeplink(link); + } + } catch (error) { + console.error('Failed to generate deeplink:', error); + if (!isCancelled) { + setDeeplink('Error generating deeplink'); + } + } finally { + if (!isCancelled) { + setIsGeneratingDeeplink(false); + } + } + }; + + generateLink(); + + return () => { + isCancelled = true; + }; + }, [ + title, + description, + instructions, + prompt, + activities, + parameters, + recipeExtensions, + getCurrentConfig, + ]); const [errors, setErrors] = useState<{ title?: string; @@ -217,9 +267,11 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { ); }; - const deeplink = generateDeepLink(getCurrentConfig()); - const handleCopy = () => { + if (!deeplink || isGeneratingDeeplink || deeplink === 'Error generating deeplink') { + return; + } + navigator.clipboard .writeText(deeplink) .then(() => { @@ -437,6 +489,9 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { onClick={() => validateForm() && handleCopy()} variant="ghost" size="sm" + disabled={ + !deeplink || isGeneratingDeeplink || deeplink === 'Error generating deeplink' + } className="p-2 hover:bg-background-default rounded-lg transition-colors flex items-center disabled:opacity-50 disabled:hover:bg-transparent flex-shrink-0" > {copied ? ( @@ -457,7 +512,9 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { className={`text-sm dark:text-white font-mono cursor-pointer hover:bg-background-default p-2 rounded transition-colors overflow-x-auto whitespace-nowrap ${!title.trim() || !description.trim() ? 'text-textDisabled' : 'text-textStandard'}`} style={{ maxWidth: '500px', width: '100%' }} > - {deeplink} + {isGeneratingDeeplink + ? 'Generating deeplink...' + : deeplink || 'Click to generate deeplink'} )} diff --git a/ui/desktop/src/components/RecipesView.tsx b/ui/desktop/src/components/RecipesView.tsx index 9acb5ad9..1c6d46ec 100644 --- a/ui/desktop/src/components/RecipesView.tsx +++ b/ui/desktop/src/components/RecipesView.tsx @@ -21,8 +21,7 @@ import { Card } from './ui/card'; import { Button } from './ui/button'; import { Skeleton } from './ui/skeleton'; import { MainPanelLayout } from './Layout/MainPanelLayout'; -import { Recipe } from '../recipe'; -import { Buffer } from 'buffer'; +import { Recipe, decodeRecipe } from '../recipe'; import { toastSuccess, toastError } from '../toasts'; interface RecipesViewProps { @@ -149,7 +148,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { }; // Function to parse deeplink and extract recipe - const parseDeeplink = (deeplink: string): Recipe | null => { + const parseDeeplink = async (deeplink: string): Promise => { try { const cleanLink = deeplink.trim(); @@ -157,15 +156,12 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { throw new Error('Invalid deeplink format. Expected: goose://recipe?config=...'); } - // Extract and decode the base64 config - const configBase64 = cleanLink.replace('goose://recipe?config=', ''); + const recipeEncoded = cleanLink.replace('goose://recipe?config=', ''); - if (!configBase64) { + if (!recipeEncoded) { throw new Error('No recipe configuration found in deeplink'); } - const urlDecoded = decodeURIComponent(configBase64); - const configJson = Buffer.from(urlDecoded, 'base64').toString('utf-8'); - const recipe = JSON.parse(configJson) as Recipe; + const recipe = await decodeRecipe(recipeEncoded); if (!recipe.title || !recipe.description || !recipe.instructions) { throw new Error('Recipe is missing required fields (title, description, instructions)'); @@ -185,7 +181,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { setImporting(true); try { - const recipe = parseDeeplink(importDeeplink.trim()); + const recipe = await parseDeeplink(importDeeplink.trim()); if (!recipe) { throw new Error('Invalid deeplink or recipe format'); @@ -228,14 +224,19 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { }; // Auto-generate recipe name when deeplink changes - const handleDeeplinkChange = (value: string) => { + const handleDeeplinkChange = async (value: string) => { setImportDeeplink(value); if (value.trim()) { - const recipe = parseDeeplink(value.trim()); - if (recipe && recipe.title) { - const suggestedName = generateRecipeFilename(recipe); - setImportRecipeName(suggestedName); + try { + const recipe = await parseDeeplink(value.trim()); + if (recipe && recipe.title) { + const suggestedName = generateRecipeFilename(recipe); + setImportRecipeName(suggestedName); + } + } catch (error) { + // Silently handle parsing errors during auto-suggest + console.log('Could not parse deeplink for auto-suggest:', error); } } }; diff --git a/ui/desktop/src/components/ViewRecipeModal.tsx b/ui/desktop/src/components/ViewRecipeModal.tsx index bdd5e825..c521c3d0 100644 --- a/ui/desktop/src/components/ViewRecipeModal.tsx +++ b/ui/desktop/src/components/ViewRecipeModal.tsx @@ -1,7 +1,6 @@ -import { useState, useEffect } from 'react'; -import { Recipe } from '../recipe'; +import { useState, useEffect, useCallback } from 'react'; +import { Recipe, generateDeepLink } from '../recipe'; import { Parameter } from '../recipe/index'; -import { Buffer } from 'buffer'; import { FullExtensionConfig } from '../extensions'; import { Geese } from './icons/Geese'; import Copy from './icons/Copy'; @@ -23,13 +22,6 @@ interface ViewRecipeModalProps { config: Recipe; } -// Function to generate a deep link from a recipe -function generateDeepLink(recipe: Recipe): string { - const configBase64 = Buffer.from(JSON.stringify(recipe)).toString('base64'); - const urlSafe = encodeURIComponent(configBase64); - return `goose://recipe?config=${urlSafe}`; -} - export default function ViewRecipeModal({ isOpen, onClose, config }: ViewRecipeModalProps) { const { getExtensions } = useConfig(); const [recipeConfig] = useState(config); @@ -118,7 +110,7 @@ export default function ViewRecipeModal({ isOpen, onClose, config }: ViewRecipeM setParameters(allParams); }, [instructions, prompt]); - const getCurrentConfig = (): Recipe => { + const getCurrentConfig = useCallback((): Recipe => { // Transform the internal parameters state into the desired output format. const formattedParameters = parameters.map((param) => { const formattedParam: Parameter = { @@ -163,7 +155,17 @@ export default function ViewRecipeModal({ isOpen, onClose, config }: ViewRecipeM }; return updatedConfig; - }; + }, [ + recipeConfig, + title, + description, + instructions, + activities, + prompt, + parameters, + recipeExtensions, + extensionOptions, + ]); const [errors, setErrors] = useState<{ title?: string; @@ -196,9 +198,59 @@ export default function ViewRecipeModal({ isOpen, onClose, config }: ViewRecipeM ); }; - const deeplink = generateDeepLink(getCurrentConfig()); + const [deeplink, setDeeplink] = useState(''); + const [isGeneratingDeeplink, setIsGeneratingDeeplink] = useState(false); + + // Generate deeplink whenever recipe configuration changes + useEffect(() => { + let isCancelled = false; + + const generateLink = async () => { + if (!title.trim() || !description.trim() || !instructions.trim()) { + setDeeplink(''); + return; + } + + setIsGeneratingDeeplink(true); + try { + const currentConfig = getCurrentConfig(); + const link = await generateDeepLink(currentConfig); + if (!isCancelled) { + setDeeplink(link); + } + } catch (error) { + console.error('Failed to generate deeplink:', error); + if (!isCancelled) { + setDeeplink('Error generating deeplink'); + } + } finally { + if (!isCancelled) { + setIsGeneratingDeeplink(false); + } + } + }; + + generateLink(); + + return () => { + isCancelled = true; + }; + }, [ + title, + description, + instructions, + prompt, + activities, + parameters, + recipeExtensions, + getCurrentConfig, + ]); const handleCopy = () => { + if (!deeplink || isGeneratingDeeplink || deeplink === 'Error generating deeplink') { + return; + } + navigator.clipboard .writeText(deeplink) .then(() => { @@ -430,6 +482,9 @@ export default function ViewRecipeModal({ isOpen, onClose, config }: ViewRecipeM onClick={() => validateForm() && handleCopy()} variant="ghost" size="sm" + disabled={ + !deeplink || isGeneratingDeeplink || deeplink === 'Error generating deeplink' + } className="ml-4 p-2 hover:bg-background-default rounded-lg transition-colors flex items-center disabled:opacity-50 disabled:hover:bg-transparent" > {copied ? ( @@ -448,7 +503,9 @@ export default function ViewRecipeModal({ isOpen, onClose, config }: ViewRecipeM onClick={() => validateForm() && handleCopy()} className={`text-sm truncate font-mono cursor-pointer ${!title.trim() || !description.trim() ? 'text-textDisabled' : 'text-textStandard'}`} > - {deeplink} + {isGeneratingDeeplink + ? 'Generating deeplink...' + : deeplink || 'Click to generate deeplink'} )} diff --git a/ui/desktop/src/components/schedule/CreateScheduleModal.tsx b/ui/desktop/src/components/schedule/CreateScheduleModal.tsx index 4f2a302a..d83ea9b2 100644 --- a/ui/desktop/src/components/schedule/CreateScheduleModal.tsx +++ b/ui/desktop/src/components/schedule/CreateScheduleModal.tsx @@ -5,8 +5,7 @@ import { Input } from '../ui/input'; import { Select } from '../ui/Select'; import cronstrue from 'cronstrue'; import * as yaml from 'yaml'; -import { Buffer } from 'buffer'; -import { Recipe } from '../../recipe'; +import { Recipe, decodeRecipe } from '../../recipe'; import ClockIcon from '../../assets/clock-icon.svg'; type FrequencyValue = 'once' | 'every' | 'daily' | 'weekly' | 'monthly'; @@ -107,20 +106,19 @@ type SourceType = 'file' | 'deeplink'; type ExecutionMode = 'background' | 'foreground'; // Function to parse deep link and extract recipe config -function parseDeepLink(deepLink: string): Recipe | null { +async function parseDeepLink(deepLink: string): Promise { try { const url = new URL(deepLink); if (url.protocol !== 'goose:' || (url.hostname !== 'bot' && url.hostname !== 'recipe')) { return null; } - const configParam = url.searchParams.get('config'); - if (!configParam) { + const recipeParam = url.searchParams.get('config'); + if (!recipeParam) { return null; } - const configJson = Buffer.from(decodeURIComponent(configParam), 'base64').toString('utf-8'); - return JSON.parse(configJson) as Recipe; + return await decodeRecipe(recipeParam); } catch (error) { console.error('Failed to parse deep link:', error); return null; @@ -287,26 +285,33 @@ export const CreateScheduleModal: React.FC = ({ const [internalValidationError, setInternalValidationError] = useState(null); const handleDeepLinkChange = useCallback( - (value: string) => { + async (value: string) => { setDeepLinkInput(value); setInternalValidationError(null); if (value.trim()) { - const recipe = parseDeepLink(value.trim()); - if (recipe) { - setParsedRecipe(recipe); - // Auto-populate schedule ID from recipe title if available - if (recipe.title && !scheduleId) { - const cleanId = recipe.title - .toLowerCase() - .replace(/[^a-z0-9-]/g, '-') - .replace(/-+/g, '-'); - setScheduleId(cleanId); + try { + const recipe = await parseDeepLink(value.trim()); + if (recipe) { + setParsedRecipe(recipe); + // Auto-populate schedule ID from recipe title if available + if (recipe.title && !scheduleId) { + const cleanId = recipe.title + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-'); + setScheduleId(cleanId); + } + } else { + setParsedRecipe(null); + setInternalValidationError( + 'Invalid deep link format. Please use a goose://bot or goose://recipe link.' + ); } - } else { + } catch (error) { setParsedRecipe(null); setInternalValidationError( - 'Invalid deep link format. Please use a goose://bot or goose://recipe link.' + 'Failed to parse deep link. Please ensure using a goose://bot or goose://recipe link and try again.' ); } } else { diff --git a/ui/desktop/src/components/schedule/ScheduleFromRecipeModal.tsx b/ui/desktop/src/components/schedule/ScheduleFromRecipeModal.tsx index cbabbf2c..7408a873 100644 --- a/ui/desktop/src/components/schedule/ScheduleFromRecipeModal.tsx +++ b/ui/desktop/src/components/schedule/ScheduleFromRecipeModal.tsx @@ -2,8 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; -import { Recipe } from '../../recipe'; -import { generateDeepLink } from '../ui/DeepLinkModal'; +import { Recipe, generateDeepLink } from '../../recipe'; import Copy from '../icons/Copy'; import { Check } from 'lucide-react'; @@ -24,24 +23,29 @@ export const ScheduleFromRecipeModal: React.FC = ( const [deepLink, setDeepLink] = useState(''); useEffect(() => { - if (isOpen && recipe) { - // Convert Recipe to the format expected by generateDeepLink - const recipeConfig = { - id: recipe.title?.toLowerCase().replace(/[^a-z0-9-]/g, '-') || 'recipe', - title: recipe.title, - description: recipe.description, - instructions: recipe.instructions, - activities: recipe.activities || [], - prompt: recipe.prompt, - extensions: recipe.extensions, - goosehints: recipe.goosehints, - context: recipe.context, - profile: recipe.profile, - author: recipe.author, - }; - const link = generateDeepLink(recipeConfig); - setDeepLink(link); - } + let isCancelled = false; + + const generateLink = async () => { + if (isOpen && recipe) { + try { + const link = await generateDeepLink(recipe); + if (!isCancelled) { + setDeepLink(link); + } + } catch (error) { + console.error('Failed to generate deeplink:', error); + if (!isCancelled) { + setDeepLink('Error generating deeplink'); + } + } + } + }; + + generateLink(); + + return () => { + isCancelled = true; + }; }, [isOpen, recipe]); const handleCopy = () => { diff --git a/ui/desktop/src/components/ui/DeepLinkModal.tsx b/ui/desktop/src/components/ui/DeepLinkModal.tsx index db72a829..7ddf474e 100644 --- a/ui/desktop/src/components/ui/DeepLinkModal.tsx +++ b/ui/desktop/src/components/ui/DeepLinkModal.tsx @@ -1,42 +1,60 @@ -import React, { useMemo, useState, useEffect, useRef } from 'react'; -import { Buffer } from 'buffer'; +import React, { useState, useEffect, useRef } from 'react'; import Copy from '../icons/Copy'; import { Card } from './card'; - -interface RecipeConfig { - instructions?: string; - activities?: string[]; - [key: string]: unknown; -} +import { Recipe, generateDeepLink } from '../../recipe'; interface DeepLinkModalProps { - recipeConfig: RecipeConfig; + recipe: Recipe; onClose: () => void; } -// Function to generate a deep link from a bot config -export function generateDeepLink(recipeConfig: RecipeConfig): string { - const configBase64 = Buffer.from(JSON.stringify(recipeConfig)).toString('base64'); - const urlSafe = encodeURIComponent(configBase64); - return `goose://bot?config=${urlSafe}`; -} - -export function DeepLinkModal({ recipeConfig: initialRecipeConfig, onClose }: DeepLinkModalProps) { - // Create editable state for the bot config - const [recipeConfig, setRecipeConfig] = useState(initialRecipeConfig); - const [instructions, setInstructions] = useState(initialRecipeConfig.instructions || ''); - const [activities, setActivities] = useState(initialRecipeConfig.activities || []); +export function DeepLinkModal({ recipe: initialRecipe, onClose }: DeepLinkModalProps) { + // Create editable state for the recipe + const [recipe, setRecipe] = useState(initialRecipe); + const [instructions, setInstructions] = useState(initialRecipe.instructions || ''); + const [activities, setActivities] = useState(initialRecipe.activities || []); const [activityInput, setActivityInput] = useState(''); + // State for the deep link + const [deepLink, setDeepLink] = useState(''); + const [isGeneratingLink, setIsGeneratingLink] = useState(false); + // Generate the deep link using the current bot config - const deepLink = useMemo(() => { - const currentConfig = { - ...recipeConfig, - instructions, - activities, + useEffect(() => { + let isCancelled = false; + + const generateLink = async () => { + setIsGeneratingLink(true); + try { + const currentConfig = { + ...recipe, + instructions, + activities, + title: recipe.title || 'Generated Recipe', + description: recipe.description || 'Recipe created from chat', + }; + const link = await generateDeepLink(currentConfig); + if (!isCancelled) { + setDeepLink(link); + } + } catch (error) { + console.error('Failed to generate deeplink:', error); + if (!isCancelled) { + setDeepLink('Error generating deeplink'); + } + } finally { + if (!isCancelled) { + setIsGeneratingLink(false); + } + } }; - return generateDeepLink(currentConfig); - }, [recipeConfig, instructions, activities]); + + generateLink(); + + return () => { + isCancelled = true; + }; + }, [recipe, instructions, activities]); // Handle Esc key press useEffect(() => { @@ -55,10 +73,10 @@ export function DeepLinkModal({ recipeConfig: initialRecipeConfig, onClose }: De }; }, [onClose]); - // Update the bot config when instructions or activities change + // Update the recipe when instructions or activities change useEffect(() => { - setRecipeConfig({ - ...recipeConfig, + setRecipe({ + ...recipe, instructions, activities, }); @@ -114,16 +132,21 @@ export function DeepLinkModal({ recipeConfig: initialRecipeConfig, onClose }: De