| @@ -131,8 +131,24 @@ class AnnotationListApi(Resource): | |||
| raise Forbidden() | |||
| app_id = str(app_id) | |||
| AppAnnotationService.clear_all_annotations(app_id) | |||
| return {"result": "success"}, 204 | |||
| # Use request.args.getlist to get annotation_ids array directly | |||
| annotation_ids = request.args.getlist("annotation_id") | |||
| # If annotation_ids are provided, handle batch deletion | |||
| if annotation_ids: | |||
| if not annotation_ids: | |||
| return { | |||
| "code": "bad_request", | |||
| "message": "annotation_ids are required if the parameter is provided.", | |||
| }, 400 | |||
| result = AppAnnotationService.delete_app_annotations_in_batch(app_id, annotation_ids) | |||
| return result, 204 | |||
| # If no annotation_ids are provided, handle clearing all annotations | |||
| else: | |||
| AppAnnotationService.clear_all_annotations(app_id) | |||
| return {"result": "success"}, 204 | |||
| class AnnotationExportApi(Resource): | |||
| @@ -278,6 +294,7 @@ api.add_resource( | |||
| ) | |||
| api.add_resource(AnnotationListApi, "/apps/<uuid:app_id>/annotations") | |||
| api.add_resource(AnnotationExportApi, "/apps/<uuid:app_id>/annotations/export") | |||
| api.add_resource(AnnotationCreateApi, "/apps/<uuid:app_id>/annotations") | |||
| api.add_resource(AnnotationUpdateDeleteApi, "/apps/<uuid:app_id>/annotations/<uuid:annotation_id>") | |||
| api.add_resource(AnnotationBatchImportApi, "/apps/<uuid:app_id>/annotations/batch-import") | |||
| api.add_resource(AnnotationBatchImportStatusApi, "/apps/<uuid:app_id>/annotations/batch-import-status/<uuid:job_id>") | |||
| @@ -266,6 +266,54 @@ class AppAnnotationService: | |||
| annotation.id, app_id, current_user.current_tenant_id, app_annotation_setting.collection_binding_id | |||
| ) | |||
| @classmethod | |||
| def delete_app_annotations_in_batch(cls, app_id: str, annotation_ids: list[str]): | |||
| # get app info | |||
| app = ( | |||
| db.session.query(App) | |||
| .where(App.id == app_id, App.tenant_id == current_user.current_tenant_id, App.status == "normal") | |||
| .first() | |||
| ) | |||
| if not app: | |||
| raise NotFound("App not found") | |||
| # Fetch annotations and their settings in a single query | |||
| annotations_to_delete = ( | |||
| db.session.query(MessageAnnotation, AppAnnotationSetting) | |||
| .outerjoin(AppAnnotationSetting, MessageAnnotation.app_id == AppAnnotationSetting.app_id) | |||
| .filter(MessageAnnotation.id.in_(annotation_ids)) | |||
| .all() | |||
| ) | |||
| if not annotations_to_delete: | |||
| return {"deleted_count": 0} | |||
| # Step 1: Extract IDs for bulk operations | |||
| annotation_ids_to_delete = [annotation.id for annotation, _ in annotations_to_delete] | |||
| # Step 2: Bulk delete hit histories in a single query | |||
| db.session.query(AppAnnotationHitHistory).filter( | |||
| AppAnnotationHitHistory.annotation_id.in_(annotation_ids_to_delete) | |||
| ).delete(synchronize_session=False) | |||
| # Step 3: Trigger async tasks for search index deletion | |||
| for annotation, annotation_setting in annotations_to_delete: | |||
| if annotation_setting: | |||
| delete_annotation_index_task.delay( | |||
| annotation.id, app_id, current_user.current_tenant_id, annotation_setting.collection_binding_id | |||
| ) | |||
| # Step 4: Bulk delete annotations in a single query | |||
| deleted_count = ( | |||
| db.session.query(MessageAnnotation) | |||
| .filter(MessageAnnotation.id.in_(annotation_ids_to_delete)) | |||
| .delete(synchronize_session=False) | |||
| ) | |||
| db.session.commit() | |||
| return {"deleted_count": deleted_count} | |||
| @classmethod | |||
| def batch_import_app_annotations(cls, app_id, file: FileStorage) -> dict: | |||
| # get app info | |||
| @@ -0,0 +1,79 @@ | |||
| import React, { type FC } from 'react' | |||
| import { RiDeleteBinLine } from '@remixicon/react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { useBoolean } from 'ahooks' | |||
| import Divider from '@/app/components/base/divider' | |||
| import classNames from '@/utils/classnames' | |||
| import Confirm from '@/app/components/base/confirm' | |||
| const i18nPrefix = 'appAnnotation.batchAction' | |||
| type IBatchActionProps = { | |||
| className?: string | |||
| selectedIds: string[] | |||
| onBatchDelete: () => Promise<void> | |||
| onCancel: () => void | |||
| } | |||
| const BatchAction: FC<IBatchActionProps> = ({ | |||
| className, | |||
| selectedIds, | |||
| onBatchDelete, | |||
| onCancel, | |||
| }) => { | |||
| const { t } = useTranslation() | |||
| const [isShowDeleteConfirm, { | |||
| setTrue: showDeleteConfirm, | |||
| setFalse: hideDeleteConfirm, | |||
| }] = useBoolean(false) | |||
| const [isDeleting, { | |||
| setTrue: setIsDeleting, | |||
| setFalse: setIsNotDeleting, | |||
| }] = useBoolean(false) | |||
| const handleBatchDelete = async () => { | |||
| setIsDeleting() | |||
| await onBatchDelete() | |||
| hideDeleteConfirm() | |||
| setIsNotDeleting() | |||
| } | |||
| return ( | |||
| <div className={classNames('pointer-events-none flex w-full justify-center', className)}> | |||
| <div className='pointer-events-auto flex items-center gap-x-1 rounded-[10px] border border-components-actionbar-border-accent bg-components-actionbar-bg-accent p-1 shadow-xl shadow-shadow-shadow-5 backdrop-blur-[5px]'> | |||
| <div className='inline-flex items-center gap-x-2 py-1 pl-2 pr-3'> | |||
| <span className='flex h-5 w-5 items-center justify-center rounded-md bg-text-accent px-1 py-0.5 text-xs font-medium text-text-primary-on-surface'> | |||
| {selectedIds.length} | |||
| </span> | |||
| <span className='text-[13px] font-semibold leading-[16px] text-text-accent'>{t(`${i18nPrefix}.selected`)}</span> | |||
| </div> | |||
| <Divider type='vertical' className='mx-0.5 h-3.5 bg-divider-regular' /> | |||
| <div className='flex cursor-pointer items-center gap-x-0.5 px-3 py-2' onClick={showDeleteConfirm}> | |||
| <RiDeleteBinLine className='h-4 w-4 text-components-button-destructive-ghost-text' /> | |||
| <button type='button' className='px-0.5 text-[13px] font-medium leading-[16px] text-components-button-destructive-ghost-text' > | |||
| {t('common.operation.delete')} | |||
| </button> | |||
| </div> | |||
| <Divider type='vertical' className='mx-0.5 h-3.5 bg-divider-regular' /> | |||
| <button type='button' className='px-3.5 py-2 text-[13px] font-medium leading-[16px] text-components-button-ghost-text' onClick={onCancel}> | |||
| {t('common.operation.cancel')} | |||
| </button> | |||
| </div> | |||
| { | |||
| isShowDeleteConfirm && ( | |||
| <Confirm | |||
| isShow | |||
| title={t('appAnnotation.list.delete.title')} | |||
| confirmText={t('common.operation.delete')} | |||
| onConfirm={handleBatchDelete} | |||
| onCancel={hideDeleteConfirm} | |||
| isLoading={isDeleting} | |||
| isDisabled={isDeleting} | |||
| /> | |||
| ) | |||
| } | |||
| </div> | |||
| ) | |||
| } | |||
| export default React.memo(BatchAction) | |||
| @@ -26,6 +26,7 @@ import { useProviderContext } from '@/context/provider-context' | |||
| import AnnotationFullModal from '@/app/components/billing/annotation-full/modal' | |||
| import type { App } from '@/types/app' | |||
| import cn from '@/utils/classnames' | |||
| import { delAnnotations } from '@/service/annotation' | |||
| type Props = { | |||
| appDetail: App | |||
| @@ -50,7 +51,9 @@ const Annotation: FC<Props> = (props) => { | |||
| const [controlUpdateList, setControlUpdateList] = useState(Date.now()) | |||
| const [currItem, setCurrItem] = useState<AnnotationItem | null>(null) | |||
| const [isShowViewModal, setIsShowViewModal] = useState(false) | |||
| const [selectedIds, setSelectedIds] = useState<string[]>([]) | |||
| const debouncedQueryParams = useDebounce(queryParams, { wait: 500 }) | |||
| const [isBatchDeleting, setIsBatchDeleting] = useState(false) | |||
| const fetchAnnotationConfig = async () => { | |||
| const res = await doFetchAnnotationConfig(appDetail.id) | |||
| @@ -60,7 +63,6 @@ const Annotation: FC<Props> = (props) => { | |||
| useEffect(() => { | |||
| if (isChatApp) fetchAnnotationConfig() | |||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||
| }, []) | |||
| const ensureJobCompleted = async (jobId: string, status: AnnotationEnableStatus) => { | |||
| @@ -89,7 +91,6 @@ const Annotation: FC<Props> = (props) => { | |||
| useEffect(() => { | |||
| fetchList(currPage + 1) | |||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||
| }, [currPage, limit, debouncedQueryParams]) | |||
| const handleAdd = async (payload: AnnotationItemBasic) => { | |||
| @@ -106,6 +107,25 @@ const Annotation: FC<Props> = (props) => { | |||
| setControlUpdateList(Date.now()) | |||
| } | |||
| const handleBatchDelete = async () => { | |||
| if (isBatchDeleting) | |||
| return | |||
| setIsBatchDeleting(true) | |||
| try { | |||
| await delAnnotations(appDetail.id, selectedIds) | |||
| Toast.notify({ message: t('common.api.actionSuccess'), type: 'success' }) | |||
| fetchList() | |||
| setControlUpdateList(Date.now()) | |||
| setSelectedIds([]) | |||
| } | |||
| catch (e: any) { | |||
| Toast.notify({ type: 'error', message: e.message || t('common.api.actionFailed') }) | |||
| } | |||
| finally { | |||
| setIsBatchDeleting(false) | |||
| } | |||
| } | |||
| const handleView = (item: AnnotationItem) => { | |||
| setCurrItem(item) | |||
| setIsShowViewModal(true) | |||
| @@ -189,6 +209,11 @@ const Annotation: FC<Props> = (props) => { | |||
| list={list} | |||
| onRemove={handleRemove} | |||
| onView={handleView} | |||
| selectedIds={selectedIds} | |||
| onSelectedIdsChange={setSelectedIds} | |||
| onBatchDelete={handleBatchDelete} | |||
| onCancel={() => setSelectedIds([])} | |||
| isBatchDeleting={isBatchDeleting} | |||
| /> | |||
| : <div className='flex h-full grow items-center justify-center'><EmptyElement /></div> | |||
| } | |||
| @@ -1,6 +1,6 @@ | |||
| 'use client' | |||
| import type { FC } from 'react' | |||
| import React from 'react' | |||
| import React, { useCallback, useMemo } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { RiDeleteBinLine, RiEditLine } from '@remixicon/react' | |||
| import type { AnnotationItem } from './type' | |||
| @@ -8,28 +8,67 @@ import RemoveAnnotationConfirmModal from './remove-annotation-confirm-modal' | |||
| import ActionButton from '@/app/components/base/action-button' | |||
| import useTimestamp from '@/hooks/use-timestamp' | |||
| import cn from '@/utils/classnames' | |||
| import Checkbox from '@/app/components/base/checkbox' | |||
| import BatchAction from './batch-action' | |||
| type Props = { | |||
| list: AnnotationItem[] | |||
| onRemove: (id: string) => void | |||
| onView: (item: AnnotationItem) => void | |||
| onRemove: (id: string) => void | |||
| selectedIds: string[] | |||
| onSelectedIdsChange: (selectedIds: string[]) => void | |||
| onBatchDelete: () => Promise<void> | |||
| onCancel: () => void | |||
| isBatchDeleting?: boolean | |||
| } | |||
| const List: FC<Props> = ({ | |||
| list, | |||
| onView, | |||
| onRemove, | |||
| selectedIds, | |||
| onSelectedIdsChange, | |||
| onBatchDelete, | |||
| onCancel, | |||
| isBatchDeleting, | |||
| }) => { | |||
| const { t } = useTranslation() | |||
| const { formatTime } = useTimestamp() | |||
| const [currId, setCurrId] = React.useState<string | null>(null) | |||
| const [showConfirmDelete, setShowConfirmDelete] = React.useState(false) | |||
| const isAllSelected = useMemo(() => { | |||
| return list.length > 0 && list.every(item => selectedIds.includes(item.id)) | |||
| }, [list, selectedIds]) | |||
| const isSomeSelected = useMemo(() => { | |||
| return list.some(item => selectedIds.includes(item.id)) | |||
| }, [list, selectedIds]) | |||
| const handleSelectAll = useCallback(() => { | |||
| const currentPageIds = list.map(item => item.id) | |||
| const otherPageIds = selectedIds.filter(id => !currentPageIds.includes(id)) | |||
| if (isAllSelected) | |||
| onSelectedIdsChange(otherPageIds) | |||
| else | |||
| onSelectedIdsChange([...otherPageIds, ...currentPageIds]) | |||
| }, [isAllSelected, list, selectedIds, onSelectedIdsChange]) | |||
| return ( | |||
| <div className='overflow-x-auto'> | |||
| <div className='relative grow overflow-x-auto'> | |||
| <table className={cn('mt-2 w-full min-w-[440px] border-collapse border-0')}> | |||
| <thead className='system-xs-medium-uppercase text-text-tertiary'> | |||
| <tr> | |||
| <td className='w-5 whitespace-nowrap rounded-l-lg bg-background-section-burn pl-2 pr-1'>{t('appAnnotation.table.header.question')}</td> | |||
| <td className='w-12 whitespace-nowrap rounded-l-lg bg-background-section-burn px-2'> | |||
| <Checkbox | |||
| className='mr-2' | |||
| checked={isAllSelected} | |||
| indeterminate={!isAllSelected && isSomeSelected} | |||
| onCheck={handleSelectAll} | |||
| /> | |||
| </td> | |||
| <td className='w-5 whitespace-nowrap bg-background-section-burn pl-2 pr-1'>{t('appAnnotation.table.header.question')}</td> | |||
| <td className='whitespace-nowrap bg-background-section-burn py-1.5 pl-3'>{t('appAnnotation.table.header.answer')}</td> | |||
| <td className='whitespace-nowrap bg-background-section-burn py-1.5 pl-3'>{t('appAnnotation.table.header.createdAt')}</td> | |||
| <td className='whitespace-nowrap bg-background-section-burn py-1.5 pl-3'>{t('appAnnotation.table.header.hits')}</td> | |||
| @@ -47,6 +86,18 @@ const List: FC<Props> = ({ | |||
| } | |||
| } | |||
| > | |||
| <td className='w-12 px-2' onClick={e => e.stopPropagation()}> | |||
| <Checkbox | |||
| className='mr-2' | |||
| checked={selectedIds.includes(item.id)} | |||
| onCheck={() => { | |||
| if (selectedIds.includes(item.id)) | |||
| onSelectedIdsChange(selectedIds.filter(id => id !== item.id)) | |||
| else | |||
| onSelectedIdsChange([...selectedIds, item.id]) | |||
| }} | |||
| /> | |||
| </td> | |||
| <td | |||
| className='max-w-[250px] overflow-hidden text-ellipsis whitespace-nowrap p-3 pr-2' | |||
| title={item.question} | |||
| @@ -85,6 +136,15 @@ const List: FC<Props> = ({ | |||
| setShowConfirmDelete(false) | |||
| }} | |||
| /> | |||
| {selectedIds.length > 0 && ( | |||
| <BatchAction | |||
| className='absolute bottom-6 left-1/2 z-20 -translate-x-1/2' | |||
| selectedIds={selectedIds} | |||
| onBatchDelete={onBatchDelete} | |||
| onCancel={onCancel} | |||
| isBatchDeleting={isBatchDeleting} | |||
| /> | |||
| )} | |||
| </div> | |||
| ) | |||
| } | |||
| @@ -57,6 +57,16 @@ const translation = { | |||
| error: 'Import Error', | |||
| ok: 'OK', | |||
| }, | |||
| list: { | |||
| delete: { | |||
| title: 'Are you sure Delete?', | |||
| }, | |||
| }, | |||
| batchAction: { | |||
| selected: 'Selected', | |||
| delete: 'Delete', | |||
| cancel: 'Cancel', | |||
| }, | |||
| errorMessage: { | |||
| answerRequired: 'Answer is required', | |||
| queryRequired: 'Question is required', | |||
| @@ -57,6 +57,16 @@ const translation = { | |||
| error: '导入出错', | |||
| ok: '确定', | |||
| }, | |||
| list: { | |||
| delete: { | |||
| title: '确定删除吗?', | |||
| }, | |||
| }, | |||
| batchAction: { | |||
| selected: '已选择', | |||
| delete: '删除', | |||
| cancel: '取消', | |||
| }, | |||
| errorMessage: { | |||
| answerRequired: '回复不能为空', | |||
| queryRequired: '提问不能为空', | |||
| @@ -60,6 +60,11 @@ export const delAnnotation = (appId: string, annotationId: string) => { | |||
| return del(`apps/${appId}/annotations/${annotationId}`) | |||
| } | |||
| export const delAnnotations = (appId: string, annotationIds: string[]) => { | |||
| const params = annotationIds.map(id => `annotation_id=${id}`).join('&') | |||
| return del(`/apps/${appId}/annotations?${params}`) | |||
| } | |||
| export const fetchHitHistoryList = (appId: string, annotationId: string, params: Record<string, any>) => { | |||
| return get(`apps/${appId}/annotations/${annotationId}/hit-histories`, { params }) | |||
| } | |||