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:
parent
9095432806
commit
f07e14aafd
|
|
@ -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<ResponsePanelProps> = ({
|
|||
const [sourcesExpanded, setSourcesExpanded] = useState<boolean>(true)
|
||||
const [copied, setCopied] = useState<boolean>(false)
|
||||
|
||||
const processedAnswer = answer ? processCitations(answer, sources) : answer ?? ''
|
||||
|
||||
const handleCopyAnswer = async (): Promise<void> => {
|
||||
if (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 (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
|
|
@ -109,7 +123,11 @@ export const ResponsePanel: React.FC<ResponsePanelProps> = ({
|
|||
</button>
|
||||
</div>
|
||||
<div className="prose prose-sm max-w-none text-gray-800">
|
||||
<ReactMarkdown>{answer ?? ''}</ReactMarkdown>
|
||||
<ReactMarkdown
|
||||
components={{ a: CitationLink }}
|
||||
>
|
||||
{processedAnswer}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue