diff --git a/frontend/src/components/ResponsePanel.tsx b/frontend/src/components/ResponsePanel.tsx index 2e6435d..b6e3030 100644 --- a/frontend/src/components/ResponsePanel.tsx +++ b/frontend/src/components/ResponsePanel.tsx @@ -3,6 +3,7 @@ import { MessageSquare, AlertCircle, Copy, ChevronDown, ChevronRight } from 'luc import ReactMarkdown from 'react-markdown' import type { SourceMetadata } from '../types' import { getPdfViewerUrl } from '../lib/api' +import { processCitations } from '../utils/citationParser' interface ResponsePanelProps { answer: string | null @@ -20,6 +21,8 @@ export const ResponsePanel: React.FC = ({ const [sourcesExpanded, setSourcesExpanded] = useState(true) const [copied, setCopied] = useState(false) + const processedAnswer = answer ? processCitations(answer, sources) : answer ?? '' + const handleCopyAnswer = async (): Promise => { if (answer) { await navigator.clipboard.writeText(answer) @@ -93,6 +96,17 @@ export const ResponsePanel: React.FC = ({ ) } + const CitationLink = ({ href, children }: { href?: string; children?: React.ReactNode }) => ( + + {children} + + ) + return (
@@ -109,7 +123,11 @@ export const ResponsePanel: React.FC = ({
- {answer ?? ''} + + {processedAnswer} +
diff --git a/frontend/src/test/components/ResponsePanel.test.tsx b/frontend/src/test/components/ResponsePanel.test.tsx index aeb40e6..6f84613 100644 --- a/frontend/src/test/components/ResponsePanel.test.tsx +++ b/frontend/src/test/components/ResponsePanel.test.tsx @@ -3,6 +3,14 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { ResponsePanel } from '../../components/ResponsePanel' import type { SourceMetadata } from '../../types' +vi.mock('../../lib/api', () => ({ + getChunkPdfUrl: (filePath: string) => `http://localhost:8000/api/v1/chunks/${filePath}/pdf`, + getPdfViewerUrl: (filePath: string, page?: number) => { + const base = `/pdf-viewer?url=http://localhost:8000/api/v1/chunks/${filePath}/pdf` + return page !== undefined ? `${base}&page=${page}` : base + }, +})) + describe('ResponsePanel', () => { const mockSources: SourceMetadata[] = [ { @@ -163,4 +171,48 @@ describe('ResponsePanel', () => { expect(screen.getByText('Copied!')).toBeInTheDocument() }) }) + + it('renders inline citations as clickable links', () => { + const sourcesWithPdf: SourceMetadata[] = [ + { + filename: 'NEC4 ACC.pdf', + upload_date: '2024-01-15', + content_summary: 'Contract terms', + chunk_index: 0, + page_number: 3, + chunk_file_path: 'chunk_0.pdf', + }, + ] + const answer = 'The threshold is HK$1,000,000 [NEC4 ACC.pdf, page 3].' + + render( + + ) + + const citationLink = screen.getByRole('link', { name: /NEC4 ACC\.pdf, page 3/ }) + expect(citationLink).toBeInTheDocument() + expect(citationLink).toHaveAttribute('target', '_blank') + expect(citationLink).toHaveAttribute('href', expect.stringContaining('/pdf-viewer')) + }) + + it('leaves unmatched citations as plain text', () => { + const answer = 'Some info [unknown_file.pdf, page 10] here.' + + render( + + ) + + expect(screen.getByText(/unknown_file\.pdf, page 10/)).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /unknown_file/ })).not.toBeInTheDocument() + }) })