Compare commits

..

3 Commits

Author SHA1 Message Date
fa205cba37 fix: account deletion api 2026-01-26 15:00:22 +08:00
dd988d42c2 feat: enhance quota panel to support additional model providers and integrate trial models feature (#31443)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
2026-01-26 14:04:12 +08:00
a43d2ec4f0 refactor: restructure Completed component (#31435)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
2026-01-26 14:03:51 +08:00
58 changed files with 7839 additions and 958 deletions

View File

@ -143,6 +143,14 @@ class BillingService:
raise ValueError("Invalid arguments.")
if method == "POST" and response.status_code != httpx.codes.OK:
raise ValueError(f"Unable to send request to {url}. Please try again later or contact support.")
if method == "DELETE" and response.status_code != httpx.codes.OK:
logger.error(
"billing_service: %s _send_request: response: %s %s", method, response.status_code, response.text
)
raise ValueError(f"Unable to process delete request {url}. Please try again later or contact support.")
logger.info(
"billing_service: %s _send_request: response: %s %s", method, response.status_code, response.text
)
return response.json()
@staticmethod
@ -165,7 +173,7 @@ class BillingService:
def delete_account(cls, account_id: str):
"""Delete account."""
params = {"account_id": account_id}
return cls._send_request("DELETE", "/account/", params=params)
return cls._send_request("DELETE", "/account", params=params)
@classmethod
def is_email_in_freeze(cls, email: str) -> bool:

View File

@ -1,3 +1,6 @@
/**
* @vitest-environment jsdom
*/
import type { ReactNode } from 'react'
import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context'

View File

@ -1,3 +1,6 @@
/**
* @vitest-environment jsdom
*/
import type { Mock } from 'vitest'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'

View File

@ -0,0 +1,499 @@
import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context'
import type { ChildChunkDetail, ChunkingMode, ParentMode } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import ChildSegmentList from './child-segment-list'
// ============================================================================
// Hoisted Mocks
// ============================================================================
const {
mockParentMode,
mockCurrChildChunk,
} = vi.hoisted(() => ({
mockParentMode: { current: 'paragraph' as ParentMode },
mockCurrChildChunk: { current: { childChunkInfo: undefined, showModal: false } as { childChunkInfo?: ChildChunkDetail, showModal: boolean } },
}))
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { count?: number, ns?: string }) => {
if (key === 'segment.childChunks')
return options?.count === 1 ? 'child chunk' : 'child chunks'
if (key === 'segment.searchResults')
return 'search results'
if (key === 'segment.edited')
return 'edited'
if (key === 'operation.add')
return 'Add'
const prefix = options?.ns ? `${options.ns}.` : ''
return `${prefix}${key}`
},
}),
}))
// Mock document context
vi.mock('../context', () => ({
useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => {
const value: DocumentContextValue = {
datasetId: 'test-dataset-id',
documentId: 'test-document-id',
docForm: 'text' as ChunkingMode,
parentMode: mockParentMode.current,
}
return selector(value)
},
}))
// Mock segment list context
vi.mock('./index', () => ({
useSegmentListContext: (selector: (value: { currChildChunk: { childChunkInfo?: ChildChunkDetail, showModal: boolean } }) => unknown) => {
return selector({ currChildChunk: mockCurrChildChunk.current })
},
}))
// Mock skeleton component
vi.mock('./skeleton/full-doc-list-skeleton', () => ({
default: () => <div data-testid="full-doc-list-skeleton">Loading...</div>,
}))
// Mock Empty component
vi.mock('./common/empty', () => ({
default: ({ onClearFilter }: { onClearFilter: () => void }) => (
<div data-testid="empty-component">
<button onClick={onClearFilter}>Clear Filter</button>
</div>
),
}))
// Mock FormattedText and EditSlice
vi.mock('../../../formatted-text/formatted', () => ({
FormattedText: ({ children, className }: { children: React.ReactNode, className?: string }) => (
<div data-testid="formatted-text" className={className}>{children}</div>
),
}))
vi.mock('../../../formatted-text/flavours/edit-slice', () => ({
EditSlice: ({ label, text, onDelete, onClick, labelClassName, contentClassName }: {
label: string
text: string
onDelete: () => void
onClick: (e: React.MouseEvent) => void
labelClassName?: string
contentClassName?: string
}) => (
<div data-testid="edit-slice" onClick={onClick}>
<span data-testid="edit-slice-label" className={labelClassName}>{label}</span>
<span data-testid="edit-slice-content" className={contentClassName}>{text}</span>
<button
data-testid="delete-button"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
Delete
</button>
</div>
),
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createMockChildChunk = (overrides: Partial<ChildChunkDetail> = {}): ChildChunkDetail => ({
id: `child-${Math.random().toString(36).substr(2, 9)}`,
position: 1,
segment_id: 'segment-1',
content: 'Child chunk content',
word_count: 100,
created_at: 1700000000,
updated_at: 1700000000,
type: 'automatic',
...overrides,
})
// ============================================================================
// Tests
// ============================================================================
describe('ChildSegmentList', () => {
const defaultProps = {
childChunks: [] as ChildChunkDetail[],
parentChunkId: 'parent-1',
enabled: true,
}
beforeEach(() => {
vi.clearAllMocks()
mockParentMode.current = 'paragraph'
mockCurrChildChunk.current = { childChunkInfo: undefined, showModal: false }
})
describe('Rendering', () => {
it('should render with empty child chunks', () => {
render(<ChildSegmentList {...defaultProps} />)
expect(screen.getByText(/child chunks/i)).toBeInTheDocument()
})
it('should render child chunks when provided', () => {
const childChunks = [
createMockChildChunk({ id: 'child-1', position: 1, content: 'First chunk' }),
createMockChildChunk({ id: 'child-2', position: 2, content: 'Second chunk' }),
]
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
// In paragraph mode, content is collapsed by default
expect(screen.getByText(/2 child chunks/i)).toBeInTheDocument()
})
it('should render total count correctly with total prop in full-doc mode', () => {
mockParentMode.current = 'full-doc'
const childChunks = [createMockChildChunk()]
// Pass inputValue="" to ensure isSearching is false
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} total={5} isLoading={false} inputValue="" />)
expect(screen.getByText(/5 child chunks/i)).toBeInTheDocument()
})
it('should render loading skeleton in full-doc mode when loading', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} isLoading={true} />)
expect(screen.getByTestId('full-doc-list-skeleton')).toBeInTheDocument()
})
it('should not render loading skeleton when not loading', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} isLoading={false} />)
expect(screen.queryByTestId('full-doc-list-skeleton')).not.toBeInTheDocument()
})
})
describe('Paragraph Mode', () => {
beforeEach(() => {
mockParentMode.current = 'paragraph'
})
it('should show collapse icon in paragraph mode', () => {
const childChunks = [createMockChildChunk()]
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
// Check for collapse/expand behavior
const totalRow = screen.getByText(/1 child chunk/i).closest('div')
expect(totalRow).toBeInTheDocument()
})
it('should toggle collapsed state when clicked', () => {
const childChunks = [createMockChildChunk({ content: 'Test content' })]
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
// Initially collapsed in paragraph mode - content should not be visible
expect(screen.queryByTestId('formatted-text')).not.toBeInTheDocument()
// Find and click the toggle area
const toggleArea = screen.getByText(/1 child chunk/i).closest('div')
// Click to expand
if (toggleArea)
fireEvent.click(toggleArea)
// After expansion, content should be visible
expect(screen.getByTestId('formatted-text')).toBeInTheDocument()
})
it('should apply opacity when disabled', () => {
const { container } = render(<ChildSegmentList {...defaultProps} enabled={false} />)
const wrapper = container.firstChild
expect(wrapper).toHaveClass('opacity-50')
})
it('should not apply opacity when enabled', () => {
const { container } = render(<ChildSegmentList {...defaultProps} enabled={true} />)
const wrapper = container.firstChild
expect(wrapper).not.toHaveClass('opacity-50')
})
})
describe('Full-Doc Mode', () => {
beforeEach(() => {
mockParentMode.current = 'full-doc'
})
it('should show content by default in full-doc mode', () => {
const childChunks = [createMockChildChunk({ content: 'Full doc content' })]
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} isLoading={false} />)
expect(screen.getByTestId('formatted-text')).toBeInTheDocument()
})
it('should render search input in full-doc mode', () => {
render(<ChildSegmentList {...defaultProps} inputValue="" handleInputChange={vi.fn()} />)
const input = document.querySelector('input')
expect(input).toBeInTheDocument()
})
it('should call handleInputChange when input changes', () => {
const handleInputChange = vi.fn()
render(<ChildSegmentList {...defaultProps} inputValue="" handleInputChange={handleInputChange} />)
const input = document.querySelector('input')
if (input) {
fireEvent.change(input, { target: { value: 'test search' } })
expect(handleInputChange).toHaveBeenCalledWith('test search')
}
})
it('should show search results text when searching', () => {
render(<ChildSegmentList {...defaultProps} inputValue="search term" total={3} />)
expect(screen.getByText(/3 search results/i)).toBeInTheDocument()
})
it('should show empty component when no results and searching', () => {
render(
<ChildSegmentList
{...defaultProps}
childChunks={[]}
inputValue="search term"
onClearFilter={vi.fn()}
isLoading={false}
/>,
)
expect(screen.getByTestId('empty-component')).toBeInTheDocument()
})
it('should call onClearFilter when clear button clicked in empty state', () => {
const onClearFilter = vi.fn()
render(
<ChildSegmentList
{...defaultProps}
childChunks={[]}
inputValue="search term"
onClearFilter={onClearFilter}
isLoading={false}
/>,
)
const clearButton = screen.getByText('Clear Filter')
fireEvent.click(clearButton)
expect(onClearFilter).toHaveBeenCalled()
})
})
describe('Child Chunk Items', () => {
it('should render edited label when chunk is edited', () => {
mockParentMode.current = 'full-doc'
const editedChunk = createMockChildChunk({
id: 'edited-chunk',
position: 1,
created_at: 1700000000,
updated_at: 1700000001, // Different from created_at
})
render(<ChildSegmentList {...defaultProps} childChunks={[editedChunk]} isLoading={false} />)
expect(screen.getByText(/C-1 · edited/i)).toBeInTheDocument()
})
it('should not show edited label when chunk is not edited', () => {
mockParentMode.current = 'full-doc'
const normalChunk = createMockChildChunk({
id: 'normal-chunk',
position: 2,
created_at: 1700000000,
updated_at: 1700000000, // Same as created_at
})
render(<ChildSegmentList {...defaultProps} childChunks={[normalChunk]} isLoading={false} />)
expect(screen.getByText('C-2')).toBeInTheDocument()
expect(screen.queryByText(/edited/i)).not.toBeInTheDocument()
})
it('should call onClickSlice when chunk is clicked', () => {
mockParentMode.current = 'full-doc'
const onClickSlice = vi.fn()
const chunk = createMockChildChunk({ id: 'clickable-chunk' })
render(
<ChildSegmentList
{...defaultProps}
childChunks={[chunk]}
onClickSlice={onClickSlice}
isLoading={false}
/>,
)
const editSlice = screen.getByTestId('edit-slice')
fireEvent.click(editSlice)
expect(onClickSlice).toHaveBeenCalledWith(chunk)
})
it('should call onDelete when delete button is clicked', () => {
mockParentMode.current = 'full-doc'
const onDelete = vi.fn()
const chunk = createMockChildChunk({ id: 'deletable-chunk', segment_id: 'seg-1' })
render(
<ChildSegmentList
{...defaultProps}
childChunks={[chunk]}
onDelete={onDelete}
isLoading={false}
/>,
)
const deleteButton = screen.getByTestId('delete-button')
fireEvent.click(deleteButton)
expect(onDelete).toHaveBeenCalledWith('seg-1', 'deletable-chunk')
})
it('should apply focused styles when chunk is currently selected', () => {
mockParentMode.current = 'full-doc'
const chunk = createMockChildChunk({ id: 'focused-chunk' })
mockCurrChildChunk.current = { childChunkInfo: chunk, showModal: true }
render(<ChildSegmentList {...defaultProps} childChunks={[chunk]} isLoading={false} />)
const label = screen.getByTestId('edit-slice-label')
expect(label).toHaveClass('bg-state-accent-solid')
})
})
describe('Add Button', () => {
it('should call handleAddNewChildChunk when Add button is clicked', () => {
const handleAddNewChildChunk = vi.fn()
render(
<ChildSegmentList
{...defaultProps}
handleAddNewChildChunk={handleAddNewChildChunk}
parentChunkId="parent-123"
/>,
)
const addButton = screen.getByText('Add')
fireEvent.click(addButton)
expect(handleAddNewChildChunk).toHaveBeenCalledWith('parent-123')
})
it('should disable Add button when loading in full-doc mode', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} isLoading={true} />)
const addButton = screen.getByText('Add')
expect(addButton).toBeDisabled()
})
it('should stop propagation when Add button is clicked', () => {
const handleAddNewChildChunk = vi.fn()
const parentClickHandler = vi.fn()
render(
<div onClick={parentClickHandler}>
<ChildSegmentList
{...defaultProps}
handleAddNewChildChunk={handleAddNewChildChunk}
/>
</div>,
)
const addButton = screen.getByText('Add')
fireEvent.click(addButton)
expect(handleAddNewChildChunk).toHaveBeenCalled()
// Parent should not be called due to stopPropagation
})
})
describe('computeTotalInfo function', () => {
it('should return search results when searching in full-doc mode', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} inputValue="search" total={10} />)
expect(screen.getByText(/10 search results/i)).toBeInTheDocument()
})
it('should return "--" when total is 0 in full-doc mode', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} total={0} />)
// When total is 0, displayText is '--'
expect(screen.getByText(/--/)).toBeInTheDocument()
})
it('should use childChunks length in paragraph mode', () => {
mockParentMode.current = 'paragraph'
const childChunks = [
createMockChildChunk(),
createMockChildChunk(),
createMockChildChunk(),
]
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
expect(screen.getByText(/3 child chunks/i)).toBeInTheDocument()
})
})
describe('Focused State', () => {
it('should not apply opacity when focused even if disabled', () => {
const { container } = render(
<ChildSegmentList {...defaultProps} enabled={false} focused={true} />,
)
const wrapper = container.firstChild
expect(wrapper).not.toHaveClass('opacity-50')
})
})
describe('Input clear button', () => {
it('should call handleInputChange with empty string when clear is clicked', () => {
mockParentMode.current = 'full-doc'
const handleInputChange = vi.fn()
render(
<ChildSegmentList
{...defaultProps}
inputValue="test"
handleInputChange={handleInputChange}
/>,
)
// Find the clear button (it's the showClearIcon button in Input)
const input = document.querySelector('input')
if (input) {
// Trigger clear by simulating the input's onClear
const clearButton = document.querySelector('[class*="cursor-pointer"]')
if (clearButton)
fireEvent.click(clearButton)
}
})
})
})

View File

@ -1,7 +1,7 @@
import type { FC } from 'react'
import type { ChildChunkDetail } from '@/models/datasets'
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import { useMemo, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
@ -29,6 +29,37 @@ type IChildSegmentCardProps = {
focused?: boolean
}
function computeTotalInfo(
isFullDocMode: boolean,
isSearching: boolean,
total: number | undefined,
childChunksLength: number,
): { displayText: string, count: number, translationKey: 'segment.searchResults' | 'segment.childChunks' } {
if (isSearching) {
const count = total ?? 0
return {
displayText: count === 0 ? '--' : String(formatNumber(count)),
count,
translationKey: 'segment.searchResults',
}
}
if (isFullDocMode) {
const count = total ?? 0
return {
displayText: count === 0 ? '--' : String(formatNumber(count)),
count,
translationKey: 'segment.childChunks',
}
}
return {
displayText: String(formatNumber(childChunksLength)),
count: childChunksLength,
translationKey: 'segment.childChunks',
}
}
const ChildSegmentList: FC<IChildSegmentCardProps> = ({
childChunks,
parentChunkId,
@ -49,59 +80,87 @@ const ChildSegmentList: FC<IChildSegmentCardProps> = ({
const [collapsed, setCollapsed] = useState(true)
const toggleCollapse = () => {
setCollapsed(!collapsed)
const isParagraphMode = parentMode === 'paragraph'
const isFullDocMode = parentMode === 'full-doc'
const isSearching = inputValue !== '' && isFullDocMode
const contentOpacity = (enabled || focused) ? '' : 'opacity-50 group-hover/card:opacity-100'
const { displayText, count, translationKey } = computeTotalInfo(isFullDocMode, isSearching, total, childChunks.length)
const totalText = `${displayText} ${t(translationKey, { ns: 'datasetDocuments', count })}`
const toggleCollapse = () => setCollapsed(prev => !prev)
const showContent = (isFullDocMode && !isLoading) || !collapsed
const hoverVisibleClass = isParagraphMode ? 'hidden group-hover/card:inline-block' : ''
const renderCollapseIcon = () => {
if (!isParagraphMode)
return null
const Icon = collapsed ? RiArrowRightSLine : RiArrowDownSLine
return <Icon className={cn('mr-0.5 h-4 w-4 text-text-secondary', collapsed && 'opacity-50')} />
}
const isParagraphMode = useMemo(() => {
return parentMode === 'paragraph'
}, [parentMode])
const renderChildChunkItem = (childChunk: ChildChunkDetail) => {
const isEdited = childChunk.updated_at !== childChunk.created_at
const isFocused = currChildChunk?.childChunkInfo?.id === childChunk.id
const label = isEdited
? `C-${childChunk.position} · ${t('segment.edited', { ns: 'datasetDocuments' })}`
: `C-${childChunk.position}`
const isFullDocMode = useMemo(() => {
return parentMode === 'full-doc'
}, [parentMode])
return (
<EditSlice
key={childChunk.id}
label={label}
text={childChunk.content}
onDelete={() => onDelete?.(childChunk.segment_id, childChunk.id)}
className="child-chunk"
labelClassName={isFocused ? 'bg-state-accent-solid text-text-primary-on-surface' : ''}
labelInnerClassName="text-[10px] font-semibold align-bottom leading-6"
contentClassName={cn('!leading-6', isFocused ? 'bg-state-accent-hover-alt text-text-primary' : 'text-text-secondary')}
showDivider={false}
onClick={(e) => {
e.stopPropagation()
onClickSlice?.(childChunk)
}}
offsetOptions={({ rects }) => ({
mainAxis: isFullDocMode ? -rects.floating.width : 12 - rects.floating.width,
crossAxis: (20 - rects.floating.height) / 2,
})}
/>
)
}
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('segment.childChunks', { ns: 'datasetDocuments', count })}`
const renderContent = () => {
if (childChunks.length > 0) {
return (
<FormattedText className={cn('flex w-full flex-col !leading-6', isParagraphMode ? 'gap-y-2' : 'gap-y-3')}>
{childChunks.map(renderChildChunkItem)}
</FormattedText>
)
}
else {
const text = !total ? '--' : formatNumber(total)
const count = text === '--' ? 0 : total
return `${count} ${t('segment.searchResults', { ns: 'datasetDocuments', count })}`
if (inputValue !== '') {
return (
<div className="h-full w-full">
<Empty onClearFilter={onClearFilter!} />
</div>
)
}
}, [isFullDocMode, total, childChunks.length, inputValue])
return null
}
return (
<div className={cn(
'flex flex-col',
contentOpacity,
isParagraphMode ? 'pb-2 pt-1' : 'grow px-3',
(isFullDocMode && isLoading) && 'overflow-y-hidden',
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' : '')}>
{isFullDocMode && <Divider type="horizontal" className="my-1 h-px bg-divider-subtle" />}
<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',
isParagraphMode && collapsed && 'bg-dataset-child-chunk-expand-btn-bg',
isFullDocMode && 'pl-0',
)}
onClick={(event) => {
@ -109,23 +168,15 @@ const ChildSegmentList: FC<IChildSegmentCardProps> = ({
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
}
{renderCollapseIcon()}
<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>
<span className={cn('pl-1.5 text-xs font-medium text-text-quaternary', hoverVisibleClass)}>·</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' : '',
hoverVisibleClass,
isFullDocMode && isLoading && 'text-components-button-secondary-accent-text-disabled',
)}
onClick={(event) => {
event.stopPropagation()
@ -136,70 +187,28 @@ const ChildSegmentList: FC<IChildSegmentCardProps> = ({
{t('operation.add', { ns: 'common' })}
</button>
</div>
{isFullDocMode
? (
<Input
showLeftIcon
showClearIcon
wrapperClassName="!w-52"
value={inputValue}
onChange={e => handleInputChange?.(e.target.value)}
onClear={() => handleInputChange?.('')}
/>
)
: null}
{isFullDocMode && (
<Input
showLeftIcon
showClearIcon
wrapperClassName="!w-52"
value={inputValue}
onChange={e => handleInputChange?.(e.target.value)}
onClear={() => handleInputChange?.('')}
/>
)}
</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('segment.edited', { ns: 'datasetDocuments' })}` : ''}`}
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}
{isLoading && <FullDocListSkeleton />}
{showContent && (
<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>
)
: null}
)}
{renderContent()}
</div>
)}
</div>
)
}

View File

@ -17,6 +17,31 @@ type DrawerProps = {
needCheckChunks?: boolean
}
const SIDE_POSITION_CLASS = {
right: 'right-0',
left: 'left-0',
bottom: 'bottom-0',
top: 'top-0',
} as const
function containsTarget(selector: string, target: Node | null): boolean {
const elements = document.querySelectorAll(selector)
return Array.from(elements).some(el => el?.contains(target))
}
function shouldReopenChunkDetail(
isClickOnChunk: boolean,
isClickOnChildChunk: boolean,
segmentModalOpen: boolean,
childChunkModalOpen: boolean,
): boolean {
if (segmentModalOpen && isClickOnChildChunk)
return true
if (childChunkModalOpen && isClickOnChunk && !isClickOnChildChunk)
return true
return !isClickOnChunk && !isClickOnChildChunk
}
const Drawer = ({
open,
onClose,
@ -41,22 +66,22 @@ const Drawer = ({
const shouldCloseDrawer = useCallback((target: Node | null) => {
const panelContent = panelContentRef.current
if (!panelContent)
if (!panelContent || !target)
return false
const chunks = document.querySelectorAll('.chunk-card')
const childChunks = document.querySelectorAll('.child-chunk')
const imagePreviewer = document.querySelector('.image-previewer')
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)
const isClickOnImagePreviewer = imagePreviewer && imagePreviewer.contains(target)
return target && !panelContent.contains(target) && (!needCheckChunks || reopenChunkDetail) && !isClickOnImagePreviewer
}, [currSegment, currChildChunk, needCheckChunks])
if (panelContent.contains(target))
return false
if (containsTarget('.image-previewer', target))
return false
if (!needCheckChunks)
return true
const isClickOnChunk = containsTarget('.chunk-card', target)
const isClickOnChildChunk = containsTarget('.child-chunk', target)
return shouldReopenChunkDetail(isClickOnChunk, isClickOnChildChunk, currSegment.showModal, currChildChunk.showModal)
}, [currSegment.showModal, currChildChunk.showModal, needCheckChunks])
const onDownCapture = useCallback((e: PointerEvent) => {
if (!open || modal)
@ -77,32 +102,27 @@ const Drawer = ({
const isHorizontal = side === 'left' || side === 'right'
const overlayPointerEvents = modal && open ? 'pointer-events-auto' : 'pointer-events-none'
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 */}
{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',
overlayPointerEvents,
)}
/>
)}
<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',
SIDE_POSITION_CLASS[side],
isHorizontal ? 'h-screen' : 'w-screen',
panelClassName,
)}
@ -114,7 +134,10 @@ const Drawer = ({
</div>
)
return open && createPortal(content, document.body)
if (!open)
return null
return createPortal(content, document.body)
}
export default Drawer

View File

@ -0,0 +1,129 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Empty from './empty'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
if (key === 'segment.empty')
return 'No results found'
if (key === 'segment.clearFilter')
return 'Clear Filter'
return key
},
}),
}))
describe('Empty Component', () => {
const defaultProps = {
onClearFilter: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render empty state message', () => {
render(<Empty {...defaultProps} />)
expect(screen.getByText('No results found')).toBeInTheDocument()
})
it('should render clear filter button', () => {
render(<Empty {...defaultProps} />)
expect(screen.getByText('Clear Filter')).toBeInTheDocument()
})
it('should render icon', () => {
const { container } = render(<Empty {...defaultProps} />)
// Check for the icon container
const iconContainer = container.querySelector('.shadow-lg')
expect(iconContainer).toBeInTheDocument()
})
it('should render decorative lines', () => {
const { container } = render(<Empty {...defaultProps} />)
// Check for SVG lines
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThan(0)
})
it('should render background cards', () => {
const { container } = render(<Empty {...defaultProps} />)
// Check for background empty cards (10 of them)
const backgroundCards = container.querySelectorAll('.rounded-xl.bg-background-section-burn')
expect(backgroundCards.length).toBe(10)
})
it('should render mask overlay', () => {
const { container } = render(<Empty {...defaultProps} />)
const maskOverlay = container.querySelector('.bg-dataset-chunk-list-mask-bg')
expect(maskOverlay).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should call onClearFilter when clear filter button is clicked', () => {
const onClearFilter = vi.fn()
render(<Empty onClearFilter={onClearFilter} />)
const clearButton = screen.getByText('Clear Filter')
fireEvent.click(clearButton)
expect(onClearFilter).toHaveBeenCalledTimes(1)
})
})
describe('Memoization', () => {
it('should be memoized', () => {
// Empty is wrapped with React.memo
const { rerender } = render(<Empty {...defaultProps} />)
// Same props should not cause re-render issues
rerender(<Empty {...defaultProps} />)
expect(screen.getByText('No results found')).toBeInTheDocument()
})
})
})
describe('EmptyCard Component', () => {
it('should render within Empty component', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
// EmptyCard renders as background cards
const emptyCards = container.querySelectorAll('.h-32.w-full')
expect(emptyCards.length).toBe(10)
})
it('should have correct opacity', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
const emptyCards = container.querySelectorAll('.opacity-30')
expect(emptyCards.length).toBe(10)
})
})
describe('Line Component', () => {
it('should render SVG lines within Empty component', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
// Line components render as SVG elements (4 Line components + 1 icon SVG)
const lines = container.querySelectorAll('svg')
expect(lines.length).toBeGreaterThanOrEqual(4)
})
it('should have gradient definition', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
const gradients = container.querySelectorAll('linearGradient')
expect(gradients.length).toBeGreaterThan(0)
})
})

View File

@ -0,0 +1,151 @@
'use client'
import type { FC } from 'react'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { ChildChunkDetail, ChunkingMode, SegmentDetailModel } from '@/models/datasets'
import NewSegment from '@/app/components/datasets/documents/detail/new-segment'
import ChildSegmentDetail from '../child-segment-detail'
import FullScreenDrawer from '../common/full-screen-drawer'
import NewChildSegment from '../new-child-segment'
import SegmentDetail from '../segment-detail'
type DrawerGroupProps = {
// Segment detail drawer
currSegment: {
segInfo?: SegmentDetailModel
showModal: boolean
isEditMode?: boolean
}
onCloseSegmentDetail: () => void
onUpdateSegment: (
segmentId: string,
question: string,
answer: string,
keywords: string[],
attachments: FileEntity[],
needRegenerate?: boolean,
) => Promise<void>
isRegenerationModalOpen: boolean
setIsRegenerationModalOpen: (open: boolean) => void
// New segment drawer
showNewSegmentModal: boolean
onCloseNewSegmentModal: () => void
onSaveNewSegment: () => void
viewNewlyAddedChunk: () => void
// Child segment detail drawer
currChildChunk: {
childChunkInfo?: ChildChunkDetail
showModal: boolean
}
currChunkId: string
onCloseChildSegmentDetail: () => void
onUpdateChildChunk: (segmentId: string, childChunkId: string, content: string) => Promise<void>
// New child segment drawer
showNewChildSegmentModal: boolean
onCloseNewChildChunkModal: () => void
onSaveNewChildChunk: (newChildChunk?: ChildChunkDetail) => void
viewNewlyAddedChildChunk: () => void
// Common props
fullScreen: boolean
docForm: ChunkingMode
}
const DrawerGroup: FC<DrawerGroupProps> = ({
// Segment detail drawer
currSegment,
onCloseSegmentDetail,
onUpdateSegment,
isRegenerationModalOpen,
setIsRegenerationModalOpen,
// New segment drawer
showNewSegmentModal,
onCloseNewSegmentModal,
onSaveNewSegment,
viewNewlyAddedChunk,
// Child segment detail drawer
currChildChunk,
currChunkId,
onCloseChildSegmentDetail,
onUpdateChildChunk,
// New child segment drawer
showNewChildSegmentModal,
onCloseNewChildChunkModal,
onSaveNewChildChunk,
viewNewlyAddedChildChunk,
// Common props
fullScreen,
docForm,
}) => {
return (
<>
{/* 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={onUpdateSegment}
onCancel={onCloseSegmentDetail}
onModalStateChange={setIsRegenerationModalOpen}
/>
</FullScreenDrawer>
{/* Create New Segment */}
<FullScreenDrawer
isOpen={showNewSegmentModal}
fullScreen={fullScreen}
onClose={onCloseNewSegmentModal}
modal
>
<NewSegment
docForm={docForm}
onCancel={onCloseNewSegmentModal}
onSave={onSaveNewSegment}
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={onUpdateChildChunk}
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>
</>
)
}
export default DrawerGroup

View File

@ -0,0 +1,3 @@
export { default as DrawerGroup } from './drawer-group'
export { default as MenuBar } from './menu-bar'
export { FullDocModeContent, GeneralModeContent } from './segment-list-content'

View File

@ -0,0 +1,76 @@
'use client'
import type { FC } from 'react'
import type { Item } from '@/app/components/base/select'
import Checkbox from '@/app/components/base/checkbox'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import { SimpleSelect } from '@/app/components/base/select'
import DisplayToggle from '../display-toggle'
import StatusItem from '../status-item'
import s from '../style.module.css'
type MenuBarProps = {
isAllSelected: boolean
isSomeSelected: boolean
onSelectedAll: () => void
isLoading: boolean
totalText: string
statusList: Item[]
selectDefaultValue: 'all' | 0 | 1
onChangeStatus: (item: Item) => void
inputValue: string
onInputChange: (value: string) => void
isCollapsed: boolean
toggleCollapsed: () => void
}
const MenuBar: FC<MenuBarProps> = ({
isAllSelected,
isSomeSelected,
onSelectedAll,
isLoading,
totalText,
statusList,
selectDefaultValue,
onChangeStatus,
inputValue,
onInputChange,
isCollapsed,
toggleCollapsed,
}) => {
return (
<div className={s.docSearchWrapper}>
<Checkbox
className="shrink-0"
checked={isAllSelected}
indeterminate={!isAllSelected && isSomeSelected}
onCheck={onSelectedAll}
disabled={isLoading}
/>
<div className="system-sm-semibold-uppercase flex-1 pl-5 text-text-secondary">{totalText}</div>
<SimpleSelect
onSelect={onChangeStatus}
items={statusList}
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 => onInputChange(e.target.value)}
onClear={() => onInputChange('')}
/>
<Divider type="vertical" className="mx-3 h-3.5" />
<DisplayToggle isCollapsed={isCollapsed} toggleCollapsed={toggleCollapsed} />
</div>
)
}
export default MenuBar

View File

@ -0,0 +1,127 @@
'use client'
import type { FC } from 'react'
import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
import { cn } from '@/utils/classnames'
import ChildSegmentList from '../child-segment-list'
import SegmentCard from '../segment-card'
import SegmentList from '../segment-list'
type FullDocModeContentProps = {
segments: SegmentDetailModel[]
childSegments: ChildChunkDetail[]
isLoadingSegmentList: boolean
isLoadingChildSegmentList: boolean
currSegmentId?: string
onClickCard: (detail: SegmentDetailModel, isEditMode?: boolean) => void
onDeleteChildChunk: (segmentId: string, childChunkId: string) => Promise<void>
handleInputChange: (value: string) => void
handleAddNewChildChunk: (parentChunkId: string) => void
onClickSlice: (detail: ChildChunkDetail) => void
archived?: boolean
childChunkTotal: number
inputValue: string
onClearFilter: () => void
}
export const FullDocModeContent: FC<FullDocModeContentProps> = ({
segments,
childSegments,
isLoadingSegmentList,
isLoadingChildSegmentList,
currSegmentId,
onClickCard,
onDeleteChildChunk,
handleInputChange,
handleAddNewChildChunk,
onClickSlice,
archived,
childChunkTotal,
inputValue,
onClearFilter,
}) => {
const firstSegment = segments[0]
return (
<div className={cn(
'flex grow flex-col overflow-x-hidden',
(isLoadingSegmentList || isLoadingChildSegmentList) ? 'overflow-y-hidden' : 'overflow-y-auto',
)}
>
<SegmentCard
detail={firstSegment}
onClick={() => onClickCard(firstSegment)}
loading={isLoadingSegmentList}
focused={{
segmentIndex: currSegmentId === firstSegment?.id,
segmentContent: currSegmentId === firstSegment?.id,
}}
/>
<ChildSegmentList
parentChunkId={firstSegment?.id}
onDelete={onDeleteChildChunk}
childChunks={childSegments}
handleInputChange={handleInputChange}
handleAddNewChildChunk={handleAddNewChildChunk}
onClickSlice={onClickSlice}
enabled={!archived}
total={childChunkTotal}
inputValue={inputValue}
onClearFilter={onClearFilter}
isLoading={isLoadingSegmentList || isLoadingChildSegmentList}
/>
</div>
)
}
type GeneralModeContentProps = {
segmentListRef: React.RefObject<HTMLDivElement | null>
embeddingAvailable: boolean
isLoadingSegmentList: boolean
segments: SegmentDetailModel[]
selectedSegmentIds: string[]
onSelected: (segId: string) => void
onChangeSwitch: (enable: boolean, segId?: string) => Promise<void>
onDelete: (segId?: string) => Promise<void>
onClickCard: (detail: SegmentDetailModel, isEditMode?: boolean) => void
archived?: boolean
onDeleteChildChunk: (segmentId: string, childChunkId: string) => Promise<void>
handleAddNewChildChunk: (parentChunkId: string) => void
onClickSlice: (detail: ChildChunkDetail) => void
onClearFilter: () => void
}
export const GeneralModeContent: FC<GeneralModeContentProps> = ({
segmentListRef,
embeddingAvailable,
isLoadingSegmentList,
segments,
selectedSegmentIds,
onSelected,
onChangeSwitch,
onDelete,
onClickCard,
archived,
onDeleteChildChunk,
handleAddNewChildChunk,
onClickSlice,
onClearFilter,
}) => {
return (
<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}
/>
)
}

View File

@ -0,0 +1,14 @@
export { useChildSegmentData } from './use-child-segment-data'
export type { UseChildSegmentDataReturn } from './use-child-segment-data'
export { useModalState } from './use-modal-state'
export type { CurrChildChunkType, CurrSegmentType, UseModalStateReturn } from './use-modal-state'
export { useSearchFilter } from './use-search-filter'
export type { UseSearchFilterReturn } from './use-search-filter'
export { useSegmentListData } from './use-segment-list-data'
export type { UseSegmentListDataReturn } from './use-segment-list-data'
export { useSegmentSelection } from './use-segment-selection'
export type { UseSegmentSelectionReturn } from './use-segment-selection'

View File

@ -0,0 +1,568 @@
import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context'
import type { ChildChunkDetail, ChildSegmentsResponse, ChunkingMode, ParentMode, SegmentDetailModel } from '@/models/datasets'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import * as React from 'react'
import { useChildSegmentData } from './use-child-segment-data'
// Type for mutation callbacks
type MutationResponse = { data: ChildChunkDetail }
type MutationCallbacks = {
onSuccess: (res: MutationResponse) => void
onSettled: () => void
}
type _ErrorCallback = { onSuccess?: () => void, onError: () => void }
// ============================================================================
// Hoisted Mocks
// ============================================================================
const {
mockParentMode,
mockDatasetId,
mockDocumentId,
mockNotify,
mockEventEmitter,
mockQueryClient,
mockChildSegmentListData,
mockDeleteChildSegment,
mockUpdateChildSegment,
mockInvalidChildSegmentList,
} = vi.hoisted(() => ({
mockParentMode: { current: 'paragraph' as ParentMode },
mockDatasetId: { current: 'test-dataset-id' },
mockDocumentId: { current: 'test-document-id' },
mockNotify: vi.fn(),
mockEventEmitter: { emit: vi.fn(), on: vi.fn(), off: vi.fn() },
mockQueryClient: { setQueryData: vi.fn() },
mockChildSegmentListData: { current: { data: [] as ChildChunkDetail[], total: 0, total_pages: 0 } as ChildSegmentsResponse | undefined },
mockDeleteChildSegment: vi.fn(),
mockUpdateChildSegment: vi.fn(),
mockInvalidChildSegmentList: vi.fn(),
}))
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
if (key === 'actionMsg.modifiedSuccessfully')
return 'Modified successfully'
if (key === 'actionMsg.modifiedUnsuccessfully')
return 'Modified unsuccessfully'
if (key === 'segment.contentEmpty')
return 'Content cannot be empty'
return key
},
}),
}))
vi.mock('@tanstack/react-query', async () => {
const actual = await vi.importActual('@tanstack/react-query')
return {
...actual,
useQueryClient: () => mockQueryClient,
}
})
vi.mock('../../context', () => ({
useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => {
const value: DocumentContextValue = {
datasetId: mockDatasetId.current,
documentId: mockDocumentId.current,
docForm: 'text' as ChunkingMode,
parentMode: mockParentMode.current,
}
return selector(value)
},
}))
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({ eventEmitter: mockEventEmitter }),
}))
vi.mock('@/service/knowledge/use-segment', () => ({
useChildSegmentList: () => ({
isLoading: false,
data: mockChildSegmentListData.current,
}),
useChildSegmentListKey: ['segment', 'childChunkList'],
useDeleteChildSegment: () => ({ mutateAsync: mockDeleteChildSegment }),
useUpdateChildSegment: () => ({ mutateAsync: mockUpdateChildSegment }),
}))
vi.mock('@/service/use-base', () => ({
useInvalid: () => mockInvalidChildSegmentList,
}))
// ============================================================================
// Test Utilities
// ============================================================================
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const createWrapper = () => {
const queryClient = createQueryClient()
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const createMockChildChunk = (overrides: Partial<ChildChunkDetail> = {}): ChildChunkDetail => ({
id: `child-${Math.random().toString(36).substr(2, 9)}`,
position: 1,
segment_id: 'segment-1',
content: 'Child chunk content',
word_count: 100,
created_at: 1700000000,
updated_at: 1700000000,
type: 'automatic',
...overrides,
})
const createMockSegment = (overrides: Partial<SegmentDetailModel> = {}): SegmentDetailModel => ({
id: 'segment-1',
position: 1,
document_id: 'doc-1',
content: 'Test content',
sign_content: 'Test signed content',
word_count: 100,
tokens: 50,
keywords: [],
index_node_id: 'index-1',
index_node_hash: 'hash-1',
hit_count: 0,
enabled: true,
disabled_at: 0,
disabled_by: '',
status: 'completed',
created_by: 'user-1',
created_at: 1700000000,
indexing_at: 1700000100,
completed_at: 1700000200,
error: null,
stopped_at: 0,
updated_at: 1700000000,
attachments: [],
child_chunks: [],
...overrides,
})
const defaultOptions = {
searchValue: '',
currentPage: 1,
limit: 10,
segments: [createMockSegment()] as SegmentDetailModel[],
currChunkId: 'segment-1',
isFullDocMode: true,
onCloseChildSegmentDetail: vi.fn(),
refreshChunkListDataWithDetailChanged: vi.fn(),
updateSegmentInCache: vi.fn(),
}
// ============================================================================
// Tests
// ============================================================================
describe('useChildSegmentData', () => {
beforeEach(() => {
vi.clearAllMocks()
mockParentMode.current = 'paragraph'
mockDatasetId.current = 'test-dataset-id'
mockDocumentId.current = 'test-document-id'
mockChildSegmentListData.current = { data: [], total: 0, total_pages: 0, page: 1, limit: 20 }
})
describe('Initial State', () => {
it('should return empty child segments initially', () => {
const { result } = renderHook(() => useChildSegmentData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.childSegments).toEqual([])
expect(result.current.isLoadingChildSegmentList).toBe(false)
})
})
describe('resetChildList', () => {
it('should call invalidChildSegmentList', () => {
const { result } = renderHook(() => useChildSegmentData(defaultOptions), {
wrapper: createWrapper(),
})
act(() => {
result.current.resetChildList()
})
expect(mockInvalidChildSegmentList).toHaveBeenCalled()
})
})
describe('onDeleteChildChunk', () => {
it('should delete child chunk and update parent cache in paragraph mode', async () => {
mockParentMode.current = 'paragraph'
const updateSegmentInCache = vi.fn()
mockDeleteChildSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
updateSegmentInCache,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDeleteChildChunk('seg-1', 'child-1')
})
expect(mockDeleteChildSegment).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' })
expect(updateSegmentInCache).toHaveBeenCalledWith('seg-1', expect.any(Function))
})
it('should delete child chunk and reset list in full-doc mode', async () => {
mockParentMode.current = 'full-doc'
mockDeleteChildSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useChildSegmentData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDeleteChildChunk('seg-1', 'child-1')
})
expect(mockInvalidChildSegmentList).toHaveBeenCalled()
})
it('should notify error on failure', async () => {
mockDeleteChildSegment.mockImplementation(async (_params, { onError }: { onError: () => void }) => {
onError()
})
const { result } = renderHook(() => useChildSegmentData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDeleteChildChunk('seg-1', 'child-1')
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Modified unsuccessfully' })
})
})
describe('handleUpdateChildChunk', () => {
it('should validate empty content', async () => {
const { result } = renderHook(() => useChildSegmentData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateChildChunk('seg-1', 'child-1', ' ')
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Content cannot be empty' })
expect(mockUpdateChildSegment).not.toHaveBeenCalled()
})
it('should update child chunk and parent cache in paragraph mode', async () => {
mockParentMode.current = 'paragraph'
const updateSegmentInCache = vi.fn()
const onCloseChildSegmentDetail = vi.fn()
const refreshChunkListDataWithDetailChanged = vi.fn()
mockUpdateChildSegment.mockImplementation(async (_params, { onSuccess, onSettled }: MutationCallbacks) => {
onSuccess({
data: createMockChildChunk({
content: 'updated content',
type: 'customized',
word_count: 50,
updated_at: 1700000001,
}),
})
onSettled()
})
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
updateSegmentInCache,
onCloseChildSegmentDetail,
refreshChunkListDataWithDetailChanged,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'updated content')
})
expect(mockUpdateChildSegment).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' })
expect(onCloseChildSegmentDetail).toHaveBeenCalled()
expect(updateSegmentInCache).toHaveBeenCalled()
expect(refreshChunkListDataWithDetailChanged).toHaveBeenCalled()
expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-child-segment')
expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-child-segment-done')
})
it('should update child chunk cache in full-doc mode', async () => {
mockParentMode.current = 'full-doc'
const onCloseChildSegmentDetail = vi.fn()
mockUpdateChildSegment.mockImplementation(async (_params, { onSuccess, onSettled }: MutationCallbacks) => {
onSuccess({
data: createMockChildChunk({
content: 'updated content',
type: 'customized',
word_count: 50,
updated_at: 1700000001,
}),
})
onSettled()
})
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
onCloseChildSegmentDetail,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'updated content')
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
})
describe('onSaveNewChildChunk', () => {
it('should update parent cache in paragraph mode', () => {
mockParentMode.current = 'paragraph'
const updateSegmentInCache = vi.fn()
const refreshChunkListDataWithDetailChanged = vi.fn()
const newChildChunk = createMockChildChunk({ id: 'new-child' })
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
updateSegmentInCache,
refreshChunkListDataWithDetailChanged,
}), {
wrapper: createWrapper(),
})
act(() => {
result.current.onSaveNewChildChunk(newChildChunk)
})
expect(updateSegmentInCache).toHaveBeenCalled()
expect(refreshChunkListDataWithDetailChanged).toHaveBeenCalled()
})
it('should reset child list in full-doc mode', () => {
mockParentMode.current = 'full-doc'
const { result } = renderHook(() => useChildSegmentData(defaultOptions), {
wrapper: createWrapper(),
})
act(() => {
result.current.onSaveNewChildChunk(createMockChildChunk())
})
expect(mockInvalidChildSegmentList).toHaveBeenCalled()
})
})
describe('viewNewlyAddedChildChunk', () => {
it('should set needScrollToBottom and not reset when adding new page', () => {
mockChildSegmentListData.current = { data: [], total: 10, total_pages: 1, page: 1, limit: 20 }
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
limit: 10,
}), {
wrapper: createWrapper(),
})
act(() => {
result.current.viewNewlyAddedChildChunk()
})
expect(result.current.needScrollToBottom.current).toBe(true)
})
it('should call resetChildList when not adding new page', () => {
mockChildSegmentListData.current = { data: [], total: 5, total_pages: 1, page: 1, limit: 20 }
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
limit: 10,
}), {
wrapper: createWrapper(),
})
act(() => {
result.current.viewNewlyAddedChildChunk()
})
expect(mockInvalidChildSegmentList).toHaveBeenCalled()
})
})
describe('Query disabled states', () => {
it('should disable query when not in fullDocMode', () => {
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
isFullDocMode: false,
}), {
wrapper: createWrapper(),
})
// Query should be disabled but hook should still work
expect(result.current.childSegments).toEqual([])
})
it('should disable query when segments is empty', () => {
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
segments: [],
}), {
wrapper: createWrapper(),
})
expect(result.current.childSegments).toEqual([])
})
})
describe('Cache update callbacks', () => {
it('should use updateSegmentInCache when deleting in paragraph mode', async () => {
mockParentMode.current = 'paragraph'
const updateSegmentInCache = vi.fn()
mockDeleteChildSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
updateSegmentInCache,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDeleteChildChunk('seg-1', 'child-1')
})
expect(updateSegmentInCache).toHaveBeenCalledWith('seg-1', expect.any(Function))
// Verify the updater function filters correctly
const updaterFn = updateSegmentInCache.mock.calls[0][1]
const testSegment = createMockSegment({
child_chunks: [
createMockChildChunk({ id: 'child-1' }),
createMockChildChunk({ id: 'child-2' }),
],
})
const updatedSegment = updaterFn(testSegment)
expect(updatedSegment.child_chunks).toHaveLength(1)
expect(updatedSegment.child_chunks[0].id).toBe('child-2')
})
it('should use updateSegmentInCache when updating in paragraph mode', async () => {
mockParentMode.current = 'paragraph'
const updateSegmentInCache = vi.fn()
const onCloseChildSegmentDetail = vi.fn()
const refreshChunkListDataWithDetailChanged = vi.fn()
mockUpdateChildSegment.mockImplementation(async (_params, { onSuccess, onSettled }: MutationCallbacks) => {
onSuccess({
data: createMockChildChunk({
id: 'child-1',
content: 'new content',
type: 'customized',
word_count: 50,
updated_at: 1700000001,
}),
})
onSettled()
})
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
updateSegmentInCache,
onCloseChildSegmentDetail,
refreshChunkListDataWithDetailChanged,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'new content')
})
expect(updateSegmentInCache).toHaveBeenCalledWith('seg-1', expect.any(Function))
// Verify the updater function maps correctly
const updaterFn = updateSegmentInCache.mock.calls[0][1]
const testSegment = createMockSegment({
child_chunks: [
createMockChildChunk({ id: 'child-1', content: 'old content' }),
createMockChildChunk({ id: 'child-2', content: 'other content' }),
],
})
const updatedSegment = updaterFn(testSegment)
expect(updatedSegment.child_chunks).toHaveLength(2)
expect(updatedSegment.child_chunks[0].content).toBe('new content')
expect(updatedSegment.child_chunks[1].content).toBe('other content')
})
})
describe('updateChildSegmentInCache in full-doc mode', () => {
it('should use updateChildSegmentInCache when updating in full-doc mode', async () => {
mockParentMode.current = 'full-doc'
const onCloseChildSegmentDetail = vi.fn()
mockUpdateChildSegment.mockImplementation(async (_params, { onSuccess, onSettled }: MutationCallbacks) => {
onSuccess({
data: createMockChildChunk({
id: 'child-1',
content: 'new content',
type: 'customized',
word_count: 50,
updated_at: 1700000001,
}),
})
onSettled()
})
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
onCloseChildSegmentDetail,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'new content')
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,241 @@
import type { ChildChunkDetail, ChildSegmentsResponse, SegmentDetailModel, SegmentUpdater } from '@/models/datasets'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import {
useChildSegmentList,
useChildSegmentListKey,
useDeleteChildSegment,
useUpdateChildSegment,
} from '@/service/knowledge/use-segment'
import { useInvalid } from '@/service/use-base'
import { useDocumentContext } from '../../context'
export type UseChildSegmentDataOptions = {
searchValue: string
currentPage: number
limit: number
segments: SegmentDetailModel[]
currChunkId: string
isFullDocMode: boolean
onCloseChildSegmentDetail: () => void
refreshChunkListDataWithDetailChanged: () => void
updateSegmentInCache: (segmentId: string, updater: (seg: SegmentDetailModel) => SegmentDetailModel) => void
}
export type UseChildSegmentDataReturn = {
childSegments: ChildChunkDetail[]
isLoadingChildSegmentList: boolean
childChunkListData: ReturnType<typeof useChildSegmentList>['data']
childSegmentListRef: React.RefObject<HTMLDivElement | null>
needScrollToBottom: React.RefObject<boolean>
// Operations
onDeleteChildChunk: (segmentId: string, childChunkId: string) => Promise<void>
handleUpdateChildChunk: (segmentId: string, childChunkId: string, content: string) => Promise<void>
onSaveNewChildChunk: (newChildChunk?: ChildChunkDetail) => void
resetChildList: () => void
viewNewlyAddedChildChunk: () => void
}
export const useChildSegmentData = (options: UseChildSegmentDataOptions): UseChildSegmentDataReturn => {
const {
searchValue,
currentPage,
limit,
segments,
currChunkId,
isFullDocMode,
onCloseChildSegmentDetail,
refreshChunkListDataWithDetailChanged,
updateSegmentInCache,
} = options
const { t } = useTranslation()
const { notify } = useToastContext()
const { eventEmitter } = useEventEmitterContextContext()
const queryClient = useQueryClient()
const datasetId = useDocumentContext(s => s.datasetId) || ''
const documentId = useDocumentContext(s => s.documentId) || ''
const parentMode = useDocumentContext(s => s.parentMode)
const childSegmentListRef = useRef<HTMLDivElement>(null)
const needScrollToBottom = useRef(false)
// Build query params
const queryParams = useMemo(() => ({
page: currentPage === 0 ? 1 : currentPage,
limit,
keyword: searchValue,
}), [currentPage, limit, searchValue])
const segmentId = segments[0]?.id || ''
// Build query key for optimistic updates
const currentQueryKey = useMemo(() =>
[...useChildSegmentListKey, datasetId, documentId, segmentId, queryParams], [datasetId, documentId, segmentId, queryParams])
// Fetch child segment list
const { isLoading: isLoadingChildSegmentList, data: childChunkListData } = useChildSegmentList(
{
datasetId,
documentId,
segmentId,
params: queryParams,
},
!isFullDocMode || segments.length === 0,
)
// Derive child segments from query data
const childSegments = useMemo(() => childChunkListData?.data || [], [childChunkListData])
const invalidChildSegmentList = useInvalid(useChildSegmentListKey)
// Scroll to bottom when child segments change
useEffect(() => {
if (childSegmentListRef.current && needScrollToBottom.current) {
childSegmentListRef.current.scrollTo({ top: childSegmentListRef.current.scrollHeight, behavior: 'smooth' })
needScrollToBottom.current = false
}
}, [childSegments])
const resetChildList = useCallback(() => {
invalidChildSegmentList()
}, [invalidChildSegmentList])
// Optimistic update helper for child segments
const updateChildSegmentInCache = useCallback((
childChunkId: string,
updater: (chunk: ChildChunkDetail) => ChildChunkDetail,
) => {
queryClient.setQueryData<ChildSegmentsResponse>(currentQueryKey, (old) => {
if (!old)
return old
return {
...old,
data: old.data.map(chunk => chunk.id === childChunkId ? updater(chunk) : chunk),
}
})
}, [queryClient, currentQueryKey])
// Mutations
const { mutateAsync: deleteChildSegment } = useDeleteChildSegment()
const { mutateAsync: updateChildSegment } = useUpdateChildSegment()
const onDeleteChildChunk = useCallback(async (segmentIdParam: string, childChunkId: string) => {
await deleteChildSegment(
{ datasetId, documentId, segmentId: segmentIdParam, childChunkId },
{
onSuccess: () => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
if (parentMode === 'paragraph') {
// Update parent segment's child_chunks in cache
updateSegmentInCache(segmentIdParam, seg => ({
...seg,
child_chunks: seg.child_chunks?.filter(chunk => chunk.id !== childChunkId),
}))
}
else {
resetChildList()
}
},
onError: () => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
},
)
}, [datasetId, documentId, parentMode, deleteChildSegment, updateSegmentInCache, resetChildList, t, notify])
const handleUpdateChildChunk = useCallback(async (
segmentIdParam: string,
childChunkId: string,
content: string,
) => {
const params: SegmentUpdater = { content: '' }
if (!content.trim()) {
notify({ type: 'error', message: t('segment.contentEmpty', { ns: 'datasetDocuments' }) })
return
}
params.content = content
eventEmitter?.emit('update-child-segment')
await updateChildSegment({ datasetId, documentId, segmentId: segmentIdParam, childChunkId, body: params }, {
onSuccess: (res) => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
onCloseChildSegmentDetail()
if (parentMode === 'paragraph') {
// Update parent segment's child_chunks in cache
updateSegmentInCache(segmentIdParam, seg => ({
...seg,
child_chunks: seg.child_chunks?.map(childSeg =>
childSeg.id === childChunkId
? {
...childSeg,
content: res.data.content,
type: res.data.type,
word_count: res.data.word_count,
updated_at: res.data.updated_at,
}
: childSeg,
),
}))
refreshChunkListDataWithDetailChanged()
}
else {
updateChildSegmentInCache(childChunkId, chunk => ({
...chunk,
content: res.data.content,
type: res.data.type,
word_count: res.data.word_count,
updated_at: res.data.updated_at,
}))
}
},
onSettled: () => {
eventEmitter?.emit('update-child-segment-done')
},
})
}, [datasetId, documentId, parentMode, updateChildSegment, notify, eventEmitter, onCloseChildSegmentDetail, updateSegmentInCache, updateChildSegmentInCache, refreshChunkListDataWithDetailChanged, t])
const onSaveNewChildChunk = useCallback((newChildChunk?: ChildChunkDetail) => {
if (parentMode === 'paragraph') {
// Update parent segment's child_chunks in cache
updateSegmentInCache(currChunkId, seg => ({
...seg,
child_chunks: [...(seg.child_chunks || []), newChildChunk!],
}))
refreshChunkListDataWithDetailChanged()
}
else {
resetChildList()
}
}, [parentMode, currChunkId, updateSegmentInCache, 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)
return
resetChildList()
}, [childChunkListData, limit, resetChildList])
return {
childSegments,
isLoadingChildSegmentList,
childChunkListData,
childSegmentListRef,
needScrollToBottom,
onDeleteChildChunk,
handleUpdateChildChunk,
onSaveNewChildChunk,
resetChildList,
viewNewlyAddedChildChunk,
}
}

View File

@ -0,0 +1,141 @@
import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
import { useCallback, useState } from 'react'
export type CurrSegmentType = {
segInfo?: SegmentDetailModel
showModal: boolean
isEditMode?: boolean
}
export type CurrChildChunkType = {
childChunkInfo?: ChildChunkDetail
showModal: boolean
}
export type UseModalStateReturn = {
// Segment detail modal
currSegment: CurrSegmentType
onClickCard: (detail: SegmentDetailModel, isEditMode?: boolean) => void
onCloseSegmentDetail: () => void
// Child segment detail modal
currChildChunk: CurrChildChunkType
currChunkId: string
onClickSlice: (detail: ChildChunkDetail) => void
onCloseChildSegmentDetail: () => void
// New segment modal
onCloseNewSegmentModal: () => void
// New child segment modal
showNewChildSegmentModal: boolean
handleAddNewChildChunk: (parentChunkId: string) => void
onCloseNewChildChunkModal: () => void
// Regeneration modal
isRegenerationModalOpen: boolean
setIsRegenerationModalOpen: (open: boolean) => void
// Full screen
fullScreen: boolean
toggleFullScreen: () => void
setFullScreen: (fullScreen: boolean) => void
// Collapsed state
isCollapsed: boolean
toggleCollapsed: () => void
}
type UseModalStateOptions = {
onNewSegmentModalChange: (state: boolean) => void
}
export const useModalState = (options: UseModalStateOptions): UseModalStateReturn => {
const { onNewSegmentModalChange } = options
// Segment detail modal state
const [currSegment, setCurrSegment] = useState<CurrSegmentType>({ showModal: false })
// Child segment detail modal state
const [currChildChunk, setCurrChildChunk] = useState<CurrChildChunkType>({ showModal: false })
const [currChunkId, setCurrChunkId] = useState('')
// New child segment modal state
const [showNewChildSegmentModal, setShowNewChildSegmentModal] = useState(false)
// Regeneration modal state
const [isRegenerationModalOpen, setIsRegenerationModalOpen] = useState(false)
// Display state
const [fullScreen, setFullScreen] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(true)
// Segment detail handlers
const onClickCard = useCallback((detail: SegmentDetailModel, isEditMode = false) => {
setCurrSegment({ segInfo: detail, showModal: true, isEditMode })
}, [])
const onCloseSegmentDetail = useCallback(() => {
setCurrSegment({ showModal: false })
setFullScreen(false)
}, [])
// Child segment detail handlers
const onClickSlice = useCallback((detail: ChildChunkDetail) => {
setCurrChildChunk({ childChunkInfo: detail, showModal: true })
setCurrChunkId(detail.segment_id)
}, [])
const onCloseChildSegmentDetail = useCallback(() => {
setCurrChildChunk({ showModal: false })
setFullScreen(false)
}, [])
// New segment modal handlers
const onCloseNewSegmentModal = useCallback(() => {
onNewSegmentModalChange(false)
setFullScreen(false)
}, [onNewSegmentModalChange])
// New child segment modal handlers
const handleAddNewChildChunk = useCallback((parentChunkId: string) => {
setShowNewChildSegmentModal(true)
setCurrChunkId(parentChunkId)
}, [])
const onCloseNewChildChunkModal = useCallback(() => {
setShowNewChildSegmentModal(false)
setFullScreen(false)
}, [])
// Display handlers - handles both direct calls and click events
const toggleFullScreen = useCallback(() => {
setFullScreen(prev => !prev)
}, [])
const toggleCollapsed = useCallback(() => {
setIsCollapsed(prev => !prev)
}, [])
return {
// Segment detail modal
currSegment,
onClickCard,
onCloseSegmentDetail,
// Child segment detail modal
currChildChunk,
currChunkId,
onClickSlice,
onCloseChildSegmentDetail,
// New segment modal
onCloseNewSegmentModal,
// New child segment modal
showNewChildSegmentModal,
handleAddNewChildChunk,
onCloseNewChildChunkModal,
// Regeneration modal
isRegenerationModalOpen,
setIsRegenerationModalOpen,
// Full screen
fullScreen,
toggleFullScreen,
setFullScreen,
// Collapsed state
isCollapsed,
toggleCollapsed,
}
}

View File

@ -0,0 +1,85 @@
import type { Item } from '@/app/components/base/select'
import { useDebounceFn } from 'ahooks'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
export type SearchFilterState = {
inputValue: string
searchValue: string
selectedStatus: boolean | 'all'
}
export type UseSearchFilterReturn = {
inputValue: string
searchValue: string
selectedStatus: boolean | 'all'
statusList: Item[]
selectDefaultValue: 'all' | 0 | 1
handleInputChange: (value: string) => void
onChangeStatus: (item: Item) => void
onClearFilter: () => void
resetPage: () => void
}
type UseSearchFilterOptions = {
onPageChange: (page: number) => void
}
export const useSearchFilter = (options: UseSearchFilterOptions): UseSearchFilterReturn => {
const { t } = useTranslation()
const { onPageChange } = options
const [inputValue, setInputValue] = useState<string>('')
const [searchValue, setSearchValue] = useState<string>('')
const [selectedStatus, setSelectedStatus] = useState<boolean | 'all'>('all')
const statusList = useRef<Item[]>([
{ value: 'all', name: t('list.index.all', { ns: 'datasetDocuments' }) },
{ value: 0, name: t('list.status.disabled', { ns: 'datasetDocuments' }) },
{ value: 1, name: t('list.status.enabled', { ns: 'datasetDocuments' }) },
])
const { run: handleSearch } = useDebounceFn(() => {
setSearchValue(inputValue)
onPageChange(1)
}, { wait: 500 })
const handleInputChange = useCallback((value: string) => {
setInputValue(value)
handleSearch()
}, [handleSearch])
const onChangeStatus = useCallback(({ value }: Item) => {
setSelectedStatus(value === 'all' ? 'all' : !!value)
onPageChange(1)
}, [onPageChange])
const onClearFilter = useCallback(() => {
setInputValue('')
setSearchValue('')
setSelectedStatus('all')
onPageChange(1)
}, [onPageChange])
const resetPage = useCallback(() => {
onPageChange(1)
}, [onPageChange])
const selectDefaultValue = useMemo(() => {
if (selectedStatus === 'all')
return 'all'
return selectedStatus ? 1 : 0
}, [selectedStatus])
return {
inputValue,
searchValue,
selectedStatus,
statusList: statusList.current,
selectDefaultValue,
handleInputChange,
onChangeStatus,
onClearFilter,
resetPage,
}
}

View File

@ -0,0 +1,942 @@
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context'
import type { ChunkingMode, ParentMode, SegmentDetailModel, SegmentsResponse } from '@/models/datasets'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import * as React from 'react'
import { ChunkingMode as ChunkingModeEnum } from '@/models/datasets'
import { ProcessStatus } from '../../segment-add'
import { useSegmentListData } from './use-segment-list-data'
// Type for mutation callbacks
type SegmentMutationResponse = { data: SegmentDetailModel }
type SegmentMutationCallbacks = {
onSuccess: (res: SegmentMutationResponse) => void
onSettled: () => void
}
// Mock file entity factory
const createMockFileEntity = (overrides: Partial<FileEntity> = {}): FileEntity => ({
id: 'file-1',
name: 'test.png',
size: 1024,
extension: 'png',
mimeType: 'image/png',
progress: 100,
uploadedId: undefined,
base64Url: undefined,
...overrides,
})
// ============================================================================
// Hoisted Mocks
// ============================================================================
const {
mockDocForm,
mockParentMode,
mockDatasetId,
mockDocumentId,
mockNotify,
mockEventEmitter,
mockQueryClient,
mockSegmentListData,
mockEnableSegment,
mockDisableSegment,
mockDeleteSegment,
mockUpdateSegment,
mockInvalidSegmentList,
mockInvalidChunkListAll,
mockInvalidChunkListEnabled,
mockInvalidChunkListDisabled,
mockPathname,
} = vi.hoisted(() => ({
mockDocForm: { current: 'text' as ChunkingMode },
mockParentMode: { current: 'paragraph' as ParentMode },
mockDatasetId: { current: 'test-dataset-id' },
mockDocumentId: { current: 'test-document-id' },
mockNotify: vi.fn(),
mockEventEmitter: { emit: vi.fn(), on: vi.fn(), off: vi.fn() },
mockQueryClient: { setQueryData: vi.fn() },
mockSegmentListData: { current: { data: [] as SegmentDetailModel[], total: 0, total_pages: 0, has_more: false, limit: 20, page: 1 } as SegmentsResponse | undefined },
mockEnableSegment: vi.fn(),
mockDisableSegment: vi.fn(),
mockDeleteSegment: vi.fn(),
mockUpdateSegment: vi.fn(),
mockInvalidSegmentList: vi.fn(),
mockInvalidChunkListAll: vi.fn(),
mockInvalidChunkListEnabled: vi.fn(),
mockInvalidChunkListDisabled: vi.fn(),
mockPathname: { current: '/datasets/test/documents/test' },
}))
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { count?: number, ns?: string }) => {
if (key === 'actionMsg.modifiedSuccessfully')
return 'Modified successfully'
if (key === 'actionMsg.modifiedUnsuccessfully')
return 'Modified unsuccessfully'
if (key === 'segment.contentEmpty')
return 'Content cannot be empty'
if (key === 'segment.questionEmpty')
return 'Question cannot be empty'
if (key === 'segment.answerEmpty')
return 'Answer cannot be empty'
if (key === 'segment.allFilesUploaded')
return 'All files must be uploaded'
if (key === 'segment.chunks')
return options?.count === 1 ? 'chunk' : 'chunks'
if (key === 'segment.parentChunks')
return options?.count === 1 ? 'parent chunk' : 'parent chunks'
if (key === 'segment.searchResults')
return 'search results'
return `${options?.ns || ''}.${key}`
},
}),
}))
vi.mock('next/navigation', () => ({
usePathname: () => mockPathname.current,
}))
vi.mock('@tanstack/react-query', async () => {
const actual = await vi.importActual('@tanstack/react-query')
return {
...actual,
useQueryClient: () => mockQueryClient,
}
})
vi.mock('../../context', () => ({
useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => {
const value: DocumentContextValue = {
datasetId: mockDatasetId.current,
documentId: mockDocumentId.current,
docForm: mockDocForm.current,
parentMode: mockParentMode.current,
}
return selector(value)
},
}))
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({ eventEmitter: mockEventEmitter }),
}))
vi.mock('@/service/knowledge/use-segment', () => ({
useSegmentList: () => ({
isLoading: false,
data: mockSegmentListData.current,
}),
useSegmentListKey: ['segment', 'chunkList'],
useChunkListAllKey: ['segment', 'chunkList', { enabled: 'all' }],
useChunkListEnabledKey: ['segment', 'chunkList', { enabled: true }],
useChunkListDisabledKey: ['segment', 'chunkList', { enabled: false }],
useEnableSegment: () => ({ mutateAsync: mockEnableSegment }),
useDisableSegment: () => ({ mutateAsync: mockDisableSegment }),
useDeleteSegment: () => ({ mutateAsync: mockDeleteSegment }),
useUpdateSegment: () => ({ mutateAsync: mockUpdateSegment }),
}))
vi.mock('@/service/use-base', () => ({
useInvalid: (key: unknown[]) => {
const keyObj = key[2] as { enabled?: boolean | 'all' } | undefined
if (keyObj?.enabled === 'all')
return mockInvalidChunkListAll
if (keyObj?.enabled === true)
return mockInvalidChunkListEnabled
if (keyObj?.enabled === false)
return mockInvalidChunkListDisabled
return mockInvalidSegmentList
},
}))
// ============================================================================
// Test Utilities
// ============================================================================
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const createWrapper = () => {
const queryClient = createQueryClient()
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const createMockSegment = (overrides: Partial<SegmentDetailModel> = {}): SegmentDetailModel => ({
id: `segment-${Math.random().toString(36).substr(2, 9)}`,
position: 1,
document_id: 'doc-1',
content: 'Test content',
sign_content: 'Test signed content',
word_count: 100,
tokens: 50,
keywords: [],
index_node_id: 'index-1',
index_node_hash: 'hash-1',
hit_count: 0,
enabled: true,
disabled_at: 0,
disabled_by: '',
status: 'completed',
created_by: 'user-1',
created_at: 1700000000,
indexing_at: 1700000100,
completed_at: 1700000200,
error: null,
stopped_at: 0,
updated_at: 1700000000,
attachments: [],
child_chunks: [],
...overrides,
})
const defaultOptions = {
searchValue: '',
selectedStatus: 'all' as boolean | 'all',
selectedSegmentIds: [] as string[],
importStatus: undefined as ProcessStatus | string | undefined,
currentPage: 1,
limit: 10,
onCloseSegmentDetail: vi.fn(),
clearSelection: vi.fn(),
}
// ============================================================================
// Tests
// ============================================================================
describe('useSegmentListData', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDocForm.current = ChunkingModeEnum.text as ChunkingMode
mockParentMode.current = 'paragraph'
mockDatasetId.current = 'test-dataset-id'
mockDocumentId.current = 'test-document-id'
mockSegmentListData.current = { data: [], total: 0, total_pages: 0, has_more: false, limit: 20, page: 1 }
mockPathname.current = '/datasets/test/documents/test'
})
describe('Initial State', () => {
it('should return empty segments initially', () => {
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.segments).toEqual([])
expect(result.current.isLoadingSegmentList).toBe(false)
})
it('should compute isFullDocMode correctly', () => {
mockDocForm.current = ChunkingModeEnum.parentChild
mockParentMode.current = 'full-doc'
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.isFullDocMode).toBe(true)
})
it('should compute isFullDocMode as false for text mode', () => {
mockDocForm.current = ChunkingModeEnum.text
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.isFullDocMode).toBe(false)
})
})
describe('totalText computation', () => {
it('should show chunks count when not searching', () => {
mockSegmentListData.current = { data: [], total: 10, total_pages: 1, has_more: false, limit: 20, page: 1 }
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.totalText).toContain('10')
expect(result.current.totalText).toContain('chunks')
})
it('should show search results when searching', () => {
mockSegmentListData.current = { data: [], total: 5, total_pages: 1, has_more: false, limit: 20, page: 1 }
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
searchValue: 'test',
}), {
wrapper: createWrapper(),
})
expect(result.current.totalText).toContain('5')
expect(result.current.totalText).toContain('search results')
})
it('should show search results when status is filtered', () => {
mockSegmentListData.current = { data: [], total: 3, total_pages: 1, has_more: false, limit: 20, page: 1 }
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedStatus: true,
}), {
wrapper: createWrapper(),
})
expect(result.current.totalText).toContain('search results')
})
it('should show parent chunks in parentChild paragraph mode', () => {
mockDocForm.current = ChunkingModeEnum.parentChild
mockParentMode.current = 'paragraph'
mockSegmentListData.current = { data: [], total: 7, total_pages: 1, has_more: false, limit: 20, page: 1 }
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.totalText).toContain('parent chunk')
})
it('should show "--" when total is undefined', () => {
mockSegmentListData.current = undefined
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.totalText).toContain('--')
})
})
describe('resetList', () => {
it('should call clearSelection and invalidSegmentList', () => {
const clearSelection = vi.fn()
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
clearSelection,
}), {
wrapper: createWrapper(),
})
act(() => {
result.current.resetList()
})
expect(clearSelection).toHaveBeenCalled()
expect(mockInvalidSegmentList).toHaveBeenCalled()
})
})
describe('refreshChunkListWithStatusChanged', () => {
it('should invalidate disabled and enabled when status is all', async () => {
mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedStatus: 'all',
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, 'seg-1')
})
expect(mockInvalidChunkListDisabled).toHaveBeenCalled()
expect(mockInvalidChunkListEnabled).toHaveBeenCalled()
})
it('should invalidate segment list when status is not all', async () => {
mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedStatus: true,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, 'seg-1')
})
expect(mockInvalidSegmentList).toHaveBeenCalled()
})
})
describe('onChangeSwitch', () => {
it('should call enableSegment when enable is true', async () => {
mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, 'seg-1')
})
expect(mockEnableSegment).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' })
})
it('should call disableSegment when enable is false', async () => {
mockDisableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(false, 'seg-1')
})
expect(mockDisableSegment).toHaveBeenCalled()
})
it('should use selectedSegmentIds when segId is empty', async () => {
mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedSegmentIds: ['seg-1', 'seg-2'],
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, '')
})
expect(mockEnableSegment).toHaveBeenCalledWith(
expect.objectContaining({ segmentIds: ['seg-1', 'seg-2'] }),
expect.any(Object),
)
})
it('should notify error on failure', async () => {
mockEnableSegment.mockImplementation(async (_params, { onError }: { onError: () => void }) => {
onError()
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, 'seg-1')
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Modified unsuccessfully' })
})
})
describe('onDelete', () => {
it('should call deleteSegment and resetList on success', async () => {
const clearSelection = vi.fn()
mockDeleteSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
clearSelection,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDelete('seg-1')
})
expect(mockDeleteSegment).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' })
})
it('should clear selection when deleting batch (no segId)', async () => {
const clearSelection = vi.fn()
mockDeleteSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedSegmentIds: ['seg-1', 'seg-2'],
clearSelection,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDelete('')
})
// clearSelection is called twice: once in resetList, once after
expect(clearSelection).toHaveBeenCalled()
})
it('should notify error on failure', async () => {
mockDeleteSegment.mockImplementation(async (_params, { onError }: { onError: () => void }) => {
onError()
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDelete('seg-1')
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Modified unsuccessfully' })
})
})
describe('handleUpdateSegment', () => {
it('should validate empty content', async () => {
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', ' ', '', [], [])
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Content cannot be empty' })
expect(mockUpdateSegment).not.toHaveBeenCalled()
})
it('should validate empty question in QA mode', async () => {
mockDocForm.current = ChunkingModeEnum.qa
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', '', 'answer', [], [])
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Question cannot be empty' })
})
it('should validate empty answer in QA mode', async () => {
mockDocForm.current = ChunkingModeEnum.qa
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'question', ' ', [], [])
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Answer cannot be empty' })
})
it('should validate attachments are uploaded', async () => {
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [
createMockFileEntity({ id: '1', name: 'test.png', uploadedId: undefined }),
])
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'All files must be uploaded' })
})
it('should call updateSegment with correct params', async () => {
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const onCloseSegmentDetail = vi.fn()
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
onCloseSegmentDetail,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'updated content', '', ['keyword1'], [])
})
expect(mockUpdateSegment).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' })
expect(onCloseSegmentDetail).toHaveBeenCalled()
expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-segment')
expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-segment-success')
expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-segment-done')
})
it('should not close modal when needRegenerate is true', async () => {
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const onCloseSegmentDetail = vi.fn()
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
onCloseSegmentDetail,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [], true)
})
expect(onCloseSegmentDetail).not.toHaveBeenCalled()
})
it('should include attachments in params', async () => {
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [
createMockFileEntity({ id: '1', name: 'test.png', uploadedId: 'uploaded-1' }),
])
})
expect(mockUpdateSegment).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({ attachment_ids: ['uploaded-1'] }),
}),
expect.any(Object),
)
})
})
describe('viewNewlyAddedChunk', () => {
it('should set needScrollToBottom and not call resetList when adding new page', () => {
mockSegmentListData.current = { data: [], total: 10, total_pages: 1, has_more: false, limit: 20, page: 1 }
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
limit: 10,
}), {
wrapper: createWrapper(),
})
act(() => {
result.current.viewNewlyAddedChunk()
})
expect(result.current.needScrollToBottom.current).toBe(true)
})
it('should call resetList when not adding new page', () => {
mockSegmentListData.current = { data: [], total: 5, total_pages: 1, has_more: false, limit: 20, page: 1 }
const clearSelection = vi.fn()
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
clearSelection,
limit: 10,
}), {
wrapper: createWrapper(),
})
act(() => {
result.current.viewNewlyAddedChunk()
})
// resetList should be called
expect(clearSelection).toHaveBeenCalled()
})
})
describe('updateSegmentInCache', () => {
it('should call queryClient.setQueryData', () => {
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
act(() => {
result.current.updateSegmentInCache('seg-1', seg => ({ ...seg, enabled: false }))
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
})
describe('Effect: pathname change', () => {
it('should reset list when pathname changes', async () => {
const clearSelection = vi.fn()
renderHook(() => useSegmentListData({
...defaultOptions,
clearSelection,
}), {
wrapper: createWrapper(),
})
// Initial call from effect
expect(clearSelection).toHaveBeenCalled()
expect(mockInvalidSegmentList).toHaveBeenCalled()
})
})
describe('Effect: import status', () => {
it('should reset list when import status is COMPLETED', () => {
const clearSelection = vi.fn()
renderHook(() => useSegmentListData({
...defaultOptions,
importStatus: ProcessStatus.COMPLETED,
clearSelection,
}), {
wrapper: createWrapper(),
})
expect(clearSelection).toHaveBeenCalled()
})
})
describe('refreshChunkListDataWithDetailChanged', () => {
it('should call correct invalidation for status all', async () => {
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedStatus: 'all',
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [])
})
expect(mockInvalidChunkListDisabled).toHaveBeenCalled()
expect(mockInvalidChunkListEnabled).toHaveBeenCalled()
})
it('should call correct invalidation for status true', async () => {
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedStatus: true,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [])
})
expect(mockInvalidChunkListAll).toHaveBeenCalled()
expect(mockInvalidChunkListDisabled).toHaveBeenCalled()
})
it('should call correct invalidation for status false', async () => {
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedStatus: false,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [])
})
expect(mockInvalidChunkListAll).toHaveBeenCalled()
expect(mockInvalidChunkListEnabled).toHaveBeenCalled()
})
})
describe('QA Mode validation', () => {
it('should set content and answer for QA mode', async () => {
mockDocForm.current = ChunkingModeEnum.qa as ChunkingMode
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'question', 'answer', [], [])
})
expect(mockUpdateSegment).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
content: 'question',
answer: 'answer',
}),
}),
expect.any(Object),
)
})
})
describe('updateSegmentsInCache', () => {
it('should handle undefined old data', () => {
mockQueryClient.setQueryData.mockImplementation((_key, updater) => {
const result = typeof updater === 'function' ? updater(undefined) : updater
return result
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
// Call updateSegmentInCache which should handle undefined gracefully
act(() => {
result.current.updateSegmentInCache('seg-1', seg => ({ ...seg, enabled: false }))
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
it('should map segments correctly when old data exists', () => {
const mockOldData = {
data: [
createMockSegment({ id: 'seg-1', enabled: true }),
createMockSegment({ id: 'seg-2', enabled: true }),
],
total: 2,
total_pages: 1,
}
mockQueryClient.setQueryData.mockImplementation((_key, updater) => {
const result = typeof updater === 'function' ? updater(mockOldData) : updater
// Verify the updater transforms the data correctly
expect(result.data[0].enabled).toBe(false) // seg-1 should be updated
expect(result.data[1].enabled).toBe(true) // seg-2 should remain unchanged
return result
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
act(() => {
result.current.updateSegmentInCache('seg-1', seg => ({ ...seg, enabled: false }))
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
})
describe('updateSegmentsInCache batch', () => {
it('should handle undefined old data in batch update', async () => {
mockQueryClient.setQueryData.mockImplementation((_key, updater) => {
const result = typeof updater === 'function' ? updater(undefined) : updater
return result
})
mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedSegmentIds: ['seg-1', 'seg-2'],
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, '')
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
it('should map multiple segments correctly when old data exists', async () => {
const mockOldData = {
data: [
createMockSegment({ id: 'seg-1', enabled: false }),
createMockSegment({ id: 'seg-2', enabled: false }),
createMockSegment({ id: 'seg-3', enabled: false }),
],
total: 3,
total_pages: 1,
}
mockQueryClient.setQueryData.mockImplementation((_key, updater) => {
const result = typeof updater === 'function' ? updater(mockOldData) : updater
// Verify only selected segments are updated
if (result && result.data) {
expect(result.data[0].enabled).toBe(true) // seg-1 should be updated
expect(result.data[1].enabled).toBe(true) // seg-2 should be updated
expect(result.data[2].enabled).toBe(false) // seg-3 should remain unchanged
}
return result
})
mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedSegmentIds: ['seg-1', 'seg-2'],
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, '')
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,363 @@
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { SegmentDetailModel, SegmentsResponse, SegmentUpdater } from '@/models/datasets'
import { useQueryClient } from '@tanstack/react-query'
import { usePathname } from 'next/navigation'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { ChunkingMode } from '@/models/datasets'
import {
useChunkListAllKey,
useChunkListDisabledKey,
useChunkListEnabledKey,
useDeleteSegment,
useDisableSegment,
useEnableSegment,
useSegmentList,
useSegmentListKey,
useUpdateSegment,
} from '@/service/knowledge/use-segment'
import { useInvalid } from '@/service/use-base'
import { formatNumber } from '@/utils/format'
import { useDocumentContext } from '../../context'
import { ProcessStatus } from '../../segment-add'
const DEFAULT_LIMIT = 10
export type UseSegmentListDataOptions = {
searchValue: string
selectedStatus: boolean | 'all'
selectedSegmentIds: string[]
importStatus: ProcessStatus | string | undefined
currentPage: number
limit: number
onCloseSegmentDetail: () => void
clearSelection: () => void
}
export type UseSegmentListDataReturn = {
segments: SegmentDetailModel[]
isLoadingSegmentList: boolean
segmentListData: ReturnType<typeof useSegmentList>['data']
totalText: string
isFullDocMode: boolean
segmentListRef: React.RefObject<HTMLDivElement | null>
needScrollToBottom: React.RefObject<boolean>
// Operations
onChangeSwitch: (enable: boolean, segId?: string) => Promise<void>
onDelete: (segId?: string) => Promise<void>
handleUpdateSegment: (
segmentId: string,
question: string,
answer: string,
keywords: string[],
attachments: FileEntity[],
needRegenerate?: boolean,
) => Promise<void>
resetList: () => void
viewNewlyAddedChunk: () => void
invalidSegmentList: () => void
updateSegmentInCache: (segmentId: string, updater: (seg: SegmentDetailModel) => SegmentDetailModel) => void
}
export const useSegmentListData = (options: UseSegmentListDataOptions): UseSegmentListDataReturn => {
const {
searchValue,
selectedStatus,
selectedSegmentIds,
importStatus,
currentPage,
limit,
onCloseSegmentDetail,
clearSelection,
} = options
const { t } = useTranslation()
const { notify } = useToastContext()
const pathname = usePathname()
const { eventEmitter } = useEventEmitterContextContext()
const queryClient = useQueryClient()
const datasetId = useDocumentContext(s => s.datasetId) || ''
const documentId = useDocumentContext(s => s.documentId) || ''
const docForm = useDocumentContext(s => s.docForm)
const parentMode = useDocumentContext(s => s.parentMode)
const segmentListRef = useRef<HTMLDivElement>(null)
const needScrollToBottom = useRef(false)
const isFullDocMode = useMemo(() => {
return docForm === ChunkingMode.parentChild && parentMode === 'full-doc'
}, [docForm, parentMode])
// Build query params
const queryParams = useMemo(() => ({
page: isFullDocMode ? 1 : currentPage,
limit: isFullDocMode ? DEFAULT_LIMIT : limit,
keyword: isFullDocMode ? '' : searchValue,
enabled: selectedStatus,
}), [isFullDocMode, currentPage, limit, searchValue, selectedStatus])
// Build query key for optimistic updates
const currentQueryKey = useMemo(() =>
[...useSegmentListKey, datasetId, documentId, queryParams], [datasetId, documentId, queryParams])
// Fetch segment list
const { isLoading: isLoadingSegmentList, data: segmentListData } = useSegmentList({
datasetId,
documentId,
params: queryParams,
})
// Derive segments from query data
const segments = useMemo(() => segmentListData?.data || [], [segmentListData])
// Invalidation hooks
const invalidSegmentList = useInvalid(useSegmentListKey)
const invalidChunkListAll = useInvalid(useChunkListAllKey)
const invalidChunkListEnabled = useInvalid(useChunkListEnabledKey)
const invalidChunkListDisabled = useInvalid(useChunkListDisabledKey)
// Scroll to bottom when needed
useEffect(() => {
if (segmentListRef.current && needScrollToBottom.current) {
segmentListRef.current.scrollTo({ top: segmentListRef.current.scrollHeight, behavior: 'smooth' })
needScrollToBottom.current = false
}
}, [segments])
// Reset list on pathname change
useEffect(() => {
clearSelection()
invalidSegmentList()
}, [pathname])
// Reset list on import completion
useEffect(() => {
if (importStatus === ProcessStatus.COMPLETED) {
clearSelection()
invalidSegmentList()
}
}, [importStatus])
const resetList = useCallback(() => {
clearSelection()
invalidSegmentList()
}, [clearSelection, invalidSegmentList])
const refreshChunkListWithStatusChanged = useCallback(() => {
if (selectedStatus === 'all') {
invalidChunkListDisabled()
invalidChunkListEnabled()
}
else {
invalidSegmentList()
}
}, [selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidSegmentList])
const refreshChunkListDataWithDetailChanged = useCallback(() => {
const refreshMap: Record<string, () => void> = {
all: () => {
invalidChunkListDisabled()
invalidChunkListEnabled()
},
true: () => {
invalidChunkListAll()
invalidChunkListDisabled()
},
false: () => {
invalidChunkListAll()
invalidChunkListEnabled()
},
}
refreshMap[String(selectedStatus)]?.()
}, [selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidChunkListAll])
// Optimistic update helper using React Query's setQueryData
const updateSegmentInCache = useCallback((
segmentId: string,
updater: (seg: SegmentDetailModel) => SegmentDetailModel,
) => {
queryClient.setQueryData<SegmentsResponse>(currentQueryKey, (old) => {
if (!old)
return old
return {
...old,
data: old.data.map(seg => seg.id === segmentId ? updater(seg) : seg),
}
})
}, [queryClient, currentQueryKey])
// Batch update helper
const updateSegmentsInCache = useCallback((
segmentIds: string[],
updater: (seg: SegmentDetailModel) => SegmentDetailModel,
) => {
queryClient.setQueryData<SegmentsResponse>(currentQueryKey, (old) => {
if (!old)
return old
return {
...old,
data: old.data.map(seg => segmentIds.includes(seg.id) ? updater(seg) : seg),
}
})
}, [queryClient, currentQueryKey])
// Mutations
const { mutateAsync: enableSegment } = useEnableSegment()
const { mutateAsync: disableSegment } = useDisableSegment()
const { mutateAsync: deleteSegment } = useDeleteSegment()
const { mutateAsync: updateSegment } = useUpdateSegment()
const onChangeSwitch = useCallback(async (enable: boolean, segId?: string) => {
const operationApi = enable ? enableSegment : disableSegment
const targetIds = segId ? [segId] : selectedSegmentIds
await operationApi({ datasetId, documentId, segmentIds: targetIds }, {
onSuccess: () => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
updateSegmentsInCache(targetIds, seg => ({ ...seg, enabled: enable }))
refreshChunkListWithStatusChanged()
},
onError: () => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
})
}, [datasetId, documentId, selectedSegmentIds, disableSegment, enableSegment, t, notify, updateSegmentsInCache, refreshChunkListWithStatusChanged])
const onDelete = useCallback(async (segId?: string) => {
const targetIds = segId ? [segId] : selectedSegmentIds
await deleteSegment({ datasetId, documentId, segmentIds: targetIds }, {
onSuccess: () => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
resetList()
if (!segId)
clearSelection()
},
onError: () => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
})
}, [datasetId, documentId, selectedSegmentIds, deleteSegment, resetList, clearSelection, t, notify])
const handleUpdateSegment = useCallback(async (
segmentId: string,
question: string,
answer: string,
keywords: string[],
attachments: FileEntity[],
needRegenerate = false,
) => {
const params: SegmentUpdater = { content: '', attachment_ids: [] }
// Validate and build params based on doc form
if (docForm === ChunkingMode.qa) {
if (!question.trim()) {
notify({ type: 'error', message: t('segment.questionEmpty', { ns: 'datasetDocuments' }) })
return
}
if (!answer.trim()) {
notify({ type: 'error', message: t('segment.answerEmpty', { ns: 'datasetDocuments' }) })
return
}
params.content = question
params.answer = answer
}
else {
if (!question.trim()) {
notify({ type: 'error', message: t('segment.contentEmpty', { ns: 'datasetDocuments' }) })
return
}
params.content = question
}
if (keywords.length)
params.keywords = keywords
if (attachments.length) {
const notAllUploaded = attachments.some(item => !item.uploadedId)
if (notAllUploaded) {
notify({ type: 'error', message: t('segment.allFilesUploaded', { ns: 'datasetDocuments' }) })
return
}
params.attachment_ids = attachments.map(item => item.uploadedId!)
}
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('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
if (!needRegenerate)
onCloseSegmentDetail()
updateSegmentInCache(segmentId, seg => ({
...seg,
answer: res.data.answer,
content: res.data.content,
sign_content: res.data.sign_content,
keywords: res.data.keywords,
attachments: res.data.attachments,
word_count: res.data.word_count,
hit_count: res.data.hit_count,
enabled: res.data.enabled,
updated_at: res.data.updated_at,
child_chunks: res.data.child_chunks,
}))
refreshChunkListDataWithDetailChanged()
eventEmitter?.emit('update-segment-success')
},
onSettled() {
eventEmitter?.emit('update-segment-done')
},
})
}, [datasetId, documentId, docForm, updateSegment, notify, eventEmitter, onCloseSegmentDetail, updateSegmentInCache, refreshChunkListDataWithDetailChanged, t])
const viewNewlyAddedChunk = useCallback(() => {
const totalPages = segmentListData?.total_pages || 0
const total = segmentListData?.total || 0
const newPage = Math.ceil((total + 1) / limit)
needScrollToBottom.current = true
if (newPage > totalPages)
return
resetList()
}, [segmentListData, limit, resetList])
// Compute total text for display
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')
? 'segment.parentChunks' as const
: 'segment.chunks' as const
return `${total} ${t(translationKey, { ns: 'datasetDocuments', count })}`
}
const total = typeof segmentListData?.total === 'number' ? formatNumber(segmentListData.total) : 0
const count = segmentListData?.total || 0
return `${total} ${t('segment.searchResults', { ns: 'datasetDocuments', count })}`
}, [segmentListData, docForm, parentMode, searchValue, selectedStatus, t])
return {
segments,
isLoadingSegmentList,
segmentListData,
totalText,
isFullDocMode,
segmentListRef,
needScrollToBottom,
onChangeSwitch,
onDelete,
handleUpdateSegment,
resetList,
viewNewlyAddedChunk,
invalidSegmentList,
updateSegmentInCache,
}
}

View File

@ -0,0 +1,58 @@
import type { SegmentDetailModel } from '@/models/datasets'
import { useCallback, useMemo, useState } from 'react'
export type UseSegmentSelectionReturn = {
selectedSegmentIds: string[]
isAllSelected: boolean
isSomeSelected: boolean
onSelected: (segId: string) => void
onSelectedAll: () => void
onCancelBatchOperation: () => void
clearSelection: () => void
}
export const useSegmentSelection = (segments: SegmentDetailModel[]): UseSegmentSelectionReturn => {
const [selectedSegmentIds, setSelectedSegmentIds] = useState<string[]>([])
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 onCancelBatchOperation = useCallback(() => {
setSelectedSegmentIds([])
}, [])
const clearSelection = useCallback(() => {
setSelectedSegmentIds([])
}, [])
return {
selectedSegmentIds,
isAllSelected,
isSomeSelected,
onSelected,
onSelectedAll,
onCancelBatchOperation,
clearSelection,
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,89 +1,33 @@
'use client'
import type { FC } from 'react'
import type { Item } from '@/app/components/base/select'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { ChildChunkDetail, SegmentDetailModel, SegmentUpdater } from '@/models/datasets'
import { useDebounceFn } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { usePathname } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { createContext, useContext, useContextSelector } from 'use-context-selector'
import Checkbox from '@/app/components/base/checkbox'
import type { ProcessStatus } from '../segment-add'
import type { SegmentListContextValue } from './segment-list-context'
import { useCallback, useMemo, useState } from 'react'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import Pagination from '@/app/components/base/pagination'
import { SimpleSelect } from '@/app/components/base/select'
import { ToastContext } from '@/app/components/base/toast'
import NewSegment from '@/app/components/datasets/documents/detail/new-segment'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { ChunkingMode } from '@/models/datasets'
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 { cn } from '@/utils/classnames'
import { formatNumber } from '@/utils/format'
import { useDocumentContext } from '../context'
import { ProcessStatus } from '../segment-add'
import ChildSegmentDetail from './child-segment-detail'
import ChildSegmentList from './child-segment-list'
import BatchAction from './common/batch-action'
import FullScreenDrawer from './common/full-screen-drawer'
import DisplayToggle from './display-toggle'
import NewChildSegment from './new-child-segment'
import SegmentCard from './segment-card'
import SegmentDetail from './segment-detail'
import SegmentList from './segment-list'
import StatusItem from './status-item'
import s from './style.module.css'
import { DrawerGroup, FullDocModeContent, GeneralModeContent, MenuBar } from './components'
import {
useChildSegmentData,
useModalState,
useSearchFilter,
useSegmentListData,
useSegmentSelection,
} from './hooks'
import {
SegmentListContext,
useSegmentListContext,
} from './segment-list-context'
const DEFAULT_LIMIT = 10
type CurrSegmentType = {
segInfo?: SegmentDetailModel
showModal: boolean
isEditMode?: boolean
}
type CurrChildChunkType = {
childChunkInfo?: ChildChunkDetail
showModal: boolean
}
export 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
@ -91,6 +35,7 @@ type ICompletedProps = {
importStatus: ProcessStatus | string | undefined
archived?: boolean
}
/**
* Embedding done, show list of all segments
* Support search and filter
@ -102,669 +47,219 @@ const Completed: FC<ICompletedProps> = ({
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
// Pagination state
const [currentPage, setCurrentPage] = useState(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('list.index.all', { ns: 'datasetDocuments' }) },
{ value: 0, name: t('list.status.disabled', { ns: 'datasetDocuments' }) },
{ value: 1, name: t('list.status.enabled', { ns: 'datasetDocuments' }) },
])
// Search and filter state
const searchFilter = useSearchFilter({
onPageChange: setCurrentPage,
})
const { run: handleSearch } = useDebounceFn(() => {
setSearchValue(inputValue)
setCurrentPage(1)
}, { wait: 500 })
// Modal state
const modalState = useModalState({
onNewSegmentModalChange,
})
const handleInputChange = (value: string) => {
setInputValue(value)
handleSearch()
}
// Selection state (need segments first, so we use a placeholder initially)
const [segmentsForSelection, setSegmentsForSelection] = useState<string[]>([])
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()
// Invalidation hooks for child segment data
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('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
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('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
})
}, [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('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
resetList()
if (!segId)
setSelectedSegmentIds([])
},
onError: () => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
})
}, [datasetId, documentId, selectedSegmentIds, deleteSegment, resetList, t, notify])
const { mutateAsync: updateSegment } = useUpdateSegment()
const refreshChunkListDataWithDetailChanged = useCallback(() => {
switch (selectedStatus) {
case 'all':
const refreshMap: Record<string, () => void> = {
all: () => {
invalidChunkListDisabled()
invalidChunkListEnabled()
break
case true:
},
true: () => {
invalidChunkListAll()
invalidChunkListDisabled()
break
case false:
},
false: () => {
invalidChunkListAll()
invalidChunkListEnabled()
break
}
}, [selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidChunkListAll])
const handleUpdateSegment = useCallback(async (
segmentId: string,
question: string,
answer: string,
keywords: string[],
attachments: FileEntity[],
needRegenerate = false,
) => {
const params: SegmentUpdater = { content: '', attachment_ids: [] }
if (docForm === ChunkingMode.qa) {
if (!question.trim())
return notify({ type: 'error', message: t('segment.questionEmpty', { ns: 'datasetDocuments' }) })
if (!answer.trim())
return notify({ type: 'error', message: t('segment.answerEmpty', { ns: 'datasetDocuments' }) })
params.content = question
params.answer = answer
}
else {
if (!question.trim())
return notify({ type: 'error', message: t('segment.contentEmpty', { ns: 'datasetDocuments' }) })
params.content = question
}
if (keywords.length)
params.keywords = keywords
if (attachments.length) {
const notAllUploaded = attachments.some(item => !item.uploadedId)
if (notAllUploaded)
return notify({ type: 'error', message: t('segment.allFilesUploaded', { ns: 'datasetDocuments' }) })
params.attachment_ids = attachments.map(item => item.uploadedId!)
}
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('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
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.attachments = res.data.attachments
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])
}
refreshMap[String(searchFilter.selectedStatus)]?.()
}, [searchFilter.selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidChunkListAll])
useEffect(() => {
resetList()
}, [pathname])
// Segment list data
const segmentListDataHook = useSegmentListData({
searchValue: searchFilter.searchValue,
selectedStatus: searchFilter.selectedStatus,
selectedSegmentIds: segmentsForSelection,
importStatus,
currentPage,
limit,
onCloseSegmentDetail: modalState.onCloseSegmentDetail,
clearSelection: () => setSegmentsForSelection([]),
})
useEffect(() => {
if (importStatus === ProcessStatus.COMPLETED)
resetList()
}, [importStatus])
// Selection state (with actual segments)
const selectionState = useSegmentSelection(segmentListDataHook.segments)
const onCancelBatchOperation = useCallback(() => {
setSelectedSegmentIds([])
// Sync selection state for segment list data hook
useMemo(() => {
setSegmentsForSelection(selectionState.selectedSegmentIds)
}, [selectionState.selectedSegmentIds])
// Child segment data
const childSegmentDataHook = useChildSegmentData({
searchValue: searchFilter.searchValue,
currentPage,
limit,
segments: segmentListDataHook.segments,
currChunkId: modalState.currChunkId,
isFullDocMode: segmentListDataHook.isFullDocMode,
onCloseChildSegmentDetail: modalState.onCloseChildSegmentDetail,
refreshChunkListDataWithDetailChanged,
updateSegmentInCache: segmentListDataHook.updateSegmentInCache,
})
// Compute total for pagination
const paginationTotal = useMemo(() => {
if (segmentListDataHook.isFullDocMode)
return childSegmentDataHook.childChunkListData?.total || 0
return segmentListDataHook.segmentListData?.total || 0
}, [segmentListDataHook.isFullDocMode, childSegmentDataHook.childChunkListData, segmentListDataHook.segmentListData])
// Handle page change
const handlePageChange = useCallback((page: number) => {
setCurrentPage(page + 1)
}, [])
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')
? 'segment.parentChunks' as const
: 'segment.chunks' as const
return `${total} ${t(translationKey, { ns: 'datasetDocuments', count })}`
}
else {
const total = typeof segmentListData?.total === 'number' ? formatNumber(segmentListData.total) : 0
const count = segmentListData?.total || 0
return `${total} ${t('segment.searchResults', { ns: 'datasetDocuments', count })}`
}
}, [segmentListData, docForm, parentMode, searchValue, selectedStatus, t])
const toggleFullScreen = useCallback(() => {
setFullScreen(!fullScreen)
}, [fullScreen])
const toggleCollapsed = useCallback(() => {
setIsCollapsed(prev => !prev)
}, [])
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('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
if (parentMode === 'paragraph')
resetList()
else
resetChildList()
},
onError: () => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
},
)
}, [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('segment.contentEmpty', { ns: 'datasetDocuments' }) })
params.content = content
eventEmitter?.emit('update-child-segment')
await updateChildSegment({ datasetId, documentId, segmentId, childChunkId, body: params }, {
onSuccess: (res) => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
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])
// Context value
const contextValue = useMemo<SegmentListContextValue>(() => ({
isCollapsed,
fullScreen,
toggleFullScreen,
currSegment,
currChildChunk,
}), [isCollapsed, fullScreen, toggleFullScreen, currSegment, currChildChunk])
isCollapsed: modalState.isCollapsed,
fullScreen: modalState.fullScreen,
toggleFullScreen: modalState.toggleFullScreen,
currSegment: modalState.currSegment,
currChildChunk: modalState.currChildChunk,
}), [
modalState.isCollapsed,
modalState.fullScreen,
modalState.toggleFullScreen,
modalState.currSegment,
modalState.currChildChunk,
])
return (
<SegmentListContext.Provider value={contextValue}>
{/* 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={toggleCollapsed} />
</div>
{!segmentListDataHook.isFullDocMode && (
<MenuBar
isAllSelected={selectionState.isAllSelected}
isSomeSelected={selectionState.isSomeSelected}
onSelectedAll={selectionState.onSelectedAll}
isLoading={segmentListDataHook.isLoadingSegmentList}
totalText={segmentListDataHook.totalText}
statusList={searchFilter.statusList}
selectDefaultValue={searchFilter.selectDefaultValue}
onChangeStatus={searchFilter.onChangeStatus}
inputValue={searchFilter.inputValue}
onInputChange={searchFilter.handleInputChange}
isCollapsed={modalState.isCollapsed}
toggleCollapsed={modalState.toggleCollapsed}
/>
)}
{/* 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}
/>
)
}
{segmentListDataHook.isFullDocMode
? (
<FullDocModeContent
segments={segmentListDataHook.segments}
childSegments={childSegmentDataHook.childSegments}
isLoadingSegmentList={segmentListDataHook.isLoadingSegmentList}
isLoadingChildSegmentList={childSegmentDataHook.isLoadingChildSegmentList}
currSegmentId={modalState.currSegment?.segInfo?.id}
onClickCard={modalState.onClickCard}
onDeleteChildChunk={childSegmentDataHook.onDeleteChildChunk}
handleInputChange={searchFilter.handleInputChange}
handleAddNewChildChunk={modalState.handleAddNewChildChunk}
onClickSlice={modalState.onClickSlice}
archived={archived}
childChunkTotal={childSegmentDataHook.childChunkListData?.total || 0}
inputValue={searchFilter.inputValue}
onClearFilter={searchFilter.onClearFilter}
/>
)
: (
<GeneralModeContent
segmentListRef={segmentListDataHook.segmentListRef}
embeddingAvailable={embeddingAvailable}
isLoadingSegmentList={segmentListDataHook.isLoadingSegmentList}
segments={segmentListDataHook.segments}
selectedSegmentIds={selectionState.selectedSegmentIds}
onSelected={selectionState.onSelected}
onChangeSwitch={segmentListDataHook.onChangeSwitch}
onDelete={segmentListDataHook.onDelete}
onClickCard={modalState.onClickCard}
archived={archived}
onDeleteChildChunk={childSegmentDataHook.onDeleteChildChunk}
handleAddNewChildChunk={modalState.handleAddNewChildChunk}
onClickSlice={modalState.onClickSlice}
onClearFilter={searchFilter.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}
onChange={handlePageChange}
total={paginationTotal}
limit={limit}
onLimitChange={limit => setLimit(limit)}
className={isFullDocMode ? 'px-3' : ''}
onLimitChange={setLimit}
className={segmentListDataHook.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: '' }}
{/* Drawer Group - only render when docForm is available */}
{docForm && (
<DrawerGroup
currSegment={modalState.currSegment}
onCloseSegmentDetail={modalState.onCloseSegmentDetail}
onUpdateSegment={segmentListDataHook.handleUpdateSegment}
isRegenerationModalOpen={modalState.isRegenerationModalOpen}
setIsRegenerationModalOpen={modalState.setIsRegenerationModalOpen}
showNewSegmentModal={showNewSegmentModal}
onCloseNewSegmentModal={modalState.onCloseNewSegmentModal}
onSaveNewSegment={segmentListDataHook.resetList}
viewNewlyAddedChunk={segmentListDataHook.viewNewlyAddedChunk}
currChildChunk={modalState.currChildChunk}
currChunkId={modalState.currChunkId}
onCloseChildSegmentDetail={modalState.onCloseChildSegmentDetail}
onUpdateChildChunk={childSegmentDataHook.handleUpdateChildChunk}
showNewChildSegmentModal={modalState.showNewChildSegmentModal}
onCloseNewChildChunkModal={modalState.onCloseNewChildChunkModal}
onSaveNewChildChunk={childSegmentDataHook.onSaveNewChildChunk}
viewNewlyAddedChildChunk={childSegmentDataHook.viewNewlyAddedChildChunk}
fullScreen={modalState.fullScreen}
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 && (
{selectionState.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}
selectedIds={selectionState.selectedSegmentIds}
onBatchEnable={() => segmentListDataHook.onChangeSwitch(true, '')}
onBatchDisable={() => segmentListDataHook.onChangeSwitch(false, '')}
onBatchDelete={() => segmentListDataHook.onDelete('')}
onCancel={selectionState.onCancelBatchOperation}
/>
)}
</SegmentListContext.Provider>
)
}
export { useSegmentListContext }
export type { SegmentListContextValue }
export default Completed

View File

@ -0,0 +1,34 @@
import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
import { noop } from 'es-toolkit/function'
import { createContext, useContextSelector } from 'use-context-selector'
export type CurrSegmentType = {
segInfo?: SegmentDetailModel
showModal: boolean
isEditMode?: boolean
}
export type CurrChildChunkType = {
childChunkInfo?: ChildChunkDetail
showModal: boolean
}
export type SegmentListContextValue = {
isCollapsed: boolean
fullScreen: boolean
toggleFullScreen: () => void
currSegment: CurrSegmentType
currChildChunk: CurrChildChunkType
}
export const SegmentListContext = createContext<SegmentListContextValue>({
isCollapsed: true,
fullScreen: false,
toggleFullScreen: noop,
currSegment: { showModal: false },
currChildChunk: { showModal: false },
})
export const useSegmentListContext = <T>(selector: (value: SegmentListContextValue) => T): T => {
return useContextSelector(SegmentListContext, selector)
}

View File

@ -0,0 +1,93 @@
import { render } from '@testing-library/react'
import FullDocListSkeleton from './full-doc-list-skeleton'
describe('FullDocListSkeleton', () => {
describe('Rendering', () => {
it('should render the skeleton container', () => {
const { container } = render(<FullDocListSkeleton />)
const skeletonContainer = container.firstChild
expect(skeletonContainer).toHaveClass('flex', 'w-full', 'grow', 'flex-col')
})
it('should render 15 Slice components', () => {
const { container } = render(<FullDocListSkeleton />)
// Each Slice has a specific structure with gap-y-1
const slices = container.querySelectorAll('.gap-y-1')
expect(slices.length).toBe(15)
})
it('should render mask overlay', () => {
const { container } = render(<FullDocListSkeleton />)
const maskOverlay = container.querySelector('.bg-dataset-chunk-list-mask-bg')
expect(maskOverlay).toBeInTheDocument()
})
it('should have overflow hidden', () => {
const { container } = render(<FullDocListSkeleton />)
const skeletonContainer = container.firstChild
expect(skeletonContainer).toHaveClass('overflow-y-hidden')
})
})
describe('Slice Component', () => {
it('should render slice with correct structure', () => {
const { container } = render(<FullDocListSkeleton />)
// Each slice has two rows
const sliceRows = container.querySelectorAll('.bg-state-base-hover')
expect(sliceRows.length).toBeGreaterThan(0)
})
it('should render label placeholder in each slice', () => {
const { container } = render(<FullDocListSkeleton />)
// Label placeholder has specific width
const labelPlaceholders = container.querySelectorAll('.w-\\[30px\\]')
expect(labelPlaceholders.length).toBe(15) // One per slice
})
it('should render content placeholder in each slice', () => {
const { container } = render(<FullDocListSkeleton />)
// Content placeholder has 2/3 width
const contentPlaceholders = container.querySelectorAll('.w-2\\/3')
expect(contentPlaceholders.length).toBe(15) // One per slice
})
})
describe('Memoization', () => {
it('should be memoized', () => {
const { rerender, container } = render(<FullDocListSkeleton />)
const initialContent = container.innerHTML
// Rerender should produce same output
rerender(<FullDocListSkeleton />)
expect(container.innerHTML).toBe(initialContent)
})
})
describe('Styling', () => {
it('should have correct z-index layering', () => {
const { container } = render(<FullDocListSkeleton />)
const skeletonContainer = container.firstChild
expect(skeletonContainer).toHaveClass('z-10')
const maskOverlay = container.querySelector('.z-20')
expect(maskOverlay).toBeInTheDocument()
})
it('should have gap between slices', () => {
const { container } = render(<FullDocListSkeleton />)
const skeletonContainer = container.firstChild
expect(skeletonContainer).toHaveClass('gap-y-3')
})
})
})

View File

@ -1,7 +0,0 @@
'use client'
import { Agentation as AgentationComponent } from 'agentation'
export const Agentation = () => {
return <AgentationComponent />
}

View File

@ -1,21 +0,0 @@
'use client'
import { lazy, Suspense } from 'react'
import { IS_DEV } from '@/config'
const Agentation = lazy(() =>
import('./agentation').then(module => ({
default: module.Agentation,
})),
)
export const AgentationLoader = () => {
if (!IS_DEV)
return null
return (
<Suspense fallback={null}>
<Agentation />
</Suspense>
)
}

View File

@ -1,34 +1,43 @@
import type { FC } from 'react'
import type { ComponentType, FC } from 'react'
import type { ModelProvider } from '../declarations'
import type { Plugin } from '@/app/components/plugins/types'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { OpenaiSmall } from '@/app/components/base/icons/src/public/llm'
import { AnthropicShortLight, Deepseek, Gemini, Grok, OpenaiSmall, Tongyi } from '@/app/components/base/icons/src/public/llm'
import Loading from '@/app/components/base/loading'
import Tooltip from '@/app/components/base/tooltip'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useTimestamp from '@/hooks/use-timestamp'
import { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { cn } from '@/utils/classnames'
import { formatNumber } from '@/utils/format'
import { PreferredProviderTypeEnum } from '../declarations'
import { useMarketplaceAllPlugins } from '../hooks'
import { modelNameMap, ModelProviderQuotaGetPaid } from '../utils'
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap } from '../utils'
const allProviders = [
{ key: ModelProviderQuotaGetPaid.OPENAI, Icon: OpenaiSmall },
// { key: ModelProviderQuotaGetPaid.ANTHROPIC, Icon: AnthropicShortLight },
// { key: ModelProviderQuotaGetPaid.GEMINI, Icon: Gemini },
// { key: ModelProviderQuotaGetPaid.X, Icon: Grok },
// { key: ModelProviderQuotaGetPaid.DEEPSEEK, Icon: Deepseek },
// { key: ModelProviderQuotaGetPaid.TONGYI, Icon: Tongyi },
] as const
// Icon map for each provider - single source of truth for provider icons
const providerIconMap: Record<ModelProviderQuotaGetPaid, ComponentType<{ className?: string }>> = {
[ModelProviderQuotaGetPaid.OPENAI]: OpenaiSmall,
[ModelProviderQuotaGetPaid.ANTHROPIC]: AnthropicShortLight,
[ModelProviderQuotaGetPaid.GEMINI]: Gemini,
[ModelProviderQuotaGetPaid.X]: Grok,
[ModelProviderQuotaGetPaid.DEEPSEEK]: Deepseek,
[ModelProviderQuotaGetPaid.TONGYI]: Tongyi,
}
// Derive allProviders from the shared constant
const allProviders = MODEL_PROVIDER_QUOTA_GET_PAID.map(key => ({
key,
Icon: providerIconMap[key],
}))
// Map provider key to plugin ID
// provider key format: langgenius/provider/model, plugin ID format: langgenius/provider
const providerKeyToPluginId: Record<string, string> = {
const providerKeyToPluginId: Record<ModelProviderQuotaGetPaid, string> = {
[ModelProviderQuotaGetPaid.OPENAI]: 'langgenius/openai',
[ModelProviderQuotaGetPaid.ANTHROPIC]: 'langgenius/anthropic',
[ModelProviderQuotaGetPaid.GEMINI]: 'langgenius/gemini',
@ -47,6 +56,7 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
}) => {
const { t } = useTranslation()
const { currentWorkspace } = useAppContext()
const { trial_models } = useGlobalPublicStore(s => s.systemFeatures)
const credits = Math.max((currentWorkspace.trial_credits - currentWorkspace.trial_credits_used) || 0, 0)
const providerMap = useMemo(() => new Map(
providers.map(p => [p.provider, p.preferred_provider_type]),
@ -62,7 +72,7 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
}] = useBoolean(false)
const selectedPluginIdRef = useRef<string | null>(null)
const handleIconClick = useCallback((key: string) => {
const handleIconClick = useCallback((key: ModelProviderQuotaGetPaid) => {
const providerType = providerMap.get(key)
if (!providerType && allPlugins) {
const pluginId = providerKeyToPluginId[key]
@ -97,7 +107,7 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
<div className={cn('my-2 min-w-[72px] shrink-0 rounded-xl border-[0.5px] pb-2.5 pl-4 pr-2.5 pt-3 shadow-xs', credits <= 0 ? 'border-state-destructive-border hover:bg-state-destructive-hover' : 'border-components-panel-border bg-third-party-model-bg-default')}>
<div className="system-xs-medium-uppercase mb-2 flex h-4 items-center text-text-tertiary">
{t('modelProvider.quota', { ns: 'common' })}
<Tooltip popupContent={t('modelProvider.card.tip', { ns: 'common' })} />
<Tooltip popupContent={t('modelProvider.card.tip', { ns: 'common', modelNames: trial_models.map(key => modelNameMap[key as keyof typeof modelNameMap]).filter(Boolean).join(', ') })} />
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-text-tertiary">
@ -119,7 +129,7 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
: null}
</div>
<div className="flex items-center gap-1">
{allProviders.map(({ key, Icon }) => {
{allProviders.filter(({ key }) => trial_models.includes(key)).map(({ key, Icon }) => {
const providerType = providerMap.get(key)
const usingQuota = providerType === PreferredProviderTypeEnum.system
const getTooltipKey = () => {

View File

@ -1,4 +1,5 @@
import type {
CredentialFormSchemaSelect,
CredentialFormSchemaTextInput,
FormValue,
ModelLoadBalancingConfig,
@ -9,6 +10,7 @@ import {
validateModelLoadBalancingCredentials,
validateModelProvider,
} from '@/service/common'
import { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { ValidatedStatus } from '../key-validator/declarations'
import {
ConfigurationMethodEnum,
@ -17,15 +19,8 @@ import {
ModelTypeEnum,
} from './declarations'
export enum ModelProviderQuotaGetPaid {
ANTHROPIC = 'langgenius/anthropic/anthropic',
OPENAI = 'langgenius/openai/openai',
// AZURE_OPENAI = 'langgenius/azure_openai/azure_openai',
GEMINI = 'langgenius/gemini/google',
X = 'langgenius/x/x',
DEEPSEEK = 'langgenius/deepseek/deepseek',
TONGYI = 'langgenius/tongyi/tongyi',
}
export { ModelProviderQuotaGetPaid } from '@/types/model-provider'
export const MODEL_PROVIDER_QUOTA_GET_PAID = [ModelProviderQuotaGetPaid.ANTHROPIC, ModelProviderQuotaGetPaid.OPENAI, ModelProviderQuotaGetPaid.GEMINI, ModelProviderQuotaGetPaid.X, ModelProviderQuotaGetPaid.DEEPSEEK, ModelProviderQuotaGetPaid.TONGYI]
export const modelNameMap = {
@ -37,7 +32,7 @@ export const modelNameMap = {
[ModelProviderQuotaGetPaid.TONGYI]: 'Tongyi',
}
export const isNullOrUndefined = (value: any) => {
export const isNullOrUndefined = (value: unknown): value is null | undefined => {
return value === undefined || value === null
}
@ -66,8 +61,9 @@ export const validateCredentials = async (predefined: boolean, provider: string,
else
return Promise.resolve({ status: ValidatedStatus.Error, message: res.error || 'error' })
}
catch (e: any) {
return Promise.resolve({ status: ValidatedStatus.Error, message: e.message })
catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Unknown error'
return Promise.resolve({ status: ValidatedStatus.Error, message })
}
}
@ -90,8 +86,9 @@ export const validateLoadBalancingCredentials = async (predefined: boolean, prov
else
return Promise.resolve({ status: ValidatedStatus.Error, message: res.error || 'error' })
}
catch (e: any) {
return Promise.resolve({ status: ValidatedStatus.Error, message: e.message })
catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Unknown error'
return Promise.resolve({ status: ValidatedStatus.Error, message })
}
}
@ -177,7 +174,7 @@ export const modelTypeFormat = (modelType: ModelTypeEnum) => {
return modelType.toLocaleUpperCase()
}
export const genModelTypeFormSchema = (modelTypes: ModelTypeEnum[]) => {
export const genModelTypeFormSchema = (modelTypes: ModelTypeEnum[]): Omit<CredentialFormSchemaSelect, 'name'> => {
return {
type: FormTypeEnum.select,
label: {
@ -198,10 +195,10 @@ export const genModelTypeFormSchema = (modelTypes: ModelTypeEnum[]) => {
show_on: [],
}
}),
} as any
}
}
export const genModelNameFormSchema = (model?: Pick<CredentialFormSchemaTextInput, 'label' | 'placeholder'>) => {
export const genModelNameFormSchema = (model?: Pick<CredentialFormSchemaTextInput, 'label' | 'placeholder'>): Omit<CredentialFormSchemaTextInput, 'name'> => {
return {
type: FormTypeEnum.textInput,
label: model?.label || {
@ -215,5 +212,5 @@ export const genModelNameFormSchema = (model?: Pick<CredentialFormSchemaTextInpu
zh_Hans: '请输入模型名称',
en_US: 'Please enter model name',
},
} as any
}
}

View File

@ -1,5 +1,6 @@
'use client'
import type { Collection, CustomCollectionBackend, Tool, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '../types'
import type { WorkflowToolModalPayload } from '@/app/components/tools/workflow-tool'
import {
RiCloseLine,
} from '@remixicon/react'
@ -412,7 +413,7 @@ const ProviderDetail = ({
)}
{isShowEditWorkflowToolModal && (
<WorkflowToolModal
payload={customCollection}
payload={customCollection as unknown as WorkflowToolModalPayload}
onHide={() => setIsShowEditWorkflowToolModal(false)}
onRemove={onClickWorkflowToolDelete}
onSave={updateWorkflowToolProvider}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
import { RiErrorWarningLine } from '@remixicon/react'
import { produce } from 'immer'
import * as React from 'react'
@ -21,9 +21,25 @@ import { VarType } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
import { buildWorkflowOutputParameters } from './utils'
export type WorkflowToolModalPayload = {
icon: Emoji
label: string
name: string
description: string
parameters: WorkflowToolProviderParameter[]
outputParameters: WorkflowToolProviderOutputParameter[]
labels: string[]
privacy_policy: string
tool?: {
output_schema?: WorkflowToolProviderOutputSchema
}
workflow_tool_id?: string
workflow_app_id?: string
}
type Props = {
isAdd?: boolean
payload: any
payload: WorkflowToolModalPayload
onHide: () => void
onRemove?: () => void
onCreate?: (payload: WorkflowToolProviderRequest & { workflow_app_id: string }) => void
@ -73,7 +89,7 @@ const WorkflowToolAsModal: FC<Props> = ({
},
]
const handleParameterChange = (key: string, value: any, index: number) => {
const handleParameterChange = (key: string, value: string, index: number) => {
const newData = produce(parameters, (draft: WorkflowToolProviderParameter[]) => {
if (key === 'description')
draft[index].description = value
@ -136,13 +152,13 @@ const WorkflowToolAsModal: FC<Props> = ({
if (!isAdd) {
onSave?.({
...requestParams,
workflow_tool_id: payload.workflow_tool_id,
workflow_tool_id: payload.workflow_tool_id!,
})
}
else {
onCreate?.({
...requestParams,
workflow_app_id: payload.workflow_app_id,
workflow_app_id: payload.workflow_app_id!,
})
}
}

View File

@ -10,7 +10,6 @@ import { DatasetAttr } from '@/types/feature'
import { cn } from '@/utils/classnames'
import { ToastProvider } from './components/base/toast'
import BrowserInitializer from './components/browser-initializer'
import { AgentationLoader } from './components/devtools/agentation/loader'
import { ReactScanLoader } from './components/devtools/react-scan/loader'
import { I18nServerProvider } from './components/provider/i18n-server'
import { PWAProvider } from './components/provider/serwist'
@ -122,7 +121,6 @@ const LocaleLayout = async ({
</ThemeProvider>
</JotaiProvider>
<RoutePrefixHandle />
<AgentationLoader />
</PWAProvider>
</body>
</html>

View File

@ -1787,14 +1787,6 @@
"count": 1
}
},
"app/components/datasets/documents/detail/completed/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 6
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/datasets/documents/detail/completed/new-child-segment.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -2164,11 +2156,6 @@
"count": 3
}
},
"app/components/header/account-setting/model-provider-page/utils.ts": {
"ts/no-explicit-any": {
"count": 5
}
},
"app/components/header/account-setting/plugin-page/utils.ts": {
"ts/no-explicit-any": {
"count": 4
@ -2731,11 +2718,6 @@
"count": 15
}
},
"app/components/tools/workflow-tool/index.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/workflow-app/components/workflow-children.tsx": {
"no-console": {
"count": 1

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "حصة",
"modelProvider.card.quotaExhausted": "نفدت الحصة",
"modelProvider.card.removeKey": "إزالة مفتاح API",
"modelProvider.card.tip": "تدعم أرصدة الرسائل نماذج من OpenAI. ستعطى الأولوية للحصة المدفوعة. سيتم استخدام الحصة المجانية بعد نفاد الحصة المدفوعة.",
"modelProvider.card.tip": "تدعم أرصدة الرسائل نماذج من {{modelNames}}. ستعطى الأولوية للحصة المدفوعة. سيتم استخدام الحصة المجانية بعد نفاد الحصة المدفوعة.",
"modelProvider.card.tokens": "رموز",
"modelProvider.collapse": "طي",
"modelProvider.config": "تكوين",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "KONTINGENT",
"modelProvider.card.quotaExhausted": "Kontingent erschöpft",
"modelProvider.card.removeKey": "API-Schlüssel entfernen",
"modelProvider.card.tip": "Nachrichtenguthaben unterstützen Modelle von OpenAI. Der bezahlten Kontingent wird Vorrang gegeben. Das kostenlose Kontingent wird nach dem Verbrauch des bezahlten Kontingents verwendet.",
"modelProvider.card.tip": "Nachrichtenguthaben unterstützen Modelle von {{modelNames}}. Der bezahlten Kontingent wird Vorrang gegeben. Das kostenlose Kontingent wird nach dem Verbrauch des bezahlten Kontingents verwendet.",
"modelProvider.card.tokens": "Token",
"modelProvider.collapse": "Einklappen",
"modelProvider.config": "Konfigurieren",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "QUOTA",
"modelProvider.card.quotaExhausted": "Quota exhausted",
"modelProvider.card.removeKey": "Remove API Key",
"modelProvider.card.tip": "Message Credits supports models from OpenAI. Priority will be given to the paid quota. The free quota will be used after the paid quota is exhausted.",
"modelProvider.card.tip": "Message Credits supports models from {{modelNames}}. Priority will be given to the paid quota. The free quota will be used after the paid quota is exhausted.",
"modelProvider.card.tokens": "Tokens",
"modelProvider.collapse": "Collapse",
"modelProvider.config": "Config",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "CUOTA",
"modelProvider.card.quotaExhausted": "Cuota agotada",
"modelProvider.card.removeKey": "Eliminar CLAVE API",
"modelProvider.card.tip": "Créditos de mensajes admite modelos de OpenAI. Se dará prioridad a la cuota pagada. La cuota gratuita se utilizará después de que se agote la cuota pagada.",
"modelProvider.card.tip": "Créditos de mensajes admite modelos de {{modelNames}}. Se dará prioridad a la cuota pagada. La cuota gratuita se utilizará después de que se agote la cuota pagada.",
"modelProvider.card.tokens": "Tokens",
"modelProvider.collapse": "Colapsar",
"modelProvider.config": "Configurar",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "سهمیه",
"modelProvider.card.quotaExhausted": "سهمیه تمام شده",
"modelProvider.card.removeKey": "حذف کلید API",
"modelProvider.card.tip": "اعتبار پیام از مدل‌های OpenAI پشتیبانی می‌کند. اولویت به سهمیه پرداخت شده داده می‌شود. سهمیه رایگان پس از اتمام سهمیه پرداخت شده استفاده خواهد شد.",
"modelProvider.card.tip": "اعتبار پیام از مدل‌های {{modelNames}} پشتیبانی می‌کند. اولویت به سهمیه پرداخت شده داده می‌شود. سهمیه رایگان پس از اتمام سهمیه پرداخت شده استفاده خواهد شد.",
"modelProvider.card.tokens": "توکن‌ها",
"modelProvider.collapse": "جمع کردن",
"modelProvider.config": "پیکربندی",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "QUOTA",
"modelProvider.card.quotaExhausted": "Quota épuisé",
"modelProvider.card.removeKey": "Supprimer la clé API",
"modelProvider.card.tip": "Les crédits de messages prennent en charge les modèles d'OpenAI. La priorité sera donnée au quota payant. Le quota gratuit sera utilisé après épuisement du quota payant.",
"modelProvider.card.tip": "Les crédits de messages prennent en charge les modèles de {{modelNames}}. La priorité sera donnée au quota payant. Le quota gratuit sera utilisé après épuisement du quota payant.",
"modelProvider.card.tokens": "Jetons",
"modelProvider.collapse": "Effondrer",
"modelProvider.config": "Configuration",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "कोटा",
"modelProvider.card.quotaExhausted": "कोटा समाप्त",
"modelProvider.card.removeKey": "API कुंजी निकालें",
"modelProvider.card.tip": "संदेश क्रेडिट OpenAI के मॉडल का समर्थन करते हैं। भुगतान किए गए कोटा को प्राथमिकता दी जाएगी। भुगतान किए गए कोटा के समाप्त होने के बाद मुफ्त कोटा का उपयोग किया जाएगा।",
"modelProvider.card.tip": "संदेश क्रेडिट {{modelNames}} के मॉडल का समर्थन करते हैं। भुगतान किए गए कोटा को प्राथमिकता दी जाएगी। भुगतान किए गए कोटा के समाप्त होने के बाद मुफ्त कोटा का उपयोग किया जाएगा।",
"modelProvider.card.tokens": "टोकन",
"modelProvider.collapse": "संक्षिप्त करें",
"modelProvider.config": "कॉन्फ़िग",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "KUOTA",
"modelProvider.card.quotaExhausted": "Kuota habis",
"modelProvider.card.removeKey": "Menghapus Kunci API",
"modelProvider.card.tip": "Kredit pesan mendukung model dari OpenAI. Prioritas akan diberikan pada kuota yang dibayarkan. Kuota gratis akan digunakan setelah kuota yang dibayarkan habis.",
"modelProvider.card.tip": "Kredit pesan mendukung model dari {{modelNames}}. Prioritas akan diberikan pada kuota yang dibayarkan. Kuota gratis akan digunakan setelah kuota yang dibayarkan habis.",
"modelProvider.card.tokens": "Token",
"modelProvider.collapse": "Roboh",
"modelProvider.config": "Konfigurasi",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "QUOTA",
"modelProvider.card.quotaExhausted": "Quota esaurita",
"modelProvider.card.removeKey": "Rimuovi API Key",
"modelProvider.card.tip": "I crediti di messaggi supportano modelli di OpenAI. Verrà data priorità alla quota pagata. La quota gratuita sarà utilizzata dopo l'esaurimento della quota pagata.",
"modelProvider.card.tip": "I crediti di messaggi supportano modelli di {{modelNames}}. Verrà data priorità alla quota pagata. La quota gratuita sarà utilizzata dopo l'esaurimento della quota pagata.",
"modelProvider.card.tokens": "Token",
"modelProvider.collapse": "Comprimi",
"modelProvider.config": "Configura",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "クォータ",
"modelProvider.card.quotaExhausted": "クォータが使い果たされました",
"modelProvider.card.removeKey": "API キーを削除",
"modelProvider.card.tip": "メッセージ枠はOpenAIのモデルを使用することをサポートしています。無料枠は有料枠が使い果たされた後に消費されます。",
"modelProvider.card.tip": "メッセージ枠は{{modelNames}}のモデルを使用することをサポートしています。無料枠は有料枠が使い果たされた後に消費されます。",
"modelProvider.card.tokens": "トークン",
"modelProvider.collapse": "折り畳み",
"modelProvider.config": "設定",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "할당량",
"modelProvider.card.quotaExhausted": "할당량이 다 사용되었습니다",
"modelProvider.card.removeKey": "API 키 제거",
"modelProvider.card.tip": "메시지 크레딧은 OpenAI의 모델을 지원합니다. 유료 할당량에 우선순위가 부여됩니다. 무료 할당량은 유료 할당량이 소진된 후 사용됩니다.",
"modelProvider.card.tip": "메시지 크레딧은 {{modelNames}}의 모델을 지원합니다. 유료 할당량에 우선순위가 부여됩니다. 무료 할당량은 유료 할당량이 소진된 후 사용됩니다.",
"modelProvider.card.tokens": "토큰",
"modelProvider.collapse": "축소",
"modelProvider.config": "설정",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "LIMIT",
"modelProvider.card.quotaExhausted": "Wyczerpany limit",
"modelProvider.card.removeKey": "Usuń klucz API",
"modelProvider.card.tip": "Kredyty wiadomości obsługują modele od OpenAI. Priorytet zostanie nadany płatnemu limitowi. Darmowy limit zostanie użyty po wyczerpaniu płatnego limitu.",
"modelProvider.card.tip": "Kredyty wiadomości obsługują modele od {{modelNames}}. Priorytet zostanie nadany płatnemu limitowi. Darmowy limit zostanie użyty po wyczerpaniu płatnego limitu.",
"modelProvider.card.tokens": "Tokeny",
"modelProvider.collapse": "Zwiń",
"modelProvider.config": "Konfiguracja",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "QUOTA",
"modelProvider.card.quotaExhausted": "Quota esgotada",
"modelProvider.card.removeKey": "Remover Chave da API",
"modelProvider.card.tip": "Créditos de mensagens suportam modelos do OpenAI. A prioridade será dada à quota paga. A quota gratuita será usada após a quota paga ser esgotada.",
"modelProvider.card.tip": "Créditos de mensagens suportam modelos de {{modelNames}}. A prioridade será dada à quota paga. A quota gratuita será usada após a quota paga ser esgotada.",
"modelProvider.card.tokens": "Tokens",
"modelProvider.collapse": "Recolher",
"modelProvider.config": "Configuração",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "COTĂ",
"modelProvider.card.quotaExhausted": "Cotă epuizată",
"modelProvider.card.removeKey": "Elimină cheia API",
"modelProvider.card.tip": "Creditele de mesaje acceptă modele de la OpenAI. Prioritate va fi acordată cotei plătite. Cota gratuită va fi utilizată după epuizarea cotei plătite.",
"modelProvider.card.tip": "Creditele de mesaje acceptă modele de la {{modelNames}}. Prioritate va fi acordată cotei plătite. Cota gratuită va fi utilizată după epuizarea cotei plătite.",
"modelProvider.card.tokens": "Jetoane",
"modelProvider.collapse": "Restrânge",
"modelProvider.config": "Configurare",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "КВОТА",
"modelProvider.card.quotaExhausted": "Квота исчерпана",
"modelProvider.card.removeKey": "Удалить API-ключ",
"modelProvider.card.tip": "Кредиты сообщений поддерживают модели от OpenAI. Приоритет будет отдаваться платной квоте. Бесплатная квота будет использоваться после исчерпания платной квоты.",
"modelProvider.card.tip": "Кредиты сообщений поддерживают модели от {{modelNames}}. Приоритет будет отдаваться платной квоте. Бесплатная квота будет использоваться после исчерпания платной квоты.",
"modelProvider.card.tokens": "Токены",
"modelProvider.collapse": "Свернуть",
"modelProvider.config": "Настройка",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "KVOTE",
"modelProvider.card.quotaExhausted": "Kvote porabljene",
"modelProvider.card.removeKey": "Odstrani API ključ",
"modelProvider.card.tip": "Krediti za sporočila podpirajo modele od OpenAI. Prednostno se bo uporabila plačana kvota. Brezplačna kvota se bo uporabila, ko bo plačana kvota porabljena.",
"modelProvider.card.tip": "Krediti za sporočila podpirajo modele od {{modelNames}}. Prednostno se bo uporabila plačana kvota. Brezplačna kvota se bo uporabila, ko bo plačana kvota porabljena.",
"modelProvider.card.tokens": "Žetoni",
"modelProvider.collapse": "Strni",
"modelProvider.config": "Konfiguracija",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "โควตา",
"modelProvider.card.quotaExhausted": "โควต้าหมด",
"modelProvider.card.removeKey": "ลบคีย์ API",
"modelProvider.card.tip": "เครดิตข้อความรองรับโมเดลจาก OpenAI จะให้ลำดับความสำคัญกับโควต้าที่ชำระแล้ว โควต้าฟรีจะถูกใช้หลังจากโควต้าที่ชำระแล้วหมด",
"modelProvider.card.tip": "เครดิตข้อความรองรับโมเดลจาก {{modelNames}} จะให้ลำดับความสำคัญกับโควต้าที่ชำระแล้ว โควต้าฟรีจะถูกใช้หลังจากโควต้าที่ชำระแล้วหมด",
"modelProvider.card.tokens": "โท เค็น",
"modelProvider.collapse": "ทรุด",
"modelProvider.config": "กําหนดค่า",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "KOTA",
"modelProvider.card.quotaExhausted": "Kota Tükendi",
"modelProvider.card.removeKey": "API Anahtarını Kaldır",
"modelProvider.card.tip": "Mesaj kredileri OpenAI'den modelleri destekler. Öncelik ücretli kotaya verilecektir. Ücretsiz kota, ücretli kota tükendiğinde kullanılacaktır.",
"modelProvider.card.tip": "Mesaj kredileri {{modelNames}}'den modelleri destekler. Öncelik ücretli kotaya verilecektir. Ücretsiz kota, ücretli kota tükendiğinde kullanılacaktır.",
"modelProvider.card.tokens": "Tokenler",
"modelProvider.collapse": "Daralt",
"modelProvider.config": "Yapılandır",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "КВОТА",
"modelProvider.card.quotaExhausted": "Квоту вичерпано",
"modelProvider.card.removeKey": "Видалити ключ API",
"modelProvider.card.tip": "Кредити повідомлень підтримують моделі від OpenAI. Пріоритет буде надано оплаченій квоті. Безкоштовна квота буде використовуватися після вичерпання платної квоти.",
"modelProvider.card.tip": "Кредити повідомлень підтримують моделі від {{modelNames}}. Пріоритет буде надано оплаченій квоті. Безкоштовна квота буде використовуватися після вичерпання платної квоти.",
"modelProvider.card.tokens": "Токени",
"modelProvider.collapse": "Згорнути",
"modelProvider.config": "Налаштування",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "QUOTA",
"modelProvider.card.quotaExhausted": "Quota đã hết",
"modelProvider.card.removeKey": "Remove API Key",
"modelProvider.card.tip": "Tín dụng tin nhắn hỗ trợ các mô hình từ OpenAI. Ưu tiên sẽ được trao cho hạn ngạch đã thanh toán. Hạn ngạch miễn phí sẽ được sử dụng sau khi hết hạn ngạch trả phí.",
"modelProvider.card.tip": "Tín dụng tin nhắn hỗ trợ các mô hình từ {{modelNames}}. Ưu tiên sẽ được trao cho hạn ngạch đã thanh toán. Hạn ngạch miễn phí sẽ được sử dụng sau khi hết hạn ngạch trả phí.",
"modelProvider.card.tokens": "Tokens",
"modelProvider.collapse": "Thu gọn",
"modelProvider.config": "Cấu hình",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "额度",
"modelProvider.card.quotaExhausted": "配额已用完",
"modelProvider.card.removeKey": "删除 API 密钥",
"modelProvider.card.tip": "消息额度支持使用 OpenAI 的模型;免费额度会在付费额度用尽后才会消耗。",
"modelProvider.card.tip": "消息额度支持使用 {{modelNames}} 的模型;免费额度会在付费额度用尽后才会消耗。",
"modelProvider.card.tokens": "Tokens",
"modelProvider.collapse": "收起",
"modelProvider.config": "配置",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "額度",
"modelProvider.card.quotaExhausted": "配額已用完",
"modelProvider.card.removeKey": "刪除 API 金鑰",
"modelProvider.card.tip": "消息額度支持使用 OpenAI 的模型;免費額度會在付費額度用盡後才會消耗。",
"modelProvider.card.tip": "消息額度支持使用 {{modelNames}} 的模型;免費額度會在付費額度用盡後才會消耗。",
"modelProvider.card.tokens": "Tokens",
"modelProvider.collapse": "收起",
"modelProvider.config": "配置",

View File

@ -204,7 +204,6 @@
"@typescript/native-preview": "7.0.0-dev.20251209.1",
"@vitejs/plugin-react": "5.1.2",
"@vitest/coverage-v8": "4.0.17",
"agentation": "1.3.2",
"autoprefixer": "10.4.21",
"code-inspector-plugin": "1.3.6",
"cross-env": "10.1.0",

14
web/pnpm-lock.yaml generated
View File

@ -499,9 +499,6 @@ importers:
'@vitest/coverage-v8':
specifier: 4.0.17
version: 4.0.17(vitest@4.0.17(@types/node@18.15.0)(happy-dom@20.0.11)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.0))(sass@1.93.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
agentation:
specifier: 1.3.2
version: 1.3.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
autoprefixer:
specifier: 10.4.21
version: 10.4.21(postcss@8.5.6)
@ -4100,12 +4097,6 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
agentation@1.3.2:
resolution: {integrity: sha512-9yZ/3hTcNePr1asnMyipxAZU8nFdBibNfw7wTdLUd3ULTTQCp9QZX7Y5PTMzkYWX4fhqEI2LOjMCb3vkmZga9w==}
peerDependencies:
react: '>=18.0.0'
react-dom: '>=18.0.0'
ahooks@3.9.5:
resolution: {integrity: sha512-TrjXie49Q8HuHKTa84Fm9A+famMDAG1+7a9S9Gq6RQ0h90Jgqmiq3CkObuRjWT/C4d6nRZCw35Y2k2fmybb5eA==}
engines: {node: '>=18'}
@ -12718,11 +12709,6 @@ snapshots:
agent-base@7.1.4: {}
agentation@1.3.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
ahooks@3.9.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@babel/runtime': 7.28.4

View File

@ -1,3 +1,5 @@
import type { ModelProviderQuotaGetPaid } from './model-provider'
export enum SSOProtocol {
SAML = 'saml',
OIDC = 'oidc',
@ -26,6 +28,7 @@ type License = {
}
export type SystemFeatures = {
trial_models: ModelProviderQuotaGetPaid[]
plugin_installation_permission: {
plugin_installation_scope: InstallationScope
restrict_to_marketplace_only: boolean
@ -64,6 +67,7 @@ export type SystemFeatures = {
}
export const defaultSystemFeatures: SystemFeatures = {
trial_models: [],
plugin_installation_permission: {
plugin_installation_scope: InstallationScope.ALL,
restrict_to_marketplace_only: false,

View File

@ -0,0 +1,13 @@
/**
* Model provider quota types - shared type definitions for API responses
* These represent the provider identifiers that support paid/trial quotas
*/
export enum ModelProviderQuotaGetPaid {
ANTHROPIC = 'langgenius/anthropic/anthropic',
OPENAI = 'langgenius/openai/openai',
// AZURE_OPENAI = 'langgenius/azure_openai/azure_openai',
GEMINI = 'langgenius/gemini/google',
X = 'langgenius/x/x',
DEEPSEEK = 'langgenius/deepseek/deepseek',
TONGYI = 'langgenius/tongyi/tongyi',
}