refactor: Centralise deeplink encode and decode into server (#3489)

This commit is contained in:
Jarrod Sibbison
2025-07-20 18:13:55 +10:00
committed by GitHub
parent ef496329b9
commit e2e656c012
20 changed files with 667 additions and 263 deletions
+72 -15
View File
@@ -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<string[]>(() => {
// 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'}
</div>
</div>
)}
+16 -15
View File
@@ -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<Recipe | null> => {
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);
}
}
};
+71 -14
View File
@@ -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<Recipe | undefined>(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'}
</div>
)}
</div>
@@ -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<Recipe | null> {
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<CreateScheduleModalProps> = ({
const [internalValidationError, setInternalValidationError] = useState<string | null>(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 {
@@ -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<ScheduleFromRecipeModalProps> = (
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 = () => {
+62 -41
View File
@@ -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<string[]>(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<string[]>(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
<div className="flex items-center">
<input
type="text"
value={deepLink}
value={isGeneratingLink ? 'Generating deeplink...' : deepLink}
readOnly
className="flex-1 p-3 border border-borderSubtle rounded-l-md bg-transparent text-textStandard"
/>
<button
onClick={() => {
navigator.clipboard.writeText(deepLink);
window.electron.logInfo('Deep link copied to clipboard');
if (!isGeneratingLink && deepLink && deepLink !== 'Error generating deeplink') {
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
@@ -135,15 +158,13 @@ export function DeepLinkModal({ recipeConfig: initialRecipeConfig, onClose }: De
<div className="flex mb-6">
<button
onClick={() => {
// Open the deep link with the current bot config
// Open the deep link with the current recipe config
const currentConfig = {
id: 'deeplink-recipe',
name: 'DeepLink Recipe',
title: 'DeepLink Recipe',
description: 'Recipe from deep link',
...recipeConfig,
...recipe,
instructions,
activities,
title: recipe.title || 'DeepLink Recipe',
description: recipe.description || 'Recipe from deep link',
};
window.electron.createChatWindow(
undefined,