'use client' import type { FC, ReactNode } from 'react' import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import Textarea from 'rc-textarea' import { useContext } from 'use-context-selector' 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 } from './type' import { TryToAskIcon, stopIcon } from './icon-component' import Answer from './answer' import Question from './question' import TooltipPlus from '@/app/components/base/tooltip-plus' import { ToastContext } from '@/app/components/base/toast' import Button from '@/app/components/base/button' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import VoiceInput from '@/app/components/base/voice-input' import { Microphone01 } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' import { Microphone01 as Microphone01Solid } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' import { XCircle } from '@/app/components/base/icons/src/vender/solid/general' import type { DataSet } from '@/models/datasets' import ChatImageUploader from '@/app/components/base/image-uploader/chat-image-uploader' 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' import type { Emoji } from '@/app/components/tools/types' 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 */ feedbackDisabled?: boolean /** * Whether to display the input area */ isHideFeedbackEdit?: boolean isHideSendInput?: boolean onFeedback?: FeedbackFunc checkCanSend?: () => boolean query?: string onQueryChange?: (query: string) => void onSend?: (message: string, files: VisionFile[]) => void displayScene?: DisplayScene useCurrentUserAvatar?: boolean isResponsing?: boolean canStopResponsing?: boolean abortResponsing?: () => void controlClearQuery?: number controlFocus?: number isShowSuggestion?: boolean suggestionList?: string[] isShowSpeechToText?: boolean isShowTextToSpeech?: boolean isShowCitation?: boolean answerIcon?: ReactNode isShowConfigElem?: boolean dataSets?: DataSet[] isShowCitationHitInfo?: boolean isShowPromptLog?: boolean visionConfig?: VisionSettings supportAnnotation?: boolean allToolIcons?: Record } const Chat: FC = ({ configElem, chatList, query = '', onQueryChange = () => { }, feedbackDisabled = false, isHideFeedbackEdit = false, isHideSendInput = false, onFeedback, checkCanSend, onSend = () => { }, displayScene, useCurrentUserAvatar, isResponsing, canStopResponsing, abortResponsing, controlClearQuery, controlFocus, isShowSuggestion, suggestionList, isShowSpeechToText, isShowTextToSpeech, isShowCitation, answerIcon, isShowConfigElem, dataSets, isShowCitationHitInfo, isShowPromptLog, visionConfig, appId, supportAnnotation, onChatListChange, allToolIcons, }) => { const { t } = useTranslation() const { notify } = useContext(ToastContext) const { files, onUpload, onRemove, onReUpload, onImageLinkLoadError, onImageLinkLoadSuccess, onClear, } = useImageFiles() const { onPaste } = useClipboardUploader({ onUpload, visionConfig, files }) const { onDragEnter, onDragLeave, onDragOver, onDrop, isDragActive } = useDraggableUploader({ onUpload, files, visionConfig }) const isUseInputMethod = useRef(false) const handleContentChange = (e: React.ChangeEvent) => { const value = e.target.value onQueryChange(value) } const logError = (message: string) => { notify({ type: 'error', message, duration: 3000 }) } const valid = (q?: string) => { const sendQuery = q || query if (!sendQuery || sendQuery.trim() === '') { logError('Message cannot be empty') return false } return true } useEffect(() => { if (controlClearQuery) onQueryChange('') }, [controlClearQuery]) const handleSend = (q?: string) => { if (!valid(q) || (checkCanSend && !checkCanSend())) return onSend(q || query, files.filter(file => file.progress !== -1).map(fileItem => ({ type: 'image', transfer_method: fileItem.type, url: fileItem.url, upload_file_id: fileItem.fileId, }))) if (!files.find(item => item.type === TransferMethod.local_file && !item.fileId)) { if (files.length) onClear() if (!isResponsing) onQueryChange('') } } const handleKeyUp = (e: React.KeyboardEvent) => { if (e.code === 'Enter') { e.preventDefault() // prevent send message when using input method enter if (!e.shiftKey && !isUseInputMethod.current) handleSend() } } const handleKeyDown = (e: React.KeyboardEvent) => { isUseInputMethod.current = e.nativeEvent.isComposing if (e.code === 'Enter' && !e.shiftKey) { onQueryChange(query.replace(/\n$/, '')) e.preventDefault() } } const media = useBreakpoints() const isMobile = media === MediaType.mobile const sendBtn =
handleSend()}>
const suggestionListRef = useRef(null) const [hasScrollbar, setHasScrollbar] = useState(false) useLayoutEffect(() => { if (suggestionListRef.current) { const listDom = suggestionListRef.current const hasScrollbar = listDom.scrollWidth > listDom.clientWidth setHasScrollbar(hasScrollbar) } }, [suggestionList]) const [voiceInputShow, setVoiceInputShow] = useState(false) const handleVoiceInputShow = () => { (Recorder as any).getPermission().then(() => { setVoiceInputShow(true) }, () => { logError(t('common.voiceInput.notAllow')) }) } const handleQueryChangeFromAnswer = useCallback((val: string) => { onQueryChange(val) handleSend(val) }, []) const handleAnnotationEdited = useCallback((query: string, answer: string, index: number) => { onChatListChange?.(chatList.map((item, i) => { if (i === index - 1) { return { ...item, content: query, } } if (i === index) { return { ...item, content: answer, annotation: { ...item.annotation, logAnnotation: undefined, } as any, } } return item })) }, []) const handleAnnotationAdded = useCallback((annotationId: string, authorName: string, query: string, answer: string, index: number) => { 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 })) }, []) const handleAnnotationRemoved = useCallback((index: number) => { onChatListChange?.(chatList.map((item, i) => { if (i === index) { return { ...item, content: item.content, annotation: { ...(item.annotation || {}), id: '', } as Annotation, } } return item })) }, []) return (
{isShowConfigElem && (configElem || null)} {/* Chat List */}
{chatList.map((item, index) => { if (item.isAnswer) { const isLast = item.id === chatList[chatList.length - 1].id const citation = item.citation return } return ( ) })}
{ !isHideSendInput && (
{/* Thinking is sync and can not be stopped */} {(isResponsing && canStopResponsing && ((!!chatList[chatList.length - 1]?.content) || (chatList[chatList.length - 1]?.agent_thoughts && chatList[chatList.length - 1].agent_thoughts!.length > 0))) && (
)} { isShowSuggestion && (
{TryToAskIcon} {t('appDebug.feature.suggestedQuestionsAfterAnswer.tryToAsk')}
{/* has scrollbar would hide part of first item */}
{suggestionList?.map((item, index) => (
))}
) }
{ visionConfig?.enabled && ( <>
= visionConfig.number_limits} />
) }