Feature: Collect contact information from users inside the chatbot (#1948)

* Add leads settings to chatflow configuration

* Add leads tab to chatflow configuration with options for lead capture

* Add database entity and migrations for leads

* Add endpoint for adding and fetching leads

* Show lead capture form in UI chat window when enabled

* Add view leads dialog

* Make export leads functional

* Add input for configuring message on successful lead capture

* Add migrations for adding lead email in chat message if available

* show lead email in view messages

* ui touch up

* Remove unused code and update how lead email is shown in view messages dialog

* Fix lead not getting saved

* Disable input when lead form is shown and save lead info to localstorage

* Fix lead capture form not working

* disabled lead save button until at least one form field is turned on, get rid of local storage _LEAD

* add leads API to as whitelist public endpoint

* Send leadEmail in internal chat inputs

* Fix condition for disabling input field and related buttons when lead is enabled/disabled and when lead is saved

* update leads ui

* update error message and alter table add column sqlite migration

---------

Co-authored-by: Henry <hzj94@hotmail.com>
This commit is contained in:
Ilango
2024-05-02 06:24:59 +05:30
committed by GitHub
parent adea2f0830
commit db452cd74d
31 changed files with 979 additions and 57 deletions
@@ -28,6 +28,7 @@ import useApi from '@/hooks/useApi'
import { generateExportFlowData } from '@/utils/genericHelper'
import { uiBaseURL } from '@/store/constant'
import { SET_CHATFLOW } from '@/store/actions'
import ViewLeadsDialog from '@/ui-component/dialog/ViewLeadsDialog'
// ==============================|| CANVAS HEADER ||============================== //
@@ -46,6 +47,8 @@ const CanvasHeader = ({ chatflow, handleSaveFlow, handleDeleteFlow, handleLoadFl
const [apiDialogProps, setAPIDialogProps] = useState({})
const [viewMessagesDialogOpen, setViewMessagesDialogOpen] = useState(false)
const [viewMessagesDialogProps, setViewMessagesDialogProps] = useState({})
const [viewLeadsDialogOpen, setViewLeadsDialogOpen] = useState(false)
const [viewLeadsDialogProps, setViewLeadsDialogProps] = useState({})
const [upsertHistoryDialogOpen, setUpsertHistoryDialogOpen] = useState(false)
const [upsertHistoryDialogProps, setUpsertHistoryDialogProps] = useState({})
const [chatflowConfigurationDialogOpen, setChatflowConfigurationDialogOpen] = useState(false)
@@ -65,6 +68,12 @@ const CanvasHeader = ({ chatflow, handleSaveFlow, handleDeleteFlow, handleLoadFl
chatflow: chatflow
})
setViewMessagesDialogOpen(true)
} else if (setting === 'viewLeads') {
setViewLeadsDialogProps({
title: 'View Leads',
chatflow: chatflow
})
setViewLeadsDialogOpen(true)
} else if (setting === 'viewUpsertHistory') {
setUpsertHistoryDialogProps({
title: 'View Upsert History',
@@ -402,6 +411,7 @@ const CanvasHeader = ({ chatflow, handleSaveFlow, handleDeleteFlow, handleLoadFl
dialogProps={viewMessagesDialogProps}
onCancel={() => setViewMessagesDialogOpen(false)}
/>
<ViewLeadsDialog show={viewLeadsDialogOpen} dialogProps={viewLeadsDialogProps} onCancel={() => setViewLeadsDialogOpen(false)} />
<UpsertHistoryDialog
show={upsertHistoryDialogOpen}
dialogProps={upsertHistoryDialogProps}
+240 -42
View File
@@ -47,6 +47,7 @@ import chatmessageApi from '@/api/chatmessage'
import chatflowsApi from '@/api/chatflows'
import predictionApi from '@/api/prediction'
import chatmessagefeedbackApi from '@/api/chatmessagefeedback'
import leadsApi from '@/api/lead'
// Hooks
import useApi from '@/hooks/useApi'
@@ -55,7 +56,7 @@ import useApi from '@/hooks/useApi'
import { baseURL, maxScroll } from '@/store/constant'
// Utils
import { isValidURL, removeDuplicateURL, setLocalStorageChatflow } from '@/utils/genericHelper'
import { isValidURL, removeDuplicateURL, setLocalStorageChatflow, getLocalStorageChatflow } from '@/utils/genericHelper'
const messageImageStyle = {
width: '128px',
@@ -91,10 +92,20 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
const getChatflowConfig = useApi(chatflowsApi.getSpecificChatflow)
const [starterPrompts, setStarterPrompts] = useState([])
// feedback
const [chatFeedbackStatus, setChatFeedbackStatus] = useState(false)
const [feedbackId, setFeedbackId] = useState('')
const [showFeedbackContentDialog, setShowFeedbackContentDialog] = useState(false)
// leads
const [leadsConfig, setLeadsConfig] = useState(null)
const [leadName, setLeadName] = useState('')
const [leadEmail, setLeadEmail] = useState('')
const [leadPhone, setLeadPhone] = useState('')
const [isLeadSaving, setIsLeadSaving] = useState(false)
const [isLeadSaved, setIsLeadSaved] = useState(false)
// drag & drop and file input
const fileUploadRef = useRef(null)
const [isChatFlowAvailableForUploads, setIsChatFlowAvailableForUploads] = useState(false)
@@ -414,6 +425,7 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
chatId
}
if (urls && urls.length > 0) params.uploads = urls
if (leadEmail) params.leadEmail = leadEmail
if (isChatFlowAvailableToStream) params.socketIOClientId = socketIOClientId
const response = await predictionApi.sendMessageAndGetPrediction(chatflowid, params)
@@ -573,6 +585,20 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
if (config.chatFeedback) {
setChatFeedbackStatus(config.chatFeedback.status)
}
if (config.leads) {
setLeadsConfig(config.leads)
if (config.leads.status && !getLocalStorageChatflow(chatflowid).lead) {
setMessages((prevMessages) => {
const leadCaptureMessage = {
message: '',
type: 'leadCaptureMessage'
}
return [...prevMessages, leadCaptureMessage]
})
}
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -605,6 +631,13 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
setIsRecording(false)
// leads
const savedLead = getLocalStorageChatflow(chatflowid)?.lead
if (savedLead) {
setIsLeadSaved(!!savedLead)
setLeadEmail(savedLead.email)
}
// SocketIO
socket = socketIOClient(baseURL)
@@ -731,6 +764,36 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
}
}
const handleLeadCaptureSubmit = async (event) => {
if (event) event.preventDefault()
setIsLeadSaving(true)
const body = {
chatflowid,
chatId,
name: leadName,
email: leadEmail,
phone: leadPhone
}
const result = await leadsApi.addLead(body)
if (result.data) {
const data = result.data
if (!chatId) setChatId(data.chatId)
setLocalStorageChatflow(chatflowid, data.chatId, { lead: { name: leadName, email: leadEmail, phone: leadPhone } })
setIsLeadSaved(true)
setLeadEmail(leadEmail)
setMessages((prevMessages) => {
let allMessages = [...cloneDeep(prevMessages)]
if (allMessages[allMessages.length - 1].type !== 'leadCaptureMessage') return allMessages
allMessages[allMessages.length - 1].message =
leadsConfig.successMessage || 'Thank you for submitting your contact information.'
return allMessages
})
}
setIsLeadSaving(false)
}
return (
<div onDragEnter={handleDrag}>
{isDragActive && (
@@ -763,7 +826,10 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
// 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 : ''
background:
message.type === 'apiMessage' || message.type === 'leadCaptureMessage'
? theme.palette.asyncSelect.main
: ''
}}
key={index}
style={{ display: 'flex' }}
@@ -778,14 +844,26 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
}
>
{/* Display the correct icon depending on the message type */}
{message.type === 'apiMessage' ? (
{message.type === 'apiMessage' || message.type === 'leadCaptureMessage' ? (
<img src={robotPNG} alt='AI' width='30' height='30' className='boticon' />
) : (
<img src={userPNG} alt='Me' width='30' height='30' className='usericon' />
)}
<div style={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%'
}}
>
{message.usedTools && (
<div style={{ display: 'block', flexDirection: 'row', width: '100%' }}>
<div
style={{
display: 'block',
flexDirection: 'row',
width: '100%'
}}
>
{message.usedTools.map((tool, index) => {
return (
<Chip
@@ -816,7 +894,7 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
{message.fileUploads.map((item, index) => {
return (
<>
{item.mime.startsWith('image/') ? (
{item?.mime?.startsWith('image/') ? (
<Card
key={index}
sx={{
@@ -848,36 +926,122 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
</div>
)}
<div className='markdownanswer'>
{/* Messages are being rendered in Markdown format */}
<MemoizedReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeMathjax, rehypeRaw]}
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}
{message.type === 'leadCaptureMessage' &&
!getLocalStorageChatflow(chatflowid)?.lead &&
leadsConfig.status ? (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
marginTop: 2
}}
>
<Typography sx={{ lineHeight: '1.5rem', whiteSpace: 'pre-line' }}>
{leadsConfig.title || 'Let us know where we can reach you:'}
</Typography>
<form
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
width: isDialog ? '50%' : '100%'
}}
onSubmit={handleLeadCaptureSubmit}
>
{leadsConfig.name && (
<OutlinedInput
id='leadName'
type='text'
fullWidth
placeholder='Name'
name='leadName'
value={leadName}
// eslint-disable-next-line
autoFocus={true}
onChange={(e) => setLeadName(e.target.value)}
/>
) : (
<code className={className} {...props}>
{children}
</code>
)
}
}}
>
{message.message}
</MemoizedReactMarkdown>
)}
{leadsConfig.email && (
<OutlinedInput
id='leadEmail'
type='email'
fullWidth
placeholder='Email Address'
name='leadEmail'
value={leadEmail}
onChange={(e) => setLeadEmail(e.target.value)}
/>
)}
{leadsConfig.phone && (
<OutlinedInput
id='leadPhone'
type='number'
fullWidth
placeholder='Phone Number'
name='leadPhone'
value={leadPhone}
onChange={(e) => setLeadPhone(e.target.value)}
/>
)}
<Box
sx={{
display: 'flex',
alignItems: 'center'
}}
>
<Button
variant='outlined'
fullWidth
type='submit'
sx={{ borderRadius: '20px' }}
>
{isLeadSaving ? 'Saving...' : 'Save'}
</Button>
</Box>
</form>
</Box>
) : (
<>
{/* Messages are being rendered in Markdown format */}
<MemoizedReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeMathjax, rehypeRaw]}
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.type === 'apiMessage' && message.id && chatFeedbackStatus ? (
<>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'start', gap: 1 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'start',
gap: 1
}}
>
<CopyToClipboardButton onClick={() => copyMessageToClipboard(message.message)} />
{!message.feedback ||
message.feedback.rating === '' ||
@@ -901,11 +1065,21 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
</>
) : null}
{message.fileAnnotations && (
<div style={{ display: 'block', flexDirection: 'row', width: '100%' }}>
<div
style={{
display: 'block',
flexDirection: 'row',
width: '100%'
}}
>
{message.fileAnnotations.map((fileAnnotation, index) => {
return (
<Button
sx={{ fontSize: '0.85rem', textTransform: 'none', mb: 1 }}
sx={{
fontSize: '0.85rem',
textTransform: 'none',
mb: 1
}}
key={index}
variant='outlined'
onClick={() => downloadFile(fileAnnotation)}
@@ -918,7 +1092,13 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
</div>
)}
{message.sourceDocuments && (
<div style={{ display: 'block', flexDirection: 'row', width: '100%' }}>
<div
style={{
display: 'block',
flexDirection: 'row',
width: '100%'
}}
>
{removeDuplicateURL(message).map((source, index) => {
const URL =
source.metadata && source.metadata.source
@@ -1076,7 +1256,7 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
// eslint-disable-next-line
autoFocus
sx={{ width: '100%' }}
disabled={loading || !chatflowid}
disabled={loading || !chatflowid || (leadsConfig?.status && !isLeadSaved)}
onKeyDown={handleEnter}
id='userInput'
name='userInput'
@@ -1091,11 +1271,17 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
<IconButton
onClick={handleUploadClick}
type='button'
disabled={loading || !chatflowid}
disabled={loading || !chatflowid || (leadsConfig?.status && !isLeadSaved)}
edge='start'
>
<IconPhotoPlus
color={loading || !chatflowid ? '#9e9e9e' : customization.isDarkMode ? 'white' : '#1e88e5'}
color={
loading || !chatflowid || (leadsConfig?.status && !isLeadSaved)
? '#9e9e9e'
: customization.isDarkMode
? 'white'
: '#1e88e5'
}
/>
</IconButton>
</InputAdornment>
@@ -1108,20 +1294,28 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
<IconButton
onClick={() => onMicrophonePressed()}
type='button'
disabled={loading || !chatflowid}
disabled={loading || !chatflowid || (leadsConfig?.status && !isLeadSaved)}
edge='end'
>
<IconMicrophone
className={'start-recording-button'}
color={
loading || !chatflowid ? '#9e9e9e' : customization.isDarkMode ? 'white' : '#1e88e5'
loading || !chatflowid || (leadsConfig?.status && !isLeadSaved)
? '#9e9e9e'
: customization.isDarkMode
? 'white'
: '#1e88e5'
}
/>
</IconButton>
</InputAdornment>
)}
<InputAdornment position='end' sx={{ padding: '15px' }}>
<IconButton type='submit' disabled={loading || !chatflowid} edge='end'>
<IconButton
type='submit'
disabled={loading || !chatflowid || (leadsConfig?.status && !isLeadSaved)}
edge='end'
>
{loading ? (
<div>
<CircularProgress color='inherit' size={20} />
@@ -1130,7 +1324,11 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
// Send icon SVG in input field
<IconSend
color={
loading || !chatflowid ? '#9e9e9e' : customization.isDarkMode ? 'white' : '#1e88e5'
loading || !chatflowid || (leadsConfig?.status && !isLeadSaved)
? '#9e9e9e'
: customization.isDarkMode
? 'white'
: '#1e88e5'
}
/>
)}
@@ -23,6 +23,9 @@ import useNotifier from '@/utils/useNotifier'
// Const
import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from '@/store/actions'
// Utils
import { getLocalStorageChatflow, removeLocalStorageChatHistory } from '@/utils/genericHelper'
export const ChatPopUp = ({ chatflowid }) => {
const theme = useTheme()
const { confirm } = useConfirm()
@@ -86,11 +89,10 @@ export const ChatPopUp = ({ chatflowid }) => {
if (isConfirmed) {
try {
const chatDetails = localStorage.getItem(`${chatflowid}_INTERNAL`)
if (!chatDetails) return
const objChatDetails = JSON.parse(chatDetails)
const objChatDetails = getLocalStorageChatflow(chatflowid)
if (!objChatDetails.chatId) return
await chatmessageApi.deleteChatmessage(chatflowid, { chatId: objChatDetails.chatId, chatType: 'INTERNAL' })
localStorage.removeItem(`${chatflowid}_INTERNAL`)
removeLocalStorageChatHistory(chatflowid)
resetChatDialog()
enqueueSnackbar({
message: 'Succesfully cleared all chat history',