Implement chat message feedback for UI chat window

This commit is contained in:
Ilango 2024-03-11 21:07:20 +05:30
parent ac35d5f667
commit 131eccef45
8 changed files with 330 additions and 3 deletions

View File

@ -595,7 +595,27 @@ export class App {
// Get internal chatmessages from chatflowid // Get internal chatmessages from chatflowid
this.app.get('/api/v1/internal-chatmessage/:id', async (req: Request, res: Response) => { this.app.get('/api/v1/internal-chatmessage/:id', async (req: Request, res: Response) => {
const chatmessages = await this.getChatMessage(req.params.id, chatType.INTERNAL) const sortOrder = req.query?.order as string | undefined
const chatId = req.query?.chatId as string | undefined
const memoryType = req.query?.memoryType as string | undefined
const sessionId = req.query?.sessionId as string | undefined
const messageId = req.query?.messageId as string | undefined
const startDate = req.query?.startDate as string | undefined
const endDate = req.query?.endDate as string | undefined
const feedback = req.query?.feedback as boolean | undefined
const chatmessages = await this.getChatMessage(
req.params.id,
chatType.INTERNAL,
sortOrder,
chatId,
memoryType,
sessionId,
startDate,
endDate,
messageId,
feedback
)
return res.json(chatmessages) return res.json(chatmessages)
}) })

View File

@ -1,6 +1,7 @@
import client from './client' import client from './client'
const getInternalChatmessageFromChatflow = (id) => client.get(`/internal-chatmessage/${id}`) const getInternalChatmessageFromChatflow = (id, params = {}) =>
client.get(`/internal-chatmessage/${id}`, { params: { feedback: true, ...params } })
const getAllChatmessageFromChatflow = (id, params = {}) => const getAllChatmessageFromChatflow = (id, params = {}) =>
client.get(`/chatmessage/${id}`, { params: { order: 'DESC', feedback: true, ...params } }) client.get(`/chatmessage/${id}`, { params: { order: 'DESC', feedback: true, ...params } })
const getChatmessageFromPK = (id, params = {}) => client.get(`/chatmessage/${id}`, { params: { order: 'ASC', feedback: true, ...params } }) const getChatmessageFromPK = (id, params = {}) => client.get(`/chatmessage/${id}`, { params: { order: 'ASC', feedback: true, ...params } })

View File

@ -0,0 +1,9 @@
import client from './client'
const addFeedback = (id, body) => client.post(`/feedback/${id}`, body)
const updateFeedback = (id, body) => client.put(`/feedback/${id}`, body)
export default {
addFeedback,
updateFeedback
}

View File

@ -0,0 +1,31 @@
import PropTypes from 'prop-types'
import { useSelector } from 'react-redux'
import { IconButton } from '@mui/material'
import { IconClipboard } from '@tabler/icons'
const CopyToClipboardButton = (props) => {
const customization = useSelector((state) => state.customization)
return (
<IconButton
disabled={props.isDisabled || props.isLoading}
onClick={props.onClick}
size='small'
sx={{ background: 'transparent', border: 'none' }}
title='Copy to clipboard'
>
<IconClipboard
style={{ width: '20px', height: '20px' }}
color={props.isLoading ? '#9e9e9e' : customization.isDarkMode ? 'white' : '#1e88e5'}
/>
</IconButton>
)
}
CopyToClipboardButton.propTypes = {
isDisabled: PropTypes.bool,
isLoading: PropTypes.bool,
onClick: PropTypes.func
}
export default CopyToClipboardButton

View File

@ -0,0 +1,31 @@
import PropTypes from 'prop-types'
import { useSelector } from 'react-redux'
import { IconButton } from '@mui/material'
import { IconThumbDown } from '@tabler/icons'
const ThumbsDownButton = (props) => {
const customization = useSelector((state) => state.customization)
return (
<IconButton
disabled={props.isDisabled || props.isLoading}
onClick={props.onClick}
size='small'
sx={{ background: 'transparent', border: 'none' }}
title='Thumbs Down'
>
<IconThumbDown
style={{ width: '20px', height: '20px' }}
color={props.rating === 'THUMBS_DOWN' ? '#9e9e9e' : customization.isDarkMode ? 'white' : '#1e88e5'}
/>
</IconButton>
)
}
ThumbsDownButton.propTypes = {
isDisabled: PropTypes.bool,
isLoading: PropTypes.bool,
onClick: PropTypes.func,
rating: PropTypes.string
}
export default ThumbsDownButton

View File

@ -0,0 +1,31 @@
import PropTypes from 'prop-types'
import { useSelector } from 'react-redux'
import { IconButton } from '@mui/material'
import { IconThumbUp } from '@tabler/icons'
const ThumbsUpButton = (props) => {
const customization = useSelector((state) => state.customization)
return (
<IconButton
disabled={props.isDisabled || props.isLoading}
onClick={props.onClick}
size='small'
sx={{ background: 'transparent', border: 'none' }}
title='Thumbs Up'
>
<IconThumbUp
style={{ width: '20px', height: '20px' }}
color={props.rating === 'THUMBS_UP' ? '#9e9e9e' : customization.isDarkMode ? 'white' : '#1e88e5'}
/>
</IconButton>
)
}
ThumbsUpButton.propTypes = {
isDisabled: PropTypes.bool,
isLoading: PropTypes.bool,
onClick: PropTypes.func,
rating: PropTypes.string
}
export default ThumbsUpButton

View File

@ -0,0 +1,73 @@
import { useCallback, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { useDispatch } from 'react-redux'
// material-ui
import { Button, Dialog, DialogContent, DialogTitle, DialogActions, Box, OutlinedInput } from '@mui/material'
import { useState } from 'react'
// Project import
import { StyledButton } from 'ui-component/button/StyledButton'
// store
import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from 'store/actions'
const ChatFeedbackContentDialog = ({ show, onCancel, onConfirm }) => {
const portalElement = document.getElementById('portal')
const dispatch = useDispatch()
const [feedbackContent, setFeedbackContent] = useState('')
const onChange = useCallback((e) => setFeedbackContent(e.target.value), [setFeedbackContent])
const onSave = () => {
onConfirm(feedbackContent)
}
useEffect(() => {
if (show) dispatch({ type: SHOW_CANVAS_DIALOG })
else dispatch({ type: HIDE_CANVAS_DIALOG })
return () => dispatch({ type: HIDE_CANVAS_DIALOG })
}, [show, dispatch])
const component = show ? (
<Dialog
onClose={onCancel}
open={show}
fullWidth
maxWidth='sm'
aria-labelledby='alert-dialog-title'
aria-describedby='alert-dialog-description'
>
<DialogTitle sx={{ fontSize: '1rem' }} id='alert-dialog-title'>
Provide additional feedback
</DialogTitle>
<DialogContent>
<Box sx={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<OutlinedInput
// eslint-disable-next-line
autoFocus
id='feedbackContentInput'
multiline={true}
name='feedbackContentInput'
onChange={onChange}
placeholder='What do you think of the response?'
rows={4}
value={feedbackContent}
sx={{ width: '100%' }}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onCancel}>Cancel</Button>
<StyledButton variant='contained' onClick={onSave}>
Submit Feedback
</StyledButton>
</DialogActions>
</Dialog>
) : null
return createPortal(component, portalElement)
}
export default ChatFeedbackContentDialog

View File

@ -32,9 +32,13 @@ import audioUploadSVG from 'assets/images/wave-sound.jpg'
import { CodeBlock } from 'ui-component/markdown/CodeBlock' import { CodeBlock } from 'ui-component/markdown/CodeBlock'
import { MemoizedReactMarkdown } from 'ui-component/markdown/MemoizedReactMarkdown' import { MemoizedReactMarkdown } from 'ui-component/markdown/MemoizedReactMarkdown'
import SourceDocDialog from 'ui-component/dialog/SourceDocDialog' import SourceDocDialog from 'ui-component/dialog/SourceDocDialog'
import ChatFeedbackContentDialog from 'ui-component/dialog/ChatFeedbackContentDialog'
import StarterPromptsCard from 'ui-component/cards/StarterPromptsCard' import StarterPromptsCard from 'ui-component/cards/StarterPromptsCard'
import { cancelAudioRecording, startAudioRecording, stopAudioRecording } from './audio-recording' import { cancelAudioRecording, startAudioRecording, stopAudioRecording } from './audio-recording'
import { ImageButton, ImageSrc, ImageBackdrop, ImageMarked } from 'ui-component/button/ImageButton' import { ImageButton, ImageSrc, ImageBackdrop, ImageMarked } from 'ui-component/button/ImageButton'
import CopyToClipboardButton from 'ui-component/button/CopyToClipboardButton'
import ThumbsUpButton from 'ui-component/button/ThumbsUpButton'
import ThumbsDownButton from 'ui-component/button/ThumbsDownButton'
import './ChatMessage.css' import './ChatMessage.css'
import './audio-recording.css' import './audio-recording.css'
@ -42,6 +46,7 @@ import './audio-recording.css'
import chatmessageApi from 'api/chatmessage' import chatmessageApi from 'api/chatmessage'
import chatflowsApi from 'api/chatflows' import chatflowsApi from 'api/chatflows'
import predictionApi from 'api/prediction' import predictionApi from 'api/prediction'
import chatmessagefeedbackApi from 'api/chatmessagefeedback'
// Hooks // Hooks
import useApi from 'hooks/useApi' import useApi from 'hooks/useApi'
@ -86,6 +91,9 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
const getChatflowConfig = useApi(chatflowsApi.getSpecificChatflow) const getChatflowConfig = useApi(chatflowsApi.getSpecificChatflow)
const [starterPrompts, setStarterPrompts] = useState([]) const [starterPrompts, setStarterPrompts] = useState([])
const [chatFeedbackStatus, setChatFeedbackStatus] = useState(false)
const [feedbackId, setFeedbackId] = useState('')
const [showFeedbackContentDialog, setShowFeedbackContentDialog] = useState(false)
// drag & drop and file input // drag & drop and file input
const fileUploadRef = useRef(null) const fileUploadRef = useRef(null)
@ -318,6 +326,7 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
let allMessages = [...cloneDeep(prevMessages)] let allMessages = [...cloneDeep(prevMessages)]
if (allMessages[allMessages.length - 1].type === 'userMessage') return allMessages if (allMessages[allMessages.length - 1].type === 'userMessage') return allMessages
allMessages[allMessages.length - 1].message += text allMessages[allMessages.length - 1].message += text
allMessages[allMessages.length - 1].feedback = null
return allMessages return allMessages
}) })
} }
@ -389,6 +398,14 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
if (response.data) { if (response.data) {
const data = response.data const data = response.data
setMessages((prevMessages) => {
let allMessages = [...cloneDeep(prevMessages)]
if (allMessages[allMessages.length - 1].type === 'apiMessage') {
allMessages[allMessages.length - 1].id = data?.chatMessageId
}
return allMessages
})
if (!chatId) setChatId(data.chatId) if (!chatId) setChatId(data.chatId)
if (input === '' && data.question) { if (input === '' && data.question) {
@ -412,10 +429,12 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
...prevMessages, ...prevMessages,
{ {
message: text, message: text,
id: data?.chatMessageId,
sourceDocuments: data?.sourceDocuments, sourceDocuments: data?.sourceDocuments,
usedTools: data?.usedTools, usedTools: data?.usedTools,
fileAnnotations: data?.fileAnnotations, fileAnnotations: data?.fileAnnotations,
type: 'apiMessage' type: 'apiMessage',
feedback: null
} }
]) ])
} }
@ -474,7 +493,9 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
setChatId(chatId) setChatId(chatId)
const loadedMessages = getChatmessageApi.data.map((message) => { const loadedMessages = getChatmessageApi.data.map((message) => {
const obj = { const obj = {
id: message.id,
message: message.content, message: message.content,
feedback: message.feedback,
type: message.role type: message.role
} }
if (message.sourceDocuments) obj.sourceDocuments = JSON.parse(message.sourceDocuments) if (message.sourceDocuments) obj.sourceDocuments = JSON.parse(message.sourceDocuments)
@ -527,6 +548,9 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
}) })
setStarterPrompts(inputFields) setStarterPrompts(inputFields)
} }
if (config.chatFeedback) {
setChatFeedbackStatus(config.chatFeedback.status)
}
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -604,6 +628,83 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
// eslint-disable-next-line // eslint-disable-next-line
}, [previews]) }, [previews])
const copyMessageToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text || '')
} catch (error) {
console.error('Error copying to clipboard:', error)
}
}
const onThumbsUpClick = async (messageId) => {
const body = {
chatflowid,
chatId,
messageId,
rating: 'THUMBS_UP',
content: ''
}
const result = await chatmessagefeedbackApi.addFeedback(chatflowid, body)
if (result.data) {
const data = result.data
let id = ''
if (data && data.id) id = data.id
setMessages((prevMessages) => {
const allMessages = [...cloneDeep(prevMessages)]
return allMessages.map((message) => {
if (message.id === messageId) {
message.feedback = {
rating: 'THUMBS_UP'
}
}
return message
})
})
setFeedbackId(id)
setShowFeedbackContentDialog(true)
}
}
const onThumbsDownClick = async (messageId) => {
const body = {
chatflowid,
chatId,
messageId,
rating: 'THUMBS_DOWN',
content: ''
}
const result = await chatmessagefeedbackApi.addFeedback(chatflowid, body)
if (result.data) {
const data = result.data
let id = ''
if (data && data.id) id = data.id
setMessages((prevMessages) => {
const allMessages = [...cloneDeep(prevMessages)]
return allMessages.map((message) => {
if (message.id === messageId) {
message.feedback = {
rating: 'THUMBS_DOWN'
}
}
return message
})
})
setFeedbackId(id)
setShowFeedbackContentDialog(true)
}
}
const submitFeedbackContent = async (text) => {
const body = {
content: text
}
const result = await chatmessagefeedbackApi.updateFeedback(feedbackId, body)
if (result.data) {
setFeedbackId('')
setShowFeedbackContentDialog(false)
}
}
return ( return (
<div onDragEnter={handleDrag}> <div onDragEnter={handleDrag}>
{isDragActive && ( {isDragActive && (
@ -747,6 +848,36 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews
{message.message} {message.message}
</MemoizedReactMarkdown> </MemoizedReactMarkdown>
</div> </div>
{message.type === 'apiMessage' && message.id && chatFeedbackStatus ? (
<>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'start', gap: 1 }}>
<CopyToClipboardButton onClick={() => copyMessageToClipboard(message.message)} />
{!message.feedback ||
message.feedback.rating === '' ||
message.feedback.rating === 'THUMBS_UP' ? (
<ThumbsUpButton
isDisabled={message.feedback && message.feedback.rating === 'THUMBS_UP'}
rating={message.feedback ? message.feedback.rating : ''}
onClick={() => onThumbsUpClick(message.id)}
/>
) : null}
{!message.feedback ||
message.feedback.rating === '' ||
message.feedback.rating === 'THUMBS_DOWN' ? (
<ThumbsDownButton
isDisabled={message.feedback && message.feedback.rating === 'THUMBS_DOWN'}
rating={message.feedback ? message.feedback.rating : ''}
onClick={() => onThumbsDownClick(message.id)}
/>
) : null}
</Box>
<ChatFeedbackContentDialog
show={showFeedbackContentDialog}
onCancel={() => setShowFeedbackContentDialog(false)}
onConfirm={submitFeedbackContent}
/>
</>
) : null}
{message.fileAnnotations && ( {message.fileAnnotations && (
<div style={{ display: 'block', flexDirection: 'row', width: '100%' }}> <div style={{ display: 'block', flexDirection: 'row', width: '100%' }}>
{message.fileAnnotations.map((fileAnnotation, index) => { {message.fileAnnotations.map((fileAnnotation, index) => {