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
80 changes: 74 additions & 6 deletions ui/desktop/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { fetchSessionDetails, generateSessionId } from '../sessions';
import 'react-toastify/dist/ReactToastify.css';
import { useMessageStream } from '../hooks/useMessageStream';
import { SessionSummaryModal } from './context_management/SessionSummaryModal';
import ParameterInputModal from './ParameterInputModal';
import { Recipe } from '../recipe';
import {
ChatContextManagerProvider,
Expand All @@ -35,6 +36,7 @@ import { ContextHandler } from './context_management/ContextHandler';
import { LocalMessageStorage } from '../utils/localMessageStorage';
import { useModelAndProvider } from './ModelAndProviderContext';
import { getCostForModel } from '../utils/costDatabase';
import { updateSystemPromptWithParameters } from '../utils/providerUtils';
import {
Message,
createUserMessage,
Expand Down Expand Up @@ -69,6 +71,17 @@ const isUserMessage = (message: Message): boolean => {
return true;
};

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;
};

export default function ChatView({
chat,
setChat,
Expand Down Expand Up @@ -114,6 +127,8 @@ function ChatContent({
const [localOutputTokens, setLocalOutputTokens] = useState<number>(0);
const [ancestorMessages, setAncestorMessages] = useState<Message[]>([]);
const [droppedFiles, setDroppedFiles] = useState<string[]>([]);
const [isParameterModalOpen, setIsParameterModalOpen] = useState(false);
const [recipeParameters, setRecipeParameters] = useState<Record<string, string> | null>(null);
const [sessionCosts, setSessionCosts] = useState<{
[key: string]: {
inputTokens: number;
Expand Down Expand Up @@ -152,6 +167,16 @@ function ChatContent({
// Get recipeConfig directly from appConfig
const recipeConfig = window.appConfig.get('recipeConfig') as Recipe | null;

// Show parameter modal if recipe has parameters and they haven't been set yet
useEffect(() => {
if (recipeConfig?.parameters && recipeConfig.parameters.length > 0) {
// If we have parameters and they haven't been set yet, open the modal.
if (!recipeParameters) {
setIsParameterModalOpen(true);
}
}
}, [recipeConfig, recipeParameters]);

// Store message in global history when it's added
const storeMessageInHistory = useCallback((message: Message) => {
if (isUserMessage(message)) {
Expand Down Expand Up @@ -282,6 +307,7 @@ function ChatContent({
name: response.recipe.title || 'Untitled Recipe', // Does not exist on recipe type
title: response.recipe.title || 'Untitled Recipe',
description: response.recipe.description || '',
parameters: response.recipe.parameters || [],
instructions: response.recipe.instructions || '',
activities: response.recipe.activities || [],
prompt: response.recipe.prompt || '',
Expand Down Expand Up @@ -326,27 +352,48 @@ function ChatContent({

// Pre-fill input with recipe prompt instead of auto-sending it
const initialPrompt = useMemo(() => {
return recipeConfig?.prompt || '';
}, [recipeConfig?.prompt]);
if (!recipeConfig?.prompt) 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]);

// Auto-send the prompt for scheduled executions
useEffect(() => {
const hasRequiredParams = recipeConfig?.parameters && recipeConfig.parameters.length > 0;

if (
recipeConfig?.isScheduledExecution &&
recipeConfig?.prompt &&
(!hasRequiredParams || recipeParameters) &&
messages.length === 0 &&
!isLoading &&
readyForAutoUserPrompt
) {
console.log('Auto-sending prompt for scheduled execution:', recipeConfig.prompt);
// Substitute parameters if they exist
const finalPrompt = recipeParameters
? substituteParameters(recipeConfig.prompt, recipeParameters)
: recipeConfig.prompt;

// Create and send the user message
const userMessage = createUserMessage(recipeConfig.prompt);
console.log('Auto-sending substituted prompt for scheduled execution:', finalPrompt);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can remove?


const userMessage = createUserMessage(finalPrompt);
setLastInteractionTime(Date.now());
window.electron.startPowerSaveBlocker();
append(userMessage);

// Scroll to bottom after sending
setTimeout(() => {
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
Expand All @@ -356,13 +403,27 @@ function ChatContent({
}, [
recipeConfig?.isScheduledExecution,
recipeConfig?.prompt,
recipeConfig?.parameters,
recipeParameters,
messages.length,
isLoading,
readyForAutoUserPrompt,
append,
setLastInteractionTime,
]);

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 submit
const handleSubmit = (e: React.FormEvent) => {
window.electron.startPowerSaveBlocker();
Expand Down Expand Up @@ -831,6 +892,13 @@ function ChatContent({
}}
summaryContent={summaryContent}
/>
{isParameterModalOpen && recipeConfig?.parameters && (
<ParameterInputModal
parameters={recipeConfig.parameters}
onSubmit={handleParameterSubmit}
onClose={() => setIsParameterModalOpen(false)}
/>
)}
</div>
</CurrentModelContext.Provider>
);
Expand Down
162 changes: 162 additions & 0 deletions ui/desktop/src/components/ParameterInputModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import React, { useState, useEffect } from 'react';
import { Parameter } from '../recipe';
import { Button } from './ui/button';

interface ParameterInputModalProps {
parameters: Parameter[];
onSubmit: (values: Record<string, string>) => void;
onClose: () => void;
}

const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
parameters,
onSubmit,
onClose,
}) => {
const [inputValues, setInputValues] = useState<Record<string, string>>({});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const [showCancelOptions, setShowCancelOptions] = useState(false);

// Pre-fill the form with default values from the recipe
useEffect(() => {
const initialValues: Record<string, string> = {};
parameters.forEach((param) => {
if (param.default) {
initialValues[param.key] = param.default;
}
});
setInputValues(initialValues);
}, [parameters]);

const handleChange = (name: string, value: string): void => {
setInputValues((prevValues: Record<string, string>) => ({ ...prevValues, [name]: value }));
};

const handleSubmit = (): void => {
// Clear previous validation errors
setValidationErrors({});

// Check if all *required* parameters are filled
const requiredParams: Parameter[] = parameters.filter((p) => p.requirement === 'required');
const errors: Record<string, string> = {};

requiredParams.forEach((param) => {
const value = inputValues[param.key]?.trim();
if (!value) {
errors[param.key] = `${param.description || param.key} is required`;
}
});

if (Object.keys(errors).length > 0) {
setValidationErrors(errors);
return;
}

onSubmit(inputValues);
};

const handleCancel = (): void => {
// Always show cancel options if recipe has any parameters (required or optional)
const hasAnyParams = parameters.length > 0;

if (hasAnyParams) {
setShowCancelOptions(true);
} else {
onClose();
}
};

const handleCancelOption = (option: 'new-chat' | 'back-to-form'): void => {
if (option === 'new-chat') {
// Create a new chat window without recipe config
try {
const workingDir = window.appConfig.get('GOOSE_WORKING_DIR');
console.log(`Creating new chat window without recipe, working dir: ${workingDir}`);
window.electron.createChatWindow(undefined, workingDir as string);
// Close the current window after creating the new one
window.electron.hideWindow();
} catch (error) {
console.error('Error creating new window:', error);
// Fallback: just close the modal
onClose();
}
} else {
setShowCancelOptions(false); // Go back to the parameter form
}
};

return (
<div className="fixed inset-0 backdrop-blur-sm z-50 flex justify-center items-center animate-[fadein_200ms_ease-in]">
{showCancelOptions ? (
// Cancel options modal
<div className="bg-bgApp border border-borderSubtle rounded-xl p-8 shadow-2xl w-full max-w-md">
<h2 className="text-xl font-bold text-textProminent mb-4">Cancel Recipe Setup</h2>
<p className="text-textStandard mb-6">What would you like to do?</p>
<div className="flex flex-col gap-3">
<Button
onClick={() => handleCancelOption('back-to-form')}
variant="default"
size="lg"
className="w-full rounded-full"
>
Back to Parameter Form
</Button>
<Button
onClick={() => handleCancelOption('new-chat')}
variant="outline"
size="lg"
className="w-full rounded-full"
>
Start New Chat (No Recipe)
</Button>
</div>
</div>
) : (
// Main parameter form
<div className="bg-bgApp border border-borderSubtle rounded-xl p-8 shadow-2xl w-full max-w-lg">
<h2 className="text-xl font-bold text-textProminent mb-6">Recipe Parameters</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{parameters.map((param) => (
<div key={param.key}>
<label className="block text-md font-medium text-textStandard mb-2">
{param.description || param.key}
{param.requirement === 'required' && <span className="text-red-500 ml-1">*</span>}
</label>
<input
type="text"
value={inputValues[param.key] || ''}
onChange={(e) => handleChange(param.key, e.target.value)}
className={`w-full p-3 border rounded-lg bg-bgSubtle text-textStandard focus:outline-none focus:ring-2 ${
validationErrors[param.key]
? 'border-red-500 focus:ring-red-500'
: 'border-borderSubtle focus:ring-borderProminent'
}`}
placeholder={param.default || `Enter value for ${param.key}...`}
/>
{validationErrors[param.key] && (
<p className="text-red-500 text-sm mt-1">{validationErrors[param.key]}</p>
)}
</div>
))}
<div className="flex justify-end gap-4 pt-6">
<Button
type="button"
onClick={handleCancel}
variant="outline"
size="default"
className="rounded-full"
>
Cancel
</Button>
<Button type="submit" variant="default" size="default" className="rounded-full">
Start Recipe
</Button>
</div>
</form>
</div>
)}
</div>
);
};

export default ParameterInputModal;
Loading