feat: knowledge pipeline (#25360)

Signed-off-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: twwu <twwu@dify.ai>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: jyong <718720800@qq.com>
Co-authored-by: Wu Tianwei <30284043+WTW0313@users.noreply.github.com>
Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
Co-authored-by: lyzno1 <yuanyouhuilyz@gmail.com>
Co-authored-by: quicksand <quicksandzn@gmail.com>
Co-authored-by: Jyong <76649700+JohnJyong@users.noreply.github.com>
Co-authored-by: lyzno1 <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: zxhlyh <jasonapring2015@outlook.com>
Co-authored-by: Yongtao Huang <yongtaoh2022@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: nite-knite <nkCoding@gmail.com>
Co-authored-by: Hanqing Zhao <sherry9277@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Harry <xh001x@hotmail.com>
This commit is contained in:
-LAN- 2025-09-18 12:49:10 +08:00 • committed by GitHub
commit 85cda47c70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
1772 changed files with 102407 additions and 31710 deletions

View file

@ -3,7 +3,7 @@ import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import { GeneralType, ParentChildType } from '@/app/components/base/icons/src/public/knowledge'
import { GeneralChunk, ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge'
type Props = {
isGeneralMode: boolean
@ -15,13 +15,14 @@ const ChunkingModeLabel: FC<Props> = ({
isQAMode,
}) => {
const { t } = useTranslation()
const TypeIcon = isGeneralMode ? GeneralType : ParentChildType
const TypeIcon = isGeneralMode ? GeneralChunk : ParentChildChunk
const generalSuffix = isQAMode ? ' · QA' : ''
return (
<Badge>
<div className='flex h-full items-center space-x-0.5 text-text-tertiary'>
<TypeIcon className='h-3 w-3' />
<span className='system-2xs-medium-uppercase'>{isGeneralMode ? `${t('dataset.chunkingMode.general')}${isQAMode ? ' · QA' : ''}` : t('dataset.chunkingMode.parentChild')}</span>
<span className='system-2xs-medium-uppercase'>{isGeneralMode ? `${t('dataset.chunkingMode.general')}${generalSuffix}` : t('dataset.chunkingMode.parentChild')}</span>
</div>
</Badge>
)

View file

@ -0,0 +1,63 @@
import cn from '@/utils/classnames'
import React, { useCallback, useMemo, useState } from 'react'
type CredentialIconProps = {
avatar_url?: string
name: string
size?: number
className?: string
}
const ICON_BG_COLORS = [
'bg-components-icon-bg-orange-dark-solid',
'bg-components-icon-bg-pink-solid',
'bg-components-icon-bg-indigo-solid',
'bg-components-icon-bg-teal-solid',
]
export const CredentialIcon: React.FC<CredentialIconProps> = ({
avatar_url,
name,
size = 20,
className = '',
}) => {
const [showAvatar, setShowAvatar] = useState(!!avatar_url && avatar_url !== 'default')
const firstLetter = useMemo(() => name.charAt(0).toUpperCase(), [name])
const bgColor = useMemo(() => ICON_BG_COLORS[firstLetter.charCodeAt(0) % ICON_BG_COLORS.length], [firstLetter])
const onImgLoadError = useCallback(() => {
setShowAvatar(false)
}, [])
if (avatar_url && avatar_url !== 'default' && showAvatar) {
return (
<div
className='flex shrink-0 items-center justify-center overflow-hidden rounded-md border border-divider-regular'
style={{ width: `${size}px`, height: `${size}px` }}
>
<img
src={avatar_url}
width={size}
height={size}
className={cn('shrink-0 object-contain', className)}
onError={onImgLoadError}
/>
</div>
)
}
return (
<div
className={cn(
'flex shrink-0 items-center justify-center rounded-md border border-divider-regular',
bgColor,
className,
)}
style={{ width: `${size}px`, height: `${size}px` }}
>
<span className='bg-gradient-to-b from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text text-[13px] font-semibold leading-[1.2] text-transparent opacity-90'>
{firstLetter}
</span>
</div>
)
}

View file

@ -22,7 +22,7 @@ const extendToFileTypeMap: { [key: string]: FileAppearanceType } = {
type Props = {
extension?: string
name?: string
size?: 'sm' | 'lg' | 'md'
size?: 'sm' | 'md' | 'lg' | 'xl'
className?: string
}

View file

@ -30,7 +30,7 @@ const DocumentList: FC<Props> = ({
className='flex h-8 cursor-pointer items-center space-x-2 rounded-lg px-2 hover:bg-state-base-hover'
onClick={handleChange(item)}
>
<FileIcon name={item.name} extension={extension} size='md' />
<FileIcon name={item.name} extension={extension} size='lg' />
<div className='truncate text-sm text-text-secondary'>{name}</div>
</div>
)

View file

@ -1,13 +1,13 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useState } from 'react'
import React, { useCallback, useMemo, useState } from 'react'
import { useBoolean } from 'ahooks'
import { RiArrowDownSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import FileIcon from '../document-file-icon'
import DocumentList from './document-list'
import type { DocumentItem, ParentMode, SimpleDocumentDetail } from '@/models/datasets'
import { ProcessMode } from '@/models/datasets'
import { ChunkingMode } from '@/models/datasets'
import {
PortalToFollowElem,
PortalToFollowElemContent,
@ -15,7 +15,7 @@ import {
} from '@/app/components/base/portal-to-follow-elem'
import cn from '@/utils/classnames'
import SearchInput from '@/app/components/base/search-input'
import { GeneralType, ParentChildType } from '@/app/components/base/icons/src/public/knowledge'
import { GeneralChunk, ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge'
import { useDocumentList } from '@/service/knowledge/use-document'
import Loading from '@/app/components/base/loading'
@ -24,7 +24,7 @@ type Props = {
value: {
name?: string
extension?: string
processMode?: ProcessMode
chunkingMode?: ChunkingMode
parentMode?: ParentMode
}
onChange: (value: SimpleDocumentDetail) => void
@ -39,7 +39,7 @@ const DocumentPicker: FC<Props> = ({
const {
name,
extension,
processMode,
chunkingMode,
parentMode,
} = value
const [query, setQuery] = useState('')
@ -53,8 +53,10 @@ const DocumentPicker: FC<Props> = ({
},
})
const documentsList = data?.data
const isParentChild = processMode === ProcessMode.parentChild
const TypeIcon = isParentChild ? ParentChildType : GeneralType
const isGeneralMode = chunkingMode === ChunkingMode.text
const isParentChild = chunkingMode === ChunkingMode.parentChild
const isQAMode = chunkingMode === ChunkingMode.qa
const TypeIcon = isParentChild ? ParentChildChunk : GeneralChunk
const [open, {
set: setOpen,
@ -67,6 +69,12 @@ const DocumentPicker: FC<Props> = ({
setOpen(false)
}, [documentsList, onChange, setOpen])
const parentModeLabel = useMemo(() => {
if (!parentMode)
return '--'
return parentMode === 'paragraph' ? t('dataset.parentMode.paragraph') : t('dataset.parentMode.fullDoc')
}, [parentMode, t])
return (
<PortalToFollowElem
open={open}
@ -75,7 +83,7 @@ const DocumentPicker: FC<Props> = ({
>
<PortalToFollowElemTrigger onClick={togglePopup}>
<div className={cn('ml-1 flex cursor-pointer select-none items-center rounded-lg px-2 py-0.5 hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
<FileIcon name={name} extension={extension} size='lg' />
<FileIcon name={name} extension={extension} size='xl' />
<div className='ml-1 mr-0.5 flex flex-col items-start'>
<div className='flex items-center space-x-0.5'>
<span className={cn('system-md-semibold text-text-primary')}> {name || '--'}</span>
@ -84,8 +92,9 @@ const DocumentPicker: FC<Props> = ({
<div className='flex h-3 items-center space-x-0.5 text-text-tertiary'>
<TypeIcon className='h-3 w-3' />
<span className={cn('system-2xs-medium-uppercase', isParentChild && 'mt-0.5' /* to icon problem cause not ver align */)}>
{isParentChild ? t('dataset.chunkingMode.parentChild') : t('dataset.chunkingMode.general')}
{isParentChild && ` · ${!parentMode ? '--' : parentMode === 'paragraph' ? t('dataset.parentMode.paragraph') : t('dataset.parentMode.fullDoc')}`}
{isGeneralMode && t('dataset.chunkingMode.general')}
{isQAMode && t('dataset.chunkingMode.qa')}
{isParentChild && `${t('dataset.chunkingMode.parentChild')} · ${parentModeLabel}`}
</span>
</div>
</div>

View file

@ -51,7 +51,7 @@ const PreviewDocumentPicker: FC<Props> = ({
>
<PortalToFollowElemTrigger onClick={togglePopup}>
<div className={cn('flex h-6 select-none items-center rounded-md px-1 hover:bg-state-base-hover', open && 'bg-state-base-hover', className)}>
<FileIcon name={name} extension={extension} size='md' />
<FileIcon name={name} extension={extension} size='lg' />
<div className='ml-1 flex flex-col items-start'>
<div className='flex items-center space-x-0.5'>
<span className={cn('system-md-semibold max-w-[200px] truncate text-text-primary')}> {name || '--'}</span>

View file

@ -2,12 +2,12 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Image from 'next/image'
import RetrievalParamConfig from '../retrieval-param-config'
import { OptionCard } from '../../create/step-two/option-card'
import { retrievalIcon } from '../../create/icons'
import { RETRIEVE_METHOD } from '@/types/app'
import type { RetrievalConfig } from '@/types/app'
import OptionCard from '../../settings/option-card'
import { VectorSearch } from '@/app/components/base/icons/src/vender/knowledge'
import { EffectColor } from '../../settings/chunk-structure/types'
type Props = {
disabled?: boolean
@ -23,20 +23,25 @@ const EconomicalRetrievalMethodConfig: FC<Props> = ({
const { t } = useTranslation()
return (
<div className='space-y-2'>
<OptionCard
disabled={disabled} icon={<Image className='h-4 w-4' src={retrievalIcon.vector} alt='' />}
title={t('dataset.retrieval.invertedIndex.title')}
description={t('dataset.retrieval.invertedIndex.description')} isActive
activeHeaderClassName='bg-dataset-option-card-purple-gradient'
>
<RetrievalParamConfig
type={RETRIEVE_METHOD.invertedIndex}
value={value}
onChange={onChange}
/>
</OptionCard>
</div>
<OptionCard
id={RETRIEVE_METHOD.keywordSearch}
disabled={disabled}
icon={<VectorSearch className='size-4' />}
iconActiveColor='text-util-colors-purple-purple-600'
title={t('dataset.retrieval.keyword_search.title')}
description={t('dataset.retrieval.keyword_search.description')}
isActive
effectColor={EffectColor.purple}
showEffectColor
showChildren
className='gap-x-2'
>
<RetrievalParamConfig
type={RETRIEVE_METHOD.keywordSearch}
value={value}
onChange={onChange}
/>
</OptionCard>
)
}
export default React.memo(EconomicalRetrievalMethodConfig)

View file

@ -2,11 +2,7 @@
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Image from 'next/image'
import RetrievalParamConfig from '../retrieval-param-config'
import { OptionCard } from '../../create/step-two/option-card'
import Effect from '../../create/assets/option-card-effect-purple.svg'
import { retrievalIcon } from '../../create/icons'
import type { RetrievalConfig } from '@/types/app'
import { RETRIEVE_METHOD } from '@/types/app'
import { useProviderContext } from '@/context/provider-context'
@ -17,7 +13,9 @@ import {
RerankingModeEnum,
WeightedScoreEnum,
} from '@/models/datasets'
import Badge from '@/app/components/base/badge'
import OptionCard from '../../settings/option-card'
import { FullTextSearch, HybridSearch, VectorSearch } from '@/app/components/base/icons/src/vender/knowledge'
import { EffectColor } from '../../settings/chunk-structure/types'
type Props = {
disabled?: boolean
@ -92,17 +90,21 @@ const RetrievalMethodConfig: FC<Props> = ({
}, [value, rerankDefaultModel, isRerankDefaultModelValid, onChange])
return (
<div className='space-y-2'>
<div className='flex flex-col gap-y-2'>
{supportRetrievalMethods.includes(RETRIEVE_METHOD.semantic) && (
<OptionCard disabled={disabled} icon={<Image className='h-4 w-4' src={retrievalIcon.vector} alt='' />}
<OptionCard
id={RETRIEVE_METHOD.semantic}
disabled={disabled}
icon={<VectorSearch className='size-4' />}
iconActiveColor='text-util-colors-purple-purple-600'
title={t('dataset.retrieval.semantic_search.title')}
description={t('dataset.retrieval.semantic_search.description')}
isActive={
value.search_method === RETRIEVE_METHOD.semantic
}
onSwitched={() => onSwitch(RETRIEVE_METHOD.semantic)}
effectImg={Effect.src}
activeHeaderClassName='bg-dataset-option-card-purple-gradient'
isActive={value.search_method === RETRIEVE_METHOD.semantic}
onClick={onSwitch}
effectColor={EffectColor.purple}
showEffectColor
showChildren={value.search_method === RETRIEVE_METHOD.semantic}
className='gap-x-2'
>
<RetrievalParamConfig
type={RETRIEVE_METHOD.semantic}
@ -112,15 +114,19 @@ const RetrievalMethodConfig: FC<Props> = ({
</OptionCard>
)}
{supportRetrievalMethods.includes(RETRIEVE_METHOD.fullText) && (
<OptionCard disabled={disabled} icon={<Image className='h-4 w-4' src={retrievalIcon.fullText} alt='' />}
<OptionCard
id={RETRIEVE_METHOD.fullText}
disabled={disabled}
icon={<FullTextSearch className='size-4' />}
iconActiveColor='text-util-colors-purple-purple-600'
title={t('dataset.retrieval.full_text_search.title')}
description={t('dataset.retrieval.full_text_search.description')}
isActive={
value.search_method === RETRIEVE_METHOD.fullText
}
onSwitched={() => onSwitch(RETRIEVE_METHOD.fullText)}
effectImg={Effect.src}
activeHeaderClassName='bg-dataset-option-card-purple-gradient'
isActive={value.search_method === RETRIEVE_METHOD.fullText}
onClick={onSwitch}
effectColor={EffectColor.purple}
showEffectColor
showChildren={value.search_method === RETRIEVE_METHOD.fullText}
className='gap-x-2'
>
<RetrievalParamConfig
type={RETRIEVE_METHOD.fullText}
@ -130,19 +136,20 @@ const RetrievalMethodConfig: FC<Props> = ({
</OptionCard>
)}
{supportRetrievalMethods.includes(RETRIEVE_METHOD.hybrid) && (
<OptionCard disabled={disabled} icon={<Image className='h-4 w-4' src={retrievalIcon.hybrid} alt='' />}
title={
<div className='flex items-center space-x-1'>
<div>{t('dataset.retrieval.hybrid_search.title')}</div>
<Badge text={t('dataset.retrieval.hybrid_search.recommend')!} className='ml-1 h-[18px] border-text-accent-secondary text-text-accent-secondary' uppercase />
</div>
}
description={t('dataset.retrieval.hybrid_search.description')} isActive={
value.search_method === RETRIEVE_METHOD.hybrid
}
onSwitched={() => onSwitch(RETRIEVE_METHOD.hybrid)}
effectImg={Effect.src}
activeHeaderClassName='bg-dataset-option-card-purple-gradient'
<OptionCard
id={RETRIEVE_METHOD.hybrid}
disabled={disabled}
icon={<HybridSearch className='size-4' />}
iconActiveColor='text-util-colors-purple-purple-600'
title={t('dataset.retrieval.hybrid_search.title')}
description={t('dataset.retrieval.hybrid_search.description')}
isActive={value.search_method === RETRIEVE_METHOD.hybrid}
onClick={onSwitch}
effectColor={EffectColor.purple}
showEffectColor
isRecommended
showChildren={value.search_method === RETRIEVE_METHOD.hybrid}
className='gap-x-2'
>
<RetrievalParamConfig
type={RETRIEVE_METHOD.hybrid}

View file

@ -38,7 +38,7 @@ const RetrievalParamConfig: FC<Props> = ({
}) => {
const { t } = useTranslation()
const canToggleRerankModalEnable = type !== RETRIEVE_METHOD.hybrid
const isEconomical = type === RETRIEVE_METHOD.invertedIndex
const isEconomical = type === RETRIEVE_METHOD.keywordSearch
const isHybridSearch = type === RETRIEVE_METHOD.hybrid
const {
modelList: rerankModelList,
@ -201,7 +201,7 @@ const RetrievalParamConfig: FC<Props> = ({
option.value === RerankingModeEnum.WeightedScore
? ProgressIndicator
: Reranking
} alt=''/>}
} alt='' />}
title={option.label}
description={option.tips}
className='flex-1'

View file

@ -0,0 +1,46 @@
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
type DSLConfirmModalProps = {
versions?: {
importedVersion: string
systemVersion: string
}
onCancel: () => void
onConfirm: () => void
confirmDisabled?: boolean
}
const DSLConfirmModal = ({
versions = { importedVersion: '', systemVersion: '' },
onCancel,
onConfirm,
confirmDisabled = false,
}: DSLConfirmModalProps) => {
const { t } = useTranslation()
return (
<Modal
isShow
onClose={() => onCancel()}
className='w-[480px]'
>
<div className='flex flex-col items-start gap-2 self-stretch pb-4'>
<div className='title-2xl-semi-bold text-text-primary'>{t('app.newApp.appCreateDSLErrorTitle')}</div>
<div className='system-md-regular flex grow flex-col text-text-secondary'>
<div>{t('app.newApp.appCreateDSLErrorPart1')}</div>
<div>{t('app.newApp.appCreateDSLErrorPart2')}</div>
<br />
<div>{t('app.newApp.appCreateDSLErrorPart3')}<span className='system-md-medium'>{versions.importedVersion}</span></div>
<div>{t('app.newApp.appCreateDSLErrorPart4')}<span className='system-md-medium'>{versions.systemVersion}</span></div>
</div>
</div>
<div className='flex items-start justify-end gap-2 self-stretch pt-6'>
<Button variant='secondary' onClick={() => onCancel()}>{t('app.newApp.Cancel')}</Button>
<Button variant='primary' destructive onClick={onConfirm} disabled={confirmDisabled}>{t('app.newApp.Confirm')}</Button>
</div>
</Modal>
)
}
export default DSLConfirmModal

View file

@ -0,0 +1,27 @@
import { RiCloseLine } from '@remixicon/react'
import React from 'react'
import { useTranslation } from 'react-i18next'
type HeaderProps = {
onClose: () => void
}
const Header = ({
onClose,
}: HeaderProps) => {
const { t } = useTranslation()
return (
<div className='title-2xl-semi-bold relative flex items-center justify-between pb-3 pl-6 pr-14 pt-6 text-text-primary'>
{t('app.importFromDSL')}
<div
className='absolute right-5 top-5 flex size-8 cursor-pointer items-center'
onClick={onClose}
>
<RiCloseLine className='size-[18px] text-text-tertiary' />
</div>
</div>
)
}
export default React.memo(Header)

View file

@ -0,0 +1,261 @@
'use client'
import { useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useContext } from 'use-context-selector'
import { useTranslation } from 'react-i18next'
import { useDebounceFn, useKeyPress } from 'ahooks'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast'
import {
DSLImportMode,
DSLImportStatus,
} from '@/models/app'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { noop } from 'lodash-es'
import Uploader from './uploader'
import Header from './header'
import Tab from './tab'
import { useImportPipelineDSL, useImportPipelineDSLConfirm } from '@/service/use-pipeline'
type CreateFromDSLModalProps = {
show: boolean
onSuccess?: () => void
onClose: () => void
activeTab?: CreateFromDSLModalTab
dslUrl?: string
}
export enum CreateFromDSLModalTab {
FROM_FILE = 'from-file',
FROM_URL = 'from-url',
}
const CreateFromDSLModal = ({
show,
onSuccess,
onClose,
activeTab = CreateFromDSLModalTab.FROM_FILE,
dslUrl = '',
}: CreateFromDSLModalProps) => {
const { push } = useRouter()
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [currentFile, setDSLFile] = useState<File>()
const [fileContent, setFileContent] = useState<string>()
const [currentTab, setCurrentTab] = useState(activeTab)
const [dslUrlValue, setDslUrlValue] = useState(dslUrl)
const [showErrorModal, setShowErrorModal] = useState(false)
const [versions, setVersions] = useState<{ importedVersion: string; systemVersion: string }>()
const [importId, setImportId] = useState<string>()
const { handleCheckPluginDependencies } = usePluginDependencies()
const readFile = (file: File) => {
const reader = new FileReader()
reader.onload = function (event) {
const content = event.target?.result
setFileContent(content as string)
}
reader.readAsText(file)
}
const handleFile = (file?: File) => {
setDSLFile(file)
if (file)
readFile(file)
if (!file)
setFileContent('')
}
const isCreatingRef = useRef(false)
const { mutateAsync: importDSL } = useImportPipelineDSL()
const onCreate = async () => {
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile)
return
if (currentTab === CreateFromDSLModalTab.FROM_URL && !dslUrlValue)
return
if (isCreatingRef.current)
return
isCreatingRef.current = true
let response
if (currentTab === CreateFromDSLModalTab.FROM_FILE) {
response = await importDSL({
mode: DSLImportMode.YAML_CONTENT,
yaml_content: fileContent || '',
})
}
if (currentTab === CreateFromDSLModalTab.FROM_URL) {
response = await importDSL({
mode: DSLImportMode.YAML_URL,
yaml_url: dslUrlValue || '',
})
}
if (!response) {
notify({ type: 'error', message: t('datasetPipeline.creation.errorTip') })
isCreatingRef.current = false
return
}
const { id, status, pipeline_id, dataset_id, imported_dsl_version, current_dsl_version } = response
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
if (onSuccess)
onSuccess()
if (onClose)
onClose()
notify({
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
message: t(status === DSLImportStatus.COMPLETED ? 'datasetPipeline.creation.successTip' : 'datasetPipeline.creation.caution'),
children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('app.newApp.appCreateDSLWarning'),
})
if (pipeline_id)
await handleCheckPluginDependencies(pipeline_id, true)
push(`/datasets/${dataset_id}/pipeline`)
isCreatingRef.current = false
}
else if (status === DSLImportStatus.PENDING) {
setVersions({
importedVersion: imported_dsl_version ?? '',
systemVersion: current_dsl_version ?? '',
})
if (onClose)
onClose()
setTimeout(() => {
setShowErrorModal(true)
}, 300)
setImportId(id)
isCreatingRef.current = false
}
else {
notify({ type: 'error', message: t('datasetPipeline.creation.errorTip') })
isCreatingRef.current = false
}
}
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
useKeyPress('esc', () => {
if (show && !showErrorModal)
onClose()
})
const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm()
const onDSLConfirm = async () => {
if (!importId)
return
const response = await importDSLConfirm(importId)
if (!response) {
notify({ type: 'error', message: t('datasetPipeline.creation.errorTip') })
return
}
const { status, pipeline_id, dataset_id } = response
if (status === DSLImportStatus.COMPLETED) {
if (onSuccess)
onSuccess()
if (onClose)
onClose()
notify({
type: 'success',
message: t('datasetPipeline.creation.successTip'),
})
if (pipeline_id)
await handleCheckPluginDependencies(pipeline_id, true)
push(`datasets/${dataset_id}/pipeline`)
}
else if (status === DSLImportStatus.FAILED) {
notify({ type: 'error', message: t('datasetPipeline.creation.errorTip') })
}
}
const buttonDisabled = useMemo(() => {
if (currentTab === CreateFromDSLModalTab.FROM_FILE)
return !currentFile
if (currentTab === CreateFromDSLModalTab.FROM_URL)
return !dslUrlValue
return false
}, [currentTab, currentFile, dslUrlValue])
return (
<>
<Modal
className='w-[520px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0 shadow-xl'
isShow={show}
onClose={noop}
>
<Header onClose={onClose} />
<Tab
currentTab={currentTab}
setCurrentTab={setCurrentTab}
/>
<div className='px-6 py-4'>
{
currentTab === CreateFromDSLModalTab.FROM_FILE && (
<Uploader
className='mt-0'
file={currentFile}
updateFile={handleFile}
/>
)
}
{
currentTab === CreateFromDSLModalTab.FROM_URL && (
<div>
<div className='system-md-semibold leading6 mb-1 text-text-secondary'>
DSL URL
</div>
<Input
placeholder={t('app.importFromDSLUrlPlaceholder') || ''}
value={dslUrlValue}
onChange={e => setDslUrlValue(e.target.value)}
/>
</div>
)
}
</div>
<div className='flex justify-end gap-x-2 p-6 pt-5'>
<Button onClick={onClose}>
{t('app.newApp.Cancel')}
</Button>
<Button
disabled={buttonDisabled}
variant='primary'
onClick={handleCreateApp}
className='gap-1'
>
<span>{t('app.newApp.import')}</span>
</Button>
</div>
</Modal>
<Modal
isShow={showErrorModal}
onClose={() => setShowErrorModal(false)}
className='w-[480px]'
>
<div className='flex flex-col items-start gap-2 self-stretch pb-4'>
<div className='title-2xl-semi-bold text-text-primary'>{t('app.newApp.appCreateDSLErrorTitle')}</div>
<div className='system-md-regular flex grow flex-col text-text-secondary'>
<div>{t('app.newApp.appCreateDSLErrorPart1')}</div>
<div>{t('app.newApp.appCreateDSLErrorPart2')}</div>
<br />
<div>{t('app.newApp.appCreateDSLErrorPart3')}<span className='system-md-medium'>{versions?.importedVersion}</span></div>
<div>{t('app.newApp.appCreateDSLErrorPart4')}<span className='system-md-medium'>{versions?.systemVersion}</span></div>
</div>
</div>
<div className='flex items-start justify-end gap-2 self-stretch pt-6'>
<Button variant='secondary' onClick={() => setShowErrorModal(false)}>{t('app.newApp.Cancel')}</Button>
<Button variant='primary' destructive onClick={onDSLConfirm}>{t('app.newApp.Confirm')}</Button>
</div>
</Modal>
</>
)
}
export default CreateFromDSLModal

View file

@ -0,0 +1,44 @@
import React from 'react'
import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
import { useTranslation } from 'react-i18next'
import Item from './item'
type TabProps = {
currentTab: CreateFromDSLModalTab
setCurrentTab: (tab: CreateFromDSLModalTab) => void
}
const Tab = ({
currentTab,
setCurrentTab,
}: TabProps) => {
const { t } = useTranslation()
const tabs = [
{
key: CreateFromDSLModalTab.FROM_FILE,
label: t('app.importFromDSLFile'),
},
{
key: CreateFromDSLModalTab.FROM_URL,
label: t('app.importFromDSLUrl'),
},
]
return (
<div className='system-md-semibold flex h-9 items-center gap-x-6 border-b border-divider-subtle px-6 text-text-tertiary'>
{
tabs.map(tab => (
<Item
key={tab.key}
isActive={currentTab === tab.key}
label={tab.label}
onClick={setCurrentTab.bind(null, tab.key)}
/>
))
}
</div>
)
}
export default Tab

View file

@ -0,0 +1,33 @@
import React from 'react'
import cn from '@/utils/classnames'
type ItemProps = {
isActive: boolean
label: string
onClick: () => void
}
const Item = ({
isActive,
label,
onClick,
}: ItemProps) => {
return (
<div
className={cn(
'system-md-semibold relative flex h-full cursor-pointer items-center text-text-tertiary',
isActive && 'text-text-primary',
)}
onClick={onClick}
>
{label}
{
isActive && (
<div className='absolute bottom-0 h-0.5 w-full bg-util-colors-blue-brand-blue-brand-600' />
)
}
</div>
)
}
export default React.memo(Item)

View file

@ -0,0 +1,153 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import {
RiDeleteBinLine,
RiNodeTree,
RiUploadCloud2Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { formatFileSize } from '@/utils/format'
import cn from '@/utils/classnames'
import { ToastContext } from '@/app/components/base/toast'
import ActionButton from '@/app/components/base/action-button'
export type Props = {
file: File | undefined
updateFile: (file?: File) => void
className?: string
}
const Uploader: FC<Props> = ({
file,
updateFile,
className,
}) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [dragging, setDragging] = useState(false)
const dropRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<HTMLDivElement>(null)
const fileUploader = useRef<HTMLInputElement>(null)
const handleDragEnter = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.target !== dragRef.current && setDragging(true)
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDragLeave = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.target === dragRef.current && setDragging(false)
}
const handleDrop = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragging(false)
if (!e.dataTransfer)
return
const files = [...e.dataTransfer.files]
if (files.length > 1) {
notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.count') })
return
}
updateFile(files[0])
}
const selectHandle = () => {
const originalFile = file
if (fileUploader.current) {
fileUploader.current.value = ''
fileUploader.current.click()
// If no file is selected, restore the original file
fileUploader.current.oncancel = () => updateFile(originalFile)
}
}
const removeFile = () => {
if (fileUploader.current)
fileUploader.current.value = ''
updateFile()
}
const fileChangeHandle = (e: React.ChangeEvent<HTMLInputElement>) => {
const currentFile = e.target.files?.[0]
updateFile(currentFile)
}
useEffect(() => {
const dropArea = dropRef.current
dropArea?.addEventListener('dragenter', handleDragEnter)
dropArea?.addEventListener('dragover', handleDragOver)
dropArea?.addEventListener('dragleave', handleDragLeave)
dropArea?.addEventListener('drop', handleDrop)
return () => {
dropArea?.removeEventListener('dragenter', handleDragEnter)
dropArea?.removeEventListener('dragover', handleDragOver)
dropArea?.removeEventListener('dragleave', handleDragLeave)
dropArea?.removeEventListener('drop', handleDrop)
}
}, [])
return (
<div className={cn('mt-6', className)}>
<input
ref={fileUploader}
style={{ display: 'none' }}
type='file'
id='fileUploader'
accept='.pipeline'
onChange={fileChangeHandle}
/>
<div ref={dropRef}>
{!file && (
<div
className={cn(
'flex h-12 items-center rounded-[10px] border border-dashed border-components-dropzone-border bg-components-dropzone-bg text-sm font-normal',
dragging && 'border-components-dropzone-border-accent bg-components-dropzone-bg-accent',
)}>
<div className='flex w-full items-center justify-center space-x-2'>
<RiUploadCloud2Line className='h-6 w-6 text-text-tertiary' />
<div className='text-text-tertiary'>
{t('app.dslUploader.button')}
<span
className='cursor-pointer pl-1 text-text-accent'
onClick={selectHandle}
>
{t('app.dslUploader.browse')}
</span>
</div>
</div>
{dragging && <div ref={dragRef} className='absolute left-0 top-0 h-full w-full' />}
</div>
)}
{file && (
<div className='group flex items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs hover:bg-components-panel-on-panel-item-bg-hover'>
<div className='flex items-center justify-center p-3'>
<RiNodeTree className='h-6 w-6 shrink-0 text-text-secondary' />
</div>
<div className='flex grow flex-col items-start gap-0.5 py-1 pr-2'>
<span className='font-inter max-w-[calc(100%_-_30px)] overflow-hidden text-ellipsis whitespace-nowrap text-[12px] font-medium leading-4 text-text-secondary'>
{file.name}
</span>
<div className='font-inter flex h-3 items-center gap-1 self-stretch text-[10px] font-medium uppercase leading-3 text-text-tertiary'>
<span>PIPELINE</span>
<span className='text-text-quaternary'>·</span>
<span>{formatFileSize(file.size)}</span>
</div>
</div>
<div className='hidden items-center pr-3 group-hover:flex'>
<ActionButton onClick={removeFile}>
<RiDeleteBinLine className='h-4 w-4 text-text-tertiary' />
</ActionButton>
</div>
</div>
)}
</div>
</div>
)
}
export default React.memo(Uploader)

View file

@ -0,0 +1,62 @@
import React, { useCallback, useMemo, useState } from 'react'
import { RiFileUploadLine } from '@remixicon/react'
import Divider from '../../base/divider'
import { useTranslation } from 'react-i18next'
import CreateFromDSLModal, { CreateFromDSLModalTab } from './create-options/create-from-dsl-modal'
import { useRouter, useSearchParams } from 'next/navigation'
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
const Footer = () => {
const { t } = useTranslation()
const [showImportModal, setShowImportModal] = useState(false)
const searchParams = useSearchParams()
const { replace } = useRouter()
const dslUrl = searchParams.get('remoteInstallUrl') || undefined
const invalidDatasetList = useInvalidDatasetList()
const activeTab = useMemo(() => {
if (dslUrl)
return CreateFromDSLModalTab.FROM_URL
return undefined
}, [dslUrl])
const openImportFromDSL = useCallback(() => {
setShowImportModal(true)
}, [])
const onCloseImportModal = useCallback(() => {
setShowImportModal(false)
if (dslUrl)
replace('/datasets/create-from-pipeline')
}, [dslUrl, replace])
const onImportFromDSLSuccess = useCallback(() => {
invalidDatasetList()
}, [invalidDatasetList])
return (
<div className='absolute bottom-0 left-0 right-0 z-10 flex flex-col gap-y-4 bg-knowledge-pipeline-creation-footer-bg px-16 pb-6 backdrop-blur-[6px]'>
<Divider type='horizontal' className='my-0 w-8' />
<button
type='button'
className='system-md-medium flex items-center gap-x-3 text-text-accent'
onClick={openImportFromDSL}
>
<RiFileUploadLine className='size-5' />
<span>{t('datasetPipeline.creation.importDSL')}</span>
</button>
<CreateFromDSLModal
show={showImportModal}
onClose={onCloseImportModal}
activeTab={activeTab}
dslUrl={dslUrl}
onSuccess={onImportFromDSLSuccess}
/>
</div>
)
}
export default React.memo(Footer)

View file

@ -0,0 +1,29 @@
import React from 'react'
import { RiArrowLeftLine } from '@remixicon/react'
import Button from '../../base/button'
import { useTranslation } from 'react-i18next'
import Link from 'next/link'
const Header = () => {
const { t } = useTranslation()
return (
<div className='system-md-semibold relative flex px-16 pb-2 pt-5 text-text-primary'>
<span>{t('datasetPipeline.creation.backToKnowledge')}</span>
<Link
className='absolute bottom-0 left-5'
href={'/datasets'}
replace
>
<Button
variant='secondary-accent'
className='size-9 rounded-full p-0'
>
<RiArrowLeftLine className='size-5 ' />
</Button>
</Link>
</div>
)
}
export default React.memo(Header)

View file

@ -0,0 +1,20 @@
'use client'
import Header from './header'
import List from './list'
import Effect from '../../base/effect'
import Footer from './footer'
const CreateFromPipeline = () => {
return (
<div
className='relative flex h-[calc(100vh-56px)] flex-col overflow-hidden rounded-t-2xl border-t border-effects-highlight bg-background-default-subtle'
>
<Effect className='left-8 top-[-34px] opacity-20' />
<Header />
<List />
<Footer />
</div>
)
}
export default CreateFromPipeline

View file

@ -0,0 +1,24 @@
import { usePipelineTemplateList } from '@/service/use-pipeline'
import TemplateCard from './template-card'
import CreateCard from './create-card'
const BuiltInPipelineList = () => {
const { data: pipelineList, isLoading } = usePipelineTemplateList({ type: 'built-in' })
const list = pipelineList?.pipeline_templates || []
return (
<div className='grid grid-cols-1 gap-3 py-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'>
<CreateCard />
{!isLoading && list.map((pipeline, index) => (
<TemplateCard
key={index}
type='built-in'
pipeline={pipeline}
showMoreOperations={false}
/>
))}
</div>
)
}
export default BuiltInPipelineList

View file

@ -0,0 +1,58 @@
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { RiAddCircleLine } from '@remixicon/react'
import { useCreatePipelineDataset } from '@/service/knowledge/use-create-dataset'
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import Toast from '@/app/components/base/toast'
import { useRouter } from 'next/navigation'
const CreateCard = () => {
const { t } = useTranslation()
const { push } = useRouter()
const { mutateAsync: createEmptyDataset } = useCreatePipelineDataset()
const invalidDatasetList = useInvalidDatasetList()
const handleCreate = useCallback(async () => {
await createEmptyDataset(undefined, {
onSuccess: (data) => {
if (data) {
const { id } = data
Toast.notify({
type: 'success',
message: t('datasetPipeline.creation.successTip'),
})
invalidDatasetList()
push(`/datasets/${id}/pipeline`)
}
},
onError: () => {
Toast.notify({
type: 'error',
message: t('datasetPipeline.creation.errorTip'),
})
},
})
}, [createEmptyDataset, push, invalidDatasetList, t])
return (
<div
className='group relative flex h-[132px] cursor-pointer flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs shadow-shadow-shadow-3'
onClick={handleCreate}
>
<div className='flex items-center gap-x-3 p-4 pb-2'>
<div className='flex size-10 shrink-0 items-center justify-center rounded-[10px] border border-dashed border-divider-regular bg-background-section group-hover:border-state-accent-hover-alt group-hover:bg-state-accent-hover'>
<RiAddCircleLine className='size-5 text-text-quaternary group-hover:text-text-accent' />
</div>
<div className='system-md-semibold truncate text-text-primary'>
{t('datasetPipeline.creation.createFromScratch.title')}
</div>
</div>
<p className='system-xs-regular line-clamp-3 px-4 py-1 text-text-tertiary'>
{t('datasetPipeline.creation.createFromScratch.description')}
</p>
</div>
)
}
export default React.memo(CreateCard)

View file

@ -0,0 +1,29 @@
import TemplateCard from './template-card'
import { usePipelineTemplateList } from '@/service/use-pipeline'
import { useTranslation } from 'react-i18next'
const CustomizedList = () => {
const { t } = useTranslation()
const { data: pipelineList, isLoading } = usePipelineTemplateList({ type: 'customized' })
const list = pipelineList?.pipeline_templates || []
if (isLoading || list.length === 0)
return null
return (
<>
<div className='system-sm-semibold-uppercase pt-2 text-text-tertiary'>{t('datasetPipeline.templates.customized')}</div>
<div className='grid grid-cols-1 gap-3 py-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'>
{list.map((pipeline, index) => (
<TemplateCard
key={index}
type='customized'
pipeline={pipeline}
/>
))}
</div>
</>
)
}
export default CustomizedList

View file

@ -0,0 +1,13 @@
import BuiltInPipelineList from './built-in-pipeline-list'
import CustomizedList from './customized-list'
const List = () => {
return (
<div className='grow gap-y-1 overflow-y-auto px-16 pb-[60px] pt-1'>
<BuiltInPipelineList />
<CustomizedList />
</div>
)
}
export default List

View file

@ -0,0 +1,70 @@
import Button from '@/app/components/base/button'
import { RiAddLine, RiArrowRightUpLine, RiMoreFill } from '@remixicon/react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Operations from './operations'
import CustomPopover from '@/app/components/base/popover'
type ActionsProps = {
onApplyTemplate: () => void
handleShowTemplateDetails: () => void
showMoreOperations: boolean
openEditModal: () => void
handleExportDSL: (includeSecret?: boolean) => void
handleDelete: () => void
}
const Actions = ({
onApplyTemplate,
handleShowTemplateDetails,
showMoreOperations,
openEditModal,
handleExportDSL,
handleDelete,
}: ActionsProps) => {
const { t } = useTranslation()
return (
<div className='absolute bottom-0 left-0 z-10 hidden w-full items-center gap-x-1 bg-pipeline-template-card-hover-bg p-4 pt-8 group-hover:flex'>
<Button
variant='primary'
onClick={onApplyTemplate}
className='grow gap-x-0.5'
>
<RiAddLine className='size-4' />
<span className='px-0.5'>{t('datasetPipeline.operations.choose')}</span>
</Button>
<Button
variant='secondary'
onClick={handleShowTemplateDetails}
className='grow gap-x-0.5'
>
<RiArrowRightUpLine className='size-4' />
<span className='px-0.5'>{t('datasetPipeline.operations.details')}</span>
</Button>
{
showMoreOperations && (
<CustomPopover
htmlContent={
<Operations
openEditModal={openEditModal}
onExport={handleExportDSL}
onDelete={handleDelete}
/>
}
className={'z-20 min-w-[160px]'}
popupClassName={'rounded-xl bg-none shadow-none ring-0 min-w-[160px]'}
position='br'
trigger='click'
btnElement={
<RiMoreFill className='size-4 text-text-tertiary' />
}
btnClassName='size-8 cursor-pointer justify-center rounded-lg p-0 shadow-xs shadow-shadow-shadow-3'
/>
)
}
</div>
)
}
export default React.memo(Actions)

View file

@ -0,0 +1,61 @@
import AppIcon from '@/app/components/base/app-icon'
import { General } from '@/app/components/base/icons/src/public/knowledge/dataset-card'
import type { ChunkingMode, IconInfo } from '@/models/datasets'
import { DOC_FORM_ICON_WITH_BG, DOC_FORM_TEXT } from '@/models/datasets'
import React from 'react'
import { useTranslation } from 'react-i18next'
type ContentProps = {
name: string
description: string
iconInfo: IconInfo
chunkStructure: ChunkingMode
}
const Content = ({
name,
description,
iconInfo,
chunkStructure,
}: ContentProps) => {
const { t } = useTranslation()
const Icon = DOC_FORM_ICON_WITH_BG[chunkStructure] || General
return (
<>
<div className='flex items-center gap-x-3 p-4 pb-2'>
<div className='relative shrink-0'>
<AppIcon
size='large'
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_type === 'image' ? undefined : iconInfo.icon_background}
imageUrl={iconInfo.icon_type === 'image' ? iconInfo.icon_url : undefined}
/>
<div className='absolute -bottom-1 -right-1 z-10'>
<Icon className='size-4' />
</div>
</div>
<div className='flex grow flex-col gap-y-1 overflow-hidden py-px'>
<div
className='system-md-semibold truncate text-text-secondary'
title={name}
>
{name}
</div>
<div className='system-2xs-medium-uppercase text-text-tertiary'>
{t(`dataset.chunkingMode.${DOC_FORM_TEXT[chunkStructure]}`)}
</div>
</div>
</div>
<p
className='system-xs-regular line-clamp-3 grow px-4 py-1 text-text-tertiary'
title={description}
>
{description}
</p>
</>
)
}
export default React.memo(Content)

View file

@ -0,0 +1,66 @@
import React from 'react'
import cn from '@/utils/classnames'
import type { Option } from './types'
import { EffectColor } from './types'
const HEADER_EFFECT_MAP: Record<EffectColor, string> = {
[EffectColor.indigo]: 'bg-util-colors-indigo-indigo-600 opacity-80',
[EffectColor.blueLight]: 'bg-util-colors-blue-light-blue-light-500 opacity-80',
[EffectColor.green]: 'bg-util-colors-teal-teal-600 opacity-80',
[EffectColor.none]: '',
}
const IconBackgroundColorMap: Record<EffectColor, string> = {
[EffectColor.indigo]: 'bg-components-icon-bg-indigo-solid',
[EffectColor.blueLight]: 'bg-components-icon-bg-blue-light-solid',
[EffectColor.green]: 'bg-components-icon-bg-teal-solid',
[EffectColor.none]: '',
}
type ChunkStructureCardProps = {
className?: string
} & Option
const ChunkStructureCard = ({
className,
icon,
title,
description,
effectColor,
}: ChunkStructureCardProps) => {
return (
<div className={cn(
'relative flex overflow-hidden rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-bg p-2 shadow-xs shadow-shadow-shadow-3',
className,
)}>
<div className={cn(
'absolute -left-1 -top-1 size-14 rounded-full blur-[80px]',
`${HEADER_EFFECT_MAP[effectColor]}`,
)} />
<div className='p-1'>
<div className={cn(
'flex size-6 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-subtle text-text-primary-on-surface shadow-md shadow-shadow-shadow-5',
`${IconBackgroundColorMap[effectColor]}`,
)}>
{icon}
</div>
</div>
<div className='flex grow flex-col gap-y-0.5 py-px'>
<div className='flex items-center gap-x-1'>
<span className='system-sm-medium text-text-secondary'>
{title}
</span>
</div>
{
description && (
<div className='system-xs-regular text-text-tertiary'>
{description}
</div>
)
}
</div>
</div>
)
}
export default React.memo(ChunkStructureCard) as typeof ChunkStructureCard

View file

@ -0,0 +1,36 @@
import { GeneralChunk, ParentChildChunk, QuestionAndAnswer } from '@/app/components/base/icons/src/vender/knowledge'
import { useTranslation } from 'react-i18next'
import { EffectColor, type Option } from './types'
import { ChunkingMode } from '@/models/datasets'
export const useChunkStructureConfig = () => {
const { t } = useTranslation()
const GeneralOption: Option = {
icon: <GeneralChunk className='size-4' />,
title: 'General',
description: t('datasetCreation.stepTwo.generalTip'),
effectColor: EffectColor.indigo,
}
const ParentChildOption: Option = {
icon: <ParentChildChunk className='size-4' />,
title: 'Parent-Child',
description: t('datasetCreation.stepTwo.parentChildTip'),
effectColor: EffectColor.blueLight,
}
const QuestionAnswerOption: Option = {
icon: <QuestionAndAnswer className='size-4' />,
title: 'Q&A',
description: t('datasetCreation.stepTwo.qaTip'),
effectColor: EffectColor.green,
}
const chunkStructureConfig: Record<ChunkingMode, Option> = {
[ChunkingMode.text]: GeneralOption,
[ChunkingMode.parentChild]: ParentChildOption,
[ChunkingMode.qa]: QuestionAnswerOption,
}
return chunkStructureConfig
}

View file

@ -0,0 +1,116 @@
import React, { useMemo } from 'react'
import AppIcon from '@/app/components/base/app-icon'
import { usePipelineTemplateById } from '@/service/use-pipeline'
import type { AppIconType } from '@/types/app'
import { RiAddLine, RiCloseLine } from '@remixicon/react'
import Button from '@/app/components/base/button'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import Loading from '@/app/components/base/loading'
import { useChunkStructureConfig } from './hooks'
import ChunkStructureCard from './chunk-structure-card'
import WorkflowPreview from '@/app/components/workflow/workflow-preview'
type DetailsProps = {
id: string
type: 'customized' | 'built-in'
onApplyTemplate: () => void
onClose: () => void
}
const Details = ({
id,
type,
onApplyTemplate,
onClose,
}: DetailsProps) => {
const { t } = useTranslation()
const { data: pipelineTemplateInfo } = usePipelineTemplateById({
template_id: id,
type,
}, true)
const appIcon = useMemo(() => {
if (!pipelineTemplateInfo)
return { type: 'emoji', icon: '📙', background: '#FFF4ED' }
const iconInfo = pipelineTemplateInfo.icon_info
return iconInfo.icon_type === 'image'
? { type: 'image', url: iconInfo.icon_url || '', fileId: iconInfo.icon || '' }
: { type: 'icon', icon: iconInfo.icon || '', background: iconInfo.icon_background || '' }
}, [pipelineTemplateInfo])
const chunkStructureConfig = useChunkStructureConfig()
if (!pipelineTemplateInfo) {
return (
<Loading type='app' />
)
}
return (
<div className='flex h-full'>
<div className='flex grow items-center justify-center p-3 pr-0'>
<WorkflowPreview
{...pipelineTemplateInfo.graph}
className='overflow-hidden rounded-2xl'
/>
</div>
<div className='relative flex w-[360px] shrink-0 flex-col'>
<button
type='button'
className='absolute right-4 top-4 z-10 flex size-8 items-center justify-center'
onClick={onClose}
>
<RiCloseLine className='size-4 text-text-tertiary' />
</button>
<div className='flex items-center gap-x-3 pb-2 pl-4 pr-12 pt-6'>
<AppIcon
size='large'
iconType={appIcon.type as AppIconType}
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
background={appIcon.type === 'image' ? undefined : appIcon.background}
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
showEditIcon
/>
<div className='flex grow flex-col gap-y-1 py-px'>
<div className='system-md-semibold text-text-secondary'>
{pipelineTemplateInfo.name}
</div>
<div className='system-2xs-medium-uppercase text-text-tertiary'>
{t('datasetPipeline.details.createdBy', {
author: pipelineTemplateInfo.created_by,
})}
</div>
</div>
</div>
<p className='system-sm-regular px-4 pb-2 pt-1 text-text-secondary'>
{pipelineTemplateInfo.description}
</p>
<div className='p-3'>
<Button
variant='primary'
onClick={onApplyTemplate}
className='w-full gap-x-0.5'
>
<RiAddLine className='size-4' />
<span className='px-0.5'>{t('datasetPipeline.operations.useTemplate')}</span>
</Button>
</div>
<div className='flex flex-col gap-y-1 px-4 py-2'>
<div className='flex h-6 items-center gap-x-0.5'>
<span className='system-sm-semibold-uppercase text-text-secondary'>
{t('datasetPipeline.details.structure')}
</span>
<Tooltip
popupClassName='max-w-[240px]'
popupContent={t('datasetPipeline.details.structureTooltip')}
/>
</div>
<ChunkStructureCard {...chunkStructureConfig[pipelineTemplateInfo.chunk_structure]} />
</div>
</div>
</div>
)
}
export default React.memo(Details)

View file

@ -0,0 +1,15 @@
import type { ReactNode } from 'react'
export enum EffectColor {
indigo = 'indigo',
blueLight = 'blue-light',
green = 'green',
none = 'none',
}
export type Option = {
icon: ReactNode
title: string
description?: string
effectColor: EffectColor
}

View file

@ -0,0 +1,169 @@
import AppIcon from '@/app/components/base/app-icon'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { RiCloseLine } from '@remixicon/react'
import React, { useCallback, useRef, useState } from 'react'
import Button from '@/app/components/base/button'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import type { PipelineTemplate } from '@/models/pipeline'
import { PipelineTemplateListQueryKeyPrefix, useUpdateTemplateInfo } from '@/service/use-pipeline'
import { useInvalid } from '@/service/use-base'
type EditPipelineInfoProps = {
onClose: () => void
pipeline: PipelineTemplate
}
const EditPipelineInfo = ({
onClose,
pipeline,
}: EditPipelineInfoProps) => {
const { t } = useTranslation()
const [name, setName] = useState(pipeline.name)
const iconInfo = pipeline.icon
const [appIcon, setAppIcon] = useState<AppIconSelection>(
iconInfo.icon_type === 'image'
? { type: 'image' as const, url: iconInfo.icon_url || '', fileId: iconInfo.icon || '' }
: { type: 'emoji' as const, icon: iconInfo.icon || '', background: iconInfo.icon_background || '' },
)
const [description, setDescription] = useState(pipeline.description)
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const previousAppIcon = useRef<AppIconSelection>(
iconInfo.icon_type === 'image'
? { type: 'image' as const, url: iconInfo.icon_url || '', fileId: iconInfo.icon || '' }
: { type: 'emoji' as const, icon: iconInfo.icon || '', background: iconInfo.icon_background || '' },
)
const handleAppNameChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
setName(value)
}, [])
const handleOpenAppIconPicker = useCallback(() => {
setShowAppIconPicker(true)
previousAppIcon.current = appIcon
}, [appIcon])
const handleSelectAppIcon = useCallback((icon: AppIconSelection) => {
setAppIcon(icon)
setShowAppIconPicker(false)
}, [])
const handleCloseAppIconPicker = useCallback(() => {
setAppIcon(previousAppIcon.current)
setShowAppIconPicker(false)
}, [])
const handleDescriptionChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value
setDescription(value)
}, [])
const { mutateAsync: updatePipeline } = useUpdateTemplateInfo()
const invalidCustomizedTemplateList = useInvalid([...PipelineTemplateListQueryKeyPrefix, 'customized'])
const handleSave = useCallback(async () => {
if (!name) {
Toast.notify({
type: 'error',
message: 'Please enter a name for the Knowledge Base.',
})
return
}
const request = {
template_id: pipeline.id,
name,
icon_info: {
icon_type: appIcon.type,
icon: appIcon.type === 'image' ? appIcon.fileId : appIcon.icon,
icon_background: appIcon.type === 'image' ? undefined : appIcon.background,
icon_url: appIcon.type === 'image' ? appIcon.url : undefined,
},
description,
}
await updatePipeline(request, {
onSuccess: () => {
invalidCustomizedTemplateList()
onClose()
},
})
}, [name, appIcon, description, pipeline.id, updatePipeline, invalidCustomizedTemplateList, onClose])
return (
<div className='relative flex flex-col'>
{/* Header */}
<div className='pb-3 pl-6 pr-14 pt-6'>
<span className='title-2xl-semi-bold text-text-primary'>
{t('datasetPipeline.editPipelineInfo')}
</span>
</div>
<button
className='absolute right-5 top-5 flex size-8 items-center justify-center'
onClick={onClose}
>
<RiCloseLine className='size-5 text-text-tertiary' />
</button>
{/* Form */}
<div className='flex flex-col gap-y-5 px-6 py-3'>
<div className='flex items-end gap-x-3 self-stretch'>
<div className='flex grow flex-col gap-y-1 pb-1'>
<label className='system-sm-medium flex h-6 items-center text-text-secondary'>
{t('datasetPipeline.pipelineNameAndIcon')}
</label>
<Input
onChange={handleAppNameChange}
value={name}
placeholder={t('datasetPipeline.knowledgeNameAndIconPlaceholder')}
/>
</div>
<AppIcon
size='xxl'
onClick={handleOpenAppIconPicker}
className='cursor-pointer'
iconType={appIcon.type}
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
background={appIcon.type === 'image' ? undefined : appIcon.background}
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
showEditIcon
/>
</div>
<div className='flex flex-col gap-y-1'>
<label className='system-sm-medium flex h-6 items-center text-text-secondary'>
{t('datasetPipeline.knowledgeDescription')}
</label>
<Textarea
onChange={handleDescriptionChange}
value={description}
placeholder={t('datasetPipeline.knowledgeDescriptionPlaceholder')}
/>
</div>
</div>
{/* Actions */}
<div className='flex items-center justify-end gap-x-2 p-6 pt-5'>
<Button
variant='secondary'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
<Button
variant='primary'
onClick={handleSave}
>
{t('common.operation.save')}
</Button>
</div>
{showAppIconPicker && (
<AppIconPicker
onSelect={handleSelectAppIcon}
onClose={handleCloseAppIconPicker}
/>
)}
</div>
)
}
export default React.memo(EditPipelineInfo)

View file

@ -0,0 +1,197 @@
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import EditPipelineInfo from './edit-pipeline-info'
import type { PipelineTemplate } from '@/models/pipeline'
import Confirm from '@/app/components/base/confirm'
import {
PipelineTemplateListQueryKeyPrefix,
useDeleteTemplate,
useExportTemplateDSL,
usePipelineTemplateById,
} from '@/service/use-pipeline'
import { downloadFile } from '@/utils/format'
import Toast from '@/app/components/base/toast'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { useRouter } from 'next/navigation'
import Details from './details'
import Content from './content'
import Actions from './actions'
import { useCreatePipelineDatasetFromCustomized } from '@/service/knowledge/use-create-dataset'
import { useInvalid } from '@/service/use-base'
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
type TemplateCardProps = {
pipeline: PipelineTemplate
showMoreOperations?: boolean
type: 'customized' | 'built-in'
}
const TemplateCard = ({
pipeline,
showMoreOperations = true,
type,
}: TemplateCardProps) => {
const { t } = useTranslation()
const { push } = useRouter()
const [showEditModal, setShowEditModal] = useState(false)
const [showDeleteConfirm, setShowConfirmDelete] = useState(false)
const [showDetailModal, setShowDetailModal] = useState(false)
const { refetch: getPipelineTemplateInfo } = usePipelineTemplateById({
template_id: pipeline.id,
type,
}, false)
const { mutateAsync: createDataset } = useCreatePipelineDatasetFromCustomized()
const { handleCheckPluginDependencies } = usePluginDependencies()
const invalidDatasetList = useInvalidDatasetList()
const handleUseTemplate = useCallback(async () => {
const { data: pipelineTemplateInfo } = await getPipelineTemplateInfo()
if (!pipelineTemplateInfo) {
Toast.notify({
type: 'error',
message: t('datasetPipeline.creation.errorTip'),
})
return
}
const request = {
yaml_content: pipelineTemplateInfo.export_data,
}
await createDataset(request, {
onSuccess: async (newDataset) => {
Toast.notify({
type: 'success',
message: t('datasetPipeline.creation.successTip'),
})
invalidDatasetList()
if (newDataset.pipeline_id)
await handleCheckPluginDependencies(newDataset.pipeline_id, true)
push(`/datasets/${newDataset.dataset_id}/pipeline`)
},
onError: () => {
Toast.notify({
type: 'error',
message: t('datasetPipeline.creation.errorTip'),
})
},
})
}, [getPipelineTemplateInfo, createDataset, t, handleCheckPluginDependencies, push, invalidDatasetList])
const handleShowTemplateDetails = useCallback(() => {
setShowDetailModal(true)
}, [])
const openEditModal = useCallback(() => {
setShowEditModal(true)
}, [])
const closeEditModal = useCallback(() => {
setShowEditModal(false)
}, [])
const closeDetailsModal = useCallback(() => {
setShowDetailModal(false)
}, [])
const { mutateAsync: exportPipelineDSL, isPending: isExporting } = useExportTemplateDSL()
const handleExportDSL = useCallback(async () => {
if (isExporting) return
await exportPipelineDSL(pipeline.id, {
onSuccess: (res) => {
const blob = new Blob([res.data], { type: 'application/yaml' })
downloadFile({
data: blob,
fileName: `${pipeline.name}.pipeline`,
})
Toast.notify({
type: 'success',
message: t('datasetPipeline.exportDSL.successTip'),
})
},
onError: () => {
Toast.notify({
type: 'error',
message: t('datasetPipeline.exportDSL.errorTip'),
})
},
})
}, [t, isExporting, pipeline.id, pipeline.name, exportPipelineDSL])
const handleDelete = useCallback(() => {
setShowConfirmDelete(true)
}, [])
const onCancelDelete = useCallback(() => {
setShowConfirmDelete(false)
}, [])
const { mutateAsync: deletePipeline } = useDeleteTemplate()
const invalidCustomizedTemplateList = useInvalid([...PipelineTemplateListQueryKeyPrefix, 'customized'])
const onConfirmDelete = useCallback(async () => {
await deletePipeline(pipeline.id, {
onSuccess: () => {
invalidCustomizedTemplateList()
setShowConfirmDelete(false)
},
})
}, [pipeline.id, deletePipeline, invalidCustomizedTemplateList])
return (
<div className='group relative flex h-[132px] cursor-pointer flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs shadow-shadow-shadow-3'>
<Content
name={pipeline.name}
description={pipeline.description}
iconInfo={pipeline.icon}
chunkStructure={pipeline.chunk_structure}
/>
<Actions
onApplyTemplate={handleUseTemplate}
handleShowTemplateDetails={handleShowTemplateDetails}
showMoreOperations={showMoreOperations}
openEditModal={openEditModal}
handleExportDSL={handleExportDSL}
handleDelete={handleDelete}
/>
{showEditModal && (
<Modal
isShow={showEditModal}
onClose={closeEditModal}
className='max-w-[520px] p-0'
>
<EditPipelineInfo
pipeline={pipeline}
onClose={closeEditModal}
/>
</Modal>
)}
{showDeleteConfirm && (
<Confirm
title={t('datasetPipeline.deletePipeline.title')}
content={t('datasetPipeline.deletePipeline.content')}
isShow={showDeleteConfirm}
onConfirm={onConfirmDelete}
onCancel={onCancelDelete}
/>
)}
{showDetailModal && (
<Modal
isShow={showDetailModal}
onClose={closeDetailsModal}
className='h-[calc(100vh-64px)] max-w-[1680px] rounded-3xl p-0'
>
<Details
id={pipeline.id}
type={type}
onClose={closeDetailsModal}
onApplyTemplate={handleUseTemplate}
/>
</Modal>
)}
</div>
)
}
export default React.memo(TemplateCard)

View file

@ -0,0 +1,71 @@
import Divider from '@/app/components/base/divider'
import React from 'react'
import { useTranslation } from 'react-i18next'
type OperationsProps = {
openEditModal: () => void
onDelete: () => void
onExport: () => void
}
const Operations = ({
openEditModal,
onDelete,
onExport,
}: OperationsProps) => {
const { t } = useTranslation()
const onClickEdit = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.preventDefault()
openEditModal()
}
const onClickExport = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.preventDefault()
onExport()
}
const onClickDelete = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.preventDefault()
onDelete()
}
return (
<div className='relative flex w-full flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5'>
<div className='flex flex-col p-1'>
<div
className='flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover'
onClick={onClickEdit}
>
<span className='system-md-regular px-1 text-text-secondary'>
{t('datasetPipeline.operations.editInfo')}
</span>
</div>
<div
className='flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover'
onClick={onClickExport}
>
<span className='system-md-regular px-1 text-text-secondary'>
{t('datasetPipeline.operations.exportPipeline')}
</span>
</div>
</div>
<Divider type='horizontal' className='my-0 bg-divider-subtle' />
<div className='flex flex-col p-1'>
<div
className='group flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-destructive-hover'
onClick={onClickDelete}
>
<span className='system-md-regular px-1 text-text-secondary group-hover:text-text-destructive'>
{t('common.operation.delete')}
</span>
</div>
</div>
</div>
)
}
export default React.memo(Operations)

View file

@ -4,8 +4,8 @@ import useSWR from 'swr'
import { useRouter } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import { omit } from 'lodash-es'
import { ArrowRightIcon } from '@heroicons/react/24/solid'
import {
RiArrowRightLine,
RiCheckboxCircleFill,
RiErrorWarningFill,
RiLoader2Fill,
@ -31,6 +31,9 @@ import { sleep } from '@/utils'
import { RETRIEVE_METHOD } from '@/types/app'
import Tooltip from '@/app/components/base/tooltip'
import { useInvalidDocumentList } from '@/service/knowledge/use-document'
import Divider from '@/app/components/base/divider'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import Link from 'next/link'
type Props = {
datasetId: string
@ -129,7 +132,7 @@ const RuleDetail: FC<{
<FieldInfo
label={t('datasetSettings.form.retrievalSetting.title')}
// displayedValue={t(`datasetSettings.form.retrievalSetting.${retrievalMethod}`) as string}
displayedValue={t(`dataset.retrieval.${indexingType === IndexingType.ECONOMICAL ? 'invertedIndex' : retrievalMethod}.title`) as string}
displayedValue={t(`dataset.retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod}.title`) as string}
valueIcon={
<Image
className='size-4'
@ -211,9 +214,7 @@ const EmbeddingProcess: FC<Props> = ({ datasetId, batchId, documents = [], index
invalidDocumentList()
router.push(`/datasets/${datasetId}/documents`)
}
const navToApiDocs = () => {
router.push('/datasets?category=api')
}
const apiReferenceUrl = useDatasetApiAccessUrl()
const isEmbedding = useMemo(() => {
return indexingStatusBatchDetail.some(indexingStatusDetail => ['indexing', 'splitting', 'parsing', 'cleaning'].includes(indexingStatusDetail?.indexing_status || ''))
@ -250,102 +251,118 @@ const EmbeddingProcess: FC<Props> = ({ datasetId, batchId, documents = [], index
return (
<>
<div className="mb-3 flex h-5 items-center">
<div className="mr-2 flex items-center justify-between text-sm font-medium text-text-secondary">
{isEmbedding && <div className='flex items-center'>
<RiLoader2Fill className='mr-1 size-4 animate-spin text-text-secondary' />
{t('datasetDocuments.embedding.processing')}
</div>}
<div className='flex flex-col gap-y-3'>
<div className='system-md-semibold-uppercase flex items-center gap-x-1 text-text-secondary'>
{isEmbedding && (
<>
<RiLoader2Fill className='size-4 animate-spin' />
<span>{t('datasetDocuments.embedding.processing')}</span>
</>
)}
{isEmbeddingCompleted && t('datasetDocuments.embedding.completed')}
</div>
</div>
{
enableBilling && plan.type !== Plan.team && (
<div className='mb-3 flex h-14 items-center rounded-xl border-[0.5px] border-black/5 bg-white p-3 shadow-md'>
<div className='flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[#FFF6ED]'>
<ZapFast className='h-4 w-4 text-[#FB6514]' />
</div>
<div className='mx-3 grow text-[13px] font-medium text-gray-700'>
{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',
// indexingStatusDetail.indexing_status === 'completed' && 's.success',
)}>
{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.FILE && (
// <div className={cn(
// 'shrink-0 marker:size-4 bg-center bg-no-repeat bg-contain',
// s[getFileType(getSourceName(indexingStatusDetail.id))] || s.unknownFileIcon,
// )} />
<DocumentFileIcon
className="size-4 shrink-0"
name={getSourceName(indexingStatusDetail.id)}
extension={getFileType(getSourceName(indexingStatusDetail.id))}
/>
)}
{getSourceType(indexingStatusDetail.id) === DataSourceType.NOTION && (
<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' />
)
}
{
enableBilling && plan.type !== Plan.team && (
<div className='flex h-14 items-center rounded-xl border-[0.5px] border-black/5 bg-white p-3 shadow-md'>
<div className='flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[#FFF6ED]'>
<ZapFast className='h-4 w-4 text-[#FB6514]' />
</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 text-sm leading-4 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 className='mx-3 grow text-[13px] font-medium text-gray-700'>
{t('billing.plansCommon.documentProcessingPriorityUpgrade')}
</div>
<UpgradeBtn loc='knowledge-speed-up' />
</div>
</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.FILE && (
<DocumentFileIcon
size='sm'
className='shrink-0'
name={getSourceName(indexingStatusDetail.id)}
extension={getFileType(getSourceName(indexingStatusDetail.id))}
/>
)}
{getSourceType(indexingStatusDetail.id) === DataSourceType.NOTION && (
<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>
<hr className="my-3 h-px border-0 bg-divider-subtle" />
<RuleDetail
sourceData={ruleDetail}
indexingType={indexingType}
retrievalMethod={retrievalMethod}
/>
<div className='my-10 flex items-center gap-2'>
<Button className='w-fit' onClick={navToApiDocs}>
<RiTerminalBoxLine className='mr-2 size-4' />
<span>Access the API</span>
</Button>
<Button className='w-fit' variant='primary' onClick={navToDocumentList}>
<span>{t('datasetCreation.stepThree.navTo')}</span>
<ArrowRightIcon className='ml-2 size-4 stroke-current stroke-1' />
<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>
</>

View file

@ -11,6 +11,7 @@ import Button from '@/app/components/base/button'
import { ToastContext } from '@/app/components/base/toast'
import { createEmptyDataset } from '@/service/datasets'
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
type IProps = {
show: boolean
@ -25,6 +26,7 @@ const EmptyDatasetCreationModal = ({
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const router = useRouter()
const invalidDatasetList = useInvalidDatasetList()
const submit = async () => {
if (!inputValue) {
@ -37,6 +39,7 @@ const EmptyDatasetCreationModal = ({
}
try {
const dataset = await createEmptyDataset({ name: inputValue })
invalidDatasetList()
onHide()
router.push(`/datasets/${dataset.id}/documents`)
}

View file

@ -322,6 +322,7 @@ const FileUploader = ({
<div>{t('datasetCreation.stepOne.uploader.tip', {
size: fileUploadConfig.file_size_limit,
supportTypes: supportTypesShowNames,
batchCount: fileUploadConfig.batch_count_limit,
})}</div>
{dragging && <div ref={dragRef} className='absolute left-0 top-0 h-full w-full' />}
</div>
@ -339,7 +340,8 @@ const FileUploader = ({
>
<div className="flex w-12 shrink-0 items-center justify-center">
<DocumentFileIcon
className="size-6 shrink-0"
size='xl'
className="shrink-0"
name={fileItem.file.name}
extension={getFileType(fileItem.file)}
/>

View file

@ -1,5 +1,5 @@
'use client'
import React, { useCallback, useEffect, useState } from 'react'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppUnavailable from '../../base/app-unavailable'
import { ModelTypeEnum } from '../../header/account-setting/model-provider-page/declarations'
@ -8,12 +8,14 @@ import StepTwo from './step-two'
import StepThree from './step-three'
import { TopBar } from './top-bar'
import { DataSourceType } from '@/models/datasets'
import type { CrawlOptions, CrawlResultItem, DataSet, FileItem, createDocumentResponse } from '@/models/datasets'
import { fetchDataSource } from '@/service/common'
import { fetchDatasetDetail } from '@/service/datasets'
import type { CrawlOptions, CrawlResultItem, FileItem, createDocumentResponse } from '@/models/datasets'
import { DataSourceProvider, type NotionPage } from '@/models/common'
import { useModalContext } from '@/context/modal-context'
import { useModalContextSelector } from '@/context/modal-context'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useGetDefaultDataSourceListAuth } from '@/service/use-datasource'
import produce from 'immer'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import Loading from '@/app/components/base/loading'
type DatasetUpdateFormProps = {
datasetId?: string
@ -31,59 +33,63 @@ const DEFAULT_CRAWL_OPTIONS: CrawlOptions = {
const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => {
const { t } = useTranslation()
const { setShowAccountSettingModal } = useModalContext()
const [hasConnection, setHasConnection] = useState(true)
const setShowAccountSettingModal = useModalContextSelector(state => state.setShowAccountSettingModal)
const datasetDetail = useDatasetDetailContextWithSelector(state => state.dataset)
const { data: embeddingsDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding)
const [dataSourceType, setDataSourceType] = useState<DataSourceType>(DataSourceType.FILE)
const [step, setStep] = useState(1)
const [indexingTypeCache, setIndexTypeCache] = useState('')
const [retrievalMethodCache, setRetrievalMethodCache] = useState('')
const [fileList, setFiles] = useState<FileItem[]>([])
const [result, setResult] = useState<createDocumentResponse | undefined>()
const [hasError, setHasError] = useState(false)
const { data: embeddingsDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding)
const [notionPages, setNotionPages] = useState<NotionPage[]>([])
const updateNotionPages = (value: NotionPage[]) => {
setNotionPages(value)
}
const [notionCredentialId, setNotionCredentialId] = useState<string>('')
const [websitePages, setWebsitePages] = useState<CrawlResultItem[]>([])
const [crawlOptions, setCrawlOptions] = useState<CrawlOptions>(DEFAULT_CRAWL_OPTIONS)
const updateFileList = (preparedFiles: FileItem[]) => {
setFiles(preparedFiles)
}
const [websiteCrawlProvider, setWebsiteCrawlProvider] = useState<DataSourceProvider>(DataSourceProvider.fireCrawl)
const [websiteCrawlProvider, setWebsiteCrawlProvider] = useState<DataSourceProvider>(DataSourceProvider.jinaReader)
const [websiteCrawlJobId, setWebsiteCrawlJobId] = useState('')
const updateFile = (fileItem: FileItem, progress: number, list: FileItem[]) => {
const {
data: dataSourceList,
isLoading: isLoadingAuthedDataSourceList,
isError: fetchingAuthedDataSourceListError,
} = useGetDefaultDataSourceListAuth()
const updateNotionPages = useCallback((value: NotionPage[]) => {
setNotionPages(value)
}, [])
const updateNotionCredentialId = useCallback((credentialId: string) => {
setNotionCredentialId(credentialId)
}, [])
const updateFileList = useCallback((preparedFiles: FileItem[]) => {
setFiles(preparedFiles)
}, [])
const updateFile = useCallback((fileItem: FileItem, progress: number, list: FileItem[]) => {
const targetIndex = list.findIndex(file => file.fileID === fileItem.fileID)
list[targetIndex] = {
...list[targetIndex],
progress,
}
setFiles([...list])
// use follow code would cause dirty list update problem
// const newList = list.map((file) => {
// if (file.fileID === fileItem.fileID) {
// return {
// ...fileItem,
// progress,
// }
// }
// return file
// })
// setFiles(newList)
}
const updateIndexingTypeCache = (type: string) => {
const newList = produce(list, (draft) => {
draft[targetIndex] = {
...draft[targetIndex],
progress,
}
})
setFiles(newList)
}, [])
const updateIndexingTypeCache = useCallback((type: string) => {
setIndexTypeCache(type)
}
const updateResultCache = (res?: createDocumentResponse) => {
}, [])
const updateResultCache = useCallback((res?: createDocumentResponse) => {
setResult(res)
}
const updateRetrievalMethodCache = (method: string) => {
}, [])
const updateRetrievalMethodCache = useCallback((method: string) => {
setRetrievalMethodCache(method)
}
}, [])
const nextStep = useCallback(() => {
setStep(step + 1)
@ -93,82 +99,77 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => {
setStep(step + delta)
}, [step, setStep])
const checkNotionConnection = async () => {
const { data } = await fetchDataSource({ url: '/data-source/integrates' })
const hasConnection = data.filter(item => item.provider === 'notion') || []
setHasConnection(hasConnection.length > 0)
}
useEffect(() => {
checkNotionConnection()
}, [])
const [detail, setDetail] = useState<DataSet | null>(null)
useEffect(() => {
(async () => {
if (datasetId) {
try {
const detail = await fetchDatasetDetail(datasetId)
setDetail(detail)
}
catch {
setHasError(true)
}
}
})()
}, [datasetId])
if (hasError)
if (fetchingAuthedDataSourceListError)
return <AppUnavailable code={500} unknownReason={t('datasetCreation.error.unavailable') as string} />
return (
<div className='flex flex-col overflow-hidden bg-components-panel-bg' style={{ height: 'calc(100vh - 56px)' }}>
<TopBar activeIndex={step - 1} datasetId={datasetId} />
<div style={{ height: 'calc(100% - 52px)' }}>
{step === 1 && <StepOne
hasConnection={hasConnection}
onSetting={() => setShowAccountSettingModal({ payload: 'data-source' })}
datasetId={datasetId}
dataSourceType={dataSourceType}
dataSourceTypeDisable={!!detail?.data_source_type}
changeType={setDataSourceType}
files={fileList}
updateFile={updateFile}
updateFileList={updateFileList}
notionPages={notionPages}
updateNotionPages={updateNotionPages}
onStepChange={nextStep}
websitePages={websitePages}
updateWebsitePages={setWebsitePages}
onWebsiteCrawlProviderChange={setWebsiteCrawlProvider}
onWebsiteCrawlJobIdChange={setWebsiteCrawlJobId}
crawlOptions={crawlOptions}
onCrawlOptionsChange={setCrawlOptions}
/>}
{(step === 2 && (!datasetId || (datasetId && !!detail))) && <StepTwo
isAPIKeySet={!!embeddingsDefaultModel}
onSetting={() => setShowAccountSettingModal({ payload: 'provider' })}
indexingType={detail?.indexing_technique}
datasetId={datasetId}
dataSourceType={dataSourceType}
files={fileList.map(file => file.file)}
notionPages={notionPages}
websitePages={websitePages}
websiteCrawlProvider={websiteCrawlProvider}
websiteCrawlJobId={websiteCrawlJobId}
onStepChange={changeStep}
updateIndexingTypeCache={updateIndexingTypeCache}
updateRetrievalMethodCache={updateRetrievalMethodCache}
updateResultCache={updateResultCache}
crawlOptions={crawlOptions}
/>}
{step === 3 && <StepThree
datasetId={datasetId}
datasetName={detail?.name}
indexingType={detail?.indexing_technique || indexingTypeCache}
retrievalMethod={detail?.retrieval_model_dict?.search_method || retrievalMethodCache}
creationCache={result}
/>}
{
isLoadingAuthedDataSourceList && (
<Loading type='app' />
)
}
{
!isLoadingAuthedDataSourceList && (
<>
{step === 1 && (
<StepOne
authedDataSourceList={dataSourceList?.result || []}
onSetting={() => setShowAccountSettingModal({ payload: 'data-source' })}
datasetId={datasetId}
dataSourceType={dataSourceType}
dataSourceTypeDisable={!!datasetDetail?.data_source_type}
changeType={setDataSourceType}
files={fileList}
updateFile={updateFile}
updateFileList={updateFileList}
notionPages={notionPages}
notionCredentialId={notionCredentialId}
updateNotionPages={updateNotionPages}
updateNotionCredentialId={updateNotionCredentialId}
onStepChange={nextStep}
websitePages={websitePages}
updateWebsitePages={setWebsitePages}
onWebsiteCrawlProviderChange={setWebsiteCrawlProvider}
onWebsiteCrawlJobIdChange={setWebsiteCrawlJobId}
crawlOptions={crawlOptions}
onCrawlOptionsChange={setCrawlOptions}
/>
)}
{(step === 2 && (!datasetId || (datasetId && !!datasetDetail))) && (
<StepTwo
isAPIKeySet={!!embeddingsDefaultModel}
onSetting={() => setShowAccountSettingModal({ payload: 'provider' })}
indexingType={datasetDetail?.indexing_technique}
datasetId={datasetId}
dataSourceType={dataSourceType}
files={fileList.map(file => file.file)}
notionPages={notionPages}
notionCredentialId={notionCredentialId}
websitePages={websitePages}
websiteCrawlProvider={websiteCrawlProvider}
websiteCrawlJobId={websiteCrawlJobId}
onStepChange={changeStep}
updateIndexingTypeCache={updateIndexingTypeCache}
updateRetrievalMethodCache={updateRetrievalMethodCache}
updateResultCache={updateResultCache}
crawlOptions={crawlOptions}
/>
)}
{step === 3 && (
<StepThree
datasetId={datasetId}
datasetName={datasetDetail?.name}
indexingType={datasetDetail?.indexing_technique || indexingTypeCache}
retrievalMethod={datasetDetail?.retrieval_model_dict?.search_method || retrievalMethodCache}
creationCache={result}
/>
)}
</>
)
}
</div>
</div>
)

View file

@ -11,11 +11,13 @@ import { fetchNotionPagePreview } from '@/service/datasets'
type IProps = {
currentPage?: NotionPage
notionCredentialId: string
hidePreview: () => void
}
const NotionPagePreview = ({
currentPage,
notionCredentialId,
hidePreview,
}: IProps) => {
const { t } = useTranslation()
@ -30,6 +32,7 @@ const NotionPagePreview = ({
workspaceID: currentPage.workspace_id,
pageID: currentPage.page_id,
pageType: currentPage.type,
credentialID: notionCredentialId,
})
setPreviewContent(res.content)
setLoading(false)

View file

@ -15,24 +15,26 @@ import type { DataSourceProvider, NotionPage } from '@/models/common'
import { DataSourceType } from '@/models/datasets'
import Button from '@/app/components/base/button'
import { NotionPageSelector } from '@/app/components/base/notion-page-selector'
import { useDatasetDetailContext } from '@/context/dataset-detail'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useProviderContext } from '@/context/provider-context'
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
import classNames from '@/utils/classnames'
import { Icon3Dots } from '@/app/components/base/icons/src/vender/line/others'
import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config'
import NotionConnector from '@/app/components/base/notion-connector'
import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types'
type IStepOneProps = {
datasetId?: string
dataSourceType?: DataSourceType
dataSourceTypeDisable: boolean
hasConnection: boolean
onSetting: () => void
files: FileItem[]
updateFileList: (files: FileItem[]) => void
updateFile: (fileItem: FileItem, progress: number, list: FileItem[]) => void
notionPages?: NotionPage[]
notionCredentialId: string
updateNotionPages: (value: NotionPage[]) => void
updateNotionCredentialId: (credentialId: string) => void
onStepChange: () => void
changeType: (type: DataSourceType) => void
websitePages?: CrawlResultItem[]
@ -41,28 +43,7 @@ type IStepOneProps = {
onWebsiteCrawlJobIdChange: (jobId: string) => void
crawlOptions: CrawlOptions
onCrawlOptionsChange: (payload: CrawlOptions) => void
}
type NotionConnectorProps = {
onSetting: () => void
}
export const NotionConnector = (props: NotionConnectorProps) => {
const { onSetting } = props
const { t } = useTranslation()
return (
<div className='flex w-[640px] flex-col items-start rounded-2xl bg-workflow-process-bg p-6'>
<span className={cn(s.notionIcon, 'mb-2 h-12 w-12 rounded-[10px] border-[0.5px] border-components-card-border p-3 shadow-lg shadow-shadow-shadow-5')} />
<div className='mb-1 flex flex-col gap-y-1 pb-3 pt-1'>
<span className='system-md-semibold text-text-secondary'>
{t('datasetCreation.stepOne.notionSyncTitle')}
<Icon3Dots className='relative -left-1.5 -top-2.5 inline h-4 w-4 text-text-secondary' />
</span>
<div className='system-sm-regular text-text-tertiary'>{t('datasetCreation.stepOne.notionSyncTip')}</div>
</div>
<Button className='h-8' variant='primary' onClick={onSetting}>{t('datasetCreation.stepOne.connect')}</Button>
</div>
)
authedDataSourceList: DataSourceAuth[]
}
const StepOne = ({
@ -70,22 +51,24 @@ const StepOne = ({
dataSourceType: inCreatePageDataSourceType,
dataSourceTypeDisable,
changeType,
hasConnection,
onSetting,
onStepChange,
files,
updateFileList,
updateFile,
notionPages = [],
notionCredentialId,
updateNotionPages,
updateNotionCredentialId,
websitePages = [],
updateWebsitePages,
onWebsiteCrawlProviderChange,
onWebsiteCrawlJobIdChange,
crawlOptions,
onCrawlOptionsChange,
authedDataSourceList,
}: IStepOneProps) => {
const { dataset } = useDatasetDetailContext()
const dataset = useDatasetDetailContextWithSelector(state => state.dataset)
const [showModal, setShowModal] = useState(false)
const [currentFile, setCurrentFile] = useState<File | undefined>()
const [currentNotionPage, setCurrentNotionPage] = useState<NotionPage | undefined>()
@ -136,6 +119,17 @@ const StepOne = ({
return isShowVectorSpaceFull
}, [files, isShowVectorSpaceFull])
const isNotionAuthed = useMemo(() => {
if (!authedDataSourceList) return false
const notionSource = authedDataSourceList.find(item => item.provider === 'notion_datasource')
if (!notionSource) return false
return notionSource.credentials_list.length > 0
}, [authedDataSourceList])
const notionCredentialList = useMemo(() => {
return authedDataSourceList.find(item => item.provider === 'notion_datasource')?.credentials_list || []
}, [authedDataSourceList])
return (
<div className='h-full w-full overflow-x-auto'>
<div className='flex h-full w-full min-w-[1440px]'>
@ -243,7 +237,6 @@ const StepOne = ({
</div>
)}
<div className="flex max-w-[640px] justify-end gap-2">
{/* <Button>{t('datasetCreation.stepOne.cancel')}</Button> */}
<Button disabled={nextDisabled} variant='primary' onClick={onStepChange}>
<span className="flex gap-0.5 px-[10px]">
<span className="px-0.5">{t('datasetCreation.stepOne.button')}</span>
@ -255,14 +248,17 @@ const StepOne = ({
)}
{dataSourceType === DataSourceType.NOTION && (
<>
{!hasConnection && <NotionConnector onSetting={onSetting} />}
{hasConnection && (
{!isNotionAuthed && <NotionConnector onSetting={onSetting} />}
{isNotionAuthed && (
<>
<div className='mb-8 w-[640px]'>
<NotionPageSelector
value={notionPages.map(page => page.page_id)}
onSelect={updateNotionPages}
onPreview={updateCurrentPage}
credentialList={notionCredentialList}
onSelectCredential={updateNotionCredentialId}
datasetId={datasetId}
/>
</div>
{isShowVectorSpaceFull && (
@ -271,7 +267,6 @@ const StepOne = ({
</div>
)}
<div className="flex max-w-[640px] justify-end gap-2">
{/* <Button>{t('datasetCreation.stepOne.cancel')}</Button> */}
<Button disabled={isShowVectorSpaceFull || !notionPages.length} variant='primary' onClick={onStepChange}>
<span className="flex gap-0.5 px-[10px]">
<span className="px-0.5">{t('datasetCreation.stepOne.button')}</span>
@ -294,6 +289,7 @@ const StepOne = ({
onJobIdChange={onWebsiteCrawlJobIdChange}
crawlOptions={crawlOptions}
onCrawlOptionsChange={onCrawlOptionsChange}
authedDataSourceList={authedDataSourceList}
/>
</div>
{isShowVectorSpaceFull && (
@ -302,7 +298,6 @@ const StepOne = ({
</div>
)}
<div className="flex max-w-[640px] justify-end gap-2">
{/* <Button>{t('datasetCreation.stepOne.cancel')}</Button> */}
<Button disabled={isShowVectorSpaceFull || !websitePages.length} variant='primary' onClick={onStepChange}>
<span className="flex gap-0.5 px-[10px]">
<span className="px-0.5">{t('datasetCreation.stepOne.button')}</span>
@ -327,7 +322,13 @@ const StepOne = ({
</div>
<div className='h-full w-1/2 overflow-y-auto'>
{currentFile && <FilePreview file={currentFile} hidePreview={hideFilePreview} />}
{currentNotionPage && <NotionPagePreview currentPage={currentNotionPage} hidePreview={hideNotionPagePreview} />}
{currentNotionPage && (
<NotionPagePreview
currentPage={currentNotionPage}
hidePreview={hideNotionPagePreview}
notionCredentialId={notionCredentialId}
/>
)}
{currentWebsite && <WebsitePreview payload={currentWebsite} hidePreview={hideWebsitePreview} />}
</div>
</div>

View file

@ -7,6 +7,8 @@ import EmbeddingProcess from '../embedding-process'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import type { FullDocumentDetail, createDocumentResponse } from '@/models/datasets'
import AppIcon from '@/app/components/base/app-icon'
import Divider from '@/app/components/base/divider'
import { useDocLink } from '@/context/i18n'
type StepThreeProps = {
datasetId?: string
@ -18,34 +20,52 @@ type StepThreeProps = {
const StepThree = ({ datasetId, datasetName, indexingType, creationCache, retrievalMethod }: StepThreeProps) => {
const { t } = useTranslation()
const docLink = useDocLink()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const iconInfo = creationCache?.dataset?.icon_info || {
icon: '📙',
icon_type: 'emoji',
icon_background: '#FFF4ED',
icon_url: '',
}
return (
<div className="flex h-full max-h-full w-full justify-center overflow-y-auto">
<div className="h-full max-w-[960px] shrink-0 grow overflow-y-auto px-14 sm:px-16">
<div className="mx-auto max-w-[640px]">
<div className='flex h-full max-h-full w-full justify-center overflow-y-auto'>
<div className='h-full max-w-[960px] shrink-0 grow overflow-y-auto px-14 sm:px-16'>
<div className='mx-auto max-w-[640px] pb-8 pt-10'>
{!datasetId && (
<>
<div className="pt-10">
<div className="mb-1 text-xl font-semibold leading-[22px] text-text-primary">{t('datasetCreation.stepThree.creationTitle')}</div>
<div className="mb-7 text-[13px] leading-4 text-text-tertiary">{t('datasetCreation.stepThree.creationContent')}</div>
<div className="flex gap-4">
<AppIcon {...creationCache?.dataset} className="size-14 self-center text-2xl" />
<div className="flex grow flex-col gap-1">
<div className="text-[13px] font-semibold leading-6 text-text-secondary">{t('datasetCreation.stepThree.label')}</div>
<div className="w-full truncate rounded-lg bg-components-input-bg-normal px-3 py-2 text-[13px] leading-4 text-components-input-text-filled">{datasetName || creationCache?.dataset?.name}</div>
<div className='flex flex-col gap-y-1 pb-3'>
<div className='title-2xl-semi-bold text-text-primary'>{t('datasetCreation.stepThree.creationTitle')}</div>
<div className='system-sm-regular text-text-tertiary'>{t('datasetCreation.stepThree.creationContent')}</div>
</div>
<div className='flex items-center gap-x-4'>
<AppIcon
size='xxl'
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
className='shrink-0'
/>
<div className='flex grow flex-col gap-y-1'>
<div className='system-sm-semibold flex h-6 items-center text-text-secondary'>
{t('datasetCreation.stepThree.label')}
</div>
<div className='system-sm-regular w-full truncate rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled'>
<span className='px-1'>{datasetName || creationCache?.dataset?.name}</span>
</div>
</div>
</div>
<hr className="my-6 h-px border-0 bg-divider-subtle" />
<Divider type='horizontal' className='my-6 bg-divider-subtle' />
</>
)}
{datasetId && (
<div className="pt-10">
<div className="mb-1 text-xl font-semibold leading-[22px] text-text-primary">{t('datasetCreation.stepThree.additionTitle')}</div>
<div className="mb-7 text-[13px] leading-4 text-text-tertiary">{`${t('datasetCreation.stepThree.additionP1')} ${datasetName || creationCache?.dataset?.name} ${t('datasetCreation.stepThree.additionP2')}`}</div>
<div className='flex flex-col gap-y-1 pb-3'>
<div className='title-2xl-semi-bold text-text-primary'>{t('datasetCreation.stepThree.additionTitle')}</div>
<div className='system-sm-regular text-text-tertiary'>{`${t('datasetCreation.stepThree.additionP1')} ${datasetName || creationCache?.dataset?.name} ${t('datasetCreation.stepThree.additionP2')}`}</div>
</div>
)}
<EmbeddingProcess
@ -58,13 +78,21 @@ const StepThree = ({ datasetId, datasetName, indexingType, creationCache, retrie
</div>
</div>
{!isMobile && (
<div className="shrink-0 pr-8 pt-[88px] text-xs">
<div className="flex w-[328px] flex-col gap-3 rounded-xl bg-background-section p-6 text-text-tertiary">
<div className="flex size-10 items-center justify-center rounded-[10px] bg-components-card-bg shadow-lg">
<RiBookOpenLine className="size-5 text-text-accent" />
<div className='shrink-0 pr-8 pt-[88px] text-xs'>
<div className='flex w-[328px] flex-col gap-3 rounded-xl bg-background-section p-6 text-text-tertiary'>
<div className='flex size-10 items-center justify-center rounded-[10px] bg-components-card-bg shadow-lg'>
<RiBookOpenLine className='size-5 text-text-accent' />
</div>
<div className="text-base font-semibold text-text-secondary">{t('datasetCreation.stepThree.sideTipTitle')}</div>
<div className="text-text-tertiary">{t('datasetCreation.stepThree.sideTipContent')}</div>
<div className='text-base font-semibold text-text-secondary'>{t('datasetCreation.stepThree.sideTipTitle')}</div>
<div className='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>
)}

View file

@ -1,6 +1,6 @@
'use client'
import type { FC, PropsWithChildren } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import {
@ -10,14 +10,13 @@ import {
} from '@remixicon/react'
import Link from 'next/link'
import Image from 'next/image'
import { useHover } from 'ahooks'
import SettingCog from '../assets/setting-gear-mod.svg'
import OrangeEffect from '../assets/option-card-effect-orange.svg'
import FamilyMod from '../assets/family-mod.svg'
import Note from '../assets/note-mod.svg'
import FileList from '../assets/file-list-3-fill.svg'
import { indexMethodIcon } from '../icons'
import { PreviewContainer } from '../../preview/container'
import PreviewContainer from '../../preview/container'
import { ChunkContainer, QAPreview } from '../../chunk'
import { PreviewHeader } from '../../preview/header'
import { FormattedText } from '../../formatted-text/formatted'
@ -42,7 +41,7 @@ import { isReRankModelSelected } from '@/app/components/datasets/common/check-re
import Toast from '@/app/components/base/toast'
import type { NotionPage } from '@/models/common'
import { DataSourceProvider } from '@/models/common'
import { useDatasetDetailContext } from '@/context/dataset-detail'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import I18n from '@/context/i18n'
import { RETRIEVE_METHOD } from '@/types/app'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
@ -60,10 +59,10 @@ import Badge from '@/app/components/base/badge'
import { SkeletonContainer, SkeletonPoint, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import Tooltip from '@/app/components/base/tooltip'
import CustomDialog from '@/app/components/base/dialog'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import { noop } from 'lodash-es'
import { useDocLink } from '@/context/i18n'
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
const TextLabel: FC<PropsWithChildren> = (props) => {
return <label className='system-sm-semibold text-text-secondary'>{props.children}</label>
@ -80,6 +79,7 @@ type StepTwoProps = {
dataSourceType: DataSourceType
files: CustomFile[]
notionPages?: NotionPage[]
notionCredentialId: string
websitePages?: CrawlResultItem[]
crawlOptions?: CrawlOptions
websiteCrawlProvider?: DataSourceProvider
@ -135,9 +135,10 @@ const StepTwo = ({
dataSourceType: inCreatePageDataSourceType,
files,
notionPages = [],
notionCredentialId,
websitePages = [],
crawlOptions,
websiteCrawlProvider = DataSourceProvider.fireCrawl,
websiteCrawlProvider = DataSourceProvider.jinaReader,
websiteCrawlJobId = '',
onStepChange,
updateIndexingTypeCache,
@ -152,7 +153,8 @@ const StepTwo = ({
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { dataset: currentDataset, mutateDatasetRes } = useDatasetDetailContext()
const currentDataset = useDatasetDetailContextWithSelector(state => state.dataset)
const mutateDatasetRes = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
const isInUpload = Boolean(currentDataset)
const isUploadInEmptyDataset = isInUpload && !currentDataset?.doc_form
@ -282,6 +284,7 @@ const StepTwo = ({
indexingTechnique: getIndexing_technique() as any,
processRule: getProcessRule(),
dataset_id: datasetId || '',
credential_id: notionCredentialId,
})
const websiteIndexingEstimateQuery = useFetchFileIndexingEstimateForWeb({
@ -469,7 +472,7 @@ const StepTwo = ({
}
}
if (dataSourceType === DataSourceType.NOTION)
params.data_source.info_list.notion_info_list = getNotionInfo(notionPages)
params.data_source.info_list.notion_info_list = getNotionInfo(notionPages, notionCredentialId)
if (dataSourceType === DataSourceType.WEB) {
params.data_source.info_list.website_info_list = getWebsiteInfo({
@ -507,7 +510,7 @@ const StepTwo = ({
const max = rules.segmentation.max_tokens
const overlap = rules.segmentation.chunk_overlap
const isHierarchicalDocument = documentDetail.doc_form === ChunkingMode.parentChild
|| (rules.parent_mode && rules.subchunk_segmentation)
|| (rules.parent_mode && rules.subchunk_segmentation)
setSegmentIdentifier(separator)
setMaxChunkLength(max)
setOverlap(overlap!)
@ -553,6 +556,7 @@ const StepTwo = ({
})
const isCreating = createFirstDocumentMutation.isPending || createDocumentMutation.isPending
const invalidDatasetList = useInvalidDatasetList()
const createHandle = async () => {
const params = getCreationParams()
@ -582,6 +586,7 @@ const StepTwo = ({
}
if (mutateDatasetRes)
mutateDatasetRes()
invalidDatasetList()
onStepChange && onStepChange(+1)
isSetting && onSave && onSave()
}
@ -605,9 +610,6 @@ const StepTwo = ({
setIndexType(isAPIKeySet ? IndexingType.QUALIFIED : IndexingType.ECONOMICAL)
}, [isAPIKeySet, indexingType, datasetId])
const economyDomRef = useRef<HTMLDivElement>(null)
const isHoveringEconomy = useHover(economyDomRef)
const isModelAndRetrievalConfigDisabled = !!datasetId && !!currentDataset?.data_source_type
return (
@ -867,7 +869,8 @@ const StepTwo = ({
<div className={'system-md-semibold mb-1 text-text-secondary'}>{t('datasetCreation.stepTwo.indexMode')}</div>
<div className='flex items-center gap-2'>
{(!hasSetIndexType || (hasSetIndexType && indexingType === IndexingType.QUALIFIED)) && (
<OptionCard className='flex-1 self-stretch'
<OptionCard
className='flex-1 self-stretch'
title={<div className='flex items-center'>
{t('datasetCreation.stepTwo.qualified')}
<Badge className={cn('ml-1 h-[18px]', (!hasSetIndexType && indexType === IndexingType.QUALIFIED) ? 'border-text-accent-secondary text-text-accent-secondary' : '')} uppercase>
@ -913,26 +916,8 @@ const StepTwo = ({
</Button>
</div>
</CustomDialog>
<PortalToFollowElem
open={
isHoveringEconomy && docForm !== ChunkingMode.text
}
placement={'top'}
>
<PortalToFollowElemTrigger asChild>
<OptionCard className='flex-1 self-stretch'
title={t('datasetCreation.stepTwo.economical')}
description={t('datasetCreation.stepTwo.economicalTip')}
icon={<Image src={indexMethodIcon.economical} alt='' />}
isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL}
disabled={hasSetIndexType || docForm !== ChunkingMode.text}
ref={economyDomRef}
onSwitched={() => {
setIndexType(IndexingType.ECONOMICAL)
}}
/>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent>
<Tooltip
popupContent={
<div className='rounded-lg border-components-panel-border bg-components-tooltip-bg p-3 text-xs font-medium text-text-secondary shadow-lg'>
{
docForm === ChunkingMode.qa
@ -940,8 +925,24 @@ const StepTwo = ({
: t('datasetCreation.stepTwo.notAvailableForParentChild')
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
}
noDecoration
position='top'
asChild={false}
triggerClassName='flex-1 self-stretch'
>
<OptionCard
className='h-full'
title={t('datasetCreation.stepTwo.economical')}
description={t('datasetCreation.stepTwo.economicalTip')}
icon={<Image src={indexMethodIcon.economical} alt='' />}
isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL}
disabled={hasSetIndexType || docForm !== ChunkingMode.text}
onSwitched={() => {
setIndexType(IndexingType.ECONOMICAL)
}}
/>
</Tooltip>
</>)}
</div>
{!hasSetIndexType && indexType === IndexingType.QUALIFIED && (

View file

@ -64,43 +64,45 @@ export const OptionCard: FC<OptionCardProps> = (
},
) => {
const { icon, className, title, description, isActive, children, actions, activeHeaderClassName, style, effectImg, onSwitched, noHighlight, disabled, ...rest } = props
return <div
className={classNames(
'rounded-xl bg-components-option-card-option-bg shadow-xs',
(isActive && !noHighlight)
? 'border-[1.5px] border-components-option-card-option-selected-border'
: 'border border-components-option-card-option-border',
disabled && 'pointer-events-none opacity-50',
className,
)}
style={{
...style,
}}
onClick={() => {
if (!isActive && !disabled)
onSwitched?.()
}}
{...rest}
ref={ref}
>
<OptionCardHeader
icon={icon}
title={title}
description={description}
isActive={isActive && !noHighlight}
activeClassName={activeHeaderClassName}
effectImg={effectImg}
disabled={disabled}
/>
{/** Body */}
{isActive && (children || actions) && <div className='rounded-b-xl bg-components-panel-bg px-4 py-3'>
{children}
{actions && <div className='mt-4 flex gap-2'>
{actions}
</div>
}
</div>}
</div>
return (
<div
className={classNames(
'rounded-xl bg-components-option-card-option-bg shadow-xs',
(isActive && !noHighlight)
? 'border-[1.5px] border-components-option-card-option-selected-border'
: 'border border-components-option-card-option-border',
disabled && 'pointer-events-none opacity-50',
className,
)}
style={{
...style,
}}
onClick={() => {
if (!isActive && !disabled)
onSwitched?.()
}}
{...rest}
ref={ref}
>
<OptionCardHeader
icon={icon}
title={title}
description={description}
isActive={isActive && !noHighlight}
activeClassName={activeHeaderClassName}
effectImg={effectImg}
disabled={disabled}
/>
{/** Body */}
{isActive && (children || actions) && <div className='rounded-b-xl bg-components-panel-bg px-4 py-3'>
{children}
{actions && <div className='mt-4 flex gap-2'>
{actions}
</div>
}
</div>}
</div>
)
}
OptionCard.displayName = 'OptionCard'

View file

@ -0,0 +1,61 @@
import React from 'react'
import Divider from '@/app/components/base/divider'
import Button from '@/app/components/base/button'
import cn from '@/utils/classnames'
import { RiBookOpenLine, RiEqualizer2Line } from '@remixicon/react'
type HeaderProps = {
isInPipeline?: boolean
onClickConfiguration?: () => void
title: string
buttonText?: string
docTitle: string
docLink: string
}
const Header = ({
isInPipeline = false,
onClickConfiguration,
title,
buttonText,
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={cn(
'text-text-secondary',
isInPipeline ? 'system-sm-semibold' : 'system-md-semibold',
)}>
{title}
</div>
<Divider type='vertical' className='mx-1 h-3.5' />
<Button
variant='secondary'
size='small'
className={cn(isInPipeline ? 'size-6 px-1' : 'gap-x-0.5 px-1.5')}
onClick={onClickConfiguration}
>
<RiEqualizer2Line className='size-4' />
{!isInPipeline && (
<span className='system-xs-medium'>
{buttonText}
</span>
)}
</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

@ -21,7 +21,7 @@ const Input: FC<Props> = ({
const value = e.target.value
if (isNumber) {
let numberValue = Number.parseInt(value, 10) // integer only
if (isNaN(numberValue)) {
if (Number.isNaN(numberValue)) {
onChange('')
return
}

View file

@ -3,19 +3,19 @@ import type { CrawlResultItem } from '@/models/datasets'
const result: CrawlResultItem[] = [
{
title: 'Start the frontend Docker container separately',
markdown: 'Markdown 1',
content: 'Markdown 1',
description: 'Description 1',
source_url: 'https://example.com/1',
},
{
title: 'Advanced Tool Integration',
markdown: 'Markdown 2',
content: 'Markdown 2',
description: 'Description 2',
source_url: 'https://example.com/2',
},
{
title: 'Local Source Code Start | English | Dify',
markdown: 'Markdown 3',
content: 'Markdown 3',
description: 'Description 3',
source_url: 'https://example.com/3',
},

View file

@ -1,43 +0,0 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { RiBookOpenLine, RiEqualizer2Line } from '@remixicon/react'
import Button from '@/app/components/base/button'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
type Props = {
onSetting: () => void
}
const Header: FC<Props> = ({
onSetting,
}) => {
const { t } = useTranslation()
return (
<div className='flex h-6 items-center justify-between'>
<div className='flex items-center'>
<div className='text-base font-medium text-text-secondary'>{t(`${I18N_PREFIX}.firecrawlTitle`)}</div>
<div className='ml-2 mr-2 h-3.5 w-px bg-divider-regular' />
<Button className='flex h-6 items-center gap-x-[1px] px-1.5' onClick={onSetting}>
<RiEqualizer2Line className='h-3.5 w-3.5 text-components-button-secondary-text' />
<span className='px-[3px] text-xs font-medium text-components-button-secondary-text'>
{t(`${I18N_PREFIX}.configureFirecrawl`)}
</span>
</Button>
</div>
<a
href='https://docs.firecrawl.dev/introduction'
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-x-1 text-xs font-medium text-text-accent'
>
<RiBookOpenLine className='h-3.5 w-3.5 text-text-accent' />
<span>{t(`${I18N_PREFIX}.firecrawlDoc`)}</span>
</a>
</div>
)
}
export default React.memo(Header)

View file

@ -7,13 +7,13 @@ import OptionsWrap from '../base/options-wrap'
import CrawledResult from '../base/crawled-result'
import Crawling from '../base/crawling'
import ErrorMessage from '../base/error-message'
import Header from './header'
import Options from './options'
import { useModalContext } from '@/context/modal-context'
import { useModalContextSelector } from '@/context/modal-context'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import Toast from '@/app/components/base/toast'
import { checkFirecrawlTaskStatus, createFirecrawlTask } from '@/service/datasets'
import { sleep } from '@/utils'
import Header from '../base/header'
const ERROR_I18N_PREFIX = 'common.errorMsg'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
@ -48,7 +48,7 @@ const FireCrawl: FC<Props> = ({
if (step !== Step.init)
setControlFoldOptions(Date.now())
}, [step])
const { setShowAccountSettingModal } = useModalContext()
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
const handleSetting = useCallback(() => {
setShowAccountSettingModal({
payload: 'data-source',
@ -112,6 +112,10 @@ const FireCrawl: FC<Props> = ({
},
}
}
res.data = res.data.map((item: any) => ({
...item,
content: item.markdown,
}))
// update the progress
setCrawlResult({
...res,
@ -131,7 +135,7 @@ const FireCrawl: FC<Props> = ({
},
}
}
}, [crawlOptions.limit])
}, [crawlOptions.limit, onCheckedCrawlResultChange])
const handleRun = useCallback(async (url: string) => {
const { isValid, errorMsg } = checkValid(url)
@ -161,6 +165,10 @@ const FireCrawl: FC<Props> = ({
setCrawlErrorMessage(errorMessage || t(`${I18N_PREFIX}.unknownError`))
}
else {
data.data = data.data.map((item: any) => ({
...item,
content: item.markdown,
}))
setCrawlResult(data)
onCheckedCrawlResultChange(data.data || []) // default select the crawl result
setCrawlErrorMessage('')
@ -173,11 +181,17 @@ const FireCrawl: FC<Props> = ({
finally {
setStep(Step.finished)
}
}, [checkValid, crawlOptions, onJobIdChange, t, waitForCrawlFinished])
}, [checkValid, crawlOptions, onJobIdChange, t, waitForCrawlFinished, onCheckedCrawlResultChange])
return (
<div>
<Header onSetting={handleSetting} />
<Header
onClickConfiguration={handleSetting}
title={t(`${I18N_PREFIX}.firecrawlTitle`)}
buttonText={t(`${I18N_PREFIX}.configureFirecrawl`)}
docTitle={t(`${I18N_PREFIX}.firecrawlDoc`)}
docLink={'https://docs.firecrawl.dev/introduction'}
/>
<div className='mt-2 rounded-xl border border-components-panel-border bg-background-default-subtle p-4 pb-0'>
<UrlInput onRun={handleRun} isRunning={isRunning} />
<OptionsWrap

View file

@ -6,7 +6,7 @@
}
.watercrawlLogo {
@apply w-5 h-5 bg-center bg-no-repeat inline-block;
@apply w-4 h-4 bg-center bg-no-repeat inline-block;
/*background-color: #F5FAFF;*/
background-image: url(../assets/watercrawl.svg);
background-size: 16px;

View file

@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import React, { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import s from './index.module.css'
import NoData from './no-data'
@ -10,9 +10,9 @@ import JinaReader from './jina-reader'
import cn from '@/utils/classnames'
import { useModalContext } from '@/context/modal-context'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { fetchDataSources } from '@/service/datasets'
import { type DataSourceItem, DataSourceProvider } from '@/models/common'
import { DataSourceProvider } from '@/models/common'
import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config'
import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types'
type Props = {
onPreview: (payload: CrawlResultItem) => void
@ -22,6 +22,7 @@ type Props = {
onJobIdChange: (jobId: string) => void
crawlOptions: CrawlOptions
onCrawlOptionsChange: (payload: CrawlOptions) => void
authedDataSourceList: DataSourceAuth[]
}
const Website: FC<Props> = ({
@ -32,58 +33,34 @@ const Website: FC<Props> = ({
onJobIdChange,
crawlOptions,
onCrawlOptionsChange,
authedDataSourceList,
}) => {
const { t } = useTranslation()
const { setShowAccountSettingModal } = useModalContext()
const [isLoaded, setIsLoaded] = useState(false)
const [selectedProvider, setSelectedProvider] = useState<DataSourceProvider>(DataSourceProvider.jinaReader)
const [sources, setSources] = useState<DataSourceItem[]>([])
useEffect(() => {
onCrawlProviderChange(selectedProvider)
}, [selectedProvider, onCrawlProviderChange])
const availableProviders = useMemo(() => authedDataSourceList.filter((item) => {
return [
DataSourceProvider.jinaReader,
DataSourceProvider.fireCrawl,
DataSourceProvider.waterCrawl].includes(item.provider as DataSourceProvider) && item.credentials_list.length > 0
}), [authedDataSourceList])
const checkSetApiKey = useCallback(async () => {
const res = await fetchDataSources() as any
setSources(res.sources)
// If users have configured one of the providers, select it.
const availableProviders = res.sources.filter((item: DataSourceItem) =>
[
DataSourceProvider.jinaReader,
DataSourceProvider.fireCrawl,
DataSourceProvider.waterCrawl,
].includes(item.provider),
)
if (availableProviders.length > 0)
setSelectedProvider(availableProviders[0].provider)
}, [])
useEffect(() => {
checkSetApiKey().then(() => {
setIsLoaded(true)
})
}, [])
const handleOnConfig = useCallback(() => {
setShowAccountSettingModal({
payload: 'data-source',
onCancelCallback: checkSetApiKey,
})
}, [checkSetApiKey, setShowAccountSettingModal])
}, [setShowAccountSettingModal])
if (!isLoaded)
return null
const source = sources.find(source => source.provider === selectedProvider)
const source = availableProviders.find(source => source.provider === selectedProvider)
return (
<div>
<div className="mb-4">
<div className="system-md-medium mb-2 text-text-secondary">
<div className='mb-4'>
<div className='system-md-medium mb-2 text-text-secondary'>
{t('datasetCreation.stepOne.website.chooseProvider')}
</div>
<div className="flex space-x-2">
<div className='flex space-x-2'>
{ENABLE_WEBSITE_JINAREADER && <button
className={cn('flex items-center justify-center rounded-lg px-4 py-2',
selectedProvider === DataSourceProvider.jinaReader
@ -91,9 +68,12 @@ const Website: FC<Props> = ({
: `system-sm-regular border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary
hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs hover:shadow-shadow-shadow-3`,
)}
onClick={() => setSelectedProvider(DataSourceProvider.jinaReader)}
onClick={() => {
setSelectedProvider(DataSourceProvider.jinaReader)
onCrawlProviderChange(DataSourceProvider.jinaReader)
}}
>
<span className={cn(s.jinaLogo, 'mr-2')}/>
<span className={cn(s.jinaLogo, 'mr-2')} />
<span>Jina Reader</span>
</button>}
{ENABLE_WEBSITE_FIRECRAWL && <button
@ -103,7 +83,10 @@ const Website: FC<Props> = ({
: `system-sm-regular border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary
hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs hover:shadow-shadow-shadow-3`,
)}
onClick={() => setSelectedProvider(DataSourceProvider.fireCrawl)}
onClick={() => {
setSelectedProvider(DataSourceProvider.fireCrawl)
onCrawlProviderChange(DataSourceProvider.fireCrawl)
}}
>
🔥 Firecrawl
</button>}
@ -114,9 +97,12 @@ const Website: FC<Props> = ({
: `system-sm-regular border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary
hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs hover:shadow-shadow-shadow-3`,
)}
onClick={() => setSelectedProvider(DataSourceProvider.waterCrawl)}
onClick={() => {
setSelectedProvider(DataSourceProvider.waterCrawl)
onCrawlProviderChange(DataSourceProvider.waterCrawl)
}}
>
<span className={cn(s.watercrawlLogo, 'mr-2')}/>
<span className={cn(s.watercrawlLogo, 'mr-2')} />
<span>WaterCrawl</span>
</button>}
</div>
@ -152,7 +138,7 @@ const Website: FC<Props> = ({
/>
)}
{!source && (
<NoData onConfig={handleOnConfig} provider={selectedProvider}/>
<NoData onConfig={handleOnConfig} provider={selectedProvider} />
)}
</div>
)

View file

@ -1,30 +0,0 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
type Props = {
className?: string
title: string
errorMsg?: string
}
const ErrorMessage: FC<Props> = ({
className,
title,
errorMsg,
}) => {
return (
<div className={cn(className, 'border-t border-gray-200 bg-[#FFFAEB] px-4 py-2')}>
<div className='flex h-5 items-center'>
<AlertTriangle className='mr-2 h-4 w-4 text-[#F79009]' />
<div className='text-sm font-medium text-[#DC6803]'>{title}</div>
</div>
{errorMsg && (
<div className='mt-1 pl-6 text-xs font-normal leading-[18px] text-gray-700'>{errorMsg}</div>
)}
</div>
)
}
export default React.memo(ErrorMessage)

View file

@ -1,54 +0,0 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import Input from './input'
import cn from '@/utils/classnames'
import Tooltip from '@/app/components/base/tooltip'
type Props = {
className?: string
label: string
labelClassName?: string
value: string | number
onChange: (value: string | number) => void
isRequired?: boolean
placeholder?: string
isNumber?: boolean
tooltip?: string
}
const Field: FC<Props> = ({
className,
label,
labelClassName,
value,
onChange,
isRequired = false,
placeholder = '',
isNumber = false,
tooltip,
}) => {
return (
<div className={cn(className)}>
<div className='flex py-[7px]'>
<div className={cn(labelClassName, 'flex h-[18px] items-center text-[13px] font-medium text-gray-900')}>{label} </div>
{isRequired && <span className='ml-0.5 text-xs font-semibold text-[#D92D20]'>*</span>}
{tooltip && (
<Tooltip
popupContent={
<div className='w-[200px]'>{tooltip}</div>
}
triggerClassName='ml-0.5 w-4 h-4'
/>
)}
</div>
<Input
value={value}
onChange={onChange}
placeholder={placeholder}
isNumber={isNumber}
/>
</div>
)
}
export default React.memo(Field)

View file

@ -1,58 +0,0 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
type Props = {
value: string | number
onChange: (value: string | number) => void
placeholder?: string
isNumber?: boolean
}
const MIN_VALUE = 0
const Input: FC<Props> = ({
value,
onChange,
placeholder = '',
isNumber = false,
}) => {
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
if (isNumber) {
let numberValue = Number.parseInt(value, 10) // integer only
if (isNaN(numberValue)) {
onChange('')
return
}
if (numberValue < MIN_VALUE)
numberValue = MIN_VALUE
onChange(numberValue)
return
}
onChange(value)
}, [isNumber, onChange])
const otherOption = (() => {
if (isNumber) {
return {
min: MIN_VALUE,
}
}
return {
}
})()
return (
<input
type={isNumber ? 'number' : 'text'}
{...otherOption}
value={value}
onChange={handleChange}
className='flex h-9 w-full rounded-lg bg-gray-100 px-2 py-1 text-xs leading-normal caret-primary-600 placeholder:text-gray-400 hover:bg-gray-100 focus:bg-gray-50 focus:ring-1 focus:ring-inset focus:ring-gray-200 focus-visible:outline-none'
placeholder={placeholder}
/>
)
}
export default React.memo(Input)

View file

@ -1,54 +0,0 @@
'use client'
import { useBoolean } from 'ahooks'
import type { FC } from 'react'
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
import { Settings04 } from '@/app/components/base/icons/src/vender/line/general'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
type Props = {
className?: string
children: React.ReactNode
controlFoldOptions?: number
}
const OptionsWrap: FC<Props> = ({
className = '',
children,
controlFoldOptions,
}) => {
const { t } = useTranslation()
const [fold, {
toggle: foldToggle,
setTrue: foldHide,
}] = useBoolean(false)
useEffect(() => {
if (controlFoldOptions)
foldHide()
}, [controlFoldOptions])
return (
<div className={cn(className, !fold ? 'mb-0' : 'mb-3')}>
<div
className='flex h-[26px] cursor-pointer select-none items-center justify-between py-1'
onClick={foldToggle}
>
<div className='flex items-center text-gray-700'>
<Settings04 className='mr-1 h-4 w-4' />
<div className='text-[13px] font-semibold uppercase text-gray-800'>{t(`${I18N_PREFIX}.options`)}</div>
</div>
<ChevronRight className={cn(!fold && 'rotate-90', 'h-4 w-4 text-gray-500')} />
</div>
{!fold && (
<div className='mb-4'>
{children}
</div>
)}
</div>
)
}
export default React.memo(OptionsWrap)

View file

@ -2,7 +2,7 @@
import type { FC } from 'react'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from './input'
import Input from '../../base/input'
import Button from '@/app/components/base/button'
import { useDocLink } from '@/context/i18n'

View file

@ -1,43 +0,0 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { RiBookOpenLine, RiEqualizer2Line } from '@remixicon/react'
import Button from '@/app/components/base/button'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
type Props = {
onSetting: () => void
}
const Header: FC<Props> = ({
onSetting,
}) => {
const { t } = useTranslation()
return (
<div className='flex h-6 items-center justify-between'>
<div className='flex items-center'>
<div className='text-sm font-semibold text-text-secondary'>{t(`${I18N_PREFIX}.jinaReaderTitle`)}</div>
<div className='ml-2 mr-2 h-3.5 w-px bg-divider-regular' />
<Button className='flex h-6 items-center gap-x-[1px] px-1.5' onClick={onSetting}>
<RiEqualizer2Line className='h-3.5 w-3.5 text-components-button-secondary-text' />
<span className='px-[3px] text-xs font-medium text-components-button-secondary-text'>
{t(`${I18N_PREFIX}.configureJinaReader`)}
</span>
</Button>
</div>
<a
href='https://jina.ai/reader'
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-x-1 text-xs font-medium text-text-accent'
>
<RiBookOpenLine className='h-3.5 w-3.5 text-text-accent' />
<span>{t(`${I18N_PREFIX}.jinaReaderDoc`)}</span>
</a>
</div>
)
}
export default React.memo(Header)

View file

@ -7,13 +7,13 @@ import OptionsWrap from '../base/options-wrap'
import CrawledResult from '../base/crawled-result'
import Crawling from '../base/crawling'
import ErrorMessage from '../base/error-message'
import Header from './header'
import Options from './options'
import { useModalContext } from '@/context/modal-context'
import Toast from '@/app/components/base/toast'
import { checkJinaReaderTaskStatus, createJinaReaderTask } from '@/service/datasets'
import { sleep } from '@/utils'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import Header from '../base/header'
const ERROR_I18N_PREFIX = 'common.errorMsg'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
@ -150,14 +150,15 @@ const JinaReader: FC<Props> = ({
}) as any
if (res.data) {
const { title, content, description, url } = res.data
const data = {
current: 1,
total: 1,
data: [{
title: res.data.title,
markdown: res.data.content,
description: res.data.description,
source_url: res.data.url,
title,
content,
description,
source_url: url,
}],
time_consuming: (Date.now() - startTime) / 1000,
}
@ -190,7 +191,13 @@ const JinaReader: FC<Props> = ({
return (
<div>
<Header onSetting={handleSetting} />
<Header
onClickConfiguration={handleSetting}
title={t(`${I18N_PREFIX}.jinaReaderTitle`)}
buttonText={t(`${I18N_PREFIX}.configureJinaReader`)}
docTitle={t(`${I18N_PREFIX}.jinaReaderDoc`)}
docLink={'https://jina.ai/reader'}
/>
<div className='mt-2 rounded-xl border border-components-panel-border bg-background-default-subtle p-4 pb-0'>
<UrlInput onRun={handleRun} isRunning={isRunning} />
<OptionsWrap

View file

@ -5,8 +5,8 @@ import { useTranslation } from 'react-i18next'
import s from './index.module.css'
import { Icon3Dots } from '@/app/components/base/icons/src/vender/line/others'
import Button from '@/app/components/base/button'
import { DataSourceProvider } from '@/models/common'
import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config'
import { DataSourceProvider } from '@/models/common'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
@ -21,7 +21,11 @@ const NoData: FC<Props> = ({
}) => {
const { t } = useTranslation()
const providerConfig = {
const providerConfig: Record<DataSourceProvider, {
emoji: React.ReactNode
title: string
description: string
} | null> = {
[DataSourceProvider.jinaReader]: ENABLE_WEBSITE_JINAREADER ? {
emoji: <span className={s.jinaLogo} />,
title: t(`${I18N_PREFIX}.jinaReaderNotConfigured`),
@ -39,7 +43,7 @@ const NoData: FC<Props> = ({
} : null,
}
const currentProvider = providerConfig[provider] || providerConfig[DataSourceProvider.jinaReader]
const currentProvider = providerConfig[provider] || providerConfig.jinareader
if (!currentProvider) return null

View file

@ -32,7 +32,7 @@ const WebsitePreview = ({
<div className='system-xs-medium truncate text-text-tertiary' title={payload.source_url}>{payload.source_url}</div>
</div>
<div className={cn(s.previewContent, 'body-md-regular')}>
<div className={cn(s.fileContent)}>{payload.markdown}</div>
<div className={cn(s.fileContent)}>{payload.content}</div>
</div>
</div>
)

View file

@ -1,43 +0,0 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { RiBookOpenLine, RiEqualizer2Line } from '@remixicon/react'
import Button from '@/app/components/base/button'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
type Props = {
onSetting: () => void
}
const Header: FC<Props> = ({
onSetting,
}) => {
const { t } = useTranslation()
return (
<div className='flex h-6 items-center justify-between'>
<div className='flex items-center'>
<div className='text-base font-medium text-text-secondary'>{t(`${I18N_PREFIX}.watercrawlTitle`)}</div>
<div className='ml-2 mr-2 h-3.5 w-px bg-divider-regular' />
<Button className='flex h-6 items-center gap-x-[1px] px-1.5' onClick={onSetting}>
<RiEqualizer2Line className='h-3.5 w-3.5 text-components-button-secondary-text' />
<span className='px-[3px] text-xs font-medium text-components-button-secondary-text'>
{t(`${I18N_PREFIX}.configureWatercrawl`)}
</span>
</Button>
</div>
<a
href='https://docs.watercrawl.dev/'
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-x-1 text-xs font-medium text-text-accent'
>
<RiBookOpenLine className='h-3.5 w-3.5 text-text-accent' />
<span>{t(`${I18N_PREFIX}.watercrawlDoc`)}</span>
</a>
</div>
)
}
export default React.memo(Header)

View file

@ -7,13 +7,13 @@ import OptionsWrap from '../base/options-wrap'
import CrawledResult from '../base/crawled-result'
import Crawling from '../base/crawling'
import ErrorMessage from '../base/error-message'
import Header from './header'
import Options from './options'
import { useModalContext } from '@/context/modal-context'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import Toast from '@/app/components/base/toast'
import { checkWatercrawlTaskStatus, createWatercrawlTask } from '@/service/datasets'
import { sleep } from '@/utils'
import Header from '../base/header'
const ERROR_I18N_PREFIX = 'common.errorMsg'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
@ -177,7 +177,13 @@ const WaterCrawl: FC<Props> = ({
return (
<div>
<Header onSetting={handleSetting} />
<Header
onClickConfiguration={handleSetting}
title={t(`${I18N_PREFIX}.watercrawlTitle`)}
buttonText={t(`${I18N_PREFIX}.configureWatercrawl`)}
docTitle={t(`${I18N_PREFIX}.watercrawlDoc`)}
docLink={'https://docs.watercrawl.dev/'}
/>
<div className='mt-2 rounded-xl border border-components-panel-border bg-background-default-subtle p-4 pb-0'>
<UrlInput onRun={handleRun} isRunning={isRunning} />
<OptionsWrap

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,357 @@
'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 SimplePieChart from '@/app/components/base/simple-pie-chart'
import { upload } from '@/service/base'
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'
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])
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: File) => {
const completeFile = {
fileID: fileItem.fileID,
file: res,
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) => {
notify({ type: 'error', message: e?.response?.code === 'forbidden' ? e?.response?.message : t('datasetCreation.stepOne.uploader.failed') })
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()
e.target !== dragRef.current && setDragging(true)
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDragLeave = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
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>
{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: 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,173 @@
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'
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: '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,216 @@
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'
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: '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 },
})
}

Some files were not shown because too many files have changed in this diff Show more