feat(frontend): Phase 3.6 — History page with timing bars, expandable cards, and pagination
This commit is contained in:
parent
475306f2b1
commit
d7cf785452
|
|
@ -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 ✅, 3.4 ✅, 3.5 ✅, next: 3.6)
|
**Status**: ✅ Package 3 Complete (3.1 ✅, 3.2 ✅, 3.3 ✅, 3.4 ✅, 3.5 ✅, 3.6 ✅)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ 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 { SystemPromptsPage } from './pages/SystemPromptsPage'
|
||||||
|
import { HistoryPage } from './pages/HistoryPage'
|
||||||
import { PdfViewerPage } from './pages/PdfViewerPage'
|
import { PdfViewerPage } from './pages/PdfViewerPage'
|
||||||
|
|
||||||
export default function App(): JSX.Element {
|
export default function App(): JSX.Element {
|
||||||
|
|
@ -23,6 +24,7 @@ export default function App(): JSX.Element {
|
||||||
<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 />} />
|
<Route path="/system-prompts" element={<SystemPromptsPage />} />
|
||||||
|
<Route path="/history" element={<HistoryPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { ChevronDown, ChevronUp, Trash2, ChevronRight } from 'lucide-react'
|
||||||
|
import { useQueryHistoryDetail } from '../lib/queries'
|
||||||
|
import { getPdfViewerUrl } from '../lib/api'
|
||||||
|
import { TimingBar } from './TimingBar'
|
||||||
|
import type { QueryHistorySummary, SourceMetadata } from '../types'
|
||||||
|
|
||||||
|
interface HistoryCardProps {
|
||||||
|
item: QueryHistorySummary
|
||||||
|
onDelete: (id: number) => void
|
||||||
|
isDeleting: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string): string => {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleString()
|
||||||
|
} catch {
|
||||||
|
return dateStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (ms: number): string => {
|
||||||
|
if (ms >= 1000) {
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`
|
||||||
|
}
|
||||||
|
return `${Math.round(ms)}ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CollapsibleSectionProps {
|
||||||
|
title: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({ title, children }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg bg-white">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||||
|
)}
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-3 pb-3">
|
||||||
|
<div className="mt-2 overflow-x-auto bg-gray-50 rounded p-3">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HistoryCard: React.FC<HistoryCardProps> = ({
|
||||||
|
item,
|
||||||
|
onDelete,
|
||||||
|
isDeleting,
|
||||||
|
}) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false)
|
||||||
|
const { data: detail, isLoading: isLoadingDetail } = useQueryHistoryDetail(
|
||||||
|
isExpanded ? item.id : null
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (window.confirm('Delete this query history entry?')) {
|
||||||
|
onDelete(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputText = item.input_text.length > 100
|
||||||
|
? `${item.input_text.slice(0, 100)}...`
|
||||||
|
: item.input_text
|
||||||
|
|
||||||
|
let extractedQuestions: string[] = []
|
||||||
|
if (detail?.extracted_questions) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(detail.extracted_questions)
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
extractedQuestions = parsed
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sources: SourceMetadata[] = []
|
||||||
|
if (detail?.sources) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(detail.sources)
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
sources = parsed
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 shadow-sm">
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 ${isExpanded ? 'p-4' : 'px-4 py-3'}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="flex-shrink-0 text-gray-500 hover:text-gray-700 transition-colors"
|
||||||
|
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-900 truncate">{inputText}</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className="text-xs text-gray-500">{formatDate(item.created_at)}</span>
|
||||||
|
<span className="text-xs text-gray-400">•</span>
|
||||||
|
<span className="text-xs text-gray-500">{formatTime(item.total_time_ms)}</span>
|
||||||
|
<span className="text-xs text-gray-400">•</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{item.chunks_retrieved_count} → {item.chunks_filtered_count} filtered
|
||||||
|
</span>
|
||||||
|
{item.profile_used && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-gray-400">•</span>
|
||||||
|
<span className="inline-flex bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded">
|
||||||
|
Profile {item.profile_used}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="flex-shrink-0 flex items-center gap-1 px-2 py-1 text-sm text-red-600 hover:text-red-700 hover:bg-red-50 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
aria-label="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-4 pb-4 space-y-4">
|
||||||
|
{isLoadingDetail ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="h-4 bg-gray-200 rounded animate-pulse w-1/3" />
|
||||||
|
<div className="h-3 bg-gray-200 rounded animate-pulse w-full" />
|
||||||
|
<div className="h-3 bg-gray-200 rounded animate-pulse w-4/5" />
|
||||||
|
<div className="h-3 bg-gray-200 rounded animate-pulse w-3/4" />
|
||||||
|
<div className="h-3 bg-gray-200 rounded animate-pulse w-full" />
|
||||||
|
</div>
|
||||||
|
) : detail ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<TimingBar
|
||||||
|
decomposerTimeMs={detail.decomposer_time_ms}
|
||||||
|
retrieverTimeMs={detail.retriever_time_ms}
|
||||||
|
filterTimeMs={detail.filter_time_ms}
|
||||||
|
generatorTimeMs={detail.generator_time_ms}
|
||||||
|
totalTimeMs={detail.total_time_ms}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{extractedQuestions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Extracted Questions</h4>
|
||||||
|
<ol className="list-decimal list-inside space-y-1">
|
||||||
|
{extractedQuestions.map((q, idx) => (
|
||||||
|
<li key={idx} className="text-sm text-gray-700">{q}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.decompose_prompt && (
|
||||||
|
<CollapsibleSection title="Decompose Prompt">
|
||||||
|
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-gray-700">{detail.decompose_prompt}</pre>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.chunks_retrieved && (
|
||||||
|
<CollapsibleSection title="Retrieved Chunks">
|
||||||
|
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-gray-700">{detail.chunks_retrieved}</pre>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.filter_prompt && (
|
||||||
|
<CollapsibleSection title="Filter Prompt">
|
||||||
|
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-gray-700">{detail.filter_prompt}</pre>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.chunks_filtered && (
|
||||||
|
<CollapsibleSection title="Filtered Chunks">
|
||||||
|
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-gray-700">{detail.chunks_filtered}</pre>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.generate_prompt && (
|
||||||
|
<CollapsibleSection title="Generate Prompt">
|
||||||
|
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-gray-700">{detail.generate_prompt}</pre>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.final_answer && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Final Answer</h4>
|
||||||
|
<div className="text-sm text-gray-700 whitespace-pre-wrap">{detail.final_answer}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sources.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Sources</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{sources.map((source, idx) => (
|
||||||
|
<li key={idx} className="text-sm">
|
||||||
|
{source.chunk_file_path ? (
|
||||||
|
<a
|
||||||
|
href={getPdfViewerUrl(
|
||||||
|
source.chunk_file_path,
|
||||||
|
source.page_number ?? undefined,
|
||||||
|
source.filename
|
||||||
|
)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:text-blue-800 hover:underline"
|
||||||
|
>
|
||||||
|
{source.filename} (chunk {source.chunk_index}
|
||||||
|
{source.page_number !== null && source.page_number !== undefined
|
||||||
|
? `, page ${source.page_number}`
|
||||||
|
: ''}
|
||||||
|
)
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-700">
|
||||||
|
{source.filename} (chunk {source.chunk_index}
|
||||||
|
{source.page_number !== null && source.page_number !== undefined
|
||||||
|
? `, page ${source.page_number}`
|
||||||
|
: ''}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.profile_used && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-600">Profile used:</span>
|
||||||
|
<span className="inline-flex bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded">
|
||||||
|
{detail.profile_used}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-500 text-center py-4">
|
||||||
|
Failed to load details
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { HistoryCard } from './HistoryCard'
|
||||||
|
import type { QueryHistorySummary } from '../types'
|
||||||
|
|
||||||
|
interface HistoryListProps {
|
||||||
|
items: QueryHistorySummary[]
|
||||||
|
hasMore: boolean
|
||||||
|
onLoadMore: () => void
|
||||||
|
isLoadingMore: boolean
|
||||||
|
onDelete: (id: number) => void
|
||||||
|
isDeleting: boolean
|
||||||
|
onClearAll: () => void
|
||||||
|
isClearing: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HistoryList: React.FC<HistoryListProps> = ({
|
||||||
|
items,
|
||||||
|
hasMore,
|
||||||
|
onLoadMore,
|
||||||
|
isLoadingMore,
|
||||||
|
onDelete,
|
||||||
|
isDeleting,
|
||||||
|
onClearAll,
|
||||||
|
isClearing,
|
||||||
|
}) => {
|
||||||
|
const handleClearAll = () => {
|
||||||
|
if (window.confirm('Are you sure you want to clear all query history? This cannot be undone.')) {
|
||||||
|
onClearAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
disabled={isClearing}
|
||||||
|
className="px-3 py-1.5 text-sm font-medium text-red-600 border border-red-300 rounded hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||||
|
>
|
||||||
|
{isClearing ? 'Clearing...' : 'Clear All'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<HistoryCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onDelete={onDelete}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<div className="flex justify-center pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLoadMore}
|
||||||
|
disabled={isLoadingMore}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||||
|
>
|
||||||
|
{isLoadingMore && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
{isLoadingMore ? 'Loading...' : 'Load More'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,30 @@ export const NavBar: React.FC = () => {
|
||||||
>
|
>
|
||||||
System Prompts
|
System Prompts
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/history"
|
||||||
|
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'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/history"
|
||||||
|
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'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface TimingBarProps {
|
||||||
|
decomposerTimeMs: number
|
||||||
|
retrieverTimeMs: number
|
||||||
|
filterTimeMs: number
|
||||||
|
generatorTimeMs: number
|
||||||
|
totalTimeMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (ms: number): string => {
|
||||||
|
if (ms >= 1000) {
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`
|
||||||
|
}
|
||||||
|
return `${Math.round(ms)}ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BarSegment {
|
||||||
|
label: string
|
||||||
|
timeMs: number
|
||||||
|
colorClass: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TimingBar: React.FC<TimingBarProps> = ({
|
||||||
|
decomposerTimeMs,
|
||||||
|
retrieverTimeMs,
|
||||||
|
filterTimeMs,
|
||||||
|
generatorTimeMs,
|
||||||
|
totalTimeMs,
|
||||||
|
}) => {
|
||||||
|
const segments: BarSegment[] = [
|
||||||
|
{ label: 'Decompose', timeMs: decomposerTimeMs, colorClass: 'bg-blue-400' },
|
||||||
|
{ label: 'Retrieve', timeMs: retrieverTimeMs, colorClass: 'bg-green-400' },
|
||||||
|
{ label: 'Filter', timeMs: filterTimeMs, colorClass: 'bg-amber-400' },
|
||||||
|
{ label: 'Generate', timeMs: generatorTimeMs, colorClass: 'bg-purple-400' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const maxTime = totalTimeMs > 0 ? totalTimeMs : 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{segments.map((segment) => {
|
||||||
|
const widthPercent = maxTime > 0 ? (segment.timeMs / maxTime) * 100 : 0
|
||||||
|
return (
|
||||||
|
<div key={segment.label}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-500">{segment.label}</span>
|
||||||
|
<span className="text-xs text-gray-500">{formatTime(segment.timeMs)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 rounded-sm bg-gray-100 overflow-hidden">
|
||||||
|
<div
|
||||||
|
data-testid="timing-bar-fill"
|
||||||
|
className={`h-full rounded-sm ${segment.colorClass} transition-all duration-300`}
|
||||||
|
style={{ width: `${widthPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<span className="text-xs font-medium text-gray-600">Total</span>
|
||||||
|
<span className="text-xs font-medium text-gray-600">{formatTime(totalTimeMs)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import type { QueryRequest, QueryResponse, QueryStreamEvent, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse } from '../types'
|
import type { QueryRequest, QueryResponse, QueryStreamEvent, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse, QueryHistoryList, QueryHistoryDetail, HistoryStats, HistoryDeleteResponse } 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'
|
||||||
|
|
||||||
|
|
@ -118,3 +118,28 @@ export const resetPrompts = async (name: string, step?: string): Promise<PromptS
|
||||||
const resp = await apiClient.put<PromptStatusResponse>(`/prompts/profiles/${name}/reset`, step ? { step } : {})
|
const resp = await apiClient.put<PromptStatusResponse>(`/prompts/profiles/${name}/reset`, step ? { step } : {})
|
||||||
return resp.data
|
return resp.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const listQueryHistory = async (limit = 50, offset = 0): Promise<QueryHistoryList> => {
|
||||||
|
const resp = await apiClient.get<QueryHistoryList>(`/history?limit=${limit}&offset=${offset}`)
|
||||||
|
return resp.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getQueryHistoryDetail = async (id: number): Promise<QueryHistoryDetail> => {
|
||||||
|
const resp = await apiClient.get<QueryHistoryDetail>(`/history/${id}`)
|
||||||
|
return resp.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteQueryHistory = async (id: number): Promise<HistoryDeleteResponse> => {
|
||||||
|
const resp = await apiClient.delete<HistoryDeleteResponse>(`/history/${id}`)
|
||||||
|
return resp.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearQueryHistory = async (): Promise<HistoryDeleteResponse> => {
|
||||||
|
const resp = await apiClient.delete<HistoryDeleteResponse>('/history')
|
||||||
|
return resp.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getHistoryStats = async (): Promise<HistoryStats> => {
|
||||||
|
const resp = await apiClient.get<HistoryStats>('/history/stats')
|
||||||
|
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, listPromptProfiles, getPromptProfile, activatePromptProfile, updatePrompt, updateAllPrompts, resetPrompts } from './api'
|
import { queryDocument, queryDocumentStream, ingestDocument, listDocuments, listChunks, deleteDocument, deleteChunk, listPromptProfiles, getPromptProfile, activatePromptProfile, updatePrompt, updateAllPrompts, resetPrompts, listQueryHistory, getQueryHistoryDetail, deleteQueryHistory, clearQueryHistory, getHistoryStats } from './api'
|
||||||
import type { QueryRequest, QueryResponse, QueryStreamEvent, SourceMetadata, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse } from '../types'
|
import type { QueryRequest, QueryResponse, QueryStreamEvent, SourceMetadata, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse, QueryHistoryList, QueryHistoryDetail, HistoryStats, HistoryDeleteResponse } from '../types'
|
||||||
import { useState, useCallback, useRef } from 'react'
|
import { useState, useCallback, useRef } from 'react'
|
||||||
|
|
||||||
export const queryClient = new QueryClient()
|
export const queryClient = new QueryClient()
|
||||||
|
|
@ -200,6 +200,48 @@ export const useResetPrompts = () => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useQueryHistoryList = (limit = 50, offset = 0) => {
|
||||||
|
return useQuery<QueryHistoryList, Error>({
|
||||||
|
queryKey: ['history', { limit, offset }],
|
||||||
|
queryFn: () => listQueryHistory(limit, offset),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useQueryHistoryDetail = (id: number | null) => {
|
||||||
|
return useQuery<QueryHistoryDetail, Error>({
|
||||||
|
queryKey: ['history', id],
|
||||||
|
queryFn: () => getQueryHistoryDetail(id!),
|
||||||
|
enabled: id !== null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeleteQueryHistory = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation<HistoryDeleteResponse, Error, number>({
|
||||||
|
mutationFn: deleteQueryHistory,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useClearQueryHistory = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation<HistoryDeleteResponse, Error, void>({
|
||||||
|
mutationFn: clearQueryHistory,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useHistoryStats = () => {
|
||||||
|
return useQuery<HistoryStats, Error>({
|
||||||
|
queryKey: ['history', 'stats'],
|
||||||
|
queryFn: getHistoryStats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
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,165 @@
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { AlertCircle, RotateCcw, Clock } from 'lucide-react'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
useQueryHistoryList,
|
||||||
|
useHistoryStats,
|
||||||
|
useDeleteQueryHistory,
|
||||||
|
useClearQueryHistory,
|
||||||
|
} from '../lib/queries'
|
||||||
|
import { HistoryList } from '../components/HistoryList'
|
||||||
|
import type { QueryHistorySummary } from '../types'
|
||||||
|
|
||||||
|
const formatTime = (ms: number): string => {
|
||||||
|
if (ms >= 1000) {
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`
|
||||||
|
}
|
||||||
|
return `${Math.round(ms)}ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HistoryPage: React.FC = () => {
|
||||||
|
const [offset, setOffset] = useState(0)
|
||||||
|
const [allItems, setAllItems] = useState<QueryHistorySummary[]>([])
|
||||||
|
const limit = 20
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: historyData,
|
||||||
|
isLoading: isLoadingHistory,
|
||||||
|
error: historyError,
|
||||||
|
} = useQueryHistoryList(limit, offset)
|
||||||
|
|
||||||
|
const { data: statsData } = useHistoryStats()
|
||||||
|
|
||||||
|
const deleteMutation = useDeleteQueryHistory()
|
||||||
|
const clearMutation = useClearQueryHistory()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (historyData?.queries) {
|
||||||
|
if (offset === 0) {
|
||||||
|
setAllItems(historyData.queries)
|
||||||
|
} else {
|
||||||
|
setAllItems((prev) => {
|
||||||
|
const existingIds = new Set(prev.map((item) => item.id))
|
||||||
|
const newItems = historyData.queries.filter((item) => !existingIds.has(item.id))
|
||||||
|
return [...prev, ...newItems]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [historyData, offset])
|
||||||
|
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
setOffset((prev) => prev + limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: number) => {
|
||||||
|
deleteMutation.mutate(id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearAll = () => {
|
||||||
|
clearMutation.mutate(undefined, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setOffset(0)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMore = historyData
|
||||||
|
? historyData.offset + historyData.limit < historyData.total
|
||||||
|
: false
|
||||||
|
|
||||||
|
if (isLoadingHistory && offset === 0) {
|
||||||
|
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">History</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
<div data-testid="history-loading-skeleton" className="space-y-3">
|
||||||
|
<div className="h-20 bg-gray-200 rounded animate-pulse" />
|
||||||
|
<div className="h-20 bg-gray-200 rounded animate-pulse" />
|
||||||
|
<div className="h-20 bg-gray-200 rounded animate-pulse" />
|
||||||
|
<div className="h-20 bg-gray-200 rounded animate-pulse" />
|
||||||
|
<div className="h-20 bg-gray-200 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (historyError) {
|
||||||
|
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">History</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 history: {historyError.message}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => queryClient.invalidateQueries({ queryKey: ['history'] })}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasItems = allItems.length > 0
|
||||||
|
|
||||||
|
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">History</h1>
|
||||||
|
{statsData && hasItems && (
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-sm text-gray-600">
|
||||||
|
<span>Total queries: {statsData.total_queries}</span>
|
||||||
|
<span className="text-gray-300">|</span>
|
||||||
|
<span>Avg time: {formatTime(statsData.avg_time_ms)}</span>
|
||||||
|
<span className="text-gray-300">|</span>
|
||||||
|
<span>Avg chunks: {statsData.avg_chunks_retrieved.toFixed(1)} → {statsData.avg_chunks_filtered.toFixed(1)} filtered</span>
|
||||||
|
<span className="text-gray-300">|</span>
|
||||||
|
<span>Top profile: {statsData.most_used_profile ?? 'None'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
{!hasItems ? (
|
||||||
|
<div className="h-full flex items-center justify-center bg-white/50 rounded-lg border border-gray-200">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<Clock className="mx-auto w-12 h-12 text-gray-600" />
|
||||||
|
<div className="text-gray-700 font-semibold">No queries yet</div>
|
||||||
|
<div className="text-sm text-gray-500">Submit a query from the LTT page to see it here.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<HistoryList
|
||||||
|
items={allItems}
|
||||||
|
hasMore={hasMore}
|
||||||
|
onLoadMore={handleLoadMore}
|
||||||
|
isLoadingMore={isLoadingHistory && offset > 0}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
isDeleting={deleteMutation.isPending}
|
||||||
|
onClearAll={handleClearAll}
|
||||||
|
isClearing={clearMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { HistoryCard } from '../../components/HistoryCard'
|
||||||
|
import type { QueryHistorySummary, QueryHistoryDetail } from '../../types'
|
||||||
|
|
||||||
|
vi.mock('../../lib/api', async () => {
|
||||||
|
const actual = await vi.importActual('../../lib/api')
|
||||||
|
return {
|
||||||
|
...(actual as Record<string, unknown>),
|
||||||
|
getQueryHistoryDetail: vi.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { getQueryHistoryDetail } from '../../lib/api'
|
||||||
|
|
||||||
|
const mockGetQueryHistoryDetail = vi.mocked(getQueryHistoryDetail)
|
||||||
|
|
||||||
|
function createQueryClient() {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWithClient(ui: React.ReactElement) {
|
||||||
|
const qc = createQueryClient()
|
||||||
|
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HistoryCard', () => {
|
||||||
|
const mockItem: QueryHistorySummary = {
|
||||||
|
id: 42,
|
||||||
|
input_text: 'What is the NEC4 contract about?',
|
||||||
|
total_time_ms: 3500,
|
||||||
|
chunks_retrieved_count: 8,
|
||||||
|
chunks_filtered_count: 4,
|
||||||
|
profile_used: 'profile_a',
|
||||||
|
created_at: '2024-03-15T10:30:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockDetail: QueryHistoryDetail = {
|
||||||
|
id: 42,
|
||||||
|
input_text: 'What is the NEC4 contract about?',
|
||||||
|
extracted_questions: '["What is NEC4?","What are the contract terms?"]',
|
||||||
|
decompose_prompt: 'Break down the following question...',
|
||||||
|
decomposer_time_ms: 500,
|
||||||
|
retriever_time_ms: 200,
|
||||||
|
chunks_retrieved: '<chunk index="0">...</chunk>',
|
||||||
|
chunks_retrieved_count: 8,
|
||||||
|
filter_prompt: 'Filter relevant chunks...',
|
||||||
|
filter_time_ms: 150,
|
||||||
|
chunks_filtered: '<chunk index="0">...</chunk><chunk index="1">...</chunk>',
|
||||||
|
chunks_filtered_count: 4,
|
||||||
|
generate_prompt: 'Answer based on context...',
|
||||||
|
generator_time_ms: 300,
|
||||||
|
total_time_ms: 1150,
|
||||||
|
final_answer: '- NEC4 is a suite of contracts\n- Key terms include...',
|
||||||
|
sources: '[{"filename":"nec4.pdf","upload_date":"2024-01-01","content_summary":"NEC4 overview","chunk_index":0,"page_number":5,"chunk_file_path":"chunk_0.pdf"}]',
|
||||||
|
profile_used: 'profile_a',
|
||||||
|
created_at: '2024-03-15T10:30:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockOnDelete = vi.fn()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders collapsed card with input_text', () => {
|
||||||
|
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
expect(screen.getByText('What is the NEC4 contract about?')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('truncates long input_text to approximately 100 characters', () => {
|
||||||
|
const longText = 'A'.repeat(200)
|
||||||
|
const longItem = { ...mockItem, input_text: longText }
|
||||||
|
renderWithClient(<HistoryCard item={longItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
const truncated = screen.getByText(/A{100}\.\.\./)
|
||||||
|
expect(truncated.textContent!.length).toBeLessThanOrEqual(103)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows created_at formatted date', () => {
|
||||||
|
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
expect(screen.getByText(/2024/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows total_time_ms', () => {
|
||||||
|
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
expect(screen.getByText('3.5s')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows profile badge when profile_used is set', () => {
|
||||||
|
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
expect(screen.getByText(/profile_a/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show profile badge when profile_used is null', () => {
|
||||||
|
const noProfileItem = { ...mockItem, profile_used: null }
|
||||||
|
renderWithClient(<HistoryCard item={noProfileItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
expect(screen.queryByText(/Profile/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows chunk count summary', () => {
|
||||||
|
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
expect(screen.getByText(/8 → 4 filtered/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows expand button in collapsed state', () => {
|
||||||
|
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
const expandBtn = screen.getByLabelText('Expand')
|
||||||
|
expect(expandBtn).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking expand fetches detail via useQueryHistoryDetail', async () => {
|
||||||
|
mockGetQueryHistoryDetail.mockResolvedValueOnce(mockDetail)
|
||||||
|
|
||||||
|
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByLabelText('Expand'))
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockGetQueryHistoryDetail).toHaveBeenCalledWith(42)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expanded view shows collapse toggle button', async () => {
|
||||||
|
mockGetQueryHistoryDetail.mockResolvedValueOnce(mockDetail)
|
||||||
|
|
||||||
|
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByLabelText('Expand'))
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText('Collapse')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expanded view shows TimingBar with timing data', async () => {
|
||||||
|
mockGetQueryHistoryDetail.mockResolvedValueOnce(mockDetail)
|
||||||
|
|
||||||
|
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByLabelText('Expand'))
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Decompose')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Retrieve')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Filter')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Generate')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expanded view shows extracted questions parsed from JSON', async () => {
|
||||||
|
mockGetQueryHistoryDetail.mockResolvedValueOnce(mockDetail)
|
||||||
|
|
||||||
|
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByLabelText('Expand'))
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('What is NEC4?')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('What are the contract terms?')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expanded view shows final answer', async () => {
|
||||||
|
mockGetQueryHistoryDetail.mockResolvedValueOnce(mockDetail)
|
||||||
|
|
||||||
|
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByLabelText('Expand'))
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/NEC4 is a suite of contracts/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expanded view shows source links that are clickable', async () => {
|
||||||
|
mockGetQueryHistoryDetail.mockResolvedValueOnce(mockDetail)
|
||||||
|
|
||||||
|
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByLabelText('Expand'))
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const sourceLink = screen.getByRole('link', { name: /nec4\.pdf/i })
|
||||||
|
expect(sourceLink).toBeInTheDocument()
|
||||||
|
expect(sourceLink).toHaveAttribute('href', expect.stringContaining('/pdf-viewer'))
|
||||||
|
expect(sourceLink).toHaveAttribute('target', '_blank')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delete button calls onDelete with correct id after confirmation', () => {
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText('Delete'))
|
||||||
|
|
||||||
|
expect(confirmSpy).toHaveBeenCalled()
|
||||||
|
expect(mockOnDelete).toHaveBeenCalledWith(42)
|
||||||
|
confirmSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delete button does not call onDelete when confirmation is cancelled', () => {
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||||
|
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText('Delete'))
|
||||||
|
|
||||||
|
expect(confirmSpy).toHaveBeenCalled()
|
||||||
|
expect(mockOnDelete).not.toHaveBeenCalled()
|
||||||
|
confirmSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows isDeleting state with disabled button', () => {
|
||||||
|
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={true} />)
|
||||||
|
const deleteBtn = screen.getByLabelText('Delete')
|
||||||
|
expect(deleteBtn).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has collapsible sections for prompts in expanded view', async () => {
|
||||||
|
mockGetQueryHistoryDetail.mockResolvedValueOnce(mockDetail)
|
||||||
|
|
||||||
|
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByLabelText('Expand'))
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Decompose Prompt')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Filter Prompt')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Generate Prompt')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has collapsible sections for chunk XML in expanded view', async () => {
|
||||||
|
mockGetQueryHistoryDetail.mockResolvedValueOnce(mockDetail)
|
||||||
|
|
||||||
|
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByLabelText('Expand'))
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Retrieved Chunks')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Filtered Chunks')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows correct profile text for profile_b', () => {
|
||||||
|
const profileBItem = { ...mockItem, profile_used: 'profile_b' }
|
||||||
|
renderWithClient(<HistoryCard item={profileBItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
expect(screen.getByText(/profile_b/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows correct profile text for profile_c', () => {
|
||||||
|
const profileCItem = { ...mockItem, profile_used: 'profile_c' }
|
||||||
|
renderWithClient(<HistoryCard item={profileCItem} onDelete={mockOnDelete} isDeleting={false} />)
|
||||||
|
expect(screen.getByText(/profile_c/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,266 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { HistoryPage } from '../../pages/HistoryPage'
|
||||||
|
import type { QueryHistorySummary, HistoryStats } from '../../types'
|
||||||
|
|
||||||
|
vi.mock('../../lib/api', async () => {
|
||||||
|
const actual = await vi.importActual('../../lib/api')
|
||||||
|
return {
|
||||||
|
...(actual as Record<string, unknown>),
|
||||||
|
listQueryHistory: vi.fn(),
|
||||||
|
getHistoryStats: vi.fn(),
|
||||||
|
deleteQueryHistory: vi.fn(),
|
||||||
|
clearQueryHistory: vi.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { listQueryHistory, getHistoryStats, clearQueryHistory } from '../../lib/api'
|
||||||
|
|
||||||
|
const mockListQueryHistory = vi.mocked(listQueryHistory)
|
||||||
|
const mockGetHistoryStats = vi.mocked(getHistoryStats)
|
||||||
|
const mockClearQueryHistory = vi.mocked(clearQueryHistory)
|
||||||
|
|
||||||
|
function createQueryClient() {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistoryPage() {
|
||||||
|
const qc = createQueryClient()
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<HistoryPage />
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeHistoryItem = (id: number, overrides?: Partial<QueryHistorySummary>): QueryHistorySummary => ({
|
||||||
|
id,
|
||||||
|
input_text: `Query question ${id}`,
|
||||||
|
total_time_ms: 1000 + id * 100,
|
||||||
|
chunks_retrieved_count: 10,
|
||||||
|
chunks_filtered_count: 5,
|
||||||
|
profile_used: 'profile_a',
|
||||||
|
created_at: `2024-03-${String(id).padStart(2, '0')}T10:00:00Z`,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('HistoryPage', () => {
|
||||||
|
const defaultStats: HistoryStats = {
|
||||||
|
total_queries: 2,
|
||||||
|
avg_time_ms: 1500,
|
||||||
|
avg_chunks_retrieved: 10,
|
||||||
|
avg_chunks_filtered: 5,
|
||||||
|
most_used_profile: 'profile_a',
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockGetHistoryStats.mockResolvedValue(defaultStats)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows loading skeleton while data is loading', () => {
|
||||||
|
mockListQueryHistory.mockReturnValue(new Promise(() => {}))
|
||||||
|
mockGetHistoryStats.mockReturnValue(new Promise(() => {}))
|
||||||
|
|
||||||
|
renderHistoryPage()
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: 'History' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error banner with retry button on API error', async () => {
|
||||||
|
mockListQueryHistory.mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
|
||||||
|
renderHistoryPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Failed to load history/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retry button refetches data', async () => {
|
||||||
|
mockListQueryHistory.mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
mockListQueryHistory.mockResolvedValueOnce({
|
||||||
|
queries: [makeHistoryItem(1)],
|
||||||
|
total: 1,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHistoryPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /retry/i }))
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Query question 1/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when list is empty', async () => {
|
||||||
|
mockListQueryHistory.mockResolvedValueOnce({
|
||||||
|
queries: [],
|
||||||
|
total: 0,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHistoryPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/no queries yet/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows history items when data is loaded', async () => {
|
||||||
|
const items = [makeHistoryItem(1), makeHistoryItem(2), makeHistoryItem(3)]
|
||||||
|
mockListQueryHistory.mockResolvedValueOnce({
|
||||||
|
queries: items,
|
||||||
|
total: 3,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHistoryPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Query question 1/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.getByText(/Query question 2/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Query question 3/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows stats bar when stats are loaded', async () => {
|
||||||
|
mockListQueryHistory.mockResolvedValueOnce({
|
||||||
|
queries: [makeHistoryItem(1)],
|
||||||
|
total: 1,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHistoryPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Total queries: 2/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Avg time: 1\.5s/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Top profile: profile_a/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Load More button when more items are available', async () => {
|
||||||
|
const items = Array.from({ length: 5 }, (_, i) => makeHistoryItem(i + 1))
|
||||||
|
mockListQueryHistory.mockResolvedValueOnce({
|
||||||
|
queries: items,
|
||||||
|
total: 30,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHistoryPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking Load More fetches additional items', async () => {
|
||||||
|
const firstBatch = Array.from({ length: 5 }, (_, i) => makeHistoryItem(i + 1))
|
||||||
|
const secondBatch = Array.from({ length: 3 }, (_, i) => makeHistoryItem(i + 6))
|
||||||
|
|
||||||
|
mockListQueryHistory.mockResolvedValueOnce({
|
||||||
|
queries: firstBatch,
|
||||||
|
total: 30,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
mockListQueryHistory.mockResolvedValueOnce({
|
||||||
|
queries: secondBatch,
|
||||||
|
total: 30,
|
||||||
|
limit: 20,
|
||||||
|
offset: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHistoryPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /load more/i }))
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Query question 6/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Clear All button shows confirmation and calls API', async () => {
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
mockClearQueryHistory.mockResolvedValueOnce({ status: 'ok', deleted_count: 5 })
|
||||||
|
mockListQueryHistory.mockResolvedValueOnce({
|
||||||
|
queries: [makeHistoryItem(1)],
|
||||||
|
total: 1,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHistoryPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /clear all/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /clear all/i }))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(confirmSpy).toHaveBeenCalled()
|
||||||
|
expect(mockClearQueryHistory).toHaveBeenCalled()
|
||||||
|
confirmSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Clear All does not proceed when confirmation is cancelled', async () => {
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||||
|
mockListQueryHistory.mockResolvedValueOnce({
|
||||||
|
queries: [makeHistoryItem(1)],
|
||||||
|
total: 1,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHistoryPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /clear all/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /clear all/i }))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(confirmSpy).toHaveBeenCalled()
|
||||||
|
expect(mockClearQueryHistory).not.toHaveBeenCalled()
|
||||||
|
confirmSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('page title History is always visible even in error state', async () => {
|
||||||
|
mockListQueryHistory.mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
|
||||||
|
renderHistoryPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('heading', { name: /history/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { TimingBar } from '../../components/TimingBar'
|
||||||
|
|
||||||
|
describe('TimingBar', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
decomposerTimeMs: 500,
|
||||||
|
retrieverTimeMs: 200,
|
||||||
|
filterTimeMs: 150,
|
||||||
|
generatorTimeMs: 300,
|
||||||
|
totalTimeMs: 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders all 4 stage labels', () => {
|
||||||
|
render(<TimingBar {...defaultProps} />)
|
||||||
|
expect(screen.getByText('Decompose')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Retrieve')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Filter')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Generate')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders total time', () => {
|
||||||
|
render(<TimingBar {...defaultProps} />)
|
||||||
|
expect(screen.getByText('1.0s')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders bars with proportional widths based on time contribution', () => {
|
||||||
|
const { container } = render(<TimingBar {...defaultProps} />)
|
||||||
|
const bars = container.querySelectorAll('[data-testid="timing-bar-fill"]')
|
||||||
|
expect(bars).toHaveLength(4)
|
||||||
|
|
||||||
|
// decomposer=500/1000=50%, retriever=200/1000=20%, filter=150/1000=15%, generator=300/1000=30%
|
||||||
|
expect(bars[0]).toHaveStyle({ width: '50%' })
|
||||||
|
expect(bars[1]).toHaveStyle({ width: '20%' })
|
||||||
|
expect(bars[2]).toHaveStyle({ width: '15%' })
|
||||||
|
expect(bars[3]).toHaveStyle({ width: '30%' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles zero total time without division by zero', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TimingBar
|
||||||
|
{...defaultProps}
|
||||||
|
decomposerTimeMs={0}
|
||||||
|
retrieverTimeMs={0}
|
||||||
|
filterTimeMs={0}
|
||||||
|
generatorTimeMs={0}
|
||||||
|
totalTimeMs={0}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const bars = container.querySelectorAll('[data-testid="timing-bar-fill"]')
|
||||||
|
expect(bars).toHaveLength(4)
|
||||||
|
bars.forEach((bar) => {
|
||||||
|
expect(bar).toHaveStyle({ width: '0%' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats times >= 1000ms as seconds with 1 decimal', () => {
|
||||||
|
render(
|
||||||
|
<TimingBar
|
||||||
|
decomposerTimeMs={500}
|
||||||
|
retrieverTimeMs={200}
|
||||||
|
filterTimeMs={150}
|
||||||
|
generatorTimeMs={300}
|
||||||
|
totalTimeMs={1500}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('1.5s')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats times < 1000ms as milliseconds', () => {
|
||||||
|
render(
|
||||||
|
<TimingBar
|
||||||
|
decomposerTimeMs={100}
|
||||||
|
retrieverTimeMs={200}
|
||||||
|
filterTimeMs={150}
|
||||||
|
generatorTimeMs={300}
|
||||||
|
totalTimeMs={750}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('750ms')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles zero time for a single stage with bar width 0%', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TimingBar
|
||||||
|
decomposerTimeMs={0}
|
||||||
|
retrieverTimeMs={200}
|
||||||
|
filterTimeMs={150}
|
||||||
|
generatorTimeMs={300}
|
||||||
|
totalTimeMs={650}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const bars = container.querySelectorAll('[data-testid="timing-bar-fill"]')
|
||||||
|
expect(bars[0]).toHaveStyle({ width: '0%' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses correct Tailwind color classes for each stage', () => {
|
||||||
|
const { container } = render(<TimingBar {...defaultProps} />)
|
||||||
|
const bars = container.querySelectorAll('[data-testid="timing-bar-fill"]')
|
||||||
|
|
||||||
|
// Decompose = blue-400
|
||||||
|
expect(bars[0]).toHaveClass('bg-blue-400')
|
||||||
|
// Retrieve = green-400
|
||||||
|
expect(bars[1]).toHaveClass('bg-green-400')
|
||||||
|
// Filter = amber-400
|
||||||
|
expect(bars[2]).toHaveClass('bg-amber-400')
|
||||||
|
// Generate = purple-400
|
||||||
|
expect(bars[3]).toHaveClass('bg-purple-400')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders individual stage times alongside labels', () => {
|
||||||
|
render(<TimingBar {...defaultProps} />)
|
||||||
|
expect(screen.getByText('500ms')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('200ms')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('150ms')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('300ms')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a "Total" label alongside the total time', () => {
|
||||||
|
render(<TimingBar {...defaultProps} />)
|
||||||
|
expect(screen.getByText(/total/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -90,3 +90,56 @@ export interface PromptStatusResponse {
|
||||||
step?: string
|
step?: string
|
||||||
reset_step?: string
|
reset_step?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QueryHistorySummary {
|
||||||
|
id: number
|
||||||
|
input_text: string
|
||||||
|
total_time_ms: number
|
||||||
|
chunks_retrieved_count: number
|
||||||
|
chunks_filtered_count: number
|
||||||
|
profile_used: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryHistoryList {
|
||||||
|
queries: QueryHistorySummary[]
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryHistoryDetail {
|
||||||
|
id: number
|
||||||
|
input_text: string
|
||||||
|
extracted_questions: string | null
|
||||||
|
decompose_prompt: string | null
|
||||||
|
decomposer_time_ms: number
|
||||||
|
retriever_time_ms: number
|
||||||
|
chunks_retrieved: string | null
|
||||||
|
chunks_retrieved_count: number
|
||||||
|
filter_prompt: string | null
|
||||||
|
filter_time_ms: number
|
||||||
|
chunks_filtered: string | null
|
||||||
|
chunks_filtered_count: number
|
||||||
|
generate_prompt: string | null
|
||||||
|
generator_time_ms: number
|
||||||
|
total_time_ms: number
|
||||||
|
final_answer: string | null
|
||||||
|
sources: string | null
|
||||||
|
profile_used: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryStats {
|
||||||
|
total_queries: number
|
||||||
|
avg_time_ms: number
|
||||||
|
avg_chunks_retrieved: number
|
||||||
|
avg_chunks_filtered: number
|
||||||
|
most_used_profile: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryDeleteResponse {
|
||||||
|
status: string
|
||||||
|
deleted_id?: number
|
||||||
|
deleted_count?: number
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue