feat: annotation management frontend (#1764)
This commit is contained in:
parent
96d2de2258
commit
65fd4b39ce
122 changed files with 4718 additions and 214 deletions
|
|
@ -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)
|
||||
120
web/app/components/app/annotation/add-annotation-modal/index.tsx
Normal file
120
web/app/components/app/annotation/add-annotation-modal/index.tsx
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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')} {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)
|
||||
26
web/app/components/app/annotation/empty-element.tsx
Normal file
26
web/app/components/app/annotation/empty-element.tsx
Normal 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)
|
||||
54
web/app/components/app/annotation/filter.tsx
Normal file
54
web/app/components/app/annotation/filter.tsx
Normal 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)
|
||||
141
web/app/components/app/annotation/header-opts/index.tsx
Normal file
141
web/app/components/app/annotation/header-opts/index.tsx
Normal 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)
|
||||
|
|
@ -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;
|
||||
}
|
||||
315
web/app/components/app/annotation/index.tsx
Normal file
315
web/app/components/app/annotation/index.tsx
Normal 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)
|
||||
98
web/app/components/app/annotation/list.tsx
Normal file
98
web/app/components/app/annotation/list.tsx
Normal 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)
|
||||
|
|
@ -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)
|
||||
9
web/app/components/app/annotation/style.module.css
Normal file
9
web/app/components/app/annotation/style.module.css
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.logTable td {
|
||||
padding: 7px 8px;
|
||||
box-sizing: border-box;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.pagination li {
|
||||
list-style: none;
|
||||
}
|
||||
39
web/app/components/app/annotation/type.ts
Normal file
39
web/app/components/app/annotation/type.ts
Normal 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',
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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')} {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)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
.table td {
|
||||
padding: 7px 8px;
|
||||
box-sizing: border-box;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.pagination li {
|
||||
list-style: none;
|
||||
}
|
||||
|
|
@ -2,26 +2,26 @@
|
|||
import type { FC, ReactNode } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { UserCircleIcon } from '@heroicons/react/24/solid'
|
||||
import cn from 'classnames'
|
||||
import type { CitationItem, DisplayScene, FeedbackFunc, Feedbacktype, IChatItem, SubmitAnnotationFunc, ThoughtItem } from '../type'
|
||||
import type { CitationItem, DisplayScene, FeedbackFunc, Feedbacktype, IChatItem, ThoughtItem } from '../type'
|
||||
import OperationBtn from '../operation'
|
||||
import LoadingAnim from '../loading-anim'
|
||||
import { EditIcon, EditIconSolid, OpeningStatementIcon, RatingIcon } from '../icon-component'
|
||||
import { EditIconSolid, OpeningStatementIcon, RatingIcon } from '../icon-component'
|
||||
import s from '../style.module.css'
|
||||
import MoreInfo from '../more-info'
|
||||
import CopyBtn from '../copy-btn'
|
||||
import Thought from '../thought'
|
||||
import Citation from '../citation'
|
||||
import { randomString } from '@/utils'
|
||||
import type { Annotation, MessageRating } from '@/models/log'
|
||||
import AppContext from '@/context/app-context'
|
||||
import type { MessageRating } from '@/models/log'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import AutoHeightTextarea from '@/app/components/base/auto-height-textarea'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import AnnotationCtrlBtn from '@/app/components/app/configuration/toolbox/annotation/annotation-ctrl-btn'
|
||||
import EditReplyModal from '@/app/components/app/annotation/edit-annotation-modal'
|
||||
import { EditTitle } from '@/app/components/app/annotation/edit-annotation-modal/edit-item'
|
||||
import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
|
||||
const Divider: FC<{ name: string }> = ({ name }) => {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -42,7 +42,6 @@ export type IAnswerProps = {
|
|||
feedbackDisabled: boolean
|
||||
isHideFeedbackEdit: boolean
|
||||
onFeedback?: FeedbackFunc
|
||||
onSubmitAnnotation?: SubmitAnnotationFunc
|
||||
displayScene: DisplayScene
|
||||
isResponsing?: boolean
|
||||
answerIcon?: ReactNode
|
||||
|
|
@ -52,6 +51,13 @@ export type IAnswerProps = {
|
|||
dataSets?: DataSet[]
|
||||
isShowCitation?: boolean
|
||||
isShowCitationHitInfo?: boolean
|
||||
// Annotation props
|
||||
supportAnnotation?: boolean
|
||||
appId?: string
|
||||
question: string
|
||||
onAnnotationEdited?: (question: string, answer: string) => void
|
||||
onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string) => void
|
||||
onAnnotationRemoved?: () => void
|
||||
}
|
||||
// The component needs to maintain its own state to control whether to display input component
|
||||
const Answer: FC<IAnswerProps> = ({
|
||||
|
|
@ -59,7 +65,6 @@ const Answer: FC<IAnswerProps> = ({
|
|||
feedbackDisabled = false,
|
||||
isHideFeedbackEdit = false,
|
||||
onFeedback,
|
||||
onSubmitAnnotation,
|
||||
displayScene = 'web',
|
||||
isResponsing,
|
||||
answerIcon,
|
||||
|
|
@ -69,15 +74,25 @@ const Answer: FC<IAnswerProps> = ({
|
|||
dataSets,
|
||||
isShowCitation,
|
||||
isShowCitationHitInfo = false,
|
||||
supportAnnotation,
|
||||
appId,
|
||||
question,
|
||||
onAnnotationEdited,
|
||||
onAnnotationAdded,
|
||||
onAnnotationRemoved,
|
||||
}) => {
|
||||
const { id, content, more, feedback, adminFeedback, annotation: initAnnotation } = item
|
||||
const { id, content, more, feedback, adminFeedback, annotation } = item
|
||||
const hasAnnotation = !!annotation?.id
|
||||
const [showEdit, setShowEdit] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [annotation, setAnnotation] = useState<Annotation | undefined | null>(initAnnotation)
|
||||
const [inputValue, setInputValue] = useState<string>(initAnnotation?.content ?? '')
|
||||
// const [annotation, setAnnotation] = useState<Annotation | undefined | null>(initAnnotation)
|
||||
// const [inputValue, setInputValue] = useState<string>(initAnnotation?.content ?? '')
|
||||
const [localAdminFeedback, setLocalAdminFeedback] = useState<Feedbacktype | undefined | null>(adminFeedback)
|
||||
const { userProfile } = useContext(AppContext)
|
||||
// const { userProfile } = useContext(AppContext)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [isShowReplyModal, setIsShowReplyModal] = useState(false)
|
||||
|
||||
/**
|
||||
* Render feedback results (distinguish between users and administrators)
|
||||
* User reviews cannot be cancelled in Console
|
||||
|
|
@ -121,6 +136,19 @@ const Answer: FC<IAnswerProps> = ({
|
|||
)
|
||||
}
|
||||
|
||||
const renderHasAnnotationBtn = () => {
|
||||
return (
|
||||
<div
|
||||
className={cn(s.hasAnnotationBtn, 'relative box-border flex items-center justify-center h-7 w-7 p-0.5 rounded-lg bg-white cursor-pointer text-[#444CE7]')}
|
||||
style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
|
||||
>
|
||||
<div className='p-1 rounded-lg bg-[#EEF4FF] '>
|
||||
<MessageFast className='w-4 h-4' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Different scenarios have different operation items.
|
||||
* @param isWebScene Whether it is web scene
|
||||
|
|
@ -142,12 +170,6 @@ const Answer: FC<IAnswerProps> = ({
|
|||
|
||||
const adminOperation = () => {
|
||||
return <div className='flex gap-1'>
|
||||
<Tooltip selector={`user-feedback-${randomString(16)}`} content={t('appLog.detail.operation.addAnnotation') as string}>
|
||||
{OperationBtn({
|
||||
innerContent: <IconWrapper><EditIcon className='hover:text-gray-800' /></IconWrapper>,
|
||||
onClick: () => setShowEdit(true),
|
||||
})}
|
||||
</Tooltip>
|
||||
{!localAdminFeedback?.rating && <>
|
||||
<Tooltip selector={`user-feedback-${randomString(16)}`} content={t('appLog.detail.operation.like') as string}>
|
||||
{OperationBtn({
|
||||
|
|
@ -219,47 +241,27 @@ const Answer: FC<IAnswerProps> = ({
|
|||
)
|
||||
: (
|
||||
<div>
|
||||
<Markdown content={content} />
|
||||
{annotation?.logAnnotation && (
|
||||
<div className='mb-1'>
|
||||
<div className='mb-3'>
|
||||
<Markdown className='line-through !text-gray-400' content={content} />
|
||||
</div>
|
||||
<EditTitle title={t('appAnnotation.editBy', {
|
||||
author: annotation?.logAnnotation.account.name,
|
||||
})} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Markdown content={annotation?.logAnnotation ? annotation?.logAnnotation.content : content} />
|
||||
</div>
|
||||
{(hasAnnotation && !annotation?.logAnnotation) && (
|
||||
<EditTitle className='mt-1' title={t('appAnnotation.editBy', {
|
||||
author: annotation.authorName,
|
||||
})} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!showEdit
|
||||
? (annotation?.content
|
||||
&& <>
|
||||
<Divider name={annotation?.account?.name || userProfile?.name} />
|
||||
{annotation.content}
|
||||
</>)
|
||||
: <>
|
||||
<Divider name={annotation?.account?.name || userProfile?.name} />
|
||||
<AutoHeightTextarea
|
||||
placeholder={t('appLog.detail.operation.annotationPlaceholder') as string}
|
||||
value={inputValue}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
minHeight={58}
|
||||
className={`${cn(s.textArea)} !py-2 resize-none block w-full !px-3 bg-gray-50 border border-gray-200 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm text-gray-700 tracking-[0.2px]`}
|
||||
/>
|
||||
<div className="mt-2 flex flex-row">
|
||||
<Button
|
||||
type='primary'
|
||||
className='mr-2'
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
if (!inputValue)
|
||||
return
|
||||
setLoading(true)
|
||||
const res = await onSubmitAnnotation?.(id, inputValue)
|
||||
if (res)
|
||||
setAnnotation({ ...annotation, content: inputValue } as Annotation)
|
||||
setLoading(false)
|
||||
setShowEdit(false)
|
||||
}}>{t('common.operation.confirm')}</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setInputValue(annotation?.content ?? '')
|
||||
setShowEdit(false)
|
||||
}}>{t('common.operation.cancel')}</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
{
|
||||
!!citation?.length && !isThinking && isShowCitation && !isResponsing && (
|
||||
<Citation data={citation} showHitInfo={isShowCitationHitInfo} />
|
||||
|
|
@ -273,6 +275,36 @@ const Answer: FC<IAnswerProps> = ({
|
|||
className={cn(s.copyBtn, 'mr-1')}
|
||||
/>
|
||||
)}
|
||||
{supportAnnotation && (
|
||||
<AnnotationCtrlBtn
|
||||
appId={appId!}
|
||||
messageId={id}
|
||||
annotationId={annotation?.id || ''}
|
||||
className={cn(s.annotationBtn, 'ml-1')}
|
||||
cached={hasAnnotation}
|
||||
query={question}
|
||||
answer={content}
|
||||
onAdded={(id, authorName) => onAnnotationAdded?.(id, authorName, question, content)}
|
||||
onEdit={() => setIsShowReplyModal(true)}
|
||||
onRemoved={onAnnotationRemoved!}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EditReplyModal
|
||||
isShow={isShowReplyModal}
|
||||
onHide={() => setIsShowReplyModal(false)}
|
||||
query={question}
|
||||
answer={content}
|
||||
onEdited={onAnnotationEdited!}
|
||||
onAdded={onAnnotationAdded!}
|
||||
appId={appId!}
|
||||
messageId={id}
|
||||
annotationId={annotation?.id || ''}
|
||||
createdAt={annotation?.created_at}
|
||||
onRemove={() => { }}
|
||||
/>
|
||||
{hasAnnotation && renderHasAnnotationBtn()}
|
||||
|
||||
{!feedbackDisabled && !item.feedbackDisabled && renderItemOperation(displayScene !== 'console')}
|
||||
{/* Admin feedback is displayed only in the background. */}
|
||||
{!feedbackDisabled && renderFeedbackRating(localAdminFeedback?.rating, false, false)}
|
||||
|
|
@ -280,6 +312,7 @@ const Answer: FC<IAnswerProps> = ({
|
|||
{!feedbackDisabled && renderFeedbackRating(feedback?.rating, !isHideFeedbackEdit, displayScene !== 'console')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{more && <MoreInfo className='invisible group-hover:visible' more={more} isQuestion={false} />}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import cn from 'classnames'
|
|||
import Recorder from 'js-audio-recorder'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import s from './style.module.css'
|
||||
import type { DisplayScene, FeedbackFunc, IChatItem, SubmitAnnotationFunc } from './type'
|
||||
import type { DisplayScene, FeedbackFunc, IChatItem } from './type'
|
||||
import { TryToAskIcon, stopIcon } from './icon-component'
|
||||
import Answer from './answer'
|
||||
import Question from './question'
|
||||
|
|
@ -24,10 +24,13 @@ import ChatImageUploader from '@/app/components/base/image-uploader/chat-image-u
|
|||
import ImageList from '@/app/components/base/image-uploader/image-list'
|
||||
import { TransferMethod, type VisionFile, type VisionSettings } from '@/types/app'
|
||||
import { useClipboardUploader, useDraggableUploader, useImageFiles } from '@/app/components/base/image-uploader/hooks'
|
||||
import type { Annotation } from '@/models/log'
|
||||
|
||||
export type IChatProps = {
|
||||
appId?: string
|
||||
configElem?: React.ReactNode
|
||||
chatList: IChatItem[]
|
||||
onChatListChange?: (chatList: IChatItem[]) => void
|
||||
controlChatUpdateAllConversation?: number
|
||||
/**
|
||||
* Whether to display the editing area and rating status
|
||||
|
|
@ -39,7 +42,6 @@ export type IChatProps = {
|
|||
isHideFeedbackEdit?: boolean
|
||||
isHideSendInput?: boolean
|
||||
onFeedback?: FeedbackFunc
|
||||
onSubmitAnnotation?: SubmitAnnotationFunc
|
||||
checkCanSend?: () => boolean
|
||||
onSend?: (message: string, files: VisionFile[]) => void
|
||||
displayScene?: DisplayScene
|
||||
|
|
@ -59,6 +61,7 @@ export type IChatProps = {
|
|||
isShowCitationHitInfo?: boolean
|
||||
isShowPromptLog?: boolean
|
||||
visionConfig?: VisionSettings
|
||||
supportAnnotation?: boolean
|
||||
}
|
||||
|
||||
const Chat: FC<IChatProps> = ({
|
||||
|
|
@ -69,7 +72,6 @@ const Chat: FC<IChatProps> = ({
|
|||
isHideFeedbackEdit = false,
|
||||
isHideSendInput = false,
|
||||
onFeedback,
|
||||
onSubmitAnnotation,
|
||||
checkCanSend,
|
||||
onSend = () => { },
|
||||
displayScene,
|
||||
|
|
@ -89,6 +91,9 @@ const Chat: FC<IChatProps> = ({
|
|||
isShowCitationHitInfo,
|
||||
isShowPromptLog,
|
||||
visionConfig,
|
||||
appId,
|
||||
supportAnnotation,
|
||||
onChatListChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
|
|
@ -190,7 +195,7 @@ const Chat: FC<IChatProps> = ({
|
|||
{isShowConfigElem && (configElem || null)}
|
||||
{/* Chat List */}
|
||||
<div className={cn((isShowConfigElem && configElem) ? 'h-0' : 'h-full', 'space-y-[30px]')}>
|
||||
{chatList.map((item) => {
|
||||
{chatList.map((item, index) => {
|
||||
if (item.isAnswer) {
|
||||
const isLast = item.id === chatList[chatList.length - 1].id
|
||||
const thoughts = item.agent_thoughts?.filter(item => item.thought !== '[DONE]')
|
||||
|
|
@ -202,7 +207,6 @@ const Chat: FC<IChatProps> = ({
|
|||
feedbackDisabled={feedbackDisabled}
|
||||
isHideFeedbackEdit={isHideFeedbackEdit}
|
||||
onFeedback={onFeedback}
|
||||
onSubmitAnnotation={onSubmitAnnotation}
|
||||
displayScene={displayScene ?? 'web'}
|
||||
isResponsing={isResponsing && isLast}
|
||||
answerIcon={answerIcon}
|
||||
|
|
@ -212,6 +216,72 @@ const Chat: FC<IChatProps> = ({
|
|||
dataSets={dataSets}
|
||||
isShowCitation={isShowCitation}
|
||||
isShowCitationHitInfo={isShowCitationHitInfo}
|
||||
supportAnnotation={supportAnnotation}
|
||||
appId={appId}
|
||||
question={chatList[index - 1]?.content}
|
||||
onAnnotationEdited={(query, answer) => {
|
||||
onChatListChange?.(chatList.map((item, i) => {
|
||||
if (i === index - 1) {
|
||||
return {
|
||||
...item,
|
||||
content: query,
|
||||
}
|
||||
}
|
||||
if (i === index) {
|
||||
return {
|
||||
...item,
|
||||
content: answer,
|
||||
}
|
||||
}
|
||||
return item
|
||||
}))
|
||||
}}
|
||||
onAnnotationAdded={(annotationId, authorName, query, answer) => {
|
||||
onChatListChange?.(chatList.map((item, i) => {
|
||||
if (i === index - 1) {
|
||||
return {
|
||||
...item,
|
||||
content: query,
|
||||
}
|
||||
}
|
||||
if (i === index) {
|
||||
const answerItem = {
|
||||
...item,
|
||||
content: item.content,
|
||||
annotation: {
|
||||
id: annotationId,
|
||||
authorName,
|
||||
logAnnotation: {
|
||||
content: answer,
|
||||
account: {
|
||||
id: '',
|
||||
name: authorName,
|
||||
email: '',
|
||||
},
|
||||
},
|
||||
} as Annotation,
|
||||
}
|
||||
return answerItem
|
||||
}
|
||||
return item
|
||||
}))
|
||||
}}
|
||||
onAnnotationRemoved={() => {
|
||||
onChatListChange?.(chatList.map((item, i) => {
|
||||
if (i === index) {
|
||||
return {
|
||||
...item,
|
||||
content: item.content,
|
||||
annotation: {
|
||||
...(item.annotation || {}),
|
||||
id: '',
|
||||
} as Annotation,
|
||||
}
|
||||
}
|
||||
return item
|
||||
}))
|
||||
}}
|
||||
|
||||
/>
|
||||
}
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@
|
|||
background: url(./icons/answer.svg) no-repeat;
|
||||
}
|
||||
|
||||
.copyBtn {
|
||||
.copyBtn,
|
||||
.annotationBtn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
|
@ -63,10 +64,15 @@
|
|||
max-width: 100%;
|
||||
}
|
||||
|
||||
.answerWrap:hover .copyBtn {
|
||||
.answerWrap:hover .copyBtn,
|
||||
.answerWrap:hover .annotationBtn {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.answerWrap:hover .hasAnnotationBtn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.answerWrap .itemOperation {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,3 +81,12 @@ export type MessageReplace = {
|
|||
answer: string
|
||||
conversation_id: string
|
||||
}
|
||||
|
||||
export type AnnotationReply = {
|
||||
id: string
|
||||
task_id: string
|
||||
answer: string
|
||||
conversation_id: string
|
||||
annotation_id: string
|
||||
annotation_author_name: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import SuggestedQuestionsAfterAnswerIcon from '@/app/components/app/configuratio
|
|||
import { Microphone01 } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
|
||||
import { Citations } from '@/app/components/base/icons/src/vender/solid/editor'
|
||||
import { FileSearch02 } from '@/app/components/base/icons/src/vender/solid/files'
|
||||
import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
type IConfig = {
|
||||
openingStatement: boolean
|
||||
moreLikeThis: boolean
|
||||
|
|
@ -17,6 +18,7 @@ type IConfig = {
|
|||
speechToText: boolean
|
||||
citation: boolean
|
||||
moderation: boolean
|
||||
annotation: boolean
|
||||
}
|
||||
|
||||
export type IChooseFeatureProps = {
|
||||
|
|
@ -43,7 +45,6 @@ const ChooseFeature: FC<IChooseFeatureProps> = ({
|
|||
showSpeechToTextItem,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={isShow}
|
||||
|
|
@ -126,10 +127,18 @@ const ChooseFeature: FC<IChooseFeatureProps> = ({
|
|||
value={config.moderation}
|
||||
onChange={value => onChange('moderation', value)}
|
||||
/>
|
||||
{isChatApp && (
|
||||
<FeatureItem
|
||||
icon={<MessageFast className='w-4 h-4 text-[#444CE7]' />}
|
||||
title={t('appDebug.feature.annotation.title')}
|
||||
description={t('appDebug.feature.annotation.description')}
|
||||
value={config.annotation}
|
||||
onChange={value => onChange('annotation', value)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</FeatureGroup>
|
||||
</div>
|
||||
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ function useFeature({
|
|||
setSpeechToText,
|
||||
citation,
|
||||
setCitation,
|
||||
annotation,
|
||||
setAnnotation,
|
||||
moderation,
|
||||
setModeration,
|
||||
}: {
|
||||
|
|
@ -24,6 +26,8 @@ function useFeature({
|
|||
setSpeechToText: (speechToText: boolean) => void
|
||||
citation: boolean
|
||||
setCitation: (citation: boolean) => void
|
||||
annotation: boolean
|
||||
setAnnotation: (annotation: boolean) => void
|
||||
moderation: boolean
|
||||
setModeration: (moderation: boolean) => void
|
||||
}) {
|
||||
|
|
@ -45,6 +49,7 @@ function useFeature({
|
|||
suggestedQuestionsAfterAnswer,
|
||||
speechToText,
|
||||
citation,
|
||||
annotation,
|
||||
moderation,
|
||||
}
|
||||
const handleFeatureChange = (key: string, value: boolean) => {
|
||||
|
|
@ -67,6 +72,9 @@ function useFeature({
|
|||
case 'citation':
|
||||
setCitation(value)
|
||||
break
|
||||
case 'annotation':
|
||||
setAnnotation(value)
|
||||
break
|
||||
case 'moderation':
|
||||
setModeration(value)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import ExperienceEnchanceGroup from '../features/experience-enchance-group'
|
|||
import Toolbox from '../toolbox'
|
||||
import HistoryPanel from '../config-prompt/conversation-histroy/history-panel'
|
||||
import ConfigVision from '../config-vision'
|
||||
import useAnnotationConfig from '../toolbox/annotation/use-annotation-config'
|
||||
import AddFeatureBtn from './feature/add-feature-btn'
|
||||
import ChooseFeature from './feature/choose-feature'
|
||||
import useFeature from './feature/use-feature'
|
||||
|
|
@ -18,13 +19,16 @@ import AdvancedModeWaring from '@/app/components/app/configuration/prompt-mode/a
|
|||
import ConfigContext from '@/context/debug-configuration'
|
||||
import ConfigPrompt from '@/app/components/app/configuration/config-prompt'
|
||||
import ConfigVar from '@/app/components/app/configuration/config-var'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import type { CitationConfig, ModelConfig, ModerationConfig, MoreLikeThisConfig, PromptVariable, SpeechToTextConfig, SuggestedQuestionsAfterAnswerConfig } from '@/models/debug'
|
||||
import { AppType, ModelModeType } from '@/types/app'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import ConfigParamModal from '@/app/components/app/configuration/toolbox/annotation/config-param-modal'
|
||||
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
|
||||
|
||||
const Config: FC = () => {
|
||||
const {
|
||||
appId,
|
||||
mode,
|
||||
isAdvancedMode,
|
||||
modelModeType,
|
||||
|
|
@ -45,6 +49,8 @@ const Config: FC = () => {
|
|||
setSpeechToTextConfig,
|
||||
citationConfig,
|
||||
setCitationConfig,
|
||||
annotationConfig,
|
||||
setAnnotationConfig,
|
||||
moderationConfig,
|
||||
setModerationConfig,
|
||||
} = useContext(ConfigContext)
|
||||
|
|
@ -56,7 +62,7 @@ const Config: FC = () => {
|
|||
const promptVariables = modelConfig.configs.prompt_variables
|
||||
// simple mode
|
||||
const handlePromptChange = (newTemplate: string, newVariables: PromptVariable[]) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
const newModelConfig = produce(modelConfig, (draft: ModelConfig) => {
|
||||
draft.configs.prompt_template = newTemplate
|
||||
draft.configs.prompt_variables = [...draft.configs.prompt_variables, ...newVariables]
|
||||
})
|
||||
|
|
@ -70,7 +76,7 @@ const Config: FC = () => {
|
|||
|
||||
const handlePromptVariablesNameChange = (newVariables: PromptVariable[]) => {
|
||||
setPrevPromptConfig(modelConfig.configs)
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
const newModelConfig = produce(modelConfig, (draft: ModelConfig) => {
|
||||
draft.configs.prompt_variables = newVariables
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
|
|
@ -85,31 +91,42 @@ const Config: FC = () => {
|
|||
setIntroduction,
|
||||
moreLikeThis: moreLikeThisConfig.enabled,
|
||||
setMoreLikeThis: (value) => {
|
||||
setMoreLikeThisConfig(produce(moreLikeThisConfig, (draft) => {
|
||||
setMoreLikeThisConfig(produce(moreLikeThisConfig, (draft: MoreLikeThisConfig) => {
|
||||
draft.enabled = value
|
||||
}))
|
||||
},
|
||||
suggestedQuestionsAfterAnswer: suggestedQuestionsAfterAnswerConfig.enabled,
|
||||
setSuggestedQuestionsAfterAnswer: (value) => {
|
||||
setSuggestedQuestionsAfterAnswerConfig(produce(suggestedQuestionsAfterAnswerConfig, (draft) => {
|
||||
setSuggestedQuestionsAfterAnswerConfig(produce(suggestedQuestionsAfterAnswerConfig, (draft: SuggestedQuestionsAfterAnswerConfig) => {
|
||||
draft.enabled = value
|
||||
}))
|
||||
},
|
||||
speechToText: speechToTextConfig.enabled,
|
||||
setSpeechToText: (value) => {
|
||||
setSpeechToTextConfig(produce(speechToTextConfig, (draft) => {
|
||||
setSpeechToTextConfig(produce(speechToTextConfig, (draft: SpeechToTextConfig) => {
|
||||
draft.enabled = value
|
||||
}))
|
||||
},
|
||||
citation: citationConfig.enabled,
|
||||
setCitation: (value) => {
|
||||
setCitationConfig(produce(citationConfig, (draft) => {
|
||||
setCitationConfig(produce(citationConfig, (draft: CitationConfig) => {
|
||||
draft.enabled = value
|
||||
}))
|
||||
},
|
||||
annotation: annotationConfig.enabled,
|
||||
setAnnotation: async (value) => {
|
||||
if (value) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
setIsShowAnnotationConfigInit(true)
|
||||
}
|
||||
else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
await handleDisableAnnotation(annotationConfig.embedding_model)
|
||||
}
|
||||
},
|
||||
moderation: moderationConfig.enabled,
|
||||
setModeration: (value) => {
|
||||
setModerationConfig(produce(moderationConfig, (draft) => {
|
||||
setModerationConfig(produce(moderationConfig, (draft: ModerationConfig) => {
|
||||
draft.enabled = value
|
||||
}))
|
||||
if (value && !moderationConfig.type) {
|
||||
|
|
@ -127,7 +144,7 @@ const Config: FC = () => {
|
|||
},
|
||||
onSaveCallback: setModerationConfig,
|
||||
onCancelCallback: () => {
|
||||
setModerationConfig(produce(moderationConfig, (draft) => {
|
||||
setModerationConfig(produce(moderationConfig, (draft: ModerationConfig) => {
|
||||
draft.enabled = false
|
||||
showChooseFeatureTrue()
|
||||
}))
|
||||
|
|
@ -138,8 +155,22 @@ const Config: FC = () => {
|
|||
},
|
||||
})
|
||||
|
||||
const {
|
||||
handleEnableAnnotation,
|
||||
setScore,
|
||||
handleDisableAnnotation,
|
||||
isShowAnnotationConfigInit,
|
||||
setIsShowAnnotationConfigInit,
|
||||
isShowAnnotationFullModal,
|
||||
setIsShowAnnotationFullModal,
|
||||
} = useAnnotationConfig({
|
||||
appId,
|
||||
annotationConfig,
|
||||
setAnnotationConfig,
|
||||
})
|
||||
|
||||
const hasChatConfig = isChatApp && (featureConfig.openingStatement || featureConfig.suggestedQuestionsAfterAnswer || (featureConfig.speechToText && !!speech2textDefaultModel) || featureConfig.citation)
|
||||
const hasToolbox = false
|
||||
const hasToolbox = moderationConfig.enabled || featureConfig.annotation
|
||||
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const wrapScroll = useScroll(wrapRef)
|
||||
|
|
@ -229,10 +260,36 @@ const Config: FC = () => {
|
|||
|
||||
{/* Toolbox */}
|
||||
{
|
||||
moderationConfig.enabled && (
|
||||
<Toolbox showModerationSettings />
|
||||
hasToolbox && (
|
||||
<Toolbox
|
||||
showModerationSettings={moderationConfig.enabled}
|
||||
showAnnotation={isChatApp && featureConfig.annotation}
|
||||
onEmbeddingChange={handleEnableAnnotation}
|
||||
onScoreChange={setScore}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<ConfigParamModal
|
||||
appId={appId}
|
||||
isInit
|
||||
isShow={isShowAnnotationConfigInit}
|
||||
onHide={() => {
|
||||
setIsShowAnnotationConfigInit(false)
|
||||
showChooseFeatureTrue()
|
||||
}}
|
||||
onSave={async (embeddingModel, score) => {
|
||||
await handleEnableAnnotation(embeddingModel, score)
|
||||
setIsShowAnnotationConfigInit(false)
|
||||
}}
|
||||
annotationConfig={annotationConfig}
|
||||
/>
|
||||
{isShowAnnotationFullModal && (
|
||||
<AnnotationFullModal
|
||||
show={isShowAnnotationFullModal}
|
||||
onHide={() => setIsShowAnnotationFullModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,9 +15,12 @@ import IconTypeIcon from '@/app/components/app/configuration/config-var/input-ty
|
|||
|
||||
type Option = { name: string; value: string; type: string }
|
||||
export type Props = {
|
||||
triggerClassName?: string
|
||||
className?: string
|
||||
value: string | undefined
|
||||
options: Option[]
|
||||
onChange: (value: string) => void
|
||||
notSelectedVarTip?: string | null
|
||||
}
|
||||
|
||||
const VarItem: FC<{ item: Option }> = ({ item }) => (
|
||||
|
|
@ -31,9 +34,12 @@ const VarItem: FC<{ item: Option }> = ({ item }) => (
|
|||
</div>
|
||||
)
|
||||
const VarPicker: FC<Props> = ({
|
||||
triggerClassName,
|
||||
className,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
notSelectedVarTip,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
|
@ -48,9 +54,10 @@ const VarPicker: FC<Props> = ({
|
|||
mainAxis: 8,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<PortalToFollowElemTrigger className={cn(triggerClassName)} onClick={() => setOpen(v => !v)}>
|
||||
<div className={cn(
|
||||
s.trigger,
|
||||
className,
|
||||
notSetVar ? 'bg-[#FFFCF5] border-[#FEDF89] text-[#DC6803]' : ' hover:bg-gray-50 border-gray-200 text-primary-600',
|
||||
open ? 'bg-gray-50' : 'bg-white',
|
||||
`
|
||||
|
|
@ -63,7 +70,7 @@ const VarPicker: FC<Props> = ({
|
|||
<VarItem item={currItem as Option} />
|
||||
)
|
||||
: (<div>
|
||||
{t('appDebug.feature.dataSet.queryVariable.choosePlaceholder')}
|
||||
{notSelectedVarTip || t('appDebug.feature.dataSet.queryVariable.choosePlaceholder')}
|
||||
</div>)}
|
||||
</div>
|
||||
<ChevronDownIcon className={cn(s.dropdownIcon, open && 'rotate-180 text-[#98A2B3]', 'w-3.5 h-3.5')} />
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import { IS_CE_EDITION } from '@/config'
|
|||
import { useProviderContext } from '@/context/provider-context'
|
||||
import type { Inputs } from '@/models/debug'
|
||||
import { fetchFileUploadConfig } from '@/service/common'
|
||||
|
||||
import type { Annotation as AnnotationType } from '@/models/log'
|
||||
type IDebug = {
|
||||
hasSetAPIKEY: boolean
|
||||
onSetting: () => void
|
||||
|
|
@ -67,6 +67,7 @@ const Debug: FC<IDebug> = ({
|
|||
datasetConfigs,
|
||||
externalDataToolsConfig,
|
||||
visionConfig,
|
||||
annotationConfig,
|
||||
} = useContext(ConfigContext)
|
||||
const { speech2textDefaultModel } = useProviderContext()
|
||||
const [chatList, setChatList, getChatList] = useGetState<IChatItem[]>([])
|
||||
|
|
@ -225,6 +226,7 @@ const Debug: FC<IDebug> = ({
|
|||
file_upload: {
|
||||
image: visionConfig,
|
||||
},
|
||||
annotation_reply: annotationConfig,
|
||||
}
|
||||
|
||||
if (isAdvancedMode) {
|
||||
|
|
@ -359,6 +361,26 @@ const Debug: FC<IDebug> = ({
|
|||
onMessageReplace: (messageReplace) => {
|
||||
responseItem.content = messageReplace.answer
|
||||
},
|
||||
onAnnotationReply: (annotationReply) => {
|
||||
responseItem.id = annotationReply.id
|
||||
responseItem.content = annotationReply.answer
|
||||
responseItem.annotation = ({
|
||||
id: annotationReply.annotation_id,
|
||||
authorName: annotationReply.annotation_author_name,
|
||||
} as AnnotationType)
|
||||
const newListWithAnswer = produce(
|
||||
getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
|
||||
(draft) => {
|
||||
if (!draft.find(item => item.id === questionId))
|
||||
draft.push({ ...questionItem })
|
||||
|
||||
draft.push({
|
||||
...responseItem,
|
||||
id: annotationReply.id,
|
||||
})
|
||||
})
|
||||
setChatList(newListWithAnswer)
|
||||
},
|
||||
onError() {
|
||||
setResponsingFalse()
|
||||
// role back placeholder answer
|
||||
|
|
@ -477,6 +499,13 @@ const Debug: FC<IDebug> = ({
|
|||
})
|
||||
}
|
||||
|
||||
const varList = modelConfig.configs.prompt_variables.map((item: any) => {
|
||||
return {
|
||||
label: item.key,
|
||||
value: inputs[item.key],
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="shrink-0">
|
||||
|
|
@ -531,6 +560,9 @@ const Debug: FC<IDebug> = ({
|
|||
...visionConfig,
|
||||
image_file_size_limit: fileUploadConfigResponse?.image_file_size_limit,
|
||||
}}
|
||||
supportAnnotation
|
||||
appId={appId}
|
||||
onChatListChange={setChatList}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -550,6 +582,9 @@ const Debug: FC<IDebug> = ({
|
|||
messageId={messageId}
|
||||
isError={false}
|
||||
onRetry={() => { }}
|
||||
supportAnnotation
|
||||
appId={appId}
|
||||
varList={varList}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -566,7 +601,6 @@ const Debug: FC<IDebug> = ({
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasSetAPIKEY && (<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />)}
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import OpeningStatement from './opening-statement'
|
|||
import SuggestedQuestionsAfterAnswer from './suggested-questions-after-answer'
|
||||
import SpeechToText from './speech-to-text'
|
||||
import Citation from './citation'
|
||||
|
||||
/*
|
||||
* Include
|
||||
* 1. Conversation Opener
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import s from './style.module.css'
|
|||
import useAdvancedPromptConfig from './hooks/use-advanced-prompt-config'
|
||||
import EditHistoryModal from './config-prompt/conversation-histroy/edit-modal'
|
||||
import type {
|
||||
AnnotationReplyConfig,
|
||||
CompletionParams,
|
||||
DatasetConfigs,
|
||||
Inputs,
|
||||
|
|
@ -41,7 +42,7 @@ import { useProviderContext } from '@/context/provider-context'
|
|||
import { AppType, ModelModeType, RETRIEVE_TYPE, Resolution, TransferMethod } from '@/types/app'
|
||||
import { FlipBackward } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import { PromptMode } from '@/models/debug'
|
||||
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
|
||||
import { ANNOTATION_DEFAULT, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
|
||||
import SelectDataSet from '@/app/components/app/configuration/dataset-config/select-dataset'
|
||||
import I18n from '@/context/i18n'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
|
|
@ -56,6 +57,7 @@ type PublichConfig = {
|
|||
const Configuration: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const [formattingChanged, setFormattingChanged] = useState(false)
|
||||
const { setShowAccountSettingModal } = useModalContext()
|
||||
const [hasFetchedDetail, setHasFetchedDetail] = useState(false)
|
||||
const isLoading = !hasFetchedDetail
|
||||
|
|
@ -89,11 +91,25 @@ const Configuration: FC = () => {
|
|||
const [citationConfig, setCitationConfig] = useState<MoreLikeThisConfig>({
|
||||
enabled: false,
|
||||
})
|
||||
const [annotationConfig, doSetAnnotationConfig] = useState<AnnotationReplyConfig>({
|
||||
id: '',
|
||||
enabled: false,
|
||||
score_threshold: ANNOTATION_DEFAULT.score_threshold,
|
||||
embedding_model: {
|
||||
embedding_provider_name: '',
|
||||
embedding_model_name: '',
|
||||
},
|
||||
})
|
||||
const setAnnotationConfig = (config: AnnotationReplyConfig, notSetFormatChanged?: boolean) => {
|
||||
doSetAnnotationConfig(config)
|
||||
if (!notSetFormatChanged)
|
||||
setFormattingChanged(true)
|
||||
}
|
||||
|
||||
const [moderationConfig, setModerationConfig] = useState<ModerationConfig>({
|
||||
enabled: false,
|
||||
})
|
||||
const [externalDataToolsConfig, setExternalDataToolsConfig] = useState<ExternalDataTool[]>([])
|
||||
const [formattingChanged, setFormattingChanged] = useState(false)
|
||||
const [inputs, setInputs] = useState<Inputs>({})
|
||||
const [query, setQuery] = useState('')
|
||||
const [completionParams, doSetCompletionParams] = useState<CompletionParams>({
|
||||
|
|
@ -167,7 +183,7 @@ const Configuration: FC = () => {
|
|||
|
||||
setFormattingChanged(true)
|
||||
if (data.find(item => !item.name)) { // has not loaded selected dataset
|
||||
const newSelected = produce(data, (draft) => {
|
||||
const newSelected = produce(data, (draft: any) => {
|
||||
data.forEach((item, index) => {
|
||||
if (!item.name) { // not fetched database
|
||||
const newItem = dataSets.find(i => i.id === item.id)
|
||||
|
|
@ -230,7 +246,7 @@ const Configuration: FC = () => {
|
|||
if (hasFetchedDetail && !modelModeType) {
|
||||
const mode = textGenerationModelList.find(({ model_name }) => model_name === modelConfig.model_id)?.model_mode
|
||||
if (mode) {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
const newModelConfig = produce(modelConfig, (draft: ModelConfig) => {
|
||||
draft.mode = mode
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
|
|
@ -302,7 +318,7 @@ const Configuration: FC = () => {
|
|||
await migrateToDefaultPrompt(true, ModelModeType.chat)
|
||||
}
|
||||
}
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
const newModelConfig = produce(modelConfig, (draft: ModelConfig) => {
|
||||
draft.provider = provider
|
||||
draft.model_id = modelId
|
||||
draft.mode = modeMode
|
||||
|
|
@ -369,6 +385,9 @@ const Configuration: FC = () => {
|
|||
if (modelConfig.retriever_resource)
|
||||
setCitationConfig(modelConfig.retriever_resource)
|
||||
|
||||
if (modelConfig.annotation_reply)
|
||||
setAnnotationConfig(modelConfig.annotation_reply, true)
|
||||
|
||||
if (modelConfig.sensitive_word_avoidance)
|
||||
setModerationConfig(modelConfig.sensitive_word_avoidance)
|
||||
|
||||
|
|
@ -580,6 +599,8 @@ const Configuration: FC = () => {
|
|||
setSpeechToTextConfig,
|
||||
citationConfig,
|
||||
setCitationConfig,
|
||||
annotationConfig,
|
||||
setAnnotationConfig,
|
||||
moderationConfig,
|
||||
setModerationConfig,
|
||||
externalDataToolsConfig,
|
||||
|
|
@ -628,7 +649,7 @@ const Configuration: FC = () => {
|
|||
onClick={() => setPromptMode(PromptMode.simple)}
|
||||
className='flex items-center h-6 px-2 bg-indigo-600 shadow-xs border border-gray-200 rounded-lg text-white text-xs font-semibold cursor-pointer space-x-1'
|
||||
>
|
||||
<FlipBackward className='w-3 h-3 text-white'/>
|
||||
<FlipBackward className='w-3 h-3 text-white' />
|
||||
<div className='text-xs font-semibold uppercase'>{t('appDebug.promptMode.switchBack')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { useHover } from 'ahooks'
|
||||
import cn from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MessageCheckRemove, MessageFastPlus } from '@/app/components/base/icons/src/vender/line/communication'
|
||||
import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
import { Edit04 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import RemoveAnnotationConfirmModal from '@/app/components/app/annotation/remove-annotation-confirm-modal'
|
||||
import TooltipPlus from '@/app/components/base/tooltip-plus'
|
||||
import { addAnnotation, delAnnotation } from '@/service/annotation'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
|
||||
type Props = {
|
||||
appId: string
|
||||
messageId?: string
|
||||
annotationId?: string
|
||||
className?: string
|
||||
cached: boolean
|
||||
query: string
|
||||
answer: string
|
||||
onAdded: (annotationId: string, authorName: string) => void
|
||||
onEdit: () => void
|
||||
onRemoved: () => void
|
||||
}
|
||||
|
||||
const CacheCtrlBtn: FC<Props> = ({
|
||||
className,
|
||||
cached,
|
||||
query,
|
||||
answer,
|
||||
appId,
|
||||
messageId,
|
||||
annotationId,
|
||||
onAdded,
|
||||
onEdit,
|
||||
onRemoved,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const isAnnotationFull = (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse)
|
||||
const { setShowAnnotationFullModal } = useModalContext()
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const cachedBtnRef = useRef<HTMLDivElement>(null)
|
||||
const isCachedBtnHovering = useHover(cachedBtnRef)
|
||||
const handleAdd = async () => {
|
||||
if (isAnnotationFull) {
|
||||
setShowAnnotationFullModal()
|
||||
return
|
||||
}
|
||||
const res: any = await addAnnotation(appId, {
|
||||
message_id: messageId,
|
||||
question: query,
|
||||
answer,
|
||||
})
|
||||
Toast.notify({
|
||||
message: t('common.api.actionSuccess') as string,
|
||||
type: 'success',
|
||||
})
|
||||
onAdded(res.id, res.account?.name)
|
||||
}
|
||||
|
||||
const handleRemove = async () => {
|
||||
await delAnnotation(appId, annotationId!)
|
||||
Toast.notify({
|
||||
message: t('common.api.actionSuccess') as string,
|
||||
type: 'success',
|
||||
})
|
||||
onRemoved()
|
||||
setShowModal(false)
|
||||
}
|
||||
return (
|
||||
<div className={cn(className, 'inline-block')}>
|
||||
<div className='inline-flex p-0.5 space-x-0.5 rounded-lg bg-white border border-gray-100 shadow-md text-gray-500 cursor-pointer'>
|
||||
{cached
|
||||
? (
|
||||
<div>
|
||||
<div
|
||||
ref={cachedBtnRef}
|
||||
className={cn(isCachedBtnHovering ? 'bg-[#FEF3F2] text-[#D92D20]' : 'bg-[#EEF4FF] text-[#444CE7]', 'flex p-1 space-x-1 items-center rounded-md leading-4 text-xs font-medium')}
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
{!isCachedBtnHovering
|
||||
? (
|
||||
<>
|
||||
<MessageFast className='w-4 h-4' />
|
||||
<div>{t('appDebug.feature.annotation.cached')}</div>
|
||||
</>
|
||||
)
|
||||
: <>
|
||||
<MessageCheckRemove className='w-4 h-4' />
|
||||
<div>{t('appDebug.feature.annotation.remove')}</div>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<TooltipPlus
|
||||
popupContent={t('appDebug.feature.annotation.add') as string}
|
||||
>
|
||||
<div
|
||||
className='p-1 rounded-md hover:bg-[#EEF4FF] hover:text-[#444CE7] cursor-pointer'
|
||||
onClick={handleAdd}
|
||||
>
|
||||
<MessageFastPlus className='w-4 h-4' />
|
||||
</div>
|
||||
</TooltipPlus>
|
||||
)}
|
||||
<TooltipPlus
|
||||
popupContent={t('appDebug.feature.annotation.edit') as string}
|
||||
>
|
||||
<div
|
||||
className='p-1 cursor-pointer rounded-md hover:bg-black/5'
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Edit04 className='w-4 h-4' />
|
||||
</div>
|
||||
</TooltipPlus>
|
||||
|
||||
</div>
|
||||
<RemoveAnnotationConfirmModal
|
||||
isShow={showModal}
|
||||
onHide={() => setShowModal(false)}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(CacheCtrlBtn)
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ScoreSlider from '../score-slider'
|
||||
import { Item } from './config-param'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { ModelType } from '@/app/components/header/account-setting/model-page/declarations'
|
||||
import ModelSelector from '@/app/components/header/account-setting/model-page/model-selector/portal-select'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import type { AnnotationReplyConfig } from '@/models/debug'
|
||||
import { ANNOTATION_DEFAULT } from '@/config'
|
||||
|
||||
type Props = {
|
||||
appId: string
|
||||
isShow: boolean
|
||||
onHide: () => void
|
||||
onSave: (embeddingModel: {
|
||||
embedding_provider_name: string
|
||||
embedding_model_name: string
|
||||
}, score: number) => void
|
||||
isInit?: boolean
|
||||
annotationConfig: AnnotationReplyConfig
|
||||
}
|
||||
|
||||
const ConfigParamModal: FC<Props> = ({
|
||||
isShow,
|
||||
onHide: doHide,
|
||||
onSave,
|
||||
isInit,
|
||||
annotationConfig: oldAnnotationConfig,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
embeddingsDefaultModel,
|
||||
isEmbeddingsDefaultModelValid,
|
||||
} = useProviderContext()
|
||||
const [annotationConfig, setAnnotationConfig] = useState(oldAnnotationConfig)
|
||||
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
const [embeddingModel, setEmbeddingModel] = useState(oldAnnotationConfig.embedding_model
|
||||
? {
|
||||
providerName: oldAnnotationConfig.embedding_model.embedding_provider_name,
|
||||
modelName: oldAnnotationConfig.embedding_model.embedding_model_name,
|
||||
}
|
||||
: (embeddingsDefaultModel
|
||||
? {
|
||||
providerName: embeddingsDefaultModel.model_provider.provider_name,
|
||||
modelName: embeddingsDefaultModel.model_name,
|
||||
}
|
||||
: undefined))
|
||||
const onHide = () => {
|
||||
if (!isLoading)
|
||||
doHide()
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!embeddingModel || !embeddingModel.modelName || (embeddingModel.modelName === embeddingsDefaultModel?.model_name && !isEmbeddingsDefaultModelValid)) {
|
||||
Toast.notify({
|
||||
message: t('common.modelProvider.embeddingModel.required'),
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
await onSave({
|
||||
embedding_provider_name: embeddingModel.providerName,
|
||||
embedding_model_name: embeddingModel.modelName,
|
||||
}, annotationConfig.score_threshold)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={isShow}
|
||||
onClose={onHide}
|
||||
className='!p-8 !pb-6 !mt-14 !max-w-none !w-[640px]'
|
||||
wrapperClassName='!z-50'
|
||||
>
|
||||
<div className='mb-2 text-xl font-semibold text-[#1D2939]'>
|
||||
{t(`appAnnotation.initSetup.${isInit ? 'title' : 'configTitle'}`)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Item
|
||||
title={t('appDebug.feature.annotation.scoreThreshold.title')}
|
||||
tooltip={t('appDebug.feature.annotation.scoreThreshold.description')}
|
||||
>
|
||||
<ScoreSlider
|
||||
className='mt-1'
|
||||
value={(annotationConfig.score_threshold || ANNOTATION_DEFAULT.score_threshold) * 100}
|
||||
onChange={(val) => {
|
||||
setAnnotationConfig({
|
||||
...annotationConfig,
|
||||
score_threshold: val / 100,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Item>
|
||||
|
||||
<Item
|
||||
title={t('common.modelProvider.embeddingModel.key')}
|
||||
tooltip={t('appAnnotation.embeddingModelSwitchTip')}
|
||||
>
|
||||
<div className='pt-1'>
|
||||
<ModelSelector
|
||||
widthSameToTrigger
|
||||
value={embeddingModel as any}
|
||||
modelType={ModelType.embeddings}
|
||||
onChange={(val) => {
|
||||
setEmbeddingModel({
|
||||
providerName: val.model_provider.provider_name,
|
||||
modelName: val.model_name,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Item>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 flex gap-2 justify-end'>
|
||||
<Button onClick={onHide}>{t('common.operation.cancel')}</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={handleSave}
|
||||
className='flex items-center border-[0.5px]'
|
||||
loading={isLoading}
|
||||
>
|
||||
<div></div>
|
||||
<div>{t(`appAnnotation.initSetup.${isInit ? 'confirmBtn' : 'configConfirmBtn'}`)}</div>
|
||||
</Button >
|
||||
</div >
|
||||
</Modal >
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigParamModal)
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import ConfigParamModal from './config-param-modal'
|
||||
import Panel from '@/app/components/app/configuration/base/feature-panel'
|
||||
import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
import TooltipPlus from '@/app/components/base/tooltip-plus'
|
||||
import { HelpCircle, LinkExternal02, Settings04 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import type { EmbeddingModelConfig } from '@/app/components/app/annotation/type'
|
||||
import { updateAnnotationScore } from '@/service/annotation'
|
||||
|
||||
type Props = {
|
||||
onEmbeddingChange: (embeddingModel: EmbeddingModelConfig) => void
|
||||
onScoreChange: (score: number, embeddingModel?: EmbeddingModelConfig) => void
|
||||
}
|
||||
|
||||
export const Item: FC<{ title: string; tooltip: string; children: JSX.Element }> = ({
|
||||
title,
|
||||
tooltip,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<div className='flex items-center space-x-1'>
|
||||
<div>{title}</div>
|
||||
<TooltipPlus
|
||||
popupContent={
|
||||
<div className='max-w-[200px] leading-[18px] text-[13px] font-medium text-gray-800'>{tooltip}</div>
|
||||
}
|
||||
>
|
||||
<HelpCircle className='w-3.5 h-3.5 text-gray-400' />
|
||||
</TooltipPlus>
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AnnotationReplyConfig: FC<Props> = ({
|
||||
onEmbeddingChange,
|
||||
onScoreChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const matched = pathname.match(/\/app\/([^/]+)/)
|
||||
const appId = (matched?.length && matched[1]) ? matched[1] : ''
|
||||
const {
|
||||
annotationConfig,
|
||||
} = useContext(ConfigContext)
|
||||
|
||||
const [isShowEdit, setIsShowEdit] = React.useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel
|
||||
className="mt-4"
|
||||
headerIcon={
|
||||
<MessageFast className='w-4 h-4 text-[#444CE7]' />
|
||||
}
|
||||
title={t('appDebug.feature.annotation.title')}
|
||||
headerRight={
|
||||
<div className='flex items-center'>
|
||||
<div
|
||||
className='flex items-center rounded-md h-7 px-3 space-x-1 text-gray-700 cursor-pointer hover:bg-gray-200'
|
||||
onClick={() => { setIsShowEdit(true) }}
|
||||
>
|
||||
<Settings04 className="w-[14px] h-[14px]" />
|
||||
<div className='text-xs font-medium'>
|
||||
|
||||
{t('common.operation.params')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className='ml-1 flex items-center h-7 px-3 space-x-1 leading-[18px] text-xs font-medium text-gray-700 rounded-md cursor-pointer hover:bg-gray-200'
|
||||
onClick={() => {
|
||||
router.push(`/app/${appId}/annotations`)
|
||||
}}>
|
||||
<div>{t('appDebug.feature.annotation.cacheManagement')}</div>
|
||||
<LinkExternal02 className='w-3.5 h-3.5' />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
noBodySpacing
|
||||
/>
|
||||
{isShowEdit && (
|
||||
<ConfigParamModal
|
||||
appId={appId}
|
||||
isShow
|
||||
onHide={() => {
|
||||
setIsShowEdit(false)
|
||||
}}
|
||||
onSave={async (embeddingModel, score) => {
|
||||
let isEmbeddingModelChanged = false
|
||||
if (
|
||||
embeddingModel.embedding_model_name !== annotationConfig.embedding_model.embedding_model_name
|
||||
&& embeddingModel.embedding_provider_name !== annotationConfig.embedding_model.embedding_provider_name
|
||||
) {
|
||||
await onEmbeddingChange(embeddingModel)
|
||||
isEmbeddingModelChanged = true
|
||||
}
|
||||
|
||||
if (score !== annotationConfig.score_threshold) {
|
||||
await updateAnnotationScore(appId, annotationConfig.id, score)
|
||||
if (isEmbeddingModelChanged)
|
||||
onScoreChange(score, embeddingModel)
|
||||
|
||||
else
|
||||
onScoreChange(score)
|
||||
}
|
||||
|
||||
setIsShowEdit(false)
|
||||
}}
|
||||
annotationConfig={annotationConfig}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default React.memo(AnnotationReplyConfig)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export enum PageType {
|
||||
log = 'log',
|
||||
annotation = 'annotation',
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import React, { useState } from 'react'
|
||||
import produce from 'immer'
|
||||
import type { AnnotationReplyConfig } from '@/models/debug'
|
||||
import { queryAnnotationJobStatus, updateAnnotationStatus } from '@/service/annotation'
|
||||
import type { EmbeddingModelConfig } from '@/app/components/app/annotation/type'
|
||||
import { AnnotationEnableStatus, JobStatus } from '@/app/components/app/annotation/type'
|
||||
import { sleep } from '@/utils'
|
||||
import { ANNOTATION_DEFAULT } from '@/config'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
|
||||
type Params = {
|
||||
appId: string
|
||||
annotationConfig: AnnotationReplyConfig
|
||||
setAnnotationConfig: (annotationConfig: AnnotationReplyConfig) => void
|
||||
}
|
||||
const useAnnotationConfig = ({
|
||||
appId,
|
||||
annotationConfig,
|
||||
setAnnotationConfig,
|
||||
}: Params) => {
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const isAnnotationFull = (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse)
|
||||
const [isShowAnnotationFullModal, setIsShowAnnotationFullModal] = useState(false)
|
||||
const [isShowAnnotationConfigInit, doSetIsShowAnnotationConfigInit] = React.useState(false)
|
||||
const setIsShowAnnotationConfigInit = (isShow: boolean) => {
|
||||
if (isShow) {
|
||||
if (isAnnotationFull) {
|
||||
setIsShowAnnotationFullModal(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
doSetIsShowAnnotationConfigInit(isShow)
|
||||
}
|
||||
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 handleEnableAnnotation = async (embeddingModel: EmbeddingModelConfig, score?: number) => {
|
||||
if (isAnnotationFull)
|
||||
return
|
||||
|
||||
const { job_id: jobId }: any = await updateAnnotationStatus(appId, AnnotationEnableStatus.enable, embeddingModel, score)
|
||||
await ensureJobCompleted(jobId, AnnotationEnableStatus.enable)
|
||||
setAnnotationConfig(produce(annotationConfig, (draft: AnnotationReplyConfig) => {
|
||||
draft.enabled = true
|
||||
draft.embedding_model = embeddingModel
|
||||
if (!draft.score_threshold)
|
||||
draft.score_threshold = ANNOTATION_DEFAULT.score_threshold
|
||||
}))
|
||||
}
|
||||
|
||||
const setScore = (score: number, embeddingModel?: EmbeddingModelConfig) => {
|
||||
setAnnotationConfig(produce(annotationConfig, (draft: AnnotationReplyConfig) => {
|
||||
draft.score_threshold = score
|
||||
if (embeddingModel)
|
||||
draft.embedding_model = embeddingModel
|
||||
}))
|
||||
}
|
||||
|
||||
const handleDisableAnnotation = async (embeddingModel: EmbeddingModelConfig) => {
|
||||
if (!annotationConfig.enabled)
|
||||
return
|
||||
|
||||
await updateAnnotationStatus(appId, AnnotationEnableStatus.disable, embeddingModel)
|
||||
setAnnotationConfig(produce(annotationConfig, (draft: AnnotationReplyConfig) => {
|
||||
draft.enabled = false
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
handleEnableAnnotation,
|
||||
handleDisableAnnotation,
|
||||
isShowAnnotationConfigInit,
|
||||
setIsShowAnnotationConfigInit,
|
||||
isShowAnnotationFullModal,
|
||||
setIsShowAnnotationFullModal,
|
||||
setScore,
|
||||
}
|
||||
}
|
||||
|
||||
export default useAnnotationConfig
|
||||
|
|
@ -5,12 +5,22 @@ import React from 'react'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import GroupName from '../base/group-name'
|
||||
import Moderation from './moderation'
|
||||
import Annotation from './annotation/config-param'
|
||||
import type { EmbeddingModelConfig } from '@/app/components/app/annotation/type'
|
||||
|
||||
export type ToolboxProps = {
|
||||
showModerationSettings: boolean
|
||||
showAnnotation: boolean
|
||||
onEmbeddingChange: (embeddingModel: EmbeddingModelConfig) => void
|
||||
onScoreChange: (score: number, embeddingModel?: EmbeddingModelConfig) => void
|
||||
}
|
||||
|
||||
const Toolbox: FC<ToolboxProps> = ({ showModerationSettings }) => {
|
||||
const Toolbox: FC<ToolboxProps> = ({
|
||||
showModerationSettings,
|
||||
showAnnotation,
|
||||
onEmbeddingChange,
|
||||
onScoreChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
|
|
@ -21,6 +31,14 @@ const Toolbox: FC<ToolboxProps> = ({ showModerationSettings }) => {
|
|||
<Moderation />
|
||||
)
|
||||
}
|
||||
{
|
||||
(showAnnotation || true) && (
|
||||
<Annotation
|
||||
onEmbeddingChange={onEmbeddingChange}
|
||||
onScoreChange={onScoreChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import ReactSlider from 'react-slider'
|
||||
import cn from 'classnames'
|
||||
import s from './style.module.css'
|
||||
|
||||
type ISliderProps = {
|
||||
className?: string
|
||||
value: number
|
||||
max?: number
|
||||
min?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
onChange: (value: number) => void
|
||||
}
|
||||
|
||||
const Slider: React.FC<ISliderProps> = ({ className, max, min, step, value, disabled, onChange }) => {
|
||||
return <ReactSlider
|
||||
disabled={disabled}
|
||||
value={isNaN(value) ? 0 : value}
|
||||
min={min || 0}
|
||||
max={max || 100}
|
||||
step={step || 1}
|
||||
className={cn(className, s.slider)}
|
||||
thumbClassName={cn(s['slider-thumb'], 'top-[-7px] w-2 h-[18px] bg-white border !border-black/8 rounded-[36px] shadow-md cursor-pointer')}
|
||||
trackClassName={s['slider-track']}
|
||||
onChange={onChange}
|
||||
renderThumb={(props, state) => (
|
||||
<div {...props}>
|
||||
<div className='relative w-full h-full'>
|
||||
<div className='absolute top-[-16px] left-[50%] translate-x-[-50%] leading-[18px] text-xs font-medium text-gray-900'>
|
||||
{(state.valueNow / 100).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
|
||||
export default Slider
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
.slider {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slider.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.slider-thumb:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
background-color: #528BFF;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.slider-track-1 {
|
||||
background-color: #E5E7EB;
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Slider from '@/app/components/app/configuration/toolbox/score-slider/base-slider'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
}
|
||||
|
||||
const ScoreSlider: FC<Props> = ({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className='h-[1px] mt-[14px]'>
|
||||
<Slider
|
||||
max={100}
|
||||
min={80}
|
||||
step={1}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-[10px] flex justify-between items-center leading-4 text-xs font-normal '>
|
||||
<div className='flex space-x-1 text-[#00A286]'>
|
||||
<div>0.8</div>
|
||||
<div>·</div>
|
||||
<div>{t('appDebug.feature.annotation.scoreThreshold.easyMatch')}</div>
|
||||
</div>
|
||||
<div className='flex space-x-1 text-[#0057D8]'>
|
||||
<div>1.0</div>
|
||||
<div>·</div>
|
||||
<div>{t('appDebug.feature.annotation.scoreThreshold.accurateMatch')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(ScoreSlider)
|
||||
45
web/app/components/app/log-annotation/index.tsx
Normal file
45
web/app/components/app/log-annotation/index.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Log from '@/app/components/app/log'
|
||||
import Annotation from '@/app/components/app/annotation'
|
||||
import { PageType } from '@/app/components/app/configuration/toolbox/annotation/type'
|
||||
import TabSlider from '@/app/components/base/tab-slider-plain'
|
||||
|
||||
type Props = {
|
||||
pageType: PageType
|
||||
appId: string
|
||||
}
|
||||
|
||||
const LogAnnotation: FC<Props> = ({
|
||||
pageType,
|
||||
appId,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
|
||||
const options = [
|
||||
{ value: PageType.log, text: t('appLog.title') },
|
||||
{ value: PageType.annotation, text: t('appAnnotation.title') },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className='pt-4 px-6 h-full flex flex-col'>
|
||||
<TabSlider
|
||||
className='shrink-0'
|
||||
value={pageType}
|
||||
onChange={(value) => {
|
||||
router.push(`/app/${appId}/${value === PageType.log ? 'logs' : 'annotations'}`)
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
<div className='mt-3 grow'>
|
||||
{pageType === PageType.log && (<Log appId={appId} />)}
|
||||
{pageType === PageType.annotation && (<Annotation appId={appId} />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(LogAnnotation)
|
||||
|
|
@ -15,7 +15,7 @@ import s from './style.module.css'
|
|||
import Loading from '@/app/components/base/loading'
|
||||
import { fetchChatConversations, fetchCompletionConversations } from '@/service/log'
|
||||
import { fetchAppDetail } from '@/service/apps'
|
||||
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
export type ILogsProps = {
|
||||
appId: string
|
||||
}
|
||||
|
|
@ -26,9 +26,6 @@ export type QueryParam = {
|
|||
keyword?: string
|
||||
}
|
||||
|
||||
// Custom page count is not currently supported.
|
||||
const limit = 10
|
||||
|
||||
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" />
|
||||
|
|
@ -60,7 +57,7 @@ const Logs: FC<ILogsProps> = ({ appId }) => {
|
|||
|
||||
const query = {
|
||||
page: currPage + 1,
|
||||
limit,
|
||||
limit: APP_PAGE_LIMIT,
|
||||
...(queryParams.period !== 'all'
|
||||
? {
|
||||
start: dayjs().subtract(queryParams.period as number, 'day').startOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
|
|
@ -93,11 +90,8 @@ const Logs: FC<ILogsProps> = ({ appId }) => {
|
|||
|
||||
return (
|
||||
<div className='flex flex-col h-full'>
|
||||
<div className='flex flex-col justify-center px-6 pt-4'>
|
||||
<h1 className='flex text-xl font-medium text-gray-900'>{t('appLog.title')}</h1>
|
||||
<p className='flex text-sm font-normal text-gray-500'>{t('appLog.description')}</p>
|
||||
</div>
|
||||
<div className='flex flex-col px-6 py-4 flex-1'>
|
||||
<p className='flex text-sm font-normal text-gray-500'>{t('appLog.description')}</p>
|
||||
<div className='flex flex-col py-4 flex-1'>
|
||||
<Filter appId={appId} queryParams={queryParams} setQueryParams={setQueryParams} />
|
||||
{total === undefined
|
||||
? <Loading type='app' />
|
||||
|
|
@ -106,14 +100,14 @@ const Logs: FC<ILogsProps> = ({ appId }) => {
|
|||
: <EmptyElement appUrl={`${appDetail?.site.app_base_url}/${appDetail?.mode}/${appDetail?.site.access_token}`} />
|
||||
}
|
||||
{/* Show Pagination only if the total is more than the limit */}
|
||||
{(total && total > 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 / limit)}
|
||||
totalPages={Math.ceil(total / APP_PAGE_LIMIT)}
|
||||
truncableClassName="w-8 px-0.5 text-center"
|
||||
truncableText="..."
|
||||
>
|
||||
|
|
@ -131,8 +125,8 @@ const Logs: FC<ILogsProps> = ({ appId }) => {
|
|||
/>
|
||||
</div>
|
||||
<Pagination.NextButton
|
||||
disabled={currPage === Math.ceil(total / limit) - 1}
|
||||
className={`flex items-center mr-2 text-gray-500 focus:outline-none ${currPage === Math.ceil(total / limit) - 1 ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600 dark:hover:text-gray-200'}`} >
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import ModelIcon from '@/app/components/app/configuration/config-model/model-ico
|
|||
import ModelName from '@/app/components/app/configuration/config-model/model-name'
|
||||
import ModelModeTypeLabel from '@/app/components/app/configuration/config-model/model-mode-type-label'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import TextGeneration from '@/app/components/app/text-generate/item'
|
||||
|
||||
type IConversationList = {
|
||||
logs?: ChatConversationsResponse | CompletionConversationsResponse
|
||||
|
|
@ -83,7 +84,6 @@ const getFormattedChatList = (messages: ChatMessage[]) => {
|
|||
log: item.message as any,
|
||||
message_files: item.message_files,
|
||||
})
|
||||
|
||||
newChatList.push({
|
||||
id: item.id,
|
||||
content: item.answer,
|
||||
|
|
@ -96,7 +96,26 @@ const getFormattedChatList = (messages: ChatMessage[]) => {
|
|||
tokens: item.answer_tokens + item.message_tokens,
|
||||
latency: item.provider_response_latency.toFixed(2),
|
||||
},
|
||||
annotation: item.annotation,
|
||||
annotation: (() => {
|
||||
if (item.annotation_hit_history) {
|
||||
return {
|
||||
id: item.annotation_hit_history.annotation_id,
|
||||
authorName: item.annotation_hit_history.annotation_create_account.name,
|
||||
created_at: item.annotation_hit_history.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
if (item.annotation) {
|
||||
return {
|
||||
id: '',
|
||||
authorName: '',
|
||||
logAnnotation: item.annotation,
|
||||
created_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
})(),
|
||||
})
|
||||
})
|
||||
return newChatList
|
||||
|
|
@ -253,14 +272,26 @@ function DetailPanel<T extends ChatConversationFullDetailResponse | CompletionCo
|
|||
)}
|
||||
|
||||
{!isChatMode
|
||||
? <div className="px-2.5 py-4">
|
||||
<Chat
|
||||
chatList={getFormattedChatList([detail.message])}
|
||||
isHideSendInput={true}
|
||||
onFeedback={onFeedback}
|
||||
onSubmitAnnotation={onSubmitAnnotation}
|
||||
displayScene='console'
|
||||
isShowPromptLog
|
||||
? <div className="px-6 py-4">
|
||||
<div className='flex h-[18px] items-center space-x-3'>
|
||||
<div className='leading-[18px] text-xs font-semibold text-gray-500 uppercase'>{t('appLog.table.header.output')}</div>
|
||||
<div className='grow h-[1px]' style={{
|
||||
background: 'linear-gradient(270deg, rgba(243, 244, 246, 0) 0%, rgb(243, 244, 246) 100%)',
|
||||
}}></div>
|
||||
</div>
|
||||
<TextGeneration
|
||||
className='mt-2'
|
||||
content={detail.message.answer}
|
||||
messageId={detail.message.id}
|
||||
isError={false}
|
||||
onRetry={() => { }}
|
||||
isInstalledApp={false}
|
||||
supportFeedback
|
||||
feedback={detail.message.feedbacks.find((item: any) => item.from_source === 'admin')}
|
||||
onFeedback={feedback => onFeedback(detail.message.id, feedback)}
|
||||
supportAnnotation
|
||||
appId={appDetail?.id}
|
||||
varList={varList}
|
||||
/>
|
||||
</div>
|
||||
: items.length < 8
|
||||
|
|
@ -269,9 +300,11 @@ function DetailPanel<T extends ChatConversationFullDetailResponse | CompletionCo
|
|||
chatList={items}
|
||||
isHideSendInput={true}
|
||||
onFeedback={onFeedback}
|
||||
onSubmitAnnotation={onSubmitAnnotation}
|
||||
displayScene='console'
|
||||
isShowPromptLog
|
||||
supportAnnotation
|
||||
appId={appDetail?.id}
|
||||
onChatListChange={setItems}
|
||||
/>
|
||||
</div>
|
||||
: <div
|
||||
|
|
@ -309,7 +342,6 @@ function DetailPanel<T extends ChatConversationFullDetailResponse | CompletionCo
|
|||
chatList={items}
|
||||
isHideSendInput={true}
|
||||
onFeedback={onFeedback}
|
||||
onSubmitAnnotation={onSubmitAnnotation}
|
||||
displayScene='console'
|
||||
isShowPromptLog
|
||||
/>
|
||||
|
|
@ -427,7 +459,7 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
|
|||
<Tooltip
|
||||
htmlContent={
|
||||
<span className='text-xs text-gray-500 inline-flex items-center'>
|
||||
<EditIconSolid className='mr-1' />{`${t('appLog.detail.annotationTip', { user: annotation?.account?.name })} ${dayjs.unix(annotation?.created_at || dayjs().unix()).format('MM-DD hh:mm A')}`}
|
||||
<EditIconSolid className='mr-1' />{`${t('appLog.detail.annotationTip', { user: annotation?.logAnnotation?.account?.name })} ${dayjs.unix(annotation?.created_at || dayjs().unix()).format('MM-DD hh:mm A')}`}
|
||||
</span>
|
||||
}
|
||||
className={(isHighlight && !isChatMode) ? '' : '!hidden'}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import { Bookmark } from '@/app/components/base/icons/src/vender/line/general'
|
|||
import { Stars02 } from '@/app/components/base/icons/src/vender/line/weather'
|
||||
import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import { fetchTextGenerationMessge } from '@/service/debug'
|
||||
import AnnotationCtrlBtn from '@/app/components/app/configuration/toolbox/annotation/annotation-ctrl-btn'
|
||||
import EditReplyModal from '@/app/components/app/annotation/edit-annotation-modal'
|
||||
|
||||
const MAX_DEPTH = 3
|
||||
export type IGenerationItemProps = {
|
||||
|
|
@ -41,6 +43,10 @@ export type IGenerationItemProps = {
|
|||
installedAppId?: string
|
||||
taskId?: string
|
||||
controlClearMoreLikeThis?: number
|
||||
supportFeedback?: boolean
|
||||
supportAnnotation?: boolean
|
||||
appId?: string
|
||||
varList?: { label: string; value: string | number | object }[]
|
||||
}
|
||||
|
||||
export const SimpleBtn = ({ className, isDisabled, onClick, children }: {
|
||||
|
|
@ -82,6 +88,10 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
|||
installedAppId,
|
||||
taskId,
|
||||
controlClearMoreLikeThis,
|
||||
supportFeedback,
|
||||
supportAnnotation,
|
||||
appId,
|
||||
varList,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const params = useParams()
|
||||
|
|
@ -100,6 +110,8 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
|||
setChildFeedback(childFeedback)
|
||||
}
|
||||
|
||||
const [isShowReplyModal, setIsShowReplyModal] = useState(false)
|
||||
const question = (varList && varList?.length > 0) ? varList?.map(({ label, value }) => `${label}:${value}`).join('&') : ''
|
||||
const [isQuerying, { setTrue: startQuerying, setFalse: stopQuerying }] = useBoolean(false)
|
||||
|
||||
const childProps = {
|
||||
|
|
@ -168,6 +180,57 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
|||
setModal(true)
|
||||
}
|
||||
|
||||
const ratingContent = (
|
||||
<>
|
||||
{!isError && messageId && !feedback?.rating && (
|
||||
<SimpleBtn className="!px-0">
|
||||
<>
|
||||
<div
|
||||
onClick={() => {
|
||||
onFeedback?.({
|
||||
rating: 'like',
|
||||
})
|
||||
}}
|
||||
className='flex w-6 h-6 items-center justify-center rounded-md cursor-pointer hover:bg-gray-100'>
|
||||
<HandThumbUpIcon width={16} height={16} />
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
onFeedback?.({
|
||||
rating: 'dislike',
|
||||
})
|
||||
}}
|
||||
className='flex w-6 h-6 items-center justify-center rounded-md cursor-pointer hover:bg-gray-100'>
|
||||
<HandThumbDownIcon width={16} height={16} />
|
||||
</div>
|
||||
</>
|
||||
</SimpleBtn>
|
||||
)}
|
||||
{!isError && messageId && feedback?.rating === 'like' && (
|
||||
<div
|
||||
onClick={() => {
|
||||
onFeedback?.({
|
||||
rating: null,
|
||||
})
|
||||
}}
|
||||
className='flex w-7 h-7 items-center justify-center rounded-md cursor-pointer !text-primary-600 border border-primary-200 bg-primary-100 hover:border-primary-300 hover:bg-primary-200'>
|
||||
<HandThumbUpIcon width={16} height={16} />
|
||||
</div>
|
||||
)}
|
||||
{!isError && messageId && feedback?.rating === 'dislike' && (
|
||||
<div
|
||||
onClick={() => {
|
||||
onFeedback?.({
|
||||
rating: null,
|
||||
})
|
||||
}}
|
||||
className='flex w-7 h-7 items-center justify-center rounded-md cursor-pointer !text-red-600 border border-red-200 bg-red-100 hover:border-red-300 hover:bg-red-200'>
|
||||
<HandThumbDownIcon width={16} height={16} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn(className, isTop ? `rounded-xl border ${!isError ? 'border-gray-200 bg-white' : 'border-[#FECDCA] bg-[#FEF3F2]'} ` : 'rounded-br-xl !mt-0')}
|
||||
style={isTop
|
||||
|
|
@ -196,7 +259,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
|||
{isError
|
||||
? <div className='text-gray-400 text-sm'>{t('share.generation.batchFailed.outputPlaceholder')}</div>
|
||||
: (
|
||||
<Markdown content={ content } />
|
||||
<Markdown content={content} />
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
|
@ -214,7 +277,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
|||
showModal => (
|
||||
<SimpleBtn
|
||||
isDisabled={isError || !messageId}
|
||||
className={cn(isMobile && '!px-1.5', 'space-x-1 mr-2')}
|
||||
className={cn(isMobile && '!px-1.5', 'space-x-1 mr-1')}
|
||||
onClick={() => handleOpenLogModal(showModal)}>
|
||||
<File02 className='w-3.5 h-3.5' />
|
||||
{!isMobile && <div>{t('common.operation.log')}</div>}
|
||||
|
|
@ -261,54 +324,50 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
|||
{!isMobile && <div>{t('share.generation.batchFailed.retry')}</div>}
|
||||
</SimpleBtn>}
|
||||
{!isError && messageId && <div className="mx-3 w-[1px] h-[14px] bg-gray-200"></div>}
|
||||
{!isError && messageId && !feedback?.rating && (
|
||||
<SimpleBtn className="!px-0">
|
||||
<>
|
||||
<div
|
||||
onClick={() => {
|
||||
onFeedback?.({
|
||||
rating: 'like',
|
||||
})
|
||||
}}
|
||||
className='flex w-6 h-6 items-center justify-center rounded-md cursor-pointer hover:bg-gray-100'>
|
||||
<HandThumbUpIcon width={16} height={16} />
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
onFeedback?.({
|
||||
rating: 'dislike',
|
||||
})
|
||||
}}
|
||||
className='flex w-6 h-6 items-center justify-center rounded-md cursor-pointer hover:bg-gray-100'>
|
||||
<HandThumbDownIcon width={16} height={16} />
|
||||
</div>
|
||||
</>
|
||||
</SimpleBtn>
|
||||
)}
|
||||
{!isError && messageId && feedback?.rating === 'like' && (
|
||||
<div
|
||||
onClick={() => {
|
||||
onFeedback?.({
|
||||
rating: null,
|
||||
})
|
||||
}}
|
||||
className='flex w-7 h-7 items-center justify-center rounded-md cursor-pointer !text-primary-600 border border-primary-200 bg-primary-100 hover:border-primary-300 hover:bg-primary-200'>
|
||||
<HandThumbUpIcon width={16} height={16} />
|
||||
</div>
|
||||
)}
|
||||
{!isError && messageId && feedback?.rating === 'dislike' && (
|
||||
<div
|
||||
onClick={() => {
|
||||
onFeedback?.({
|
||||
rating: null,
|
||||
})
|
||||
}}
|
||||
className='flex w-7 h-7 items-center justify-center rounded-md cursor-pointer !text-red-600 border border-red-200 bg-red-100 hover:border-red-300 hover:bg-red-200'>
|
||||
<HandThumbDownIcon width={16} height={16} />
|
||||
</div>
|
||||
)}
|
||||
{ratingContent}
|
||||
</>
|
||||
)}
|
||||
|
||||
{supportAnnotation && (
|
||||
<>
|
||||
<div className='ml-2 mr-1 h-[14px] w-[1px] bg-gray-200'></div>
|
||||
<AnnotationCtrlBtn
|
||||
appId={appId!}
|
||||
messageId={messageId!}
|
||||
className='ml-1'
|
||||
query={question}
|
||||
answer={content}
|
||||
// not support cache. So can not be cached
|
||||
cached={false}
|
||||
onAdded={() => {
|
||||
|
||||
}}
|
||||
onEdit={() => setIsShowReplyModal(true)}
|
||||
onRemoved={() => { }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<EditReplyModal
|
||||
appId={appId!}
|
||||
messageId={messageId!}
|
||||
isShow={isShowReplyModal}
|
||||
onHide={() => setIsShowReplyModal(false)}
|
||||
query={question}
|
||||
answer={content}
|
||||
onAdded={() => { }}
|
||||
onEdited={() => { }}
|
||||
createdAt={0}
|
||||
onRemove={() => { }}
|
||||
onlyEditResponse
|
||||
/>
|
||||
|
||||
{supportFeedback && (
|
||||
<div className='ml-1'>
|
||||
{ratingContent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className='text-xs text-gray-500'>{content?.length} {t('common.unit.char')}</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue