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 (
Generating answers...
) } 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 (
{error}
) } 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 ( ) }