Feat annotations panel (#22968)
This commit is contained in:
@@ -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>")
|
||||
|
@@ -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
|
||||
|
79
web/app/components/app/annotation/batch-action.tsx
Normal file
79
web/app/components/app/annotation/batch-action.tsx
Normal 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)
|
@@ -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 })
|
||||
}
|
||||
|
Reference in New Issue
Block a user