dify/web/features/new-rag/document-list.tsx
Stephen Zhou 2d5161a647
feat(dataset): add document detail (#39361)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-24 15:27:35 +00:00

723 lines
26 KiB
TypeScript

'use client'
import type { LogicalDocument } from '@dify/contracts/knowledge-fs/types.gen'
import type { FocusEventHandler } from 'react'
import type { DocumentDisplayStatus } from './document-model'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { memo, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import Link from '@/next/link'
import { sourceName } from './document-model'
export type DocumentFilter = DocumentDisplayStatus | 'all'
const DOCUMENT_RENDER_BATCH_SIZE = 100
const PARTIAL_RESULTS_DESCRIPTION_ID = 'partial-document-results'
const statusIconClass: Record<DocumentDisplayStatus, string> = {
ready: 'i-ri-check-line text-text-success',
queued: 'i-ri-time-line text-text-tertiary',
processing: 'i-ri-loader-2-line animate-spin text-text-accent motion-reduce:animate-none',
failed: 'i-ri-error-warning-fill text-text-destructive',
disabled: 'i-ri-indeterminate-circle-line text-text-tertiary',
}
function TaskTrigger({
activeTaskCount,
attentionTaskBadge,
hasTaskError,
onOpenTasks,
tasksButtonLabel,
tasksLiveStatus,
}: {
activeTaskCount: number
attentionTaskBadge?: string
hasTaskError: boolean
onOpenTasks: () => void
tasksButtonLabel: string
tasksLiveStatus: string
}) {
const { t } = useTranslation('dataset')
return (
<>
<Button aria-label={tasksButtonLabel} data-has-error={hasTaskError} onClick={onOpenTasks}>
<span
aria-hidden
className={cn(
'size-4',
activeTaskCount ? 'i-ri-loader-2-line animate-spin' : 'i-ri-task-line',
activeTaskCount && 'motion-reduce:animate-none',
)}
/>
{t(($) => $['newKnowledge.tasks'])}
{attentionTaskBadge && (
<span
aria-hidden
className={cn(
'flex min-w-4 items-center justify-center rounded px-1 system-2xs-medium',
hasTaskError
? 'bg-state-destructive-hover text-text-destructive'
: 'bg-state-accent-hover text-text-accent',
)}
>
{attentionTaskBadge}
</span>
)}
</Button>
<span className="sr-only" role="status" aria-live="polite">
{tasksLiveStatus}
</span>
</>
)
}
const DocumentRow = memo(
({
document,
documentHref,
formatTimeFromNow,
onSelectedChange,
readOnlyReasonId,
selected,
selectionDisabled,
source,
sourcePending,
status,
statusPending,
}: {
document: LogicalDocument
documentHref: string
formatTimeFromNow: (time: number) => string
onSelectedChange: (documentId: string) => void
readOnlyReasonId?: string
selected: boolean
selectionDisabled: boolean
source?: string
sourcePending: boolean
status: DocumentDisplayStatus
statusPending: boolean
}) => {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const titleId = `new-document-${document.id}`
const revision = document.activeRevision ?? document.active?.revision
const updatedTime = Date.parse(document.updatedAt)
return (
<tr
className={cn(
'border-t border-divider-subtle',
status === 'disabled' && !statusPending && 'opacity-60',
)}
>
<td className="w-10 py-3 pr-3">
<Checkbox
checked={selected}
disabled={selectionDisabled || status === 'disabled'}
aria-describedby={selectionDisabled ? readOnlyReasonId : undefined}
aria-labelledby={titleId}
onCheckedChange={() => onSelectedChange(document.id)}
/>
</td>
<td className="min-w-0 py-3 pr-3 sm:min-w-72 sm:pr-6">
<div className="flex min-w-0 items-center gap-2.5">
<span
aria-hidden
className="i-ri-file-text-line size-[18px] shrink-0 text-text-tertiary"
/>
<Link
id={titleId}
className="truncate rounded system-xs-medium text-text-primary hover:text-text-accent focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
href={documentHref}
>
{document.title}
</Link>
{revision !== undefined && (
<span className="shrink-0 rounded border border-divider-regular px-1 system-2xs-medium text-text-tertiary">
v{revision}
</span>
)}
</div>
</td>
<td className="hidden w-52 py-3 pr-6 system-xs-regular text-text-secondary lg:table-cell">
{sourcePending ? (
<span className="inline-flex items-center gap-2">
<span
aria-hidden
className="h-3 w-24 animate-pulse rounded bg-background-section motion-reduce:animate-none"
/>
<span className="sr-only">{tCommon(($) => $.loading)}</span>
</span>
) : (
<span className="block truncate">
{source ?? t(($) => $['newKnowledge.manualUpload'])}
</span>
)}
</td>
<td className="w-24 py-3 pr-2 sm:w-56 sm:pr-6">
{statusPending ? (
<span className="inline-flex items-center gap-2">
<span
aria-hidden
className="h-3 w-20 animate-pulse rounded bg-background-section motion-reduce:animate-none"
/>
<span className="sr-only">{tCommon(($) => $.loading)}</span>
</span>
) : (
<span
className={cn(
'inline-flex items-center gap-1.5 system-xs-regular',
status === 'failed' ? 'text-text-destructive' : 'text-text-secondary',
)}
>
<span aria-hidden className={cn('size-3.5', statusIconClass[status])} />
{t(($) => $[`newKnowledge.documentStatus.${status}`])}
</span>
)}
</td>
<td className="hidden w-40 py-3 pr-6 system-xs-regular text-text-tertiary lg:table-cell">
{Number.isNaN(updatedTime) ? document.updatedAt : formatTimeFromNow(updatedTime)}
</td>
<td className="w-10 py-3 text-right">
<DropdownMenu modal={false}>
<DropdownMenuTrigger
aria-label={t(($) => $['newKnowledge.documentActions'], { name: document.title })}
className="flex size-7 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-44">
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => toast.info(t(($) => $['newKnowledge.documentActionsUnavailable']))}
>
<span aria-hidden className="i-ri-download-line size-4" />
{t(($) => $['newKnowledge.downloadDocuments'])}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
className="gap-2 px-3"
onClick={() => toast.info(t(($) => $['newKnowledge.documentActionsUnavailable']))}
>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
{t(($) => $['newKnowledge.deleteDocuments'])}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
)
},
)
export function DocumentsEmpty({
activeTaskCount,
attentionTaskBadge,
canEdit,
hasTaskError,
onAddDocument,
onDropFiles,
onOpenTasks,
readOnlyReasonId,
tasksButtonLabel,
tasksLiveStatus,
uploading,
}: {
activeTaskCount: number
attentionTaskBadge?: string
canEdit: boolean
hasTaskError: boolean
onAddDocument: () => void
onDropFiles: (files: File[]) => void
onOpenTasks: () => void
readOnlyReasonId?: string
tasksButtonLabel: string
tasksLiveStatus: string
uploading: boolean
}) {
const { t } = useTranslation('dataset')
const tasksVisible = activeTaskCount > 0 || Boolean(attentionTaskBadge) || hasTaskError
return (
<div
className="flex min-h-96 flex-1 flex-col items-center justify-center px-6 text-center"
onDragOver={(event) => {
event.preventDefault()
event.dataTransfer.dropEffect = canEdit ? 'copy' : 'none'
}}
onDrop={(event) => {
event.preventDefault()
if (canEdit) onDropFiles([...event.dataTransfer.files])
}}
>
<span className="flex size-12 items-center justify-center rounded-xl bg-background-section text-text-accent">
<span aria-hidden className="i-ri-file-text-fill size-6" />
</span>
<h2 className="mt-4 system-md-semibold text-text-primary">
{t(($) => $['newKnowledge.documentsEmptyTitle'])}
</h2>
<p className="mt-2 max-w-lg system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.documentsEmptyDescription'])}
</p>
<Button
className="mt-4"
variant="primary"
aria-busy={uploading}
disabled={!canEdit}
loading={uploading}
aria-describedby={!canEdit ? readOnlyReasonId : undefined}
onClick={onAddDocument}
>
<span aria-hidden className="i-ri-add-line size-4" />
{t(($) => $['newKnowledge.addDocument'])}
</Button>
{canEdit && (
<p className="mt-2 system-2xs-regular text-text-quaternary">
{t(($) => $['newKnowledge.documentsDropHint'])}
</p>
)}
{tasksVisible && (
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<TaskTrigger
activeTaskCount={activeTaskCount}
attentionTaskBadge={attentionTaskBadge}
hasTaskError={hasTaskError}
onOpenTasks={onOpenTasks}
tasksButtonLabel={tasksButtonLabel}
tasksLiveStatus={tasksLiveStatus}
/>
</div>
)}
</div>
)
}
export function DocumentsList({
activeTaskCount,
allSelected,
attentionTaskBadge,
canEdit,
completingResults,
documents,
filter,
getDocumentHref,
hasNextPage,
hasSelectableDocuments,
hasTaskError,
isFetchNextPageError,
isFetchingNextDocumentPage,
isFetchingNextPage,
onAddDocument,
onFilterChange,
onLoadMore,
onOpenTasks,
onSearchChange,
onSelectAll,
onSelectDocument,
readOnlyReasonId,
resultsIncomplete,
search,
selectionDisabled,
selectedDocumentIds,
someSelected,
sourcesPending,
sourceNames,
statusPending,
statuses,
tasksPending,
tasksButtonLabel,
tasksLiveStatus,
uploading,
}: {
activeTaskCount: number
allSelected: boolean
attentionTaskBadge?: string
canEdit: boolean
completingResults: boolean
documents: LogicalDocument[]
filter: DocumentFilter
getDocumentHref: (documentId: string) => string
hasNextPage: boolean
hasSelectableDocuments: boolean
hasTaskError: boolean
isFetchNextPageError: boolean
isFetchingNextDocumentPage: boolean
isFetchingNextPage: boolean
onAddDocument: () => void
onFilterChange: (filter: DocumentFilter) => void
onLoadMore: () => void
onOpenTasks: () => void
onSearchChange: (search: string) => void
onSelectAll: () => void
onSelectDocument: (documentId: string) => void
readOnlyReasonId?: string
resultsIncomplete: boolean
search: string
selectionDisabled: boolean
selectedDocumentIds: Set<string>
someSelected: boolean
sourcesPending: boolean
sourceNames: Map<string, string>
statusPending: boolean
statuses: Map<string, DocumentDisplayStatus>
tasksPending: boolean
tasksButtonLabel: string
tasksLiveStatus: string
uploading: boolean
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const { formatTimeFromNow } = useFormatTimeFromNow()
const [visibleDocumentLimit, setVisibleDocumentLimit] = useState(DOCUMENT_RENDER_BATCH_SIZE)
const loadMoreButtonRef = useRef<HTMLButtonElement>(null)
const resultsContainerRef = useRef<HTMLDivElement>(null)
const restoreLoadMoreFocusRef = useRef(false)
const filterActive = filter !== 'all' || Boolean(search.trim())
const visibleDocuments = documents.slice(0, visibleDocumentLimit)
const hasHiddenDocuments = visibleDocuments.length < documents.length
useEffect(() => {
if (isFetchingNextPage || !restoreLoadMoreFocusRef.current) return
if (hasHiddenDocuments || hasNextPage || isFetchNextPageError) {
loadMoreButtonRef.current?.focus()
return
}
restoreLoadMoreFocusRef.current = false
resultsContainerRef.current?.focus()
}, [hasHiddenDocuments, hasNextPage, isFetchNextPageError, isFetchingNextPage])
return (
<>
<div className="mt-5 flex flex-col gap-2 xl:flex-row xl:items-center">
<label className="sr-only" htmlFor="document-filter">
{t(($) => $['newKnowledge.documentFilterLabel'])}
</label>
<select
id="document-filter"
disabled={statusPending}
value={filter}
onChange={(event) => {
setVisibleDocumentLimit(DOCUMENT_RENDER_BATCH_SIZE)
onFilterChange(event.target.value as DocumentFilter)
}}
className="h-8 rounded-lg border-0 bg-components-input-bg-normal px-3 system-xs-regular text-text-secondary outline-hidden focus:ring-2 focus:ring-state-accent-solid xl:w-36"
>
<option value="all">{t(($) => $['newKnowledge.allDocumentStatuses'])}</option>
{(['ready', 'queued', 'processing', 'failed', 'disabled'] as const).map((status) => (
<option key={status} value={status}>
{t(($) => $[`newKnowledge.documentStatus.${status}`])}
</option>
))}
</select>
<label className="relative xl:w-60">
<span className="sr-only">{t(($) => $['newKnowledge.searchDocuments'])}</span>
<span
aria-hidden
className="pointer-events-none absolute top-2 left-2.5 i-ri-search-line size-4 text-text-quaternary"
/>
<input
type="search"
value={search}
onChange={(event) => {
setVisibleDocumentLimit(DOCUMENT_RENDER_BATCH_SIZE)
onSearchChange(event.target.value)
}}
placeholder={t(($) => $['newKnowledge.searchDocuments'])}
className="h-8 w-full rounded-lg border-0 bg-components-input-bg-normal pr-3 pl-8 system-xs-regular text-text-primary outline-hidden placeholder:text-text-quaternary focus:ring-2 focus:ring-state-accent-solid"
/>
</label>
<span className="min-w-0 flex-1" />
<TaskTrigger
activeTaskCount={activeTaskCount}
attentionTaskBadge={attentionTaskBadge}
hasTaskError={hasTaskError}
onOpenTasks={onOpenTasks}
tasksButtonLabel={tasksButtonLabel}
tasksLiveStatus={tasksLiveStatus}
/>
<Button onClick={() => toast.info(t(($) => $['newKnowledge.filtersUnavailable']))}>
<span aria-hidden className="i-ri-price-tag-3-line size-4" />
{t(($) => $['newKnowledge.metadata'])}
</Button>
<Button
variant="primary"
aria-busy={uploading}
disabled={!canEdit}
loading={uploading}
aria-describedby={!canEdit ? readOnlyReasonId : undefined}
onClick={onAddDocument}
>
<span aria-hidden className="i-ri-add-line size-4" />
{t(($) => $['newKnowledge.addDocument'])}
</Button>
</div>
<div
ref={resultsContainerRef}
aria-labelledby="new-knowledge-documents-title"
aria-busy={completingResults || isFetchingNextPage || sourcesPending || tasksPending}
className="mt-4 overflow-x-auto rounded-lg outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
role="region"
tabIndex={-1}
>
<table className="w-full table-fixed border-collapse text-left lg:min-w-[900px] lg:table-auto">
<thead className="system-2xs-medium text-text-tertiary uppercase">
<tr>
<th className="pb-2 font-medium">
<Checkbox
checked={allSelected}
indeterminate={someSelected && !allSelected}
disabled={!canEdit || selectionDisabled || !hasSelectableDocuments}
aria-describedby={
!canEdit
? readOnlyReasonId
: selectionDisabled && resultsIncomplete
? PARTIAL_RESULTS_DESCRIPTION_ID
: undefined
}
aria-label={t(($) => $['newKnowledge.selectAllDocuments'])}
onCheckedChange={onSelectAll}
/>
</th>
<th className="pb-2 font-medium">{t(($) => $['newKnowledge.documentColumn'])}</th>
<th className="hidden pb-2 font-medium lg:table-cell">
{t(($) => $['newKnowledge.sourceColumn'])}
</th>
<th className="pb-2 font-medium">{t(($) => $['newKnowledge.statusColumn'])}</th>
<th className="hidden pb-2 font-medium lg:table-cell">
{t(($) => $['newKnowledge.updatedColumn'])}
</th>
<th aria-label={t(($) => $['newKnowledge.actionsColumn'])} />
</tr>
</thead>
<tbody>
{visibleDocuments.map((document) => (
<DocumentRow
key={document.id}
document={document}
documentHref={getDocumentHref(document.id)}
formatTimeFromNow={formatTimeFromNow}
onSelectedChange={onSelectDocument}
readOnlyReasonId={
!canEdit
? readOnlyReasonId
: selectionDisabled && resultsIncomplete
? PARTIAL_RESULTS_DESCRIPTION_ID
: undefined
}
selected={selectedDocumentIds.has(document.id)}
selectionDisabled={!canEdit || selectionDisabled}
source={
(document.sourceId && sourceNames.get(document.sourceId)) ?? sourceName(document)
}
sourcePending={Boolean(
sourcesPending && document.sourceId && !sourceNames.has(document.sourceId),
)}
status={statuses.get(document.id) ?? 'queued'}
statusPending={Boolean(
tasksPending ||
(statusPending && document.sourceId && !sourceNames.has(document.sourceId)),
)}
/>
))}
</tbody>
</table>
{!documents.length && !completingResults && !isFetchNextPageError && !resultsIncomplete && (
<p
aria-live="polite"
className="py-16 text-center body-sm-regular text-text-tertiary"
role="status"
>
{t(($) => $['newKnowledge.noMatchingDocuments'])}
</p>
)}
{resultsIncomplete && (
<p
id={PARTIAL_RESULTS_DESCRIPTION_ID}
aria-live="polite"
className={cn(
'text-center body-sm-regular text-text-tertiary',
completingResults || isFetchNextPageError
? 'sr-only'
: documents.length
? 'py-4'
: 'py-16',
)}
role="status"
>
{t(($) => $['newKnowledge.partialDocumentResults'])}
</p>
)}
{completingResults && (
<div className="flex min-h-32 items-center justify-center">
<Loading />
</div>
)}
</div>
<p className="mt-3 flex items-center gap-1.5 system-xs-regular text-text-tertiary">
<span aria-hidden className="i-ri-information-2-line size-3.5" />
{t(($) => $['newKnowledge.lastReadyRevisionHint'])}
</p>
{hasHiddenDocuments ? (
<div className="mt-5 flex justify-center">
<Button
ref={loadMoreButtonRef}
onBlur={() => {
restoreLoadMoreFocusRef.current = false
}}
onClick={() => {
restoreLoadMoreFocusRef.current = document.activeElement === loadMoreButtonRef.current
setVisibleDocumentLimit((current) => current + DOCUMENT_RENDER_BATCH_SIZE)
}}
>
{t(($) => $['newKnowledge.loadMore'])}
</Button>
</div>
) : isFetchNextPageError ? (
<div className="mt-5 flex items-center justify-center gap-3" role="alert">
<span className="system-xs-regular text-text-destructive">
{t(($) => $['newKnowledge.documentsErrorDescription'])}
</span>
<Button
ref={loadMoreButtonRef}
aria-label={`${tCommon(($) => $['operation.retry'])} · ${t(($) => $['newKnowledge.documentsErrorDescription'])}`}
aria-busy={isFetchingNextDocumentPage}
loading={isFetchingNextDocumentPage}
onBlur={(event) => {
if (event.relatedTarget) restoreLoadMoreFocusRef.current = false
}}
onClick={() => {
restoreLoadMoreFocusRef.current = document.activeElement === loadMoreButtonRef.current
onLoadMore()
}}
>
{tCommon(($) => $['operation.retry'])}
</Button>
</div>
) : hasNextPage && (!filterActive || !completingResults) ? (
<div className="mt-5 flex justify-center">
<Button
ref={loadMoreButtonRef}
aria-busy={isFetchingNextPage}
loading={isFetchingNextPage}
onBlur={(event) => {
if (event.relatedTarget) restoreLoadMoreFocusRef.current = false
}}
onClick={() => {
restoreLoadMoreFocusRef.current = document.activeElement === loadMoreButtonRef.current
onLoadMore()
}}
>
{t(($) => $['newKnowledge.loadMore'])}
</Button>
</div>
) : null}
</>
)
}
export function DocumentBulkActions({
disabled,
disabledReason,
onClear,
onBlurCapture,
onFocusCapture,
onReindex,
reindexing,
selectedCount,
}: {
disabled: boolean
disabledReason?: string
onClear: () => void
onBlurCapture: FocusEventHandler<HTMLDivElement>
onFocusCapture: FocusEventHandler<HTMLDivElement>
onReindex: () => void
reindexing: boolean
selectedCount: number
}) {
const { t } = useTranslation('dataset')
return (
<div className="pointer-events-none fixed inset-x-0 bottom-[calc(1.75rem+env(safe-area-inset-bottom,0px))] z-20 flex justify-center pr-[calc(1rem+env(safe-area-inset-right,0px))] pl-[calc(1rem+env(safe-area-inset-left,0px))]">
<div
aria-label={t(($) => $['newKnowledge.bulkDocumentActions'])}
className="pointer-events-auto flex max-w-full min-w-0 items-center gap-2 overflow-x-auto rounded-[14px] border border-divider-subtle bg-components-panel-bg px-3 py-2.5 shadow-xl"
role="group"
onBlurCapture={onBlurCapture}
onFocusCapture={onFocusCapture}
>
<Button
aria-describedby={disabled ? 'document-reindex-unavailable' : undefined}
aria-busy={reindexing}
className="shrink-0"
disabled={disabled}
loading={reindexing}
size="small"
onClick={onReindex}
>
{t(($) => $['newKnowledge.reindexDocuments'])}
</Button>
<button
type="button"
aria-label={t(($) => $['newKnowledge.clearDocumentSelection'])}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={onClear}
>
<span aria-hidden className="i-ri-close-line size-4" />
</button>
<span className="shrink-0 px-1 system-xs-medium text-text-primary">
{t(($) => $['newKnowledge.documentsSelected'], { count: selectedCount })}
</span>
{disabled && disabledReason && (
<span
id="document-reindex-unavailable"
className="max-w-44 shrink-0 system-2xs-regular text-text-tertiary"
role="status"
>
{t(($) => $['newKnowledge.reindexDocuments'])}
{' · '}
{disabledReason}
</span>
)}
<span
id="document-actions-unavailable"
className="max-w-44 shrink-0 system-2xs-regular text-text-tertiary"
>
{t(($) => $['newKnowledge.downloadDocuments'])}
{' / '}
{t(($) => $['newKnowledge.deleteDocuments'])}
{' · '}
{t(($) => $['cornerLabel.unavailable'])}
</span>
<Button
aria-describedby="document-actions-unavailable"
className="shrink-0"
size="small"
disabled
>
{t(($) => $['newKnowledge.downloadDocuments'])}
</Button>
<Button
aria-describedby="document-actions-unavailable"
className="shrink-0"
size="small"
disabled
>
{t(($) => $['newKnowledge.deleteDocuments'])}
</Button>
</div>
</div>
)
}