fix(web): improve document detail layout (WTA-2026)

This commit is contained in:
Stephen Zhou 2026-08-14 23:47:18 +08:00
parent 426b5e9fee
commit ebc7322e00
No known key found for this signature in database
7 changed files with 270 additions and 42 deletions

View File

@ -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<typeof import('@/service/base')>()),
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(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
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(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
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)

View File

@ -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 (
<Heading
className={cn(
'wrap-break-word text-text-primary',
headingLevel === 2 && 'system-xl-semibold',
headingLevel === 3 && 'system-sm-semibold',
headingLevel >= 4 && 'system-sm-semibold',
)}
>
{children}
</Heading>
)
}
function DocumentSectionSummary({ children }: { children: React.ReactNode }) {
const { t } = useTranslation('dataset')
const [expanded, setExpanded] = useState(true)
return (
<div className="mt-3 overflow-hidden rounded-lg bg-background-section">
<button
aria-expanded={expanded}
className="flex w-full items-center gap-1.5 px-3.5 pt-3 text-left system-xs-regular text-text-secondary outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:ring-inset"
type="button"
onClick={() => setExpanded((current) => !current)}
>
<span aria-hidden className="i-ri-file-list-3-line size-4 shrink-0" />
<span className="min-w-0 flex-1">{t(($) => $['newKnowledge.documentSummary'])}</span>
<span
aria-hidden
className={cn(
'i-ri-arrow-down-s-line size-4 shrink-0 transition-transform motion-reduce:transition-none',
!expanded && '-rotate-90',
)}
/>
</button>
{expanded && (
<p className="px-3.5 pt-1 pb-3 system-xs-regular wrap-break-word text-text-secondary">
{children}
</p>
)}
</div>
)
}
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 && (
<section className="space-y-3 rounded-lg px-3 pt-2 first:pt-3 xl:px-0">
<h3 className="system-sm-semibold text-text-primary">
{t(($) => $['newKnowledge.documentImages'])}
</h3>
{multimodalPlacement.unplaced.map((item) => (
<DocumentMultimodalAsset item={item} key={item.id} />
))}
</section>
)}
{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 (
<section
@ -190,18 +231,16 @@ export function DocumentChunkDetail({
<div className="min-w-0 flex-1">
<div className="flex items-start gap-1">
{!content.body && markerLabel && <ChunkMarker label={markerLabel} />}
<h3 className="system-sm-semibold wrap-break-word text-text-primary">
<DocumentSectionHeading level={sectionLevel}>
{outlineNode?.title.trim() ||
content.heading ||
t(($) => $['newKnowledge.chunkHeading'], {
position: chunk.ordinal + 1,
})}
</h3>
</DocumentSectionHeading>
</div>
{outlineSummary && (
<p className="mt-1 text-[13px] leading-5.5 wrap-break-word text-text-tertiary">
{outlineSummary}
</p>
<DocumentSectionSummary>{outlineSummary}</DocumentSectionSummary>
)}
</div>
<Button
@ -241,6 +280,16 @@ export function DocumentChunkDetail({
</section>
)
})}
{multimodalPlacement.unplaced.length > 0 && (
<section className="space-y-3 rounded-lg px-3 pt-2 first:pt-3 xl:px-0">
<h3 className="system-sm-semibold text-text-primary">
{t(($) => $['newKnowledge.documentImages'])}
</h3>
{multimodalPlacement.unplaced.map((item) => (
<DocumentMultimodalAsset item={item} key={item.id} />
))}
</section>
)}
</div>
) : (
<div className="flex min-h-72 items-center justify-center px-6 text-center body-sm-regular text-text-tertiary">

View File

@ -76,15 +76,31 @@ export function DocumentChunkTreePanel({
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(() => new Set())
const [expansionOverrides, setExpansionOverrides] = useState<{
collapsed: Set<string>
expanded: Set<string>
}>(() => ({ collapsed: new Set(), expanded: new Set() }))
const [focusedNodeId, setFocusedNodeId] = useState<string>()
const [selectedNodeId, setSelectedNodeId] = useState<string>()
const [treeHasFocus, setTreeHasFocus] = useState(false)
const treeScrollRef = useRef<HTMLDivElement>(null)
const expandedNodeIds = useMemo(
() => new Set([...tree.byId.keys()].filter((id) => !collapsedNodeIds.has(id))),
[collapsedNodeIds, tree.byId],
)
const selectedBranchNodeIds = useMemo(() => {
const selectedNode =
(selectedNodeId ? tree.byId.get(selectedNodeId) : undefined) ??
[...tree.byId.values()].find((node) => node.targetChunkId === selectedChunkId)
const branch = new Set<string>()
let node = selectedNode
while (node) {
branch.add(node.id)
node = node.parentId ? tree.byId.get(node.parentId) : undefined
}
return branch
}, [selectedChunkId, selectedNodeId, tree.byId])
const expandedNodeIds = useMemo(() => {
const expanded = new Set([...selectedBranchNodeIds, ...expansionOverrides.expanded])
for (const nodeId of expansionOverrides.collapsed) expanded.delete(nodeId)
return expanded
}, [expansionOverrides, selectedBranchNodeIds])
const visibleNodes = useMemo(
() => visibleDocumentChunkNodes(tree.roots, expandedNodeIds),
[expandedNodeIds, tree.roots],
@ -111,10 +127,19 @@ export function DocumentChunkTreePanel({
const virtualRows = rowVirtualizer.getVirtualItems()
const toggleExpanded = (nodeId: string) => {
setCollapsedNodeIds((current) => {
const next = new Set(current)
if (next.has(nodeId)) next.delete(nodeId)
else next.add(nodeId)
const expanded = expandedNodeIds.has(nodeId)
setExpansionOverrides((current) => {
const next = {
collapsed: new Set(current.collapsed),
expanded: new Set(current.expanded),
}
if (expanded) {
next.expanded.delete(nodeId)
next.collapsed.add(nodeId)
} else {
next.collapsed.delete(nodeId)
next.expanded.add(nodeId)
}
return next
})
}
@ -145,10 +170,10 @@ export function DocumentChunkTreePanel({
else if (event.key === 'Home') nextId = visibleNodes[0]?.node.id
else if (event.key === 'End') nextId = visibleNodes.at(-1)?.node.id
else if (event.key === 'ArrowRight' && current.node.children.length) {
if (collapsedNodeIds.has(nodeId)) toggleExpanded(nodeId)
if (!expandedNodeIds.has(nodeId)) toggleExpanded(nodeId)
else nextId = current.node.children[0]?.id
} else if (event.key === 'ArrowLeft') {
if (current.node.children.length && !collapsedNodeIds.has(nodeId)) toggleExpanded(nodeId)
if (current.node.children.length && expandedNodeIds.has(nodeId)) toggleExpanded(nodeId)
else if (parentId && tree.byId.has(parentId)) nextId = parentId
} else if (event.key === 'Enter' || event.key === ' ') selectNode(current.node)
else return
@ -159,7 +184,7 @@ export function DocumentChunkTreePanel({
const renderTreeItem = (item: (typeof visibleNodes)[number], style?: React.CSSProperties) => {
const { depth, node, positionInSet, setSize } = item
const hasChildren = node.children.length > 0
const expanded = !collapsedNodeIds.has(node.id)
const expanded = expandedNodeIds.has(node.id)
const label = chunkTreeLabel(node.label)
return (
<button

View File

@ -1,8 +1,18 @@
'use client'
import type { KnowledgeFsDocumentMultimodalItemResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
// oxlint-disable-next-line no-restricted-imports
import { get } from '@/service/base'
const CONSOLE_API_PATH = '/console/api'
function consoleApiAssetPath(source: string) {
const url = new URL(source, 'http://dify.invalid')
if (!url.pathname.startsWith(`${CONSOLE_API_PATH}/`)) return undefined
return `${url.pathname.slice(CONSOLE_API_PATH.length)}${url.search}`
}
export function DocumentMultimodalAsset({
item,
@ -11,16 +21,51 @@ export function DocumentMultimodalAsset({
}) {
const { t } = useTranslation('dataset')
const [failedSources, setFailedSources] = useState<Set<string>>(() => new Set())
const source = [item.thumbnail_url, item.asset_url].find(
const rawSource = [item.asset_url, item.thumbnail_url].find(
(candidate) => candidate && !failedSources.has(candidate),
)
const apiAssetPath = rawSource ? consoleApiAssetPath(rawSource) : undefined
const [loadedAsset, setLoadedAsset] = useState<{ objectUrl: string; rawSource: string }>()
const source = apiAssetPath
? loadedAsset && loadedAsset.rawSource === rawSource
? loadedAsset.objectUrl
: undefined
: rawSource
const rawLabel =
item.caption?.trim() || item.title?.trim() || item.text_preview?.trim() || item.ocr_text?.trim()
const label = rawLabel && rawLabel.length > 500 ? `${rawLabel.slice(0, 497)}...` : rawLabel
useEffect(() => {
if (!apiAssetPath || !rawSource) return
const abortController = new AbortController()
let objectUrl: string | undefined
void get<Response>(
apiAssetPath,
{ signal: abortController.signal },
{ needAllResponseContent: true, silent: true },
)
.then(async (response) => {
const blob = await response.blob()
if (abortController.signal.aborted) return
objectUrl = URL.createObjectURL(blob)
setLoadedAsset({ objectUrl, rawSource })
})
.catch(() => {
if (abortController.signal.aborted) return
setFailedSources((current) => new Set(current).add(rawSource))
})
return () => {
abortController.abort()
if (objectUrl) URL.revokeObjectURL(objectUrl)
}
}, [apiAssetPath, rawSource])
const handleError = () => {
if (!source) return
setFailedSources((current) => new Set(current).add(source))
if (!rawSource) return
setFailedSources((current) => new Set(current).add(rawSource))
}
return (
@ -37,6 +82,13 @@ export function DocumentMultimodalAsset({
onError={handleError}
src={source}
/>
) : rawSource ? (
<div
aria-busy="true"
className="flex min-h-32 items-center justify-center bg-background-default text-text-tertiary"
>
<span aria-hidden className="i-ri-loader-4-line size-5 animate-spin" />
</div>
) : (
<div className="flex min-h-32 flex-col items-center justify-center gap-2 px-4 py-6 text-center text-text-tertiary">
<span aria-hidden className="i-ri-image-line size-6" />

View File

@ -188,7 +188,7 @@ function LoadedDocumentRevisionContent({
])
return (
<div className="mt-7 grid min-h-0 flex-1 gap-4 xl:grid-cols-[14rem_minmax(0,1fr)_20rem] xl:gap-0">
<div className="mt-4 grid min-h-0 flex-1 gap-4 xl:grid-cols-[14rem_minmax(0,1fr)_20rem] xl:gap-0">
<DocumentChunkTreePanel
chunkCount={chunks.length}
error={Boolean(chunksQuery.error)}

View File

@ -230,11 +230,11 @@
"newKnowledge.documentContents": "Contents",
"newKnowledge.documentFacts": "Document details",
"newKnowledge.documentFilterLabel": "Filter documents by status",
"newKnowledge.documentLoadErrorDescription": "We couldn't load this document. Try again.",
"newKnowledge.documentLoadErrorTitle": "Document couldn't load",
"newKnowledge.documentImageAlt": "Extracted document image",
"newKnowledge.documentImageUnavailable": "This image could not be displayed.",
"newKnowledge.documentImages": "Document images",
"newKnowledge.documentLoadErrorDescription": "We couldn't load this document. Try again.",
"newKnowledge.documentLoadErrorTitle": "Document couldn't load",
"newKnowledge.documentNotFoundDescription": "This document may have been removed, or you may not have access.",
"newKnowledge.documentNotFoundTitle": "Document not found",
"newKnowledge.documentOverviewDescription": "Labeling metadata for documents allows AI to access them in a timely manner and exposes the source of references for users.",
@ -252,6 +252,7 @@
"newKnowledge.documentStatus.processing": "Processing",
"newKnowledge.documentStatus.queued": "Queued",
"newKnowledge.documentStatus.ready": "Ready",
"newKnowledge.documentSummary": "Summary",
"newKnowledge.documentTaskLookupIncomplete": "More task history is available. Continue checking to find this document's status.",
"newKnowledge.documentUploadExclusion.batchLimit": "batch size limit exceeded",
"newKnowledge.documentUploadExclusion.countLimit": "file count limit exceeded",

View File

@ -230,11 +230,11 @@
"newKnowledge.documentContents": "目录",
"newKnowledge.documentFacts": "文档详情",
"newKnowledge.documentFilterLabel": "按状态筛选文档",
"newKnowledge.documentLoadErrorDescription": "无法加载此文档,请重试。",
"newKnowledge.documentLoadErrorTitle": "文档加载失败",
"newKnowledge.documentImageAlt": "文档中的图片",
"newKnowledge.documentImageUnavailable": "这张图片暂时无法显示。",
"newKnowledge.documentImages": "文档图片",
"newKnowledge.documentLoadErrorDescription": "无法加载此文档,请重试。",
"newKnowledge.documentLoadErrorTitle": "文档加载失败",
"newKnowledge.documentNotFoundDescription": "此文档可能已被删除,或你没有访问权限。",
"newKnowledge.documentNotFoundTitle": "未找到文档",
"newKnowledge.documentOverviewDescription": "为文档添加元数据,可让 AI 及时获取文档内容,并向用户展示引用来源。",
@ -252,6 +252,7 @@
"newKnowledge.documentStatus.processing": "处理中",
"newKnowledge.documentStatus.queued": "排队中",
"newKnowledge.documentStatus.ready": "就绪",
"newKnowledge.documentSummary": "摘要",
"newKnowledge.documentTaskLookupIncomplete": "还有更多任务记录,请继续检查以确认此文档的状态。",
"newKnowledge.documentUploadExclusion.batchLimit": "超出批次大小限制",
"newKnowledge.documentUploadExclusion.countLimit": "超出文件数量限制",