You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. 'use client'
  2. import type { FC } from 'react'
  3. import React, { useCallback, useEffect, useMemo, useState } from 'react'
  4. import { useBoolean } from 'ahooks'
  5. import { ArrowDownIcon } from '@heroicons/react/24/outline'
  6. import { pick, uniq } from 'lodash-es'
  7. import {
  8. RiEditLine,
  9. RiGlobalLine,
  10. } from '@remixicon/react'
  11. import { useRouter } from 'next/navigation'
  12. import { useTranslation } from 'react-i18next'
  13. import ChunkingModeLabel from '../common/chunking-mode-label'
  14. import FileTypeIcon from '../../base/file-uploader/file-type-icon'
  15. import s from './style.module.css'
  16. import RenameModal from './rename-modal'
  17. import BatchAction from './detail/completed/common/batch-action'
  18. import cn from '@/utils/classnames'
  19. import Tooltip from '@/app/components/base/tooltip'
  20. import Toast from '@/app/components/base/toast'
  21. import type { Item } from '@/app/components/base/select'
  22. import { asyncRunSafe } from '@/utils'
  23. import { formatNumber } from '@/utils/format'
  24. import NotionIcon from '@/app/components/base/notion-icon'
  25. import ProgressBar from '@/app/components/base/progress-bar'
  26. import type { LegacyDataSourceInfo, LocalFileInfo, OnlineDocumentInfo, OnlineDriveInfo } from '@/models/datasets'
  27. import { ChunkingMode, DataSourceType, DocumentActionType, type SimpleDocumentDetail } from '@/models/datasets'
  28. import type { CommonResponse } from '@/models/common'
  29. import useTimestamp from '@/hooks/use-timestamp'
  30. import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '@/context/dataset-detail'
  31. import type { Props as PaginationProps } from '@/app/components/base/pagination'
  32. import Pagination from '@/app/components/base/pagination'
  33. import Checkbox from '@/app/components/base/checkbox'
  34. import { useDocumentArchive, useDocumentDelete, useDocumentDisable, useDocumentEnable } from '@/service/knowledge/use-document'
  35. import { extensionToFileType } from '@/app/components/datasets/hit-testing/utils/extension-to-file-type'
  36. import useBatchEditDocumentMetadata from '../metadata/hooks/use-batch-edit-document-metadata'
  37. import EditMetadataBatchModal from '@/app/components/datasets/metadata/edit-metadata-batch/modal'
  38. import StatusItem from './status-item'
  39. import Operations from './operations'
  40. import { DatasourceType } from '@/models/pipeline'
  41. export const renderTdValue = (value: string | number | null, isEmptyStyle = false) => {
  42. return (
  43. <div className={cn(isEmptyStyle ? 'text-text-tertiary' : 'text-text-secondary', s.tdValue)}>
  44. {value ?? '-'}
  45. </div>
  46. )
  47. }
  48. const renderCount = (count: number | undefined) => {
  49. if (!count)
  50. return renderTdValue(0, true)
  51. if (count < 1000)
  52. return count
  53. return `${formatNumber((count / 1000).toFixed(1))}k`
  54. }
  55. type LocalDoc = SimpleDocumentDetail & { percent?: number }
  56. type IDocumentListProps = {
  57. embeddingAvailable: boolean
  58. documents: LocalDoc[]
  59. selectedIds: string[]
  60. onSelectedIdChange: (selectedIds: string[]) => void
  61. datasetId: string
  62. pagination: PaginationProps
  63. onUpdate: () => void
  64. onManageMetadata: () => void
  65. statusFilter: Item
  66. onStatusFilterChange: (filter: string) => void
  67. }
  68. /**
  69. * Document list component including basic information
  70. */
  71. const DocumentList: FC<IDocumentListProps> = ({
  72. embeddingAvailable,
  73. documents = [],
  74. selectedIds,
  75. onSelectedIdChange,
  76. datasetId,
  77. pagination,
  78. onUpdate,
  79. onManageMetadata,
  80. statusFilter,
  81. }) => {
  82. const { t } = useTranslation()
  83. const { formatTime } = useTimestamp()
  84. const router = useRouter()
  85. const [datasetConfig] = useDatasetDetailContext(s => [s.dataset])
  86. const chunkingMode = datasetConfig?.doc_form
  87. const isGeneralMode = chunkingMode !== ChunkingMode.parentChild
  88. const isQAMode = chunkingMode === ChunkingMode.qa
  89. const [localDocs, setLocalDocs] = useState<LocalDoc[]>(documents)
  90. const [sortField, setSortField] = useState<'name' | 'word_count' | 'hit_count' | 'created_at' | null>('created_at')
  91. const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
  92. const {
  93. isShowEditModal,
  94. showEditModal,
  95. hideEditModal,
  96. originalList,
  97. handleSave,
  98. } = useBatchEditDocumentMetadata({
  99. datasetId,
  100. docList: documents.filter(doc => selectedIds.includes(doc.id)),
  101. selectedDocumentIds: selectedIds, // Pass all selected IDs separately
  102. onUpdate,
  103. })
  104. useEffect(() => {
  105. let filteredDocs = documents
  106. if (statusFilter.value !== 'all') {
  107. filteredDocs = filteredDocs.filter(doc =>
  108. typeof doc.display_status === 'string'
  109. && typeof statusFilter.value === 'string'
  110. && doc.display_status.toLowerCase() === statusFilter.value.toLowerCase(),
  111. )
  112. }
  113. if (!sortField) {
  114. setLocalDocs(filteredDocs)
  115. return
  116. }
  117. const sortedDocs = [...filteredDocs].sort((a, b) => {
  118. let aValue: any
  119. let bValue: any
  120. switch (sortField) {
  121. case 'name':
  122. aValue = a.name?.toLowerCase() || ''
  123. bValue = b.name?.toLowerCase() || ''
  124. break
  125. case 'word_count':
  126. aValue = a.word_count || 0
  127. bValue = b.word_count || 0
  128. break
  129. case 'hit_count':
  130. aValue = a.hit_count || 0
  131. bValue = b.hit_count || 0
  132. break
  133. case 'created_at':
  134. aValue = a.created_at
  135. bValue = b.created_at
  136. break
  137. default:
  138. return 0
  139. }
  140. if (sortField === 'name') {
  141. const result = aValue.localeCompare(bValue)
  142. return sortOrder === 'asc' ? result : -result
  143. }
  144. else {
  145. const result = aValue - bValue
  146. return sortOrder === 'asc' ? result : -result
  147. }
  148. })
  149. setLocalDocs(sortedDocs)
  150. }, [documents, sortField, sortOrder, statusFilter])
  151. const handleSort = (field: 'name' | 'word_count' | 'hit_count' | 'created_at') => {
  152. if (sortField === field) {
  153. setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
  154. }
  155. else {
  156. setSortField(field)
  157. setSortOrder('desc')
  158. }
  159. }
  160. const renderSortHeader = (field: 'name' | 'word_count' | 'hit_count' | 'created_at', label: string) => {
  161. const isActive = sortField === field
  162. const isDesc = isActive && sortOrder === 'desc'
  163. return (
  164. <div className='flex cursor-pointer items-center hover:text-text-secondary' onClick={() => handleSort(field)}>
  165. {label}
  166. <ArrowDownIcon
  167. className={cn('ml-0.5 h-3 w-3 stroke-current stroke-2 transition-all',
  168. isActive ? 'text-text-tertiary' : 'text-text-disabled',
  169. isActive && !isDesc ? 'rotate-180' : '',
  170. )}
  171. />
  172. </div>
  173. )
  174. }
  175. const [currDocument, setCurrDocument] = useState<LocalDoc | null>(null)
  176. const [isShowRenameModal, {
  177. setTrue: setShowRenameModalTrue,
  178. setFalse: setShowRenameModalFalse,
  179. }] = useBoolean(false)
  180. const handleShowRenameModal = useCallback((doc: LocalDoc) => {
  181. setCurrDocument(doc)
  182. setShowRenameModalTrue()
  183. }, [setShowRenameModalTrue])
  184. const handleRenamed = useCallback(() => {
  185. onUpdate()
  186. }, [onUpdate])
  187. const isAllSelected = useMemo(() => {
  188. return localDocs.length > 0 && localDocs.every(doc => selectedIds.includes(doc.id))
  189. }, [localDocs, selectedIds])
  190. const isSomeSelected = useMemo(() => {
  191. return localDocs.some(doc => selectedIds.includes(doc.id))
  192. }, [localDocs, selectedIds])
  193. const onSelectedAll = useCallback(() => {
  194. if (isAllSelected)
  195. onSelectedIdChange([])
  196. else
  197. onSelectedIdChange(uniq([...selectedIds, ...localDocs.map(doc => doc.id)]))
  198. }, [isAllSelected, localDocs, onSelectedIdChange, selectedIds])
  199. const { mutateAsync: archiveDocument } = useDocumentArchive()
  200. const { mutateAsync: enableDocument } = useDocumentEnable()
  201. const { mutateAsync: disableDocument } = useDocumentDisable()
  202. const { mutateAsync: deleteDocument } = useDocumentDelete()
  203. const handleAction = (actionName: DocumentActionType) => {
  204. return async () => {
  205. let opApi
  206. switch (actionName) {
  207. case DocumentActionType.archive:
  208. opApi = archiveDocument
  209. break
  210. case DocumentActionType.enable:
  211. opApi = enableDocument
  212. break
  213. case DocumentActionType.disable:
  214. opApi = disableDocument
  215. break
  216. default:
  217. opApi = deleteDocument
  218. break
  219. }
  220. const [e] = await asyncRunSafe<CommonResponse>(opApi({ datasetId, documentIds: selectedIds }) as Promise<CommonResponse>)
  221. if (!e) {
  222. if (actionName === DocumentActionType.delete)
  223. onSelectedIdChange([])
  224. Toast.notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
  225. onUpdate()
  226. }
  227. else { Toast.notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') }) }
  228. }
  229. }
  230. const getFileExtension = useCallback((fileName: string): string => {
  231. if (!fileName)
  232. return ''
  233. const parts = fileName.split('.')
  234. if (parts.length <= 1 || (parts[0] === '' && parts.length === 2))
  235. return ''
  236. return parts[parts.length - 1].toLowerCase()
  237. }, [])
  238. const isCreateFromRAGPipeline = useCallback((createFrom: string) => {
  239. return createFrom === 'rag-pipeline'
  240. }, [])
  241. /**
  242. * Calculate the data source type
  243. * DataSourceType: FILE, NOTION, WEB (legacy)
  244. * DatasourceType: localFile, onlineDocument, websiteCrawl, onlineDrive (new)
  245. */
  246. const isLocalFile = useCallback((dataSourceType: DataSourceType | DatasourceType, createFrom: string) => {
  247. if (createFrom === 'rag-pipeline')
  248. return dataSourceType === DatasourceType.localFile
  249. return dataSourceType === DataSourceType.FILE
  250. }, [])
  251. const isOnlineDocument = useCallback((dataSourceType: DataSourceType | DatasourceType, createFrom: string) => {
  252. if (createFrom === 'rag-pipeline')
  253. return dataSourceType === DatasourceType.onlineDocument
  254. return dataSourceType === DataSourceType.NOTION
  255. }, [])
  256. const isWebsiteCrawl = useCallback((dataSourceType: DataSourceType | DatasourceType, createFrom: string) => {
  257. if (createFrom === 'rag-pipeline')
  258. return dataSourceType === DatasourceType.websiteCrawl
  259. return dataSourceType === DataSourceType.WEB
  260. }, [])
  261. const isOnlineDrive = useCallback((dataSourceType: DataSourceType | DatasourceType, createFrom: string) => {
  262. if (createFrom === 'rag-pipeline')
  263. return dataSourceType === DatasourceType.onlineDrive
  264. return false
  265. }, [])
  266. return (
  267. <div className='relative flex h-full w-full flex-col'>
  268. <div className='relative grow overflow-x-auto'>
  269. <table className={`mt-3 w-full min-w-[700px] max-w-full border-collapse border-0 text-sm ${s.documentTable}`}>
  270. <thead className="h-8 border-b border-divider-subtle text-xs font-medium uppercase leading-8 text-text-tertiary">
  271. <tr>
  272. <td className='w-12'>
  273. <div className='flex items-center' onClick={e => e.stopPropagation()}>
  274. {embeddingAvailable && (
  275. <Checkbox
  276. className='mr-2 shrink-0'
  277. checked={isAllSelected}
  278. indeterminate={!isAllSelected && isSomeSelected}
  279. onCheck={onSelectedAll}
  280. />
  281. )}
  282. #
  283. </div>
  284. </td>
  285. <td>
  286. {renderSortHeader('name', t('datasetDocuments.list.table.header.fileName'))}
  287. </td>
  288. <td className='w-[130px]'>{t('datasetDocuments.list.table.header.chunkingMode')}</td>
  289. <td className='w-24'>
  290. {renderSortHeader('word_count', t('datasetDocuments.list.table.header.words'))}
  291. </td>
  292. <td className='w-44'>
  293. {renderSortHeader('hit_count', t('datasetDocuments.list.table.header.hitCount'))}
  294. </td>
  295. <td className='w-44'>
  296. {renderSortHeader('created_at', t('datasetDocuments.list.table.header.uploadTime'))}
  297. </td>
  298. <td className='w-40'>{t('datasetDocuments.list.table.header.status')}</td>
  299. <td className='w-20'>{t('datasetDocuments.list.table.header.action')}</td>
  300. </tr>
  301. </thead>
  302. <tbody className="text-text-secondary">
  303. {localDocs.map((doc, index) => {
  304. const isFile = isLocalFile(doc.data_source_type, doc.created_from)
  305. const createFromRAGPipeline = isCreateFromRAGPipeline(doc.created_from)
  306. const fileType = isFile ? doc.data_source_detail_dict?.upload_file?.extension : ''
  307. return <tr
  308. key={doc.id}
  309. className={'h-8 cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover'}
  310. onClick={() => {
  311. router.push(`/datasets/${datasetId}/documents/${doc.id}`)
  312. }}>
  313. <td className='text-left align-middle text-xs text-text-tertiary'>
  314. <div className='flex items-center' onClick={e => e.stopPropagation()}>
  315. <Checkbox
  316. className='mr-2 shrink-0'
  317. checked={selectedIds.includes(doc.id)}
  318. onCheck={() => {
  319. onSelectedIdChange(
  320. selectedIds.includes(doc.id)
  321. ? selectedIds.filter(id => id !== doc.id)
  322. : [...selectedIds, doc.id],
  323. )
  324. }}
  325. />
  326. {index + 1}
  327. </div>
  328. </td>
  329. <td>
  330. <div className={'group mr-6 flex max-w-[460px] items-center hover:mr-0'}>
  331. <div className='flex shrink-0 items-center'>
  332. {isOnlineDocument(doc.data_source_type, doc.created_from) && (
  333. <NotionIcon
  334. className='mr-1.5'
  335. type='page'
  336. src={
  337. createFromRAGPipeline
  338. ? (doc.data_source_info as OnlineDocumentInfo).page.page_icon
  339. : (doc.data_source_info as LegacyDataSourceInfo).notion_page_icon
  340. }
  341. />
  342. )}
  343. {isLocalFile(doc.data_source_type, doc.created_from) && (
  344. <FileTypeIcon
  345. type={
  346. extensionToFileType(
  347. createFromRAGPipeline
  348. ? (doc?.data_source_info as LocalFileInfo)?.extension
  349. : ((doc?.data_source_info as LegacyDataSourceInfo)?.upload_file?.extension ?? fileType),
  350. )
  351. }
  352. className='mr-1.5'
  353. />
  354. )}
  355. {isOnlineDrive(doc.data_source_type, doc.created_from) && (
  356. <FileTypeIcon
  357. type={
  358. extensionToFileType(
  359. getFileExtension((doc?.data_source_info as unknown as OnlineDriveInfo)?.name),
  360. )
  361. }
  362. className='mr-1.5'
  363. />
  364. )}
  365. {isWebsiteCrawl(doc.data_source_type, doc.created_from) && (
  366. <RiGlobalLine className='mr-1.5 size-4' />
  367. )}
  368. </div>
  369. <Tooltip
  370. popupContent={doc.name}
  371. >
  372. <span className='grow-1 truncate text-sm'>{doc.name}</span>
  373. </Tooltip>
  374. <div className='hidden shrink-0 group-hover:ml-auto group-hover:flex'>
  375. <Tooltip
  376. popupContent={t('datasetDocuments.list.table.rename')}
  377. >
  378. <div
  379. className='cursor-pointer rounded-md p-1 hover:bg-state-base-hover'
  380. onClick={(e) => {
  381. e.stopPropagation()
  382. handleShowRenameModal(doc)
  383. }}
  384. >
  385. <RiEditLine className='h-4 w-4 text-text-tertiary' />
  386. </div>
  387. </Tooltip>
  388. </div>
  389. </div>
  390. </td>
  391. <td>
  392. <ChunkingModeLabel
  393. isGeneralMode={isGeneralMode}
  394. isQAMode={isQAMode}
  395. />
  396. </td>
  397. <td>{renderCount(doc.word_count)}</td>
  398. <td>{renderCount(doc.hit_count)}</td>
  399. <td className='text-[13px] text-text-secondary'>
  400. {formatTime(doc.created_at, t('datasetHitTesting.dateTimeFormat') as string)}
  401. </td>
  402. <td>
  403. {
  404. (['indexing', 'splitting', 'parsing', 'cleaning'].includes(doc.indexing_status)
  405. && isOnlineDocument(doc.data_source_type, doc.created_from))
  406. ? <ProgressBar percent={doc.percent || 0} />
  407. : <StatusItem status={doc.display_status} />
  408. }
  409. </td>
  410. <td>
  411. <Operations
  412. embeddingAvailable={embeddingAvailable}
  413. datasetId={datasetId}
  414. detail={pick(doc, ['name', 'enabled', 'archived', 'id', 'data_source_type', 'doc_form', 'display_status'])}
  415. onUpdate={onUpdate}
  416. />
  417. </td>
  418. </tr>
  419. })}
  420. </tbody>
  421. </table>
  422. </div>
  423. {(selectedIds.length > 0) && (
  424. <BatchAction
  425. className='absolute bottom-16 left-0 z-20'
  426. selectedIds={selectedIds}
  427. onArchive={handleAction(DocumentActionType.archive)}
  428. onBatchEnable={handleAction(DocumentActionType.enable)}
  429. onBatchDisable={handleAction(DocumentActionType.disable)}
  430. onBatchDelete={handleAction(DocumentActionType.delete)}
  431. onEditMetadata={showEditModal}
  432. onCancel={() => {
  433. onSelectedIdChange([])
  434. }}
  435. />
  436. )}
  437. {/* Show Pagination only if the total is more than the limit */}
  438. {pagination.total && (
  439. <Pagination
  440. {...pagination}
  441. className='w-full shrink-0 px-0 pb-0'
  442. />
  443. )}
  444. {isShowRenameModal && currDocument && (
  445. <RenameModal
  446. datasetId={datasetId}
  447. documentId={currDocument.id}
  448. name={currDocument.name}
  449. onClose={setShowRenameModalFalse}
  450. onSaved={handleRenamed}
  451. />
  452. )}
  453. {isShowEditModal && (
  454. <EditMetadataBatchModal
  455. datasetId={datasetId}
  456. documentNum={selectedIds.length}
  457. list={originalList}
  458. onSave={handleSave}
  459. onHide={hideEditModal}
  460. onShowManage={() => {
  461. hideEditModal()
  462. onManageMetadata()
  463. }}
  464. />
  465. )}
  466. </div>
  467. )
  468. }
  469. export default DocumentList