feat: annotation management frontend (#1764)

This commit is contained in:
Joel 2023-12-18 15:41:24 +08:00 • committed by GitHub
commit 65fd4b39ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
122 changed files with 4718 additions and 214 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,4 @@
export enum PageType {
log = 'log',
annotation = 'annotation',
}

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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