diff --git a/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx b/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx index 79e2e0f4e68..c076dba117d 100644 --- a/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx +++ b/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx @@ -464,6 +464,52 @@ describe('RetrievalTestPage', () => { ).toBeInTheDocument() }) + it('expands hidden evidence and jumps to a generated answer citation', async () => { + apiMock.researchTasks = [ + { + completed_at: 1_800_000_025, + cost: {}, + created_at: 1_800_000_000, + id: 'research-completed', + knowledge_space_id: 'space-1', + metadata: {}, + mode: 'research', + query: 'What is the warranty?', + stage: 'completed', + updated_at: 1_800_000_025, + }, + ] + apiMock.partials = [ + { + answer: 'The seventh source contains the relevant detail. [7]', + evidence_bundle: { + items: Array.from({ length: 10 }, (_, index) => ({ + id: `chunk-${index + 1}`, + score: 1, + text: `Evidence ${index + 1}`, + title: `Chunk ${index + 1}`, + })), + }, + knowledge_space_id: 'space-1', + research_task_job_id: 'research-completed', + sequence: 1, + }, + ] + const scrollIntoView = vi + .spyOn(HTMLElement.prototype, 'scrollIntoView') + .mockImplementation(() => undefined) + const user = userEvent.setup() + + renderPage({ searchParams: '?research=research-completed' }) + + expect(screen.queryByRole('heading', { name: 'Chunk 7' })).not.toBeInTheDocument() + await user.click(await screen.findByRole('link', { name: '[7]' })) + + const citedEvidence = screen.getByRole('heading', { name: 'Chunk 7' }).closest('article') + expect(citedEvidence).toHaveFocus() + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' }) + }) + it('keeps the selected research record in the URL', async () => { apiMock.researchTasks = [ { diff --git a/web/features/new-rag/retrieval-test-page.tsx b/web/features/new-rag/retrieval-test-page.tsx index c0868ba5c39..f16a9e6d516 100644 --- a/web/features/new-rag/retrieval-test-page.tsx +++ b/web/features/new-rag/retrieval-test-page.tsx @@ -5,6 +5,7 @@ import type { KnowledgeFsResearchTaskResponse, } from '@dify/contracts/api/console/knowledge-fs/types.gen' import type { Hotkey } from '@tanstack/react-hotkeys' +import type { AnchorHTMLAttributes, PropsWithChildren } from 'react' import type { RetrievalEvidence, RetrievalTestMode, @@ -12,15 +13,17 @@ import type { } from './retrieval-test-model' import type { KnowledgeQueryEvent } from './services/knowledge-query-events' import type { ResearchTaskProgressEvent } from './services/research-task-events' +import type { MarkdownProps } from '@/app/components/base/markdown' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' import { matchesKeyboardEvent } from '@tanstack/react-hotkeys' import { useQuery, useQueryClient } from '@tanstack/react-query' import { parseAsString, useQueryStates } from 'nuqs' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Markdown } from '@/app/components/base/markdown' +import { Link as MarkdownLink } from '@/app/components/base/markdown-blocks' import Link from '@/next/link' import { consoleClient, consoleQuery } from '@/service/client' import { @@ -317,11 +320,15 @@ async function queryFailure(error: unknown) { } function EvidenceCard({ + citationTargetId, + citationTargeted, documentReference, evidence, index, knowledgeSpaceId, }: { + citationTargetId?: string + citationTargeted?: boolean documentReference?: { id: string title: string @@ -336,7 +343,14 @@ function EvidenceCard({ : undefined return ( -
+

@@ -463,8 +477,81 @@ function FailedResult({ description, onRetry }: { description: string; onRetry: ) } -function ResearchAnswer({ answer, streaming }: { answer: string; streaming: boolean }) { +const researchCitationPattern = /(? { + if (index % 2 === 1) return segment + return segment.replace(researchCitationPattern, (citation, rawCitationNumber: string) => { + const citationNumber = Number(rawCitationNumber) + if (citationNumber < 1 || citationNumber > citationCount) return citation + return `[${citation}](#research-evidence-${citationNumber})` + }) + }) + .join('') +} + +type ResearchAnswerLinkProps = PropsWithChildren> & { + node?: unknown + onCitationClick: (citationIndex: number) => void +} + +function ResearchAnswerLink({ + children, + href, + node, + onCitationClick, + ...props +}: ResearchAnswerLinkProps) { + const citationMatch = href?.match(/^#research-evidence-(\d+)$/) + if (!citationMatch) + return ( + + {children} + + ) + + const citationIndex = Number(citationMatch[1]) - 1 + return ( + { + event.preventDefault() + onCitationClick(citationIndex) + }} + > + {children} + + ) +} + +function ResearchAnswer({ + answer, + citationCount, + onCitationClick, + streaming, +}: { + answer: string + citationCount: number + onCitationClick: (citationIndex: number) => void + streaming: boolean +}) { const { t } = useTranslation('dataset') + const linkedAnswer = useMemo( + () => linkResearchCitations(answer, citationCount), + [answer, citationCount], + ) + const citationComponents = useMemo>( + () => ({ + a: (props) => , + }), + [onCitationClick], + ) return (
@@ -486,7 +573,8 @@ function ResearchAnswer({ answer, streaming }: { answer: string; streaming: bool
@@ -847,6 +935,11 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri const [qualityDecisions, setQualityDecisions] = useState>({}) const [qualityPendingKey, setQualityPendingKey] = useState() const [showAll, setShowAll] = useState(false) + const [selectedCitation, setSelectedCitation] = useState<{ + citationIndex: number + requestId: number + taskId: string + }>() const queryAbortControllerRef = useRef(undefined) useEffect( @@ -1085,6 +1178,31 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri const selectedHasNoResults = selected?.kind === 'local' && localRun?.status === 'no-results' const initialEvidenceCount = selectedMode === 'research' ? 5 : 3 const visibleEvidence = showAll ? currentEvidence : currentEvidence.slice(0, initialEvidenceCount) + const selectedCitationIndex = + selectedCitation && selectedCitation.taskId === selectedResearchTaskId + ? selectedCitation.citationIndex + : undefined + const jumpToResearchCitation = useCallback( + (citationIndex: number) => { + if (!selectedResearchTaskId || citationIndex < 0 || citationIndex >= currentEvidence.length) + return + setShowAll(true) + setSelectedCitation((current) => ({ + citationIndex, + requestId: (current?.requestId ?? 0) + 1, + taskId: selectedResearchTaskId, + })) + }, + [currentEvidence.length, selectedResearchTaskId], + ) + + useEffect(() => { + if (selectedCitationIndex === undefined || !selectedCitation) return + const target = document.getElementById(`research-evidence-${selectedCitationIndex + 1}`) + if (!target) return + target.scrollIntoView({ behavior: 'smooth', block: 'center' }) + target.focus({ preventScroll: true }) + }, [selectedCitation, selectedCitationIndex, visibleEvidence.length]) const selectRecord = (record: RetrievalTestRecord) => { if (record.kind === 'local') { @@ -1475,6 +1593,8 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri {selectedResearchTask && researchAnswer && ( )} @@ -1515,6 +1635,10 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri {visibleEvidence.map((evidence, index) => (