feat(frontend): Phase 3.3 — System Prompt Configuration page
- SystemPromptsPage: profile selector, activation, edit with TanStack Query
- ProfileList: 3 profile cards (A/B/C) with active indicator + edit button
- PromptEditor: 3 monospace textareas, placeholder badges, char count,
unknown placeholder warnings, per-step reset (↺), action bar
- PlaceholderDocs: info box showing {question}/{chunks}/{context}
- Data layer: +7 types, +6 API functions, +6 TanStack Query hooks
- Routing: /system-prompts route + NavBar link
- Tests: 27 tests (PlaceholderDocs 6, ProfileList 7, PromptEditor 14)
- 0TS errors, 27/27 tests pass, 1 pre-existing e2e failure (unrelated)
This commit is contained in:
parent
e49a68b0bd
commit
8e6597a86e
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
**Source**: User request (2026-04-25)
|
**Source**: User request (2026-04-25)
|
||||||
**Scope**: System Prompt Configuration Page + Query History Page
|
**Scope**: System Prompt Configuration Page + Query History Page
|
||||||
**Status**: 🔧 In Progress (3.1 ✅, 3.2 ✅, 3.3 in progress)
|
**Status**: 🔧 In Progress (3.1 ✅, 3.2 ✅, 3.3 ✅, next: 3.4)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
import { NavBar } from './components/NavBar'
|
import { NavBar } from './components/NavBar'
|
||||||
import { LTTPage } from './pages/LTTPage'
|
import { LTTPage } from './pages/LTTPage'
|
||||||
import { RAGDatabasePage } from './pages/RAGDatabasePage'
|
import { RAGDatabasePage } from './pages/RAGDatabasePage'
|
||||||
|
import { SystemPromptsPage } from './pages/SystemPromptsPage'
|
||||||
import { PdfViewerPage } from './pages/PdfViewerPage'
|
import { PdfViewerPage } from './pages/PdfViewerPage'
|
||||||
|
|
||||||
export default function App(): JSX.Element {
|
export default function App(): JSX.Element {
|
||||||
|
|
@ -21,6 +22,7 @@ export default function App(): JSX.Element {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<LTTPage />} />
|
<Route path="/" element={<LTTPage />} />
|
||||||
<Route path="/rag-database" element={<RAGDatabasePage />} />
|
<Route path="/rag-database" element={<RAGDatabasePage />} />
|
||||||
|
<Route path="/system-prompts" element={<SystemPromptsPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,18 @@ export const NavBar: React.FC = () => {
|
||||||
>
|
>
|
||||||
RAG Database
|
RAG Database
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/system-prompts"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'text-gray-900 border-b-2 border-gray-900'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 border-b-2 border-transparent'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
System Prompts
|
||||||
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Info, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
|
||||||
|
interface PlaceholderRow {
|
||||||
|
placeholder: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLACEHOLDERS: PlaceholderRow[] = [
|
||||||
|
{ placeholder: '{question}', description: 'The user\'s input question' },
|
||||||
|
{ placeholder: '{chunks}', description: 'Retrieved document chunks (filter step only)' },
|
||||||
|
{ placeholder: '{context}', description: 'Formatted chunks with citations (generate step only)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PlaceholderDocs: React.FC = () => {
|
||||||
|
const [isOpen, setIsOpen] = useState(true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="flex items-center gap-2 w-full text-left"
|
||||||
|
>
|
||||||
|
<Info className="w-4 h-4 text-blue-600" />
|
||||||
|
<span className="font-medium text-gray-900">Available Placeholders</span>
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronUp className="w-4 h-4 text-gray-500 ml-auto" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4 text-gray-500 ml-auto" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Use these placeholders in your prompt templates. They will be replaced with actual values at runtime.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{PLACEHOLDERS.map((row) => (
|
||||||
|
<div key={row.placeholder} className="flex items-start gap-3">
|
||||||
|
<code className="shrink-0 bg-gray-100 px-1.5 py-0.5 rounded text-sm font-mono text-gray-800">
|
||||||
|
{row.placeholder}
|
||||||
|
</code>
|
||||||
|
<span className="text-sm text-gray-600">{row.description}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { Pencil } from 'lucide-react'
|
||||||
|
import type { PromptProfile } from '../types'
|
||||||
|
|
||||||
|
export interface ProfileListProps {
|
||||||
|
profiles: PromptProfile[]
|
||||||
|
selectedProfile: string | null
|
||||||
|
onSelect: (name: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileList: React.FC<ProfileListProps> = ({ profiles, selectedProfile, onSelect }) => {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{profiles.map((profile) => {
|
||||||
|
const isActive = profile.is_active
|
||||||
|
const isSelected = selectedProfile === profile.name
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={profile.name}
|
||||||
|
className={`bg-white rounded-lg border p-4 transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-blue-500 shadow-md ring-1 ring-blue-500'
|
||||||
|
: isActive
|
||||||
|
? 'border-blue-300 shadow-sm'
|
||||||
|
: 'border-gray-200 shadow-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-block w-2.5 h-2.5 rounded-full ${
|
||||||
|
isActive ? 'bg-green-500' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
title={isActive ? 'Active' : 'Inactive'}
|
||||||
|
/>
|
||||||
|
<span className="font-semibold text-gray-900">Profile {profile.name}</span>
|
||||||
|
</div>
|
||||||
|
{isActive && (
|
||||||
|
<span className="text-xs font-medium text-green-700 bg-green-50 px-2 py-0.5 rounded-full">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(profile.name)}
|
||||||
|
className={`w-full flex items-center justify-center gap-2 px-3 py-2 rounded text-sm font-medium transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
{isSelected ? 'Editing' : 'Edit'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { RotateCcw, AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
export interface PromptEditorProps {
|
||||||
|
profileName: string
|
||||||
|
prompts: Record<string, string>
|
||||||
|
isSaving: boolean
|
||||||
|
onUpdate: (step: string, template: string) => void
|
||||||
|
onSave: () => void
|
||||||
|
onResetStep: (step: string) => void
|
||||||
|
onResetAll: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ key: 'decompose', label: 'Step 1: Query Decomposition', placeholders: ['question'] },
|
||||||
|
{ key: 'filter', label: 'Step 2: Relevance Filtering', placeholders: ['question', 'chunks'] },
|
||||||
|
{ key: 'generate', label: 'Step 3: Response Generation', placeholders: ['question', 'context'] },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const VALID_PLACEHOLDERS: Record<string, string[]> = {
|
||||||
|
decompose: ['{question}'],
|
||||||
|
filter: ['{question}', '{chunks}'],
|
||||||
|
generate: ['{question}', '{context}'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const findUnknownPlaceholders = (template: string, stepKey: string): string[] => {
|
||||||
|
const valid = VALID_PLACEHOLDERS[stepKey] ?? []
|
||||||
|
const found = template.match(/\{[a-zA-Z_]+\}/g) ?? []
|
||||||
|
return found.filter((p) => !valid.includes(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PromptEditor: React.FC<PromptEditorProps> = ({
|
||||||
|
profileName,
|
||||||
|
prompts,
|
||||||
|
isSaving,
|
||||||
|
onUpdate,
|
||||||
|
onSave,
|
||||||
|
onResetStep,
|
||||||
|
onResetAll,
|
||||||
|
onCancel,
|
||||||
|
}) => {
|
||||||
|
const [localPrompts, setLocalPrompts] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalPrompts({ ...prompts })
|
||||||
|
}, [prompts])
|
||||||
|
|
||||||
|
const handleChange = (stepKey: string, value: string) => {
|
||||||
|
setLocalPrompts((prev) => ({ ...prev, [stepKey]: value }))
|
||||||
|
onUpdate(stepKey, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetAll = () => {
|
||||||
|
if (window.confirm('Reset all prompts to defaults? This cannot be undone.')) {
|
||||||
|
onResetAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetStep = (stepKey: string) => {
|
||||||
|
if (window.confirm(`Reset "${STEPS.find((s) => s.key === stepKey)?.label}" to default?`)) {
|
||||||
|
onResetStep(stepKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirty = useCallback(() => {
|
||||||
|
return STEPS.some((step) => localPrompts[step.key] !== prompts[step.key])
|
||||||
|
}, [localPrompts, prompts])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6 space-y-6">
|
||||||
|
<div className="flex items-center gap-2 border-b border-gray-200 pb-3">
|
||||||
|
<span className="text-sm font-medium text-gray-500">Editing Profile</span>
|
||||||
|
<span className="text-lg font-bold text-gray-900">{profileName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{STEPS.map((step) => {
|
||||||
|
const value = localPrompts[step.key] ?? ''
|
||||||
|
const unknownPlaceholders = findUnknownPlaceholders(value, step.key)
|
||||||
|
const placeholderBadges = step.placeholders.map((p) => `{${p}}`).join(', ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.key} className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-sm font-semibold text-gray-900">{step.label}</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleResetStep(step.key)}
|
||||||
|
title={`Reset ${step.label} to default`}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-gray-500">Placeholders:</span>
|
||||||
|
{step.placeholders.map((p) => (
|
||||||
|
<code
|
||||||
|
key={p}
|
||||||
|
className="bg-gray-100 px-1.5 py-0.5 rounded text-xs font-mono text-gray-700"
|
||||||
|
>
|
||||||
|
{'{'}{p}{'}'}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => handleChange(step.key, e.target.value)}
|
||||||
|
disabled={isSaving}
|
||||||
|
rows={6}
|
||||||
|
className="w-full min-h-[8rem] rounded border border-gray-300 px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed resize-y"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-500">{value.length} characters</span>
|
||||||
|
|
||||||
|
{unknownPlaceholders.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 bg-yellow-50 border border-yellow-200 rounded px-2 py-1">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 text-yellow-600" />
|
||||||
|
<span className="text-xs text-yellow-700">
|
||||||
|
Unknown placeholder{unknownPlaceholders.length > 1 ? 's' : ''}:{' '}
|
||||||
|
{unknownPlaceholders.join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-4 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={isSaving || !isDirty()}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600 transition-all duration-200"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResetAll}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="px-4 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||||
|
>
|
||||||
|
Reset All to Defaults
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="px-4 py-2 text-gray-600 text-sm font-medium hover:text-gray-900 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import type { QueryRequest, QueryResponse, QueryStreamEvent, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse } from '../types'
|
import type { QueryRequest, QueryResponse, QueryStreamEvent, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse } from '../types'
|
||||||
|
|
||||||
const BASE_URL: string = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000/api/v1'
|
const BASE_URL: string = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000/api/v1'
|
||||||
|
|
||||||
|
|
@ -88,3 +88,33 @@ export const getPdfViewerUrl = (filePath: string, page?: number, title?: string)
|
||||||
if (title) params.set('title', title)
|
if (title) params.set('title', title)
|
||||||
return `/pdf-viewer?${params.toString()}`
|
return `/pdf-viewer?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const listPromptProfiles = async (): Promise<PromptProfileListResponse> => {
|
||||||
|
const resp = await apiClient.get<PromptProfileListResponse>('/prompts/profiles')
|
||||||
|
return resp.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPromptProfile = async (name: string): Promise<PromptSetResponse> => {
|
||||||
|
const resp = await apiClient.get<PromptSetResponse>(`/prompts/profiles/${name}`)
|
||||||
|
return resp.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const activatePromptProfile = async (name: string): Promise<PromptActivateResponse> => {
|
||||||
|
const resp = await apiClient.put<PromptActivateResponse>(`/prompts/profiles/${name}/activate`)
|
||||||
|
return resp.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updatePrompt = async (name: string, step: string, request: PromptUpdateRequest): Promise<PromptStatusResponse> => {
|
||||||
|
const resp = await apiClient.put<PromptStatusResponse>(`/prompts/profiles/${name}/${step}`, request)
|
||||||
|
return resp.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateAllPrompts = async (name: string, request: PromptBatchUpdateRequest): Promise<PromptStatusResponse> => {
|
||||||
|
const resp = await apiClient.put<PromptStatusResponse>(`/prompts/profiles/${name}/all`, request)
|
||||||
|
return resp.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resetPrompts = async (name: string, step?: string): Promise<PromptStatusResponse> => {
|
||||||
|
const resp = await apiClient.put<PromptStatusResponse>(`/prompts/profiles/${name}/reset`, step ? { step } : {})
|
||||||
|
return resp.data
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { queryDocument, queryDocumentStream, ingestDocument, listDocuments, listChunks, deleteDocument, deleteChunk } from './api'
|
import { queryDocument, queryDocumentStream, ingestDocument, listDocuments, listChunks, deleteDocument, deleteChunk, listPromptProfiles, getPromptProfile, activatePromptProfile, updatePrompt, updateAllPrompts, resetPrompts } from './api'
|
||||||
import type { QueryRequest, QueryResponse, QueryStreamEvent, SourceMetadata, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse } from '../types'
|
import type { QueryRequest, QueryResponse, QueryStreamEvent, SourceMetadata, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse } from '../types'
|
||||||
import { useState, useCallback, useRef } from 'react'
|
import { useState, useCallback, useRef } from 'react'
|
||||||
|
|
||||||
export const queryClient = new QueryClient()
|
export const queryClient = new QueryClient()
|
||||||
|
|
@ -145,6 +145,61 @@ export const useDeleteChunk = () => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const usePromptProfiles = () => {
|
||||||
|
return useQuery<PromptProfileListResponse, Error>({
|
||||||
|
queryKey: ['prompts', 'profiles'],
|
||||||
|
queryFn: listPromptProfiles,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePromptProfile = (name: string | null) => {
|
||||||
|
return useQuery<PromptSetResponse, Error>({
|
||||||
|
queryKey: ['prompts', 'profiles', name],
|
||||||
|
queryFn: () => getPromptProfile(name!),
|
||||||
|
enabled: name !== null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useActivateProfile = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation<PromptActivateResponse, Error, string>({
|
||||||
|
mutationFn: activatePromptProfile,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompts'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpdatePrompt = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation<PromptStatusResponse, Error, { name: string; step: string; request: PromptUpdateRequest }>({
|
||||||
|
mutationFn: ({ name, step, request }) => updatePrompt(name, step, request),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompts'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpdateAllPrompts = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation<PromptStatusResponse, Error, { name: string; request: PromptBatchUpdateRequest }>({
|
||||||
|
mutationFn: ({ name, request }) => updateAllPrompts(name, request),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompts'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useResetPrompts = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation<PromptStatusResponse, Error, { name: string; step?: string }>({
|
||||||
|
mutationFn: ({ name, step }) => resetPrompts(name, step),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompts'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const AppQueryProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const AppQueryProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { AlertCircle, RotateCcw } from 'lucide-react'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
usePromptProfiles,
|
||||||
|
usePromptProfile,
|
||||||
|
useActivateProfile,
|
||||||
|
useUpdateAllPrompts,
|
||||||
|
useResetPrompts,
|
||||||
|
} from '../lib/queries'
|
||||||
|
import { ProfileList } from '../components/ProfileList'
|
||||||
|
import { PromptEditor } from '../components/PromptEditor'
|
||||||
|
import { PlaceholderDocs } from '../components/PlaceholderDocs'
|
||||||
|
import type { PromptProfile } from '../types'
|
||||||
|
|
||||||
|
export const SystemPromptsPage: React.FC = () => {
|
||||||
|
const [selectedProfile, setSelectedProfile] = useState<string | null>(null)
|
||||||
|
const [activeDropdown, setActiveDropdown] = useState<string>('')
|
||||||
|
const [editPrompts, setEditPrompts] = useState<Record<string, string>>({})
|
||||||
|
const [hasChanges, setHasChanges] = useState(false)
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: profilesData,
|
||||||
|
isLoading: isLoadingProfiles,
|
||||||
|
error: profilesError,
|
||||||
|
} = usePromptProfiles()
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: profileData,
|
||||||
|
isLoading: isLoadingProfile,
|
||||||
|
} = usePromptProfile(selectedProfile)
|
||||||
|
|
||||||
|
const activateMutation = useActivateProfile()
|
||||||
|
const updateAllMutation = useUpdateAllPrompts()
|
||||||
|
const resetMutation = useResetPrompts()
|
||||||
|
|
||||||
|
const profiles: PromptProfile[] = profilesData?.profiles ?? []
|
||||||
|
const activeProfile = profiles.find((p) => p.is_active)?.name ?? ''
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (activeProfile && !activeDropdown) {
|
||||||
|
setActiveDropdown(activeProfile)
|
||||||
|
}
|
||||||
|
}, [activeProfile, activeDropdown])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (profileData?.prompts) {
|
||||||
|
setEditPrompts(profileData.prompts)
|
||||||
|
setHasChanges(false)
|
||||||
|
}
|
||||||
|
}, [profileData])
|
||||||
|
|
||||||
|
const handleSelectProfile = (name: string) => {
|
||||||
|
if (selectedProfile === name) {
|
||||||
|
setSelectedProfile(null)
|
||||||
|
setEditPrompts({})
|
||||||
|
setHasChanges(false)
|
||||||
|
} else {
|
||||||
|
setSelectedProfile(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetActive = () => {
|
||||||
|
if (!activeDropdown || activeDropdown === activeProfile) return
|
||||||
|
activateMutation.mutate(activeDropdown, {
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompts'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = (step: string, template: string) => {
|
||||||
|
setEditPrompts((prev) => ({ ...prev, [step]: template }))
|
||||||
|
setHasChanges(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!selectedProfile || !hasChanges) return
|
||||||
|
updateAllMutation.mutate(
|
||||||
|
{ name: selectedProfile, request: { prompts: editPrompts } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompts'] })
|
||||||
|
setHasChanges(false)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetStep = (step: string) => {
|
||||||
|
if (!selectedProfile) return
|
||||||
|
resetMutation.mutate(
|
||||||
|
{ name: selectedProfile, step },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompts', 'profiles', selectedProfile] })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetAll = () => {
|
||||||
|
if (!selectedProfile) return
|
||||||
|
resetMutation.mutate(
|
||||||
|
{ name: selectedProfile },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompts', 'profiles', selectedProfile] })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setSelectedProfile(null)
|
||||||
|
setEditPrompts({})
|
||||||
|
setHasChanges(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSaving = updateAllMutation.isPending || resetMutation.isPending || activateMutation.isPending
|
||||||
|
|
||||||
|
if (isLoadingProfiles) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col bg-gray-50">
|
||||||
|
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||||
|
<div className="h-8 bg-gray-200 rounded animate-pulse w-1/3" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="h-32 bg-gray-200 rounded animate-pulse" />
|
||||||
|
<div className="h-32 bg-gray-200 rounded animate-pulse" />
|
||||||
|
<div className="h-32 bg-gray-200 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profilesError) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col bg-gray-50">
|
||||||
|
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">System Prompts</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 rounded p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
<span>Failed to load profiles: {profilesError.message}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => queryClient.invalidateQueries({ queryKey: ['prompts'] })}
|
||||||
|
className="mt-3 flex items-center gap-1.5 text-sm font-medium text-red-700 hover:text-red-800"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col bg-gray-50">
|
||||||
|
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">System Prompts</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-6 space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Active Profile:</label>
|
||||||
|
<select
|
||||||
|
value={activeDropdown}
|
||||||
|
onChange={(e) => setActiveDropdown(e.target.value)}
|
||||||
|
className="rounded border border-gray-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
|
||||||
|
>
|
||||||
|
<option value="">Select profile...</option>
|
||||||
|
{profiles.map((p) => (
|
||||||
|
<option key={p.name} value={p.name}>
|
||||||
|
Profile {p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSetActive}
|
||||||
|
disabled={!activeDropdown || activeDropdown === activeProfile || activateMutation.isPending}
|
||||||
|
className="px-3 py-1.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600 transition-all duration-200"
|
||||||
|
>
|
||||||
|
{activateMutation.isPending ? 'Setting...' : 'Set Active'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProfileList
|
||||||
|
profiles={profiles}
|
||||||
|
selectedProfile={selectedProfile}
|
||||||
|
onSelect={handleSelectProfile}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedProfile && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isLoadingProfile ? (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6 space-y-4">
|
||||||
|
<div className="h-6 bg-gray-200 rounded animate-pulse w-1/4" />
|
||||||
|
<div className="h-32 bg-gray-200 rounded animate-pulse" />
|
||||||
|
<div className="h-32 bg-gray-200 rounded animate-pulse" />
|
||||||
|
<div className="h-32 bg-gray-200 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
) : profileData?.prompts ? (
|
||||||
|
<>
|
||||||
|
<PlaceholderDocs />
|
||||||
|
<PromptEditor
|
||||||
|
profileName={selectedProfile}
|
||||||
|
prompts={editPrompts}
|
||||||
|
isSaving={isSaving}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
onSave={handleSave}
|
||||||
|
onResetStep={handleResetStep}
|
||||||
|
onResetAll={handleResetAll}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8 text-center">
|
||||||
|
<p className="text-gray-500">No prompts found for this profile.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { PlaceholderDocs } from '../../components/PlaceholderDocs'
|
||||||
|
|
||||||
|
describe('PlaceholderDocs', () => {
|
||||||
|
it('renders the "Available Placeholders" title', () => {
|
||||||
|
render(<PlaceholderDocs />)
|
||||||
|
expect(screen.getByText('Available Placeholders')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all 3 placeholders: {question}, {chunks}, {context}', () => {
|
||||||
|
render(<PlaceholderDocs />)
|
||||||
|
expect(screen.getByText('{question}')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('{chunks}')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('{context}')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders each placeholder in a monospace element', () => {
|
||||||
|
render(<PlaceholderDocs />)
|
||||||
|
const placeholders = ['{question}', '{chunks}', '{context}']
|
||||||
|
placeholders.forEach((ph) => {
|
||||||
|
const el = screen.getByText(ph)
|
||||||
|
expect(el.tagName.toLowerCase()).toBe('code')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders descriptions for each placeholder', () => {
|
||||||
|
render(<PlaceholderDocs />)
|
||||||
|
expect(screen.getByText("The user's input question")).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Retrieved document chunks (filter step only)')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Formatted chunks with citations (generate step only)')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides placeholder details when toggle button is clicked', () => {
|
||||||
|
render(<PlaceholderDocs />)
|
||||||
|
const toggleBtn = screen.getByRole('button', { name: /available placeholders/i })
|
||||||
|
fireEvent.click(toggleBtn)
|
||||||
|
|
||||||
|
expect(screen.queryByText('{question}')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('{chunks}')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('{context}')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows placeholder details again when toggle is clicked twice', () => {
|
||||||
|
render(<PlaceholderDocs />)
|
||||||
|
const toggleBtn = screen.getByRole('button', { name: /available placeholders/i })
|
||||||
|
|
||||||
|
fireEvent.click(toggleBtn)
|
||||||
|
expect(screen.queryByText('{question}')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(toggleBtn)
|
||||||
|
expect(screen.getByText('{question}')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { ProfileList } from '../../components/ProfileList'
|
||||||
|
import type { PromptProfile } from '../../types'
|
||||||
|
|
||||||
|
const mockProfiles: PromptProfile[] = [
|
||||||
|
{ name: 'A', is_active: true },
|
||||||
|
{ name: 'B', is_active: false },
|
||||||
|
{ name: 'C', is_active: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('ProfileList', () => {
|
||||||
|
const mockOnSelect = vi.fn()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockOnSelect.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders 3 profile cards', () => {
|
||||||
|
render(<ProfileList profiles={mockProfiles} selectedProfile={null} onSelect={mockOnSelect} />)
|
||||||
|
expect(screen.getByText('Profile A')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Profile B')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Profile C')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows active indicator for the active profile', () => {
|
||||||
|
render(<ProfileList profiles={mockProfiles} selectedProfile={null} onSelect={mockOnSelect} />)
|
||||||
|
const activeDot = screen.getByTitle('Active')
|
||||||
|
expect(activeDot).toBeInTheDocument()
|
||||||
|
expect(activeDot).toHaveClass('bg-green-500')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Active" badge for the active profile', () => {
|
||||||
|
render(<ProfileList profiles={mockProfiles} selectedProfile={null} onSelect={mockOnSelect} />)
|
||||||
|
// The "Active" text appears both as a title attribute and as badge text
|
||||||
|
const badges = screen.getAllByText('Active')
|
||||||
|
// At least one visible badge should exist for the active profile
|
||||||
|
expect(badges.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows inactive state for inactive profiles', () => {
|
||||||
|
render(<ProfileList profiles={mockProfiles} selectedProfile={null} onSelect={mockOnSelect} />)
|
||||||
|
const inactiveDots = screen.getAllByTitle('Inactive')
|
||||||
|
expect(inactiveDots).toHaveLength(2)
|
||||||
|
inactiveDots.forEach((dot) => {
|
||||||
|
expect(dot).toHaveClass('bg-gray-300')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSelect with profile name when Edit button is clicked', () => {
|
||||||
|
render(<ProfileList profiles={mockProfiles} selectedProfile={null} onSelect={mockOnSelect} />)
|
||||||
|
|
||||||
|
const editButtons = screen.getAllByRole('button', { name: /edit/i })
|
||||||
|
fireEvent.click(editButtons[0])
|
||||||
|
|
||||||
|
expect(mockOnSelect).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockOnSelect).toHaveBeenCalledWith('A')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('highlights the selected profile with distinct styling', () => {
|
||||||
|
render(<ProfileList profiles={mockProfiles} selectedProfile="B" onSelect={mockOnSelect} />)
|
||||||
|
|
||||||
|
// Selected profile should show "Editing" instead of "Edit"
|
||||||
|
expect(screen.getByRole('button', { name: /editing/i })).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Other profiles should still show "Edit" (exact match to exclude "Editing")
|
||||||
|
const editButtons = screen.getAllByRole('button').filter(
|
||||||
|
(btn) => btn.textContent?.trim() === 'Edit'
|
||||||
|
)
|
||||||
|
expect(editButtons).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing when profiles array is empty', () => {
|
||||||
|
const { container } = render(<ProfileList profiles={[]} selectedProfile={null} onSelect={mockOnSelect} />)
|
||||||
|
// The grid renders but with no children
|
||||||
|
expect(container.querySelector('.grid')).toBeInTheDocument()
|
||||||
|
expect(container.querySelectorAll('.grid > div')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { PromptEditor } from '../../components/PromptEditor'
|
||||||
|
|
||||||
|
const mockPrompts: Record<string, string> = {
|
||||||
|
decompose: 'Decompose template with {question}',
|
||||||
|
filter: 'Filter template with {question} and {chunks}',
|
||||||
|
generate: 'Generate template with {question} and {context}',
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
profileName: 'A',
|
||||||
|
prompts: mockPrompts,
|
||||||
|
isSaving: false,
|
||||||
|
onUpdate: vi.fn(),
|
||||||
|
onSave: vi.fn(),
|
||||||
|
onResetStep: vi.fn(),
|
||||||
|
onResetAll: vi.fn(),
|
||||||
|
onCancel: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PromptEditor', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
defaultProps.onUpdate.mockClear()
|
||||||
|
defaultProps.onSave.mockClear()
|
||||||
|
defaultProps.onResetStep.mockClear()
|
||||||
|
defaultProps.onResetAll.mockClear()
|
||||||
|
defaultProps.onCancel.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders 3 textareas for decompose, filter, and generate steps', () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />)
|
||||||
|
const textareas = screen.getAllByRole('textbox')
|
||||||
|
expect(textareas).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders labels for each step', () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />)
|
||||||
|
expect(screen.getByText('Step 1: Query Decomposition')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Step 2: Relevance Filtering')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Step 3: Response Generation')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows valid placeholders in the label area for each step', () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />)
|
||||||
|
// {question} appears in all 3 steps (decompose, filter, generate)
|
||||||
|
const questionPlaceholders = screen.getAllByText('{question}')
|
||||||
|
expect(questionPlaceholders).toHaveLength(3)
|
||||||
|
// {chunks} appears only in filter step
|
||||||
|
expect(screen.getByText('{chunks}')).toBeInTheDocument()
|
||||||
|
// {context} appears only in generate step
|
||||||
|
expect(screen.getByText('{context}')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders per-step reset buttons that call onResetStep when clicked', () => {
|
||||||
|
// Mock window.confirm to return true
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
|
||||||
|
render(<PromptEditor {...defaultProps} />)
|
||||||
|
const resetButtons = screen.getAllByTitle(/reset.*to default/i)
|
||||||
|
expect(resetButtons).toHaveLength(3)
|
||||||
|
|
||||||
|
fireEvent.click(resetButtons[0])
|
||||||
|
expect(defaultProps.onResetStep).toHaveBeenCalledWith('decompose')
|
||||||
|
|
||||||
|
fireEvent.click(resetButtons[1])
|
||||||
|
expect(defaultProps.onResetStep).toHaveBeenCalledWith('filter')
|
||||||
|
|
||||||
|
fireEvent.click(resetButtons[2])
|
||||||
|
expect(defaultProps.onResetStep).toHaveBeenCalledWith('generate')
|
||||||
|
|
||||||
|
confirmSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call onResetStep when confirm is cancelled', () => {
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||||
|
|
||||||
|
render(<PromptEditor {...defaultProps} />)
|
||||||
|
const resetButtons = screen.getAllByTitle(/reset.*to default/i)
|
||||||
|
|
||||||
|
fireEvent.click(resetButtons[0])
|
||||||
|
expect(defaultProps.onResetStep).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
confirmSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSave when "Save Changes" button is clicked', () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />)
|
||||||
|
|
||||||
|
// The Save button is disabled when not dirty, so we need to change something first
|
||||||
|
const textareas = screen.getAllByRole('textbox')
|
||||||
|
fireEvent.change(textareas[0], { target: { value: 'Modified decompose template' } })
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole('button', { name: /save changes/i })
|
||||||
|
expect(saveButton).not.toBeDisabled()
|
||||||
|
fireEvent.click(saveButton)
|
||||||
|
|
||||||
|
expect(defaultProps.onSave).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onResetAll when "Reset All to Defaults" is clicked and confirmed', () => {
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
|
||||||
|
render(<PromptEditor {...defaultProps} />)
|
||||||
|
const resetAllBtn = screen.getByRole('button', { name: /reset all to defaults/i })
|
||||||
|
fireEvent.click(resetAllBtn)
|
||||||
|
|
||||||
|
expect(confirmSpy).toHaveBeenCalledWith('Reset all prompts to defaults? This cannot be undone.')
|
||||||
|
expect(defaultProps.onResetAll).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
confirmSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call onResetAll when confirmation is dismissed', () => {
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||||
|
|
||||||
|
render(<PromptEditor {...defaultProps} />)
|
||||||
|
const resetAllBtn = screen.getByRole('button', { name: /reset all to defaults/i })
|
||||||
|
fireEvent.click(resetAllBtn)
|
||||||
|
|
||||||
|
expect(defaultProps.onResetAll).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
confirmSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onCancel when "Cancel" button is clicked', () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />)
|
||||||
|
const cancelButton = screen.getByRole('button', { name: /cancel/i })
|
||||||
|
fireEvent.click(cancelButton)
|
||||||
|
|
||||||
|
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills textareas with provided prompt values', () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />)
|
||||||
|
const textareas = screen.getAllByRole('textbox')
|
||||||
|
|
||||||
|
expect(textareas[0]).toHaveValue('Decompose template with {question}')
|
||||||
|
expect(textareas[1]).toHaveValue('Filter template with {question} and {chunks}')
|
||||||
|
expect(textareas[2]).toHaveValue('Generate template with {question} and {context}')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onUpdate when typing in a textarea', () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />)
|
||||||
|
const textareas = screen.getAllByRole('textbox')
|
||||||
|
|
||||||
|
fireEvent.change(textareas[0], { target: { value: 'New decompose content' } })
|
||||||
|
|
||||||
|
expect(defaultProps.onUpdate).toHaveBeenCalledWith('decompose', 'New decompose content')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays character count for each textarea', () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />)
|
||||||
|
|
||||||
|
const decomposeLength = mockPrompts.decompose.length
|
||||||
|
const filterLength = mockPrompts.filter.length
|
||||||
|
const generateLength = mockPrompts.generate.length
|
||||||
|
|
||||||
|
expect(screen.getByText(`${decomposeLength} characters`)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(`${filterLength} characters`)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(`${generateLength} characters`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the profile name being edited', () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />)
|
||||||
|
expect(screen.getByText('A')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables buttons when isSaving is true', () => {
|
||||||
|
render(<PromptEditor {...defaultProps} isSaving={true} />)
|
||||||
|
|
||||||
|
const textareas = screen.getAllByRole('textbox')
|
||||||
|
textareas.forEach((ta) => expect(ta).toBeDisabled())
|
||||||
|
|
||||||
|
// "Reset All to Defaults" and "Cancel" should be disabled when saving
|
||||||
|
expect(screen.getByRole('button', { name: /saving\.\.\./i })).toBeDisabled()
|
||||||
|
expect(screen.getByRole('button', { name: /reset all to defaults/i })).toBeDisabled()
|
||||||
|
expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -56,3 +56,37 @@ export interface DeleteResponse {
|
||||||
deleted: boolean
|
deleted: boolean
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PromptProfile {
|
||||||
|
name: string
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptProfileListResponse {
|
||||||
|
profiles: PromptProfile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptSetResponse {
|
||||||
|
profile_name: string
|
||||||
|
prompts: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptUpdateRequest {
|
||||||
|
template: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptBatchUpdateRequest {
|
||||||
|
prompts: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptActivateResponse {
|
||||||
|
status: string
|
||||||
|
active_profile: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptStatusResponse {
|
||||||
|
status: string
|
||||||
|
profile: string
|
||||||
|
step?: string
|
||||||
|
reset_step?: string
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue