Files
tkmind_go/ui/desktop/src/components/recipes/ImportRecipeForm.tsx
T

392 lines
15 KiB
TypeScript

import { useState } from 'react';
import { useForm } from '@tanstack/react-form';
import { z } from 'zod';
import { Download } from 'lucide-react';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Recipe, decodeRecipe } from '../../recipe';
import { toastSuccess, toastError } from '../../toasts';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { getRecipeJsonSchema } from '../../recipe/validation';
import { saveRecipe } from '../../recipe/recipe_management';
import { parseRecipe } from '../../api';
interface ImportRecipeFormProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
// Define Zod schema for the import form
const importRecipeSchema = z
.object({
deeplink: z
.string()
.refine(
(value) => !value || value.trim().startsWith('goose://recipe?config='),
'Invalid deeplink format. Expected: goose://recipe?config=...'
),
recipeUploadFile: z
.instanceof(File)
.nullable()
.refine((file) => {
if (!file) return true;
return file.size <= 1024 * 1024;
}, 'File is too large, max size is 1MB'),
})
.refine((data) => (data.deeplink && data.deeplink.trim()) || data.recipeUploadFile, {
message: 'Either of deeplink or recipe file are required',
path: ['deeplink'],
});
export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportRecipeFormProps) {
const [importing, setImporting] = useState(false);
const [showSchemaModal, setShowSchemaModal] = useState(false);
useEscapeKey(isOpen, onClose);
const parseDeeplink = async (deeplink: string): Promise<Recipe | null> => {
try {
const cleanLink = deeplink.trim();
if (!cleanLink.startsWith('goose://recipe?config=')) {
throw new Error('Invalid deeplink format. Expected: goose://recipe?config=...');
}
const recipeEncoded = cleanLink.replace('goose://recipe?config=', '');
if (!recipeEncoded) {
throw new Error('No recipe configuration found in deeplink');
}
const recipe = await decodeRecipe(recipeEncoded);
if (!recipe.title || !recipe.description) {
throw new Error('Recipe is missing required fields (title, description)');
}
if (!recipe.instructions && !recipe.prompt) {
throw new Error('Recipe must have either instructions or prompt');
}
return recipe;
} catch (error) {
console.error('Failed to parse deeplink:', error);
return null;
}
};
const parseRecipeFromFile = async (fileContent: string): Promise<Recipe> => {
try {
let response = await parseRecipe({
body: {
content: fileContent,
},
throwOnError: true,
});
return response.data.recipe;
} catch (error) {
let error_message = 'unknown error';
if (typeof error === 'object' && error !== null && 'message' in error) {
error_message = error.message as string;
}
throw new Error(error_message);
}
};
const importRecipeForm = useForm({
defaultValues: {
deeplink: '',
recipeUploadFile: null as File | null,
},
validators: {
onChange: importRecipeSchema,
},
onSubmit: async ({ value }) => {
setImporting(true);
try {
let recipe: Recipe;
// Parse recipe from either deeplink or recipe file
if (value.deeplink && value.deeplink.trim()) {
const parsedRecipe = await parseDeeplink(value.deeplink.trim());
if (!parsedRecipe) {
throw new Error('Invalid deeplink or recipe format');
}
recipe = parsedRecipe;
} else {
const fileContent = await value.recipeUploadFile!.text();
recipe = await parseRecipeFromFile(fileContent);
}
await saveRecipe(recipe, null);
// Reset dialog state
importRecipeForm.reset({
deeplink: '',
recipeUploadFile: null,
});
onClose();
onSuccess();
toastSuccess({
title: recipe.title.trim(),
msg: 'Recipe imported successfully',
});
} catch (error) {
console.error('Failed to import recipe:', error);
toastError({
title: 'Import Failed',
msg: `Failed to import recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
traceback: error instanceof Error ? error.message : String(error),
});
} finally {
setImporting(false);
}
},
});
const handleClose = () => {
importRecipeForm.reset({
deeplink: '',
recipeUploadFile: null,
});
onClose();
};
const handleDeeplinkChange = async (
value: string,
field: { handleChange: (value: string) => void }
) => {
field.handleChange(value);
if (value.trim()) {
try {
await parseDeeplink(value.trim());
} catch (error) {
toastError({
title: 'Invalid Deeplink',
msg: `The deeplink format is invalid: ${error instanceof Error ? error.message : 'Unknown error'}`,
});
}
}
};
const handleRecipeUploadChange = async (file: File | undefined) => {
importRecipeForm.setFieldValue('recipeUploadFile', file || null);
if (file) {
try {
const fileContent = await file.text();
await parseRecipeFromFile(fileContent);
} catch (error) {
toastError({
title: 'Invalid Recipe File',
msg: error instanceof Error ? error.message : 'Unknown error',
});
}
}
};
if (!isOpen) return null;
return (
<>
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[500px] max-w-[90vw]">
<h3 className="text-lg font-medium text-text-standard mb-4">Import Recipe</h3>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
importRecipeForm.handleSubmit();
}}
>
<div className="space-y-4">
<importRecipeForm.Subscribe selector={(state) => state.values}>
{(values) => (
<>
<importRecipeForm.Field name="deeplink">
{(field) => {
const isDisabled = values.recipeUploadFile !== null;
return (
<div className={isDisabled ? 'opacity-50' : ''}>
<label
htmlFor="import-deeplink"
className="block text-sm font-medium text-text-standard mb-2"
>
Recipe Deeplink
</label>
<textarea
id="import-deeplink"
value={field.state.value}
onChange={(e) => handleDeeplinkChange(e.target.value, field)}
onBlur={field.handleBlur}
disabled={isDisabled}
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none ${
field.state.meta.errors.length > 0
? 'border-red-500'
: 'border-border-subtle'
} ${isDisabled ? 'cursor-not-allowed bg-gray-40 text-gray-300' : ''}`}
placeholder="Paste your goose://recipe?config=... deeplink here"
rows={3}
autoFocus={!isDisabled}
/>
<p
className={`text-xs mt-1 ${isDisabled ? 'text-gray-300' : 'text-text-muted'}`}
>
Paste a recipe deeplink starting with "goose://recipe?config="
</p>
{field.state.meta.errors.length > 0 && (
<p className="text-red-500 text-sm mt-1">
{typeof field.state.meta.errors[0] === 'string'
? field.state.meta.errors[0]
: field.state.meta.errors[0]?.message ||
String(field.state.meta.errors[0])}
</p>
)}
</div>
);
}}
</importRecipeForm.Field>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border-subtle" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-3 bg-background-default text-text-muted font-medium">
OR
</span>
</div>
</div>
<importRecipeForm.Field name="recipeUploadFile">
{(field) => {
const hasDeeplink = values.deeplink?.trim();
const isDisabled = !!hasDeeplink;
return (
<div className={isDisabled ? 'opacity-50' : ''}>
<label
htmlFor="import-recipe-file"
className="block text-sm font-medium text-text-standard mb-3"
>
Recipe File
</label>
<div className="relative">
<Input
id="import-recipe-file"
type="file"
accept=".yaml,.yml,.json"
disabled={isDisabled}
onChange={(e) => {
handleRecipeUploadChange(e.target.files?.[0]);
}}
onBlur={field.handleBlur}
className={`file:pt-1 ${field.state.meta.errors.length > 0 ? 'border-red-500' : ''} ${
isDisabled ? 'cursor-not-allowed' : ''
}`}
/>
</div>
<div className="flex items-center justify-between">
<p
className={`text-xs mt-1 ${isDisabled ? 'text-gray-300' : 'text-text-muted'}`}
>
Upload a YAML or JSON file containing the recipe structure
</p>
<button
type="button"
onClick={() => setShowSchemaModal(true)}
className="text-xs text-blue-500 hover:text-blue-700 underline"
disabled={isDisabled}
>
example
</button>
</div>
{field.state.meta.errors.length > 0 && (
<p className="text-red-500 text-sm mt-1">
{typeof field.state.meta.errors[0] === 'string'
? field.state.meta.errors[0]
: field.state.meta.errors[0]?.message ||
String(field.state.meta.errors[0])}
</p>
)}
</div>
);
}}
</importRecipeForm.Field>
</>
)}
</importRecipeForm.Subscribe>
<p className="text-xs text-text-muted">
Ensure you review contents of recipe files before adding them to your goose
interface.
</p>
</div>
<div className="flex justify-end space-x-3 mt-6">
<Button type="button" onClick={handleClose} variant="ghost" disabled={importing}>
Cancel
</Button>
<importRecipeForm.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
>
{([canSubmit, isSubmitting]) => (
<Button
type="submit"
disabled={!canSubmit || importing || isSubmitting}
variant="default"
>
{importing || isSubmitting ? 'Importing...' : 'Import Recipe'}
</Button>
)}
</importRecipeForm.Subscribe>
</div>
</form>
</div>
</div>
{/* Schema Modal */}
{showSchemaModal && (
<div className="fixed inset-0 z-[400] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[800px] max-w-[90vw] max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-text-standard">Expected Recipe Structure</h3>
<button
type="button"
onClick={() => setShowSchemaModal(false)}
className="text-text-muted hover:text-text-standard"
>
</button>
</div>
<p className="mt-4 text-blue-700 text-sm">
Your YAML or JSON file should follow this structure. Required fields are: title,
description, and either instructions or prompt.
</p>
<div className="flex-1 overflow-auto">
<pre className="text-xs bg-whitedark:bg-gray-800 p-4 rounded overflow-auto whitespace-pre font-mono">
{JSON.stringify(getRecipeJsonSchema(), null, 2)}
</pre>
</div>
</div>
</div>
)}
</>
);
}
export function ImportRecipeButton({ onClick }: { onClick: () => void }) {
return (
<Button onClick={onClick} variant="default" size="sm" className="flex items-center gap-2">
<Download className="w-4 h-4" />
Import Recipe
</Button>
);
}