feat: annotation management frontend (#1764)

This commit is contained in:
Joel
2023-12-18 15:41:24 +08:00
committed by GitHub
parent 96d2de2258
commit 65fd4b39ce
122 changed files with 4718 additions and 214 deletions

View File

@@ -0,0 +1,47 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from 'rc-textarea'
import { Robot, User } from '@/app/components/base/icons/src/public/avatar'
export enum EditItemType {
Query = 'query',
Answer = 'answer',
}
type Props = {
type: EditItemType
content: string
onChange: (content: string) => void
}
const EditItem: FC<Props> = ({
type,
content,
onChange,
}) => {
const { t } = useTranslation()
const avatar = type === EditItemType.Query ? <User className='w-6 h-6' /> : <Robot className='w-6 h-6' />
const name = type === EditItemType.Query ? t('appAnnotation.addModal.queryName') : t('appAnnotation.addModal.answerName')
const placeholder = type === EditItemType.Query ? t('appAnnotation.addModal.queryPlaceholder') : t('appAnnotation.addModal.answerPlaceholder')
return (
<div className='flex' onClick={e => e.stopPropagation()}>
<div className='shrink-0 mr-3'>
{avatar}
</div>
<div className='grow'>
<div className='mb-1 leading-[18px] text-xs font-semibold text-gray-900'>{name}</div>
<Textarea
className='mt-1 block w-full leading-5 max-h-none text-sm text-gray-700 outline-none appearance-none resize-none'
value={content}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
autoSize={{ minRows: 3 }}
placeholder={placeholder}
autoFocus
/>
</div>
</div>
)
}
export default React.memo(EditItem)

View File

@@ -0,0 +1,120 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { AnnotationItemBasic } from '../type'
import EditItem, { EditItemType } from './edit-item'
import Drawer from '@/app/components/base/drawer-plus'
import Button from '@/app/components/base/button'
import Toast from '@/app/components/base/toast'
import { useProviderContext } from '@/context/provider-context'
import AnnotationFull from '@/app/components/billing/annotation-full'
type Props = {
isShow: boolean
onHide: () => void
onAdd: (payload: AnnotationItemBasic) => void
}
const AddAnnotationModal: FC<Props> = ({
isShow,
onHide,
onAdd,
}) => {
const { t } = useTranslation()
const { plan, enableBilling } = useProviderContext()
const isAnnotationFull = (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse)
const [question, setQuestion] = useState('')
const [answer, setAnswer] = useState('')
const [isCreateNext, setIsCreateNext] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const isValid = (payload: AnnotationItemBasic) => {
if (!payload.question)
return t('appAnnotation.errorMessage.queryRequired')
if (!payload.answer)
return t('appAnnotation.errorMessage.answerRequired')
return true
}
const handleSave = async () => {
const payload = {
question,
answer,
}
if (isValid(payload) !== true) {
Toast.notify({
type: 'error',
message: isValid(payload) as string,
})
return
}
setIsSaving(true)
try {
await onAdd(payload)
}
catch (e) {
}
setIsSaving(false)
if (isCreateNext) {
setQuestion('')
setAnswer('')
}
else {
onHide()
}
}
return (
<div>
<Drawer
isShow={isShow}
onHide={onHide}
maxWidthClassName='!max-w-[480px]'
title={t('appAnnotation.addModal.title') as string}
body={(
<div className='p-6 pb-4 space-y-6'>
<EditItem
type={EditItemType.Query}
content={question}
onChange={setQuestion}
/>
<EditItem
type={EditItemType.Answer}
content={answer}
onChange={setAnswer}
/>
</div>
)}
foot={
(
<div>
{isAnnotationFull && (
<div className='mt-6 mb-4 px-6'>
<AnnotationFull />
</div>
)}
<div className='px-6 flex h-16 items-center justify-between border-t border-black/5 bg-gray-50 rounded-bl-xl rounded-br-xl leading-[18px] text-[13px] font-medium text-gray-500'>
<div
className='flex items-center space-x-2'
>
<input type="checkbox" checked={isCreateNext} onChange={() => setIsCreateNext(!isCreateNext)} className="w-4 h-4 rounded border-gray-300 text-blue-700 focus:ring-blue-700" />
<div>{t('appAnnotation.addModal.createNext')}</div>
</div>
<div className='mt-2 flex space-x-2'>
<Button className='!h-7 !text-xs !font-medium' onClick={onHide}>{t('common.operation.cancel')}</Button>
<Button className='!h-7 !text-xs !font-medium' type='primary' onClick={handleSave} loading={isSaving} disabled={isAnnotationFull}>{t('common.operation.add')}</Button>
</div>
</div>
</div>
)
}
>
</Drawer>
</div>
)
}
export default React.memo(AddAnnotationModal)

View File

@@ -0,0 +1,74 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import {
useCSVDownloader,
} from 'react-papaparse'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general'
import I18n from '@/context/i18n'
const CSV_TEMPLATE_QA_EN = [
['question', 'answer'],
['question1', 'answer1'],
['question2', 'answer2'],
]
const CSV_TEMPLATE_QA_CN = [
['问题', '答案'],
['问题 1', '答案 1'],
['问题 2', '答案 2'],
]
const CSVDownload: FC = () => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const { CSVDownloader, Type } = useCSVDownloader()
const getTemplate = () => {
if (locale === 'en')
return CSV_TEMPLATE_QA_EN
return CSV_TEMPLATE_QA_CN
}
return (
<div className='mt-6'>
<div className='text-sm text-gray-900 font-medium'>{t('share.generation.csvStructureTitle')}</div>
<div className='mt-2 max-h-[500px] overflow-auto'>
<table className='table-fixed w-full border-separate border-spacing-0 border border-gray-200 rounded-lg text-xs'>
<thead className='text-gray-500'>
<tr>
<td className='h-9 pl-3 pr-2 border-b border-gray-200'>{t('appAnnotation.batchModal.question')}</td>
<td className='h-9 pl-3 pr-2 border-b border-gray-200'>{t('appAnnotation.batchModal.answer')}</td>
</tr>
</thead>
<tbody className='text-gray-700'>
<tr>
<td className='h-9 pl-3 pr-2 border-b border-gray-100 text-[13px]'>{t('appAnnotation.batchModal.question')} 1</td>
<td className='h-9 pl-3 pr-2 border-b border-gray-100 text-[13px]'>{t('appAnnotation.batchModal.answer')} 1</td>
</tr>
<tr>
<td className='h-9 pl-3 pr-2 text-[13px]'>{t('appAnnotation.batchModal.question')} 2</td>
<td className='h-9 pl-3 pr-2 text-[13px]'>{t('appAnnotation.batchModal.answer')} 2</td>
</tr>
</tbody>
</table>
</div>
<CSVDownloader
className="block mt-2 cursor-pointer"
type={Type.Link}
filename={'template'}
bom={true}
data={getTemplate()}
>
<div className='flex items-center h-[18px] space-x-1 text-[#155EEF] text-xs font-medium'>
<DownloadIcon className='w-3 h-3 mr-1' />
{t('appAnnotation.batchModal.template')}
</div>
</CSVDownloader>
</div>
)
}
export default React.memo(CSVDownload)

View File

@@ -0,0 +1,126 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import cn from 'classnames'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { Csv as CSVIcon } from '@/app/components/base/icons/src/public/files'
import { ToastContext } from '@/app/components/base/toast'
import { Trash03 } from '@/app/components/base/icons/src/vender/line/general'
import Button from '@/app/components/base/button'
export type Props = {
file: File | undefined
updateFile: (file?: File) => void
}
const CSVUploader: FC<Props> = ({
file,
updateFile,
}) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [dragging, setDragging] = useState(false)
const dropRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<HTMLDivElement>(null)
const fileUploader = useRef<HTMLInputElement>(null)
const handleDragEnter = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.target !== dragRef.current && setDragging(true)
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDragLeave = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.target === dragRef.current && setDragging(false)
}
const handleDrop = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragging(false)
if (!e.dataTransfer)
return
const files = [...e.dataTransfer.files]
if (files.length > 1) {
notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.count') })
return
}
updateFile(files[0])
}
const selectHandle = () => {
if (fileUploader.current)
fileUploader.current.click()
}
const removeFile = () => {
if (fileUploader.current)
fileUploader.current.value = ''
updateFile()
}
const fileChangeHandle = (e: React.ChangeEvent<HTMLInputElement>) => {
const currentFile = e.target.files?.[0]
updateFile(currentFile)
}
useEffect(() => {
dropRef.current?.addEventListener('dragenter', handleDragEnter)
dropRef.current?.addEventListener('dragover', handleDragOver)
dropRef.current?.addEventListener('dragleave', handleDragLeave)
dropRef.current?.addEventListener('drop', handleDrop)
return () => {
dropRef.current?.removeEventListener('dragenter', handleDragEnter)
dropRef.current?.removeEventListener('dragover', handleDragOver)
dropRef.current?.removeEventListener('dragleave', handleDragLeave)
dropRef.current?.removeEventListener('drop', handleDrop)
}
}, [])
return (
<div className='mt-6'>
<input
ref={fileUploader}
style={{ display: 'none' }}
type="file"
id="fileUploader"
accept='.csv'
onChange={fileChangeHandle}
/>
<div ref={dropRef}>
{!file && (
<div className={cn('flex items-center h-20 rounded-xl bg-gray-50 border border-dashed border-gray-200 text-sm font-normal', dragging && 'bg-[#F5F8FF] border border-[#B2CCFF]')}>
<div className='w-full flex items-center justify-center space-x-2'>
<CSVIcon className="shrink-0" />
<div className='text-gray-500'>
{t('appAnnotation.batchModal.csvUploadTitle')}
<span className='text-primary-400 cursor-pointer' onClick={selectHandle}>{t('appAnnotation.batchModal.browse')}</span>
</div>
</div>
{dragging && <div ref={dragRef} className='absolute w-full h-full top-0 left-0' />}
</div>
)}
{file && (
<div className={cn('flex items-center h-20 px-6 rounded-xl bg-gray-50 border border-gray-200 text-sm font-normal group', 'hover:bg-[#F5F8FF] hover:border-[#B2CCFF]')}>
<CSVIcon className="shrink-0" />
<div className='flex ml-2 w-0 grow'>
<span className='max-w-[calc(100%_-_30px)] text-ellipsis whitespace-nowrap overflow-hidden text-gray-800'>{file.name.replace(/.csv$/, '')}</span>
<span className='shrink-0 text-gray-500'>.csv</span>
</div>
<div className='hidden group-hover:flex items-center'>
<Button className='!h-8 !px-3 !py-[6px] bg-white !text-[13px] !leading-[18px] text-gray-700' onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.change')}</Button>
<div className='mx-2 w-px h-4 bg-gray-200' />
<div className='p-2 cursor-pointer' onClick={removeFile}>
<Trash03 className='w-4 h-4 text-gray-500' />
</div>
</div>
</div>
)}
</div>
</div>
)
}
export default React.memo(CSVUploader)

View File

@@ -0,0 +1,124 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CSVUploader from './csv-uploader'
import CSVDownloader from './csv-downloader'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import { XClose } from '@/app/components/base/icons/src/vender/line/general'
import Toast from '@/app/components/base/toast'
import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation'
import { useProviderContext } from '@/context/provider-context'
import AnnotationFull from '@/app/components/billing/annotation-full'
export enum ProcessStatus {
WAITING = 'waiting',
PROCESSING = 'processing',
COMPLETED = 'completed',
ERROR = 'error',
}
export type IBatchModalProps = {
appId: string
isShow: boolean
onCancel: () => void
onAdded: () => void
}
const BatchModal: FC<IBatchModalProps> = ({
appId,
isShow,
onCancel,
onAdded,
}) => {
const { t } = useTranslation()
const { plan, enableBilling } = useProviderContext()
const isAnnotationFull = (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse)
const [currentCSV, setCurrentCSV] = useState<File>()
const handleFile = (file?: File) => setCurrentCSV(file)
useEffect(() => {
if (!isShow)
setCurrentCSV(undefined)
}, [isShow])
const [importStatus, setImportStatus] = useState<ProcessStatus | string>()
const notify = Toast.notify
const checkProcess = async (jobID: string) => {
try {
const res = await checkAnnotationBatchImportProgress({ jobID, appId })
setImportStatus(res.job_status)
if (res.job_status === ProcessStatus.WAITING || res.job_status === ProcessStatus.PROCESSING)
setTimeout(() => checkProcess(res.job_id), 2500)
if (res.job_status === ProcessStatus.ERROR)
notify({ type: 'error', message: `${t('appAnnotation.batchModal.runError')}` })
if (res.job_status === ProcessStatus.COMPLETED) {
notify({ type: 'success', message: `${t('appAnnotation.batchModal.completed')}` })
onAdded()
onCancel()
}
}
catch (e: any) {
notify({ type: 'error', message: `${t('appAnnotation.batchModal.runError')}${'message' in e ? `: ${e.message}` : ''}` })
}
}
const runBatch = async (csv: File) => {
const formData = new FormData()
formData.append('file', csv)
try {
const res = await annotationBatchImport({
url: `/apps/${appId}/annotations/batch-import`,
body: formData,
})
setImportStatus(res.job_status)
checkProcess(res.job_id)
}
catch (e: any) {
notify({ type: 'error', message: `${t('appAnnotation.batchModal.runError')}${'message' in e ? `: ${e.message}` : ''}` })
}
}
const handleSend = () => {
if (!currentCSV)
return
runBatch(currentCSV)
}
return (
<Modal isShow={isShow} onClose={() => { }} wrapperClassName='!z-[20]' className='px-8 py-6 !max-w-[520px] !rounded-xl'>
<div className='relative pb-1 text-xl font-medium leading-[30px] text-gray-900'>{t('appAnnotation.batchModal.title')}</div>
<div className='absolute right-4 top-4 p-2 cursor-pointer' onClick={onCancel}>
<XClose className='w-4 h-4 text-gray-500' />
</div>
<CSVUploader
file={currentCSV}
updateFile={handleFile}
/>
<CSVDownloader />
{isAnnotationFull && (
<div className='mt-4'>
<AnnotationFull />
</div>
)}
<div className='mt-[28px] pt-6 flex justify-end'>
<Button className='mr-2 text-gray-700 text-sm font-medium' onClick={onCancel}>
{t('appAnnotation.batchModal.cancel')}
</Button>
<Button
className='text-sm font-medium'
type="primary"
onClick={handleSend}
disabled={isAnnotationFull || !currentCSV}
loading={importStatus === ProcessStatus.PROCESSING}
>
{t('appAnnotation.batchModal.run')}
</Button>
</div>
</Modal>
)
}
export default React.memo(BatchModal)

View File

@@ -0,0 +1,131 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from 'rc-textarea'
import cn from 'classnames'
import { Robot, User } from '@/app/components/base/icons/src/public/avatar'
import { Edit04, Trash03 } from '@/app/components/base/icons/src/vender/line/general'
import { Edit04 as EditSolid } from '@/app/components/base/icons/src/vender/solid/general'
import Button from '@/app/components/base/button'
export enum EditItemType {
Query = 'query',
Answer = 'answer',
}
type Props = {
type: EditItemType
content: string
readonly?: boolean
onSave: (content: string) => void
}
export const EditTitle: FC<{ className?: string; title: string }> = ({ className, title }) => (
<div className={cn(className, 'flex items-center height-[18px] text-xs font-medium text-gray-500')}>
<EditSolid className='mr-1 w-3.5 h-3.5' />
<div>{title}</div>
<div
className='ml-2 grow h-[1px]'
style={{
background: 'linear-gradient(90deg, rgba(0, 0, 0, 0.05) -1.65%, rgba(0, 0, 0, 0.00) 100%)',
}}
></div>
</div>
)
const EditItem: FC<Props> = ({
type,
readonly,
content,
onSave,
}) => {
const { t } = useTranslation()
const [newContent, setNewContent] = useState('')
const showNewContent = newContent && newContent !== content
const avatar = type === EditItemType.Query ? <User className='w-6 h-6' /> : <Robot className='w-6 h-6' />
const name = type === EditItemType.Query ? t('appAnnotation.editModal.queryName') : t('appAnnotation.editModal.answerName')
const editTitle = type === EditItemType.Query ? t('appAnnotation.editModal.yourQuery') : t('appAnnotation.editModal.yourAnswer')
const placeholder = type === EditItemType.Query ? t('appAnnotation.editModal.queryPlaceholder') : t('appAnnotation.editModal.answerPlaceholder')
const [isEdit, setIsEdit] = useState(false)
const handleSave = () => {
onSave(newContent)
setIsEdit(false)
}
const handleCancel = () => {
setNewContent('')
setIsEdit(false)
}
return (
<div className='flex' onClick={e => e.stopPropagation()}>
<div className='shrink-0 mr-3'>
{avatar}
</div>
<div className='grow'>
<div className='mb-1 leading-[18px] text-xs font-semibold text-gray-900'>{name}</div>
<div className='leading-5 text-sm font-normal text-gray-900'>{content}</div>
{!isEdit
? (
<div>
{showNewContent && (
<div className='mt-3'>
<EditTitle title={editTitle} />
<div className='mt-1 leading-5 text-sm font-normal text-gray-900'>{newContent}</div>
</div>
)}
<div className='mt-2 flex items-center'>
{!readonly && (
<div
className='flex items-center space-x-1 leading-[18px] text-xs font-medium text-[#155EEF] cursor-pointer'
onClick={(e) => {
setIsEdit(true)
}}
>
<Edit04 className='mr-1 w-3.5 h-3.5' />
<div>{t('common.operation.edit')}</div>
</div>
)}
{showNewContent && (
<div className='ml-2 flex items-center leading-[18px] text-xs font-medium text-gray-500'>
<div className='mr-2'>·</div>
<div
className='flex items-center space-x-1 cursor-pointer'
onClick={() => {
setNewContent(content)
onSave(content)
}}
>
<div className='w-3.5 h-3.5'>
<Trash03 className='w-3.5 h-3.5' />
</div>
<div>{t('common.operation.delete')}</div>
</div>
</div>
)}
</div>
</div>
)
: (
<div className='mt-3'>
<EditTitle title={editTitle} />
<Textarea
className='mt-1 block w-full leading-5 max-h-none text-sm text-gray-700 outline-none appearance-none resize-none'
value={newContent}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewContent(e.target.value)}
autoSize={{ minRows: 3 }}
placeholder={placeholder}
autoFocus
/>
<div className='mt-2 flex space-x-2'>
<Button className='!h-7 !text-xs !font-medium' type='primary' onClick={handleSave}>{t('common.operation.save')}</Button>
<Button className='!h-7 !text-xs !font-medium' onClick={handleCancel}>{t('common.operation.cancel')}</Button>
</div>
</div>
)}
</div>
</div>
)
}
export default React.memo(EditItem)

View File

@@ -0,0 +1,142 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
import EditItem, { EditItemType } from './edit-item'
import Drawer from '@/app/components/base/drawer-plus'
import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication'
import DeleteConfirmModal from '@/app/components/base/modal/delete-confirm-modal'
import { addAnnotation, editAnnotation } from '@/service/annotation'
import Toast from '@/app/components/base/toast'
import { useProviderContext } from '@/context/provider-context'
import AnnotationFull from '@/app/components/billing/annotation-full'
type Props = {
isShow: boolean
onHide: () => void
appId: string
messageId?: string
annotationId?: string
query: string
answer: string
onEdited: (editedQuery: string, editedAnswer: string) => void
onAdded: (annotationId: string, authorName: string, editedQuery: string, editedAnswer: string) => void
createdAt?: number
onRemove: () => void
onlyEditResponse?: boolean
}
const EditAnnotationModal: FC<Props> = ({
isShow,
onHide,
query,
answer,
onEdited,
onAdded,
appId,
messageId,
annotationId,
createdAt,
onRemove,
onlyEditResponse,
}) => {
const { t } = useTranslation()
const { plan, enableBilling } = useProviderContext()
const isAdd = !annotationId
const isAnnotationFull = (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse)
const handleSave = async (type: EditItemType, editedContent: string) => {
let postQuery = query
let postAnswer = answer
if (type === EditItemType.Query)
postQuery = editedContent
else
postAnswer = editedContent
if (!isAdd) {
await editAnnotation(appId, annotationId, {
message_id: messageId,
question: postQuery,
answer: postAnswer,
})
onEdited(postQuery, postAnswer)
}
else {
const res: any = await addAnnotation(appId, {
question: postQuery,
answer: postAnswer,
message_id: messageId,
})
onAdded(res.id, res.account?.name, postQuery, postAnswer)
}
Toast.notify({
message: t('common.api.actionSuccess') as string,
type: 'success',
})
}
const [showModal, setShowModal] = useState(false)
return (
<div>
<Drawer
isShow={isShow}
onHide={onHide}
maxWidthClassName='!max-w-[480px]'
title={t('appAnnotation.editModal.title') as string}
body={(
<div className='p-6 pb-4 space-y-6'>
<EditItem
type={EditItemType.Query}
content={query}
readonly={(isAdd && isAnnotationFull) || onlyEditResponse}
onSave={editedContent => handleSave(EditItemType.Query, editedContent)}
/>
<EditItem
type={EditItemType.Answer}
content={answer}
readonly={isAdd && isAnnotationFull}
onSave={editedContent => handleSave(EditItemType.Answer, editedContent)}
/>
</div>
)}
foot={
<div>
{isAnnotationFull && (
<div className='mt-6 mb-4 px-6'>
<AnnotationFull />
</div>
)}
{
annotationId
? (
<div className='px-4 flex h-16 items-center justify-between border-t border-black/5 bg-gray-50 rounded-bl-xl rounded-br-xl leading-[18px] text-[13px] font-medium text-gray-500'>
<div
className='flex items-center pl-3 space-x-2 cursor-pointer'
onClick={() => setShowModal(true)}
>
<MessageCheckRemove />
<div>{t('appAnnotation.editModal.removeThisCache')}</div>
</div>
{createdAt && <div>{t('appAnnotation.editModal.createdAt')}&nbsp;{dayjs(createdAt * 1000).format('YYYY-MM-DD hh:mm')}</div>}
</div>
)
: undefined
}
</div>
}
>
</Drawer>
<DeleteConfirmModal
isShow={showModal}
onHide={() => setShowModal(false)}
onRemove={() => {
onRemove()
setShowModal(false)
}}
text={t('appDebug.feature.annotation.removeConfirm') as string}
/>
</div>
)
}
export default React.memo(EditAnnotationModal)

View File

@@ -0,0 +1,26 @@
'use client'
import type { FC, SVGProps } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
const ThreeDotsIcon = ({ className }: SVGProps<SVGElement>) => {
return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}>
<path d="M5 6.5V5M8.93934 7.56066L10 6.5M10.0103 11.5H11.5103" stroke="#374151" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
}
const EmptyElement: FC = () => {
const { t } = useTranslation()
return (
<div className='flex items-center justify-center h-full'>
<div className='bg-gray-50 w-[560px] h-fit box-border px-5 py-4 rounded-2xl'>
<span className='text-gray-700 font-semibold'>{t('appAnnotation.noData.title')}<ThreeDotsIcon className='inline relative -top-3 -left-1.5' /></span>
<div className='mt-2 text-gray-500 text-sm font-normal'>
{t('appAnnotation.noData.description')}
</div>
</div>
</div>
)
}
export default React.memo(EmptyElement)

View File

@@ -0,0 +1,54 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import {
MagnifyingGlassIcon,
} from '@heroicons/react/24/solid'
import useSWR from 'swr'
import { fetchAnnotationsCount } from '@/service/log'
export type QueryParam = {
keyword?: string
}
type IFilterProps = {
appId: string
queryParams: QueryParam
setQueryParams: (v: QueryParam) => void
children: JSX.Element
}
const Filter: FC<IFilterProps> = ({
appId,
queryParams,
setQueryParams,
children,
}) => {
// TODO: change fetch list api
const { data } = useSWR({ url: `/apps/${appId}/annotations/count` }, fetchAnnotationsCount)
const { t } = useTranslation()
if (!data)
return null
return (
<div className='flex justify-between flex-row flex-wrap gap-y-2 gap-x-4 items-center mb-4 text-gray-900 text-base'>
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
type="text"
name="query"
className="block w-[240px] bg-gray-100 shadow-sm rounded-md border-0 py-1.5 pl-10 text-gray-900 placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-gray-200 focus-visible:outline-none sm:text-sm sm:leading-6"
placeholder={t('common.operation.search') as string}
value={queryParams.keyword}
onChange={(e) => {
setQueryParams({ ...queryParams, keyword: e.target.value })
}}
/>
</div>
{children}
</div>
)
}
export default React.memo(Filter)

View File

@@ -0,0 +1,141 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import { useContext } from 'use-context-selector'
import {
useCSVDownloader,
} from 'react-papaparse'
import Button from '../../../base/button'
import { Plus } from '../../../base/icons/src/vender/line/general'
import AddAnnotationModal from '../add-annotation-modal'
import type { AnnotationItemBasic } from '../type'
import BatchAddModal from '../batch-add-annotation-modal'
import s from './style.module.css'
import CustomPopover from '@/app/components/base/popover'
// import Divider from '@/app/components/base/divider'
import { FileDownload02, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
import I18n from '@/context/i18n'
import { fetchExportAnnotationList } from '@/service/annotation'
const CSV_HEADER_QA_EN = ['Question', 'Answer']
const CSV_HEADER_QA_CN = ['问题', '答案']
type Props = {
appId: string
onAdd: (payload: AnnotationItemBasic) => void
onAdded: () => void
controlUpdateList: number
// onClearAll: () => void
}
const HeaderOptions: FC<Props> = ({
appId,
onAdd,
onAdded,
// onClearAll,
controlUpdateList,
}) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const { CSVDownloader, Type } = useCSVDownloader()
const [list, setList] = useState<AnnotationItemBasic[]>([])
const fetchList = async () => {
const { data }: any = await fetchExportAnnotationList(appId)
setList(data as AnnotationItemBasic[])
}
useEffect(() => {
fetchList()
}, [])
useEffect(() => {
if (controlUpdateList)
fetchList()
}, [controlUpdateList])
const [showBulkImportModal, setShowBulkImportModal] = useState(false)
const Operations = () => {
return (
<div className="w-full py-1">
<button className={s.actionItem} onClick={() => {
setShowBulkImportModal(true)
}}>
<FilePlus02 className={s.actionItemIcon} />
<span className={s.actionName}>{t('appAnnotation.table.header.bulkImport')}</span>
</button>
<CSVDownloader
type={Type.Link}
filename="annotations"
bom={true}
data={[
locale === 'en' ? CSV_HEADER_QA_EN : CSV_HEADER_QA_CN,
...list.map(item => [item.question, item.answer]),
]}
>
<button className={s.actionItem}>
<FileDownload02 className={s.actionItemIcon} />
<span className={s.actionName}>{t('appAnnotation.table.header.bulkExport')}</span>
</button>
</CSVDownloader>
{/* <Divider className="!my-1" />
<div
className={cn(s.actionItem, s.deleteActionItem, 'group')}
onClick={onClickDelete}
>
<Trash03 className={cn(s.actionItemIcon, 'group-hover:text-red-500')} />
<span className={cn(s.actionName, 'group-hover:text-red-500')}>
{t('appAnnotation.table.header.clearAll')}
</span>
</div> */}
</div>
)
}
const [showAddModal, setShowAddModal] = React.useState(false)
return (
<div className='flex space-x-2'>
<Button type='primary' onClick={() => setShowAddModal(true)} className='flex items-center !h-8 !px-3 !text-[13px] space-x-2'>
<Plus className='w-4 h-4' />
<div>{t('appAnnotation.table.header.addAnnotation')}</div>
</Button>
<CustomPopover
htmlContent={<Operations />}
position="br"
trigger="click"
btnElement={<div className={cn(s.actionIcon, s.commonIcon)} />}
btnClassName={open =>
cn(
open ? 'border-gray-300 !bg-gray-100 !shadow-none' : 'border-gray-200',
s.actionIconWrapper,
)
}
// !w-[208px]
className={'!w-[131px] h-fit !z-20'}
manualClose
/>
{showAddModal && (
<AddAnnotationModal
isShow={showAddModal}
onHide={() => setShowAddModal(false)}
onAdd={onAdd}
/>
)}
{
showBulkImportModal && (
<BatchAddModal
appId={appId}
isShow={showBulkImportModal}
onCancel={() => setShowBulkImportModal(false)}
onAdded={onAdded}
/>
)
}
</div>
)
}
export default React.memo(HeaderOptions)

View File

@@ -0,0 +1,32 @@
.actionIconWrapper {
@apply h-8 w-8 p-2 rounded-md hover:bg-gray-100 !important;
}
.commonIcon {
@apply w-4 h-4 inline-block align-middle;
background-repeat: no-repeat;
background-position: center center;
background-size: contain;
}
.actionIcon {
@apply bg-gray-500;
mask-image: url(~@/assets/action.svg);
}
.actionItemIcon {
@apply w-4 h-4 text-gray-500;
}
.actionItem {
@apply h-9 py-2 px-3 mx-1 flex items-center space-x-2 hover:bg-gray-100 rounded-lg cursor-pointer;
width: calc(100% - 0.5rem);
}
.deleteActionItem {
@apply hover:bg-red-50 !important;
}
.actionName {
@apply text-gray-700 text-sm;
}

View File

@@ -0,0 +1,315 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Pagination } from 'react-headless-pagination'
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'
import cn from 'classnames'
import Toast from '../../base/toast'
import Filter from './filter'
import type { QueryParam } from './filter'
import List from './list'
import EmptyElement from './empty-element'
import HeaderOpts from './header-opts'
import s from './style.module.css'
import { AnnotationEnableStatus, type AnnotationItem, type AnnotationItemBasic, JobStatus } from './type'
import ViewAnnotationModal from './view-annotation-modal'
import Switch from '@/app/components/base/switch'
import { addAnnotation, delAnnotation, fetchAnnotationConfig as doFetchAnnotationConfig, editAnnotation, fetchAnnotationList, queryAnnotationJobStatus, updateAnnotationScore, updateAnnotationStatus } from '@/service/annotation'
import Loading from '@/app/components/base/loading'
import { APP_PAGE_LIMIT } from '@/config'
import ConfigParamModal from '@/app/components/app/configuration/toolbox/annotation/config-param-modal'
import type { AnnotationReplyConfig } from '@/models/debug'
import { sleep } from '@/utils'
import { useProviderContext } from '@/context/provider-context'
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
import { Settings04 } from '@/app/components/base/icons/src/vender/line/general'
import { fetchAppDetail } from '@/service/apps'
type Props = {
appId: string
}
const Annotation: FC<Props> = ({
appId,
}) => {
const { t } = useTranslation()
const [isShowEdit, setIsShowEdit] = React.useState(false)
const [annotationConfig, setAnnotationConfig] = useState<AnnotationReplyConfig | null>(null)
const [isChatApp, setIsChatApp] = useState(false)
const fetchAnnotationConfig = async () => {
const res = await doFetchAnnotationConfig(appId)
setAnnotationConfig(res as AnnotationReplyConfig)
}
useEffect(() => {
fetchAppDetail({ url: '/apps', id: appId }).then(async (res: any) => {
const isChatApp = res.mode === 'chat'
setIsChatApp(isChatApp)
if (isChatApp)
fetchAnnotationConfig()
})
}, [])
const [controlRefreshSwitch, setControlRefreshSwitch] = useState(Date.now())
const { plan, enableBilling } = useProviderContext()
const isAnnotationFull = (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse)
const [isShowAnnotationFullModal, setIsShowAnnotationFullModal] = useState(false)
const ensureJobCompleted = async (jobId: string, status: AnnotationEnableStatus) => {
let isCompleted = false
while (!isCompleted) {
const res: any = await queryAnnotationJobStatus(appId, status, jobId)
isCompleted = res.job_status === JobStatus.completed
if (isCompleted)
break
await sleep(2000)
}
}
const [queryParams, setQueryParams] = useState<QueryParam>({})
const [currPage, setCurrPage] = React.useState<number>(0)
const query = {
page: currPage + 1,
limit: APP_PAGE_LIMIT,
keyword: queryParams.keyword || '',
}
const [controlUpdateList, setControlUpdateList] = useState(Date.now())
const [list, setList] = useState<AnnotationItem[]>([])
const [total, setTotal] = useState(10)
const [isLoading, setIsLoading] = useState(false)
const fetchList = async (page = 1) => {
setIsLoading(true)
try {
const { data, total }: any = await fetchAnnotationList(appId, {
...query,
page,
})
setList(data as AnnotationItem[])
setTotal(total)
}
catch (e) {
}
setIsLoading(false)
}
useEffect(() => {
fetchList(currPage + 1)
}, [currPage])
useEffect(() => {
fetchList(1)
setControlUpdateList(Date.now())
}, [queryParams])
const handleAdd = async (payload: AnnotationItemBasic) => {
await addAnnotation(appId, {
...payload,
})
Toast.notify({
message: t('common.api.actionSuccess'),
type: 'success',
})
fetchList()
setControlUpdateList(Date.now())
}
const handleRemove = async (id: string) => {
await delAnnotation(appId, id)
Toast.notify({
message: t('common.api.actionSuccess'),
type: 'success',
})
fetchList()
setControlUpdateList(Date.now())
}
const [currItem, setCurrItem] = useState<AnnotationItem | null>(list[0])
const [isShowViewModal, setIsShowViewModal] = useState(false)
useEffect(() => {
if (!isShowEdit)
setControlRefreshSwitch(Date.now())
}, [isShowEdit])
const handleView = (item: AnnotationItem) => {
setCurrItem(item)
setIsShowViewModal(true)
}
const handleSave = async (question: string, answer: string) => {
await editAnnotation(appId, (currItem as AnnotationItem).id, {
question,
answer,
})
Toast.notify({
message: t('common.api.actionSuccess'),
type: 'success',
})
fetchList()
setControlUpdateList(Date.now())
}
return (
<div className='flex flex-col h-full'>
<p className='flex text-sm font-normal text-gray-500'>{t('appLog.description')}</p>
<div className='grow flex flex-col py-4 '>
<Filter appId={appId} queryParams={queryParams} setQueryParams={setQueryParams}>
<div className='flex items-center space-x-2'>
{isChatApp && (
<>
<div className={cn(!annotationConfig?.enabled && 'pr-2', 'flex items-center h-7 rounded-lg border border-gray-200 pl-2 space-x-1')}>
<div className='leading-[18px] text-[13px] font-medium text-gray-900'>{t('appAnnotation.name')}</div>
<Switch
key={controlRefreshSwitch}
defaultValue={annotationConfig?.enabled}
size='md'
onChange={async (value) => {
if (value) {
if (isAnnotationFull) {
setIsShowAnnotationFullModal(true)
setControlRefreshSwitch(Date.now())
return
}
setIsShowEdit(true)
}
else {
const { job_id: jobId }: any = await updateAnnotationStatus(appId, AnnotationEnableStatus.disable, annotationConfig?.embedding_model, annotationConfig?.score_threshold)
await ensureJobCompleted(jobId, AnnotationEnableStatus.disable)
await fetchAnnotationConfig()
Toast.notify({
message: t('common.api.actionSuccess'),
type: 'success',
})
}
}}
></Switch>
{annotationConfig?.enabled && (
<div className='flex items-center pl-1.5'>
<div className='shrink-0 mr-1 w-[1px] h-3.5 bg-gray-200'></div>
<div
className={`
shrink-0 h-7 w-7 flex items-center justify-center
text-xs text-gray-700 font-medium
`}
onClick={() => { setIsShowEdit(true) }}
>
<div className='flex h-6 w-6 items-center justify-center rounded-md cursor-pointer hover:bg-gray-200'>
<Settings04 className='w-4 h-4' />
</div>
</div>
</div>
)}
</div>
<div className='shrink-0 mx-3 w-[1px] h-3.5 bg-gray-200'></div>
</>
)}
<HeaderOpts
appId={appId}
controlUpdateList={controlUpdateList}
onAdd={handleAdd}
onAdded={() => {
fetchList()
}}
/>
</div>
</Filter>
{isLoading
? <Loading type='app' />
: total > 0
? <List
list={list}
onRemove={handleRemove}
onView={handleView}
/>
: <div className='grow flex h-full items-center justify-center'><EmptyElement /></div>
}
{/* Show Pagination only if the total is more than the limit */}
{(total && total > APP_PAGE_LIMIT)
? <Pagination
className="flex items-center w-full h-10 text-sm select-none mt-8"
currentPage={currPage}
edgePageCount={2}
middlePagesSiblingCount={1}
setCurrentPage={setCurrPage}
totalPages={Math.ceil(total / APP_PAGE_LIMIT)}
truncableClassName="w-8 px-0.5 text-center"
truncableText="..."
>
<Pagination.PrevButton
disabled={currPage === 0}
className={`flex items-center mr-2 text-gray-500 focus:outline-none ${currPage === 0 ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600 dark:hover:text-gray-200'}`} >
<ArrowLeftIcon className="mr-3 h-3 w-3" />
{t('appLog.table.pagination.previous')}
</Pagination.PrevButton>
<div className={`flex items-center justify-center flex-grow ${s.pagination}`}>
<Pagination.PageButton
activeClassName="bg-primary-50 dark:bg-opacity-0 text-primary-600 dark:text-white"
className="flex items-center justify-center h-8 w-8 rounded-full cursor-pointer"
inactiveClassName="text-gray-500"
/>
</div>
<Pagination.NextButton
disabled={currPage === Math.ceil(total / APP_PAGE_LIMIT) - 1}
className={`flex items-center mr-2 text-gray-500 focus:outline-none ${currPage === Math.ceil(total / APP_PAGE_LIMIT) - 1 ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600 dark:hover:text-gray-200'}`} >
{t('appLog.table.pagination.next')}
<ArrowRightIcon className="ml-3 h-3 w-3" />
</Pagination.NextButton>
</Pagination>
: null}
{isShowViewModal && (
<ViewAnnotationModal
appId={appId}
isShow={isShowViewModal}
onHide={() => setIsShowViewModal(false)}
onRemove={async () => {
await handleRemove((currItem as AnnotationItem)?.id)
}}
item={currItem as AnnotationItem}
onSave={handleSave}
/>
)}
{isShowEdit && (
<ConfigParamModal
appId={appId}
isShow
isInit={!annotationConfig?.enabled}
onHide={() => {
setIsShowEdit(false)
}}
onSave={async (embeddingModel, score) => {
if (
embeddingModel.embedding_model_name !== annotationConfig?.embedding_model?.embedding_model_name
&& embeddingModel.embedding_provider_name !== annotationConfig?.embedding_model?.embedding_provider_name
) {
const { job_id: jobId }: any = await updateAnnotationStatus(appId, AnnotationEnableStatus.enable, embeddingModel, score)
await ensureJobCompleted(jobId, AnnotationEnableStatus.enable)
}
if (score !== annotationConfig?.score_threshold)
await updateAnnotationScore(appId, annotationConfig?.id || '', score)
await fetchAnnotationConfig()
Toast.notify({
message: t('common.api.actionSuccess'),
type: 'success',
})
setIsShowEdit(false)
}}
annotationConfig={annotationConfig!}
/>
)}
{
isShowAnnotationFullModal && (
<AnnotationFullModal
show={isShowAnnotationFullModal}
onHide={() => setIsShowAnnotationFullModal(false)}
/>
)
}
</div>
</div>
)
}
export default React.memo(Annotation)

View File

@@ -0,0 +1,98 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import dayjs from 'dayjs'
import { Edit02, Trash03 } from '../../base/icons/src/vender/line/general'
import s from './style.module.css'
import type { AnnotationItem } from './type'
import RemoveAnnotationConfirmModal from './remove-annotation-confirm-modal'
type Props = {
list: AnnotationItem[]
onRemove: (id: string) => void
onView: (item: AnnotationItem) => void
}
const List: FC<Props> = ({
list,
onView,
onRemove,
}) => {
const { t } = useTranslation()
const [currId, setCurrId] = React.useState<string | null>(null)
const [showConfirmDelete, setShowConfirmDelete] = React.useState(false)
return (
<div className='overflow-x-auto'>
<table className={cn(s.logTable, 'w-full min-w-[440px] border-collapse border-0 text-sm')} >
<thead className="h-8 leading-8 border-b border-gray-200 text-gray-500 font-bold">
<tr className='uppercase'>
<td className='whitespace-nowrap'>{t('appAnnotation.table.header.question')}</td>
<td className='whitespace-nowrap'>{t('appAnnotation.table.header.answer')}</td>
<td className='whitespace-nowrap'>{t('appAnnotation.table.header.createdAt')}</td>
<td className='whitespace-nowrap'>{t('appAnnotation.table.header.hits')}</td>
<td className='whitespace-nowrap w-[96px]'>{t('appAnnotation.table.header.actions')}</td>
</tr>
</thead>
<tbody className="text-gray-500">
{list.map(item => (
<tr
key={item.id}
className={'border-b border-gray-200 h-8 hover:bg-gray-50 cursor-pointer'}
onClick={
() => {
onView(item)
}
}
>
<td
className='whitespace-nowrap overflow-hidden text-ellipsis max-w-[250px]'
title={item.question}
>{item.question}</td>
<td
className='whitespace-nowrap overflow-hidden text-ellipsis max-w-[250px]'
title={item.answer}
>{item.answer}</td>
<td>{dayjs(item.created_at * 1000).format('YYYY-MM-DD hh:mm')}</td>
<td>{item.hit_count}</td>
<td className='w-[96px]' onClick={e => e.stopPropagation()}>
{/* Actions */}
<div className='flex space-x-2 text-gray-500'>
<div
className='p-1 cursor-pointer rounded-md hover:bg-black/5'
onClick={
() => {
onView(item)
}
}
>
<Edit02 className='w-4 h-4' />
</div>
<div
className='p-1 cursor-pointer rounded-md hover:bg-black/5'
onClick={() => {
setCurrId(item.id)
setShowConfirmDelete(true)
}}
>
<Trash03 className='w-4 h-4' />
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
<RemoveAnnotationConfirmModal
isShow={showConfirmDelete}
onHide={() => setShowConfirmDelete(false)}
onRemove={() => {
onRemove(currId as string)
setShowConfirmDelete(false)
}}
/>
</div>
)
}
export default React.memo(List)

View File

@@ -0,0 +1,29 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import DeleteConfirmModal from '@/app/components/base/modal/delete-confirm-modal'
type Props = {
isShow: boolean
onHide: () => void
onRemove: () => void
}
const RemoveAnnotationConfirmModal: FC<Props> = ({
isShow,
onHide,
onRemove,
}) => {
const { t } = useTranslation()
return (
<DeleteConfirmModal
isShow={isShow}
onHide={onHide}
onRemove={onRemove}
text={t('appDebug.feature.annotation.removeConfirm') as string}
/>
)
}
export default React.memo(RemoveAnnotationConfirmModal)

View File

@@ -0,0 +1,9 @@
.logTable td {
padding: 7px 8px;
box-sizing: border-box;
max-width: 200px;
}
.pagination li {
list-style: none;
}

View File

@@ -0,0 +1,39 @@
export type AnnotationItemBasic = {
message_id?: string
question: string
answer: string
}
export type AnnotationItem = {
id: string
question: string
answer: string
created_at: number
hit_count: number
}
export type HitHistoryItem = {
id: string
question: string
match: string
response: string
source: string
score: number
created_at: number
}
export type EmbeddingModelConfig = {
embedding_provider_name: string
embedding_model_name: string
}
export enum AnnotationEnableStatus {
enable = 'enable',
disable = 'disable',
}
export enum JobStatus {
waiting = 'waiting',
processing = 'processing',
completed = 'completed',
}

View File

@@ -0,0 +1,19 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { ClockFastForward } from '@/app/components/base/icons/src/vender/line/time'
const HitHistoryNoData: FC = () => {
const { t } = useTranslation()
return (
<div className='mx-auto mt-20 w-[480px] p-5 rounded-2xl bg-gray-50 space-y-2'>
<div className='inline-block p-3 rounded-lg border border-gray-200'>
<ClockFastForward className='w-5 h-5 text-gray-500' />
</div>
<div className='leading-5 text-sm font-normal text-gray-500'>{t('appAnnotation.viewModal.noHitHistory')}</div>
</div>
)
}
export default React.memo(HitHistoryNoData)

View File

@@ -0,0 +1,237 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import dayjs from 'dayjs'
import { Pagination } from 'react-headless-pagination'
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'
import EditItem, { EditItemType } from '../edit-annotation-modal/edit-item'
import type { AnnotationItem, HitHistoryItem } from '../type'
import s from './style.module.css'
import HitHistoryNoData from './hit-history-no-data'
import Drawer from '@/app/components/base/drawer-plus'
import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication'
import DeleteConfirmModal from '@/app/components/base/modal/delete-confirm-modal'
import TabSlider from '@/app/components/base/tab-slider-plain'
import { fetchHitHistoryList } from '@/service/annotation'
import { APP_PAGE_LIMIT } from '@/config'
type Props = {
appId: string
isShow: boolean
onHide: () => void
item: AnnotationItem
onSave: (editedQuery: string, editedAnswer: string) => void
onRemove: () => void
}
enum TabType {
annotation = 'annotation',
hitHistory = 'hitHistory',
}
const ViewAnnotationModal: FC<Props> = ({
appId,
isShow,
onHide,
item,
onSave,
onRemove,
}) => {
const { id, question, answer, created_at: createdAt } = item
const [newQuestion, setNewQuery] = useState(question)
const [newAnswer, setNewAnswer] = useState(answer)
const { t } = useTranslation()
const [currPage, setCurrPage] = React.useState<number>(0)
const [total, setTotal] = useState(0)
const [hitHistoryList, setHitHistoryList] = useState<HitHistoryItem[]>([])
const fetchHitHistory = async (page = 1) => {
try {
const { data, total }: any = await fetchHitHistoryList(appId, id, {
page,
limit: 10,
})
setHitHistoryList(data as HitHistoryItem[])
setTotal(total)
}
catch (e) {
}
}
useEffect(() => {
fetchHitHistory(currPage + 1)
}, [currPage])
const tabs = [
{ value: TabType.annotation, text: t('appAnnotation.viewModal.annotatedResponse') },
{
value: TabType.hitHistory,
text: (
hitHistoryList.length > 0
? (
<div className='flex items-center space-x-1'>
<div>{t('appAnnotation.viewModal.hitHistory')}</div>
<div className='flex px-1.5 item-center rounded-md border border-black/[8%] h-5 text-xs font-medium text-gray-500'>{total} {t(`appAnnotation.viewModal.hit${hitHistoryList.length > 1 ? 's' : ''}`)}</div>
</div>
)
: t('appAnnotation.viewModal.hitHistory')
),
},
]
const [activeTab, setActiveTab] = useState(TabType.annotation)
const handleSave = (type: EditItemType, editedContent: string) => {
if (type === EditItemType.Query) {
setNewQuery(editedContent)
onSave(editedContent, newAnswer)
}
else {
setNewAnswer(editedContent)
onSave(newQuestion, editedContent)
}
}
const [showModal, setShowModal] = useState(false)
const annotationTab = (
<>
<EditItem
type={EditItemType.Query}
content={question}
onSave={editedContent => handleSave(EditItemType.Query, editedContent)}
/>
<EditItem
type={EditItemType.Answer}
content={answer}
onSave={editedContent => handleSave(EditItemType.Answer, editedContent)}
/>
</>
)
const hitHistoryTab = total === 0
? (<HitHistoryNoData />)
: (
<div>
<table className={cn(s.table, 'w-full min-w-[440px] border-collapse border-0 text-sm')} >
<thead className="h-8 leading-8 border-b border-gray-200 text-gray-500 font-bold">
<tr className='uppercase'>
<td className='whitespace-nowrap'>{t('appAnnotation.hitHistoryTable.query')}</td>
<td className='whitespace-nowrap'>{t('appAnnotation.hitHistoryTable.match')}</td>
<td className='whitespace-nowrap'>{t('appAnnotation.hitHistoryTable.response')}</td>
<td className='whitespace-nowrap'>{t('appAnnotation.hitHistoryTable.source')}</td>
<td className='whitespace-nowrap'>{t('appAnnotation.hitHistoryTable.score')}</td>
<td className='whitespace-nowrap w-[140px]'>{t('appAnnotation.hitHistoryTable.time')}</td>
</tr>
</thead>
<tbody className="text-gray-500">
{hitHistoryList.map(item => (
<tr
key={item.id}
className={'border-b border-gray-200 h-8 hover:bg-gray-50 cursor-pointer'}
>
<td
className='whitespace-nowrap overflow-hidden text-ellipsis max-w-[250px]'
title={item.question}
>{item.question}</td>
<td
className='whitespace-nowrap overflow-hidden text-ellipsis max-w-[250px]'
title={item.match}
>{item.match}</td>
<td
className='whitespace-nowrap overflow-hidden text-ellipsis max-w-[250px]'
title={item.response}
>{item.response}</td>
<td>{item.source}</td>
<td>{item.score ? item.score.toFixed(2) : '-'}</td>
<td>{dayjs(item.created_at * 1000).format('YYYY-MM-DD hh:mm')}</td>
</tr>
))}
</tbody>
</table>
{(total && total > APP_PAGE_LIMIT)
? <Pagination
className="flex items-center w-full h-10 text-sm select-none mt-8"
currentPage={currPage}
edgePageCount={2}
middlePagesSiblingCount={1}
setCurrentPage={setCurrPage}
totalPages={Math.ceil(total / APP_PAGE_LIMIT)}
truncableClassName="w-8 px-0.5 text-center"
truncableText="..."
>
<Pagination.PrevButton
disabled={currPage === 0}
className={`flex items-center mr-2 text-gray-500 focus:outline-none ${currPage === 0 ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600 dark:hover:text-gray-200'}`} >
<ArrowLeftIcon className="mr-3 h-3 w-3" />
{t('appLog.table.pagination.previous')}
</Pagination.PrevButton>
<div className={`flex items-center justify-center flex-grow ${s.pagination}`}>
<Pagination.PageButton
activeClassName="bg-primary-50 dark:bg-opacity-0 text-primary-600 dark:text-white"
className="flex items-center justify-center h-8 w-8 rounded-full cursor-pointer"
inactiveClassName="text-gray-500"
/>
</div>
<Pagination.NextButton
disabled={currPage === Math.ceil(total / APP_PAGE_LIMIT) - 1}
className={`flex items-center mr-2 text-gray-500 focus:outline-none ${currPage === Math.ceil(total / APP_PAGE_LIMIT) - 1 ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600 dark:hover:text-gray-200'}`} >
{t('appLog.table.pagination.next')}
<ArrowRightIcon className="ml-3 h-3 w-3" />
</Pagination.NextButton>
</Pagination>
: null}
</div>
)
return (
<div>
<Drawer
isShow={isShow}
onHide={onHide}
maxWidthClassName='!max-w-[800px]'
// t('appAnnotation.editModal.title') as string
title={
<TabSlider
className='shrink-0 relative top-[9px]'
value={activeTab}
onChange={v => setActiveTab(v as TabType)}
options={tabs}
noBorderBottom
itemClassName='!pb-3.5'
/>
}
body={(
<div className='p-6 pb-4 space-y-6'>
{activeTab === TabType.annotation ? annotationTab : hitHistoryTab}
</div>
)}
foot={id
? (
<div className='px-4 flex h-16 items-center justify-between border-t border-black/5 bg-gray-50 rounded-bl-xl rounded-br-xl leading-[18px] text-[13px] font-medium text-gray-500'>
<div
className='flex items-center pl-3 space-x-2 cursor-pointer'
onClick={() => setShowModal(true)}
>
<MessageCheckRemove />
<div>{t('appAnnotation.editModal.removeThisCache')}</div>
</div>
<div>{t('appAnnotation.editModal.createdAt')}&nbsp;{dayjs(createdAt * 1000).format('YYYY-MM-DD hh:mm')}</div>
</div>
)
: undefined}
>
</Drawer>
<DeleteConfirmModal
isShow={showModal}
onHide={() => setShowModal(false)}
onRemove={async () => {
await onRemove()
setShowModal(false)
onHide()
}}
text={t('appDebug.feature.annotation.removeConfirm') as string}
/>
</div>
)
}
export default React.memo(ViewAnnotationModal)

View File

@@ -0,0 +1,9 @@
.table td {
padding: 7px 8px;
box-sizing: border-box;
max-width: 200px;
}
.pagination li {
list-style: none;
}