This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,90 @@
import React, { useMemo } from 'react'
import Button from '@/app/components/base/button'
import { useTranslation } from 'react-i18next'
import { useParams } from 'next/navigation'
import { RiArrowRightLine } from '@remixicon/react'
import Link from 'next/link'
import Checkbox from '@/app/components/base/checkbox'
type ActionsProps = {
disabled?: boolean
handleNextStep: () => void
showSelect?: boolean
totalOptions?: number
selectedOptions?: number
onSelectAll?: () => void
tip?: string
}
const Actions = ({
disabled,
handleNextStep,
showSelect = false,
totalOptions,
selectedOptions,
onSelectAll,
tip = '',
}: ActionsProps) => {
const { t } = useTranslation()
const { datasetId } = useParams()
const indeterminate = useMemo(() => {
if (!showSelect) return false
if (selectedOptions === undefined || totalOptions === undefined) return false
return selectedOptions > 0 && selectedOptions < totalOptions
}, [showSelect, selectedOptions, totalOptions])
const checked = useMemo(() => {
if (!showSelect) return false
if (selectedOptions === undefined || totalOptions === undefined) return false
return selectedOptions > 0 && selectedOptions === totalOptions
}, [showSelect, selectedOptions, totalOptions])
return (
<div className='flex items-center gap-x-2 overflow-hidden'>
{showSelect && (
<>
<div className='flex shrink-0 items-center gap-x-2 py-[3px] pl-4 pr-2'>
<Checkbox
onCheck={onSelectAll}
indeterminate={indeterminate}
checked={checked}
/>
<span className='system-sm-medium text-text-accent'>
{t('common.operation.selectAll')}
</span>
</div>
{tip && (
<div title={tip} className='system-xs-regular max-w-full truncate text-text-tertiary'>
{tip}
</div>
)}
</>
)}
<div className='flex grow items-center justify-end gap-x-2'>
<Link
href={`/datasets/${datasetId}/documents`}
replace
>
<Button
variant='ghost'
className='px-3 py-2'
>
{t('common.operation.cancel')}
</Button>
</Link>
<Button
disabled={disabled}
variant='primary'
onClick={handleNextStep}
className='gap-x-0.5'
>
<span className='px-0.5'>{t('datasetCreation.stepOne.button')}</span>
<RiArrowRightLine className='size-4' />
</Button>
</div>
</div>
)
}
export default React.memo(Actions)

View File

@@ -0,0 +1,40 @@
import type { FC } from 'react'
import { memo } from 'react'
import cn from '@/utils/classnames'
type DatasourceIconProps = {
size?: string
className?: string
iconUrl: string
}
const ICON_CONTAINER_CLASSNAME_SIZE_MAP: Record<string, string> = {
xs: 'w-4 h-4 rounded-[5px] shadow-xs',
sm: 'w-5 h-5 rounded-md shadow-xs',
md: 'w-6 h-6 rounded-lg shadow-md',
}
const DatasourceIcon: FC<DatasourceIconProps> = ({
size = 'sm',
className,
iconUrl,
}) => {
return (
<div className={
cn(
'flex items-center justify-center shadow-none',
ICON_CONTAINER_CLASSNAME_SIZE_MAP[size],
className,
)}
>
<div
className='h-full w-full shrink-0 rounded-md bg-cover bg-center'
style={{
backgroundImage: `url(${iconUrl})`,
}}
/>
</div>
)
}
export default memo(DatasourceIcon)

View File

@@ -0,0 +1,23 @@
import { useMemo } from 'react'
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import { basePath } from '@/utils/var'
import { useDataSourceList } from '@/service/use-pipeline'
import { transformDataSourceToTool } from '@/app/components/workflow/block-selector/utils'
export const useDatasourceIcon = (data: DataSourceNodeType) => {
const { data: dataSourceListData, isSuccess } = useDataSourceList(true)
const datasourceIcon = useMemo(() => {
if (!isSuccess) return
const dataSourceList = [...(dataSourceListData || [])]
dataSourceList.forEach((item) => {
const icon = item.declaration.identity.icon
if (typeof icon == 'string' && !icon.includes(basePath))
item.declaration.identity.icon = `${basePath}${icon}`
})
const formattedDataSourceList = dataSourceList.map(item => transformDataSourceToTool(item))
return formattedDataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon
}, [data.plugin_id, dataSourceListData, isSuccess])
return datasourceIcon
}

View File

@@ -0,0 +1,52 @@
import { useCallback, useEffect } from 'react'
import { useDatasourceOptions } from '../hooks'
import OptionCard from './option-card'
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
import type { Node } from '@/app/components/workflow/types'
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
type DataSourceOptionsProps = {
pipelineNodes: Node<DataSourceNodeType>[]
datasourceNodeId: string
onSelect: (option: Datasource) => void
}
const DataSourceOptions = ({
pipelineNodes,
datasourceNodeId,
onSelect,
}: DataSourceOptionsProps) => {
const options = useDatasourceOptions(pipelineNodes)
const handelSelect = useCallback((value: string) => {
const selectedOption = options.find(option => option.value === value)
if (!selectedOption)
return
const datasource = {
nodeId: selectedOption.value,
nodeData: selectedOption.data,
}
onSelect(datasource)
}, [onSelect, options])
useEffect(() => {
if (options.length > 0 && !datasourceNodeId)
handelSelect(options[0].value)
}, [])
return (
<div className='grid w-full grid-cols-4 gap-1'>
{options.map(option => (
<OptionCard
key={option.value}
label={option.label}
selected={datasourceNodeId === option.value}
nodeData={option.data}
onClick={handelSelect.bind(null, option.value)}
/>
))}
</div>
)
}
export default DataSourceOptions

View File

@@ -0,0 +1,45 @@
import React from 'react'
import cn from '@/utils/classnames'
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import DatasourceIcon from './datasource-icon'
import { useDatasourceIcon } from './hooks'
type OptionCardProps = {
label: string
selected: boolean
nodeData: DataSourceNodeType
onClick?: () => void
}
const OptionCard = ({
label,
selected,
nodeData,
onClick,
}: OptionCardProps) => {
const iconUrl = useDatasourceIcon(nodeData) as string
return (
<div
className={cn(
'flex cursor-pointer items-center gap-2 rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg p-3 shadow-shadow-shadow-3',
selected
? 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs ring-[0.5px] ring-inset ring-components-option-card-option-selected-border'
: 'hover:bg-components-option-card-bg-hover hover:border-components-option-card-option-border-hover hover:shadow-xs',
)}
onClick={onClick}
>
<div className='flex size-8 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-panel-border bg-background-default-dodge p-1.5'>
<DatasourceIcon iconUrl={iconUrl} />
</div>
<div
className={cn('system-sm-medium line-clamp-2 grow text-text-secondary', selected && 'text-text-primary')}
title={label}
>
{label}
</div>
</div>
)
}
export default React.memo(OptionCard)

View File

@@ -0,0 +1,69 @@
import React, { useCallback, useEffect, useMemo } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import type { DataSourceCredential } from '@/types/pipeline'
import { useBoolean } from 'ahooks'
import Trigger from './trigger'
import List from './list'
export type CredentialSelectorProps = {
pluginName: string
currentCredentialId: string
onCredentialChange: (credentialId: string) => void
credentials: Array<DataSourceCredential>
}
const CredentialSelector = ({
pluginName,
currentCredentialId,
onCredentialChange,
credentials,
}: CredentialSelectorProps) => {
const [open, { toggle }] = useBoolean(false)
const currentCredential = useMemo(() => {
return credentials.find(cred => cred.id === currentCredentialId)
}, [credentials, currentCredentialId])
useEffect(() => {
if (!currentCredential && credentials.length)
onCredentialChange(credentials[0].id)
}, [currentCredential, credentials])
const handleCredentialChange = useCallback((credentialId: string) => {
onCredentialChange(credentialId)
toggle()
}, [onCredentialChange, toggle])
return (
<PortalToFollowElem
open={open}
onOpenChange={toggle}
placement='bottom-start'
offset={{
mainAxis: 4,
}}
>
<PortalToFollowElemTrigger onClick={toggle} className='grow overflow-hidden'>
<Trigger
currentCredential={currentCredential}
pluginName={pluginName}
isOpen={open}
/>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<List
currentCredentialId={currentCredentialId}
credentials={credentials}
pluginName={pluginName}
onCredentialChange={handleCredentialChange}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(CredentialSelector)

View File

@@ -0,0 +1,52 @@
import { CredentialIcon } from '@/app/components/datasets/common/credential-icon'
import type { DataSourceCredential } from '@/types/pipeline'
import { RiCheckLine } from '@remixicon/react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
type ItemProps = {
credential: DataSourceCredential
pluginName: string
isSelected: boolean
onCredentialChange: (credentialId: string) => void
}
const Item = ({
credential,
pluginName,
isSelected,
onCredentialChange,
}: ItemProps) => {
const { t } = useTranslation()
const { avatar_url, name } = credential
const handleCredentialChange = useCallback(() => {
onCredentialChange(credential.id)
}, [credential.id, onCredentialChange])
return (
<div
className='flex cursor-pointer items-center gap-x-2 rounded-lg p-2 hover:bg-state-base-hover'
onClick={handleCredentialChange}
>
<CredentialIcon
avatar_url={avatar_url}
name={name}
size={20}
/>
<span className='system-sm-medium grow truncate text-text-secondary'>
{t('datasetPipeline.credentialSelector.name', {
credentialName: name,
pluginName,
})}
</span>
{
isSelected && (
<RiCheckLine className='size-4 shrink-0 text-text-accent' />
)
}
</div>
)
}
export default React.memo(Item)

View File

@@ -0,0 +1,38 @@
import type { DataSourceCredential } from '@/types/pipeline'
import React from 'react'
import Item from './item'
type ListProps = {
currentCredentialId: string
credentials: Array<DataSourceCredential>
pluginName: string
onCredentialChange: (credentialId: string) => void
}
const List = ({
currentCredentialId,
credentials,
pluginName,
onCredentialChange,
}: ListProps) => {
return (
<div className='flex w-[280px] flex-col gap-y-1 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]'>
{
credentials.map((credential) => {
const isSelected = credential.id === currentCredentialId
return (
<Item
key={credential.id}
credential={credential}
pluginName={pluginName}
isSelected={isSelected}
onCredentialChange={onCredentialChange}
/>
)
})
}
</div>
)
}
export default React.memo(List)

View File

@@ -0,0 +1,51 @@
import React from 'react'
import type { DataSourceCredential } from '@/types/pipeline'
import { useTranslation } from 'react-i18next'
import { RiArrowDownSLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import { CredentialIcon } from '@/app/components/datasets/common/credential-icon'
type TriggerProps = {
currentCredential: DataSourceCredential | undefined
pluginName: string
isOpen: boolean
}
const Trigger = ({
currentCredential,
pluginName,
isOpen,
}: TriggerProps) => {
const { t } = useTranslation()
const {
avatar_url,
name = '',
} = currentCredential || {}
return (
<div
className={cn(
'flex cursor-pointer items-center gap-x-2 rounded-md p-1 pr-2',
isOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
>
<CredentialIcon
avatar_url={avatar_url}
name={name}
size={20}
/>
<div className='flex grow items-center gap-x-1 overflow-hidden'>
<span className='system-md-semibold grow truncate text-text-secondary'>
{t('datasetPipeline.credentialSelector.name', {
credentialName: name,
pluginName,
})}
</span>
<RiArrowDownSLine className='size-4 shrink-0 text-text-secondary' />
</div>
</div>
)
}
export default React.memo(Trigger)

View File

@@ -0,0 +1,60 @@
import React from 'react'
import Divider from '@/app/components/base/divider'
import Button from '@/app/components/base/button'
import { RiBookOpenLine, RiEqualizer2Line } from '@remixicon/react'
import type { CredentialSelectorProps } from './credential-selector'
import CredentialSelector from './credential-selector'
import Tooltip from '@/app/components/base/tooltip'
import { useTranslation } from 'react-i18next'
type HeaderProps = {
docTitle: string
docLink: string
onClickConfiguration?: () => void
} & CredentialSelectorProps
const Header = ({
docTitle,
docLink,
onClickConfiguration,
...rest
}: HeaderProps) => {
const { t } = useTranslation()
return (
<div className='flex items-center justify-between gap-x-2'>
<div className='flex items-center gap-x-1 overflow-hidden'>
<CredentialSelector
{...rest}
/>
<Divider type='vertical' className='mx-1 h-3.5 shrink-0' />
<Tooltip
popupContent={t('datasetPipeline.configurationTip', { pluginName: rest.pluginName })}
position='top'
>
<Button
variant='ghost'
size='small'
className='size-6 shrink-0 px-1'
>
<RiEqualizer2Line
className='h-4 w-4'
onClick={onClickConfiguration}
/>
</Button>
</Tooltip>
</div>
<a
className='system-xs-medium flex shrink-0 items-center gap-x-1 text-text-accent'
href={docLink}
target='_blank'
rel='noopener noreferrer'
>
<RiBookOpenLine className='size-3.5 shrink-0' />
<span title={docTitle}>{docTitle}</span>
</a>
</div>
)
}
export default React.memo(Header)

View File

@@ -0,0 +1,369 @@
'use client'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { RiDeleteBinLine, RiErrorWarningFill, RiUploadCloud2Line } from '@remixicon/react'
import DocumentFileIcon from '@/app/components/datasets/common/document-file-icon'
import cn from '@/utils/classnames'
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { ToastContext } from '@/app/components/base/toast'
import { upload } from '@/service/base'
import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils'
import I18n from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import { IS_CE_EDITION } from '@/config'
import { Theme } from '@/types/app'
import useTheme from '@/hooks/use-theme'
import { useFileUploadConfig } from '@/service/use-common'
import { useDataSourceStore, useDataSourceStoreWithSelector } from '../store'
import { produce } from 'immer'
import dynamic from 'next/dynamic'
const SimplePieChart = dynamic(() => import('@/app/components/base/simple-pie-chart'), { ssr: false })
const FILES_NUMBER_LIMIT = 20
export type LocalFileProps = {
allowedExtensions: string[]
notSupportBatchUpload?: boolean
}
const LocalFile = ({
allowedExtensions,
notSupportBatchUpload,
}: LocalFileProps) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const { locale } = useContext(I18n)
const localFileList = useDataSourceStoreWithSelector(state => state.localFileList)
const dataSourceStore = useDataSourceStore()
const [dragging, setDragging] = useState(false)
const dropRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<HTMLDivElement>(null)
const fileUploader = useRef<HTMLInputElement>(null)
const fileListRef = useRef<FileItem[]>([])
const hideUpload = notSupportBatchUpload && localFileList.length > 0
const { data: fileUploadConfigResponse } = useFileUploadConfig()
const supportTypesShowNames = useMemo(() => {
const extensionMap: { [key: string]: string } = {
md: 'markdown',
pptx: 'pptx',
htm: 'html',
xlsx: 'xlsx',
docx: 'docx',
}
return allowedExtensions
.map(item => extensionMap[item] || item) // map to standardized extension
.map(item => item.toLowerCase()) // convert to lower case
.filter((item, index, self) => self.indexOf(item) === index) // remove duplicates
.map(item => item.toUpperCase()) // convert to upper case
.join(locale !== LanguagesSupported[1] ? ', ' : '、 ')
}, [locale, allowedExtensions])
const ACCEPTS = allowedExtensions.map((ext: string) => `.${ext}`)
const fileUploadConfig = useMemo(() => fileUploadConfigResponse ?? {
file_size_limit: 15,
batch_count_limit: 5,
}, [fileUploadConfigResponse])
const updateFile = useCallback((fileItem: FileItem, progress: number, list: FileItem[]) => {
const { setLocalFileList } = dataSourceStore.getState()
const newList = produce(list, (draft) => {
const targetIndex = draft.findIndex(file => file.fileID === fileItem.fileID)
draft[targetIndex] = {
...draft[targetIndex],
progress,
}
})
setLocalFileList(newList)
}, [dataSourceStore])
const updateFileList = useCallback((preparedFiles: FileItem[]) => {
const { setLocalFileList } = dataSourceStore.getState()
setLocalFileList(preparedFiles)
}, [dataSourceStore])
const handlePreview = useCallback((file: File) => {
const { setCurrentLocalFile } = dataSourceStore.getState()
if (file.id)
setCurrentLocalFile(file)
}, [dataSourceStore])
// utils
const getFileType = (currentFile: File) => {
if (!currentFile)
return ''
const arr = currentFile.name.split('.')
return arr[arr.length - 1]
}
const getFileSize = (size: number) => {
if (size / 1024 < 10)
return `${(size / 1024).toFixed(2)}KB`
return `${(size / 1024 / 1024).toFixed(2)}MB`
}
const isValid = useCallback((file: File) => {
const { size } = file
const ext = `.${getFileType(file)}`
const isValidType = ACCEPTS.includes(ext.toLowerCase())
if (!isValidType)
notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.typeError') })
const isValidSize = size <= fileUploadConfig.file_size_limit * 1024 * 1024
if (!isValidSize)
notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.size', { size: fileUploadConfig.file_size_limit }) })
return isValidType && isValidSize
}, [fileUploadConfig, notify, t, ACCEPTS])
type UploadResult = Awaited<ReturnType<typeof upload>>
const fileUpload = useCallback(async (fileItem: FileItem): Promise<FileItem> => {
const formData = new FormData()
formData.append('file', fileItem.file)
const onProgress = (e: ProgressEvent) => {
if (e.lengthComputable) {
const percent = Math.floor(e.loaded / e.total * 100)
updateFile(fileItem, percent, fileListRef.current)
}
}
return upload({
xhr: new XMLHttpRequest(),
data: formData,
onprogress: onProgress,
}, false, undefined, '?source=datasets')
.then((res: UploadResult) => {
const updatedFile = Object.assign({}, fileItem.file, {
id: res.id,
...(res as Partial<File>),
}) as File
const completeFile: FileItem = {
fileID: fileItem.fileID,
file: updatedFile,
progress: -1,
}
const index = fileListRef.current.findIndex(item => item.fileID === fileItem.fileID)
fileListRef.current[index] = completeFile
updateFile(completeFile, 100, fileListRef.current)
return Promise.resolve({ ...completeFile })
})
.catch((e) => {
const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t)
notify({ type: 'error', message: errorMessage })
updateFile(fileItem, -2, fileListRef.current)
return Promise.resolve({ ...fileItem })
})
.finally()
}, [fileListRef, notify, updateFile, t])
const uploadBatchFiles = useCallback((bFiles: FileItem[]) => {
bFiles.forEach(bf => (bf.progress = 0))
return Promise.all(bFiles.map(fileUpload))
}, [fileUpload])
const uploadMultipleFiles = useCallback(async (files: FileItem[]) => {
const batchCountLimit = fileUploadConfig.batch_count_limit
const length = files.length
let start = 0
let end = 0
while (start < length) {
if (start + batchCountLimit > length)
end = length
else
end = start + batchCountLimit
const bFiles = files.slice(start, end)
await uploadBatchFiles(bFiles)
start = end
}
}, [fileUploadConfig, uploadBatchFiles])
const initialUpload = useCallback((files: File[]) => {
if (!files.length)
return false
if (files.length + localFileList.length > FILES_NUMBER_LIMIT && !IS_CE_EDITION) {
notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.filesNumber', { filesNumber: FILES_NUMBER_LIMIT }) })
return false
}
const preparedFiles = files.map((file, index) => ({
fileID: `file${index}-${Date.now()}`,
file,
progress: -1,
}))
const newFiles = [...fileListRef.current, ...preparedFiles]
updateFileList(newFiles)
fileListRef.current = newFiles
uploadMultipleFiles(preparedFiles)
}, [updateFileList, uploadMultipleFiles, notify, t, localFileList])
const handleDragEnter = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.target !== dragRef.current)
setDragging(true)
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDragLeave = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.target === dragRef.current)
setDragging(false)
}
const handleDrop = useCallback((e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragging(false)
if (!e.dataTransfer)
return
let files = [...e.dataTransfer.files] as File[]
if (notSupportBatchUpload)
files = files.slice(0, 1)
const validFiles = files.filter(isValid)
initialUpload(validFiles)
}, [initialUpload, isValid, notSupportBatchUpload])
const selectHandle = useCallback(() => {
if (fileUploader.current)
fileUploader.current.click()
}, [])
const removeFile = (fileID: string) => {
if (fileUploader.current)
fileUploader.current.value = ''
fileListRef.current = fileListRef.current.filter(item => item.fileID !== fileID)
updateFileList([...fileListRef.current])
}
const fileChangeHandle = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = [...(e.target.files ?? [])] as File[]
initialUpload(files.filter(isValid))
}, [isValid, initialUpload])
const { theme } = useTheme()
const chartColor = useMemo(() => theme === Theme.dark ? '#5289ff' : '#296dff', [theme])
useEffect(() => {
const dropElement = dropRef.current
dropElement?.addEventListener('dragenter', handleDragEnter)
dropElement?.addEventListener('dragover', handleDragOver)
dropElement?.addEventListener('dragleave', handleDragLeave)
dropElement?.addEventListener('drop', handleDrop)
return () => {
dropElement?.removeEventListener('dragenter', handleDragEnter)
dropElement?.removeEventListener('dragover', handleDragOver)
dropElement?.removeEventListener('dragleave', handleDragLeave)
dropElement?.removeEventListener('drop', handleDrop)
}
}, [handleDrop])
return (
<div className='flex flex-col'>
{!hideUpload && (
<input
ref={fileUploader}
id='fileUploader'
className='hidden'
type='file'
multiple={!notSupportBatchUpload}
accept={ACCEPTS.join(',')}
onChange={fileChangeHandle}
/>
)}
{!hideUpload && (
<div
ref={dropRef}
className={cn(
'relative box-border flex min-h-20 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg px-4 py-3 text-xs leading-4 text-text-tertiary',
dragging && 'border-components-dropzone-border-accent bg-components-dropzone-bg-accent',
)}>
<div className='flex min-h-5 items-center justify-center text-sm leading-4 text-text-secondary'>
<RiUploadCloud2Line className='mr-2 size-5' />
<span>
{notSupportBatchUpload ? t('datasetCreation.stepOne.uploader.buttonSingleFile') : t('datasetCreation.stepOne.uploader.button')}
{allowedExtensions.length > 0 && (
<label className='ml-1 cursor-pointer text-text-accent' onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.browse')}</label>
)}
</span>
</div>
<div>{t('datasetCreation.stepOne.uploader.tip', {
size: fileUploadConfig.file_size_limit,
supportTypes: supportTypesShowNames,
batchCount: notSupportBatchUpload ? 1 : fileUploadConfig.batch_count_limit,
})}</div>
{dragging && <div ref={dragRef} className='absolute left-0 top-0 h-full w-full' />}
</div>
)}
{localFileList.length > 0 && (
<div className='mt-1 flex flex-col gap-y-1'>
{localFileList.map((fileItem, index) => {
const isUploading = fileItem.progress >= 0 && fileItem.progress < 100
const isError = fileItem.progress === -2
return (
<div
key={`${fileItem.fileID}-${index}`}
onClick={handlePreview.bind(null, fileItem.file)}
className={cn(
'flex h-12 items-center rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs shadow-shadow-shadow-4',
isError && 'border-state-destructive-border bg-state-destructive-hover',
)}
>
<div className='flex w-12 shrink-0 items-center justify-center'>
<DocumentFileIcon
size='lg'
className='shrink-0'
name={fileItem.file.name}
extension={getFileType(fileItem.file)}
/>
</div>
<div className='flex shrink grow flex-col gap-0.5'>
<div className='flex w-full'>
<div className='w-0 grow truncate text-xs text-text-secondary'>{fileItem.file.name}</div>
</div>
<div className='w-full truncate text-2xs leading-3 text-text-tertiary'>
<span className='uppercase'>{getFileType(fileItem.file)}</span>
<span className='px-1 text-text-quaternary'>·</span>
<span>{getFileSize(fileItem.file.size)}</span>
</div>
</div>
<div className='flex w-16 shrink-0 items-center justify-end gap-1 pr-3'>
{isUploading && (
<SimplePieChart percentage={fileItem.progress} stroke={chartColor} fill={chartColor} animationDuration={0} />
)}
{
isError && (
<RiErrorWarningFill className='size-4 text-text-destructive' />
)
}
<span className='flex h-6 w-6 cursor-pointer items-center justify-center' onClick={(e) => {
e.stopPropagation()
removeFile(fileItem.fileID)
}}>
<RiDeleteBinLine className='size-4 text-text-tertiary' />
</span>
</div>
</div>
)
})}
</div>
)}
</div>
)
}
export default LocalFile

View File

@@ -0,0 +1,174 @@
import { useCallback, useEffect, useMemo } from 'react'
import SearchInput from '@/app/components/base/notion-page-selector/search-input'
import PageSelector from './page-selector'
import type { DataSourceNotionPageMap, DataSourceNotionWorkspace } from '@/models/common'
import Header from '../base/header'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { DatasourceType } from '@/models/pipeline'
import { ssePost } from '@/service/base'
import Toast from '@/app/components/base/toast'
import type { DataSourceNodeCompletedResponse, DataSourceNodeErrorResponse } from '@/types/pipeline'
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import { useDataSourceStore, useDataSourceStoreWithSelector } from '../store'
import { useShallow } from 'zustand/react/shallow'
import { useModalContextSelector } from '@/context/modal-context'
import Title from './title'
import { useGetDataSourceAuth } from '@/service/use-datasource'
import Loading from '@/app/components/base/loading'
import { useDocLink } from '@/context/i18n'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
type OnlineDocumentsProps = {
isInPipeline?: boolean
nodeId: string
nodeData: DataSourceNodeType
onCredentialChange: (credentialId: string) => void
}
const OnlineDocuments = ({
nodeId,
nodeData,
isInPipeline = false,
onCredentialChange,
}: OnlineDocumentsProps) => {
const docLink = useDocLink()
const pipelineId = useDatasetDetailContextWithSelector(s => s.dataset?.pipeline_id)
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
const {
documentsData,
searchValue,
selectedPagesId,
currentCredentialId,
} = useDataSourceStoreWithSelector(useShallow(state => ({
documentsData: state.documentsData,
searchValue: state.searchValue,
selectedPagesId: state.selectedPagesId,
currentCredentialId: state.currentCredentialId,
})))
const { data: dataSourceAuth } = useGetDataSourceAuth({
pluginId: nodeData.plugin_id,
provider: nodeData.provider_name,
})
const dataSourceStore = useDataSourceStore()
const PagesMapAndSelectedPagesId: DataSourceNotionPageMap = useMemo(() => {
const pagesMap = (documentsData || []).reduce((prev: DataSourceNotionPageMap, next: DataSourceNotionWorkspace) => {
next.pages.forEach((page) => {
prev[page.page_id] = {
...page,
workspace_id: next.workspace_id,
}
})
return prev
}, {})
return pagesMap
}, [documentsData])
const datasourceNodeRunURL = !isInPipeline
? `/rag/pipelines/${pipelineId}/workflows/published/datasource/nodes/${nodeId}/run`
: `/rag/pipelines/${pipelineId}/workflows/draft/datasource/nodes/${nodeId}/run`
const getOnlineDocuments = useCallback(async () => {
const { currentCredentialId } = dataSourceStore.getState()
ssePost(
datasourceNodeRunURL,
{
body: {
inputs: {},
credential_id: currentCredentialId,
datasource_type: DatasourceType.onlineDocument,
},
},
{
onDataSourceNodeCompleted: (documentsData: DataSourceNodeCompletedResponse) => {
const { setDocumentsData } = dataSourceStore.getState()
setDocumentsData(documentsData.data as DataSourceNotionWorkspace[])
},
onDataSourceNodeError: (error: DataSourceNodeErrorResponse) => {
Toast.notify({
type: 'error',
message: error.error,
})
},
},
)
}, [dataSourceStore, datasourceNodeRunURL])
useEffect(() => {
if (!currentCredentialId) return
getOnlineDocuments()
}, [currentCredentialId])
const handleSearchValueChange = useCallback((value: string) => {
const { setSearchValue } = dataSourceStore.getState()
setSearchValue(value)
}, [dataSourceStore])
const handleSelectPages = useCallback((newSelectedPagesId: Set<string>) => {
const { setSelectedPagesId, setOnlineDocuments } = dataSourceStore.getState()
const selectedPages = Array.from(newSelectedPagesId).map(pageId => PagesMapAndSelectedPagesId[pageId])
setSelectedPagesId(new Set(Array.from(newSelectedPagesId)))
setOnlineDocuments(selectedPages)
}, [dataSourceStore, PagesMapAndSelectedPagesId])
const handlePreviewPage = useCallback((previewPageId: string) => {
const { setCurrentDocument } = dataSourceStore.getState()
setCurrentDocument(PagesMapAndSelectedPagesId[previewPageId])
}, [PagesMapAndSelectedPagesId, dataSourceStore])
const handleSetting = useCallback(() => {
setShowAccountSettingModal({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
}, [setShowAccountSettingModal])
return (
<div className='flex flex-col gap-y-2'>
<Header
docTitle='Docs'
docLink={docLink('/guides/knowledge-base/knowledge-pipeline/authorize-data-source')}
onClickConfiguration={handleSetting}
pluginName={nodeData.datasource_label}
currentCredentialId={currentCredentialId}
onCredentialChange={onCredentialChange}
credentials={dataSourceAuth?.result || []}
/>
<div className='rounded-xl border border-components-panel-border bg-background-default-subtle'>
<div className='flex items-center gap-x-2 rounded-t-xl border-b border-b-divider-regular bg-components-panel-bg p-1 pl-3'>
<div className='flex grow items-center'>
<Title name={nodeData.datasource_label} />
</div>
<SearchInput
value={searchValue}
onChange={handleSearchValueChange}
/>
</div>
<div className='overflow-hidden rounded-b-xl'>
{documentsData?.length ? (
<PageSelector
checkedIds={selectedPagesId}
disabledValue={new Set()}
searchValue={searchValue}
list={documentsData[0].pages || []}
pagesMap={PagesMapAndSelectedPagesId}
onSelect={handleSelectPages}
canPreview={!isInPipeline}
onPreview={handlePreviewPage}
isMultipleChoice={!isInPipeline}
currentCredentialId={currentCredentialId}
/>
) : (
<div className='flex h-[296px] items-center justify-center'>
<Loading type='app' />
</div>
)}
</div>
</div>
</div>
)
}
export default OnlineDocuments

View File

@@ -0,0 +1,190 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FixedSizeList as List } from 'react-window'
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
import Item from './item'
import { recursivePushInParentDescendants } from './utils'
type PageSelectorProps = {
checkedIds: Set<string>
disabledValue: Set<string>
searchValue: string
pagesMap: DataSourceNotionPageMap
list: DataSourceNotionPage[]
onSelect: (selectedPagesId: Set<string>) => void
canPreview?: boolean
onPreview?: (selectedPageId: string) => void
isMultipleChoice?: boolean
currentCredentialId: string
}
export type NotionPageTreeItem = {
children: Set<string>
descendants: Set<string>
depth: number
ancestors: string[]
} & DataSourceNotionPage
export type NotionPageTreeMap = Record<string, NotionPageTreeItem>
type NotionPageItem = {
expand: boolean
depth: number
} & DataSourceNotionPage
const PageSelector = ({
checkedIds,
disabledValue,
searchValue,
pagesMap,
list,
onSelect,
canPreview = true,
onPreview,
isMultipleChoice = true,
currentCredentialId,
}: PageSelectorProps) => {
const { t } = useTranslation()
const [dataList, setDataList] = useState<NotionPageItem[]>([])
const [currentPreviewPageId, setCurrentPreviewPageId] = useState('')
useEffect(() => {
setDataList(list.filter(item => item.parent_id === 'root' || !pagesMap[item.parent_id]).map((item) => {
return {
...item,
expand: false,
depth: 0,
}
}))
}, [currentCredentialId])
const searchDataList = list.filter((item) => {
return item.page_name.includes(searchValue)
}).map((item) => {
return {
...item,
expand: false,
depth: 0,
}
})
const currentDataList = searchValue ? searchDataList : dataList
const listMapWithChildrenAndDescendants = useMemo(() => {
return list.reduce((prev: NotionPageTreeMap, next: DataSourceNotionPage) => {
const pageId = next.page_id
if (!prev[pageId])
prev[pageId] = { ...next, children: new Set(), descendants: new Set(), depth: 0, ancestors: [] }
recursivePushInParentDescendants(pagesMap, prev, prev[pageId], prev[pageId])
return prev
}, {})
}, [list, pagesMap])
const handleToggle = useCallback((index: number) => {
const current = dataList[index]
const pageId = current.page_id
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
const descendantsIds = Array.from(currentWithChildrenAndDescendants.descendants)
const childrenIds = Array.from(currentWithChildrenAndDescendants.children)
let newDataList = []
if (current.expand) {
current.expand = false
newDataList = dataList.filter(item => !descendantsIds.includes(item.page_id))
}
else {
current.expand = true
newDataList = [
...dataList.slice(0, index + 1),
...childrenIds.map(item => ({
...pagesMap[item],
expand: false,
depth: listMapWithChildrenAndDescendants[item].depth,
})),
...dataList.slice(index + 1),
]
}
setDataList(newDataList)
}, [dataList, listMapWithChildrenAndDescendants, pagesMap])
const handleCheck = useCallback((index: number) => {
const copyValue = new Set(checkedIds)
const current = currentDataList[index]
const pageId = current.page_id
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
if (copyValue.has(pageId)) {
if (!searchValue && isMultipleChoice) {
for (const item of currentWithChildrenAndDescendants.descendants)
copyValue.delete(item)
}
copyValue.delete(pageId)
}
else {
if (!searchValue && isMultipleChoice) {
for (const item of currentWithChildrenAndDescendants.descendants)
copyValue.add(item)
}
// Single choice mode, clear previous selection
if (!isMultipleChoice && copyValue.size > 0) {
copyValue.clear()
copyValue.add(pageId)
}
else {
copyValue.add(pageId)
}
}
onSelect(new Set(copyValue))
}, [currentDataList, isMultipleChoice, listMapWithChildrenAndDescendants, onSelect, searchValue, checkedIds])
const handlePreview = useCallback((index: number) => {
const current = currentDataList[index]
const pageId = current.page_id
setCurrentPreviewPageId(pageId)
if (onPreview)
onPreview(pageId)
}, [currentDataList, onPreview])
if (!currentDataList.length) {
return (
<div className='flex h-[296px] items-center justify-center text-[13px] text-text-tertiary'>
{t('common.dataSource.notion.selector.noSearchResult')}
</div>
)
}
return (
<List
className='py-2'
height={296}
itemCount={currentDataList.length}
itemSize={28}
width='100%'
itemKey={(index, data) => data.dataList[index].page_id}
itemData={{
dataList: currentDataList,
handleToggle,
checkedIds,
disabledCheckedIds: disabledValue,
handleCheck,
canPreview,
handlePreview,
listMapWithChildrenAndDescendants,
searchValue,
previewPageId: currentPreviewPageId,
pagesMap,
isMultipleChoice,
}}
>
{Item}
</List>
)
}
export default PageSelector

View File

@@ -0,0 +1,149 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { areEqual } from 'react-window'
import type { ListChildComponentProps } from 'react-window'
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import Checkbox from '@/app/components/base/checkbox'
import NotionIcon from '@/app/components/base/notion-icon'
import cn from '@/utils/classnames'
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
import Radio from '@/app/components/base/radio/ui'
type NotionPageTreeItem = {
children: Set<string>
descendants: Set<string>
depth: number
ancestors: string[]
} & DataSourceNotionPage
type NotionPageTreeMap = Record<string, NotionPageTreeItem>
type NotionPageItem = {
expand: boolean
depth: number
} & DataSourceNotionPage
const Item = ({ index, style, data }: ListChildComponentProps<{
dataList: NotionPageItem[]
handleToggle: (index: number) => void
checkedIds: Set<string>
disabledCheckedIds: Set<string>
handleCheck: (index: number) => void
canPreview?: boolean
handlePreview: (index: number) => void
listMapWithChildrenAndDescendants: NotionPageTreeMap
searchValue: string
previewPageId: string
pagesMap: DataSourceNotionPageMap
isMultipleChoice?: boolean
}>) => {
const { t } = useTranslation()
const {
dataList,
handleToggle,
checkedIds,
disabledCheckedIds,
handleCheck,
canPreview,
handlePreview,
listMapWithChildrenAndDescendants,
searchValue,
previewPageId,
pagesMap,
isMultipleChoice,
} = data
const current = dataList[index]
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[current.page_id]
const hasChild = currentWithChildrenAndDescendants.descendants.size > 0
const ancestors = currentWithChildrenAndDescendants.ancestors
const breadCrumbs = ancestors.length ? [...ancestors, current.page_name] : [current.page_name]
const disabled = disabledCheckedIds.has(current.page_id)
const renderArrow = () => {
if (hasChild) {
return (
<div
className='mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-md hover:bg-components-button-ghost-bg-hover'
style={{ marginLeft: current.depth * 8 }}
onClick={() => handleToggle(index)}
>
{
current.expand
? <RiArrowDownSLine className='h-4 w-4 text-text-tertiary' />
: <RiArrowRightSLine className='h-4 w-4 text-text-tertiary' />
}
</div>
)
}
if (current.parent_id === 'root' || !pagesMap[current.parent_id]) {
return (
<div></div>
)
}
return (
<div className='mr-1 h-5 w-5 shrink-0' style={{ marginLeft: current.depth * 8 }} />
)
}
return (
<div
className={cn('group flex cursor-pointer items-center rounded-md pl-2 pr-[2px] hover:bg-state-base-hover',
previewPageId === current.page_id && 'bg-state-base-hover')}
style={{ ...style, top: style.top as number + 8, left: 8, right: 8, width: 'calc(100% - 16px)' }}
>
{isMultipleChoice ? (
<Checkbox
className='mr-2 shrink-0'
checked={checkedIds.has(current.page_id)}
disabled={disabled}
onCheck={() => {
handleCheck(index)
}}
/>) : (
<Radio
className='mr-2 shrink-0'
isChecked={checkedIds.has(current.page_id)}
disabled={disabled}
onCheck={() => {
handleCheck(index)
}}
/>
)}
{!searchValue && renderArrow()}
<NotionIcon
className='mr-1 shrink-0'
type='page'
src={current.page_icon}
/>
<div
className='grow truncate text-[13px] font-medium leading-4 text-text-secondary'
title={current.page_name}
>
{current.page_name}
</div>
{
canPreview && (
<div
className='ml-1 hidden h-6 shrink-0 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-xs
font-medium leading-4 text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[10px]
hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover group-hover:flex'
onClick={() => handlePreview(index)}>
{t('common.dataSource.notion.selector.preview')}
</div>
)
}
{
searchValue && (
<div
className='ml-1 max-w-[120px] shrink-0 truncate text-xs text-text-quaternary'
title={breadCrumbs.join(' / ')}
>
{breadCrumbs.join(' / ')}
</div>
)
}
</div>
)
}
export default React.memo(Item, areEqual)

View File

@@ -0,0 +1,39 @@
import type { DataSourceNotionPageMap } from '@/models/common'
import type { NotionPageTreeItem, NotionPageTreeMap } from './index'
export const recursivePushInParentDescendants = (
pagesMap: DataSourceNotionPageMap,
listTreeMap: NotionPageTreeMap,
current: NotionPageTreeItem,
leafItem: NotionPageTreeItem,
) => {
const parentId = current.parent_id
const pageId = current.page_id
if (!parentId || !pageId)
return
if (parentId !== 'root' && pagesMap[parentId]) {
if (!listTreeMap[parentId]) {
const children = new Set([pageId])
const descendants = new Set([pageId, leafItem.page_id])
listTreeMap[parentId] = {
...pagesMap[parentId],
children,
descendants,
depth: 0,
ancestors: [],
}
}
else {
listTreeMap[parentId].children.add(pageId)
listTreeMap[parentId].descendants.add(pageId)
listTreeMap[parentId].descendants.add(leafItem.page_id)
}
leafItem.depth++
leafItem.ancestors.unshift(listTreeMap[parentId].page_name)
if (listTreeMap[parentId].parent_id !== 'root')
recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap[parentId], leafItem)
}
}

View File

@@ -0,0 +1,20 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
type TitleProps = {
name: string
}
const Title = ({
name,
}: TitleProps) => {
const { t } = useTranslation()
return (
<div className='system-sm-medium px-[5px] py-1 text-text-secondary'>
{t('datasetPipeline.onlineDocument.pageSelectorTitle', { name })}
</div>
)
}
export default React.memo(Title)

View File

@@ -0,0 +1,50 @@
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { Icon3Dots } from '@/app/components/base/icons/src/vender/line/others'
import BlockIcon from '@/app/components/workflow/block-icon'
import { useToolIcon } from '@/app/components/workflow/hooks'
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import { BlockEnum } from '@/app/components/workflow/types'
type ConnectProps = {
nodeData: DataSourceNodeType
onSetting: () => void
}
const Connect = ({
nodeData,
onSetting,
}: ConnectProps) => {
const { t } = useTranslation()
const toolIcon = useToolIcon(nodeData)
return (
<div className='flex flex-col items-start gap-y-2 rounded-xl bg-workflow-process-bg p-6'>
<div className='flex size-12 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg p-1 shadow-lg shadow-shadow-shadow-5'>
<BlockIcon
type={BlockEnum.DataSource}
toolIcon={toolIcon}
size='md'
/>
</div>
<div className='flex flex-col gap-y-1'>
<div className='flex flex-col gap-y-1 pb-3 pt-1'>
<div className='system-md-semibold text-text-secondary'>
<span className='relative'>
{t('datasetPipeline.onlineDrive.notConnected', { name: nodeData.title })}
<Icon3Dots className='absolute -right-2.5 -top-1.5 size-4 text-text-secondary' />
</span>
</div>
<div className='system-sm-regular text-text-tertiary'>
{t('datasetPipeline.onlineDrive.notConnectedTip', { name: nodeData.title })}
</div>
</div>
<Button className='w-fit' variant='primary' onClick={onSetting}>
{t('datasetCreation.stepOne.connect')}
</Button>
</div>
</div>
)
}
export default Connect

View File

@@ -0,0 +1,62 @@
import React, { useCallback } from 'react'
import { BucketsGray } from '@/app/components/base/icons/src/public/knowledge/online-drive'
import Tooltip from '@/app/components/base/tooltip'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
type BucketProps = {
bucketName: string
isActive?: boolean
disabled?: boolean
showSeparator?: boolean
handleBackToBucketList: () => void
handleClickBucketName: () => void
}
const Bucket = ({
bucketName,
handleBackToBucketList,
handleClickBucketName,
disabled = false,
isActive = false,
showSeparator = true,
}: BucketProps) => {
const { t } = useTranslation()
const handleClickItem = useCallback(() => {
if (!disabled)
handleClickBucketName()
}, [disabled, handleClickBucketName])
return (
<>
<Tooltip
popupContent={t('datasetPipeline.onlineDrive.breadcrumbs.allBuckets')}
>
<button
type='button'
className='flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover'
onClick={handleBackToBucketList}
>
<BucketsGray />
</button>
</Tooltip>
<span className='system-xs-regular text-divider-deep'>/</span>
<button
type='button'
className={cn(
'max-w-full shrink truncate rounded-md px-[5px] py-1',
isActive ? 'system-sm-medium text-text-secondary' : 'system-sm-regular text-text-tertiary',
!disabled && 'hover:bg-state-base-hover',
)}
disabled={disabled}
onClick={handleClickItem}
title={bucketName}
>
{bucketName}
</button>
{showSeparator && <span className='system-xs-regular shrink-0 text-divider-deep'>/</span>}
</>
)
}
export default React.memo(Bucket)

View File

@@ -0,0 +1,35 @@
import React from 'react'
import cn from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
type DriveProps = {
breadcrumbs: string[]
handleBackToRoot: () => void
}
const Drive = ({
breadcrumbs,
handleBackToRoot,
}: DriveProps) => {
const { t } = useTranslation()
return (
<>
<button
type='button'
className={cn(
'max-w-full shrink truncate rounded-md px-[5px] py-1',
breadcrumbs.length > 0 && 'system-sm-regular text-text-tertiary hover:bg-state-base-hover',
breadcrumbs.length === 0 && 'system-sm-medium text-text-secondary',
)}
onClick={handleBackToRoot}
disabled={breadcrumbs.length === 0}
>
{t('datasetPipeline.onlineDrive.breadcrumbs.allFiles')}
</button>
{breadcrumbs.length > 0 && <span className='system-xs-regular text-divider-deep'>/</span>}
</>
)
}
export default React.memo(Drive)

View File

@@ -0,0 +1,66 @@
import React, { useCallback, useState } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { RiMoreFill } from '@remixicon/react'
import cn from '@/utils/classnames'
import Menu from './menu'
type DropdownProps = {
startIndex: number
breadcrumbs: string[]
onBreadcrumbClick: (index: number) => void
}
const Dropdown = ({
startIndex,
breadcrumbs,
onBreadcrumbClick,
}: DropdownProps) => {
const [open, setOpen] = useState(false)
const handleTrigger = useCallback(() => {
setOpen(prev => !prev)
}, [])
const handleBreadCrumbClick = useCallback((index: number) => {
onBreadcrumbClick(index)
setOpen(false)
}, [onBreadcrumbClick])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={{
mainAxis: 4,
crossAxis: -13,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<button
type='button'
className={cn(
'flex size-6 items-center justify-center rounded-md',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
>
<RiMoreFill className='size-4 text-text-tertiary' />
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[11]'>
<Menu
breadcrumbs={breadcrumbs}
startIndex={startIndex}
onBreadcrumbClick={handleBreadCrumbClick}
/>
</PortalToFollowElemContent>
<span className='system-xs-regular text-divider-deep'>/</span>
</PortalToFollowElem>
)
}
export default React.memo(Dropdown)

View File

@@ -0,0 +1,28 @@
import React, { useCallback } from 'react'
type ItemProps = {
name: string
index: number
onBreadcrumbClick: (index: number) => void
}
const Item = ({
name,
index,
onBreadcrumbClick,
}: ItemProps) => {
const handleClick = useCallback(() => {
onBreadcrumbClick(index)
}, [index, onBreadcrumbClick])
return (
<div
className='system-md-regular rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'
onClick={handleClick}
>
{name}
</div>
)
}
export default React.memo(Item)

View File

@@ -0,0 +1,31 @@
import React from 'react'
import Item from './item'
type MenuProps = {
breadcrumbs: string[]
startIndex: number
onBreadcrumbClick: (index: number) => void
}
const Menu = ({
breadcrumbs,
startIndex,
onBreadcrumbClick,
}: MenuProps) => {
return (
<div className='flex w-[136px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]'>
{breadcrumbs.map((breadcrumb, index) => {
return (
<Item
key={`${breadcrumb}-${index}`}
name={breadcrumb}
index={startIndex + index}
onBreadcrumbClick={onBreadcrumbClick}
/>
)
})}
</div>
)
}
export default React.memo(Menu)

View File

@@ -0,0 +1,166 @@
import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useDataSourceStore, useDataSourceStoreWithSelector } from '../../../../store'
import Bucket from './bucket'
import BreadcrumbItem from './item'
import Dropdown from './dropdown'
import Drive from './drive'
type BreadcrumbsProps = {
breadcrumbs: string[]
keywords: string
bucket: string
searchResultsLength: number
isInPipeline: boolean
}
const Breadcrumbs = ({
breadcrumbs,
keywords,
bucket,
searchResultsLength,
isInPipeline,
}: BreadcrumbsProps) => {
const { t } = useTranslation()
const dataSourceStore = useDataSourceStore()
const hasBucket = useDataSourceStoreWithSelector(s => s.hasBucket)
const showSearchResult = !!keywords && searchResultsLength > 0
const showBucketListTitle = breadcrumbs.length === 0 && hasBucket && bucket === ''
const displayBreadcrumbNum = useMemo(() => {
const num = isInPipeline ? 2 : 3
return bucket ? num - 1 : num
}, [isInPipeline, bucket])
const breadcrumbsConfig = useMemo(() => {
const prefixToDisplay = breadcrumbs.slice(0, displayBreadcrumbNum - 1)
const collapsedBreadcrumbs = breadcrumbs.slice(displayBreadcrumbNum - 1, breadcrumbs.length - 1)
return {
original: breadcrumbs,
needCollapsed: breadcrumbs.length > displayBreadcrumbNum,
prefixBreadcrumbs: prefixToDisplay,
collapsedBreadcrumbs,
lastBreadcrumb: breadcrumbs[breadcrumbs.length - 1],
}
}, [displayBreadcrumbNum, breadcrumbs])
const handleBackToBucketList = useCallback(() => {
const { setOnlineDriveFileList, setSelectedFileIds, setBreadcrumbs, setPrefix, setBucket } = dataSourceStore.getState()
setOnlineDriveFileList([])
setSelectedFileIds([])
setBucket('')
setBreadcrumbs([])
setPrefix([])
}, [dataSourceStore])
const handleClickBucketName = useCallback(() => {
const { setOnlineDriveFileList, setSelectedFileIds, setBreadcrumbs, setPrefix } = dataSourceStore.getState()
setOnlineDriveFileList([])
setSelectedFileIds([])
setBreadcrumbs([])
setPrefix([])
}, [dataSourceStore])
const handleBackToRoot = useCallback(() => {
const { setOnlineDriveFileList, setSelectedFileIds, setBreadcrumbs, setPrefix } = dataSourceStore.getState()
setOnlineDriveFileList([])
setSelectedFileIds([])
setBreadcrumbs([])
setPrefix([])
}, [dataSourceStore])
const handleClickBreadcrumb = useCallback((index: number) => {
const { breadcrumbs, prefix, setOnlineDriveFileList, setSelectedFileIds, setBreadcrumbs, setPrefix } = dataSourceStore.getState()
const newBreadcrumbs = breadcrumbs.slice(0, index + 1)
const newPrefix = prefix.slice(0, index + 1)
setOnlineDriveFileList([])
setSelectedFileIds([])
setBreadcrumbs(newBreadcrumbs)
setPrefix(newPrefix)
}, [dataSourceStore])
return (
<div className='flex grow items-center overflow-hidden'>
{showSearchResult && (
<div className='system-sm-medium text-test-secondary px-[5px]'>
{t('datasetPipeline.onlineDrive.breadcrumbs.searchResult', {
searchResultsLength,
folderName: breadcrumbs.length > 0 ? breadcrumbs[breadcrumbs.length - 1] : bucket,
})}
</div>
)}
{!showSearchResult && showBucketListTitle && (
<div className='system-sm-medium text-test-secondary px-[5px]'>
{t('datasetPipeline.onlineDrive.breadcrumbs.allBuckets')}
</div>
)}
{!showSearchResult && !showBucketListTitle && (
<div className='flex w-full items-center gap-x-0.5 overflow-hidden'>
{hasBucket && bucket && (
<Bucket
bucketName={bucket}
handleBackToBucketList={handleBackToBucketList}
handleClickBucketName={handleClickBucketName}
isActive={breadcrumbs.length === 0}
disabled={breadcrumbs.length === 0}
showSeparator={breadcrumbs.length > 0}
/>
)}
{!hasBucket && (
<Drive
breadcrumbs={breadcrumbs}
handleBackToRoot={handleBackToRoot}
/>
)}
{!breadcrumbsConfig.needCollapsed && (
<>
{breadcrumbsConfig.original.map((breadcrumb, index) => {
const isLast = index === breadcrumbsConfig.original.length - 1
return (
<BreadcrumbItem
key={`${breadcrumb}-${index}`}
index={index}
handleClick={handleClickBreadcrumb}
name={breadcrumb}
isActive={isLast}
showSeparator={!isLast}
disabled={isLast}
/>
)
})}
</>
)}
{breadcrumbsConfig.needCollapsed && (
<>
{breadcrumbsConfig.prefixBreadcrumbs.map((breadcrumb, index) => {
return (
<BreadcrumbItem
key={`${breadcrumb}-${index}`}
index={index}
handleClick={handleClickBreadcrumb}
name={breadcrumb}
/>
)
})}
<Dropdown
startIndex={breadcrumbsConfig.prefixBreadcrumbs.length}
breadcrumbs={breadcrumbsConfig.collapsedBreadcrumbs}
onBreadcrumbClick={handleClickBreadcrumb}
/>
<BreadcrumbItem
index={breadcrumbs.length - 1}
handleClick={handleClickBreadcrumb}
name={breadcrumbsConfig.lastBreadcrumb}
isActive={true}
disabled={true}
showSeparator={false}
/>
</>
)}
</div>
)}
</div>
)
}
export default React.memo(Breadcrumbs)

View File

@@ -0,0 +1,48 @@
import React, { useCallback } from 'react'
import cn from '@/utils/classnames'
type BreadcrumbItemProps = {
name: string
index: number
handleClick: (index: number) => void
disabled?: boolean
isActive?: boolean
showSeparator?: boolean
}
const BreadcrumbItem = ({
name,
index,
handleClick,
disabled = false,
isActive = false,
showSeparator = true,
}: BreadcrumbItemProps) => {
const handleClickItem = useCallback(() => {
if (!disabled)
handleClick(index)
}, [disabled, handleClick, index])
return (
<>
<button
type='button'
className={cn(
'max-w-full shrink truncate rounded-md px-[5px] py-1',
isActive ? 'system-sm-medium text-text-secondary' : 'system-sm-regular text-text-tertiary',
!disabled && 'hover:bg-state-base-hover',
)}
disabled={disabled}
onClick={handleClickItem}
title={name}
>
{name}
</button>
{showSeparator && <span className='system-xs-regular shrink-0 text-divider-deep'>/</span>}
</>
)
}
BreadcrumbItem.displayName = 'BreadcrumbItem'
export default React.memo(BreadcrumbItem)

View File

@@ -0,0 +1,51 @@
import React from 'react'
import Breadcrumbs from './breadcrumbs'
import Input from '@/app/components/base/input'
import { useTranslation } from 'react-i18next'
type HeaderProps = {
breadcrumbs: string[]
inputValue: string
keywords: string
bucket: string
searchResultsLength: number
handleInputChange: React.ChangeEventHandler<HTMLInputElement>
handleResetKeywords: () => void
isInPipeline: boolean
}
const Header = ({
breadcrumbs,
inputValue,
keywords,
bucket,
isInPipeline,
searchResultsLength,
handleInputChange,
handleResetKeywords,
}: HeaderProps) => {
const { t } = useTranslation()
return (
<div className='flex items-center gap-x-2 bg-components-panel-bg p-1 pl-3'>
<Breadcrumbs
breadcrumbs={breadcrumbs}
keywords={keywords}
bucket={bucket}
searchResultsLength={searchResultsLength}
isInPipeline={isInPipeline}
/>
<Input
value={inputValue}
onChange={handleInputChange}
onClear={handleResetKeywords}
placeholder={t('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')}
showLeftIcon
showClearIcon
wrapperClassName='w-[200px] h-8 shrink-0'
/>
</div>
)
}
export default React.memo(Header)

View File

@@ -0,0 +1,82 @@
import type { OnlineDriveFile } from '@/models/pipeline'
import Header from './header'
import List from './list'
import { useState } from 'react'
import { useDebounceFn } from 'ahooks'
type FileListProps = {
fileList: OnlineDriveFile[]
selectedFileIds: string[]
breadcrumbs: string[]
keywords: string
bucket: string
isInPipeline: boolean
resetKeywords: () => void
updateKeywords: (keywords: string) => void
searchResultsLength: number
handleSelectFile: (file: OnlineDriveFile) => void
handleOpenFolder: (file: OnlineDriveFile) => void
isLoading: boolean
}
const FileList = ({
fileList,
selectedFileIds,
breadcrumbs,
keywords,
bucket,
resetKeywords,
updateKeywords,
searchResultsLength,
handleSelectFile,
handleOpenFolder,
isInPipeline,
isLoading,
}: FileListProps) => {
const [inputValue, setInputValue] = useState(keywords)
const { run: updateKeywordsWithDebounce } = useDebounceFn(
(keywords: string) => {
updateKeywords(keywords)
},
{ wait: 500 },
)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const keywords = e.target.value
setInputValue(keywords)
updateKeywordsWithDebounce(keywords)
}
const handleResetKeywords = () => {
setInputValue('')
resetKeywords()
}
return (
<div className='flex h-[400px] flex-col overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3'>
<Header
breadcrumbs={breadcrumbs}
inputValue={inputValue}
keywords={keywords}
bucket={bucket}
isInPipeline={isInPipeline}
handleInputChange={handleInputChange}
searchResultsLength={searchResultsLength}
handleResetKeywords={handleResetKeywords}
/>
<List
fileList={fileList}
selectedFileIds={selectedFileIds}
keywords={keywords}
handleResetKeywords={handleResetKeywords}
handleOpenFolder={handleOpenFolder}
handleSelectFile={handleSelectFile}
isInPipeline={isInPipeline}
isLoading={isLoading}
/>
</div>
)
}
export default FileList

View File

@@ -0,0 +1,14 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
const EmptyFolder = () => {
const { t } = useTranslation()
return (
<div className='flex size-full items-center justify-center rounded-[10px] bg-background-section px-1 py-1.5'>
<span className='system-xs-regular text-text-tertiary'>{t('datasetPipeline.onlineDrive.emptyFolder')}</span>
</div>
)
}
export default React.memo(EmptyFolder)

View File

@@ -0,0 +1,35 @@
import Button from '@/app/components/base/button'
import { SearchMenu } from '@/app/components/base/icons/src/vender/knowledge'
import React from 'react'
import { useTranslation } from 'react-i18next'
type EmptySearchResultProps = {
onResetKeywords: () => void
}
const EmptySearchResult = ({
onResetKeywords,
}: EmptySearchResultProps & {
className?: string
}) => {
const { t } = useTranslation()
return (
<div className='flex size-full flex-col items-center justify-center gap-y-2 rounded-[10px] bg-background-section p-6'>
<SearchMenu className='size-8 text-text-tertiary' />
<div className='system-sm-regular text-text-secondary'>
{t('datasetPipeline.onlineDrive.emptySearchResult')}
</div>
<Button
variant='secondary-accent'
size='small'
onClick={onResetKeywords}
className='px-1.5'
>
<span className='px-[3px]'>{t('datasetPipeline.onlineDrive.resetKeywords')}</span>
</Button>
</div>
)
}
export default React.memo(EmptySearchResult)

View File

@@ -0,0 +1,49 @@
import React, { useMemo } from 'react'
import { OnlineDriveFileType } from '@/models/pipeline'
import { BucketsBlue, Folder } from '@/app/components/base/icons/src/public/knowledge/online-drive'
import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon'
import { getFileType } from './utils'
import cn from '@/utils/classnames'
type FileIconProps = {
type: OnlineDriveFileType
fileName: string
size?: 'sm' | 'md' | 'lg' | 'xl'
className?: string
}
const FileIcon = ({
type,
fileName,
size = 'md',
className,
}: FileIconProps) => {
const fileType = useMemo(() => {
if (type === OnlineDriveFileType.bucket || type === OnlineDriveFileType.folder)
return 'custom'
return getFileType(fileName)
}, [type, fileName])
if (type === OnlineDriveFileType.bucket) {
return (
<BucketsBlue className={cn('size-[18px]', className)} />
)
}
if (type === OnlineDriveFileType.folder) {
return (
<Folder className={cn('size-[18px]', className)} />
)
}
return (
<FileTypeIcon
size={size}
type={fileType}
className={cn('size-[18px]', className)}
/>
)
}
export default React.memo(FileIcon)

View File

@@ -0,0 +1,102 @@
import React, { useEffect, useRef } from 'react'
import type { OnlineDriveFile } from '@/models/pipeline'
import Item from './item'
import EmptyFolder from './empty-folder'
import EmptySearchResult from './empty-search-result'
import Loading from '@/app/components/base/loading'
import { RiLoader2Line } from '@remixicon/react'
import { useDataSourceStore } from '../../../store'
type FileListProps = {
fileList: OnlineDriveFile[]
selectedFileIds: string[]
keywords: string
isInPipeline: boolean
isLoading: boolean
handleResetKeywords: () => void
handleSelectFile: (file: OnlineDriveFile) => void
handleOpenFolder: (file: OnlineDriveFile) => void
}
const List = ({
fileList,
selectedFileIds,
keywords,
handleResetKeywords,
handleSelectFile,
handleOpenFolder,
isInPipeline,
isLoading,
}: FileListProps) => {
const anchorRef = useRef<HTMLDivElement>(null)
const observerRef = useRef<IntersectionObserver>(null)
const dataSourceStore = useDataSourceStore()
useEffect(() => {
if (anchorRef.current) {
observerRef.current = new IntersectionObserver((entries) => {
const { setNextPageParameters, currentNextPageParametersRef, isTruncated } = dataSourceStore.getState()
if (entries[0].isIntersecting && isTruncated.current && !isLoading)
setNextPageParameters(currentNextPageParametersRef.current)
}, {
rootMargin: '100px',
})
observerRef.current.observe(anchorRef.current)
}
return () => observerRef.current?.disconnect()
}, [anchorRef, isLoading, dataSourceStore])
const isAllLoading = isLoading && fileList.length === 0 && keywords.length === 0
const isPartialLoading = isLoading && fileList.length > 0
const isEmptyFolder = !isLoading && fileList.length === 0 && keywords.length === 0
const isSearchResultEmpty = !isLoading && fileList.length === 0 && keywords.length > 0
return (
<div className='grow overflow-hidden p-1 pt-0'>
{
isAllLoading && (
<Loading type='app' />
)
}
{
isEmptyFolder && (
<EmptyFolder />
)
}
{
isSearchResultEmpty && (
<EmptySearchResult onResetKeywords={handleResetKeywords} />
)
}
{fileList.length > 0 && (
<div className='flex h-full flex-col gap-y-px overflow-y-auto rounded-[10px] bg-background-section px-1 py-1.5'>
{
fileList.map((file) => {
const isSelected = selectedFileIds.includes(file.id)
return (
<Item
key={file.id}
file={file}
isSelected={isSelected}
onSelect={handleSelectFile}
onOpen={handleOpenFolder}
isMultipleChoice={!isInPipeline}
/>
)
})
}
{
isPartialLoading && (
<div className='flex items-center justify-center py-2'>
<RiLoader2Line className='animation-spin size-4 text-text-tertiary' />
</div>
)
}
<div ref={anchorRef} className='h-0' />
</div>
)}
</div>
)
}
export default React.memo(List)

View File

@@ -0,0 +1,103 @@
import Checkbox from '@/app/components/base/checkbox'
import Radio from '@/app/components/base/radio/ui'
import type { OnlineDriveFile } from '@/models/pipeline'
import React, { useCallback } from 'react'
import FileIcon from './file-icon'
import { formatFileSize } from '@/utils/format'
import Tooltip from '@/app/components/base/tooltip'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
import type { Placement } from '@floating-ui/react'
type ItemProps = {
file: OnlineDriveFile
isSelected: boolean
disabled?: boolean
isMultipleChoice?: boolean
onSelect: (file: OnlineDriveFile) => void
onOpen: (file: OnlineDriveFile) => void
}
const Item = ({
file,
isSelected,
disabled = false,
isMultipleChoice = true,
onSelect,
onOpen,
}: ItemProps) => {
const { t } = useTranslation()
const { id, name, type, size } = file
const isBucket = type === 'bucket'
const isFolder = type === 'folder'
const Wrapper = disabled ? Tooltip : React.Fragment
const wrapperProps = disabled ? {
popupContent: t('datasetPipeline.onlineDrive.notSupportedFileType'),
position: 'top-end' as Placement,
offset: { mainAxis: 4, crossAxis: -104 },
} : {}
const handleSelect = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
onSelect(file)
}, [file, onSelect])
const handleClickItem = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
if (disabled) return
if (isBucket || isFolder) {
onOpen(file)
return
}
onSelect(file)
}, [disabled, file, isBucket, isFolder, onOpen, onSelect])
return (
<div
className='flex cursor-pointer items-center gap-2 rounded-md px-2 py-[3px] hover:bg-state-base-hover'
onClick={handleClickItem}
>
{!isBucket && isMultipleChoice && (
<Checkbox
className='shrink-0'
disabled={disabled}
id={id}
checked={isSelected}
onCheck={handleSelect}
/>
)}
{!isBucket && !isMultipleChoice && (
<Radio
className='shrink-0'
disabled={disabled}
isChecked={isSelected}
onCheck={handleSelect}
/>
)}
<Wrapper
{...wrapperProps}
>
<div
className={cn(
'flex grow items-center gap-x-1 overflow-hidden py-0.5',
disabled && 'opacity-30',
)}>
<FileIcon type={type} fileName={name} className='shrink-0 transform-gpu' />
<span
className='system-sm-medium grow truncate text-text-secondary'
title={name}
>
{name}
</span>
{!isFolder && typeof size === 'number' && (
<span className='system-xs-regular shrink-0 text-text-tertiary'>{formatFileSize(size)}</span>
)}
</div>
</Wrapper>
</div>
)
}
export default React.memo(Item)

View File

@@ -0,0 +1,51 @@
import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
export const getFileExtension = (fileName: string): string => {
if (!fileName)
return ''
const parts = fileName.split('.')
if (parts.length <= 1 || (parts[0] === '' && parts.length === 2))
return ''
return parts[parts.length - 1].toLowerCase()
}
export const getFileType = (fileName: string) => {
const extension = getFileExtension(fileName)
if (extension === 'gif')
return FileAppearanceTypeEnum.gif
if (FILE_EXTS.image.includes(extension.toUpperCase()))
return FileAppearanceTypeEnum.image
if (FILE_EXTS.video.includes(extension.toUpperCase()))
return FileAppearanceTypeEnum.video
if (FILE_EXTS.audio.includes(extension.toUpperCase()))
return FileAppearanceTypeEnum.audio
if (extension === 'html' || extension === 'htm' || extension === 'xml' || extension === 'json')
return FileAppearanceTypeEnum.code
if (extension === 'pdf')
return FileAppearanceTypeEnum.pdf
if (extension === 'md' || extension === 'markdown' || extension === 'mdx')
return FileAppearanceTypeEnum.markdown
if (extension === 'xlsx' || extension === 'xls' || extension === 'csv')
return FileAppearanceTypeEnum.excel
if (extension === 'docx' || extension === 'doc')
return FileAppearanceTypeEnum.word
if (extension === 'pptx' || extension === 'ppt')
return FileAppearanceTypeEnum.ppt
if (FILE_EXTS.document.includes(extension.toUpperCase()))
return FileAppearanceTypeEnum.document
return FileAppearanceTypeEnum.custom
}

View File

@@ -0,0 +1,48 @@
import React from 'react'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import { RiBookOpenLine, RiEqualizer2Line } from '@remixicon/react'
type HeaderProps = {
onClickConfiguration?: () => void
docTitle: string
docLink: string
}
const Header = ({
onClickConfiguration,
docTitle,
docLink,
}: HeaderProps) => {
return (
<div className='flex items-center gap-x-2'>
<div className='flex shrink-0 grow items-center gap-x-1'>
<div className='w-20 bg-black'>
{/* placeholder */}
</div>
<Divider type='vertical' className='mx-1 h-3.5' />
<Button
variant='ghost'
size='small'
className='px-1'
>
<RiEqualizer2Line
className='size-4'
onClick={onClickConfiguration}
/>
</Button>
</div>
<a
className='system-xs-medium flex items-center gap-x-1 overflow-hidden text-text-accent'
href={docLink}
target='_blank'
rel='noopener noreferrer'
>
<RiBookOpenLine className='size-3.5 shrink-0' />
<span className='grow truncate' title={docTitle}>{docTitle}</span>
</a>
</div>
)
}
export default React.memo(Header)

View File

@@ -0,0 +1,217 @@
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import Header from '../base/header'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import FileList from './file-list'
import type { OnlineDriveFile } from '@/models/pipeline'
import { DatasourceType, OnlineDriveFileType } from '@/models/pipeline'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { ssePost } from '@/service/base'
import type { DataSourceNodeCompletedResponse, DataSourceNodeErrorResponse } from '@/types/pipeline'
import Toast from '@/app/components/base/toast'
import { useDataSourceStore, useDataSourceStoreWithSelector } from '../store'
import { convertOnlineDriveData } from './utils'
import { produce } from 'immer'
import { useShallow } from 'zustand/react/shallow'
import { useModalContextSelector } from '@/context/modal-context'
import { useGetDataSourceAuth } from '@/service/use-datasource'
import { useDocLink } from '@/context/i18n'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
type OnlineDriveProps = {
nodeId: string
nodeData: DataSourceNodeType
isInPipeline?: boolean
onCredentialChange: (credentialId: string) => void
}
const OnlineDrive = ({
nodeId,
nodeData,
isInPipeline = false,
onCredentialChange,
}: OnlineDriveProps) => {
const docLink = useDocLink()
const [isInitialMount, setIsInitialMount] = useState(true)
const pipelineId = useDatasetDetailContextWithSelector(s => s.dataset?.pipeline_id)
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
const {
nextPageParameters,
breadcrumbs,
prefix,
keywords,
bucket,
selectedFileIds,
onlineDriveFileList,
currentCredentialId,
} = useDataSourceStoreWithSelector(useShallow(state => ({
nextPageParameters: state.nextPageParameters,
breadcrumbs: state.breadcrumbs,
prefix: state.prefix,
keywords: state.keywords,
bucket: state.bucket,
selectedFileIds: state.selectedFileIds,
onlineDriveFileList: state.onlineDriveFileList,
currentCredentialId: state.currentCredentialId,
})))
const dataSourceStore = useDataSourceStore()
const [isLoading, setIsLoading] = useState(false)
const isLoadingRef = useRef(false)
const { data: dataSourceAuth } = useGetDataSourceAuth({
pluginId: nodeData.plugin_id,
provider: nodeData.provider_name,
})
const datasourceNodeRunURL = !isInPipeline
? `/rag/pipelines/${pipelineId}/workflows/published/datasource/nodes/${nodeId}/run`
: `/rag/pipelines/${pipelineId}/workflows/draft/datasource/nodes/${nodeId}/run`
const getOnlineDriveFiles = useCallback(async () => {
if (isLoadingRef.current) return
const { nextPageParameters, prefix, bucket, onlineDriveFileList, currentCredentialId } = dataSourceStore.getState()
setIsLoading(true)
isLoadingRef.current = true
ssePost(
datasourceNodeRunURL,
{
body: {
inputs: {
prefix: prefix[prefix.length - 1],
bucket,
next_page_parameters: nextPageParameters,
max_keys: 30,
},
datasource_type: DatasourceType.onlineDrive,
credential_id: currentCredentialId,
},
},
{
onDataSourceNodeCompleted: (documentsData: DataSourceNodeCompletedResponse) => {
const { setOnlineDriveFileList, isTruncated, currentNextPageParametersRef, setHasBucket } = dataSourceStore.getState()
const {
fileList: newFileList,
isTruncated: newIsTruncated,
nextPageParameters: newNextPageParameters,
hasBucket: newHasBucket,
} = convertOnlineDriveData(documentsData.data, breadcrumbs, bucket)
setOnlineDriveFileList([...onlineDriveFileList, ...newFileList])
isTruncated.current = newIsTruncated
currentNextPageParametersRef.current = newNextPageParameters
setHasBucket(newHasBucket)
setIsLoading(false)
isLoadingRef.current = false
},
onDataSourceNodeError: (error: DataSourceNodeErrorResponse) => {
Toast.notify({
type: 'error',
message: error.error,
})
setIsLoading(false)
isLoadingRef.current = false
},
},
)
}, [datasourceNodeRunURL, dataSourceStore])
useEffect(() => {
if (!currentCredentialId) return
if (isInitialMount) {
// Only fetch files on initial mount if fileList is empty
if (onlineDriveFileList.length === 0)
getOnlineDriveFiles()
setIsInitialMount(false)
}
else {
getOnlineDriveFiles()
}
}, [nextPageParameters, prefix, bucket, currentCredentialId])
const filteredOnlineDriveFileList = useMemo(() => {
if (keywords)
return onlineDriveFileList.filter(file => file.name.toLowerCase().includes(keywords.toLowerCase()))
return onlineDriveFileList
}, [onlineDriveFileList, keywords])
const updateKeywords = useCallback((keywords: string) => {
const { setKeywords } = dataSourceStore.getState()
setKeywords(keywords)
}, [dataSourceStore])
const resetKeywords = useCallback(() => {
const { setKeywords } = dataSourceStore.getState()
setKeywords('')
}, [dataSourceStore])
const handleSelectFile = useCallback((file: OnlineDriveFile) => {
const { selectedFileIds, setSelectedFileIds } = dataSourceStore.getState()
if (file.type === OnlineDriveFileType.bucket) return
const newSelectedFileList = produce(selectedFileIds, (draft) => {
if (draft.includes(file.id)) {
const index = draft.indexOf(file.id)
draft.splice(index, 1)
}
else {
if (isInPipeline && draft.length >= 1) return
draft.push(file.id)
}
})
setSelectedFileIds(newSelectedFileList)
}, [dataSourceStore, isInPipeline])
const handleOpenFolder = useCallback((file: OnlineDriveFile) => {
const { breadcrumbs, prefix, setBreadcrumbs, setPrefix, setBucket, setOnlineDriveFileList, setSelectedFileIds } = dataSourceStore.getState()
if (file.type === OnlineDriveFileType.file) return
setOnlineDriveFileList([])
if (file.type === OnlineDriveFileType.bucket) {
setBucket(file.name)
}
else {
setSelectedFileIds([])
const newBreadcrumbs = produce(breadcrumbs, (draft) => {
draft.push(file.name)
})
const newPrefix = produce(prefix, (draft) => {
draft.push(file.id)
})
setBreadcrumbs(newBreadcrumbs)
setPrefix(newPrefix)
}
}, [dataSourceStore, getOnlineDriveFiles])
const handleSetting = useCallback(() => {
setShowAccountSettingModal({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
}, [setShowAccountSettingModal])
return (
<div className='flex flex-col gap-y-2'>
<Header
docTitle='Docs'
docLink={docLink('/guides/knowledge-base/knowledge-pipeline/authorize-data-source')}
onClickConfiguration={handleSetting}
pluginName={nodeData.datasource_label}
currentCredentialId={currentCredentialId}
onCredentialChange={onCredentialChange}
credentials={dataSourceAuth?.result || []}
/>
<FileList
fileList={filteredOnlineDriveFileList}
selectedFileIds={selectedFileIds}
breadcrumbs={breadcrumbs}
keywords={keywords}
bucket={bucket}
resetKeywords={resetKeywords}
updateKeywords={updateKeywords}
searchResultsLength={filteredOnlineDriveFileList.length}
handleSelectFile={handleSelectFile}
handleOpenFolder={handleOpenFolder}
isInPipeline={isInPipeline}
isLoading={isLoading}
/>
</div>
)
}
export default OnlineDrive

View File

@@ -0,0 +1,54 @@
import { type OnlineDriveFile, OnlineDriveFileType } from '@/models/pipeline'
import type { OnlineDriveData } from '@/types/pipeline'
export const isFile = (type: 'file' | 'folder'): boolean => {
return type === 'file'
}
export const isBucketListInitiation = (data: OnlineDriveData[], prefix: string[], bucket: string): boolean => {
if (bucket || prefix.length > 0) return false
const hasBucket = data.every(item => !!item.bucket)
return hasBucket && (data.length > 1 || (data.length === 1 && !!data[0].bucket && data[0].files.length === 0))
}
export const convertOnlineDriveData = (data: OnlineDriveData[], prefix: string[], bucket: string): {
fileList: OnlineDriveFile[],
isTruncated: boolean,
nextPageParameters: Record<string, any>
hasBucket: boolean
} => {
const fileList: OnlineDriveFile[] = []
let isTruncated = false
let nextPageParameters: Record<string, any> = {}
let hasBucket = false
if (data.length === 0)
return { fileList, isTruncated, nextPageParameters, hasBucket }
if (isBucketListInitiation(data, prefix, bucket)) {
data.forEach((item) => {
fileList.push({
id: item.bucket,
name: item.bucket,
type: OnlineDriveFileType.bucket,
})
})
hasBucket = true
}
else {
data[0].files.forEach((file) => {
const { id, name, size, type } = file
const isFileType = isFile(type)
fileList.push({
id,
name,
size: isFileType ? size : undefined,
type: isFileType ? OnlineDriveFileType.file : OnlineDriveFileType.folder,
})
})
isTruncated = data[0].is_truncated ?? false
nextPageParameters = data[0].next_page_parameters ?? {}
hasBucket = !!data[0].bucket
}
return { fileList, isTruncated, nextPageParameters, hasBucket }
}

View File

@@ -0,0 +1,45 @@
import { useContext } from 'react'
import { createStore, useStore } from 'zustand'
import { DataSourceContext } from './provider'
import type { CommonShape } from './slices/common'
import { createCommonSlice } from './slices/common'
import type { LocalFileSliceShape } from './slices/local-file'
import { createLocalFileSlice } from './slices/local-file'
import type { OnlineDocumentSliceShape } from './slices/online-document'
import { createOnlineDocumentSlice } from './slices/online-document'
import type { WebsiteCrawlSliceShape } from './slices/website-crawl'
import { createWebsiteCrawlSlice } from './slices/website-crawl'
import type { OnlineDriveSliceShape } from './slices/online-drive'
import { createOnlineDriveSlice } from './slices/online-drive'
export type DataSourceShape = CommonShape
& LocalFileSliceShape
& OnlineDocumentSliceShape
& WebsiteCrawlSliceShape
& OnlineDriveSliceShape
export const createDataSourceStore = () => {
return createStore<DataSourceShape>((...args) => ({
...createCommonSlice(...args),
...createLocalFileSlice(...args),
...createOnlineDocumentSlice(...args),
...createWebsiteCrawlSlice(...args),
...createOnlineDriveSlice(...args),
}))
}
export const useDataSourceStoreWithSelector = <T>(selector: (state: DataSourceShape) => T): T => {
const store = useContext(DataSourceContext)
if (!store)
throw new Error('Missing DataSourceContext.Provider in the tree')
return useStore(store, selector)
}
export const useDataSourceStore = () => {
const store = useContext(DataSourceContext)
if (!store)
throw new Error('Missing DataSourceContext.Provider in the tree')
return store
}

View File

@@ -0,0 +1,29 @@
import { createContext, useRef } from 'react'
import { createDataSourceStore } from './'
type DataSourceStoreApi = ReturnType<typeof createDataSourceStore>
type DataSourceContextType = DataSourceStoreApi | null
export const DataSourceContext = createContext<DataSourceContextType>(null)
type DataSourceProviderProps = {
children: React.ReactNode
}
const DataSourceProvider = ({
children,
}: DataSourceProviderProps) => {
const storeRef = useRef<DataSourceStoreApi>(null)
if (!storeRef.current)
storeRef.current = createDataSourceStore()
return (
<DataSourceContext.Provider value={storeRef.current!}>
{children}
</DataSourceContext.Provider>
)
}
export default DataSourceProvider

View File

@@ -0,0 +1,19 @@
import type { StateCreator } from 'zustand'
export type CommonShape = {
currentNodeIdRef: React.RefObject<string>
currentCredentialId: string
setCurrentCredentialId: (credentialId: string) => void
currentCredentialIdRef: React.RefObject<string>
}
export const createCommonSlice: StateCreator<CommonShape> = (set) => {
return ({
currentNodeIdRef: { current: '' },
currentCredentialId: '',
setCurrentCredentialId: (credentialId: string) => {
set({ currentCredentialId: credentialId })
},
currentCredentialIdRef: { current: '' },
})
}

View File

@@ -0,0 +1,28 @@
import type { StateCreator } from 'zustand'
import type { DocumentItem, CustomFile as File, FileItem } from '@/models/datasets'
export type LocalFileSliceShape = {
localFileList: FileItem[]
setLocalFileList: (fileList: FileItem[]) => void
currentLocalFile: File | undefined
setCurrentLocalFile: (file: File | undefined) => void
previewLocalFileRef: React.RefObject<DocumentItem | undefined>
}
export const createLocalFileSlice: StateCreator<LocalFileSliceShape> = (set, get) => {
return ({
localFileList: [],
setLocalFileList: (fileList: FileItem[]) => {
set(() => ({
localFileList: fileList,
}))
const { previewLocalFileRef } = get()
previewLocalFileRef.current = fileList[0]?.file as DocumentItem
},
currentLocalFile: undefined,
setCurrentLocalFile: (file: File | undefined) => set(() => ({
currentLocalFile: file,
})),
previewLocalFileRef: { current: undefined },
})
}

View File

@@ -0,0 +1,46 @@
import type { StateCreator } from 'zustand'
import type { DataSourceNotionWorkspace, NotionPage } from '@/models/common'
export type OnlineDocumentSliceShape = {
documentsData: DataSourceNotionWorkspace[]
setDocumentsData: (documentData: DataSourceNotionWorkspace[]) => void
searchValue: string
setSearchValue: (searchValue: string) => void
onlineDocuments: NotionPage[]
setOnlineDocuments: (documents: NotionPage[]) => void
currentDocument: NotionPage | undefined
setCurrentDocument: (document: NotionPage | undefined) => void
selectedPagesId: Set<string>
setSelectedPagesId: (selectedPagesId: Set<string>) => void
previewOnlineDocumentRef: React.RefObject<NotionPage | undefined>
}
export const createOnlineDocumentSlice: StateCreator<OnlineDocumentSliceShape> = (set, get) => {
return ({
documentsData: [],
setDocumentsData: (documentsData: DataSourceNotionWorkspace[]) => set(() => ({
documentsData,
})),
searchValue: '',
setSearchValue: (searchValue: string) => set(() => ({
searchValue,
})),
onlineDocuments: [],
setOnlineDocuments: (documents: NotionPage[]) => {
set(() => ({
onlineDocuments: documents,
}))
const { previewOnlineDocumentRef } = get()
previewOnlineDocumentRef.current = documents[0]
},
currentDocument: undefined,
setCurrentDocument: (document: NotionPage | undefined) => set(() => ({
currentDocument: document,
})),
selectedPagesId: new Set(),
setSelectedPagesId: (selectedPagesId: Set<string>) => set(() => ({
selectedPagesId,
})),
previewOnlineDocumentRef: { current: undefined },
})
}

View File

@@ -0,0 +1,69 @@
import type { StateCreator } from 'zustand'
import type { OnlineDriveFile } from '@/models/pipeline'
export type OnlineDriveSliceShape = {
breadcrumbs: string[]
setBreadcrumbs: (breadcrumbs: string[]) => void
prefix: string[]
setPrefix: (prefix: string[]) => void
keywords: string
setKeywords: (keywords: string) => void
selectedFileIds: string[]
setSelectedFileIds: (selectedFileIds: string[]) => void
onlineDriveFileList: OnlineDriveFile[]
setOnlineDriveFileList: (onlineDriveFileList: OnlineDriveFile[]) => void
bucket: string
setBucket: (bucket: string) => void
nextPageParameters: Record<string, any>
currentNextPageParametersRef: React.RefObject<Record<string, any>>
setNextPageParameters: (nextPageParameters: Record<string, any>) => void
isTruncated: React.RefObject<boolean>
previewOnlineDriveFileRef: React.RefObject<OnlineDriveFile | undefined>
hasBucket: boolean
setHasBucket: (hasBucket: boolean) => void
}
export const createOnlineDriveSlice: StateCreator<OnlineDriveSliceShape> = (set, get) => {
return ({
breadcrumbs: [],
setBreadcrumbs: (breadcrumbs: string[]) => set(() => ({
breadcrumbs,
})),
prefix: [],
setPrefix: (prefix: string[]) => set(() => ({
prefix,
})),
keywords: '',
setKeywords: (keywords: string) => set(() => ({
keywords,
})),
selectedFileIds: [],
setSelectedFileIds: (selectedFileIds: string[]) => {
set(() => ({
selectedFileIds,
}))
const id = selectedFileIds[0]
const { onlineDriveFileList, previewOnlineDriveFileRef } = get()
previewOnlineDriveFileRef.current = onlineDriveFileList.find(file => file.id === id)
},
onlineDriveFileList: [],
setOnlineDriveFileList: (onlineDriveFileList: OnlineDriveFile[]) => set(() => ({
onlineDriveFileList,
})),
bucket: '',
setBucket: (bucket: string) => set(() => ({
bucket,
})),
nextPageParameters: {},
currentNextPageParametersRef: { current: {} },
setNextPageParameters: (nextPageParameters: Record<string, any>) => set(() => ({
nextPageParameters,
})),
isTruncated: { current: false },
previewOnlineDriveFileRef: { current: undefined },
hasBucket: false,
setHasBucket: (hasBucket: boolean) => set(() => ({
hasBucket,
})),
})
}

View File

@@ -0,0 +1,47 @@
import type { StateCreator } from 'zustand'
import type { CrawlResult, CrawlResultItem } from '@/models/datasets'
import { CrawlStep } from '@/models/datasets'
export type WebsiteCrawlSliceShape = {
websitePages: CrawlResultItem[]
setWebsitePages: (pages: CrawlResultItem[]) => void
currentWebsite: CrawlResultItem | undefined
setCurrentWebsite: (website: CrawlResultItem | undefined) => void
crawlResult: CrawlResult | undefined
setCrawlResult: (result: CrawlResult | undefined) => void
step: CrawlStep
setStep: (step: CrawlStep) => void
previewIndex: number
setPreviewIndex: (index: number) => void
previewWebsitePageRef: React.RefObject<CrawlResultItem | undefined>
}
export const createWebsiteCrawlSlice: StateCreator<WebsiteCrawlSliceShape> = (set, get) => {
return ({
websitePages: [],
setWebsitePages: (pages: CrawlResultItem[]) => {
set(() => ({
websitePages: pages,
}))
const { previewWebsitePageRef } = get()
previewWebsitePageRef.current = pages[0]
},
currentWebsite: undefined,
setCurrentWebsite: (website: CrawlResultItem | undefined) => set(() => ({
currentWebsite: website,
})),
crawlResult: undefined,
setCrawlResult: (result: CrawlResult | undefined) => set(() => ({
crawlResult: result,
})),
step: CrawlStep.init,
setStep: (step: CrawlStep) => set(() => ({
step,
})),
previewIndex: -1,
setPreviewIndex: (index: number) => set(() => ({
previewIndex: index,
})),
previewWebsitePageRef: { current: undefined },
})
}

View File

@@ -0,0 +1,39 @@
'use client'
import React from 'react'
import cn from '@/utils/classnames'
import Checkbox from '@/app/components/base/checkbox'
import Tooltip from '@/app/components/base/tooltip'
type CheckboxWithLabelProps = {
className?: string
isChecked: boolean
onChange: (isChecked: boolean) => void
label: string
labelClassName?: string
tooltip?: string
}
const CheckboxWithLabel = ({
className = '',
isChecked,
onChange,
label,
labelClassName,
tooltip,
}: CheckboxWithLabelProps) => {
return (
<label className={cn('flex items-center space-x-2', className)}>
<Checkbox checked={isChecked} onCheck={() => onChange(!isChecked)} />
<div className={cn('system-sm-medium text-text-secondary', labelClassName)}>{label}</div>
{tooltip && (
<Tooltip
popupContent={
<div className='w-[200px]'>{tooltip}</div>
}
triggerClassName='ml-0.5 w-4 h-4'
/>
)}
</label>
)
}
export default React.memo(CheckboxWithLabel)

View File

@@ -0,0 +1,80 @@
'use client'
import React, { useCallback } from 'react'
import cn from '@/utils/classnames'
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
import Checkbox from '@/app/components/base/checkbox'
import Button from '@/app/components/base/button'
import { useTranslation } from 'react-i18next'
import Radio from '@/app/components/base/radio/ui'
type CrawledResultItemProps = {
payload: CrawlResultItemType
isChecked: boolean
onCheckChange: (checked: boolean) => void
isPreview: boolean
showPreview: boolean
onPreview: () => void
isMultipleChoice?: boolean
}
const CrawledResultItem = ({
payload,
isChecked,
onCheckChange,
isPreview,
onPreview,
showPreview,
isMultipleChoice = true,
}: CrawledResultItemProps) => {
const { t } = useTranslation()
const handleCheckChange = useCallback(() => {
onCheckChange(!isChecked)
}, [isChecked, onCheckChange])
return (
<div className={cn(
'relative flex cursor-pointer gap-x-2 rounded-lg p-2',
isPreview ? 'bg-state-base-active' : 'group hover:bg-state-base-hover',
)}>
{
isMultipleChoice ? (
<Checkbox
className='shrink-0'
checked={isChecked}
onCheck={handleCheckChange}
/>
) : (
<Radio
isChecked={isChecked}
onCheck={handleCheckChange}
/>
)
}
<div className='flex min-w-0 grow flex-col gap-y-0.5'>
<div
className='system-sm-medium truncate text-text-secondary'
title={payload.title}
>
{payload.title}
</div>
<div
className='system-xs-regular truncate text-text-tertiary'
title={payload.source_url}
>
{payload.source_url}
</div>
</div>
{showPreview && (
<Button
size='small'
onClick={onPreview}
className='system-xs-medium-uppercase right-2 top-2 hidden px-1.5 group-hover:absolute group-hover:block'
>
{t('datasetCreation.stepOne.website.preview')}
</Button>
)}
</div>
)
}
export default React.memo(CrawledResultItem)

View File

@@ -0,0 +1,98 @@
'use client'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
import type { CrawlResultItem } from '@/models/datasets'
import CheckboxWithLabel from './checkbox-with-label'
import CrawledResultItem from './crawled-result-item'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
type CrawledResultProps = {
className?: string
previewIndex?: number
list: CrawlResultItem[]
checkedList: CrawlResultItem[]
onSelectedChange: (selected: CrawlResultItem[]) => void
onPreview?: (payload: CrawlResultItem, index: number) => void
showPreview?: boolean
usedTime: number
isMultipleChoice?: boolean
}
const CrawledResult = ({
className = '',
previewIndex,
list,
checkedList,
onSelectedChange,
usedTime,
onPreview,
showPreview = false,
isMultipleChoice = true,
}: CrawledResultProps) => {
const { t } = useTranslation()
const isCheckAll = checkedList.length === list.length
const handleCheckedAll = useCallback(() => {
if (!isCheckAll)
onSelectedChange(list)
else
onSelectedChange([])
}, [isCheckAll, list, onSelectedChange])
const handleItemCheckChange = useCallback((item: CrawlResultItem) => {
return (checked: boolean) => {
if (checked) {
if (isMultipleChoice)
onSelectedChange([...checkedList, item])
else
onSelectedChange([item])
}
else { onSelectedChange(checkedList.filter(checkedItem => checkedItem.source_url !== item.source_url)) }
}
}, [checkedList, onSelectedChange, isMultipleChoice])
const handlePreview = useCallback((index: number) => {
if (!onPreview) return
onPreview(list[index], index)
}, [list, onPreview])
return (
<div className={cn('flex flex-col gap-y-2', className)}>
<div className='system-sm-medium pt-2 text-text-primary'>
{t(`${I18N_PREFIX}.scrapTimeInfo`, {
total: list.length,
time: usedTime.toFixed(1),
})}
</div>
<div className='overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg'>
{isMultipleChoice && (
<div className='flex items-center px-4 py-2'>
<CheckboxWithLabel
isChecked={isCheckAll}
onChange={handleCheckedAll} label={isCheckAll ? t(`${I18N_PREFIX}.resetAll`) : t(`${I18N_PREFIX}.selectAll`)}
/>
</div>
)}
<div className='flex flex-col gap-y-px border-t border-divider-subtle bg-background-default-subtle p-2'>
{list.map((item, index) => (
<CrawledResultItem
key={item.source_url}
payload={item}
isChecked={checkedList.some(checkedItem => checkedItem.source_url === item.source_url)}
onCheckChange={handleItemCheckChange(item)}
isPreview={index === previewIndex}
onPreview={handlePreview.bind(null, index)}
showPreview={showPreview}
isMultipleChoice={isMultipleChoice}
/>
))}
</div>
</div>
</div>
)
}
export default React.memo(CrawledResult)

View File

@@ -0,0 +1,89 @@
'use client'
import React from 'react'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
type CrawlingProps = {
className?: string
crawledNum: number
totalNum: number
}
type BlockProps = {
className?: string
}
type ItemProps = {
firstLineWidth: string
secondLineWidth: string
}
const Block = React.memo(({
className,
}: BlockProps) => {
return <div className={cn('bg-text-quaternary opacity-20', className)} />
})
const Item = React.memo(({
firstLineWidth,
secondLineWidth,
}: ItemProps) => {
return (
<div className='flex gap-x-2 px-2 py-[5px]'>
<div className='py-0.5'>
<Block className='size-4 rounded-[4px]' />
</div>
<div className='flex grow flex-col'>
<div className='flex h-5 w-full items-center'>
<Block className={cn('h-2.5 rounded-sm', firstLineWidth)} />
</div>
<div className='flex h-[18px] w-full items-center'>
<Block className={cn('h-1.5 rounded-sm', secondLineWidth)} />
</div>
</div>
</div>
)
})
const Crawling = ({
className = '',
crawledNum,
totalNum,
}: CrawlingProps) => {
const { t } = useTranslation()
const itemsConfig = [{
firstLineWidth: 'w-[35%]',
secondLineWidth: 'w-[50%]',
}, {
firstLineWidth: 'w-[40%]',
secondLineWidth: 'w-[45%]',
}, {
firstLineWidth: 'w-[30%]',
secondLineWidth: 'w-[36%]',
}]
return (
<div className={cn('mt-2 flex flex-col gap-y-2 pt-2', className)}>
<div className='system-sm-medium text-text-primary'>
{t('datasetCreation.stepOne.website.totalPageScraped')} {crawledNum}/{totalNum}
</div>
<div className='overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg'>
<div className='flex items-center gap-x-2 px-4 py-2'>
<Block className='size-4 rounded-[4px]' />
<Block className='h-2.5 w-14 rounded-sm' />
</div>
<div className='flex flex-col gap-px border-t border-divider-subtle bg-background-default-subtle p-2'>
{itemsConfig.map((item, index) => (
<Item
key={index}
firstLineWidth={item.firstLineWidth}
secondLineWidth={item.secondLineWidth}
/>
))}
</div>
</div>
</div>
)
}
export default React.memo(Crawling)

View File

@@ -0,0 +1,34 @@
import React from 'react'
import cn from '@/utils/classnames'
import { RiErrorWarningFill } from '@remixicon/react'
type ErrorMessageProps = {
className?: string
title: string
errorMsg?: string
}
const ErrorMessage = ({
className,
title,
errorMsg,
}: ErrorMessageProps) => {
return (
// eslint-disable-next-line tailwindcss/migration-from-tailwind-2
<div className={cn(
'flex gap-x-0.5 rounded-xl border-[0.5px] border-components-panel-border bg-opacity-40 bg-toast-error-bg p-2 shadow-xs shadow-shadow-shadow-3',
className,
)}>
<div className='flex size-6 items-center justify-center'>
<RiErrorWarningFill className='h-4 w-4 text-text-destructive' />
</div>
<div className='flex flex-col gap-y-0.5 py-1'>
<div className='system-xs-medium text-text-primary'>{title}</div>
{errorMsg && (
<div className='system-xs-regular text-text-secondary'>{errorMsg}</div>
)}
</div>
</div>
)
}
export default React.memo(ErrorMessage)

View File

@@ -0,0 +1,123 @@
import Button from '@/app/components/base/button'
import { useAppForm } from '@/app/components/base/form'
import BaseField from '@/app/components/base/form/form-scenarios/base/field'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
import cn from '@/utils/classnames'
import { RiPlayLargeLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import type { RAGPipelineVariables } from '@/models/pipeline'
import { useConfigurations, useInitialData } from '@/app/components/rag-pipeline/hooks/use-input-fields'
import { generateZodSchema } from '@/app/components/base/form/form-scenarios/base/utils'
import { CrawlStep } from '@/models/datasets'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
type OptionsProps = {
variables: RAGPipelineVariables
step: CrawlStep
runDisabled?: boolean
onSubmit: (data: Record<string, any>) => void
}
const Options = ({
variables,
step,
runDisabled,
onSubmit,
}: OptionsProps) => {
const { t } = useTranslation()
const initialData = useInitialData(variables)
const configurations = useConfigurations(variables)
const schema = useMemo(() => {
return generateZodSchema(configurations)
}, [configurations])
const form = useAppForm({
defaultValues: initialData,
validators: {
onSubmit: ({ value }) => {
const result = schema.safeParse(value)
if (!result.success) {
const issues = result.error.issues
const firstIssue = issues[0]
const errorMessage = `"${firstIssue.path.join('.')}" ${firstIssue.message}`
Toast.notify({
type: 'error',
message: errorMessage,
})
return errorMessage
}
return undefined
},
},
onSubmit: ({ value }) => {
onSubmit(value)
},
})
const [fold, {
toggle: foldToggle,
setTrue: foldHide,
setFalse: foldShow,
}] = useBoolean(false)
useEffect(() => {
// When the step change
if (step !== CrawlStep.init)
foldHide()
else
foldShow()
}, [step])
const isRunning = useMemo(() => step === CrawlStep.running, [step])
return (
<form
className='w-full'
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
>
<div className='flex items-center gap-x-1 px-4 py-2'>
<div
className='flex grow cursor-pointer select-none items-center gap-x-0.5'
onClick={foldToggle}
>
<span className='system-sm-semibold-uppercase text-text-secondary'>
{t(`${I18N_PREFIX}.options`)}
</span>
<ArrowDownRoundFill className={cn('h-4 w-4 shrink-0 text-text-quaternary', fold && '-rotate-90')} />
</div>
<Button
variant='primary'
onClick={form.handleSubmit}
disabled={runDisabled || isRunning}
loading={isRunning}
className='shrink-0 gap-x-0.5'
spinnerClassName='!ml-0'
>
<RiPlayLargeLine className='size-4' />
<span className='px-0.5'>{!isRunning ? t(`${I18N_PREFIX}.run`) : t(`${I18N_PREFIX}.running`)}</span>
</Button>
</div>
{!fold && (
<div className='flex flex-col gap-3 border-t border-divider-subtle px-4 py-3'>
{configurations.map((config, index) => {
const FieldComponent = BaseField({
initialData,
config,
})
return <FieldComponent key={index} form={form} />
})}
</div>
)}
</form>
)
}
export default Options

View File

@@ -0,0 +1,206 @@
'use client'
import React, { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { CrawlResultItem } from '@/models/datasets'
import { CrawlStep } from '@/models/datasets'
import Header from '../base/header'
import Options from './base/options'
import Crawling from './base/crawling'
import ErrorMessage from './base/error-message'
import CrawledResult from './base/crawled-result'
import {
useDraftPipelinePreProcessingParams,
usePublishedPipelinePreProcessingParams,
} from '@/service/use-pipeline'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { DatasourceType } from '@/models/pipeline'
import { ssePost } from '@/service/base'
import type {
DataSourceNodeCompletedResponse,
DataSourceNodeErrorResponse,
DataSourceNodeProcessingResponse,
} from '@/types/pipeline'
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import { useDataSourceStore, useDataSourceStoreWithSelector } from '../store'
import { useShallow } from 'zustand/react/shallow'
import { useModalContextSelector } from '@/context/modal-context'
import { useGetDataSourceAuth } from '@/service/use-datasource'
import { useDocLink } from '@/context/i18n'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
export type WebsiteCrawlProps = {
nodeId: string
nodeData: DataSourceNodeType
isInPipeline?: boolean
onCredentialChange: (credentialId: string) => void
}
const WebsiteCrawl = ({
nodeId,
nodeData,
isInPipeline = false,
onCredentialChange,
}: WebsiteCrawlProps) => {
const { t } = useTranslation()
const docLink = useDocLink()
const [totalNum, setTotalNum] = useState(0)
const [crawledNum, setCrawledNum] = useState(0)
const [crawlErrorMessage, setCrawlErrorMessage] = useState('')
const pipelineId = useDatasetDetailContextWithSelector(s => s.dataset?.pipeline_id)
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
const {
crawlResult,
step,
checkedCrawlResult,
previewIndex,
currentCredentialId,
} = useDataSourceStoreWithSelector(useShallow(state => ({
crawlResult: state.crawlResult,
step: state.step,
checkedCrawlResult: state.websitePages,
previewIndex: state.previewIndex,
currentCredentialId: state.currentCredentialId,
})))
const { data: dataSourceAuth } = useGetDataSourceAuth({
pluginId: nodeData.plugin_id,
provider: nodeData.provider_name,
})
const dataSourceStore = useDataSourceStore()
const usePreProcessingParams = useRef(!isInPipeline ? usePublishedPipelinePreProcessingParams : useDraftPipelinePreProcessingParams)
const { data: paramsConfig, isFetching: isFetchingParams } = usePreProcessingParams.current({
pipeline_id: pipelineId!,
node_id: nodeId,
}, !!pipelineId && !!nodeId)
const isInit = step === CrawlStep.init
const isCrawlFinished = step === CrawlStep.finished
const isRunning = step === CrawlStep.running
const showError = isCrawlFinished && crawlErrorMessage
const datasourceNodeRunURL = !isInPipeline
? `/rag/pipelines/${pipelineId}/workflows/published/datasource/nodes/${nodeId}/run`
: `/rag/pipelines/${pipelineId}/workflows/draft/datasource/nodes/${nodeId}/run`
const handleCheckedCrawlResultChange = useCallback((checkedCrawlResult: CrawlResultItem[]) => {
const { setWebsitePages } = dataSourceStore.getState()
setWebsitePages(checkedCrawlResult)
}, [dataSourceStore])
const handlePreview = useCallback((website: CrawlResultItem, index: number) => {
const { setCurrentWebsite, setPreviewIndex } = dataSourceStore.getState()
setCurrentWebsite(website)
setPreviewIndex(index)
}, [dataSourceStore])
const handleRun = useCallback(async (value: Record<string, any>) => {
const { setStep, setCrawlResult, currentCredentialId } = dataSourceStore.getState()
setStep(CrawlStep.running)
ssePost(
datasourceNodeRunURL,
{
body: {
inputs: value,
datasource_type: DatasourceType.websiteCrawl,
credential_id: currentCredentialId,
response_mode: 'streaming',
},
},
{
onDataSourceNodeProcessing: (data: DataSourceNodeProcessingResponse) => {
setTotalNum(data.total ?? 0)
setCrawledNum(data.completed ?? 0)
},
onDataSourceNodeCompleted: (data: DataSourceNodeCompletedResponse) => {
const { data: crawlData, time_consuming } = data
const crawlResultData = {
data: crawlData as CrawlResultItem[],
time_consuming: time_consuming ?? 0,
}
setCrawlResult(crawlResultData)
handleCheckedCrawlResultChange(isInPipeline ? [crawlData[0]] : crawlData) // default select the crawl result
setCrawlErrorMessage('')
setStep(CrawlStep.finished)
},
onDataSourceNodeError: (error: DataSourceNodeErrorResponse) => {
setCrawlErrorMessage(error.error || t(`${I18N_PREFIX}.unknownError`))
setStep(CrawlStep.finished)
},
},
)
}, [dataSourceStore, datasourceNodeRunURL, handleCheckedCrawlResultChange, isInPipeline, t])
const handleSubmit = useCallback((value: Record<string, any>) => {
handleRun(value)
}, [handleRun])
const handleSetting = useCallback(() => {
setShowAccountSettingModal({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
}, [setShowAccountSettingModal])
const handleCredentialChange = useCallback((credentialId: string) => {
setCrawledNum(0)
setTotalNum(0)
setCrawlErrorMessage('')
onCredentialChange(credentialId)
}, [dataSourceStore, onCredentialChange])
return (
<div className='flex flex-col'>
<Header
docTitle='Docs'
docLink={docLink('/guides/knowledge-base/knowledge-pipeline/authorize-data-source')}
onClickConfiguration={handleSetting}
pluginName={nodeData.datasource_label}
currentCredentialId={currentCredentialId}
onCredentialChange={handleCredentialChange}
credentials={dataSourceAuth?.result || []}
/>
<div className='mt-2 rounded-xl border border-components-panel-border bg-background-default-subtle'>
<Options
variables={paramsConfig?.variables || []}
step={step}
runDisabled={!currentCredentialId || isFetchingParams}
onSubmit={handleSubmit}
/>
</div>
{!isInit && (
<div className='relative flex flex-col'>
{isRunning && (
<Crawling
crawledNum={crawledNum}
totalNum={totalNum}
/>
)}
{showError && (
<ErrorMessage
className='mt-2'
title={t(`${I18N_PREFIX}.exceptionErrorTitle`)}
errorMsg={crawlErrorMessage}
/>
)}
{isCrawlFinished && !showError && (
<CrawledResult
className='mt-2'
list={crawlResult?.data || []}
checkedList={checkedCrawlResult}
onSelectedChange={handleCheckedCrawlResultChange}
usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0}
previewIndex={previewIndex}
onPreview={handlePreview}
showPreview={!isInPipeline}
isMultipleChoice={!isInPipeline} // only support single choice in test run
/>
)}
</div>
)}
</div>
)
}
export default React.memo(WebsiteCrawl)

View File

@@ -0,0 +1,223 @@
import { useTranslation } from 'react-i18next'
import { AddDocumentsStep } from './types'
import type { DataSourceOption } from '@/app/components/rag-pipeline/components/panel/test-run/types'
import { useCallback, useMemo, useState } from 'react'
import { BlockEnum, type Node } from '@/app/components/workflow/types'
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import { useDataSourceStore, useDataSourceStoreWithSelector } from './data-source/store'
import type { DataSourceNotionPageMap, DataSourceNotionWorkspace } from '@/models/common'
import { useShallow } from 'zustand/react/shallow'
import { CrawlStep } from '@/models/datasets'
export const useAddDocumentsSteps = () => {
const { t } = useTranslation()
const [currentStep, setCurrentStep] = useState(1)
const handleNextStep = useCallback(() => {
setCurrentStep(preStep => preStep + 1)
}, [])
const handleBackStep = useCallback(() => {
setCurrentStep(preStep => preStep - 1)
}, [])
const steps = [
{
label: t('datasetPipeline.addDocuments.steps.chooseDatasource'),
value: AddDocumentsStep.dataSource,
},
{
label: t('datasetPipeline.addDocuments.steps.processDocuments'),
value: AddDocumentsStep.processDocuments,
},
{
label: t('datasetPipeline.addDocuments.steps.processingDocuments'),
value: AddDocumentsStep.processingDocuments,
},
]
return {
steps,
currentStep,
handleNextStep,
handleBackStep,
}
}
export const useDatasourceOptions = (pipelineNodes: Node<DataSourceNodeType>[]) => {
const datasourceNodes = pipelineNodes.filter(node => node.data.type === BlockEnum.DataSource)
const options = useMemo(() => {
const options: DataSourceOption[] = []
datasourceNodes.forEach((node) => {
const label = node.data.title
options.push({
label,
value: node.id,
data: node.data,
})
})
return options
}, [datasourceNodes])
return options
}
export const useLocalFile = () => {
const {
localFileList,
currentLocalFile,
} = useDataSourceStoreWithSelector(useShallow(state => ({
localFileList: state.localFileList,
currentLocalFile: state.currentLocalFile,
})))
const dataSourceStore = useDataSourceStore()
const allFileLoaded = useMemo(() => (localFileList.length > 0 && localFileList.every(file => file.file.id)), [localFileList])
const hidePreviewLocalFile = useCallback(() => {
const { setCurrentLocalFile } = dataSourceStore.getState()
setCurrentLocalFile(undefined)
}, [dataSourceStore])
return {
localFileList,
allFileLoaded,
currentLocalFile,
hidePreviewLocalFile,
}
}
export const useOnlineDocument = () => {
const {
documentsData,
onlineDocuments,
currentDocument,
} = useDataSourceStoreWithSelector(useShallow(state => ({
documentsData: state.documentsData,
onlineDocuments: state.onlineDocuments,
currentDocument: state.currentDocument,
})))
const dataSourceStore = useDataSourceStore()
const currentWorkspace = documentsData[0]
const PagesMapAndSelectedPagesId: DataSourceNotionPageMap = useMemo(() => {
const pagesMap = (documentsData || []).reduce((prev: DataSourceNotionPageMap, next: DataSourceNotionWorkspace) => {
next.pages.forEach((page) => {
prev[page.page_id] = {
...page,
workspace_id: next.workspace_id,
}
})
return prev
}, {})
return pagesMap
}, [documentsData])
const hidePreviewOnlineDocument = useCallback(() => {
const { setCurrentDocument } = dataSourceStore.getState()
setCurrentDocument(undefined)
}, [dataSourceStore])
const clearOnlineDocumentData = useCallback(() => {
const {
setDocumentsData,
setSearchValue,
setSelectedPagesId,
setOnlineDocuments,
setCurrentDocument,
} = dataSourceStore.getState()
setDocumentsData([])
setSearchValue('')
setSelectedPagesId(new Set())
setOnlineDocuments([])
setCurrentDocument(undefined)
}, [dataSourceStore])
return {
currentWorkspace,
onlineDocuments,
currentDocument,
PagesMapAndSelectedPagesId,
hidePreviewOnlineDocument,
clearOnlineDocumentData,
}
}
export const useWebsiteCrawl = () => {
const {
websitePages,
currentWebsite,
} = useDataSourceStoreWithSelector(useShallow(state => ({
websitePages: state.websitePages,
currentWebsite: state.currentWebsite,
})))
const dataSourceStore = useDataSourceStore()
const hideWebsitePreview = useCallback(() => {
const { setCurrentWebsite, setPreviewIndex } = dataSourceStore.getState()
setCurrentWebsite(undefined)
setPreviewIndex(-1)
}, [dataSourceStore])
const clearWebsiteCrawlData = useCallback(() => {
const {
setStep,
setCrawlResult,
setWebsitePages,
setPreviewIndex,
setCurrentWebsite,
} = dataSourceStore.getState()
setStep(CrawlStep.init)
setCrawlResult(undefined)
setCurrentWebsite(undefined)
setWebsitePages([])
setPreviewIndex(-1)
}, [dataSourceStore])
return {
websitePages,
currentWebsite,
hideWebsitePreview,
clearWebsiteCrawlData,
}
}
export const useOnlineDrive = () => {
const {
onlineDriveFileList,
selectedFileIds,
} = useDataSourceStoreWithSelector(useShallow(state => ({
onlineDriveFileList: state.onlineDriveFileList,
selectedFileIds: state.selectedFileIds,
})))
const dataSourceStore = useDataSourceStore()
const selectedOnlineDriveFileList = useMemo(() => {
return selectedFileIds.map(id => onlineDriveFileList.find(item => item.id === id)!)
}, [onlineDriveFileList, selectedFileIds])
const clearOnlineDriveData = useCallback(() => {
const {
setOnlineDriveFileList,
setBucket,
setPrefix,
setKeywords,
setSelectedFileIds,
} = dataSourceStore.getState()
setOnlineDriveFileList([])
setBucket('')
setPrefix([])
setKeywords('')
setSelectedFileIds([])
}, [dataSourceStore])
return {
onlineDriveFileList,
selectedFileIds,
selectedOnlineDriveFileList,
clearOnlineDriveData,
}
}

View File

@@ -0,0 +1,572 @@
'use client'
import { useCallback, useMemo, useRef, useState } from 'react'
import DataSourceOptions from './data-source-options'
import type { CrawlResultItem, DocumentItem, CustomFile as File, FileIndexingEstimateResponse } from '@/models/datasets'
import LocalFile from '@/app/components/datasets/documents/create-from-pipeline/data-source/local-file'
import { useProviderContextSelector } from '@/context/provider-context'
import type { NotionPage } from '@/models/common'
import OnlineDocuments from '@/app/components/datasets/documents/create-from-pipeline/data-source/online-documents'
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
import WebsiteCrawl from '@/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl'
import OnlineDrive from '@/app/components/datasets/documents/create-from-pipeline/data-source/online-drive'
import Actions from './actions'
import { useTranslation } from 'react-i18next'
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
import LeftHeader from './left-header'
import { usePublishedPipelineInfo, useRunPublishedPipeline } from '@/service/use-pipeline'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import Loading from '@/app/components/base/loading'
import type { Node } from '@/app/components/workflow/types'
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import FilePreview from './preview/file-preview'
import OnlineDocumentPreview from './preview/online-document-preview'
import WebsitePreview from './preview/web-preview'
import ProcessDocuments from './process-documents'
import ChunkPreview from './preview/chunk-preview'
import Processing from './processing'
import type {
InitialDocumentDetail,
OnlineDriveFile,
PublishedPipelineRunPreviewResponse,
PublishedPipelineRunResponse,
} from '@/models/pipeline'
import { DatasourceType } from '@/models/pipeline'
import { TransferMethod } from '@/types/app'
import { useAddDocumentsSteps, useLocalFile, useOnlineDocument, useOnlineDrive, useWebsiteCrawl } from './hooks'
import DataSourceProvider from './data-source/store/provider'
import { useDataSourceStore } from './data-source/store'
import { useFileUploadConfig } from '@/service/use-common'
const CreateFormPipeline = () => {
const { t } = useTranslation()
const plan = useProviderContextSelector(state => state.plan)
const enableBilling = useProviderContextSelector(state => state.enableBilling)
const pipelineId = useDatasetDetailContextWithSelector(s => s.dataset?.pipeline_id)
const [datasource, setDatasource] = useState<Datasource>()
const [estimateData, setEstimateData] = useState<FileIndexingEstimateResponse | undefined>(undefined)
const [batchId, setBatchId] = useState('')
const [documents, setDocuments] = useState<InitialDocumentDetail[]>([])
const dataSourceStore = useDataSourceStore()
const isPreview = useRef(false)
const formRef = useRef<any>(null)
const { data: pipelineInfo, isFetching: isFetchingPipelineInfo } = usePublishedPipelineInfo(pipelineId || '')
const { data: fileUploadConfigResponse } = useFileUploadConfig()
const {
steps,
currentStep,
handleNextStep,
handleBackStep,
} = useAddDocumentsSteps()
const {
localFileList,
allFileLoaded,
currentLocalFile,
hidePreviewLocalFile,
} = useLocalFile()
const {
currentWorkspace,
onlineDocuments,
currentDocument,
PagesMapAndSelectedPagesId,
hidePreviewOnlineDocument,
clearOnlineDocumentData,
} = useOnlineDocument()
const {
websitePages,
currentWebsite,
hideWebsitePreview,
clearWebsiteCrawlData,
} = useWebsiteCrawl()
const {
onlineDriveFileList,
selectedFileIds,
selectedOnlineDriveFileList,
clearOnlineDriveData,
} = useOnlineDrive()
const datasourceType = useMemo(() => datasource?.nodeData.provider_type, [datasource])
const isVectorSpaceFull = plan.usage.vectorSpace >= plan.total.vectorSpace
const isShowVectorSpaceFull = useMemo(() => {
if (!datasource)
return false
if (datasourceType === DatasourceType.localFile)
return allFileLoaded && isVectorSpaceFull && enableBilling
if (datasourceType === DatasourceType.onlineDocument)
return onlineDocuments.length > 0 && isVectorSpaceFull && enableBilling
if (datasourceType === DatasourceType.websiteCrawl)
return websitePages.length > 0 && isVectorSpaceFull && enableBilling
if (datasourceType === DatasourceType.onlineDrive)
return onlineDriveFileList.length > 0 && isVectorSpaceFull && enableBilling
return false
}, [allFileLoaded, datasource, datasourceType, enableBilling, isVectorSpaceFull, onlineDocuments.length, onlineDriveFileList.length, websitePages.length])
const notSupportBatchUpload = enableBilling && plan.type === 'sandbox'
const nextBtnDisabled = useMemo(() => {
if (!datasource) return true
if (datasourceType === DatasourceType.localFile)
return isShowVectorSpaceFull || !localFileList.length || !allFileLoaded
if (datasourceType === DatasourceType.onlineDocument)
return isShowVectorSpaceFull || !onlineDocuments.length
if (datasourceType === DatasourceType.websiteCrawl)
return isShowVectorSpaceFull || !websitePages.length
if (datasourceType === DatasourceType.onlineDrive)
return isShowVectorSpaceFull || !selectedFileIds.length
return false
}, [datasource, datasourceType, isShowVectorSpaceFull, localFileList.length, allFileLoaded, onlineDocuments.length, websitePages.length, selectedFileIds.length])
const fileUploadConfig = useMemo(() => fileUploadConfigResponse ?? {
file_size_limit: 15,
batch_count_limit: 5,
}, [fileUploadConfigResponse])
const showSelect = useMemo(() => {
if (datasourceType === DatasourceType.onlineDocument) {
const pagesCount = currentWorkspace?.pages.length ?? 0
return pagesCount > 0
}
if (datasourceType === DatasourceType.onlineDrive) {
const isBucketList = onlineDriveFileList.some(file => file.type === 'bucket')
return !isBucketList && onlineDriveFileList.filter((item) => {
return item.type !== 'bucket'
}).length > 0
}
}, [currentWorkspace?.pages.length, datasourceType, onlineDriveFileList])
const totalOptions = useMemo(() => {
if (datasourceType === DatasourceType.onlineDocument)
return currentWorkspace?.pages.length
if (datasourceType === DatasourceType.onlineDrive) {
return onlineDriveFileList.filter((item) => {
return item.type !== 'bucket'
}).length
}
}, [currentWorkspace?.pages.length, datasourceType, onlineDriveFileList])
const selectedOptions = useMemo(() => {
if (datasourceType === DatasourceType.onlineDocument)
return onlineDocuments.length
if (datasourceType === DatasourceType.onlineDrive)
return selectedFileIds.length
}, [datasourceType, onlineDocuments.length, selectedFileIds.length])
const tip = useMemo(() => {
if (datasourceType === DatasourceType.onlineDocument)
return t('datasetPipeline.addDocuments.selectOnlineDocumentTip', { count: 50 })
if (datasourceType === DatasourceType.onlineDrive) {
return t('datasetPipeline.addDocuments.selectOnlineDriveTip', {
count: fileUploadConfig.batch_count_limit,
fileSize: fileUploadConfig.file_size_limit,
})
}
return ''
}, [datasourceType, fileUploadConfig.batch_count_limit, fileUploadConfig.file_size_limit, t])
const { mutateAsync: runPublishedPipeline, isIdle, isPending } = useRunPublishedPipeline()
const handlePreviewChunks = useCallback(async (data: Record<string, any>) => {
if (!datasource)
return
const {
previewLocalFileRef,
previewOnlineDocumentRef,
previewWebsitePageRef,
previewOnlineDriveFileRef,
currentCredentialId,
} = dataSourceStore.getState()
const datasourceInfoList: Record<string, any>[] = []
if (datasourceType === DatasourceType.localFile) {
const { id, name, type, size, extension, mime_type } = previewLocalFileRef.current as File
const documentInfo = {
related_id: id,
name,
type,
size,
extension,
mime_type,
url: '',
transfer_method: TransferMethod.local_file,
credential_id: currentCredentialId,
}
datasourceInfoList.push(documentInfo)
}
if (datasourceType === DatasourceType.onlineDocument) {
const { workspace_id, ...rest } = previewOnlineDocumentRef.current!
const documentInfo = {
workspace_id,
page: rest,
credential_id: currentCredentialId,
}
datasourceInfoList.push(documentInfo)
}
if (datasourceType === DatasourceType.websiteCrawl) {
datasourceInfoList.push({
...previewWebsitePageRef.current!,
credential_id: currentCredentialId,
})
}
if (datasourceType === DatasourceType.onlineDrive) {
const { bucket } = dataSourceStore.getState()
const { id, type, name } = previewOnlineDriveFileRef.current!
datasourceInfoList.push({
bucket,
id,
name,
type,
credential_id: currentCredentialId,
})
}
await runPublishedPipeline({
pipeline_id: pipelineId!,
inputs: data,
start_node_id: datasource.nodeId,
datasource_type: datasourceType as DatasourceType,
datasource_info_list: datasourceInfoList,
is_preview: true,
}, {
onSuccess: (res) => {
setEstimateData((res as PublishedPipelineRunPreviewResponse).data.outputs)
},
})
}, [datasource, datasourceType, runPublishedPipeline, pipelineId, dataSourceStore])
const handleProcess = useCallback(async (data: Record<string, any>) => {
if (!datasource)
return
const { currentCredentialId } = dataSourceStore.getState()
const datasourceInfoList: Record<string, any>[] = []
if (datasourceType === DatasourceType.localFile) {
const {
localFileList,
} = dataSourceStore.getState()
localFileList.forEach((file) => {
const { id, name, type, size, extension, mime_type } = file.file
const documentInfo = {
related_id: id,
name,
type,
size,
extension,
mime_type,
url: '',
transfer_method: TransferMethod.local_file,
credential_id: currentCredentialId,
}
datasourceInfoList.push(documentInfo)
})
}
if (datasourceType === DatasourceType.onlineDocument) {
const {
onlineDocuments,
} = dataSourceStore.getState()
onlineDocuments.forEach((page) => {
const { workspace_id, ...rest } = page
const documentInfo = {
workspace_id,
page: rest,
credential_id: currentCredentialId,
}
datasourceInfoList.push(documentInfo)
})
}
if (datasourceType === DatasourceType.websiteCrawl) {
const {
websitePages,
} = dataSourceStore.getState()
websitePages.forEach((websitePage) => {
datasourceInfoList.push({
...websitePage,
credential_id: currentCredentialId,
})
})
}
if (datasourceType === DatasourceType.onlineDrive) {
const {
bucket,
selectedFileIds,
onlineDriveFileList,
} = dataSourceStore.getState()
selectedFileIds.forEach((id) => {
const file = onlineDriveFileList.find(file => file.id === id)
datasourceInfoList.push({
bucket,
id: file?.id,
name: file?.name,
type: file?.type,
credential_id: currentCredentialId,
})
})
}
await runPublishedPipeline({
pipeline_id: pipelineId!,
inputs: data,
start_node_id: datasource.nodeId,
datasource_type: datasourceType as DatasourceType,
datasource_info_list: datasourceInfoList,
is_preview: false,
}, {
onSuccess: (res) => {
setBatchId((res as PublishedPipelineRunResponse).batch || '')
setDocuments((res as PublishedPipelineRunResponse).documents || [])
handleNextStep()
},
})
}, [dataSourceStore, datasource, datasourceType, handleNextStep, pipelineId, runPublishedPipeline])
const onClickProcess = useCallback(() => {
isPreview.current = false
formRef.current?.submit()
}, [])
const onClickPreview = useCallback(() => {
isPreview.current = true
formRef.current?.submit()
}, [])
const handleSubmit = useCallback((data: Record<string, any>) => {
if (isPreview.current)
handlePreviewChunks(data)
else
handleProcess(data)
}, [handlePreviewChunks, handleProcess])
const handlePreviewFileChange = useCallback((file: DocumentItem) => {
const { previewLocalFileRef } = dataSourceStore.getState()
previewLocalFileRef.current = file
onClickPreview()
}, [dataSourceStore, onClickPreview])
const handlePreviewOnlineDocumentChange = useCallback((page: NotionPage) => {
const { previewOnlineDocumentRef } = dataSourceStore.getState()
previewOnlineDocumentRef.current = page
onClickPreview()
}, [dataSourceStore, onClickPreview])
const handlePreviewWebsiteChange = useCallback((website: CrawlResultItem) => {
const { previewWebsitePageRef } = dataSourceStore.getState()
previewWebsitePageRef.current = website
onClickPreview()
}, [dataSourceStore, onClickPreview])
const handlePreviewOnlineDriveFileChange = useCallback((file: OnlineDriveFile) => {
const { previewOnlineDriveFileRef } = dataSourceStore.getState()
previewOnlineDriveFileRef.current = file
onClickPreview()
}, [dataSourceStore, onClickPreview])
const handleSelectAll = useCallback(() => {
const {
onlineDocuments,
onlineDriveFileList,
selectedFileIds,
setOnlineDocuments,
setSelectedFileIds,
setSelectedPagesId,
} = dataSourceStore.getState()
if (datasourceType === DatasourceType.onlineDocument) {
const allIds = currentWorkspace?.pages.map(page => page.page_id) || []
if (onlineDocuments.length < allIds.length) {
const selectedPages = Array.from(allIds).map(pageId => PagesMapAndSelectedPagesId[pageId])
setOnlineDocuments(selectedPages)
setSelectedPagesId(new Set(allIds))
}
else {
setOnlineDocuments([])
setSelectedPagesId(new Set())
}
}
if (datasourceType === DatasourceType.onlineDrive) {
const allKeys = onlineDriveFileList.filter((item) => {
return item.type !== 'bucket'
}).map(file => file.id)
if (selectedFileIds.length < allKeys.length)
setSelectedFileIds(allKeys)
else
setSelectedFileIds([])
}
}, [PagesMapAndSelectedPagesId, currentWorkspace?.pages, dataSourceStore, datasourceType])
const clearDataSourceData = useCallback((dataSource: Datasource) => {
if (dataSource.nodeData.provider_type === DatasourceType.onlineDocument)
clearOnlineDocumentData()
else if (dataSource.nodeData.provider_type === DatasourceType.websiteCrawl)
clearWebsiteCrawlData()
else if (dataSource.nodeData.provider_type === DatasourceType.onlineDrive)
clearOnlineDriveData()
}, [])
const handleSwitchDataSource = useCallback((dataSource: Datasource) => {
const {
setCurrentCredentialId,
currentNodeIdRef,
} = dataSourceStore.getState()
clearDataSourceData(dataSource)
setCurrentCredentialId('')
currentNodeIdRef.current = dataSource.nodeId
setDatasource(dataSource)
}, [dataSourceStore])
const handleCredentialChange = useCallback((credentialId: string) => {
const { setCurrentCredentialId } = dataSourceStore.getState()
clearDataSourceData(datasource!)
setCurrentCredentialId(credentialId)
}, [dataSourceStore, datasource])
if (isFetchingPipelineInfo) {
return (
<Loading type='app' />
)
}
return (
<div
className='relative flex h-[calc(100vh-56px)] w-full min-w-[1024px] overflow-x-auto rounded-t-2xl border-t border-effects-highlight bg-background-default-subtle'
>
<div className='h-full min-w-0 flex-1'>
<div className='flex h-full flex-col px-14'>
<LeftHeader
steps={steps}
title={t('datasetPipeline.addDocuments.title')}
currentStep={currentStep}
/>
<div className='grow overflow-y-auto'>
{
currentStep === 1 && (
<div className='flex flex-col gap-y-5 pt-4'>
<DataSourceOptions
datasourceNodeId={datasource?.nodeId || ''}
onSelect={handleSwitchDataSource}
pipelineNodes={(pipelineInfo?.graph.nodes || []) as Node<DataSourceNodeType>[]}
/>
{datasourceType === DatasourceType.localFile && (
<LocalFile
allowedExtensions={datasource!.nodeData.fileExtensions || []}
notSupportBatchUpload={notSupportBatchUpload}
/>
)}
{datasourceType === DatasourceType.onlineDocument && (
<OnlineDocuments
nodeId={datasource!.nodeId}
nodeData={datasource!.nodeData}
onCredentialChange={handleCredentialChange}
/>
)}
{datasourceType === DatasourceType.websiteCrawl && (
<WebsiteCrawl
nodeId={datasource!.nodeId}
nodeData={datasource!.nodeData}
onCredentialChange={handleCredentialChange}
/>
)}
{datasourceType === DatasourceType.onlineDrive && (
<OnlineDrive
nodeId={datasource!.nodeId}
nodeData={datasource!.nodeData}
onCredentialChange={handleCredentialChange}
/>
)}
{isShowVectorSpaceFull && (
<VectorSpaceFull />
)}
<Actions
showSelect={showSelect}
totalOptions={totalOptions}
selectedOptions={selectedOptions}
onSelectAll={handleSelectAll}
disabled={nextBtnDisabled}
handleNextStep={handleNextStep}
tip={tip}
/>
</div>
)
}
{
currentStep === 2 && (
<ProcessDocuments
ref={formRef}
dataSourceNodeId={datasource!.nodeId}
isRunning={isPending}
onProcess={onClickProcess}
onPreview={onClickPreview}
onSubmit={handleSubmit}
onBack={handleBackStep}
/>
)
}
{
currentStep === 3 && (
<Processing
batchId={batchId}
documents={documents}
/>
)
}
</div>
</div>
</div>
{/* Preview */}
{
currentStep === 1 && (
<div className='h-full min-w-0 flex-1'>
<div className='flex h-full flex-col pl-2 pt-2'>
{currentLocalFile && (
<FilePreview
file={currentLocalFile}
hidePreview={hidePreviewLocalFile}
/>
)}
{currentDocument && (
<OnlineDocumentPreview
datasourceNodeId={datasource!.nodeId}
currentPage={currentDocument}
hidePreview={hidePreviewOnlineDocument}
/>
)}
{currentWebsite && (
<WebsitePreview
currentWebsite={currentWebsite}
hidePreview={hideWebsitePreview}
/>
)}
</div>
</div>
)
}
{
currentStep === 2 && (
<div className='h-full min-w-0 flex-1'>
<div className='flex h-full flex-col pl-2 pt-2'>
<ChunkPreview
dataSourceType={datasourceType as DatasourceType}
localFiles={localFileList.map(file => file.file)}
onlineDocuments={onlineDocuments}
websitePages={websitePages}
onlineDriveFiles={selectedOnlineDriveFileList}
isIdle={isIdle}
isPending={isPending && isPreview.current}
estimateData={estimateData}
onPreview={onClickPreview}
handlePreviewFileChange={handlePreviewFileChange}
handlePreviewOnlineDocumentChange={handlePreviewOnlineDocumentChange}
handlePreviewWebsitePageChange={handlePreviewWebsiteChange}
handlePreviewOnlineDriveFileChange={handlePreviewOnlineDriveFileChange}
/>
</div>
</div>
)
}
</div>
)
}
const CreateFormPipelineWrapper = () => {
return (
<DataSourceProvider>
<CreateFormPipeline />
</DataSourceProvider>
)
}
export default CreateFormPipelineWrapper

View File

@@ -0,0 +1,53 @@
import React from 'react'
import { RiArrowLeftLine } from '@remixicon/react'
import Button from '@/app/components/base/button'
import { useParams } from 'next/navigation'
import Effect from '@/app/components/base/effect'
import type { Step } from './step-indicator'
import StepIndicator from './step-indicator'
import Link from 'next/link'
type LeftHeaderProps = {
steps: Array<Step>
title: string
currentStep: number
}
const LeftHeader = ({
steps,
title,
currentStep,
}: LeftHeaderProps) => {
const { datasetId } = useParams()
return (
<div className='relative flex flex-col gap-y-0.5 pb-2 pt-4'>
<div className='flex items-center gap-x-2'>
<span className='system-2xs-semibold-uppercase bg-pipeline-add-documents-title-bg bg-clip-text text-transparent'>
{title}
</span>
<span className='system-2xs-regular text-divider-regular'>/</span>
<StepIndicator steps={steps} currentStep={currentStep} />
</div>
<div className='system-md-semibold text-text-primary'>
{steps[currentStep - 1]?.label}
</div>
{currentStep !== steps.length && (
<Link
href={`/datasets/${datasetId}/documents`}
replace
>
<Button
variant='secondary-accent'
className='absolute -left-11 top-3.5 size-9 rounded-full p-0'
>
<RiArrowLeftLine className='size-5 ' />
</Button>
</Link>
)}
<Effect className='left-8 top-[-34px] opacity-20' />
</div>
)
}
export default React.memo(LeftHeader)

View File

@@ -0,0 +1,238 @@
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import PreviewContainer from '../../../preview/container'
import { PreviewHeader } from '../../../preview/header'
import type { CrawlResultItem, CustomFile, DocumentItem, FileIndexingEstimateResponse } from '@/models/datasets'
import { ChunkingMode } from '@/models/datasets'
import type { NotionPage } from '@/models/common'
import PreviewDocumentPicker from '../../../common/document-picker/preview-document-picker'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { ChunkContainer, QAPreview } from '../../../chunk'
import { FormattedText } from '../../../formatted-text/formatted'
import { PreviewSlice } from '../../../formatted-text/flavours/preview-slice'
import { SkeletonContainer, SkeletonPoint, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { RiSearchEyeLine } from '@remixicon/react'
import Badge from '@/app/components/base/badge'
import Button from '@/app/components/base/button'
import type { OnlineDriveFile } from '@/models/pipeline'
import { DatasourceType } from '@/models/pipeline'
import { getFileExtension } from '../data-source/online-drive/file-list/list/utils'
type ChunkPreviewProps = {
dataSourceType: DatasourceType
localFiles: CustomFile[]
onlineDocuments: NotionPage[]
websitePages: CrawlResultItem[]
onlineDriveFiles: OnlineDriveFile[]
isIdle: boolean
isPending: boolean
estimateData: FileIndexingEstimateResponse | undefined
onPreview: () => void
handlePreviewFileChange: (file: DocumentItem) => void
handlePreviewOnlineDocumentChange: (page: NotionPage) => void
handlePreviewWebsitePageChange: (page: CrawlResultItem) => void
handlePreviewOnlineDriveFileChange: (file: OnlineDriveFile) => void
}
const ChunkPreview = ({
dataSourceType,
localFiles,
onlineDocuments,
websitePages,
onlineDriveFiles,
isIdle,
isPending,
estimateData,
onPreview,
handlePreviewFileChange,
handlePreviewOnlineDocumentChange,
handlePreviewWebsitePageChange,
handlePreviewOnlineDriveFileChange,
}: ChunkPreviewProps) => {
const { t } = useTranslation()
const currentDocForm = useDatasetDetailContextWithSelector(s => s.dataset?.doc_form)
const [previewFile, setPreviewFile] = useState<DocumentItem>(localFiles[0] as DocumentItem)
const [previewOnlineDocument, setPreviewOnlineDocument] = useState<NotionPage>(onlineDocuments[0])
const [previewWebsitePage, setPreviewWebsitePage] = useState<CrawlResultItem>(websitePages[0])
const [previewOnlineDriveFile, setPreviewOnlineDriveFile] = useState<OnlineDriveFile>(onlineDriveFiles[0])
return (
<PreviewContainer
header={<PreviewHeader
title={t('datasetCreation.stepTwo.preview')}
>
<div className='flex items-center gap-1'>
{dataSourceType === DatasourceType.localFile
&& <PreviewDocumentPicker
files={localFiles as Array<Required<CustomFile>>}
onChange={(selected) => {
setPreviewFile(selected)
handlePreviewFileChange(selected)
}}
value={previewFile}
/>
}
{dataSourceType === DatasourceType.onlineDocument
&& <PreviewDocumentPicker
files={
onlineDocuments.map(page => ({
id: page.page_id,
name: page.page_name,
extension: 'md',
}))
}
onChange={(selected) => {
const selectedPage = onlineDocuments.find(page => page.page_id === selected.id)
setPreviewOnlineDocument(selectedPage!)
handlePreviewOnlineDocumentChange(selectedPage!)
}}
value={{
id: previewOnlineDocument?.page_id || '',
name: previewOnlineDocument?.page_name || '',
extension: 'md',
}}
/>
}
{dataSourceType === DatasourceType.websiteCrawl
&& <PreviewDocumentPicker
files={
websitePages.map(page => ({
id: page.source_url,
name: page.title,
extension: 'md',
}))
}
onChange={(selected) => {
const selectedPage = websitePages.find(page => page.source_url === selected.id)
setPreviewWebsitePage(selectedPage!)
handlePreviewWebsitePageChange(selectedPage!)
}}
value={
{
id: previewWebsitePage?.source_url || '',
name: previewWebsitePage?.title || '',
extension: 'md',
}
}
/>
}
{dataSourceType === DatasourceType.onlineDrive
&& <PreviewDocumentPicker
files={
onlineDriveFiles.map(file => ({
id: file.id,
name: file.name,
extension: getFileExtension(previewOnlineDriveFile?.name),
}))
}
onChange={(selected) => {
const selectedFile = onlineDriveFiles.find(file => file.id === selected.id)
setPreviewOnlineDriveFile(selectedFile!)
handlePreviewOnlineDriveFileChange(selectedFile!)
}}
value={
{
id: previewOnlineDriveFile?.id || '',
name: previewOnlineDriveFile?.name || '',
extension: getFileExtension(previewOnlineDriveFile?.name),
}
}
/>
}
{
currentDocForm !== ChunkingMode.qa
&& <Badge text={t('datasetCreation.stepTwo.previewChunkCount', {
count: estimateData?.total_segments || 0,
}) as string}
/>
}
</div>
</PreviewHeader>}
className='relative flex h-full w-full shrink-0'
mainClassName='space-y-6'
>
{!isPending && currentDocForm === ChunkingMode.qa && estimateData?.qa_preview && (
estimateData?.qa_preview.map((item, index) => (
<ChunkContainer
key={`${item.question}-${index}`}
label={`Chunk-${index + 1}`}
characterCount={item.question.length + item.answer.length}
>
<QAPreview qa={item} />
</ChunkContainer>
))
)}
{!isPending && currentDocForm === ChunkingMode.text && estimateData?.preview && (
estimateData?.preview.map((item, index) => (
<ChunkContainer
key={`${item.content}-${index}`}
label={`Chunk-${index + 1}`}
characterCount={item.content.length}
>
{item.content}
</ChunkContainer>
))
)}
{!isPending && currentDocForm === ChunkingMode.parentChild && estimateData?.preview && (
estimateData?.preview?.map((item, index) => {
const indexForLabel = index + 1
return (
<ChunkContainer
key={`${item.content}-${index}`}
label={`Chunk-${indexForLabel}`}
characterCount={item.content.length}
>
<FormattedText>
{item.child_chunks.map((child, index) => {
const indexForLabel = index + 1
return (
<PreviewSlice
key={child}
label={`C-${indexForLabel}`}
text={child}
tooltip={`Child-chunk-${indexForLabel} · ${child.length} Characters`}
labelInnerClassName='text-[10px] font-semibold align-bottom leading-7'
dividerClassName='leading-7'
/>
)
})}
</FormattedText>
</ChunkContainer>
)
})
)}
{isIdle && (
<div className='flex h-full w-full items-center justify-center'>
<div className='flex flex-col items-center justify-center gap-3 pb-4'>
<RiSearchEyeLine className='size-10 text-text-empty-state-icon' />
<p className='text-sm text-text-tertiary'>
{t('datasetCreation.stepTwo.previewChunkTip')}
</p>
<Button onClick={onPreview}>
{t('datasetPipeline.addDocuments.stepTwo.previewChunks')}
</Button>
</div>
</div>
)}
{isPending && (
<div className='h-full w-full space-y-6 overflow-hidden'>
{Array.from({ length: 10 }, (_, i) => (
<SkeletonContainer key={i}>
<SkeletonRow>
<SkeletonRectangle className='w-20' />
<SkeletonPoint />
<SkeletonRectangle className='w-24' />
</SkeletonRow>
<SkeletonRectangle className='w-full' />
<SkeletonRectangle className='w-full' />
<SkeletonRectangle className='w-[422px]' />
</SkeletonContainer>
))}
</div>
)}
</PreviewContainer>
)
}
export default React.memo(ChunkPreview)

View File

@@ -0,0 +1,75 @@
'use client'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from './loading'
import type { CustomFile as File } from '@/models/datasets'
import { RiCloseLine } from '@remixicon/react'
import { useFilePreview } from '@/service/use-common'
import DocumentFileIcon from '../../../common/document-file-icon'
import { formatFileSize, formatNumberAbbreviated } from '@/utils/format'
type FilePreviewProps = {
file: File
hidePreview: () => void
}
const FilePreview = ({
file,
hidePreview,
}: FilePreviewProps) => {
const { t } = useTranslation()
const { data: fileData, isFetching } = useFilePreview(file.id || '')
const fileName = useMemo(() => {
if (!file)
return ''
const arr = file.name.split('.')
return arr.slice(0, -1).join()
}, [file])
return (
<div className='flex h-full w-full flex-col rounded-t-xl border-l border-t border-components-panel-border bg-background-default-lighter shadow-md shadow-shadow-shadow-5'>
<div className='flex gap-x-2 border-b border-divider-subtle pb-3 pl-6 pr-4 pt-4'>
<div className='flex grow flex-col gap-y-1'>
<div className='system-2xs-semibold-uppercase text-text-accent'>{t('datasetPipeline.addDocuments.stepOne.preview')}</div>
<div className='title-md-semi-bold text-tex-primary'>{`${fileName}.${file.extension || ''}`}</div>
<div className='system-xs-medium flex items-center gap-x-1 text-text-tertiary'>
<DocumentFileIcon
className='size-3.5 shrink-0'
name={file.name}
extension={file.extension}
/>
<span className='uppercase'>{file.extension}</span>
<span>·</span>
<span>{formatFileSize(file.size)}</span>
{fileData && (
<>
<span>·</span>
<span>{`${formatNumberAbbreviated(fileData.content.length)} ${t('datasetPipeline.addDocuments.characters')}`}</span>
</>
)}
</div>
</div>
<button
type='button'
className='flex h-8 w-8 shrink-0 items-center justify-center'
onClick={hidePreview}
>
<RiCloseLine className='size-[18px]' />
</button>
</div>
{isFetching && (
<div className='grow'>
<Loading />
</div>
)}
{!isFetching && fileData && (
<div className='body-md-regular grow overflow-hidden px-6 py-5 text-text-secondary'>
{fileData.content}
</div>
)}
</div>
)
}
export default FilePreview

View File

@@ -0,0 +1,52 @@
import React from 'react'
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
const Loading = () => {
return (
<div className='flex h-full w-full flex-col gap-y-3 overflow-hidden bg-gradient-to-b from-components-panel-bg-transparent to-components-panel-bg px-6 py-5'>
<SkeletonContainer className='w-full gap-0'>
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-3/5' />
</SkeletonContainer>
<SkeletonContainer className='w-full gap-0'>
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-[70%]' />
</SkeletonContainer>
<SkeletonContainer className='w-full gap-0'>
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-[56%]' />
</SkeletonContainer>
<SkeletonContainer className='w-full gap-0'>
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-3/5' />
</SkeletonContainer>
<SkeletonContainer className='w-full gap-0'>
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-3/5' />
</SkeletonContainer>
<SkeletonContainer className='w-full gap-0'>
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-full' />
<SkeletonRectangle className='my-1.5 w-1/2' />
</SkeletonContainer>
</div>
)
}
export default React.memo(Loading)

View File

@@ -0,0 +1,89 @@
'use client'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { NotionPage } from '@/models/common'
import { RiCloseLine } from '@remixicon/react'
import { formatNumberAbbreviated } from '@/utils/format'
import Loading from './loading'
import { Notion } from '@/app/components/base/icons/src/public/common'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { usePreviewOnlineDocument } from '@/service/use-pipeline'
import Toast from '@/app/components/base/toast'
import { Markdown } from '@/app/components/base/markdown'
import { useDataSourceStore } from '../data-source/store'
type OnlineDocumentPreviewProps = {
currentPage: NotionPage
datasourceNodeId: string
hidePreview: () => void
}
const OnlineDocumentPreview = ({
currentPage,
datasourceNodeId,
hidePreview,
}: OnlineDocumentPreviewProps) => {
const { t } = useTranslation()
const [content, setContent] = useState('')
const pipelineId = useDatasetDetailContextWithSelector(state => state.dataset?.pipeline_id)
const { mutateAsync: getOnlineDocumentContent, isPending } = usePreviewOnlineDocument()
const dataSourceStore = useDataSourceStore()
useEffect(() => {
const { currentCredentialId } = dataSourceStore.getState()
getOnlineDocumentContent({
workspaceID: currentPage.workspace_id,
pageID: currentPage.page_id,
pageType: currentPage.type,
pipelineId: pipelineId || '',
datasourceNodeId,
credentialId: currentCredentialId,
}, {
onSuccess(data) {
setContent(data.content)
},
onError(error) {
Toast.notify({
type: 'error',
message: error.message,
})
},
})
}, [currentPage.page_id])
return (
<div className='flex h-full w-full flex-col rounded-t-xl border-l border-t border-components-panel-border bg-background-default-lighter shadow-md shadow-shadow-shadow-5'>
<div className='flex gap-x-2 border-b border-divider-subtle pb-3 pl-6 pr-4 pt-4'>
<div className='flex grow flex-col gap-y-1'>
<div className='system-2xs-semibold-uppercase text-text-accent'>{t('datasetPipeline.addDocuments.stepOne.preview')}</div>
<div className='title-md-semi-bold text-tex-primary'>{currentPage?.page_name}</div>
<div className='system-xs-medium flex items-center gap-x-1 text-text-tertiary'>
<Notion className='size-3.5' />
<span>{currentPage.type}</span>
<span>·</span>
<span>{`${formatNumberAbbreviated(content.length)} ${t('datasetPipeline.addDocuments.characters')}`}</span>
</div>
</div>
<button
type='button'
className='flex h-8 w-8 shrink-0 items-center justify-center'
onClick={hidePreview}
>
<RiCloseLine className='size-[18px]' />
</button>
</div>
{isPending && (
<div className='grow'>
<Loading />
</div>
)}
{!isPending && content && (
<div className='body-md-regular grow overflow-hidden px-6 py-5 text-text-secondary'>
<Markdown content={content} />
</div>
)}
</div>
)
}
export default OnlineDocumentPreview

View File

@@ -0,0 +1,48 @@
'use client'
import React from 'react'
import { useTranslation } from 'react-i18next'
import type { CrawlResultItem } from '@/models/datasets'
import { RiCloseLine, RiGlobalLine } from '@remixicon/react'
import { formatNumberAbbreviated } from '@/utils/format'
type WebsitePreviewProps = {
currentWebsite: CrawlResultItem
hidePreview: () => void
}
const WebsitePreview = ({
currentWebsite,
hidePreview,
}: WebsitePreviewProps) => {
const { t } = useTranslation()
return (
<div className='flex h-full w-full flex-col rounded-t-xl border-l border-t border-components-panel-border bg-background-default-lighter shadow-md shadow-shadow-shadow-5'>
<div className='flex gap-x-2 border-b border-divider-subtle pb-3 pl-6 pr-4 pt-4'>
<div className='flex grow flex-col gap-y-1'>
<div className='system-2xs-semibold-uppercase'>{t('datasetPipeline.addDocuments.stepOne.preview')}</div>
<div className='title-md-semi-bold text-tex-primary'>{currentWebsite.title}</div>
<div className='system-xs-medium flex gap-x-1 text-text-tertiary'>
<RiGlobalLine className='size-3.5' />
<span className='uppercase' title={currentWebsite.source_url}>{currentWebsite.source_url}</span>
<span>·</span>
<span>·</span>
<span>{`${formatNumberAbbreviated(currentWebsite.content.length)} ${t('datasetPipeline.addDocuments.characters')}`}</span>
</div>
</div>
<button
type='button'
className='flex h-8 w-8 shrink-0 items-center justify-center'
onClick={hidePreview}
>
<RiCloseLine className='size-[18px]' />
</button>
</div>
<div className='body-md-regular grow overflow-hidden px-6 py-5 text-text-secondary'>
{currentWebsite.content}
</div>
</div>
)
}
export default WebsitePreview

View File

@@ -0,0 +1,40 @@
import React from 'react'
import Button from '@/app/components/base/button'
import { useTranslation } from 'react-i18next'
import { RiArrowLeftLine } from '@remixicon/react'
type ActionsProps = {
onBack: () => void
runDisabled?: boolean
onProcess: () => void
}
const Actions = ({
onBack,
runDisabled,
onProcess,
}: ActionsProps) => {
const { t } = useTranslation()
return (
<div className='flex items-center justify-between'>
<Button
variant='secondary'
onClick={onBack}
className='gap-x-0.5'
>
<RiArrowLeftLine className='size-4' />
<span className='px-0.5'>{t('datasetPipeline.operations.dataSource')}</span>
</Button>
<Button
variant='primary'
disabled={runDisabled}
onClick={onProcess}
>
{t('datasetPipeline.operations.saveAndProcess')}
</Button>
</div>
)
}
export default React.memo(Actions)

View File

@@ -0,0 +1,96 @@
import { useAppForm } from '@/app/components/base/form'
import BaseField from '@/app/components/base/form/form-scenarios/base/field'
import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types'
import Toast from '@/app/components/base/toast'
import { useCallback, useImperativeHandle } from 'react'
import type { ZodSchema } from 'zod'
import Header from './header'
type OptionsProps = {
initialData: Record<string, any>
configurations: BaseConfiguration[]
schema: ZodSchema
onSubmit: (data: Record<string, any>) => void
onPreview: () => void
ref: React.RefObject<any>
isRunning: boolean
}
const Form = ({
initialData,
configurations,
schema,
onSubmit,
onPreview,
ref,
isRunning,
}: OptionsProps) => {
const form = useAppForm({
defaultValues: initialData,
validators: {
onSubmit: ({ value }) => {
const result = schema.safeParse(value)
if (!result.success) {
const issues = result.error.issues
const firstIssue = issues[0]
const errorMessage = `"${firstIssue.path.join('.')}" ${firstIssue.message}`
Toast.notify({
type: 'error',
message: errorMessage,
})
return errorMessage
}
return undefined
},
},
onSubmit: ({ value }) => {
onSubmit(value)
},
})
useImperativeHandle(ref, () => {
return {
submit: () => {
form.handleSubmit()
},
}
}, [form])
const handleReset = useCallback(() => {
form.reset()
}, [form])
return (
<form
className='flex w-full flex-col rounded-lg border border-components-panel-border bg-components-panel-bg'
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
>
<form.Subscribe
selector={state => state.isDirty}
children={isDirty => (
<Header
onReset={handleReset}
resetDisabled={!isDirty}
onPreview={onPreview}
previewDisabled={isRunning}
/>
)}
/>
<div className='flex flex-col gap-3 border-t border-divider-subtle px-4 py-3'>
{configurations.map((config, index) => {
const FieldComponent = BaseField({
initialData,
config,
})
return <FieldComponent key={index} form={form} />
})}
</div>
</form>
)
}
export default Form

View File

@@ -0,0 +1,42 @@
import React from 'react'
import Button from '@/app/components/base/button'
import { useTranslation } from 'react-i18next'
import { RiSearchEyeLine } from '@remixicon/react'
type HeaderProps = {
onReset: () => void
resetDisabled: boolean
previewDisabled: boolean
onPreview?: () => void
}
const Header = ({
onReset,
resetDisabled,
previewDisabled,
onPreview,
}: HeaderProps) => {
const { t } = useTranslation()
return (
<div className='flex items-center gap-x-1 px-4 py-2'>
<div className='system-sm-semibold-uppercase grow text-text-secondary'>
{t('datasetPipeline.addDocuments.stepTwo.chunkSettings')}
</div>
<Button variant='ghost' disabled={resetDisabled} onClick={onReset}>
{t('common.operation.reset')}
</Button>
<Button
variant='secondary-accent'
onClick={onPreview}
className='gap-x-0.5'
disabled={previewDisabled}
>
<RiSearchEyeLine className='size-4' />
<span className='px-0.5'>{t('datasetPipeline.addDocuments.stepTwo.previewChunks')}</span>
</Button>
</div>
)
}
export default React.memo(Header)

View File

@@ -0,0 +1,15 @@
import { usePublishedPipelineProcessingParams } from '@/service/use-pipeline'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
export const useInputVariables = (datasourceNodeId: string) => {
const pipelineId = useDatasetDetailContextWithSelector(state => state.dataset?.pipeline_id)
const { data: paramsConfig, isFetching: isFetchingParams } = usePublishedPipelineProcessingParams({
pipeline_id: pipelineId!,
node_id: datasourceNodeId,
})
return {
paramsConfig,
isFetchingParams,
}
}

View File

@@ -0,0 +1,48 @@
import React from 'react'
import { generateZodSchema } from '@/app/components/base/form/form-scenarios/base/utils'
import { useInputVariables } from './hooks'
import Form from './form'
import Actions from './actions'
import { useConfigurations, useInitialData } from '@/app/components/rag-pipeline/hooks/use-input-fields'
type ProcessDocumentsProps = {
dataSourceNodeId: string
ref: React.RefObject<any>
isRunning: boolean
onProcess: () => void
onPreview: () => void
onSubmit: (data: Record<string, any>) => void
onBack: () => void
}
const ProcessDocuments = ({
dataSourceNodeId,
isRunning,
onProcess,
onPreview,
onSubmit,
onBack,
ref,
}: ProcessDocumentsProps) => {
const { isFetchingParams, paramsConfig } = useInputVariables(dataSourceNodeId)
const initialData = useInitialData(paramsConfig?.variables || [])
const configurations = useConfigurations(paramsConfig?.variables || [])
const schema = generateZodSchema(configurations)
return (
<div className='flex flex-col gap-y-4 pt-4'>
<Form
ref={ref}
initialData={initialData}
configurations={configurations}
schema={schema}
onSubmit={onSubmit}
onPreview={onPreview}
isRunning={isRunning}
/>
<Actions runDisabled={isFetchingParams || isRunning} onBack={onBack} onProcess={onProcess} />
</div>
)
}
export default React.memo(ProcessDocuments)

View File

@@ -0,0 +1,251 @@
import React, { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import {
RiAedFill,
RiArrowRightLine,
RiCheckboxCircleFill,
RiErrorWarningFill,
RiLoader2Fill,
RiTerminalBoxLine,
} from '@remixicon/react'
import cn from '@/utils/classnames'
import Button from '@/app/components/base/button'
import type { IndexingStatusResponse } from '@/models/datasets'
import NotionIcon from '@/app/components/base/notion-icon'
import PriorityLabel from '@/app/components/billing/priority-label'
import { Plan } from '@/app/components/billing/type'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import { useProviderContext } from '@/context/provider-context'
import Tooltip from '@/app/components/base/tooltip'
import { useInvalidDocumentList } from '@/service/knowledge/use-document'
import DocumentFileIcon from '@/app/components/datasets/common/document-file-icon'
import RuleDetail from './rule-detail'
import type { IndexingType } from '@/app/components/datasets/create/step-two'
import type { RETRIEVE_METHOD } from '@/types/app'
import { DatasourceType, type InitialDocumentDetail } from '@/models/pipeline'
import { useIndexingStatusBatch, useProcessRule } from '@/service/knowledge/use-dataset'
import Divider from '@/app/components/base/divider'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import Link from 'next/link'
type EmbeddingProcessProps = {
datasetId: string
batchId: string
documents?: InitialDocumentDetail[]
indexingType?: IndexingType
retrievalMethod?: RETRIEVE_METHOD
}
const EmbeddingProcess = ({
datasetId,
batchId,
documents = [],
indexingType,
retrievalMethod,
}: EmbeddingProcessProps) => {
const { t } = useTranslation()
const router = useRouter()
const { enableBilling, plan } = useProviderContext()
const [indexingStatusBatchDetail, setIndexingStatusDetail] = useState<IndexingStatusResponse[]>([])
const [shouldPoll, setShouldPoll] = useState(true)
const { mutateAsync: fetchIndexingStatus } = useIndexingStatusBatch({ datasetId, batchId })
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>
const fetchData = async () => {
await fetchIndexingStatus(undefined, {
onSuccess: (res) => {
const indexingStatusDetailList = res.data
setIndexingStatusDetail(indexingStatusDetailList)
const isCompleted = indexingStatusDetailList.every(indexingStatusDetail => ['completed', 'error', 'paused'].includes(indexingStatusDetail.indexing_status))
if (isCompleted)
setShouldPoll(false)
},
onSettled: () => {
if (shouldPoll)
timeoutId = setTimeout(fetchData, 2500)
},
})
}
fetchData()
return () => {
clearTimeout(timeoutId)
}
}, [shouldPoll])
// get rule
const firstDocument = documents[0]
const { data: ruleDetail } = useProcessRule(firstDocument.id)
const invalidDocumentList = useInvalidDocumentList()
const navToDocumentList = () => {
invalidDocumentList()
router.push(`/datasets/${datasetId}/documents`)
}
const apiReferenceUrl = useDatasetApiAccessUrl()
const isEmbeddingWaiting = useMemo(() => {
if (!indexingStatusBatchDetail.length) return false
return indexingStatusBatchDetail.every(indexingStatusDetail => ['waiting'].includes(indexingStatusDetail?.indexing_status || ''))
}, [indexingStatusBatchDetail])
const isEmbedding = useMemo(() => {
if (!indexingStatusBatchDetail.length) return false
return indexingStatusBatchDetail.some(indexingStatusDetail => ['indexing', 'splitting', 'parsing', 'cleaning'].includes(indexingStatusDetail?.indexing_status || ''))
}, [indexingStatusBatchDetail])
const isEmbeddingCompleted = useMemo(() => {
if (!indexingStatusBatchDetail.length) return false
return indexingStatusBatchDetail.every(indexingStatusDetail => ['completed', 'error', 'paused'].includes(indexingStatusDetail?.indexing_status || ''))
}, [indexingStatusBatchDetail])
const getSourceName = (id: string) => {
const doc = documents.find(document => document.id === id)
return doc?.name
}
const getFileType = (name?: string) => name?.split('.').pop() || 'txt'
const getSourcePercent = (detail: IndexingStatusResponse) => {
const completedCount = detail.completed_segments || 0
const totalCount = detail.total_segments || 0
if (totalCount === 0)
return 0
const percent = Math.round(completedCount * 100 / totalCount)
return percent > 100 ? 100 : percent
}
const getSourceType = (id: string) => {
const doc = documents.find(document => document.id === id)
return doc?.data_source_type
}
const getIcon = (id: string) => {
const doc = documents.find(document => document.id === id)
return doc?.data_source_info.notion_page_icon
}
const isSourceEmbedding = (detail: IndexingStatusResponse) =>
['indexing', 'splitting', 'parsing', 'cleaning', 'waiting'].includes(detail.indexing_status || '')
return (
<>
<div className='flex flex-col gap-y-3'>
<div className='system-md-semibold-uppercase flex items-center gap-x-1 text-text-secondary'>
{(isEmbeddingWaiting || isEmbedding) && (
<>
<RiLoader2Fill className='size-4 animate-spin' />
<span>
{isEmbeddingWaiting ? t('datasetDocuments.embedding.waiting') : t('datasetDocuments.embedding.processing')}
</span>
</>
)}
{isEmbeddingCompleted && t('datasetDocuments.embedding.completed')}
</div>
{
enableBilling && plan.type !== Plan.team && (
<div className='flex h-[52px] items-center gap-x-2 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-2.5 pl-3 shadow-xs shadow-shadow-shadow-3'>
<div className='flex shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 shadow-md shadow-shadow-shadow-5'>
<RiAedFill className='size-4 text-text-primary-on-surface' />
</div>
<div className='system-md-medium grow text-text-primary'>
{t('billing.plansCommon.documentProcessingPriorityUpgrade')}
</div>
<UpgradeBtn loc='knowledge-speed-up' />
</div>
)
}
<div className='flex flex-col gap-0.5 pb-2'>
{indexingStatusBatchDetail.map(indexingStatusDetail => (
<div
key={indexingStatusDetail.id}
className={cn(
'relative h-[26px] overflow-hidden rounded-md bg-components-progress-bar-bg',
indexingStatusDetail.indexing_status === 'error' && 'bg-state-destructive-hover-alt',
)}
>
{isSourceEmbedding(indexingStatusDetail) && (
<div
className='absolute left-0 top-0 h-full min-w-0.5 border-r-[2px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress'
style={{ width: `${getSourcePercent(indexingStatusDetail)}%` }}
/>
)}
<div className='z-[1] flex h-full items-center gap-1 pl-[6px] pr-2'>
{getSourceType(indexingStatusDetail.id) === DatasourceType.localFile && (
<DocumentFileIcon
size='sm'
className='shrink-0'
name={getSourceName(indexingStatusDetail.id)}
extension={getFileType(getSourceName(indexingStatusDetail.id))}
/>
)}
{getSourceType(indexingStatusDetail.id) === DatasourceType.onlineDocument && (
<NotionIcon
className='shrink-0'
type='page'
src={getIcon(indexingStatusDetail.id)}
/>
)}
<div className='flex w-0 grow items-center gap-1' title={getSourceName(indexingStatusDetail.id)}>
<div className='system-xs-medium truncate text-text-secondary'>
{getSourceName(indexingStatusDetail.id)}
</div>
{
enableBilling && (
<PriorityLabel className='ml-0' />
)
}
</div>
{isSourceEmbedding(indexingStatusDetail) && (
<div className='shrink-0 text-xs text-text-secondary'>{`${getSourcePercent(indexingStatusDetail)}%`}</div>
)}
{indexingStatusDetail.indexing_status === 'error' && (
<Tooltip
popupClassName='px-4 py-[14px] max-w-60 body-xs-regular text-text-secondary border-[0.5px] border-components-panel-border rounded-xl'
offset={4}
popupContent={indexingStatusDetail.error}
>
<span>
<RiErrorWarningFill className='size-4 shrink-0 text-text-destructive' />
</span>
</Tooltip>
)}
{indexingStatusDetail.indexing_status === 'completed' && (
<RiCheckboxCircleFill className='size-4 shrink-0 text-text-success' />
)}
</div>
</div>
))}
</div>
<Divider type='horizontal' className='my-0 bg-divider-subtle' />
<RuleDetail
sourceData={ruleDetail}
indexingType={indexingType}
retrievalMethod={retrievalMethod}
/>
</div>
<div className='mt-6 flex items-center gap-x-2 py-2'>
<Link
href={apiReferenceUrl}
target='_blank'
rel='noopener noreferrer'
>
<Button
className='w-fit gap-x-0.5 px-3'
>
<RiTerminalBoxLine className='size-4' />
<span className='px-0.5'>Access the API</span>
</Button>
</Link>
<Button
className='w-fit gap-x-0.5 px-3'
variant='primary'
onClick={navToDocumentList}
>
<span className='px-0.5'>{t('datasetCreation.stepThree.navTo')}</span>
<RiArrowRightLine className='size-4 stroke-current stroke-1' />
</Button>
</div>
</>
)
}
export default EmbeddingProcess

View File

@@ -0,0 +1,84 @@
import React, { useCallback } from 'react'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ProcessMode, type ProcessRuleResponse } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import { useTranslation } from 'react-i18next'
import { FieldInfo } from '@/app/components/datasets/documents/detail/metadata'
import Image from 'next/image'
import { indexMethodIcon, retrievalIcon } from '@/app/components/datasets/create/icons'
type RuleDetailProps = {
sourceData?: ProcessRuleResponse
indexingType?: IndexingType
retrievalMethod?: RETRIEVE_METHOD
}
const RuleDetail = ({
sourceData,
indexingType,
retrievalMethod,
}: RuleDetailProps) => {
const { t } = useTranslation()
const getValue = useCallback((field: string) => {
let value = '-'
switch (field) {
case 'mode':
value = !sourceData?.mode
? value
// eslint-disable-next-line sonarjs/no-nested-conditional
: sourceData.mode === ProcessMode.general
? (t('datasetDocuments.embedding.custom') as string)
// eslint-disable-next-line sonarjs/no-nested-conditional
: `${t('datasetDocuments.embedding.hierarchical')} · ${sourceData?.rules?.parent_mode === 'paragraph'
? t('dataset.parentMode.paragraph')
: t('dataset.parentMode.fullDoc')}`
break
}
return value
}, [sourceData, t])
return (
<div className='flex flex-col gap-1'>
<FieldInfo
label={t('datasetDocuments.embedding.mode')}
displayedValue={getValue('mode')}
/>
<FieldInfo
label={t('datasetCreation.stepTwo.indexMode')}
displayedValue={t(`datasetCreation.stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`) as string}
valueIcon={
<Image
className='size-4'
src={
indexingType === IndexingType.ECONOMICAL
? indexMethodIcon.economical
: indexMethodIcon.high_quality
}
alt=''
/>
}
/>
<FieldInfo
label={t('datasetSettings.form.retrievalSetting.title')}
displayedValue={t(`dataset.retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod}.title`) as string}
valueIcon={
<Image
className='size-4'
src={
retrievalMethod === RETRIEVE_METHOD.fullText
? retrievalIcon.fullText
// eslint-disable-next-line sonarjs/no-nested-conditional
: retrievalMethod === RETRIEVE_METHOD.hybrid
? retrievalIcon.hybrid
: retrievalIcon.vector
}
alt=''
/>
}
/>
</div>
)
}
export default React.memo(RuleDetail)

View File

@@ -0,0 +1,61 @@
'use client'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { RiBookOpenLine } from '@remixicon/react'
import { useDocLink } from '@/context/i18n'
import EmbeddingProcess from './embedding-process'
import type { InitialDocumentDetail } from '@/models/pipeline'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
type ProcessingProps = {
batchId: string
documents: InitialDocumentDetail[]
}
const Processing = ({
batchId,
documents,
}: ProcessingProps) => {
const { t } = useTranslation()
const docLink = useDocLink()
const datasetId = useDatasetDetailContextWithSelector(s => s.dataset?.id)
const indexingType = useDatasetDetailContextWithSelector(s => s.dataset?.indexing_technique)
const retrievalMethod = useDatasetDetailContextWithSelector(s => s.dataset?.retrieval_model_dict.search_method)
return (
<div className='flex h-full w-full justify-center overflow-hidden'>
<div className='h-full w-3/5 overflow-y-auto pb-8 pt-10'>
<div className='max-w-[640px]'>
<EmbeddingProcess
datasetId={datasetId!}
batchId={batchId}
documents={documents}
indexingType={indexingType}
retrievalMethod={retrievalMethod}
/>
</div>
</div>
<div className='w-2/5 pr-8 pt-[88px]'>
<div className='flex w-[328px] flex-col gap-3 rounded-xl bg-background-section p-6'>
<div className='flex size-10 items-center justify-center rounded-[10px] bg-components-card-bg shadow-lg shadow-shadow-shadow-5'>
<RiBookOpenLine className='size-5 text-text-accent' />
</div>
<div className='flex flex-col gap-y-2'>
<div className='system-xl-semibold text-text-secondary'>{t('datasetCreation.stepThree.sideTipTitle')}</div>
<div className='system-sm-regular text-text-tertiary'>{t('datasetCreation.stepThree.sideTipContent')}</div>
<a
href={docLink('/guides/knowledge-base/integrate-knowledge-within-application')}
target='_blank'
rel='noreferrer noopener'
className='system-sm-regular text-text-accent'
>
{t('datasetPipeline.addDocuments.stepThree.learnMore')}
</a>
</div>
</div>
</div>
</div>
)
}
export default Processing

View File

@@ -0,0 +1,33 @@
import cn from '@/utils/classnames'
import React from 'react'
export type Step = {
label: string
value: string
}
type StepIndicatorProps = {
currentStep: number
steps: Step[]
}
const StepIndicator = ({
currentStep,
steps,
}: StepIndicatorProps) => {
return (
<div className='flex gap-x-1'>
{steps.map((step, index) => {
const isActive = index === currentStep - 1
return (
<div
key={step.value}
className={cn('h-1 w-1 rounded-lg bg-divider-solid', isActive && 'w-2 bg-state-accent-solid')}
/>
)
})}
</div>
)
}
export default React.memo(StepIndicator)

View File

@@ -0,0 +1,5 @@
export enum AddDocumentsStep {
dataSource = 'dataSource',
processDocuments = 'processDocuments',
processingDocuments = 'processingDocuments',
}