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)
|
||||
**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 { LTTPage } from './pages/LTTPage'
|
||||
import { RAGDatabasePage } from './pages/RAGDatabasePage'
|
||||
import { SystemPromptsPage } from './pages/SystemPromptsPage'
|
||||
import { PdfViewerPage } from './pages/PdfViewerPage'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
|
|
@ -21,6 +22,7 @@ export default function App(): JSX.Element {
|
|||
<Routes>
|
||||
<Route path="/" element={<LTTPage />} />
|
||||
<Route path="/rag-database" element={<RAGDatabasePage />} />
|
||||
<Route path="/system-prompts" element={<SystemPromptsPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,18 @@ export const NavBar: React.FC = () => {
|
|||
>
|
||||
RAG Database
|
||||
</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>
|
||||
</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 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'
|
||||
|
||||
|
|
@ -88,3 +88,33 @@ export const getPdfViewerUrl = (filePath: string, page?: number, title?: string)
|
|||
if (title) params.set('title', title)
|
||||
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 { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { queryDocument, queryDocumentStream, ingestDocument, listDocuments, listChunks, deleteDocument, deleteChunk } from './api'
|
||||
import type { QueryRequest, QueryResponse, QueryStreamEvent, SourceMetadata, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse } from '../types'
|
||||
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, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse } from '../types'
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
|
||||
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 }) => {
|
||||
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
|
||||
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