Files
tkmind_go/ui/desktop/src/hooks/useRecipeManager.ts
T
Zane ab8089499e Add recipe install warning (#3537)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
2025-07-22 17:34:25 +02:00

306 lines
10 KiB
TypeScript

import { useEffect, useMemo, useState, useRef } from 'react';
import { createRecipe, Recipe } from '../recipe';
import { Message, createUserMessage } from '../types/message';
import { updateSystemPromptWithParameters } from '../utils/providerUtils';
import { useChatContext } from '../contexts/ChatContext';
interface LocationState {
recipeConfig?: Recipe;
disableAnimation?: boolean;
reset?: boolean;
}
export const useRecipeManager = (messages: Message[], locationState?: LocationState) => {
const [isGeneratingRecipe, setIsGeneratingRecipe] = useState(false);
const [isParameterModalOpen, setIsParameterModalOpen] = useState(false);
const [recipeParameters, setRecipeParameters] = useState<Record<string, string> | null>(null);
const [readyForAutoUserPrompt, setReadyForAutoUserPrompt] = useState(false);
const [recipeError, setRecipeError] = useState<string | null>(null);
const [isRecipeWarningModalOpen, setIsRecipeWarningModalOpen] = useState(false);
const [recipeAccepted, setRecipeAccepted] = useState(false);
// Get chat context to access persisted recipe
const chatContext = useChatContext();
// Use a ref to capture the current messages for the event handler
const messagesRef = useRef(messages);
const isCreatingRecipeRef = useRef(false);
// Update the ref when messages change
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
// Get recipeConfig from multiple sources with priority:
// 1. Chat context (persisted recipe)
// 2. Navigation state (from recipes view)
// 3. App config (from deeplinks)
const recipeConfig = useMemo(() => {
// First check if we have a persisted recipe in chat context
// We need to distinguish between null (explicitly cleared) and undefined (not set)
if (chatContext?.chat.recipeConfig !== undefined) {
return chatContext.chat.recipeConfig; // This could be null or a Recipe
}
// Then check if recipe config is passed via navigation state
if (locationState?.recipeConfig) {
return locationState.recipeConfig as Recipe;
}
// Fallback to app config (from deeplinks)
const appRecipeConfig = window.appConfig.get('recipe') as Recipe | null;
if (appRecipeConfig) {
return appRecipeConfig;
}
return null;
}, [chatContext, locationState]);
// Effect to persist recipe config to chat context when it changes
useEffect(() => {
if (!chatContext?.setRecipeConfig) return;
// If we have a recipe from navigation state, persist it
if (locationState?.recipeConfig && !chatContext.chat.recipeConfig) {
chatContext.setRecipeConfig(locationState.recipeConfig);
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) {
// Only set if recipeConfig is undefined, not if it's explicitly null
chatContext.setRecipeConfig(appRecipeConfig);
}
}, [chatContext, locationState]);
// Check if recipe has been accepted before
useEffect(() => {
const checkRecipeAcceptance = async () => {
if (recipeConfig) {
try {
const hasAccepted = await window.electron.hasAcceptedRecipeBefore(recipeConfig);
if (!hasAccepted) {
setIsRecipeWarningModalOpen(true);
} else {
setRecipeAccepted(true);
}
} catch (error) {
console.error('Error checking recipe acceptance:', error);
// If there's an error, assume the recipe hasn't been accepted
setIsRecipeWarningModalOpen(true);
}
}
};
checkRecipeAcceptance();
}, [recipeConfig]);
// Show parameter modal if recipe has parameters and they haven't been set yet
useEffect(() => {
if (recipeConfig?.parameters && recipeConfig.parameters.length > 0 && recipeAccepted) {
// If we have parameters and they haven't been set yet, open the modal.
if (!recipeParameters) {
setIsParameterModalOpen(true);
}
}
}, [recipeConfig, recipeParameters, recipeAccepted]);
// Set ready for auto user prompt after component initialization
useEffect(() => {
setReadyForAutoUserPrompt(true);
}, []);
// Substitute parameters in prompt
const substituteParameters = (prompt: string, params: Record<string, string>): string => {
let substitutedPrompt = prompt;
for (const key in params) {
// Escape special characters in the key (parameter) and match optional whitespace
const regex = new RegExp(`{{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*}}`, 'g');
substitutedPrompt = substitutedPrompt.replace(regex, params[key]);
}
return substitutedPrompt;
};
// Pre-fill input with recipe prompt instead of auto-sending it
const initialPrompt = useMemo(() => {
if (!recipeConfig?.prompt || !recipeAccepted) return '';
const hasRequiredParams = recipeConfig.parameters && recipeConfig.parameters.length > 0;
// If params are required and have been collected, substitute them into the prompt.
if (hasRequiredParams && recipeParameters) {
return substituteParameters(recipeConfig.prompt, recipeParameters);
}
// If there are no parameters, return the original prompt.
if (!hasRequiredParams) {
return recipeConfig.prompt;
}
// Otherwise, we are waiting for parameters, so the input should be empty.
return '';
}, [recipeConfig, recipeParameters, recipeAccepted]);
// Handle parameter submission
const handleParameterSubmit = async (inputValues: Record<string, string>) => {
setRecipeParameters(inputValues);
setIsParameterModalOpen(false);
// Update the system prompt with parameter-substituted instructions
try {
await updateSystemPromptWithParameters(inputValues);
} catch (error) {
console.error('Failed to update system prompt with parameters:', error);
}
};
// Handle recipe acceptance
const handleRecipeAccept = async () => {
try {
if (recipeConfig) {
await window.electron.recordRecipeHash(recipeConfig);
setRecipeAccepted(true);
setIsRecipeWarningModalOpen(false);
}
} catch (error) {
console.error('Error recording recipe hash:', error);
// Even if recording fails, we should still allow the user to proceed
setRecipeAccepted(true);
setIsRecipeWarningModalOpen(false);
}
};
// Handle recipe cancellation
const handleRecipeCancel = () => {
setIsRecipeWarningModalOpen(false);
window.electron.closeWindow();
};
// Auto-execution handler for scheduled recipes
const handleAutoExecution = (append: (message: Message) => void, isLoading: boolean) => {
const hasRequiredParams = recipeConfig?.parameters && recipeConfig.parameters.length > 0;
if (
recipeConfig?.isScheduledExecution &&
recipeConfig?.prompt &&
(!hasRequiredParams || recipeParameters) &&
messages.length === 0 &&
!isLoading &&
readyForAutoUserPrompt &&
recipeAccepted
) {
// Substitute parameters if they exist
const finalPrompt = recipeParameters
? substituteParameters(recipeConfig.prompt, recipeParameters)
: recipeConfig.prompt;
console.log('Auto-sending substituted prompt for scheduled execution:', finalPrompt);
const userMessage = createUserMessage(finalPrompt);
append(userMessage);
}
};
// Listen for make-agent-from-chat event
useEffect(() => {
const handleMakeAgent = async () => {
// Prevent duplicate calls using global flag
if (window.isCreatingRecipe) {
return;
}
// Prevent duplicate calls using local ref
if (isCreatingRecipeRef.current) {
return;
}
window.electron.logInfo('Making recipe from chat...');
// Set both local and global flags
isCreatingRecipeRef.current = true;
window.isCreatingRecipe = true;
setIsGeneratingRecipe(true);
try {
// Create recipe directly from chat messages using the ref to get current messages
const createRecipeRequest = {
messages: messagesRef.current,
title: '',
description: '',
};
const response = await createRecipe(createRecipeRequest);
if (response.error) {
throw new Error(`Failed to create recipe: ${response.error}`);
}
window.electron.logInfo('Created recipe successfully');
// Verify the recipe data
if (!response.recipe) {
throw new Error('No recipe data received');
}
// Set a flag to prevent the current window from reacting to recipe config changes
// This prevents navigation conflicts when creating new windows
window.sessionStorage.setItem('ignoreRecipeConfigChanges', 'true');
// Create the new window
window.electron.createChatWindow(
undefined, // query
undefined, // dir
undefined, // version
undefined, // resumeSessionId
response.recipe, // recipe config
'recipeEditor' // view type
);
window.electron.logInfo('Opening recipe editor window');
// Clear the ignore flag after a short delay
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);
// Set the error state to show in modal
setRecipeError(errorMessage);
} finally {
isCreatingRecipeRef.current = false;
window.isCreatingRecipe = false;
setIsGeneratingRecipe(false);
}
};
window.addEventListener('make-agent-from-chat', handleMakeAgent);
return () => {
window.removeEventListener('make-agent-from-chat', handleMakeAgent);
};
}, []);
return {
recipeConfig,
initialPrompt,
isGeneratingRecipe,
isParameterModalOpen,
setIsParameterModalOpen,
recipeParameters,
handleParameterSubmit,
handleAutoExecution,
recipeError,
setRecipeError,
isRecipeWarningModalOpen,
setIsRecipeWarningModalOpen,
recipeAccepted,
handleRecipeAccept,
handleRecipeCancel,
};
};