From 4499cda186144a67dda349548acec54682553d6f Mon Sep 17 00:00:00 2001 From: GuanMu Date: Wed, 30 Jul 2025 13:40:48 +0800 Subject: [PATCH] Feat annotations panel (#22968) --- api/controllers/console/app/annotation.py | 21 ++++- api/services/annotation_service.py | 48 +++++++++++ .../app/annotation/batch-action.tsx | 79 +++++++++++++++++++ web/app/components/app/annotation/index.tsx | 29 ++++++- web/app/components/app/annotation/list.tsx | 68 +++++++++++++++- web/i18n/en-US/app-annotation.ts | 10 +++ web/i18n/zh-Hans/app-annotation.ts | 10 +++ web/service/annotation.ts | 5 ++ 8 files changed, 262 insertions(+), 8 deletions(-) create mode 100644 web/app/components/app/annotation/batch-action.tsx diff --git a/api/controllers/console/app/annotation.py b/api/controllers/console/app/annotation.py index c2ba880405..2af7136f14 100644 --- a/api/controllers/console/app/annotation.py +++ b/api/controllers/console/app/annotation.py @@ -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//annotations") api.add_resource(AnnotationExportApi, "/apps//annotations/export") +api.add_resource(AnnotationCreateApi, "/apps//annotations") api.add_resource(AnnotationUpdateDeleteApi, "/apps//annotations/") api.add_resource(AnnotationBatchImportApi, "/apps//annotations/batch-import") api.add_resource(AnnotationBatchImportStatusApi, "/apps//annotations/batch-import-status/") diff --git a/api/services/annotation_service.py b/api/services/annotation_service.py index cfa917daf6..b7a047914e 100644 --- a/api/services/annotation_service.py +++ b/api/services/annotation_service.py @@ -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 diff --git a/web/app/components/app/annotation/batch-action.tsx b/web/app/components/app/annotation/batch-action.tsx new file mode 100644 index 0000000000..6e80d0c4c8 --- /dev/null +++ b/web/app/components/app/annotation/batch-action.tsx @@ -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 + onCancel: () => void +} + +const BatchAction: FC = ({ + 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 ( +
+
+
+ + {selectedIds.length} + + {t(`${i18nPrefix}.selected`)} +
+ +
+ + +
+ + + +
+ { + isShowDeleteConfirm && ( + + ) + } +
+ ) +} + +export default React.memo(BatchAction) diff --git a/web/app/components/app/annotation/index.tsx b/web/app/components/app/annotation/index.tsx index 04bce1947b..0b0691eb7d 100644 --- a/web/app/components/app/annotation/index.tsx +++ b/web/app/components/app/annotation/index.tsx @@ -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) => { const [controlUpdateList, setControlUpdateList] = useState(Date.now()) const [currItem, setCurrItem] = useState(null) const [isShowViewModal, setIsShowViewModal] = useState(false) + const [selectedIds, setSelectedIds] = useState([]) 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) => { 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) => { 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) => { 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) => { list={list} onRemove={handleRemove} onView={handleView} + selectedIds={selectedIds} + onSelectedIdsChange={setSelectedIds} + onBatchDelete={handleBatchDelete} + onCancel={() => setSelectedIds([])} + isBatchDeleting={isBatchDeleting} /> :
} diff --git a/web/app/components/app/annotation/list.tsx b/web/app/components/app/annotation/list.tsx index 319f09983f..6705ac5768 100644 --- a/web/app/components/app/annotation/list.tsx +++ b/web/app/components/app/annotation/list.tsx @@ -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 + onCancel: () => void + isBatchDeleting?: boolean } const List: FC = ({ list, onView, onRemove, + selectedIds, + onSelectedIdsChange, + onBatchDelete, + onCancel, + isBatchDeleting, }) => { const { t } = useTranslation() const { formatTime } = useTimestamp() const [currId, setCurrId] = React.useState(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 ( -
+
- + + @@ -47,6 +86,18 @@ const List: FC = ({ } } > +
{t('appAnnotation.table.header.question')} + + {t('appAnnotation.table.header.question')} {t('appAnnotation.table.header.answer')} {t('appAnnotation.table.header.createdAt')} {t('appAnnotation.table.header.hits')} e.stopPropagation()}> + { + if (selectedIds.includes(item.id)) + onSelectedIdsChange(selectedIds.filter(id => id !== item.id)) + else + onSelectedIdsChange([...selectedIds, item.id]) + }} + /> + = ({ setShowConfirmDelete(false) }} /> + {selectedIds.length > 0 && ( + + )} ) } diff --git a/web/i18n/en-US/app-annotation.ts b/web/i18n/en-US/app-annotation.ts index c0a8008d9a..f7cd24dc37 100644 --- a/web/i18n/en-US/app-annotation.ts +++ b/web/i18n/en-US/app-annotation.ts @@ -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', diff --git a/web/i18n/zh-Hans/app-annotation.ts b/web/i18n/zh-Hans/app-annotation.ts index cb2d3be0cd..d92dff8e62 100644 --- a/web/i18n/zh-Hans/app-annotation.ts +++ b/web/i18n/zh-Hans/app-annotation.ts @@ -57,6 +57,16 @@ const translation = { error: '导入出错', ok: '确定', }, + list: { + delete: { + title: '确定删除吗?', + }, + }, + batchAction: { + selected: '已选择', + delete: '删除', + cancel: '取消', + }, errorMessage: { answerRequired: '回复不能为空', queryRequired: '提问不能为空', diff --git a/web/service/annotation.ts b/web/service/annotation.ts index 9f025f8eb9..58efb7b976 100644 --- a/web/service/annotation.ts +++ b/web/service/annotation.ts @@ -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) => { return get(`apps/${appId}/annotations/${annotationId}/hit-histories`, { params }) }