mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 00:31:19 +08:00
refactor(knowledge-fs): move document upload surface ownership
This commit is contained in:
parent
ed8cfe3400
commit
d18325cab7
@ -1,11 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import type { DocumentUploadFormHandle } from '../upload/form'
|
||||
import type { DocumentAction } from './actions-dropdown'
|
||||
import type { DocumentProcessingTask } from './models'
|
||||
import type { UploadExclusionReasonKey } from './upload/model'
|
||||
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 } from 'jotai'
|
||||
@ -18,7 +15,6 @@ import {
|
||||
workspacePermissionKeysErrorAtom,
|
||||
workspacePermissionKeysLoadingAtom,
|
||||
} from '@/context/permission-state'
|
||||
import { knowledgeFsUploadEnabledAtom } from '@/features/system-features/state'
|
||||
import { consoleClient, consoleQuery } from '@/service/client'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import { DatasetACLPermission, hasPermission } from '@/utils/permission'
|
||||
@ -28,12 +24,8 @@ import { knowledgeFsTaskFailureMessageKey } from '../knowledge-fs-task-error'
|
||||
import { createRequestId } from '../request-id'
|
||||
import { newKnowledgeDocumentDetailPath } from '../routes'
|
||||
import { sourceFromApi } from '../sources/source-models'
|
||||
import { DocumentUploadForm } from '../upload/form'
|
||||
import { uploadKnowledgeFsDocuments } from '../upload/knowledge-fs-upload'
|
||||
import { documentUploadIssue } from '../upload/policy'
|
||||
import { useKnowledgeFileSizeLimit } from '../upload/use-file-size-limit'
|
||||
import { useKnowledgeModelSetupGuard } from '../use-knowledge-model-setup-guard'
|
||||
import { DocumentBulkActions, DocumentDropOverlay, DocumentsEmpty, DocumentsList } from './list'
|
||||
import { DocumentBulkActions, DocumentsEmpty, DocumentsList } from './list'
|
||||
import { DocumentMetadataDrawer } from './metadata/drawer'
|
||||
import {
|
||||
documentCanDownload,
|
||||
@ -55,46 +47,28 @@ import {
|
||||
} from './permission-recovery/recovery-boundary'
|
||||
import { useDocumentPermissionRecovery } from './permission-recovery/use-permission-recovery'
|
||||
import { documentSourcesInfiniteOptions, logicalDocumentsInfiniteOptions } from './queries'
|
||||
import {
|
||||
documentFilterParser,
|
||||
documentMetadataParser,
|
||||
documentSearchParser,
|
||||
documentUploadParser,
|
||||
} from './query-state'
|
||||
import { documentFilterParser, documentMetadataParser, documentSearchParser } from './query-state'
|
||||
import { responseStatus } from './request-error'
|
||||
import { useAuxiliaryTaskReadGuard } from './tasks/auxiliary-read-guard'
|
||||
import { ProcessingTasksDrawer } from './tasks/drawer'
|
||||
import { TaskEventObserver } from './tasks/event-observer'
|
||||
import { MAX_AUTO_CURSOR_PAGES, queryKeyMatchesKnowledgeSpace } from './tasks/recovery'
|
||||
import { useTaskRuntime } from './tasks/use-task-runtime'
|
||||
import { DocumentStagingCanceledError } from './upload/model'
|
||||
import { useDocumentUploadSession } from './upload/use-document-upload-session'
|
||||
import {
|
||||
DocumentUploadContent,
|
||||
DocumentUploadHeader,
|
||||
DocumentUploadSurface,
|
||||
} from './upload/surface'
|
||||
|
||||
const KNOWLEDGE_FS_BATCH_DOCUMENT_MAX_DOCUMENTS = 100
|
||||
|
||||
export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const fileSizeLimitMb = useKnowledgeFileSizeLimit()
|
||||
const {
|
||||
beginUpload,
|
||||
completeUploads,
|
||||
discardAllStagedFiles,
|
||||
discardStagedFile,
|
||||
endUpload,
|
||||
prepareUploads,
|
||||
progress: stagedUploadProgress,
|
||||
resetProgress: resetUploadProgress,
|
||||
stageFiles,
|
||||
updateProgress: updateUploadProgress,
|
||||
uploading,
|
||||
uploadProgress,
|
||||
} = useDocumentUploadSession(knowledgeSpaceId)
|
||||
const queryClient = useQueryClient()
|
||||
const datasetDefaultPermissionKeys = useAtomValue(datasetDefaultPermissionKeysAtom)
|
||||
const workspacePermissionKeysLoading = useAtomValue(workspacePermissionKeysLoadingAtom)
|
||||
const workspacePermissionKeysError = useAtomValue(workspacePermissionKeysErrorAtom)
|
||||
const uploadAvailable = useAtomValue(knowledgeFsUploadEnabledAtom)
|
||||
const hasDocumentDownloadPermission = hasPermission(
|
||||
datasetDefaultPermissionKeys,
|
||||
DatasetACLPermission.DocumentDownload,
|
||||
@ -108,7 +82,6 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
|
||||
const bulkActionPendingRef = useRef(false)
|
||||
const [filter, setFilter] = useQueryState('status', documentFilterParser)
|
||||
const [search, setSearch] = useQueryState('query', documentSearchParser)
|
||||
const [uploadRequest, setUploadRequest] = useQueryState('upload', documentUploadParser)
|
||||
const [metadataRequest, setMetadataRequest] = useQueryState('metadata', documentMetadataParser)
|
||||
const metadataOpen = metadataRequest === '1'
|
||||
const setMetadataOpen = useCallback(
|
||||
@ -118,10 +91,6 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
|
||||
[setMetadataRequest],
|
||||
)
|
||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<Set<string>>(() => new Set())
|
||||
const [uploadFormInitialFiles, setUploadFormInitialFiles] = useState<File[]>([])
|
||||
const uploadFormRef = useRef<DocumentUploadFormHandle>(null)
|
||||
const [isFileDragActive, setIsFileDragActive] = useState(false)
|
||||
const fileDragDepthRef = useRef(0)
|
||||
const [tasksOpen, setTasksOpen] = useState(false)
|
||||
const [blockingDependencyRetries, setBlockingDependencyRetries] = useState({
|
||||
sources: false,
|
||||
@ -218,39 +187,10 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
|
||||
sourcePermissionDenied,
|
||||
taskPermissionDenied,
|
||||
})
|
||||
const canUpload = canWrite && uploadAvailable
|
||||
const uploadFormOpen = canUpload && uploadRequest === '1'
|
||||
const openUploadForm = useCallback(
|
||||
(files: File[] = []) => {
|
||||
fileDragDepthRef.current = 0
|
||||
setIsFileDragActive(false)
|
||||
resetUploadProgress()
|
||||
setUploadFormInitialFiles(files)
|
||||
void setUploadRequest('1')
|
||||
},
|
||||
[resetUploadProgress, setUploadRequest],
|
||||
const uploadPermission = useMemo(
|
||||
() => ({ canRead, canWrite, denyWrite: handleWritePermissionDenied }),
|
||||
[canRead, canWrite, handleWritePermissionDenied],
|
||||
)
|
||||
const closeUploadForm = useCallback(() => {
|
||||
resetUploadProgress()
|
||||
setUploadFormInitialFiles([])
|
||||
void setUploadRequest(null)
|
||||
}, [resetUploadProgress, setUploadRequest])
|
||||
const cancelUploadForm = useCallback(() => {
|
||||
discardAllStagedFiles()
|
||||
closeUploadForm()
|
||||
}, [closeUploadForm, discardAllStagedFiles])
|
||||
useEffect(() => {
|
||||
if (uploadRequest !== '1' || canUpload) return
|
||||
discardAllStagedFiles()
|
||||
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Consume the route-owned one-shot signal after authorization resolves.
|
||||
void setUploadRequest(null)
|
||||
}, [canUpload, discardAllStagedFiles, setUploadRequest, uploadRequest])
|
||||
const documentWriteRestrictionReasonId = canWrite ? undefined : 'documents-readonly-reason'
|
||||
const documentUploadRestrictionReasonId = !uploadAvailable
|
||||
? 'documents-upload-unavailable'
|
||||
: documentWriteRestrictionReasonId
|
||||
const documentsSectionRef = useRef<HTMLElement>(null)
|
||||
const documentsTitleRef = useRef<HTMLHeadingElement>(null)
|
||||
const mainRetryFocusRequestedRef = useRef(false)
|
||||
const documentsRetryButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const dependencyRetryButtonRef = useRef<HTMLButtonElement>(null)
|
||||
@ -533,7 +473,7 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
|
||||
return
|
||||
}
|
||||
mainRetryFocusRequestedRef.current = false
|
||||
documentsTitleRef.current?.focus()
|
||||
document.getElementById('new-knowledge-documents-title')?.focus()
|
||||
}, [canRead, documentsQuery.error, mainRecoveryIdentity, mainRecoveryVisible])
|
||||
|
||||
useEffect(() => {
|
||||
@ -599,112 +539,6 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
|
||||
])
|
||||
}, [knowledgeSpaceId, queryClient])
|
||||
|
||||
const handleUploadFiles = useCallback(
|
||||
async (files: File[]): Promise<boolean> => {
|
||||
if (!canUpload || !files.length || !beginUpload()) return false
|
||||
const uploadableFiles: File[] = []
|
||||
const localExclusions: Array<{
|
||||
filename: string
|
||||
reasonKey: UploadExclusionReasonKey
|
||||
}> = []
|
||||
for (const file of files) {
|
||||
const issue = documentUploadIssue(file, fileSizeLimitMb)
|
||||
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 }) => {
|
||||
const reason =
|
||||
reasonKey === 'fileSize'
|
||||
? t(($) => $['newKnowledge.documentUploadExclusion.fileSize'], {
|
||||
size: fileSizeLimitMb,
|
||||
})
|
||||
: t(($) => $[`newKnowledge.documentUploadExclusion.${reasonKey}`])
|
||||
return `${filename} (${reason})`
|
||||
})
|
||||
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),
|
||||
}),
|
||||
)
|
||||
endUpload()
|
||||
return false
|
||||
}
|
||||
try {
|
||||
if ((await ensureModelReady({ capability: 'ingest', intent: 'upload' })).status !== 'ready')
|
||||
return false
|
||||
let acceptedCount = 0
|
||||
const exclusions = [...localExclusions]
|
||||
await stageFiles(uploadableFiles)
|
||||
const uploads = prepareUploads(uploadableFiles)
|
||||
await uploadKnowledgeFsDocuments(
|
||||
knowledgeSpaceId,
|
||||
uploads,
|
||||
uploadProgress,
|
||||
updateUploadProgress,
|
||||
)
|
||||
completeUploads()
|
||||
acceptedCount = uploadableFiles.length
|
||||
const exclusionDetails = formatExclusionDetails(exclusions)
|
||||
if (!acceptedCount) {
|
||||
toast.error(
|
||||
t(($) => $['newKnowledge.documentUploadRejected'], {
|
||||
details: exclusionDetails,
|
||||
}),
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (exclusions.length)
|
||||
toast.warning(
|
||||
t(($) => $['newKnowledge.documentUploadPartial'], {
|
||||
accepted: acceptedCount,
|
||||
details: exclusionDetails,
|
||||
excluded: exclusions.length,
|
||||
}),
|
||||
)
|
||||
else toast.success(t(($) => $['newKnowledge.documentUploadStarted']))
|
||||
refreshDocumentsAndTasks()
|
||||
return true
|
||||
} catch (error) {
|
||||
if (responseStatus(error) === 403) {
|
||||
cancelUploadForm()
|
||||
handleWritePermissionDenied()
|
||||
} else toast.error(t(($) => $['newKnowledge.documentUploadFailed']))
|
||||
return false
|
||||
} finally {
|
||||
endUpload()
|
||||
}
|
||||
},
|
||||
[
|
||||
beginUpload,
|
||||
canUpload,
|
||||
cancelUploadForm,
|
||||
completeUploads,
|
||||
endUpload,
|
||||
ensureModelReady,
|
||||
fileSizeLimitMb,
|
||||
handleWritePermissionDenied,
|
||||
knowledgeSpaceId,
|
||||
prepareUploads,
|
||||
refreshDocumentsAndTasks,
|
||||
stageFiles,
|
||||
t,
|
||||
updateUploadProgress,
|
||||
uploadProgress,
|
||||
],
|
||||
)
|
||||
|
||||
const handleReindexDocuments = useCallback(async () => {
|
||||
if (
|
||||
!canWrite ||
|
||||
@ -1218,78 +1052,13 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
|
||||
readSurfaceOpen={tasksOpen || metadataOpen}
|
||||
recoverySurface={recoverySurface}
|
||||
>
|
||||
<section
|
||||
ref={documentsSectionRef}
|
||||
className={cn(
|
||||
'relative flex min-h-full w-full flex-col gap-4 px-6 pt-3',
|
||||
bulkActionsVisible ? 'pb-[calc(7rem+env(safe-area-inset-bottom,0px))]' : 'pb-6',
|
||||
)}
|
||||
onDragEnter={(event) => {
|
||||
const types = Array.from(event.dataTransfer.types ?? [])
|
||||
if (types.length && !types.includes('Files')) return
|
||||
event.preventDefault()
|
||||
if (!canUpload || uploading) return
|
||||
fileDragDepthRef.current += 1
|
||||
setIsFileDragActive(true)
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (!fileDragDepthRef.current) return
|
||||
fileDragDepthRef.current -= 1
|
||||
if (!fileDragDepthRef.current) setIsFileDragActive(false)
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
const types = Array.from(event.dataTransfer.types ?? [])
|
||||
if (types.length && !types.includes('Files')) return
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = canUpload && !uploading ? 'copy' : 'none'
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
const types = Array.from(event.dataTransfer.types ?? [])
|
||||
if (types.length && !types.includes('Files')) return
|
||||
event.preventDefault()
|
||||
fileDragDepthRef.current = 0
|
||||
setIsFileDragActive(false)
|
||||
if (!canUpload || uploading) return
|
||||
const files = [...event.dataTransfer.files]
|
||||
if (!files.length) return
|
||||
if (uploadFormOpen) uploadFormRef.current?.addFiles(files)
|
||||
else openUploadForm(files)
|
||||
}}
|
||||
<DocumentUploadSurface
|
||||
bulkActionsVisible={bulkActionsVisible}
|
||||
knowledgeSpaceId={knowledgeSpaceId}
|
||||
onUploadStarted={refreshDocumentsAndTasks}
|
||||
permission={uploadPermission}
|
||||
>
|
||||
{!uploadAvailable && (
|
||||
<span id="documents-upload-unavailable" className="sr-only">
|
||||
{t(($) => $['cornerLabel.unavailable'])}
|
||||
</span>
|
||||
)}
|
||||
<header>
|
||||
<h2
|
||||
ref={documentsTitleRef}
|
||||
id="new-knowledge-documents-title"
|
||||
className="title-xl-semi-bold leading-6 text-text-primary"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{t(($) =>
|
||||
uploadFormOpen ? $['newKnowledge.addDocument'] : $['newKnowledge.documents'],
|
||||
)}
|
||||
</h2>
|
||||
<p className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{t(($) =>
|
||||
uploadFormOpen
|
||||
? $['newKnowledge.uploadFilesDescription']
|
||||
: $['newKnowledge.documentsDescription'],
|
||||
)}
|
||||
</p>
|
||||
{canRead && !canWrite && (
|
||||
<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>
|
||||
<DocumentUploadHeader />
|
||||
<KnowledgeModelReadinessBanner capability="index" knowledgeSpaceId={knowledgeSpaceId} />
|
||||
{documentsQuery.error &&
|
||||
documentsQuery.data &&
|
||||
@ -1428,102 +1197,81 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
|
||||
{tCommon(($) => $['operation.retry'])}
|
||||
</Button>
|
||||
</div>
|
||||
) : uploadFormOpen ? (
|
||||
<DocumentUploadForm
|
||||
ref={uploadFormRef}
|
||||
fileSizeLimitMb={fileSizeLimitMb}
|
||||
initialFiles={uploadFormInitialFiles}
|
||||
uploadProgress={stagedUploadProgress}
|
||||
uploading={uploading}
|
||||
onCancel={cancelUploadForm}
|
||||
onFilesAdded={async (files) => {
|
||||
try {
|
||||
await stageFiles(files)
|
||||
} catch (error) {
|
||||
if (error instanceof DocumentStagingCanceledError) return
|
||||
toast.error(t(($) => $['newKnowledge.documentUploadFailed']))
|
||||
throw error
|
||||
}
|
||||
}}
|
||||
onFileRemoved={discardStagedFile}
|
||||
onSubmit={async (files) => {
|
||||
const uploaded = await handleUploadFiles(files)
|
||||
if (uploaded) closeUploadForm()
|
||||
return uploaded
|
||||
}}
|
||||
/>
|
||||
) : !documents.length ? (
|
||||
<DocumentsEmpty
|
||||
canEdit={canUpload}
|
||||
onAddDocument={() => openUploadForm()}
|
||||
onOpenMetadata={() => setMetadataOpen(true)}
|
||||
readOnlyReasonId={documentUploadRestrictionReasonId}
|
||||
uploading={uploading}
|
||||
/>
|
||||
) : (
|
||||
<DocumentsList
|
||||
activeTaskCount={activeTasks.length}
|
||||
allSelected={allFilteredSelected}
|
||||
attentionTaskBadge={attentionTaskBadge}
|
||||
canDownload={canDownload}
|
||||
canEdit={canWrite}
|
||||
canUpload={canUpload}
|
||||
completingResults={completingFilteredResults}
|
||||
documents={filteredDocuments}
|
||||
failureReasons={documentFailureReasons}
|
||||
filter={filter}
|
||||
getDocumentHref={(documentId) =>
|
||||
newKnowledgeDocumentDetailPath(knowledgeSpaceId, documentId)
|
||||
<DocumentUploadContent>
|
||||
{(upload) =>
|
||||
!documents.length ? (
|
||||
<DocumentsEmpty
|
||||
canEdit={upload.canUpload}
|
||||
onAddDocument={() => upload.openUpload()}
|
||||
onOpenMetadata={() => setMetadataOpen(true)}
|
||||
readOnlyReasonId={upload.uploadRestrictionReasonId}
|
||||
uploading={upload.uploading}
|
||||
/>
|
||||
) : (
|
||||
<DocumentsList
|
||||
activeTaskCount={activeTasks.length}
|
||||
allSelected={allFilteredSelected}
|
||||
attentionTaskBadge={attentionTaskBadge}
|
||||
canDownload={canDownload}
|
||||
canEdit={canWrite}
|
||||
canUpload={upload.canUpload}
|
||||
completingResults={completingFilteredResults}
|
||||
documents={filteredDocuments}
|
||||
failureReasons={documentFailureReasons}
|
||||
filter={filter}
|
||||
getDocumentHref={(documentId) =>
|
||||
newKnowledgeDocumentDetailPath(knowledgeSpaceId, documentId)
|
||||
}
|
||||
hasNextPage={Boolean(hasNextDocumentPage || hasRelevantNextSourcePage)}
|
||||
hasSelectableDocuments={Boolean(selectableFilteredDocuments.length)}
|
||||
hasTaskError={hasTaskError}
|
||||
isFetchNextPageError={documentsQuery.isFetchNextPageError}
|
||||
isFetchingNextDocumentPage={isFetchingNextDocumentPage}
|
||||
isFetchingNextPage={isFetchingNextResultsPage}
|
||||
onAddDocument={() => upload.openUpload()}
|
||||
onFilterChange={setFilter}
|
||||
onLoadMore={loadMoreResults}
|
||||
onDownloadDocument={handleDownloadDocument}
|
||||
onOpenMetadata={() => setMetadataOpen(true)}
|
||||
onOpenTasks={() => setTasksOpen(true)}
|
||||
onRemoveDocument={handleRemoveDocument}
|
||||
onRenameDocument={handleRenameDocument}
|
||||
onReindexDocument={(documentId) => void handleReindexDocument(documentId)}
|
||||
onRetryDocument={handleRetryDocument}
|
||||
onSearchChange={setSearch}
|
||||
onSelectAll={toggleAllFiltered}
|
||||
onSelectDocument={toggleDocument}
|
||||
onToggleDocumentAvailability={handleToggleDocumentAvailability}
|
||||
pendingDocumentAction={pendingDocumentAction}
|
||||
readOnlyReasonId={upload.readOnlyReasonId}
|
||||
resultsIncomplete={filteredResultsIncomplete}
|
||||
retryableDocumentIds={retryableDocumentIds}
|
||||
search={search}
|
||||
selectionDisabled={selectionDisabled}
|
||||
selectedDocumentIds={validSelectedDocumentIds}
|
||||
showTasks={Boolean(
|
||||
tasks.length ||
|
||||
tasksQuery.error ||
|
||||
tasksQuery.isFetchNextPageError ||
|
||||
hasNextTaskPage,
|
||||
)}
|
||||
someSelected={someFilteredSelected}
|
||||
sourcesPending={sourceResultsIncomplete}
|
||||
sourceNames={sourceNames}
|
||||
statusPending={dependencyResultsIncomplete}
|
||||
statuses={documentStatuses}
|
||||
tasksPending={taskResultsIncomplete}
|
||||
tasksButtonLabel={tasksButtonLabel}
|
||||
tasksLiveStatus={tasksLiveStatus}
|
||||
uploadRestrictionReasonId={upload.uploadRestrictionReasonId}
|
||||
uploading={upload.uploading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
hasNextPage={Boolean(hasNextDocumentPage || hasRelevantNextSourcePage)}
|
||||
hasSelectableDocuments={Boolean(selectableFilteredDocuments.length)}
|
||||
hasTaskError={hasTaskError}
|
||||
isFetchNextPageError={documentsQuery.isFetchNextPageError}
|
||||
isFetchingNextDocumentPage={isFetchingNextDocumentPage}
|
||||
isFetchingNextPage={isFetchingNextResultsPage}
|
||||
onAddDocument={() => openUploadForm()}
|
||||
onFilterChange={setFilter}
|
||||
onLoadMore={loadMoreResults}
|
||||
onDownloadDocument={handleDownloadDocument}
|
||||
onOpenMetadata={() => setMetadataOpen(true)}
|
||||
onOpenTasks={() => setTasksOpen(true)}
|
||||
onRemoveDocument={handleRemoveDocument}
|
||||
onRenameDocument={handleRenameDocument}
|
||||
onReindexDocument={(documentId) => void handleReindexDocument(documentId)}
|
||||
onRetryDocument={handleRetryDocument}
|
||||
onSearchChange={setSearch}
|
||||
onSelectAll={toggleAllFiltered}
|
||||
onSelectDocument={toggleDocument}
|
||||
onToggleDocumentAvailability={handleToggleDocumentAvailability}
|
||||
pendingDocumentAction={pendingDocumentAction}
|
||||
readOnlyReasonId={documentWriteRestrictionReasonId}
|
||||
resultsIncomplete={filteredResultsIncomplete}
|
||||
retryableDocumentIds={retryableDocumentIds}
|
||||
search={search}
|
||||
selectionDisabled={selectionDisabled}
|
||||
selectedDocumentIds={validSelectedDocumentIds}
|
||||
showTasks={Boolean(
|
||||
tasks.length ||
|
||||
tasksQuery.error ||
|
||||
tasksQuery.isFetchNextPageError ||
|
||||
hasNextTaskPage,
|
||||
)}
|
||||
someSelected={someFilteredSelected}
|
||||
sourcesPending={sourceResultsIncomplete}
|
||||
sourceNames={sourceNames}
|
||||
statusPending={dependencyResultsIncomplete}
|
||||
statuses={documentStatuses}
|
||||
tasksPending={taskResultsIncomplete}
|
||||
tasksButtonLabel={tasksButtonLabel}
|
||||
tasksLiveStatus={tasksLiveStatus}
|
||||
uploadRestrictionReasonId={documentUploadRestrictionReasonId}
|
||||
uploading={uploading}
|
||||
/>
|
||||
</DocumentUploadContent>
|
||||
)}
|
||||
{isFileDragActive && canUpload && (
|
||||
<DocumentDropOverlay fileSizeLimitMb={fileSizeLimitMb} />
|
||||
)}
|
||||
</section>
|
||||
</DocumentUploadSurface>
|
||||
<DocumentPermissionRecoveryBulkRegion>
|
||||
{bulkActionsVisible && (
|
||||
<DocumentBulkActions
|
||||
|
||||
413
web/features/new-rag/documents/upload/surface.tsx
Normal file
413
web/features/new-rag/documents/upload/surface.tsx
Normal file
@ -0,0 +1,413 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import type { DocumentUploadFormHandle } from '../../upload/form'
|
||||
import type { UploadExclusionReasonKey } from './model'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { knowledgeFsUploadEnabledAtom } from '@/features/system-features/state'
|
||||
import { KnowledgeModelSetupDialog } from '../../components/knowledge-model-setup-dialog'
|
||||
import { DocumentUploadForm } from '../../upload/form'
|
||||
import { uploadKnowledgeFsDocuments } from '../../upload/knowledge-fs-upload'
|
||||
import { documentUploadIssue } from '../../upload/policy'
|
||||
import { useKnowledgeFileSizeLimit } from '../../upload/use-file-size-limit'
|
||||
import { useKnowledgeModelSetupGuard } from '../../use-knowledge-model-setup-guard'
|
||||
import { DocumentDropOverlay } from '../list'
|
||||
import { documentUploadParser } from '../query-state'
|
||||
import { responseStatus } from '../request-error'
|
||||
import { DocumentStagingCanceledError } from './model'
|
||||
import { useDocumentUploadSession } from './use-document-upload-session'
|
||||
|
||||
export type DocumentUploadPermission = {
|
||||
canRead: boolean
|
||||
canWrite: boolean
|
||||
denyWrite: () => void
|
||||
}
|
||||
|
||||
export type DocumentUploadTrigger = {
|
||||
canUpload: boolean
|
||||
openUpload: (files?: File[]) => void
|
||||
readOnlyReasonId?: string
|
||||
uploadRestrictionReasonId?: string
|
||||
uploading: boolean
|
||||
}
|
||||
|
||||
type DocumentUploadSurfaceContextValue = DocumentUploadTrigger & {
|
||||
canRead: boolean
|
||||
canWrite: boolean
|
||||
fileSizeLimitMb: number
|
||||
formInitialFiles: File[]
|
||||
formOpen: boolean
|
||||
formRef: RefObject<DocumentUploadFormHandle | null>
|
||||
onCancel: () => void
|
||||
onFileRemoved: (file: File) => void
|
||||
onFilesAdded: (files: File[]) => Promise<void>
|
||||
onSubmit: (files: File[]) => Promise<boolean>
|
||||
stagedUploadProgress: ReturnType<typeof useDocumentUploadSession>['progress']
|
||||
uploadAvailable: boolean
|
||||
}
|
||||
|
||||
const DocumentUploadSurfaceContext = createContext<DocumentUploadSurfaceContextValue | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
function useDocumentUploadSurface() {
|
||||
const value = use(DocumentUploadSurfaceContext)
|
||||
if (!value)
|
||||
throw new Error('Document upload components must be used within DocumentUploadSurface')
|
||||
return value
|
||||
}
|
||||
|
||||
export function DocumentUploadSurface({
|
||||
bulkActionsVisible,
|
||||
children,
|
||||
knowledgeSpaceId,
|
||||
onUploadStarted,
|
||||
permission,
|
||||
}: {
|
||||
bulkActionsVisible: boolean
|
||||
children: ReactNode
|
||||
knowledgeSpaceId: string
|
||||
onUploadStarted: () => void
|
||||
permission: DocumentUploadPermission
|
||||
}) {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { canRead, canWrite, denyWrite } = permission
|
||||
const fileSizeLimitMb = useKnowledgeFileSizeLimit()
|
||||
const uploadAvailable = useAtomValue(knowledgeFsUploadEnabledAtom)
|
||||
const [uploadRequest, setUploadRequest] = useQueryState('upload', documentUploadParser)
|
||||
const [formInitialFiles, setFormInitialFiles] = useState<File[]>([])
|
||||
const [fileDragActive, setFileDragActive] = useState(false)
|
||||
const formRef = useRef<DocumentUploadFormHandle>(null)
|
||||
const fileDragDepthRef = useRef(0)
|
||||
const {
|
||||
beginUpload,
|
||||
completeUploads,
|
||||
discardAllStagedFiles,
|
||||
discardStagedFile,
|
||||
endUpload,
|
||||
prepareUploads,
|
||||
progress: stagedUploadProgress,
|
||||
resetProgress,
|
||||
stageFiles,
|
||||
updateProgress,
|
||||
uploading,
|
||||
uploadProgress,
|
||||
} = useDocumentUploadSession(knowledgeSpaceId)
|
||||
const {
|
||||
configureModelSetup,
|
||||
ensureModelReady,
|
||||
modelReadiness,
|
||||
modelSetupDialogOpen,
|
||||
setModelSetupDialogOpen,
|
||||
} = useKnowledgeModelSetupGuard(knowledgeSpaceId)
|
||||
|
||||
const canUpload = canWrite && uploadAvailable
|
||||
const formOpen = canUpload && uploadRequest === '1'
|
||||
const readOnlyReasonId = canWrite ? undefined : 'documents-readonly-reason'
|
||||
const uploadRestrictionReasonId = !uploadAvailable
|
||||
? 'documents-upload-unavailable'
|
||||
: readOnlyReasonId
|
||||
|
||||
const close = useCallback(() => {
|
||||
resetProgress()
|
||||
setFormInitialFiles([])
|
||||
void setUploadRequest(null)
|
||||
}, [resetProgress, setUploadRequest])
|
||||
|
||||
const openUpload = useCallback(
|
||||
(files: File[] = []) => {
|
||||
fileDragDepthRef.current = 0
|
||||
setFileDragActive(false)
|
||||
resetProgress()
|
||||
setFormInitialFiles(files)
|
||||
void setUploadRequest('1')
|
||||
},
|
||||
[resetProgress, setUploadRequest],
|
||||
)
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
discardAllStagedFiles()
|
||||
close()
|
||||
}, [close, discardAllStagedFiles])
|
||||
|
||||
useEffect(() => {
|
||||
if (uploadRequest !== '1' || canUpload) return
|
||||
discardAllStagedFiles()
|
||||
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Consume the route-owned one-shot signal after authorization resolves.
|
||||
void setUploadRequest(null)
|
||||
}, [canUpload, discardAllStagedFiles, setUploadRequest, uploadRequest])
|
||||
|
||||
const formatExclusionDetails = useCallback(
|
||||
(exclusions: Array<{ filename: string; reasonKey: UploadExclusionReasonKey }>) => {
|
||||
const detailItems = exclusions.slice(0, 3).map(({ filename, reasonKey }) => {
|
||||
const reason =
|
||||
reasonKey === 'fileSize'
|
||||
? t(($) => $['newKnowledge.documentUploadExclusion.fileSize'], {
|
||||
size: fileSizeLimitMb,
|
||||
})
|
||||
: t(($) => $[`newKnowledge.documentUploadExclusion.${reasonKey}`])
|
||||
return `${filename} (${reason})`
|
||||
})
|
||||
if (exclusions.length > detailItems.length)
|
||||
detailItems.push(
|
||||
t(($) => $['newKnowledge.documentUploadExclusion.more'], {
|
||||
count: exclusions.length - detailItems.length,
|
||||
}),
|
||||
)
|
||||
return detailItems.join('; ')
|
||||
},
|
||||
[fileSizeLimitMb, t],
|
||||
)
|
||||
|
||||
const uploadFiles = useCallback(
|
||||
async (files: File[]): Promise<boolean> => {
|
||||
if (!canUpload || !files.length || !beginUpload()) return false
|
||||
const uploadableFiles: File[] = []
|
||||
const localExclusions: Array<{
|
||||
filename: string
|
||||
reasonKey: UploadExclusionReasonKey
|
||||
}> = []
|
||||
for (const file of files) {
|
||||
const issue = documentUploadIssue(file, fileSizeLimitMb)
|
||||
if (issue) localExclusions.push({ filename: file.name, reasonKey: issue })
|
||||
else uploadableFiles.push(file)
|
||||
}
|
||||
if (!uploadableFiles.length) {
|
||||
toast.error(
|
||||
t(($) => $['newKnowledge.documentUploadRejected'], {
|
||||
details: formatExclusionDetails(localExclusions),
|
||||
}),
|
||||
)
|
||||
endUpload()
|
||||
return false
|
||||
}
|
||||
try {
|
||||
if ((await ensureModelReady({ capability: 'ingest', intent: 'upload' })).status !== 'ready')
|
||||
return false
|
||||
await stageFiles(uploadableFiles)
|
||||
const uploads = prepareUploads(uploadableFiles)
|
||||
await uploadKnowledgeFsDocuments(knowledgeSpaceId, uploads, uploadProgress, updateProgress)
|
||||
completeUploads()
|
||||
const exclusionDetails = formatExclusionDetails(localExclusions)
|
||||
if (localExclusions.length)
|
||||
toast.warning(
|
||||
t(($) => $['newKnowledge.documentUploadPartial'], {
|
||||
accepted: uploadableFiles.length,
|
||||
details: exclusionDetails,
|
||||
excluded: localExclusions.length,
|
||||
}),
|
||||
)
|
||||
else toast.success(t(($) => $['newKnowledge.documentUploadStarted']))
|
||||
onUploadStarted()
|
||||
return true
|
||||
} catch (error) {
|
||||
if (responseStatus(error) === 403) {
|
||||
cancel()
|
||||
denyWrite()
|
||||
} else toast.error(t(($) => $['newKnowledge.documentUploadFailed']))
|
||||
return false
|
||||
} finally {
|
||||
endUpload()
|
||||
}
|
||||
},
|
||||
[
|
||||
beginUpload,
|
||||
canUpload,
|
||||
cancel,
|
||||
completeUploads,
|
||||
endUpload,
|
||||
ensureModelReady,
|
||||
fileSizeLimitMb,
|
||||
formatExclusionDetails,
|
||||
knowledgeSpaceId,
|
||||
onUploadStarted,
|
||||
denyWrite,
|
||||
prepareUploads,
|
||||
stageFiles,
|
||||
t,
|
||||
updateProgress,
|
||||
uploadProgress,
|
||||
],
|
||||
)
|
||||
|
||||
const onFilesAdded = useCallback(
|
||||
async (files: File[]) => {
|
||||
try {
|
||||
await stageFiles(files)
|
||||
} catch (error) {
|
||||
if (error instanceof DocumentStagingCanceledError) return
|
||||
toast.error(t(($) => $['newKnowledge.documentUploadFailed']))
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[stageFiles, t],
|
||||
)
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (files: File[]) => {
|
||||
const uploaded = await uploadFiles(files)
|
||||
if (uploaded) close()
|
||||
return uploaded
|
||||
},
|
||||
[close, uploadFiles],
|
||||
)
|
||||
|
||||
const trigger = useMemo<DocumentUploadTrigger>(
|
||||
() => ({
|
||||
canUpload,
|
||||
openUpload,
|
||||
readOnlyReasonId,
|
||||
uploadRestrictionReasonId,
|
||||
uploading,
|
||||
}),
|
||||
[canUpload, openUpload, readOnlyReasonId, uploadRestrictionReasonId, uploading],
|
||||
)
|
||||
const context = useMemo<DocumentUploadSurfaceContextValue>(
|
||||
() => ({
|
||||
...trigger,
|
||||
canRead,
|
||||
canWrite,
|
||||
fileSizeLimitMb,
|
||||
formInitialFiles,
|
||||
formOpen,
|
||||
formRef,
|
||||
onCancel: cancel,
|
||||
onFileRemoved: discardStagedFile,
|
||||
onFilesAdded,
|
||||
onSubmit,
|
||||
stagedUploadProgress,
|
||||
uploadAvailable,
|
||||
}),
|
||||
[
|
||||
cancel,
|
||||
discardStagedFile,
|
||||
fileSizeLimitMb,
|
||||
formInitialFiles,
|
||||
formOpen,
|
||||
onFilesAdded,
|
||||
onSubmit,
|
||||
canRead,
|
||||
canWrite,
|
||||
stagedUploadProgress,
|
||||
trigger,
|
||||
uploadAvailable,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
<DocumentUploadSurfaceContext value={context}>
|
||||
<section
|
||||
className={cn(
|
||||
'relative flex min-h-full w-full flex-col gap-4 px-6 pt-3',
|
||||
bulkActionsVisible ? 'pb-[calc(7rem+env(safe-area-inset-bottom,0px))]' : 'pb-6',
|
||||
)}
|
||||
onDragEnter={(event) => {
|
||||
const types = Array.from(event.dataTransfer.types ?? [])
|
||||
if (types.length && !types.includes('Files')) return
|
||||
event.preventDefault()
|
||||
if (!canUpload || uploading) return
|
||||
fileDragDepthRef.current += 1
|
||||
setFileDragActive(true)
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (!fileDragDepthRef.current) return
|
||||
fileDragDepthRef.current -= 1
|
||||
if (!fileDragDepthRef.current) setFileDragActive(false)
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
const types = Array.from(event.dataTransfer.types ?? [])
|
||||
if (types.length && !types.includes('Files')) return
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = canUpload && !uploading ? 'copy' : 'none'
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
const types = Array.from(event.dataTransfer.types ?? [])
|
||||
if (types.length && !types.includes('Files')) return
|
||||
event.preventDefault()
|
||||
fileDragDepthRef.current = 0
|
||||
setFileDragActive(false)
|
||||
if (!canUpload || uploading) return
|
||||
const files = [...event.dataTransfer.files]
|
||||
if (!files.length) return
|
||||
if (formOpen) formRef.current?.addFiles(files)
|
||||
else openUpload(files)
|
||||
}}
|
||||
>
|
||||
{!uploadAvailable && (
|
||||
<span id="documents-upload-unavailable" className="sr-only">
|
||||
{t(($) => $['cornerLabel.unavailable'])}
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
{fileDragActive && canUpload && <DocumentDropOverlay fileSizeLimitMb={fileSizeLimitMb} />}
|
||||
</section>
|
||||
<KnowledgeModelSetupDialog
|
||||
open={modelSetupDialogOpen}
|
||||
readiness={modelReadiness}
|
||||
onConfigure={configureModelSetup}
|
||||
onOpenChange={setModelSetupDialogOpen}
|
||||
/>
|
||||
</DocumentUploadSurfaceContext>
|
||||
)
|
||||
}
|
||||
|
||||
export function DocumentUploadHeader() {
|
||||
const { canRead, canWrite, formOpen } = useDocumentUploadSurface()
|
||||
const { t } = useTranslation('dataset')
|
||||
|
||||
return (
|
||||
<header>
|
||||
<h2
|
||||
id="new-knowledge-documents-title"
|
||||
className="title-xl-semi-bold leading-6 text-text-primary"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{t(($) => (formOpen ? $['newKnowledge.addDocument'] : $['newKnowledge.documents']))}
|
||||
</h2>
|
||||
<p className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{t(($) =>
|
||||
formOpen
|
||||
? $['newKnowledge.uploadFilesDescription']
|
||||
: $['newKnowledge.documentsDescription'],
|
||||
)}
|
||||
</p>
|
||||
{canRead && !canWrite && (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export function DocumentUploadContent({
|
||||
children,
|
||||
}: {
|
||||
children: (trigger: DocumentUploadTrigger) => ReactNode
|
||||
}) {
|
||||
const context = useDocumentUploadSurface()
|
||||
if (!context.formOpen) return children(context)
|
||||
return (
|
||||
<DocumentUploadForm
|
||||
ref={context.formRef}
|
||||
fileSizeLimitMb={context.fileSizeLimitMb}
|
||||
initialFiles={context.formInitialFiles}
|
||||
uploadProgress={context.stagedUploadProgress}
|
||||
uploading={context.uploading}
|
||||
onCancel={context.onCancel}
|
||||
onFileRemoved={context.onFileRemoved}
|
||||
onFilesAdded={context.onFilesAdded}
|
||||
onSubmit={context.onSubmit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user