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

View File

@@ -0,0 +1,109 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import {
useCSVDownloader,
} from 'react-papaparse'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general'
import { ChunkingMode } from '@/models/datasets'
import I18n from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
const CSV_TEMPLATE_QA_EN = [
['question', 'answer'],
['question1', 'answer1'],
['question2', 'answer2'],
]
const CSV_TEMPLATE_QA_CN = [
['问题', '答案'],
['问题 1', '答案 1'],
['问题 2', '答案 2'],
]
const CSV_TEMPLATE_EN = [
['segment content'],
['content1'],
['content2'],
]
const CSV_TEMPLATE_CN = [
['分段内容'],
['内容 1'],
['内容 2'],
]
const CSVDownload: FC<{ docForm: ChunkingMode }> = ({ docForm }) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const { CSVDownloader, Type } = useCSVDownloader()
const getTemplate = () => {
if (locale === LanguagesSupported[1]) {
if (docForm === ChunkingMode.qa)
return CSV_TEMPLATE_QA_CN
return CSV_TEMPLATE_CN
}
if (docForm === ChunkingMode.qa)
return CSV_TEMPLATE_QA_EN
return CSV_TEMPLATE_EN
}
return (
<div className='mt-6'>
<div className='text-sm font-medium text-text-primary'>{t('share.generation.csvStructureTitle')}</div>
<div className='mt-2 max-h-[500px] overflow-auto'>
{docForm === ChunkingMode.qa && (
<table className='w-full table-fixed border-separate border-spacing-0 rounded-lg border border-divider-subtle text-xs'>
<thead className='text-text-secondary'>
<tr>
<td className='h-9 border-b border-divider-subtle pl-3 pr-2'>{t('datasetDocuments.list.batchModal.question')}</td>
<td className='h-9 border-b border-divider-subtle pl-3 pr-2'>{t('datasetDocuments.list.batchModal.answer')}</td>
</tr>
</thead>
<tbody className='text-text-tertiary'>
<tr>
<td className='h-9 border-b border-divider-subtle pl-3 pr-2 text-[13px]'>{t('datasetDocuments.list.batchModal.question')} 1</td>
<td className='h-9 border-b border-divider-subtle pl-3 pr-2 text-[13px]'>{t('datasetDocuments.list.batchModal.answer')} 1</td>
</tr>
<tr>
<td className='h-9 pl-3 pr-2 text-[13px]'>{t('datasetDocuments.list.batchModal.question')} 2</td>
<td className='h-9 pl-3 pr-2 text-[13px]'>{t('datasetDocuments.list.batchModal.answer')} 2</td>
</tr>
</tbody>
</table>
)}
{docForm === ChunkingMode.text && (
<table className='w-full table-fixed border-separate border-spacing-0 rounded-lg border border-divider-subtle text-xs'>
<thead className='text-text-secondary'>
<tr>
<td className='h-9 border-b border-divider-subtle pl-3 pr-2'>{t('datasetDocuments.list.batchModal.contentTitle')}</td>
</tr>
</thead>
<tbody className='text-text-tertiary'>
<tr>
<td className='h-9 border-b border-divider-subtle pl-3 pr-2 text-[13px]'>{t('datasetDocuments.list.batchModal.content')} 1</td>
</tr>
<tr>
<td className='h-9 pl-3 pr-2 text-[13px]'>{t('datasetDocuments.list.batchModal.content')} 2</td>
</tr>
</tbody>
</table>
)}
</div>
<CSVDownloader
className="mt-2 block cursor-pointer"
type={Type.Link}
filename={'template'}
bom={true}
data={getTemplate()}
>
<div className='flex h-[18px] items-center space-x-1 text-xs font-medium text-text-accent'>
<DownloadIcon className='mr-1 h-3 w-3' />
{t('datasetDocuments.list.batchModal.template')}
</div>
</CSVDownloader>
</div>
)
}
export default React.memo(CSVDownload)

View File

@@ -0,0 +1,245 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
RiDeleteBinLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import cn from '@/utils/classnames'
import { Csv as CSVIcon } from '@/app/components/base/icons/src/public/files'
import { ToastContext } from '@/app/components/base/toast'
import Button from '@/app/components/base/button'
import type { FileItem } from '@/models/datasets'
import { upload } from '@/service/base'
import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils'
import useSWR from 'swr'
import { fetchFileUploadConfig } from '@/service/common'
import SimplePieChart from '@/app/components/base/simple-pie-chart'
import { Theme } from '@/types/app'
import useTheme from '@/hooks/use-theme'
export type Props = {
file: FileItem | undefined
updateFile: (file?: FileItem) => void
}
const CSVUploader: FC<Props> = ({
file,
updateFile,
}) => {
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 { data: fileUploadConfigResponse } = useSWR({ url: '/files/upload' }, fetchFileUploadConfig)
const fileUploadConfig = useMemo(() => fileUploadConfigResponse ?? {
file_size_limit: 15,
}, [fileUploadConfigResponse])
type UploadResult = Awaited<ReturnType<typeof upload>>
const fileUpload = useCallback(async (fileItem: FileItem): Promise<FileItem> => {
fileItem.progress = 0
const formData = new FormData()
formData.append('file', fileItem.file)
const onProgress = (e: ProgressEvent) => {
if (e.lengthComputable) {
const progress = Math.floor(e.loaded / e.total * 100)
updateFile({
...fileItem,
progress,
})
}
}
return upload({
xhr: new XMLHttpRequest(),
data: formData,
onprogress: onProgress,
}, false, undefined, '?source=datasets')
.then((res: UploadResult) => {
const updatedFile = Object.assign({}, fileItem.file, {
id: res.id,
...(res as Partial<File>),
}) as File
const completeFile: FileItem = {
fileID: fileItem.fileID,
file: updatedFile,
progress: 100,
}
updateFile(completeFile)
return Promise.resolve({ ...completeFile })
})
.catch((e) => {
const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t)
notify({ type: 'error', message: errorMessage })
const errorFile = {
...fileItem,
progress: -2,
}
updateFile(errorFile)
return Promise.resolve({ ...errorFile })
})
.finally()
}, [notify, t, updateFile])
const uploadFile = useCallback(async (fileItem: FileItem) => {
await fileUpload(fileItem)
}, [fileUpload])
const initialUpload = useCallback((file?: File) => {
if (!file)
return false
const newFile: FileItem = {
fileID: `file0-${Date.now()}`,
file,
progress: -1,
}
updateFile(newFile)
uploadFile(newFile)
}, [updateFile, uploadFile])
const handleDragEnter = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.target !== dragRef.current)
setDragging(true)
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDragLeave = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.target === dragRef.current)
setDragging(false)
}
const handleDrop = (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
}
initialUpload(files[0])
}
const selectHandle = () => {
if (fileUploader.current)
fileUploader.current.click()
}
const removeFile = () => {
if (fileUploader.current)
fileUploader.current.value = ''
updateFile()
}
const getFileType = (currentFile: File) => {
if (!currentFile)
return ''
const arr = currentFile.name.split('.')
return arr[arr.length - 1]
}
const isValid = useCallback((file?: File) => {
if (!file)
return false
const { size } = file
const ext = `.${getFileType(file)}`
const isValidType = ext.toLowerCase() === '.csv'
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])
const fileChangeHandle = (e: React.ChangeEvent<HTMLInputElement>) => {
const currentFile = e.target.files?.[0]
if (!isValid(currentFile))
return
initialUpload(currentFile)
}
const { theme } = useTheme()
const chartColor = useMemo(() => theme === Theme.dark ? '#5289ff' : '#296dff', [theme])
useEffect(() => {
dropRef.current?.addEventListener('dragenter', handleDragEnter)
dropRef.current?.addEventListener('dragover', handleDragOver)
dropRef.current?.addEventListener('dragleave', handleDragLeave)
dropRef.current?.addEventListener('drop', handleDrop)
return () => {
dropRef.current?.removeEventListener('dragenter', handleDragEnter)
dropRef.current?.removeEventListener('dragover', handleDragOver)
dropRef.current?.removeEventListener('dragleave', handleDragLeave)
dropRef.current?.removeEventListener('drop', handleDrop)
}
}, [])
return (
<div className='mt-6'>
<input
ref={fileUploader}
style={{ display: 'none' }}
type="file"
id="fileUploader"
accept='.csv'
onChange={fileChangeHandle}
/>
<div ref={dropRef}>
{!file && (
<div className={cn('flex h-20 items-center rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur text-sm font-normal', dragging && 'border border-divider-subtle bg-components-panel-on-panel-item-bg-hover')}>
<div className='flex w-full items-center justify-center space-x-2'>
<CSVIcon className="shrink-0" />
<div className='text-text-secondary'>
{t('datasetDocuments.list.batchModal.csvUploadTitle')}
<span className='cursor-pointer text-text-accent' onClick={selectHandle}>{t('datasetDocuments.list.batchModal.browse')}</span>
</div>
</div>
{dragging && <div ref={dragRef} className='absolute left-0 top-0 h-full w-full' />}
</div>
)}
{file && (
<div className={cn('group flex h-20 items-center rounded-xl border border-components-panel-border bg-components-panel-bg-blur px-6 text-sm font-normal', 'hover:border-divider-subtle hover:bg-components-panel-on-panel-item-bg-hover')}>
<CSVIcon className="shrink-0" />
<div className='ml-2 flex w-0 grow'>
<span className='max-w-[calc(100%_-_30px)] overflow-hidden text-ellipsis whitespace-nowrap text-text-primary'>{file.file.name.replace(/.csv$/, '')}</span>
<span className='shrink-0 text-text-secondary'>.csv</span>
</div>
<div className='hidden items-center group-hover:flex'>
{(file.progress < 100 && file.progress >= 0) && (
<>
<SimplePieChart percentage={file.progress} stroke={chartColor} fill={chartColor} animationDuration={0}/>
<div className='mx-2 h-4 w-px bg-text-secondary'/>
</>
)}
<Button onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.change')}</Button>
<div className='mx-2 h-4 w-px bg-text-secondary' />
<div className='cursor-pointer p-2' onClick={removeFile}>
<RiDeleteBinLine className='h-4 w-4 text-text-secondary' />
</div>
</div>
</div>
)}
</div>
</div>
)
}
export default React.memo(CSVUploader)

View File

@@ -0,0 +1,66 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
import CSVUploader from './csv-uploader'
import CSVDownloader from './csv-downloader'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import type { ChunkingMode, FileItem } from '@/models/datasets'
import { noop } from 'lodash-es'
export type IBatchModalProps = {
isShow: boolean
docForm: ChunkingMode
onCancel: () => void
onConfirm: (file: FileItem) => void
}
const BatchModal: FC<IBatchModalProps> = ({
isShow,
docForm,
onCancel,
onConfirm,
}) => {
const { t } = useTranslation()
const [currentCSV, setCurrentCSV] = useState<FileItem>()
const handleFile = (file?: FileItem) => setCurrentCSV(file)
const handleSend = () => {
if (!currentCSV)
return
onCancel()
onConfirm(currentCSV)
}
useEffect(() => {
if (!isShow)
setCurrentCSV(undefined)
}, [isShow])
return (
<Modal isShow={isShow} onClose={noop} className='!max-w-[520px] !rounded-xl px-8 py-6'>
<div className='relative pb-1 text-xl font-medium leading-[30px] text-text-primary'>{t('datasetDocuments.list.batchModal.title')}</div>
<div className='absolute right-4 top-4 cursor-pointer p-2' onClick={onCancel}>
<RiCloseLine className='h-4 w-4 text-text-secondary' />
</div>
<CSVUploader
file={currentCSV}
updateFile={handleFile}
/>
<CSVDownloader
docForm={docForm}
/>
<div className='mt-[28px] flex justify-end pt-6'>
<Button className='mr-2' onClick={onCancel}>
{t('datasetDocuments.list.batchModal.cancel')}
</Button>
<Button variant="primary" onClick={handleSend} disabled={!currentCSV || !currentCSV.file || !currentCSV.file.id}>
{t('datasetDocuments.list.batchModal.run')}
</Button>
</div>
</Modal>
)
}
export default React.memo(BatchModal)

View File

@@ -0,0 +1,132 @@
import React, { type FC, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiCloseLine,
RiCollapseDiagonalLine,
RiExpandDiagonalLine,
} from '@remixicon/react'
import ActionButtons from './common/action-buttons'
import ChunkContent from './common/chunk-content'
import Dot from './common/dot'
import { SegmentIndexTag } from './common/segment-index-tag'
import { useSegmentListContext } from './index'
import type { ChildChunkDetail, ChunkingMode } from '@/models/datasets'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { formatNumber } from '@/utils/format'
import classNames from '@/utils/classnames'
import Divider from '@/app/components/base/divider'
import { formatTime } from '@/utils/time'
type IChildSegmentDetailProps = {
chunkId: string
childChunkInfo?: Partial<ChildChunkDetail> & { id: string }
onUpdate: (segmentId: string, childChunkId: string, content: string) => void
onCancel: () => void
docForm: ChunkingMode
}
/**
* Show all the contents of the segment
*/
const ChildSegmentDetail: FC<IChildSegmentDetailProps> = ({
chunkId,
childChunkInfo,
onUpdate,
onCancel,
docForm,
}) => {
const { t } = useTranslation()
const [content, setContent] = useState(childChunkInfo?.content || '')
const { eventEmitter } = useEventEmitterContextContext()
const [loading, setLoading] = useState(false)
const fullScreen = useSegmentListContext(s => s.fullScreen)
const toggleFullScreen = useSegmentListContext(s => s.toggleFullScreen)
eventEmitter?.useSubscription((v) => {
if (v === 'update-child-segment')
setLoading(true)
if (v === 'update-child-segment-done')
setLoading(false)
})
const handleCancel = () => {
onCancel()
}
const handleSave = () => {
onUpdate(chunkId, childChunkInfo?.id || '', content)
}
const wordCountText = useMemo(() => {
const count = content.length
return `${formatNumber(count)} ${t('datasetDocuments.segment.characters', { count })}`
}, [content.length])
const EditTimeText = useMemo(() => {
const timeText = formatTime({
date: (childChunkInfo?.updated_at ?? 0) * 1000,
dateFormat: `${t('datasetDocuments.segment.dateTimeFormat')}`,
})
return `${t('datasetDocuments.segment.editedAt')} ${timeText}`
}, [childChunkInfo?.updated_at])
return (
<div className={'flex h-full flex-col'}>
<div className={classNames('flex items-center justify-between', fullScreen ? 'border border-divider-subtle py-3 pl-6 pr-4' : 'pl-4 pr-3 pt-3')}>
<div className='flex flex-col'>
<div className='system-xl-semibold text-text-primary'>{t('datasetDocuments.segment.editChildChunk')}</div>
<div className='flex items-center gap-x-2'>
<SegmentIndexTag positionId={childChunkInfo?.position || ''} labelPrefix={t('datasetDocuments.segment.childChunk') as string} />
<Dot />
<span className='system-xs-medium text-text-tertiary'>{wordCountText}</span>
<Dot />
<span className='system-xs-medium text-text-tertiary'>
{EditTimeText}
</span>
</div>
</div>
<div className='flex items-center'>
{fullScreen && (
<>
<ActionButtons
handleCancel={handleCancel}
handleSave={handleSave}
loading={loading}
isChildChunk={true}
/>
<Divider type='vertical' className='ml-4 mr-2 h-3.5 bg-divider-regular' />
</>
)}
<div className='mr-1 flex h-8 w-8 cursor-pointer items-center justify-center p-1.5' onClick={toggleFullScreen}>
{fullScreen ? <RiCollapseDiagonalLine className='h-4 w-4 text-text-tertiary' /> : <RiExpandDiagonalLine className='h-4 w-4 text-text-tertiary' />}
</div>
<div className='flex h-8 w-8 cursor-pointer items-center justify-center p-1.5' onClick={onCancel}>
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
</div>
</div>
</div>
<div className={classNames('flex w-full grow', fullScreen ? 'flex-row justify-center px-6 pt-6' : 'px-4 py-3')}>
<div className={classNames('h-full overflow-hidden whitespace-pre-line break-all', fullScreen ? 'w-1/2' : 'w-full')}>
<ChunkContent
docForm={docForm}
question={content}
onQuestionChange={content => setContent(content)}
isEditMode={true}
/>
</div>
</div>
{!fullScreen && (
<div className='flex items-center justify-end border-t-[1px] border-t-divider-subtle p-4 pt-3'>
<ActionButtons
handleCancel={handleCancel}
handleSave={handleSave}
loading={loading}
isChildChunk={true}
/>
</div>
)}
</div>
)
}
export default React.memo(ChildSegmentDetail)

View File

@@ -0,0 +1,196 @@
import { type FC, useMemo, useState } from 'react'
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { EditSlice } from '../../../formatted-text/flavours/edit-slice'
import { useDocumentContext } from '../context'
import { FormattedText } from '../../../formatted-text/formatted'
import Empty from './common/empty'
import FullDocListSkeleton from './skeleton/full-doc-list-skeleton'
import { useSegmentListContext } from './index'
import type { ChildChunkDetail } from '@/models/datasets'
import Input from '@/app/components/base/input'
import cn from '@/utils/classnames'
import Divider from '@/app/components/base/divider'
import { formatNumber } from '@/utils/format'
type IChildSegmentCardProps = {
childChunks: ChildChunkDetail[]
parentChunkId: string
handleInputChange?: (value: string) => void
handleAddNewChildChunk?: (parentChunkId: string) => void
enabled: boolean
onDelete?: (segId: string, childChunkId: string) => Promise<void>
onClickSlice?: (childChunk: ChildChunkDetail) => void
total?: number
inputValue?: string
onClearFilter?: () => void
isLoading?: boolean
focused?: boolean
}
const ChildSegmentList: FC<IChildSegmentCardProps> = ({
childChunks,
parentChunkId,
handleInputChange,
handleAddNewChildChunk,
enabled,
onDelete,
onClickSlice,
total,
inputValue,
onClearFilter,
isLoading,
focused = false,
}) => {
const { t } = useTranslation()
const parentMode = useDocumentContext(s => s.parentMode)
const currChildChunk = useSegmentListContext(s => s.currChildChunk)
const [collapsed, setCollapsed] = useState(true)
const toggleCollapse = () => {
setCollapsed(!collapsed)
}
const isParagraphMode = useMemo(() => {
return parentMode === 'paragraph'
}, [parentMode])
const isFullDocMode = useMemo(() => {
return parentMode === 'full-doc'
}, [parentMode])
const contentOpacity = useMemo(() => {
return (enabled || focused) ? '' : 'opacity-50 group-hover/card:opacity-100'
}, [enabled, focused])
const totalText = useMemo(() => {
const isSearch = inputValue !== '' && isFullDocMode
if (!isSearch) {
const text = isFullDocMode
? !total
? '--'
: formatNumber(total)
: formatNumber(childChunks.length)
const count = isFullDocMode
? text === '--'
? 0
: total
: childChunks.length
return `${text} ${t('datasetDocuments.segment.childChunks', { count })}`
}
else {
const text = !total ? '--' : formatNumber(total)
const count = text === '--' ? 0 : total
return `${count} ${t('datasetDocuments.segment.searchResults', { count })}`
}
}, [isFullDocMode, total, childChunks.length, inputValue])
return (
<div className={cn(
'flex flex-col',
contentOpacity,
isParagraphMode ? 'pb-2 pt-1' : 'grow px-3',
(isFullDocMode && isLoading) && 'overflow-y-hidden',
)}>
{isFullDocMode ? <Divider type='horizontal' className='my-1 h-px bg-divider-subtle' /> : null}
<div className={cn('flex items-center justify-between', isFullDocMode ? 'sticky -top-2 left-0 bg-background-default pb-3 pt-2' : '')}>
<div
className={cn(
'flex h-7 items-center rounded-lg pl-1 pr-3',
isParagraphMode && 'cursor-pointer',
(isParagraphMode && collapsed) && 'bg-dataset-child-chunk-expand-btn-bg',
isFullDocMode && 'pl-0',
)}
onClick={(event) => {
event.stopPropagation()
toggleCollapse()
}}
>
{
isParagraphMode
? collapsed
? (
<RiArrowRightSLine className='mr-0.5 h-4 w-4 text-text-secondary opacity-50' />
)
: (<RiArrowDownSLine className='mr-0.5 h-4 w-4 text-text-secondary' />)
: null
}
<span className='system-sm-semibold-uppercase text-text-secondary'>{totalText}</span>
<span className={cn('pl-1.5 text-xs font-medium text-text-quaternary', isParagraphMode ? 'hidden group-hover/card:inline-block' : '')}>·</span>
<button
type='button'
className={cn(
'system-xs-semibold-uppercase px-1.5 py-1 text-components-button-secondary-accent-text',
isParagraphMode ? 'hidden group-hover/card:inline-block' : '',
(isFullDocMode && isLoading) ? 'text-components-button-secondary-accent-text-disabled' : '',
)}
onClick={(event) => {
event.stopPropagation()
handleAddNewChildChunk?.(parentChunkId)
}}
disabled={isLoading}
>
{t('common.operation.add')}
</button>
</div>
{isFullDocMode
? <Input
showLeftIcon
showClearIcon
wrapperClassName='!w-52'
value={inputValue}
onChange={e => handleInputChange?.(e.target.value)}
onClear={() => handleInputChange?.('')}
/>
: null}
</div>
{isLoading ? <FullDocListSkeleton /> : null}
{((isFullDocMode && !isLoading) || !collapsed)
? <div className={cn('flex gap-x-0.5', isFullDocMode ? 'mb-6 grow' : 'items-center')}>
{isParagraphMode && (
<div className='self-stretch'>
<Divider type='vertical' className='mx-[7px] w-[2px] bg-text-accent-secondary' />
</div>
)}
{childChunks.length > 0
? <FormattedText className={cn('flex w-full flex-col !leading-6', isParagraphMode ? 'gap-y-2' : 'gap-y-3')}>
{childChunks.map((childChunk) => {
const edited = childChunk.updated_at !== childChunk.created_at
const focused = currChildChunk?.childChunkInfo?.id === childChunk.id
return <EditSlice
key={childChunk.id}
label={`C-${childChunk.position}${edited ? ` · ${t('datasetDocuments.segment.edited')}` : ''}`}
text={childChunk.content}
onDelete={() => onDelete?.(childChunk.segment_id, childChunk.id)}
className='child-chunk'
labelClassName={focused ? 'bg-state-accent-solid text-text-primary-on-surface' : ''}
labelInnerClassName={'text-[10px] font-semibold align-bottom leading-6'}
contentClassName={cn('!leading-6', focused ? 'bg-state-accent-hover-alt text-text-primary' : 'text-text-secondary')}
showDivider={false}
onClick={(e) => {
e.stopPropagation()
onClickSlice?.(childChunk)
}}
offsetOptions={({ rects }) => {
return {
mainAxis: isFullDocMode ? -rects.floating.width : 12 - rects.floating.width,
crossAxis: (20 - rects.floating.height) / 2,
}
}}
/>
})}
</FormattedText>
: inputValue !== ''
? <div className='h-full w-full'>
<Empty onClearFilter={onClearFilter!} />
</div>
: null
}
</div>
: null}
</div>
)
}
export default ChildSegmentList

View File

@@ -0,0 +1,87 @@
import React, { type FC, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useKeyPress } from 'ahooks'
import { useDocumentContext } from '../../context'
import Button from '@/app/components/base/button'
import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
import { ChunkingMode } from '@/models/datasets'
type IActionButtonsProps = {
handleCancel: () => void
handleSave: () => void
loading: boolean
actionType?: 'edit' | 'add'
handleRegeneration?: () => void
isChildChunk?: boolean
}
const ActionButtons: FC<IActionButtonsProps> = ({
handleCancel,
handleSave,
loading,
actionType = 'edit',
handleRegeneration,
isChildChunk = false,
}) => {
const { t } = useTranslation()
const docForm = useDocumentContext(s => s.docForm)
const parentMode = useDocumentContext(s => s.parentMode)
useKeyPress(['esc'], (e) => {
e.preventDefault()
handleCancel()
})
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.s`, (e) => {
e.preventDefault()
if (loading)
return
handleSave()
},
{ exactMatch: true, useCapture: true })
const isParentChildParagraphMode = useMemo(() => {
return docForm === ChunkingMode.parentChild && parentMode === 'paragraph'
}, [docForm, parentMode])
return (
<div className='flex items-center gap-x-2'>
<Button
onClick={handleCancel}
>
<div className='flex items-center gap-x-1'>
<span className='system-sm-medium text-components-button-secondary-text'>{t('common.operation.cancel')}</span>
<span className='system-kbd rounded-[4px] bg-components-kbd-bg-gray px-[1px] text-text-tertiary'>ESC</span>
</div>
</Button>
{(isParentChildParagraphMode && actionType === 'edit' && !isChildChunk)
? <Button
onClick={handleRegeneration}
disabled={loading}
>
<span className='system-sm-medium text-components-button-secondary-text'>
{t('common.operation.saveAndRegenerate')}
</span>
</Button>
: null
}
<Button
variant='primary'
onClick={handleSave}
disabled={loading}
>
<div className='flex items-center gap-x-1'>
<span className='text-components-button-primary-text'>{t('common.operation.save')}</span>
<div className='flex items-center gap-x-0.5'>
<span className='system-kbd h-4 w-4 rounded-[4px] bg-components-kbd-bg-white capitalize text-text-primary-on-surface'>{getKeyboardKeyNameBySystem('ctrl')}</span>
<span className='system-kbd h-4 w-4 rounded-[4px] bg-components-kbd-bg-white text-text-primary-on-surface'>S</span>
</div>
</div>
</Button>
</div>
)
}
ActionButtons.displayName = 'ActionButtons'
export default React.memo(ActionButtons)

View File

@@ -0,0 +1,32 @@
import React, { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import classNames from '@/utils/classnames'
import Checkbox from '@/app/components/base/checkbox'
type AddAnotherProps = {
className?: string
isChecked: boolean
onCheck: () => void
}
const AddAnother: FC<AddAnotherProps> = ({
className,
isChecked,
onCheck,
}) => {
const { t } = useTranslation()
return (
<div className={classNames('flex items-center gap-x-1 pl-1', className)}>
<Checkbox
key='add-another-checkbox'
className='shrink-0'
checked={isChecked}
onCheck={onCheck}
/>
<span className='system-xs-medium text-text-tertiary'>{t('datasetDocuments.segment.addAnother')}</span>
</div>
)
}
export default React.memo(AddAnother)

View File

@@ -0,0 +1,130 @@
import React, { type FC } from 'react'
import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLine, RiDraftLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import Divider from '@/app/components/base/divider'
import cn from '@/utils/classnames'
import Confirm from '@/app/components/base/confirm'
import Button from '@/app/components/base/button'
const i18nPrefix = 'dataset.batchAction'
type IBatchActionProps = {
className?: string
selectedIds: string[]
onBatchEnable: () => void
onBatchDisable: () => void
onBatchDelete: () => Promise<void>
onArchive?: () => void
onEditMetadata?: () => void
onCancel: () => void
}
const BatchAction: FC<IBatchActionProps> = ({
className,
selectedIds,
onBatchEnable,
onBatchDisable,
onArchive,
onBatchDelete,
onEditMetadata,
onCancel,
}) => {
const { t } = useTranslation()
const [isShowDeleteConfirm, {
setTrue: showDeleteConfirm,
setFalse: hideDeleteConfirm,
}] = useBoolean(false)
const [isDeleting, {
setTrue: setIsDeleting,
}] = useBoolean(false)
const handleBatchDelete = async () => {
setIsDeleting()
await onBatchDelete()
hideDeleteConfirm()
}
return (
<div className={cn('pointer-events-none flex w-full justify-center gap-x-2', className)}>
<div className='pointer-events-auto flex items-center gap-x-1 rounded-[10px] border border-components-actionbar-border-accent bg-components-actionbar-bg-accent p-1 shadow-xl shadow-shadow-shadow-5'>
<div className='inline-flex items-center gap-x-2 py-1 pl-2 pr-3'>
<span className='system-xs-medium flex h-5 w-5 items-center justify-center rounded-md bg-text-accent text-text-primary-on-surface'>
{selectedIds.length}
</span>
<span className='system-sm-semibold text-text-accent'>{t(`${i18nPrefix}.selected`)}</span>
</div>
<Divider type='vertical' className='mx-0.5 h-3.5 bg-divider-regular' />
<Button
variant='ghost'
className='gap-x-0.5 px-3'
onClick={onBatchEnable}
>
<RiCheckboxCircleLine className='size-4' />
<span className='px-0.5'>{t(`${i18nPrefix}.enable`)}</span>
</Button>
<Button
variant='ghost'
className='gap-x-0.5 px-3'
onClick={onBatchDisable}
>
<RiCloseCircleLine className='size-4' />
<span className='px-0.5'>{t(`${i18nPrefix}.disable`)}</span>
</Button>
{onEditMetadata && (
<Button
variant='ghost'
className='gap-x-0.5 px-3'
onClick={onEditMetadata}
>
<RiDraftLine className='size-4' />
<span className='px-0.5'>{t('dataset.metadata.metadata')}</span>
</Button>
)}
{onArchive && (
<Button
variant='ghost'
className='gap-x-0.5 px-3'
onClick={onArchive}
>
<RiArchive2Line className='size-4' />
<span className='px-0.5'>{t(`${i18nPrefix}.archive`)}</span>
</Button>
)}
<Button
variant='ghost'
destructive
className='gap-x-0.5 px-3'
onClick={showDeleteConfirm}
>
<RiDeleteBinLine className='size-4' />
<span className='px-0.5'>{t(`${i18nPrefix}.delete`)}</span>
</Button>
<Divider type='vertical' className='mx-0.5 h-3.5 bg-divider-regular' />
<Button
variant='ghost'
className='px-3'
onClick={onCancel}
>
<span className='px-0.5'>{t(`${i18nPrefix}.cancel`)}</span>
</Button>
</div>
{
isShowDeleteConfirm && (
<Confirm
isShow
title={t('datasetDocuments.list.delete.title')}
content={t('datasetDocuments.list.delete.content')}
confirmText={t('common.operation.sure')}
onConfirm={handleBatchDelete}
onCancel={hideDeleteConfirm}
isLoading={isDeleting}
isDisabled={isDeleting}
/>
)
}
</div>
)
}
export default React.memo(BatchAction)

View File

@@ -0,0 +1,203 @@
import React, { useEffect, useRef, useState } from 'react'
import type { ComponentProps, FC } from 'react'
import { useTranslation } from 'react-i18next'
import { ChunkingMode } from '@/models/datasets'
import classNames from '@/utils/classnames'
import { Markdown } from '@/app/components/base/markdown'
type IContentProps = ComponentProps<'textarea'>
const Textarea: FC<IContentProps> = React.memo(({
value,
placeholder,
className,
disabled,
...rest
}) => {
return (
<textarea
className={classNames(
'inset-0 w-full resize-none appearance-none overflow-y-auto border-none bg-transparent outline-none',
className,
)}
placeholder={placeholder}
value={value}
disabled={disabled}
{...rest}
/>
)
})
Textarea.displayName = 'Textarea'
type IAutoResizeTextAreaProps = ComponentProps<'textarea'> & {
containerRef: React.RefObject<HTMLDivElement | null>
labelRef: React.RefObject<HTMLDivElement | null>
}
const AutoResizeTextArea: FC<IAutoResizeTextAreaProps> = React.memo(({
className,
placeholder,
value,
disabled,
containerRef,
labelRef,
...rest
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const observerRef = useRef<ResizeObserver>(null)
const [maxHeight, setMaxHeight] = useState(0)
useEffect(() => {
const textarea = textareaRef.current
if (!textarea)
return
textarea.style.height = 'auto'
const lineHeight = Number.parseInt(getComputedStyle(textarea).lineHeight)
const textareaHeight = Math.max(textarea.scrollHeight, lineHeight)
textarea.style.height = `${textareaHeight}px`
}, [value])
useEffect(() => {
const container = containerRef.current
const label = labelRef.current
if (!container || !label)
return
const updateMaxHeight = () => {
const containerHeight = container.clientHeight
const labelHeight = label.clientHeight
const padding = 32
const space = 12
const maxHeight = Math.floor((containerHeight - 2 * labelHeight - padding - space) / 2)
setMaxHeight(maxHeight)
}
updateMaxHeight()
observerRef.current = new ResizeObserver(updateMaxHeight)
observerRef.current.observe(container)
return () => {
observerRef.current?.disconnect()
}
}, [])
return (
<textarea
ref={textareaRef}
className={classNames(
'inset-0 w-full resize-none appearance-none border-none bg-transparent outline-none',
className,
)}
style={{
maxHeight,
}}
placeholder={placeholder}
value={value}
disabled={disabled}
{...rest}
/>
)
})
AutoResizeTextArea.displayName = 'AutoResizeTextArea'
type IQATextAreaProps = {
question: string
answer?: string
onQuestionChange: (question: string) => void
onAnswerChange?: (answer: string) => void
isEditMode?: boolean
}
const QATextArea: FC<IQATextAreaProps> = React.memo(({
question,
answer,
onQuestionChange,
onAnswerChange,
isEditMode = true,
}) => {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
const labelRef = useRef<HTMLDivElement>(null)
return (
<div ref={containerRef} className='h-full overflow-hidden'>
<div ref={labelRef} className='mb-1 text-xs font-medium text-text-tertiary'>QUESTION</div>
<AutoResizeTextArea
className='text-sm tracking-[-0.07px] text-text-secondary caret-[#295EFF]'
value={question}
placeholder={t('datasetDocuments.segment.questionPlaceholder') || ''}
onChange={e => onQuestionChange(e.target.value)}
disabled={!isEditMode}
containerRef={containerRef}
labelRef={labelRef}
/>
<div className='mb-1 mt-6 text-xs font-medium text-text-tertiary'>ANSWER</div>
<AutoResizeTextArea
className='text-sm tracking-[-0.07px] text-text-secondary caret-[#295EFF]'
value={answer}
placeholder={t('datasetDocuments.segment.answerPlaceholder') || ''}
onChange={e => onAnswerChange?.(e.target.value)}
disabled={!isEditMode}
autoFocus
containerRef={containerRef}
labelRef={labelRef}
/>
</div>
)
})
QATextArea.displayName = 'QATextArea'
type IChunkContentProps = {
question: string
answer?: string
onQuestionChange: (question: string) => void
onAnswerChange?: (answer: string) => void
isEditMode?: boolean
docForm: ChunkingMode
}
const ChunkContent: FC<IChunkContentProps> = ({
question,
answer,
onQuestionChange,
onAnswerChange,
isEditMode,
docForm,
}) => {
const { t } = useTranslation()
if (docForm === ChunkingMode.qa) {
return <QATextArea
question={question}
answer={answer}
onQuestionChange={onQuestionChange}
onAnswerChange={onAnswerChange}
isEditMode={isEditMode}
/>
}
if (!isEditMode) {
return (
<Markdown
className='h-full w-full !text-text-secondary'
content={question}
customDisallowedElements={['input']}
/>
)
}
return (
<Textarea
className='body-md-regular h-full w-full pb-6 tracking-[-0.07px] text-text-secondary caret-[#295EFF]'
value={question}
placeholder={t('datasetDocuments.segment.contentPlaceholder') || ''}
onChange={e => onQuestionChange(e.target.value)}
disabled={!isEditMode}
autoFocus
/>
)
}
ChunkContent.displayName = 'ChunkContent'
export default React.memo(ChunkContent)

View File

@@ -0,0 +1,11 @@
import React from 'react'
const Dot = () => {
return (
<div className='system-xs-medium text-text-quaternary'>·</div>
)
}
Dot.displayName = 'Dot'
export default React.memo(Dot)

View File

@@ -0,0 +1,111 @@
import React, { useCallback, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import cn from '@/utils/classnames'
import { useKeyPress } from 'ahooks'
import { useSegmentListContext } from '..'
type DrawerProps = {
open: boolean
onClose: () => void
side?: 'right' | 'left' | 'bottom' | 'top'
showOverlay?: boolean
modal?: boolean // click outside event can pass through if modal is false
closeOnOutsideClick?: boolean
panelClassName?: string
panelContentClassName?: string
needCheckChunks?: boolean
}
const Drawer = ({
open,
onClose,
side = 'right',
showOverlay = true,
modal = false,
needCheckChunks = false,
children,
panelClassName,
panelContentClassName,
}: React.PropsWithChildren<DrawerProps>) => {
const panelContentRef = useRef<HTMLDivElement>(null)
const currSegment = useSegmentListContext(s => s.currSegment)
const currChildChunk = useSegmentListContext(s => s.currChildChunk)
useKeyPress('esc', (e) => {
if (!open) return
e.preventDefault()
onClose()
}, { exactMatch: true, useCapture: true })
const shouldCloseDrawer = useCallback((target: Node | null) => {
const panelContent = panelContentRef.current
if (!panelContent) return false
const chunks = document.querySelectorAll('.chunk-card')
const childChunks = document.querySelectorAll('.child-chunk')
const isClickOnChunk = Array.from(chunks).some((chunk) => {
return chunk && chunk.contains(target)
})
const isClickOnChildChunk = Array.from(childChunks).some((chunk) => {
return chunk && chunk.contains(target)
})
const reopenChunkDetail = (currSegment.showModal && isClickOnChildChunk)
|| (currChildChunk.showModal && isClickOnChunk && !isClickOnChildChunk) || (!isClickOnChunk && !isClickOnChildChunk)
return target && !panelContent.contains(target) && (!needCheckChunks || reopenChunkDetail)
}, [currSegment, currChildChunk, needCheckChunks])
const onDownCapture = useCallback((e: PointerEvent) => {
if (!open || modal) return
const panelContent = panelContentRef.current
if (!panelContent) return
const target = e.target as Node | null
if (shouldCloseDrawer(target))
queueMicrotask(onClose)
}, [shouldCloseDrawer, onClose, open, modal])
useEffect(() => {
window.addEventListener('pointerdown', onDownCapture, { capture: true })
return () =>
window.removeEventListener('pointerdown', onDownCapture, { capture: true })
}, [onDownCapture])
const isHorizontal = side === 'left' || side === 'right'
const content = (
<div className='pointer-events-none fixed inset-0 z-[9999]'>
{showOverlay ? (
<div
onClick={modal ? onClose : undefined}
aria-hidden='true'
className={cn(
'fixed inset-0 bg-black/30 opacity-0 transition-opacity duration-200 ease-in',
open && 'opacity-100',
modal && open ? 'pointer-events-auto' : 'pointer-events-none',
)}
/>
) : null}
{/* Drawer panel */}
<div
role='dialog'
aria-modal={modal ? 'true' : 'false'}
className={cn(
'pointer-events-auto fixed flex flex-col',
side === 'right' && 'right-0',
side === 'left' && 'left-0',
side === 'bottom' && 'bottom-0',
side === 'top' && 'top-0',
isHorizontal ? 'h-screen' : 'w-screen',
panelClassName,
)}
>
<div ref={panelContentRef} className={cn('flex grow flex-col', panelContentClassName)}>
{children}
</div>
</div>
</div>
)
return open && createPortal(content, document.body)
}
export default Drawer

View File

@@ -0,0 +1,78 @@
import React, { type FC } from 'react'
import { RiFileList2Line } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
type IEmptyProps = {
onClearFilter: () => void
}
const EmptyCard = React.memo(() => {
return (
<div className='h-32 w-full shrink-0 rounded-xl bg-background-section-burn opacity-30' />
)
})
EmptyCard.displayName = 'EmptyCard'
type LineProps = {
className?: string
}
const Line = React.memo(({
className,
}: LineProps) => {
return (
<svg xmlns='http://www.w3.org/2000/svg' width='2' height='241' viewBox='0 0 2 241' fill='none' className={className}>
<path d='M1 0.5L1 240.5' stroke='url(#paint0_linear_1989_74474)' />
<defs>
<linearGradient id='paint0_linear_1989_74474' x1='-7.99584' y1='240.5' x2='-7.88094' y2='0.50004' gradientUnits='userSpaceOnUse'>
<stop stopColor='white' stopOpacity='0.01' />
<stop offset='0.503965' stopColor='#101828' stopOpacity='0.08' />
<stop offset='1' stopColor='white' stopOpacity='0.01' />
</linearGradient>
</defs>
</svg>
)
})
Line.displayName = 'Line'
const Empty: FC<IEmptyProps> = ({
onClearFilter,
}) => {
const { t } = useTranslation()
return (
<div className={'relative z-0 flex h-full items-center justify-center'}>
<div className='flex flex-col items-center'>
<div className='relative z-10 flex h-14 w-14 items-center justify-center rounded-xl border border-divider-subtle bg-components-card-bg shadow-lg shadow-shadow-shadow-5'>
<RiFileList2Line className='h-6 w-6 text-text-secondary' />
<Line className='absolute -right-px top-1/2 -translate-y-1/2' />
<Line className='absolute -left-px top-1/2 -translate-y-1/2' />
<Line className='absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 rotate-90' />
<Line className='absolute left-1/2 top-full -translate-x-1/2 -translate-y-1/2 rotate-90' />
</div>
<div className='system-md-regular mt-3 text-text-tertiary'>
{t('datasetDocuments.segment.empty')}
</div>
<button
type='button'
className='system-sm-medium mt-1 text-text-accent'
onClick={onClearFilter}
>
{t('datasetDocuments.segment.clearFilter')}
</button>
</div>
<div className='absolute left-0 top-0 -z-20 flex h-full w-full flex-col gap-y-3 overflow-hidden'>
{
Array.from({ length: 10 }).map((_, i) => (
<EmptyCard key={i} />
))
}
</div>
<div className='absolute left-0 top-0 -z-10 h-full w-full bg-dataset-chunk-list-mask-bg' />
</div>
)
}
export default React.memo(Empty)

View File

@@ -0,0 +1,45 @@
import React from 'react'
import Drawer from './drawer'
import cn from '@/utils/classnames'
import { noop } from 'lodash-es'
type IFullScreenDrawerProps = {
isOpen: boolean
onClose?: () => void
fullScreen: boolean
showOverlay?: boolean
needCheckChunks?: boolean
modal?: boolean
}
const FullScreenDrawer = ({
isOpen,
onClose = noop,
fullScreen,
children,
showOverlay = true,
needCheckChunks = false,
modal = false,
}: React.PropsWithChildren<IFullScreenDrawerProps>) => {
return (
<Drawer
open={isOpen}
onClose={onClose}
panelClassName={cn(
fullScreen
? 'w-full'
: 'w-[560px] pb-2 pr-2 pt-16',
)}
panelContentClassName={cn(
'bg-components-panel-bg',
!fullScreen && 'rounded-xl border-[0.5px] border-components-panel-border',
)}
showOverlay={showOverlay}
needCheckChunks={needCheckChunks}
modal={modal}
>
{children}
</Drawer>)
}
export default FullScreenDrawer

View File

@@ -0,0 +1,47 @@
import React, { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import classNames from '@/utils/classnames'
import type { SegmentDetailModel } from '@/models/datasets'
import TagInput from '@/app/components/base/tag-input'
type IKeywordsProps = {
segInfo?: Partial<SegmentDetailModel> & { id: string }
className?: string
keywords: string[]
onKeywordsChange: (keywords: string[]) => void
isEditMode?: boolean
actionType?: 'edit' | 'add' | 'view'
}
const Keywords: FC<IKeywordsProps> = ({
segInfo,
className,
keywords,
onKeywordsChange,
isEditMode,
actionType = 'view',
}) => {
const { t } = useTranslation()
return (
<div className={classNames('flex flex-col', className)}>
<div className='system-xs-medium-uppercase text-text-tertiary'>{t('datasetDocuments.segment.keywords')}</div>
<div className='flex max-h-[200px] w-full flex-wrap gap-1 overflow-auto text-text-tertiary'>
{(!segInfo?.keywords?.length && actionType === 'view')
? '-'
: (
<TagInput
items={keywords}
onChange={newKeywords => onKeywordsChange(newKeywords)}
disableAdd={!isEditMode}
disableRemove={!isEditMode || (keywords.length === 1)}
/>
)
}
</div>
</div>
)
}
Keywords.displayName = 'Keywords'
export default React.memo(Keywords)

View File

@@ -0,0 +1,132 @@
import React, { type FC, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiLoader2Line } from '@remixicon/react'
import { useCountDown } from 'ahooks'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { noop } from 'lodash-es'
type IDefaultContentProps = {
onCancel: () => void
onConfirm: () => void
}
const DefaultContent: FC<IDefaultContentProps> = React.memo(({
onCancel,
onConfirm,
}) => {
const { t } = useTranslation()
return (
<>
<div className='pb-4'>
<span className='title-2xl-semi-bold text-text-primary'>{t('datasetDocuments.segment.regenerationConfirmTitle')}</span>
<p className='system-md-regular text-text-secondary'>{t('datasetDocuments.segment.regenerationConfirmMessage')}</p>
</div>
<div className='flex justify-end gap-x-2 pt-6'>
<Button onClick={onCancel}>
{t('common.operation.cancel')}
</Button>
<Button variant='warning' destructive onClick={onConfirm}>
{t('common.operation.regenerate')}
</Button>
</div>
</>
)
})
DefaultContent.displayName = 'DefaultContent'
const RegeneratingContent: FC = React.memo(() => {
const { t } = useTranslation()
return (
<>
<div className='pb-4'>
<span className='title-2xl-semi-bold text-text-primary'>{t('datasetDocuments.segment.regeneratingTitle')}</span>
<p className='system-md-regular text-text-secondary'>{t('datasetDocuments.segment.regeneratingMessage')}</p>
</div>
<div className='flex justify-end pt-6'>
<Button variant='warning' destructive disabled className='inline-flex items-center gap-x-0.5'>
<RiLoader2Line className='h-4 w-4 animate-spin text-components-button-destructive-primary-text-disabled' />
<span>{t('common.operation.regenerate')}</span>
</Button>
</div>
</>
)
})
RegeneratingContent.displayName = 'RegeneratingContent'
type IRegenerationCompletedContentProps = {
onClose: () => void
}
const RegenerationCompletedContent: FC<IRegenerationCompletedContentProps> = React.memo(({
onClose,
}) => {
const { t } = useTranslation()
const targetTime = useRef(Date.now() + 5000)
const [countdown] = useCountDown({
targetDate: targetTime.current,
onEnd: () => {
onClose()
},
})
return (
<>
<div className='pb-4'>
<span className='title-2xl-semi-bold text-text-primary'>{t('datasetDocuments.segment.regenerationSuccessTitle')}</span>
<p className='system-md-regular text-text-secondary'>{t('datasetDocuments.segment.regenerationSuccessMessage')}</p>
</div>
<div className='flex justify-end pt-6'>
<Button variant='primary' onClick={onClose}>
{`${t('common.operation.close')}${countdown === 0 ? '' : `(${Math.round(countdown / 1000)})`}`}
</Button>
</div>
</>
)
})
RegenerationCompletedContent.displayName = 'RegenerationCompletedContent'
type IRegenerationModalProps = {
isShow: boolean
onConfirm: () => void
onCancel: () => void
onClose: () => void
}
const RegenerationModal: FC<IRegenerationModalProps> = ({
isShow,
onConfirm,
onCancel,
onClose,
}) => {
const [loading, setLoading] = useState(false)
const [updateSucceeded, setUpdateSucceeded] = useState(false)
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription((v) => {
if (v === 'update-segment') {
setLoading(true)
setUpdateSucceeded(false)
}
if (v === 'update-segment-success')
setUpdateSucceeded(true)
if (v === 'update-segment-done')
setLoading(false)
})
return (
<Modal isShow={isShow} onClose={noop} className='!max-w-[480px] !rounded-2xl' wrapperClassName='!z-[10000]'>
{!loading && !updateSucceeded && <DefaultContent onCancel={onCancel} onConfirm={onConfirm} />}
{loading && !updateSucceeded && <RegeneratingContent />}
{!loading && updateSucceeded && <RegenerationCompletedContent onClose={onClose} />}
</Modal>
)
}
export default RegenerationModal

View File

@@ -0,0 +1,40 @@
import React, { type FC, useMemo } from 'react'
import { Chunk } from '@/app/components/base/icons/src/vender/knowledge'
import cn from '@/utils/classnames'
type ISegmentIndexTagProps = {
positionId?: string | number
label?: string
className?: string
labelPrefix?: string
iconClassName?: string
labelClassName?: string
}
export const SegmentIndexTag: FC<ISegmentIndexTagProps> = ({
positionId,
label,
className,
labelPrefix = 'Chunk',
iconClassName,
labelClassName,
}) => {
const localPositionId = useMemo(() => {
const positionIdStr = String(positionId)
if (positionIdStr.length >= 2)
return `${labelPrefix}-${positionId}`
return `${labelPrefix}-${positionIdStr.padStart(2, '0')}`
}, [positionId, labelPrefix])
return (
<div className={cn('flex items-center', className)}>
<Chunk className={cn('mr-0.5 h-3 w-3 p-[1px] text-text-tertiary', iconClassName)} />
<div className={cn('system-xs-medium text-text-tertiary', labelClassName)}>
{label || localPositionId}
</div>
</div>
)
}
SegmentIndexTag.displayName = 'SegmentIndexTag'
export default React.memo(SegmentIndexTag)

View File

@@ -0,0 +1,15 @@
import React from 'react'
import cn from '@/utils/classnames'
const Tag = ({ text, className }: { text: string; className?: string }) => {
return (
<div className={cn('inline-flex items-center gap-x-0.5', className)}>
<span className='text-xs font-medium text-text-quaternary'>#</span>
<span className='max-w-12 shrink-0 truncate text-xs text-text-tertiary'>{text}</span>
</div>
)
}
Tag.displayName = 'Tag'
export default React.memo(Tag)

View File

@@ -0,0 +1,40 @@
import React, { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import { RiLineHeight } from '@remixicon/react'
import Tooltip from '@/app/components/base/tooltip'
import { Collapse } from '@/app/components/base/icons/src/vender/knowledge'
type DisplayToggleProps = {
isCollapsed: boolean
toggleCollapsed: () => void
}
const DisplayToggle: FC<DisplayToggleProps> = ({
isCollapsed,
toggleCollapsed,
}) => {
const { t } = useTranslation()
return (
<Tooltip
popupContent={isCollapsed ? t('datasetDocuments.segment.expandChunks') : t('datasetDocuments.segment.collapseChunks')}
popupClassName='text-text-secondary system-xs-medium border-[0.5px] border-components-panel-border'
>
<button
type='button'
className='flex items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border
bg-components-button-secondary-bg p-2 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]'
onClick={toggleCollapsed}
>
{
isCollapsed
? <RiLineHeight className='h-4 w-4 text-components-button-secondary-text' />
: <Collapse className='h-4 w-4 text-components-button-secondary-text' />
}
</button>
</Tooltip>
)
}
export default React.memo(DisplayToggle)

View File

@@ -0,0 +1,745 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useDebounceFn } from 'ahooks'
import { useTranslation } from 'react-i18next'
import { createContext, useContext, useContextSelector } from 'use-context-selector'
import { usePathname } from 'next/navigation'
import { useDocumentContext } from '../context'
import { ProcessStatus } from '../segment-add'
import s from './style.module.css'
import SegmentList from './segment-list'
import DisplayToggle from './display-toggle'
import BatchAction from './common/batch-action'
import SegmentDetail from './segment-detail'
import SegmentCard from './segment-card'
import ChildSegmentList from './child-segment-list'
import NewChildSegment from './new-child-segment'
import FullScreenDrawer from './common/full-screen-drawer'
import ChildSegmentDetail from './child-segment-detail'
import StatusItem from './status-item'
import Pagination from '@/app/components/base/pagination'
import cn from '@/utils/classnames'
import { formatNumber } from '@/utils/format'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import { ToastContext } from '@/app/components/base/toast'
import type { Item } from '@/app/components/base/select'
import { SimpleSelect } from '@/app/components/base/select'
import { type ChildChunkDetail, ChunkingMode, type SegmentDetailModel, type SegmentUpdater } from '@/models/datasets'
import NewSegment from '@/app/components/datasets/documents/detail/new-segment'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import Checkbox from '@/app/components/base/checkbox'
import {
useChildSegmentList,
useChildSegmentListKey,
useChunkListAllKey,
useChunkListDisabledKey,
useChunkListEnabledKey,
useDeleteChildSegment,
useDeleteSegment,
useDisableSegment,
useEnableSegment,
useSegmentList,
useSegmentListKey,
useUpdateChildSegment,
useUpdateSegment,
} from '@/service/knowledge/use-segment'
import { useInvalid } from '@/service/use-base'
import { noop } from 'lodash-es'
const DEFAULT_LIMIT = 10
type CurrSegmentType = {
segInfo?: SegmentDetailModel
showModal: boolean
isEditMode?: boolean
}
type CurrChildChunkType = {
childChunkInfo?: ChildChunkDetail
showModal: boolean
}
type SegmentListContextValue = {
isCollapsed: boolean
fullScreen: boolean
toggleFullScreen: (fullscreen?: boolean) => void
currSegment: CurrSegmentType
currChildChunk: CurrChildChunkType
}
const SegmentListContext = createContext<SegmentListContextValue>({
isCollapsed: true,
fullScreen: false,
toggleFullScreen: noop,
currSegment: { showModal: false },
currChildChunk: { showModal: false },
})
export const useSegmentListContext = (selector: (value: SegmentListContextValue) => any) => {
return useContextSelector(SegmentListContext, selector)
}
type ICompletedProps = {
embeddingAvailable: boolean
showNewSegmentModal: boolean
onNewSegmentModalChange: (state: boolean) => void
importStatus: ProcessStatus | string | undefined
archived?: boolean
}
/**
* Embedding done, show list of all segments
* Support search and filter
*/
const Completed: FC<ICompletedProps> = ({
embeddingAvailable,
showNewSegmentModal,
onNewSegmentModalChange,
importStatus,
archived,
}) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const pathname = usePathname()
const datasetId = useDocumentContext(s => s.datasetId) || ''
const documentId = useDocumentContext(s => s.documentId) || ''
const docForm = useDocumentContext(s => s.docForm)
const parentMode = useDocumentContext(s => s.parentMode)
// the current segment id and whether to show the modal
const [currSegment, setCurrSegment] = useState<CurrSegmentType>({ showModal: false })
const [currChildChunk, setCurrChildChunk] = useState<CurrChildChunkType>({ showModal: false })
const [currChunkId, setCurrChunkId] = useState('')
const [inputValue, setInputValue] = useState<string>('') // the input value
const [searchValue, setSearchValue] = useState<string>('') // the search value
const [selectedStatus, setSelectedStatus] = useState<boolean | 'all'>('all') // the selected status, enabled/disabled/undefined
const [segments, setSegments] = useState<SegmentDetailModel[]>([]) // all segments data
const [childSegments, setChildSegments] = useState<ChildChunkDetail[]>([]) // all child segments data
const [selectedSegmentIds, setSelectedSegmentIds] = useState<string[]>([])
const { eventEmitter } = useEventEmitterContextContext()
const [isCollapsed, setIsCollapsed] = useState(true)
const [currentPage, setCurrentPage] = useState(1) // start from 1
const [limit, setLimit] = useState(DEFAULT_LIMIT)
const [fullScreen, setFullScreen] = useState(false)
const [showNewChildSegmentModal, setShowNewChildSegmentModal] = useState(false)
const [isRegenerationModalOpen, setIsRegenerationModalOpen] = useState(false)
const segmentListRef = useRef<HTMLDivElement>(null)
const childSegmentListRef = useRef<HTMLDivElement>(null)
const needScrollToBottom = useRef(false)
const statusList = useRef<Item[]>([
{ value: 'all', name: t('datasetDocuments.list.index.all') },
{ value: 0, name: t('datasetDocuments.list.status.disabled') },
{ value: 1, name: t('datasetDocuments.list.status.enabled') },
])
const { run: handleSearch } = useDebounceFn(() => {
setSearchValue(inputValue)
setCurrentPage(1)
}, { wait: 500 })
const handleInputChange = (value: string) => {
setInputValue(value)
handleSearch()
}
const onChangeStatus = ({ value }: Item) => {
setSelectedStatus(value === 'all' ? 'all' : !!value)
setCurrentPage(1)
}
const isFullDocMode = useMemo(() => {
return docForm === ChunkingMode.parentChild && parentMode === 'full-doc'
}, [docForm, parentMode])
const { isLoading: isLoadingSegmentList, data: segmentListData } = useSegmentList(
{
datasetId,
documentId,
params: {
page: isFullDocMode ? 1 : currentPage,
limit: isFullDocMode ? 10 : limit,
keyword: isFullDocMode ? '' : searchValue,
enabled: selectedStatus,
},
},
)
const invalidSegmentList = useInvalid(useSegmentListKey)
useEffect(() => {
if (segmentListData) {
setSegments(segmentListData.data || [])
const totalPages = segmentListData.total_pages
if (totalPages < currentPage)
setCurrentPage(totalPages === 0 ? 1 : totalPages)
}
}, [segmentListData])
useEffect(() => {
if (segmentListRef.current && needScrollToBottom.current) {
segmentListRef.current.scrollTo({ top: segmentListRef.current.scrollHeight, behavior: 'smooth' })
needScrollToBottom.current = false
}
}, [segments])
const { isLoading: isLoadingChildSegmentList, data: childChunkListData } = useChildSegmentList(
{
datasetId,
documentId,
segmentId: segments[0]?.id || '',
params: {
page: currentPage === 0 ? 1 : currentPage,
limit,
keyword: searchValue,
},
},
!isFullDocMode || segments.length === 0,
)
const invalidChildSegmentList = useInvalid(useChildSegmentListKey)
useEffect(() => {
if (childSegmentListRef.current && needScrollToBottom.current) {
childSegmentListRef.current.scrollTo({ top: childSegmentListRef.current.scrollHeight, behavior: 'smooth' })
needScrollToBottom.current = false
}
}, [childSegments])
useEffect(() => {
if (childChunkListData) {
setChildSegments(childChunkListData.data || [])
const totalPages = childChunkListData.total_pages
if (totalPages < currentPage)
setCurrentPage(totalPages === 0 ? 1 : totalPages)
}
}, [childChunkListData])
const resetList = useCallback(() => {
setSelectedSegmentIds([])
invalidSegmentList()
}, [invalidSegmentList])
const resetChildList = useCallback(() => {
invalidChildSegmentList()
}, [invalidChildSegmentList])
const onClickCard = (detail: SegmentDetailModel, isEditMode = false) => {
setCurrSegment({ segInfo: detail, showModal: true, isEditMode })
}
const onCloseSegmentDetail = useCallback(() => {
setCurrSegment({ showModal: false })
setFullScreen(false)
}, [])
const onCloseNewSegmentModal = useCallback(() => {
onNewSegmentModalChange(false)
setFullScreen(false)
}, [onNewSegmentModalChange])
const onCloseNewChildChunkModal = useCallback(() => {
setShowNewChildSegmentModal(false)
setFullScreen(false)
}, [])
const { mutateAsync: enableSegment } = useEnableSegment()
const { mutateAsync: disableSegment } = useDisableSegment()
const invalidChunkListAll = useInvalid(useChunkListAllKey)
const invalidChunkListEnabled = useInvalid(useChunkListEnabledKey)
const invalidChunkListDisabled = useInvalid(useChunkListDisabledKey)
const refreshChunkListWithStatusChanged = useCallback(() => {
switch (selectedStatus) {
case 'all':
invalidChunkListDisabled()
invalidChunkListEnabled()
break
default:
invalidSegmentList()
}
}, [selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidSegmentList])
const onChangeSwitch = useCallback(async (enable: boolean, segId?: string) => {
const operationApi = enable ? enableSegment : disableSegment
await operationApi({ datasetId, documentId, segmentIds: segId ? [segId] : selectedSegmentIds }, {
onSuccess: () => {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
for (const seg of segments) {
if (segId ? seg.id === segId : selectedSegmentIds.includes(seg.id))
seg.enabled = enable
}
setSegments([...segments])
refreshChunkListWithStatusChanged()
},
onError: () => {
notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') })
},
})
}, [datasetId, documentId, selectedSegmentIds, segments, disableSegment, enableSegment, t, notify, refreshChunkListWithStatusChanged])
const { mutateAsync: deleteSegment } = useDeleteSegment()
const onDelete = useCallback(async (segId?: string) => {
await deleteSegment({ datasetId, documentId, segmentIds: segId ? [segId] : selectedSegmentIds }, {
onSuccess: () => {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
resetList()
if (!segId)
setSelectedSegmentIds([])
},
onError: () => {
notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') })
},
})
}, [datasetId, documentId, selectedSegmentIds, deleteSegment, resetList, t, notify])
const { mutateAsync: updateSegment } = useUpdateSegment()
const refreshChunkListDataWithDetailChanged = useCallback(() => {
switch (selectedStatus) {
case 'all':
invalidChunkListDisabled()
invalidChunkListEnabled()
break
case true:
invalidChunkListAll()
invalidChunkListDisabled()
break
case false:
invalidChunkListAll()
invalidChunkListEnabled()
break
}
}, [selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidChunkListAll])
const handleUpdateSegment = useCallback(async (
segmentId: string,
question: string,
answer: string,
keywords: string[],
needRegenerate = false,
) => {
const params: SegmentUpdater = { content: '' }
if (docForm === ChunkingMode.qa) {
if (!question.trim())
return notify({ type: 'error', message: t('datasetDocuments.segment.questionEmpty') })
if (!answer.trim())
return notify({ type: 'error', message: t('datasetDocuments.segment.answerEmpty') })
params.content = question
params.answer = answer
}
else {
if (!question.trim())
return notify({ type: 'error', message: t('datasetDocuments.segment.contentEmpty') })
params.content = question
}
if (keywords.length)
params.keywords = keywords
if (needRegenerate)
params.regenerate_child_chunks = needRegenerate
eventEmitter?.emit('update-segment')
await updateSegment({ datasetId, documentId, segmentId, body: params }, {
onSuccess(res) {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
if (!needRegenerate)
onCloseSegmentDetail()
for (const seg of segments) {
if (seg.id === segmentId) {
seg.answer = res.data.answer
seg.content = res.data.content
seg.sign_content = res.data.sign_content
seg.keywords = res.data.keywords
seg.word_count = res.data.word_count
seg.hit_count = res.data.hit_count
seg.enabled = res.data.enabled
seg.updated_at = res.data.updated_at
seg.child_chunks = res.data.child_chunks
}
}
setSegments([...segments])
refreshChunkListDataWithDetailChanged()
eventEmitter?.emit('update-segment-success')
},
onSettled() {
eventEmitter?.emit('update-segment-done')
},
})
}, [segments, datasetId, documentId, updateSegment, docForm, notify, eventEmitter, onCloseSegmentDetail, refreshChunkListDataWithDetailChanged, t])
useEffect(() => {
resetList()
}, [pathname])
useEffect(() => {
if (importStatus === ProcessStatus.COMPLETED)
resetList()
}, [importStatus])
const onCancelBatchOperation = useCallback(() => {
setSelectedSegmentIds([])
}, [])
const onSelected = useCallback((segId: string) => {
setSelectedSegmentIds(prev =>
prev.includes(segId)
? prev.filter(id => id !== segId)
: [...prev, segId],
)
}, [])
const isAllSelected = useMemo(() => {
return segments.length > 0 && segments.every(seg => selectedSegmentIds.includes(seg.id))
}, [segments, selectedSegmentIds])
const isSomeSelected = useMemo(() => {
return segments.some(seg => selectedSegmentIds.includes(seg.id))
}, [segments, selectedSegmentIds])
const onSelectedAll = useCallback(() => {
setSelectedSegmentIds((prev) => {
const currentAllSegIds = segments.map(seg => seg.id)
const prevSelectedIds = prev.filter(item => !currentAllSegIds.includes(item))
return [...prevSelectedIds, ...(isAllSelected ? [] : currentAllSegIds)]
})
}, [segments, isAllSelected])
const totalText = useMemo(() => {
const isSearch = searchValue !== '' || selectedStatus !== 'all'
if (!isSearch) {
const total = segmentListData?.total ? formatNumber(segmentListData.total) : '--'
const count = total === '--' ? 0 : segmentListData!.total
const translationKey = (docForm === ChunkingMode.parentChild && parentMode === 'paragraph')
? 'datasetDocuments.segment.parentChunks'
: 'datasetDocuments.segment.chunks'
return `${total} ${t(translationKey, { count })}`
}
else {
const total = typeof segmentListData?.total === 'number' ? formatNumber(segmentListData.total) : 0
const count = segmentListData?.total || 0
return `${total} ${t('datasetDocuments.segment.searchResults', { count })}`
}
}, [segmentListData, docForm, parentMode, searchValue, selectedStatus, t])
const toggleFullScreen = useCallback(() => {
setFullScreen(!fullScreen)
}, [fullScreen])
const viewNewlyAddedChunk = useCallback(async () => {
const totalPages = segmentListData?.total_pages || 0
const total = segmentListData?.total || 0
const newPage = Math.ceil((total + 1) / limit)
needScrollToBottom.current = true
if (newPage > totalPages) {
setCurrentPage(totalPages + 1)
}
else {
resetList()
if (currentPage !== totalPages)
setCurrentPage(totalPages)
}
}, [segmentListData, limit, currentPage, resetList])
const { mutateAsync: deleteChildSegment } = useDeleteChildSegment()
const onDeleteChildChunk = useCallback(async (segmentId: string, childChunkId: string) => {
await deleteChildSegment(
{ datasetId, documentId, segmentId, childChunkId },
{
onSuccess: () => {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
if (parentMode === 'paragraph')
resetList()
else
resetChildList()
},
onError: () => {
notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') })
},
},
)
}, [datasetId, documentId, parentMode, deleteChildSegment, resetList, resetChildList, t, notify])
const handleAddNewChildChunk = useCallback((parentChunkId: string) => {
setShowNewChildSegmentModal(true)
setCurrChunkId(parentChunkId)
}, [])
const onSaveNewChildChunk = useCallback((newChildChunk?: ChildChunkDetail) => {
if (parentMode === 'paragraph') {
for (const seg of segments) {
if (seg.id === currChunkId)
seg.child_chunks?.push(newChildChunk!)
}
setSegments([...segments])
refreshChunkListDataWithDetailChanged()
}
else {
resetChildList()
}
}, [parentMode, currChunkId, segments, refreshChunkListDataWithDetailChanged, resetChildList])
const viewNewlyAddedChildChunk = useCallback(() => {
const totalPages = childChunkListData?.total_pages || 0
const total = childChunkListData?.total || 0
const newPage = Math.ceil((total + 1) / limit)
needScrollToBottom.current = true
if (newPage > totalPages) {
setCurrentPage(totalPages + 1)
}
else {
resetChildList()
if (currentPage !== totalPages)
setCurrentPage(totalPages)
}
}, [childChunkListData, limit, currentPage, resetChildList])
const onClickSlice = useCallback((detail: ChildChunkDetail) => {
setCurrChildChunk({ childChunkInfo: detail, showModal: true })
setCurrChunkId(detail.segment_id)
}, [])
const onCloseChildSegmentDetail = useCallback(() => {
setCurrChildChunk({ showModal: false })
setFullScreen(false)
}, [])
const { mutateAsync: updateChildSegment } = useUpdateChildSegment()
const handleUpdateChildChunk = useCallback(async (
segmentId: string,
childChunkId: string,
content: string,
) => {
const params: SegmentUpdater = { content: '' }
if (!content.trim())
return notify({ type: 'error', message: t('datasetDocuments.segment.contentEmpty') })
params.content = content
eventEmitter?.emit('update-child-segment')
await updateChildSegment({ datasetId, documentId, segmentId, childChunkId, body: params }, {
onSuccess: (res) => {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
onCloseChildSegmentDetail()
if (parentMode === 'paragraph') {
for (const seg of segments) {
if (seg.id === segmentId) {
for (const childSeg of seg.child_chunks!) {
if (childSeg.id === childChunkId) {
childSeg.content = res.data.content
childSeg.type = res.data.type
childSeg.word_count = res.data.word_count
childSeg.updated_at = res.data.updated_at
}
}
}
}
setSegments([...segments])
refreshChunkListDataWithDetailChanged()
}
else {
resetChildList()
}
},
onSettled: () => {
eventEmitter?.emit('update-child-segment-done')
},
})
}, [segments, datasetId, documentId, parentMode, updateChildSegment, notify, eventEmitter, onCloseChildSegmentDetail, refreshChunkListDataWithDetailChanged, resetChildList, t])
const onClearFilter = useCallback(() => {
setInputValue('')
setSearchValue('')
setSelectedStatus('all')
setCurrentPage(1)
}, [])
const selectDefaultValue = useMemo(() => {
if (selectedStatus === 'all')
return 'all'
return selectedStatus ? 1 : 0
}, [selectedStatus])
return (
<SegmentListContext.Provider value={{
isCollapsed,
fullScreen,
toggleFullScreen,
currSegment,
currChildChunk,
}}>
{/* Menu Bar */}
{!isFullDocMode && <div className={s.docSearchWrapper}>
<Checkbox
className='shrink-0'
checked={isAllSelected}
indeterminate={!isAllSelected && isSomeSelected}
onCheck={onSelectedAll}
disabled={isLoadingSegmentList}
/>
<div className={'system-sm-semibold-uppercase flex-1 pl-5 text-text-secondary'}>{totalText}</div>
<SimpleSelect
onSelect={onChangeStatus}
items={statusList.current}
defaultValue={selectDefaultValue}
className={s.select}
wrapperClassName='h-fit mr-2'
optionWrapClassName='w-[160px]'
optionClassName='p-0'
renderOption={({ item, selected }) => <StatusItem item={item} selected={selected} />}
notClearable
/>
<Input
showLeftIcon
showClearIcon
wrapperClassName='!w-52'
value={inputValue}
onChange={e => handleInputChange(e.target.value)}
onClear={() => handleInputChange('')}
/>
<Divider type='vertical' className='mx-3 h-3.5' />
<DisplayToggle isCollapsed={isCollapsed} toggleCollapsed={() => setIsCollapsed(!isCollapsed)} />
</div>}
{/* Segment list */}
{
isFullDocMode
? <div className={cn(
'flex grow flex-col overflow-x-hidden',
(isLoadingSegmentList || isLoadingChildSegmentList) ? 'overflow-y-hidden' : 'overflow-y-auto',
)}>
<SegmentCard
detail={segments[0]}
onClick={() => onClickCard(segments[0])}
loading={isLoadingSegmentList}
focused={{
segmentIndex: currSegment?.segInfo?.id === segments[0]?.id,
segmentContent: currSegment?.segInfo?.id === segments[0]?.id,
}}
/>
<ChildSegmentList
parentChunkId={segments[0]?.id}
onDelete={onDeleteChildChunk}
childChunks={childSegments}
handleInputChange={handleInputChange}
handleAddNewChildChunk={handleAddNewChildChunk}
onClickSlice={onClickSlice}
enabled={!archived}
total={childChunkListData?.total || 0}
inputValue={inputValue}
onClearFilter={onClearFilter}
isLoading={isLoadingSegmentList || isLoadingChildSegmentList}
/>
</div>
: <SegmentList
ref={segmentListRef}
embeddingAvailable={embeddingAvailable}
isLoading={isLoadingSegmentList}
items={segments}
selectedSegmentIds={selectedSegmentIds}
onSelected={onSelected}
onChangeSwitch={onChangeSwitch}
onDelete={onDelete}
onClick={onClickCard}
archived={archived}
onDeleteChildChunk={onDeleteChildChunk}
handleAddNewChildChunk={handleAddNewChildChunk}
onClickSlice={onClickSlice}
onClearFilter={onClearFilter}
/>
}
{/* Pagination */}
<Divider type='horizontal' className='mx-6 my-0 h-px w-auto bg-divider-subtle' />
<Pagination
current={currentPage - 1}
onChange={cur => setCurrentPage(cur + 1)}
total={(isFullDocMode ? childChunkListData?.total : segmentListData?.total) || 0}
limit={limit}
onLimitChange={limit => setLimit(limit)}
className={isFullDocMode ? 'px-3' : ''}
/>
{/* Edit or view segment detail */}
<FullScreenDrawer
isOpen={currSegment.showModal}
fullScreen={fullScreen}
onClose={onCloseSegmentDetail}
showOverlay={false}
needCheckChunks
modal={isRegenerationModalOpen}
>
<SegmentDetail
key={currSegment.segInfo?.id}
segInfo={currSegment.segInfo ?? { id: '' }}
docForm={docForm}
isEditMode={currSegment.isEditMode}
onUpdate={handleUpdateSegment}
onCancel={onCloseSegmentDetail}
onModalStateChange={setIsRegenerationModalOpen}
/>
</FullScreenDrawer>
{/* Create New Segment */}
<FullScreenDrawer
isOpen={showNewSegmentModal}
fullScreen={fullScreen}
onClose={onCloseNewSegmentModal}
modal
>
<NewSegment
docForm={docForm}
onCancel={onCloseNewSegmentModal}
onSave={resetList}
viewNewlyAddedChunk={viewNewlyAddedChunk}
/>
</FullScreenDrawer>
{/* Edit or view child segment detail */}
<FullScreenDrawer
isOpen={currChildChunk.showModal}
fullScreen={fullScreen}
onClose={onCloseChildSegmentDetail}
showOverlay={false}
needCheckChunks
>
<ChildSegmentDetail
key={currChildChunk.childChunkInfo?.id}
chunkId={currChunkId}
childChunkInfo={currChildChunk.childChunkInfo ?? { id: '' }}
docForm={docForm}
onUpdate={handleUpdateChildChunk}
onCancel={onCloseChildSegmentDetail}
/>
</FullScreenDrawer>
{/* Create New Child Segment */}
<FullScreenDrawer
isOpen={showNewChildSegmentModal}
fullScreen={fullScreen}
onClose={onCloseNewChildChunkModal}
modal
>
<NewChildSegment
chunkId={currChunkId}
onCancel={onCloseNewChildChunkModal}
onSave={onSaveNewChildChunk}
viewNewlyAddedChildChunk={viewNewlyAddedChildChunk}
/>
</FullScreenDrawer>
{/* Batch Action Buttons */}
{selectedSegmentIds.length > 0 && (
<BatchAction
className='absolute bottom-16 left-0 z-20'
selectedIds={selectedSegmentIds}
onBatchEnable={onChangeSwitch.bind(null, true, '')}
onBatchDisable={onChangeSwitch.bind(null, false, '')}
onBatchDelete={onDelete.bind(null, '')}
onCancel={onCancelBatchOperation}
/>
)}
</SegmentListContext.Provider>
)
}
export default Completed

View File

@@ -0,0 +1,174 @@
import { memo, useMemo, useRef, useState } from 'react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useParams } from 'next/navigation'
import { RiCloseLine, RiExpandDiagonalLine } from '@remixicon/react'
import { useShallow } from 'zustand/react/shallow'
import { useDocumentContext } from '../context'
import { SegmentIndexTag } from './common/segment-index-tag'
import ActionButtons from './common/action-buttons'
import ChunkContent from './common/chunk-content'
import AddAnother from './common/add-another'
import Dot from './common/dot'
import { useSegmentListContext } from './index'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ToastContext } from '@/app/components/base/toast'
import { type ChildChunkDetail, ChunkingMode, type SegmentUpdater } from '@/models/datasets'
import classNames from '@/utils/classnames'
import { formatNumber } from '@/utils/format'
import Divider from '@/app/components/base/divider'
import { useAddChildSegment } from '@/service/knowledge/use-segment'
type NewChildSegmentModalProps = {
chunkId: string
onCancel: () => void
onSave: (ChildChunk?: ChildChunkDetail) => void
viewNewlyAddedChildChunk?: () => void
}
const NewChildSegmentModal: FC<NewChildSegmentModalProps> = ({
chunkId,
onCancel,
onSave,
viewNewlyAddedChildChunk,
}) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [content, setContent] = useState('')
const { datasetId, documentId } = useParams<{ datasetId: string; documentId: string }>()
const [loading, setLoading] = useState(false)
const [addAnother, setAddAnother] = useState(true)
const fullScreen = useSegmentListContext(s => s.fullScreen)
const toggleFullScreen = useSegmentListContext(s => s.toggleFullScreen)
const { appSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
})))
const parentMode = useDocumentContext(s => s.parentMode)
const refreshTimer = useRef<any>(null)
const isFullDocMode = useMemo(() => {
return parentMode === 'full-doc'
}, [parentMode])
const CustomButton = <>
<Divider type='vertical' className='mx-1 h-3 bg-divider-regular' />
<button
type='button'
className='system-xs-semibold text-text-accent'
onClick={() => {
clearTimeout(refreshTimer.current)
viewNewlyAddedChildChunk?.()
}}>
{t('common.operation.view')}
</button>
</>
const handleCancel = (actionType: 'esc' | 'add' = 'esc') => {
if (actionType === 'esc' || !addAnother)
onCancel()
}
const { mutateAsync: addChildSegment } = useAddChildSegment()
const handleSave = async () => {
const params: SegmentUpdater = { content: '' }
if (!content.trim())
return notify({ type: 'error', message: t('datasetDocuments.segment.contentEmpty') })
params.content = content
setLoading(true)
await addChildSegment({ datasetId, documentId, segmentId: chunkId, body: params }, {
onSuccess(res) {
notify({
type: 'success',
message: t('datasetDocuments.segment.childChunkAdded'),
className: `!w-[296px] !bottom-0 ${appSidebarExpand === 'expand' ? '!left-[216px]' : '!left-14'}
!top-auto !right-auto !mb-[52px] !ml-11`,
customComponent: isFullDocMode && CustomButton,
})
handleCancel('add')
setContent('')
if (isFullDocMode) {
refreshTimer.current = setTimeout(() => {
onSave()
}, 3000)
}
else {
onSave(res.data)
}
},
onSettled() {
setLoading(false)
},
})
}
const wordCountText = useMemo(() => {
const count = content.length
return `${formatNumber(count)} ${t('datasetDocuments.segment.characters', { count })}`
}, [content.length])
return (
<div className={'flex h-full flex-col'}>
<div className={classNames('flex items-center justify-between', fullScreen ? 'border border-divider-subtle py-3 pl-6 pr-4' : 'pl-4 pr-3 pt-3')}>
<div className='flex flex-col'>
<div className='system-xl-semibold text-text-primary'>{t('datasetDocuments.segment.addChildChunk')}</div>
<div className='flex items-center gap-x-2'>
<SegmentIndexTag label={t('datasetDocuments.segment.newChildChunk') as string} />
<Dot />
<span className='system-xs-medium text-text-tertiary'>{wordCountText}</span>
</div>
</div>
<div className='flex items-center'>
{fullScreen && (
<>
<AddAnother className='mr-3' isChecked={addAnother} onCheck={() => setAddAnother(!addAnother)} />
<ActionButtons
handleCancel={handleCancel.bind(null, 'esc')}
handleSave={handleSave}
loading={loading}
actionType='add'
isChildChunk={true}
/>
<Divider type='vertical' className='ml-4 mr-2 h-3.5 bg-divider-regular' />
</>
)}
<div className='mr-1 flex h-8 w-8 cursor-pointer items-center justify-center p-1.5' onClick={toggleFullScreen}>
<RiExpandDiagonalLine className='h-4 w-4 text-text-tertiary' />
</div>
<div className='flex h-8 w-8 cursor-pointer items-center justify-center p-1.5' onClick={handleCancel.bind(null, 'esc')}>
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
</div>
</div>
</div>
<div className={classNames('flex w-full grow', fullScreen ? 'flex-row justify-center px-6 pt-6' : 'px-4 py-3')}>
<div className={classNames('h-full overflow-hidden whitespace-pre-line break-all', fullScreen ? 'w-1/2' : 'w-full')}>
<ChunkContent
docForm={ChunkingMode.parentChild}
question={content}
onQuestionChange={content => setContent(content)}
isEditMode={true}
/>
</div>
</div>
{!fullScreen && (
<div className='flex items-center justify-between border-t-[1px] border-t-divider-subtle p-4 pt-3'>
<AddAnother isChecked={addAnother} onCheck={() => setAddAnother(!addAnother)} />
<ActionButtons
handleCancel={handleCancel.bind(null, 'esc')}
handleSave={handleSave}
loading={loading}
actionType='add'
isChildChunk={true}
/>
</div>
)}
</div>
)
}
export default memo(NewChildSegmentModal)

View File

@@ -0,0 +1,62 @@
import React, { type FC } from 'react'
import cn from '@/utils/classnames'
import { useSegmentListContext } from '..'
import { Markdown } from '@/app/components/base/markdown'
type ChunkContentProps = {
detail: {
answer?: string
content: string
sign_content: string
}
isFullDocMode: boolean
className?: string
}
const ChunkContent: FC<ChunkContentProps> = ({
detail,
isFullDocMode,
className,
}) => {
const { answer, content, sign_content } = detail
const isCollapsed = useSegmentListContext(s => s.isCollapsed)
if (answer) {
return (
<div className={className}>
<div className='flex gap-x-1'>
<div className='w-4 shrink-0 text-[13px] font-medium leading-[20px] text-text-tertiary'>Q</div>
<Markdown
className={cn('body-md-regular text-text-secondary',
isCollapsed ? 'line-clamp-2' : 'line-clamp-20',
)}
content={content}
customDisallowedElements={['input']}
/>
</div>
<div className='flex gap-x-1'>
<div className='w-4 shrink-0 text-[13px] font-medium leading-[20px] text-text-tertiary'>A</div>
<Markdown
className={cn('body-md-regular text-text-secondary',
isCollapsed ? 'line-clamp-2' : 'line-clamp-20',
)}
content={answer}
customDisallowedElements={['input']}
/>
</div>
</div>
)
}
return (
<Markdown
className={cn('!mt-0.5 !text-text-secondary',
isFullDocMode ? 'line-clamp-3' : isCollapsed ? 'line-clamp-2' : 'line-clamp-20',
className,
)}
content={sign_content || content || ''}
customDisallowedElements={['input']}
/>
)
}
export default React.memo(ChunkContent)

View File

@@ -0,0 +1,254 @@
import React, { type FC, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiDeleteBinLine, RiEditLine } from '@remixicon/react'
import StatusItem from '../../../status-item'
import { useDocumentContext } from '../../context'
import ChildSegmentList from '../child-segment-list'
import Tag from '../common/tag'
import Dot from '../common/dot'
import { SegmentIndexTag } from '../common/segment-index-tag'
import ParentChunkCardSkeleton from '../skeleton/parent-chunk-card-skeleton'
import { type ChildChunkDetail, ChunkingMode, type SegmentDetailModel } from '@/models/datasets'
import Switch from '@/app/components/base/switch'
import Divider from '@/app/components/base/divider'
import { formatNumber } from '@/utils/format'
import Confirm from '@/app/components/base/confirm'
import cn from '@/utils/classnames'
import Badge from '@/app/components/base/badge'
import { isAfter } from '@/utils/time'
import Tooltip from '@/app/components/base/tooltip'
import ChunkContent from './chunk-content'
type ISegmentCardProps = {
loading: boolean
detail?: SegmentDetailModel & { document?: { name: string } }
onClick?: () => void
onChangeSwitch?: (enabled: boolean, segId?: string) => Promise<void>
onDelete?: (segId: string) => Promise<void>
onDeleteChildChunk?: (segId: string, childChunkId: string) => Promise<void>
handleAddNewChildChunk?: (parentChunkId: string) => void
onClickSlice?: (childChunk: ChildChunkDetail) => void
onClickEdit?: () => void
className?: string
archived?: boolean
embeddingAvailable?: boolean
focused: {
segmentIndex: boolean
segmentContent: boolean
}
}
const SegmentCard: FC<ISegmentCardProps> = ({
detail = {},
onClick,
onChangeSwitch,
onDelete,
onDeleteChildChunk,
handleAddNewChildChunk,
onClickSlice,
onClickEdit,
loading = true,
className = '',
archived,
embeddingAvailable,
focused,
}) => {
const { t } = useTranslation()
const {
id,
position,
enabled,
content,
sign_content,
word_count,
hit_count,
answer,
keywords,
child_chunks = [],
created_at,
updated_at,
} = detail as Required<ISegmentCardProps>['detail']
const [showModal, setShowModal] = useState(false)
const docForm = useDocumentContext(s => s.docForm)
const parentMode = useDocumentContext(s => s.parentMode)
const isGeneralMode = useMemo(() => {
return docForm === ChunkingMode.text
}, [docForm])
const isParentChildMode = useMemo(() => {
return docForm === ChunkingMode.parentChild
}, [docForm])
const isParagraphMode = useMemo(() => {
return docForm === ChunkingMode.parentChild && parentMode === 'paragraph'
}, [docForm, parentMode])
const isFullDocMode = useMemo(() => {
return docForm === ChunkingMode.parentChild && parentMode === 'full-doc'
}, [docForm, parentMode])
const chunkEdited = useMemo(() => {
if (docForm === ChunkingMode.parentChild && parentMode === 'full-doc')
return false
return isAfter(updated_at * 1000, created_at * 1000)
}, [docForm, parentMode, updated_at, created_at])
const contentOpacity = useMemo(() => {
return (enabled || focused.segmentContent) ? '' : 'opacity-50 group-hover/card:opacity-100'
}, [enabled, focused.segmentContent])
const handleClickCard = useCallback(() => {
if (docForm !== ChunkingMode.parentChild || parentMode !== 'full-doc')
onClick?.()
}, [docForm, parentMode, onClick])
const wordCountText = useMemo(() => {
const total = formatNumber(word_count)
return `${total} ${t('datasetDocuments.segment.characters', { count: word_count })}`
}, [word_count, t])
const labelPrefix = useMemo(() => {
return isParentChildMode ? t('datasetDocuments.segment.parentChunk') : t('datasetDocuments.segment.chunk')
}, [isParentChildMode, t])
if (loading)
return <ParentChunkCardSkeleton />
return (
<div
className={cn(
'chunk-card group/card w-full rounded-xl px-3',
isFullDocMode ? '' : 'pb-2 pt-2.5 hover:bg-dataset-chunk-detail-card-hover-bg',
focused.segmentContent ? 'bg-dataset-chunk-detail-card-hover-bg' : '',
className,
)}
onClick={handleClickCard}
>
<div className='relative flex h-5 items-center justify-between'>
<>
<div className='flex items-center gap-x-2'>
<SegmentIndexTag
className={cn(contentOpacity)}
iconClassName={focused.segmentIndex ? 'text-text-accent' : ''}
labelClassName={focused.segmentIndex ? 'text-text-accent' : ''}
positionId={position}
label={isFullDocMode ? labelPrefix : ''}
labelPrefix={labelPrefix}
/>
<Dot />
<div className={cn('system-xs-medium text-text-tertiary', contentOpacity)}>{wordCountText}</div>
<Dot />
<div className={cn('system-xs-medium text-text-tertiary', contentOpacity)}>{`${formatNumber(hit_count)} ${t('datasetDocuments.segment.hitCount')}`}</div>
{chunkEdited && (
<>
<Dot />
<Badge text={t('datasetDocuments.segment.edited') as string} uppercase className={contentOpacity} />
</>
)}
</div>
{!isFullDocMode
? <div className='flex items-center'>
<StatusItem status={enabled ? 'enabled' : 'disabled'} reverse textCls="text-text-tertiary system-xs-regular" />
{embeddingAvailable && (
<div className="absolute -right-2.5 -top-2 z-20 hidden items-center gap-x-0.5 rounded-[10px] border-[0.5px]
border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-md backdrop-blur-[5px] group-hover/card:flex">
{!archived && (
<>
<Tooltip
popupContent='Edit'
popupClassName='text-text-secondary system-xs-medium'
>
<div
className='flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover'
onClick={(e) => {
e.stopPropagation()
onClickEdit?.()
}}>
<RiEditLine className='h-4 w-4 text-text-tertiary' />
</div>
</Tooltip>
<Tooltip
popupContent='Delete'
popupClassName='text-text-secondary system-xs-medium'
>
<div className='group/delete flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-destructive-hover'
onClick={(e) => {
e.stopPropagation()
setShowModal(true)
}
}>
<RiDeleteBinLine className='h-4 w-4 text-text-tertiary group-hover/delete:text-text-destructive' />
</div>
</Tooltip>
<Divider type="vertical" className="h-3.5 bg-divider-regular" />
</>
)}
<div
onClick={(e: React.MouseEvent<HTMLDivElement, MouseEvent>) =>
e.stopPropagation()
}
className="flex items-center"
>
<Switch
size='md'
disabled={archived || detail?.status !== 'completed'}
defaultValue={enabled}
onChange={async (val) => {
await onChangeSwitch?.(val, id)
}}
/>
</div>
</div>
)}
</div>
: null}
</>
</div>
<ChunkContent
detail={{
answer,
content,
sign_content,
}}
isFullDocMode={isFullDocMode}
className={contentOpacity}
/>
{isGeneralMode && <div className={cn('flex flex-wrap items-center gap-2 py-1.5', contentOpacity)}>
{keywords?.map(keyword => <Tag key={keyword} text={keyword} />)}
</div>}
{
isFullDocMode
? <button
type='button'
className='system-xs-semibold-uppercase mb-2 mt-0.5 text-text-accent'
onClick={() => onClick?.()}
>{t('common.operation.viewMore')}</button>
: null
}
{
isParagraphMode && child_chunks.length > 0
&& <ChildSegmentList
parentChunkId={id}
childChunks={child_chunks}
enabled={enabled}
onDelete={onDeleteChildChunk!}
handleAddNewChildChunk={handleAddNewChildChunk}
onClickSlice={onClickSlice}
focused={focused.segmentContent}
/>
}
{showModal
&& <Confirm
isShow={showModal}
title={t('datasetDocuments.segment.delete')}
confirmText={t('common.operation.sure')}
onConfirm={async () => { await onDelete?.(id) }}
onCancel={() => setShowModal(false)}
/>
}
</div>
)
}
export default React.memo(SegmentCard)

View File

@@ -0,0 +1,182 @@
import React, { type FC, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiCloseLine,
RiCollapseDiagonalLine,
RiExpandDiagonalLine,
} from '@remixicon/react'
import { useDocumentContext } from '../context'
import ActionButtons from './common/action-buttons'
import ChunkContent from './common/chunk-content'
import Keywords from './common/keywords'
import RegenerationModal from './common/regeneration-modal'
import { SegmentIndexTag } from './common/segment-index-tag'
import Dot from './common/dot'
import { useSegmentListContext } from './index'
import { ChunkingMode, type SegmentDetailModel } from '@/models/datasets'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { formatNumber } from '@/utils/format'
import cn from '@/utils/classnames'
import Divider from '@/app/components/base/divider'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { IndexingType } from '../../../create/step-two'
type ISegmentDetailProps = {
segInfo?: Partial<SegmentDetailModel> & { id: string }
onUpdate: (segmentId: string, q: string, a: string, k: string[], needRegenerate?: boolean) => void
onCancel: () => void
isEditMode?: boolean
docForm: ChunkingMode
onModalStateChange?: (isOpen: boolean) => void
}
/**
* Show all the contents of the segment
*/
const SegmentDetail: FC<ISegmentDetailProps> = ({
segInfo,
onUpdate,
onCancel,
isEditMode,
docForm,
onModalStateChange,
}) => {
const { t } = useTranslation()
const [question, setQuestion] = useState(isEditMode ? segInfo?.content || '' : segInfo?.sign_content || '')
const [answer, setAnswer] = useState(segInfo?.answer || '')
const [keywords, setKeywords] = useState<string[]>(segInfo?.keywords || [])
const { eventEmitter } = useEventEmitterContextContext()
const [loading, setLoading] = useState(false)
const [showRegenerationModal, setShowRegenerationModal] = useState(false)
const fullScreen = useSegmentListContext(s => s.fullScreen)
const toggleFullScreen = useSegmentListContext(s => s.toggleFullScreen)
const parentMode = useDocumentContext(s => s.parentMode)
const indexingTechnique = useDatasetDetailContextWithSelector(s => s.dataset?.indexing_technique)
eventEmitter?.useSubscription((v) => {
if (v === 'update-segment')
setLoading(true)
if (v === 'update-segment-done')
setLoading(false)
})
const handleCancel = useCallback(() => {
onCancel()
}, [onCancel])
const handleSave = useCallback(() => {
onUpdate(segInfo?.id || '', question, answer, keywords)
}, [onUpdate, segInfo?.id, question, answer, keywords])
const handleRegeneration = useCallback(() => {
setShowRegenerationModal(true)
onModalStateChange?.(true)
}, [onModalStateChange])
const onCancelRegeneration = useCallback(() => {
setShowRegenerationModal(false)
onModalStateChange?.(false)
}, [onModalStateChange])
const onCloseAfterRegeneration = useCallback(() => {
setShowRegenerationModal(false)
onModalStateChange?.(false)
onCancel() // Close the edit drawer
}, [onCancel, onModalStateChange])
const onConfirmRegeneration = useCallback(() => {
onUpdate(segInfo?.id || '', question, answer, keywords, true)
}, [onUpdate, segInfo?.id, question, answer, keywords])
const wordCountText = useMemo(() => {
const contentLength = docForm === ChunkingMode.qa ? (question.length + answer.length) : question.length
const total = formatNumber(isEditMode ? contentLength : segInfo!.word_count as number)
const count = isEditMode ? contentLength : segInfo!.word_count as number
return `${total} ${t('datasetDocuments.segment.characters', { count })}`
}, [isEditMode, question.length, answer.length, docForm, segInfo, t])
const isFullDocMode = docForm === ChunkingMode.parentChild && parentMode === 'full-doc'
const titleText = isEditMode ? t('datasetDocuments.segment.editChunk') : t('datasetDocuments.segment.chunkDetail')
const labelPrefix = docForm === ChunkingMode.parentChild ? t('datasetDocuments.segment.parentChunk') : t('datasetDocuments.segment.chunk')
const isECOIndexing = indexingTechnique === IndexingType.ECONOMICAL
return (
<div className={'flex h-full flex-col'}>
<div className={cn('flex items-center justify-between', fullScreen ? 'border border-divider-subtle py-3 pl-6 pr-4' : 'pl-4 pr-3 pt-3')}>
<div className='flex flex-col'>
<div className='system-xl-semibold text-text-primary'>{titleText}</div>
<div className='flex items-center gap-x-2'>
<SegmentIndexTag positionId={segInfo?.position || ''} label={isFullDocMode ? labelPrefix : ''} labelPrefix={labelPrefix} />
<Dot />
<span className='system-xs-medium text-text-tertiary'>{wordCountText}</span>
</div>
</div>
<div className='flex items-center'>
{isEditMode && fullScreen && (
<>
<ActionButtons
handleCancel={handleCancel}
handleRegeneration={handleRegeneration}
handleSave={handleSave}
loading={loading}
/>
<Divider type='vertical' className='ml-4 mr-2 h-3.5 bg-divider-regular' />
</>
)}
<div className='mr-1 flex h-8 w-8 cursor-pointer items-center justify-center p-1.5' onClick={toggleFullScreen}>
{fullScreen ? <RiCollapseDiagonalLine className='h-4 w-4 text-text-tertiary' /> : <RiExpandDiagonalLine className='h-4 w-4 text-text-tertiary' />}
</div>
<div className='flex h-8 w-8 cursor-pointer items-center justify-center p-1.5' onClick={onCancel}>
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
</div>
</div>
</div>
<div className={cn(
'flex grow',
fullScreen ? 'w-full flex-row justify-center gap-x-8 px-6 pt-6' : 'flex-col gap-y-1 px-4 py-3',
!isEditMode && 'overflow-hidden pb-0',
)}>
<div className={cn(isEditMode ? 'overflow-hidden whitespace-pre-line break-all' : 'overflow-y-auto', fullScreen ? 'w-1/2' : 'grow')}>
<ChunkContent
docForm={docForm}
question={question}
answer={answer}
onQuestionChange={question => setQuestion(question)}
onAnswerChange={answer => setAnswer(answer)}
isEditMode={isEditMode}
/>
</div>
{isECOIndexing && <Keywords
className={fullScreen ? 'w-1/5' : ''}
actionType={isEditMode ? 'edit' : 'view'}
segInfo={segInfo}
keywords={keywords}
isEditMode={isEditMode}
onKeywordsChange={keywords => setKeywords(keywords)}
/>}
</div>
{isEditMode && !fullScreen && (
<div className='flex items-center justify-end border-t-[1px] border-t-divider-subtle p-4 pt-3'>
<ActionButtons
handleCancel={handleCancel}
handleRegeneration={handleRegeneration}
handleSave={handleSave}
loading={loading}
/>
</div>
)}
{
showRegenerationModal && (
<RegenerationModal
isShow={showRegenerationModal}
onConfirm={onConfirmRegeneration}
onCancel={onCancelRegeneration}
onClose={onCloseAfterRegeneration}
/>
)
}
</div>
)
}
export default React.memo(SegmentDetail)

View File

@@ -0,0 +1,119 @@
import React, { useMemo } from 'react'
import { useDocumentContext } from '../context'
import SegmentCard from './segment-card'
import Empty from './common/empty'
import GeneralListSkeleton from './skeleton/general-list-skeleton'
import ParagraphListSkeleton from './skeleton/paragraph-list-skeleton'
import { useSegmentListContext } from './index'
import { type ChildChunkDetail, ChunkingMode, type SegmentDetailModel } from '@/models/datasets'
import Checkbox from '@/app/components/base/checkbox'
import Divider from '@/app/components/base/divider'
type ISegmentListProps = {
isLoading: boolean
items: SegmentDetailModel[]
selectedSegmentIds: string[]
onSelected: (segId: string) => void
onClick: (detail: SegmentDetailModel, isEditMode?: boolean) => void
onChangeSwitch: (enabled: boolean, segId?: string) => Promise<void>
onDelete: (segId: string) => Promise<void>
onDeleteChildChunk: (sgId: string, childChunkId: string) => Promise<void>
handleAddNewChildChunk: (parentChunkId: string) => void
onClickSlice: (childChunk: ChildChunkDetail) => void
archived?: boolean
embeddingAvailable: boolean
onClearFilter: () => void
}
const SegmentList = (
{
ref,
isLoading,
items,
selectedSegmentIds,
onSelected,
onClick: onClickCard,
onChangeSwitch,
onDelete,
onDeleteChildChunk,
handleAddNewChildChunk,
onClickSlice,
archived,
embeddingAvailable,
onClearFilter,
}: ISegmentListProps & {
ref: React.LegacyRef<HTMLDivElement>
},
) => {
const docForm = useDocumentContext(s => s.docForm)
const parentMode = useDocumentContext(s => s.parentMode)
const currSegment = useSegmentListContext(s => s.currSegment)
const currChildChunk = useSegmentListContext(s => s.currChildChunk)
const Skeleton = useMemo(() => {
return (docForm === ChunkingMode.parentChild && parentMode === 'paragraph') ? ParagraphListSkeleton : GeneralListSkeleton
}, [docForm, parentMode])
// Loading skeleton
if (isLoading)
return <Skeleton />
// Search result is empty
if (items.length === 0) {
return (
<div className='h-full pl-6'>
<Empty onClearFilter={onClearFilter} />
</div>
)
}
return (
<div ref={ref} className={'flex grow flex-col overflow-y-auto'}>
{
items.map((segItem) => {
const isLast = items[items.length - 1].id === segItem.id
const segmentIndexFocused
= currSegment?.segInfo?.id === segItem.id
|| (!currSegment && currChildChunk?.childChunkInfo?.segment_id === segItem.id)
const segmentContentFocused = currSegment?.segInfo?.id === segItem.id
|| currChildChunk?.childChunkInfo?.segment_id === segItem.id
return (
<div key={segItem.id} className='flex items-start gap-x-2'>
<Checkbox
key={`${segItem.id}-checkbox`}
className='mt-3.5 shrink-0'
checked={selectedSegmentIds.includes(segItem.id)}
onCheck={() => onSelected(segItem.id)}
/>
<div className='min-w-0 grow'>
<SegmentCard
key={`${segItem.id}-card`}
detail={segItem}
onClick={() => onClickCard(segItem, true)}
onChangeSwitch={onChangeSwitch}
onClickEdit={() => onClickCard(segItem, true)}
onDelete={onDelete}
onDeleteChildChunk={onDeleteChildChunk}
handleAddNewChildChunk={handleAddNewChildChunk}
onClickSlice={onClickSlice}
loading={false}
archived={archived}
embeddingAvailable={embeddingAvailable}
focused={{
segmentIndex: segmentIndexFocused,
segmentContent: segmentContentFocused,
}}
/>
{!isLast && <div className='w-full px-3'>
<Divider type='horizontal' className='my-1 bg-divider-subtle' />
</div>}
</div>
</div>
)
})
}
</div>
)
}
SegmentList.displayName = 'SegmentList'
export default SegmentList

View File

@@ -0,0 +1,25 @@
import React from 'react'
const Slice = React.memo(() => {
return (
<div className='flex flex-col gap-y-1'>
<div className='flex h-5 w-full items-center bg-state-base-hover'>
<span className='h-5 w-[30px] bg-state-base-hover-alt' />
</div>
<div className='h-5 w-2/3 bg-state-base-hover' />
</div>
)
})
Slice.displayName = 'Slice'
const FullDocListSkeleton = () => {
return (
<div className='relative z-10 flex w-full grow flex-col gap-y-3 overflow-y-hidden'>
<div className='absolute bottom-14 left-0 top-0 z-20 h-full w-full bg-dataset-chunk-list-mask-bg' />
{Array.from({ length: 15 }).map((_, index) => <Slice key={index} />)}
</div>
)
}
export default React.memo(FullDocListSkeleton)

View File

@@ -0,0 +1,74 @@
import React from 'react'
import {
SkeletonContainer,
SkeletonPoint,
SkeletonRectangle,
SkeletonRow,
} from '@/app/components/base/skeleton'
import Checkbox from '@/app/components/base/checkbox'
import Divider from '@/app/components/base/divider'
export const CardSkelton = React.memo(() => {
return (
<SkeletonContainer className='gap-y-0 p-1 pb-2'>
<SkeletonContainer className='gap-y-0.5 px-2 pt-1.5'>
<SkeletonRow className='py-0.5'>
<SkeletonRectangle className='w-[72px] bg-text-quaternary' />
<SkeletonPoint className='opacity-20' />
<SkeletonRectangle className='w-24 bg-text-quaternary' />
<SkeletonPoint className='opacity-20' />
<SkeletonRectangle className='w-24 bg-text-quaternary' />
<SkeletonRow className='grow justify-end gap-1'>
<SkeletonRectangle className='w-12 bg-text-quaternary' />
<SkeletonRectangle className='mx-1 w-2 bg-text-quaternary' />
</SkeletonRow>
</SkeletonRow>
<SkeletonRow className='py-0.5'>
<SkeletonRectangle className='w-full bg-text-quaternary' />
</SkeletonRow>
<SkeletonRow className='py-0.5'>
<SkeletonRectangle className='w-full bg-text-quaternary' />
</SkeletonRow>
<SkeletonRow className='py-0.5'>
<SkeletonRectangle className='w-2/3 bg-text-quaternary' />
</SkeletonRow>
</SkeletonContainer>
<SkeletonContainer className='px-2 py-1.5'>
<SkeletonRow>
<SkeletonRectangle className='w-14 bg-text-quaternary' />
<SkeletonRectangle className='w-[88px] bg-text-quaternary' />
<SkeletonRectangle className='w-14 bg-text-quaternary' />
</SkeletonRow>
</SkeletonContainer>
</SkeletonContainer>
)
})
CardSkelton.displayName = 'CardSkelton'
const GeneralListSkeleton = () => {
return (
<div className='relative z-10 flex grow flex-col overflow-y-hidden'>
<div className='absolute left-0 top-0 z-20 h-full w-full bg-dataset-chunk-list-mask-bg' />
{Array.from({ length: 10 }).map((_, index) => {
return (
<div key={index} className='flex items-start gap-x-2'>
<Checkbox
key={`${index}-checkbox`}
className='mt-3.5 shrink-0'
disabled
/>
<div className='grow'>
<CardSkelton />
{index !== 9 && <div className='w-full px-3'>
<Divider type='horizontal' className='my-1 bg-divider-subtle' />
</div>}
</div>
</div>
)
})}
</div>
)
}
export default React.memo(GeneralListSkeleton)

View File

@@ -0,0 +1,76 @@
import React from 'react'
import { RiArrowRightSLine } from '@remixicon/react'
import {
SkeletonContainer,
SkeletonPoint,
SkeletonRectangle,
SkeletonRow,
} from '@/app/components/base/skeleton'
import Checkbox from '@/app/components/base/checkbox'
import Divider from '@/app/components/base/divider'
const CardSkelton = React.memo(() => {
return (
<SkeletonContainer className='gap-y-0 p-1 pb-2'>
<SkeletonContainer className='gap-y-0.5 px-2 pt-1.5'>
<SkeletonRow className='py-0.5'>
<SkeletonRectangle className='w-[72px] bg-text-quaternary' />
<SkeletonPoint className='opacity-20' />
<SkeletonRectangle className='w-24 bg-text-quaternary' />
<SkeletonPoint className='opacity-20' />
<SkeletonRectangle className='w-24 bg-text-quaternary' />
<SkeletonRow className='grow justify-end gap-1'>
<SkeletonRectangle className='w-12 bg-text-quaternary' />
<SkeletonRectangle className='mx-1 w-2 bg-text-quaternary' />
</SkeletonRow>
</SkeletonRow>
<SkeletonRow className='py-0.5'>
<SkeletonRectangle className='w-full bg-text-quaternary' />
</SkeletonRow>
<SkeletonRow className='py-0.5'>
<SkeletonRectangle className='w-full bg-text-quaternary' />
</SkeletonRow>
<SkeletonRow className='py-0.5'>
<SkeletonRectangle className='w-2/3 bg-text-quaternary' />
</SkeletonRow>
</SkeletonContainer>
<SkeletonContainer className='p-1 pb-2'>
<SkeletonRow>
<SkeletonRow className='h-7 gap-x-0.5 rounded-lg bg-dataset-child-chunk-expand-btn-bg pl-1 pr-3'>
<RiArrowRightSLine className='h-4 w-4 text-text-secondary opacity-20' />
<SkeletonRectangle className='w-32 bg-text-quaternary' />
</SkeletonRow>
</SkeletonRow>
</SkeletonContainer>
</SkeletonContainer>
)
})
CardSkelton.displayName = 'CardSkelton'
const ParagraphListSkeleton = () => {
return (
<div className='relative z-10 flex h-full flex-col overflow-y-hidden'>
<div className='absolute left-0 top-0 z-20 h-full w-full bg-dataset-chunk-list-mask-bg' />
{Array.from({ length: 10 }).map((_, index) => {
return (
<div key={index} className='flex items-start gap-x-2'>
<Checkbox
key={`${index}-checkbox`}
className='mt-3.5 shrink-0'
disabled
/>
<div className='grow'>
<CardSkelton />
{index !== 9 && <div className='w-full px-3'>
<Divider type='horizontal' className='my-1 bg-divider-subtle' />
</div>}
</div>
</div>
)
})}
</div>
)
}
export default React.memo(ParagraphListSkeleton)

View File

@@ -0,0 +1,45 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import {
SkeletonContainer,
SkeletonPoint,
SkeletonRectangle,
SkeletonRow,
} from '@/app/components/base/skeleton'
const ParentChunkCardSkelton = () => {
const { t } = useTranslation()
return (
<div className='flex flex-col pb-2'>
<SkeletonContainer className='gap-y-0 p-1 pb-0'>
<SkeletonContainer className='gap-y-0.5 px-2 pt-1.5'>
<SkeletonRow className='py-0.5'>
<SkeletonRectangle className='w-[72px] bg-text-quaternary' />
<SkeletonPoint className='opacity-20' />
<SkeletonRectangle className='w-24 bg-text-quaternary' />
<SkeletonPoint className='opacity-20' />
<SkeletonRectangle className='w-24 bg-text-quaternary' />
</SkeletonRow>
<SkeletonRow className='py-0.5'>
<SkeletonRectangle className='w-full bg-text-quaternary' />
</SkeletonRow>
<SkeletonRow className='py-0.5'>
<SkeletonRectangle className='w-full bg-text-quaternary' />
</SkeletonRow>
<SkeletonRow className='py-0.5'>
<SkeletonRectangle className='w-2/3 bg-text-quaternary' />
</SkeletonRow>
</SkeletonContainer>
</SkeletonContainer>
<div className='mt-0.5 flex items-center px-3'>
<button type='button' className='system-xs-semibold-uppercase pt-0.5 text-components-button-secondary-accent-text-disabled' disabled>
{t('common.operation.viewMore')}
</button>
</div>
</div>
)
}
ParentChunkCardSkelton.displayName = 'ParentChunkCardSkelton'
export default React.memo(ParentChunkCardSkelton)

View File

@@ -0,0 +1,22 @@
import React, { type FC } from 'react'
import { RiCheckLine } from '@remixicon/react'
import type { Item } from '@/app/components/base/select'
type IStatusItemProps = {
item: Item
selected: boolean
}
const StatusItem: FC<IStatusItemProps> = ({
item,
selected,
}) => {
return (
<div className='flex items-center justify-between px-2 py-1.5'>
<span className='system-md-regular'>{item.name}</span>
{selected && <RiCheckLine className='h-4 w-4 text-text-accent' />}
</div>
)
}
export default React.memo(StatusItem)

View File

@@ -0,0 +1,146 @@
.docSearchWrapper {
@apply sticky w-full -top-3 flex items-center mb-3 justify-between z-[11] flex-wrap gap-y-1 pr-3;
}
.listContainer {
height: calc(100% - 3.25rem);
@apply box-border pb-[30px];
}
.cardWrapper {
@apply grid gap-4 grid-cols-3 min-w-[902px] last:mb-[30px];
}
.segWrapper {
@apply box-border h-[180px] w-full xl:min-w-[290px] bg-gray-50 px-4 pt-4 flex flex-col text-opacity-50 rounded-xl border border-transparent hover:border-gray-200 hover:shadow-lg hover:cursor-pointer hover:bg-white;
}
.segTitleWrapper {
@apply flex items-center justify-between;
}
.segStatusWrapper {
@apply flex items-center box-border;
}
.segContent {
white-space: wrap;
@apply flex-1 h-0 min-h-0 mt-2 text-sm text-gray-800 overflow-ellipsis overflow-hidden from-gray-800 to-white;
}
.segData {
@apply hidden text-gray-500 text-xs pt-2;
}
.segDataText {
@apply max-w-[80px] truncate;
}
.chartLinkText {
background: linear-gradient(to left, white, 90%, transparent);
@apply text-primary-600 font-semibold text-xs absolute right-0 hidden h-12 pl-12 items-center;
}
.select {
@apply h-8 py-0 pr-5 w-[100px] shadow-none !important;
}
.segModalContent {
@apply h-96 text-gray-800 text-base break-all overflow-y-scroll;
white-space: pre-line;
}
.footer {
@apply flex items-center justify-between box-border border-t-gray-200 border-t-[0.5px] pt-3 mt-4 flex-wrap gap-y-2;
}
.numberInfo {
@apply text-gray-500 text-xs font-medium;
}
.keywordTitle {
@apply text-gray-500 mb-2 mt-1 text-xs uppercase;
}
.keywordWrapper {
@apply text-gray-700 w-full max-h-[200px] overflow-auto flex flex-wrap;
}
.keyword {
@apply text-sm border border-gray-200 max-w-[200px] max-h-[100px] whitespace-pre-line overflow-y-auto mr-1 mb-2 last:mr-0 px-2 py-1 rounded-lg;
}
.hashText {
@apply w-48 inline-block truncate;
}
.commonIcon {
@apply w-3 h-3 inline-block align-middle mr-1 bg-gray-500;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center center;
}
.targetIcon {
mask-image: url(../../assets/target.svg);
}
.typeSquareIcon {
mask-image: url(../../assets/typeSquare.svg);
}
.bezierCurveIcon {
mask-image: url(../../assets/bezierCurve.svg);
}
.cardLoadingWrapper {
@apply relative w-full h-full inline-block rounded-b-xl;
background-position: center center;
background-repeat: no-repeat;
background-size: 100% 100%;
background-origin: content-box;
}
.cardLoadingIcon {
background-image: url(../../assets/cardLoading.svg);
}
/* .hitLoadingIcon {
background-image: url(../../assets/hitLoading.svg);
} */
.cardLoadingBg {
@apply h-full relative rounded-b-xl mt-4;
left: calc(-1rem - 1px);
width: calc(100% + 2rem + 2px);
height: calc(100% - 1rem + 1px);
background: linear-gradient(
180deg,
rgba(252, 252, 253, 0) 0%,
#fcfcfd 74.15%
);
}
.hitTitleWrapper {
@apply w-full flex items-center justify-between mb-2;
}
.progressWrapper {
@apply flex items-center justify-between w-full;
}
.progress {
border-radius: 3px;
@apply relative h-1.5 box-border border border-gray-300 flex-1 mr-2;
}
.progressLoading {
@apply border-[#EAECF0] bg-[#EAECF0];
}
.progressInner {
@apply absolute top-0 h-full bg-gray-300;
}
.progressText {
font-size: 13px;
@apply text-gray-700 font-bold;
}
.progressTextLoading {
border-radius: 5px;
@apply h-3.5 w-3.5 bg-[#EAECF0];
}
.editTip {
box-shadow: 0px 4px 6px -2px rgba(16, 24, 40, 0.03), 0px 12px 16px -4px rgba(16, 24, 40, 0.08);
}
.delModal {
background: linear-gradient(
180deg,
rgba(217, 45, 32, 0.05) 0%,
rgba(217, 45, 32, 0) 24.02%
),
#f9fafb;
box-shadow: 0px 20px 24px -4px rgba(16, 24, 40, 0.08),
0px 8px 8px -4px rgba(16, 24, 40, 0.03);
@apply rounded-2xl p-8;
}
.warningWrapper {
box-shadow: 0px 20px 24px -4px rgba(16, 24, 40, 0.08),
0px 8px 8px -4px rgba(16, 24, 40, 0.03);
background: rgba(255, 255, 255, 0.9);
@apply h-12 w-12 border-[0.5px] border-gray-100 rounded-xl mb-3 flex items-center justify-center;
}
.warningIcon {
@apply w-[22px] h-[22px] fill-current text-red-600;
}

View File

@@ -0,0 +1,15 @@
import type { ChunkingMode, ParentMode } from '@/models/datasets'
import { createContext, useContextSelector } from 'use-context-selector'
type DocumentContextValue = {
datasetId?: string
documentId?: string
docForm?: ChunkingMode
parentMode?: ParentMode
}
export const DocumentContext = createContext<DocumentContextValue>({})
export const useDocumentContext = (selector: (value: DocumentContextValue) => any) => {
return useContextSelector(DocumentContext, selector)
}

View File

@@ -0,0 +1,43 @@
import type { FC } from 'react'
import type { ChunkingMode, ParentMode } from '@/models/datasets'
import { useRouter } from 'next/navigation'
import DocumentPicker from '../../common/document-picker'
import cn from '@/utils/classnames'
type DocumentTitleProps = {
datasetId: string
extension?: string
name?: string
chunkingMode?: ChunkingMode
parent_mode?: ParentMode
iconCls?: string
textCls?: string
wrapperCls?: string
}
export const DocumentTitle: FC<DocumentTitleProps> = ({
datasetId,
extension,
name,
chunkingMode,
parent_mode,
wrapperCls,
}) => {
const router = useRouter()
return (
<div className={cn('flex flex-1 items-center justify-start', wrapperCls)}>
<DocumentPicker
datasetId={datasetId}
value={{
name,
extension,
chunkingMode,
parentMode: parent_mode || 'paragraph',
}}
onChange={(doc) => {
router.push(`/datasets/${datasetId}/documents/${doc.id}`)
}}
/>
</div>
)
}

View File

@@ -0,0 +1,312 @@
import type { FC } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import useSWR from 'swr'
import { useContext } from 'use-context-selector'
import { useTranslation } from 'react-i18next'
import { omit } from 'lodash-es'
import { RiLoader2Line, RiPauseCircleLine, RiPlayCircleLine } from '@remixicon/react'
import Image from 'next/image'
import { FieldInfo } from '../metadata'
import { useDocumentContext } from '../context'
import { IndexingType } from '../../../create/step-two'
import { indexMethodIcon, retrievalIcon } from '../../../create/icons'
import EmbeddingSkeleton from './skeleton'
import { RETRIEVE_METHOD } from '@/types/app'
import cn from '@/utils/classnames'
import Divider from '@/app/components/base/divider'
import { ToastContext } from '@/app/components/base/toast'
import type { IndexingStatusResponse } from '@/models/datasets'
import { ProcessMode, type ProcessRuleResponse } from '@/models/datasets'
import type { CommonResponse } from '@/models/common'
import { asyncRunSafe, sleep } from '@/utils'
import {
fetchIndexingStatus as doFetchIndexingStatus,
fetchProcessRule,
pauseDocIndexing,
resumeDocIndexing,
} from '@/service/datasets'
type IEmbeddingDetailProps = {
datasetId?: string
documentId?: string
indexingType?: IndexingType
retrievalMethod?: RETRIEVE_METHOD
detailUpdate: VoidFunction
}
type IRuleDetailProps = {
sourceData?: ProcessRuleResponse
indexingType?: IndexingType
retrievalMethod?: RETRIEVE_METHOD
}
const RuleDetail: FC<IRuleDetailProps> = React.memo(({
sourceData,
indexingType,
retrievalMethod,
}) => {
const { t } = useTranslation()
const segmentationRuleMap = {
mode: t('datasetDocuments.embedding.mode'),
segmentLength: t('datasetDocuments.embedding.segmentLength'),
textCleaning: t('datasetDocuments.embedding.textCleaning'),
}
const getRuleName = (key: string) => {
if (key === 'remove_extra_spaces')
return t('datasetCreation.stepTwo.removeExtraSpaces')
if (key === 'remove_urls_emails')
return t('datasetCreation.stepTwo.removeUrlEmails')
if (key === 'remove_stopwords')
return t('datasetCreation.stepTwo.removeStopwords')
}
const isNumber = (value: unknown) => {
return typeof value === 'number'
}
const getValue = useCallback((field: string) => {
let value: string | number | undefined = '-'
const maxTokens = isNumber(sourceData?.rules?.segmentation?.max_tokens)
? sourceData.rules.segmentation.max_tokens
: value
const childMaxTokens = isNumber(sourceData?.rules?.subchunk_segmentation?.max_tokens)
? sourceData.rules.subchunk_segmentation.max_tokens
: value
switch (field) {
case 'mode':
value = !sourceData?.mode
? value
: sourceData.mode === ProcessMode.general
? (t('datasetDocuments.embedding.custom') as string)
: `${t('datasetDocuments.embedding.hierarchical')} · ${sourceData?.rules?.parent_mode === 'paragraph'
? t('dataset.parentMode.paragraph')
: t('dataset.parentMode.fullDoc')}`
break
case 'segmentLength':
value = !sourceData?.mode
? value
: sourceData.mode === ProcessMode.general
? maxTokens
: `${t('datasetDocuments.embedding.parentMaxTokens')} ${maxTokens}; ${t('datasetDocuments.embedding.childMaxTokens')} ${childMaxTokens}`
break
default:
value = !sourceData?.mode
? value
: sourceData?.rules?.pre_processing_rules?.filter(rule =>
rule.enabled).map(rule => getRuleName(rule.id)).join(',')
break
}
return value
}, [sourceData])
return <div className='py-3'>
<div className='flex flex-col gap-y-1'>
{Object.keys(segmentationRuleMap).map((field) => {
return <FieldInfo
key={field}
label={segmentationRuleMap[field as keyof typeof segmentationRuleMap]}
displayedValue={String(getValue(field))}
/>
})}
</div>
<Divider type='horizontal' className='bg-divider-subtle' />
<FieldInfo
label={t('datasetCreation.stepTwo.indexMode')}
displayedValue={t(`datasetCreation.stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`) as string}
valueIcon={
<Image
className='size-4'
src={
indexingType === IndexingType.ECONOMICAL
? indexMethodIcon.economical
: indexMethodIcon.high_quality
}
alt=''
/>
}
/>
<FieldInfo
label={t('datasetSettings.form.retrievalSetting.title')}
displayedValue={t(`dataset.retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod}.title`) as string}
valueIcon={
<Image
className='size-4'
src={
retrievalMethod === RETRIEVE_METHOD.fullText
? retrievalIcon.fullText
: retrievalMethod === RETRIEVE_METHOD.hybrid
? retrievalIcon.hybrid
: retrievalIcon.vector
}
alt=''
/>
}
/>
</div>
})
RuleDetail.displayName = 'RuleDetail'
const EmbeddingDetail: FC<IEmbeddingDetailProps> = ({
datasetId: dstId,
documentId: docId,
detailUpdate,
indexingType,
retrievalMethod,
}) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const datasetId = useDocumentContext(s => s.datasetId)
const documentId = useDocumentContext(s => s.documentId)
const localDatasetId = dstId ?? datasetId
const localDocumentId = docId ?? documentId
const [indexingStatusDetail, setIndexingStatusDetail] = useState<IndexingStatusResponse | null>(null)
const fetchIndexingStatus = async () => {
const status = await doFetchIndexingStatus({ datasetId: localDatasetId, documentId: localDocumentId })
setIndexingStatusDetail(status)
return status
}
const isStopQuery = useRef(false)
const stopQueryStatus = useCallback(() => {
isStopQuery.current = true
}, [])
const startQueryStatus = useCallback(async () => {
if (isStopQuery.current)
return
try {
const indexingStatusDetail = await fetchIndexingStatus()
if (['completed', 'error', 'paused'].includes(indexingStatusDetail?.indexing_status)) {
stopQueryStatus()
detailUpdate()
return
}
await sleep(2500)
await startQueryStatus()
}
catch {
await sleep(2500)
await startQueryStatus()
}
}, [stopQueryStatus])
useEffect(() => {
isStopQuery.current = false
startQueryStatus()
return () => {
stopQueryStatus()
}
}, [startQueryStatus, stopQueryStatus])
const { data: ruleDetail } = useSWR({
action: 'fetchProcessRule',
params: { documentId: localDocumentId },
}, apiParams => fetchProcessRule(omit(apiParams, 'action')), {
revalidateOnFocus: false,
})
const isEmbedding = useMemo(() => ['indexing', 'splitting', 'parsing', 'cleaning'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
const isEmbeddingCompleted = useMemo(() => ['completed'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
const isEmbeddingPaused = useMemo(() => ['paused'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
const isEmbeddingError = useMemo(() => ['error'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
const percent = useMemo(() => {
const completedCount = indexingStatusDetail?.completed_segments || 0
const totalCount = indexingStatusDetail?.total_segments || 0
if (totalCount === 0)
return 0
const percent = Math.round(completedCount * 100 / totalCount)
return percent > 100 ? 100 : percent
}, [indexingStatusDetail])
const handleSwitch = async () => {
const opApi = isEmbedding ? pauseDocIndexing : resumeDocIndexing
const [e] = await asyncRunSafe<CommonResponse>(opApi({ datasetId: localDatasetId, documentId: localDocumentId }) as Promise<CommonResponse>)
if (!e) {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
// if the embedding is resumed from paused, we need to start the query status
if (isEmbeddingPaused) {
isStopQuery.current = false
startQueryStatus()
detailUpdate()
}
setIndexingStatusDetail(null)
}
else {
notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') })
}
}
return (
<>
<div className='flex flex-col gap-y-2 px-16 py-12'>
<div className='flex h-6 items-center gap-x-1'>
{isEmbedding && <RiLoader2Line className='h-4 w-4 animate-spin text-text-secondary' />}
<span className='system-md-semibold-uppercase grow text-text-secondary'>
{isEmbedding && t('datasetDocuments.embedding.processing')}
{isEmbeddingCompleted && t('datasetDocuments.embedding.completed')}
{isEmbeddingPaused && t('datasetDocuments.embedding.paused')}
{isEmbeddingError && t('datasetDocuments.embedding.error')}
</span>
{isEmbedding && (
<button
type='button'
className={`flex items-center gap-x-1 rounded-md border-[0.5px]
border-components-button-secondary-border bg-components-button-secondary-bg px-1.5 py-1 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]`}
onClick={handleSwitch}
>
<RiPauseCircleLine className='h-3.5 w-3.5 text-components-button-secondary-text' />
<span className='system-xs-medium pr-[3px] text-components-button-secondary-text'>
{t('datasetDocuments.embedding.pause')}
</span>
</button>
)}
{isEmbeddingPaused && (
<button
type='button'
className={`flex items-center gap-x-1 rounded-md border-[0.5px]
border-components-button-secondary-border bg-components-button-secondary-bg px-1.5 py-1 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]`}
onClick={handleSwitch}
>
<RiPlayCircleLine className='h-3.5 w-3.5 text-components-button-secondary-text' />
<span className='system-xs-medium pr-[3px] text-components-button-secondary-text'>
{t('datasetDocuments.embedding.resume')}
</span>
</button>
)}
</div>
{/* progress bar */}
<div className={cn(
'flex h-2 w-full items-center overflow-hidden rounded-md border border-components-progress-bar-border',
isEmbedding ? 'bg-components-progress-bar-bg/50' : 'bg-components-progress-bar-bg',
)}>
<div
className={cn(
'h-full',
(isEmbedding || isEmbeddingCompleted) && 'bg-components-progress-bar-progress-solid',
(isEmbeddingPaused || isEmbeddingError) && 'bg-components-progress-bar-progress-highlight',
)}
style={{ width: `${percent}%` }}
/>
</div>
<div className={'flex w-full items-center'}>
<span className='system-xs-medium text-text-secondary'>
{`${t('datasetDocuments.embedding.segments')} ${indexingStatusDetail?.completed_segments || '--'}/${indexingStatusDetail?.total_segments || '--'} · ${percent}%`}
</span>
</div>
<RuleDetail sourceData={ruleDetail} indexingType={indexingType} retrievalMethod={retrievalMethod} />
</div>
<EmbeddingSkeleton />
</>
)
}
export default React.memo(EmbeddingDetail)

View File

@@ -0,0 +1,66 @@
import React from 'react'
import {
SkeletonContainer,
SkeletonPoint,
SkeletonRectangle,
SkeletonRow,
} from '@/app/components/base/skeleton'
import Divider from '@/app/components/base/divider'
const CardSkelton = React.memo(() => {
return (
<SkeletonContainer className='gap-y-0 p-1 pb-2'>
<SkeletonContainer className='gap-y-0.5 px-2 pt-1.5'>
<SkeletonRow className='py-0.5'>
<SkeletonRectangle className='w-[72px] bg-text-quaternary' />
<SkeletonPoint className='opacity-20' />
<SkeletonRectangle className='w-24 bg-text-quaternary' />
<SkeletonPoint className='opacity-20' />
<SkeletonRectangle className='w-24 bg-text-quaternary' />
<SkeletonRow className='grow justify-end gap-1'>
<SkeletonRectangle className='w-12 bg-text-quaternary' />
<SkeletonRectangle className='mx-1 w-2 bg-text-quaternary' />
</SkeletonRow>
</SkeletonRow>
<SkeletonRow className='py-0.5'>
<SkeletonRectangle className='w-full bg-text-quaternary' />
</SkeletonRow>
<SkeletonRow className='py-0.5'>
<SkeletonRectangle className='w-full bg-text-quaternary' />
</SkeletonRow>
<SkeletonRow className='py-0.5'>
<SkeletonRectangle className='w-2/3 bg-text-quaternary' />
</SkeletonRow>
</SkeletonContainer>
<SkeletonContainer className='px-2 py-1.5'>
<SkeletonRow>
<SkeletonRectangle className='w-14 bg-text-quaternary' />
<SkeletonRectangle className='w-[88px] bg-text-quaternary' />
<SkeletonRectangle className='w-14 bg-text-quaternary' />
</SkeletonRow>
</SkeletonContainer>
</SkeletonContainer>
)
})
CardSkelton.displayName = 'CardSkelton'
const EmbeddingSkeleton = () => {
return (
<div className='relative z-10 flex grow flex-col overflow-y-hidden'>
<div className='absolute left-0 top-0 z-20 h-full w-full bg-dataset-chunk-list-mask-bg' />
{Array.from({ length: 5 }).map((_, index) => {
return (
<div key={index} className='w-full px-11'>
<CardSkelton />
{index !== 9 && <div className='w-full px-3'>
<Divider type='horizontal' className='my-1 bg-divider-subtle' />
</div>}
</div>
)
})}
</div>
)
}
export default React.memo(EmbeddingSkeleton)

View File

@@ -0,0 +1,59 @@
.progressBar {
@apply absolute top-0 h-4;
}
.barPaused {
background: linear-gradient(
270deg,
rgba(208, 213, 221, 0.8) -2.21%,
rgba(208, 213, 221, 0.5) 100%
);
}
.barProcessing {
background: linear-gradient(
90deg,
rgba(41, 112, 255, 0.9) 0%,
rgba(21, 94, 239, 0.9) 100%
);
}
.opBtn {
@apply w-fit h-6 text-xs px-2 py-1 text-gray-700 rounded-md !important;
}
.opIcon {
@apply mr-1 stroke-current text-gray-700 w-3 h-3;
}
.progressContainer {
@apply relative flex mb-2 h-4 rounded-md w-full;
}
.progressBgItem {
@apply flex-1 border-r border-r-white first:rounded-l-md;
}
.progressBgItem:nth-last-child(2) {
@apply rounded-r-md;
}
.progressData {
@apply w-full flex items-center text-xs text-gray-700;
}
.previewTip {
@apply pb-1 pt-12 text-gray-900 text-sm font-medium;
}
.embeddingStatus {
@apply flex items-center justify-between text-gray-900 font-medium text-base mb-3;
}
.commonIcon {
@apply w-3 h-3 mr-1 inline-block align-middle;
}
.highIcon {
mask-image: url(../../assets/star.svg);
@apply bg-orange-500;
}
.economyIcon {
background-color: #444ce7;
mask-image: url(../../assets/normal.svg);
}
.tokens {
@apply text-xs font-medium px-1;
}
.price {
color: #f79009;
@apply text-xs font-medium;
}

View File

@@ -0,0 +1,270 @@
'use client'
import type { FC } from 'react'
import React, { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter } from 'next/navigation'
import { RiArrowLeftLine, RiLayoutLeft2Line, RiLayoutRight2Line } from '@remixicon/react'
import Operations from '../operations'
import StatusItem from '../status-item'
import Completed from './completed'
import Embedding from './embedding'
import Metadata from '@/app/components/datasets/metadata/metadata-document'
import SegmentAdd, { ProcessStatus } from './segment-add'
import BatchModal from './batch-modal'
import style from './style.module.css'
import cn from '@/utils/classnames'
import Divider from '@/app/components/base/divider'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import { ChunkingMode } from '@/models/datasets'
import type { DataSourceInfo, FileItem, LegacyDataSourceInfo } from '@/models/datasets'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import FloatRightContainer from '@/app/components/base/float-right-container'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useCheckSegmentBatchImportProgress, useChildSegmentListKey, useSegmentBatchImport, useSegmentListKey } from '@/service/knowledge/use-segment'
import { useDocumentDetail, useDocumentMetadata, useInvalidDocumentList } from '@/service/knowledge/use-document'
import { useInvalid } from '@/service/use-base'
import { DocumentContext } from './context'
import { DocumentTitle } from './document-title'
type DocumentDetailProps = {
datasetId: string
documentId: string
}
const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
const router = useRouter()
const { t } = useTranslation()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const dataset = useDatasetDetailContextWithSelector(s => s.dataset)
const embeddingAvailable = !!dataset?.embedding_available
const [showMetadata, setShowMetadata] = useState(!isMobile)
const [newSegmentModalVisible, setNewSegmentModalVisible] = useState(false)
const [batchModalVisible, setBatchModalVisible] = useState(false)
const [importStatus, setImportStatus] = useState<ProcessStatus | string>()
const showNewSegmentModal = () => setNewSegmentModalVisible(true)
const showBatchModal = () => setBatchModalVisible(true)
const hideBatchModal = () => setBatchModalVisible(false)
const resetProcessStatus = () => setImportStatus('')
const { mutateAsync: checkSegmentBatchImportProgress } = useCheckSegmentBatchImportProgress()
const checkProcess = async (jobID: string) => {
await checkSegmentBatchImportProgress({ jobID }, {
onSuccess: (res) => {
setImportStatus(res.job_status)
if (res.job_status === ProcessStatus.WAITING || res.job_status === ProcessStatus.PROCESSING)
setTimeout(() => checkProcess(res.job_id), 2500)
if (res.job_status === ProcessStatus.ERROR)
Toast.notify({ type: 'error', message: `${t('datasetDocuments.list.batchModal.runError')}` })
},
onError: (e) => {
const message = 'message' in e ? `: ${e.message}` : ''
Toast.notify({ type: 'error', message: `${t('datasetDocuments.list.batchModal.runError')}${message}` })
},
})
}
const { mutateAsync: segmentBatchImport } = useSegmentBatchImport()
const runBatch = async (csv: FileItem) => {
await segmentBatchImport({
url: `/datasets/${datasetId}/documents/${documentId}/segments/batch_import`,
body: { upload_file_id: csv.file.id! },
}, {
onSuccess: (res) => {
setImportStatus(res.job_status)
checkProcess(res.job_id)
},
onError: (e) => {
const message = 'message' in e ? `: ${e.message}` : ''
Toast.notify({ type: 'error', message: `${t('datasetDocuments.list.batchModal.runError')}${message}` })
},
})
}
const { data: documentDetail, error, refetch: detailMutate } = useDocumentDetail({
datasetId,
documentId,
params: { metadata: 'without' },
})
const { data: documentMetadata } = useDocumentMetadata({
datasetId,
documentId,
params: { metadata: 'only' },
})
const backToPrev = () => {
// Preserve pagination and filter states when navigating back
const searchParams = new URLSearchParams(window.location.search)
const queryString = searchParams.toString()
const separator = queryString ? '?' : ''
const backPath = `/datasets/${datasetId}/documents${separator}${queryString}`
router.push(backPath)
}
const isDetailLoading = !documentDetail && !error
const embedding = ['queuing', 'indexing', 'paused'].includes((documentDetail?.display_status || '').toLowerCase())
const isLegacyDataSourceInfo = (info?: DataSourceInfo): info is LegacyDataSourceInfo => {
return !!info && 'upload_file' in info
}
const documentUploadFile = useMemo(() => {
if (!documentDetail?.data_source_info)
return undefined
if (isLegacyDataSourceInfo(documentDetail.data_source_info))
return documentDetail.data_source_info.upload_file
return undefined
}, [documentDetail?.data_source_info])
const invalidChunkList = useInvalid(useSegmentListKey)
const invalidChildChunkList = useInvalid(useChildSegmentListKey)
const invalidDocumentList = useInvalidDocumentList(datasetId)
const handleOperate = (operateName?: string) => {
invalidDocumentList()
if (operateName === 'delete') {
backToPrev()
}
else {
detailMutate()
// If operation is not rename, refresh the chunk list after 5 seconds
if (operateName) {
setTimeout(() => {
invalidChunkList()
invalidChildChunkList()
}, 5000)
}
}
}
const parentMode = useMemo(() => {
return documentDetail?.document_process_rule?.rules?.parent_mode || documentDetail?.dataset_process_rule?.rules?.parent_mode || 'paragraph'
}, [documentDetail?.document_process_rule?.rules?.parent_mode, documentDetail?.dataset_process_rule?.rules?.parent_mode])
const isFullDocMode = useMemo(() => {
const chunkMode = documentDetail?.doc_form
return chunkMode === ChunkingMode.parentChild && parentMode === 'full-doc'
}, [documentDetail?.doc_form, parentMode])
return (
<DocumentContext.Provider value={{
datasetId,
documentId,
docForm: documentDetail?.doc_form as ChunkingMode,
parentMode,
}}>
<div className='flex h-full flex-col bg-background-default'>
<div className='flex min-h-16 flex-wrap items-center justify-between border-b border-b-divider-subtle py-2.5 pl-3 pr-4'>
<div onClick={backToPrev} className={'flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full hover:bg-components-button-tertiary-bg'}>
<RiArrowLeftLine className='h-4 w-4 text-components-button-ghost-text hover:text-text-tertiary' />
</div>
<DocumentTitle
datasetId={datasetId}
extension={documentUploadFile?.extension}
name={documentDetail?.name}
wrapperCls='mr-2'
parent_mode={parentMode}
chunkingMode={documentDetail?.doc_form as ChunkingMode}
/>
<div className='flex flex-wrap items-center'>
{embeddingAvailable && documentDetail && !documentDetail.archived && !isFullDocMode && (
<>
<SegmentAdd
importStatus={importStatus}
clearProcessStatus={resetProcessStatus}
showNewSegmentModal={showNewSegmentModal}
showBatchModal={showBatchModal}
embedding={embedding}
/>
<Divider type='vertical' className='!mx-3 !h-[14px] !bg-divider-regular' />
</>
)}
<StatusItem
status={documentDetail?.display_status || 'available'}
scene='detail'
errorMessage={documentDetail?.error || ''}
textCls='font-semibold text-xs uppercase'
detail={{
enabled: documentDetail?.enabled || false,
archived: documentDetail?.archived || false,
id: documentId,
}}
datasetId={datasetId}
onUpdate={handleOperate}
/>
<Operations
scene='detail'
embeddingAvailable={embeddingAvailable}
detail={{
name: documentDetail?.name || '',
enabled: documentDetail?.enabled || false,
archived: documentDetail?.archived || false,
id: documentId,
data_source_type: documentDetail?.data_source_type || '',
doc_form: documentDetail?.doc_form || '',
}}
datasetId={datasetId}
onUpdate={handleOperate}
className='!w-[200px]'
/>
<button type="button"
className={style.layoutRightIcon}
onClick={() => setShowMetadata(!showMetadata)}
>
{
showMetadata
? <RiLayoutLeft2Line className='h-4 w-4 text-components-button-secondary-text' />
: <RiLayoutRight2Line className='h-4 w-4 text-components-button-secondary-text' />
}
</button>
</div>
</div>
<div className='flex flex-1 flex-row' style={{ height: 'calc(100% - 4rem)' }}>
{isDetailLoading
? <Loading type='app' />
: <div className={cn('flex h-full min-w-0 grow flex-col',
!embedding && isFullDocMode && 'relative pl-11 pr-11 pt-4',
!embedding && !isFullDocMode && 'relative pl-5 pr-11 pt-3',
)}>
{embedding
? <Embedding
detailUpdate={detailMutate}
indexingType={dataset?.indexing_technique}
retrievalMethod={dataset?.retrieval_model_dict?.search_method}
/>
: <Completed
embeddingAvailable={embeddingAvailable}
showNewSegmentModal={newSegmentModalVisible}
onNewSegmentModalChange={setNewSegmentModalVisible}
importStatus={importStatus}
archived={documentDetail?.archived}
/>
}
</div>
}
<FloatRightContainer showClose isOpen={showMetadata} onClose={() => setShowMetadata(false)} isMobile={isMobile} panelClassName='!justify-start' footer={null}>
<Metadata
className='mr-2 mt-3'
datasetId={datasetId}
documentId={documentId}
docDetail={{ ...documentDetail, ...documentMetadata, doc_type: documentMetadata?.doc_type === 'others' ? '' : documentMetadata?.doc_type } as any}
/>
</FloatRightContainer>
</div>
<BatchModal
isShow={batchModalVisible}
onCancel={hideBatchModal}
onConfirm={runBatch}
docForm={documentDetail?.doc_form as ChunkingMode}
/>
</div>
</DocumentContext.Provider>
)
}
export default DocumentDetail

View File

@@ -0,0 +1,389 @@
'use client'
import type { FC, ReactNode } from 'react'
import React, { useEffect, useState } from 'react'
import { PencilIcon } from '@heroicons/react/24/outline'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { get } from 'lodash-es'
import { useDocumentContext } from '../context'
import s from './style.module.css'
import cn from '@/utils/classnames'
import Input from '@/app/components/base/input'
import Button from '@/app/components/base/button'
import Tooltip from '@/app/components/base/tooltip'
import Radio from '@/app/components/base/radio'
import Divider from '@/app/components/base/divider'
import { ToastContext } from '@/app/components/base/toast'
import { SimpleSelect } from '@/app/components/base/select'
import Loading from '@/app/components/base/loading'
import AutoHeightTextarea from '@/app/components/base/auto-height-textarea'
import { asyncRunSafe, getTextWidthWithCanvas } from '@/utils'
import { modifyDocMetadata } from '@/service/datasets'
import type { CommonResponse } from '@/models/common'
import type { DocType, FullDocumentDetail } from '@/models/datasets'
import { CUSTOMIZABLE_DOC_TYPES } from '@/models/datasets'
import type { inputType, metadataType } from '@/hooks/use-metadata'
import { useBookCategories, useBusinessDocCategories, useLanguages, useMetadataMap, usePersonalDocCategories } from '@/hooks/use-metadata'
const map2Options = (map: { [key: string]: string }) => {
return Object.keys(map).map(key => ({ value: key, name: map[key] }))
}
type IFieldInfoProps = {
label: string
value?: string
valueIcon?: ReactNode
displayedValue?: string
defaultValue?: string
showEdit?: boolean
inputType?: inputType
selectOptions?: Array<{ value: string; name: string }>
onUpdate?: (v: any) => void
}
export const FieldInfo: FC<IFieldInfoProps> = ({
label,
value = '',
valueIcon,
displayedValue = '',
defaultValue,
showEdit = false,
inputType = 'input',
selectOptions = [],
onUpdate,
}) => {
const { t } = useTranslation()
const textNeedWrap = getTextWidthWithCanvas(displayedValue) > 190
const editAlignTop = showEdit && inputType === 'textarea'
const readAlignTop = !showEdit && textNeedWrap
const renderContent = () => {
if (!showEdit)
return displayedValue
if (inputType === 'select') {
return <SimpleSelect
onSelect={({ value }) => onUpdate?.(value as string)}
items={selectOptions}
defaultValue={value}
className={s.select}
wrapperClassName={s.selectWrapper}
placeholder={`${t('datasetDocuments.metadata.placeholder.select')}${label}`}
/>
}
if (inputType === 'textarea') {
return <AutoHeightTextarea
onChange={e => onUpdate?.(e.target.value)}
value={value}
className={s.textArea}
placeholder={`${t('datasetDocuments.metadata.placeholder.add')}${label}`}
/>
}
return <Input
onChange={e => onUpdate?.(e.target.value)}
value={value}
defaultValue={defaultValue}
placeholder={`${t('datasetDocuments.metadata.placeholder.add')}${label}`}
/>
}
return (
<div className={cn('flex min-h-5 items-center gap-1 py-0.5 text-xs', editAlignTop && '!items-start', readAlignTop && '!items-start pt-1')}>
<div className={cn('w-[200px] shrink-0 overflow-hidden text-ellipsis whitespace-nowrap text-text-tertiary', editAlignTop && 'pt-1')}>{label}</div>
<div className="flex grow items-center gap-1 text-text-secondary">
{valueIcon}
{renderContent()}
</div>
</div>
)
}
const TypeIcon: FC<{ iconName: string; className?: string }> = ({ iconName, className = '' }) => {
return <div className={cn(s.commonIcon, s[`${iconName}Icon`], className)}
/>
}
const IconButton: FC<{
type: DocType
isChecked: boolean
}> = ({ type, isChecked = false }) => {
const metadataMap = useMetadataMap()
return (
<Tooltip
popupContent={metadataMap[type].text}
>
<button type="button" className={cn(s.iconWrapper, 'group', isChecked ? s.iconCheck : '')}>
<TypeIcon
iconName={metadataMap[type].iconName || ''}
className={`group-hover:bg-primary-600 ${isChecked ? '!bg-primary-600' : ''}`}
/>
</button>
</Tooltip>
)
}
type IMetadataProps = {
docDetail?: FullDocumentDetail
loading: boolean
onUpdate: () => void
}
type MetadataState = {
documentType?: DocType | ''
metadata: Record<string, string>
}
const Metadata: FC<IMetadataProps> = ({ docDetail, loading, onUpdate }) => {
const { doc_metadata = {} } = docDetail || {}
const rawDocType = docDetail?.doc_type ?? ''
const doc_type = rawDocType === 'others' ? '' : rawDocType
const { t } = useTranslation()
const metadataMap = useMetadataMap()
const languageMap = useLanguages()
const bookCategoryMap = useBookCategories()
const personalDocCategoryMap = usePersonalDocCategories()
const businessDocCategoryMap = useBusinessDocCategories()
const [editStatus, setEditStatus] = useState(!doc_type) // if no documentType, in editing status by default
// the initial values are according to the documentType
const [metadataParams, setMetadataParams] = useState<MetadataState>(
doc_type
? {
documentType: doc_type as DocType,
metadata: (doc_metadata || {}) as Record<string, string>,
}
: { metadata: {} },
)
const [showDocTypes, setShowDocTypes] = useState(!doc_type) // whether show doc types
const [tempDocType, setTempDocType] = useState<DocType | ''>('') // for remember icon click
const [saveLoading, setSaveLoading] = useState(false)
const { notify } = useContext(ToastContext)
const datasetId = useDocumentContext(s => s.datasetId)
const documentId = useDocumentContext(s => s.documentId)
useEffect(() => {
if (docDetail?.doc_type) {
setEditStatus(false)
setShowDocTypes(false)
setTempDocType(doc_type as DocType | '')
setMetadataParams({
documentType: doc_type as DocType | '',
metadata: (docDetail?.doc_metadata || {}) as Record<string, string>,
})
}
}, [docDetail?.doc_type, docDetail?.doc_metadata, doc_type])
// confirm doc type
const confirmDocType = () => {
if (!tempDocType)
return
setMetadataParams({
documentType: tempDocType,
metadata: tempDocType === metadataParams.documentType ? metadataParams.metadata : {} as Record<string, string>, // change doc type, clear metadata
})
setEditStatus(true)
setShowDocTypes(false)
}
// cancel doc type
const cancelDocType = () => {
setTempDocType(metadataParams.documentType ?? '')
setEditStatus(true)
setShowDocTypes(false)
}
// show doc type select
const renderSelectDocType = () => {
const { documentType } = metadataParams
return (
<>
{!doc_type && !documentType && <>
<div className={s.desc}>{t('datasetDocuments.metadata.desc')}</div>
</>}
<div className={s.operationWrapper}>
{!doc_type && !documentType && <>
<span className={s.title}>{t('datasetDocuments.metadata.docTypeSelectTitle')}</span>
</>}
{documentType && <>
<span className={s.title}>{t('datasetDocuments.metadata.docTypeChangeTitle')}</span>
<span className={s.changeTip}>{t('datasetDocuments.metadata.docTypeSelectWarning')}</span>
</>}
<Radio.Group value={tempDocType ?? documentType ?? ''} onChange={setTempDocType} className={s.radioGroup}>
{CUSTOMIZABLE_DOC_TYPES.map((type, index) => {
const currValue = tempDocType ?? documentType
return <Radio key={index} value={type} className={`${s.radio} ${currValue === type ? 'shadow-none' : ''}`}>
<IconButton
type={type}
isChecked={currValue === type}
/>
</Radio>
})}
</Radio.Group>
{!doc_type && !documentType && (
<Button variant='primary'
onClick={confirmDocType}
disabled={!tempDocType}
>
{t('datasetDocuments.metadata.firstMetaAction')}
</Button>
)}
{documentType && <div className={s.opBtnWrapper}>
<Button onClick={confirmDocType} className={`${s.opBtn} ${s.opSaveBtn}`} variant='primary' >{t('common.operation.save')}</Button>
<Button onClick={cancelDocType} className={`${s.opBtn} ${s.opCancelBtn}`}>{t('common.operation.cancel')}</Button>
</div>}
</div >
</>
)
}
// show metadata info and edit
const renderFieldInfos = ({ mainField = 'book', canEdit }: { mainField?: metadataType | ''; canEdit?: boolean }) => {
if (!mainField)
return null
const fieldMap = metadataMap[mainField]?.subFieldsMap
const sourceData = ['originInfo', 'technicalParameters'].includes(mainField) ? docDetail : metadataParams.metadata
const getTargetMap = (field: string) => {
if (field === 'language')
return languageMap
if (field === 'category' && mainField === 'book')
return bookCategoryMap
if (field === 'document_type') {
if (mainField === 'personal_document')
return personalDocCategoryMap
if (mainField === 'business_document')
return businessDocCategoryMap
}
return {} as any
}
const getTargetValue = (field: string) => {
const val = get(sourceData, field, '')
if (!val && val !== 0)
return '-'
if (fieldMap[field]?.inputType === 'select')
return getTargetMap(field)[val]
if (fieldMap[field]?.render)
return fieldMap[field]?.render?.(val, field === 'hit_count' ? get(sourceData, 'segment_count', 0) as number : undefined)
return val
}
return <div className='flex flex-col gap-1'>
{Object.keys(fieldMap).map((field) => {
return <FieldInfo
key={fieldMap[field]?.label}
label={fieldMap[field]?.label}
displayedValue={getTargetValue(field)}
value={get(sourceData, field, '')}
inputType={fieldMap[field]?.inputType || 'input'}
showEdit={canEdit}
onUpdate={(val) => {
setMetadataParams(pre => ({ ...pre, metadata: { ...pre.metadata, [field]: val } }))
}}
selectOptions={map2Options(getTargetMap(field))}
/>
})}
</div>
}
const enabledEdit = () => {
setEditStatus(true)
}
const onCancel = () => {
setMetadataParams({ documentType: doc_type || '', metadata: { ...docDetail?.doc_metadata } })
setEditStatus(!doc_type)
if (!doc_type)
setShowDocTypes(true)
}
const onSave = async () => {
setSaveLoading(true)
const [e] = await asyncRunSafe<CommonResponse>(modifyDocMetadata({
datasetId,
documentId,
body: {
doc_type: metadataParams.documentType || doc_type || '',
doc_metadata: metadataParams.metadata,
},
}) as Promise<CommonResponse>)
if (!e)
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
else
notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') })
onUpdate?.()
setEditStatus(false)
setSaveLoading(false)
}
return (
<div className={`${s.main} ${editStatus ? 'bg-white' : 'bg-gray-25'}`}>
{loading
? (<Loading type='app' />)
: (
<>
<div className={s.titleWrapper}>
<span className={s.title}>{t('datasetDocuments.metadata.title')}</span>
{!editStatus
? <Button onClick={enabledEdit} className={`${s.opBtn} ${s.opEditBtn}`}>
<PencilIcon className={s.opIcon} />
{t('common.operation.edit')}
</Button>
: showDocTypes
? null
: <div className={s.opBtnWrapper}>
<Button onClick={onCancel} className={`${s.opBtn} ${s.opCancelBtn}`}>{t('common.operation.cancel')}</Button>
<Button onClick={onSave}
className={`${s.opBtn} ${s.opSaveBtn}`}
variant='primary'
loading={saveLoading}
>
{t('common.operation.save')}
</Button>
</div>}
</div>
{/* show selected doc type and changing entry */}
{!editStatus
? <div className={s.documentTypeShow}>
<TypeIcon iconName={metadataMap[doc_type || 'book']?.iconName || ''} className={s.iconShow} />
{metadataMap[doc_type || 'book'].text}
</div>
: showDocTypes
? null
: <div className={s.documentTypeShow}>
{metadataParams.documentType && <>
<TypeIcon iconName={metadataMap[metadataParams.documentType || 'book'].iconName || ''} className={s.iconShow} />
{metadataMap[metadataParams.documentType || 'book'].text}
{editStatus && <div className='ml-1 inline-flex items-center gap-1'>
·
<div
onClick={() => { setShowDocTypes(true) }}
className='cursor-pointer hover:text-text-accent'
>
{t('common.operation.change')}
</div>
</div>}
</>}
</div>
}
{(!doc_type && showDocTypes) ? null : <Divider />}
{showDocTypes ? renderSelectDocType() : renderFieldInfos({ mainField: metadataParams.documentType, canEdit: editStatus })}
{/* show fixed fields */}
<Divider />
{renderFieldInfos({ mainField: 'originInfo', canEdit: false })}
<div className={`${s.title} mt-8`}>{metadataMap.technicalParameters.text}</div>
<Divider />
{renderFieldInfos({ mainField: 'technicalParameters', canEdit: false })}
</>
)}
</div>
)
}
export default Metadata

View File

@@ -0,0 +1,103 @@
.main {
@apply w-full sm:w-96 xl:w-[360px] flex-shrink-0 p-0 sm:px-6 sm:py-5 overflow-y-auto border-none sm:border-l-gray-100 sm:border-l;
}
.operationWrapper {
@apply flex flex-col items-center gap-4 mt-7 mb-8;
}
.iconWrapper {
@apply box-border cursor-pointer h-8 w-8 inline-flex items-center justify-center;
@apply border-[#EAECF5] border rounded-lg hover:border-primary-200 hover:bg-primary-25 hover:shadow-md;
}
.icon {
@apply h-4 w-4 stroke-current stroke-[2px] text-gray-700 group-hover:stroke-primary-600;
}
.iconCheck {
@apply border-primary-400 border-[1.5px] bg-primary-25 shadow-sm !important;
}
.commonIcon {
@apply w-4 h-4 inline-block align-middle bg-gray-700 hover:bg-primary-600;
}
.bookOpenIcon {
mask-image: url(../../assets/bookOpen.svg);
}
.globeIcon {
mask-image: url(../../assets/globe.svg);
}
.graduationHatIcon {
mask-image: url(../../assets/graduationHat.svg);
}
.fileIcon {
mask-image: url(../../assets/file.svg);
}
.briefcaseIcon {
mask-image: url(../../assets/briefcase.svg);
}
.atSignIcon {
mask-image: url(../../assets/atSign.svg);
}
.messageTextCircleIcon {
mask-image: url(../../assets/messageTextCircle.svg);
}
.radioGroup {
@apply !bg-transparent !gap-2;
}
.radio {
@apply !p-0 !mr-0 hover:bg-transparent !rounded-lg;
}
.title {
@apply text-sm text-gray-800 font-medium leading-6;
}
.titleWrapper {
@apply flex items-center justify-between;
}
.desc {
@apply text-gray-500 text-xs;
}
.changeTip {
@apply text-[#D92D20] text-xs text-center;
}
.opBtnWrapper {
@apply flex items-center justify-center gap-1;
}
.opBtn {
@apply h-6 w-14 px-0 text-xs font-medium rounded-md !important;
}
.opEditBtn {
box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05);
@apply border-[0.5px] border-gray-200 bg-white !important;
}
.opCancelBtn {
@apply border-none bg-gray-50 font-medium text-gray-700 hover:bg-gray-100 !important;
}
.opSaveBtn {
@apply border-primary-700 border-[0.5px] font-medium hover:border-none !important;
}
.opIcon {
@apply h-3 w-3 stroke-current stroke-2 mr-1;
}
.select {
@apply h-7 py-0 pl-2 text-xs bg-gray-50 hover:bg-gray-100 rounded-md shadow-none !important;
}
.selectWrapper {
@apply !h-7 w-full
}
.selectWrapper ul {
@apply text-xs
}
.selectWrapper li {
@apply flex items-center h-8
}
.documentTypeShow {
@apply flex items-center text-xs text-gray-500;
}
.iconShow {
mask-size: contain;
@apply w-3 h-3 bg-gray-500 hover:bg-none mr-1 !important;
}
.textArea {
@apply placeholder:text-gray-400 bg-gray-50 px-2 py-1 caret-primary-600 rounded-md hover:bg-gray-100 focus-visible:outline-none focus-visible:bg-white focus-visible:border focus-visible:border-gray-300 hover:shadow-[0_1px_2px_rgba(16,24,40,0.05);];
}
.input {
@apply bg-gray-50 hover:bg-gray-100 focus-visible:bg-white !important
}

View File

@@ -0,0 +1,213 @@
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useParams } from 'next/navigation'
import { RiCloseLine, RiExpandDiagonalLine } from '@remixicon/react'
import { useShallow } from 'zustand/react/shallow'
import { useSegmentListContext } from './completed'
import { SegmentIndexTag } from './completed/common/segment-index-tag'
import ActionButtons from './completed/common/action-buttons'
import Keywords from './completed/common/keywords'
import ChunkContent from './completed/common/chunk-content'
import AddAnother from './completed/common/add-another'
import Dot from './completed/common/dot'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ToastContext } from '@/app/components/base/toast'
import { ChunkingMode, type SegmentUpdater } from '@/models/datasets'
import classNames from '@/utils/classnames'
import { formatNumber } from '@/utils/format'
import Divider from '@/app/components/base/divider'
import { useAddSegment } from '@/service/knowledge/use-segment'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { IndexingType } from '../../create/step-two'
type NewSegmentModalProps = {
onCancel: () => void
docForm: ChunkingMode
onSave: () => void
viewNewlyAddedChunk: () => void
}
const NewSegmentModal: FC<NewSegmentModalProps> = ({
onCancel,
docForm,
onSave,
viewNewlyAddedChunk,
}) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [question, setQuestion] = useState('')
const [answer, setAnswer] = useState('')
const { datasetId, documentId } = useParams<{ datasetId: string; documentId: string }>()
const [keywords, setKeywords] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [addAnother, setAddAnother] = useState(true)
const fullScreen = useSegmentListContext(s => s.fullScreen)
const toggleFullScreen = useSegmentListContext(s => s.toggleFullScreen)
const indexingTechnique = useDatasetDetailContextWithSelector(s => s.dataset?.indexing_technique)
const { appSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
})))
const refreshTimer = useRef<any>(null)
const CustomButton = useMemo(() => (
<>
<Divider type='vertical' className='mx-1 h-3 bg-divider-regular' />
<button
type='button'
className='system-xs-semibold text-text-accent'
onClick={() => {
clearTimeout(refreshTimer.current)
viewNewlyAddedChunk()
}}>
{t('common.operation.view')}
</button>
</>
), [viewNewlyAddedChunk, t])
const handleCancel = useCallback((actionType: 'esc' | 'add' = 'esc') => {
if (actionType === 'esc' || !addAnother)
onCancel()
}, [onCancel, addAnother])
const { mutateAsync: addSegment } = useAddSegment()
const handleSave = useCallback(async () => {
const params: SegmentUpdater = { content: '' }
if (docForm === ChunkingMode.qa) {
if (!question.trim()) {
return notify({
type: 'error',
message: t('datasetDocuments.segment.questionEmpty'),
})
}
if (!answer.trim()) {
return notify({
type: 'error',
message: t('datasetDocuments.segment.answerEmpty'),
})
}
params.content = question
params.answer = answer
}
else {
if (!question.trim()) {
return notify({
type: 'error',
message: t('datasetDocuments.segment.contentEmpty'),
})
}
params.content = question
}
if (keywords?.length)
params.keywords = keywords
setLoading(true)
await addSegment({ datasetId, documentId, body: params }, {
onSuccess() {
notify({
type: 'success',
message: t('datasetDocuments.segment.chunkAdded'),
className: `!w-[296px] !bottom-0 ${appSidebarExpand === 'expand' ? '!left-[216px]' : '!left-14'}
!top-auto !right-auto !mb-[52px] !ml-11`,
customComponent: CustomButton,
})
handleCancel('add')
setQuestion('')
setAnswer('')
setKeywords([])
refreshTimer.current = setTimeout(() => {
onSave()
}, 3000)
},
onSettled() {
setLoading(false)
},
})
}, [docForm, keywords, addSegment, datasetId, documentId, question, answer, notify, t, appSidebarExpand, CustomButton, handleCancel, onSave])
const wordCountText = useMemo(() => {
const count = docForm === ChunkingMode.qa ? (question.length + answer.length) : question.length
return `${formatNumber(count)} ${t('datasetDocuments.segment.characters', { count })}`
}, [question.length, answer.length, docForm, t])
const isECOIndexing = indexingTechnique === IndexingType.ECONOMICAL
return (
<div className={'flex h-full flex-col'}>
<div
className={classNames(
'flex items-center justify-between',
fullScreen ? 'border border-divider-subtle py-3 pl-6 pr-4' : 'pl-4 pr-3 pt-3',
)}
>
<div className='flex flex-col'>
<div className='system-xl-semibold text-text-primary'>
{t('datasetDocuments.segment.addChunk')}
</div>
<div className='flex items-center gap-x-2'>
<SegmentIndexTag label={t('datasetDocuments.segment.newChunk')!} />
<Dot />
<span className='system-xs-medium text-text-tertiary'>{wordCountText}</span>
</div>
</div>
<div className='flex items-center'>
{fullScreen && (
<>
<AddAnother className='mr-3' isChecked={addAnother} onCheck={() => setAddAnother(!addAnother)} />
<ActionButtons
handleCancel={handleCancel.bind(null, 'esc')}
handleSave={handleSave}
loading={loading}
actionType='add'
/>
<Divider type='vertical' className='ml-4 mr-2 h-3.5 bg-divider-regular' />
</>
)}
<div className='mr-1 flex h-8 w-8 cursor-pointer items-center justify-center p-1.5' onClick={toggleFullScreen}>
<RiExpandDiagonalLine className='h-4 w-4 text-text-tertiary' />
</div>
<div className='flex h-8 w-8 cursor-pointer items-center justify-center p-1.5' onClick={handleCancel.bind(null, 'esc')}>
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
</div>
</div>
</div>
<div className={classNames('flex grow', fullScreen ? 'w-full flex-row justify-center gap-x-8 px-6 pt-6' : 'flex-col gap-y-1 px-4 py-3')}>
<div className={classNames('overflow-hidden whitespace-pre-line break-all', fullScreen ? 'w-1/2' : 'grow')}>
<ChunkContent
docForm={docForm}
question={question}
answer={answer}
onQuestionChange={question => setQuestion(question)}
onAnswerChange={answer => setAnswer(answer)}
isEditMode={true}
/>
</div>
{isECOIndexing && <Keywords
className={fullScreen ? 'w-1/5' : ''}
actionType='add'
keywords={keywords}
isEditMode={true}
onKeywordsChange={keywords => setKeywords(keywords)}
/>}
</div>
{!fullScreen && (
<div className='flex items-center justify-between border-t-[1px] border-t-divider-subtle p-4 pt-3'>
<AddAnother isChecked={addAnother} onCheck={() => setAddAnother(!addAnother)} />
<ActionButtons
handleCancel={handleCancel.bind(null, 'esc')}
handleSave={handleSave}
loading={loading}
actionType='add'
/>
</div>
)}
</div>
)
}
export default memo(NewSegmentModal)

View File

@@ -0,0 +1,135 @@
'use client'
import type { FC } from 'react'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiAddLine,
RiArrowDownSLine,
RiErrorWarningFill,
RiLoader2Line,
} from '@remixicon/react'
import cn from '@/utils/classnames'
import { CheckCircle } from '@/app/components/base/icons/src/vender/solid/general'
import Popover from '@/app/components/base/popover'
export type ISegmentAddProps = {
importStatus: ProcessStatus | string | undefined
clearProcessStatus: () => void
showNewSegmentModal: () => void
showBatchModal: () => void
embedding: boolean
}
export enum ProcessStatus {
WAITING = 'waiting',
PROCESSING = 'processing',
COMPLETED = 'completed',
ERROR = 'error',
}
const SegmentAdd: FC<ISegmentAddProps> = ({
importStatus,
clearProcessStatus,
showNewSegmentModal,
showBatchModal,
embedding,
}) => {
const { t } = useTranslation()
const textColor = useMemo(() => {
return embedding
? 'text-components-button-secondary-accent-text-disabled'
: 'text-components-button-secondary-accent-text'
}, [embedding])
if (importStatus) {
return (
<>
{(importStatus === ProcessStatus.WAITING || importStatus === ProcessStatus.PROCESSING) && (
<div className='relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-progress-bar-border
bg-components-progress-bar-border px-2.5 py-2 text-components-button-secondary-accent-text
shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]'>
<div className={cn('absolute left-0 top-0 z-0 h-full border-r-[1.5px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress', importStatus === ProcessStatus.WAITING ? 'w-3/12' : 'w-2/3')} />
<RiLoader2Line className='mr-1 h-4 w-4 animate-spin' />
<span className='system-sm-medium z-10 pr-0.5'>{t('datasetDocuments.list.batchModal.processing')}</span>
</div>
)}
{importStatus === ProcessStatus.COMPLETED && (
<div className='relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]'>
<div className='inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-success'>
<CheckCircle className='mr-1 h-4 w-4' />
<span className='system-sm-medium pr-0.5'>{t('datasetDocuments.list.batchModal.completed')}</span>
</div>
<div className='m-1 inline-flex items-center'>
<span className='system-xs-medium cursor-pointer rounded-md px-1.5 py-1 text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover' onClick={clearProcessStatus}>{t('datasetDocuments.list.batchModal.ok')}</span>
</div>
<div className='absolute left-0 top-0 -z-10 h-full w-full bg-dataset-chunk-process-success-bg opacity-40' />
</div>
)}
{importStatus === ProcessStatus.ERROR && (
<div className='relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]'>
<div className='inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-destructive'>
<RiErrorWarningFill className='mr-1 h-4 w-4' />
<span className='system-sm-medium pr-0.5'>{t('datasetDocuments.list.batchModal.error')}</span>
</div>
<div className='m-1 inline-flex items-center'>
<span className='system-xs-medium cursor-pointer rounded-md px-1.5 py-1 text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover' onClick={clearProcessStatus}>{t('datasetDocuments.list.batchModal.ok')}</span>
</div>
<div className='absolute left-0 top-0 -z-10 h-full w-full bg-dataset-chunk-process-error-bg opacity-40' />
</div>
)}
</>
)
}
return (
<div className={cn(
'relative z-20 flex items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]',
embedding && 'border-components-button-secondary-border-disabled bg-components-button-secondary-bg-disabled',
)}>
<button
type='button'
className={`inline-flex items-center rounded-l-lg border-r-[1px] border-r-divider-subtle px-2.5 py-2
hover:bg-state-base-hover disabled:cursor-not-allowed disabled:hover:bg-transparent`}
onClick={showNewSegmentModal}
disabled={embedding}
>
<RiAddLine className={cn('h-4 w-4', textColor)} />
<span className={cn('ml-0.5 px-0.5 text-[13px] font-medium capitalize leading-[16px]', textColor)}>
{t('datasetDocuments.list.action.addButton')}
</span>
</button>
<Popover
position='br'
manualClose
trigger='click'
htmlContent={
// need to wrapper the button with div when manualClose is true
<div className='w-full p-1'>
<button
type='button'
className='system-md-regular flex w-full items-center rounded-lg px-2 py-1.5 text-text-secondary'
onClick={showBatchModal}
>
{t('datasetDocuments.list.action.batchAdd')}
</button>
</div>
}
btnElement={
<div className='flex items-center justify-center' >
<RiArrowDownSLine className={cn('h-4 w-4', textColor)}/>
</div>
}
btnClassName={open => cn(
`!hover:bg-state-base-hover !rounded-l-none !rounded-r-lg !border-0 !p-2 backdrop-blur-[5px]
disabled:cursor-not-allowed disabled:bg-transparent disabled:hover:bg-transparent`,
open ? '!bg-state-base-hover' : '',
)}
popupClassName='!min-w-[128px] !bg-components-panel-bg-blur !rounded-xl border-[0.5px] !ring-0
border-components-panel-border !shadow-xl !shadow-shadow-shadow-5 backdrop-blur-[5px]'
className='h-fit min-w-[128px]'
disabled={embedding}
/>
</div>
)
}
export default React.memo(SegmentAdd)

View File

@@ -0,0 +1,163 @@
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import { useContext } from 'use-context-selector'
import { useRouter } from 'next/navigation'
import DatasetDetailContext from '@/context/dataset-detail'
import type {
CrawlOptions,
CustomFile,
DataSourceInfo,
DataSourceType,
LegacyDataSourceInfo,
LocalFileInfo,
OnlineDocumentInfo,
WebsiteCrawlInfo,
} from '@/models/datasets'
import type { DataSourceProvider } from '@/models/common'
import Loading from '@/app/components/base/loading'
import StepTwo from '@/app/components/datasets/create/step-two'
import AccountSetting from '@/app/components/header/account-setting'
import AppUnavailable from '@/app/components/base/app-unavailable'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { NotionPage } from '@/models/common'
import { useDocumentDetail, useInvalidDocumentDetail, useInvalidDocumentList } from '@/service/knowledge/use-document'
type DocumentSettingsProps = {
datasetId: string
documentId: string
}
const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => {
const { t } = useTranslation()
const router = useRouter()
const [isShowSetAPIKey, { setTrue: showSetAPIKey, setFalse: hideSetAPIkey }] = useBoolean()
const { indexingTechnique, dataset } = useContext(DatasetDetailContext)
const { data: embeddingsDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding)
const invalidDocumentList = useInvalidDocumentList(datasetId)
const invalidDocumentDetail = useInvalidDocumentDetail()
const saveHandler = () => {
invalidDocumentList()
invalidDocumentDetail()
router.push(`/datasets/${datasetId}/documents/${documentId}`)
}
const cancelHandler = () => router.back()
const { data: documentDetail, error } = useDocumentDetail({
datasetId,
documentId,
params: { metadata: 'without' },
})
const dataSourceInfo = documentDetail?.data_source_info
const isLegacyDataSourceInfo = (info: DataSourceInfo | undefined): info is LegacyDataSourceInfo => {
return !!info && 'upload_file' in info
}
const isWebsiteCrawlInfo = (info: DataSourceInfo | undefined): info is WebsiteCrawlInfo => {
return !!info && 'source_url' in info && 'title' in info
}
const isOnlineDocumentInfo = (info: DataSourceInfo | undefined): info is OnlineDocumentInfo => {
return !!info && 'page' in info
}
const isLocalFileInfo = (info: DataSourceInfo | undefined): info is LocalFileInfo => {
return !!info && 'related_id' in info && 'transfer_method' in info
}
const legacyInfo = isLegacyDataSourceInfo(dataSourceInfo) ? dataSourceInfo : undefined
const websiteInfo = isWebsiteCrawlInfo(dataSourceInfo) ? dataSourceInfo : undefined
const onlineDocumentInfo = isOnlineDocumentInfo(dataSourceInfo) ? dataSourceInfo : undefined
const localFileInfo = isLocalFileInfo(dataSourceInfo) ? dataSourceInfo : undefined
const currentPage = useMemo(() => {
if (legacyInfo) {
return {
workspace_id: legacyInfo.notion_workspace_id ?? '',
page_id: legacyInfo.notion_page_id ?? '',
page_name: documentDetail?.name,
page_icon: legacyInfo.notion_page_icon,
type: documentDetail?.data_source_type,
}
}
if (onlineDocumentInfo) {
return {
workspace_id: onlineDocumentInfo.workspace_id,
page_id: onlineDocumentInfo.page.page_id,
page_name: onlineDocumentInfo.page.page_name,
page_icon: onlineDocumentInfo.page.page_icon,
type: onlineDocumentInfo.page.type,
}
}
return undefined
}, [documentDetail?.data_source_type, documentDetail?.name, legacyInfo, onlineDocumentInfo])
const files = useMemo<CustomFile[]>(() => {
if (legacyInfo?.upload_file)
return [legacyInfo.upload_file as CustomFile]
if (localFileInfo) {
const { related_id, name, extension } = localFileInfo
return [{
id: related_id,
name,
extension,
} as unknown as CustomFile]
}
return []
}, [legacyInfo?.upload_file, localFileInfo])
const websitePages = useMemo(() => {
if (!websiteInfo)
return []
return [{
title: websiteInfo.title,
source_url: websiteInfo.source_url,
content: websiteInfo.content,
description: websiteInfo.description,
}]
}, [websiteInfo])
const crawlOptions = (dataSourceInfo && typeof dataSourceInfo === 'object' && 'includes' in dataSourceInfo && 'excludes' in dataSourceInfo)
? dataSourceInfo as unknown as CrawlOptions
: undefined
const websiteCrawlProvider = (websiteInfo?.provider ?? legacyInfo?.provider) as DataSourceProvider | undefined
const websiteCrawlJobId = websiteInfo?.job_id ?? legacyInfo?.job_id
if (error)
return <AppUnavailable code={500} unknownReason={t('datasetCreation.error.unavailable') as string} />
return (
<div className='flex' style={{ height: 'calc(100vh - 56px)' }}>
<div className='grow'>
{!documentDetail && <Loading type='app' />}
{dataset && documentDetail && (
<StepTwo
isAPIKeySet={!!embeddingsDefaultModel}
onSetting={showSetAPIKey}
datasetId={datasetId}
dataSourceType={documentDetail.data_source_type as DataSourceType}
notionPages={currentPage ? [currentPage as unknown as NotionPage] : []}
notionCredentialId={legacyInfo?.credential_id || onlineDocumentInfo?.credential_id || ''}
websitePages={websitePages}
websiteCrawlProvider={websiteCrawlProvider}
websiteCrawlJobId={websiteCrawlJobId || ''}
crawlOptions={crawlOptions}
indexingType={indexingTechnique}
isSetting
documentDetail={documentDetail}
files={files}
onSave={saveHandler}
onCancel={cancelHandler}
/>
)}
</div>
{isShowSetAPIKey && <AccountSetting activeTab='provider' onCancel={async () => {
hideSetAPIkey()
}} />}
</div>
)
}
export default DocumentSettings

View File

@@ -0,0 +1,36 @@
'use client'
import React from 'react'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import DocumentSettings from './document-settings'
import PipelineSettings from './pipeline-settings'
type SettingsProps = {
datasetId: string
documentId: string
}
const Settings = ({
datasetId,
documentId,
}: SettingsProps) => {
const runtimeMode = useDatasetDetailContextWithSelector(s => s.dataset?.runtime_mode)
const isGeneralDataset = runtimeMode === 'general'
if (isGeneralDataset) {
return (
<DocumentSettings
datasetId={datasetId}
documentId={documentId}
/>
)
}
return (
<PipelineSettings
datasetId={datasetId}
documentId={documentId}
/>
)
}
export default Settings

View File

@@ -0,0 +1,210 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import type { CrawlResultItem, CustomFile, FileIndexingEstimateResponse } from '@/models/datasets'
import type { NotionPage } from '@/models/common'
import { useTranslation } from 'react-i18next'
import AppUnavailable from '@/app/components/base/app-unavailable'
import ChunkPreview from '../../../create-from-pipeline/preview/chunk-preview'
import Loading from '@/app/components/base/loading'
import ProcessDocuments from './process-documents'
import LeftHeader from './left-header'
import { usePipelineExecutionLog, useRunPublishedPipeline } from '@/service/use-pipeline'
import type { OnlineDriveFile, PublishedPipelineRunPreviewResponse } from '@/models/pipeline'
import { DatasourceType } from '@/models/pipeline'
import { noop } from 'lodash-es'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useRouter } from 'next/navigation'
import { useInvalidDocumentDetail, useInvalidDocumentList } from '@/service/knowledge/use-document'
type PipelineSettingsProps = {
datasetId: string
documentId: string
}
const PipelineSettings = ({
datasetId,
documentId,
}: PipelineSettingsProps) => {
const { t } = useTranslation()
const { push } = useRouter()
const [estimateData, setEstimateData] = useState<FileIndexingEstimateResponse | undefined>(undefined)
const pipelineId = useDatasetDetailContextWithSelector(state => state.dataset?.pipeline_id)
const isPreview = useRef(false)
const formRef = useRef<any>(null)
const { data: lastRunData, isFetching: isFetchingLastRunData, isError } = usePipelineExecutionLog({
dataset_id: datasetId,
document_id: documentId,
})
const files = useMemo(() => {
const files: CustomFile[] = []
if (lastRunData?.datasource_type === DatasourceType.localFile) {
const { related_id, name, extension } = lastRunData.datasource_info
files.push({
id: related_id,
name,
extension,
} as CustomFile)
}
return files
}, [lastRunData])
const websitePages = useMemo(() => {
const websitePages: CrawlResultItem[] = []
if (lastRunData?.datasource_type === DatasourceType.websiteCrawl) {
const { content, description, source_url, title } = lastRunData.datasource_info
websitePages.push({
content,
description,
source_url,
title,
})
}
return websitePages
}, [lastRunData])
const onlineDocuments = useMemo(() => {
const onlineDocuments: NotionPage[] = []
if (lastRunData?.datasource_type === DatasourceType.onlineDocument) {
const { workspace_id, page } = lastRunData.datasource_info
onlineDocuments.push({
workspace_id,
...page,
})
}
return onlineDocuments
}, [lastRunData])
const onlineDriveFiles = useMemo(() => {
const onlineDriveFiles: OnlineDriveFile[] = []
if (lastRunData?.datasource_type === DatasourceType.onlineDrive) {
const { id, type, name, size } = lastRunData.datasource_info
onlineDriveFiles.push({
id,
name,
type,
size,
})
}
return onlineDriveFiles
}, [lastRunData])
const { mutateAsync: runPublishedPipeline, isIdle, isPending } = useRunPublishedPipeline()
const handlePreviewChunks = useCallback(async (data: Record<string, any>) => {
if (!lastRunData)
return
const datasourceInfoList: Record<string, any>[] = []
const documentInfo = lastRunData.datasource_info
datasourceInfoList.push(documentInfo)
await runPublishedPipeline({
pipeline_id: pipelineId!,
inputs: data,
start_node_id: lastRunData.datasource_node_id,
datasource_type: lastRunData.datasource_type,
datasource_info_list: datasourceInfoList,
is_preview: true,
}, {
onSuccess: (res) => {
setEstimateData((res as PublishedPipelineRunPreviewResponse).data.outputs)
},
})
}, [lastRunData, pipelineId, runPublishedPipeline])
const invalidDocumentList = useInvalidDocumentList(datasetId)
const invalidDocumentDetail = useInvalidDocumentDetail()
const handleProcess = useCallback(async (data: Record<string, any>) => {
if (!lastRunData)
return
const datasourceInfoList: Record<string, any>[] = []
const documentInfo = lastRunData.datasource_info
datasourceInfoList.push(documentInfo)
await runPublishedPipeline({
pipeline_id: pipelineId!,
inputs: data,
start_node_id: lastRunData.datasource_node_id,
datasource_type: lastRunData.datasource_type,
datasource_info_list: datasourceInfoList,
original_document_id: documentId,
is_preview: false,
}, {
onSuccess: () => {
invalidDocumentList()
invalidDocumentDetail()
push(`/datasets/${datasetId}/documents`)
},
})
}, [datasetId, invalidDocumentDetail, invalidDocumentList, lastRunData, pipelineId, push, runPublishedPipeline])
const onClickProcess = useCallback(() => {
isPreview.current = false
formRef.current?.submit()
}, [])
const onClickPreview = useCallback(() => {
isPreview.current = true
formRef.current?.submit()
}, [])
const handleSubmit = useCallback((data: Record<string, any>) => {
if (isPreview.current)
handlePreviewChunks(data)
else
handleProcess(data)
}, [handlePreviewChunks, handleProcess])
if (isFetchingLastRunData) {
return (
<Loading type='app' />
)
}
if (isError)
return <AppUnavailable code={500} unknownReason={t('datasetCreation.error.unavailable') as string} />
return (
<div
className='relative flex h-[calc(100vh-56px)] min-w-[1024px] overflow-x-auto rounded-t-2xl border-t border-effects-highlight bg-background-default-subtle'
>
<div className='h-full min-w-0 flex-1'>
<div className='flex h-full flex-col px-14'>
<LeftHeader title={t('datasetPipeline.documentSettings.title')} />
<div className='grow overflow-y-auto'>
<ProcessDocuments
ref={formRef}
lastRunInputData={lastRunData!.input_data}
datasourceNodeId={lastRunData!.datasource_node_id}
onProcess={onClickProcess}
onPreview={onClickPreview}
onSubmit={handleSubmit}
isRunning={isPending}
/>
</div>
</div>
</div>
{/* Preview */}
<div className='h-full min-w-0 flex-1'>
<div className='flex h-full flex-col pl-2 pt-2'>
<ChunkPreview
dataSourceType={lastRunData!.datasource_type}
localFiles={files}
onlineDocuments={onlineDocuments}
websitePages={websitePages}
onlineDriveFiles={onlineDriveFiles}
isIdle={isIdle}
isPending={isPending && isPreview.current}
estimateData={estimateData}
onPreview={onClickPreview}
handlePreviewFileChange={noop}
handlePreviewOnlineDocumentChange={noop}
handlePreviewWebsitePageChange={noop}
handlePreviewOnlineDriveFileChange={noop}
/>
</div>
</div>
</div>
)
}
export default PipelineSettings

View File

@@ -0,0 +1,42 @@
import React, { useCallback } from 'react'
import { RiArrowLeftLine } from '@remixicon/react'
import Button from '@/app/components/base/button'
import { useRouter } from 'next/navigation'
import Effect from '@/app/components/base/effect'
import { useTranslation } from 'react-i18next'
type LeftHeaderProps = {
title: string
}
const LeftHeader = ({
title,
}: LeftHeaderProps) => {
const { t } = useTranslation()
const { back } = useRouter()
const navigateBack = useCallback(() => {
back()
}, [back])
return (
<div className='relative flex flex-col gap-y-0.5 pb-2 pt-4'>
<div className='system-2xs-semibold-uppercase bg-pipeline-add-documents-title-bg bg-clip-text text-transparent'>
{title}
</div>
<div className='system-md-semibold text-text-primary'>
{t('datasetPipeline.addDocuments.steps.processDocuments')}
</div>
<Button
variant='secondary-accent'
className='absolute -left-11 top-3.5 size-9 rounded-full p-0'
onClick={navigateBack}
>
<RiArrowLeftLine className='size-5 ' />
</Button>
<Effect className='left-8 top-[-34px] opacity-20' />
</div>
)
}
export default React.memo(LeftHeader)

View File

@@ -0,0 +1,29 @@
import React from 'react'
import Button from '@/app/components/base/button'
import { useTranslation } from 'react-i18next'
type ActionsProps = {
runDisabled?: boolean
onProcess: () => void
}
const Actions = ({
onProcess,
runDisabled,
}: ActionsProps) => {
const { t } = useTranslation()
return (
<div className='flex items-center justify-end'>
<Button
variant='primary'
onClick={onProcess}
disabled={runDisabled}
>
{t('datasetPipeline.operations.saveAndProcess')}
</Button>
</div>
)
}
export default React.memo(Actions)

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
.titleIcon {
background-position-x: center;
background-repeat: no-repeat;
background-size: 28px 28px;
@apply h-6 w-6 !important;
}
.layoutRightIcon {
@apply p-2 ml-2 border-[0.5px] border-components-button-secondary-border hover:border-components-button-secondary-border-hover
rounded-lg bg-components-button-secondary-bg hover:bg-components-button-secondary-bg-hover cursor-pointer
shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px];
}