refactor: Centralise deeplink encode and decode into server (#3489)
This commit is contained in:
Generated
+1
-2
@@ -3491,6 +3491,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"url",
|
"url",
|
||||||
|
"urlencoding",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"uuid",
|
"uuid",
|
||||||
"webbrowser 0.8.15",
|
"webbrowser 0.8.15",
|
||||||
@@ -3571,7 +3572,6 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-appender",
|
"tracing-appender",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"urlencoding",
|
|
||||||
"webbrowser 1.0.4",
|
"webbrowser 1.0.4",
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
@@ -3666,7 +3666,6 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"umya-spreadsheet",
|
"umya-spreadsheet",
|
||||||
"url",
|
"url",
|
||||||
"urlencoding",
|
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"webbrowser 0.8.15",
|
"webbrowser 0.8.15",
|
||||||
"which 6.0.3",
|
"which 6.0.3",
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ http = "1.0"
|
|||||||
webbrowser = "1.0"
|
webbrowser = "1.0"
|
||||||
|
|
||||||
indicatif = "0.17.11"
|
indicatif = "0.17.11"
|
||||||
urlencoding = "2"
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
winapi = { version = "0.3", features = ["wincred"] }
|
winapi = { version = "0.3", features = ["wincred"] }
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use base64::Engine;
|
|
||||||
use console::style;
|
use console::style;
|
||||||
use serde_json;
|
|
||||||
|
|
||||||
use crate::recipes::github_recipe::RecipeSource;
|
use crate::recipes::github_recipe::RecipeSource;
|
||||||
use crate::recipes::recipe::load_recipe_for_validation;
|
use crate::recipes::recipe::load_recipe_for_validation;
|
||||||
use crate::recipes::search_recipe::list_available_recipes;
|
use crate::recipes::search_recipe::list_available_recipes;
|
||||||
|
use goose::recipe_deeplink;
|
||||||
|
|
||||||
/// Validates a recipe file
|
/// Validates a recipe file
|
||||||
///
|
///
|
||||||
@@ -42,21 +41,26 @@ pub fn handle_validate(recipe_name: &str) -> Result<()> {
|
|||||||
pub fn handle_deeplink(recipe_name: &str) -> Result<String> {
|
pub fn handle_deeplink(recipe_name: &str) -> Result<String> {
|
||||||
// Load the recipe file first to validate it
|
// Load the recipe file first to validate it
|
||||||
match load_recipe_for_validation(recipe_name) {
|
match load_recipe_for_validation(recipe_name) {
|
||||||
Ok(recipe) => {
|
Ok(recipe) => match recipe_deeplink::encode(&recipe) {
|
||||||
let mut full_url = String::new();
|
Ok(encoded) => {
|
||||||
if let Ok(recipe_json) = serde_json::to_string(&recipe) {
|
|
||||||
let deeplink = base64::engine::general_purpose::STANDARD.encode(recipe_json);
|
|
||||||
println!(
|
println!(
|
||||||
"{} Generated deeplink for: {}",
|
"{} Generated deeplink for: {}",
|
||||||
style("✓").green().bold(),
|
style("✓").green().bold(),
|
||||||
recipe.title
|
recipe.title
|
||||||
);
|
);
|
||||||
let url_safe = urlencoding::encode(&deeplink);
|
let full_url = format!("goose://recipe?config={}", encoded);
|
||||||
full_url = format!("goose://recipe?config={}", url_safe);
|
|
||||||
println!("{}", full_url);
|
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) => {
|
Err(err) => {
|
||||||
println!("{} {}", style("✗").red().bold(), err);
|
println!("{} {}", style("✗").red().bold(), err);
|
||||||
Err(err)
|
Err(err)
|
||||||
@@ -185,7 +189,10 @@ response:
|
|||||||
|
|
||||||
let result = handle_deeplink(&recipe_path);
|
let result = handle_deeplink(&recipe_path);
|
||||||
assert!(result.is_ok());
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ tracing = "0.1"
|
|||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
tracing-appender = "0.2"
|
tracing-appender = "0.2"
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
urlencoding = "2.1.3"
|
|
||||||
base64 = "0.21"
|
base64 = "0.21"
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
|||||||
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
|
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
|
||||||
use goose::message::Message;
|
use goose::message::Message;
|
||||||
use goose::recipe::Recipe;
|
use goose::recipe::Recipe;
|
||||||
|
use goose::recipe_deeplink;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -34,6 +35,26 @@ pub struct CreateRecipeResponse {
|
|||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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
|
/// Create a Recipe configuration from the current state of an agent
|
||||||
async fn create_recipe(
|
async fn create_recipe(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
@@ -84,8 +105,70 @@ async fn create_recipe(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn encode_recipe(
|
||||||
|
Json(request): Json<EncodeRecipeRequest>,
|
||||||
|
) -> Result<Json<EncodeRecipeResponse>, 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<DecodeRecipeRequest>,
|
||||||
|
) -> Result<Json<DecodeRecipeResponse>, 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<AppState>) -> Router {
|
pub fn routes(state: Arc<AppState>) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/recipe/create", post(create_recipe))
|
.route("/recipe/create", post(create_recipe))
|
||||||
|
.route("/recipes/encode", post(encode_recipe))
|
||||||
|
.route("/recipes/decode", post(decode_recipe))
|
||||||
.with_state(state)
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ nanoid = "0.4"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
base64 = "0.21"
|
base64 = "0.21"
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
|
urlencoding = "2.1"
|
||||||
axum = "0.8.1"
|
axum = "0.8.1"
|
||||||
webbrowser = "0.8"
|
webbrowser = "0.8"
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub mod project;
|
|||||||
pub mod prompt_template;
|
pub mod prompt_template;
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
pub mod recipe;
|
pub mod recipe;
|
||||||
|
pub mod recipe_deeplink;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
pub mod scheduler_factory;
|
pub mod scheduler_factory;
|
||||||
pub mod scheduler_trait;
|
pub mod scheduler_trait;
|
||||||
|
|||||||
@@ -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<String, serde_json::Error> {
|
||||||
|
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<Recipe, DecodeError> {
|
||||||
|
// 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>(&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>(&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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -432,7 +432,7 @@ const RecipeEditorRoute = () => {
|
|||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
const electronConfig = window.electron.getConfig();
|
const electronConfig = window.electron.getConfig();
|
||||||
config = electronConfig.recipeConfig;
|
config = electronConfig.recipe;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <RecipeEditor config={config} />;
|
return <RecipeEditor config={config} />;
|
||||||
@@ -758,7 +758,7 @@ export default function App() {
|
|||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const viewType = urlParams.get('view');
|
const viewType = urlParams.get('view');
|
||||||
const resumeSessionId = urlParams.get('resumeSessionId');
|
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
|
// Check for session resume first - this takes priority over other navigation
|
||||||
if (resumeSessionId) {
|
if (resumeSessionId) {
|
||||||
@@ -979,7 +979,7 @@ export default function App() {
|
|||||||
|
|
||||||
// Handle navigation to pair view for recipe deeplinks after router is ready
|
// Handle navigation to pair view for recipe deeplinks after router is ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const recipeConfig = window.appConfig.get('recipeConfig');
|
const recipeConfig = window.appConfig.get('recipe');
|
||||||
if (
|
if (
|
||||||
recipeConfig &&
|
recipeConfig &&
|
||||||
typeof recipeConfig === 'object' &&
|
typeof recipeConfig === 'object' &&
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Recipe } from '../recipe';
|
import { Recipe, generateDeepLink } from '../recipe';
|
||||||
import { Parameter } from '../recipe/index';
|
import { Parameter } from '../recipe/index';
|
||||||
|
|
||||||
import { Buffer } from 'buffer';
|
|
||||||
import { FullExtensionConfig } from '../extensions';
|
import { FullExtensionConfig } from '../extensions';
|
||||||
import { Geese } from './icons/Geese';
|
import { Geese } from './icons/Geese';
|
||||||
import Copy from './icons/Copy';
|
import Copy from './icons/Copy';
|
||||||
@@ -23,13 +22,6 @@ interface RecipeEditorProps {
|
|||||||
config?: Recipe;
|
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) {
|
export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||||
const { getExtensions } = useConfig();
|
const { getExtensions } = useConfig();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -58,6 +50,9 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
setValue: (value: string) => void;
|
setValue: (value: string) => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
const [deeplink, setDeeplink] = useState('');
|
||||||
|
const [isGeneratingDeeplink, setIsGeneratingDeeplink] = useState(false);
|
||||||
|
|
||||||
// Initialize selected extensions for the recipe from config or localStorage
|
// Initialize selected extensions for the recipe from config or localStorage
|
||||||
const [recipeExtensions] = useState<string[]>(() => {
|
const [recipeExtensions] = useState<string[]>(() => {
|
||||||
// First try to get from localStorage
|
// First try to get from localStorage
|
||||||
@@ -134,7 +129,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
setParameters(allParams);
|
setParameters(allParams);
|
||||||
}, [instructions, prompt]);
|
}, [instructions, prompt]);
|
||||||
|
|
||||||
const getCurrentConfig = (): Recipe => {
|
const getCurrentConfig = useCallback((): Recipe => {
|
||||||
// Transform the internal parameters state into the desired output format.
|
// Transform the internal parameters state into the desired output format.
|
||||||
const formattedParameters = parameters.map((param) => {
|
const formattedParameters = parameters.map((param) => {
|
||||||
const formattedParam: Parameter = {
|
const formattedParam: Parameter = {
|
||||||
@@ -184,7 +179,62 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
console.log('Final config extensions:', config.extensions);
|
console.log('Final config extensions:', config.extensions);
|
||||||
|
|
||||||
return config;
|
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<{
|
const [errors, setErrors] = useState<{
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -217,9 +267,11 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deeplink = generateDeepLink(getCurrentConfig());
|
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
|
if (!deeplink || isGeneratingDeeplink || deeplink === 'Error generating deeplink') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
navigator.clipboard
|
navigator.clipboard
|
||||||
.writeText(deeplink)
|
.writeText(deeplink)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -437,6 +489,9 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
onClick={() => validateForm() && handleCopy()}
|
onClick={() => validateForm() && handleCopy()}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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"
|
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 ? (
|
{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'}`}
|
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%' }}
|
style={{ maxWidth: '500px', width: '100%' }}
|
||||||
>
|
>
|
||||||
{deeplink}
|
{isGeneratingDeeplink
|
||||||
|
? 'Generating deeplink...'
|
||||||
|
: deeplink || 'Click to generate deeplink'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ import { Card } from './ui/card';
|
|||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Skeleton } from './ui/skeleton';
|
import { Skeleton } from './ui/skeleton';
|
||||||
import { MainPanelLayout } from './Layout/MainPanelLayout';
|
import { MainPanelLayout } from './Layout/MainPanelLayout';
|
||||||
import { Recipe } from '../recipe';
|
import { Recipe, decodeRecipe } from '../recipe';
|
||||||
import { Buffer } from 'buffer';
|
|
||||||
import { toastSuccess, toastError } from '../toasts';
|
import { toastSuccess, toastError } from '../toasts';
|
||||||
|
|
||||||
interface RecipesViewProps {
|
interface RecipesViewProps {
|
||||||
@@ -149,7 +148,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Function to parse deeplink and extract recipe
|
// Function to parse deeplink and extract recipe
|
||||||
const parseDeeplink = (deeplink: string): Recipe | null => {
|
const parseDeeplink = async (deeplink: string): Promise<Recipe | null> => {
|
||||||
try {
|
try {
|
||||||
const cleanLink = deeplink.trim();
|
const cleanLink = deeplink.trim();
|
||||||
|
|
||||||
@@ -157,15 +156,12 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) {
|
|||||||
throw new Error('Invalid deeplink format. Expected: goose://recipe?config=...');
|
throw new Error('Invalid deeplink format. Expected: goose://recipe?config=...');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract and decode the base64 config
|
const recipeEncoded = cleanLink.replace('goose://recipe?config=', '');
|
||||||
const configBase64 = cleanLink.replace('goose://recipe?config=', '');
|
|
||||||
|
|
||||||
if (!configBase64) {
|
if (!recipeEncoded) {
|
||||||
throw new Error('No recipe configuration found in deeplink');
|
throw new Error('No recipe configuration found in deeplink');
|
||||||
}
|
}
|
||||||
const urlDecoded = decodeURIComponent(configBase64);
|
const recipe = await decodeRecipe(recipeEncoded);
|
||||||
const configJson = Buffer.from(urlDecoded, 'base64').toString('utf-8');
|
|
||||||
const recipe = JSON.parse(configJson) as Recipe;
|
|
||||||
|
|
||||||
if (!recipe.title || !recipe.description || !recipe.instructions) {
|
if (!recipe.title || !recipe.description || !recipe.instructions) {
|
||||||
throw new Error('Recipe is missing required fields (title, description, instructions)');
|
throw new Error('Recipe is missing required fields (title, description, instructions)');
|
||||||
@@ -185,7 +181,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) {
|
|||||||
|
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
try {
|
try {
|
||||||
const recipe = parseDeeplink(importDeeplink.trim());
|
const recipe = await parseDeeplink(importDeeplink.trim());
|
||||||
|
|
||||||
if (!recipe) {
|
if (!recipe) {
|
||||||
throw new Error('Invalid deeplink or recipe format');
|
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
|
// Auto-generate recipe name when deeplink changes
|
||||||
const handleDeeplinkChange = (value: string) => {
|
const handleDeeplinkChange = async (value: string) => {
|
||||||
setImportDeeplink(value);
|
setImportDeeplink(value);
|
||||||
|
|
||||||
if (value.trim()) {
|
if (value.trim()) {
|
||||||
const recipe = parseDeeplink(value.trim());
|
try {
|
||||||
if (recipe && recipe.title) {
|
const recipe = await parseDeeplink(value.trim());
|
||||||
const suggestedName = generateRecipeFilename(recipe);
|
if (recipe && recipe.title) {
|
||||||
setImportRecipeName(suggestedName);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Recipe } from '../recipe';
|
import { Recipe, generateDeepLink } from '../recipe';
|
||||||
import { Parameter } from '../recipe/index';
|
import { Parameter } from '../recipe/index';
|
||||||
import { Buffer } from 'buffer';
|
|
||||||
import { FullExtensionConfig } from '../extensions';
|
import { FullExtensionConfig } from '../extensions';
|
||||||
import { Geese } from './icons/Geese';
|
import { Geese } from './icons/Geese';
|
||||||
import Copy from './icons/Copy';
|
import Copy from './icons/Copy';
|
||||||
@@ -23,13 +22,6 @@ interface ViewRecipeModalProps {
|
|||||||
config: Recipe;
|
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) {
|
export default function ViewRecipeModal({ isOpen, onClose, config }: ViewRecipeModalProps) {
|
||||||
const { getExtensions } = useConfig();
|
const { getExtensions } = useConfig();
|
||||||
const [recipeConfig] = useState<Recipe | undefined>(config);
|
const [recipeConfig] = useState<Recipe | undefined>(config);
|
||||||
@@ -118,7 +110,7 @@ export default function ViewRecipeModal({ isOpen, onClose, config }: ViewRecipeM
|
|||||||
setParameters(allParams);
|
setParameters(allParams);
|
||||||
}, [instructions, prompt]);
|
}, [instructions, prompt]);
|
||||||
|
|
||||||
const getCurrentConfig = (): Recipe => {
|
const getCurrentConfig = useCallback((): Recipe => {
|
||||||
// Transform the internal parameters state into the desired output format.
|
// Transform the internal parameters state into the desired output format.
|
||||||
const formattedParameters = parameters.map((param) => {
|
const formattedParameters = parameters.map((param) => {
|
||||||
const formattedParam: Parameter = {
|
const formattedParam: Parameter = {
|
||||||
@@ -163,7 +155,17 @@ export default function ViewRecipeModal({ isOpen, onClose, config }: ViewRecipeM
|
|||||||
};
|
};
|
||||||
|
|
||||||
return updatedConfig;
|
return updatedConfig;
|
||||||
};
|
}, [
|
||||||
|
recipeConfig,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
instructions,
|
||||||
|
activities,
|
||||||
|
prompt,
|
||||||
|
parameters,
|
||||||
|
recipeExtensions,
|
||||||
|
extensionOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
const [errors, setErrors] = useState<{
|
const [errors, setErrors] = useState<{
|
||||||
title?: string;
|
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 = () => {
|
const handleCopy = () => {
|
||||||
|
if (!deeplink || isGeneratingDeeplink || deeplink === 'Error generating deeplink') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
navigator.clipboard
|
navigator.clipboard
|
||||||
.writeText(deeplink)
|
.writeText(deeplink)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -430,6 +482,9 @@ export default function ViewRecipeModal({ isOpen, onClose, config }: ViewRecipeM
|
|||||||
onClick={() => validateForm() && handleCopy()}
|
onClick={() => validateForm() && handleCopy()}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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"
|
className="ml-4 p-2 hover:bg-background-default rounded-lg transition-colors flex items-center disabled:opacity-50 disabled:hover:bg-transparent"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
@@ -448,7 +503,9 @@ export default function ViewRecipeModal({ isOpen, onClose, config }: ViewRecipeM
|
|||||||
onClick={() => validateForm() && handleCopy()}
|
onClick={() => validateForm() && handleCopy()}
|
||||||
className={`text-sm truncate font-mono cursor-pointer ${!title.trim() || !description.trim() ? 'text-textDisabled' : 'text-textStandard'}`}
|
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'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { Input } from '../ui/input';
|
|||||||
import { Select } from '../ui/Select';
|
import { Select } from '../ui/Select';
|
||||||
import cronstrue from 'cronstrue';
|
import cronstrue from 'cronstrue';
|
||||||
import * as yaml from 'yaml';
|
import * as yaml from 'yaml';
|
||||||
import { Buffer } from 'buffer';
|
import { Recipe, decodeRecipe } from '../../recipe';
|
||||||
import { Recipe } from '../../recipe';
|
|
||||||
import ClockIcon from '../../assets/clock-icon.svg';
|
import ClockIcon from '../../assets/clock-icon.svg';
|
||||||
|
|
||||||
type FrequencyValue = 'once' | 'every' | 'daily' | 'weekly' | 'monthly';
|
type FrequencyValue = 'once' | 'every' | 'daily' | 'weekly' | 'monthly';
|
||||||
@@ -107,20 +106,19 @@ type SourceType = 'file' | 'deeplink';
|
|||||||
type ExecutionMode = 'background' | 'foreground';
|
type ExecutionMode = 'background' | 'foreground';
|
||||||
|
|
||||||
// Function to parse deep link and extract recipe config
|
// Function to parse deep link and extract recipe config
|
||||||
function parseDeepLink(deepLink: string): Recipe | null {
|
async function parseDeepLink(deepLink: string): Promise<Recipe | null> {
|
||||||
try {
|
try {
|
||||||
const url = new URL(deepLink);
|
const url = new URL(deepLink);
|
||||||
if (url.protocol !== 'goose:' || (url.hostname !== 'bot' && url.hostname !== 'recipe')) {
|
if (url.protocol !== 'goose:' || (url.hostname !== 'bot' && url.hostname !== 'recipe')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const configParam = url.searchParams.get('config');
|
const recipeParam = url.searchParams.get('config');
|
||||||
if (!configParam) {
|
if (!recipeParam) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const configJson = Buffer.from(decodeURIComponent(configParam), 'base64').toString('utf-8');
|
return await decodeRecipe(recipeParam);
|
||||||
return JSON.parse(configJson) as Recipe;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse deep link:', error);
|
console.error('Failed to parse deep link:', error);
|
||||||
return null;
|
return null;
|
||||||
@@ -287,26 +285,33 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
|||||||
const [internalValidationError, setInternalValidationError] = useState<string | null>(null);
|
const [internalValidationError, setInternalValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleDeepLinkChange = useCallback(
|
const handleDeepLinkChange = useCallback(
|
||||||
(value: string) => {
|
async (value: string) => {
|
||||||
setDeepLinkInput(value);
|
setDeepLinkInput(value);
|
||||||
setInternalValidationError(null);
|
setInternalValidationError(null);
|
||||||
|
|
||||||
if (value.trim()) {
|
if (value.trim()) {
|
||||||
const recipe = parseDeepLink(value.trim());
|
try {
|
||||||
if (recipe) {
|
const recipe = await parseDeepLink(value.trim());
|
||||||
setParsedRecipe(recipe);
|
if (recipe) {
|
||||||
// Auto-populate schedule ID from recipe title if available
|
setParsedRecipe(recipe);
|
||||||
if (recipe.title && !scheduleId) {
|
// Auto-populate schedule ID from recipe title if available
|
||||||
const cleanId = recipe.title
|
if (recipe.title && !scheduleId) {
|
||||||
.toLowerCase()
|
const cleanId = recipe.title
|
||||||
.replace(/[^a-z0-9-]/g, '-')
|
.toLowerCase()
|
||||||
.replace(/-+/g, '-');
|
.replace(/[^a-z0-9-]/g, '-')
|
||||||
setScheduleId(cleanId);
|
.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);
|
setParsedRecipe(null);
|
||||||
setInternalValidationError(
|
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 {
|
} else {
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { Card } from '../ui/card';
|
import { Card } from '../ui/card';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import { Recipe } from '../../recipe';
|
import { Recipe, generateDeepLink } from '../../recipe';
|
||||||
import { generateDeepLink } from '../ui/DeepLinkModal';
|
|
||||||
import Copy from '../icons/Copy';
|
import Copy from '../icons/Copy';
|
||||||
import { Check } from 'lucide-react';
|
import { Check } from 'lucide-react';
|
||||||
|
|
||||||
@@ -24,24 +23,29 @@ export const ScheduleFromRecipeModal: React.FC<ScheduleFromRecipeModalProps> = (
|
|||||||
const [deepLink, setDeepLink] = useState('');
|
const [deepLink, setDeepLink] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && recipe) {
|
let isCancelled = false;
|
||||||
// Convert Recipe to the format expected by generateDeepLink
|
|
||||||
const recipeConfig = {
|
const generateLink = async () => {
|
||||||
id: recipe.title?.toLowerCase().replace(/[^a-z0-9-]/g, '-') || 'recipe',
|
if (isOpen && recipe) {
|
||||||
title: recipe.title,
|
try {
|
||||||
description: recipe.description,
|
const link = await generateDeepLink(recipe);
|
||||||
instructions: recipe.instructions,
|
if (!isCancelled) {
|
||||||
activities: recipe.activities || [],
|
setDeepLink(link);
|
||||||
prompt: recipe.prompt,
|
}
|
||||||
extensions: recipe.extensions,
|
} catch (error) {
|
||||||
goosehints: recipe.goosehints,
|
console.error('Failed to generate deeplink:', error);
|
||||||
context: recipe.context,
|
if (!isCancelled) {
|
||||||
profile: recipe.profile,
|
setDeepLink('Error generating deeplink');
|
||||||
author: recipe.author,
|
}
|
||||||
};
|
}
|
||||||
const link = generateDeepLink(recipeConfig);
|
}
|
||||||
setDeepLink(link);
|
};
|
||||||
}
|
|
||||||
|
generateLink();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
}, [isOpen, recipe]);
|
}, [isOpen, recipe]);
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
|
|||||||
@@ -1,42 +1,60 @@
|
|||||||
import React, { useMemo, useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Buffer } from 'buffer';
|
|
||||||
import Copy from '../icons/Copy';
|
import Copy from '../icons/Copy';
|
||||||
import { Card } from './card';
|
import { Card } from './card';
|
||||||
|
import { Recipe, generateDeepLink } from '../../recipe';
|
||||||
interface RecipeConfig {
|
|
||||||
instructions?: string;
|
|
||||||
activities?: string[];
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DeepLinkModalProps {
|
interface DeepLinkModalProps {
|
||||||
recipeConfig: RecipeConfig;
|
recipe: Recipe;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to generate a deep link from a bot config
|
export function DeepLinkModal({ recipe: initialRecipe, onClose }: DeepLinkModalProps) {
|
||||||
export function generateDeepLink(recipeConfig: RecipeConfig): string {
|
// Create editable state for the recipe
|
||||||
const configBase64 = Buffer.from(JSON.stringify(recipeConfig)).toString('base64');
|
const [recipe, setRecipe] = useState(initialRecipe);
|
||||||
const urlSafe = encodeURIComponent(configBase64);
|
const [instructions, setInstructions] = useState(initialRecipe.instructions || '');
|
||||||
return `goose://bot?config=${urlSafe}`;
|
const [activities, setActivities] = useState<string[]>(initialRecipe.activities || []);
|
||||||
}
|
|
||||||
|
|
||||||
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<string[]>(initialRecipeConfig.activities || []);
|
|
||||||
const [activityInput, setActivityInput] = useState('');
|
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
|
// Generate the deep link using the current bot config
|
||||||
const deepLink = useMemo(() => {
|
useEffect(() => {
|
||||||
const currentConfig = {
|
let isCancelled = false;
|
||||||
...recipeConfig,
|
|
||||||
instructions,
|
const generateLink = async () => {
|
||||||
activities,
|
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
|
// Handle Esc key press
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,10 +73,10 @@ export function DeepLinkModal({ recipeConfig: initialRecipeConfig, onClose }: De
|
|||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
// Update the bot config when instructions or activities change
|
// Update the recipe when instructions or activities change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRecipeConfig({
|
setRecipe({
|
||||||
...recipeConfig,
|
...recipe,
|
||||||
instructions,
|
instructions,
|
||||||
activities,
|
activities,
|
||||||
});
|
});
|
||||||
@@ -114,16 +132,21 @@ export function DeepLinkModal({ recipeConfig: initialRecipeConfig, onClose }: De
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={deepLink}
|
value={isGeneratingLink ? 'Generating deeplink...' : deepLink}
|
||||||
readOnly
|
readOnly
|
||||||
className="flex-1 p-3 border border-borderSubtle rounded-l-md bg-transparent text-textStandard"
|
className="flex-1 p-3 border border-borderSubtle rounded-l-md bg-transparent text-textStandard"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(deepLink);
|
if (!isGeneratingLink && deepLink && deepLink !== 'Error generating deeplink') {
|
||||||
window.electron.logInfo('Deep link copied to clipboard');
|
navigator.clipboard.writeText(deepLink);
|
||||||
|
window.electron.logInfo('Deep link copied to clipboard');
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="p-2 bg-blue-500 text-white rounded-r-md hover:bg-blue-600 flex items-center justify-center min-w-[100px]"
|
disabled={
|
||||||
|
isGeneratingLink || !deepLink || deepLink === 'Error generating deeplink'
|
||||||
|
}
|
||||||
|
className="p-2 bg-blue-500 text-white rounded-r-md hover:bg-blue-600 flex items-center justify-center min-w-[100px] disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Copy className="w-5 h-5 mr-1" />
|
<Copy className="w-5 h-5 mr-1" />
|
||||||
Copy
|
Copy
|
||||||
@@ -135,15 +158,13 @@ export function DeepLinkModal({ recipeConfig: initialRecipeConfig, onClose }: De
|
|||||||
<div className="flex mb-6">
|
<div className="flex mb-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Open the deep link with the current bot config
|
// Open the deep link with the current recipe config
|
||||||
const currentConfig = {
|
const currentConfig = {
|
||||||
id: 'deeplink-recipe',
|
...recipe,
|
||||||
name: 'DeepLink Recipe',
|
|
||||||
title: 'DeepLink Recipe',
|
|
||||||
description: 'Recipe from deep link',
|
|
||||||
...recipeConfig,
|
|
||||||
instructions,
|
instructions,
|
||||||
activities,
|
activities,
|
||||||
|
title: recipe.title || 'DeepLink Recipe',
|
||||||
|
description: recipe.description || 'Recipe from deep link',
|
||||||
};
|
};
|
||||||
window.electron.createChatWindow(
|
window.electron.createChatWindow(
|
||||||
undefined,
|
undefined,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to app config (from deeplinks)
|
// Fallback to app config (from deeplinks)
|
||||||
const appRecipeConfig = window.appConfig.get('recipeConfig') as Recipe | null;
|
const appRecipeConfig = window.appConfig.get('recipe') as Recipe | null;
|
||||||
if (appRecipeConfig) {
|
if (appRecipeConfig) {
|
||||||
return appRecipeConfig;
|
return appRecipeConfig;
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
|
|||||||
|
|
||||||
// If we have a recipe from app config (deeplink), persist it
|
// If we have a recipe from app config (deeplink), persist it
|
||||||
// But only if the chat context doesn't explicitly have null (which indicates it was cleared)
|
// But only if the chat context doesn't explicitly have null (which indicates it was cleared)
|
||||||
const appRecipeConfig = window.appConfig.get('recipeConfig') as Recipe | null;
|
const appRecipeConfig = window.appConfig.get('recipe') as Recipe | null;
|
||||||
if (appRecipeConfig && chatContext.chat.recipeConfig === undefined) {
|
if (appRecipeConfig && chatContext.chat.recipeConfig === undefined) {
|
||||||
// Only set if recipeConfig is undefined, not if it's explicitly null
|
// Only set if recipeConfig is undefined, not if it's explicitly null
|
||||||
chatContext.setRecipeConfig(appRecipeConfig);
|
chatContext.setRecipeConfig(appRecipeConfig);
|
||||||
@@ -197,16 +197,6 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
|
|||||||
throw new Error('No recipe data received');
|
throw new Error('No recipe data received');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new window for the recipe editor
|
|
||||||
const recipeConfig = {
|
|
||||||
id: response.recipe.title || 'untitled',
|
|
||||||
title: response.recipe.title || 'Untitled Recipe',
|
|
||||||
description: response.recipe.description || '',
|
|
||||||
instructions: response.recipe.instructions || '',
|
|
||||||
activities: response.recipe.activities || [],
|
|
||||||
prompt: response.recipe.prompt || '',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set a flag to prevent the current window from reacting to recipe config changes
|
// Set a flag to prevent the current window from reacting to recipe config changes
|
||||||
// This prevents navigation conflicts when creating new windows
|
// This prevents navigation conflicts when creating new windows
|
||||||
window.sessionStorage.setItem('ignoreRecipeConfigChanges', 'true');
|
window.sessionStorage.setItem('ignoreRecipeConfigChanges', 'true');
|
||||||
@@ -217,7 +207,7 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
|
|||||||
undefined, // dir
|
undefined, // dir
|
||||||
undefined, // version
|
undefined, // version
|
||||||
undefined, // resumeSessionId
|
undefined, // resumeSessionId
|
||||||
recipeConfig, // recipe config
|
response.recipe, // recipe config
|
||||||
'recipeEditor' // view type
|
'recipeEditor' // view type
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+106
-91
@@ -49,6 +49,34 @@ import {
|
|||||||
getUpdateAvailable,
|
getUpdateAvailable,
|
||||||
} from './utils/autoUpdater';
|
} from './utils/autoUpdater';
|
||||||
import { UPDATES_ENABLED } from './updates';
|
import { UPDATES_ENABLED } from './updates';
|
||||||
|
import { Recipe } from './recipe';
|
||||||
|
|
||||||
|
// API URL constructor for main process before window is ready
|
||||||
|
function getApiUrlMain(endpoint: string, dynamicPort: number): string {
|
||||||
|
const host = process.env.GOOSE_API_HOST || 'http://127.0.0.1';
|
||||||
|
const port = dynamicPort || process.env.GOOSE_PORT;
|
||||||
|
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||||
|
return `${host}:${port}${cleanEndpoint}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When opening the app with a deeplink, the window is still initializing so we have to duplicate some window dependant logic here.
|
||||||
|
async function decodeRecipeMain(deeplink: string, port: number): Promise<Recipe | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(getApiUrlMain('/recipes/decode', port), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ deeplink }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
return data.recipe;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to decode recipe:', error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Updater functions (moved here to keep updates.ts minimal for release replacement)
|
// Updater functions (moved here to keep updates.ts minimal for release replacement)
|
||||||
function shouldSetupUpdater(): boolean {
|
function shouldSetupUpdater(): boolean {
|
||||||
@@ -171,31 +199,24 @@ if (process.platform === 'win32') {
|
|||||||
|
|
||||||
// If it's a bot/recipe URL, handle it directly by creating a new window
|
// If it's a bot/recipe URL, handle it directly by creating a new window
|
||||||
if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') {
|
if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') {
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(async () => {
|
||||||
const recentDirs = loadRecentDirs();
|
const recentDirs = loadRecentDirs();
|
||||||
const openDir = recentDirs.length > 0 ? recentDirs[0] : null;
|
const openDir = recentDirs.length > 0 ? recentDirs[0] : null;
|
||||||
|
|
||||||
let recipeConfig = null;
|
const recipeDeeplink = parsedUrl.searchParams.get('config');
|
||||||
const configParam = parsedUrl.searchParams.get('config');
|
const scheduledJobId = parsedUrl.searchParams.get('scheduledJob');
|
||||||
if (configParam) {
|
|
||||||
try {
|
|
||||||
recipeConfig = JSON.parse(
|
|
||||||
Buffer.from(decodeURIComponent(configParam), 'base64').toString('utf-8')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if this is a scheduled job
|
createChat(
|
||||||
const scheduledJobId = parsedUrl.searchParams.get('scheduledJob');
|
app,
|
||||||
if (scheduledJobId) {
|
undefined,
|
||||||
console.log(`[main] Opening scheduled job: ${scheduledJobId}`);
|
openDir || undefined,
|
||||||
recipeConfig.scheduledJobId = scheduledJobId;
|
undefined,
|
||||||
recipeConfig.isScheduledExecution = true;
|
undefined,
|
||||||
}
|
undefined,
|
||||||
} catch (e) {
|
undefined,
|
||||||
console.error('Failed to parse bot config:', e);
|
recipeDeeplink || undefined,
|
||||||
}
|
scheduledJobId || undefined
|
||||||
}
|
);
|
||||||
|
|
||||||
createChat(app, undefined, openDir || undefined, undefined, undefined, recipeConfig);
|
|
||||||
});
|
});
|
||||||
return; // Skip the rest of the handler
|
return; // Skip the rest of the handler
|
||||||
}
|
}
|
||||||
@@ -244,7 +265,7 @@ async function handleProtocolUrl(url: string) {
|
|||||||
existingWindows.length > 0
|
existingWindows.length > 0
|
||||||
? existingWindows[0]
|
? existingWindows[0]
|
||||||
: await createChat(app, undefined, openDir || undefined);
|
: await createChat(app, undefined, openDir || undefined);
|
||||||
processProtocolUrl(parsedUrl, targetWindow);
|
await processProtocolUrl(parsedUrl, targetWindow);
|
||||||
} else {
|
} else {
|
||||||
// For other URL types, reuse existing window if available
|
// For other URL types, reuse existing window if available
|
||||||
const existingWindows = BrowserWindow.getAllWindows();
|
const existingWindows = BrowserWindow.getAllWindows();
|
||||||
@@ -261,17 +282,17 @@ async function handleProtocolUrl(url: string) {
|
|||||||
if (firstOpenWindow) {
|
if (firstOpenWindow) {
|
||||||
const webContents = firstOpenWindow.webContents;
|
const webContents = firstOpenWindow.webContents;
|
||||||
if (webContents.isLoadingMainFrame()) {
|
if (webContents.isLoadingMainFrame()) {
|
||||||
webContents.once('did-finish-load', () => {
|
webContents.once('did-finish-load', async () => {
|
||||||
processProtocolUrl(parsedUrl, firstOpenWindow);
|
await processProtocolUrl(parsedUrl, firstOpenWindow);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
processProtocolUrl(parsedUrl, firstOpenWindow);
|
await processProtocolUrl(parsedUrl, firstOpenWindow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function processProtocolUrl(parsedUrl: URL, window: BrowserWindow) {
|
async function processProtocolUrl(parsedUrl: URL, window: BrowserWindow) {
|
||||||
const recentDirs = loadRecentDirs();
|
const recentDirs = loadRecentDirs();
|
||||||
const openDir = recentDirs.length > 0 ? recentDirs[0] : null;
|
const openDir = recentDirs.length > 0 ? recentDirs[0] : null;
|
||||||
|
|
||||||
@@ -280,27 +301,21 @@ function processProtocolUrl(parsedUrl: URL, window: BrowserWindow) {
|
|||||||
} else if (parsedUrl.hostname === 'sessions') {
|
} else if (parsedUrl.hostname === 'sessions') {
|
||||||
window.webContents.send('open-shared-session', pendingDeepLink);
|
window.webContents.send('open-shared-session', pendingDeepLink);
|
||||||
} else if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') {
|
} else if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') {
|
||||||
let recipeConfig = null;
|
const recipeDeeplink = parsedUrl.searchParams.get('config');
|
||||||
const configParam = parsedUrl.searchParams.get('config');
|
const scheduledJobId = parsedUrl.searchParams.get('scheduledJob');
|
||||||
if (configParam) {
|
|
||||||
try {
|
|
||||||
recipeConfig = JSON.parse(
|
|
||||||
Buffer.from(decodeURIComponent(configParam), 'base64').toString('utf-8')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if this is a scheduled job
|
|
||||||
const scheduledJobId = parsedUrl.searchParams.get('scheduledJob');
|
|
||||||
if (scheduledJobId) {
|
|
||||||
console.log(`[main] Opening scheduled job: ${scheduledJobId}`);
|
|
||||||
recipeConfig.scheduledJobId = scheduledJobId;
|
|
||||||
recipeConfig.isScheduledExecution = true;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse bot config:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Create a new window and ignore the passed-in window
|
// Create a new window and ignore the passed-in window
|
||||||
createChat(app, undefined, openDir || undefined, undefined, undefined, recipeConfig);
|
createChat(
|
||||||
|
app,
|
||||||
|
undefined,
|
||||||
|
openDir || undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
recipeDeeplink || undefined,
|
||||||
|
scheduledJobId || undefined
|
||||||
|
);
|
||||||
}
|
}
|
||||||
pendingDeepLink = null;
|
pendingDeepLink = null;
|
||||||
}
|
}
|
||||||
@@ -312,28 +327,24 @@ app.on('open-url', async (_event, url) => {
|
|||||||
const openDir = recentDirs.length > 0 ? recentDirs[0] : null;
|
const openDir = recentDirs.length > 0 ? recentDirs[0] : null;
|
||||||
|
|
||||||
// Handle bot/recipe URLs by directly creating a new window
|
// Handle bot/recipe URLs by directly creating a new window
|
||||||
|
console.log('[Main] Received open-url event:', url);
|
||||||
if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') {
|
if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') {
|
||||||
let recipeConfig = null;
|
console.log('[Main] Detected bot/recipe URL, creating new chat window');
|
||||||
const configParam = parsedUrl.searchParams.get('config');
|
const recipeDeeplink = parsedUrl.searchParams.get('config');
|
||||||
const base64 = decodeURIComponent(configParam || '');
|
const scheduledJobId = parsedUrl.searchParams.get('scheduledJob');
|
||||||
if (configParam) {
|
|
||||||
try {
|
|
||||||
recipeConfig = JSON.parse(Buffer.from(base64, 'base64').toString('utf-8'));
|
|
||||||
|
|
||||||
// Check if this is a scheduled job
|
|
||||||
const scheduledJobId = parsedUrl.searchParams.get('scheduledJob');
|
|
||||||
if (scheduledJobId) {
|
|
||||||
console.log(`[main] Opening scheduled job: ${scheduledJobId}`);
|
|
||||||
recipeConfig.scheduledJobId = scheduledJobId;
|
|
||||||
recipeConfig.isScheduledExecution = true;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse bot config:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new window directly
|
// Create a new window directly
|
||||||
await createChat(app, undefined, openDir || undefined, undefined, undefined, recipeConfig);
|
await createChat(
|
||||||
|
app,
|
||||||
|
undefined,
|
||||||
|
openDir || undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
recipeDeeplink || undefined,
|
||||||
|
scheduledJobId || undefined
|
||||||
|
);
|
||||||
return; // Skip the rest of the handler
|
return; // Skip the rest of the handler
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,23 +508,16 @@ const windowMap = new Map<number, BrowserWindow>();
|
|||||||
// Track power save blocker ID globally
|
// Track power save blocker ID globally
|
||||||
let powerSaveBlockerId: number | null = null;
|
let powerSaveBlockerId: number | null = null;
|
||||||
|
|
||||||
interface RecipeConfig {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
instructions: string;
|
|
||||||
activities: string[];
|
|
||||||
prompt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createChat = async (
|
const createChat = async (
|
||||||
app: App,
|
app: App,
|
||||||
query?: string,
|
query?: string,
|
||||||
dir?: string,
|
dir?: string,
|
||||||
_version?: string,
|
_version?: string,
|
||||||
resumeSessionId?: string,
|
resumeSessionId?: string,
|
||||||
recipeConfig?: RecipeConfig, // Bot configuration
|
recipe?: Recipe, // Recipe configuration when already loaded, takes precedence over deeplink
|
||||||
viewType?: string // View type
|
viewType?: string,
|
||||||
|
recipeDeeplink?: string, // Raw deeplink used as a fallback when recipe is not loaded. Required on new windows as we need to wait for the window to load before decoding.
|
||||||
|
scheduledJobId?: string // Scheduled job ID if applicable
|
||||||
) => {
|
) => {
|
||||||
// Initialize variables for process and configuration
|
// Initialize variables for process and configuration
|
||||||
let port = 0;
|
let port = 0;
|
||||||
@@ -560,6 +564,20 @@ const createChat = async (
|
|||||||
goosedProcess = newGoosedProcess;
|
goosedProcess = newGoosedProcess;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode recipe from deeplink if needed
|
||||||
|
if (!recipe && recipeDeeplink) {
|
||||||
|
const decodedRecipe = await decodeRecipeMain(recipeDeeplink, port);
|
||||||
|
if (decodedRecipe) {
|
||||||
|
recipe = decodedRecipe;
|
||||||
|
|
||||||
|
// Handle scheduled job parameters if present
|
||||||
|
if (scheduledJobId) {
|
||||||
|
recipe.scheduledJobId = scheduledJobId;
|
||||||
|
recipe.isScheduledExecution = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load and manage window state
|
// Load and manage window state
|
||||||
const mainWindowState = windowStateKeeper({
|
const mainWindowState = windowStateKeeper({
|
||||||
defaultWidth: 940, // large enough to show the sidebar on launch
|
defaultWidth: 940, // large enough to show the sidebar on launch
|
||||||
@@ -594,7 +612,7 @@ const createChat = async (
|
|||||||
REQUEST_DIR: dir,
|
REQUEST_DIR: dir,
|
||||||
GOOSE_BASE_URL_SHARE: sharingUrl,
|
GOOSE_BASE_URL_SHARE: sharingUrl,
|
||||||
GOOSE_VERSION: gooseVersion,
|
GOOSE_VERSION: gooseVersion,
|
||||||
recipeConfig: recipeConfig,
|
recipe: recipe,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
partition: 'persist:goose', // Add this line to ensure persistence
|
partition: 'persist:goose', // Add this line to ensure persistence
|
||||||
@@ -648,7 +666,7 @@ const createChat = async (
|
|||||||
GOOSE_WORKING_DIR: working_dir,
|
GOOSE_WORKING_DIR: working_dir,
|
||||||
REQUEST_DIR: dir,
|
REQUEST_DIR: dir,
|
||||||
GOOSE_BASE_URL_SHARE: sharingUrl,
|
GOOSE_BASE_URL_SHARE: sharingUrl,
|
||||||
recipeConfig: recipeConfig,
|
recipe: recipe,
|
||||||
};
|
};
|
||||||
|
|
||||||
// We need to wait for the window to load before we can access localStorage
|
// We need to wait for the window to load before we can access localStorage
|
||||||
@@ -1873,21 +1891,18 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on(
|
ipcMain.on('create-chat-window', (_, query, dir, version, resumeSessionId, recipe, viewType) => {
|
||||||
'create-chat-window',
|
if (!dir?.trim()) {
|
||||||
(_, query, dir, version, resumeSessionId, recipeConfig, viewType) => {
|
const recentDirs = loadRecentDirs();
|
||||||
if (!dir?.trim()) {
|
dir = recentDirs.length > 0 ? recentDirs[0] : undefined;
|
||||||
const recentDirs = loadRecentDirs();
|
|
||||||
dir = recentDirs.length > 0 ? recentDirs[0] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the recipeConfig for debugging
|
|
||||||
console.log('Creating chat window with recipeConfig:', recipeConfig);
|
|
||||||
|
|
||||||
// Pass recipeConfig as part of viewOptions when viewType is recipeEditor
|
|
||||||
createChat(app, query, dir, version, resumeSessionId, recipeConfig, viewType);
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
// Log the recipe for debugging
|
||||||
|
console.log('Creating chat window with recipe:', recipe);
|
||||||
|
|
||||||
|
// Pass recipe as part of viewOptions when viewType is recipeEditor
|
||||||
|
createChat(app, query, dir, version, resumeSessionId, recipe, viewType);
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.on('notify', (_event, data) => {
|
ipcMain.on('notify', (_event, data) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import Electron, { contextBridge, ipcRenderer, webUtils } from 'electron';
|
import Electron, { contextBridge, ipcRenderer, webUtils } from 'electron';
|
||||||
import { Recipe } from './recipe';
|
import { Recipe } from './recipe';
|
||||||
|
|
||||||
// RecipeConfig is used for window creation and should match Recipe interface
|
|
||||||
type RecipeConfig = Recipe;
|
|
||||||
|
|
||||||
interface NotificationData {
|
interface NotificationData {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
@@ -55,7 +52,7 @@ type ElectronAPI = {
|
|||||||
dir?: string,
|
dir?: string,
|
||||||
version?: string,
|
version?: string,
|
||||||
resumeSessionId?: string,
|
resumeSessionId?: string,
|
||||||
recipeConfig?: RecipeConfig,
|
recipe?: Recipe,
|
||||||
viewType?: string
|
viewType?: string
|
||||||
) => void;
|
) => void;
|
||||||
logInfo: (txt: string) => void;
|
logInfo: (txt: string) => void;
|
||||||
@@ -139,18 +136,10 @@ const electronAPI: ElectronAPI = {
|
|||||||
dir?: string,
|
dir?: string,
|
||||||
version?: string,
|
version?: string,
|
||||||
resumeSessionId?: string,
|
resumeSessionId?: string,
|
||||||
recipeConfig?: RecipeConfig,
|
recipe?: Recipe,
|
||||||
viewType?: string
|
viewType?: string
|
||||||
) =>
|
) =>
|
||||||
ipcRenderer.send(
|
ipcRenderer.send('create-chat-window', query, dir, version, resumeSessionId, recipe, viewType),
|
||||||
'create-chat-window',
|
|
||||||
query,
|
|
||||||
dir,
|
|
||||||
version,
|
|
||||||
resumeSessionId,
|
|
||||||
recipeConfig,
|
|
||||||
viewType
|
|
||||||
),
|
|
||||||
logInfo: (txt: string) => ipcRenderer.send('logInfo', txt),
|
logInfo: (txt: string) => ipcRenderer.send('logInfo', txt),
|
||||||
showNotification: (data: NotificationData) => ipcRenderer.send('notify', data),
|
showNotification: (data: NotificationData) => ipcRenderer.send('notify', data),
|
||||||
showMessageBox: (options: MessageBoxOptions) => ipcRenderer.invoke('show-message-box', options),
|
showMessageBox: (options: MessageBoxOptions) => ipcRenderer.invoke('show-message-box', options),
|
||||||
|
|||||||
@@ -72,3 +72,71 @@ export async function createRecipe(request: CreateRecipeRequest): Promise<Create
|
|||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EncodeRecipeRequest {
|
||||||
|
recipe: Recipe;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncodeRecipeResponse {
|
||||||
|
deeplink: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecodeRecipeRequest {
|
||||||
|
deeplink: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecodeRecipeResponse {
|
||||||
|
recipe: Recipe;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encodeRecipe(recipe: Recipe): Promise<string> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: EncodeRecipeResponse = await response.json();
|
||||||
|
return data.deeplink;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decodeRecipe(deeplink: string): Promise<Recipe> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
return data.recipe;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateDeepLink(recipe: Recipe): Promise<string> {
|
||||||
|
const encoded = await encodeRecipe(recipe);
|
||||||
|
return `goose://recipe?config=${encoded}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export const updateSystemPromptWithParameters = async (
|
|||||||
recipeParameters: Record<string, string>
|
recipeParameters: Record<string, string>
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const recipeConfig = window.appConfig?.get?.('recipeConfig');
|
const recipeConfig = window.appConfig?.get?.('recipe');
|
||||||
const originalInstructions = (recipeConfig as { instructions?: string })?.instructions;
|
const originalInstructions = (recipeConfig as { instructions?: string })?.instructions;
|
||||||
|
|
||||||
if (!originalInstructions) {
|
if (!originalInstructions) {
|
||||||
@@ -189,7 +189,7 @@ export const initializeSystem = async (
|
|||||||
await initializeAgent({ provider, model });
|
await initializeAgent({ provider, model });
|
||||||
|
|
||||||
// Get recipeConfig directly here
|
// Get recipeConfig directly here
|
||||||
const recipeConfig = window.appConfig?.get?.('recipeConfig');
|
const recipeConfig = window.appConfig?.get?.('recipe');
|
||||||
const botPrompt = (recipeConfig as { instructions?: string })?.instructions;
|
const botPrompt = (recipeConfig as { instructions?: string })?.instructions;
|
||||||
const responseConfig = (recipeConfig as { response?: { json_schema?: unknown } })?.response;
|
const responseConfig = (recipeConfig as { response?: { json_schema?: unknown } })?.response;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user