'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()) const drawerCloseButtonRef = useRef(null) const taskQueryRetryButtonRef = useRef(null) const documentQueryRetryButtonRef = useRef(null) const permissionQueryRetryButtonRef = useRef(null) const focusedTaskActionRef = useRef(null) const loadMoreRequestedRef = useRef(false) const queryRetryFocusRequestedRef = useRef(false) const permissionRetryFocusRequestedRef = useRef(false) const loadMoreButtonRef = useRef(null) const openCycleRef = useRef(0) const openRef = useRef(open) const actionResultsValidRef = useRef(actionResultsValid) const previousOpenRef = useRef(open) const [pendingActions, setPendingActions] = useState>(() => new Set()) const [actionErrors, setActionErrors] = useState>({}) 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(), ) 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 ( { openRef.current = nextOpen onOpenChange(nextOpen) }} >
{t(($) => $['newKnowledge.backgroundTasks'])} $['operation.close'])} className="size-6 rounded-md" />
{t(($) => $['newKnowledge.backgroundTasksDescription'])} {readOnlyReason && (

{readOnlyReason}

)} {permissionQueryPending && (

{t(($) => $['newKnowledge.permission'])} {' · '} {tCommon(($) => $.loading)}

)}
{permissionQueryError && (

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

)} {taskQueryError && (

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

)} {documentQueryError && hasUnresolvedTaskDocuments && (

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

)} {taskQueryPending && !orderedTasks.length ? (
) : orderedTasks.length ? (
    {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 (
  • {t(($) => $['newKnowledge.processDocument'], { name: title })}

    {t(($) => $[`newKnowledge.processingTaskState.${task.state}`], { progress: task.progressPercent, })} {!Number.isNaN(timestamp) && ( <> · {formatTimeFromNow(timestamp)} )}

    {taskError && (

    {taskError}

    )} {actionErrors[task.id] === taskLifecycle(task) && (

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

    )}
    {canEdit && taskIsActive(task) ? ( ) : canEdit && taskCanRetry(task) ? ( ) : null}
  • ) })}
) : !taskQueryError && !hasMoreTasks ? (

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

) : null} {hasMoreTasks && (
)}
) }