Create / edit recipe form unification and improvements (#4693)

This commit is contained in:
Zane
2025-10-05 09:00:59 -07:00
committed by GitHub
parent ebb3888ff1
commit f4d79404ee
55 changed files with 4486 additions and 3074 deletions
+22 -8
View File
@@ -26,7 +26,7 @@ export enum AgentState {
}
export interface InitializationContext {
recipeConfig?: Recipe;
recipe?: Recipe;
resumeSessionId?: string;
setAgentWaitingMessage: (msg: string | null) => void;
setIsExtensionsLoading?: (isLoading: boolean) => void;
@@ -81,7 +81,8 @@ export function useAgent(): UseAgentReturn {
messages: messages?.map((message: ApiMessage) =>
convertApiMessageToFrontendMessage(message)
),
recipeConfig: agentSession.recipe,
recipe: agentSession.recipe,
recipeParameters: agentSession.user_recipe_values || null,
};
}
@@ -114,7 +115,7 @@ export function useAgent(): UseAgentReturn {
: await startAgent({
body: {
working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string,
recipe: recipeFromAppConfig ?? initContext.recipeConfig,
recipe: recipeFromAppConfig ?? initContext.recipe,
},
throwOnError: true,
});
@@ -137,10 +138,14 @@ export function useAgent(): UseAgentReturn {
}
agentWaitingMessage('Extensions are loading');
const recipeForInit = initContext.recipe || agentSession.recipe || undefined;
await initializeSystem(agentSession.id, provider as string, model as string, {
getExtensions,
addExtension,
setIsExtensionsLoading: initContext.setIsExtensionsLoading,
recipeParameters: agentSession.user_recipe_values,
recipe: recipeForInit,
});
if (COST_TRACKING_ENABLED) {
@@ -151,15 +156,24 @@ export function useAgent(): UseAgentReturn {
}
}
const messages = agentSession.conversation || [];
const recipe = initContext.recipe || agentSession.recipe;
const conversation = agentSession.conversation || [];
// If we're loading a recipe from initContext (new recipe load), start with empty messages
// Otherwise, use the messages from the session
const messages =
initContext.recipe && !initContext.resumeSessionId
? []
: conversation.map((message: ApiMessage) =>
convertApiMessageToFrontendMessage(message)
);
let initChat: ChatType = {
sessionId: agentSession.id,
title: agentSession.recipe?.title || agentSession.description,
messageHistoryIndex: 0,
messages: messages.map((message: ApiMessage) =>
convertApiMessageToFrontendMessage(message)
),
recipeConfig: agentSession.recipe,
messages: messages,
recipe: recipe,
recipeParameters: agentSession.user_recipe_values || null,
};
setAgentState(AgentState.INITIALIZED);
+5 -5
View File
@@ -94,10 +94,10 @@ export const useChatEngine = ({
body: {
session_id: chat.sessionId,
session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR'),
...(chat.recipeConfig?.title
...(chat.recipe?.title
? {
recipe_name: chat.recipeConfig.title,
recipe_version: chat.recipeConfig?.version ?? 'unknown',
recipe_name: chat.recipe.title,
recipe_version: chat.recipe?.version ?? 'unknown',
}
: {}),
},
@@ -220,9 +220,9 @@ export const useChatEngine = ({
}
}, [chat.sessionId, messages, chatState]);
// Update token counts when sessionMetadata changes from the message stream
// Update token counts when session changes from the message stream
useEffect(() => {
console.log('Session metadata received:', session);
console.log('Session received:', session);
if (session) {
setSessionTokenCount(session.total_tokens || 0);
setSessionInputTokens(session.accumulated_input_tokens || 0);
+1 -1
View File
@@ -626,7 +626,7 @@ export function useMessageStream({
updateMessageStreamBody,
notifications,
currentModelInfo,
session: session,
session,
setError,
};
}
+147 -104
View File
@@ -1,24 +1,25 @@
import { useEffect, useMemo, useState, useRef } from 'react';
import { createRecipe, Recipe, scanRecipe } from '../recipe';
import { Recipe, scanRecipe } from '../recipe';
import { Message, createUserMessage } from '../types/message';
import {
updateSystemPromptWithParameters,
substituteParameters,
filterValidUsedParameters,
} from '../utils/providerUtils';
import { updateSessionUserRecipeValues } from '../api';
import { useChatContext } from '../contexts/ChatContext';
import { ChatType } from '../types/chat';
import { toastSuccess } from '../toasts';
export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) => {
const [isGeneratingRecipe, setIsGeneratingRecipe] = useState(false);
export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => {
const [isParameterModalOpen, setIsParameterModalOpen] = useState(false);
const [readyForAutoUserPrompt, setReadyForAutoUserPrompt] = useState(false);
const [recipeError, setRecipeError] = useState<string | null>(null);
const [isRecipeWarningModalOpen, setIsRecipeWarningModalOpen] = useState(false);
const [recipeAccepted, setRecipeAccepted] = useState(false);
const [isCreateRecipeModalOpen, setIsCreateRecipeModalOpen] = useState(false);
const [hasSecurityWarnings, setHasSecurityWarnings] = useState(false);
const [recipeParameters, setRecipeParameters] = useState<Record<string, string> | null>(null);
const [readyForAutoUserPrompt, setReadyForAutoUserPrompt] = useState(false);
const [recipeError, setRecipeError] = useState<string | null>(null);
const recipeParameters = chat.recipeParameters;
const chatContext = useChatContext();
const messages = chat.messages;
@@ -30,33 +31,66 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) =
messagesRef.current = messages;
}, [messages]);
const finalRecipeConfig = chat.recipeConfig;
const finalRecipe = chat.recipe;
useEffect(() => {
if (!chatContext) return;
// If we have a recipe from navigation state, persist it
if (recipeConfig && !chatContext.chat.recipeConfig) {
chatContext.setRecipeConfig(recipeConfig);
// If we have a recipe from navigation state, always set it and reset acceptance state
// This ensures that when loading a new recipe, we start fresh
if (recipe) {
// Check if this is actually a different recipe (by comparing title and content)
const currentRecipe = chatContext.chat.recipe;
const isNewRecipe =
!currentRecipe ||
currentRecipe.title !== recipe.title ||
currentRecipe.instructions !== recipe.instructions ||
currentRecipe.prompt !== recipe.prompt ||
JSON.stringify(currentRecipe.activities) !== JSON.stringify(recipe.activities);
if (isNewRecipe) {
console.log('Setting new recipe config:', recipe.title);
// Reset recipe acceptance state when loading a new recipe
setRecipeAccepted(false);
setIsParameterModalOpen(false);
setIsRecipeWarningModalOpen(false);
chatContext.setChat({
...chatContext.chat,
recipe: recipe,
recipeParameters: null,
messages: [],
});
}
return;
}
// 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)
const appRecipeConfig = window.appConfig.get('recipe') as Recipe | null;
if (appRecipeConfig && chatContext.chat.recipeConfig === undefined) {
chatContext.setRecipeConfig(appRecipeConfig);
const appRecipe = window.appConfig.get('recipe') as Recipe | null;
if (appRecipe && chatContext.chat.recipe === undefined) {
chatContext.setRecipe(appRecipe);
}
}, [chatContext, recipeConfig]);
}, [chatContext, recipe]);
useEffect(() => {
const checkRecipeAcceptance = async () => {
if (finalRecipeConfig) {
if (finalRecipe) {
// If the recipe comes from session metadata (not from navigation state),
// it means it was already accepted in a previous session, so auto-accept it
const isFromSessionMetadata = !recipe && finalRecipe;
if (isFromSessionMetadata) {
// Recipe loaded from session metadata should be automatically accepted
setRecipeAccepted(true);
return;
}
try {
const hasAccepted = await window.electron.hasAcceptedRecipeBefore(finalRecipeConfig);
const hasAccepted = await window.electron.hasAcceptedRecipeBefore(finalRecipe);
if (!hasAccepted) {
const securityScanResult = await scanRecipe(finalRecipeConfig);
const securityScanResult = await scanRecipe(finalRecipe);
setHasSecurityWarnings(securityScanResult.has_security_warnings);
setIsRecipeWarningModalOpen(true);
@@ -67,63 +101,116 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) =
setHasSecurityWarnings(false);
setIsRecipeWarningModalOpen(true);
}
} else {
setRecipeAccepted(false);
setIsRecipeWarningModalOpen(false);
}
};
checkRecipeAcceptance();
}, [finalRecipeConfig]);
}, [finalRecipe, recipe]);
// Filter parameters to only show valid ones that are actually used in the recipe
const filteredParameters = useMemo(() => {
if (!finalRecipeConfig?.parameters) {
if (!finalRecipe?.parameters) {
return [];
}
return filterValidUsedParameters(finalRecipeConfig.parameters, {
prompt: finalRecipeConfig.prompt || undefined,
instructions: finalRecipeConfig.instructions || undefined,
return filterValidUsedParameters(finalRecipe.parameters, {
prompt: finalRecipe.prompt || undefined,
instructions: finalRecipe.instructions || undefined,
activities: finalRecipe.activities || undefined,
});
}, [finalRecipeConfig]);
}, [finalRecipe]);
// Check if template variables are actually used in the recipe content
const requiresParameters = useMemo(() => {
return filteredParameters.length > 0;
}, [filteredParameters]);
const hasParameters = !!recipeParameters;
// Check if all required parameters have been filled in
const hasAllRequiredParameters = useMemo(() => {
if (!requiresParameters) {
return true; // No parameters required, so all are "filled"
}
if (!recipeParameters) {
return false; // Parameters required but none provided
}
// Check if all filtered parameters have values
return filteredParameters.every((param) => {
const value = recipeParameters[param.key];
return value !== undefined && value !== null && value.trim() !== '';
});
}, [filteredParameters, recipeParameters, requiresParameters]);
const hasMessages = messages.length > 0;
useEffect(() => {
if (requiresParameters && recipeAccepted) {
if (!hasParameters && !hasMessages) {
setIsParameterModalOpen(true);
}
// Only show parameter modal if:
// 1. Recipe requires parameters
// 2. Recipe has been accepted
// 3. Not all required parameters have been filled in yet
// 4. Parameter modal is not already open (prevent multiple opens)
// 5. No messages in chat yet (don't show after conversation has started)
if (
requiresParameters &&
recipeAccepted &&
!hasAllRequiredParameters &&
!isParameterModalOpen &&
!hasMessages
) {
setIsParameterModalOpen(true);
}
}, [requiresParameters, hasParameters, recipeAccepted, hasMessages]);
}, [
requiresParameters,
hasAllRequiredParameters,
recipeAccepted,
filteredParameters,
isParameterModalOpen,
hasMessages,
chat.sessionId,
finalRecipe?.title,
]);
useEffect(() => {
setReadyForAutoUserPrompt(true);
}, []);
const initialPrompt = useMemo(() => {
if (!finalRecipeConfig?.prompt || !recipeAccepted || finalRecipeConfig?.isScheduledExecution) {
if (!finalRecipe?.prompt || !recipeAccepted || finalRecipe?.isScheduledExecution) {
return '';
}
if (requiresParameters && recipeParameters) {
return substituteParameters(finalRecipeConfig.prompt, recipeParameters);
return substituteParameters(finalRecipe.prompt, recipeParameters);
}
return finalRecipeConfig.prompt;
}, [finalRecipeConfig, recipeParameters, recipeAccepted, requiresParameters]);
return finalRecipe.prompt;
}, [finalRecipe, recipeParameters, recipeAccepted, requiresParameters]);
const handleParameterSubmit = async (inputValues: Record<string, string>) => {
setRecipeParameters(inputValues);
// Update chat state with parameters
if (chatContext) {
chatContext.setChat({
...chatContext.chat,
recipeParameters: inputValues,
});
}
setIsParameterModalOpen(false);
try {
await updateSystemPromptWithParameters(
chat.sessionId,
inputValues,
finalRecipeConfig || undefined
);
await updateSystemPromptWithParameters(chat.sessionId, inputValues, finalRecipe || undefined);
// Save recipe parameters to session metadata
await updateSessionUserRecipeValues({
path: {
session_id: chat.sessionId,
},
body: {
userRecipeValues: inputValues,
},
throwOnError: true,
});
} catch (error) {
console.error('Failed to update system prompt with parameters:', error);
}
@@ -131,8 +218,8 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) =
const handleRecipeAccept = async () => {
try {
if (finalRecipeConfig) {
await window.electron.recordRecipeHash(finalRecipeConfig);
if (finalRecipe) {
await window.electron.recordRecipeHash(finalRecipe);
setRecipeAccepted(true);
setIsRecipeWarningModalOpen(false);
}
@@ -154,8 +241,8 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) =
onAutoExecute?: () => void
) => {
if (
finalRecipeConfig?.isScheduledExecution &&
finalRecipeConfig?.prompt &&
finalRecipe?.isScheduledExecution &&
finalRecipe?.prompt &&
(!requiresParameters || recipeParameters) &&
messages.length === 0 &&
!isLoading &&
@@ -163,10 +250,8 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) =
recipeAccepted
) {
const finalPrompt = recipeParameters
? substituteParameters(finalRecipeConfig.prompt, recipeParameters)
: finalRecipeConfig.prompt;
console.log('Auto-sending substituted prompt for scheduled execution:', finalPrompt);
? substituteParameters(finalRecipe.prompt, recipeParameters)
: finalRecipe.prompt;
const userMessage = createUserMessage(finalPrompt);
append(userMessage);
@@ -184,59 +269,7 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) =
return;
}
window.electron.logInfo('Making recipe from chat...');
isCreatingRecipeRef.current = true;
window.isCreatingRecipe = true;
setIsGeneratingRecipe(true);
try {
const createRecipeRequest = {
messages: messagesRef.current,
title: '',
description: '',
session_id: chat.sessionId,
};
const response = await createRecipe(createRecipeRequest);
if (response.error) {
throw new Error(`Failed to create recipe: ${response.error}`);
}
window.electron.logInfo('Created recipe successfully');
if (!response.recipe) {
throw new Error('No recipe data received');
}
window.sessionStorage.setItem('ignoreRecipeConfigChanges', 'true');
window.electron.createChatWindow(
undefined,
undefined,
undefined,
undefined,
response.recipe,
'recipeEditor'
);
window.electron.logInfo('Opening recipe editor window');
setTimeout(() => {
window.sessionStorage.removeItem('ignoreRecipeConfigChanges');
}, 1000);
} catch (error) {
window.electron.logInfo('Failed to create recipe:');
const errorMessage = error instanceof Error ? error.message : String(error);
window.electron.logInfo(errorMessage);
setRecipeError(errorMessage);
} finally {
isCreatingRecipeRef.current = false;
window.isCreatingRecipe = false;
setIsGeneratingRecipe(false);
}
setIsCreateRecipeModalOpen(true);
};
window.addEventListener('make-agent-from-chat', handleMakeAgent);
@@ -246,14 +279,21 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) =
};
}, [chat.sessionId]);
const handleRecipeCreated = (recipe: Recipe) => {
toastSuccess({
title: 'Recipe created successfully!',
msg: `"${recipe.title}" has been saved and is ready to use.`,
});
};
return {
recipeConfig: finalRecipeConfig,
recipe: finalRecipe,
recipeParameters,
filteredParameters,
initialPrompt,
isGeneratingRecipe,
isParameterModalOpen,
setIsParameterModalOpen,
recipeParameters,
readyForAutoUserPrompt,
handleParameterSubmit,
handleAutoExecution,
recipeError,
@@ -264,5 +304,8 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) =
handleRecipeAccept,
handleRecipeCancel,
hasSecurityWarnings,
isCreateRecipeModalOpen,
setIsCreateRecipeModalOpen,
handleRecipeCreated,
};
};