mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
refactor(web): extract complex components into modular structure with comprehensive tests (#31729)
Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@ -0,0 +1,4 @@
|
||||
export { default as ProgressBar } from './progress-bar'
|
||||
export { default as RuleDetail } from './rule-detail'
|
||||
export { default as SegmentProgress } from './segment-progress'
|
||||
export { default as StatusHeader } from './status-header'
|
||||
@ -0,0 +1,159 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ProgressBar from './progress-bar'
|
||||
|
||||
describe('ProgressBar', () => {
|
||||
const defaultProps = {
|
||||
percent: 50,
|
||||
isEmbedding: false,
|
||||
isCompleted: false,
|
||||
isPaused: false,
|
||||
isError: false,
|
||||
}
|
||||
|
||||
const getProgressElements = (container: HTMLElement) => {
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
const progressBar = wrapper.firstChild as HTMLElement
|
||||
return { wrapper, progressBar }
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<ProgressBar {...defaultProps} />)
|
||||
const { wrapper, progressBar } = getProgressElements(container)
|
||||
expect(wrapper).toBeInTheDocument()
|
||||
expect(progressBar).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render progress bar container with correct classes', () => {
|
||||
const { container } = render(<ProgressBar {...defaultProps} />)
|
||||
const { wrapper } = getProgressElements(container)
|
||||
expect(wrapper).toHaveClass('flex', 'h-2', 'w-full', 'items-center', 'overflow-hidden', 'rounded-md')
|
||||
})
|
||||
|
||||
it('should render inner progress bar with transition classes', () => {
|
||||
const { container } = render(<ProgressBar {...defaultProps} />)
|
||||
const { progressBar } = getProgressElements(container)
|
||||
expect(progressBar).toHaveClass('h-full', 'transition-all', 'duration-300')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Progress Width', () => {
|
||||
it('should set progress width to 0%', () => {
|
||||
const { container } = render(<ProgressBar {...defaultProps} percent={0} />)
|
||||
const { progressBar } = getProgressElements(container)
|
||||
expect(progressBar).toHaveStyle({ width: '0%' })
|
||||
})
|
||||
|
||||
it('should set progress width to 50%', () => {
|
||||
const { container } = render(<ProgressBar {...defaultProps} percent={50} />)
|
||||
const { progressBar } = getProgressElements(container)
|
||||
expect(progressBar).toHaveStyle({ width: '50%' })
|
||||
})
|
||||
|
||||
it('should set progress width to 100%', () => {
|
||||
const { container } = render(<ProgressBar {...defaultProps} percent={100} />)
|
||||
const { progressBar } = getProgressElements(container)
|
||||
expect(progressBar).toHaveStyle({ width: '100%' })
|
||||
})
|
||||
|
||||
it('should set progress width to 75%', () => {
|
||||
const { container } = render(<ProgressBar {...defaultProps} percent={75} />)
|
||||
const { progressBar } = getProgressElements(container)
|
||||
expect(progressBar).toHaveStyle({ width: '75%' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Container Background States', () => {
|
||||
it('should apply semi-transparent background when isEmbedding is true', () => {
|
||||
const { container } = render(<ProgressBar {...defaultProps} isEmbedding />)
|
||||
const { wrapper } = getProgressElements(container)
|
||||
expect(wrapper).toHaveClass('bg-components-progress-bar-bg/50')
|
||||
})
|
||||
|
||||
it('should apply default background when isEmbedding is false', () => {
|
||||
const { container } = render(<ProgressBar {...defaultProps} isEmbedding={false} />)
|
||||
const { wrapper } = getProgressElements(container)
|
||||
expect(wrapper).toHaveClass('bg-components-progress-bar-bg')
|
||||
expect(wrapper).not.toHaveClass('bg-components-progress-bar-bg/50')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Progress Bar Fill States', () => {
|
||||
it('should apply solid progress style when isEmbedding is true', () => {
|
||||
const { container } = render(<ProgressBar {...defaultProps} isEmbedding />)
|
||||
const { progressBar } = getProgressElements(container)
|
||||
expect(progressBar).toHaveClass('bg-components-progress-bar-progress-solid')
|
||||
})
|
||||
|
||||
it('should apply solid progress style when isCompleted is true', () => {
|
||||
const { container } = render(<ProgressBar {...defaultProps} isCompleted />)
|
||||
const { progressBar } = getProgressElements(container)
|
||||
expect(progressBar).toHaveClass('bg-components-progress-bar-progress-solid')
|
||||
})
|
||||
|
||||
it('should apply highlight style when isPaused is true', () => {
|
||||
const { container } = render(<ProgressBar {...defaultProps} isPaused />)
|
||||
const { progressBar } = getProgressElements(container)
|
||||
expect(progressBar).toHaveClass('bg-components-progress-bar-progress-highlight')
|
||||
})
|
||||
|
||||
it('should apply highlight style when isError is true', () => {
|
||||
const { container } = render(<ProgressBar {...defaultProps} isError />)
|
||||
const { progressBar } = getProgressElements(container)
|
||||
expect(progressBar).toHaveClass('bg-components-progress-bar-progress-highlight')
|
||||
})
|
||||
|
||||
it('should not apply fill styles when no status flags are set', () => {
|
||||
const { container } = render(<ProgressBar {...defaultProps} />)
|
||||
const { progressBar } = getProgressElements(container)
|
||||
expect(progressBar).not.toHaveClass('bg-components-progress-bar-progress-solid')
|
||||
expect(progressBar).not.toHaveClass('bg-components-progress-bar-progress-highlight')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Combined States', () => {
|
||||
it('should apply highlight when isEmbedding and isPaused', () => {
|
||||
const { container } = render(<ProgressBar {...defaultProps} isEmbedding isPaused />)
|
||||
const { progressBar } = getProgressElements(container)
|
||||
// highlight takes precedence since isPaused condition is separate
|
||||
expect(progressBar).toHaveClass('bg-components-progress-bar-progress-highlight')
|
||||
})
|
||||
|
||||
it('should apply highlight when isCompleted and isError', () => {
|
||||
const { container } = render(<ProgressBar {...defaultProps} isCompleted isError />)
|
||||
const { progressBar } = getProgressElements(container)
|
||||
// highlight takes precedence since isError condition is separate
|
||||
expect(progressBar).toHaveClass('bg-components-progress-bar-progress-highlight')
|
||||
})
|
||||
|
||||
it('should apply semi-transparent bg for embedding and highlight for paused', () => {
|
||||
const { container } = render(<ProgressBar {...defaultProps} isEmbedding isPaused />)
|
||||
const { wrapper } = getProgressElements(container)
|
||||
expect(wrapper).toHaveClass('bg-components-progress-bar-bg/50')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle all props set to false', () => {
|
||||
const { container } = render(
|
||||
<ProgressBar
|
||||
percent={0}
|
||||
isEmbedding={false}
|
||||
isCompleted={false}
|
||||
isPaused={false}
|
||||
isError={false}
|
||||
/>,
|
||||
)
|
||||
const { wrapper, progressBar } = getProgressElements(container)
|
||||
expect(wrapper).toBeInTheDocument()
|
||||
expect(progressBar).toHaveStyle({ width: '0%' })
|
||||
})
|
||||
|
||||
it('should handle decimal percent values', () => {
|
||||
const { container } = render(<ProgressBar {...defaultProps} percent={33.33} />)
|
||||
const { progressBar } = getProgressElements(container)
|
||||
expect(progressBar).toHaveStyle({ width: '33.33%' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,44 @@
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type ProgressBarProps = {
|
||||
percent: number
|
||||
isEmbedding: boolean
|
||||
isCompleted: boolean
|
||||
isPaused: boolean
|
||||
isError: boolean
|
||||
}
|
||||
|
||||
const ProgressBar: FC<ProgressBarProps> = React.memo(({
|
||||
percent,
|
||||
isEmbedding,
|
||||
isCompleted,
|
||||
isPaused,
|
||||
isError,
|
||||
}) => {
|
||||
const isActive = isEmbedding || isCompleted
|
||||
const isHighlighted = isPaused || isError
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-2 w-full items-center overflow-hidden rounded-md border border-components-progress-bar-border',
|
||||
isEmbedding ? 'bg-components-progress-bar-bg/50' : 'bg-components-progress-bar-bg',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full transition-all duration-300',
|
||||
isActive && 'bg-components-progress-bar-progress-solid',
|
||||
isHighlighted && 'bg-components-progress-bar-progress-highlight',
|
||||
)}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
ProgressBar.displayName = 'ProgressBar'
|
||||
|
||||
export default ProgressBar
|
||||
@ -0,0 +1,203 @@
|
||||
import type { ProcessRuleResponse } from '@/models/datasets'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ProcessMode } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { IndexingType } from '../../../../create/step-two'
|
||||
import RuleDetail from './rule-detail'
|
||||
|
||||
describe('RuleDetail', () => {
|
||||
const defaultProps = {
|
||||
indexingType: IndexingType.QUALIFIED,
|
||||
retrievalMethod: RETRIEVE_METHOD.semantic,
|
||||
}
|
||||
|
||||
const createSourceData = (overrides: Partial<ProcessRuleResponse> = {}): ProcessRuleResponse => ({
|
||||
mode: ProcessMode.general,
|
||||
rules: {
|
||||
segmentation: {
|
||||
separator: '\n',
|
||||
max_tokens: 500,
|
||||
chunk_overlap: 50,
|
||||
},
|
||||
pre_processing_rules: [
|
||||
{ id: 'remove_extra_spaces', enabled: true },
|
||||
{ id: 'remove_urls_emails', enabled: false },
|
||||
],
|
||||
parent_mode: 'full-doc',
|
||||
subchunk_segmentation: {
|
||||
separator: '\n',
|
||||
max_tokens: 200,
|
||||
chunk_overlap: 20,
|
||||
},
|
||||
},
|
||||
limits: { indexing_max_segmentation_tokens_length: 4000 },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<RuleDetail {...defaultProps} />)
|
||||
expect(screen.getByText(/stepTwo\.indexMode/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with sourceData', () => {
|
||||
const sourceData = createSourceData()
|
||||
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
|
||||
expect(screen.getByText(/embedding\.mode/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all segmentation rule fields', () => {
|
||||
const sourceData = createSourceData()
|
||||
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
|
||||
expect(screen.getByText(/embedding\.mode/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/embedding\.segmentLength/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/embedding\.textCleaning/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mode Display', () => {
|
||||
it('should display custom mode for general process mode', () => {
|
||||
const sourceData = createSourceData({ mode: ProcessMode.general })
|
||||
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
|
||||
expect(screen.getByText(/embedding\.custom/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display mode label field', () => {
|
||||
const sourceData = createSourceData()
|
||||
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
|
||||
expect(screen.getByText(/embedding\.mode/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Segment Length Display', () => {
|
||||
it('should display max tokens for general mode', () => {
|
||||
const sourceData = createSourceData({
|
||||
mode: ProcessMode.general,
|
||||
rules: {
|
||||
segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 50 },
|
||||
pre_processing_rules: [],
|
||||
parent_mode: 'full-doc',
|
||||
subchunk_segmentation: { separator: '\n', max_tokens: 200, chunk_overlap: 20 },
|
||||
},
|
||||
})
|
||||
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
|
||||
expect(screen.getByText('500')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display segment length label', () => {
|
||||
const sourceData = createSourceData()
|
||||
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
|
||||
expect(screen.getByText(/embedding\.segmentLength/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Text Cleaning Display', () => {
|
||||
it('should display enabled pre-processing rules', () => {
|
||||
const sourceData = createSourceData({
|
||||
rules: {
|
||||
segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 50 },
|
||||
pre_processing_rules: [
|
||||
{ id: 'remove_extra_spaces', enabled: true },
|
||||
{ id: 'remove_urls_emails', enabled: true },
|
||||
],
|
||||
parent_mode: 'full-doc',
|
||||
subchunk_segmentation: { separator: '\n', max_tokens: 200, chunk_overlap: 20 },
|
||||
},
|
||||
})
|
||||
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
|
||||
expect(screen.getByText(/removeExtraSpaces/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/removeUrlEmails/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display text cleaning label', () => {
|
||||
const sourceData = createSourceData()
|
||||
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
|
||||
expect(screen.getByText(/embedding\.textCleaning/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Index Mode Display', () => {
|
||||
it('should display economical mode when indexingType is ECONOMICAL', () => {
|
||||
render(<RuleDetail {...defaultProps} indexingType={IndexingType.ECONOMICAL} />)
|
||||
expect(screen.getByText(/stepTwo\.economical/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display qualified mode when indexingType is QUALIFIED', () => {
|
||||
render(<RuleDetail {...defaultProps} indexingType={IndexingType.QUALIFIED} />)
|
||||
expect(screen.getByText(/stepTwo\.qualified/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Retrieval Method Display', () => {
|
||||
it('should display keyword search for economical mode', () => {
|
||||
render(<RuleDetail {...defaultProps} indexingType={IndexingType.ECONOMICAL} />)
|
||||
expect(screen.getByText(/retrieval\.keyword_search\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display semantic search as default for qualified mode', () => {
|
||||
render(<RuleDetail {...defaultProps} indexingType={IndexingType.QUALIFIED} />)
|
||||
expect(screen.getByText(/retrieval\.semantic_search\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display full text search when retrievalMethod is fullText', () => {
|
||||
render(<RuleDetail {...defaultProps} retrievalMethod={RETRIEVE_METHOD.fullText} />)
|
||||
expect(screen.getByText(/retrieval\.full_text_search\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display hybrid search when retrievalMethod is hybrid', () => {
|
||||
render(<RuleDetail {...defaultProps} retrievalMethod={RETRIEVE_METHOD.hybrid} />)
|
||||
expect(screen.getByText(/retrieval\.hybrid_search\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should display dash for missing sourceData', () => {
|
||||
render(<RuleDetail {...defaultProps} />)
|
||||
const dashes = screen.getAllByText('-')
|
||||
expect(dashes.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should display dash when mode is undefined', () => {
|
||||
const sourceData = { rules: {} } as ProcessRuleResponse
|
||||
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
|
||||
const dashes = screen.getAllByText('-')
|
||||
expect(dashes.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should handle undefined retrievalMethod', () => {
|
||||
render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
|
||||
expect(screen.getByText(/retrieval\.semantic_search\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty pre_processing_rules array', () => {
|
||||
const sourceData = createSourceData({
|
||||
rules: {
|
||||
segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 50 },
|
||||
pre_processing_rules: [],
|
||||
parent_mode: 'full-doc',
|
||||
subchunk_segmentation: { separator: '\n', max_tokens: 200, chunk_overlap: 20 },
|
||||
},
|
||||
})
|
||||
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
|
||||
expect(screen.getByText(/embedding\.textCleaning/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render container with correct structure', () => {
|
||||
const { container } = render(<RuleDetail {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('py-3')
|
||||
})
|
||||
|
||||
it('should handle undefined indexingType', () => {
|
||||
render(<RuleDetail retrievalMethod={RETRIEVE_METHOD.semantic} />)
|
||||
expect(screen.getByText(/stepTwo\.indexMode/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render divider between sections', () => {
|
||||
const { container } = render(<RuleDetail {...defaultProps} />)
|
||||
const dividers = container.querySelectorAll('.bg-divider-subtle')
|
||||
expect(dividers.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,128 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ProcessRuleResponse } from '@/models/datasets'
|
||||
import type { RETRIEVE_METHOD } from '@/types/app'
|
||||
import Image from 'next/image'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { ProcessMode } from '@/models/datasets'
|
||||
import { indexMethodIcon, retrievalIcon } from '../../../../create/icons'
|
||||
import { IndexingType } from '../../../../create/step-two'
|
||||
import { FieldInfo } from '../../metadata'
|
||||
|
||||
type RuleDetailProps = {
|
||||
sourceData?: ProcessRuleResponse
|
||||
indexingType?: IndexingType
|
||||
retrievalMethod?: RETRIEVE_METHOD
|
||||
}
|
||||
|
||||
const getRetrievalIcon = (method?: RETRIEVE_METHOD) => {
|
||||
if (method === 'full_text_search')
|
||||
return retrievalIcon.fullText
|
||||
if (method === 'hybrid_search')
|
||||
return retrievalIcon.hybrid
|
||||
return retrievalIcon.vector
|
||||
}
|
||||
|
||||
const RuleDetail: FC<RuleDetailProps> = React.memo(({
|
||||
sourceData,
|
||||
indexingType,
|
||||
retrievalMethod,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const segmentationRuleMap = {
|
||||
mode: t('embedding.mode', { ns: 'datasetDocuments' }),
|
||||
segmentLength: t('embedding.segmentLength', { ns: 'datasetDocuments' }),
|
||||
textCleaning: t('embedding.textCleaning', { ns: 'datasetDocuments' }),
|
||||
}
|
||||
|
||||
const getRuleName = useCallback((key: string) => {
|
||||
const ruleNameMap: Record<string, string> = {
|
||||
remove_extra_spaces: t('stepTwo.removeExtraSpaces', { ns: 'datasetCreation' }),
|
||||
remove_urls_emails: t('stepTwo.removeUrlEmails', { ns: 'datasetCreation' }),
|
||||
remove_stopwords: t('stepTwo.removeStopwords', { ns: 'datasetCreation' }),
|
||||
}
|
||||
return ruleNameMap[key]
|
||||
}, [t])
|
||||
|
||||
const getValue = useCallback((field: string) => {
|
||||
const defaultValue = '-'
|
||||
|
||||
if (!sourceData?.mode)
|
||||
return defaultValue
|
||||
|
||||
const maxTokens = typeof sourceData?.rules?.segmentation?.max_tokens === 'number'
|
||||
? sourceData.rules.segmentation.max_tokens
|
||||
: defaultValue
|
||||
|
||||
const childMaxTokens = typeof sourceData?.rules?.subchunk_segmentation?.max_tokens === 'number'
|
||||
? sourceData.rules.subchunk_segmentation.max_tokens
|
||||
: defaultValue
|
||||
|
||||
const isGeneralMode = sourceData.mode === ProcessMode.general
|
||||
|
||||
const fieldValueMap: Record<string, string | number> = {
|
||||
mode: isGeneralMode
|
||||
? t('embedding.custom', { ns: 'datasetDocuments' })
|
||||
: `${t('embedding.hierarchical', { ns: 'datasetDocuments' })} · ${
|
||||
sourceData?.rules?.parent_mode === 'paragraph'
|
||||
? t('parentMode.paragraph', { ns: 'dataset' })
|
||||
: t('parentMode.fullDoc', { ns: 'dataset' })
|
||||
}`,
|
||||
segmentLength: isGeneralMode
|
||||
? maxTokens
|
||||
: `${t('embedding.parentMaxTokens', { ns: 'datasetDocuments' })} ${maxTokens}; ${t('embedding.childMaxTokens', { ns: 'datasetDocuments' })} ${childMaxTokens}`,
|
||||
textCleaning: sourceData?.rules?.pre_processing_rules
|
||||
?.filter(rule => rule.enabled)
|
||||
.map(rule => getRuleName(rule.id))
|
||||
.join(',') || defaultValue,
|
||||
}
|
||||
|
||||
return fieldValueMap[field] ?? defaultValue
|
||||
}, [sourceData, t, getRuleName])
|
||||
|
||||
const isEconomical = indexingType === IndexingType.ECONOMICAL
|
||||
|
||||
return (
|
||||
<div className="py-3">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
{Object.keys(segmentationRuleMap).map(field => (
|
||||
<FieldInfo
|
||||
key={field}
|
||||
label={segmentationRuleMap[field as keyof typeof segmentationRuleMap]}
|
||||
displayedValue={String(getValue(field))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Divider type="horizontal" className="bg-divider-subtle" />
|
||||
<FieldInfo
|
||||
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
displayedValue={t(`stepTwo.${isEconomical ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
|
||||
valueIcon={(
|
||||
<Image
|
||||
className="size-4"
|
||||
src={isEconomical ? indexMethodIcon.economical : indexMethodIcon.high_quality}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldInfo
|
||||
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
|
||||
displayedValue={t(`retrieval.${isEconomical ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })}
|
||||
valueIcon={(
|
||||
<Image
|
||||
className="size-4"
|
||||
src={getRetrievalIcon(retrievalMethod)}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
RuleDetail.displayName = 'RuleDetail'
|
||||
|
||||
export default RuleDetail
|
||||
@ -0,0 +1,81 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import SegmentProgress from './segment-progress'
|
||||
|
||||
describe('SegmentProgress', () => {
|
||||
const defaultProps = {
|
||||
completedSegments: 50,
|
||||
totalSegments: 100,
|
||||
percent: 50,
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<SegmentProgress {...defaultProps} />)
|
||||
expect(screen.getByText(/segments/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with correct CSS classes', () => {
|
||||
const { container } = render(<SegmentProgress {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('flex', 'w-full', 'items-center')
|
||||
})
|
||||
|
||||
it('should render text with correct styling class', () => {
|
||||
render(<SegmentProgress {...defaultProps} />)
|
||||
const text = screen.getByText(/segments/i)
|
||||
expect(text).toHaveClass('system-xs-medium', 'text-text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Progress Display', () => {
|
||||
it('should display completed and total segments', () => {
|
||||
render(<SegmentProgress completedSegments={50} totalSegments={100} percent={50} />)
|
||||
expect(screen.getByText(/50\/100/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display percent value', () => {
|
||||
render(<SegmentProgress completedSegments={50} totalSegments={100} percent={50} />)
|
||||
expect(screen.getByText(/50%/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display 0/0 when segments are 0', () => {
|
||||
render(<SegmentProgress completedSegments={0} totalSegments={0} percent={0} />)
|
||||
expect(screen.getByText(/0\/0/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/0%/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display 100% when completed', () => {
|
||||
render(<SegmentProgress completedSegments={100} totalSegments={100} percent={100} />)
|
||||
expect(screen.getByText(/100\/100/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/100%/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should display -- when completedSegments is undefined', () => {
|
||||
render(<SegmentProgress totalSegments={100} percent={0} />)
|
||||
expect(screen.getByText(/--\/100/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display -- when totalSegments is undefined', () => {
|
||||
render(<SegmentProgress completedSegments={50} percent={50} />)
|
||||
expect(screen.getByText(/50\/--/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display --/-- when both segments are undefined', () => {
|
||||
render(<SegmentProgress percent={0} />)
|
||||
expect(screen.getByText(/--\/--/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle large numbers', () => {
|
||||
render(<SegmentProgress completedSegments={999999} totalSegments={1000000} percent={99} />)
|
||||
expect(screen.getByText(/999999\/1000000/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle decimal percent', () => {
|
||||
render(<SegmentProgress completedSegments={33} totalSegments={100} percent={33.33} />)
|
||||
expect(screen.getByText(/33.33%/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,32 @@
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type SegmentProgressProps = {
|
||||
completedSegments?: number
|
||||
totalSegments?: number
|
||||
percent: number
|
||||
}
|
||||
|
||||
const SegmentProgress: FC<SegmentProgressProps> = React.memo(({
|
||||
completedSegments,
|
||||
totalSegments,
|
||||
percent,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const completed = completedSegments ?? '--'
|
||||
const total = totalSegments ?? '--'
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center">
|
||||
<span className="system-xs-medium text-text-secondary">
|
||||
{`${t('embedding.segments', { ns: 'datasetDocuments' })} ${completed}/${total} · ${percent}%`}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
SegmentProgress.displayName = 'SegmentProgress'
|
||||
|
||||
export default SegmentProgress
|
||||
@ -0,0 +1,155 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import StatusHeader from './status-header'
|
||||
|
||||
describe('StatusHeader', () => {
|
||||
const defaultProps = {
|
||||
isEmbedding: false,
|
||||
isCompleted: false,
|
||||
isPaused: false,
|
||||
isError: false,
|
||||
onPause: vi.fn(),
|
||||
onResume: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<StatusHeader {...defaultProps} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with correct container classes', () => {
|
||||
const { container } = render(<StatusHeader {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('flex', 'h-6', 'items-center', 'gap-x-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Status Text', () => {
|
||||
it('should display processing text when isEmbedding is true', () => {
|
||||
render(<StatusHeader {...defaultProps} isEmbedding />)
|
||||
expect(screen.getByText(/embedding\.processing/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display completed text when isCompleted is true', () => {
|
||||
render(<StatusHeader {...defaultProps} isCompleted />)
|
||||
expect(screen.getByText(/embedding\.completed/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display paused text when isPaused is true', () => {
|
||||
render(<StatusHeader {...defaultProps} isPaused />)
|
||||
expect(screen.getByText(/embedding\.paused/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display error text when isError is true', () => {
|
||||
render(<StatusHeader {...defaultProps} isError />)
|
||||
expect(screen.getByText(/embedding\.error/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display empty text when no status flags are set', () => {
|
||||
render(<StatusHeader {...defaultProps} />)
|
||||
const statusText = screen.getByText('', { selector: 'span.system-md-semibold-uppercase' })
|
||||
expect(statusText).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading Spinner', () => {
|
||||
it('should show loading spinner when isEmbedding is true', () => {
|
||||
const { container } = render(<StatusHeader {...defaultProps} isEmbedding />)
|
||||
const spinner = container.querySelector('svg.animate-spin')
|
||||
expect(spinner).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show loading spinner when isEmbedding is false', () => {
|
||||
const { container } = render(<StatusHeader {...defaultProps} isEmbedding={false} />)
|
||||
const spinner = container.querySelector('svg.animate-spin')
|
||||
expect(spinner).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pause Button', () => {
|
||||
it('should show pause button when isEmbedding is true', () => {
|
||||
render(<StatusHeader {...defaultProps} isEmbedding />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
expect(screen.getByText(/embedding\.pause/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show pause button when isEmbedding is false', () => {
|
||||
render(<StatusHeader {...defaultProps} isEmbedding={false} />)
|
||||
expect(screen.queryByText(/embedding\.pause/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onPause when pause button is clicked', () => {
|
||||
const onPause = vi.fn()
|
||||
render(<StatusHeader {...defaultProps} isEmbedding onPause={onPause} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(onPause).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should disable pause button when isPauseLoading is true', () => {
|
||||
render(<StatusHeader {...defaultProps} isEmbedding isPauseLoading />)
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Resume Button', () => {
|
||||
it('should show resume button when isPaused is true', () => {
|
||||
render(<StatusHeader {...defaultProps} isPaused />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
expect(screen.getByText(/embedding\.resume/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show resume button when isPaused is false', () => {
|
||||
render(<StatusHeader {...defaultProps} isPaused={false} />)
|
||||
expect(screen.queryByText(/embedding\.resume/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onResume when resume button is clicked', () => {
|
||||
const onResume = vi.fn()
|
||||
render(<StatusHeader {...defaultProps} isPaused onResume={onResume} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(onResume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should disable resume button when isResumeLoading is true', () => {
|
||||
render(<StatusHeader {...defaultProps} isPaused isResumeLoading />)
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button Styles', () => {
|
||||
it('should have correct button styles for pause button', () => {
|
||||
render(<StatusHeader {...defaultProps} isEmbedding />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('flex', 'items-center', 'gap-x-1', 'rounded-md')
|
||||
})
|
||||
|
||||
it('should have correct button styles for resume button', () => {
|
||||
render(<StatusHeader {...defaultProps} isPaused />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('flex', 'items-center', 'gap-x-1', 'rounded-md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should not show any buttons when isCompleted', () => {
|
||||
render(<StatusHeader {...defaultProps} isCompleted />)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show any buttons when isError', () => {
|
||||
render(<StatusHeader {...defaultProps} isError />)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show both buttons when isEmbedding and isPaused are both true', () => {
|
||||
render(<StatusHeader {...defaultProps} isEmbedding isPaused />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,84 @@
|
||||
import type { FC } from 'react'
|
||||
import { RiLoader2Line, RiPauseCircleLine, RiPlayCircleLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type StatusHeaderProps = {
|
||||
isEmbedding: boolean
|
||||
isCompleted: boolean
|
||||
isPaused: boolean
|
||||
isError: boolean
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
isPauseLoading?: boolean
|
||||
isResumeLoading?: boolean
|
||||
}
|
||||
|
||||
const StatusHeader: FC<StatusHeaderProps> = React.memo(({
|
||||
isEmbedding,
|
||||
isCompleted,
|
||||
isPaused,
|
||||
isError,
|
||||
onPause,
|
||||
onResume,
|
||||
isPauseLoading,
|
||||
isResumeLoading,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const getStatusText = () => {
|
||||
if (isEmbedding)
|
||||
return t('embedding.processing', { ns: 'datasetDocuments' })
|
||||
if (isCompleted)
|
||||
return t('embedding.completed', { ns: 'datasetDocuments' })
|
||||
if (isPaused)
|
||||
return t('embedding.paused', { ns: 'datasetDocuments' })
|
||||
if (isError)
|
||||
return t('embedding.error', { ns: 'datasetDocuments' })
|
||||
return ''
|
||||
}
|
||||
|
||||
const buttonBaseClass = `flex items-center gap-x-1 rounded-md border-[0.5px]
|
||||
border-components-button-secondary-border bg-components-button-secondary-bg
|
||||
px-1.5 py-1 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]
|
||||
disabled:cursor-not-allowed disabled:opacity-50`
|
||||
|
||||
return (
|
||||
<div className="flex h-6 items-center gap-x-1">
|
||||
{isEmbedding && <RiLoader2Line className="h-4 w-4 animate-spin text-text-secondary" />}
|
||||
<span className="system-md-semibold-uppercase grow text-text-secondary">
|
||||
{getStatusText()}
|
||||
</span>
|
||||
{isEmbedding && (
|
||||
<button
|
||||
type="button"
|
||||
className={buttonBaseClass}
|
||||
onClick={onPause}
|
||||
disabled={isPauseLoading}
|
||||
>
|
||||
<RiPauseCircleLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
|
||||
<span className="system-xs-medium pr-[3px] text-components-button-secondary-text">
|
||||
{t('embedding.pause', { ns: 'datasetDocuments' })}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{isPaused && (
|
||||
<button
|
||||
type="button"
|
||||
className={buttonBaseClass}
|
||||
onClick={onResume}
|
||||
disabled={isResumeLoading}
|
||||
>
|
||||
<RiPlayCircleLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
|
||||
<span className="system-xs-medium pr-[3px] text-components-button-secondary-text">
|
||||
{t('embedding.resume', { ns: 'datasetDocuments' })}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
StatusHeader.displayName = 'StatusHeader'
|
||||
|
||||
export default StatusHeader
|
||||
@ -0,0 +1,10 @@
|
||||
export {
|
||||
calculatePercent,
|
||||
isEmbeddingStatus,
|
||||
isTerminalStatus,
|
||||
useEmbeddingStatus,
|
||||
useInvalidateEmbeddingStatus,
|
||||
usePauseIndexing,
|
||||
useResumeIndexing,
|
||||
} from './use-embedding-status'
|
||||
export type { EmbeddingStatusType } from './use-embedding-status'
|
||||
@ -0,0 +1,462 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IndexingStatusResponse } from '@/models/datasets'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as datasetsService from '@/service/datasets'
|
||||
import {
|
||||
calculatePercent,
|
||||
isEmbeddingStatus,
|
||||
isTerminalStatus,
|
||||
useEmbeddingStatus,
|
||||
useInvalidateEmbeddingStatus,
|
||||
usePauseIndexing,
|
||||
useResumeIndexing,
|
||||
} from './use-embedding-status'
|
||||
|
||||
vi.mock('@/service/datasets')
|
||||
|
||||
const mockFetchIndexingStatus = vi.mocked(datasetsService.fetchIndexingStatus)
|
||||
const mockPauseDocIndexing = vi.mocked(datasetsService.pauseDocIndexing)
|
||||
const mockResumeDocIndexing = vi.mocked(datasetsService.resumeDocIndexing)
|
||||
|
||||
const createTestQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = createTestQueryClient()
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const mockIndexingStatus = (overrides: Partial<IndexingStatusResponse> = {}): IndexingStatusResponse => ({
|
||||
id: 'doc1',
|
||||
indexing_status: 'indexing',
|
||||
completed_segments: 50,
|
||||
total_segments: 100,
|
||||
processing_started_at: 0,
|
||||
parsing_completed_at: 0,
|
||||
cleaning_completed_at: 0,
|
||||
splitting_completed_at: 0,
|
||||
completed_at: null,
|
||||
paused_at: null,
|
||||
error: null,
|
||||
stopped_at: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('use-embedding-status', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('isEmbeddingStatus', () => {
|
||||
it('should return true for indexing status', () => {
|
||||
expect(isEmbeddingStatus('indexing')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for splitting status', () => {
|
||||
expect(isEmbeddingStatus('splitting')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for parsing status', () => {
|
||||
expect(isEmbeddingStatus('parsing')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for cleaning status', () => {
|
||||
expect(isEmbeddingStatus('cleaning')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for completed status', () => {
|
||||
expect(isEmbeddingStatus('completed')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for paused status', () => {
|
||||
expect(isEmbeddingStatus('paused')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for error status', () => {
|
||||
expect(isEmbeddingStatus('error')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for undefined', () => {
|
||||
expect(isEmbeddingStatus(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(isEmbeddingStatus('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTerminalStatus', () => {
|
||||
it('should return true for completed status', () => {
|
||||
expect(isTerminalStatus('completed')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for error status', () => {
|
||||
expect(isTerminalStatus('error')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for paused status', () => {
|
||||
expect(isTerminalStatus('paused')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for indexing status', () => {
|
||||
expect(isTerminalStatus('indexing')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for undefined', () => {
|
||||
expect(isTerminalStatus(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculatePercent', () => {
|
||||
it('should calculate percent correctly', () => {
|
||||
expect(calculatePercent(50, 100)).toBe(50)
|
||||
})
|
||||
|
||||
it('should return 0 when total is 0', () => {
|
||||
expect(calculatePercent(50, 0)).toBe(0)
|
||||
})
|
||||
|
||||
it('should return 0 when total is undefined', () => {
|
||||
expect(calculatePercent(50, undefined)).toBe(0)
|
||||
})
|
||||
|
||||
it('should return 0 when completed is undefined', () => {
|
||||
expect(calculatePercent(undefined, 100)).toBe(0)
|
||||
})
|
||||
|
||||
it('should cap at 100 when percent exceeds 100', () => {
|
||||
expect(calculatePercent(150, 100)).toBe(100)
|
||||
})
|
||||
|
||||
it('should round to nearest integer', () => {
|
||||
expect(calculatePercent(33, 100)).toBe(33)
|
||||
expect(calculatePercent(1, 3)).toBe(33)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useEmbeddingStatus', () => {
|
||||
it('should return initial state when disabled', () => {
|
||||
const { result } = renderHook(
|
||||
() => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1', enabled: false }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.isEmbedding).toBe(false)
|
||||
expect(result.current.isCompleted).toBe(false)
|
||||
expect(result.current.isPaused).toBe(false)
|
||||
expect(result.current.isError).toBe(false)
|
||||
expect(result.current.percent).toBe(0)
|
||||
})
|
||||
|
||||
it('should not fetch when datasetId is missing', () => {
|
||||
renderHook(
|
||||
() => useEmbeddingStatus({ documentId: 'doc1' }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(mockFetchIndexingStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not fetch when documentId is missing', () => {
|
||||
renderHook(
|
||||
() => useEmbeddingStatus({ datasetId: 'ds1' }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(mockFetchIndexingStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fetch indexing status when enabled with valid ids', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isEmbedding).toBe(true)
|
||||
})
|
||||
|
||||
expect(mockFetchIndexingStatus).toHaveBeenCalledWith({
|
||||
datasetId: 'ds1',
|
||||
documentId: 'doc1',
|
||||
})
|
||||
expect(result.current.percent).toBe(50)
|
||||
})
|
||||
|
||||
it('should set isCompleted when status is completed', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({
|
||||
indexing_status: 'completed',
|
||||
completed_segments: 100,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isCompleted).toBe(true)
|
||||
})
|
||||
|
||||
expect(result.current.percent).toBe(100)
|
||||
})
|
||||
|
||||
it('should set isPaused when status is paused', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({
|
||||
indexing_status: 'paused',
|
||||
}))
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isPaused).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should set isError when status is error', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({
|
||||
indexing_status: 'error',
|
||||
completed_segments: 25,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should provide invalidate function', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isEmbedding).toBe(true)
|
||||
})
|
||||
|
||||
expect(typeof result.current.invalidate).toBe('function')
|
||||
|
||||
// Call invalidate should not throw
|
||||
await act(async () => {
|
||||
result.current.invalidate()
|
||||
})
|
||||
})
|
||||
|
||||
it('should provide resetStatus function that clears data', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toBeDefined()
|
||||
})
|
||||
|
||||
// Reset status should clear the data
|
||||
await act(async () => {
|
||||
result.current.resetStatus()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePauseIndexing', () => {
|
||||
it('should call pauseDocIndexing when mutate is called', async () => {
|
||||
mockPauseDocIndexing.mockResolvedValue({ result: 'success' })
|
||||
|
||||
const { result } = renderHook(
|
||||
() => usePauseIndexing({ datasetId: 'ds1', documentId: 'doc1' }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPauseDocIndexing).toHaveBeenCalledWith({
|
||||
datasetId: 'ds1',
|
||||
documentId: 'doc1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onSuccess callback on successful pause', async () => {
|
||||
mockPauseDocIndexing.mockResolvedValue({ result: 'success' })
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const { result } = renderHook(
|
||||
() => usePauseIndexing({ datasetId: 'ds1', documentId: 'doc1', onSuccess }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSuccess).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onError callback on failed pause', async () => {
|
||||
const error = new Error('Network error')
|
||||
mockPauseDocIndexing.mockRejectedValue(error)
|
||||
const onError = vi.fn()
|
||||
|
||||
const { result } = renderHook(
|
||||
() => usePauseIndexing({ datasetId: 'ds1', documentId: 'doc1', onError }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onError).toHaveBeenCalled()
|
||||
expect(onError.mock.calls[0][0]).toEqual(error)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useResumeIndexing', () => {
|
||||
it('should call resumeDocIndexing when mutate is called', async () => {
|
||||
mockResumeDocIndexing.mockResolvedValue({ result: 'success' })
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useResumeIndexing({ datasetId: 'ds1', documentId: 'doc1' }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockResumeDocIndexing).toHaveBeenCalledWith({
|
||||
datasetId: 'ds1',
|
||||
documentId: 'doc1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onSuccess callback on successful resume', async () => {
|
||||
mockResumeDocIndexing.mockResolvedValue({ result: 'success' })
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useResumeIndexing({ datasetId: 'ds1', documentId: 'doc1', onSuccess }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSuccess).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useInvalidateEmbeddingStatus', () => {
|
||||
it('should return a function', () => {
|
||||
const { result } = renderHook(
|
||||
() => useInvalidateEmbeddingStatus(),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(typeof result.current).toBe('function')
|
||||
})
|
||||
|
||||
it('should invalidate specific query when datasetId and documentId are provided', async () => {
|
||||
const queryClient = createTestQueryClient()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
// Set some initial data in the cache
|
||||
queryClient.setQueryData(['embedding', 'indexing-status', 'ds1', 'doc1'], {
|
||||
id: 'doc1',
|
||||
indexing_status: 'indexing',
|
||||
})
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useInvalidateEmbeddingStatus(),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
result.current('ds1', 'doc1')
|
||||
})
|
||||
|
||||
// The query should be invalidated (marked as stale)
|
||||
const queryState = queryClient.getQueryState(['embedding', 'indexing-status', 'ds1', 'doc1'])
|
||||
expect(queryState?.isInvalidated).toBe(true)
|
||||
})
|
||||
|
||||
it('should invalidate all embedding status queries when ids are not provided', async () => {
|
||||
const queryClient = createTestQueryClient()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
// Set some initial data in the cache for multiple documents
|
||||
queryClient.setQueryData(['embedding', 'indexing-status', 'ds1', 'doc1'], {
|
||||
id: 'doc1',
|
||||
indexing_status: 'indexing',
|
||||
})
|
||||
queryClient.setQueryData(['embedding', 'indexing-status', 'ds2', 'doc2'], {
|
||||
id: 'doc2',
|
||||
indexing_status: 'completed',
|
||||
})
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useInvalidateEmbeddingStatus(),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
result.current()
|
||||
})
|
||||
|
||||
// Both queries should be invalidated
|
||||
const queryState1 = queryClient.getQueryState(['embedding', 'indexing-status', 'ds1', 'doc1'])
|
||||
const queryState2 = queryClient.getQueryState(['embedding', 'indexing-status', 'ds2', 'doc2'])
|
||||
expect(queryState1?.isInvalidated).toBe(true)
|
||||
expect(queryState2?.isInvalidated).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,149 @@
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import type { IndexingStatusResponse } from '@/models/datasets'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
fetchIndexingStatus,
|
||||
pauseDocIndexing,
|
||||
resumeDocIndexing,
|
||||
} from '@/service/datasets'
|
||||
|
||||
const NAME_SPACE = 'embedding'
|
||||
|
||||
export type EmbeddingStatusType = 'indexing' | 'splitting' | 'parsing' | 'cleaning' | 'completed' | 'paused' | 'error' | 'waiting' | ''
|
||||
|
||||
const EMBEDDING_STATUSES = ['indexing', 'splitting', 'parsing', 'cleaning'] as const
|
||||
const TERMINAL_STATUSES = ['completed', 'error', 'paused'] as const
|
||||
|
||||
export const isEmbeddingStatus = (status?: string): boolean => {
|
||||
return EMBEDDING_STATUSES.includes(status as typeof EMBEDDING_STATUSES[number])
|
||||
}
|
||||
|
||||
export const isTerminalStatus = (status?: string): boolean => {
|
||||
return TERMINAL_STATUSES.includes(status as typeof TERMINAL_STATUSES[number])
|
||||
}
|
||||
|
||||
export const calculatePercent = (completed?: number, total?: number): number => {
|
||||
if (!total || total === 0)
|
||||
return 0
|
||||
const percent = Math.round((completed || 0) * 100 / total)
|
||||
return Math.min(percent, 100)
|
||||
}
|
||||
|
||||
type UseEmbeddingStatusOptions = {
|
||||
datasetId?: string
|
||||
documentId?: string
|
||||
enabled?: boolean
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
export const useEmbeddingStatus = ({
|
||||
datasetId,
|
||||
documentId,
|
||||
enabled = true,
|
||||
onComplete,
|
||||
}: UseEmbeddingStatusOptions) => {
|
||||
const queryClient = useQueryClient()
|
||||
const isPolling = useRef(false)
|
||||
const onCompleteRef = useRef(onComplete)
|
||||
onCompleteRef.current = onComplete
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [NAME_SPACE, 'indexing-status', datasetId, documentId] as const,
|
||||
[datasetId, documentId],
|
||||
)
|
||||
|
||||
const query = useQuery<IndexingStatusResponse>({
|
||||
queryKey,
|
||||
queryFn: () => fetchIndexingStatus({ datasetId: datasetId!, documentId: documentId! }),
|
||||
enabled: enabled && !!datasetId && !!documentId,
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.indexing_status
|
||||
if (isTerminalStatus(status)) {
|
||||
return false
|
||||
}
|
||||
return 2500
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const status = query.data?.indexing_status || ''
|
||||
const isEmbedding = isEmbeddingStatus(status)
|
||||
const isCompleted = status === 'completed'
|
||||
const isPaused = status === 'paused'
|
||||
const isError = status === 'error'
|
||||
const percent = calculatePercent(query.data?.completed_segments, query.data?.total_segments)
|
||||
|
||||
// Handle completion callback
|
||||
useEffect(() => {
|
||||
if (isTerminalStatus(status) && isPolling.current) {
|
||||
isPolling.current = false
|
||||
onCompleteRef.current?.()
|
||||
}
|
||||
if (isEmbedding) {
|
||||
isPolling.current = true
|
||||
}
|
||||
}, [status, isEmbedding])
|
||||
|
||||
const invalidate = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
}, [queryClient, queryKey])
|
||||
|
||||
const resetStatus = useCallback(() => {
|
||||
queryClient.setQueryData(queryKey, null)
|
||||
}, [queryClient, queryKey])
|
||||
|
||||
return {
|
||||
data: query.data,
|
||||
isLoading: query.isLoading,
|
||||
isEmbedding,
|
||||
isCompleted,
|
||||
isPaused,
|
||||
isError,
|
||||
percent,
|
||||
invalidate,
|
||||
resetStatus,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
type UsePauseResumeOptions = {
|
||||
datasetId?: string
|
||||
documentId?: string
|
||||
onSuccess?: () => void
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
export const usePauseIndexing = ({ datasetId, documentId, onSuccess, onError }: UsePauseResumeOptions) => {
|
||||
return useMutation<CommonResponse, Error>({
|
||||
mutationKey: [NAME_SPACE, 'pause', datasetId, documentId],
|
||||
mutationFn: () => pauseDocIndexing({ datasetId: datasetId!, documentId: documentId! }),
|
||||
onSuccess,
|
||||
onError,
|
||||
})
|
||||
}
|
||||
|
||||
export const useResumeIndexing = ({ datasetId, documentId, onSuccess, onError }: UsePauseResumeOptions) => {
|
||||
return useMutation<CommonResponse, Error>({
|
||||
mutationKey: [NAME_SPACE, 'resume', datasetId, documentId],
|
||||
mutationFn: () => resumeDocIndexing({ datasetId: datasetId!, documentId: documentId! }),
|
||||
onSuccess,
|
||||
onError,
|
||||
})
|
||||
}
|
||||
|
||||
export const useInvalidateEmbeddingStatus = () => {
|
||||
const queryClient = useQueryClient()
|
||||
return useCallback((datasetId?: string, documentId?: string) => {
|
||||
if (datasetId && documentId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [NAME_SPACE, 'indexing-status', datasetId, documentId],
|
||||
})
|
||||
}
|
||||
else {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [NAME_SPACE, 'indexing-status'],
|
||||
})
|
||||
}
|
||||
}, [queryClient])
|
||||
}
|
||||
@ -0,0 +1,337 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { DocumentContextValue } from '../context'
|
||||
import type { IndexingStatusResponse, ProcessRuleResponse } from '@/models/datasets'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ProcessMode } from '@/models/datasets'
|
||||
import * as datasetsService from '@/service/datasets'
|
||||
import * as useDataset from '@/service/knowledge/use-dataset'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { IndexingType } from '../../../create/step-two'
|
||||
import { DocumentContext } from '../context'
|
||||
import EmbeddingDetail from './index'
|
||||
|
||||
vi.mock('@/service/datasets')
|
||||
vi.mock('@/service/knowledge/use-dataset')
|
||||
|
||||
const mockFetchIndexingStatus = vi.mocked(datasetsService.fetchIndexingStatus)
|
||||
const mockPauseDocIndexing = vi.mocked(datasetsService.pauseDocIndexing)
|
||||
const mockResumeDocIndexing = vi.mocked(datasetsService.resumeDocIndexing)
|
||||
const mockUseProcessRule = vi.mocked(useDataset.useProcessRule)
|
||||
|
||||
const createTestQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const createWrapper = (contextValue: DocumentContextValue = { datasetId: 'ds1', documentId: 'doc1' }) => {
|
||||
const queryClient = createTestQueryClient()
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DocumentContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</DocumentContext.Provider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const mockIndexingStatus = (overrides: Partial<IndexingStatusResponse> = {}): IndexingStatusResponse => ({
|
||||
id: 'doc1',
|
||||
indexing_status: 'indexing',
|
||||
completed_segments: 50,
|
||||
total_segments: 100,
|
||||
processing_started_at: Date.now(),
|
||||
parsing_completed_at: 0,
|
||||
cleaning_completed_at: 0,
|
||||
splitting_completed_at: 0,
|
||||
completed_at: null,
|
||||
paused_at: null,
|
||||
error: null,
|
||||
stopped_at: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const mockProcessRule = (overrides: Partial<ProcessRuleResponse> = {}): ProcessRuleResponse => ({
|
||||
mode: ProcessMode.general,
|
||||
rules: {
|
||||
segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 50 },
|
||||
pre_processing_rules: [{ id: 'remove_extra_spaces', enabled: true }],
|
||||
parent_mode: 'full-doc',
|
||||
subchunk_segmentation: { separator: '\n', max_tokens: 200, chunk_overlap: 20 },
|
||||
},
|
||||
limits: { indexing_max_segmentation_tokens_length: 4000 },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('EmbeddingDetail', () => {
|
||||
const defaultProps = {
|
||||
detailUpdate: vi.fn(),
|
||||
indexingType: IndexingType.QUALIFIED,
|
||||
retrievalMethod: RETRIEVE_METHOD.semantic,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockUseProcessRule.mockReturnValue({
|
||||
data: mockProcessRule(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useDataset.useProcessRule>)
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
|
||||
|
||||
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/embedding\.processing/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render with provided datasetId and documentId props', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
|
||||
|
||||
render(
|
||||
<EmbeddingDetail {...defaultProps} datasetId="custom-ds" documentId="custom-doc" />,
|
||||
{ wrapper: createWrapper({ datasetId: '', documentId: '' }) },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchIndexingStatus).toHaveBeenCalledWith({
|
||||
datasetId: 'custom-ds',
|
||||
documentId: 'custom-doc',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should fall back to context values when props are not provided', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
|
||||
|
||||
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchIndexingStatus).toHaveBeenCalledWith({
|
||||
datasetId: 'ds1',
|
||||
documentId: 'doc1',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Status Display', () => {
|
||||
it('should show processing status when indexing', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'indexing' }))
|
||||
|
||||
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/embedding\.processing/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show completed status', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'completed' }))
|
||||
|
||||
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/embedding\.completed/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show paused status', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'paused' }))
|
||||
|
||||
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/embedding\.paused/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error status', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'error' }))
|
||||
|
||||
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/embedding\.error/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Progress Display', () => {
|
||||
it('should display segment progress', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({
|
||||
completed_segments: 50,
|
||||
total_segments: 100,
|
||||
}))
|
||||
|
||||
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/50\/100/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/50%/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pause/Resume Actions', () => {
|
||||
it('should show pause button when embedding is in progress', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'indexing' }))
|
||||
|
||||
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/embedding\.pause/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show resume button when paused', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'paused' }))
|
||||
|
||||
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/embedding\.resume/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call pause API when pause button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'indexing' }))
|
||||
mockPauseDocIndexing.mockResolvedValue({ result: 'success' })
|
||||
|
||||
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/embedding\.pause/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /pause/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPauseDocIndexing).toHaveBeenCalledWith({
|
||||
datasetId: 'ds1',
|
||||
documentId: 'doc1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call resume API when resume button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'paused' }))
|
||||
mockResumeDocIndexing.mockResolvedValue({ result: 'success' })
|
||||
|
||||
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/embedding\.resume/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /resume/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockResumeDocIndexing).toHaveBeenCalledWith({
|
||||
datasetId: 'ds1',
|
||||
documentId: 'doc1',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rule Detail', () => {
|
||||
it('should display rule detail section', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
|
||||
|
||||
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/stepTwo\.indexMode/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display qualified index mode', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
|
||||
|
||||
render(
|
||||
<EmbeddingDetail {...defaultProps} indexingType={IndexingType.QUALIFIED} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/stepTwo\.qualified/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display economical index mode', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
|
||||
|
||||
render(
|
||||
<EmbeddingDetail {...defaultProps} indexingType={IndexingType.ECONOMICAL} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/stepTwo\.economical/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('detailUpdate Callback', () => {
|
||||
it('should call detailUpdate when status becomes terminal', async () => {
|
||||
const detailUpdate = vi.fn()
|
||||
// First call returns indexing, subsequent call returns completed
|
||||
mockFetchIndexingStatus
|
||||
.mockResolvedValueOnce(mockIndexingStatus({ indexing_status: 'indexing' }))
|
||||
.mockResolvedValueOnce(mockIndexingStatus({ indexing_status: 'completed' }))
|
||||
|
||||
render(
|
||||
<EmbeddingDetail {...defaultProps} detailUpdate={detailUpdate} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Wait for the terminal status to trigger detailUpdate
|
||||
await waitFor(() => {
|
||||
expect(mockFetchIndexingStatus).toHaveBeenCalled()
|
||||
}, { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle missing context values', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
|
||||
|
||||
render(
|
||||
<EmbeddingDetail {...defaultProps} datasetId="explicit-ds" documentId="explicit-doc" />,
|
||||
{ wrapper: createWrapper({ datasetId: undefined, documentId: undefined }) },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchIndexingStatus).toHaveBeenCalledWith({
|
||||
datasetId: 'explicit-ds',
|
||||
documentId: 'explicit-doc',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should render skeleton component', async () => {
|
||||
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
|
||||
|
||||
const { container } = render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// EmbeddingSkeleton should be rendered - check for the skeleton wrapper element
|
||||
await waitFor(() => {
|
||||
const skeletonWrapper = container.querySelector('.bg-dataset-chunk-list-mask-bg')
|
||||
expect(skeletonWrapper).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,31 +1,18 @@
|
||||
import type { FC } from 'react'
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import type { IndexingStatusResponse, ProcessRuleResponse } from '@/models/datasets'
|
||||
import { RiLoader2Line, RiPauseCircleLine, RiPlayCircleLine } from '@remixicon/react'
|
||||
import Image from 'next/image'
|
||||
import type { IndexingType } from '../../../create/step-two'
|
||||
import type { RETRIEVE_METHOD } from '@/types/app'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { ProcessMode } from '@/models/datasets'
|
||||
import {
|
||||
fetchIndexingStatus as doFetchIndexingStatus,
|
||||
pauseDocIndexing,
|
||||
resumeDocIndexing,
|
||||
} from '@/service/datasets'
|
||||
import { useProcessRule } from '@/service/knowledge/use-dataset'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { asyncRunSafe, sleep } from '@/utils'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { indexMethodIcon, retrievalIcon } from '../../../create/icons'
|
||||
import { IndexingType } from '../../../create/step-two'
|
||||
import { useDocumentContext } from '../context'
|
||||
import { FieldInfo } from '../metadata'
|
||||
import { ProgressBar, RuleDetail, SegmentProgress, StatusHeader } from './components'
|
||||
import { useEmbeddingStatus, usePauseIndexing, useResumeIndexing } from './hooks'
|
||||
import EmbeddingSkeleton from './skeleton'
|
||||
|
||||
type IEmbeddingDetailProps = {
|
||||
type EmbeddingDetailProps = {
|
||||
datasetId?: string
|
||||
documentId?: string
|
||||
indexingType?: IndexingType
|
||||
@ -33,128 +20,7 @@ type IEmbeddingDetailProps = {
|
||||
detailUpdate: VoidFunction
|
||||
}
|
||||
|
||||
type IRuleDetailProps = {
|
||||
sourceData?: ProcessRuleResponse
|
||||
indexingType?: IndexingType
|
||||
retrievalMethod?: RETRIEVE_METHOD
|
||||
}
|
||||
|
||||
const RuleDetail: FC<IRuleDetailProps> = React.memo(({
|
||||
sourceData,
|
||||
indexingType,
|
||||
retrievalMethod,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const segmentationRuleMap = {
|
||||
mode: t('embedding.mode', { ns: 'datasetDocuments' }),
|
||||
segmentLength: t('embedding.segmentLength', { ns: 'datasetDocuments' }),
|
||||
textCleaning: t('embedding.textCleaning', { ns: 'datasetDocuments' }),
|
||||
}
|
||||
|
||||
const getRuleName = (key: string) => {
|
||||
if (key === 'remove_extra_spaces')
|
||||
return t('stepTwo.removeExtraSpaces', { ns: 'datasetCreation' })
|
||||
|
||||
if (key === 'remove_urls_emails')
|
||||
return t('stepTwo.removeUrlEmails', { ns: 'datasetCreation' })
|
||||
|
||||
if (key === 'remove_stopwords')
|
||||
return t('stepTwo.removeStopwords', { ns: 'datasetCreation' })
|
||||
}
|
||||
|
||||
const isNumber = (value: unknown) => {
|
||||
return typeof value === 'number'
|
||||
}
|
||||
|
||||
const getValue = useCallback((field: string) => {
|
||||
let value: string | number | undefined = '-'
|
||||
const maxTokens = isNumber(sourceData?.rules?.segmentation?.max_tokens)
|
||||
? sourceData.rules.segmentation.max_tokens
|
||||
: value
|
||||
const childMaxTokens = isNumber(sourceData?.rules?.subchunk_segmentation?.max_tokens)
|
||||
? sourceData.rules.subchunk_segmentation.max_tokens
|
||||
: value
|
||||
switch (field) {
|
||||
case 'mode':
|
||||
value = !sourceData?.mode
|
||||
? value
|
||||
: sourceData.mode === ProcessMode.general
|
||||
? (t('embedding.custom', { ns: 'datasetDocuments' }) as string)
|
||||
: `${t('embedding.hierarchical', { ns: 'datasetDocuments' })} · ${sourceData?.rules?.parent_mode === 'paragraph'
|
||||
? t('parentMode.paragraph', { ns: 'dataset' })
|
||||
: t('parentMode.fullDoc', { ns: 'dataset' })}`
|
||||
break
|
||||
case 'segmentLength':
|
||||
value = !sourceData?.mode
|
||||
? value
|
||||
: sourceData.mode === ProcessMode.general
|
||||
? maxTokens
|
||||
: `${t('embedding.parentMaxTokens', { ns: 'datasetDocuments' })} ${maxTokens}; ${t('embedding.childMaxTokens', { ns: 'datasetDocuments' })} ${childMaxTokens}`
|
||||
break
|
||||
default:
|
||||
value = !sourceData?.mode
|
||||
? value
|
||||
: sourceData?.rules?.pre_processing_rules?.filter(rule =>
|
||||
rule.enabled).map(rule => getRuleName(rule.id)).join(',')
|
||||
break
|
||||
}
|
||||
return value
|
||||
}, [sourceData])
|
||||
|
||||
return (
|
||||
<div className="py-3">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
{Object.keys(segmentationRuleMap).map((field) => {
|
||||
return (
|
||||
<FieldInfo
|
||||
key={field}
|
||||
label={segmentationRuleMap[field as keyof typeof segmentationRuleMap]}
|
||||
displayedValue={String(getValue(field))}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Divider type="horizontal" className="bg-divider-subtle" />
|
||||
<FieldInfo
|
||||
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
displayedValue={t(`stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
|
||||
valueIcon={(
|
||||
<Image
|
||||
className="size-4"
|
||||
src={
|
||||
indexingType === IndexingType.ECONOMICAL
|
||||
? indexMethodIcon.economical
|
||||
: indexMethodIcon.high_quality
|
||||
}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldInfo
|
||||
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
|
||||
displayedValue={t(`retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })}
|
||||
valueIcon={(
|
||||
<Image
|
||||
className="size-4"
|
||||
src={
|
||||
retrievalMethod === RETRIEVE_METHOD.fullText
|
||||
? retrievalIcon.fullText
|
||||
: retrievalMethod === RETRIEVE_METHOD.hybrid
|
||||
? retrievalIcon.hybrid
|
||||
: retrievalIcon.vector
|
||||
}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
RuleDetail.displayName = 'RuleDetail'
|
||||
|
||||
const EmbeddingDetail: FC<IEmbeddingDetailProps> = ({
|
||||
const EmbeddingDetail: FC<EmbeddingDetailProps> = ({
|
||||
datasetId: dstId,
|
||||
documentId: docId,
|
||||
detailUpdate,
|
||||
@ -164,144 +30,95 @@ const EmbeddingDetail: FC<IEmbeddingDetailProps> = ({
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
|
||||
const datasetId = useDocumentContext(s => s.datasetId)
|
||||
const documentId = useDocumentContext(s => s.documentId)
|
||||
const localDatasetId = dstId ?? datasetId
|
||||
const localDocumentId = docId ?? documentId
|
||||
const contextDatasetId = useDocumentContext(s => s.datasetId)
|
||||
const contextDocumentId = useDocumentContext(s => s.documentId)
|
||||
const datasetId = dstId ?? contextDatasetId
|
||||
const documentId = docId ?? contextDocumentId
|
||||
|
||||
const [indexingStatusDetail, setIndexingStatusDetail] = useState<IndexingStatusResponse | null>(null)
|
||||
const fetchIndexingStatus = async () => {
|
||||
const status = await doFetchIndexingStatus({ datasetId: localDatasetId, documentId: localDocumentId })
|
||||
setIndexingStatusDetail(status)
|
||||
return status
|
||||
}
|
||||
const {
|
||||
data: indexingStatus,
|
||||
isEmbedding,
|
||||
isCompleted,
|
||||
isPaused,
|
||||
isError,
|
||||
percent,
|
||||
resetStatus,
|
||||
refetch,
|
||||
} = useEmbeddingStatus({
|
||||
datasetId,
|
||||
documentId,
|
||||
onComplete: detailUpdate,
|
||||
})
|
||||
|
||||
const isStopQuery = useRef(false)
|
||||
const stopQueryStatus = useCallback(() => {
|
||||
isStopQuery.current = true
|
||||
}, [])
|
||||
const { data: ruleDetail } = useProcessRule(documentId)
|
||||
|
||||
const startQueryStatus = useCallback(async () => {
|
||||
if (isStopQuery.current)
|
||||
return
|
||||
const handleSuccess = useCallback(() => {
|
||||
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
|
||||
}, [notify, t])
|
||||
|
||||
try {
|
||||
const indexingStatusDetail = await fetchIndexingStatus()
|
||||
if (['completed', 'error', 'paused'].includes(indexingStatusDetail?.indexing_status)) {
|
||||
stopQueryStatus()
|
||||
detailUpdate()
|
||||
return
|
||||
}
|
||||
const handleError = useCallback(() => {
|
||||
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
|
||||
}, [notify, t])
|
||||
|
||||
await sleep(2500)
|
||||
await startQueryStatus()
|
||||
}
|
||||
catch {
|
||||
await sleep(2500)
|
||||
await startQueryStatus()
|
||||
}
|
||||
}, [stopQueryStatus])
|
||||
const pauseMutation = usePauseIndexing({
|
||||
datasetId,
|
||||
documentId,
|
||||
onSuccess: () => {
|
||||
handleSuccess()
|
||||
resetStatus()
|
||||
},
|
||||
onError: handleError,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
isStopQuery.current = false
|
||||
startQueryStatus()
|
||||
return () => {
|
||||
stopQueryStatus()
|
||||
}
|
||||
}, [startQueryStatus, stopQueryStatus])
|
||||
const resumeMutation = useResumeIndexing({
|
||||
datasetId,
|
||||
documentId,
|
||||
onSuccess: () => {
|
||||
handleSuccess()
|
||||
refetch()
|
||||
detailUpdate()
|
||||
},
|
||||
onError: handleError,
|
||||
})
|
||||
|
||||
const { data: ruleDetail } = useProcessRule(localDocumentId)
|
||||
const handlePause = useCallback(() => {
|
||||
pauseMutation.mutate()
|
||||
}, [pauseMutation])
|
||||
|
||||
const isEmbedding = useMemo(() => ['indexing', 'splitting', 'parsing', 'cleaning'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
|
||||
const isEmbeddingCompleted = useMemo(() => ['completed'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
|
||||
const isEmbeddingPaused = useMemo(() => ['paused'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
|
||||
const isEmbeddingError = useMemo(() => ['error'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
|
||||
const percent = useMemo(() => {
|
||||
const completedCount = indexingStatusDetail?.completed_segments || 0
|
||||
const totalCount = indexingStatusDetail?.total_segments || 0
|
||||
if (totalCount === 0)
|
||||
return 0
|
||||
const percent = Math.round(completedCount * 100 / totalCount)
|
||||
return percent > 100 ? 100 : percent
|
||||
}, [indexingStatusDetail])
|
||||
|
||||
const handleSwitch = async () => {
|
||||
const opApi = isEmbedding ? pauseDocIndexing : resumeDocIndexing
|
||||
const [e] = await asyncRunSafe<CommonResponse>(opApi({ datasetId: localDatasetId, documentId: localDocumentId }) as Promise<CommonResponse>)
|
||||
if (!e) {
|
||||
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
|
||||
// if the embedding is resumed from paused, we need to start the query status
|
||||
if (isEmbeddingPaused) {
|
||||
isStopQuery.current = false
|
||||
startQueryStatus()
|
||||
detailUpdate()
|
||||
}
|
||||
setIndexingStatusDetail(null)
|
||||
}
|
||||
else {
|
||||
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
|
||||
}
|
||||
}
|
||||
const handleResume = useCallback(() => {
|
||||
resumeMutation.mutate()
|
||||
}, [resumeMutation])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-y-2 px-16 py-12">
|
||||
<div className="flex h-6 items-center gap-x-1">
|
||||
{isEmbedding && <RiLoader2Line className="h-4 w-4 animate-spin text-text-secondary" />}
|
||||
<span className="system-md-semibold-uppercase grow text-text-secondary">
|
||||
{isEmbedding && t('embedding.processing', { ns: 'datasetDocuments' })}
|
||||
{isEmbeddingCompleted && t('embedding.completed', { ns: 'datasetDocuments' })}
|
||||
{isEmbeddingPaused && t('embedding.paused', { ns: 'datasetDocuments' })}
|
||||
{isEmbeddingError && t('embedding.error', { ns: 'datasetDocuments' })}
|
||||
</span>
|
||||
{isEmbedding && (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-x-1 rounded-md border-[0.5px]
|
||||
border-components-button-secondary-border bg-components-button-secondary-bg px-1.5 py-1 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]`}
|
||||
onClick={handleSwitch}
|
||||
>
|
||||
<RiPauseCircleLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
|
||||
<span className="system-xs-medium pr-[3px] text-components-button-secondary-text">
|
||||
{t('embedding.pause', { ns: 'datasetDocuments' })}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{isEmbeddingPaused && (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-x-1 rounded-md border-[0.5px]
|
||||
border-components-button-secondary-border bg-components-button-secondary-bg px-1.5 py-1 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]`}
|
||||
onClick={handleSwitch}
|
||||
>
|
||||
<RiPlayCircleLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
|
||||
<span className="system-xs-medium pr-[3px] text-components-button-secondary-text">
|
||||
{t('embedding.resume', { ns: 'datasetDocuments' })}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* progress bar */}
|
||||
<div className={cn(
|
||||
'flex h-2 w-full items-center overflow-hidden rounded-md border border-components-progress-bar-border',
|
||||
isEmbedding ? 'bg-components-progress-bar-bg/50' : 'bg-components-progress-bar-bg',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full',
|
||||
(isEmbedding || isEmbeddingCompleted) && 'bg-components-progress-bar-progress-solid',
|
||||
(isEmbeddingPaused || isEmbeddingError) && 'bg-components-progress-bar-progress-highlight',
|
||||
)}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full items-center">
|
||||
<span className="system-xs-medium text-text-secondary">
|
||||
{`${t('embedding.segments', { ns: 'datasetDocuments' })} ${indexingStatusDetail?.completed_segments || '--'}/${indexingStatusDetail?.total_segments || '--'} · ${percent}%`}
|
||||
</span>
|
||||
</div>
|
||||
<RuleDetail sourceData={ruleDetail} indexingType={indexingType} retrievalMethod={retrievalMethod} />
|
||||
<StatusHeader
|
||||
isEmbedding={isEmbedding}
|
||||
isCompleted={isCompleted}
|
||||
isPaused={isPaused}
|
||||
isError={isError}
|
||||
onPause={handlePause}
|
||||
onResume={handleResume}
|
||||
isPauseLoading={pauseMutation.isPending}
|
||||
isResumeLoading={resumeMutation.isPending}
|
||||
/>
|
||||
<ProgressBar
|
||||
percent={percent}
|
||||
isEmbedding={isEmbedding}
|
||||
isCompleted={isCompleted}
|
||||
isPaused={isPaused}
|
||||
isError={isError}
|
||||
/>
|
||||
<SegmentProgress
|
||||
completedSegments={indexingStatus?.completed_segments}
|
||||
totalSegments={indexingStatus?.total_segments}
|
||||
percent={percent}
|
||||
/>
|
||||
<RuleDetail
|
||||
sourceData={ruleDetail}
|
||||
indexingType={indexingType}
|
||||
retrievalMethod={retrievalMethod}
|
||||
/>
|
||||
</div>
|
||||
<EmbeddingSkeleton />
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user