mirror of
https://github.com/langgenius/webapp-conversation.git
synced 2025-12-08 17:32:27 +08:00
243 lines
8.1 KiB
TypeScript
243 lines
8.1 KiB
TypeScript
'use client'
|
|
import type { FC } from 'react'
|
|
import React, { useEffect, useRef } from 'react'
|
|
import cn from 'classnames'
|
|
import { useTranslation } from 'react-i18next'
|
|
import Textarea from 'rc-textarea'
|
|
import s from './style.module.css'
|
|
import Answer from './answer'
|
|
import Question from './question'
|
|
import type { FeedbackFunc } from './type'
|
|
import type { ChatItem, VisionFile, VisionSettings } from '@/types/app'
|
|
import { TransferMethod } from '@/types/app'
|
|
import Tooltip from '@/app/components/base/tooltip'
|
|
import Toast from '@/app/components/base/toast'
|
|
import ChatImageUploader from '@/app/components/base/image-uploader/chat-image-uploader'
|
|
import ImageList from '@/app/components/base/image-uploader/image-list'
|
|
import { useImageFiles } from '@/app/components/base/image-uploader/hooks'
|
|
import FileUploaderInAttachmentWrapper from '@/app/components/base/file-uploader-in-attachment'
|
|
import type { FileEntity, FileUpload } from '@/app/components/base/file-uploader-in-attachment/types'
|
|
import { getProcessedFiles } from '@/app/components/base/file-uploader-in-attachment/utils'
|
|
|
|
export interface IChatProps {
|
|
chatList: ChatItem[]
|
|
/**
|
|
* Whether to display the editing area and rating status
|
|
*/
|
|
feedbackDisabled?: boolean
|
|
/**
|
|
* Whether to display the input area
|
|
*/
|
|
isHideSendInput?: boolean
|
|
onFeedback?: FeedbackFunc
|
|
checkCanSend?: () => boolean
|
|
onSend?: (message: string, files: VisionFile[]) => void
|
|
useCurrentUserAvatar?: boolean
|
|
isResponding?: boolean
|
|
controlClearQuery?: number
|
|
visionConfig?: VisionSettings
|
|
fileConfig?: FileUpload
|
|
}
|
|
|
|
const Chat: FC<IChatProps> = ({
|
|
chatList,
|
|
feedbackDisabled = false,
|
|
isHideSendInput = false,
|
|
onFeedback,
|
|
checkCanSend,
|
|
onSend = () => { },
|
|
useCurrentUserAvatar,
|
|
isResponding,
|
|
controlClearQuery,
|
|
visionConfig,
|
|
fileConfig,
|
|
}) => {
|
|
const { t } = useTranslation()
|
|
const { notify } = Toast
|
|
const isUseInputMethod = useRef(false)
|
|
|
|
const [query, setQuery] = React.useState('')
|
|
const queryRef = useRef('')
|
|
|
|
const handleContentChange = (e: any) => {
|
|
const value = e.target.value
|
|
setQuery(value)
|
|
queryRef.current = value
|
|
}
|
|
|
|
const logError = (message: string) => {
|
|
notify({ type: 'error', message, duration: 3000 })
|
|
}
|
|
|
|
const valid = () => {
|
|
const query = queryRef.current
|
|
if (!query || query.trim() === '') {
|
|
logError(t('app.errorMessage.valueOfVarRequired'))
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (controlClearQuery) {
|
|
setQuery('')
|
|
queryRef.current = ''
|
|
}
|
|
}, [controlClearQuery])
|
|
const {
|
|
files,
|
|
onUpload,
|
|
onRemove,
|
|
onReUpload,
|
|
onImageLinkLoadError,
|
|
onImageLinkLoadSuccess,
|
|
onClear,
|
|
} = useImageFiles()
|
|
|
|
const [attachmentFiles, setAttachmentFiles] = React.useState<FileEntity[]>([])
|
|
|
|
const handleSend = () => {
|
|
if (!valid() || (checkCanSend && !checkCanSend())) { return }
|
|
const imageFiles: VisionFile[] = files.filter(file => file.progress !== -1).map(fileItem => ({
|
|
type: 'image',
|
|
transfer_method: fileItem.type,
|
|
url: fileItem.url,
|
|
upload_file_id: fileItem.fileId,
|
|
}))
|
|
const docAndOtherFiles: VisionFile[] = getProcessedFiles(attachmentFiles)
|
|
const combinedFiles: VisionFile[] = [...imageFiles, ...docAndOtherFiles]
|
|
onSend(queryRef.current, combinedFiles)
|
|
if (!files.find(item => item.type === TransferMethod.local_file && !item.fileId)) {
|
|
if (files.length) { onClear() }
|
|
if (!isResponding) {
|
|
setQuery('')
|
|
queryRef.current = ''
|
|
}
|
|
}
|
|
if (!attachmentFiles.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) { setAttachmentFiles([]) }
|
|
}
|
|
|
|
const handleKeyUp = (e: any) => {
|
|
if (e.code === 'Enter') {
|
|
e.preventDefault()
|
|
// prevent send message when using input method enter
|
|
if (!e.shiftKey && !isUseInputMethod.current) { handleSend() }
|
|
}
|
|
}
|
|
|
|
const handleKeyDown = (e: any) => {
|
|
isUseInputMethod.current = e.nativeEvent.isComposing
|
|
if (e.code === 'Enter' && !e.shiftKey) {
|
|
const result = query.replace(/\n$/, '')
|
|
setQuery(result)
|
|
queryRef.current = result
|
|
e.preventDefault()
|
|
}
|
|
}
|
|
|
|
const suggestionClick = (suggestion: string) => {
|
|
setQuery(suggestion)
|
|
queryRef.current = suggestion
|
|
handleSend()
|
|
}
|
|
|
|
return (
|
|
<div className={cn(!feedbackDisabled && 'px-3.5', 'h-full')}>
|
|
{/* Chat List */}
|
|
<div className="h-full space-y-[30px]">
|
|
{chatList.map((item) => {
|
|
if (item.isAnswer) {
|
|
const isLast = item.id === chatList[chatList.length - 1].id
|
|
return <Answer
|
|
key={item.id}
|
|
item={item}
|
|
feedbackDisabled={feedbackDisabled}
|
|
onFeedback={onFeedback}
|
|
isResponding={isResponding && isLast}
|
|
suggestionClick={suggestionClick}
|
|
/>
|
|
}
|
|
return (
|
|
<Question
|
|
key={item.id}
|
|
id={item.id}
|
|
content={item.content}
|
|
useCurrentUserAvatar={useCurrentUserAvatar}
|
|
imgSrcs={(item.message_files && item.message_files?.length > 0) ? item.message_files.map(item => item.url) : []}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
{
|
|
!isHideSendInput && (
|
|
<div className='fixed z-10 bottom-0 left-1/2 transform -translate-x-1/2 pc:ml-[122px] tablet:ml-[96px] mobile:ml-0 pc:w-[794px] tablet:w-[794px] max-w-full mobile:w-full px-3.5'>
|
|
<div className='p-[5.5px] max-h-[150px] bg-white border-[1.5px] border-gray-200 rounded-xl overflow-y-auto'>
|
|
{
|
|
visionConfig?.enabled && (
|
|
<>
|
|
<div className='absolute bottom-2 left-2 flex items-center'>
|
|
<ChatImageUploader
|
|
settings={visionConfig}
|
|
onUpload={onUpload}
|
|
disabled={files.length >= visionConfig.number_limits}
|
|
/>
|
|
<div className='mx-1 w-[1px] h-4 bg-black/5' />
|
|
</div>
|
|
<div className='pl-[52px]'>
|
|
<ImageList
|
|
list={files}
|
|
onRemove={onRemove}
|
|
onReUpload={onReUpload}
|
|
onImageLinkLoadSuccess={onImageLinkLoadSuccess}
|
|
onImageLinkLoadError={onImageLinkLoadError}
|
|
/>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
{
|
|
fileConfig?.enabled && (
|
|
<div className={`${visionConfig?.enabled ? 'pl-[52px]' : ''} mb-1`}>
|
|
<FileUploaderInAttachmentWrapper
|
|
fileConfig={fileConfig}
|
|
value={attachmentFiles}
|
|
onChange={setAttachmentFiles}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
<Textarea
|
|
className={`
|
|
block w-full px-2 pr-[118px] py-[7px] leading-5 max-h-none text-base text-gray-700 outline-none appearance-none resize-none
|
|
${visionConfig?.enabled && 'pl-12'}
|
|
`}
|
|
value={query}
|
|
onChange={handleContentChange}
|
|
onKeyUp={handleKeyUp}
|
|
onKeyDown={handleKeyDown}
|
|
autoSize
|
|
/>
|
|
<div className="absolute bottom-2 right-6 flex items-center h-8">
|
|
<div className={`${s.count} mr-3 h-5 leading-5 text-sm bg-gray-50 text-gray-500 px-2 rounded`}>{query.trim().length}</div>
|
|
<Tooltip
|
|
selector='send-tip'
|
|
htmlContent={
|
|
<div>
|
|
<div>{t('common.operation.send')} Enter</div>
|
|
<div>{t('common.operation.lineBreak')} Shift Enter</div>
|
|
</div>
|
|
}
|
|
>
|
|
<div className={`${s.sendBtn} w-8 h-8 cursor-pointer rounded-md`} onClick={handleSend}></div>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default React.memo(Chat)
|