Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ui/desktop/src/api/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
24 changes: 21 additions & 3 deletions ui/desktop/src/components/BaseChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -195,6 +196,10 @@ function BaseChatContent({
handleAutoExecution,
recipeError,
setRecipeError,
isRecipeWarningModalOpen,
recipeAccepted,
handleRecipeAccept,
handleRecipeCancel,
} = useRecipeManager(messages, location.state);

// Reset recipe usage tracking when recipe changes
Expand Down Expand Up @@ -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;
})() ? (
Expand All @@ -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
Expand Down Expand Up @@ -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">
Expand Down
71 changes: 71 additions & 0 deletions ui/desktop/src/components/ui/RecipeWarningModal.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
62 changes: 57 additions & 5 deletions ui/desktop/src/hooks/useRecipeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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;

Expand All @@ -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>) => {
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -249,5 +296,10 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
handleAutoExecution,
recipeError,
setRecipeError,
isRecipeWarningModalOpen,
setIsRecipeWarningModalOpen,
recipeAccepted,
handleRecipeAccept,
handleRecipeCancel,
};
};
1 change: 1 addition & 0 deletions ui/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions ui/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down
46 changes: 46 additions & 0 deletions ui/desktop/src/utils/recipeHash.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
Loading