From c867f7080b10ce5724f52b7609a148dbb32e0bb4 Mon Sep 17 00:00:00 2001 From: Amir Ghasemi Date: Wed, 29 Oct 2025 15:23:49 -0400 Subject: [PATCH 01/47] feat(prompts): Add prompt templates with a card-based Prompts page and AI-assisted prompt template builder. Also supports prompt version historyand ability to restore older prompts. - Implement AI-assisted prompt builder using LLM for template generation (uses liteLLM and LLM_SERVICE_GENERAL_MODEL_NAME env variable) - Add version history with restore functionality - Support variable placeholders ({{variable_name}}) in prompts - Enable prompt editing with automatic version creation - Include search functionality and empty states - Add VariableDialog for filling prompt variables before use Backend: - Add PromptBuilderAssistant service with LiteLLM integration - Update PATCH endpoint to create new versions on prompt text changes - Add version management endpoints (list, restore) - Update DTOs to support prompt text updates --- client/webui/frontend/src/App.tsx | 3 + .../src/lib/components/chat/ChatInputArea.tsx | 55 +- .../lib/components/chat/PromptsCommand.tsx | 237 ++++++ .../lib/components/chat/VariableDialog.tsx | 126 +++ .../lib/components/navigation/navigation.ts | 7 +- .../src/lib/components/pages/PromptsPage.tsx | 229 ++++++ .../src/lib/components/pages/index.ts | 1 + .../components/prompts/CreatePromptCard.tsx | 47 ++ .../components/prompts/PromptBuilderChat.tsx | 289 +++++++ .../components/prompts/PromptDeleteDialog.tsx | 38 + .../components/prompts/PromptDisplayCard.tsx | 200 +++++ .../components/prompts/PromptGroupForm.tsx | 223 +++++ .../components/prompts/PromptMeshCards.tsx | 86 ++ .../prompts/PromptRestoreDialog.tsx | 38 + .../prompts/PromptTemplateBuilder.tsx | 328 ++++++++ .../prompts/TemplatePreviewPanel.tsx | 229 ++++++ .../prompts/VersionHistoryDialog.tsx | 169 ++++ .../prompts/hooks/usePromptTemplateBuilder.ts | 168 ++++ .../src/lib/components/prompts/index.ts | 10 + .../frontend/src/lib/components/ui/index.ts | 3 + .../frontend/src/lib/components/ui/label.tsx | 16 + .../webui/frontend/src/lib/types/prompts.ts | 73 ++ .../frontend/src/lib/utils/promptUtils.ts | 152 ++++ .../versions/20251029_create_prompt_tables.py | 99 +++ .../gateway/http_sse/main.py | 2 + .../http_sse/repository/models/__init__.py | 3 + .../repository/models/prompt_model.py | 109 +++ .../http_sse/routers/dto/prompt_dto.py | 165 ++++ .../gateway/http_sse/routers/prompts.py | 769 ++++++++++++++++++ .../services/prompt_builder_assistant.py | 276 +++++++ 30 files changed, 4147 insertions(+), 3 deletions(-) create mode 100644 client/webui/frontend/src/lib/components/chat/PromptsCommand.tsx create mode 100644 client/webui/frontend/src/lib/components/chat/VariableDialog.tsx create mode 100644 client/webui/frontend/src/lib/components/pages/PromptsPage.tsx create mode 100644 client/webui/frontend/src/lib/components/prompts/CreatePromptCard.tsx create mode 100644 client/webui/frontend/src/lib/components/prompts/PromptBuilderChat.tsx create mode 100644 client/webui/frontend/src/lib/components/prompts/PromptDeleteDialog.tsx create mode 100644 client/webui/frontend/src/lib/components/prompts/PromptDisplayCard.tsx create mode 100644 client/webui/frontend/src/lib/components/prompts/PromptGroupForm.tsx create mode 100644 client/webui/frontend/src/lib/components/prompts/PromptMeshCards.tsx create mode 100644 client/webui/frontend/src/lib/components/prompts/PromptRestoreDialog.tsx create mode 100644 client/webui/frontend/src/lib/components/prompts/PromptTemplateBuilder.tsx create mode 100644 client/webui/frontend/src/lib/components/prompts/TemplatePreviewPanel.tsx create mode 100644 client/webui/frontend/src/lib/components/prompts/VersionHistoryDialog.tsx create mode 100644 client/webui/frontend/src/lib/components/prompts/hooks/usePromptTemplateBuilder.ts create mode 100644 client/webui/frontend/src/lib/components/prompts/index.ts create mode 100644 client/webui/frontend/src/lib/components/ui/label.tsx create mode 100644 client/webui/frontend/src/lib/types/prompts.ts create mode 100644 client/webui/frontend/src/lib/utils/promptUtils.ts create mode 100644 src/solace_agent_mesh/gateway/http_sse/alembic/versions/20251029_create_prompt_tables.py create mode 100644 src/solace_agent_mesh/gateway/http_sse/repository/models/prompt_model.py create mode 100644 src/solace_agent_mesh/gateway/http_sse/routers/dto/prompt_dto.py create mode 100644 src/solace_agent_mesh/gateway/http_sse/routers/prompts.py create mode 100644 src/solace_agent_mesh/gateway/http_sse/services/prompt_builder_assistant.py diff --git a/client/webui/frontend/src/App.tsx b/client/webui/frontend/src/App.tsx index 16b19c456..5008f7151 100644 --- a/client/webui/frontend/src/App.tsx +++ b/client/webui/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { AgentMeshPage, ChatPage, bottomNavigationItems, topNavigationItems, NavigationSidebar, ToastContainer, Button } from "@/lib/components"; +import { PromptsPage } from "@/lib/components/pages/PromptsPage"; import { AuthProvider, ChatProvider, ConfigProvider, CsrfProvider, TaskProvider, ThemeProvider } from "@/lib/providers"; import { useAuthContext, useBeforeUnload } from "@/lib/hooks"; @@ -40,6 +41,8 @@ function AppContent() { return ; case "agentMesh": return ; + case "prompts": + return ; } }; diff --git a/client/webui/frontend/src/lib/components/chat/ChatInputArea.tsx b/client/webui/frontend/src/lib/components/chat/ChatInputArea.tsx index 3d9622d93..2e8c47606 100644 --- a/client/webui/frontend/src/lib/components/chat/ChatInputArea.tsx +++ b/client/webui/frontend/src/lib/components/chat/ChatInputArea.tsx @@ -8,6 +8,7 @@ import { useChatContext, useDragAndDrop, useAgentSelection } from "@/lib/hooks"; import type { AgentCardInfo } from "@/lib/types"; import { FileBadge } from "./file/FileBadge"; +import { PromptsCommand } from "./PromptsCommand"; export const ChatInputArea: React.FC<{ agents: AgentCardInfo[]; scrollToBottom?: () => void }> = ({ agents = [], scrollToBottom }) => { const { isResponding, isCancelling, selectedAgentName, sessionId, handleSubmit, handleCancel } = useChatContext(); @@ -23,10 +24,14 @@ export const ChatInputArea: React.FC<{ agents: AgentCardInfo[]; scrollToBottom?: // Local state for input value (no debouncing needed!) const [inputValue, setInputValue] = useState(""); + + // Prompts command state + const [showPromptsCommand, setShowPromptsCommand] = useState(false); // Clear input when session changes useEffect(() => { setInputValue(""); + setShowPromptsCommand(false); }, [sessionId]); // Focus the chat input when isResponding becomes false @@ -115,6 +120,44 @@ 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); + }; + return (
)} + {/* Prompts Command Popover */} + setShowPromptsCommand(false)} + textAreaRef={chatInputRef} + onPromptSelect={handlePromptSelect} + /> + {/* Chat Input */} setInputValue(event.target.value)} - placeholder="How can I help you today?" + onChange={handleInputChange} + placeholder="How can I help you today? (Type / for prompts)" 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/PromptsCommand.tsx b/client/webui/frontend/src/lib/components/chat/PromptsCommand.tsx new file mode 100644 index 000000000..634cb5ab4 --- /dev/null +++ b/client/webui/frontend/src/lib/components/chat/PromptsCommand.tsx @@ -0,0 +1,237 @@ +/** + * Displays a popover with searchable prompt library when "/" is typed + */ + +import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; +import { Command, Search } from 'lucide-react'; +import type { PromptGroup } from '@/lib/types/prompts'; +import { detectVariables } from '@/lib/utils/promptUtils'; +import { VariableDialog } from './VariableDialog'; + +interface PromptsCommandProps { + isOpen: boolean; + onClose: () => void; + textAreaRef: React.RefObject; + onPromptSelect: (promptText: string) => void; +} + +export const PromptsCommand: React.FC = ({ + isOpen, + onClose, + textAreaRef, + onPromptSelect, +}) => { + 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 inputRef = useRef(null); + const popoverRef = 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]); + + // 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]); + + // Handle prompt selection + const handleSelect = 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 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') { + onClose(); + setSearchValue(''); + textAreaRef.current?.focus(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex(prev => Math.min(prev + 1, filteredGroups.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex(prev => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + if (filteredGroups[activeIndex]) { + handleSelect(filteredGroups[activeIndex]); + } + } else if (e.key === 'Backspace' && searchValue === '') { + onClose(); + textAreaRef.current?.focus(); + } + }, [filteredGroups, 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 ( + <> +
+
+ {/* Search Input */} +
+ + setSearchValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search prompts... (use / command or name)" + className="flex-1 bg-transparent text-sm outline-none placeholder:text-[var(--muted-foreground)]" + /> + + ESC + +
+ + {/* Results List */} +
+ {isLoading ? ( +
+
+
+ ) : filteredGroups.length === 0 ? ( +
+ {searchValue ? 'No prompts found' : 'No prompts available. Create one in the Prompts panel.'} +
+ ) : ( +
+ {filteredGroups.map((group, index) => ( + + ))} +
+ )} +
+
+
+ + {/* Variable Dialog */} + {showVariableDialog && selectedGroup && ( + { + setShowVariableDialog(false); + setSelectedGroup(null); + onClose(); + }} + /> + )} + + ); +}; \ 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..00932d639 --- /dev/null +++ b/client/webui/frontend/src/lib/components/chat/VariableDialog.tsx @@ -0,0 +1,126 @@ +/** + * Modal dialog for substituting variables in prompts + */ + +import React, { useState, useEffect } from 'react'; +import { X } from 'lucide-react'; +import type { PromptGroup } from '@/lib/types/prompts'; +import { detectVariables, replaceVariables } from '@/lib/utils/promptUtils'; +import { Button } from '@/lib/components/ui'; + +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; + }); + + // 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) { + 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]); + + const allFilled = variables.every(v => values[v]?.trim()); + + return ( +
+
+ {/* Header */} +
+
+

Fill in Variables

+

+ {group.name} +

+
+ +
+ + {/* Form */} +
+
+ {variables.map((variable) => ( +
+ +