setCreatePromptDialogOpen(false)}
+ onYAMLGenerated={handleYAMLGenerated}
+ />
+
+ {/* Prompt editor */}
+
)
}
diff --git a/sippy-ng/src/chat/ChatSettings.js b/sippy-ng/src/chat/ChatSettings.js
index c92b86c8c..c1d0fd262 100644
--- a/sippy-ng/src/chat/ChatSettings.js
+++ b/sippy-ng/src/chat/ChatSettings.js
@@ -21,6 +21,7 @@ import {
import {
VerticalAlignBottom as AutoScrollIcon,
Close as CloseIcon,
+ Code as CodeIcon,
Delete as DeleteIcon,
Info as InfoIcon,
Masks as MasksIcon,
@@ -37,10 +38,12 @@ import { makeStyles } from '@mui/styles'
import {
useConnectionState,
usePersonas,
+ usePrompts,
useSessionActions,
useSessionState,
useSettings,
} from './store/useChatStore'
+import PromptManagerModal from './PromptManagerModal'
import PropTypes from 'prop-types'
import React, { useCallback, useEffect, useState } from 'react'
@@ -109,6 +112,7 @@ export default function ChatSettings({ onClearMessages, onReconnect }) {
const { connectionState } = useConnectionState()
const { personas, personasLoading, personasError, loadPersonas } =
usePersonas()
+ const { localPrompts } = usePrompts()
const { sessions, activeSessionId } = useSessionState()
const { clearAllSessions, clearOldSessions } = useSessionActions()
@@ -122,6 +126,9 @@ export default function ChatSettings({ onClearMessages, onReconnect }) {
})
const [storageLoading, setStorageLoading] = useState(false)
+ // Prompt manager
+ const [promptManagerOpen, setPromptManagerOpen] = useState(false)
+
useEffect(() => {
if (personas.length === 0 && !personasLoading) {
loadPersonas()
@@ -475,6 +482,57 @@ export default function ChatSettings({ onClearMessages, onReconnect }) {
+ {/* Custom Prompts */}
+
+
+
+
+ Custom Prompts
+
+
+
+
+
+
+
+ theme.palette.mode === 'dark'
+ ? 'rgba(255, 255, 255, 0.05)'
+ : 'rgba(0, 0, 0, 0.02)',
+ borderRadius: 1,
+ textAlign: 'center',
+ }}
+ >
+
+ {localPrompts.length === 0
+ ? 'No custom prompts yet'
+ : `${localPrompts.length} custom prompt${
+ localPrompts.length !== 1 ? 's' : ''
+ }`}
+
+
+
+ }
+ onClick={() => setPromptManagerOpen(true)}
+ fullWidth
+ >
+ Manage Custom Prompts
+
+
+
+
+
{/* Tour Management */}
@@ -500,6 +558,12 @@ export default function ChatSettings({ onClearMessages, onReconnect }) {
)}
+
+ {/* Prompt Manager Modal */}
+ setPromptManagerOpen(false)}
+ />
)
}
diff --git a/sippy-ng/src/chat/CreatePromptDialog.js b/sippy-ng/src/chat/CreatePromptDialog.js
new file mode 100644
index 000000000..d05762238
--- /dev/null
+++ b/sippy-ng/src/chat/CreatePromptDialog.js
@@ -0,0 +1,280 @@
+import { AutoAwesome as AutoAwesomeIcon } from '@mui/icons-material'
+import {
+ Button,
+ Checkbox,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ FormControlLabel,
+ TextField,
+ Typography,
+} from '@mui/material'
+import { makeStyles } from '@mui/styles'
+import { useSessionState } from './store/useChatStore'
+import OneShotChatModal from './OneShotChatModal'
+import PropTypes from 'prop-types'
+import React, { useState } from 'react'
+
+const useStyles = makeStyles((theme) => ({
+ dialogPaper: {
+ minWidth: 500,
+ },
+ content: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: theme.spacing(2),
+ },
+ description: {
+ color: theme.palette.text.secondary,
+ marginBottom: theme.spacing(1),
+ },
+}))
+
+/**
+ * CreatePromptDialog - Dialog for AI-assisted prompt creation
+ * Allows user to describe desired prompt and optionally include chat history as example
+ */
+export default function CreatePromptDialog({ open, onClose, onYAMLGenerated }) {
+ const classes = useStyles()
+ const { activeSession } = useSessionState()
+
+ const [promptDescription, setPromptDescription] = useState('')
+ const [includeHistory, setIncludeHistory] = useState(false)
+ const [aiModalOpen, setAiModalOpen] = useState(false)
+
+ // Pre-fill when there's conversation history
+ React.useEffect(() => {
+ if (open && activeSession && activeSession.messages?.length > 0) {
+ setIncludeHistory(true)
+ if (!promptDescription) {
+ setPromptDescription(
+ 'Create a reusable prompt template based on the conversation above. Identify any specific values (like release versions, job names, test names, etc.) and make them parameterized arguments.'
+ )
+ }
+ }
+ }, [open, activeSession])
+
+ const handleCreate = () => {
+ setAiModalOpen(true)
+ }
+
+ const handleClose = () => {
+ setPromptDescription('')
+ setIncludeHistory(false)
+ onClose()
+ }
+
+ const handleAIResult = (result) => {
+ console.log('=== AI Generated Prompt Response ===')
+ console.log('Full response length:', result.length)
+
+ // Pass the generated YAML to parent (should be raw YAML)
+ onYAMLGenerated(result)
+ handleClose()
+ }
+
+ const buildAIPrompt = () => {
+ let prompt = `Create a Sippy prompt in YAML format based on this request:
+
+${promptDescription}
+
+`
+
+ // Include chat history if checkbox is checked
+ if (includeHistory && activeSession && activeSession.messages) {
+ const conversationHistory = activeSession.messages
+ .map((msg) => {
+ const role = msg.role === 'user' ? 'User' : 'Assistant'
+ return `${role}: ${msg.content}`
+ })
+ .join('\n\n')
+
+ prompt += `Example Conversation:
+---
+${conversationHistory}
+---
+
+Based on this conversation, create a reusable prompt template with appropriate arguments.
+Use Jinja2/Nunjucks templating for variable substitution (e.g., {{ variable_name }}).
+
+`
+ }
+
+ prompt += `IMPORTANT GUIDELINES FOR CREATING SIPPY PROMPTS:
+
+1. **Arguments are for VARIABLES only** - Use arguments to parameterize inputs like job names, releases, test names, etc.
+2. **All instructions go in the prompt field** - The entire workflow, formatting requirements, and detailed instructions should be in the prompt template itself
+3. **Be VERY detailed in the prompt** - Include exact steps, SQL queries, tool usage patterns, formatting requirements, and output structure
+4. **Specify exact output format** - Tell the LLM exactly how to structure the response (markdown headings, tables, lists, etc.)
+5. **Include examples** - Show the LLM what good output looks like
+
+**CRITICAL OUTPUT REQUIREMENT:**
+Return ONLY the YAML content. Do NOT wrap it in markdown code blocks or add any other text.
+Start your response directly with "name:" and nothing else before it.
+
+Here's an example of a WELL-WRITTEN prompt:
+
+name: test-failure-analysis
+description: Analyze a test's performance, identify failure patterns, and provide recommendations
+arguments:
+ - name: release
+ description: Release version (e.g., 4.18, 4.17)
+ required: true
+ type: string
+ autocomplete: releases
+ - name: test_name
+ description: Fully qualified test name
+ required: true
+ type: string
+ autocomplete: tests
+prompt: |
+ Analyze test: **{{ test_name }}** on release **{{ release }}**
+
+ ## 1. Overview
+ - Display test name as a markdown link to the Sippy analysis page
+ - Use format: \`[Test Name]({base_url}/sippy-ng/tests/{release}/analysis?test={url_encoded_test_name})\`
+
+ ## 2. 7-Day Performance
+ Use \`prow_test_report_7d_matview\` materialized view:
+ - Overall pass rate: sum(current_successes) / sum(current_runs) × 100
+ - Total runs, failures, flakes
+ - Trend: consistent passing/failing or intermittent
+
+ ## 3. Variant Analysis
+ Query grouped by variants:
+ - Calculate failure rate per variant combination
+ - Report: variant combo, pass rate, failure count vs runs
+ - Order by worst performing first
+
+ ## 4. Failure Modes
+ Query \`prow_job_run_test_outputs\` for recent failures:
+ - Examine up to 10 failure outputs
+ - Categorize: Consistent error vs diverse issues
+ - Include exact error messages
+
+ ## 5. Assessment & Recommendations
+ **Root Cause (confidence: High/Medium/Low):**
+ - Product bug / Test issue / Infrastructure / Variant-specific
+ - Key evidence
+ - Next steps
+
+ **Guidelines:** Use exact data, include links, use markdown tables, state explicitly if data unavailable
+
+Now create a prompt following this pattern. Make your prompt DETAILED with:
+- Exact step-by-step workflow
+- Specific tool calls and database queries
+- Clear output format with markdown structure
+- Explicit guidelines for the LLM
+
+**CRITICAL: GENERALIZE THE CONVERSATION**
+When basing this on a conversation:
+- Identify any SPECIFIC VALUES mentioned (release "4.21", job name "periodic-ci-...", test name, payload URL, etc.)
+- Make those into ARGUMENTS - do NOT hardcode them in the prompt
+- Example: If user asked about "release 4.21", create an argument \`release\` and use \`{{ release }}\` in the prompt
+- Example: If user asked about a specific job name, create a \`job_name\` argument with autocomplete: jobs
+- The prompt should work for ANY similar scenario, not just the specific one from the conversation
+
+**MAKE THE PROMPT COMPREHENSIVE**
+- Include ALL the steps the LLM took in the conversation
+- Specify exact database tables, materialized views, and query patterns
+- **CRITICAL**: Tell the LLM that the database tables DO EXIST and should be used as specified
+- If the LLM needs table schema information, instruct it to query the PostgreSQL database directly to retrieve column names and types
+- The Sippy database has many more tables than what's documented - the LLM should trust the table names in the prompt
+- Describe the output format in detail (headings, sections, tables, charts)
+- Include any guidelines about when to stop, how to handle errors, parallel tool calls
+- Tell the LLM exactly what to do, don't leave anything ambiguous
+
+**ABOUT PLOTLY CHARTS**
+- If the conversation included creating a chart, describe the chart requirements in detail
+- Do NOT include sample Plotly JSON in the prompt itself
+- Instead, describe what the chart should look like (chart type, axes, colors, hover mode, etc.)
+- The LLM executing the prompt will generate the actual Plotly JSON at runtime
+`
+
+ return prompt
+ }
+
+ return (
+ <>
+
+
+ {/* AI Generation Modal */}
+ setAiModalOpen(false)}
+ prompt={buildAIPrompt()}
+ onResult={handleAIResult}
+ title="Generating Prompt YAML"
+ />
+ >
+ )
+}
+
+CreatePromptDialog.propTypes = {
+ open: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onYAMLGenerated: PropTypes.func.isRequired,
+}
diff --git a/sippy-ng/src/chat/PromptEditor.js b/sippy-ng/src/chat/PromptEditor.js
new file mode 100644
index 000000000..d2dd228ad
--- /dev/null
+++ b/sippy-ng/src/chat/PromptEditor.js
@@ -0,0 +1,697 @@
+import {
+ Add as AddIcon,
+ AutoAwesome as AutoAwesomeIcon,
+ Close as CloseIcon,
+ Delete as DeleteIcon,
+ History as HistoryIcon,
+ Save as SaveIcon,
+} from '@mui/icons-material'
+import {
+ Alert,
+ Box,
+ Button,
+ Chip,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Divider,
+ FormControlLabel,
+ IconButton,
+ List,
+ ListItem,
+ ListItemText,
+ MenuItem,
+ Select,
+ Switch,
+ Tab,
+ Tabs,
+ TextField,
+ Typography,
+} from '@mui/material'
+import {
+ getDefaultPromptTemplate,
+ promptToYAML,
+ validatePromptYAML,
+} from './promptSchema'
+import { makeStyles } from '@mui/styles'
+import { usePrompts } from './store/useChatStore'
+import OneShotChatModal from './OneShotChatModal'
+import PropTypes from 'prop-types'
+import React, { useEffect, useState } from 'react'
+import YamlEditor from './YamlEditor'
+
+const useStyles = makeStyles((theme) => ({
+ dialogPaper: {
+ minWidth: 800,
+ maxWidth: '90vw',
+ height: '80vh',
+ },
+ dialogContent: {
+ display: 'flex',
+ flexDirection: 'column',
+ padding: 0,
+ height: '100%',
+ overflow: 'hidden',
+ },
+ header: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ tabs: {
+ borderBottom: `1px solid ${theme.palette.divider}`,
+ paddingLeft: theme.spacing(2),
+ paddingRight: theme.spacing(2),
+ },
+ tabContent: {
+ flex: 1,
+ overflow: 'auto',
+ minHeight: 0,
+ padding: theme.spacing(2),
+ },
+ formFields: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: theme.spacing(2),
+ },
+ argumentsList: {
+ border: `1px solid ${theme.palette.divider}`,
+ borderRadius: theme.shape.borderRadius,
+ padding: theme.spacing(1),
+ },
+ argumentItem: {
+ backgroundColor:
+ theme.palette.mode === 'dark'
+ ? 'rgba(255, 255, 255, 0.05)'
+ : 'rgba(0, 0, 0, 0.02)',
+ borderRadius: theme.shape.borderRadius,
+ marginBottom: theme.spacing(1),
+ padding: theme.spacing(1),
+ },
+ aiRefinementSection: {
+ marginTop: theme.spacing(2),
+ padding: theme.spacing(2),
+ backgroundColor:
+ theme.palette.mode === 'dark'
+ ? 'rgba(138, 43, 226, 0.1)'
+ : 'rgba(138, 43, 226, 0.05)',
+ borderRadius: theme.shape.borderRadius,
+ border: `1px solid ${theme.palette.primary.main}`,
+ },
+ versionHistory: {
+ marginTop: theme.spacing(2),
+ },
+ versionItem: {
+ cursor: 'pointer',
+ '&:hover': {
+ backgroundColor: theme.palette.action.hover,
+ },
+ },
+}))
+
+/**
+ * PromptEditor - Dialog for creating and editing custom prompts
+ * Features: Dual view (YAML/Form), version history, AI refinement
+ */
+export default function PromptEditor({
+ open,
+ onClose,
+ promptName = null,
+ initialYAML = null,
+}) {
+ const classes = useStyles()
+ const {
+ saveLocalPrompt,
+ updateLocalPrompt,
+ deleteLocalPrompt,
+ getLocalPrompt,
+ serverPrompts,
+ } = usePrompts()
+
+ const [viewMode, setViewMode] = useState(1) // 0 = YAML, 1 = Form
+ const [yamlContent, setYamlContent] = useState('')
+ const [validationErrors, setValidationErrors] = useState([])
+ const [versions, setVersions] = useState([])
+ const [aiModalOpen, setAiModalOpen] = useState(false)
+ const [aiRefinementPrompt, setAiRefinementPrompt] = useState('')
+ const [saveError, setSaveError] = useState(null)
+
+ // Form fields (parsed from YAML)
+ const [formData, setFormData] = useState({
+ name: '',
+ description: '',
+ hide: false,
+ arguments: [],
+ prompt: '',
+ })
+
+ // Load existing prompt or use default template
+ useEffect(() => {
+ if (open) {
+ if (promptName) {
+ // Editing existing prompt
+ const existingPrompt = getLocalPrompt(promptName)
+ if (existingPrompt) {
+ const { createdAt, updatedAt, source, ...cleanPrompt } =
+ existingPrompt
+ const yaml = promptToYAML(cleanPrompt)
+ setYamlContent(yaml)
+ setFormData(cleanPrompt)
+ setVersions([{ yaml, timestamp: new Date().toISOString() }])
+ }
+ } else if (initialYAML) {
+ // Creating from AI-generated YAML
+ console.log(
+ 'PromptEditor: Received initialYAML, length:',
+ initialYAML.length
+ )
+ console.log('PromptEditor: initialYAML content:', initialYAML)
+ setYamlContent(initialYAML)
+ parseYAMLToForm(initialYAML)
+ setVersions([
+ { yaml: initialYAML, timestamp: new Date().toISOString() },
+ ])
+ } else {
+ // New prompt with default template
+ const defaultYAML = getDefaultPromptTemplate()
+ setYamlContent(defaultYAML)
+ parseYAMLToForm(defaultYAML)
+ setVersions([
+ { yaml: defaultYAML, timestamp: new Date().toISOString() },
+ ])
+ }
+ } else {
+ // Reset on close
+ setYamlContent('')
+ setFormData({
+ name: '',
+ description: '',
+ hide: false,
+ arguments: [],
+ prompt: '',
+ })
+ setVersions([])
+ setValidationErrors([])
+ setSaveError(null)
+ setAiRefinementPrompt('')
+ }
+ }, [open, promptName, initialYAML, getLocalPrompt])
+
+ // Parse YAML to form data
+ const parseYAMLToForm = (yamlStr) => {
+ const validation = validatePromptYAML(yamlStr)
+ if (validation.valid) {
+ setFormData(validation.prompt)
+ setValidationErrors([])
+ } else {
+ setValidationErrors(validation.errors)
+ }
+ }
+
+ // Sync form to YAML when switching to YAML view
+ const syncFormToYAML = () => {
+ try {
+ const yamlStr = promptToYAML(formData)
+ setYamlContent(yamlStr)
+ setValidationErrors([])
+ } catch (error) {
+ setValidationErrors([`Failed to convert form to YAML: ${error.message}`])
+ }
+ }
+
+ // Handle tab change
+ const handleTabChange = (event, newValue) => {
+ if (newValue === 1 && viewMode === 0) {
+ // Switching from Form to YAML
+ syncFormToYAML()
+ } else if (newValue === 0 && viewMode === 1) {
+ // Switching from YAML to Form
+ parseYAMLToForm(yamlContent)
+ }
+ setViewMode(newValue)
+ }
+
+ // Handle YAML change
+ const handleYAMLChange = (newYAML) => {
+ setYamlContent(newYAML)
+ parseYAMLToForm(newYAML)
+ }
+
+ // Handle form field changes
+ const handleFormFieldChange = (field, value) => {
+ setFormData((prev) => ({ ...prev, [field]: value }))
+ }
+
+ // Handle argument changes
+ const addArgument = () => {
+ setFormData((prev) => ({
+ ...prev,
+ arguments: [
+ ...(prev.arguments || []),
+ {
+ name: '',
+ description: '',
+ required: false,
+ type: 'string',
+ },
+ ],
+ }))
+ }
+
+ const updateArgument = (index, field, value) => {
+ setFormData((prev) => {
+ const newArgs = [...(prev.arguments || [])]
+ newArgs[index] = { ...newArgs[index], [field]: value }
+ return { ...prev, arguments: newArgs }
+ })
+ }
+
+ const deleteArgument = (index) => {
+ setFormData((prev) => ({
+ ...prev,
+ arguments: (prev.arguments || []).filter((_, i) => i !== index),
+ }))
+ }
+
+ // Save prompt
+ const handleSave = () => {
+ // Ensure we have the latest YAML
+ const finalYAML = viewMode === 1 ? promptToYAML(formData) : yamlContent
+
+ // Validate
+ const validation = validatePromptYAML(finalYAML)
+ if (!validation.valid) {
+ setValidationErrors(validation.errors)
+ return
+ }
+
+ try {
+ if (promptName) {
+ // Update existing
+ updateLocalPrompt(promptName, validation.prompt)
+ } else {
+ // Save new
+ saveLocalPrompt(validation.prompt)
+ }
+ onClose()
+ } catch (error) {
+ setSaveError(error.message)
+ }
+ }
+
+ // Delete prompt
+ const handleDelete = () => {
+ if (
+ window.confirm(
+ `Are you sure you want to delete the prompt "${promptName}"?`
+ )
+ ) {
+ try {
+ deleteLocalPrompt(promptName)
+ onClose()
+ } catch (error) {
+ setSaveError(error.message)
+ }
+ }
+ }
+
+ // AI refinement
+ const handleAIRefinement = () => {
+ setAiModalOpen(true)
+ }
+
+ const handleAIRefinementResult = (result) => {
+ // Extract YAML from the result
+ const yamlBlockRegex = /```(?:yaml|yml)?\s*\n([\s\S]*?)```/i
+ const match = result.match(yamlBlockRegex)
+
+ if (match) {
+ const newYAML = match[1].trim()
+ // Add to version history
+ setVersions((prev) => [
+ { yaml: newYAML, timestamp: new Date().toISOString() },
+ ...prev.slice(0, 9), // Keep max 10 versions
+ ])
+ setYamlContent(newYAML)
+ parseYAMLToForm(newYAML)
+ } else {
+ // If no YAML block found, try to use the whole result
+ setVersions((prev) => [
+ { yaml: result, timestamp: new Date().toISOString() },
+ ...prev.slice(0, 9),
+ ])
+ setYamlContent(result)
+ parseYAMLToForm(result)
+ }
+ setAiRefinementPrompt('')
+ }
+
+ // Revert to a previous version
+ const handleRevertToVersion = (versionYAML) => {
+ setYamlContent(versionYAML)
+ parseYAMLToForm(versionYAML)
+ }
+
+ // Build AI refinement prompt
+ const buildAIPrompt = () => {
+ const currentYAML = viewMode === 1 ? promptToYAML(formData) : yamlContent
+ return `Adjust this Sippy prompt YAML according to the following request:
+
+Current YAML:
+\`\`\`yaml
+${currentYAML}
+\`\`\`
+
+Requested changes: ${aiRefinementPrompt}
+
+Please provide the updated YAML in a code block. Maintain the same structure and format.`
+ }
+
+ return (
+ <>
+
+
+ {/* AI Refinement Modal */}
+ setAiModalOpen(false)}
+ prompt={buildAIPrompt()}
+ onResult={handleAIRefinementResult}
+ title="Refining Prompt with AI"
+ />
+ >
+ )
+}
+
+PromptEditor.propTypes = {
+ open: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ promptName: PropTypes.string,
+ initialYAML: PropTypes.string,
+}
diff --git a/sippy-ng/src/chat/PromptManagerModal.js b/sippy-ng/src/chat/PromptManagerModal.js
new file mode 100644
index 000000000..21c445194
--- /dev/null
+++ b/sippy-ng/src/chat/PromptManagerModal.js
@@ -0,0 +1,548 @@
+import {
+ Add as AddIcon,
+ Close as CloseIcon,
+ Code as CodeIcon,
+ Computer as ComputerIcon,
+ Delete as DeleteIcon,
+ Edit as EditIcon,
+ FileDownload as FileDownloadIcon,
+ FileUpload as FileUploadIcon,
+ Search as SearchIcon,
+} from '@mui/icons-material'
+import {
+ Box,
+ Button,
+ Dialog,
+ DialogContent,
+ Divider,
+ IconButton,
+ InputAdornment,
+ List,
+ ListItem,
+ ListItemButton,
+ ListItemText,
+ TextField,
+ Toolbar,
+ Typography,
+} from '@mui/material'
+import { extractYAMLFromText, promptToYAML } from './promptSchema'
+import { makeStyles } from '@mui/styles'
+import { usePrompts } from './store/useChatStore'
+import CreatePromptDialog from './CreatePromptDialog'
+import PromptEditor from './PromptEditor'
+import PropTypes from 'prop-types'
+import React, { useState } from 'react'
+
+const useStyles = makeStyles((theme) => ({
+ dialog: {
+ '& .MuiDialog-paper': {
+ width: '90vw',
+ maxWidth: 1400,
+ height: '85vh',
+ maxHeight: 900,
+ },
+ },
+ dialogContent: {
+ padding: 0,
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ },
+ toolbar: {
+ borderBottom: `1px solid ${theme.palette.divider}`,
+ padding: theme.spacing(2),
+ gap: theme.spacing(2),
+ },
+ mainContent: {
+ display: 'flex',
+ flex: 1,
+ overflow: 'hidden',
+ },
+ sidebar: {
+ width: 320,
+ borderRight: `1px solid ${theme.palette.divider}`,
+ display: 'flex',
+ flexDirection: 'column',
+ backgroundColor:
+ theme.palette.mode === 'dark'
+ ? 'rgba(255, 255, 255, 0.02)'
+ : 'rgba(0, 0, 0, 0.02)',
+ },
+ sidebarHeader: {
+ padding: theme.spacing(2),
+ borderBottom: `1px solid ${theme.palette.divider}`,
+ },
+ promptList: {
+ flex: 1,
+ overflow: 'auto',
+ padding: 0,
+ },
+ promptListItem: {
+ borderBottom: `1px solid ${theme.palette.divider}`,
+ '&.Mui-selected': {
+ backgroundColor:
+ theme.palette.mode === 'dark'
+ ? 'rgba(144, 202, 249, 0.16)'
+ : 'rgba(25, 118, 210, 0.08)',
+ },
+ },
+ emptyState: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ height: '100%',
+ padding: theme.spacing(4),
+ textAlign: 'center',
+ color: theme.palette.text.secondary,
+ },
+ detailPanel: {
+ flex: 1,
+ display: 'flex',
+ flexDirection: 'column',
+ overflow: 'hidden',
+ },
+ detailHeader: {
+ padding: theme.spacing(2),
+ borderBottom: `1px solid ${theme.palette.divider}`,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ detailContent: {
+ flex: 1,
+ overflow: 'auto',
+ padding: theme.spacing(3),
+ },
+ promptPreview: {
+ backgroundColor:
+ theme.palette.mode === 'dark'
+ ? 'rgba(255, 255, 255, 0.05)'
+ : 'rgba(0, 0, 0, 0.02)',
+ padding: theme.spacing(2),
+ borderRadius: theme.shape.borderRadius,
+ fontFamily: 'monospace',
+ fontSize: '0.875rem',
+ whiteSpace: 'pre-wrap',
+ wordBreak: 'break-word',
+ },
+ metadataRow: {
+ display: 'flex',
+ gap: theme.spacing(2),
+ marginBottom: theme.spacing(1),
+ },
+}))
+
+export default function PromptManagerModal({ open, onClose }) {
+ const classes = useStyles()
+ const {
+ localPrompts,
+ deleteLocalPrompt,
+ exportLocalPromptsAsYAML,
+ saveLocalPrompt,
+ } = usePrompts()
+
+ const [selectedPromptName, setSelectedPromptName] = useState(null)
+ const [searchQuery, setSearchQuery] = useState('')
+ const [createDialogOpen, setCreateDialogOpen] = useState(false)
+ const [editorOpen, setEditorOpen] = useState(false)
+ const [editorPromptName, setEditorPromptName] = useState(null)
+ const [editorInitialYAML, setEditorInitialYAML] = useState(null)
+
+ // Filter prompts based on search
+ const filteredPrompts = localPrompts.filter(
+ (prompt) =>
+ prompt.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ prompt.description?.toLowerCase().includes(searchQuery.toLowerCase())
+ )
+
+ const selectedPrompt = localPrompts.find((p) => p.name === selectedPromptName)
+
+ const handlePromptSelect = (promptName) => {
+ setSelectedPromptName(promptName)
+ }
+
+ const handleCreateNew = () => {
+ setCreateDialogOpen(true)
+ }
+
+ const handleEdit = () => {
+ if (selectedPrompt) {
+ setEditorPromptName(selectedPrompt.name)
+ setEditorInitialYAML(null)
+ setEditorOpen(true)
+ }
+ }
+
+ const handleDelete = () => {
+ if (
+ selectedPrompt &&
+ window.confirm(
+ `Are you sure you want to delete "${selectedPrompt.name}"?`
+ )
+ ) {
+ deleteLocalPrompt(selectedPrompt.name)
+ setSelectedPromptName(null)
+ }
+ }
+
+ const handleYAMLGenerated = (aiResponse) => {
+ const yamlBlocks = extractYAMLFromText(aiResponse)
+ const yamlContent = yamlBlocks.length > 0 ? yamlBlocks[0] : aiResponse
+ setEditorInitialYAML(yamlContent)
+ setEditorPromptName(null)
+ setEditorOpen(true)
+ }
+
+ const handleEditorClose = () => {
+ setEditorOpen(false)
+ setEditorPromptName(null)
+ setEditorInitialYAML(null)
+ }
+
+ const handleExport = () => {
+ if (!selectedPrompt) {
+ return
+ }
+
+ // Export selected prompt with its name as filename
+ const { createdAt, updatedAt, source, ...cleanPrompt } = selectedPrompt
+ const yamlContent = promptToYAML(cleanPrompt)
+ const dataStr =
+ 'data:text/yaml;charset=utf-8,' + encodeURIComponent(yamlContent)
+ const a = document.createElement('a')
+ a.href = dataStr
+ a.download = `${selectedPrompt.name}.yaml`
+ a.click()
+ }
+
+ const handleImport = () => {
+ const input = document.createElement('input')
+ input.type = 'file'
+ input.accept = '.yaml,.yml'
+ input.onchange = (e) => {
+ const file = e.target.files[0]
+ if (file) {
+ const reader = new FileReader()
+ reader.onload = (event) => {
+ try {
+ const yamlContent = event.target.result
+ // Split by document separator if multiple prompts
+ const prompts = yamlContent.split(/\n---\n/)
+ prompts.forEach((promptYAML) => {
+ if (promptYAML.trim()) {
+ setEditorInitialYAML(promptYAML.trim())
+ setEditorPromptName(null)
+ setEditorOpen(true)
+ }
+ })
+ } catch (error) {
+ alert(`Failed to import: ${error.message}`)
+ }
+ }
+ reader.readAsText(file)
+ }
+ }
+ input.click()
+ }
+
+ return (
+ <>
+
+
+ {/* Create Dialog */}
+ setCreateDialogOpen(false)}
+ onYAMLGenerated={handleYAMLGenerated}
+ />
+
+ {/* Editor */}
+
+ >
+ )
+}
+
+PromptManagerModal.propTypes = {
+ open: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+}
diff --git a/sippy-ng/src/chat/SlashCommandModal.js b/sippy-ng/src/chat/SlashCommandModal.js
index 55d7736b3..1f7df7380 100644
--- a/sippy-ng/src/chat/SlashCommandModal.js
+++ b/sippy-ng/src/chat/SlashCommandModal.js
@@ -5,6 +5,7 @@ import {
Autocomplete,
Box,
Button,
+ Chip,
CircularProgress,
Dialog,
DialogActions,
@@ -13,7 +14,10 @@ import {
TextField,
Typography,
} from '@mui/material'
-import { ExpandMore as ExpandMoreIcon } from '@mui/icons-material'
+import {
+ Computer as ComputerIcon,
+ ExpandMore as ExpandMoreIcon,
+} from '@mui/icons-material'
import { makeStyles } from '@mui/styles'
import { safeEncodeURIComponent } from '../helpers'
import { usePrompts } from './store/useChatStore'
@@ -353,7 +357,20 @@ export default function SlashCommandModal({
maxWidth="md"
fullWidth
>
- /{prompt.name}
+
+
+ /{prompt.name}
+ {prompt.source === 'local' && (
+ }
+ label="Local"
+ size="small"
+ color="secondary"
+ variant="outlined"
+ />
+ )}
+
+
{prompt.description}
@@ -404,6 +421,7 @@ SlashCommandModal.propTypes = {
prompt: PropTypes.shape({
name: PropTypes.string.isRequired,
description: PropTypes.string,
+ source: PropTypes.string,
arguments: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
diff --git a/sippy-ng/src/chat/SlashCommandSelector.js b/sippy-ng/src/chat/SlashCommandSelector.js
index e95d01b80..1469f6eb8 100644
--- a/sippy-ng/src/chat/SlashCommandSelector.js
+++ b/sippy-ng/src/chat/SlashCommandSelector.js
@@ -1,4 +1,5 @@
import {
+ Chip,
ClickAwayListener,
List,
ListItem,
@@ -6,6 +7,7 @@ import {
Paper,
Popper,
} from '@mui/material'
+import { Computer as ComputerIcon } from '@mui/icons-material'
import { makeStyles } from '@mui/styles'
import { usePrompts } from './store/useChatStore'
import PropTypes from 'prop-types'
@@ -25,6 +27,15 @@ const useStyles = makeStyles((theme) => ({
backgroundColor: theme.palette.action.hover,
},
},
+ listItemContent: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: theme.spacing(1),
+ width: '100%',
+ },
+ localChip: {
+ marginLeft: 'auto',
+ },
}))
export default function SlashCommandSelector({
@@ -116,10 +127,22 @@ export default function SlashCommandSelector({
onClick={() => handlePromptClick(prompt)}
selected={index === selectedIndex}
>
-
+
+
+ {prompt.source === 'local' && (
+ }
+ label="Local"
+ size="small"
+ color="secondary"
+ variant="outlined"
+ className={classes.localChip}
+ />
+ )}
+
))}
diff --git a/sippy-ng/src/chat/YamlEditor.js b/sippy-ng/src/chat/YamlEditor.js
new file mode 100644
index 000000000..88179ad9b
--- /dev/null
+++ b/sippy-ng/src/chat/YamlEditor.js
@@ -0,0 +1,126 @@
+import {
+ atomOneDark,
+ atomOneLight,
+} from 'react-syntax-highlighter/dist/esm/styles/hljs'
+import { Box, TextField } from '@mui/material'
+import { makeStyles } from '@mui/styles'
+import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'
+import { useTheme } from '@mui/material/styles'
+import PropTypes from 'prop-types'
+import React from 'react'
+import yaml from 'react-syntax-highlighter/dist/esm/languages/hljs/yaml'
+
+// Register YAML language
+SyntaxHighlighter.registerLanguage('yaml', yaml)
+
+const useStyles = makeStyles((theme) => ({
+ editorContainer: {
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ minHeight: 400,
+ overflow: 'hidden',
+ },
+ textFieldContainer: {
+ flex: 1,
+ display: 'flex',
+ overflow: 'hidden',
+ '& .MuiTextField-root': {
+ flex: 1,
+ },
+ '& .MuiInputBase-root': {
+ height: '100%',
+ alignItems: 'flex-start',
+ fontFamily: 'monospace',
+ fontSize: '0.875rem',
+ },
+ '& textarea': {
+ height: '100% !important',
+ overflow: 'auto !important',
+ },
+ },
+ previewContainer: {
+ flex: 1,
+ overflow: 'auto',
+ border: `1px solid ${theme.palette.divider}`,
+ borderRadius: theme.shape.borderRadius,
+ backgroundColor:
+ theme.palette.mode === 'dark'
+ ? 'rgba(0, 0, 0, 0.3)'
+ : 'rgba(0, 0, 0, 0.02)',
+ },
+ syntaxHighlighter: {
+ margin: 0,
+ height: '100%',
+ '& code': {
+ fontFamily: 'monospace',
+ fontSize: '0.875rem',
+ },
+ },
+}))
+
+/**
+ * YamlEditor - Component for editing YAML with syntax highlighting
+ * Can be used in edit mode (with TextField) or preview mode (read-only with highlighting)
+ */
+export default function YamlEditor({
+ value,
+ onChange,
+ readOnly = false,
+ error = null,
+ placeholder = 'Enter YAML here...',
+}) {
+ const classes = useStyles()
+ const theme = useTheme()
+
+ const syntaxTheme = theme.palette.mode === 'dark' ? atomOneDark : atomOneLight
+
+ if (readOnly) {
+ return (
+
+
+ {value || placeholder}
+
+
+ )
+ }
+
+ return (
+
+
+ onChange(e.target.value)}
+ placeholder={placeholder}
+ error={!!error}
+ helperText={error}
+ fullWidth
+ variant="outlined"
+ InputProps={{
+ style: {
+ fontFamily: 'Consolas, Monaco, "Courier New", monospace',
+ },
+ }}
+ />
+
+
+ )
+}
+
+YamlEditor.propTypes = {
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func,
+ readOnly: PropTypes.bool,
+ error: PropTypes.string,
+ placeholder: PropTypes.string,
+}
diff --git a/sippy-ng/src/chat/chatUtils.js b/sippy-ng/src/chat/chatUtils.js
index 4dbdd0024..c8db9a8bd 100644
--- a/sippy-ng/src/chat/chatUtils.js
+++ b/sippy-ng/src/chat/chatUtils.js
@@ -125,10 +125,10 @@ export function validateMessage(content) {
return { valid: false, error: 'Message cannot be empty' }
}
- if (content.length > 10000) {
+ if (content.length > 100000) {
return {
valid: false,
- error: 'Message is too long (max 10,000 characters)',
+ error: 'Message is too long (max 100,000 characters)',
}
}
diff --git a/sippy-ng/src/chat/promptSchema.js b/sippy-ng/src/chat/promptSchema.js
new file mode 100644
index 000000000..844a09c55
--- /dev/null
+++ b/sippy-ng/src/chat/promptSchema.js
@@ -0,0 +1,230 @@
+import yaml from 'js-yaml'
+
+/**
+ * Validates a prompt YAML string and returns the parsed object or validation errors
+ * @param {string} yamlString - The YAML string to validate
+ * @returns {{valid: boolean, prompt?: object, errors?: string[]}}
+ */
+export function validatePromptYAML(yamlString) {
+ const errors = []
+
+ if (!yamlString || yamlString.trim() === '') {
+ return { valid: false, errors: ['YAML content is empty'] }
+ }
+
+ let parsed
+ try {
+ parsed = yaml.load(yamlString)
+ } catch (error) {
+ return {
+ valid: false,
+ errors: [`Invalid YAML syntax: ${error.message}`],
+ }
+ }
+
+ // Validate required fields
+ if (!parsed || typeof parsed !== 'object') {
+ errors.push('YAML must contain a valid object')
+ return { valid: false, errors }
+ }
+
+ if (!parsed.name || typeof parsed.name !== 'string') {
+ errors.push('Missing or invalid "name" field (must be a non-empty string)')
+ } else if (!/^[a-z0-9-]+$/.test(parsed.name)) {
+ errors.push(
+ 'Invalid "name" format (must contain only lowercase letters, numbers, and hyphens)'
+ )
+ }
+
+ if (!parsed.description || typeof parsed.description !== 'string') {
+ errors.push(
+ 'Missing or invalid "description" field (must be a non-empty string)'
+ )
+ }
+
+ if (!parsed.prompt || typeof parsed.prompt !== 'string') {
+ errors.push(
+ 'Missing or invalid "prompt" field (must be a non-empty string)'
+ )
+ }
+
+ // Validate optional arguments field
+ if (parsed.arguments !== undefined) {
+ if (!Array.isArray(parsed.arguments)) {
+ errors.push('"arguments" field must be an array')
+ } else {
+ parsed.arguments.forEach((arg, index) => {
+ if (!arg.name || typeof arg.name !== 'string') {
+ errors.push(
+ `Argument at index ${index} is missing or has invalid "name" field`
+ )
+ }
+ if (!arg.description || typeof arg.description !== 'string') {
+ errors.push(
+ `Argument "${
+ arg.name || index
+ }" is missing or has invalid "description" field`
+ )
+ }
+ if (arg.type && !['string', 'array'].includes(arg.type)) {
+ errors.push(
+ `Argument "${
+ arg.name || index
+ }" has invalid "type" (must be "string" or "array")`
+ )
+ }
+ if (arg.required !== undefined && typeof arg.required !== 'boolean') {
+ errors.push(
+ `Argument "${
+ arg.name || index
+ }" has invalid "required" field (must be boolean)`
+ )
+ }
+ if (
+ arg.autocomplete !== undefined &&
+ typeof arg.autocomplete !== 'string'
+ ) {
+ errors.push(
+ `Argument "${
+ arg.name || index
+ }" has invalid "autocomplete" field (must be string)`
+ )
+ }
+ })
+ }
+ }
+
+ // Validate optional hide field
+ if (parsed.hide !== undefined && typeof parsed.hide !== 'boolean') {
+ errors.push('"hide" field must be a boolean')
+ }
+
+ if (errors.length > 0) {
+ return { valid: false, errors }
+ }
+
+ return { valid: true, prompt: parsed }
+}
+
+/**
+ * Converts a prompt object to YAML string
+ * @param {object} promptObject - The prompt object to convert
+ * @returns {string} YAML string representation
+ */
+export function promptToYAML(promptObject) {
+ return yaml.dump(promptObject, {
+ indent: 2,
+ lineWidth: -1, // No line wrapping
+ noRefs: true,
+ })
+}
+
+/**
+ * Extracts YAML code blocks from markdown/text content
+ * @param {string} content - Text content that may contain YAML code blocks
+ * @returns {string[]} Array of YAML strings found in code blocks
+ */
+export function extractYAMLFromText(content) {
+ const yamlBlocks = []
+
+ // Find all code block starts marked as yaml or yml
+ const yamlStartRegex = /```(?:yaml|yml)\s*\n/gi
+ let startMatch
+
+ while ((startMatch = yamlStartRegex.exec(content)) !== null) {
+ const startPos = startMatch.index + startMatch[0].length
+
+ // Find the matching closing ``` by counting nested blocks
+ let depth = 1
+ let pos = startPos
+ let endPos = -1
+
+ while (pos < content.length && depth > 0) {
+ const nextBackticks = content.indexOf('```', pos)
+ if (nextBackticks === -1) {
+ break
+ }
+
+ // Check if this is a new opening block
+ const beforeBackticks = content.substring(
+ Math.max(0, nextBackticks - 50),
+ nextBackticks
+ )
+ if (/\n```[a-z]*\s*$/.test(beforeBackticks)) {
+ depth++
+ } else {
+ depth--
+ if (depth === 0) {
+ endPos = nextBackticks
+ break
+ }
+ }
+
+ pos = nextBackticks + 3
+ }
+
+ if (endPos !== -1) {
+ const yamlContent = content.substring(startPos, endPos).trim()
+ console.log('Extracted YAML block length:', yamlContent.length)
+ yamlBlocks.push(yamlContent)
+ }
+ }
+
+ console.log('Total YAML blocks found:', yamlBlocks.length)
+ return yamlBlocks
+}
+
+/**
+ * Creates a default prompt template
+ * @returns {string} Default YAML template string
+ */
+export function getDefaultPromptTemplate() {
+ return `name: my-custom-prompt
+description: A brief description of what this prompt does
+arguments:
+ - name: example_arg
+ description: Description of this argument
+ required: true
+ type: string
+prompt: |
+ This is your prompt template.
+ You can use {{ example_arg }} for variable substitution.
+`
+}
+
+/**
+ * Validates that a prompt name doesn't conflict with server prompts
+ * @param {string} name - The prompt name to check
+ * @param {Array} serverPrompts - Array of server prompts
+ * @param {string} currentName - Current name if editing (to allow keeping same name)
+ * @returns {{valid: boolean, error?: string}}
+ */
+export function validatePromptName(name, serverPrompts, currentName = null) {
+ if (!name || name.trim() === '') {
+ return { valid: false, error: 'Prompt name cannot be empty' }
+ }
+
+ if (!/^[a-z0-9-]+$/.test(name)) {
+ return {
+ valid: false,
+ error:
+ 'Prompt name must contain only lowercase letters, numbers, and hyphens',
+ }
+ }
+
+ // Allow keeping the same name when editing
+ if (currentName && name === currentName) {
+ return { valid: true }
+ }
+
+ // Check for conflicts with server prompts
+ const conflictingServerPrompt = serverPrompts.find((p) => p.name === name)
+ if (conflictingServerPrompt) {
+ return {
+ valid: false,
+ error: `A server prompt with name "${name}" already exists. Please choose a different name.`,
+ }
+ }
+
+ return { valid: true }
+}
diff --git a/sippy-ng/src/chat/store/promptsSlice.js b/sippy-ng/src/chat/store/promptsSlice.js
index 482de3ce1..3f1ee11df 100644
--- a/sippy-ng/src/chat/store/promptsSlice.js
+++ b/sippy-ng/src/chat/store/promptsSlice.js
@@ -1,12 +1,33 @@
+import { promptToYAML, validatePromptName } from '../promptSchema'
+import nunjucks from 'nunjucks'
+
+// Configure nunjucks for template rendering
+const nunjucksEnv = new nunjucks.Environment(null, { autoescape: false })
+
/**
* Zustand slice for managing slash command prompts
*/
export const createPromptsSlice = (set, get) => ({
// State
- prompts: [],
+ serverPrompts: [], // Prompts fetched from the server
+ localPrompts: [], // User-created prompts stored locally
promptsLoading: false,
promptsError: null,
+ // Function that merges server and local prompts
+ getPrompts: () => {
+ const state = get()
+ const server = (state.serverPrompts || []).map((p) => ({
+ ...p,
+ source: 'server',
+ }))
+ const local = (state.localPrompts || []).map((p) => ({
+ ...p,
+ source: 'local',
+ }))
+ return [...server, ...local].sort((a, b) => a.name.localeCompare(b.name))
+ },
+
// Fetch prompts from the server
fetchPrompts: async () => {
set({ promptsLoading: true, promptsError: null })
@@ -22,7 +43,7 @@ export const createPromptsSlice = (set, get) => ({
const data = await response.json()
set({
- prompts: data.prompts || [],
+ serverPrompts: data.prompts || [],
promptsLoading: false,
})
} catch (error) {
@@ -35,6 +56,42 @@ export const createPromptsSlice = (set, get) => ({
// Render a prompt with arguments
renderPrompt: async (promptName, args) => {
+ const state = get()
+ const allPrompts = state.getPrompts()
+
+ // Find the prompt
+ const prompt = allPrompts.find((p) => p.name === promptName)
+
+ if (!prompt) {
+ throw new Error(`Prompt "${promptName}" not found`)
+ }
+
+ // If it's a local prompt, render it client-side
+ if (prompt.source === 'local') {
+ try {
+ // Fill in default values for missing arguments
+ const filledArgs = { ...args }
+ if (prompt.arguments) {
+ prompt.arguments.forEach((arg) => {
+ if (
+ filledArgs[arg.name] === undefined &&
+ arg.default !== undefined
+ ) {
+ filledArgs[arg.name] = arg.default
+ }
+ })
+ }
+
+ // Render the template using nunjucks
+ const rendered = nunjucksEnv.renderString(prompt.prompt, filledArgs)
+ return rendered
+ } catch (error) {
+ console.error('Error rendering local prompt:', error)
+ throw new Error(`Failed to render local prompt: ${error.message}`)
+ }
+ }
+
+ // For server prompts, use the server API
try {
const response = await fetch(
(process.env.REACT_APP_CHAT_API_URL || '/api/chat') + '/prompts/render',
@@ -57,8 +114,117 @@ export const createPromptsSlice = (set, get) => ({
const data = await response.json()
return data.rendered
} catch (error) {
- console.error('Error rendering prompt:', error)
+ console.error('Error rendering server prompt:', error)
throw error
}
},
+
+ // Save a new local prompt
+ saveLocalPrompt: (promptData) => {
+ const state = get()
+
+ // Validate prompt name doesn't conflict with server prompts
+ const nameValidation = validatePromptName(
+ promptData.name,
+ state.serverPrompts
+ )
+ if (!nameValidation.valid) {
+ throw new Error(nameValidation.error)
+ }
+
+ // Check for duplicate local prompt names
+ const existingLocal = state.localPrompts.find(
+ (p) => p.name === promptData.name
+ )
+ if (existingLocal) {
+ throw new Error(
+ `A local prompt with name "${promptData.name}" already exists`
+ )
+ }
+
+ // Add metadata
+ const newPrompt = {
+ ...promptData,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ }
+
+ set({
+ localPrompts: [...state.localPrompts, newPrompt],
+ })
+
+ return newPrompt
+ },
+
+ // Update an existing local prompt
+ updateLocalPrompt: (promptName, promptData) => {
+ const state = get()
+
+ const index = state.localPrompts.findIndex((p) => p.name === promptName)
+ if (index === -1) {
+ throw new Error(`Local prompt "${promptName}" not found`)
+ }
+
+ // If name is changing, validate the new name
+ if (promptData.name && promptData.name !== promptName) {
+ const nameValidation = validatePromptName(
+ promptData.name,
+ state.serverPrompts,
+ promptName
+ )
+ if (!nameValidation.valid) {
+ throw new Error(nameValidation.error)
+ }
+
+ // Check for duplicate in other local prompts
+ const duplicateLocal = state.localPrompts.find(
+ (p) => p.name === promptData.name && p.name !== promptName
+ )
+ if (duplicateLocal) {
+ throw new Error(
+ `A local prompt with name "${promptData.name}" already exists`
+ )
+ }
+ }
+
+ // Update the prompt
+ const updatedPrompts = [...state.localPrompts]
+ updatedPrompts[index] = {
+ ...updatedPrompts[index],
+ ...promptData,
+ updatedAt: new Date().toISOString(),
+ }
+
+ set({ localPrompts: updatedPrompts })
+
+ return updatedPrompts[index]
+ },
+
+ // Delete a local prompt
+ deleteLocalPrompt: (promptName) => {
+ const state = get()
+
+ const filtered = state.localPrompts.filter((p) => p.name !== promptName)
+ if (filtered.length === state.localPrompts.length) {
+ throw new Error(`Local prompt "${promptName}" not found`)
+ }
+
+ set({ localPrompts: filtered })
+ },
+
+ // Get a local prompt by name
+ getLocalPrompt: (promptName) => {
+ const state = get()
+ return state.localPrompts.find((p) => p.name === promptName)
+ },
+
+ // Export local prompts as YAML
+ exportLocalPromptsAsYAML: () => {
+ const state = get()
+ return state.localPrompts.map((prompt) => {
+ // Remove metadata fields for clean export
+ const { createdAt, updatedAt, source, ...cleanPrompt } = prompt
+ return promptToYAML(cleanPrompt)
+ })
+ },
})
diff --git a/sippy-ng/src/chat/store/useChatStore.js b/sippy-ng/src/chat/store/useChatStore.js
index 884b5c75e..343a6ed7e 100644
--- a/sippy-ng/src/chat/store/useChatStore.js
+++ b/sippy-ng/src/chat/store/useChatStore.js
@@ -37,6 +37,7 @@ export const useChatStore = create(
sessions: state.sessions,
activeSessionId: state.activeSessionId,
settings: state.settings,
+ localPrompts: state.localPrompts,
}),
}
)
@@ -161,10 +162,17 @@ export const useWebSocketActions = () =>
export const usePrompts = () =>
useChatStore(
useShallow((state) => ({
- prompts: state.prompts,
+ prompts: state.getPrompts(),
+ localPrompts: state.localPrompts,
+ serverPrompts: state.serverPrompts,
promptsLoading: state.promptsLoading,
promptsError: state.promptsError,
fetchPrompts: state.fetchPrompts,
renderPrompt: state.renderPrompt,
+ saveLocalPrompt: state.saveLocalPrompt,
+ updateLocalPrompt: state.updateLocalPrompt,
+ deleteLocalPrompt: state.deleteLocalPrompt,
+ getLocalPrompt: state.getLocalPrompt,
+ exportLocalPromptsAsYAML: state.exportLocalPromptsAsYAML,
}))
)