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 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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue