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:
Woody 2026-04-25 21:26:42 +08:00
parent e49a68b0bd
commit 8e6597a86e
13 changed files with 968 additions and 4 deletions

View File

@ -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)
---

View File

@ -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>

View File

@ -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>
)

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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
}

View File

@ -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>
}

View File

@ -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>
)
}

View File

@ -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()
})
})

View File

@ -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)
})
})

View File

@ -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()
})
})

View File

@ -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
}