diff --git a/libs/chatbot/lib/components/ChatBot/BotMessage.tsx b/libs/chatbot/lib/components/ChatBot/BotMessage.tsx new file mode 100644 index 0000000000..19fc2f7b5a --- /dev/null +++ b/libs/chatbot/lib/components/ChatBot/BotMessage.tsx @@ -0,0 +1,129 @@ +import * as React from 'react'; +import { Message } from '@patternfly-6/chatbot'; +import { UserFeedbackProps } from '@patternfly-6/chatbot/dist/cjs/Message/UserFeedback/UserFeedback'; + +type MsgProps = React.ComponentProps; + +type SentimentActionClick = (isPositive: boolean) => void; + +export type FeedbackRequest = { + messageIndex: number; + userFeedback: string; + sentiment: number; +}; + +const getActions = (text: string, onActionClick: SentimentActionClick) => ({ + positive: { + ariaLabel: 'Good response', + tooltipContent: 'Good response', + clickedTooltipContent: 'Feedback sent', + onClick: () => { + onActionClick(true); + }, + }, + negative: { + ariaLabel: 'Bad response', + tooltipContent: 'Bad response', + clickedTooltipContent: 'Feedback sent', + onClick: () => { + onActionClick(false); + }, + }, + copy: { + onClick: () => { + void navigator.clipboard.writeText(text); + }, + }, +}); + +const userFeedbackForm = ( + onSubmit: (quickResponse: string | undefined, additionalFeedback: string | undefined) => void, + onClose: VoidFunction, +) => ({ + onClose, + onSubmit, + title: 'Please provide feedback', + textAreaAriaLabel: 'Additional feedback', + textAreaPlaceholder: 'Add details here', + hasTextArea: true, + closeButtonAriaLabel: 'Close feedback form', + focusOnLoad: false, +}); + +export type BotMessageProps = { + onFeedbackSubmit: (req: FeedbackRequest) => Promise; + messageIndex: number; + message: MsgProps; + onScrollToBottom: () => void; +}; + +const BotMessage = ({ + onFeedbackSubmit, + messageIndex, + message, + onScrollToBottom, +}: BotMessageProps) => { + const [isNegativeFeedback, setIsNegativeFeedback] = React.useState(false); + + // Scroll to bottom when negative feedback form opens + React.useEffect(() => { + if (isNegativeFeedback) { + // Use requestAnimationFrame to ensure the form is rendered and painted + requestAnimationFrame(() => { + // Double RAF to ensure layout is complete + requestAnimationFrame(() => { + onScrollToBottom(); + }); + }); + } + }, [isNegativeFeedback, onScrollToBottom]); + + const actions = React.useMemo(() => { + return getActions(message.content || '', (positiveFeedback) => { + if (positiveFeedback) { + const submitPositiveFeedback = async () => { + try { + await onFeedbackSubmit({ + messageIndex, + userFeedback: '', + sentiment: 1, + }); + } finally { + setIsNegativeFeedback(false); + } + }; + void submitPositiveFeedback(); + } else { + setIsNegativeFeedback(true); + } + }); + }, [message.content, onFeedbackSubmit, messageIndex]); + + const userFeedbackFormConfig = React.useMemo(() => { + return isNegativeFeedback + ? userFeedbackForm( + (_quickResponse: string | undefined, additionalFeedback: string | undefined) => { + const submitNegativeFeedback = async () => { + try { + await onFeedbackSubmit({ + messageIndex, + userFeedback: additionalFeedback || '', + sentiment: -1, + }); + } finally { + setIsNegativeFeedback(false); + } + }; + void submitNegativeFeedback(); + }, + () => { + setIsNegativeFeedback(false); + }, + ) + : undefined; + }, [isNegativeFeedback, onFeedbackSubmit, messageIndex]); + + return ; +}; + +export default BotMessage; diff --git a/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx b/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx index 56fa3be059..4a07daed8e 100644 --- a/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx +++ b/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx @@ -15,6 +15,7 @@ import { import { Alert, AlertActionCloseButton, Button } from '@patternfly-6/react-core'; import { ExternalLinkAltIcon } from '@patternfly-6/react-icons/dist/js/icons/external-link-alt-icon'; +import BotMessage, { FeedbackRequest } from './BotMessage'; import AIAvatar from '../../assets/rh-logo.svg'; import UserAvatar from '../../assets/avatarimg.svg'; @@ -23,6 +24,9 @@ type StreamEvent = | { event: 'token'; data: { token: string; role: string } } | { event: 'end' }; +const botRole = 'bot'; +const userRole = 'user'; + const getErrorMessage = (error: unknown) => { if (error instanceof Error) { return error.message; @@ -46,6 +50,25 @@ export type ChatBotWindowProps = { username: string; }; +// Helper function to get the user question for a bot message +const getUserQuestionForBotAnswer = ( + messages: MsgProps[], + messageIndex: number, +): string | undefined => { + if (messageIndex === 0) { + return undefined; + } + // Look backwards from the previous message to find the most recent user message + for (let i = messageIndex - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg?.role === userRole && msg.content) { + return String(msg.content); + } + } + + return undefined; +}; + const ChatBotWindow = ({ conversationId, setConversationId, @@ -63,6 +86,10 @@ const ChatBotWindow = ({ ); const scrollToBottomRef = React.useRef(null); + const scrollToBottom = React.useCallback(() => { + scrollToBottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, []); + const handleSend = async (message: string | number) => { setError(undefined); setIsLoading(true); @@ -71,7 +98,7 @@ const ChatBotWindow = ({ setMessages((msgs) => [ ...msgs, { - role: 'user', + role: userRole, content: `${message}`, name: username, avatar: UserAvatar, @@ -121,12 +148,12 @@ const ChatBotWindow = ({ setMessages((msgs) => { const lastMsg = msgs[msgs.length - 1]; const msg = - lastMsg.timestamp === timestamp && lastMsg.role === 'bot' ? lastMsg : undefined; + lastMsg.timestamp === timestamp && lastMsg.role === botRole ? lastMsg : undefined; if (!msg) { return [ ...msgs, { - role: 'bot', + role: botRole, content: token, name: 'AI', avatar: AIAvatar, @@ -166,6 +193,37 @@ const ChatBotWindow = ({ } }; + const onFeedbackSubmit = React.useCallback( + async (req: FeedbackRequest): Promise => { + const botMessageIdx = req.messageIndex; + + if (botMessageIdx < 0 || botMessageIdx >= messages.length) { + throw new Error(`Invalid message index: ${botMessageIdx}`); + } + + const resp = await onApiCall('/v1/feedback', { + method: 'POST', + body: JSON.stringify({ + conversation_id: conversationId, + user_question: getUserQuestionForBotAnswer(messages, botMessageIdx), + user_feedback: req.userFeedback, + llm_response: messages[botMessageIdx].content || '', + sentiment: req.sentiment, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + if (!resp.ok) { + throw new Error(`Failed to submit feedback: ${resp.status} ${resp.statusText}`); + } + + // Resolve the promise to avoid unhandled rejection + await resp.json(); + }, + [onApiCall, conversationId, messages], + ); + React.useEffect(() => { scrollToBottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); @@ -213,10 +271,24 @@ const ChatBotWindow = ({ description="How can I help you today?" /> )} - {messages.map((message, index) => ( - - ))} - {isLoading && } + {messages.map((message, index) => { + const messageKey = conversationId ? `${conversationId}-${index}` : index; + const isBotMessage = message.role === botRole; + if (isBotMessage) { + return ( + + ); + } + + return ; + })} + {isLoading && } {error && (