diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 071c7bfd557e..45a43b44ba18 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -273,6 +273,10 @@ export type ModelInfo = { * Cost per token for output (optional) */ output_token_cost?: number | null; + /** + * Whether this model supports cache control + */ + supports_cache_control?: boolean | null; }; export type PermissionConfirmationRequest = { diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 247d8bee1116..c20c29de3884 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -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({ 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 */} + + {/* Recipe Error Modal */} {recipeError && (
diff --git a/ui/desktop/src/components/ui/RecipeWarningModal.tsx b/ui/desktop/src/components/ui/RecipeWarningModal.tsx new file mode 100644 index 000000000000..c34a8cf6f1ad --- /dev/null +++ b/ui/desktop/src/components/ui/RecipeWarningModal.tsx @@ -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 ( + !open && onCancel()}> + + + ⚠️ New Recipe Warning + + You are about to execute a recipe that you haven't run before. Only proceed if you trust + the source of this recipe. + + + +
+
+

Recipe Details:

+
+ {recipeDetails.title && ( +

+ Title: {recipeDetails.title} +

+ )} + {recipeDetails.description && ( +

+ Description: {recipeDetails.description} +

+ )} + {recipeDetails.instructions && ( +

+ Instructions: {recipeDetails.instructions} +

+ )} +
+
+
+ + + + + +
+
+ ); +} diff --git a/ui/desktop/src/hooks/useRecipeManager.ts b/ui/desktop/src/hooks/useRecipeManager.ts index a461bc2e21f6..8d462d7b2928 100644 --- a/ui/desktop/src/hooks/useRecipeManager.ts +++ b/ui/desktop/src/hooks/useRecipeManager.ts @@ -16,6 +16,8 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt const [recipeParameters, setRecipeParameters] = useState | null>(null); const [readyForAutoUserPrompt, setReadyForAutoUserPrompt] = useState(false); const [recipeError, setRecipeError] = useState(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) => { @@ -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, }; }; diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 1934ac4eded7..28a8e25bdbb8 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -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 { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 574310d14088..cd701b0d2638 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -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; + recordRecipeHash: (recipeConfig: Recipe) => Promise; }; 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 = { diff --git a/ui/desktop/src/utils/recipeHash.ts b/ui/desktop/src/utils/recipeHash.ts new file mode 100644 index 000000000000..c0b0e64561fe --- /dev/null +++ b/ui/desktop/src/utils/recipeHash.ts @@ -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 { + 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(); + } +});