diff --git a/web/features/new-rag/__tests__/document-detail-page.spec.tsx b/web/features/new-rag/__tests__/document-detail-page.spec.tsx index ef3e6237822..033c2017d58 100644 --- a/web/features/new-rag/__tests__/document-detail-page.spec.tsx +++ b/web/features/new-rag/__tests__/document-detail-page.spec.tsx @@ -16,6 +16,13 @@ import copy from 'copy-to-clipboard' import { renderWithNuqs as render } from '@/test/nuqs-testing' import { DocumentDetailPage } from '../document-detail-page' +const multimodalAssetGet = vi.hoisted(() => vi.fn()) + +vi.mock('@/service/base', async (importOriginal) => ({ + ...(await importOriginal()), + get: multimodalAssetGet, +})) + vi.mock('../components/knowledge-model-readiness-banner', () => ({ KnowledgeModelReadinessBanner: () => null, })) @@ -690,6 +697,9 @@ describe('DocumentDetailPage', () => { multimodalQuery.data = undefined multimodalQuery.error = null multimodalQuery.isPending = false + multimodalAssetGet.mockImplementation( + async () => new Response(new Blob(['image-bytes'], { type: 'image/png' })), + ) tasksQuery.data = { pages: [{ items: [] }] } tasksQuery.error = null tasksQuery.hasNextPage = false @@ -887,7 +897,8 @@ describe('DocumentDetailPage', () => { ) }) - it('renders the persisted outline tree and summaries while hiding a legacy title chunk', () => { + it('renders collapsible outline summaries and semantic heading levels', async () => { + const user = userEvent.setup() chunksQuery.data = { pages: [ { @@ -969,11 +980,29 @@ describe('DocumentDetailPage', () => { expect(within(tree).queryByRole('treeitem', { name: '#0' })).not.toBeInTheDocument() expect(screen.getByText('Generated guide summary.')).toBeInTheDocument() expect(screen.getByText('Generated setup summary.')).toBeInTheDocument() + const summaryButtons = screen.getAllByRole('button', { + name: 'dataset.newKnowledge.documentSummary', + }) + expect(summaryButtons[0]).toHaveAttribute('aria-expanded', 'true') + await user.click(summaryButtons[0]!) + expect(summaryButtons[0]).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('Generated guide summary.')).not.toBeInTheDocument() + expect(screen.getByText('Generated setup summary.')).toBeInTheDocument() expect(screen.getByText('Guide body')).toBeInTheDocument() expect(screen.getByText('Setup body')).toBeInTheDocument() + const article = screen.getByRole('article') + expect( + within(article).getByRole('heading', { level: 2, name: 'Guide Operating safely' }), + ).toBeInTheDocument() + expect(within(article).getByRole('heading', { level: 3, name: 'Setup' })).toBeInTheDocument() }) - it('renders extracted document images next to the chunk selected by canonical offsets', () => { + it('loads protected document images next to the chunk selected by canonical offsets', async () => { + const createObjectUrl = vi + .spyOn(URL, 'createObjectURL') + .mockReturnValueOnce('blob:asset') + .mockReturnValueOnce('blob:thumbnail') + const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) chunksQuery.data = { pages: [ { @@ -1012,14 +1041,77 @@ describe('DocumentDetailPage', () => { render() - const image = screen.getByRole('img', { name: 'Screenshot of the source configuration' }) - expect(image).toHaveAttribute('src', '/console/api/knowledge-fs/image-1?variant=thumbnail') + const image = await screen.findByRole('img', { + name: 'Screenshot of the source configuration', + }) + expect(image).toHaveAttribute('src', 'blob:asset') + expect(multimodalAssetGet).toHaveBeenNthCalledWith( + 1, + '/knowledge-fs/image-1', + { signal: expect.any(AbortSignal) }, + { needAllResponseContent: true, silent: true }, + ) fireEvent.error(image) - expect(image).toHaveAttribute('src', '/console/api/knowledge-fs/image-1') + await waitFor(() => + expect( + screen.getByRole('img', { name: 'Screenshot of the source configuration' }), + ).toHaveAttribute('src', 'blob:thumbnail'), + ) + expect(multimodalAssetGet).toHaveBeenNthCalledWith( + 2, + '/knowledge-fs/image-1?variant=thumbnail', + { signal: expect.any(AbortSignal) }, + { needAllResponseContent: true, silent: true }, + ) + expect(revokeObjectUrl).toHaveBeenCalledWith('blob:asset') + expect(createObjectUrl).toHaveBeenCalledTimes(2) expect(screen.getByText('Screenshot of the source configuration')).toBeInTheDocument() expect(screen.getByText('The image caption follows.')).toBeInTheDocument() }) + it('shows images without location metadata after the document chunks', () => { + chunksQuery.data = { + pages: [ + { + items: [ + chunk({ + id: 'chapter', + ordinal: 1, + sectionPath: ['Chapter'], + text: 'Chapter\n\nChapter body', + }), + ], + }, + ], + } + multimodalQuery.data = { + artifact_hash: 'artifact-hash', + created_at: '2026-08-07T10:00:00Z', + document_asset_id: 'asset-1', + id: 'manifest-1', + items: [ + { + asset_url: '/image-without-location', + caption: 'Screenshot without location metadata', + id: 'image-without-location', + modality: 'image', + section_path: [], + }, + ], + manifest_version: 'document-multimodal-manifest-v1', + version: 1, + } + + render() + + const article = screen.getByRole('article') + const chunkHeading = within(article).getByRole('heading', { level: 2, name: 'Chapter' }) + const image = screen.getByRole('img', { name: 'Screenshot without location metadata' }) + expect(chunkHeading.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ) + }) + it('labels flat document chunks by their visible order', () => { chunksQuery.data = { pages: [ @@ -1491,6 +1583,12 @@ describe('DocumentDetailPage', () => { text: 'Child content', }), chunk({ id: 'second', ordinal: 3, sectionPath: ['Second root'] }), + chunk({ + id: 'second-child', + ordinal: 4, + parentChunkId: 'second', + sectionPath: ['Second root', 'Hidden child'], + }), ], }, ], @@ -1501,6 +1599,8 @@ describe('DocumentDetailPage', () => { const parent = screen.getByRole('treeitem', { name: /Parent node/ }) const child = screen.getByRole('treeitem', { name: /Child node/ }) const second = screen.getByRole('treeitem', { name: /Second root/ }) + expect(second).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('treeitem', { name: /Hidden child/ })).not.toBeInTheDocument() tree.focus() fireEvent.keyDown(tree, { key: 'ArrowRight' }) expect(tree).toHaveAttribute('aria-activedescendant', child.id) diff --git a/web/features/new-rag/document-chunk-detail.tsx b/web/features/new-rag/document-chunk-detail.tsx index 4bafda755e7..32d99d4cf32 100644 --- a/web/features/new-rag/document-chunk-detail.tsx +++ b/web/features/new-rag/document-chunk-detail.tsx @@ -8,9 +8,10 @@ import type { LogicalDocumentRevision, } from './document-models' import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' import copy from 'copy-to-clipboard' -import { useEffect, useMemo, useRef } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Markdown } from '@/app/components/base/markdown' import { @@ -61,6 +62,54 @@ function ChunkMarker({ label }: { label: string }) { ) } +function DocumentSectionHeading({ children, level }: { children: React.ReactNode; level: number }) { + const headingLevel = Math.min(6, Math.max(2, Math.trunc(level) + 1)) + const Heading = `h${headingLevel}` as 'h2' | 'h3' | 'h4' | 'h5' | 'h6' + return ( + = 4 && 'system-sm-semibold', + )} + > + {children} + + ) +} + +function DocumentSectionSummary({ children }: { children: React.ReactNode }) { + const { t } = useTranslation('dataset') + const [expanded, setExpanded] = useState(true) + + return ( +
+ + {expanded && ( +

+ {children} +

+ )} +
+ ) +} + export function DocumentChunkDetail({ canEdit, controlSpaceId, @@ -162,16 +211,6 @@ export function DocumentChunkDetail({ className="flex max-h-[70vh] flex-col gap-3 overflow-auto px-2 pt-1 xl:h-full xl:max-h-none xl:px-0" data-testid="chunk-content-scroll" > - {multimodalPlacement.unplaced.length > 0 && ( -
-

- {t(($) => $['newKnowledge.documentImages'])} -

- {multimodalPlacement.unplaced.map((item) => ( - - ))} -
- )} {chunks.map((chunk) => { const content = chunkContentParts(chunk) const markerLabel = chunkMarkerLabels.get(chunk.id) @@ -179,6 +218,8 @@ export function DocumentChunkDetail({ const outlineSummary = outlineSummaryChunkIds.has(chunk.id) ? outlineNode?.summary?.trim() : undefined + const sectionLevel = + outlineNode?.level ?? (chunk.sectionPath.length > 0 ? chunk.sectionPath.length : 2) const chunkMultimodalItems = multimodalPlacement.byChunkId.get(chunk.id) ?? [] return (
{!content.body && markerLabel && } -

+ {outlineNode?.title.trim() || content.heading || t(($) => $['newKnowledge.chunkHeading'], { position: chunk.ordinal + 1, })} -

+
{outlineSummary && ( -

- {outlineSummary} -

+ {outlineSummary} )}