|
import type { |
|
FC, |
|
ReactNode, |
|
} from 'react' |
|
import { |
|
memo, |
|
useCallback, |
|
useEffect, |
|
useRef, |
|
useState, |
|
} from 'react' |
|
import { useTranslation } from 'react-i18next' |
|
import { debounce } from 'lodash-es' |
|
import { useShallow } from 'zustand/react/shallow' |
|
import type { |
|
ChatConfig, |
|
ChatItem, |
|
Feedback, |
|
OnRegenerate, |
|
OnSend, |
|
} from '../types' |
|
import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context' |
|
import Question from './question' |
|
import Answer from './answer' |
|
import ChatInputArea from './chat-input-area' |
|
import TryToAsk from './try-to-ask' |
|
import { ChatContextProvider } from './context' |
|
import type { InputForm } from './type' |
|
import cn from '@/utils/classnames' |
|
import type { Emoji } from '@/app/components/tools/types' |
|
import Button from '@/app/components/base/button' |
|
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' |
|
import AgentLogModal from '@/app/components/base/agent-log-modal' |
|
import PromptLogModal from '@/app/components/base/prompt-log-modal' |
|
import { useStore as useAppStore } from '@/app/components/app/store' |
|
import type { AppData } from '@/models/share' |
|
|
|
export type ChatProps = { |
|
appData?: AppData |
|
chatList: ChatItem[] |
|
config?: ChatConfig |
|
isResponding?: boolean |
|
noStopResponding?: boolean |
|
onStopResponding?: () => void |
|
noChatInput?: boolean |
|
onSend?: OnSend |
|
inputs?: Record<string, any> |
|
inputsForm?: InputForm[] |
|
onRegenerate?: OnRegenerate |
|
chatContainerClassName?: string |
|
chatContainerInnerClassName?: string |
|
chatFooterClassName?: string |
|
chatFooterInnerClassName?: string |
|
suggestedQuestions?: string[] |
|
showPromptLog?: boolean |
|
questionIcon?: ReactNode |
|
answerIcon?: ReactNode |
|
allToolIcons?: Record<string, string | Emoji> |
|
onAnnotationEdited?: (question: string, answer: string, index: number) => void |
|
onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void |
|
onAnnotationRemoved?: (index: number) => void |
|
chatNode?: ReactNode |
|
onFeedback?: (messageId: string, feedback: Feedback) => void |
|
chatAnswerContainerInner?: string |
|
hideProcessDetail?: boolean |
|
hideLogModal?: boolean |
|
themeBuilder?: ThemeBuilder |
|
switchSibling?: (siblingMessageId: string) => void |
|
showFeatureBar?: boolean |
|
showFileUpload?: boolean |
|
onFeatureBarClick?: (state: boolean) => void |
|
noSpacing?: boolean |
|
} |
|
|
|
const Chat: FC<ChatProps> = ({ |
|
appData, |
|
config, |
|
onSend, |
|
inputs, |
|
inputsForm, |
|
onRegenerate, |
|
chatList, |
|
isResponding, |
|
noStopResponding, |
|
onStopResponding, |
|
noChatInput, |
|
chatContainerClassName, |
|
chatContainerInnerClassName, |
|
chatFooterClassName, |
|
chatFooterInnerClassName, |
|
suggestedQuestions, |
|
showPromptLog, |
|
questionIcon, |
|
answerIcon, |
|
onAnnotationAdded, |
|
onAnnotationEdited, |
|
onAnnotationRemoved, |
|
chatNode, |
|
onFeedback, |
|
chatAnswerContainerInner, |
|
hideProcessDetail, |
|
hideLogModal, |
|
themeBuilder, |
|
switchSibling, |
|
showFeatureBar, |
|
showFileUpload, |
|
onFeatureBarClick, |
|
noSpacing, |
|
}) => { |
|
const { t } = useTranslation() |
|
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({ |
|
currentLogItem: state.currentLogItem, |
|
setCurrentLogItem: state.setCurrentLogItem, |
|
showPromptLogModal: state.showPromptLogModal, |
|
setShowPromptLogModal: state.setShowPromptLogModal, |
|
showAgentLogModal: state.showAgentLogModal, |
|
setShowAgentLogModal: state.setShowAgentLogModal, |
|
}))) |
|
const [width, setWidth] = useState(0) |
|
const chatContainerRef = useRef<HTMLDivElement>(null) |
|
const chatContainerInnerRef = useRef<HTMLDivElement>(null) |
|
const chatFooterRef = useRef<HTMLDivElement>(null) |
|
const chatFooterInnerRef = useRef<HTMLDivElement>(null) |
|
const userScrolledRef = useRef(false) |
|
|
|
const handleScrollToBottom = useCallback(() => { |
|
if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current) |
|
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight |
|
}, [chatList.length]) |
|
|
|
const handleWindowResize = useCallback(() => { |
|
if (chatContainerRef.current) |
|
setWidth(document.body.clientWidth - (chatContainerRef.current?.clientWidth + 16) - 8) |
|
|
|
if (chatContainerRef.current && chatFooterRef.current) |
|
chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px` |
|
|
|
if (chatContainerInnerRef.current && chatFooterInnerRef.current) |
|
chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px` |
|
}, []) |
|
|
|
useEffect(() => { |
|
handleScrollToBottom() |
|
handleWindowResize() |
|
}, [handleScrollToBottom, handleWindowResize]) |
|
|
|
useEffect(() => { |
|
if (chatContainerRef.current) { |
|
requestAnimationFrame(() => { |
|
handleScrollToBottom() |
|
handleWindowResize() |
|
}) |
|
} |
|
}) |
|
|
|
useEffect(() => { |
|
window.addEventListener('resize', debounce(handleWindowResize)) |
|
return () => window.removeEventListener('resize', handleWindowResize) |
|
}, [handleWindowResize]) |
|
|
|
useEffect(() => { |
|
if (chatFooterRef.current && chatContainerRef.current) { |
|
const resizeObserver = new ResizeObserver((entries) => { |
|
for (const entry of entries) { |
|
const { blockSize } = entry.borderBoxSize[0] |
|
|
|
chatContainerRef.current!.style.paddingBottom = `${blockSize}px` |
|
handleScrollToBottom() |
|
} |
|
}) |
|
|
|
resizeObserver.observe(chatFooterRef.current) |
|
|
|
return () => { |
|
resizeObserver.disconnect() |
|
} |
|
} |
|
}, [handleScrollToBottom]) |
|
|
|
useEffect(() => { |
|
const chatContainer = chatContainerRef.current |
|
if (chatContainer) { |
|
const setUserScrolled = () => { |
|
if (chatContainer) |
|
userScrolledRef.current = chatContainer.scrollHeight - chatContainer.scrollTop >= chatContainer.clientHeight + 300 |
|
} |
|
chatContainer.addEventListener('scroll', setUserScrolled) |
|
return () => chatContainer.removeEventListener('scroll', setUserScrolled) |
|
} |
|
}, []) |
|
|
|
const hasTryToAsk = config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend |
|
|
|
return ( |
|
<ChatContextProvider |
|
config={config} |
|
chatList={chatList} |
|
isResponding={isResponding} |
|
showPromptLog={showPromptLog} |
|
questionIcon={questionIcon} |
|
answerIcon={answerIcon} |
|
onSend={onSend} |
|
onRegenerate={onRegenerate} |
|
onAnnotationAdded={onAnnotationAdded} |
|
onAnnotationEdited={onAnnotationEdited} |
|
onAnnotationRemoved={onAnnotationRemoved} |
|
onFeedback={onFeedback} |
|
> |
|
<div className='relative h-full'> |
|
<div |
|
ref={chatContainerRef} |
|
className={cn('relative h-full overflow-y-auto overflow-x-hidden', chatContainerClassName)} |
|
> |
|
{chatNode} |
|
<div |
|
ref={chatContainerInnerRef} |
|
className={cn('w-full', !noSpacing && 'px-8', chatContainerInnerClassName)} |
|
> |
|
{ |
|
chatList.map((item, index) => { |
|
if (item.isAnswer) { |
|
const isLast = item.id === chatList[chatList.length - 1]?.id |
|
return ( |
|
<Answer |
|
appData={appData} |
|
key={item.id} |
|
item={item} |
|
question={chatList[index - 1]?.content} |
|
index={index} |
|
config={config} |
|
answerIcon={answerIcon} |
|
responding={isLast && isResponding} |
|
showPromptLog={showPromptLog} |
|
chatAnswerContainerInner={chatAnswerContainerInner} |
|
hideProcessDetail={hideProcessDetail} |
|
noChatInput={noChatInput} |
|
switchSibling={switchSibling} |
|
/> |
|
) |
|
} |
|
return ( |
|
<Question |
|
key={item.id} |
|
item={item} |
|
questionIcon={questionIcon} |
|
theme={themeBuilder?.theme} |
|
/> |
|
) |
|
}) |
|
} |
|
</div> |
|
</div> |
|
<div |
|
className={`absolute bottom-0 ${(hasTryToAsk || !noChatInput || !noStopResponding) && chatFooterClassName}`} |
|
ref={chatFooterRef} |
|
style={{ |
|
background: 'linear-gradient(0deg, #F9FAFB 40%, rgba(255, 255, 255, 0.00) 100%)', |
|
}} |
|
> |
|
<div |
|
ref={chatFooterInnerRef} |
|
className={cn('relative', chatFooterInnerClassName)} |
|
> |
|
{ |
|
!noStopResponding && isResponding && ( |
|
<div className='flex justify-center mb-2'> |
|
<Button onClick={onStopResponding}> |
|
<StopCircle className='mr-[5px] w-3.5 h-3.5 text-gray-500' /> |
|
<span className='text-xs text-gray-500 font-normal'>{t('appDebug.operation.stopResponding')}</span> |
|
</Button> |
|
</div> |
|
) |
|
} |
|
{ |
|
hasTryToAsk && ( |
|
<TryToAsk |
|
suggestedQuestions={suggestedQuestions} |
|
onSend={onSend} |
|
/> |
|
) |
|
} |
|
{ |
|
!noChatInput && ( |
|
<ChatInputArea |
|
showFeatureBar={showFeatureBar} |
|
showFileUpload={showFileUpload} |
|
featureBarDisabled={isResponding} |
|
onFeatureBarClick={onFeatureBarClick} |
|
visionConfig={config?.file_upload} |
|
speechToTextConfig={config?.speech_to_text} |
|
onSend={onSend} |
|
inputs={inputs} |
|
inputsForm={inputsForm} |
|
theme={themeBuilder?.theme} |
|
/> |
|
) |
|
} |
|
</div> |
|
</div> |
|
{showPromptLogModal && !hideLogModal && ( |
|
<PromptLogModal |
|
width={width} |
|
currentLogItem={currentLogItem} |
|
onCancel={() => { |
|
setCurrentLogItem() |
|
setShowPromptLogModal(false) |
|
}} |
|
/> |
|
)} |
|
{showAgentLogModal && !hideLogModal && ( |
|
<AgentLogModal |
|
width={width} |
|
currentLogItem={currentLogItem} |
|
onCancel={() => { |
|
setCurrentLogItem() |
|
setShowAgentLogModal(false) |
|
}} |
|
/> |
|
)} |
|
</div> |
|
</ChatContextProvider> |
|
) |
|
} |
|
|
|
export default memo(Chat) |
|
|