| raise Forbidden() | raise Forbidden() | ||||
| app_id = str(app_id) | 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): | class AnnotationExportApi(Resource): | ||||
| ) | ) | ||||
| api.add_resource(AnnotationListApi, "/apps/<uuid:app_id>/annotations") | api.add_resource(AnnotationListApi, "/apps/<uuid:app_id>/annotations") | ||||
| api.add_resource(AnnotationExportApi, "/apps/<uuid:app_id>/annotations/export") | 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(AnnotationUpdateDeleteApi, "/apps/<uuid:app_id>/annotations/<uuid:annotation_id>") | ||||
| api.add_resource(AnnotationBatchImportApi, "/apps/<uuid:app_id>/annotations/batch-import") | 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>") | api.add_resource(AnnotationBatchImportStatusApi, "/apps/<uuid:app_id>/annotations/batch-import-status/<uuid:job_id>") |
| annotation.id, app_id, current_user.current_tenant_id, app_annotation_setting.collection_binding_id | 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 | @classmethod | ||||
| def batch_import_app_annotations(cls, app_id, file: FileStorage) -> dict: | def batch_import_app_annotations(cls, app_id, file: FileStorage) -> dict: | ||||
| # get app info | # get app info |
| 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) |
| import AnnotationFullModal from '@/app/components/billing/annotation-full/modal' | import AnnotationFullModal from '@/app/components/billing/annotation-full/modal' | ||||
| import type { App } from '@/types/app' | import type { App } from '@/types/app' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import { delAnnotations } from '@/service/annotation' | |||||
| type Props = { | type Props = { | ||||
| appDetail: App | appDetail: App | ||||
| const [controlUpdateList, setControlUpdateList] = useState(Date.now()) | const [controlUpdateList, setControlUpdateList] = useState(Date.now()) | ||||
| const [currItem, setCurrItem] = useState<AnnotationItem | null>(null) | const [currItem, setCurrItem] = useState<AnnotationItem | null>(null) | ||||
| const [isShowViewModal, setIsShowViewModal] = useState(false) | const [isShowViewModal, setIsShowViewModal] = useState(false) | ||||
| const [selectedIds, setSelectedIds] = useState<string[]>([]) | |||||
| const debouncedQueryParams = useDebounce(queryParams, { wait: 500 }) | const debouncedQueryParams = useDebounce(queryParams, { wait: 500 }) | ||||
| const [isBatchDeleting, setIsBatchDeleting] = useState(false) | |||||
| const fetchAnnotationConfig = async () => { | const fetchAnnotationConfig = async () => { | ||||
| const res = await doFetchAnnotationConfig(appDetail.id) | const res = await doFetchAnnotationConfig(appDetail.id) | ||||
| useEffect(() => { | useEffect(() => { | ||||
| if (isChatApp) fetchAnnotationConfig() | if (isChatApp) fetchAnnotationConfig() | ||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||||
| }, []) | }, []) | ||||
| const ensureJobCompleted = async (jobId: string, status: AnnotationEnableStatus) => { | const ensureJobCompleted = async (jobId: string, status: AnnotationEnableStatus) => { | ||||
| useEffect(() => { | useEffect(() => { | ||||
| fetchList(currPage + 1) | fetchList(currPage + 1) | ||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||||
| }, [currPage, limit, debouncedQueryParams]) | }, [currPage, limit, debouncedQueryParams]) | ||||
| const handleAdd = async (payload: AnnotationItemBasic) => { | const handleAdd = async (payload: AnnotationItemBasic) => { | ||||
| setControlUpdateList(Date.now()) | 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) => { | const handleView = (item: AnnotationItem) => { | ||||
| setCurrItem(item) | setCurrItem(item) | ||||
| setIsShowViewModal(true) | setIsShowViewModal(true) | ||||
| list={list} | list={list} | ||||
| onRemove={handleRemove} | onRemove={handleRemove} | ||||
| onView={handleView} | onView={handleView} | ||||
| selectedIds={selectedIds} | |||||
| onSelectedIdsChange={setSelectedIds} | |||||
| onBatchDelete={handleBatchDelete} | |||||
| onCancel={() => setSelectedIds([])} | |||||
| isBatchDeleting={isBatchDeleting} | |||||
| /> | /> | ||||
| : <div className='flex h-full grow items-center justify-center'><EmptyElement /></div> | : <div className='flex h-full grow items-center justify-center'><EmptyElement /></div> | ||||
| } | } |
| 'use client' | 'use client' | ||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| import React from 'react' | |||||
| import React, { useCallback, useMemo } from 'react' | |||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import { RiDeleteBinLine, RiEditLine } from '@remixicon/react' | import { RiDeleteBinLine, RiEditLine } from '@remixicon/react' | ||||
| import type { AnnotationItem } from './type' | import type { AnnotationItem } from './type' | ||||
| import ActionButton from '@/app/components/base/action-button' | import ActionButton from '@/app/components/base/action-button' | ||||
| import useTimestamp from '@/hooks/use-timestamp' | import useTimestamp from '@/hooks/use-timestamp' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import Checkbox from '@/app/components/base/checkbox' | |||||
| import BatchAction from './batch-action' | |||||
| type Props = { | type Props = { | ||||
| list: AnnotationItem[] | list: AnnotationItem[] | ||||
| onRemove: (id: string) => void | |||||
| onView: (item: AnnotationItem) => 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> = ({ | const List: FC<Props> = ({ | ||||
| list, | list, | ||||
| onView, | onView, | ||||
| onRemove, | onRemove, | ||||
| selectedIds, | |||||
| onSelectedIdsChange, | |||||
| onBatchDelete, | |||||
| onCancel, | |||||
| isBatchDeleting, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| const { formatTime } = useTimestamp() | const { formatTime } = useTimestamp() | ||||
| const [currId, setCurrId] = React.useState<string | null>(null) | const [currId, setCurrId] = React.useState<string | null>(null) | ||||
| const [showConfirmDelete, setShowConfirmDelete] = React.useState(false) | 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 ( | 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')}> | <table className={cn('mt-2 w-full min-w-[440px] border-collapse border-0')}> | ||||
| <thead className='system-xs-medium-uppercase text-text-tertiary'> | <thead className='system-xs-medium-uppercase text-text-tertiary'> | ||||
| <tr> | <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.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.createdAt')}</td> | ||||
| <td className='whitespace-nowrap bg-background-section-burn py-1.5 pl-3'>{t('appAnnotation.table.header.hits')}</td> | <td className='whitespace-nowrap bg-background-section-burn py-1.5 pl-3'>{t('appAnnotation.table.header.hits')}</td> | ||||
| } | } | ||||
| } | } | ||||
| > | > | ||||
| <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 | <td | ||||
| className='max-w-[250px] overflow-hidden text-ellipsis whitespace-nowrap p-3 pr-2' | className='max-w-[250px] overflow-hidden text-ellipsis whitespace-nowrap p-3 pr-2' | ||||
| title={item.question} | title={item.question} | ||||
| setShowConfirmDelete(false) | 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> | </div> | ||||
| ) | ) | ||||
| } | } |
| error: 'Import Error', | error: 'Import Error', | ||||
| ok: 'OK', | ok: 'OK', | ||||
| }, | }, | ||||
| list: { | |||||
| delete: { | |||||
| title: 'Are you sure Delete?', | |||||
| }, | |||||
| }, | |||||
| batchAction: { | |||||
| selected: 'Selected', | |||||
| delete: 'Delete', | |||||
| cancel: 'Cancel', | |||||
| }, | |||||
| errorMessage: { | errorMessage: { | ||||
| answerRequired: 'Answer is required', | answerRequired: 'Answer is required', | ||||
| queryRequired: 'Question is required', | queryRequired: 'Question is required', |
| error: '导入出错', | error: '导入出错', | ||||
| ok: '确定', | ok: '确定', | ||||
| }, | }, | ||||
| list: { | |||||
| delete: { | |||||
| title: '确定删除吗?', | |||||
| }, | |||||
| }, | |||||
| batchAction: { | |||||
| selected: '已选择', | |||||
| delete: '删除', | |||||
| cancel: '取消', | |||||
| }, | |||||
| errorMessage: { | errorMessage: { | ||||
| answerRequired: '回复不能为空', | answerRequired: '回复不能为空', | ||||
| queryRequired: '提问不能为空', | queryRequired: '提问不能为空', |
| return del(`apps/${appId}/annotations/${annotationId}`) | 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>) => { | export const fetchHitHistoryList = (appId: string, annotationId: string, params: Record<string, any>) => { | ||||
| return get(`apps/${appId}/annotations/${annotationId}/hit-histories`, { params }) | return get(`apps/${appId}/annotations/${annotationId}/hit-histories`, { params }) | ||||
| } | } |