|
import { useState, useRef, useEffect, useCallback } from 'react' |
|
import { useSelector } from 'react-redux' |
|
import PropTypes from 'prop-types' |
|
import socketIOClient from 'socket.io-client' |
|
import { cloneDeep } from 'lodash' |
|
import rehypeMathjax from 'rehype-mathjax' |
|
import remarkGfm from 'remark-gfm' |
|
import remarkMath from 'remark-math' |
|
|
|
import { CircularProgress, OutlinedInput, Divider, InputAdornment, IconButton, Box, Chip } from '@mui/material' |
|
import { useTheme } from '@mui/material/styles' |
|
import { IconSend } from '@tabler/icons' |
|
|
|
|
|
import { CodeBlock } from 'ui-component/markdown/CodeBlock' |
|
import { MemoizedReactMarkdown } from 'ui-component/markdown/MemoizedReactMarkdown' |
|
import SourceDocDialog from 'ui-component/dialog/SourceDocDialog' |
|
import './ChatMessage.css' |
|
|
|
|
|
import chatmessageApi from 'api/chatmessage' |
|
import chatflowsApi from 'api/chatflows' |
|
import predictionApi from 'api/prediction' |
|
|
|
|
|
import useApi from 'hooks/useApi' |
|
|
|
|
|
import { baseURL, maxScroll } from 'store/constant' |
|
|
|
export const ChatMessage = ({ open, chatflowid, isDialog }) => { |
|
const theme = useTheme() |
|
const customization = useSelector((state) => state.customization) |
|
|
|
const ps = useRef() |
|
|
|
const [userInput, setUserInput] = useState('') |
|
const [loading, setLoading] = useState(false) |
|
const [messages, setMessages] = useState([ |
|
{ |
|
message: 'Hi there! How can I help?', |
|
type: 'apiMessage' |
|
} |
|
]) |
|
const [socketIOClientId, setSocketIOClientId] = useState('') |
|
const [isChatFlowAvailableToStream, setIsChatFlowAvailableToStream] = useState(false) |
|
const [sourceDialogOpen, setSourceDialogOpen] = useState(false) |
|
const [sourceDialogProps, setSourceDialogProps] = useState({}) |
|
|
|
const inputRef = useRef(null) |
|
const getChatmessageApi = useApi(chatmessageApi.getChatmessageFromChatflow) |
|
const getIsChatflowStreamingApi = useApi(chatflowsApi.getIsChatflowStreaming) |
|
|
|
const onSourceDialogClick = (data) => { |
|
setSourceDialogProps({ data }) |
|
setSourceDialogOpen(true) |
|
} |
|
|
|
const scrollToBottom = () => { |
|
if (ps.current) { |
|
ps.current.scrollTo({ top: maxScroll }) |
|
} |
|
} |
|
|
|
const onChange = useCallback((e) => setUserInput(e.target.value), [setUserInput]) |
|
|
|
const addChatMessage = async (message, type, sourceDocuments) => { |
|
try { |
|
const newChatMessageBody = { |
|
role: type, |
|
content: message, |
|
chatflowid: chatflowid |
|
} |
|
if (sourceDocuments) newChatMessageBody.sourceDocuments = JSON.stringify(sourceDocuments) |
|
await chatmessageApi.createNewChatmessage(chatflowid, newChatMessageBody) |
|
} catch (error) { |
|
console.error(error) |
|
} |
|
} |
|
|
|
const updateLastMessage = (text) => { |
|
setMessages((prevMessages) => { |
|
let allMessages = [...cloneDeep(prevMessages)] |
|
if (allMessages[allMessages.length - 1].type === 'userMessage') return allMessages |
|
allMessages[allMessages.length - 1].message += text |
|
return allMessages |
|
}) |
|
} |
|
|
|
const updateLastMessageSourceDocuments = (sourceDocuments) => { |
|
setMessages((prevMessages) => { |
|
let allMessages = [...cloneDeep(prevMessages)] |
|
if (allMessages[allMessages.length - 1].type === 'userMessage') return allMessages |
|
allMessages[allMessages.length - 1].sourceDocuments = sourceDocuments |
|
return allMessages |
|
}) |
|
} |
|
|
|
|
|
const handleError = (message = 'Oops! There seems to be an error. Please try again.') => { |
|
message = message.replace(`Unable to parse JSON response from chat agent.\n\n`, '') |
|
setMessages((prevMessages) => [...prevMessages, { message, type: 'apiMessage' }]) |
|
addChatMessage(message, 'apiMessage') |
|
setLoading(false) |
|
setUserInput('') |
|
setTimeout(() => { |
|
inputRef.current?.focus() |
|
}, 100) |
|
} |
|
|
|
|
|
const handleSubmit = async (e) => { |
|
e.preventDefault() |
|
|
|
if (userInput.trim() === '') { |
|
return |
|
} |
|
|
|
setLoading(true) |
|
setMessages((prevMessages) => [...prevMessages, { message: userInput, type: 'userMessage' }]) |
|
addChatMessage(userInput, 'userMessage') |
|
|
|
|
|
try { |
|
const params = { |
|
question: userInput, |
|
history: messages.filter((msg) => msg.message !== 'Hi there! How can I help?') |
|
} |
|
if (isChatFlowAvailableToStream) params.socketIOClientId = socketIOClientId |
|
|
|
const response = await predictionApi.sendMessageAndGetPrediction(chatflowid, params) |
|
|
|
if (response.data) { |
|
const data = response.data |
|
if (typeof data === 'object' && data.text && data.sourceDocuments) { |
|
if (!isChatFlowAvailableToStream) { |
|
setMessages((prevMessages) => [ |
|
...prevMessages, |
|
{ message: data.text, sourceDocuments: data.sourceDocuments, type: 'apiMessage' } |
|
]) |
|
} |
|
addChatMessage(data.text, 'apiMessage', data.sourceDocuments) |
|
} else { |
|
if (!isChatFlowAvailableToStream) { |
|
setMessages((prevMessages) => [...prevMessages, { message: data, type: 'apiMessage' }]) |
|
} |
|
addChatMessage(data, 'apiMessage') |
|
} |
|
setLoading(false) |
|
setUserInput('') |
|
setTimeout(() => { |
|
inputRef.current?.focus() |
|
scrollToBottom() |
|
}, 100) |
|
} |
|
} catch (error) { |
|
const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}` |
|
handleError(errorData) |
|
return |
|
} |
|
} |
|
|
|
|
|
const handleEnter = (e) => { |
|
if (e.key === 'Enter' && userInput) { |
|
if (!e.shiftKey && userInput) { |
|
handleSubmit(e) |
|
} |
|
} else if (e.key === 'Enter') { |
|
e.preventDefault() |
|
} |
|
} |
|
|
|
|
|
useEffect(() => { |
|
if (getChatmessageApi.data) { |
|
const loadedMessages = [] |
|
for (const message of getChatmessageApi.data) { |
|
const obj = { |
|
message: message.content, |
|
type: message.role |
|
} |
|
if (message.sourceDocuments) obj.sourceDocuments = JSON.parse(message.sourceDocuments) |
|
loadedMessages.push(obj) |
|
} |
|
setMessages((prevMessages) => [...prevMessages, ...loadedMessages]) |
|
} |
|
|
|
|
|
}, [getChatmessageApi.data]) |
|
|
|
|
|
useEffect(() => { |
|
if (getIsChatflowStreamingApi.data) { |
|
setIsChatFlowAvailableToStream(getIsChatflowStreamingApi.data?.isStreaming ?? false) |
|
} |
|
|
|
|
|
}, [getIsChatflowStreamingApi.data]) |
|
|
|
|
|
useEffect(() => { |
|
scrollToBottom() |
|
}, [messages]) |
|
|
|
useEffect(() => { |
|
if (isDialog && inputRef) { |
|
setTimeout(() => { |
|
inputRef.current?.focus() |
|
}, 100) |
|
} |
|
}, [isDialog, inputRef]) |
|
|
|
useEffect(() => { |
|
let socket |
|
if (open && chatflowid) { |
|
getChatmessageApi.request(chatflowid) |
|
getIsChatflowStreamingApi.request(chatflowid) |
|
scrollToBottom() |
|
|
|
socket = socketIOClient(baseURL) |
|
|
|
socket.on('connect', () => { |
|
setSocketIOClientId(socket.id) |
|
}) |
|
|
|
socket.on('start', () => { |
|
setMessages((prevMessages) => [...prevMessages, { message: '', type: 'apiMessage' }]) |
|
}) |
|
|
|
socket.on('sourceDocuments', updateLastMessageSourceDocuments) |
|
|
|
socket.on('token', updateLastMessage) |
|
} |
|
|
|
return () => { |
|
setUserInput('') |
|
setLoading(false) |
|
setMessages([ |
|
{ |
|
message: 'Hi there! How can I help?', |
|
type: 'apiMessage' |
|
} |
|
]) |
|
if (socket) { |
|
socket.disconnect() |
|
setSocketIOClientId('') |
|
} |
|
} |
|
|
|
|
|
}, [open, chatflowid]) |
|
|
|
return ( |
|
<> |
|
<div className={isDialog ? 'cloud-dialog' : 'cloud'}> |
|
<div ref={ps} className='messagelist'> |
|
{messages && |
|
messages.map((message, index) => { |
|
return ( |
|
// The latest message sent by the user will be animated while waiting for a response |
|
<> |
|
<Box |
|
sx={{ |
|
background: message.type === 'apiMessage' ? theme.palette.asyncSelect.main : '' |
|
}} |
|
key={index} |
|
style={{ display: 'flex' }} |
|
className={ |
|
message.type === 'userMessage' && loading && index === messages.length - 1 |
|
? customization.isDarkMode |
|
? 'usermessagewaiting-dark' |
|
: 'usermessagewaiting-light' |
|
: message.type === 'usermessagewaiting' |
|
? 'apimessage' |
|
: 'usermessage' |
|
} |
|
> |
|
{/* Display the correct icon depending on the message type */} |
|
{message.type === 'apiMessage' ? ( |
|
<img |
|
src='https://raw.githubusercontent.com/zahidkhawaja/langchain-chat-nextjs/main/public/parroticon.png' |
|
alt='AI' |
|
width='30' |
|
height='30' |
|
className='boticon' |
|
/> |
|
) : ( |
|
<img |
|
src='https://raw.githubusercontent.com/zahidkhawaja/langchain-chat-nextjs/main/public/usericon.png' |
|
alt='Me' |
|
width='30' |
|
height='30' |
|
className='usericon' |
|
/> |
|
)} |
|
<div style={{ display: 'flex', flexDirection: 'column', width: '100%' }}> |
|
<div className='markdownanswer'> |
|
{/* Messages are being rendered in Markdown format */} |
|
<MemoizedReactMarkdown |
|
remarkPlugins={[remarkGfm, remarkMath]} |
|
rehypePlugins={[rehypeMathjax]} |
|
components={{ |
|
code({ inline, className, children, ...props }) { |
|
const match = /language-(\w+)/.exec(className || '') |
|
return !inline ? ( |
|
<CodeBlock |
|
key={Math.random()} |
|
chatflowid={chatflowid} |
|
isDialog={isDialog} |
|
language={(match && match[1]) || ''} |
|
value={String(children).replace(/\n$/, '')} |
|
{...props} |
|
/> |
|
) : ( |
|
<code className={className} {...props}> |
|
{children} |
|
</code> |
|
) |
|
} |
|
}} |
|
> |
|
{message.message} |
|
</MemoizedReactMarkdown> |
|
</div> |
|
{message.sourceDocuments && ( |
|
<div style={{ display: 'block', flexDirection: 'row', width: '100%' }}> |
|
{message.sourceDocuments.map((source, index) => { |
|
return ( |
|
<Chip |
|
size='small' |
|
key={index} |
|
label={`${source.pageContent.substring(0, 15)}...`} |
|
component='a' |
|
sx={{ mr: 1, mb: 1 }} |
|
variant='outlined' |
|
clickable |
|
onClick={() => onSourceDialogClick(source)} |
|
/> |
|
) |
|
})} |
|
</div> |
|
)} |
|
</div> |
|
</Box> |
|
</> |
|
) |
|
})} |
|
</div> |
|
</div> |
|
<Divider /> |
|
<div className='center'> |
|
<div style={{ width: '100%' }}> |
|
<form style={{ width: '100%' }} onSubmit={handleSubmit}> |
|
<OutlinedInput |
|
inputRef={inputRef} |
|
// eslint-disable-next-line |
|
autoFocus |
|
sx={{ width: '100%' }} |
|
disabled={loading || !chatflowid} |
|
onKeyDown={handleEnter} |
|
id='userInput' |
|
name='userInput' |
|
placeholder={loading ? 'Waiting for response...' : 'Type your question...'} |
|
value={userInput} |
|
onChange={onChange} |
|
multiline={true} |
|
endAdornment={ |
|
<InputAdornment position='end' sx={{ padding: '15px' }}> |
|
<IconButton type='submit' disabled={loading || !chatflowid} edge='end'> |
|
{loading ? ( |
|
<div> |
|
<CircularProgress color='inherit' size={20} /> |
|
</div> |
|
) : ( |
|
// Send icon SVG in input field |
|
<IconSend |
|
color={loading || !chatflowid ? '#9e9e9e' : customization.isDarkMode ? 'white' : '#1e88e5'} |
|
/> |
|
)} |
|
</IconButton> |
|
</InputAdornment> |
|
} |
|
/> |
|
</form> |
|
</div> |
|
</div> |
|
<SourceDocDialog show={sourceDialogOpen} dialogProps={sourceDialogProps} onCancel={() => setSourceDialogOpen(false)} /> |
|
</> |
|
) |
|
} |
|
|
|
ChatMessage.propTypes = { |
|
open: PropTypes.bool, |
|
chatflowid: PropTypes.string, |
|
isDialog: PropTypes.bool |
|
} |
|
|