dify
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user