Restore recipe parameters functionality (#3530)

This commit is contained in:
Zane
2025-07-22 10:31:44 -07:00
committed by GitHub
parent 914c537965
commit 246ba19256
28 changed files with 1057 additions and 479 deletions
@@ -80,6 +80,7 @@ pub async fn handle_configure() -> Result<(), Box<dyn Error>> {
display_name: Some(goose::config::DEFAULT_DISPLAY_NAME.to_string()),
timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT),
bundled: Some(true),
description: None,
},
})?;
}
@@ -558,6 +559,7 @@ pub fn configure_extensions_dialog() -> Result<(), Box<dyn Error>> {
display_name: Some(display_name),
timeout: Some(timeout),
bundled: Some(true),
description: None,
},
})?;
@@ -33,6 +33,7 @@ pub fn extract_recipe_info_from_cli(
name,
values: None,
sequential_when_repeated: true,
description: None,
};
all_sub_recipes.push(additional_sub_recipe);
}
+1
View File
@@ -304,6 +304,7 @@ impl Session {
// TODO: should set a timeout
timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT),
bundled: None,
description: None,
};
self.agent
.add_extension(config)
@@ -19,9 +19,12 @@ fn main() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&output_path, schema).unwrap();
println!(
fs::write(&output_path, &schema).unwrap();
eprintln!(
"Successfully generated OpenAPI schema at {}",
output_path.display()
);
// Output the schema to stdout for piping
println!("{}", schema);
}
@@ -251,6 +251,7 @@ async fn add_extension(
display_name,
timeout,
bundled: None,
description: None,
},
ExtensionConfigRequest::Frontend {
name,
+2
View File
@@ -163,6 +163,7 @@ pub enum ExtensionConfig {
/// The name used to identify this extension
name: String,
display_name: Option<String>, // needed for the UI
description: Option<String>,
timeout: Option<u64>,
/// Whether this extension is bundled with Goose
#[serde(default)]
@@ -208,6 +209,7 @@ impl Default for ExtensionConfig {
Self::Builtin {
name: config::DEFAULT_EXTENSION.to_string(),
display_name: Some(config::DEFAULT_DISPLAY_NAME.to_string()),
description: None,
timeout: Some(config::DEFAULT_EXTENSION_TIMEOUT),
bundled: Some(true),
}
@@ -242,6 +242,7 @@ impl ExtensionManager {
ExtensionConfig::Builtin {
name,
display_name: _,
description: _,
timeout,
bundled: _,
} => {
+1
View File
@@ -46,6 +46,7 @@ impl ExtensionConfigManager {
display_name: Some(DEFAULT_DISPLAY_NAME.to_string()),
timeout: Some(DEFAULT_EXTENSION_TIMEOUT),
bundled: Some(true),
description: Some(DEFAULT_EXTENSION_DESCRIPTION.to_string()),
},
},
)]);
+5
View File
@@ -151,6 +151,8 @@ pub struct SubRecipe {
pub values: Option<HashMap<String, String>>,
#[serde(default)]
pub sequential_when_repeated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
fn deserialize_value_map_as_string<'de, D>(
@@ -204,6 +206,7 @@ pub enum RecipeParameterInputType {
Boolean,
Date,
File,
Select,
}
impl fmt::Display for RecipeParameterInputType {
@@ -224,6 +227,8 @@ pub struct RecipeParameter {
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<Vec<String>>,
}
/// Builder for creating Recipe instances
+17 -1
View File
@@ -1527,6 +1527,10 @@
"description": "Whether this extension is bundled with Goose",
"nullable": true
},
"description": {
"type": "string",
"nullable": true
},
"display_name": {
"type": "string",
"nullable": true
@@ -2297,6 +2301,13 @@
"key": {
"type": "string"
},
"options": {
"type": "array",
"items": {
"type": "string"
},
"nullable": true
},
"requirement": {
"$ref": "#/components/schemas/RecipeParameterRequirement"
}
@@ -2309,7 +2320,8 @@
"number",
"boolean",
"date",
"file"
"file",
"select"
]
},
"RecipeParameterRequirement": {
@@ -2715,6 +2727,10 @@
"path"
],
"properties": {
"description": {
"type": "string",
"nullable": true
},
"name": {
"type": "string"
},
+37 -13
View File
@@ -15,7 +15,8 @@ import AnnouncementModal from './components/AnnouncementModal';
import { generateSessionId } from './sessions';
import ProviderGuard from './components/ProviderGuard';
import Hub, { type ChatType } from './components/hub';
import { ChatType } from './types/chat';
import Hub from './components/hub';
import Pair from './components/pair';
import SettingsView, { SettingsViewOptions } from './components/settings/SettingsView';
import SessionsView from './components/sessions/SessionsView';
@@ -182,6 +183,12 @@ const PairRouteWrapper = ({
// Check if we have a resumed session or recipe config from navigation state
useEffect(() => {
// Only process if we actually have navigation state
if (!location.state) {
console.log('No navigation state, preserving existing chat state');
return;
}
const resumedSession = location.state?.resumedSession as SessionDetails | undefined;
const recipeConfig = location.state?.recipeConfig as Recipe | undefined;
const resetChat = location.state?.resetChat as boolean | undefined;
@@ -205,30 +212,47 @@ const PairRouteWrapper = ({
// Clear the navigation state to prevent reloading on navigation
window.history.replaceState({}, document.title);
} else if (recipeConfig) {
console.log('Loading recipe config in pair view:', recipeConfig.title);
} else if (recipeConfig && resetChat) {
console.log('Loading new recipe config in pair view:', recipeConfig.title);
// Load recipe config and optionally reset chat
// Use the ref to get the current chat state without adding it as a dependency
const currentChat = chatRef.current;
const updatedChat: ChatType = {
...currentChat,
recipeConfig: recipeConfig,
id: chatRef.current.id, // Keep the same ID
title: recipeConfig.title || 'Recipe Chat',
messages: [], // Clear messages to start fresh
messageHistoryIndex: 0,
recipeConfig: recipeConfig,
recipeParameters: null, // Clear parameters for new recipe
};
if (resetChat) {
updatedChat.messages = [];
updatedChat.messageHistoryIndex = 0;
}
// Update both the local chat state and the app-level pairChat state
setChat(updatedChat);
setPairChat(updatedChat);
// Clear the navigation state to prevent reloading on navigation
window.history.replaceState({}, document.title);
} else if (recipeConfig && !chatRef.current.recipeConfig) {
// Only set recipe config if we don't already have one (e.g., from deeplinks)
const updatedChat: ChatType = {
...chatRef.current,
recipeConfig: recipeConfig,
title: recipeConfig.title || chatRef.current.title,
};
// Update both the local chat state and the app-level pairChat state
setChat(updatedChat);
setPairChat(updatedChat);
// Clear the navigation state to prevent reloading on navigation
window.history.replaceState({}, document.title);
} else if (location.state) {
// We have navigation state but it doesn't match our conditions
// Clear it to prevent future processing, but don't modify chat state
console.log('Clearing unprocessed navigation state');
window.history.replaceState({}, document.title);
}
// If we have a recipe config but resetChat is false and we already have a recipe,
// do nothing - just continue with the existing chat state
}, [location.state, setChat, setPairChat]);
return (
+4 -1
View File
@@ -165,6 +165,7 @@ export type ExtensionConfig = {
* Whether this extension is bundled with Goose
*/
bundled?: boolean | null;
description?: string | null;
display_name?: string | null;
/**
* The name used to identify this extension
@@ -451,10 +452,11 @@ export type RecipeParameter = {
description: string;
input_type: RecipeParameterInputType;
key: string;
options?: Array<string> | null;
requirement: RecipeParameterRequirement;
};
export type RecipeParameterInputType = 'string' | 'number' | 'boolean' | 'date' | 'file';
export type RecipeParameterInputType = 'string' | 'number' | 'boolean' | 'date' | 'file' | 'select';
export type RecipeParameterRequirement = 'required' | 'optional' | 'user_prompt';
@@ -622,6 +624,7 @@ export type Settings = {
};
export type SubRecipe = {
description?: string | null;
name: string;
path: string;
sequential_when_repeated?: boolean;
+20 -38
View File
@@ -67,19 +67,12 @@ import { useSessionContinuation } from '../hooks/useSessionContinuation';
import { useFileDrop } from '../hooks/useFileDrop';
import { useCostTracking } from '../hooks/useCostTracking';
import { Message } from '../types/message';
import { Recipe } from '../recipe';
// Context for sharing current model info
const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null);
export const useCurrentModelInfo = () => useContext(CurrentModelContext);
export interface ChatType {
id: string;
title: string;
messageHistoryIndex: number;
messages: Message[];
recipeConfig?: Recipe | null; // Add recipe configuration to chat state
}
import { ChatType } from '../types/chat';
interface BaseChatProps {
chat: ChatType;
@@ -206,17 +199,29 @@ function BaseChatContent({
// Reset recipe usage tracking when recipe changes
useEffect(() => {
if (recipeConfig?.title !== currentRecipeTitle) {
setCurrentRecipeTitle(recipeConfig?.title || null);
setHasStartedUsingRecipe(false);
const previousTitle = currentRecipeTitle;
const newTitle = recipeConfig?.title || null;
const hasRecipeChanged = newTitle !== currentRecipeTitle;
// Clear existing messages when a new recipe is loaded
if (recipeConfig?.title && recipeConfig.title !== currentRecipeTitle) {
if (hasRecipeChanged) {
setCurrentRecipeTitle(newTitle);
const isSwitchingBetweenRecipes = previousTitle && newTitle;
const isInitialRecipeLoad = !previousTitle && newTitle && messages.length === 0;
const hasExistingConversation = newTitle && messages.length > 0;
if (isSwitchingBetweenRecipes) {
console.log('Switching from recipe:', previousTitle, 'to:', newTitle);
setHasStartedUsingRecipe(false);
setMessages([]);
setAncestorMessages([]);
} else if (isInitialRecipeLoad) {
setHasStartedUsingRecipe(false);
} else if (hasExistingConversation) {
setHasStartedUsingRecipe(true);
}
}
}, [recipeConfig?.title, currentRecipeTitle, setMessages, setAncestorMessages]);
}, [recipeConfig?.title, currentRecipeTitle, messages.length, setMessages, setAncestorMessages]);
// Handle recipe auto-execution
useEffect(() => {
@@ -429,29 +434,6 @@ function BaseChatContent({
{error.message || 'Honk! Goose experienced an error while responding'}
</div>
{/* Expandable Error Details */}
<details className="w-full max-w-2xl mb-2">
<summary className="text-xs text-textSubtle cursor-pointer hover:text-textStandard transition-colors">
Error details
</summary>
<div className="mt-2 p-3 bg-bgSubtle border border-borderSubtle rounded-lg text-xs font-mono text-textStandard">
<div className="mb-2">
<strong>Error Type:</strong> {error.name || 'Unknown'}
</div>
<div className="mb-2">
<strong>Message:</strong> {error.message || 'No message'}
</div>
{error.stack && (
<div>
<strong>Stack Trace:</strong>
<pre className="mt-1 whitespace-pre-wrap text-xs overflow-x-auto">
{error.stack}
</pre>
</div>
)}
</div>
</details>
{/* Regular retry button for non-token-limit errors */}
<div
className="px-3 py-2 mt-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"
@@ -507,7 +489,7 @@ function BaseChatContent({
isLoading={isLoading}
onStop={onStopGoose}
commandHistory={commandHistory}
initialValue={_input || initialPrompt}
initialValue={_input || (messages.length === 0 ? initialPrompt : '')}
setView={setView}
numTokens={sessionTokenCount}
inputTokens={sessionInputTokens || localInputTokens}
+357 -355
View File
@@ -896,373 +896,375 @@ export default function ChatInput({
onDrop={handleLocalDrop}
onDragOver={handleLocalDragOver}
>
<form onSubmit={onFormSubmit} className="flex flex-col">
{/* Input row with inline action buttons */}
<div className="relative flex items-end">
<div className="relative flex-1">
<textarea
data-testid="chat-input"
autoFocus
id="dynamic-textarea"
placeholder={isRecording ? '' : '⌘↑/⌘↓ to navigate messages'}
value={displayValue}
onChange={handleChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
ref={textAreaRef}
rows={1}
style={{
maxHeight: `${maxHeight}px`,
overflowY: 'auto',
opacity: isRecording ? 0 : 1,
}}
className="w-full outline-none border-none focus:ring-0 bg-transparent px-3 pt-3 pb-1.5 pr-20 text-sm resize-none text-textStandard placeholder:text-textPlaceholder"
/>
{isRecording && (
<div className="absolute inset-0 flex items-center pl-4 pr-20 pt-3 pb-1.5">
<WaveformVisualizer
audioContext={audioContext}
analyser={analyser}
isRecording={isRecording}
/>
</div>
)}
</div>
{/* Inline action buttons on the right */}
<div className="flex items-center gap-1 px-2 relative">
{/* Microphone button - show if dictation is enabled, disable if not configured */}
{dictationSettings?.enabled && (
<>
{!canUseDictation ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
type="button"
size="sm"
shape="round"
variant="outline"
onClick={() => {}}
disabled={true}
className="bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600 rounded-full px-6 py-2"
>
<Microphone />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{dictationSettings.provider === 'openai'
? 'OpenAI API key is not configured. Set it up in Settings > Models.'
: dictationSettings.provider === 'elevenlabs'
? 'ElevenLabs API key is not configured. Set it up in Settings > Chat > Voice Dictation.'
: 'Dictation provider is not properly configured.'}
</TooltipContent>
</Tooltip>
) : (
<Button
type="button"
size="sm"
shape="round"
variant="outline"
onClick={() => {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
}}
disabled={isTranscribing}
className={`rounded-full px-6 py-2 ${
isRecording
? 'bg-red-500 text-white hover:bg-red-600 border-red-500'
: isTranscribing
? 'bg-slate-600 text-white cursor-not-allowed animate-pulse border-slate-600'
: 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600'
}`}
>
<Microphone />
</Button>
)}
</>
)}
{/* Send/Stop button */}
{isLoading ? (
<Button
type="button"
onClick={onStop}
size="sm"
shape="round"
variant="outline"
className="bg-slate-600 text-white hover:bg-slate-700 border-slate-600 rounded-full px-6 py-2"
>
<Stop />
</Button>
) : (
<Button
type="submit"
size="sm"
shape="round"
variant="outline"
disabled={
!hasSubmittableContent ||
isAnyImageLoading ||
isAnyDroppedFileLoading ||
isRecording ||
isTranscribing ||
isLoadingSummary
}
className={`rounded-full px-10 py-2 flex items-center gap-2 ${
!hasSubmittableContent ||
isAnyImageLoading ||
isAnyDroppedFileLoading ||
isRecording ||
isTranscribing ||
isLoadingSummary
? 'bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600'
: 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600 hover:cursor-pointer'
}`}
title={
isLoadingSummary
? 'Summarizing conversation...'
: isAnyImageLoading
? 'Waiting for images to save...'
: isAnyDroppedFileLoading
? 'Processing dropped files...'
: isRecording
? 'Recording...'
: isTranscribing
? 'Transcribing...'
: 'Send'
}
>
<Send className="w-4 h-4" />
<span className="text-sm">Send</span>
</Button>
)}
{/* Recording/transcribing status indicator - positioned above the button row */}
{(isRecording || isTranscribing) && (
<div className="absolute right-0 -top-8 bg-background-default px-2 py-1 rounded text-xs whitespace-nowrap shadow-md border border-borderSubtle">
{isTranscribing ? (
<span className="text-blue-500 flex items-center gap-1">
<span className="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
Transcribing...
</span>
) : (
<span
className={`flex items-center gap-2 ${estimatedSize > 20 ? 'text-orange-500' : 'text-textSubtle'}`}
>
<span className="inline-block w-2 h-2 bg-red-500 rounded-full animate-pulse" />
{Math.floor(recordingDuration)}s ~{estimatedSize.toFixed(1)}MB
{estimatedSize > 20 && <span className="text-xs">(near 25MB limit)</span>}
</span>
)}
</div>
)}
</div>
{/* Input row with inline action buttons wrapped in form */}
<form onSubmit={onFormSubmit} className="relative flex items-end">
<div className="relative flex-1">
<textarea
data-testid="chat-input"
autoFocus
id="dynamic-textarea"
placeholder={isRecording ? '' : '⌘↑/⌘↓ to navigate messages'}
value={displayValue}
onChange={handleChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
ref={textAreaRef}
rows={1}
style={{
maxHeight: `${maxHeight}px`,
overflowY: 'auto',
opacity: isRecording ? 0 : 1,
}}
className="w-full outline-none border-none focus:ring-0 bg-transparent px-3 pt-3 pb-1.5 pr-20 text-sm resize-none text-textStandard placeholder:text-textPlaceholder"
/>
{isRecording && (
<div className="absolute inset-0 flex items-center pl-4 pr-20 pt-3 pb-1.5">
<WaveformVisualizer
audioContext={audioContext}
analyser={analyser}
isRecording={isRecording}
/>
</div>
)}
</div>
{/* Combined files and images preview */}
{(pastedImages.length > 0 || allDroppedFiles.length > 0) && (
<div className="flex flex-wrap gap-2 p-2 border-t border-borderSubtle">
{/* Render pasted images first */}
{pastedImages.map((img) => (
<div key={img.id} className="relative group w-20 h-20">
{img.dataUrl && (
<img
src={img.dataUrl}
alt={`Pasted image ${img.id}`}
className={`w-full h-full object-cover rounded border ${img.error ? 'border-red-500' : 'border-borderStandard'}`}
/>
)}
{img.isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 rounded">
<div className="animate-spin rounded-full h-6 w-6 border-t-2 border-b-2 border-white"></div>
</div>
)}
{img.error && !img.isLoading && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black bg-opacity-75 rounded p-1 text-center">
<p className="text-red-400 text-[10px] leading-tight break-all mb-1">
{img.error.substring(0, 50)}
</p>
{img.dataUrl && (
{/* Inline action buttons on the right */}
<div className="flex items-center gap-1 px-2 relative">
{/* Microphone button - show if dictation is enabled, disable if not configured */}
{dictationSettings?.enabled && (
<>
{!canUseDictation ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
type="button"
onClick={() => handleRetryImageSave(img.id)}
title="Retry saving image"
size="sm"
shape="round"
variant="outline"
size="xs"
onClick={() => {}}
disabled={true}
className="bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600 rounded-full px-6 py-2"
>
Retry
<Microphone />
</Button>
)}
</div>
)}
{!img.isLoading && (
<Button
type="button"
shape="round"
onClick={() => handleRemovePastedImage(img.id)}
className="absolute -top-1 -right-1 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity z-10"
aria-label="Remove image"
variant="outline"
size="xs"
>
<Close />
</Button>
)}
</div>
))}
</span>
</TooltipTrigger>
<TooltipContent>
{dictationSettings.provider === 'openai'
? 'OpenAI API key is not configured. Set it up in Settings > Models.'
: dictationSettings.provider === 'elevenlabs'
? 'ElevenLabs API key is not configured. Set it up in Settings > Chat > Voice Dictation.'
: 'Dictation provider is not properly configured.'}
</TooltipContent>
</Tooltip>
) : (
<Button
type="button"
size="sm"
shape="round"
variant="outline"
onClick={() => {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
}}
disabled={isTranscribing}
className={`rounded-full px-6 py-2 ${
isRecording
? 'bg-red-500 text-white hover:bg-red-600 border-red-500'
: isTranscribing
? 'bg-slate-600 text-white cursor-not-allowed animate-pulse border-slate-600'
: 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600'
}`}
>
<Microphone />
</Button>
)}
</>
)}
{/* Render dropped files after pasted images */}
{allDroppedFiles.map((file) => (
<div key={file.id} className="relative group">
{file.isImage ? (
// Image preview
<div className="w-20 h-20">
{file.dataUrl && (
<img
src={file.dataUrl}
alt={file.name}
className={`w-full h-full object-cover rounded border ${file.error ? 'border-red-500' : 'border-borderStandard'}`}
/>
)}
{file.isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 rounded">
<div className="animate-spin rounded-full h-6 w-6 border-t-2 border-b-2 border-white"></div>
</div>
)}
{file.error && !file.isLoading && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black bg-opacity-75 rounded p-1 text-center">
<p className="text-red-400 text-[10px] leading-tight break-all">
{file.error.substring(0, 30)}
</p>
</div>
)}
</div>
) : (
// File box preview
<div className="flex items-center gap-2 px-3 py-2 bg-bgSubtle border border-borderStandard rounded-lg min-w-[120px] max-w-[200px]">
<div className="flex-shrink-0 w-8 h-8 bg-background-default border border-borderSubtle rounded flex items-center justify-center text-xs font-mono text-textSubtle">
{file.name.split('.').pop()?.toUpperCase() || 'FILE'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-textStandard truncate" title={file.name}>
{file.name}
</p>
<p className="text-xs text-textSubtle">{file.type || 'Unknown type'}</p>
</div>
</div>
)}
{!file.isLoading && (
<Button
type="button"
shape="round"
onClick={() => handleRemoveDroppedFile(file.id)}
className="absolute -top-1 -right-1 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity z-10"
aria-label="Remove file"
variant="outline"
size="xs"
>
<Close />
</Button>
)}
</div>
))}
</div>
)}
{/* Send/Stop button */}
{isLoading ? (
<Button
type="button"
onClick={onStop}
size="sm"
shape="round"
variant="outline"
className="bg-slate-600 text-white hover:bg-slate-700 border-slate-600 rounded-full px-6 py-2"
>
<Stop />
</Button>
) : (
<Button
type="submit"
size="sm"
shape="round"
variant="outline"
disabled={
!hasSubmittableContent ||
isAnyImageLoading ||
isAnyDroppedFileLoading ||
isRecording ||
isTranscribing ||
isLoadingSummary
}
className={`rounded-full px-10 py-2 flex items-center gap-2 ${
!hasSubmittableContent ||
isAnyImageLoading ||
isAnyDroppedFileLoading ||
isRecording ||
isTranscribing ||
isLoadingSummary
? 'bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600'
: 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600 hover:cursor-pointer'
}`}
title={
isLoadingSummary
? 'Summarizing conversation...'
: isAnyImageLoading
? 'Waiting for images to save...'
: isAnyDroppedFileLoading
? 'Processing dropped files...'
: isRecording
? 'Recording...'
: isTranscribing
? 'Transcribing...'
: 'Send'
}
>
<Send className="w-4 h-4" />
<span className="text-sm">Send</span>
</Button>
)}
{/* Secondary actions and controls row below input */}
<div className="flex flex-row items-center gap-1 p-2 relative">
{/* Directory path */}
<DirSwitcher hasMessages={messages.length > 0} className="mr-0" />
<div className="w-px h-4 bg-border-default mx-2" />
{/* Attach button */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex items-center justify-center text-text-default/70 hover:text-text-default text-xs cursor-pointer transition-colors"
onClick={handleFileSelect}
>
<Attach className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>Attach file or directory</TooltipContent>
</Tooltip>
<div className="w-px h-4 bg-border-default mx-2" />
{/* Model selector, mode selector, alerts, summarize button */}
<div className="flex flex-row items-center">
{/* Cost Tracker */}
{COST_TRACKING_ENABLED && (
<>
<div className="flex items-center h-full ml-1 mr-1">
<CostTracker
inputTokens={inputTokens}
outputTokens={outputTokens}
sessionCosts={sessionCosts}
/>
</div>
</>
)}
<Tooltip>
<div>
<ModelsBottomBar
dropdownRef={dropdownRef}
setView={setView}
alerts={alerts}
recipeConfig={recipeConfig}
hasMessages={messages.length > 0}
/>
</div>
</Tooltip>
<div className="w-px h-4 bg-border-default mx-2" />
<BottomMenuModeSelection />
{messages.length > 0 && (
<ManualSummarizeButton
messages={messages}
isLoading={isLoading}
setMessages={setMessages}
/>
)}
<div className="w-px h-4 bg-border-default mx-2" />
<div className="flex items-center h-full">
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex items-center justify-center text-text-default/70 hover:text-text-default text-xs cursor-pointer"
onClick={() => setIsGoosehintsModalOpen?.(true)}
>
<FolderKey size={16} />
</button>
</TooltipTrigger>
<TooltipContent>Configure goosehints</TooltipContent>
</Tooltip>
{/* Recording/transcribing status indicator - positioned above the button row */}
{(isRecording || isTranscribing) && (
<div className="absolute right-0 -top-8 bg-background-default px-2 py-1 rounded text-xs whitespace-nowrap shadow-md border border-borderSubtle">
{isTranscribing ? (
<span className="text-blue-500 flex items-center gap-1">
<span className="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
Transcribing...
</span>
) : (
<span
className={`flex items-center gap-2 ${estimatedSize > 20 ? 'text-orange-500' : 'text-textSubtle'}`}
>
<span className="inline-block w-2 h-2 bg-red-500 rounded-full animate-pulse" />
{Math.floor(recordingDuration)}s ~{estimatedSize.toFixed(1)}MB
{estimatedSize > 20 && <span className="text-xs">(near 25MB limit)</span>}
</span>
)}
</div>
</div>
<MentionPopover
ref={mentionPopoverRef}
isOpen={mentionPopover.isOpen}
onClose={() => setMentionPopover((prev) => ({ ...prev, isOpen: false }))}
onSelect={handleMentionFileSelect}
position={mentionPopover.position}
query={mentionPopover.query}
selectedIndex={mentionPopover.selectedIndex}
onSelectedIndexChange={(index) =>
setMentionPopover((prev) => ({ ...prev, selectedIndex: index }))
}
/>
)}
</div>
</form>
{/* Combined files and images preview */}
{(pastedImages.length > 0 || allDroppedFiles.length > 0) && (
<div className="flex flex-wrap gap-2 p-2 border-t border-borderSubtle">
{/* Render pasted images first */}
{pastedImages.map((img) => (
<div key={img.id} className="relative group w-20 h-20">
{img.dataUrl && (
<img
src={img.dataUrl}
alt={`Pasted image ${img.id}`}
className={`w-full h-full object-cover rounded border ${img.error ? 'border-red-500' : 'border-borderStandard'}`}
/>
)}
{img.isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 rounded">
<div className="animate-spin rounded-full h-6 w-6 border-t-2 border-b-2 border-white"></div>
</div>
)}
{img.error && !img.isLoading && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black bg-opacity-75 rounded p-1 text-center">
<p className="text-red-400 text-[10px] leading-tight break-all mb-1">
{img.error.substring(0, 50)}
</p>
{img.dataUrl && (
<Button
type="button"
onClick={() => handleRetryImageSave(img.id)}
title="Retry saving image"
variant="outline"
size="xs"
>
Retry
</Button>
)}
</div>
)}
{!img.isLoading && (
<Button
type="button"
shape="round"
onClick={() => handleRemovePastedImage(img.id)}
className="absolute -top-1 -right-1 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity z-10"
aria-label="Remove image"
variant="outline"
size="xs"
>
<Close />
</Button>
)}
</div>
))}
{/* Render dropped files after pasted images */}
{allDroppedFiles.map((file) => (
<div key={file.id} className="relative group">
{file.isImage ? (
// Image preview
<div className="w-20 h-20">
{file.dataUrl && (
<img
src={file.dataUrl}
alt={file.name}
className={`w-full h-full object-cover rounded border ${file.error ? 'border-red-500' : 'border-borderStandard'}`}
/>
)}
{file.isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 rounded">
<div className="animate-spin rounded-full h-6 w-6 border-t-2 border-b-2 border-white"></div>
</div>
)}
{file.error && !file.isLoading && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black bg-opacity-75 rounded p-1 text-center">
<p className="text-red-400 text-[10px] leading-tight break-all">
{file.error.substring(0, 30)}
</p>
</div>
)}
</div>
) : (
// File box preview
<div className="flex items-center gap-2 px-3 py-2 bg-bgSubtle border border-borderStandard rounded-lg min-w-[120px] max-w-[200px]">
<div className="flex-shrink-0 w-8 h-8 bg-background-default border border-borderSubtle rounded flex items-center justify-center text-xs font-mono text-textSubtle">
{file.name.split('.').pop()?.toUpperCase() || 'FILE'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-textStandard truncate" title={file.name}>
{file.name}
</p>
<p className="text-xs text-textSubtle">{file.type || 'Unknown type'}</p>
</div>
</div>
)}
{!file.isLoading && (
<Button
type="button"
shape="round"
onClick={() => handleRemoveDroppedFile(file.id)}
className="absolute -top-1 -right-1 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity z-10"
aria-label="Remove file"
variant="outline"
size="xs"
>
<Close />
</Button>
)}
</div>
))}
</div>
)}
{/* Secondary actions and controls row below input */}
<div className="flex flex-row items-center gap-1 p-2 relative">
{/* Directory path */}
<DirSwitcher hasMessages={messages.length > 0} className="mr-0" />
<div className="w-px h-4 bg-border-default mx-2" />
{/* Attach button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
onClick={handleFileSelect}
variant="ghost"
size="sm"
className="flex items-center justify-center text-text-default/70 hover:text-text-default text-xs cursor-pointer transition-colors"
>
<Attach className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Attach file or directory</TooltipContent>
</Tooltip>
<div className="w-px h-4 bg-border-default mx-2" />
{/* Model selector, mode selector, alerts, summarize button */}
<div className="flex flex-row items-center">
{/* Cost Tracker */}
{COST_TRACKING_ENABLED && (
<>
<div className="flex items-center h-full ml-1 mr-1">
<CostTracker
inputTokens={inputTokens}
outputTokens={outputTokens}
sessionCosts={sessionCosts}
/>
</div>
</>
)}
<Tooltip>
<div>
<ModelsBottomBar
dropdownRef={dropdownRef}
setView={setView}
alerts={alerts}
recipeConfig={recipeConfig}
hasMessages={messages.length > 0}
/>
</div>
</Tooltip>
<div className="w-px h-4 bg-border-default mx-2" />
<BottomMenuModeSelection />
{messages.length > 0 && (
<ManualSummarizeButton
messages={messages}
isLoading={isLoading}
setMessages={setMessages}
/>
)}
<div className="w-px h-4 bg-border-default mx-2" />
<div className="flex items-center h-full">
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => setIsGoosehintsModalOpen?.(true)}
variant="ghost"
size="sm"
className="flex items-center justify-center text-text-default/70 hover:text-text-default text-xs cursor-pointer"
>
<FolderKey size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>Configure goosehints</TooltipContent>
</Tooltip>
</div>
</div>
<MentionPopover
ref={mentionPopoverRef}
isOpen={mentionPopover.isOpen}
onClose={() => setMentionPopover((prev) => ({ ...prev, isOpen: false }))}
onSelect={handleMentionFileSelect}
position={mentionPopover.position}
query={mentionPopover.query}
selectedIndex={mentionPopover.selectedIndex}
onSelectedIndexChange={(index) =>
setMentionPopover((prev) => ({ ...prev, selectedIndex: index }))
}
/>
</div>
</div>
);
}
@@ -21,8 +21,10 @@ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
useEffect(() => {
const initialValues: Record<string, string> = {};
parameters.forEach((param) => {
if (param.default) {
initialValues[param.key] = param.default;
if (param.requirement === 'optional' && param.default) {
const defaultValue =
param.input_type === 'boolean' ? param.default.toLowerCase() : param.default;
initialValues[param.key] = defaultValue;
}
});
setInputValues(initialValues);
@@ -122,17 +124,53 @@ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
{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}...`}
/>
{/* Render different input types */}
{param.input_type === 'select' && param.options ? (
<select
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'
}`}
>
<option value="">Select an option...</option>
{param.options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
) : param.input_type === 'boolean' ? (
<select
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'
}`}
>
<option value="">Select...</option>
<option value="true">True</option>
<option value="false">False</option>
</select>
) : (
<input
type={param.input_type === 'number' ? 'number' : '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>
)}
+51 -8
View File
@@ -141,7 +141,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
const formattedParameters = parameters.map((param) => {
const formattedParam: Parameter = {
key: param.key,
input_type: 'string',
input_type: param.input_type || 'string', // Use actual input_type instead of hardcoded 'string'
requirement: param.requirement,
description: param.description,
};
@@ -152,6 +152,11 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
formattedParam.default = param.default;
}
// Add options for select input type
if (param.input_type === 'select' && param.options) {
formattedParam.options = param.options.filter((opt) => opt.trim() !== ''); // Filter empty options when saving
}
return formattedParam;
});
@@ -460,13 +465,51 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
<div className="text-red-500 text-sm mt-1">{errors.instructions}</div>
)}
</div>
{parameters.map((parameter: Parameter) => (
<ParameterInput
key={parameter.key}
parameter={parameter}
onChange={(name, value) => handleParameterChange(name, value)}
/>
))}
{/* Parameters section */}
<div className="pt-3 pb-6 border-b-2 border-borderSubtle">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-textProminent">Parameters</h3>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
const newKey = `param_${Date.now()}`;
const newParam: Parameter = {
key: newKey,
description: `Enter value for ${newKey}`,
input_type: 'string',
requirement: 'required',
};
setParameters((prev) => [...prev, newParam]);
}}
className="px-3 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors text-sm"
>
Add Parameter
</button>
{parameters.length > 0 && (
<button
type="button"
onClick={() => {
if (parameters.length > 0) {
setParameters((prev) => prev.slice(0, -1));
}
}}
className="px-3 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors text-sm"
>
Remove Last
</button>
)}
</div>
</div>
{parameters.map((parameter: Parameter) => (
<ParameterInput
key={parameter.key}
parameter={parameter}
onChange={(name, value) => handleParameterChange(name, value)}
/>
))}
</div>
<div className="pt-3 pb-6 border-b-2 border-borderSubtle">
<RecipeExpandableInfo
infoLabel="Initial Prompt"
+365 -2
View File
@@ -21,7 +21,7 @@ import { Card } from './ui/card';
import { Button } from './ui/button';
import { Skeleton } from './ui/skeleton';
import { MainPanelLayout } from './Layout/MainPanelLayout';
import { Recipe, decodeRecipe } from '../recipe';
import { Recipe, decodeRecipe, generateDeepLink } from '../recipe';
import { toastSuccess, toastError } from '../toasts';
import { useEscapeKey } from '../hooks/useEscapeKey';
@@ -43,6 +43,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) {
const [importRecipeName, setImportRecipeName] = useState('');
const [importGlobal, setImportGlobal] = useState(true);
const [importing, setImporting] = useState(false);
const [previewDeeplink, setPreviewDeeplink] = useState<string>('');
// Create Recipe state
const [showCreateDialog, setShowCreateDialog] = useState(false);
@@ -160,9 +161,18 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) {
}
};
const handlePreviewRecipe = (savedRecipe: SavedRecipe) => {
const handlePreviewRecipe = async (savedRecipe: SavedRecipe) => {
setSelectedRecipe(savedRecipe);
setShowPreview(true);
// Generate deeplink for preview
try {
const deeplink = await generateDeepLink(savedRecipe.recipe);
setPreviewDeeplink(deeplink);
} catch (error) {
console.error('Failed to generate deeplink for preview:', error);
setPreviewDeeplink('Error generating deeplink');
}
};
// Function to parse deeplink and extract recipe
@@ -565,11 +575,79 @@ Parameters you can use:
</div>
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Deeplink</h4>
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-text-muted">
Copy this link to share with friends or paste directly in Chrome to open
</div>
<Button
onClick={async () => {
try {
const deeplink =
previewDeeplink || (await generateDeepLink(selectedRecipe.recipe));
navigator.clipboard.writeText(deeplink);
toastSuccess({
title: 'Copied!',
msg: 'Recipe deeplink copied to clipboard',
});
} catch (error) {
toastError({
title: 'Copy Failed',
msg: 'Failed to copy deeplink to clipboard',
traceback: error instanceof Error ? error.message : String(error),
});
}
}}
variant="ghost"
size="sm"
className="ml-4 p-2 hover:bg-background-default rounded-lg transition-colors flex items-center"
>
<span className="text-sm text-text-muted">Copy</span>
</Button>
</div>
<div
onClick={async () => {
try {
const deeplink =
previewDeeplink || (await generateDeepLink(selectedRecipe.recipe));
navigator.clipboard.writeText(deeplink);
toastSuccess({
title: 'Copied!',
msg: 'Recipe deeplink copied to clipboard',
});
} catch (error) {
toastError({
title: 'Copy Failed',
msg: 'Failed to copy deeplink to clipboard',
traceback: error instanceof Error ? error.message : String(error),
});
}
}}
className="text-sm truncate font-mono cursor-pointer text-text-standard"
>
{previewDeeplink || 'Generating deeplink...'}
</div>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Description</h4>
<p className="text-text-muted">{selectedRecipe.recipe.description}</p>
</div>
{selectedRecipe.recipe.version && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Version</h4>
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
<span className="text-sm text-text-muted font-mono">
{selectedRecipe.recipe.version}
</span>
</div>
</div>
)}
{selectedRecipe.recipe.instructions && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Instructions</h4>
@@ -592,6 +670,65 @@ Parameters you can use:
</div>
)}
{selectedRecipe.recipe.parameters && selectedRecipe.recipe.parameters.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Parameters</h4>
<div className="space-y-3">
{selectedRecipe.recipe.parameters.map((param, index) => (
<div
key={index}
className="bg-background-muted border border-border-subtle p-3 rounded-lg"
>
<div className="flex items-center gap-2 mb-2">
<code className="text-sm font-mono bg-background-default px-2 py-1 rounded text-text-standard">
{param.key}
</code>
<span className="text-xs px-2 py-1 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{param.input_type}
</span>
<span
className={`text-xs px-2 py-1 rounded ${
param.requirement === 'required'
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
: param.requirement === 'user_prompt'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200'
}`}
>
{param.requirement}
</span>
</div>
<p className="text-sm text-text-muted mb-2">{param.description}</p>
{param.default && (
<div className="text-xs text-text-muted">
<span className="font-medium">Default:</span> {param.default}
</div>
)}
{param.input_type === 'select' &&
param.options &&
param.options.length > 0 && (
<div className="text-xs text-text-muted mt-2">
<span className="font-medium">Options:</span>
<div className="flex flex-wrap gap-1 mt-1">
{param.options.map((option, optIndex) => (
<span
key={optIndex}
className="px-2 py-1 bg-background-default border border-border-subtle rounded text-xs"
>
{option}
</span>
))}
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{selectedRecipe.recipe.activities && selectedRecipe.recipe.activities.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Activities</h4>
@@ -607,6 +744,232 @@ Parameters you can use:
</div>
</div>
)}
{selectedRecipe.recipe.extensions && selectedRecipe.recipe.extensions.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Extensions</h4>
<div className="space-y-2">
{selectedRecipe.recipe.extensions.map((extension, index) => {
const extWithDetails = extension as typeof extension & {
version?: string;
type?: string;
bundled?: boolean;
cmd?: string;
args?: string[];
timeout?: number;
};
return (
<div
key={index}
className="bg-background-muted border border-border-subtle p-3 rounded-lg"
>
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-text-standard">{extension.name}</span>
{extWithDetails.version && (
<span className="text-xs px-2 py-1 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 rounded">
v{extWithDetails.version}
</span>
)}
{extWithDetails.type && (
<span className="text-xs px-2 py-1 bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 rounded">
{extWithDetails.type}
</span>
)}
{extWithDetails.bundled && (
<span className="text-xs px-2 py-1 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded">
bundled
</span>
)}
</div>
{'description' in extension && extension.description && (
<p className="text-sm text-text-muted mb-2">{extension.description}</p>
)}
{/* Extension command details */}
{extWithDetails.cmd && (
<div className="text-xs text-text-muted mt-2">
<div className="mb-1">
<span className="font-medium">Command:</span>{' '}
<code className="bg-background-default px-1 rounded">
{extWithDetails.cmd}
</code>
</div>
{extWithDetails.args && extWithDetails.args.length > 0 && (
<div className="mb-1">
<span className="font-medium">Args:</span>
<div className="flex flex-wrap gap-1 mt-1">
{extWithDetails.args.map((arg: string, argIndex: number) => (
<code
key={argIndex}
className="px-1 bg-background-default border border-border-subtle rounded text-xs"
>
{arg}
</code>
))}
</div>
</div>
)}
{extWithDetails.timeout && (
<div>
<span className="font-medium">Timeout:</span>{' '}
{extWithDetails.timeout}s
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
)}
{selectedRecipe.recipe.sub_recipes &&
selectedRecipe.recipe.sub_recipes.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Sub Recipes</h4>
<div className="space-y-3">
{selectedRecipe.recipe.sub_recipes.map((subRecipe, index: number) => (
<div
key={index}
className="bg-background-muted border border-border-subtle p-3 rounded-lg"
>
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-text-standard">{subRecipe.name}</span>
<span className="text-xs px-2 py-1 bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200 rounded">
sub-recipe
</span>
</div>
<div className="text-sm text-text-muted mb-2">
<span className="font-medium">Path:</span>{' '}
<code className="bg-background-default px-1 rounded font-mono text-xs">
{subRecipe.path}
</code>
</div>
{subRecipe.values && Object.keys(subRecipe.values).length > 0 && (
<div className="text-xs text-text-muted mt-2">
<span className="font-medium">Parameter Values:</span>
<div className="mt-1 space-y-1">
{Object.entries(subRecipe.values).map(([key, value]) => (
<div key={key} className="flex items-center gap-2">
<code className="bg-background-default px-1 rounded text-xs">
{key}
</code>
<span>=</span>
<code className="bg-background-default px-1 rounded text-xs">
{String(value)}
</code>
</div>
))}
</div>
</div>
)}
{subRecipe.description && (
<p className="text-sm text-text-muted mt-2">{subRecipe.description}</p>
)}
</div>
))}
</div>
</div>
)}
{selectedRecipe.recipe.response && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Response Schema</h4>
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
<pre className="text-sm text-text-muted whitespace-pre-wrap font-mono">
{
(() => {
const response = selectedRecipe.recipe.response;
try {
if (typeof response === 'string') {
return response;
}
return JSON.stringify(response, null, 2);
} catch {
return String(response);
}
})() as string
}
</pre>
</div>
</div>
)}
{selectedRecipe.recipe.goosehints && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Goose Hints</h4>
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
<pre className="text-sm text-text-muted whitespace-pre-wrap font-mono">
{selectedRecipe.recipe.goosehints}
</pre>
</div>
</div>
)}
{selectedRecipe.recipe.context && selectedRecipe.recipe.context.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Context</h4>
<div className="space-y-2">
{selectedRecipe.recipe.context.map((contextItem, index) => (
<div
key={index}
className="bg-background-muted border border-border-subtle p-2 rounded text-sm text-text-muted font-mono"
>
{contextItem}
</div>
))}
</div>
</div>
)}
{selectedRecipe.recipe.profile && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Profile</h4>
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
<span className="text-sm text-text-muted font-mono">
{selectedRecipe.recipe.profile}
</span>
</div>
</div>
)}
{selectedRecipe.recipe.mcps && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">
Max Completion Tokens per Second
</h4>
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
<span className="text-sm text-text-muted font-mono">
{selectedRecipe.recipe.mcps}
</span>
</div>
</div>
)}
{selectedRecipe.recipe.author && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Author</h4>
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
{selectedRecipe.recipe.author.contact && (
<div className="text-sm text-text-muted mb-1">
<span className="font-medium">Contact:</span>{' '}
{selectedRecipe.recipe.author.contact}
</div>
)}
{selectedRecipe.recipe.author.metadata && (
<div className="text-sm text-text-muted">
<span className="font-medium">Metadata:</span>{' '}
{selectedRecipe.recipe.author.metadata}
</div>
)}
</div>
</div>
)}
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-border-subtle">
+33 -13
View File
@@ -30,9 +30,7 @@ export default function ViewRecipeModal({ isOpen, onClose, config }: ViewRecipeM
const [instructions, setInstructions] = useState(config?.instructions || '');
const [prompt, setPrompt] = useState(config?.prompt || '');
const [activities, setActivities] = useState<string[]>(config?.activities || []);
const [parameters, setParameters] = useState<Parameter[]>(
parseParametersFromInstructions(instructions)
);
const [parameters, setParameters] = useState<Parameter[]>(config?.parameters || []);
const [extensionOptions, setExtensionOptions] = useState<FixedExtensionEntry[]>([]);
const [extensionsLoaded, setExtensionsLoaded] = useState(false);
@@ -65,7 +63,7 @@ export default function ViewRecipeModal({ isOpen, onClose, config }: ViewRecipeM
setInstructions(config.instructions || '');
setPrompt(config.prompt || '');
setActivities(config.activities || []);
setParameters(parseParametersFromInstructions(config.instructions || ''));
setParameters(config.parameters || []);
}
}, [config]);
@@ -94,28 +92,45 @@ export default function ViewRecipeModal({ isOpen, onClose, config }: ViewRecipeM
}
}, [isOpen, getExtensions, recipeExtensions, extensionsLoaded]);
// Use effect to set parameters whenever instructions or prompt changes
// Auto-detect new parameters from instructions and prompt
// This adds new parameters without overwriting existing ones
useEffect(() => {
const instructionsParams = parseParametersFromInstructions(instructions);
const promptParams = parseParametersFromInstructions(prompt);
// Combine parameters, ensuring no duplicates by key
const allParams = [...instructionsParams];
promptParams.forEach((promptParam) => {
if (!allParams.some((param) => param.key === promptParam.key)) {
allParams.push(promptParam);
// Combine all detected parameters, ensuring no duplicates by key
const detectedParamsMap = new Map<string, Parameter>();
// Add instruction parameters
instructionsParams.forEach((param) => {
detectedParamsMap.set(param.key, param);
});
// Add prompt parameters (won't overwrite existing keys)
promptParams.forEach((param) => {
if (!detectedParamsMap.has(param.key)) {
detectedParamsMap.set(param.key, param);
}
});
setParameters(allParams);
}, [instructions, prompt]);
const existingParamKeys = new Set(parameters.map((param) => param.key));
// Only add parameters that don't already exist
const newParams = Array.from(detectedParamsMap.values()).filter(
(detectedParam) => !existingParamKeys.has(detectedParam.key)
);
if (newParams.length > 0) {
setParameters((prev) => [...prev, ...newParams]);
}
}, [instructions, prompt, parameters]);
const getCurrentConfig = useCallback((): Recipe => {
// Transform the internal parameters state into the desired output format.
const formattedParameters = parameters.map((param) => {
const formattedParam: Parameter = {
key: param.key,
input_type: 'string',
input_type: param.input_type || 'string',
requirement: param.requirement,
description: param.description,
};
@@ -125,6 +140,11 @@ export default function ViewRecipeModal({ isOpen, onClose, config }: ViewRecipeM
formattedParam.default = param.default;
}
// Add options for select input type
if (param.input_type === 'select' && param.options) {
formattedParam.options = param.options.filter((opt) => opt.trim() !== ''); // Filter empty options when saving
}
return formattedParam;
});
+2 -9
View File
@@ -22,21 +22,13 @@
import { useState } from 'react';
import FlappyGoose from './FlappyGoose';
import { type View, ViewOptions } from '../App';
import { Message } from '../types/message';
import { SessionInsights } from './sessions/SessionsInsights';
import ChatInput from './ChatInput';
import { generateSessionId } from '../sessions';
import { ChatContextManagerProvider } from './context_management/ChatContextManager';
import { Recipe } from '../recipe';
import 'react-toastify/dist/ReactToastify.css';
export interface ChatType {
id: string;
title: string;
messageHistoryIndex: number;
messages: Message[];
recipeConfig?: Recipe | null; // Add recipe configuration to chat state
}
import { ChatType } from '../types/chat';
export default function Hub({
chat: _chat,
@@ -68,6 +60,7 @@ export default function Hub({
messages: [], // Always start with empty messages
messageHistoryIndex: 0,
recipeConfig: null, // Clear recipe for new chats from Hub
recipeParameters: null, // Clear parameters for new chats from Hub
};
// Update the PAIR chat state immediately to prevent flashing
+2 -9
View File
@@ -27,23 +27,15 @@
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { type View, ViewOptions } from '../App';
import { Message } from '../types/message';
import BaseChat from './BaseChat';
import ParameterInputModal from './ParameterInputModal';
import { useRecipeManager } from '../hooks/useRecipeManager';
import { useIsMobile } from '../hooks/use-mobile';
import { useSidebar } from './ui/sidebar';
import { Recipe } from '../recipe';
import 'react-toastify/dist/ReactToastify.css';
import { cn } from '../utils';
export interface ChatType {
id: string;
title: string;
messageHistoryIndex: number;
messages: Message[];
recipeConfig?: Recipe | null; // Add recipe configuration to chat state
}
import { ChatType } from '../types/chat';
export default function Pair({
chat,
@@ -83,6 +75,7 @@ export default function Pair({
messages: [], // Clear messages to start fresh
messageHistoryIndex: 0,
recipeConfig: location.state.recipeConfig, // Set the recipe config in chat state
recipeParameters: null, // Clear parameters for new recipe
};
setChat(newChat);
@@ -30,8 +30,24 @@ const ParameterInput: React.FC<ParameterInputProps> = ({ parameter, onChange })
<p className="text-sm text-textSubtle mt-1">This is the message the end-user will see.</p>
</div>
{/* Controls for requirement and default value */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Controls for requirement, input type, and default value */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-md text-textStandard mb-2 font-semibold">Input Type</label>
<select
className="w-full p-3 border rounded-lg bg-background-default text-textStandard"
value={parameter.input_type || 'string'}
onChange={(e) =>
onChange(key, { input_type: e.target.value as Parameter['input_type'] })
}
>
<option value="string">String</option>
<option value="select">Select</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
</select>
</div>
<div>
<label className="block text-md text-textStandard mb-2 font-semibold">Requirement</label>
<select
@@ -62,6 +78,35 @@ const ParameterInput: React.FC<ParameterInputProps> = ({ parameter, onChange })
</div>
)}
</div>
{/* Options field for select input type */}
{parameter.input_type === 'select' && (
<div className="mt-4">
<label className="block text-md text-textStandard mb-2 font-semibold">
Options (one per line)
</label>
<textarea
value={(parameter.options || []).join('\n')}
onChange={(e) => {
// Don't filter out empty lines - preserve them so user can type on new lines
const options = e.target.value.split('\n');
onChange(key, { options });
}}
onKeyDown={(e) => {
// Allow Enter key to work normally in textarea (prevent form submission or modal close)
if (e.key === 'Enter') {
e.stopPropagation();
}
}}
className="w-full p-3 border rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
placeholder="Option 1&#10;Option 2&#10;Option 3"
rows={4}
/>
<p className="text-sm text-textSubtle mt-1">
Enter each option on a new line. These will be shown as dropdown choices.
</p>
</div>
)}
</div>
);
};
+20 -1
View File
@@ -1,5 +1,5 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { ChatType } from '../components/BaseChat';
import { ChatType } from '../types/chat';
import { generateSessionId } from '../sessions';
import { Recipe } from '../recipe';
import { useDraftContext } from './DraftContext';
@@ -11,6 +11,8 @@ interface ChatContextType {
hasActiveSession: boolean;
setRecipeConfig: (recipe: Recipe | null) => void;
clearRecipeConfig: () => void;
setRecipeParameters: (parameters: Record<string, string> | null) => void;
clearRecipeParameters: () => void;
// Draft functionality
draft: string;
setDraft: (draft: string) => void;
@@ -55,6 +57,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
messages: [],
messageHistoryIndex: 0,
recipeConfig: null, // Clear recipe when resetting chat
recipeParameters: null, // Clear parameters when resetting chat
});
// Clear draft when resetting chat
clearDraft();
@@ -74,6 +77,20 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
});
};
const setRecipeParameters = (parameters: Record<string, string> | null) => {
setChat({
...chat,
recipeParameters: parameters,
});
};
const clearRecipeParameters = () => {
setChat({
...chat,
recipeParameters: null,
});
};
const hasActiveSession = chat.messages.length > 0;
const value: ChatContextType = {
@@ -83,6 +100,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
hasActiveSession,
setRecipeConfig,
clearRecipeConfig,
setRecipeParameters,
clearRecipeParameters,
draft,
setDraft,
clearDraft,
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { ChatType } from '../components/BaseChat';
import { ChatType } from '../types/chat';
import { fetchSessionDetails, generateSessionId } from '../sessions';
import { View, ViewOptions } from '../App';
+1 -1
View File
@@ -14,7 +14,7 @@ import {
getTextContent,
TextContent,
} from '../types/message';
import { ChatType } from '../components/hub';
import { ChatType } from '../types/chat';
// Helper function to determine if a message is a user message
const isUserMessage = (message: Message): boolean => {
+12 -5
View File
@@ -13,13 +13,12 @@ interface LocationState {
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
// Get chat context to access persisted recipe and parameters
const chatContext = useChatContext();
// Use a ref to capture the current messages for the event handler
@@ -55,6 +54,11 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
return null;
}, [chatContext, locationState]);
// Get recipe parameters from chat context
const recipeParameters = useMemo(() => {
return chatContext?.chat.recipeParameters || null;
}, [chatContext?.chat.recipeParameters]);
// Effect to persist recipe config to chat context when it changes
useEffect(() => {
if (!chatContext?.setRecipeConfig) return;
@@ -123,7 +127,7 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
return substitutedPrompt;
};
// Pre-fill input with recipe prompt instead of auto-sending it
// Get the recipe's initial prompt (always return the actual prompt, don't modify based on conversation state)
const initialPrompt = useMemo(() => {
if (!recipeConfig?.prompt || !recipeAccepted) return '';
@@ -145,12 +149,15 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
// Handle parameter submission
const handleParameterSubmit = async (inputValues: Record<string, string>) => {
setRecipeParameters(inputValues);
// Store parameters in chat context instead of local state
if (chatContext?.setRecipeParameters) {
chatContext.setRecipeParameters(inputValues);
}
setIsParameterModalOpen(false);
// Update the system prompt with parameter-substituted instructions
try {
await updateSystemPromptWithParameters(inputValues);
await updateSystemPromptWithParameters(inputValues, recipeConfig || undefined);
} catch (error) {
console.error('Failed to update system prompt with parameters:', error);
}
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { generateSessionId } from '../sessions';
import { ChatType } from '../components/hub';
import { ChatType } from '../types/chat';
interface UseSessionContinuationProps {
chat: ChatType;
+11
View File
@@ -0,0 +1,11 @@
import { Message } from './message';
import { Recipe } from '../recipe';
export interface ChatType {
id: string;
title: string;
messageHistoryIndex: number;
messages: Message[];
recipeConfig?: Recipe | null; // Add recipe configuration to chat state
recipeParameters?: Record<string, string> | null; // Add recipe parameters to chat state
}
+6 -4
View File
@@ -70,11 +70,11 @@ const substituteParameters = (text: string, params: Record<string, string>): str
* This should be called after recipe parameters are collected
*/
export const updateSystemPromptWithParameters = async (
recipeParameters: Record<string, string>
recipeParameters: Record<string, string>,
recipeConfig?: { instructions?: string | null }
): Promise<void> => {
try {
const recipeConfig = window.appConfig?.get?.('recipe');
const originalInstructions = (recipeConfig as { instructions?: string })?.instructions;
const originalInstructions = recipeConfig?.instructions;
if (!originalInstructions) {
return;
@@ -96,7 +96,9 @@ export const updateSystemPromptWithParameters = async (
});
if (!response.ok) {
console.warn(`Failed to update system prompt with parameters: ${response.statusText}`);
console.warn(
`Failed to update system prompt with parameters: ${response.status} ${response.statusText}`
);
}
} catch (error) {
console.error('Error updating system prompt with parameters:', error);