diff --git a/.github/workflows/ui-ci.yml b/.github/workflows/ui-ci.yml index 836474379..3da5c7ed5 100644 --- a/.github/workflows/ui-ci.yml +++ b/.github/workflows/ui-ci.yml @@ -99,6 +99,9 @@ jobs: - name: Build UI package run: npm run build-package + - name: Build Storybook + run: npm run build-storybook + - name: Run WhiteSource Scan id: whitesource_scan uses: SolaceDev/solace-public-workflows/.github/actions/whitesource-scan@main diff --git a/client/webui/frontend/src/App.tsx b/client/webui/frontend/src/App.tsx index 2d65403a8..37a525da5 100644 --- a/client/webui/frontend/src/App.tsx +++ b/client/webui/frontend/src/App.tsx @@ -3,14 +3,25 @@ import { BrowserRouter } from "react-router-dom"; import { AgentMeshPage, ChatPage, bottomNavigationItems, getTopNavigationItems, NavigationSidebar, ToastContainer, Button } from "@/lib/components"; import { ProjectsPage } from "@/lib/components/projects"; +import { PromptsPage } from "@/lib/components/pages/PromptsPage"; +import { TextSelectionProvider, SelectionContextMenu, useTextSelection } from "@/lib/components/chat/selection"; import { AuthProvider, ChatProvider, ConfigProvider, CsrfProvider, ProjectProvider, TaskProvider, ThemeProvider } from "@/lib/providers"; +import { UnsavedChangesProvider, useUnsavedChangesContext } from "@/lib/contexts"; import { useAuthContext, useBeforeUnload, useConfigContext } from "@/lib/hooks"; -function AppContent() { +function AppContentInner() { const [activeNavItem, setActiveNavItem] = useState("chat"); const { isAuthenticated, login, useAuthorization } = useAuthContext(); - const { projectsEnabled } = useConfigContext(); + const { configFeatureEnablement, projectsEnabled } = useConfigContext(); + const { isMenuOpen, menuPosition, selectedText, clearSelection } = useTextSelection(); + const { checkUnsavedChanges } = useUnsavedChangesContext(); + + // Get navigation items based on feature flags + const topNavItems = useMemo( + () => getTopNavigationItems(configFeatureEnablement), + [configFeatureEnablement] + ); // Enable beforeunload warning when chat data is present useBeforeUnload(); @@ -34,10 +45,29 @@ function AppContent() { }; }, [projectsEnabled]); - // Get filtered navigation items based on feature flags - const topNavigationItems = useMemo(() => { - return getTopNavigationItems(projectsEnabled ?? false); - }, [projectsEnabled]); + // Listen for create-template-from-session events + useEffect(() => { + const handleCreateTemplateFromSession = () => { + setActiveNavItem("prompts"); + }; + + window.addEventListener("create-template-from-session", handleCreateTemplateFromSession); + return () => { + window.removeEventListener("create-template-from-session", handleCreateTemplateFromSession as EventListener); + }; + }, []); + + // Listen for use-prompt-in-chat events + useEffect(() => { + const handleUsePromptInChat = () => { + setActiveNavItem("chat"); + }; + + window.addEventListener("use-prompt-in-chat", handleUsePromptInChat); + return () => { + window.removeEventListener("use-prompt-in-chat", handleUsePromptInChat as EventListener); + }; + }, []); if (useAuthorization && !isAuthenticated) { return ( @@ -48,17 +78,23 @@ function AppContent() { } const handleNavItemChange = (itemId: string) => { - const item = topNavigationItems.find(item => item.id === itemId) || bottomNavigationItems.find(item => item.id === itemId); + // Check for unsaved changes before navigating + checkUnsavedChanges(() => { + const item = topNavItems.find(item => item.id === itemId) || bottomNavigationItems.find(item => item.id === itemId); - if (item?.onClick && itemId !== "settings") { - item.onClick(); - } else if (itemId !== "settings") { - setActiveNavItem(itemId); - } + if (item?.onClick && itemId !== "settings") { + item.onClick(); + } else if (itemId !== "settings") { + setActiveNavItem(itemId); + } + }); }; const handleHeaderClick = () => { - setActiveNavItem("chat"); + // Check for unsaved changes before navigating to chat + checkUnsavedChanges(() => { + setActiveNavItem("chat"); + }); }; const renderMainContent = () => { @@ -74,18 +110,38 @@ function AppContent() { } // Fallback to chat if projects are disabled but somehow navigated here return ; + case "prompts": + return ; + default: + return ; } }; return (
- +
{renderMainContent()}
+
); } +function AppContent() { + return ( + + + + + + ); +} + function App() { return ( diff --git a/client/webui/frontend/src/lib/components/chat/ChatInputArea.tsx b/client/webui/frontend/src/lib/components/chat/ChatInputArea.tsx index bc5087fb0..5aff3e933 100644 --- a/client/webui/frontend/src/lib/components/chat/ChatInputArea.tsx +++ b/client/webui/frontend/src/lib/components/chat/ChatInputArea.tsx @@ -6,30 +6,87 @@ import { Ban, Paperclip, Send } from "lucide-react"; import { Button, ChatInput, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/lib/components/ui"; import { useChatContext, useDragAndDrop, useAgentSelection } from "@/lib/hooks"; import type { AgentCardInfo } from "@/lib/types"; +import type { PromptGroup } from "@/lib/types/prompts"; +import { detectVariables } from "@/lib/utils/promptUtils"; import { FileBadge } from "./file/FileBadge"; +import { PromptsCommand } from "./PromptsCommand"; +import { VariableDialog } from "./VariableDialog"; +import { + PastedTextBadge, + PasteActionDialog, + isLargeText, + type PastedArtifactItem +} from "./paste"; export const ChatInputArea: React.FC<{ agents: AgentCardInfo[]; scrollToBottom?: () => void }> = ({ agents = [], scrollToBottom }) => { - const { isResponding, isCancelling, selectedAgentName, sessionId, handleSubmit, handleCancel } = useChatContext(); + const { isResponding, isCancelling, selectedAgentName, sessionId, setSessionId, handleSubmit, handleCancel, uploadArtifactFile, artifactsRefetch, addNotification, artifacts, setPreviewArtifact, openSidePanelTab, messages } = useChatContext(); const { handleAgentSelection } = useAgentSelection(); // File selection support const fileInputRef = useRef(null); const [selectedFiles, setSelectedFiles] = useState([]); - // Chat input ref for focus management + // Pasted artifact support + const [pastedArtifactItems, setPastedArtifactItems] = useState([]); + const [pendingPasteContent, setPendingPasteContent] = useState(null); + const [showArtifactForm, setShowArtifactForm] = useState(false); + + const [contextText, setContextText] = useState(null); + const chatInputRef = useRef(null); const prevIsRespondingRef = useRef(isResponding); - // Local state for input value (no debouncing needed!) const [inputValue, setInputValue] = useState(""); - - // Clear input when session changes + + const [showPromptsCommand, setShowPromptsCommand] = useState(false); + + const [showVariableDialog, setShowVariableDialog] = useState(false); + const [pendingPromptGroup, setPendingPromptGroup] = useState(null); + + // Clear input when session changes (but keep track of previous session to avoid clearing on initial session creation) + const prevSessionIdRef = useRef(sessionId); + useEffect(() => { - setInputValue(""); + // Check for pending prompt use on mount or session change + const promptData = sessionStorage.getItem('pending-prompt-use'); + if (promptData) { + sessionStorage.removeItem('pending-prompt-use'); + try { + const { promptText, groupId, groupName } = JSON.parse(promptData); + + // Check if prompt has variables + const variables = detectVariables(promptText); + if (variables.length > 0) { + // Show variable dialog + setPendingPromptGroup({ + id: groupId, + name: groupName, + production_prompt: { prompt_text: promptText } + } as PromptGroup); + setShowVariableDialog(true); + } else { + setInputValue(promptText); + setTimeout(() => { + chatInputRef.current?.focus(); + }, 100); + } + } catch (error) { + console.error('Error parsing prompt data:', error); + } + return; // Don't clear input if we just set it + } + + // Only clear if session actually changed (not just initialized) + if (prevSessionIdRef.current && prevSessionIdRef.current !== sessionId) { + setInputValue(""); + setShowPromptsCommand(false); + setPastedArtifactItems([]); + } + prevSessionIdRef.current = sessionId; + setContextText(null); }, [sessionId]); - // Focus the chat input when isResponding becomes false useEffect(() => { if (prevIsRespondingRef.current && !isResponding) { // Small delay to ensure the input is fully enabled @@ -54,6 +111,44 @@ export const ChatInputArea: React.FC<{ agents: AgentCardInfo[]; scrollToBottom?: }; }, []); + + // Handle follow-up question from text selection + useEffect(() => { + const handleFollowUp = async (event: Event) => { + const customEvent = event as CustomEvent; + const { text, prompt, autoSubmit } = customEvent.detail; + setContextText(text); + + // If a prompt is provided, pre-fill the input + if (prompt) { + setInputValue(prompt + " "); + + if (autoSubmit) { + // Small delay to ensure state is updated + setTimeout(async () => { + const fullMessage = `${prompt}\n\nContext: "${text}"`; + const fakeEvent = new Event('submit') as unknown as FormEvent; + await handleSubmit(fakeEvent, [], fullMessage); + setContextText(null); + setInputValue(""); + scrollToBottom?.(); + }, 50); + return; + } + } + + // Focus the input for custom questions + setTimeout(() => { + chatInputRef.current?.focus(); + }, 100); + }; + + window.addEventListener('follow-up-question', handleFollowUp); + return () => { + window.removeEventListener('follow-up-question', handleFollowUp); + }; + }, [handleSubmit, scrollToBottom]); + const handleFileSelect = () => { if (!isResponding) { fileInputRef.current?.click(); @@ -79,41 +174,119 @@ export const ChatInputArea: React.FC<{ agents: AgentCardInfo[]; scrollToBottom?: }, 100); }; - const handlePaste = (event: ClipboardEvent) => { + const handlePaste = async (event: ClipboardEvent) => { if (isResponding) return; const clipboardData = event.clipboardData; - if (!clipboardData || !clipboardData.files || clipboardData.files.length === 0) return; + if (!clipboardData) return; - if (clipboardData.files.length > 0) { + // Handle file pastes (existing logic) + if (clipboardData.files && clipboardData.files.length > 0) { event.preventDefault(); // Prevent the default paste behavior for files + + // Filter out duplicates based on name, size, and last modified time + const newFiles = Array.from(clipboardData.files).filter(newFile => + !selectedFiles.some(existingFile => + existingFile.name === newFile.name && + existingFile.size === newFile.size && + existingFile.lastModified === newFile.lastModified + ) + ); + if (newFiles.length > 0) { + setSelectedFiles(prev => [...prev, ...newFiles]); + } + return; } - // Filter out duplicates based on name, size, and last modified time - const newFiles = Array.from(clipboardData.files).filter(newFile => !selectedFiles.some(existingFile => existingFile.name === newFile.name && existingFile.size === newFile.size && existingFile.lastModified === newFile.lastModified)); - if (newFiles.length > 0) { - setSelectedFiles(prev => [...prev, ...newFiles]); + // Handle text pastes - show artifact form for large text + const pastedText = clipboardData.getData('text'); + if (pastedText && isLargeText(pastedText)) { + // Large text - show artifact creation form + event.preventDefault(); + setPendingPasteContent(pastedText); + setShowArtifactForm(true); } + // Small text pastes go through normally (no preventDefault) + }; + + const handleSaveAsArtifact = async (title: string, fileType: string, description?: string) => { + if (!pendingPasteContent) return; + + try { + // Determine MIME type + let mimeType = 'text/plain'; + if (fileType !== 'auto') { + mimeType = fileType; + } + + // Create a File object from the text content + const blob = new Blob([pendingPasteContent], { type: mimeType }); + const file = new File([blob], title, { type: mimeType }); + + // Upload the artifact + const result = await uploadArtifactFile(file, sessionId, description); + + if (result) { + // If a new session was created, update our sessionId + if (result.sessionId && result.sessionId !== sessionId) { + setSessionId(result.sessionId); + } + + // Create a badge item for this pasted artifact + const artifactItem: PastedArtifactItem = { + id: `paste-artifact-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + artifactId: result.uri, + filename: title, + timestamp: Date.now(), + }; + setPastedArtifactItems(prev => { + return [...prev, artifactItem]; + }); + + addNotification(`Artifact "${title}" created from pasted content.`); + // Refresh artifacts panel + await artifactsRefetch(); + } else { + addNotification(`Failed to create artifact from pasted content.`, 'error'); + } + } catch (error) { + console.error('Error saving artifact:', error); + addNotification(`Error creating artifact: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error'); + } finally { + setPendingPasteContent(null); + setShowArtifactForm(false); + } + }; + + const handleCancelArtifactForm = () => { + setPendingPasteContent(null); + setShowArtifactForm(false); }; const handleRemoveFile = (index: number) => { setSelectedFiles(prev => prev.filter((_, i) => i !== index)); }; - const isSubmittingEnabled = useMemo(() => !isResponding && (inputValue?.trim() || selectedFiles.length !== 0), [isResponding, inputValue, selectedFiles]); + const isSubmittingEnabled = useMemo( + () => !isResponding && (inputValue?.trim() || selectedFiles.length !== 0), + [isResponding, inputValue, selectedFiles] + ); const onSubmit = async (event: FormEvent) => { event.preventDefault(); - if (!isSubmittingEnabled) return; - - const trimmedInput = inputValue.trim(); - const files = [...selectedFiles]; - - setInputValue(""); - setSelectedFiles([]); - - await handleSubmit(event, files, trimmedInput); - scrollToBottom?.(); + if (isSubmittingEnabled) { + let fullMessage = inputValue.trim() + if (contextText) { + fullMessage = `${fullMessage}\n\nContext: "${contextText}"`; + } + + await handleSubmit(event, selectedFiles, fullMessage); + setSelectedFiles([]); + setPastedArtifactItems([]); + setInputValue(""); + setContextText(null); + scrollToBottom?.(); + } }; const handleFilesDropped = (files: File[]) => { @@ -132,6 +305,104 @@ export const ChatInputArea: React.FC<{ agents: AgentCardInfo[]; scrollToBottom?: disabled: isResponding, }); + // Handle input change with "/" detection + const handleInputChange = (event: ChangeEvent) => { + const value = event.target.value; + setInputValue(value); + + // Check if "/" is typed at start or after space + const cursorPosition = event.target.selectionStart; + const textBeforeCursor = value.substring(0, cursorPosition); + const lastChar = textBeforeCursor[textBeforeCursor.length - 1]; + const charBeforeLast = textBeforeCursor[textBeforeCursor.length - 2]; + + if (lastChar === '/' && (!charBeforeLast || charBeforeLast === ' ' || charBeforeLast === '\n')) { + setShowPromptsCommand(true); + } else if (showPromptsCommand && !textBeforeCursor.includes('/')) { + setShowPromptsCommand(false); + } + }; + + // Handle prompt selection + const handlePromptSelect = (promptText: string) => { + // Remove the "/" trigger and insert the prompt + const cursorPosition = chatInputRef.current?.selectionStart || 0; + const textBeforeCursor = inputValue.substring(0, cursorPosition); + const textAfterCursor = inputValue.substring(cursorPosition); + + // Find the last "/" before cursor + const lastSlashIndex = textBeforeCursor.lastIndexOf('/'); + const newText = textBeforeCursor.substring(0, lastSlashIndex) + promptText + textAfterCursor; + + setInputValue(newText); + setShowPromptsCommand(false); + + // Focus back on input + setTimeout(() => { + chatInputRef.current?.focus(); + }, 100); + }; + + // Handle reserved command + const handleReservedCommand = (command: string, context?: string) => { + if (command === 'create-template') { + // Create enhanced message for AI builder + const enhancedMessage = context + ? `I want to create a reusable prompt template based on this conversation I just had: + + +${context} + + +Please help me create a prompt template by: + +1. **Analyzing the Pattern**: Identify the core task/question pattern in this conversation +2. **Extracting Variables**: Determine which parts should be variables (use {{variable_name}} syntax) +3. **Generalizing**: Make it reusable for similar tasks +4. **Suggesting Metadata**: Recommend a name, description, category, and chat shortcut + +Focus on capturing what made this conversation successful so it can be reused with different inputs.` + : 'Help me create a new prompt template.'; + + // Store in sessionStorage before dispatching event + sessionStorage.setItem('pending-template-context', enhancedMessage); + + // Dispatch custom event to navigate to prompts page with context + window.dispatchEvent(new CustomEvent('create-template-from-session', { + detail: { initialMessage: enhancedMessage } + })); + + // Clear input + setInputValue(''); + setShowPromptsCommand(false); + } + }; + + // Handle pasted artifact management + const handleRemovePastedArtifact = (id: string) => { + setPastedArtifactItems(prev => prev.filter(item => item.id !== id)); + }; + + const handleViewPastedArtifact = (filename: string) => { + // Find the artifact in the artifacts list + const artifact = artifacts.find(a => a.filename === filename); + if (artifact) { + // Use the existing artifact preview functionality + setPreviewArtifact(artifact); + openSidePanelTab('files'); + } + }; + + // Handle variable dialog submission from "Use in Chat" + const handleVariableSubmit = (processedPrompt: string) => { + setInputValue(processedPrompt); + setShowVariableDialog(false); + setPendingPromptGroup(null); + setTimeout(() => { + chatInputRef.current?.focus(); + }, 100); + }; + return (
)} + {/* Pasted Artifact Items */} + {(() => { + return pastedArtifactItems.length > 0 && ( +
+ {pastedArtifactItems.map((item, index) => ( + handleViewPastedArtifact(item.filename)} + onRemove={() => handleRemovePastedArtifact(item.id)} + /> + ))} +
+ ); + })()} + + {/* Artifact Creation Dialog */} + a.filename)} + /> + + {/* Prompts Command Popover */} + { + setShowPromptsCommand(false); + }} + textAreaRef={chatInputRef} + onPromptSelect={handlePromptSelect} + messages={messages} + onReservedCommand={handleReservedCommand} + /> + + {/* Variable Dialog for "Use in Chat" */} + {showVariableDialog && pendingPromptGroup && ( + { + setShowVariableDialog(false); + setPendingPromptGroup(null); + }} + /> + )} + {/* Chat Input */} setInputValue(event.target.value)} - placeholder="How can I help you today?" + onChange={handleInputChange} + placeholder="How can I help you today? (Type '/' to insert a prompt)" className="field-sizing-content max-h-50 min-h-0 resize-none rounded-2xl border-none p-3 text-base/normal shadow-none transition-[height] duration-500 ease-in-out focus-visible:outline-none" rows={1} onPaste={handlePaste} diff --git a/client/webui/frontend/src/lib/components/chat/ChatMessage.tsx b/client/webui/frontend/src/lib/components/chat/ChatMessage.tsx index f17af2062..efd9a8f79 100644 --- a/client/webui/frontend/src/lib/components/chat/ChatMessage.tsx +++ b/client/webui/frontend/src/lib/components/chat/ChatMessage.tsx @@ -18,6 +18,7 @@ import { decodeBase64Content } from "./preview/previewUtils"; import { downloadFile } from "@/lib/utils/download"; import type { ExtractedContent } from "./preview/contentUtils"; import { AuthenticationMessage } from "./authentication/AuthenticationMessage"; +import { SelectableMessageContent } from "./selection"; const RENDER_TYPES_WITH_RAW_CONTENT = ["image", "audio"]; @@ -97,55 +98,71 @@ const MessageContent = React.memo<{ message: MessageFE }>(({ message }) => { // Trim text for user messages to prevent trailing whitespace issues const displayText = message.isUser ? textContent.trim() : textContent; - if (message.isError) { - return ( -
- - {displayText} -
- ); - } - - const embeddedContent = extractEmbeddedContent(displayText); - if (embeddedContent.length === 0) { - return {displayText}; - } - - let modifiedText = displayText; - const contentElements: ReactNode[] = []; - embeddedContent.forEach((item: ExtractedContent, index: number) => { - modifiedText = modifiedText.replace(item.originalMatch, ""); - - if (item.type === "file") { - const fileAttachment: FileAttachment = { - name: item.filename || "downloaded_file", - content: item.content, - mime_type: item.mimeType, - }; - contentElements.push( -
- downloadFile(fileAttachment, sessionId)} isEmbedded={true} /> + const renderContent = () => { + if (message.isError) { + return ( +
+ + {displayText}
); - } else if (!RENDER_TYPES_WITH_RAW_CONTENT.includes(item.type)) { - const finalContent = decodeBase64Content(item.content); - if (finalContent) { + } + + const embeddedContent = extractEmbeddedContent(displayText); + if (embeddedContent.length === 0) { + return {displayText}; + } + + let modifiedText = displayText; + const contentElements: ReactNode[] = []; + embeddedContent.forEach((item: ExtractedContent, index: number) => { + modifiedText = modifiedText.replace(item.originalMatch, ""); + + if (item.type === "file") { + const fileAttachment: FileAttachment = { + name: item.filename || "downloaded_file", + content: item.content, + mime_type: item.mimeType, + }; contentElements.push( -
- +
+ downloadFile(fileAttachment, sessionId)} isEmbedded={true} />
); + } else if (!RENDER_TYPES_WITH_RAW_CONTENT.includes(item.type)) { + const finalContent = decodeBase64Content(item.content); + if (finalContent) { + contentElements.push( +
+ +
+ ); + } } - } - }); + }); - return ( -
- {renderError && } - {modifiedText} - {contentElements} -
- ); + return ( +
+ {renderError && } + {modifiedText} + {contentElements} +
+ ); + }; + + // Wrap AI messages with SelectableMessageContent for text selection + if (!message.isUser) { + return ( + + {renderContent()} + + ); + } + + return renderContent(); }); const MessageWrapper = React.memo<{ message: MessageFE; children: ReactNode; className?: string }>(({ message, children, className }) => { diff --git a/client/webui/frontend/src/lib/components/chat/PromptsCommand.tsx b/client/webui/frontend/src/lib/components/chat/PromptsCommand.tsx new file mode 100644 index 000000000..eea340da2 --- /dev/null +++ b/client/webui/frontend/src/lib/components/chat/PromptsCommand.tsx @@ -0,0 +1,396 @@ +/** + * Displays a popover with searchable prompt library when "/" is typed + * Also handles reserved commands like /create-template + */ + +import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; +import { Search, FileText, Plus } from 'lucide-react'; +import type { PromptGroup } from '@/lib/types/prompts'; +import type { MessageFE } from '@/lib/types'; +import { detectVariables } from '@/lib/utils/promptUtils'; +import { VariableDialog } from './VariableDialog'; + +interface ReservedCommand { + command: string; + name: string; + description: string; + icon: typeof FileText; +} + +const RESERVED_COMMANDS: ReservedCommand[] = [ + { + command: 'create-template', + name: 'Create Template from Session', + description: 'Create a reusable prompt template from this conversation', + icon: FileText, + }, +]; + +interface PromptsCommandProps { + isOpen: boolean; + onClose: () => void; + textAreaRef: React.RefObject; + onPromptSelect: (promptText: string) => void; + messages?: MessageFE[]; + onReservedCommand?: (command: string, context?: string) => void; +} + +export const PromptsCommand: React.FC = ({ + isOpen, + onClose, + textAreaRef, + onPromptSelect, + messages = [], + onReservedCommand, +}) => { + const [searchValue, setSearchValue] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + const [promptGroups, setPromptGroups] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [selectedGroup, setSelectedGroup] = useState(null); + const [showVariableDialog, setShowVariableDialog] = useState(false); + const [isKeyboardMode, setIsKeyboardMode] = useState(false); + + const inputRef = useRef(null); + const popoverRef = useRef(null); + const backdropRef = useRef(null); + + // Fetch prompt groups when opened + useEffect(() => { + if (!isOpen) return; + + const fetchPromptGroups = async () => { + setIsLoading(true); + try { + const response = await fetch('/api/v1/prompts/groups/all', { + credentials: 'include', + }); + if (response.ok) { + const data = await response.json(); + setPromptGroups(data); + } + } catch (error) { + console.error('Failed to fetch prompt groups:', error); + } finally { + setIsLoading(false); + } + }; + + fetchPromptGroups(); + }, [isOpen]); + + // Reserved commands - always shown (not filtered), only check availability + const availableReservedCommands = useMemo(() => { + // Only show create-template if there are user messages in the session + const hasUserMessages = messages.some(m => m.isUser && !m.isStatusBubble); + return RESERVED_COMMANDS.filter(cmd => { + if (cmd.command === 'create-template') { + return hasUserMessages; + } + return true; + }); + }, [messages]); + + // Filter prompt groups based on search + const filteredGroups = useMemo(() => { + if (!searchValue) return promptGroups; + + const search = searchValue.toLowerCase(); + return promptGroups.filter(group => + group.name.toLowerCase().includes(search) || + group.description?.toLowerCase().includes(search) || + group.command?.toLowerCase().includes(search) || + group.category?.toLowerCase().includes(search) + ); + }, [promptGroups, searchValue]); + + // Combine prompts and reserved commands for display (reserved at bottom) + const allItems = useMemo(() => { + return [...filteredGroups, ...availableReservedCommands]; + }, [filteredGroups, availableReservedCommands]); + + // Format session history for context + const formatSessionHistory = useCallback((messages: MessageFE[]): string => { + return messages + .filter(m => !m.isStatusBubble && !m.isError && !m.authenticationLink) + .map(m => { + const role = m.isUser ? 'User' : 'Assistant'; + const text = m.parts + ?.filter(p => p.kind === 'text') + .map(p => (p as { text: string }).text) + .join('\n') || ''; + return `${role}: ${text}`; + }) + .join('\n\n'); + }, []); + + // Handle reserved command selection + const handleReservedCommandSelect = useCallback((cmd: ReservedCommand) => { + if (cmd.command === 'create-template' && onReservedCommand) { + const sessionHistory = formatSessionHistory(messages); + onReservedCommand(cmd.command, sessionHistory); + onClose(); + setSearchValue(''); + } + }, [messages, formatSessionHistory, onReservedCommand, onClose]); + + // Handle navigation to Prompts area + const handleNavigateToPrompts = useCallback(() => { + onClose(); + setSearchValue(''); + // Dispatch event to navigate to prompts page + window.dispatchEvent(new CustomEvent('create-template-from-session')); + }, [onClose]); + + // Handle prompt selection + const handlePromptSelect = useCallback((group: PromptGroup) => { + const promptText = group.production_prompt?.prompt_text || ''; + + // Check for variables + const variables = detectVariables(promptText); + const hasVariables = variables.length > 0; + + if (hasVariables) { + setSelectedGroup(group); + setShowVariableDialog(true); + } else { + onPromptSelect(promptText); + onClose(); + setSearchValue(''); + } + }, [onPromptSelect, onClose]); + + // Handle item selection (reserved command or prompt) + const handleSelect = useCallback((item: ReservedCommand | PromptGroup) => { + if ('command' in item && RESERVED_COMMANDS.some(cmd => cmd.command === item.command)) { + handleReservedCommandSelect(item as ReservedCommand); + } else { + handlePromptSelect(item as PromptGroup); + } + }, [handleReservedCommandSelect, handlePromptSelect]); + + // Handle variable dialog completion + const handleVariableSubmit = useCallback((processedPrompt: string) => { + onPromptSelect(processedPrompt); + setShowVariableDialog(false); + setSelectedGroup(null); + onClose(); + setSearchValue(''); + }, [onPromptSelect, onClose]); + + // Keyboard navigation + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onClose(); + setSearchValue(''); + textAreaRef.current?.focus(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + setIsKeyboardMode(true); + setActiveIndex(prev => Math.min(prev + 1, allItems.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setIsKeyboardMode(true); + setActiveIndex(prev => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + if (allItems[activeIndex]) { + handleSelect(allItems[activeIndex]); + } + } else if (e.key === 'Backspace' && searchValue === '') { + onClose(); + textAreaRef.current?.focus(); + } + }, [allItems, activeIndex, searchValue, handleSelect, onClose, textAreaRef]); + + // Auto-focus input when opened + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + } + }, [isOpen]); + + // Reset active index when search changes + useEffect(() => { + setActiveIndex(0); + }, [searchValue]); + + // Scroll active item into view + useEffect(() => { + const activeElement = document.getElementById(`prompt-item-${activeIndex}`); + if (activeElement) { + activeElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + }, [activeIndex]); + + if (!isOpen) return null; + + return ( + <> + {/* Backdrop */} +
+ +
+
+ {/* Search Input */} +
+ + setSearchValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search by shortcut or name..." + className="flex-1 bg-transparent text-sm outline-none placeholder:text-[var(--muted-foreground)]" + /> +
+ + {/* Results List */} +
+ {isLoading ? ( +
+
+
+ ) : allItems.length === 0 ? ( +
+

+ {searchValue ? 'No prompts found' : 'No prompts available.'} +

+ {!searchValue && ( + + )} +
+ ) : ( +
+ {/* Regular Prompts */} + {filteredGroups.map((group, index) => { + return ( + + ); + })} + + {/* Reserved Commands - Always visible at bottom */} + {availableReservedCommands.length > 0 && ( + <> + {filteredGroups.length > 0 && ( +
+ )} + {availableReservedCommands.map((cmd, index) => { + const actualIndex = filteredGroups.length + index; + const Icon = cmd.icon; + return ( + + ); + })} + + )} +
+ )} +
+
+
+ + {/* Variable Dialog */} + {showVariableDialog && selectedGroup && ( + { + setShowVariableDialog(false); + setSelectedGroup(null); + // Don't close the main popover - let user select a different prompt + }} + /> + )} + + ); +}; \ No newline at end of file diff --git a/client/webui/frontend/src/lib/components/chat/VariableDialog.tsx b/client/webui/frontend/src/lib/components/chat/VariableDialog.tsx new file mode 100644 index 000000000..45bbb8893 --- /dev/null +++ b/client/webui/frontend/src/lib/components/chat/VariableDialog.tsx @@ -0,0 +1,127 @@ +/** + * Modal dialog for substituting variables in prompts + */ + +import React, { useState, useEffect } from 'react'; +import type { PromptGroup } from '@/lib/types/prompts'; +import { detectVariables, replaceVariables } from '@/lib/utils/promptUtils'; +import { Button } from '@/lib/components/ui'; +import { MessageBanner } from '@/lib/components/common'; + +interface VariableDialogProps { + group: PromptGroup; + onSubmit: (processedPrompt: string) => void; + onClose: () => void; +} + +export const VariableDialog: React.FC = ({ + group, + onSubmit, + onClose, +}) => { + const promptText = group.production_prompt?.prompt_text || ''; + const variables = detectVariables(promptText); + + const [values, setValues] = useState>(() => { + const initial: Record = {}; + variables.forEach(v => { + initial[v] = ''; + }); + return initial; + }); + const [showError, setShowError] = useState(false); + + // Handle form submission + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + // Check if all variables have values + const allFilled = variables.every(v => values[v]?.trim()); + if (!allFilled) { + setShowError(true); + setTimeout(() => setShowError(false), 3000); + return; + } + + const processedPrompt = replaceVariables(promptText, values); + onSubmit(processedPrompt); + }; + + // Handle escape key + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + window.addEventListener('keydown', handleEscape); + return () => window.removeEventListener('keydown', handleEscape); + }, [onClose]); + + + return ( +
+
+ {/* Header - Fixed */} +
+

Insert {group.name}

+

+ Variables represent placeholder information in the template. Enter a value for each placeholder below. +

+
+ + {showError && ( +
+ +
+ )} + +
+
+
+ {variables.map((variable) => ( +
+ +