dify/web/features/new-rag/processing-tasks-drawer.tsx
Stephen Zhou 799c7eea3a
feat(dataset): add document processing tasks (#39326)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-24 12:58:15 +00:00

637 lines
26 KiB
TypeScript

'use client'
import type {
DocumentProcessingTask,
LogicalDocument,
} from '@dify/contracts/knowledge-fs/types.gen'
import type { TaskProgressStore } from './task-progress-store'
import { Button } from '@langgenius/dify-ui/button'
import {
Drawer,
DrawerBackdrop,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerPopup,
DrawerPortal,
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { useMutation } from '@tanstack/react-query'
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { consoleQuery } from '@/service/client'
import { taskCanRetry, taskIsActive, taskVersionIsAfter } from './document-model'
type TaskAction = 'cancel' | 'retry'
const TASK_DRAWER_LIMIT = 100
const noopSubscribe = () => () => undefined
function taskTime(task: DocumentProcessingTask) {
return task.completedAt ?? task.updatedAt
}
function taskLifecycle(task: DocumentProcessingTask) {
return `${task.updatedAt}:${task.state}`
}
function responseStatus(error: unknown): number | undefined {
if (error instanceof Response) return error.status
if (error && typeof error === 'object' && 'status' in error)
return typeof error.status === 'number' ? error.status : undefined
if (error && typeof error === 'object' && 'data' in error) {
const data = error.data
if (data && typeof data === 'object' && 'status' in data)
return typeof data.status === 'number' ? data.status : undefined
}
}
function compareTaskRecency(left: DocumentProcessingTask, right: DocumentProcessingTask) {
if (taskVersionIsAfter(left.updatedAt, right.updatedAt)) return -1
if (taskVersionIsAfter(right.updatedAt, left.updatedAt)) return 1
return right.id.localeCompare(left.id)
}
function newestTasks(
tasks: DocumentProcessingTask[],
limit: number,
predicate: (task: DocumentProcessingTask) => boolean,
) {
const selected: DocumentProcessingTask[] = []
for (const task of tasks) {
if (!predicate(task)) continue
let low = 0
let high = selected.length
while (low < high) {
const middle = Math.floor((low + high) / 2)
if (compareTaskRecency(task, selected[middle]!) < 0) high = middle
else low = middle + 1
}
selected.splice(low, 0, task)
if (selected.length > limit) selected.pop()
}
return selected
}
export function ProcessingTasksDrawer({
canEdit,
actionResultsValid,
documentQueryError,
documentQueryFetching,
documents,
documentsPending,
hasNextDocumentPage,
hasNextTaskPage,
hasUnresolvedTaskDocuments,
isFetchingNextDocumentPage,
isFetchingNextTaskPage,
knowledgeSpaceId,
onLoadMoreDocuments,
onLoadMoreTasks,
onOpenChange,
onRefreshDocumentsAndTasks,
onRetryPermissionQuery,
onTaskUpdated,
onWritePermissionDenied,
open,
permissionQueryError,
permissionQueryFetching,
permissionQueryPending,
readOnlyReason,
taskQueryError,
taskQueryFetching,
taskQueryPending,
tasks,
taskProgressStore,
onRetryTaskQuery,
onRetryDocumentQuery,
}: {
canEdit: boolean
actionResultsValid: boolean
documentQueryError: boolean
documentQueryFetching: boolean
documents: LogicalDocument[]
documentsPending: boolean
hasNextDocumentPage: boolean
hasNextTaskPage: boolean
hasUnresolvedTaskDocuments: boolean
isFetchingNextDocumentPage: boolean
isFetchingNextTaskPage: boolean
knowledgeSpaceId: string
onLoadMoreDocuments: () => void
onLoadMoreTasks: () => void
onOpenChange: (open: boolean) => void
onRefreshDocumentsAndTasks: () => void
onRetryPermissionQuery: () => void
onTaskUpdated: (task: DocumentProcessingTask) => void
onWritePermissionDenied: () => void
open: boolean
permissionQueryError: boolean
permissionQueryFetching: boolean
permissionQueryPending: boolean
readOnlyReason?: string
taskQueryError: boolean
taskQueryFetching: boolean
taskQueryPending: boolean
tasks: DocumentProcessingTask[]
taskProgressStore: TaskProgressStore
onRetryDocumentQuery: () => void
onRetryTaskQuery: () => void
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const { formatTimeFromNow } = useFormatTimeFromNow()
const cancelTask = useMutation(
consoleQuery.knowledgeFs.deleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId.mutationOptions(),
)
const retryTask = useMutation(
consoleQuery.knowledgeFs.postKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetry.mutationOptions(),
)
const pendingActionsRef = useRef(new Set<string>())
const drawerCloseButtonRef = useRef<HTMLButtonElement>(null)
const taskQueryRetryButtonRef = useRef<HTMLButtonElement>(null)
const documentQueryRetryButtonRef = useRef<HTMLButtonElement>(null)
const permissionQueryRetryButtonRef = useRef<HTMLButtonElement>(null)
const focusedTaskActionRef = useRef<HTMLButtonElement | null>(null)
const loadMoreRequestedRef = useRef(false)
const queryRetryFocusRequestedRef = useRef(false)
const permissionRetryFocusRequestedRef = useRef(false)
const loadMoreButtonRef = useRef<HTMLButtonElement>(null)
const openCycleRef = useRef(0)
const openRef = useRef(open)
const actionResultsValidRef = useRef(actionResultsValid)
const previousOpenRef = useRef(open)
const [pendingActions, setPendingActions] = useState<Set<string>>(() => new Set())
const [actionErrors, setActionErrors] = useState<Record<string, string>>({})
const [visibleTaskLimit, setVisibleTaskLimit] = useState(TASK_DRAWER_LIMIT)
useSyncExternalStore(
open ? taskProgressStore.subscribe : noopSubscribe,
taskProgressStore.getSnapshot,
taskProgressStore.getSnapshot,
)
const documentTitles = useMemo(
() => new Map(documents.map((document) => [document.id, document.title])),
[documents],
)
const orderedBaseTasks = useMemo(() => {
if (!open) return []
const reservedLimit = Math.min(TASK_DRAWER_LIMIT / 2, visibleTaskLimit)
const retryableTasks = newestTasks(tasks, reservedLimit, taskCanRetry)
const activeTasks = newestTasks(tasks, reservedLimit, taskIsActive)
const attentionTaskIds = new Set([
...retryableTasks.map((task) => task.id),
...activeTasks.map((task) => task.id),
])
const remainingAttentionTasks = newestTasks(
tasks,
visibleTaskLimit - attentionTaskIds.size,
(task) => (taskCanRetry(task) || taskIsActive(task)) && !attentionTaskIds.has(task.id),
)
for (const task of remainingAttentionTasks) attentionTaskIds.add(task.id)
const terminalTasks = newestTasks(
tasks,
visibleTaskLimit - attentionTaskIds.size,
(task) => !attentionTaskIds.has(task.id),
)
return [...retryableTasks, ...activeTasks, ...remainingAttentionTasks, ...terminalTasks].sort(
compareTaskRecency,
)
}, [open, tasks, visibleTaskLimit])
const hasMoreTasks =
open &&
(tasks.length > orderedBaseTasks.length ||
hasNextTaskPage ||
(hasUnresolvedTaskDocuments && hasNextDocumentPage))
const orderedTasks = orderedBaseTasks.map((task) => {
const progress = taskProgressStore.get(task.id)
if (!progress || !taskIsActive(task) || taskVersionIsAfter(task.updatedAt, progress.updatedAt))
return task
return {
...task,
errorCode: undefined,
errorMessage: undefined,
...progress,
}
})
const activeActionCount = orderedTasks.filter(taskIsActive).length
const retryActionCount = orderedTasks.filter(taskCanRetry).length
const taskLifecycles = useMemo(
() => new Map(tasks.map((task) => [task.id, taskLifecycle(task)])),
[tasks],
)
const taskLifecycleGenerationsRef = useRef(
new Map<string, { generation: number; lifecycle: string }>(),
)
useLayoutEffect(() => {
actionResultsValidRef.current = actionResultsValid
const currentTaskIds = new Set(tasks.map((task) => task.id))
for (const task of tasks) {
const lifecycle = taskLifecycle(task)
const previous = taskLifecycleGenerationsRef.current.get(task.id)
if (previous?.lifecycle === lifecycle) continue
taskLifecycleGenerationsRef.current.set(task.id, {
generation: (previous?.generation ?? 0) + 1,
lifecycle,
})
}
for (const taskId of taskLifecycleGenerationsRef.current.keys()) {
if (!currentTaskIds.has(taskId)) taskLifecycleGenerationsRef.current.delete(taskId)
}
}, [actionResultsValid, tasks])
useEffect(() => {
openRef.current = open
const wasOpen = previousOpenRef.current
previousOpenRef.current = open
if (!wasOpen || open) return
openCycleRef.current += 1
loadMoreRequestedRef.current = false
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Every committed controlled close resets drawer-local pagination and errors.
setVisibleTaskLimit(TASK_DRAWER_LIMIT)
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Every committed controlled close starts a fresh action-error cycle.
setActionErrors({})
}, [open])
useEffect(() => {
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Task lifecycle changes retire action errors from older task versions.
setActionErrors((current) => {
const staleTaskIds = Object.keys(current).filter(
(taskId) => taskLifecycles.get(taskId) !== current[taskId],
)
if (!staleTaskIds.length) return current
const next = { ...current }
for (const taskId of staleTaskIds) delete next[taskId]
return next
})
}, [taskLifecycles])
useEffect(() => {
if (!open) return
// oxlint-disable-next-line eslint-react/set-state-in-effect -- A newly opened drawer starts a fresh action-error cycle.
setActionErrors({})
}, [open])
useEffect(() => {
if (!open || hasMoreTasks || !loadMoreRequestedRef.current) return
loadMoreRequestedRef.current = false
drawerCloseButtonRef.current?.focus()
}, [hasMoreTasks, open])
useEffect(() => {
const queryRetryVisible = taskQueryError || (documentQueryError && hasUnresolvedTaskDocuments)
if (!open || !queryRetryFocusRequestedRef.current) return
if (queryRetryVisible) {
if (taskQueryError) taskQueryRetryButtonRef.current?.focus()
else documentQueryRetryButtonRef.current?.focus()
return
}
queryRetryFocusRequestedRef.current = false
drawerCloseButtonRef.current?.focus()
}, [documentQueryError, hasUnresolvedTaskDocuments, open, taskQueryError])
useEffect(() => {
if (!open || !permissionRetryFocusRequestedRef.current) return
if (permissionQueryError) {
permissionQueryRetryButtonRef.current?.focus()
return
}
permissionRetryFocusRequestedRef.current = false
drawerCloseButtonRef.current?.focus()
}, [open, permissionQueryError])
useEffect(() => {
const focusedAction = focusedTaskActionRef.current
if (!open || !focusedAction || focusedAction.isConnected) return
focusedTaskActionRef.current = null
drawerCloseButtonRef.current?.focus()
}, [open, orderedTasks])
const performAction = async (task: DocumentProcessingTask, action: TaskAction) => {
if (!canEdit || pendingActionsRef.current.has(task.id)) return
pendingActionsRef.current.add(task.id)
const actionOpenCycle = openCycleRef.current
const actionLifecycleGeneration = taskLifecycleGenerationsRef.current.get(task.id)?.generation
const actionFocusTarget = document.activeElement
setPendingActions((current) => new Set(current).add(task.id))
setActionErrors((current) => {
const next = { ...current }
delete next[task.id]
return next
})
try {
const input = {
params: {
documentId: task.documentId,
id: knowledgeSpaceId,
taskId: task.id,
},
}
const updated =
action === 'cancel'
? await cancelTask.mutateAsync(input)
: await retryTask.mutateAsync(input)
if (
!actionResultsValidRef.current ||
taskLifecycleGenerationsRef.current.get(task.id)?.generation !== actionLifecycleGeneration
)
return
onTaskUpdated(updated)
setActionErrors((current) => {
const next = { ...current }
delete next[task.id]
return next
})
if (
actionResultsValidRef.current &&
openRef.current &&
openCycleRef.current === actionOpenCycle &&
document.activeElement === actionFocusTarget
)
drawerCloseButtonRef.current?.focus()
} catch (error) {
const permissionDenied = responseStatus(error) === 403
if (permissionDenied) onWritePermissionDenied()
if (
!permissionDenied &&
actionResultsValidRef.current &&
openRef.current &&
openCycleRef.current === actionOpenCycle &&
taskLifecycleGenerationsRef.current.get(task.id)?.generation === actionLifecycleGeneration
)
setActionErrors((current) => ({ ...current, [task.id]: taskLifecycle(task) }))
} finally {
pendingActionsRef.current.delete(task.id)
setPendingActions((current) => {
const next = new Set(current)
next.delete(task.id)
return next
})
if (actionResultsValidRef.current) onRefreshDocumentsAndTasks()
}
}
return (
<Drawer
open={open}
modal
swipeDirection="right"
onOpenChange={(nextOpen) => {
openRef.current = nextOpen
onOpenChange(nextOpen)
}}
>
<DrawerPortal>
<DrawerBackdrop />
<DrawerViewport>
<DrawerPopup className="data-[swipe-direction=right]:w-[440px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)]">
<DrawerContent className="flex min-h-0 flex-1 flex-col bg-components-panel-bg p-0 pb-0">
<header className="shrink-0 pt-[calc(1.25rem+env(safe-area-inset-top,0px))] pr-[calc(1.5rem+env(safe-area-inset-right,0px))] pb-4 pl-[calc(1.5rem+env(safe-area-inset-left,0px))]">
<div className="flex items-center justify-between gap-3">
<DrawerTitle className="system-md-semibold text-text-primary">
{t(($) => $['newKnowledge.backgroundTasks'])}
</DrawerTitle>
<DrawerCloseButton
ref={drawerCloseButtonRef}
aria-label={tCommon(($) => $['operation.close'])}
className="size-6 rounded-md"
/>
</div>
<DrawerDescription className="mt-1 system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.backgroundTasksDescription'])}
</DrawerDescription>
{readOnlyReason && (
<p
className="mt-2 inline-flex items-center gap-1.5 system-xs-regular text-text-warning"
role="status"
>
<span aria-hidden className="i-ri-lock-line size-3.5" />
{readOnlyReason}
</p>
)}
{permissionQueryPending && (
<p className="mt-2 system-xs-regular text-text-tertiary" role="status">
{t(($) => $['newKnowledge.permission'])}
{' · '}
{tCommon(($) => $.loading)}
</p>
)}
</header>
<div className="min-h-0 flex-1 overflow-y-auto pr-[calc(1.5rem+env(safe-area-inset-right,0px))] pb-[calc(1.5rem+env(safe-area-inset-bottom,0px))] pl-[calc(1.5rem+env(safe-area-inset-left,0px))]">
{permissionQueryError && (
<div className="mb-3 rounded-xl border border-divider-regular p-4" role="alert">
<p className="system-xs-regular text-text-destructive">
{t(($) => $['newKnowledge.permissionLoadFailed'])}
</p>
<Button
ref={permissionQueryRetryButtonRef}
aria-label={`${tCommon(($) => $['operation.retry'])} · ${t(($) => $['newKnowledge.permissionLoadFailed'])}`}
aria-busy={permissionQueryFetching}
className="mt-3"
loading={permissionQueryFetching}
size="small"
onBlur={(event) => {
if (event.relatedTarget) permissionRetryFocusRequestedRef.current = false
}}
onClick={() => {
permissionRetryFocusRequestedRef.current = true
onRetryPermissionQuery()
}}
>
{tCommon(($) => $['operation.retry'])}
</Button>
</div>
)}
{taskQueryError && (
<div className="mb-3 rounded-xl border border-divider-regular p-4" role="alert">
<p className="system-xs-regular text-text-destructive">
{t(($) => $['newKnowledge.tasksErrorDescription'])}
</p>
<Button
ref={taskQueryRetryButtonRef}
aria-label={`${tCommon(($) => $['operation.retry'])} · ${t(($) => $['newKnowledge.tasksErrorDescription'])}`}
aria-busy={taskQueryFetching}
className="mt-3"
loading={taskQueryFetching}
size="small"
onBlur={(event) => {
if (event.relatedTarget) queryRetryFocusRequestedRef.current = false
}}
onClick={() => {
queryRetryFocusRequestedRef.current = true
onRetryTaskQuery()
}}
>
{tCommon(($) => $['operation.retry'])}
</Button>
</div>
)}
{documentQueryError && hasUnresolvedTaskDocuments && (
<div className="mb-3 rounded-xl border border-divider-regular p-4" role="alert">
<p className="system-xs-regular text-text-destructive">
{t(($) => $['newKnowledge.documentsErrorDescription'])}
</p>
<Button
ref={documentQueryRetryButtonRef}
aria-label={`${tCommon(($) => $['operation.retry'])} · ${t(($) => $['newKnowledge.documentsErrorDescription'])}`}
aria-busy={documentQueryFetching}
className="mt-3"
loading={documentQueryFetching}
size="small"
onBlur={(event) => {
if (event.relatedTarget) queryRetryFocusRequestedRef.current = false
}}
onClick={() => {
queryRetryFocusRequestedRef.current = true
onRetryDocumentQuery()
}}
>
{tCommon(($) => $['operation.retry'])}
</Button>
</div>
)}
{taskQueryPending && !orderedTasks.length ? (
<div className="flex min-h-40 items-center justify-center">
<Loading />
</div>
) : orderedTasks.length ? (
<ul className="divide-y divide-divider-subtle">
{orderedTasks.map((task) => {
const title =
documentTitles.get(task.documentId) ??
(documentsPending
? t(($) => $['newKnowledge.documentColumn'])
: task.documentId)
const timestamp = Date.parse(
taskIsActive(task) ? task.createdAt : taskTime(task),
)
const taskError = task.errorMessage ?? task.errorCode
const actionTarget = `${documentTitles.get(task.documentId) ?? task.documentId} · ${task.id}`
return (
<li key={task.id} className="flex min-h-[62px] items-center gap-2.5 py-3.5">
<span
aria-hidden
className={
task.state === 'failed'
? 'i-ri-error-warning-fill size-4 shrink-0 text-text-destructive'
: taskIsActive(task)
? 'i-ri-loader-2-line size-4 shrink-0 animate-spin text-text-accent motion-reduce:animate-none'
: task.state === 'succeeded'
? 'i-ri-check-line size-4 shrink-0 text-text-success'
: 'i-ri-indeterminate-circle-line size-4 shrink-0 text-text-tertiary'
}
/>
<div className="min-w-0 flex-1">
<p className="truncate system-xs-medium text-text-primary">
{t(($) => $['newKnowledge.processDocument'], { name: title })}
</p>
<p className="mt-0.5 truncate system-2xs-regular text-text-tertiary">
{t(($) => $[`newKnowledge.processingTaskState.${task.state}`], {
progress: task.progressPercent,
})}
{!Number.isNaN(timestamp) && (
<>
<span aria-hidden> · </span>
{formatTimeFromNow(timestamp)}
</>
)}
</p>
{taskError && (
<p className="mt-1 system-2xs-regular break-words whitespace-pre-wrap text-text-destructive">
{taskError}
</p>
)}
{actionErrors[task.id] === taskLifecycle(task) && (
<p
className="mt-1 system-2xs-regular text-text-destructive"
role="alert"
>
{t(($) => $['newKnowledge.taskActionFailed'])}
</p>
)}
</div>
{canEdit && taskIsActive(task) ? (
<Button
aria-label={
activeActionCount > 1
? `${t(($) => $['newKnowledge.interruptTask'])} · ${actionTarget}`
: undefined
}
size="small"
aria-busy={pendingActions.has(task.id)}
disabled={pendingActions.has(task.id)}
loading={pendingActions.has(task.id)}
onBlur={(event) => {
if (event.relatedTarget) focusedTaskActionRef.current = null
}}
onFocus={(event) => {
focusedTaskActionRef.current = event.currentTarget
}}
onClick={() => void performAction(task, 'cancel')}
>
{t(($) => $['newKnowledge.interruptTask'])}
</Button>
) : canEdit && taskCanRetry(task) ? (
<Button
aria-label={
retryActionCount > 1
? `${t(($) => $['newKnowledge.retryTask'])} · ${actionTarget}`
: undefined
}
size="small"
aria-busy={pendingActions.has(task.id)}
disabled={pendingActions.has(task.id)}
loading={pendingActions.has(task.id)}
onBlur={(event) => {
if (event.relatedTarget) focusedTaskActionRef.current = null
}}
onFocus={(event) => {
focusedTaskActionRef.current = event.currentTarget
}}
onClick={() => void performAction(task, 'retry')}
>
{t(($) => $['newKnowledge.retryTask'])}
</Button>
) : null}
</li>
)
})}
</ul>
) : !taskQueryError && !hasMoreTasks ? (
<p className="py-16 text-center system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.noBackgroundTasks'])}
</p>
) : null}
{hasMoreTasks && (
<div className="mt-4 flex justify-center">
<Button
ref={loadMoreButtonRef}
aria-busy={isFetchingNextTaskPage || isFetchingNextDocumentPage}
loading={isFetchingNextTaskPage || isFetchingNextDocumentPage}
onBlur={() => {
loadMoreRequestedRef.current = false
}}
onClick={() => {
loadMoreRequestedRef.current =
document.activeElement === loadMoreButtonRef.current
if (tasks.length <= orderedBaseTasks.length && hasNextTaskPage)
onLoadMoreTasks()
if (hasUnresolvedTaskDocuments && hasNextDocumentPage) onLoadMoreDocuments()
setVisibleTaskLimit((current) => current + TASK_DRAWER_LIMIT)
}}
>
{t(($) => $['newKnowledge.loadMore'])}
</Button>
</div>
)}
</div>
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
)
}