Feat annotations panel (#22968)

This commit is contained in:
GuanMu
2025-07-30 13:40:48 +08:00
committed by GitHub
parent c05c5953a8
commit 4499cda186
8 changed files with 262 additions and 8 deletions

View File

@@ -131,6 +131,22 @@ class AnnotationListApi(Resource):
raise Forbidden()
app_id = str(app_id)
# 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
@@ -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>")

View File

@@ -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

View File

@@ -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)

View File

@@ -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>
}

View File

@@ -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>
)
}

View File

@@ -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',

View File

@@ -57,6 +57,16 @@ const translation = {
error: '导入出错',
ok: '确定',
},
list: {
delete: {
title: '确定删除吗?',
},
},
batchAction: {
selected: '已选择',
delete: '删除',
cancel: '取消',
},
errorMessage: {
answerRequired: '回复不能为空',
queryRequired: '提问不能为空',

View File

@@ -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 })
}