Add recipe install warning (#3537)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
@@ -60,6 +60,7 @@ import { type View, ViewOptions } from '../App';
|
||||
import { MainPanelLayout } from './Layout/MainPanelLayout';
|
||||
import ChatInput from './ChatInput';
|
||||
import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area';
|
||||
import { RecipeWarningModal } from './ui/RecipeWarningModal';
|
||||
import { useChatEngine } from '../hooks/useChatEngine';
|
||||
import { useRecipeManager } from '../hooks/useRecipeManager';
|
||||
import { useSessionContinuation } from '../hooks/useSessionContinuation';
|
||||
@@ -195,6 +196,10 @@ function BaseChatContent({
|
||||
handleAutoExecution,
|
||||
recipeError,
|
||||
setRecipeError,
|
||||
isRecipeWarningModalOpen,
|
||||
recipeAccepted,
|
||||
handleRecipeAccept,
|
||||
handleRecipeCancel,
|
||||
} = useRecipeManager(messages, location.state);
|
||||
|
||||
// Reset recipe usage tracking when recipe changes
|
||||
@@ -356,9 +361,9 @@ function BaseChatContent({
|
||||
{
|
||||
// Check if we should show splash instead of messages
|
||||
(() => {
|
||||
// Show splash if we have a recipe and user hasn't started using it yet
|
||||
// Show splash if we have a recipe and user hasn't started using it yet, and recipe has been accepted
|
||||
const shouldShowSplash =
|
||||
recipeConfig && !hasStartedUsingRecipe && !suppressEmptyState;
|
||||
recipeConfig && recipeAccepted && !hasStartedUsingRecipe && !suppressEmptyState;
|
||||
|
||||
return shouldShowSplash;
|
||||
})() ? (
|
||||
@@ -377,7 +382,8 @@ function BaseChatContent({
|
||||
<PopularChatTopics append={(text: string) => appendWithTracking(text)} />
|
||||
) : null}
|
||||
</>
|
||||
) : filteredMessages.length > 0 || (recipeConfig && hasStartedUsingRecipe) ? (
|
||||
) : filteredMessages.length > 0 ||
|
||||
(recipeConfig && recipeAccepted && hasStartedUsingRecipe) ? (
|
||||
<>
|
||||
{disableSearch ? (
|
||||
// Render messages without SearchView wrapper when search is disabled
|
||||
@@ -523,6 +529,18 @@ function BaseChatContent({
|
||||
summaryContent={summaryContent}
|
||||
/>
|
||||
|
||||
{/* Recipe Warning Modal */}
|
||||
<RecipeWarningModal
|
||||
isOpen={isRecipeWarningModalOpen}
|
||||
onConfirm={handleRecipeAccept}
|
||||
onCancel={handleRecipeCancel}
|
||||
recipeDetails={{
|
||||
title: recipeConfig?.title,
|
||||
description: recipeConfig?.description,
|
||||
instructions: recipeConfig?.instructions,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Recipe Error Modal */}
|
||||
{recipeError && (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './dialog';
|
||||
import { Button } from './button';
|
||||
|
||||
interface RecipeWarningModalProps {
|
||||
isOpen: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
recipeDetails: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
instructions?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function RecipeWarningModal({
|
||||
isOpen,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
recipeDetails,
|
||||
}: RecipeWarningModalProps) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>⚠️ New Recipe Warning</DialogTitle>
|
||||
<DialogDescription>
|
||||
You are about to execute a recipe that you haven't run before. Only proceed if you trust
|
||||
the source of this recipe.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-background-muted p-4 rounded-lg">
|
||||
<h3 className="font-medium mb-2 text-text-standard">Recipe Details:</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
{recipeDetails.title && (
|
||||
<p className="text-text-standard">
|
||||
<strong>Title:</strong> {recipeDetails.title}
|
||||
</p>
|
||||
)}
|
||||
{recipeDetails.description && (
|
||||
<p className="text-text-standard">
|
||||
<strong>Description:</strong> {recipeDetails.description}
|
||||
</p>
|
||||
)}
|
||||
{recipeDetails.instructions && (
|
||||
<p className="text-text-standard">
|
||||
<strong>Instructions:</strong> {recipeDetails.instructions}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onConfirm}>Trust and Execute</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,8 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
|
||||
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();
|
||||
@@ -72,15 +74,37 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
|
||||
}
|
||||
}, [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) {
|
||||
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]);
|
||||
}, [recipeConfig, recipeParameters, recipeAccepted]);
|
||||
|
||||
// Set ready for auto user prompt after component initialization
|
||||
useEffect(() => {
|
||||
@@ -101,7 +125,7 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
|
||||
|
||||
// Pre-fill input with recipe prompt instead of auto-sending it
|
||||
const initialPrompt = useMemo(() => {
|
||||
if (!recipeConfig?.prompt) return '';
|
||||
if (!recipeConfig?.prompt || !recipeAccepted) return '';
|
||||
|
||||
const hasRequiredParams = recipeConfig.parameters && recipeConfig.parameters.length > 0;
|
||||
|
||||
@@ -117,7 +141,7 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
|
||||
|
||||
// Otherwise, we are waiting for parameters, so the input should be empty.
|
||||
return '';
|
||||
}, [recipeConfig, recipeParameters]);
|
||||
}, [recipeConfig, recipeParameters, recipeAccepted]);
|
||||
|
||||
// Handle parameter submission
|
||||
const handleParameterSubmit = async (inputValues: Record<string, string>) => {
|
||||
@@ -132,6 +156,28 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
@@ -142,7 +188,8 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
|
||||
(!hasRequiredParams || recipeParameters) &&
|
||||
messages.length === 0 &&
|
||||
!isLoading &&
|
||||
readyForAutoUserPrompt
|
||||
readyForAutoUserPrompt &&
|
||||
recipeAccepted
|
||||
) {
|
||||
// Substitute parameters if they exist
|
||||
const finalPrompt = recipeParameters
|
||||
@@ -249,5 +296,10 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
|
||||
handleAutoExecution,
|
||||
recipeError,
|
||||
setRecipeError,
|
||||
isRecipeWarningModalOpen,
|
||||
setIsRecipeWarningModalOpen,
|
||||
recipeAccepted,
|
||||
handleRecipeAccept,
|
||||
handleRecipeCancel,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from './utils/autoUpdater';
|
||||
import { UPDATES_ENABLED } from './updates';
|
||||
import { Recipe } from './recipe';
|
||||
import './utils/recipeHash';
|
||||
|
||||
// API URL constructor for main process before window is ready
|
||||
function getApiUrlMain(endpoint: string, dynamicPort: number): string {
|
||||
|
||||
@@ -105,6 +105,10 @@ type ElectronAPI = {
|
||||
restartApp: () => void;
|
||||
onUpdaterEvent: (callback: (event: UpdaterEvent) => void) => void;
|
||||
getUpdateState: () => Promise<{ updateAvailable: boolean; latestVersion?: string } | null>;
|
||||
// Recipe warning functions
|
||||
closeWindow: () => void;
|
||||
hasAcceptedRecipeBefore: (recipeConfig: Recipe) => Promise<boolean>;
|
||||
recordRecipeHash: (recipeConfig: Recipe) => Promise<boolean>;
|
||||
};
|
||||
|
||||
type AppConfigAPI = {
|
||||
@@ -215,6 +219,11 @@ const electronAPI: ElectronAPI = {
|
||||
getUpdateState: (): Promise<{ updateAvailable: boolean; latestVersion?: string } | null> => {
|
||||
return ipcRenderer.invoke('get-update-state');
|
||||
},
|
||||
closeWindow: () => ipcRenderer.send('close-window'),
|
||||
hasAcceptedRecipeBefore: (recipeConfig: Recipe) =>
|
||||
ipcRenderer.invoke('has-accepted-recipe-before', recipeConfig),
|
||||
recordRecipeHash: (recipeConfig: Recipe) =>
|
||||
ipcRenderer.invoke('record-recipe-hash', recipeConfig),
|
||||
};
|
||||
|
||||
const appConfigAPI: AppConfigAPI = {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ipcMain, app, BrowserWindow } from 'electron';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import crypto from 'crypto';
|
||||
|
||||
function calculateRecipeHash(recipeConfig: unknown): string {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(JSON.stringify(recipeConfig));
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
async function getRecipeHashesDir(): Promise<string> {
|
||||
const userDataPath = app.getPath('userData');
|
||||
const hashesDir = path.join(userDataPath, 'recipe_hashes');
|
||||
await fs.mkdir(hashesDir, { recursive: true });
|
||||
return hashesDir;
|
||||
}
|
||||
|
||||
ipcMain.handle('has-accepted-recipe-before', async (_event, recipeConfig) => {
|
||||
const hash = calculateRecipeHash(recipeConfig);
|
||||
const hashFile = path.join(await getRecipeHashesDir(), `${hash}.hash`);
|
||||
try {
|
||||
await fs.access(hashFile);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('record-recipe-hash', async (_event, recipeConfig) => {
|
||||
const hash = calculateRecipeHash(recipeConfig);
|
||||
const filePath = path.join(await getRecipeHashesDir(), `${hash}.hash`);
|
||||
const timestamp = new Date().toISOString();
|
||||
await fs.writeFile(filePath, timestamp);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.on('close-window', () => {
|
||||
const currentWindow = BrowserWindow.getFocusedWindow();
|
||||
if (currentWindow) {
|
||||
currentWindow.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user