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 }) => (
{children}
)
const HighlightMark = ({ children }: { children?: React.ReactNode }) => (
{children}
)
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 (
{source.filename}
{source.page_number !== null && (
Page {source.page_number}
)}
{source.upload_date}
{source.content_summary}
Chunk {source.chunk_index}
{source.chunk_file_path && (
View PDF
)}
)
}
function SubQuestionSection({
index,
subQuestion,
answerSection,
allSubQuestionSources,
highlightReadyKeys,
}: {
index: number
subQuestion: SubQuestionSources
answerSection: string
allSubQuestionSources: SubQuestionSources[]
highlightReadyKeys: Set
}) {
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 (
Sub-question {index + 1}:
{subQuestion.sub_question_text}
{highlightedAnswer}
{subQuestion.sources.length > 0 && (
{expanded && (
{subQuestion.sources.map((source, idx) => (
))}
)}
)}
)
}
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>(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()
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 => {
if (answer) {
await navigator.clipboard.writeText(answer)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
if (isLoading) {
return (
)
}
return (
{highlightStatus !== '' && (
{highlightStatus === 'loading' && (
Preparing highlights...
)}
{highlightStatus === 'done' && 'Highlights ready'}
)}
{subQuestionSources.map((subQuestion, index) => (
))}
)
}
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 => {
if (answer) {
await navigator.clipboard.writeText(answer)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
if (answer === null && !isLoading && !error) {
return (
Ask a question to see the answer here.
)
}
const phaseMessages: Record = {
retrieving: 'Searching documents...',
filtering: 'Filtering relevant passages...',
generating: 'Generating answer...',
}
if (isLoading) {
return (
{phaseMessages[phase ?? ''] || 'Processing...'}
)
}
if (error) {
return (
)
}
return (
{highlightedAnswer}
{safeSources.length > 0 && (
{sourcesExpanded && (
{safeSources.map((source, index) => (
{source.filename}
{source.page_number !== null && (
Page {source.page_number}
)}
{source.upload_date}
{source.content_summary}
Chunk {source.chunk_index}
{source.chunk_file_path && (
View PDF
)}
))}
)}
)}
)
}
export const ResponsePanel: React.FC = ({
answer,
sources,
subQuestionSources,
isLoading,
phase,
error,
historyId,
}) => {
if (subQuestionSources && subQuestionSources.length > 0) {
return (
)
}
return (
)
}