feat(frontend): render inline citations as clickable PDF links (sub-phase 2.6)

ResponsePanel now calls processCitations() on answer text before rendering.
Custom ReactMarkdown 'a' component adds target="_blank" to all citation links.
Adds tests for citation link rendering and unmatched citation fallback.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Woody 2026-04-24 17:53:25 +08:00
parent 9095432806
commit f07e14aafd
2 changed files with 71 additions and 1 deletions

View File

@ -3,6 +3,7 @@ import { MessageSquare, AlertCircle, Copy, ChevronDown, ChevronRight } from 'luc
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import type { SourceMetadata } from '../types' import type { SourceMetadata } from '../types'
import { getPdfViewerUrl } from '../lib/api' import { getPdfViewerUrl } from '../lib/api'
import { processCitations } from '../utils/citationParser'
interface ResponsePanelProps { interface ResponsePanelProps {
answer: string | null answer: string | null
@ -20,6 +21,8 @@ export const ResponsePanel: React.FC<ResponsePanelProps> = ({
const [sourcesExpanded, setSourcesExpanded] = useState<boolean>(true) const [sourcesExpanded, setSourcesExpanded] = useState<boolean>(true)
const [copied, setCopied] = useState<boolean>(false) const [copied, setCopied] = useState<boolean>(false)
const processedAnswer = answer ? processCitations(answer, sources) : answer ?? ''
const handleCopyAnswer = async (): Promise<void> => { const handleCopyAnswer = async (): Promise<void> => {
if (answer) { if (answer) {
await navigator.clipboard.writeText(answer) await navigator.clipboard.writeText(answer)
@ -93,6 +96,17 @@ export const ResponsePanel: React.FC<ResponsePanelProps> = ({
) )
} }
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>
)
return ( return (
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@ -109,7 +123,11 @@ export const ResponsePanel: React.FC<ResponsePanelProps> = ({
</button> </button>
</div> </div>
<div className="prose prose-sm max-w-none text-gray-800"> <div className="prose prose-sm max-w-none text-gray-800">
<ReactMarkdown>{answer ?? ''}</ReactMarkdown> <ReactMarkdown
components={{ a: CitationLink }}
>
{processedAnswer}
</ReactMarkdown>
</div> </div>
</div> </div>

View File

@ -3,6 +3,14 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ResponsePanel } from '../../components/ResponsePanel' import { ResponsePanel } from '../../components/ResponsePanel'
import type { SourceMetadata } from '../../types' 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', () => { describe('ResponsePanel', () => {
const mockSources: SourceMetadata[] = [ const mockSources: SourceMetadata[] = [
{ {
@ -163,4 +171,48 @@ describe('ResponsePanel', () => {
expect(screen.getByText('Copied!')).toBeInTheDocument() 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(
<ResponsePanel
answer={answer}
sources={sourcesWithPdf}
isLoading={false}
error={null}
/>
)
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(
<ResponsePanel
answer={answer}
sources={mockSources}
isLoading={false}
error={null}
/>
)
expect(screen.getByText(/unknown_file\.pdf, page 10/)).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /unknown_file/ })).not.toBeInTheDocument()
})
}) })