From 9ebf3e8eb829b595192212a5af196cffba75520a Mon Sep 17 00:00:00 2001 From: rawagner Date: Tue, 12 Aug 2025 12:46:43 +0200 Subject: [PATCH] Improve feedback form. Extract remote calls to hook. --- .../lib/components/ChatBot/AIAlert.tsx | 51 +++ .../lib/components/ChatBot/BotMessage.tsx | 171 ++++------ .../lib/components/ChatBot/ChatBot.tsx | 12 +- .../lib/components/ChatBot/ChatBotWindow.tsx | 322 +++--------------- .../ChatBot/ConfirmNewChatModal.tsx | 8 +- .../lib/components/ChatBot/FeedbackCard.tsx | 159 +++++++++ .../chatbot/lib/components/ChatBot/helpers.ts | 28 -- libs/chatbot/lib/hooks/use-message.ts | 195 +++++++++++ 8 files changed, 528 insertions(+), 418 deletions(-) create mode 100644 libs/chatbot/lib/components/ChatBot/AIAlert.tsx create mode 100644 libs/chatbot/lib/components/ChatBot/FeedbackCard.tsx create mode 100644 libs/chatbot/lib/hooks/use-message.ts diff --git a/libs/chatbot/lib/components/ChatBot/AIAlert.tsx b/libs/chatbot/lib/components/ChatBot/AIAlert.tsx new file mode 100644 index 0000000000..4c782dfa47 --- /dev/null +++ b/libs/chatbot/lib/components/ChatBot/AIAlert.tsx @@ -0,0 +1,51 @@ +import { Alert, AlertActionCloseButton, Button } from '@patternfly-6/react-core'; +import { ExternalLinkAltIcon } from '@patternfly-6/react-icons/dist/js/icons/external-link-alt-icon'; +import * as React from 'react'; + +const CHAT_ALERT_LOCAL_STORAGE_KEY = 'assisted.hide.chat.alert'; + +const AIAlert = () => { + const [isAlertVisible, setIsAlertVisible] = React.useState( + localStorage.getItem(CHAT_ALERT_LOCAL_STORAGE_KEY) !== 'true', + ); + + if (!isAlertVisible) { + return null; + } + + return ( + + This feature uses AI technology. Do not include personal or sensitive information in your + input. Interactions may be used to improve Red Hat's products or services. For more + information about Red Hat's privacy practices, please refer to the + + + } + actionClose={ + { + localStorage.setItem(CHAT_ALERT_LOCAL_STORAGE_KEY, 'true'); + setIsAlertVisible(false); + }} + /> + } + /> + ); +}; + +export default AIAlert; diff --git a/libs/chatbot/lib/components/ChatBot/BotMessage.tsx b/libs/chatbot/lib/components/ChatBot/BotMessage.tsx index 2a1d031a8d..3a99f6b767 100644 --- a/libs/chatbot/lib/components/ChatBot/BotMessage.tsx +++ b/libs/chatbot/lib/components/ChatBot/BotMessage.tsx @@ -1,90 +1,67 @@ import * as React from 'react'; import { Message } from '@patternfly-6/chatbot'; import MessageLoading from '@patternfly-6/chatbot/dist/cjs/Message/MessageLoading'; -import { UserFeedbackProps } from '@patternfly-6/chatbot/dist/cjs/Message/UserFeedback/UserFeedback'; import { MsgProps } from './helpers'; import { Button, Stack, StackItem } from '@patternfly-6/react-core'; import { saveAs } from 'file-saver'; import { DownloadIcon } from '@patternfly-6/react-icons'; - -type SentimentActionClick = (isPositive: boolean) => void; +import FeedbackForm from './FeedbackCard'; // eslint-disable-next-line // @ts-ignore const MsgLoading = () => ; export type FeedbackRequest = { - messageIndex: number; userFeedback: string; sentiment: number; + category?: string; }; -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, -): UserFeedbackProps => ({ - onClose, - onSubmit, - title: 'Please provide feedback', - textAreaAriaLabel: 'Additional feedback', - textAreaPlaceholder: 'Add details here', - hasTextArea: true, - closeButtonAriaLabel: 'Close feedback form', - focusOnLoad: true, -}); - export type BotMessageProps = { - onFeedbackSubmit: (req: FeedbackRequest) => Promise; - messageIndex: number; message: MsgProps; isLoading: boolean; isLastMsg: boolean; initHeight?: number; + onApiCall: typeof fetch; + conversationId: string | undefined; + userMsg: string; }; const BotMessage = ({ - onFeedbackSubmit, - messageIndex, + onApiCall, message, isLoading, initHeight, isLastMsg, + conversationId, + userMsg, }: BotMessageProps) => { + const [openFeedback, setOpenFeedback] = React.useState(false); const [height, setHeight] = React.useState(initHeight); - const [isNegativeFeedback, setIsNegativeFeedback] = React.useState(false); const msgRef = React.useRef(null); - const scrollToMsgRef = React.useRef(null); - // Scroll to bottom when negative feedback form opens - React.useLayoutEffect(() => { - if (isNegativeFeedback) { - scrollToMsgRef.current?.scrollIntoView({ behavior: 'smooth' }); - } - }, [isNegativeFeedback]); + const onFeedbackSubmit = React.useCallback( + async (req: FeedbackRequest): Promise => { + const resp = await onApiCall('/v1/feedback', { + method: 'POST', + body: JSON.stringify({ + conversation_id: conversationId, + user_question: userMsg, + user_feedback: req.userFeedback, + llm_response: message.pfProps.content || '', + sentiment: req.sentiment, + category: req.category, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + if (!resp.ok) { + throw new Error(`${resp.status} ${resp.statusText}`); + } + }, + [onApiCall, conversationId, message, userMsg], + ); // run on every re-render // eslint-disable-next-line react-hooks/exhaustive-deps @@ -94,63 +71,44 @@ const BotMessage = ({ } }); - const actions = React.useMemo(() => { - return getActions(message.pfProps.content || '', (positiveFeedback) => { - if (positiveFeedback) { - const submitPositiveFeedback = async () => { - try { - await onFeedbackSubmit({ - messageIndex, - userFeedback: '', - sentiment: 1, - }); - } finally { - setIsNegativeFeedback(false); - } - }; - void submitPositiveFeedback(); - } else { - setIsNegativeFeedback(true); - } - }); - }, [message.pfProps.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 ( <> { + void onFeedbackSubmit({ + userFeedback: '', + sentiment: 1, + }); + }, + }, + negative: { + ariaLabel: 'Bad response', + tooltipContent: 'Bad response', + clickedTooltipContent: 'Feedback sent', + onClick: () => setOpenFeedback(true), + }, + copy: { + isDisabled: !message.pfProps.content, + onClick: () => { + void navigator.clipboard.writeText(message.pfProps.content || ''); + }, + }, + } + } innerRef={msgRef} extraContent={{ afterMainContent: ( <> -
{isLoading && } {!isLoading && message.actions?.length && ( @@ -176,6 +134,15 @@ const BotMessage = ({ )} ), + endContent: openFeedback && ( + { + await onFeedbackSubmit(req); + setOpenFeedback(false); + }} + onClose={() => setOpenFeedback(false)} + /> + ), }} /> diff --git a/libs/chatbot/lib/components/ChatBot/ChatBot.tsx b/libs/chatbot/lib/components/ChatBot/ChatBot.tsx index fb44c499c0..aa8f26b45d 100644 --- a/libs/chatbot/lib/components/ChatBot/ChatBot.tsx +++ b/libs/chatbot/lib/components/ChatBot/ChatBot.tsx @@ -4,12 +4,15 @@ import { ChatbotToggle } from '@patternfly-6/chatbot'; import ChatBotWindow, { ChatBotWindowProps } from './ChatBotWindow'; import './Chatbot.css'; +import { useMessages } from '../../hooks/use-message'; type ChatBotProps = Pick; const ChatBot = ({ onApiCall, username }: ChatBotProps) => { - const [conversationId, setConversationId] = React.useState(); - const [messages, setMessages] = React.useState([]); + const messagesProps = useMessages({ + onApiCall, + username, + }); const [chatbotVisible, setChatbotVisible] = React.useState(false); return (
@@ -20,10 +23,7 @@ const ChatBot = ({ onApiCall, username }: ChatBotProps) => { /> {chatbotVisible && ( { setChatbotVisible(false); }} diff --git a/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx b/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx index b10f8aaa18..ad224f4bf7 100644 --- a/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx +++ b/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx @@ -14,93 +14,55 @@ import { MessageBox, MessageBoxHandle, } from '@patternfly-6/chatbot'; -import { - Alert, - AlertActionCloseButton, - Button, - Flex, - FlexItem, - Tooltip, -} from '@patternfly-6/react-core'; -import { ExternalLinkAltIcon } from '@patternfly-6/react-icons/dist/js/icons/external-link-alt-icon'; +import { Button, Flex, FlexItem, Tooltip } from '@patternfly-6/react-core'; import { PlusIcon } from '@patternfly-6/react-icons/dist/js/icons/plus-icon'; import { TimesIcon } from '@patternfly-6/react-icons/dist/js/icons/times-icon'; -import BotMessage, { FeedbackRequest } from './BotMessage'; +import BotMessage from './BotMessage'; import ConfirmNewChatModal from './ConfirmNewChatModal'; -import { - focusSendMessageInput as focusNewMessageBox, - getErrorMessage, - getUserQuestionForBotAnswer, - MESSAGE_BAR_ID, - botRole, - userRole, - MsgProps, - getToolAction, -} from './helpers'; -import AIAvatar from '../../assets/rh-logo.svg'; -import UserAvatar from '../../assets/avatarimg.svg'; -import { - isEndStreamEvent, - isInferenceStreamEvent, - isStartStreamEvent, - isToolArgStreamEvent, - isToolResponseStreamEvent, - StreamEvent, -} from './types'; - -const CHAT_ALERT_LOCAL_STORAGE_KEY = 'assisted.hide.chat.alert'; +import { MESSAGE_BAR_ID, botRole, userRole, MsgProps } from './helpers'; +import AIAlert from './AIAlert'; export type ChatBotWindowProps = { + error?: string; + resetError: VoidFunction; conversationId: string | undefined; - setConversationId: (id: string | undefined) => void; - setMessages: React.Dispatch>; messages: MsgProps[]; onApiCall: typeof fetch; username: string; onClose: () => void; + sentMessage: (msg: string) => Promise; + startNewConversation: VoidFunction; + isStreaming: boolean; + announcement: string | undefined; }; const ChatBotWindow = ({ conversationId, - setConversationId, messages, - setMessages, onApiCall, onClose, username, + sentMessage, + startNewConversation, + isStreaming, + announcement, + error, + resetError, }: ChatBotWindowProps) => { const [triggerScroll, setTriggerScroll] = React.useState(0); const [msg, setMsg] = React.useState(''); - const [error, setError] = React.useState(); - const [isStreaming, setIsStreaming] = React.useState(false); - const [announcement, setAnnouncement] = React.useState(); - const [isAlertVisible, setIsAlertVisible] = React.useState( - localStorage.getItem(CHAT_ALERT_LOCAL_STORAGE_KEY) !== 'true', - ); const [isConfirmModalOpen, setIsConfirmModalOpen] = React.useState(false); const scrollToBottomRef = React.useRef(null); const lastUserMsgRef = React.useRef(null); const msgBoxRef = React.useRef(null); + const msgBarRef = React.useRef(null); - React.useEffect(() => { - !isConfirmModalOpen && focusNewMessageBox(); - }, [isConfirmModalOpen]); - - const startNewChat = () => { - setConversationId(undefined); - setMessages([]); - setIsConfirmModalOpen(false); - }; - - const handleNewChat = () => { - // Only show confirmation if there are existing messages - if (messages.length > 0) { - setIsConfirmModalOpen(true); - } else { - startNewChat(); + React.useLayoutEffect(() => { + if (!isConfirmModalOpen) { + msgBarRef.current?.focus(); } - }; + }, [isConfirmModalOpen]); React.useEffect(() => { if (triggerScroll === 0) { @@ -123,184 +85,6 @@ const ChatBotWindow = ({ return undefined; }; - const handleSend = async (message: string | number) => { - setError(undefined); - setIsStreaming(true); - let reader: ReadableStreamDefaultReader | undefined = undefined; - let eventEnded = false; - const timestamp = new Date().toLocaleString(); - try { - setAnnouncement(`Message from User: ${message}. Message from Bot is loading.`); - setMessages((msgs) => [ - ...msgs, - { - pfProps: { - role: userRole, - content: `${message}`, - name: username, - avatar: UserAvatar, - timestamp, - }, - }, - { - pfProps: { - role: botRole, - content: '', - name: 'AI', - avatar: AIAvatar, - timestamp, - }, - }, - ]); - setTriggerScroll(triggerScroll + 1); - - let convId = ''; - - const resp = await onApiCall('/v1/streaming_query', { - method: 'POST', - body: JSON.stringify({ - query: `${message}`, - conversation_id: conversationId, - }), - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - }); - - if (!resp.ok) { - let errMsg = 'An error occured'; - try { - const detailMsg = ((await resp.json()) as { detail: string }).detail; - if (detailMsg) { - errMsg = detailMsg; - } - } catch {} - - throw Error(`${resp.status}: ${errMsg}`); - } - - reader = resp.body?.getReader(); - const decoder = new TextDecoder(); - - let completeMsg = ''; - let buffer = ''; - const toolArgs: { [key: number]: { [key: string]: string } } = {}; - while (reader) { - const { done, value } = await reader.read(); - if (done) { - break; - } - - buffer += decoder.decode(value, { stream: true }); - const parts = buffer.split('\n\n'); - buffer = parts.pop() || ''; - for (const part of parts) { - const lines = part.split('\n'); - let data = ''; - for (const line of lines) { - if (line.startsWith('data:')) { - data += line.slice(5).trim() + '\n'; - } - } - const ev = JSON.parse(data) as StreamEvent; - if (isEndStreamEvent(ev)) { - eventEnded = true; - } else if (isStartStreamEvent(ev)) { - convId = ev.data.conversation_id; - } else if (isInferenceStreamEvent(ev)) { - const token = ev.data.token; - completeMsg = `${completeMsg}${token}`; - setMessages((msgs) => { - const lastMsg = msgs[msgs.length - 1]; - const allButLast = msgs.slice(0, -1); - return [ - ...allButLast, - { - ...lastMsg, - pfProps: { - ...lastMsg.pfProps, - content: `${lastMsg.pfProps.content || ''}${token}`, - }, - }, - ]; - }); - } else if (isToolArgStreamEvent(ev)) { - toolArgs[ev.data.id] = ev.data.token.arguments; - } else if (isToolResponseStreamEvent(ev)) { - const action = getToolAction({ - toolName: ev.data.token.tool_name, - response: ev.data.token.response, - args: toolArgs[ev.data.id], - }); - if (action) { - setMessages((msgs) => { - const lastMsg = msgs[msgs.length - 1]; - const allButLast = msgs.slice(0, -1); - return [ - ...allButLast, - { - ...lastMsg, - actions: lastMsg.actions ? [...lastMsg.actions, action] : [action], - }, - ]; - }); - } - } - } - } - - setConversationId(convId); - setAnnouncement(`Message from Bot: ${completeMsg}`); - if (!eventEnded) { - setError('An error occured retrieving response'); - } - } catch (e) { - if (reader) { - try { - await reader.cancel('An error occured'); - } catch (e) { - // eslint-disable-next-line - console.warn('Failed to cancel reader:', e); - } - } - setError(getErrorMessage(e)); - } finally { - setIsStreaming(false); - } - }; - - 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].pfProps.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], - ); - const lastUserMsg = [...messages].reverse().findIndex((msg) => msg.pfProps.role === userRole); return ( @@ -318,7 +102,8 @@ const ChatBotWindow = ({ aria-label="New chat" id="new-chat-button" icon={} - onClick={handleNewChat} + onClick={() => setIsConfirmModalOpen(true)} + isDisabled={messages.length === 0} /> @@ -338,39 +123,7 @@ const ChatBotWindow = ({ - {isAlertVisible && ( - - This feature uses AI technology. Do not include personal or sensitive information - in your input. Interactions may be used to improve Red Hat's products or services. - For more information about Red Hat's privacy practices, please refer to the - - - } - actionClose={ - { - localStorage.setItem(CHAT_ALERT_LOCAL_STORAGE_KEY, 'true'); - setIsAlertVisible(false); - }} - /> - } - /> - )} + {messages.length === 0 && ( 0 ? messages[index - 1].pfProps.content || '' : ''} isLoading={index === messages.length - 1 && isStreaming} initHeight={isLastMsg ? getVisibleHeight() : undefined} isLastMsg={isLastMsg} @@ -404,11 +158,7 @@ const ChatBotWindow = ({ ); })} {error && ( - setError(undefined)} - title="An error occured" - > + {error} )} @@ -418,10 +168,15 @@ const ChatBotWindow = ({ void handleSend(msg)} + onSendMessage={() => { + void sentMessage(msg); + setTriggerScroll(triggerScroll + 1); + setMsg(''); + }} isSendButtonDisabled={isStreaming || !msg.trim()} hasAttachButton={false} onChange={(_, value) => setMsg(`${value}`)} + ref={msgBarRef} /> {isConfirmModalOpen && ( setIsConfirmModalOpen(false)} + onConfirm={() => { + startNewConversation(); + setIsConfirmModalOpen(false); + }} + onCancel={() => { + setIsConfirmModalOpen(false); + }} /> )} diff --git a/libs/chatbot/lib/components/ChatBot/ConfirmNewChatModal.tsx b/libs/chatbot/lib/components/ChatBot/ConfirmNewChatModal.tsx index 49c895f033..c95d202b5f 100644 --- a/libs/chatbot/lib/components/ChatBot/ConfirmNewChatModal.tsx +++ b/libs/chatbot/lib/components/ChatBot/ConfirmNewChatModal.tsx @@ -15,6 +15,12 @@ const ConfirmNewChatModal = ({ onConfirm: VoidFunction; onCancel: VoidFunction; }) => { + const btnRef = React.useRef(null); + + React.useEffect(() => { + btnRef.current?.focus(); + }, []); + return ( @@ -26,7 +32,7 @@ const ConfirmNewChatModal = ({ - diff --git a/libs/chatbot/lib/components/ChatBot/FeedbackCard.tsx b/libs/chatbot/lib/components/ChatBot/FeedbackCard.tsx new file mode 100644 index 0000000000..3540117993 --- /dev/null +++ b/libs/chatbot/lib/components/ChatBot/FeedbackCard.tsx @@ -0,0 +1,159 @@ +import { + Alert, + Button, + ButtonVariant, + Card, + CardBody, + CardFooter, + CardHeader, + CardTitle, + Form, + FormGroup, + MenuToggle, + Select, + SelectList, + SelectOption, + Stack, + StackItem, + TextArea, +} from '@patternfly-6/react-core'; +import * as React from 'react'; +import { FeedbackRequest } from './BotMessage'; +import { TimesIcon } from '@patternfly-6/react-icons'; +import { getErrorMessage } from './helpers'; + +const categories: { [key: string]: string } = { + incorrect: 'Incorrect', + not_relevant: 'Not relevant', + incomplete: 'Incomplete', + outdated_information: 'Outdated information', + unsafe: 'Unsafe', + other: 'Other', +}; + +const FeedbackForm = ({ + onFeedbackSubmit, + onClose, +}: { + onFeedbackSubmit: (req: FeedbackRequest) => Promise; + onClose: VoidFunction; +}) => { + const [categoryOpen, setCategoryOpen] = React.useState(false); + const [category, setCategory] = React.useState(Object.keys(categories)[0]); + const [feedback, setFeedback] = React.useState(''); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [error, setError] = React.useState(); + + const textAreaRef = React.useRef(null); + + React.useLayoutEffect(() => { + textAreaRef.current?.focus(); + }, []); + + const submit = async () => { + setError(undefined); + setIsSubmitting(true); + try { + await onFeedbackSubmit({ + sentiment: -1, + userFeedback: feedback, + category, + }); + } catch (e) { + setError(getErrorMessage(e)); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + } + isDisabled={isSubmitting} + /> + ), + }} + > + Please provide feedback + + + + +
+ + + + +