diff --git a/web/app/components/datasets/documents/detail/completed/components/drawer-group.tsx b/web/app/components/datasets/documents/detail/completed/components/drawer-group.tsx new file mode 100644 index 0000000000..e6465559de --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/components/drawer-group.tsx @@ -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 + 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 + // New child segment drawer + showNewChildSegmentModal: boolean + onCloseNewChildChunkModal: () => void + onSaveNewChildChunk: (newChildChunk?: ChildChunkDetail) => void + viewNewlyAddedChildChunk: () => void + // Common props + fullScreen: boolean + docForm: ChunkingMode +} + +const DrawerGroup: FC = ({ + // 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 */} + + + + + {/* Create New Segment */} + + + + + {/* Edit or view child segment detail */} + + + + + {/* Create New Child Segment */} + + + + + ) +} + +export default DrawerGroup diff --git a/web/app/components/datasets/documents/detail/completed/components/index.ts b/web/app/components/datasets/documents/detail/completed/components/index.ts new file mode 100644 index 0000000000..67bd6ae643 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/components/index.ts @@ -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' diff --git a/web/app/components/datasets/documents/detail/completed/components/menu-bar.tsx b/web/app/components/datasets/documents/detail/completed/components/menu-bar.tsx new file mode 100644 index 0000000000..95272549f6 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/components/menu-bar.tsx @@ -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 = ({ + isAllSelected, + isSomeSelected, + onSelectedAll, + isLoading, + totalText, + statusList, + selectDefaultValue, + onChangeStatus, + inputValue, + onInputChange, + isCollapsed, + toggleCollapsed, +}) => { + return ( +
+ +
{totalText}
+ } + notClearable + /> + onInputChange(e.target.value)} + onClear={() => onInputChange('')} + /> + + +
+ ) +} + +export default MenuBar diff --git a/web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx b/web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx new file mode 100644 index 0000000000..78159a5cf6 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx @@ -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 + handleInputChange: (value: string) => void + handleAddNewChildChunk: (parentChunkId: string) => void + onClickSlice: (detail: ChildChunkDetail) => void + archived?: boolean + childChunkTotal: number + inputValue: string + onClearFilter: () => void +} + +export const FullDocModeContent: FC = ({ + segments, + childSegments, + isLoadingSegmentList, + isLoadingChildSegmentList, + currSegmentId, + onClickCard, + onDeleteChildChunk, + handleInputChange, + handleAddNewChildChunk, + onClickSlice, + archived, + childChunkTotal, + inputValue, + onClearFilter, +}) => { + const firstSegment = segments[0] + + return ( +
+ onClickCard(firstSegment)} + loading={isLoadingSegmentList} + focused={{ + segmentIndex: currSegmentId === firstSegment?.id, + segmentContent: currSegmentId === firstSegment?.id, + }} + /> + +
+ ) +} + +type GeneralModeContentProps = { + segmentListRef: React.RefObject + embeddingAvailable: boolean + isLoadingSegmentList: boolean + segments: SegmentDetailModel[] + selectedSegmentIds: string[] + onSelected: (segId: string) => void + onChangeSwitch: (enable: boolean, segId?: string) => Promise + onDelete: (segId?: string) => Promise + onClickCard: (detail: SegmentDetailModel, isEditMode?: boolean) => void + archived?: boolean + onDeleteChildChunk: (segmentId: string, childChunkId: string) => Promise + handleAddNewChildChunk: (parentChunkId: string) => void + onClickSlice: (detail: ChildChunkDetail) => void + onClearFilter: () => void +} + +export const GeneralModeContent: FC = ({ + segmentListRef, + embeddingAvailable, + isLoadingSegmentList, + segments, + selectedSegmentIds, + onSelected, + onChangeSwitch, + onDelete, + onClickCard, + archived, + onDeleteChildChunk, + handleAddNewChildChunk, + onClickSlice, + onClearFilter, +}) => { + return ( + + ) +} diff --git a/web/app/components/datasets/documents/detail/completed/hooks/index.ts b/web/app/components/datasets/documents/detail/completed/hooks/index.ts new file mode 100644 index 0000000000..858b448563 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/hooks/index.ts @@ -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' diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-child-segment-data.ts b/web/app/components/datasets/documents/detail/completed/hooks/use-child-segment-data.ts new file mode 100644 index 0000000000..4f4c6a532d --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/hooks/use-child-segment-data.ts @@ -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['data'] + childSegmentListRef: React.RefObject + needScrollToBottom: React.RefObject + // Operations + onDeleteChildChunk: (segmentId: string, childChunkId: string) => Promise + handleUpdateChildChunk: (segmentId: string, childChunkId: string, content: string) => Promise + 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(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(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, + } +} diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-modal-state.ts b/web/app/components/datasets/documents/detail/completed/hooks/use-modal-state.ts new file mode 100644 index 0000000000..ecb45ac1ee --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/hooks/use-modal-state.ts @@ -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({ showModal: false }) + + // Child segment detail modal state + const [currChildChunk, setCurrChildChunk] = useState({ 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, + } +} diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-search-filter.ts b/web/app/components/datasets/documents/detail/completed/hooks/use-search-filter.ts new file mode 100644 index 0000000000..e7fafa692d --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/hooks/use-search-filter.ts @@ -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('') + const [searchValue, setSearchValue] = useState('') + const [selectedStatus, setSelectedStatus] = useState('all') + + const statusList = useRef([ + { 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, + } +} diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts b/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts new file mode 100644 index 0000000000..f176cb89f5 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts @@ -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['data'] + totalText: string + isFullDocMode: boolean + segmentListRef: React.RefObject + needScrollToBottom: React.RefObject + // Operations + onChangeSwitch: (enable: boolean, segId?: string) => Promise + onDelete: (segId?: string) => Promise + handleUpdateSegment: ( + segmentId: string, + question: string, + answer: string, + keywords: string[], + attachments: FileEntity[], + needRegenerate?: boolean, + ) => Promise + 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(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 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(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(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, + } +} diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-selection.ts b/web/app/components/datasets/documents/detail/completed/hooks/use-segment-selection.ts new file mode 100644 index 0000000000..b1adeedaf4 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/hooks/use-segment-selection.ts @@ -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([]) + + 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, + } +} diff --git a/web/app/components/datasets/documents/detail/completed/index.tsx b/web/app/components/datasets/documents/detail/completed/index.tsx index 78cf0e1178..0251919e26 100644 --- a/web/app/components/datasets/documents/detail/completed/index.tsx +++ b/web/app/components/datasets/documents/detail/completed/index.tsx @@ -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({ - 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 = ({ 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({ showModal: false }) - const [currChildChunk, setCurrChildChunk] = useState({ showModal: false }) - const [currChunkId, setCurrChunkId] = useState('') - const [inputValue, setInputValue] = useState('') // the input value - const [searchValue, setSearchValue] = useState('') // the search value - const [selectedStatus, setSelectedStatus] = useState('all') // the selected status, enabled/disabled/undefined - - const [segments, setSegments] = useState([]) // all segments data - const [childSegments, setChildSegments] = useState([]) // all child segments data - const [selectedSegmentIds, setSelectedSegmentIds] = useState([]) - 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(null) - const childSegmentListRef = useRef(null) - const needScrollToBottom = useRef(false) - const statusList = useRef([ - { 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([]) - 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 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(() => ({ - 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 ( {/* Menu Bar */} - {!isFullDocMode && ( -
- -
{totalText}
- } - notClearable - /> - handleInputChange(e.target.value)} - onClear={() => handleInputChange('')} - /> - - -
+ {!segmentListDataHook.isFullDocMode && ( + )} + {/* Segment list */} - { - isFullDocMode - ? ( -
- onClickCard(segments[0])} - loading={isLoadingSegmentList} - focused={{ - segmentIndex: currSegment?.segInfo?.id === segments[0]?.id, - segmentContent: currSegment?.segInfo?.id === segments[0]?.id, - }} - /> - -
- ) - : ( - - ) - } + {segmentListDataHook.isFullDocMode + ? ( + + ) + : ( + + )} + {/* Pagination */} 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 */} - - - - {/* Create New Segment */} - - - - {/* Edit or view child segment detail */} - - - - {/* Create New Child Segment */} - - - + )} + {/* Batch Action Buttons */} - {selectedSegmentIds.length > 0 && ( + {selectionState.selectedSegmentIds.length > 0 && ( segmentListDataHook.onChangeSwitch(true, '')} + onBatchDisable={() => segmentListDataHook.onChangeSwitch(false, '')} + onBatchDelete={() => segmentListDataHook.onDelete('')} + onCancel={selectionState.onCancelBatchOperation} /> )}
) } +export { useSegmentListContext } +export type { SegmentListContextValue } + export default Completed diff --git a/web/app/components/datasets/documents/detail/completed/segment-list-context.ts b/web/app/components/datasets/documents/detail/completed/segment-list-context.ts new file mode 100644 index 0000000000..3ce9f8b987 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/segment-list-context.ts @@ -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({ + isCollapsed: true, + fullScreen: false, + toggleFullScreen: noop, + currSegment: { showModal: false }, + currChildChunk: { showModal: false }, +}) + +export const useSegmentListContext = (selector: (value: SegmentListContextValue) => T): T => { + return useContextSelector(SegmentListContext, selector) +} diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 14dff720f8..595f2d0779 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -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