mirror of
https://github.com/langgenius/dify.git
synced 2026-07-29 08:19:34 +08:00
2231 lines
92 KiB
TypeScript
2231 lines
92 KiB
TypeScript
'use client'
|
|
|
|
import type { DocumentProcessingTask } from '@dify/contracts/knowledge-fs/types.gen'
|
|
import type {
|
|
ProcessingTaskEvent,
|
|
ProcessingTaskProgressEvent,
|
|
} from './services/processing-task-events'
|
|
import { Button } from '@langgenius/dify-ui/button'
|
|
import { cn } from '@langgenius/dify-ui/cn'
|
|
import { toast } from '@langgenius/dify-ui/toast'
|
|
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { useAtomValue, useSetAtom } from 'jotai'
|
|
import { debounce, parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs'
|
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import Loading from '@/app/components/base/loading'
|
|
import {
|
|
datasetDefaultPermissionKeysAtom,
|
|
refreshWorkspacePermissionKeysAfterMutationDenialAtom,
|
|
retryWorkspacePermissionKeysAtom,
|
|
workspacePermissionKeysErrorAtom,
|
|
workspacePermissionKeysFetchingAtom,
|
|
workspacePermissionKeysLoadingAtom,
|
|
} from '@/context/permission-state'
|
|
import { consoleClient, consoleQuery } from '@/service/client'
|
|
import { DatasetACLPermission, hasPermission } from '@/utils/permission'
|
|
import { useAuxiliaryTaskReadGuard } from './auxiliary-task-read-guard'
|
|
import { DocumentBulkActions, DocumentsEmpty, DocumentsList } from './document-list'
|
|
import {
|
|
ACTIVE_TASK_STATES,
|
|
documentDisplayStatus,
|
|
newestTaskByDocument,
|
|
sourceName,
|
|
taskIsActive,
|
|
taskNeedsAttention,
|
|
taskVersionIsAfter,
|
|
} from './document-model'
|
|
import { DOCUMENT_UPLOAD_ACCEPT, documentUploadIssue } from './document-upload-policy'
|
|
import { ProcessingTasksDrawer } from './processing-tasks-drawer'
|
|
import { newKnowledgeDocumentDetailPath } from './routes'
|
|
import { TaskEventObserver } from './task-event-observer'
|
|
import { createTaskProgressStore } from './task-progress-store'
|
|
import { useQueryDataUpdateCount } from './use-query-data-update-count'
|
|
|
|
const DOCUMENT_PAGE_SIZE = 50
|
|
const TASK_PAGE_SIZE = 100
|
|
const MAX_TASK_EVENT_STREAMS = 6
|
|
const MAX_AUTO_CURSOR_PAGES = 20
|
|
const FAILED_TASK_POLL_REQUEST_TIMEOUT = 3000
|
|
const TERMINAL_RECONCILIATION_REQUEST_TIMEOUT = 3000
|
|
const BLOCKED_ACTIVE_TASK_REFRESH_INTERVAL = 5000
|
|
const MAX_BLOCKED_ACTIVE_TASK_REFRESH_INTERVAL = 30000
|
|
const documentFilterParser = parseAsStringLiteral([
|
|
'all',
|
|
'ready',
|
|
'queued',
|
|
'processing',
|
|
'failed',
|
|
'disabled',
|
|
] as const)
|
|
.withDefault('all')
|
|
.withOptions({ history: 'push' })
|
|
const documentSearchParser = parseAsString.withDefault('').withOptions({
|
|
limitUrlUpdates: debounce(300),
|
|
})
|
|
|
|
const uploadExclusionReasonKey = {
|
|
batch_byte_limit_exceeded: 'batchLimit',
|
|
document_not_found: 'target',
|
|
file_count_limit_exceeded: 'countLimit',
|
|
file_too_large: 'fileSize',
|
|
invalid_file: 'fileType',
|
|
invalid_target: 'target',
|
|
processing_failed: 'processing',
|
|
quota_exceeded: 'quota',
|
|
revision_conflict: 'target',
|
|
unsupported_mime_type: 'fileType',
|
|
} as const
|
|
|
|
type UploadExclusionReasonKey =
|
|
(typeof uploadExclusionReasonKey)[keyof typeof uploadExclusionReasonKey]
|
|
|
|
type TerminalTaskPin = {
|
|
observedAt: string
|
|
taskListGeneration: number
|
|
}
|
|
|
|
type TrustedActiveOverride = {
|
|
taskListGeneration: number
|
|
updatedAt: string
|
|
}
|
|
|
|
type AuxiliaryTaskReadDenial = {
|
|
taskListGeneration: number
|
|
taskVersion: string
|
|
}
|
|
|
|
const DOCUMENT_PERMISSION_DENIED = 1
|
|
const TASK_PERMISSION_DENIED = 2
|
|
const SOURCE_PERMISSION_DENIED = 4
|
|
|
|
function recoveryQueryMaskForPermissionDenials(permissionDenialMask: number) {
|
|
let recoveryQueryMask = 0
|
|
if (permissionDenialMask & DOCUMENT_PERMISSION_DENIED)
|
|
recoveryQueryMask |= TASK_PERMISSION_DENIED | SOURCE_PERMISSION_DENIED
|
|
if (permissionDenialMask & TASK_PERMISSION_DENIED) recoveryQueryMask |= SOURCE_PERMISSION_DENIED
|
|
if (permissionDenialMask & SOURCE_PERMISSION_DENIED) recoveryQueryMask |= TASK_PERMISSION_DENIED
|
|
return recoveryQueryMask & ~permissionDenialMask
|
|
}
|
|
|
|
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 taskSnapshotErrorIsTransient(error: unknown) {
|
|
const status = responseStatus(error)
|
|
return status === undefined || status === 408 || status === 429 || status >= 500
|
|
}
|
|
|
|
function queryKeyMatchesKnowledgeSpace(queryKey: readonly unknown[], knowledgeSpaceId: string) {
|
|
const state = queryKey[1]
|
|
if (!state || typeof state !== 'object' || !('input' in state)) return false
|
|
const input = state.input
|
|
if (!input || typeof input !== 'object' || !('params' in input)) return false
|
|
const params = input.params
|
|
return Boolean(
|
|
params && typeof params === 'object' && 'id' in params && params.id === knowledgeSpaceId,
|
|
)
|
|
}
|
|
|
|
function normalizedTaskSnapshot(task: DocumentProcessingTask): DocumentProcessingTask {
|
|
return {
|
|
...task,
|
|
errorCode: task.errorCode,
|
|
errorMessage: task.errorMessage,
|
|
}
|
|
}
|
|
|
|
export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) {
|
|
const { t } = useTranslation('dataset')
|
|
const { t: tCommon } = useTranslation('common')
|
|
const queryClient = useQueryClient()
|
|
const datasetDefaultPermissionKeys = useAtomValue(datasetDefaultPermissionKeysAtom)
|
|
const workspacePermissionKeysLoading = useAtomValue(workspacePermissionKeysLoadingAtom)
|
|
const workspacePermissionKeysFetching = useAtomValue(workspacePermissionKeysFetchingAtom)
|
|
const workspacePermissionKeysError = useAtomValue(workspacePermissionKeysErrorAtom)
|
|
const retryWorkspacePermissionKeys = useSetAtom(retryWorkspacePermissionKeysAtom)
|
|
const refreshWorkspacePermissionKeysAfterMutationDenial = useSetAtom(
|
|
refreshWorkspacePermissionKeysAfterMutationDenialAtom,
|
|
)
|
|
const canEdit = hasPermission(datasetDefaultPermissionKeys, DatasetACLPermission.Edit)
|
|
const permissionPending = workspacePermissionKeysLoading
|
|
const permissionQueryError = Boolean(workspacePermissionKeysError)
|
|
const hasWorkspaceWritePermission = canEdit && !permissionPending && !permissionQueryError
|
|
const uploadInputRef = useRef<HTMLInputElement>(null)
|
|
const documentPermissionAlertRef = useRef<HTMLDivElement>(null)
|
|
const writePermissionFocusRecoveryRequestedRef = useRef(false)
|
|
const writePermissionFocusOriginRef = useRef<HTMLElement | null>(null)
|
|
const uploadPendingRef = useRef(false)
|
|
const reindexPendingRef = useRef(false)
|
|
const [filter, setFilter] = useQueryState('status', documentFilterParser)
|
|
const [search, setSearch] = useQueryState('query', documentSearchParser)
|
|
const [selectedDocumentIds, setSelectedDocumentIds] = useState<Set<string>>(() => new Set())
|
|
const [tasksOpen, setTasksOpen] = useState(false)
|
|
const [writePermissionRevoked, setWritePermissionRevoked] = useState(false)
|
|
const [writePermissionRecoveryGeneration, setWritePermissionRecoveryGeneration] = useState<
|
|
number | undefined
|
|
>()
|
|
const writePermissionDenialGenerationRef = useRef(0)
|
|
const writePermissionRecoveryFetchSeenRef = useRef(false)
|
|
const [blockingDependencyRetries, setBlockingDependencyRetries] = useState({
|
|
sources: false,
|
|
tasks: false,
|
|
})
|
|
const [taskStreamOffset, setTaskStreamOffset] = useState(0)
|
|
const failedTaskPollOffsetRef = useRef(0)
|
|
const [taskOverrides, setTaskOverrides] = useState<
|
|
Record<string, Partial<DocumentProcessingTask>>
|
|
>({})
|
|
const [terminalTaskPins, setTerminalTaskPins] = useState<Record<string, TerminalTaskPin>>({})
|
|
const [taskObserverGenerations, setTaskObserverGenerations] = useState<Record<string, number>>({})
|
|
const terminalReconciliationGenerationsRef = useRef(new Map<string, number>())
|
|
const failedTaskPollGenerationsRef = useRef(new Map<string, number>())
|
|
const blockedFailedTaskPollVersionsRef = useRef(new Map<string, string>())
|
|
const failedPollAuxiliaryDenialsRef = useRef(new Map<string, AuxiliaryTaskReadDenial>())
|
|
const terminalConfirmableAuxiliaryDenialsRef = useRef(new Map<string, AuxiliaryTaskReadDenial>())
|
|
const equalRetryListGenerationsRef = useRef(new Map<string, number>())
|
|
const terminalReconciliationTimeoutsRef = useRef(new Map<string, number>())
|
|
const terminalReconciliationControllersRef = useRef(new Map<string, AbortController>())
|
|
const pendingTerminalProgressRef = useRef(new Map<string, ProcessingTaskProgressEvent>())
|
|
const taskEventCursorsRef = useRef(new Map<string, string>())
|
|
const streamActiveOverrideVersionsRef = useRef(new Map<string, string>())
|
|
const trustedActiveOverrideVersionsRef = useRef(new Map<string, TrustedActiveOverride>())
|
|
const trustedOverrideListGenerationsRef = useRef(new Map<string, number>())
|
|
const taskProgressStoreRef = useRef<ReturnType<typeof createTaskProgressStore> | null>(null)
|
|
if (!taskProgressStoreRef.current) taskProgressStoreRef.current = createTaskProgressStore()
|
|
const taskProgressStore = taskProgressStoreRef.current
|
|
const [uploading, setUploading] = useState(false)
|
|
const [reindexing, setReindexing] = useState(false)
|
|
const { mutateAsync: uploadDocument } = useMutation(
|
|
consoleQuery.knowledgeFs.postKnowledgeSpacesByIdDocuments.mutationOptions(),
|
|
)
|
|
const { mutateAsync: bulkUploadDocuments } = useMutation(
|
|
consoleQuery.knowledgeFs.postKnowledgeSpacesByIdDocumentsBulk.mutationOptions(),
|
|
)
|
|
const { mutateAsync: reindexDocuments } = useMutation(
|
|
consoleQuery.knowledgeFs.postKnowledgeSpacesByIdDocumentsBulkReindex.mutationOptions(),
|
|
)
|
|
|
|
const documentsQuery = useInfiniteQuery(
|
|
consoleQuery.knowledgeFs.getKnowledgeSpacesByIdLogicalDocuments.infiniteOptions({
|
|
input: (pageParam) => ({
|
|
params: { id: knowledgeSpaceId },
|
|
query: {
|
|
limit: DOCUMENT_PAGE_SIZE,
|
|
...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
|
|
},
|
|
}),
|
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
initialPageParam: null as string | null,
|
|
}),
|
|
)
|
|
const documentPermissionDenied = responseStatus(documentsQuery.error) === 403
|
|
const refetchDocumentsQuery = documentsQuery.refetch
|
|
const {
|
|
deny: denyAuxiliaryTaskRead,
|
|
guard: auxiliaryTaskReadGuard,
|
|
permissionDenied: auxiliaryReadPermissionDenied,
|
|
retry: retryAuxiliaryTaskRead,
|
|
} = useAuxiliaryTaskReadGuard({
|
|
documentPermissionDenied,
|
|
refetchDocuments: refetchDocumentsQuery,
|
|
})
|
|
useEffect(() => {
|
|
if (!documentPermissionDenied) return
|
|
failedPollAuxiliaryDenialsRef.current.clear()
|
|
terminalConfirmableAuxiliaryDenialsRef.current.clear()
|
|
}, [documentPermissionDenied])
|
|
const tasksQueryOptions = useMemo(
|
|
() =>
|
|
consoleQuery.knowledgeFs.getKnowledgeSpacesByIdProcessingTasks.infiniteOptions({
|
|
enabled: !documentPermissionDenied,
|
|
input: (pageParam) => ({
|
|
params: { id: knowledgeSpaceId },
|
|
query: {
|
|
limit: TASK_PAGE_SIZE,
|
|
...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
|
|
},
|
|
}),
|
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
initialPageParam: null as string | null,
|
|
}),
|
|
[documentPermissionDenied, knowledgeSpaceId],
|
|
)
|
|
const tasksQuery = useInfiniteQuery(tasksQueryOptions)
|
|
const sourcesQuery = useInfiniteQuery(
|
|
consoleQuery.knowledgeFs.getKnowledgeSpacesByIdSources.infiniteOptions({
|
|
enabled: !documentPermissionDenied,
|
|
input: (pageParam) => ({
|
|
params: { id: knowledgeSpaceId },
|
|
query: {
|
|
limit: TASK_PAGE_SIZE,
|
|
...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
|
|
},
|
|
}),
|
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
initialPageParam: null as string | null,
|
|
}),
|
|
)
|
|
const permissionDenialMask =
|
|
(documentPermissionDenied || auxiliaryReadPermissionDenied ? DOCUMENT_PERMISSION_DENIED : 0) |
|
|
(responseStatus(tasksQuery.error) === 403 ? TASK_PERMISSION_DENIED : 0) |
|
|
(responseStatus(sourcesQuery.error) === 403 ? SOURCE_PERMISSION_DENIED : 0)
|
|
const permissionDenied = permissionDenialMask !== 0
|
|
const previousPermissionDenialMaskRef = useRef(permissionDenialMask)
|
|
const permissionRecoveryQueryMaskRef = useRef(
|
|
recoveryQueryMaskForPermissionDenials(permissionDenialMask),
|
|
)
|
|
const canWrite = hasWorkspaceWritePermission && !permissionDenied && !writePermissionRevoked
|
|
const documentWriteRestrictionReasonId = permissionPending
|
|
? 'documents-permission-pending'
|
|
: permissionQueryError
|
|
? 'documents-permission-error'
|
|
: !canEdit || writePermissionRevoked
|
|
? 'documents-readonly-reason'
|
|
: undefined
|
|
const documentsRecoveryDescription = auxiliaryReadPermissionDenied
|
|
? t(($) => $['newKnowledge.documentsPermissionDescription'])
|
|
: t(($) => $['newKnowledge.documentsErrorDescription'])
|
|
const documentsSectionRef = useRef<HTMLElement>(null)
|
|
const documentsTitleRef = useRef<HTMLHeadingElement>(null)
|
|
const documentSurfaceHadFocusRef = useRef(false)
|
|
const bulkActionsHadFocusRef = useRef(false)
|
|
const mainRetryFocusRequestedRef = useRef(false)
|
|
const permissionRetryButtonRef = useRef<HTMLButtonElement>(null)
|
|
const documentsRetryButtonRef = useRef<HTMLButtonElement>(null)
|
|
const dependencyRetryButtonRef = useRef<HTMLButtonElement>(null)
|
|
const {
|
|
fetchNextPage: fetchNextDocumentPage,
|
|
hasNextPage: hasNextDocumentPage,
|
|
isFetchNextPageError: isFetchNextDocumentPageError,
|
|
isFetchingNextPage: isFetchingNextDocumentPage,
|
|
} = documentsQuery
|
|
const {
|
|
fetchNextPage: fetchNextTaskPage,
|
|
hasNextPage: hasNextTaskPage,
|
|
isFetchNextPageError: isFetchNextTaskPageError,
|
|
isFetchingNextPage: isFetchingNextTaskPage,
|
|
refetch: refetchTasksQuery,
|
|
} = tasksQuery
|
|
const {
|
|
fetchNextPage: fetchNextSourcePage,
|
|
hasNextPage: hasNextSourcePage,
|
|
isFetchNextPageError: isFetchNextSourcePageError,
|
|
isFetchingNextPage: isFetchingNextSourcePage,
|
|
refetch: refetchSourcesQuery,
|
|
} = sourcesQuery
|
|
const canAutoFetchDocumentPage = Boolean(
|
|
hasNextDocumentPage && (documentsQuery.data?.pages.length ?? 0) < MAX_AUTO_CURSOR_PAGES,
|
|
)
|
|
const canAutoFetchTaskPage = Boolean(
|
|
hasNextTaskPage && (tasksQuery.data?.pages.length ?? 0) < MAX_AUTO_CURSOR_PAGES,
|
|
)
|
|
const documents = useMemo(
|
|
() => documentsQuery.data?.pages.flatMap((page) => page.items) ?? [],
|
|
[documentsQuery.data],
|
|
)
|
|
const baseTasks = useMemo(
|
|
() => tasksQuery.data?.pages.flatMap((page) => page.items) ?? [],
|
|
[tasksQuery.data],
|
|
)
|
|
const documentIds = useMemo(() => new Set(documents.map((document) => document.id)), [documents])
|
|
const unresolvedTaskDocumentIds = useMemo(
|
|
() =>
|
|
new Set(
|
|
baseTasks.flatMap((task) => (!documentIds.has(task.documentId) ? [task.documentId] : [])),
|
|
),
|
|
[baseTasks, documentIds],
|
|
)
|
|
const taskDataUpdateCount = useQueryDataUpdateCount(queryClient, tasksQueryOptions.queryKey)
|
|
const taskListSnapshotRef = useRef({
|
|
data: tasksQuery.data,
|
|
dataUpdateCount: taskDataUpdateCount,
|
|
dataUpdatedAt: tasksQuery.dataUpdatedAt,
|
|
})
|
|
const taskListGenerationRef = useRef(0)
|
|
const [taskListGeneration, setTaskListGeneration] = useState(0)
|
|
useLayoutEffect(() => {
|
|
if (
|
|
taskListSnapshotRef.current.dataUpdateCount === taskDataUpdateCount &&
|
|
taskListSnapshotRef.current.dataUpdatedAt === tasksQuery.dataUpdatedAt &&
|
|
taskListSnapshotRef.current.data === tasksQuery.data
|
|
)
|
|
return
|
|
taskListSnapshotRef.current = {
|
|
data: tasksQuery.data,
|
|
dataUpdateCount: taskDataUpdateCount,
|
|
dataUpdatedAt: tasksQuery.dataUpdatedAt,
|
|
}
|
|
taskListGenerationRef.current += 1
|
|
setTaskListGeneration(taskListGenerationRef.current)
|
|
}, [taskDataUpdateCount, tasksQuery.data, tasksQuery.dataUpdatedAt])
|
|
const baseTaskById = useMemo(() => new Map(baseTasks.map((task) => [task.id, task])), [baseTasks])
|
|
const sourceNames = useMemo(
|
|
() =>
|
|
new Map(
|
|
(sourcesQuery.data?.pages.flatMap((page) => page.items) ?? []).map((source) => [
|
|
source.id,
|
|
source.name,
|
|
]),
|
|
),
|
|
[sourcesQuery.data],
|
|
)
|
|
const unresolvedDocumentSourceIds = useMemo(
|
|
() =>
|
|
new Set(
|
|
documents.flatMap((document) =>
|
|
document.sourceId && !sourceNames.has(document.sourceId) ? [document.sourceId] : [],
|
|
),
|
|
),
|
|
[documents, sourceNames],
|
|
)
|
|
const hasRelevantNextSourcePage = Boolean(hasNextSourcePage && unresolvedDocumentSourceIds.size)
|
|
const isFetchingNextResultsPage = Boolean(
|
|
isFetchingNextDocumentPage ||
|
|
(hasNextTaskPage && isFetchingNextTaskPage) ||
|
|
(hasRelevantNextSourcePage && isFetchingNextSourcePage),
|
|
)
|
|
const canAutoFetchSourcePage = Boolean(
|
|
hasRelevantNextSourcePage && (sourcesQuery.data?.pages.length ?? 0) < MAX_AUTO_CURSOR_PAGES,
|
|
)
|
|
const disabledSourceIds = useMemo(
|
|
() =>
|
|
new Set(
|
|
(sourcesQuery.data?.pages.flatMap((page) => page.items) ?? [])
|
|
.filter((source) => source.status === 'disabled')
|
|
.map((source) => source.id),
|
|
),
|
|
[sourcesQuery.data],
|
|
)
|
|
const baseTaskByIdRef = useRef(baseTaskById)
|
|
useLayoutEffect(() => {
|
|
baseTaskByIdRef.current = baseTaskById
|
|
}, [baseTaskById])
|
|
const tasks = useMemo(
|
|
() =>
|
|
baseTasks.map((task) => {
|
|
const override = taskOverrides[task.id]
|
|
const terminalTaskPin = terminalTaskPins[task.id]
|
|
if (
|
|
terminalTaskPin &&
|
|
override &&
|
|
taskIsActive(task) &&
|
|
!taskVersionIsAfter(task.updatedAt, terminalTaskPin.observedAt)
|
|
)
|
|
return { ...task, ...override }
|
|
if (!override?.updatedAt) return override ? { ...task, ...override } : task
|
|
if (taskVersionIsAfter(task.updatedAt, override.updatedAt)) return task
|
|
const mergedTask = { ...task, ...override }
|
|
if (
|
|
!taskIsActive(task) &&
|
|
taskIsActive(mergedTask) &&
|
|
!taskVersionIsAfter(override.updatedAt, task.updatedAt) &&
|
|
streamActiveOverrideVersionsRef.current.get(task.id) === override.updatedAt
|
|
)
|
|
return task
|
|
return mergedTask
|
|
}),
|
|
[baseTasks, taskOverrides, terminalTaskPins],
|
|
)
|
|
const effectiveTaskById = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks])
|
|
useEffect(() => {
|
|
for (const task of tasks) {
|
|
if (!taskIsActive(task)) {
|
|
taskEventCursorsRef.current.delete(task.id)
|
|
streamActiveOverrideVersionsRef.current.delete(task.id)
|
|
trustedActiveOverrideVersionsRef.current.delete(task.id)
|
|
trustedOverrideListGenerationsRef.current.delete(task.id)
|
|
}
|
|
}
|
|
}, [tasks])
|
|
|
|
useEffect(() => {
|
|
const clearedActiveTaskIds: string[] = []
|
|
for (const task of baseTasks) {
|
|
const denial = terminalConfirmableAuxiliaryDenialsRef.current.get(task.id)
|
|
if (!denial || taskListGeneration <= denial.taskListGeneration) continue
|
|
if (taskIsActive(task)) {
|
|
if (!taskVersionIsAfter(task.updatedAt, denial.taskVersion)) continue
|
|
auxiliaryTaskReadGuard.clearTask(task.id)
|
|
terminalConfirmableAuxiliaryDenialsRef.current.delete(task.id)
|
|
continue
|
|
}
|
|
if (!auxiliaryTaskReadGuard.clearThrough(task.id, task.updatedAt)) continue
|
|
terminalConfirmableAuxiliaryDenialsRef.current.delete(task.id)
|
|
const effectiveTask = effectiveTaskById.get(task.id)
|
|
if (effectiveTask && taskIsActive(effectiveTask)) clearedActiveTaskIds.push(task.id)
|
|
}
|
|
if (!clearedActiveTaskIds.length) return
|
|
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Clearing a committed terminal guard must remount effective active observers.
|
|
setTaskObserverGenerations((current) => {
|
|
const next = { ...current }
|
|
for (const taskId of clearedActiveTaskIds) next[taskId] = (next[taskId] ?? 0) + 1
|
|
return next
|
|
})
|
|
}, [auxiliaryTaskReadGuard, baseTasks, effectiveTaskById, taskListGeneration])
|
|
const currentTaskStateRef = useRef(new Map(tasks.map((task) => [task.id, task.state])))
|
|
const currentTaskVersionRef = useRef(new Map(tasks.map((task) => [task.id, task.updatedAt])))
|
|
useLayoutEffect(() => {
|
|
const currentTaskIds = new Set(tasks.map((task) => task.id))
|
|
for (const task of tasks) {
|
|
const currentVersion = currentTaskVersionRef.current.get(task.id)
|
|
if (!currentVersion || !taskVersionIsAfter(currentVersion, task.updatedAt)) {
|
|
currentTaskStateRef.current.set(task.id, task.state)
|
|
currentTaskVersionRef.current.set(task.id, task.updatedAt)
|
|
}
|
|
}
|
|
for (const taskId of currentTaskVersionRef.current.keys()) {
|
|
if (currentTaskIds.has(taskId)) continue
|
|
currentTaskStateRef.current.delete(taskId)
|
|
currentTaskVersionRef.current.delete(taskId)
|
|
}
|
|
}, [tasks])
|
|
|
|
const taskByDocument = useMemo(() => newestTaskByDocument(tasks), [tasks])
|
|
const documentStatuses = useMemo(
|
|
() =>
|
|
new Map(
|
|
documents.map((document) => [
|
|
document.id,
|
|
documentDisplayStatus(
|
|
document,
|
|
taskByDocument.get(document.id),
|
|
Boolean(
|
|
document.sourceId &&
|
|
(disabledSourceIds.has(document.sourceId) ||
|
|
(!hasNextSourcePage &&
|
|
!sourcesQuery.error &&
|
|
!sourcesQuery.isFetchNextPageError &&
|
|
!sourceNames.has(document.sourceId))),
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
[
|
|
disabledSourceIds,
|
|
documents,
|
|
hasNextSourcePage,
|
|
sourceNames,
|
|
sourcesQuery.error,
|
|
sourcesQuery.isFetchNextPageError,
|
|
taskByDocument,
|
|
],
|
|
)
|
|
const filterActive = filter !== 'all' || Boolean(search.trim())
|
|
const statusFilterActive = filter !== 'all'
|
|
const availableDocumentIds = useMemo(
|
|
() =>
|
|
new Set(
|
|
documents
|
|
.filter((document) => documentStatuses.get(document.id) !== 'disabled')
|
|
.map((document) => document.id),
|
|
),
|
|
[documents, documentStatuses],
|
|
)
|
|
const validSelectedDocumentIds = useMemo(
|
|
() =>
|
|
new Set(
|
|
[...selectedDocumentIds].filter((documentId) => availableDocumentIds.has(documentId)),
|
|
),
|
|
[availableDocumentIds, selectedDocumentIds],
|
|
)
|
|
const filteredDocuments = useMemo(() => {
|
|
const normalizedSearch = search.trim().toLocaleLowerCase()
|
|
return documents.filter((document) => {
|
|
const status = documentStatuses.get(document.id)
|
|
if (filter !== 'all' && status !== filter) return false
|
|
if (!normalizedSearch) return true
|
|
const resolvedSourceName =
|
|
(document.sourceId && sourceNames.get(document.sourceId)) ?? sourceName(document)
|
|
return `${document.title} ${resolvedSourceName ?? ''}`
|
|
.toLocaleLowerCase()
|
|
.includes(normalizedSearch)
|
|
})
|
|
}, [documentStatuses, documents, filter, search, sourceNames])
|
|
const completingFilteredResults =
|
|
filterActive &&
|
|
!documentsQuery.isFetchNextPageError &&
|
|
(canAutoFetchDocumentPage || documentsQuery.isFetchingNextPage)
|
|
const filteredResultsIncomplete = Boolean(
|
|
(filterActive &&
|
|
(hasNextDocumentPage ||
|
|
documentsQuery.isFetchingNextPage ||
|
|
documentsQuery.isFetchNextPageError)) ||
|
|
(statusFilterActive &&
|
|
(hasNextTaskPage || tasksQuery.isFetchingNextPage || tasksQuery.isFetchNextPageError)) ||
|
|
(filterActive &&
|
|
unresolvedDocumentSourceIds.size > 0 &&
|
|
(hasNextSourcePage || sourcesQuery.isFetchingNextPage || sourcesQuery.isFetchNextPageError)),
|
|
)
|
|
const taskQueryBlockingError = Boolean(
|
|
!tasksQuery.data && (tasksQuery.error || blockingDependencyRetries.tasks),
|
|
)
|
|
const sourceQueryBlockingError = Boolean(
|
|
!sourcesQuery.data && (sourcesQuery.error || blockingDependencyRetries.sources),
|
|
)
|
|
const dependencyQueryBlockingError = taskQueryBlockingError || sourceQueryBlockingError
|
|
const dependencyQueryWarning = Boolean(
|
|
(tasksQuery.error && tasksQuery.data) ||
|
|
tasksQuery.isFetchNextPageError ||
|
|
(sourcesQuery.error && sourcesQuery.data) ||
|
|
sourcesQuery.isFetchNextPageError,
|
|
)
|
|
const taskResultsIncomplete = Boolean(
|
|
!tasksQuery.data || tasksQuery.isPending || tasksQuery.isFetchingNextPage,
|
|
)
|
|
const sourceResultsIncomplete = Boolean(
|
|
!sourcesQuery.data ||
|
|
sourcesQuery.isPending ||
|
|
(unresolvedDocumentSourceIds.size &&
|
|
(hasNextSourcePage ||
|
|
sourcesQuery.error ||
|
|
sourcesQuery.isFetchingNextPage ||
|
|
sourcesQuery.isFetchNextPageError)),
|
|
)
|
|
const sourceQueryWarning = Boolean(
|
|
(sourcesQuery.error && sourcesQuery.data) || sourcesQuery.isFetchNextPageError,
|
|
)
|
|
const taskQueryWarning = Boolean(
|
|
(tasksQuery.error && tasksQuery.data) || tasksQuery.isFetchNextPageError,
|
|
)
|
|
const documentQueryWarning = Boolean(documentsQuery.error && documentsQuery.data)
|
|
const dependencyRetryFetching = Boolean(
|
|
(taskQueryWarning && tasksQuery.isFetching) || (sourceQueryWarning && sourcesQuery.isFetching),
|
|
)
|
|
const blockingDependencyRetryFetching = Boolean(
|
|
(taskQueryBlockingError && tasksQuery.isFetching) ||
|
|
(sourceQueryBlockingError && sourcesQuery.isFetching),
|
|
)
|
|
const mainRecoveryVisible = Boolean(
|
|
permissionQueryError ||
|
|
documentsQuery.error ||
|
|
dependencyQueryBlockingError ||
|
|
dependencyQueryWarning,
|
|
)
|
|
const mainRecoveryIdentity = [
|
|
permissionQueryError ? 'permission' : '',
|
|
documentsQuery.error ? 'documents' : '',
|
|
taskQueryBlockingError ? 'tasks-blocking' : '',
|
|
sourceQueryBlockingError ? 'sources-blocking' : '',
|
|
taskQueryWarning ? 'tasks-warning' : '',
|
|
sourceQueryWarning ? 'sources-warning' : '',
|
|
].join(':')
|
|
const dependencyResultsIncomplete = taskResultsIncomplete || sourceResultsIncomplete
|
|
const selectionDisabled =
|
|
!canWrite ||
|
|
dependencyResultsIncomplete ||
|
|
documentQueryWarning ||
|
|
taskQueryWarning ||
|
|
(sourceQueryWarning && unresolvedDocumentSourceIds.size > 0) ||
|
|
filteredResultsIncomplete
|
|
const reindexUnavailableReason =
|
|
tasksQuery.error || tasksQuery.isFetchNextPageError
|
|
? t(($) => $['newKnowledge.tasksErrorDescription'])
|
|
: (sourcesQuery.error || sourcesQuery.isFetchNextPageError) &&
|
|
unresolvedDocumentSourceIds.size > 0
|
|
? t(($) => $['newKnowledge.sourcesErrorDescription'])
|
|
: documentsQuery.error || documentsQuery.isFetchNextPageError
|
|
? t(($) => $['newKnowledge.documentsErrorDescription'])
|
|
: dependencyResultsIncomplete
|
|
? tCommon(($) => $.loading)
|
|
: filteredResultsIncomplete
|
|
? t(($) => $['newKnowledge.partialDocumentResults'])
|
|
: undefined
|
|
const selectableFilteredDocuments = filteredDocuments.filter(
|
|
(document) => documentStatuses.get(document.id) !== 'disabled',
|
|
)
|
|
const attentionTasks = tasks.filter(taskNeedsAttention)
|
|
const hasTaskError = attentionTasks.some(
|
|
(task) => task.state === 'failed' || task.state === 'canceled',
|
|
)
|
|
const activeTasks = useMemo(() => tasks.filter(taskIsActive), [tasks])
|
|
const orderedActiveTasks = useMemo(
|
|
() =>
|
|
[...activeTasks].sort(
|
|
(left, right) =>
|
|
left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
|
|
),
|
|
[activeTasks],
|
|
)
|
|
const taskObserverVersion = (task: DocumentProcessingTask) => {
|
|
let latestVersion = task.updatedAt
|
|
for (const candidate of [
|
|
currentTaskVersionRef.current.get(task.id),
|
|
taskProgressStore.get(task.id)?.updatedAt,
|
|
]) {
|
|
if (candidate && taskVersionIsAfter(candidate, latestVersion)) latestVersion = candidate
|
|
}
|
|
return latestVersion
|
|
}
|
|
const streamedActiveTasks = (() => {
|
|
if (permissionDenied) return []
|
|
const streamableActiveTasks = orderedActiveTasks.filter(
|
|
(task) => !auxiliaryTaskReadGuard.isBlocked(task.id, taskObserverVersion(task)),
|
|
)
|
|
const streamCount = Math.min(MAX_TASK_EVENT_STREAMS, streamableActiveTasks.length)
|
|
if (!streamCount) return []
|
|
const offset = taskStreamOffset % streamableActiveTasks.length
|
|
return Array.from(
|
|
{ length: streamCount },
|
|
(_, index) => streamableActiveTasks[(offset + index) % streamableActiveTasks.length]!,
|
|
)
|
|
})()
|
|
const blockedActiveTaskSignature = orderedActiveTasks
|
|
.map((task) => [task.id, taskObserverVersion(task)] as const)
|
|
.filter(([taskId, taskVersion]) => auxiliaryTaskReadGuard.isBlocked(taskId, taskVersion))
|
|
.map(([taskId, taskVersion]) => `${taskId}:${taskVersion}`)
|
|
.join('|')
|
|
const orderedFailedTasks = useMemo(
|
|
() =>
|
|
tasks
|
|
.filter((task) => task.state === 'failed')
|
|
.sort((left, right) => {
|
|
if (taskVersionIsAfter(left.updatedAt, right.updatedAt)) return -1
|
|
if (taskVersionIsAfter(right.updatedAt, left.updatedAt)) return 1
|
|
return right.id.localeCompare(left.id)
|
|
}),
|
|
[tasks],
|
|
)
|
|
const orderedFailedTasksRef = useRef(orderedFailedTasks)
|
|
useLayoutEffect(() => {
|
|
orderedFailedTasksRef.current = orderedFailedTasks
|
|
}, [orderedFailedTasks])
|
|
const failedTaskPollSignature = orderedFailedTasks
|
|
.map((task) => `${task.id}:${task.updatedAt}`)
|
|
.join('|')
|
|
const incompleteTaskHistoryHint = hasNextTaskPage
|
|
? ` · ${t(($) => $['newKnowledge.taskHistoryIncomplete'])}`
|
|
: ''
|
|
const attentionTaskBadge =
|
|
attentionTasks.length || hasNextTaskPage
|
|
? `${attentionTasks.length}${hasNextTaskPage ? '+' : ''}`
|
|
: undefined
|
|
const tasksButtonLabel = `${
|
|
attentionTasks.length || hasNextTaskPage
|
|
? t(($) => $['newKnowledge.tasksWithAttention'], { count: attentionTasks.length })
|
|
: t(($) => $['newKnowledge.tasks'])
|
|
}${incompleteTaskHistoryHint}`
|
|
const tasksLiveStatus = `${
|
|
hasTaskError
|
|
? t(($) => $['newKnowledge.taskAttentionErrorCount'], { count: attentionTasks.length })
|
|
: attentionTasks.length || hasNextTaskPage
|
|
? t(($) => $['newKnowledge.taskAttentionCount'], { count: attentionTasks.length })
|
|
: t(($) => $['newKnowledge.taskAttentionClear'])
|
|
}${incompleteTaskHistoryHint}`
|
|
const allFilteredSelected =
|
|
selectableFilteredDocuments.length > 0 &&
|
|
selectableFilteredDocuments.every((document) => validSelectedDocumentIds.has(document.id))
|
|
const someFilteredSelected = selectableFilteredDocuments.some((document) =>
|
|
validSelectedDocumentIds.has(document.id),
|
|
)
|
|
const bulkActionsVisible = canWrite && validSelectedDocumentIds.size > 0
|
|
const previousBulkActionsVisibleRef = useRef(bulkActionsVisible)
|
|
|
|
useLayoutEffect(() => {
|
|
const wasVisible = previousBulkActionsVisibleRef.current
|
|
previousBulkActionsVisibleRef.current = bulkActionsVisible
|
|
if (!wasVisible || bulkActionsVisible || !bulkActionsHadFocusRef.current) return
|
|
if (permissionDenied) return
|
|
bulkActionsHadFocusRef.current = false
|
|
documentsTitleRef.current?.focus()
|
|
}, [bulkActionsVisible, permissionDenied])
|
|
|
|
useEffect(() => {
|
|
if (permissionDenied || orderedActiveTasks.length <= MAX_TASK_EVENT_STREAMS) return
|
|
const interval = window.setInterval(
|
|
() => setTaskStreamOffset((current) => current + MAX_TASK_EVENT_STREAMS),
|
|
5000,
|
|
)
|
|
return () => window.clearInterval(interval)
|
|
}, [orderedActiveTasks.length, permissionDenied])
|
|
|
|
useEffect(() => {
|
|
if (permissionDenied || !blockedActiveTaskSignature) return
|
|
let canceled = false
|
|
let refreshInterval = BLOCKED_ACTIVE_TASK_REFRESH_INTERVAL
|
|
let timeout: number | undefined
|
|
const refreshBlockedTasks = async () => {
|
|
if (canceled) return
|
|
await refetchTasksQuery({ cancelRefetch: false }).catch(() => undefined)
|
|
if (canceled) return
|
|
refreshInterval = Math.min(refreshInterval * 2, MAX_BLOCKED_ACTIVE_TASK_REFRESH_INTERVAL)
|
|
timeout = window.setTimeout(refreshBlockedTasks, refreshInterval)
|
|
}
|
|
timeout = window.setTimeout(refreshBlockedTasks, BLOCKED_ACTIVE_TASK_REFRESH_INTERVAL)
|
|
return () => {
|
|
canceled = true
|
|
if (timeout !== undefined) window.clearTimeout(timeout)
|
|
}
|
|
}, [blockedActiveTaskSignature, permissionDenied, refetchTasksQuery])
|
|
|
|
useLayoutEffect(() => {
|
|
if (!writePermissionRevoked || !writePermissionFocusRecoveryRequestedRef.current) return
|
|
writePermissionFocusRecoveryRequestedRef.current = false
|
|
writePermissionFocusOriginRef.current = null
|
|
documentsTitleRef.current?.focus()
|
|
}, [writePermissionRevoked])
|
|
|
|
useEffect(() => {
|
|
const previousPermissionDenialMask = previousPermissionDenialMaskRef.current
|
|
previousPermissionDenialMaskRef.current = permissionDenialMask
|
|
const addedPermissionDenials = permissionDenialMask & ~previousPermissionDenialMask
|
|
let recoveryQueryMask =
|
|
permissionRecoveryQueryMaskRef.current |
|
|
recoveryQueryMaskForPermissionDenials(addedPermissionDenials)
|
|
if (
|
|
previousPermissionDenialMask & TASK_PERMISSION_DENIED &&
|
|
!(permissionDenialMask & TASK_PERMISSION_DENIED)
|
|
)
|
|
recoveryQueryMask &= ~TASK_PERMISSION_DENIED
|
|
if (
|
|
previousPermissionDenialMask & SOURCE_PERMISSION_DENIED &&
|
|
!(permissionDenialMask & SOURCE_PERMISSION_DENIED)
|
|
)
|
|
recoveryQueryMask &= ~SOURCE_PERMISSION_DENIED
|
|
recoveryQueryMask &= ~permissionDenialMask
|
|
permissionRecoveryQueryMaskRef.current = recoveryQueryMask
|
|
if (previousPermissionDenialMask && !permissionDenied) {
|
|
blockedFailedTaskPollVersionsRef.current.clear()
|
|
if (recoveryQueryMask & TASK_PERMISSION_DENIED)
|
|
void refetchTasksQuery({ cancelRefetch: false })
|
|
if (recoveryQueryMask & SOURCE_PERMISSION_DENIED)
|
|
void refetchSourcesQuery({ cancelRefetch: false })
|
|
permissionRecoveryQueryMaskRef.current = 0
|
|
if (documentSurfaceHadFocusRef.current || bulkActionsHadFocusRef.current)
|
|
documentsTitleRef.current?.focus()
|
|
bulkActionsHadFocusRef.current = false
|
|
return
|
|
}
|
|
if (previousPermissionDenialMask || !permissionDenied) return
|
|
const shouldRestoreFocus =
|
|
tasksOpen || documentSurfaceHadFocusRef.current || bulkActionsHadFocusRef.current
|
|
if (tasksOpen) {
|
|
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Permission revocation permanently closes the controlled task drawer.
|
|
setTasksOpen(false)
|
|
}
|
|
if (shouldRestoreFocus) documentPermissionAlertRef.current?.focus()
|
|
bulkActionsHadFocusRef.current = false
|
|
}, [permissionDenialMask, permissionDenied, refetchSourcesQuery, refetchTasksQuery, tasksOpen])
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!writePermissionRevoked ||
|
|
writePermissionRecoveryGeneration !== writePermissionDenialGenerationRef.current
|
|
) {
|
|
writePermissionRecoveryFetchSeenRef.current = false
|
|
return
|
|
}
|
|
if (workspacePermissionKeysFetching) {
|
|
writePermissionRecoveryFetchSeenRef.current = true
|
|
return
|
|
}
|
|
if (
|
|
!writePermissionRecoveryFetchSeenRef.current ||
|
|
permissionPending ||
|
|
permissionQueryError ||
|
|
!canEdit
|
|
)
|
|
return
|
|
writePermissionRecoveryFetchSeenRef.current = false
|
|
// oxlint-disable-next-line eslint-react/set-state-in-effect -- A post-denial permission request is the authoritative event that retires the local mutation lock.
|
|
setWritePermissionRevoked(false)
|
|
// oxlint-disable-next-line eslint-react/set-state-in-effect -- The completed recovery generation is retired with its write lock.
|
|
setWritePermissionRecoveryGeneration(undefined)
|
|
}, [
|
|
canEdit,
|
|
permissionPending,
|
|
permissionQueryError,
|
|
workspacePermissionKeysFetching,
|
|
writePermissionRecoveryGeneration,
|
|
writePermissionRevoked,
|
|
])
|
|
|
|
useEffect(() => {
|
|
if (!mainRetryFocusRequestedRef.current) return
|
|
if (mainRecoveryVisible) {
|
|
if (documentPermissionDenied) {
|
|
mainRetryFocusRequestedRef.current = false
|
|
documentPermissionAlertRef.current?.focus()
|
|
} else if (permissionQueryError) permissionRetryButtonRef.current?.focus()
|
|
else if (documentsQuery.error && !permissionDenied) documentsRetryButtonRef.current?.focus()
|
|
else dependencyRetryButtonRef.current?.focus()
|
|
return
|
|
}
|
|
mainRetryFocusRequestedRef.current = false
|
|
documentsTitleRef.current?.focus()
|
|
}, [
|
|
documentsQuery.error,
|
|
documentPermissionDenied,
|
|
mainRecoveryIdentity,
|
|
mainRecoveryVisible,
|
|
permissionDenied,
|
|
permissionQueryError,
|
|
])
|
|
|
|
useEffect(() => {
|
|
if (!blockingDependencyRetries.tasks && !blockingDependencyRetries.sources) return
|
|
if (
|
|
(!blockingDependencyRetries.tasks || !tasksQuery.data) &&
|
|
(!blockingDependencyRetries.sources || !sourcesQuery.data)
|
|
)
|
|
return
|
|
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Successful first-page retries retire the locally stabilized blocking state.
|
|
setBlockingDependencyRetries((current) => ({
|
|
sources: current.sources && !sourcesQuery.data,
|
|
tasks: current.tasks && !tasksQuery.data,
|
|
}))
|
|
}, [blockingDependencyRetries, sourcesQuery.data, tasksQuery.data])
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!permissionDenied &&
|
|
(filterActive || (tasksOpen && unresolvedTaskDocumentIds.size > 0)) &&
|
|
canAutoFetchDocumentPage &&
|
|
!isFetchingNextDocumentPage &&
|
|
!isFetchNextDocumentPageError
|
|
)
|
|
void fetchNextDocumentPage()
|
|
}, [
|
|
fetchNextDocumentPage,
|
|
filterActive,
|
|
tasksOpen,
|
|
unresolvedTaskDocumentIds,
|
|
canAutoFetchDocumentPage,
|
|
isFetchNextDocumentPageError,
|
|
isFetchingNextDocumentPage,
|
|
permissionDenied,
|
|
])
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!permissionDenied &&
|
|
canAutoFetchTaskPage &&
|
|
!isFetchingNextTaskPage &&
|
|
!isFetchNextTaskPageError
|
|
)
|
|
void fetchNextTaskPage()
|
|
}, [
|
|
canAutoFetchTaskPage,
|
|
fetchNextTaskPage,
|
|
isFetchNextTaskPageError,
|
|
isFetchingNextTaskPage,
|
|
permissionDenied,
|
|
])
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!permissionDenied &&
|
|
canAutoFetchSourcePage &&
|
|
!isFetchingNextSourcePage &&
|
|
!isFetchNextSourcePageError
|
|
)
|
|
void fetchNextSourcePage()
|
|
}, [
|
|
canAutoFetchSourcePage,
|
|
fetchNextSourcePage,
|
|
isFetchNextSourcePageError,
|
|
isFetchingNextSourcePage,
|
|
permissionDenied,
|
|
])
|
|
|
|
const refreshDocuments = useCallback(() => {
|
|
void queryClient.invalidateQueries({
|
|
predicate: (query) => queryKeyMatchesKnowledgeSpace(query.queryKey, knowledgeSpaceId),
|
|
queryKey: consoleQuery.knowledgeFs.getKnowledgeSpacesByIdLogicalDocuments.key(),
|
|
})
|
|
}, [knowledgeSpaceId, queryClient])
|
|
|
|
const refreshDocumentsAndTasks = useCallback(() => {
|
|
void Promise.allSettled([
|
|
queryClient.invalidateQueries({
|
|
predicate: (query) => queryKeyMatchesKnowledgeSpace(query.queryKey, knowledgeSpaceId),
|
|
queryKey: consoleQuery.knowledgeFs.getKnowledgeSpacesByIdLogicalDocuments.key(),
|
|
}),
|
|
queryClient.invalidateQueries({
|
|
predicate: (query) => queryKeyMatchesKnowledgeSpace(query.queryKey, knowledgeSpaceId),
|
|
queryKey: consoleQuery.knowledgeFs.getKnowledgeSpacesByIdProcessingTasks.key(),
|
|
}),
|
|
])
|
|
}, [knowledgeSpaceId, queryClient])
|
|
|
|
const refreshWorkspacePermissions = useCallback(
|
|
async (releaseWriteLockOnSuccess: boolean) => {
|
|
const denialGeneration = writePermissionDenialGenerationRef.current
|
|
const result = await (releaseWriteLockOnSuccess && writePermissionRevoked
|
|
? refreshWorkspacePermissionKeysAfterMutationDenial()
|
|
: retryWorkspacePermissionKeys())
|
|
const refreshedPermissionKeys = result.data?.dataset?.default_permission_keys
|
|
if (
|
|
releaseWriteLockOnSuccess &&
|
|
writePermissionDenialGenerationRef.current === denialGeneration &&
|
|
!result.error &&
|
|
refreshedPermissionKeys &&
|
|
hasPermission(refreshedPermissionKeys, DatasetACLPermission.Edit)
|
|
) {
|
|
writePermissionRecoveryFetchSeenRef.current = false
|
|
setWritePermissionRevoked(false)
|
|
setWritePermissionRecoveryGeneration(undefined)
|
|
}
|
|
},
|
|
[
|
|
refreshWorkspacePermissionKeysAfterMutationDenial,
|
|
retryWorkspacePermissionKeys,
|
|
writePermissionRevoked,
|
|
],
|
|
)
|
|
|
|
const handleWritePermissionDenied = useCallback(() => {
|
|
const denialGeneration = writePermissionDenialGenerationRef.current + 1
|
|
writePermissionDenialGenerationRef.current = denialGeneration
|
|
writePermissionRecoveryFetchSeenRef.current = false
|
|
setWritePermissionRecoveryGeneration(undefined)
|
|
setWritePermissionRevoked(true)
|
|
void refreshWorkspacePermissionKeysAfterMutationDenial().then((result) => {
|
|
const refreshedPermissionKeys = result.data?.dataset?.default_permission_keys
|
|
if (writePermissionDenialGenerationRef.current !== denialGeneration) return
|
|
if (
|
|
!result.error &&
|
|
refreshedPermissionKeys &&
|
|
hasPermission(refreshedPermissionKeys, DatasetACLPermission.Edit)
|
|
) {
|
|
writePermissionRecoveryFetchSeenRef.current = false
|
|
setWritePermissionRevoked(false)
|
|
setWritePermissionRecoveryGeneration(undefined)
|
|
return
|
|
}
|
|
setWritePermissionRecoveryGeneration(denialGeneration)
|
|
})
|
|
}, [refreshWorkspacePermissionKeysAfterMutationDenial])
|
|
|
|
const reconcileTerminalTask = useCallback(
|
|
async function reconcileTerminalTaskRequest(
|
|
taskId: string,
|
|
terminalVersion: string,
|
|
reconciliationGeneration: number,
|
|
retryAttempt = 0,
|
|
) {
|
|
const currentTask = baseTaskByIdRef.current.get(taskId)
|
|
if (!currentTask || auxiliaryTaskReadGuard.isBlocked(taskId, terminalVersion)) return
|
|
const pendingRetryTimeout = terminalReconciliationTimeoutsRef.current.get(taskId)
|
|
if (pendingRetryTimeout !== undefined) window.clearTimeout(pendingRetryTimeout)
|
|
terminalReconciliationTimeoutsRef.current.delete(taskId)
|
|
terminalReconciliationControllersRef.current.get(taskId)?.abort()
|
|
const controller = new AbortController()
|
|
terminalReconciliationControllersRef.current.set(taskId, controller)
|
|
const requestTimeout = window.setTimeout(
|
|
() => controller.abort(),
|
|
TERMINAL_RECONCILIATION_REQUEST_TIMEOUT,
|
|
)
|
|
try {
|
|
const snapshot =
|
|
await consoleClient.knowledgeFs.getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId(
|
|
{
|
|
params: {
|
|
documentId: currentTask.documentId,
|
|
id: knowledgeSpaceId,
|
|
taskId,
|
|
},
|
|
},
|
|
{ signal: controller.signal },
|
|
)
|
|
if (
|
|
terminalReconciliationControllersRef.current.get(taskId) !== controller ||
|
|
terminalReconciliationGenerationsRef.current.get(taskId) !== reconciliationGeneration
|
|
)
|
|
return
|
|
terminalReconciliationControllersRef.current.delete(taskId)
|
|
const currentTaskVersion = currentTaskVersionRef.current.get(taskId)
|
|
if (currentTaskVersion && taskVersionIsAfter(currentTaskVersion, snapshot.updatedAt)) return
|
|
if (taskVersionIsAfter(terminalVersion, snapshot.updatedAt)) return
|
|
auxiliaryTaskReadGuard.clearTask(taskId)
|
|
failedPollAuxiliaryDenialsRef.current.delete(taskId)
|
|
terminalConfirmableAuxiliaryDenialsRef.current.delete(taskId)
|
|
const normalizedSnapshot = normalizedTaskSnapshot(snapshot)
|
|
if (taskIsActive(snapshot))
|
|
trustedActiveOverrideVersionsRef.current.set(taskId, {
|
|
taskListGeneration: taskListGenerationRef.current,
|
|
updatedAt: snapshot.updatedAt,
|
|
})
|
|
else trustedActiveOverrideVersionsRef.current.delete(taskId)
|
|
taskProgressStore.delete(taskId)
|
|
currentTaskStateRef.current.set(taskId, snapshot.state)
|
|
currentTaskVersionRef.current.set(taskId, snapshot.updatedAt)
|
|
setTaskOverrides((current) => {
|
|
const currentVersion = current[taskId]?.updatedAt
|
|
if (currentVersion && taskVersionIsAfter(currentVersion, snapshot.updatedAt))
|
|
return current
|
|
return { ...current, [taskId]: normalizedSnapshot }
|
|
})
|
|
if (taskIsActive(snapshot)) {
|
|
blockedFailedTaskPollVersionsRef.current.delete(taskId)
|
|
taskEventCursorsRef.current.delete(taskId)
|
|
const pollGeneration = failedTaskPollGenerationsRef.current.get(taskId) ?? 0
|
|
failedTaskPollGenerationsRef.current.set(taskId, pollGeneration + 1)
|
|
setTerminalTaskPins((current) => {
|
|
const pin = current[taskId]
|
|
if (!pin || taskVersionIsAfter(pin.observedAt, snapshot.updatedAt)) return current
|
|
const next = { ...current }
|
|
delete next[taskId]
|
|
return next
|
|
})
|
|
setTaskObserverGenerations((current) => ({
|
|
...current,
|
|
[taskId]: (current[taskId] ?? 0) + 1,
|
|
}))
|
|
}
|
|
} catch (error) {
|
|
if (terminalReconciliationControllersRef.current.get(taskId) !== controller) return
|
|
terminalReconciliationControllersRef.current.delete(taskId)
|
|
if (responseStatus(error) === 403) {
|
|
const currentTaskVersion = currentTaskVersionRef.current.get(taskId)
|
|
const deniedVersion =
|
|
currentTaskVersion && taskVersionIsAfter(currentTaskVersion, terminalVersion)
|
|
? currentTaskVersion
|
|
: terminalVersion
|
|
terminalConfirmableAuxiliaryDenialsRef.current.set(taskId, {
|
|
taskListGeneration: taskListGenerationRef.current,
|
|
taskVersion: deniedVersion,
|
|
})
|
|
denyAuxiliaryTaskRead(taskId, deniedVersion)
|
|
return
|
|
}
|
|
if (
|
|
retryAttempt >= 4 ||
|
|
!taskSnapshotErrorIsTransient(error) ||
|
|
terminalReconciliationGenerationsRef.current.get(taskId) !== reconciliationGeneration
|
|
)
|
|
return
|
|
const timeout = window.setTimeout(
|
|
() => {
|
|
terminalReconciliationTimeoutsRef.current.delete(taskId)
|
|
if (
|
|
terminalReconciliationGenerationsRef.current.get(taskId) === reconciliationGeneration
|
|
)
|
|
void reconcileTerminalTaskRequest(
|
|
taskId,
|
|
terminalVersion,
|
|
reconciliationGeneration,
|
|
retryAttempt + 1,
|
|
)
|
|
},
|
|
Math.min(1000 * 2 ** retryAttempt, 30000),
|
|
)
|
|
terminalReconciliationTimeoutsRef.current.set(taskId, timeout)
|
|
} finally {
|
|
window.clearTimeout(requestTimeout)
|
|
}
|
|
},
|
|
[auxiliaryTaskReadGuard, denyAuxiliaryTaskRead, knowledgeSpaceId, taskProgressStore],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (permissionDenied) return
|
|
for (const task of baseTasks) {
|
|
if (taskIsActive(task)) continue
|
|
const override = taskOverrides[task.id]
|
|
const trustedOverride = trustedActiveOverrideVersionsRef.current.get(task.id)
|
|
if (
|
|
!override?.updatedAt ||
|
|
!taskIsActive({ ...task, ...override }) ||
|
|
taskVersionIsAfter(override.updatedAt, task.updatedAt) ||
|
|
trustedOverride?.updatedAt !== override.updatedAt ||
|
|
taskListGeneration <= trustedOverride.taskListGeneration ||
|
|
trustedOverrideListGenerationsRef.current.get(task.id) === taskListGeneration
|
|
)
|
|
continue
|
|
trustedOverrideListGenerationsRef.current.set(task.id, taskListGeneration)
|
|
const reconciliationGeneration =
|
|
(terminalReconciliationGenerationsRef.current.get(task.id) ?? 0) + 1
|
|
terminalReconciliationGenerationsRef.current.set(task.id, reconciliationGeneration)
|
|
void reconcileTerminalTask(task.id, task.updatedAt, reconciliationGeneration)
|
|
}
|
|
}, [baseTasks, permissionDenied, reconcileTerminalTask, taskListGeneration, taskOverrides])
|
|
|
|
useEffect(
|
|
() => () => {
|
|
for (const controller of terminalReconciliationControllersRef.current.values())
|
|
controller.abort()
|
|
terminalReconciliationControllersRef.current.clear()
|
|
for (const timeout of terminalReconciliationTimeoutsRef.current.values())
|
|
window.clearTimeout(timeout)
|
|
terminalReconciliationTimeoutsRef.current.clear()
|
|
},
|
|
[knowledgeSpaceId],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!permissionDenied) return
|
|
void queryClient.cancelQueries({
|
|
predicate: (query) => queryKeyMatchesKnowledgeSpace(query.queryKey, knowledgeSpaceId),
|
|
queryKey: consoleQuery.knowledgeFs.getKnowledgeSpacesByIdProcessingTasks.key(),
|
|
})
|
|
void queryClient.cancelQueries({
|
|
predicate: (query) => queryKeyMatchesKnowledgeSpace(query.queryKey, knowledgeSpaceId),
|
|
queryKey: consoleQuery.knowledgeFs.getKnowledgeSpacesByIdSources.key(),
|
|
})
|
|
for (const [taskId, controller] of terminalReconciliationControllersRef.current) {
|
|
controller.abort()
|
|
terminalReconciliationControllersRef.current.delete(taskId)
|
|
const generation = terminalReconciliationGenerationsRef.current.get(taskId) ?? 0
|
|
terminalReconciliationGenerationsRef.current.set(taskId, generation + 1)
|
|
}
|
|
for (const timeout of terminalReconciliationTimeoutsRef.current.values())
|
|
window.clearTimeout(timeout)
|
|
terminalReconciliationTimeoutsRef.current.clear()
|
|
equalRetryListGenerationsRef.current.clear()
|
|
}, [knowledgeSpaceId, permissionDenied, queryClient])
|
|
|
|
useEffect(() => {
|
|
const taskIds = new Set(baseTasks.map((task) => task.id))
|
|
const pruneMap = (map: Map<string, unknown>) => {
|
|
for (const taskId of map.keys()) {
|
|
if (!taskIds.has(taskId)) map.delete(taskId)
|
|
}
|
|
}
|
|
|
|
for (const [taskId, controller] of terminalReconciliationControllersRef.current) {
|
|
if (taskIds.has(taskId)) continue
|
|
controller.abort()
|
|
terminalReconciliationControllersRef.current.delete(taskId)
|
|
}
|
|
for (const [taskId, timeout] of terminalReconciliationTimeoutsRef.current) {
|
|
if (taskIds.has(taskId)) continue
|
|
window.clearTimeout(timeout)
|
|
terminalReconciliationTimeoutsRef.current.delete(taskId)
|
|
}
|
|
pruneMap(terminalReconciliationGenerationsRef.current)
|
|
pruneMap(failedTaskPollGenerationsRef.current)
|
|
pruneMap(blockedFailedTaskPollVersionsRef.current)
|
|
pruneMap(failedPollAuxiliaryDenialsRef.current)
|
|
pruneMap(terminalConfirmableAuxiliaryDenialsRef.current)
|
|
auxiliaryTaskReadGuard.retain(taskIds)
|
|
pruneMap(equalRetryListGenerationsRef.current)
|
|
pruneMap(pendingTerminalProgressRef.current)
|
|
pruneMap(taskEventCursorsRef.current)
|
|
pruneMap(trustedOverrideListGenerationsRef.current)
|
|
for (const map of [
|
|
streamActiveOverrideVersionsRef.current,
|
|
trustedActiveOverrideVersionsRef.current,
|
|
]) {
|
|
for (const taskId of map.keys()) {
|
|
if (!taskIds.has(taskId)) map.delete(taskId)
|
|
}
|
|
}
|
|
taskProgressStore.retain(taskIds)
|
|
|
|
const retainTaskState = <Value,>(current: Record<string, Value>) => {
|
|
const staleTaskIds = Object.keys(current).filter((taskId) => !taskIds.has(taskId))
|
|
if (!staleTaskIds.length) return current
|
|
const next = { ...current }
|
|
for (const taskId of staleTaskIds) delete next[taskId]
|
|
return next
|
|
}
|
|
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Prune state for tasks removed by a refreshed cursor result.
|
|
setTaskOverrides(retainTaskState)
|
|
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Prune state for tasks removed by a refreshed cursor result.
|
|
setTerminalTaskPins(retainTaskState)
|
|
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Prune state for tasks removed by a refreshed cursor result.
|
|
setTaskObserverGenerations(retainTaskState)
|
|
}, [auxiliaryTaskReadGuard, baseTasks, taskProgressStore])
|
|
|
|
useEffect(() => {
|
|
if (permissionDenied) return
|
|
const strictRetries = new Map<string, DocumentProcessingTask>()
|
|
const equalTimestampRetries = new Map<string, TerminalTaskPin>()
|
|
const confirmedTerminals = new Map<string, DocumentProcessingTask>()
|
|
for (const task of baseTasks) {
|
|
const pin = terminalTaskPins[task.id]
|
|
if (!pin || taskListGeneration <= pin.taskListGeneration) continue
|
|
if (!taskIsActive(task)) {
|
|
if (!taskVersionIsAfter(pin.observedAt, task.updatedAt))
|
|
confirmedTerminals.set(task.id, task)
|
|
continue
|
|
}
|
|
if (taskVersionIsAfter(task.updatedAt, pin.observedAt)) strictRetries.set(task.id, task)
|
|
else if (!taskVersionIsAfter(pin.observedAt, task.updatedAt))
|
|
equalTimestampRetries.set(task.id, pin)
|
|
}
|
|
|
|
for (const taskId of confirmedTerminals.keys()) {
|
|
const generation = terminalReconciliationGenerationsRef.current.get(taskId) ?? 0
|
|
terminalReconciliationGenerationsRef.current.set(taskId, generation + 1)
|
|
terminalReconciliationControllersRef.current.get(taskId)?.abort()
|
|
terminalReconciliationControllersRef.current.delete(taskId)
|
|
const timeout = terminalReconciliationTimeoutsRef.current.get(taskId)
|
|
if (timeout !== undefined) window.clearTimeout(timeout)
|
|
terminalReconciliationTimeoutsRef.current.delete(taskId)
|
|
}
|
|
if (confirmedTerminals.size) {
|
|
// oxlint-disable-next-line eslint-react/set-state-in-effect -- A current terminal list snapshot supersedes the partial SSE terminal payload.
|
|
setTerminalTaskPins((current) => {
|
|
const next = { ...current }
|
|
for (const taskId of confirmedTerminals.keys()) delete next[taskId]
|
|
return next
|
|
})
|
|
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Restore complete server error details from the authoritative terminal list snapshot.
|
|
setTaskOverrides((current) => {
|
|
const next = { ...current }
|
|
for (const [taskId, task] of confirmedTerminals) {
|
|
const overrideVersion = current[taskId]?.updatedAt
|
|
if (!overrideVersion || !taskVersionIsAfter(overrideVersion, task.updatedAt))
|
|
delete next[taskId]
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
for (const taskId of strictRetries.keys()) {
|
|
taskProgressStore.delete(taskId)
|
|
blockedFailedTaskPollVersionsRef.current.delete(taskId)
|
|
auxiliaryTaskReadGuard.clearTask(taskId)
|
|
failedPollAuxiliaryDenialsRef.current.delete(taskId)
|
|
terminalConfirmableAuxiliaryDenialsRef.current.delete(taskId)
|
|
taskEventCursorsRef.current.delete(taskId)
|
|
const pollGeneration = failedTaskPollGenerationsRef.current.get(taskId) ?? 0
|
|
failedTaskPollGenerationsRef.current.set(taskId, pollGeneration + 1)
|
|
const generation = terminalReconciliationGenerationsRef.current.get(taskId) ?? 0
|
|
terminalReconciliationGenerationsRef.current.set(taskId, generation + 1)
|
|
terminalReconciliationControllersRef.current.get(taskId)?.abort()
|
|
terminalReconciliationControllersRef.current.delete(taskId)
|
|
const timeout = terminalReconciliationTimeoutsRef.current.get(taskId)
|
|
if (timeout !== undefined) window.clearTimeout(timeout)
|
|
terminalReconciliationTimeoutsRef.current.delete(taskId)
|
|
}
|
|
if (strictRetries.size) {
|
|
// oxlint-disable-next-line eslint-react/set-state-in-effect -- A strictly newer active list snapshot proves a retry without another request.
|
|
setTerminalTaskPins((current) => {
|
|
const next = { ...current }
|
|
for (const taskId of strictRetries.keys()) delete next[taskId]
|
|
return next
|
|
})
|
|
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Drop terminal overrides only after a strictly newer active list snapshot.
|
|
setTaskOverrides((current) => {
|
|
const next = { ...current }
|
|
for (const [taskId, task] of strictRetries) {
|
|
const overrideVersion = current[taskId]?.updatedAt
|
|
if (!overrideVersion || !taskVersionIsAfter(overrideVersion, task.updatedAt))
|
|
delete next[taskId]
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
for (const [taskId, pin] of equalTimestampRetries) {
|
|
if (equalRetryListGenerationsRef.current.get(taskId) === taskListGeneration) continue
|
|
equalRetryListGenerationsRef.current.set(taskId, taskListGeneration)
|
|
auxiliaryTaskReadGuard.clearTask(taskId)
|
|
failedPollAuxiliaryDenialsRef.current.delete(taskId)
|
|
terminalConfirmableAuxiliaryDenialsRef.current.delete(taskId)
|
|
const timeout = terminalReconciliationTimeoutsRef.current.get(taskId)
|
|
if (timeout !== undefined) window.clearTimeout(timeout)
|
|
terminalReconciliationTimeoutsRef.current.delete(taskId)
|
|
const reconciliationGeneration =
|
|
(terminalReconciliationGenerationsRef.current.get(taskId) ?? 0) + 1
|
|
terminalReconciliationGenerationsRef.current.set(taskId, reconciliationGeneration)
|
|
void reconcileTerminalTask(taskId, pin.observedAt, reconciliationGeneration)
|
|
}
|
|
}, [
|
|
auxiliaryTaskReadGuard,
|
|
baseTasks,
|
|
permissionDenied,
|
|
reconcileTerminalTask,
|
|
taskListGeneration,
|
|
taskProgressStore,
|
|
terminalTaskPins,
|
|
])
|
|
|
|
const handleUploadFiles = useCallback(
|
|
async (files: File[]) => {
|
|
if (!canWrite || !files.length || uploadPendingRef.current) return
|
|
const uploadableFiles: File[] = []
|
|
const localExclusions: Array<{
|
|
filename: string
|
|
reasonKey: UploadExclusionReasonKey
|
|
}> = []
|
|
for (const file of files) {
|
|
const issue = documentUploadIssue(file)
|
|
if (issue) localExclusions.push({ filename: file.name, reasonKey: issue })
|
|
else uploadableFiles.push(file)
|
|
}
|
|
const formatExclusionDetails = (
|
|
exclusions: Array<{ filename: string; reasonKey: UploadExclusionReasonKey }>,
|
|
) => {
|
|
const detailItems = exclusions
|
|
.slice(0, 3)
|
|
.map(
|
|
({ filename, reasonKey }) =>
|
|
`${filename} (${t(($) => $[`newKnowledge.documentUploadExclusion.${reasonKey}`])})`,
|
|
)
|
|
if (exclusions.length > detailItems.length)
|
|
detailItems.push(
|
|
t(($) => $['newKnowledge.documentUploadExclusion.more'], {
|
|
count: exclusions.length - detailItems.length,
|
|
}),
|
|
)
|
|
return detailItems.join('; ')
|
|
}
|
|
if (!uploadableFiles.length) {
|
|
toast.error(
|
|
t(($) => $['newKnowledge.documentUploadRejected'], {
|
|
details: formatExclusionDetails(localExclusions),
|
|
}),
|
|
)
|
|
return
|
|
}
|
|
let writePermissionDenied = false
|
|
uploadPendingRef.current = true
|
|
setUploading(true)
|
|
try {
|
|
let acceptedCount = 0
|
|
const exclusions = [...localExclusions]
|
|
if (uploadableFiles.length === 1) {
|
|
await uploadDocument({
|
|
body: { file: uploadableFiles[0]! },
|
|
params: { id: knowledgeSpaceId },
|
|
})
|
|
acceptedCount = 1
|
|
} else {
|
|
const result = await bulkUploadDocuments({
|
|
body: { files: uploadableFiles },
|
|
params: { id: knowledgeSpaceId },
|
|
})
|
|
acceptedCount = result.accepted
|
|
for (const item of result.items) {
|
|
if (!('reason' in item)) continue
|
|
const reasonKey = uploadExclusionReasonKey[item.reason]
|
|
exclusions.push({ filename: item.filename, reasonKey })
|
|
}
|
|
}
|
|
const exclusionDetails = formatExclusionDetails(exclusions)
|
|
if (!acceptedCount) {
|
|
toast.error(
|
|
t(($) => $['newKnowledge.documentUploadRejected'], {
|
|
details: exclusionDetails,
|
|
}),
|
|
)
|
|
return
|
|
}
|
|
if (exclusions.length)
|
|
toast.warning(
|
|
t(($) => $['newKnowledge.documentUploadPartial'], {
|
|
accepted: acceptedCount,
|
|
details: exclusionDetails,
|
|
excluded: exclusions.length,
|
|
}),
|
|
)
|
|
else toast.success(t(($) => $['newKnowledge.documentUploadStarted']))
|
|
refreshDocumentsAndTasks()
|
|
} catch (error) {
|
|
if (responseStatus(error) === 403) {
|
|
writePermissionDenied = true
|
|
handleWritePermissionDenied()
|
|
} else toast.error(t(($) => $['newKnowledge.documentUploadFailed']))
|
|
} finally {
|
|
if (!writePermissionDenied) {
|
|
writePermissionFocusRecoveryRequestedRef.current = false
|
|
writePermissionFocusOriginRef.current = null
|
|
}
|
|
uploadPendingRef.current = false
|
|
setUploading(false)
|
|
}
|
|
},
|
|
[
|
|
bulkUploadDocuments,
|
|
canWrite,
|
|
handleWritePermissionDenied,
|
|
knowledgeSpaceId,
|
|
refreshDocumentsAndTasks,
|
|
t,
|
|
uploadDocument,
|
|
],
|
|
)
|
|
|
|
const handleReindexDocuments = useCallback(async () => {
|
|
if (
|
|
!canWrite ||
|
|
selectionDisabled ||
|
|
!validSelectedDocumentIds.size ||
|
|
reindexPendingRef.current
|
|
)
|
|
return
|
|
reindexPendingRef.current = true
|
|
setReindexing(true)
|
|
try {
|
|
const selectedIds = [...validSelectedDocumentIds].sort()
|
|
const result = await reindexDocuments({
|
|
body: { documentIds: selectedIds },
|
|
params: { id: knowledgeSpaceId },
|
|
})
|
|
const missingIds = result.items
|
|
.filter((item) => item.status === 'not_found')
|
|
.map((item) => item.documentId)
|
|
const queuedCount = result.items.length - missingIds.length
|
|
if (!queuedCount) {
|
|
setSelectedDocumentIds(new Set())
|
|
toast.error(
|
|
t(($) => $['newKnowledge.documentsReindexPartial'], {
|
|
missing: missingIds.length,
|
|
queued: 0,
|
|
}),
|
|
)
|
|
refreshDocumentsAndTasks()
|
|
return
|
|
}
|
|
setSelectedDocumentIds(new Set(missingIds))
|
|
if (missingIds.length)
|
|
toast.warning(
|
|
t(($) => $['newKnowledge.documentsReindexPartial'], {
|
|
missing: missingIds.length,
|
|
queued: queuedCount,
|
|
}),
|
|
)
|
|
else toast.success(t(($) => $['newKnowledge.documentsReindexStarted']))
|
|
refreshDocumentsAndTasks()
|
|
} catch (error) {
|
|
if (responseStatus(error) === 403) handleWritePermissionDenied()
|
|
else toast.error(t(($) => $['newKnowledge.documentsReindexFailed']))
|
|
} finally {
|
|
reindexPendingRef.current = false
|
|
setReindexing(false)
|
|
}
|
|
}, [
|
|
canWrite,
|
|
handleWritePermissionDenied,
|
|
knowledgeSpaceId,
|
|
refreshDocumentsAndTasks,
|
|
reindexDocuments,
|
|
selectionDisabled,
|
|
t,
|
|
validSelectedDocumentIds,
|
|
])
|
|
|
|
const handleTaskEvent = useCallback(
|
|
(taskId: string, taskVersion: string, event: ProcessingTaskEvent) => {
|
|
const eventVersion = event.event === 'progress' ? event.data.updatedAt : taskVersion
|
|
const terminalSnapshot = !ACTIVE_TASK_STATES.has(event.data.state)
|
|
const currentVersion = currentTaskVersionRef.current.get(taskId)
|
|
if (currentVersion && taskVersionIsAfter(currentVersion, eventVersion)) {
|
|
if (terminalSnapshot) pendingTerminalProgressRef.current.delete(taskId)
|
|
return false
|
|
}
|
|
|
|
if (event.event === 'progress' && ACTIVE_TASK_STATES.has(event.data.state)) {
|
|
if (trustedActiveOverrideVersionsRef.current.get(taskId)?.updatedAt === eventVersion)
|
|
streamActiveOverrideVersionsRef.current.delete(taskId)
|
|
else streamActiveOverrideVersionsRef.current.set(taskId, eventVersion)
|
|
} else streamActiveOverrideVersionsRef.current.delete(taskId)
|
|
|
|
if (event.event === 'progress' && terminalSnapshot) {
|
|
currentTaskVersionRef.current.set(taskId, eventVersion)
|
|
taskProgressStore.set(taskId, event.data)
|
|
pendingTerminalProgressRef.current.set(taskId, event)
|
|
return true
|
|
}
|
|
|
|
const pendingTerminalProgress =
|
|
event.event === 'terminal' ? pendingTerminalProgressRef.current.get(taskId) : undefined
|
|
if (event.event === 'terminal') pendingTerminalProgressRef.current.delete(taskId)
|
|
|
|
if (event.event === 'progress') taskProgressStore.set(taskId, event.data)
|
|
else taskProgressStore.delete(taskId)
|
|
|
|
const currentTaskState = currentTaskStateRef.current.get(taskId)
|
|
currentTaskVersionRef.current.set(taskId, eventVersion)
|
|
if (event.event === 'progress' && currentTaskState === event.data.state) return true
|
|
currentTaskStateRef.current.set(taskId, event.data.state)
|
|
|
|
setTaskOverrides((current) => {
|
|
const previous = current[taskId]
|
|
if (
|
|
event.event === 'progress' &&
|
|
previous?.updatedAt &&
|
|
taskVersionIsAfter(previous.updatedAt, event.data.updatedAt)
|
|
)
|
|
return current
|
|
return {
|
|
...current,
|
|
[taskId]:
|
|
event.event === 'progress'
|
|
? {
|
|
errorCode: undefined,
|
|
errorMessage: undefined,
|
|
progressPercent: event.data.progressPercent,
|
|
stage: event.data.stage,
|
|
state: event.data.state,
|
|
updatedAt: event.data.updatedAt,
|
|
}
|
|
: {
|
|
errorCode: event.data.errorCode,
|
|
errorMessage: undefined,
|
|
...(pendingTerminalProgress
|
|
? {
|
|
progressPercent: pendingTerminalProgress.data.progressPercent,
|
|
stage: pendingTerminalProgress.data.stage,
|
|
}
|
|
: {}),
|
|
state: event.data.state,
|
|
updatedAt: eventVersion,
|
|
},
|
|
}
|
|
})
|
|
if (event.event === 'terminal') {
|
|
const pollGeneration = failedTaskPollGenerationsRef.current.get(taskId) ?? 0
|
|
failedTaskPollGenerationsRef.current.set(taskId, pollGeneration + 1)
|
|
const timeout = terminalReconciliationTimeoutsRef.current.get(taskId)
|
|
if (timeout !== undefined) window.clearTimeout(timeout)
|
|
terminalReconciliationTimeoutsRef.current.delete(taskId)
|
|
const reconciliationGeneration =
|
|
(terminalReconciliationGenerationsRef.current.get(taskId) ?? 0) + 1
|
|
terminalReconciliationGenerationsRef.current.set(taskId, reconciliationGeneration)
|
|
setTerminalTaskPins((current) => ({
|
|
...current,
|
|
[taskId]: {
|
|
observedAt: eventVersion,
|
|
taskListGeneration: taskListGenerationRef.current,
|
|
},
|
|
}))
|
|
if (event.data.state === 'failed')
|
|
toast.error(t(($) => $['newKnowledge.taskFailedNotification']))
|
|
refreshDocuments()
|
|
void reconcileTerminalTask(taskId, eventVersion, reconciliationGeneration)
|
|
}
|
|
return true
|
|
},
|
|
[reconcileTerminalTask, refreshDocuments, t, taskProgressStore],
|
|
)
|
|
|
|
const handleTaskUpdated = useCallback(
|
|
(task: DocumentProcessingTask) => {
|
|
const currentVersion = currentTaskVersionRef.current.get(task.id)
|
|
if (currentVersion && taskVersionIsAfter(currentVersion, task.updatedAt)) return
|
|
streamActiveOverrideVersionsRef.current.delete(task.id)
|
|
auxiliaryTaskReadGuard.clearTask(task.id)
|
|
failedPollAuxiliaryDenialsRef.current.delete(task.id)
|
|
terminalConfirmableAuxiliaryDenialsRef.current.delete(task.id)
|
|
if (taskIsActive(task))
|
|
trustedActiveOverrideVersionsRef.current.set(task.id, {
|
|
taskListGeneration: taskListGenerationRef.current,
|
|
updatedAt: task.updatedAt,
|
|
})
|
|
else trustedActiveOverrideVersionsRef.current.delete(task.id)
|
|
trustedOverrideListGenerationsRef.current.delete(task.id)
|
|
taskProgressStore.delete(task.id)
|
|
currentTaskStateRef.current.set(task.id, task.state)
|
|
currentTaskVersionRef.current.set(task.id, task.updatedAt)
|
|
// oxlint-disable-next-line eslint-react/set-state-in-effect -- A committed active list lifecycle retires the denied failed snapshot.
|
|
setTaskOverrides((current) => ({ ...current, [task.id]: normalizedTaskSnapshot(task) }))
|
|
if (taskIsActive(task)) {
|
|
blockedFailedTaskPollVersionsRef.current.delete(task.id)
|
|
taskEventCursorsRef.current.delete(task.id)
|
|
terminalReconciliationControllersRef.current.get(task.id)?.abort()
|
|
terminalReconciliationControllersRef.current.delete(task.id)
|
|
const timeout = terminalReconciliationTimeoutsRef.current.get(task.id)
|
|
if (timeout !== undefined) window.clearTimeout(timeout)
|
|
terminalReconciliationTimeoutsRef.current.delete(task.id)
|
|
const generation = terminalReconciliationGenerationsRef.current.get(task.id) ?? 0
|
|
terminalReconciliationGenerationsRef.current.set(task.id, generation + 1)
|
|
const pollGeneration = failedTaskPollGenerationsRef.current.get(task.id) ?? 0
|
|
failedTaskPollGenerationsRef.current.set(task.id, pollGeneration + 1)
|
|
pendingTerminalProgressRef.current.delete(task.id)
|
|
// oxlint-disable-next-line eslint-react/set-state-in-effect -- The authoritative active lifecycle supersedes the terminal pin.
|
|
setTerminalTaskPins((current) => {
|
|
const next = { ...current }
|
|
delete next[task.id]
|
|
return next
|
|
})
|
|
}
|
|
},
|
|
[auxiliaryTaskReadGuard, taskProgressStore],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (permissionDenied) return
|
|
for (const task of baseTasks) {
|
|
if (!taskIsActive(task)) continue
|
|
const denial = failedPollAuxiliaryDenialsRef.current.get(task.id)
|
|
if (!denial || taskListGeneration <= denial.taskListGeneration) continue
|
|
if (taskVersionIsAfter(denial.taskVersion, task.updatedAt)) continue
|
|
failedPollAuxiliaryDenialsRef.current.delete(task.id)
|
|
handleTaskUpdated(task)
|
|
}
|
|
}, [baseTasks, handleTaskUpdated, permissionDenied, taskListGeneration])
|
|
|
|
useEffect(() => {
|
|
for (const task of activeTasks) {
|
|
const hadBlockedPoll = blockedFailedTaskPollVersionsRef.current.delete(task.id)
|
|
if (!hadBlockedPoll) continue
|
|
const generation = failedTaskPollGenerationsRef.current.get(task.id) ?? 0
|
|
failedTaskPollGenerationsRef.current.set(task.id, generation + 1)
|
|
}
|
|
}, [activeTasks])
|
|
|
|
useEffect(() => {
|
|
if (permissionDenied || !orderedFailedTasksRef.current.length) return
|
|
let canceled = false
|
|
let timeout: number | undefined
|
|
const cancelRequests = new Set<() => void>()
|
|
const pollNextBatch = async () => {
|
|
const failedTasks = orderedFailedTasksRef.current
|
|
const pollableTasks = failedTasks.filter(
|
|
(task) =>
|
|
blockedFailedTaskPollVersionsRef.current.get(task.id) !== task.updatedAt &&
|
|
!auxiliaryTaskReadGuard.isBlocked(task.id, task.updatedAt),
|
|
)
|
|
if (pollableTasks.length) {
|
|
const pollCount = Math.min(MAX_TASK_EVENT_STREAMS, pollableTasks.length)
|
|
const offset = failedTaskPollOffsetRef.current % pollableTasks.length
|
|
const tasksToPoll = Array.from(
|
|
{ length: pollCount },
|
|
(_, index) => pollableTasks[(offset + index) % pollableTasks.length]!,
|
|
)
|
|
failedTaskPollOffsetRef.current += MAX_TASK_EVENT_STREAMS
|
|
await Promise.allSettled(
|
|
tasksToPoll.map(async (task) => {
|
|
const requestGeneration = (failedTaskPollGenerationsRef.current.get(task.id) ?? 0) + 1
|
|
failedTaskPollGenerationsRef.current.set(task.id, requestGeneration)
|
|
const requestController = new AbortController()
|
|
let requestTimeout: number | undefined
|
|
let rejectDeadline: ((reason?: unknown) => void) | undefined
|
|
const cancelRequest = () => {
|
|
requestController.abort()
|
|
rejectDeadline?.(new DOMException('Task snapshot request aborted', 'AbortError'))
|
|
}
|
|
try {
|
|
const request =
|
|
consoleClient.knowledgeFs.getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId(
|
|
{
|
|
params: {
|
|
documentId: task.documentId,
|
|
id: knowledgeSpaceId,
|
|
taskId: task.id,
|
|
},
|
|
},
|
|
{ signal: requestController.signal },
|
|
)
|
|
const deadline = new Promise<never>((_resolve, reject) => {
|
|
rejectDeadline = reject
|
|
requestTimeout = window.setTimeout(() => {
|
|
requestController.abort()
|
|
reject(new DOMException('Task snapshot request timed out', 'TimeoutError'))
|
|
}, FAILED_TASK_POLL_REQUEST_TIMEOUT)
|
|
})
|
|
cancelRequests.add(cancelRequest)
|
|
const snapshot = await Promise.race([request, deadline])
|
|
if (
|
|
canceled ||
|
|
failedTaskPollGenerationsRef.current.get(task.id) !== requestGeneration
|
|
)
|
|
return
|
|
const currentVersion = currentTaskVersionRef.current.get(task.id)
|
|
if (currentVersion && taskVersionIsAfter(currentVersion, snapshot.updatedAt)) return
|
|
handleTaskUpdated(snapshot)
|
|
} catch (error) {
|
|
if (
|
|
canceled ||
|
|
failedTaskPollGenerationsRef.current.get(task.id) !== requestGeneration
|
|
)
|
|
return
|
|
if (responseStatus(error) === 403) {
|
|
const currentTaskVersion = currentTaskVersionRef.current.get(task.id)
|
|
const deniedVersion =
|
|
currentTaskVersion && taskVersionIsAfter(currentTaskVersion, task.updatedAt)
|
|
? currentTaskVersion
|
|
: task.updatedAt
|
|
failedPollAuxiliaryDenialsRef.current.set(task.id, {
|
|
taskListGeneration: taskListGenerationRef.current,
|
|
taskVersion: deniedVersion,
|
|
})
|
|
denyAuxiliaryTaskRead(task.id, deniedVersion)
|
|
return
|
|
}
|
|
if (!taskSnapshotErrorIsTransient(error))
|
|
blockedFailedTaskPollVersionsRef.current.set(task.id, task.updatedAt)
|
|
} finally {
|
|
if (requestTimeout !== undefined) window.clearTimeout(requestTimeout)
|
|
cancelRequests.delete(cancelRequest)
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
if (!canceled) timeout = window.setTimeout(() => void pollNextBatch(), 5000)
|
|
}
|
|
timeout = window.setTimeout(() => void pollNextBatch(), 5000)
|
|
return () => {
|
|
canceled = true
|
|
for (const cancelRequest of cancelRequests) cancelRequest()
|
|
cancelRequests.clear()
|
|
if (timeout !== undefined) window.clearTimeout(timeout)
|
|
}
|
|
}, [
|
|
auxiliaryTaskReadGuard,
|
|
denyAuxiliaryTaskRead,
|
|
failedTaskPollSignature,
|
|
handleTaskUpdated,
|
|
knowledgeSpaceId,
|
|
permissionDenied,
|
|
])
|
|
|
|
const toggleDocument = useCallback(
|
|
(documentId: string) => {
|
|
if (!canWrite || selectionDisabled) return
|
|
setSelectedDocumentIds((current) => {
|
|
const next = new Set(current)
|
|
if (next.has(documentId)) next.delete(documentId)
|
|
else next.add(documentId)
|
|
return next
|
|
})
|
|
},
|
|
[canWrite, selectionDisabled],
|
|
)
|
|
|
|
const toggleAllFiltered = () => {
|
|
if (!canWrite || selectionDisabled) return
|
|
setSelectedDocumentIds((current) => {
|
|
const next = new Set(current)
|
|
if (allFilteredSelected)
|
|
selectableFilteredDocuments.forEach((document) => next.delete(document.id))
|
|
else selectableFilteredDocuments.forEach((document) => next.add(document.id))
|
|
return next
|
|
})
|
|
}
|
|
|
|
const retryDependencyQueries = () => {
|
|
if (taskQueryBlockingError || sourceQueryBlockingError)
|
|
setBlockingDependencyRetries((current) => ({
|
|
sources: current.sources || sourceQueryBlockingError,
|
|
tasks: current.tasks || taskQueryBlockingError,
|
|
}))
|
|
if (tasksQuery.isFetchNextPageError) void tasksQuery.fetchNextPage()
|
|
else if (tasksQuery.error || taskQueryBlockingError) void tasksQuery.refetch()
|
|
if (sourcesQuery.isFetchNextPageError) void sourcesQuery.fetchNextPage()
|
|
else if (sourcesQuery.error || sourceQueryBlockingError) void sourcesQuery.refetch()
|
|
}
|
|
|
|
const loadMoreResults = () => {
|
|
const requests: Promise<unknown>[] = []
|
|
if (hasNextDocumentPage && !isFetchingNextDocumentPage) requests.push(fetchNextDocumentPage())
|
|
if (hasNextTaskPage && !isFetchingNextTaskPage) requests.push(fetchNextTaskPage())
|
|
if (hasRelevantNextSourcePage && !isFetchingNextSourcePage) requests.push(fetchNextSourcePage())
|
|
void Promise.allSettled(requests)
|
|
}
|
|
|
|
const handleTaskEventCursor = useCallback((taskId: string, eventId?: string) => {
|
|
if (eventId) taskEventCursorsRef.current.set(taskId, eventId)
|
|
else taskEventCursorsRef.current.delete(taskId)
|
|
}, [])
|
|
|
|
const handleTaskStreamPermissionDenied = useCallback(
|
|
(taskId: string, taskVersion: string) => {
|
|
terminalConfirmableAuxiliaryDenialsRef.current.set(taskId, {
|
|
taskListGeneration: taskListGenerationRef.current,
|
|
taskVersion,
|
|
})
|
|
denyAuxiliaryTaskRead(taskId, taskVersion)
|
|
},
|
|
[denyAuxiliaryTaskRead],
|
|
)
|
|
|
|
return (
|
|
<>
|
|
{streamedActiveTasks.map((task) => {
|
|
const observerVersion = taskObserverVersion(task)
|
|
return (
|
|
<TaskEventObserver
|
|
key={`${task.id}:${taskObserverGenerations[task.id] ?? 0}`}
|
|
documentId={task.documentId}
|
|
knowledgeSpaceId={knowledgeSpaceId}
|
|
lastEventId={taskEventCursorsRef.current.get(task.id)}
|
|
onEvent={handleTaskEvent}
|
|
onLastEventIdChange={handleTaskEventCursor}
|
|
onPermissionDenied={handleTaskStreamPermissionDenied}
|
|
taskId={task.id}
|
|
taskVersion={observerVersion}
|
|
/>
|
|
)
|
|
})}
|
|
{canWrite && (
|
|
<input
|
|
ref={uploadInputRef}
|
|
multiple
|
|
hidden
|
|
accept={DOCUMENT_UPLOAD_ACCEPT}
|
|
aria-label={t(($) => $['newKnowledge.uploadDocuments'])}
|
|
tabIndex={-1}
|
|
type="file"
|
|
onChange={(event) => {
|
|
const files = [...(event.currentTarget.files ?? [])]
|
|
event.currentTarget.value = ''
|
|
void handleUploadFiles(files)
|
|
}}
|
|
/>
|
|
)}
|
|
<section
|
|
ref={documentsSectionRef}
|
|
className={cn(
|
|
'flex min-h-full flex-col px-4 pt-6 sm:px-8 sm:pt-7',
|
|
bulkActionsVisible ? 'pb-[calc(7rem+env(safe-area-inset-bottom,0px))]' : 'pb-6 sm:pb-7',
|
|
)}
|
|
onBlurCapture={(event) => {
|
|
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
documentSurfaceHadFocusRef.current = false
|
|
if (event.relatedTarget && event.relatedTarget !== uploadInputRef.current) {
|
|
writePermissionFocusRecoveryRequestedRef.current = false
|
|
writePermissionFocusOriginRef.current = null
|
|
}
|
|
}
|
|
}}
|
|
onFocusCapture={(event) => {
|
|
if (
|
|
writePermissionFocusRecoveryRequestedRef.current &&
|
|
event.target !== writePermissionFocusOriginRef.current
|
|
) {
|
|
writePermissionFocusRecoveryRequestedRef.current = false
|
|
writePermissionFocusOriginRef.current = null
|
|
}
|
|
documentSurfaceHadFocusRef.current = true
|
|
}}
|
|
>
|
|
<header>
|
|
<h2
|
|
ref={documentsTitleRef}
|
|
id="new-knowledge-documents-title"
|
|
className="title-xl-semi-bold text-text-primary"
|
|
tabIndex={-1}
|
|
>
|
|
{t(($) => $['newKnowledge.documents'])}
|
|
</h2>
|
|
<p className="mt-1 system-xs-regular text-text-tertiary">
|
|
{t(($) => $['newKnowledge.documentsDescription'])}
|
|
</p>
|
|
{permissionPending && (
|
|
<p
|
|
id="documents-permission-pending"
|
|
className="mt-2 system-xs-regular text-text-tertiary"
|
|
role="status"
|
|
>
|
|
{t(($) => $['newKnowledge.permission'])}
|
|
{' · '}
|
|
{tCommon(($) => $.loading)}
|
|
</p>
|
|
)}
|
|
{!permissionPending && !permissionQueryError && (!canEdit || writePermissionRevoked) && (
|
|
<p
|
|
id="documents-readonly-reason"
|
|
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" />
|
|
{t(($) => $['newKnowledge.documentPermissionRestricted'])}
|
|
</p>
|
|
)}
|
|
</header>
|
|
{permissionQueryError && (
|
|
<div
|
|
className="mt-4 flex items-center justify-between gap-3 rounded-lg border border-divider-regular bg-background-section px-3 py-2"
|
|
role="alert"
|
|
>
|
|
<span id="documents-permission-error" className="system-xs-regular text-text-tertiary">
|
|
{t(($) => $['newKnowledge.permissionLoadFailed'])}
|
|
</span>
|
|
<Button
|
|
ref={permissionRetryButtonRef}
|
|
aria-label={`${tCommon(($) => $['operation.retry'])} · ${t(($) => $['newKnowledge.permissionLoadFailed'])}`}
|
|
aria-busy={workspacePermissionKeysFetching}
|
|
loading={workspacePermissionKeysFetching}
|
|
size="small"
|
|
onBlur={(event) => {
|
|
if (event.relatedTarget) mainRetryFocusRequestedRef.current = false
|
|
}}
|
|
onClick={() => {
|
|
mainRetryFocusRequestedRef.current = true
|
|
void refreshWorkspacePermissions(true)
|
|
}}
|
|
>
|
|
{tCommon(($) => $['operation.retry'])}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{documentsQuery.error &&
|
|
documentsQuery.data &&
|
|
!permissionDenied &&
|
|
!documentsQuery.isFetchNextPageError && (
|
|
<div
|
|
className="mt-4 flex items-center justify-between gap-3 rounded-lg border border-divider-regular bg-background-section px-3 py-2"
|
|
role="alert"
|
|
>
|
|
<span className="system-xs-regular text-text-tertiary">
|
|
{t(($) =>
|
|
responseStatus(documentsQuery.error) === 403
|
|
? $['newKnowledge.documentsPermissionDescription']
|
|
: $['newKnowledge.documentsErrorDescription'],
|
|
)}
|
|
</span>
|
|
{responseStatus(documentsQuery.error) !== 403 && (
|
|
<Button
|
|
ref={documentsRetryButtonRef}
|
|
aria-label={`${tCommon(($) => $['operation.retry'])} · ${t(($) => $['newKnowledge.documentsErrorDescription'])}`}
|
|
aria-busy={documentsQuery.isRefetching}
|
|
loading={documentsQuery.isRefetching}
|
|
size="small"
|
|
onBlur={(event) => {
|
|
if (event.relatedTarget) mainRetryFocusRequestedRef.current = false
|
|
}}
|
|
onClick={() => {
|
|
mainRetryFocusRequestedRef.current = true
|
|
void refetchDocumentsQuery()
|
|
}}
|
|
>
|
|
{tCommon(($) => $['operation.retry'])}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{!permissionDenied && !dependencyQueryBlockingError && dependencyQueryWarning && (
|
|
<div
|
|
className="mt-4 flex items-center justify-between gap-3 rounded-lg border border-divider-regular bg-background-section px-3 py-2"
|
|
role="alert"
|
|
>
|
|
<span className="system-xs-regular text-text-tertiary">
|
|
{sourcesQuery.error || sourcesQuery.isFetchNextPageError
|
|
? t(($) => $['newKnowledge.sourcesErrorDescription'])
|
|
: t(($) => $['newKnowledge.tasksErrorDescription'])}
|
|
</span>
|
|
<Button
|
|
ref={dependencyRetryButtonRef}
|
|
aria-label={`${tCommon(($) => $['operation.retry'])} · ${
|
|
sourcesQuery.error || sourcesQuery.isFetchNextPageError
|
|
? t(($) => $['newKnowledge.sourcesErrorDescription'])
|
|
: t(($) => $['newKnowledge.tasksErrorDescription'])
|
|
}`}
|
|
aria-busy={dependencyRetryFetching}
|
|
loading={dependencyRetryFetching}
|
|
size="small"
|
|
onBlur={(event) => {
|
|
if (event.relatedTarget) mainRetryFocusRequestedRef.current = false
|
|
}}
|
|
onClick={() => {
|
|
mainRetryFocusRequestedRef.current = true
|
|
retryDependencyQueries()
|
|
}}
|
|
>
|
|
{tCommon(($) => $['operation.retry'])}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{documentsQuery.isPending && !permissionDenied ? (
|
|
<div className="flex min-h-64 flex-1 items-center justify-center">
|
|
<Loading />
|
|
</div>
|
|
) : permissionDenied || (documentsQuery.error && !documentsQuery.data) ? (
|
|
<div
|
|
ref={documentPermissionAlertRef}
|
|
className="flex min-h-64 flex-1 flex-col items-center justify-center px-6 text-center"
|
|
role="alert"
|
|
tabIndex={permissionDenied ? -1 : undefined}
|
|
>
|
|
<span aria-hidden className="i-ri-error-warning-line size-7 text-text-tertiary" />
|
|
<h2 className="mt-3 title-xl-semi-bold text-text-primary">
|
|
{t(($) =>
|
|
permissionDenied
|
|
? $['newKnowledge.documentsPermissionTitle']
|
|
: $['newKnowledge.documentsErrorTitle'],
|
|
)}
|
|
</h2>
|
|
<p className="mt-2 max-w-md body-sm-regular text-text-tertiary">
|
|
{t(($) =>
|
|
permissionDenied
|
|
? $['newKnowledge.documentsPermissionDescription']
|
|
: $['newKnowledge.documentsErrorDescription'],
|
|
)}
|
|
</p>
|
|
{(!permissionDenied ||
|
|
(auxiliaryReadPermissionDenied && !documentPermissionDenied)) && (
|
|
<Button
|
|
ref={documentsRetryButtonRef}
|
|
aria-label={`${tCommon(($) => $['operation.retry'])} · ${documentsRecoveryDescription}`}
|
|
aria-busy={documentsQuery.isFetching}
|
|
className="mt-4"
|
|
loading={documentsQuery.isFetching}
|
|
onBlur={(event) => {
|
|
if (event.relatedTarget) mainRetryFocusRequestedRef.current = false
|
|
}}
|
|
onClick={() => {
|
|
mainRetryFocusRequestedRef.current = true
|
|
if (auxiliaryReadPermissionDenied) retryAuxiliaryTaskRead()
|
|
else void refetchDocumentsQuery()
|
|
}}
|
|
>
|
|
{tCommon(($) => $['operation.retry'])}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
) : dependencyQueryBlockingError ? (
|
|
<div
|
|
className="flex min-h-64 flex-1 flex-col items-center justify-center px-6 text-center"
|
|
role="alert"
|
|
>
|
|
<span aria-hidden className="i-ri-error-warning-line size-7 text-text-tertiary" />
|
|
<p className="mt-3 max-w-md body-sm-regular text-text-tertiary">
|
|
{taskQueryBlockingError
|
|
? t(($) => $['newKnowledge.tasksErrorDescription'])
|
|
: t(($) => $['newKnowledge.sourcesErrorDescription'])}
|
|
</p>
|
|
<Button
|
|
ref={dependencyRetryButtonRef}
|
|
aria-label={`${tCommon(($) => $['operation.retry'])} · ${
|
|
taskQueryBlockingError
|
|
? t(($) => $['newKnowledge.tasksErrorDescription'])
|
|
: t(($) => $['newKnowledge.sourcesErrorDescription'])
|
|
}`}
|
|
aria-busy={blockingDependencyRetryFetching}
|
|
className="mt-4"
|
|
loading={blockingDependencyRetryFetching}
|
|
onBlur={(event) => {
|
|
if (event.relatedTarget) mainRetryFocusRequestedRef.current = false
|
|
}}
|
|
onClick={() => {
|
|
mainRetryFocusRequestedRef.current = true
|
|
retryDependencyQueries()
|
|
}}
|
|
>
|
|
{tCommon(($) => $['operation.retry'])}
|
|
</Button>
|
|
</div>
|
|
) : !documents.length ? (
|
|
<DocumentsEmpty
|
|
activeTaskCount={activeTasks.length}
|
|
attentionTaskBadge={attentionTaskBadge}
|
|
canEdit={canWrite}
|
|
hasTaskError={hasTaskError}
|
|
onAddDocument={() => {
|
|
writePermissionFocusRecoveryRequestedRef.current = true
|
|
writePermissionFocusOriginRef.current = document.activeElement as HTMLElement | null
|
|
uploadInputRef.current?.click()
|
|
}}
|
|
onDropFiles={(files) => void handleUploadFiles(files)}
|
|
onOpenTasks={() => setTasksOpen(true)}
|
|
readOnlyReasonId={documentWriteRestrictionReasonId}
|
|
tasksButtonLabel={tasksButtonLabel}
|
|
tasksLiveStatus={tasksLiveStatus}
|
|
uploading={uploading}
|
|
/>
|
|
) : (
|
|
<DocumentsList
|
|
activeTaskCount={activeTasks.length}
|
|
allSelected={allFilteredSelected}
|
|
attentionTaskBadge={attentionTaskBadge}
|
|
canEdit={canWrite}
|
|
completingResults={completingFilteredResults}
|
|
documents={filteredDocuments}
|
|
filter={filter}
|
|
getDocumentHref={(documentId) =>
|
|
newKnowledgeDocumentDetailPath(knowledgeSpaceId, documentId)
|
|
}
|
|
hasNextPage={Boolean(
|
|
hasNextDocumentPage || hasNextTaskPage || hasRelevantNextSourcePage,
|
|
)}
|
|
hasSelectableDocuments={Boolean(selectableFilteredDocuments.length)}
|
|
hasTaskError={hasTaskError}
|
|
isFetchNextPageError={documentsQuery.isFetchNextPageError}
|
|
isFetchingNextDocumentPage={isFetchingNextDocumentPage}
|
|
isFetchingNextPage={isFetchingNextResultsPage}
|
|
onAddDocument={() => {
|
|
writePermissionFocusRecoveryRequestedRef.current = true
|
|
writePermissionFocusOriginRef.current = document.activeElement as HTMLElement | null
|
|
uploadInputRef.current?.click()
|
|
}}
|
|
onFilterChange={setFilter}
|
|
onLoadMore={loadMoreResults}
|
|
onOpenTasks={() => setTasksOpen(true)}
|
|
onSearchChange={setSearch}
|
|
onSelectAll={toggleAllFiltered}
|
|
onSelectDocument={toggleDocument}
|
|
readOnlyReasonId={documentWriteRestrictionReasonId}
|
|
resultsIncomplete={filteredResultsIncomplete}
|
|
search={search}
|
|
selectionDisabled={selectionDisabled}
|
|
selectedDocumentIds={validSelectedDocumentIds}
|
|
someSelected={someFilteredSelected}
|
|
sourcesPending={sourceResultsIncomplete}
|
|
sourceNames={sourceNames}
|
|
statusPending={dependencyResultsIncomplete}
|
|
statuses={documentStatuses}
|
|
tasksPending={taskResultsIncomplete}
|
|
tasksButtonLabel={tasksButtonLabel}
|
|
tasksLiveStatus={tasksLiveStatus}
|
|
uploading={uploading}
|
|
/>
|
|
)}
|
|
</section>
|
|
{bulkActionsVisible && (
|
|
<DocumentBulkActions
|
|
disabled={selectionDisabled}
|
|
disabledReason={reindexUnavailableReason}
|
|
onClear={() => setSelectedDocumentIds(new Set())}
|
|
onBlurCapture={(event) => {
|
|
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
|
|
bulkActionsHadFocusRef.current = false
|
|
}}
|
|
onFocusCapture={() => {
|
|
bulkActionsHadFocusRef.current = true
|
|
}}
|
|
onReindex={() => void handleReindexDocuments()}
|
|
reindexing={reindexing}
|
|
selectedCount={validSelectedDocumentIds.size}
|
|
/>
|
|
)}
|
|
<ProcessingTasksDrawer
|
|
actionResultsValid={!permissionDenied}
|
|
canEdit={canWrite}
|
|
documentQueryError={Boolean(documentsQuery.error || documentsQuery.isFetchNextPageError)}
|
|
documentQueryFetching={documentsQuery.isFetching}
|
|
documents={documents}
|
|
documentsPending={Boolean(hasNextDocumentPage || documentsQuery.isFetchingNextPage)}
|
|
hasNextDocumentPage={Boolean(hasNextDocumentPage)}
|
|
hasNextTaskPage={Boolean(hasNextTaskPage)}
|
|
hasUnresolvedTaskDocuments={unresolvedTaskDocumentIds.size > 0}
|
|
isFetchingNextDocumentPage={isFetchingNextDocumentPage}
|
|
isFetchingNextTaskPage={isFetchingNextTaskPage}
|
|
knowledgeSpaceId={knowledgeSpaceId}
|
|
onLoadMoreDocuments={() => void fetchNextDocumentPage()}
|
|
onLoadMoreTasks={() => void fetchNextTaskPage()}
|
|
onOpenChange={setTasksOpen}
|
|
onRefreshDocumentsAndTasks={refreshDocumentsAndTasks}
|
|
onRetryPermissionQuery={() => void refreshWorkspacePermissions(true)}
|
|
onRetryDocumentQuery={() => {
|
|
if (documentsQuery.isFetchNextPageError) void documentsQuery.fetchNextPage()
|
|
else void refetchDocumentsQuery()
|
|
}}
|
|
onRetryTaskQuery={() => {
|
|
if (tasksQuery.isFetchNextPageError) void tasksQuery.fetchNextPage()
|
|
else void tasksQuery.refetch()
|
|
}}
|
|
onTaskUpdated={handleTaskUpdated}
|
|
onWritePermissionDenied={handleWritePermissionDenied}
|
|
open={tasksOpen && !permissionDenied}
|
|
permissionQueryError={permissionQueryError}
|
|
permissionQueryFetching={workspacePermissionKeysFetching}
|
|
permissionQueryPending={permissionPending}
|
|
readOnlyReason={
|
|
writePermissionRevoked || (!permissionPending && !permissionQueryError && !canEdit)
|
|
? t(($) => $['newKnowledge.documentPermissionRestricted'])
|
|
: undefined
|
|
}
|
|
taskQueryPending={tasksQuery.isPending}
|
|
taskQueryError={Boolean(tasksQuery.error || tasksQuery.isFetchNextPageError)}
|
|
taskQueryFetching={tasksQuery.isFetching}
|
|
taskProgressStore={taskProgressStore}
|
|
tasks={tasks}
|
|
/>
|
|
</>
|
|
)
|
|
}
|