mirror of
https://github.com/langgenius/dify.git
synced 2026-02-14 07:15:35 +08:00
Compare commits
3 Commits
feat/add-a
...
build/debu
| Author | SHA1 | Date | |
|---|---|---|---|
| fa205cba37 | |||
| dd988d42c2 | |||
| a43d2ec4f0 |
@ -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:
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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'
|
||||
@ -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
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,7 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Agentation as AgentationComponent } from 'agentation'
|
||||
|
||||
export const Agentation = () => {
|
||||
return <AgentationComponent />
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
1975
web/app/components/tools/workflow-tool/configure-button.spec.tsx
Normal file
1975
web/app/components/tools/workflow-tool/configure-button.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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!,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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": "تكوين",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "پیکربندی",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "कॉन्फ़िग",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "設定",
|
||||
|
||||
@ -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": "설정",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Настройка",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "กําหนดค่า",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Налаштування",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "配置",
|
||||
|
||||
@ -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": "配置",
|
||||
|
||||
@ -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
14
web/pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
13
web/types/model-provider.ts
Normal file
13
web/types/model-provider.ts
Normal 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',
|
||||
}
|
||||
Reference in New Issue
Block a user