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.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. 'use client'
  2. import { useRouter } from 'next/navigation'
  3. import { useTranslation } from 'react-i18next'
  4. import type { DataSet } from '@/models/datasets'
  5. import { useSelector as useAppContextWithSelector } from '@/context/app-context'
  6. import { useKnowledge } from '@/hooks/use-knowledge'
  7. import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
  8. import type { Tag } from '@/app/components/base/tag-management/constant'
  9. import TagSelector from '@/app/components/base/tag-management/selector'
  10. import cn from '@/utils/classnames'
  11. import { useHover } from 'ahooks'
  12. import { RiFileTextFill, RiMoreFill, RiRobot2Fill } from '@remixicon/react'
  13. import Tooltip from '@/app/components/base/tooltip'
  14. import { useGetLanguage } from '@/context/i18n'
  15. import dayjs from 'dayjs'
  16. import relativeTime from 'dayjs/plugin/relativeTime'
  17. import { checkIsUsedInApp, deleteDataset } from '@/service/datasets'
  18. import RenameDatasetModal from '../../rename-modal'
  19. import Confirm from '@/app/components/base/confirm'
  20. import Toast from '@/app/components/base/toast'
  21. import CustomPopover from '@/app/components/base/popover'
  22. import Operations from './operations'
  23. import AppIcon from '@/app/components/base/app-icon'
  24. import CornerLabel from '@/app/components/base/corner-label'
  25. import { DOC_FORM_ICON_WITH_BG, DOC_FORM_TEXT } from '@/models/datasets'
  26. import { useExportPipelineDSL } from '@/service/use-pipeline'
  27. dayjs.extend(relativeTime)
  28. const EXTERNAL_PROVIDER = 'external'
  29. type DatasetCardProps = {
  30. dataset: DataSet
  31. onSuccess?: () => void
  32. }
  33. const DatasetCard = ({
  34. dataset,
  35. onSuccess,
  36. }: DatasetCardProps) => {
  37. const { t } = useTranslation()
  38. const { push } = useRouter()
  39. const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator)
  40. const [tags, setTags] = useState<Tag[]>(dataset.tags)
  41. const tagSelectorRef = useRef<HTMLDivElement>(null)
  42. const isHoveringTagSelector = useHover(tagSelectorRef)
  43. const [showRenameModal, setShowRenameModal] = useState(false)
  44. const [showConfirmDelete, setShowConfirmDelete] = useState(false)
  45. const [confirmMessage, setConfirmMessage] = useState<string>('')
  46. const [exporting, setExporting] = useState(false)
  47. const isExternalProvider = useMemo(() => {
  48. return dataset.provider === EXTERNAL_PROVIDER
  49. }, [dataset.provider])
  50. const isPipelineUnpublished = useMemo(() => {
  51. return dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published
  52. }, [dataset.runtime_mode, dataset.is_published])
  53. const isShowChunkingModeIcon = useMemo(() => {
  54. return dataset.doc_form && (dataset.runtime_mode !== 'rag_pipeline' || dataset.is_published)
  55. }, [dataset.doc_form, dataset.runtime_mode, dataset.is_published])
  56. const isShowDocModeInfo = useMemo(() => {
  57. return dataset.doc_form && dataset.indexing_technique && dataset.retrieval_model_dict?.search_method && (dataset.runtime_mode !== 'rag_pipeline' || dataset.is_published)
  58. }, [dataset.doc_form, dataset.indexing_technique, dataset.retrieval_model_dict?.search_method, dataset.runtime_mode, dataset.is_published])
  59. const chunkingModeIcon = dataset.doc_form ? DOC_FORM_ICON_WITH_BG[dataset.doc_form] : React.Fragment
  60. const Icon = isExternalProvider ? DOC_FORM_ICON_WITH_BG.external : chunkingModeIcon
  61. const iconInfo = dataset.icon_info || {
  62. icon: '📙',
  63. icon_type: 'emoji',
  64. icon_background: '#FFF4ED',
  65. icon_url: '',
  66. }
  67. const { formatIndexingTechniqueAndMethod } = useKnowledge()
  68. const documentCount = useMemo(() => {
  69. const availableDocCount = dataset.total_available_documents ?? 0
  70. if (availableDocCount === dataset.document_count)
  71. return `${dataset.document_count}`
  72. if (availableDocCount < dataset.document_count)
  73. return `${availableDocCount} / ${dataset.document_count}`
  74. }, [dataset.document_count, dataset.total_available_documents])
  75. const documentCountTooltip = useMemo(() => {
  76. const availableDocCount = dataset.total_available_documents ?? 0
  77. if (availableDocCount === dataset.document_count)
  78. return t('dataset.docAllEnabled', { count: availableDocCount })
  79. if (availableDocCount < dataset.document_count)
  80. return t('dataset.partialEnabled', { count: dataset.document_count, num: availableDocCount })
  81. }, [t, dataset.document_count, dataset.total_available_documents])
  82. const language = useGetLanguage()
  83. const formatTimeFromNow = useCallback((time: number) => {
  84. return dayjs(time * 1_000).locale(language === 'zh_Hans' ? 'zh-cn' : language.replace('_', '-')).fromNow()
  85. }, [language])
  86. const openRenameModal = useCallback(() => {
  87. setShowRenameModal(true)
  88. }, [])
  89. const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL()
  90. const handleExportPipeline = useCallback(async (include = false) => {
  91. const { pipeline_id, name } = dataset
  92. if (!pipeline_id)
  93. return
  94. if (exporting)
  95. return
  96. try {
  97. setExporting(true)
  98. const { data } = await exportPipelineConfig({
  99. pipelineId: pipeline_id,
  100. include,
  101. })
  102. const a = document.createElement('a')
  103. const file = new Blob([data], { type: 'application/yaml' })
  104. a.href = URL.createObjectURL(file)
  105. a.download = `${name}.pipeline`
  106. a.click()
  107. }
  108. catch {
  109. Toast.notify({ type: 'error', message: t('app.exportFailed') })
  110. }
  111. finally {
  112. setExporting(false)
  113. }
  114. }, [dataset, exportPipelineConfig, exporting, t])
  115. const detectIsUsedByApp = useCallback(async () => {
  116. try {
  117. const { is_using: isUsedByApp } = await checkIsUsedInApp(dataset.id)
  118. setConfirmMessage(isUsedByApp ? t('dataset.datasetUsedByApp')! : t('dataset.deleteDatasetConfirmContent')!)
  119. setShowConfirmDelete(true)
  120. }
  121. catch (e: any) {
  122. const res = await e.json()
  123. Toast.notify({ type: 'error', message: res?.message || 'Unknown error' })
  124. }
  125. }, [dataset.id, t])
  126. const onConfirmDelete = useCallback(async () => {
  127. try {
  128. await deleteDataset(dataset.id)
  129. Toast.notify({ type: 'success', message: t('dataset.datasetDeleted') })
  130. if (onSuccess)
  131. onSuccess()
  132. }
  133. finally {
  134. setShowConfirmDelete(false)
  135. }
  136. }, [dataset.id, onSuccess, t])
  137. useEffect(() => {
  138. setTags(dataset.tags)
  139. }, [dataset])
  140. return (
  141. <>
  142. <div
  143. className='group relative col-span-1 flex h-[166px] cursor-pointer flex-col rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-all duration-200 ease-in-out hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5'
  144. data-disable-nprogress={true}
  145. onClick={(e) => {
  146. e.preventDefault()
  147. isExternalProvider
  148. ? push(`/datasets/${dataset.id}/hitTesting`)
  149. // eslint-disable-next-line sonarjs/no-nested-conditional
  150. : isPipelineUnpublished
  151. ? push(`/datasets/${dataset.id}/pipeline`)
  152. : push(`/datasets/${dataset.id}/documents`)
  153. }}
  154. >
  155. {!dataset.embedding_available && (
  156. <CornerLabel
  157. label='Unavailable'
  158. className='absolute right-0 top-0 z-10'
  159. labelClassName='rounded-tr-xl' />
  160. )}
  161. <div className={cn('flex items-center gap-x-3 px-4 pb-2 pt-4', !dataset.embedding_available && 'opacity-30')}>
  162. <div className='relative shrink-0'>
  163. <AppIcon
  164. size='large'
  165. iconType={iconInfo.icon_type}
  166. icon={iconInfo.icon}
  167. background={iconInfo.icon_type === 'image' ? undefined : iconInfo.icon_background}
  168. imageUrl={iconInfo.icon_type === 'image' ? iconInfo.icon_url : undefined}
  169. />
  170. {(isShowChunkingModeIcon || isExternalProvider) && (
  171. <div className='absolute -bottom-1 -right-1 z-[5]'>
  172. <Icon className='size-4' />
  173. </div>
  174. )}
  175. </div>
  176. <div className='flex grow flex-col gap-y-1 overflow-hidden py-px'>
  177. <div
  178. className='system-md-semibold truncate text-text-secondary'
  179. title={dataset.name}
  180. >
  181. {dataset.name}
  182. </div>
  183. <div className='system-2xs-medium-uppercase flex items-center gap-x-3 text-text-tertiary'>
  184. {isExternalProvider && <span>{t('dataset.externalKnowledgeBase')}</span>}
  185. {!isExternalProvider && isShowDocModeInfo && (
  186. <>
  187. {dataset.doc_form && <span>{t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)}</span>}
  188. {dataset.indexing_technique && <span>{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</span>}
  189. </>
  190. )}
  191. </div>
  192. </div>
  193. </div>
  194. <div
  195. className={cn('system-xs-regular line-clamp-2 h-10 px-4 py-1 text-text-tertiary', !dataset.embedding_available && 'opacity-30')}
  196. title={dataset.description}
  197. >
  198. {dataset.description}
  199. </div>
  200. <div
  201. className={cn('relative w-full px-3', !dataset.embedding_available && 'opacity-30')}
  202. onClick={(e) => {
  203. e.stopPropagation()
  204. e.preventDefault()
  205. }}
  206. >
  207. <div
  208. ref={tagSelectorRef}
  209. className={cn(
  210. 'invisible w-full group-hover:visible',
  211. tags.length > 0 && 'visible',
  212. )}
  213. >
  214. <TagSelector
  215. position='bl'
  216. type='knowledge'
  217. targetID={dataset.id}
  218. value={tags.map(tag => tag.id)}
  219. selectedTags={tags}
  220. onCacheUpdate={setTags}
  221. onChange={onSuccess}
  222. />
  223. </div>
  224. {/* Tag Mask */}
  225. <div
  226. className={cn(
  227. 'absolute right-0 top-0 z-[5] h-full w-20 bg-tag-selector-mask-bg group-hover:bg-tag-selector-mask-hover-bg',
  228. isHoveringTagSelector && 'hidden',
  229. )}
  230. />
  231. </div>
  232. <div
  233. className={cn(
  234. 'flex items-center gap-x-3 px-4 pb-3 pt-2 text-text-tertiary',
  235. !dataset.embedding_available && 'opacity-30',
  236. )}
  237. >
  238. <Tooltip popupContent={documentCountTooltip} >
  239. <div className='flex items-center gap-x-1'>
  240. <RiFileTextFill className='size-3 text-text-quaternary' />
  241. <span className='system-xs-medium'>{documentCount}</span>
  242. </div>
  243. </Tooltip>
  244. {!isExternalProvider && (
  245. <Tooltip popupContent={`${dataset.app_count} ${t('dataset.appCount')}`}>
  246. <div className='flex items-center gap-x-1'>
  247. <RiRobot2Fill className='size-3 text-text-quaternary' />
  248. <span className='system-xs-medium'>{dataset.app_count}</span>
  249. </div>
  250. </Tooltip>
  251. )}
  252. <span className='system-xs-regular text-divider-deep'>/</span>
  253. <span className='system-xs-regular'>{`${t('dataset.updated')} ${formatTimeFromNow(dataset.updated_at)}`}</span>
  254. </div>
  255. <div className='absolute right-2 top-2 z-[5] hidden group-hover:block'>
  256. <CustomPopover
  257. htmlContent={
  258. <Operations
  259. showDelete={!isCurrentWorkspaceDatasetOperator}
  260. openRenameModal={openRenameModal}
  261. handleExportPipeline={handleExportPipeline}
  262. detectIsUsedByApp={detectIsUsedByApp}
  263. />
  264. }
  265. className={'z-20 min-w-[186px]'}
  266. popupClassName={'rounded-xl bg-none shadow-none ring-0 min-w-[186px]'}
  267. position='br'
  268. trigger='click'
  269. btnElement={
  270. <div className='flex size-8 items-center justify-center rounded-[10px] hover:bg-state-base-hover'>
  271. <RiMoreFill className='h-5 w-5 text-text-tertiary' />
  272. </div>
  273. }
  274. btnClassName={open =>
  275. cn(
  276. 'size-9 cursor-pointer justify-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0 shadow-lg shadow-shadow-shadow-5 ring-[2px] ring-inset ring-components-actionbar-bg hover:border-components-actionbar-border',
  277. open ? 'border-components-actionbar-border bg-state-base-hover' : '',
  278. )
  279. }
  280. />
  281. </div>
  282. </div>
  283. {showRenameModal && (
  284. <RenameDatasetModal
  285. show={showRenameModal}
  286. dataset={dataset}
  287. onClose={() => setShowRenameModal(false)}
  288. onSuccess={onSuccess}
  289. />
  290. )}
  291. {showConfirmDelete && (
  292. <Confirm
  293. title={t('dataset.deleteDatasetConfirmTitle')}
  294. content={confirmMessage}
  295. isShow={showConfirmDelete}
  296. onConfirm={onConfirmDelete}
  297. onCancel={() => setShowConfirmDelete(false)}
  298. />
  299. )}
  300. </>
  301. )
  302. }
  303. export default DatasetCard