581 lines
20 KiB
TypeScript
581 lines
20 KiB
TypeScript
import React, { useState, useEffect } from 'react'
|
|
import { MessageSquare, AlertCircle, Copy, ChevronDown, ChevronRight } from 'lucide-react'
|
|
import ReactMarkdown from 'react-markdown'
|
|
import rehypeRaw from 'rehype-raw'
|
|
import type { SourceMetadata, SubQuestionSources } from '../types'
|
|
import { getPdfViewerUrl } from '../lib/api'
|
|
import { processCitations, processCitationsForSubq, extractCitedSources, highlightTerms } from '../utils/citationParser'
|
|
import { bulletizeMarkdown } from '../utils/citationParser'
|
|
|
|
const V2_BASE = `${import.meta.env.VITE_API_BASE_URL ?? '/api/v1'}/v2`
|
|
|
|
function getHighlightUrl(document_id: string, chunk_index: number, sub_question: string): string {
|
|
return `${V2_BASE}/highlights?document_id=${encodeURIComponent(document_id)}&chunk_index=${chunk_index}&sub_question=${encodeURIComponent(sub_question)}`
|
|
}
|
|
|
|
interface ResponsePanelProps {
|
|
answer: string | null
|
|
sources?: SourceMetadata[]
|
|
subQuestionSources?: SubQuestionSources[] | null
|
|
isLoading?: boolean
|
|
phase?: string
|
|
error?: string | null
|
|
historyId?: number | null
|
|
}
|
|
|
|
const CitationLink = ({ href, children }: { href?: string; children?: React.ReactNode }) => (
|
|
<a
|
|
href={href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-blue-600 hover:text-blue-800 hover:underline"
|
|
>
|
|
{children}
|
|
</a>
|
|
)
|
|
|
|
const HighlightMark = ({ children }: { children?: React.ReactNode }) => (
|
|
<mark className="bg-yellow-200 rounded px-0.5">{children}</mark>
|
|
)
|
|
|
|
function parseAnswerSections(answer: string): string[] {
|
|
const sections = answer.split(/## Sub-question \d+:[^\n]*\n/)
|
|
if (sections.length <= 1) return [bulletizeMarkdown(answer)]
|
|
return sections.filter((s) => s.trim().length > 0).map((s) => bulletizeMarkdown(s.trim()))
|
|
}
|
|
|
|
function SubQuestionSourceCard({
|
|
source,
|
|
index,
|
|
highlightReady = false,
|
|
subQuestionText = '',
|
|
}: {
|
|
source: SourceMetadata
|
|
index: number
|
|
highlightReady?: boolean
|
|
subQuestionText?: string
|
|
}) {
|
|
return (
|
|
<div
|
|
key={index}
|
|
className="border border-gray-200 rounded p-3 bg-gray-50"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="font-medium text-sm">{source.filename}</div>
|
|
{source.page_number !== null && (
|
|
<span className="text-xs text-blue-600 font-medium">
|
|
Page {source.page_number}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-sm text-gray-500">{source.upload_date}</div>
|
|
<div className="text-sm text-gray-600 mt-1">{source.content_summary}</div>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<div className="text-xs text-gray-400">Chunk {source.chunk_index}</div>
|
|
{source.chunk_file_path && (
|
|
<a
|
|
href={
|
|
highlightReady && source.document_id
|
|
? getHighlightUrl(source.document_id, source.chunk_index, subQuestionText)
|
|
: getPdfViewerUrl(source.chunk_file_path, source.page_number ?? undefined, source.filename)
|
|
}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs text-blue-600 hover:text-blue-800 hover:underline"
|
|
data-testid="view-chunk-pdf-link"
|
|
>
|
|
View PDF
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SubQuestionSection({
|
|
index,
|
|
subQuestion,
|
|
answerSection,
|
|
allSubQuestionSources,
|
|
highlightReadyKeys,
|
|
}: {
|
|
index: number
|
|
subQuestion: SubQuestionSources
|
|
answerSection: string
|
|
allSubQuestionSources: SubQuestionSources[]
|
|
highlightReadyKeys: Set<string>
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
// Enrich sources with sub_question_text so buildCitationUrl can route to highlight page.
|
|
// Look up citations across ALL sub-questions' sources because the LLM
|
|
// may cite chunks from other sub-questions' contexts.
|
|
const allSources = allSubQuestionSources.flatMap(sq =>
|
|
sq.sources.map(s => ({ ...s, sub_question_text: sq.sub_question_text }))
|
|
)
|
|
const processedAnswer = processCitations(answerSection, allSources, highlightReadyKeys)
|
|
const highlightedAnswer = highlightTerms(processedAnswer)
|
|
|
|
return (
|
|
<div
|
|
id={`subq-${index}`}
|
|
data-testid={`subq-section-${index}`}
|
|
className="border border-gray-200 rounded-lg p-4 space-y-3"
|
|
>
|
|
<div className="flex items-center space-x-2 text-gray-700 font-medium">
|
|
<span className="text-gray-400">Sub-question {index + 1}:</span>
|
|
<span>{subQuestion.sub_question_text}</span>
|
|
</div>
|
|
|
|
<div className="prose prose-sm max-w-none text-gray-800">
|
|
<ReactMarkdown
|
|
components={{ a: CitationLink, mark: HighlightMark }}
|
|
rehypePlugins={[rehypeRaw]}
|
|
>
|
|
{highlightedAnswer}
|
|
</ReactMarkdown>
|
|
</div>
|
|
|
|
{subQuestion.sources.length > 0 && (
|
|
<div className="mt-2">
|
|
<button
|
|
data-testid="sources-toggle"
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="flex items-center space-x-2 text-xs uppercase text-gray-500 tracking-wide mb-2 hover:text-gray-700 transition-colors duration-200"
|
|
>
|
|
{expanded ? (
|
|
<ChevronDown className="w-4 h-4" />
|
|
) : (
|
|
<ChevronRight className="w-4 h-4" />
|
|
)}
|
|
<span>Sources ({subQuestion.sources.length})</span>
|
|
</button>
|
|
{expanded && (
|
|
<div data-testid="sources-container" className="grid grid-cols-2 gap-2">
|
|
{subQuestion.sources.map((source, idx) => (
|
|
<SubQuestionSourceCard
|
|
key={idx}
|
|
source={source}
|
|
index={idx}
|
|
highlightReady={source.document_id ? highlightReadyKeys.has(`${source.document_id}_${source.chunk_index}_${encodeURIComponent(subQuestion.sub_question_text)}`) : false}
|
|
subQuestionText={subQuestion.sub_question_text}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SubQuestionSections({
|
|
answer,
|
|
subQuestionSources,
|
|
isLoading,
|
|
historyId,
|
|
}: {
|
|
answer: string | null
|
|
subQuestionSources: SubQuestionSources[]
|
|
isLoading?: boolean
|
|
historyId?: number | null
|
|
}) {
|
|
const [copied, setCopied] = useState(false)
|
|
const [highlightReadyKeys, setHighlightReadyKeys] = useState<Set<string>>(new Set())
|
|
const [highlightStatus, setHighlightStatus] = useState<'loading' | 'done' | ''>('')
|
|
|
|
useEffect(() => {
|
|
if (!answer || isLoading || !subQuestionSources.length) return
|
|
|
|
const targets: Array<{
|
|
document_id: string
|
|
chunk_index: number
|
|
sub_question_text: string
|
|
sub_question_index: number
|
|
}> = []
|
|
|
|
const sections = parseAnswerSections(answer)
|
|
|
|
subQuestionSources.forEach((sq) => {
|
|
const answerSection = sections[sq.sub_question_index] ?? ''
|
|
const citedSources = extractCitedSources(answerSection, sq.sources)
|
|
citedSources.forEach((source) => {
|
|
if (source.document_id) {
|
|
targets.push({
|
|
document_id: source.document_id,
|
|
chunk_index: source.chunk_index,
|
|
sub_question_text: sq.sub_question_text,
|
|
sub_question_index: sq.sub_question_index,
|
|
})
|
|
}
|
|
})
|
|
})
|
|
|
|
if (targets.length === 0) return
|
|
|
|
setHighlightStatus('loading')
|
|
|
|
console.log(
|
|
`[Highlight] Sending batch request: ${targets.length} targets`,
|
|
targets.map((t: { document_id: string; chunk_index: number }) => `${t.document_id}#${t.chunk_index}`),
|
|
)
|
|
const startTime = performance.now()
|
|
|
|
const url = historyId
|
|
? `${V2_BASE}/highlights/batch?history_id=${historyId}`
|
|
: `${V2_BASE}/highlights/batch`
|
|
fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targets }),
|
|
})
|
|
.then((res) => res.json())
|
|
.then((data: { status: string; cached_count?: number; errors?: string[]; highlight_time_ms?: number }) => {
|
|
const elapsed = Math.round(performance.now() - startTime)
|
|
console.log(
|
|
`[Highlight] Response: status=${data.status} cached=${data.cached_count ?? '?'} ` +
|
|
`errors=${data.errors?.length ?? 0} backend_time=${data.highlight_time_ms ?? '?'}ms total=${elapsed}ms`,
|
|
)
|
|
if (data.errors?.length) {
|
|
console.warn('[Highlight] Errors:', data.errors)
|
|
}
|
|
if (data.status === 'failed') {
|
|
console.error('[Highlight] Batch computation failed:', data.errors)
|
|
setHighlightStatus('')
|
|
return
|
|
}
|
|
if (data.status === 'completed' || data.status === 'partial') {
|
|
const keys = new Set<string>()
|
|
targets.forEach((t: { document_id: string; chunk_index: number; sub_question_text: string }) => {
|
|
keys.add(`${t.document_id}_${t.chunk_index}_${encodeURIComponent(t.sub_question_text)}`)
|
|
})
|
|
setHighlightReadyKeys(keys)
|
|
setHighlightStatus('done')
|
|
setTimeout(() => setHighlightStatus(''), 4000)
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.error('Highlight batch computation failed:', err)
|
|
setHighlightStatus('')
|
|
})
|
|
}, [answer, isLoading, subQuestionSources, historyId])
|
|
|
|
const sections = answer ? parseAnswerSections(answer) : []
|
|
|
|
const handleCopyAnswer = async (): Promise<void> => {
|
|
if (answer) {
|
|
await navigator.clipboard.writeText(answer)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-3 p-4">
|
|
<div className="flex items-center space-x-2 text-sm text-gray-500 mb-4">
|
|
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
|
|
<span>Generating answers...</span>
|
|
</div>
|
|
<div
|
|
data-testid="skeleton-line"
|
|
className="h-4 bg-gray-200 rounded animate-pulse w-full"
|
|
/>
|
|
<div
|
|
data-testid="skeleton-line"
|
|
className="h-4 bg-gray-200 rounded animate-pulse w-11/12"
|
|
/>
|
|
<div
|
|
data-testid="skeleton-line"
|
|
className="h-4 bg-gray-200 rounded animate-pulse w-4/5"
|
|
/>
|
|
<div
|
|
data-testid="skeleton-line"
|
|
className="h-4 bg-gray-200 rounded animate-pulse w-3/5"
|
|
/>
|
|
<div
|
|
data-testid="skeleton-line"
|
|
className="h-4 bg-gray-200 rounded animate-pulse w-2/5"
|
|
/>
|
|
<div className="mt-4 space-y-2">
|
|
<div
|
|
data-testid="source-skeleton"
|
|
className="border border-gray-200 rounded p-3 bg-gray-50"
|
|
>
|
|
<div className="h-3 bg-gray-200 rounded w-3/4 mb-2 animate-pulse" />
|
|
<div className="h-2 bg-gray-200 rounded w-1/2 animate-pulse" />
|
|
</div>
|
|
<div
|
|
data-testid="source-skeleton"
|
|
className="border border-gray-200 rounded p-3 bg-gray-50"
|
|
>
|
|
<div className="h-3 bg-gray-200 rounded w-2/3 mb-2 animate-pulse" />
|
|
<div className="h-2 bg-gray-200 rounded w-1/3 animate-pulse" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="p-4 space-y-4">
|
|
{highlightStatus !== '' && (
|
|
<div
|
|
data-testid="highlight-status-toast"
|
|
className={`fixed top-4 left-4 z-50 px-4 py-2 rounded-lg shadow-lg text-sm font-medium transition-opacity duration-300 ${
|
|
highlightStatus === 'done'
|
|
? 'bg-green-50 border border-green-200 text-green-700'
|
|
: 'bg-amber-50 border border-amber-200 text-amber-700'
|
|
}`}
|
|
>
|
|
{highlightStatus === 'loading' && (
|
|
<span className="flex items-center gap-2">
|
|
<span className="w-3 h-3 border-2 border-amber-400 border-t-transparent rounded-full animate-spin" />
|
|
Preparing highlights...
|
|
</span>
|
|
)}
|
|
{highlightStatus === 'done' && 'Highlights ready'}
|
|
</div>
|
|
)}
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1" />
|
|
<button
|
|
data-testid="copy-answer-btn"
|
|
onClick={handleCopyAnswer}
|
|
className="flex items-center space-x-1 text-gray-500 hover:text-gray-700 transition-colors duration-200"
|
|
title="Copy answer to clipboard"
|
|
>
|
|
<Copy className="w-4 h-4" />
|
|
<span className="text-sm">{copied ? 'Copied!' : 'Copy'}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{subQuestionSources.map((subQuestion, index) => (
|
|
<SubQuestionSection
|
|
key={index}
|
|
index={index}
|
|
subQuestion={subQuestion}
|
|
answerSection={sections[index] ?? ''}
|
|
allSubQuestionSources={subQuestionSources}
|
|
highlightReadyKeys={highlightReadyKeys}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FlatResponse({
|
|
answer,
|
|
sources,
|
|
isLoading,
|
|
phase,
|
|
error,
|
|
}: {
|
|
answer: string | null
|
|
sources?: SourceMetadata[]
|
|
isLoading?: boolean
|
|
phase?: string
|
|
error?: string | null
|
|
}) {
|
|
const [sourcesExpanded, setSourcesExpanded] = useState(true)
|
|
const [copied, setCopied] = useState(false)
|
|
const safeSources = sources ?? []
|
|
|
|
const processedAnswer = answer ? processCitations(answer, safeSources) : answer ?? ''
|
|
const highlightedAnswer = processedAnswer ? highlightTerms(processedAnswer) : processedAnswer
|
|
|
|
const handleCopyAnswer = async (): Promise<void> => {
|
|
if (answer) {
|
|
await navigator.clipboard.writeText(answer)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}
|
|
}
|
|
|
|
if (answer === null && !isLoading && !error) {
|
|
return (
|
|
<div className="h-full flex items-center justify-center bg-white/50">
|
|
<div className="text-center space-y-2">
|
|
<MessageSquare className="mx-auto w-12 h-12 text-gray-600" />
|
|
<div className="text-gray-700 font-semibold">Ask a question to see the answer here.</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const phaseMessages: Record<string, string> = {
|
|
retrieving: 'Searching documents...',
|
|
filtering: 'Filtering relevant passages...',
|
|
generating: 'Generating answer...',
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-3 p-4">
|
|
<div className="flex items-center space-x-2 text-sm text-gray-500 mb-4">
|
|
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
|
|
<span>{phaseMessages[phase ?? ''] || 'Processing...'}</span>
|
|
</div>
|
|
<div
|
|
data-testid="skeleton-line"
|
|
className="h-4 bg-gray-200 rounded animate-pulse w-full"
|
|
/>
|
|
<div
|
|
data-testid="skeleton-line"
|
|
className="h-4 bg-gray-200 rounded animate-pulse w-11/12"
|
|
/>
|
|
<div
|
|
data-testid="skeleton-line"
|
|
className="h-4 bg-gray-200 rounded animate-pulse w-4/5"
|
|
/>
|
|
<div
|
|
data-testid="skeleton-line"
|
|
className="h-4 bg-gray-200 rounded animate-pulse w-3/5"
|
|
/>
|
|
<div
|
|
data-testid="skeleton-line"
|
|
className="h-4 bg-gray-200 rounded animate-pulse w-2/5"
|
|
/>
|
|
<div className="mt-4 space-y-2">
|
|
<div
|
|
data-testid="source-skeleton"
|
|
className="border border-gray-200 rounded p-3 bg-gray-50"
|
|
>
|
|
<div className="h-3 bg-gray-200 rounded w-3/4 mb-2 animate-pulse" />
|
|
<div className="h-2 bg-gray-200 rounded w-1/2 animate-pulse" />
|
|
</div>
|
|
<div
|
|
data-testid="source-skeleton"
|
|
className="border border-gray-200 rounded p-3 bg-gray-50"
|
|
>
|
|
<div className="h-3 bg-gray-200 rounded w-2/3 mb-2 animate-pulse" />
|
|
<div className="h-2 bg-gray-200 rounded w-1/3 animate-pulse" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="bg-red-50 border border-red-200 text-red-700 rounded p-4 m-4">
|
|
<div className="flex items-center space-x-2">
|
|
<AlertCircle className="w-5 h-5" />
|
|
<span>{error}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="p-4 space-y-4">
|
|
<div className="space-y-2">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1" />
|
|
<button
|
|
data-testid="copy-answer-btn"
|
|
onClick={handleCopyAnswer}
|
|
className="flex items-center space-x-1 text-gray-500 hover:text-gray-700 transition-colors duration-200"
|
|
title="Copy answer to clipboard"
|
|
>
|
|
<Copy className="w-4 h-4" />
|
|
<span className="text-sm">{copied ? 'Copied!' : 'Copy'}</span>
|
|
</button>
|
|
</div>
|
|
<div className="prose prose-sm max-w-none text-gray-800">
|
|
<ReactMarkdown
|
|
components={{ a: CitationLink, mark: HighlightMark }}
|
|
rehypePlugins={[rehypeRaw]}
|
|
>
|
|
{highlightedAnswer}
|
|
</ReactMarkdown>
|
|
</div>
|
|
</div>
|
|
|
|
{safeSources.length > 0 && (
|
|
<div className="mt-6">
|
|
<button
|
|
data-testid="sources-toggle"
|
|
onClick={() => setSourcesExpanded(!sourcesExpanded)}
|
|
className="flex items-center space-x-2 text-xs uppercase text-gray-500 tracking-wide mb-3 hover:text-gray-700 transition-colors duration-200"
|
|
>
|
|
{sourcesExpanded ? (
|
|
<ChevronDown className="w-4 h-4" />
|
|
) : (
|
|
<ChevronRight className="w-4 h-4" />
|
|
)}
|
|
<span>Sources ({safeSources.length})</span>
|
|
</button>
|
|
{sourcesExpanded && (
|
|
<div data-testid="sources-container" className="grid grid-cols-2 gap-2">
|
|
{safeSources.map((source, index) => (
|
|
<div
|
|
key={index}
|
|
className="border border-gray-200 rounded p-3 bg-gray-50"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="font-medium text-sm">{source.filename}</div>
|
|
{source.page_number !== null && (
|
|
<span className="text-xs text-blue-600 font-medium">
|
|
Page {source.page_number}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-sm text-gray-500">{source.upload_date}</div>
|
|
<div className="text-sm text-gray-600 mt-1">{source.content_summary}</div>
|
|
<div className="flex items-center justify-between mt-1">
|
|
<div className="text-xs text-gray-400">Chunk {source.chunk_index}</div>
|
|
{source.chunk_file_path && (
|
|
<a
|
|
href={getPdfViewerUrl(source.chunk_file_path, source.page_number ?? undefined, source.filename)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs text-blue-600 hover:text-blue-800 hover:underline"
|
|
data-testid="view-chunk-pdf-link"
|
|
>
|
|
View PDF
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const ResponsePanel: React.FC<ResponsePanelProps> = ({
|
|
answer,
|
|
sources,
|
|
subQuestionSources,
|
|
isLoading,
|
|
phase,
|
|
error,
|
|
historyId,
|
|
}) => {
|
|
if (subQuestionSources && subQuestionSources.length > 0) {
|
|
return (
|
|
<SubQuestionSections
|
|
answer={answer}
|
|
subQuestionSources={subQuestionSources}
|
|
isLoading={isLoading}
|
|
historyId={historyId}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<FlatResponse
|
|
answer={answer}
|
|
sources={sources}
|
|
isLoading={isLoading}
|
|
phase={phase}
|
|
error={error}
|
|
/>
|
|
)
|
|
}
|