mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat: view/edit existing recipe in Desktop (#2670)
This commit is contained in:
Generated
+11
@@ -67,6 +67,7 @@
|
||||
"@hey-api/openapi-ts": "^0.64.4",
|
||||
"@modelcontextprotocol/sdk": "^1.8.0",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/electron": "^1.4.38",
|
||||
@@ -4585,6 +4586,16 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/line-clamp": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz",
|
||||
"integrity": "sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"@hey-api/openapi-ts": "^0.64.4",
|
||||
"@modelcontextprotocol/sdk": "^1.8.0",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/electron": "^1.4.38",
|
||||
|
||||
@@ -527,23 +527,7 @@ export default function App() {
|
||||
)}
|
||||
{view === 'recipeEditor' && (
|
||||
<RecipeEditor
|
||||
key={viewOptions?.config ? 'with-config' : 'no-config'}
|
||||
config={viewOptions?.config || window.electron.getConfig().recipeConfig}
|
||||
onClose={() => setView('chat')}
|
||||
setView={setView}
|
||||
onSave={(config) => {
|
||||
console.log('Saving recipe config:', config);
|
||||
window.electron.createChatWindow(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
config,
|
||||
'recipeEditor',
|
||||
{ config }
|
||||
);
|
||||
setView('chat');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{view === 'permission' && (
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function RecipeActivityEditor({
|
||||
activities,
|
||||
setActivities,
|
||||
}: {
|
||||
activities: string[];
|
||||
setActivities: (prev: string[]) => void;
|
||||
}) {
|
||||
const [newActivity, setNewActivity] = useState('');
|
||||
const handleAddActivity = () => {
|
||||
if (newActivity.trim()) {
|
||||
setActivities([...activities, newActivity.trim()]);
|
||||
setNewActivity('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveActivity = (activity: string) => {
|
||||
setActivities(activities.filter((a) => a !== activity));
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="activities" className="block text-md text-textProminent mb-2 font-bold">
|
||||
Activities
|
||||
</label>
|
||||
<p className="text-textSubtle space-y-2 pb-2">
|
||||
The top-line prompts and activities that will display within your goose home page.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{activities.map((activity, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="inline-flex items-center bg-bgApp border-2 border-borderSubtle rounded-full px-4 py-2 text-sm text-textStandard"
|
||||
title={activity.length > 100 ? activity : undefined}
|
||||
>
|
||||
<span>{activity.length > 100 ? activity.slice(0, 100) + '...' : activity}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveActivity(activity)}
|
||||
className="ml-2 text-textStandard hover:text-textSubtle transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-3 mt-6">
|
||||
<input
|
||||
type="text"
|
||||
value={newActivity}
|
||||
onChange={(e) => setNewActivity(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddActivity()}
|
||||
className="flex-1 px-4 py-3 border rounded-lg bg-bgApp text-textStandard placeholder-textPlaceholder focus:outline-none focus:ring-2 focus:ring-borderProminent"
|
||||
placeholder="Add new activity..."
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddActivity}
|
||||
className="px-5 py-1.5 text-sm bg-bgAppInverse text-textProminentInverse rounded-xl hover:bg-bgStandardInverse transition-colors"
|
||||
>
|
||||
Add activity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Recipe } from '../recipe';
|
||||
import { Buffer } from 'buffer';
|
||||
import { FullExtensionConfig } from '../extensions';
|
||||
import { ChevronRight } from './icons/ChevronRight';
|
||||
import Back from './icons/Back';
|
||||
import { Bars } from './icons/Bars';
|
||||
import { Geese } from './icons/Geese';
|
||||
import Copy from './icons/Copy';
|
||||
import { Check } from 'lucide-react';
|
||||
import { useConfig } from './ConfigContext';
|
||||
import { FixedExtensionEntry } from './ConfigContext';
|
||||
import RecipeActivityEditor from './RecipeActivityEditor';
|
||||
import RecipeInfoModal from './RecipeInfoModal';
|
||||
import RecipeExpandableInfo from './RecipeExpandableInfo';
|
||||
// import ExtensionList from './settings_v2/extensions/subcomponents/ExtensionList';
|
||||
|
||||
interface RecipeEditorProps {
|
||||
@@ -28,10 +28,17 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
const [title, setTitle] = useState(config?.title || '');
|
||||
const [description, setDescription] = useState(config?.description || '');
|
||||
const [instructions, setInstructions] = useState(config?.instructions || '');
|
||||
const [prompt, setPrompt] = useState(config?.prompt || '');
|
||||
const [activities, setActivities] = useState<string[]>(config?.activities || []);
|
||||
const [extensionOptions, setExtensionOptions] = useState<FixedExtensionEntry[]>([]);
|
||||
const [extensionsLoaded, setExtensionsLoaded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isRecipeInfoModalOpen, setRecipeInfoModalOpen] = useState(false);
|
||||
const [recipeInfoModelProps, setRecipeInfoModelProps] = useState<{
|
||||
label: string;
|
||||
value: string;
|
||||
setValue: (value: string) => void;
|
||||
} | null>(null);
|
||||
|
||||
// Initialize selected extensions for the recipe from config or localStorage
|
||||
const [recipeExtensions] = useState<string[]>(() => {
|
||||
@@ -50,12 +57,10 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
const exts = [];
|
||||
return exts;
|
||||
});
|
||||
const [newActivity, setNewActivity] = useState('');
|
||||
|
||||
// Section visibility state
|
||||
const [activeSection, setActiveSection] = useState<
|
||||
'none' | 'activities' | 'instructions' | 'extensions'
|
||||
>('none');
|
||||
const [activeSection, _] = useState<'none' | 'activities' | 'instructions' | 'extensions'>(
|
||||
'none'
|
||||
);
|
||||
|
||||
// Load extensions when component mounts and when switching to extensions section
|
||||
useEffect(() => {
|
||||
@@ -95,32 +100,6 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [recipeExtensions, extensionsLoaded]);
|
||||
|
||||
// const handleExtensionToggle = (extension: FixedExtensionEntry) => {
|
||||
// console.log('Toggling extension:', extension.name);
|
||||
// setRecipeExtensions((prev) => {
|
||||
// const isSelected = prev.includes(extension.name);
|
||||
// const newState = isSelected
|
||||
// ? prev.filter((extName) => extName !== extension.name)
|
||||
// : [...prev, extension.name];
|
||||
|
||||
// // Persist to localStorage
|
||||
// localStorage.setItem('recipe_editor_extensions', JSON.stringify(newState));
|
||||
|
||||
// return newState;
|
||||
// });
|
||||
// };
|
||||
|
||||
const handleAddActivity = () => {
|
||||
if (newActivity.trim()) {
|
||||
setActivities((prev) => [...prev, newActivity.trim()]);
|
||||
setNewActivity('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveActivity = (activity: string) => {
|
||||
setActivities((prev) => prev.filter((a) => a !== activity));
|
||||
};
|
||||
|
||||
const getCurrentConfig = (): Recipe => {
|
||||
console.log('Creating config with:', {
|
||||
selectedExtensions: recipeExtensions,
|
||||
@@ -134,6 +113,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
description,
|
||||
instructions,
|
||||
activities,
|
||||
prompt,
|
||||
extensions: recipeExtensions
|
||||
.map((name) => {
|
||||
const extension = extensionOptions.find((e) => e.name === name);
|
||||
@@ -154,16 +134,27 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
return config;
|
||||
};
|
||||
|
||||
const [errors, setErrors] = useState<{ title?: string; description?: string }>({});
|
||||
const [errors, setErrors] = useState<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
instructions?: string;
|
||||
}>({});
|
||||
|
||||
const requiredFieldsAreFilled = () => {
|
||||
return title.trim() && description.trim() && instructions.trim();
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: { title?: string; description?: string } = {};
|
||||
const newErrors: { title?: string; description?: string; instructions?: string } = {};
|
||||
if (!title.trim()) {
|
||||
newErrors.title = 'Title is required';
|
||||
}
|
||||
if (!description.trim()) {
|
||||
newErrors.description = 'Description is required';
|
||||
}
|
||||
if (!instructions.trim()) {
|
||||
newErrors.instructions = 'Instructions are required';
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
@@ -182,268 +173,180 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const onClickEditTextArea = ({
|
||||
label,
|
||||
value,
|
||||
setValue,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
setValue: (value: string) => void;
|
||||
}) => {
|
||||
setRecipeInfoModalOpen(true);
|
||||
setRecipeInfoModelProps({
|
||||
label,
|
||||
value,
|
||||
setValue,
|
||||
});
|
||||
};
|
||||
// Reset extensionsLoaded when section changes away from extensions
|
||||
useEffect(() => {
|
||||
if (activeSection !== 'extensions') {
|
||||
setExtensionsLoaded(false);
|
||||
}
|
||||
}, [activeSection]);
|
||||
|
||||
// Render expanded section content
|
||||
const renderSectionContent = () => {
|
||||
switch (activeSection) {
|
||||
case 'activities':
|
||||
return (
|
||||
<div className="p-6 pt-10">
|
||||
<button onClick={() => setActiveSection('none')} className="mb-6">
|
||||
<Back className="w-6 h-6 text-iconProminent" />
|
||||
</button>
|
||||
<div className="py-2">
|
||||
<Bars className="w-6 h-6 text-iconSubtle" />
|
||||
</div>
|
||||
<div className="mb-8 mt-6">
|
||||
<h2 className="text-2xl font-medium mb-2 text-textProminent">Activities</h2>
|
||||
<p className="text-textSubtle">
|
||||
The top-line prompts and activities that will display within your goose home page.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{activities.map((activity, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="inline-flex items-center bg-bgApp border-2 border-borderSubtle rounded-full px-4 py-2 text-sm text-textStandard"
|
||||
title={activity.length > 100 ? activity : undefined}
|
||||
>
|
||||
<span>{activity.length > 100 ? activity.slice(0, 100) + '...' : activity}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveActivity(activity)}
|
||||
className="ml-2 text-textStandard hover:text-textSubtle transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-3 mt-6">
|
||||
<input
|
||||
type="text"
|
||||
value={newActivity}
|
||||
onChange={(e) => setNewActivity(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddActivity()}
|
||||
className="flex-1 px-4 py-3 bg-bgSubtle text-textStandard rounded-xl placeholder-textPlaceholder focus:outline-none focus:ring-2 focus:ring-borderProminent"
|
||||
placeholder="Add new activity..."
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddActivity}
|
||||
className="px-5 py-3 bg-bgAppInverse text-textProminentInverse rounded-xl hover:bg-bgStandardInverse transition-colors"
|
||||
>
|
||||
Add activity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'instructions':
|
||||
return (
|
||||
<div className="p-6 pt-10">
|
||||
<button onClick={() => setActiveSection('none')} className="mb-6">
|
||||
<Back className="w-6 h-6 text-iconProminent" />
|
||||
</button>
|
||||
<div className="py-2">
|
||||
<Bars className="w-6 h-6 text-iconSubtle" />
|
||||
</div>
|
||||
<div className="mb-8 mt-6">
|
||||
<h2 className="text-2xl font-medium mb-2 text-textProminent">Instructions</h2>
|
||||
<p className="text-textSubtle">
|
||||
Hidden instructions that will be passed to the provider to help direct and add
|
||||
context to your responses.
|
||||
</p>
|
||||
</div>
|
||||
<textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
className="w-full h-96 p-4 bg-bgSubtle text-textStandard rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-borderProminent"
|
||||
placeholder="Enter instructions..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// case 'extensions':
|
||||
// return (
|
||||
// <div className="p-6 pt-10">
|
||||
// <button onClick={() => setActiveSection('none')} className="mb-6">
|
||||
// <Back className="w-6 h-6 text-iconProminent" />
|
||||
// </button>
|
||||
// <div className="py-2">
|
||||
// <Bars className="w-6 h-6 text-iconSubtle" />
|
||||
// </div>
|
||||
// <div className="mb-8 mt-6">
|
||||
// <h2 className="text-2xl font-medium mb-2 text-textProminent">Extensions</h2>
|
||||
// <p className="text-textSubtle">Select extensions to bundle in the recipe</p>
|
||||
// </div>
|
||||
// {extensionsLoaded ? (
|
||||
// <ExtensionList
|
||||
// extensions={extensionOptions}
|
||||
// onToggle={handleExtensionToggle}
|
||||
// isStatic={true}
|
||||
// />
|
||||
// ) : (
|
||||
// <div className="text-center py-8 text-textSubtle">Loading extensions...</div>
|
||||
// )}
|
||||
// </div>
|
||||
// );
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="space-y-2 py-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium mb-2 text-textProminent">Agent</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value);
|
||||
if (errors.title) {
|
||||
setErrors({ ...errors, title: undefined });
|
||||
}
|
||||
}}
|
||||
className={`w-full p-3 border rounded-lg bg-bgApp text-textStandard ${
|
||||
errors.title ? 'border-red-500' : 'border-borderSubtle'
|
||||
}`}
|
||||
placeholder="Agent Recipe Name (required)"
|
||||
/>
|
||||
{errors.title && <div className="text-red-500 text-sm mt-1">{errors.title}</div>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
if (errors.description) {
|
||||
setErrors({ ...errors, description: undefined });
|
||||
}
|
||||
}}
|
||||
className={`w-full p-3 border rounded-lg bg-bgApp text-textStandard ${
|
||||
errors.description ? 'border-red-500' : 'border-borderSubtle'
|
||||
}`}
|
||||
placeholder="Description (required)"
|
||||
/>
|
||||
{errors.description && (
|
||||
<div className="text-red-500 text-sm mt-1">{errors.description}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section buttons */}
|
||||
<button
|
||||
onClick={() => setActiveSection('activities')}
|
||||
className="w-full flex items-start justify-between p-4 border border-borderSubtle rounded-lg bg-bgApp hover:bg-bgSubtle"
|
||||
>
|
||||
<div className="text-left">
|
||||
<h3 className="font-medium text-textProminent">Activities</h3>
|
||||
<p className="text-textSubtle text-sm">
|
||||
Starting activities present in the home panel on a fresh session
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 mt-1 text-iconSubtle" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveSection('instructions')}
|
||||
className="w-full flex items-start justify-between p-4 border border-borderSubtle rounded-lg bg-bgApp hover:bg-bgSubtle"
|
||||
>
|
||||
<div className="text-left">
|
||||
<h3 className="font-medium text-textProminent">Instructions</h3>
|
||||
<p className="text-textSubtle text-sm">Recipe instructions sent to the model</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 mt-1 text-iconSubtle" />
|
||||
</button>
|
||||
|
||||
{/* <button
|
||||
onClick={() => setActiveSection('extensions')}
|
||||
className="w-full flex items-start justify-between p-4 border border-borderSubtle rounded-lg bg-bgApp hover:bg-bgSubtle"
|
||||
>
|
||||
<div className="text-left">
|
||||
<h3 className="font-medium text-textProminent">Extensions</h3>
|
||||
<p className="text-textSubtle text-sm">
|
||||
Extensions to be enabled by default with this recipe
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 mt-1 text-iconSubtle" />
|
||||
</button> */}
|
||||
|
||||
{/* Deep Link Display */}
|
||||
<div className="w-full p-4 bg-bgSubtle rounded-lg">
|
||||
{!title.trim() || !description.trim() ? (
|
||||
<div className="text-sm text-textSubtle text-xs text-textSubtle">
|
||||
Fill in required fields to generate link
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm text-textSubtle text-xs text-textSubtle">
|
||||
Copy this link to share with friends or paste directly in Chrome to open
|
||||
</div>
|
||||
<button
|
||||
onClick={() => validateForm() && handleCopy()}
|
||||
className="ml-4 p-2 hover:bg-bgApp rounded-lg transition-colors flex items-center disabled:opacity-50 disabled:hover:bg-transparent"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-iconSubtle" />
|
||||
)}
|
||||
<span className="ml-1 text-sm text-textSubtle">
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{title.trim() && description.trim() && (
|
||||
<div
|
||||
onClick={() => validateForm() && handleCopy()}
|
||||
className={`text-sm truncate dark:text-white font-mono ${!title.trim() || !description.trim() ? 'text-textDisabled' : 'text-textStandard'}`}
|
||||
>
|
||||
{deeplink}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col space-y-2 pt-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.removeItem('recipe_editor_extensions');
|
||||
window.close();
|
||||
}}
|
||||
className="w-full p-3 text-textSubtle rounded-lg hover:bg-bgSubtle"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const page_title = config?.title ? 'View/edit current recipe' : 'Create an agent recipe';
|
||||
const subtitle = config?.title
|
||||
? "You can edit the recipe below to change the agent's behavior in a new session."
|
||||
: 'Your custom agent recipe can be shared with others. Fill in the sections below to create!';
|
||||
return (
|
||||
<div className="flex flex-col w-full h-screen bg-bgApp max-w-3xl mx-auto">
|
||||
{activeSection === 'none' && (
|
||||
<div className="flex flex-col items-center mb-6 px-6 pt-10">
|
||||
<div className="flex flex-col items-center mb-2 px-6 pt-10">
|
||||
<div className="w-16 h-16 bg-bgApp rounded-full flex items-center justify-center mb-4">
|
||||
<Geese className="w-12 h-12 text-iconProminent" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-medium text-center text-textProminent">
|
||||
Create an agent recipe
|
||||
</h1>
|
||||
<p className="text-textSubtle text-center mt-2 text-sm">
|
||||
Your custom agent recipe can be shared with others. Fill in the sections below to
|
||||
create!
|
||||
</p>
|
||||
<h1 className="text-2xl font-medium text-center text-textProminent">{page_title}</h1>
|
||||
<p className="text-textSubtle text-center mt-2 text-sm">{subtitle}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto px-6">{renderSectionContent()}</div>
|
||||
<div className="flex-1 overflow-y-auto px-6">
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-lg font-medium mb-2 text-textProminent">Agent Recipe Details</h2>
|
||||
</div>
|
||||
<div className="space-y-2 py-2">
|
||||
<div className="pb-6 border-b-2 border-borderSubtle">
|
||||
<label htmlFor="title" className="block text-md text-textProminent mb-2 font-bold">
|
||||
Title <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value);
|
||||
if (errors.title) {
|
||||
setErrors({ ...errors, title: undefined });
|
||||
}
|
||||
}}
|
||||
className={`w-full p-3 border rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent ${
|
||||
errors.title ? 'border-red-500' : 'border-borderSubtle'
|
||||
}`}
|
||||
placeholder="Agent Recipe Title (required)"
|
||||
/>
|
||||
{errors.title && <div className="text-red-500 text-sm mt-1">{errors.title}</div>}
|
||||
</div>
|
||||
<div className="pt-3 pb-6 border-b-2 border-borderSubtle">
|
||||
<label
|
||||
htmlFor="description"
|
||||
className="block text-md text-textProminent mb-2 font-bold"
|
||||
>
|
||||
Description <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
if (errors.description) {
|
||||
setErrors({ ...errors, description: undefined });
|
||||
}
|
||||
}}
|
||||
className={`w-full p-3 border rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent ${
|
||||
errors.description ? 'border-red-500' : 'border-borderSubtle'
|
||||
}`}
|
||||
placeholder="Description (required)"
|
||||
/>
|
||||
{errors.description && (
|
||||
<div className="text-red-500 text-sm mt-1">{errors.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-3 pb-6 border-b-2 border-borderSubtle">
|
||||
<RecipeExpandableInfo
|
||||
infoLabel="Instructions"
|
||||
infoValue={instructions}
|
||||
required={true}
|
||||
onClickEdit={() =>
|
||||
onClickEditTextArea({
|
||||
label: 'Instructions',
|
||||
value: instructions,
|
||||
setValue: setInstructions,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{errors.instructions && (
|
||||
<div className="text-red-500 text-sm mt-1">{errors.instructions}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-3 pb-6 border-b-2 border-borderSubtle">
|
||||
<RecipeExpandableInfo
|
||||
infoLabel="Initial Prompt"
|
||||
infoValue={prompt}
|
||||
required={false}
|
||||
onClickEdit={() =>
|
||||
onClickEditTextArea({ label: 'Initial Prompt', value: prompt, setValue: setPrompt })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-3 pb-6">
|
||||
<RecipeActivityEditor activities={activities} setActivities={setActivities} />
|
||||
</div>
|
||||
|
||||
{/* Deep Link Display */}
|
||||
<div className="w-full p-4 bg-bgSubtle rounded-lg">
|
||||
{!requiredFieldsAreFilled() ? (
|
||||
<div className="text-sm text-textSubtle text-xs text-textSubtle">
|
||||
Fill in required fields to generate link
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm text-textSubtle text-xs text-textSubtle">
|
||||
Copy this link to share with friends or paste directly in Chrome to open
|
||||
</div>
|
||||
<button
|
||||
onClick={() => validateForm() && handleCopy()}
|
||||
className="ml-4 p-2 hover:bg-bgApp rounded-lg transition-colors flex items-center disabled:opacity-50 disabled:hover:bg-transparent"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-iconSubtle" />
|
||||
)}
|
||||
<span className="ml-1 text-sm text-textSubtle">
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{requiredFieldsAreFilled() && (
|
||||
<div
|
||||
onClick={() => validateForm() && handleCopy()}
|
||||
className={`text-sm truncate dark:text-white font-mono ${!title.trim() || !description.trim() ? 'text-textDisabled' : 'text-textStandard'}`}
|
||||
>
|
||||
{deeplink}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col space-y-2 pt-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.removeItem('recipe_editor_extensions');
|
||||
window.close();
|
||||
}}
|
||||
className="w-full p-3 text-textSubtle rounded-lg hover:bg-bgSubtle"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<RecipeInfoModal
|
||||
infoLabel={recipeInfoModelProps?.label}
|
||||
originalValue={recipeInfoModelProps?.value}
|
||||
isOpen={isRecipeInfoModalOpen}
|
||||
onClose={() => setRecipeInfoModalOpen(false)}
|
||||
onSaveValue={recipeInfoModelProps?.setValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
interface RecipeExpandableInfoProps {
|
||||
infoLabel: string;
|
||||
infoValue: string;
|
||||
required?: boolean;
|
||||
onClickEdit: () => void;
|
||||
}
|
||||
|
||||
export default function RecipeExpandableInfo({
|
||||
infoValue,
|
||||
infoLabel,
|
||||
required = false,
|
||||
onClickEdit,
|
||||
}: RecipeExpandableInfoProps) {
|
||||
const [isValueExpanded, setValueExpanded] = useState(false);
|
||||
const [isClamped, setIsClamped] = useState(false);
|
||||
// eslint-disable-next-line no-undef
|
||||
const contentRef = useRef<HTMLParagraphElement>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = measureRef.current;
|
||||
if (el) {
|
||||
const lineHeight = parseFloat(window.getComputedStyle(el).lineHeight || '0');
|
||||
const maxHeight = lineHeight * 3;
|
||||
const actualHeight = el.scrollHeight;
|
||||
setIsClamped(actualHeight > maxHeight);
|
||||
}
|
||||
}, [infoValue]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="block text-md text-textProminent font-bold">
|
||||
{infoLabel} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="relative rounded-lg bg-white text-textStandard">
|
||||
{infoValue && (
|
||||
<>
|
||||
<div
|
||||
ref={measureRef}
|
||||
className="invisible absolute whitespace-pre-wrap w-full pointer-events-none"
|
||||
style={{ position: 'absolute', top: '-9999px' }}
|
||||
>
|
||||
{infoValue}
|
||||
</div>
|
||||
|
||||
<p
|
||||
ref={contentRef}
|
||||
className={`whitespace-pre-wrap transition-all duration-300 ${
|
||||
!isValueExpanded ? 'line-clamp-3' : ''
|
||||
}`}
|
||||
>
|
||||
{infoValue}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setValueExpanded(true);
|
||||
onClickEdit();
|
||||
}}
|
||||
className="w-36 px-3 py-3 bg-bgAppInverse text-sm text-textProminentInverse rounded-xl hover:bg-bgStandardInverse transition-colors"
|
||||
>
|
||||
{infoValue ? 'Edit' : 'Add'} {infoLabel.toLowerCase()}
|
||||
</button>
|
||||
|
||||
{infoValue && isClamped && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValueExpanded(!isValueExpanded)}
|
||||
aria-label={isValueExpanded ? 'Collapse content' : 'Expand content'}
|
||||
title={isValueExpanded ? 'Collapse' : 'Expand'}
|
||||
className="bg-gray-100 hover:bg-gray-200 p-2 rounded text-black hover:text-blue-800 transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-6 h-6 transition-transform duration-300 ${
|
||||
isValueExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Card } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface RecipeInfoModalProps {
|
||||
infoLabel?: string;
|
||||
originalValue?: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSaveValue?: (val: string) => void;
|
||||
}
|
||||
export default function RecipeInfoModal({
|
||||
infoLabel = '',
|
||||
isOpen,
|
||||
onClose,
|
||||
originalValue = '',
|
||||
onSaveValue = () => {},
|
||||
}: RecipeInfoModalProps) {
|
||||
const [value, setValue] = useState(originalValue);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setValue(originalValue);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [isOpen, originalValue]);
|
||||
|
||||
const onSave = (event: React.FormEvent) => {
|
||||
onSaveValue(value);
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/20 dark:bg-white/20 backdrop-blur-sm transition-colors animate-[fadein_200ms_ease-in_forwards] z-[1000]">
|
||||
<Card className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col min-w-[80%] min-h-[80%] bg-bgApp rounded-xl overflow-hidden shadow-lg px-8 pt-[24px] pb-0">
|
||||
<div className="flex mb-6">
|
||||
<h2 className="text-xl font-semibold text-textProminent">Edit {infoLabel}</h2>
|
||||
</div>
|
||||
<div className="flex flex-col flex-grow overflow-y-auto space-y-8">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="w-full flex-grow resize-none min-h-[300px] max-h-[calc(100vh-300px)] border border-borderSubtle rounded-lg p-3 text-textStandard focus:outline-none focus:ring-2 focus:border-borderSubtle"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={`Enter ${infoLabel.toLowerCase()}...`}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
className="w-full h-[60px] rounded-none border-b border-borderSubtle bg-transparent hover:bg-bgSubtle text-textProminent font-medium text-md"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
className="w-full h-[60px] rounded-none hover:bg-bgSubtle text-textSubtle hover:text-textStandard text-md font-regular"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -158,7 +158,7 @@ export default function MoreMenu({
|
||||
const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => {
|
||||
setThemeMode(newTheme);
|
||||
};
|
||||
|
||||
const recipeConfig = window.appConfig.get('recipeConfig');
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -235,31 +235,38 @@ export default function MoreMenu({
|
||||
Configure .goosehints
|
||||
</MenuButton>
|
||||
|
||||
{/* Make Agent from Chat - disabled if already in a recipe */}
|
||||
<MenuButton
|
||||
onClick={() => {
|
||||
const recipeConfig = window.appConfig.get('recipeConfig');
|
||||
if (!recipeConfig) {
|
||||
{recipeConfig ? (
|
||||
<MenuButton
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
window.electron.createChatWindow(
|
||||
undefined, // query
|
||||
undefined, // dir
|
||||
undefined, // version
|
||||
undefined, // resumeSessionId
|
||||
recipeConfig, // recipe config
|
||||
'recipeEditor' // view type
|
||||
);
|
||||
}}
|
||||
subtitle="View the recipe you're using"
|
||||
icon={<Send className="w-4 h-4" />}
|
||||
>
|
||||
View recipe
|
||||
</MenuButton>
|
||||
) : (
|
||||
<MenuButton
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
// Signal to ChatView that we want to make an agent from the current chat
|
||||
window.electron.logInfo('Make Agent button clicked');
|
||||
window.dispatchEvent(new CustomEvent('make-agent-from-chat'));
|
||||
}
|
||||
}}
|
||||
subtitle="Make a custom agent you can share or reuse with a link"
|
||||
icon={<Send className="w-4 h-4" />}
|
||||
className={
|
||||
window.appConfig.get('recipeConfig') ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}
|
||||
>
|
||||
Make Agent from this session
|
||||
{window.appConfig.get('recipeConfig') && (
|
||||
<div className="text-xs text-textSubtle mt-1">
|
||||
(Not available while using a recipe/botling)
|
||||
</div>
|
||||
)}
|
||||
</MenuButton>
|
||||
|
||||
}}
|
||||
subtitle="Make a custom agent you can share or reuse with a link"
|
||||
icon={<Send className="w-4 h-4" />}
|
||||
>
|
||||
Make Agent from this session
|
||||
</MenuButton>
|
||||
)}
|
||||
<MenuButton
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function ViewRecipe() {
|
||||
return <div>ViewRecipe</div>;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export interface Recipe {
|
||||
title: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
prompt?: string;
|
||||
activities?: string[];
|
||||
author?: {
|
||||
contact?: string;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
export default {
|
||||
darkMode: ['class'],
|
||||
content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html'],
|
||||
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
|
||||
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography'), require('@tailwindcss/line-clamp') ],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
|
||||
Reference in New Issue
Block a user