Restore recipe parameters functionality (#3530)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: _,
|
||||
} => {
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -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
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 Option 2 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user