From c4d60ea1d80269460956899afcb7b2ad537c27a4 Mon Sep 17 00:00:00 2001 From: rawagner Date: Mon, 25 Aug 2025 08:54:49 +0200 Subject: [PATCH 1/2] Chatbot conversation history --- .../lib/components/ChatBot/ChatBotHistory.tsx | 101 +++++++ .../lib/components/ChatBot/ChatBotWindow.tsx | 259 +++++++++--------- .../ChatBot/ConfirmNewChatModal.tsx | 43 --- libs/chatbot/lib/hooks/use-message.ts | 51 +++- 4 files changed, 279 insertions(+), 175 deletions(-) create mode 100644 libs/chatbot/lib/components/ChatBot/ChatBotHistory.tsx delete mode 100644 libs/chatbot/lib/components/ChatBot/ConfirmNewChatModal.tsx diff --git a/libs/chatbot/lib/components/ChatBot/ChatBotHistory.tsx b/libs/chatbot/lib/components/ChatBot/ChatBotHistory.tsx new file mode 100644 index 0000000000..05274a2e21 --- /dev/null +++ b/libs/chatbot/lib/components/ChatBot/ChatBotHistory.tsx @@ -0,0 +1,101 @@ +import * as React from 'react'; +import { + ChatbotConversationHistoryNav, + ChatbotDisplayMode, + Conversation, +} from '@patternfly-6/chatbot'; +import { Alert } from '@patternfly-6/react-core'; +import { getErrorMessage } from './helpers'; + +type ConversationHistory = { conversations: { conversation_id: string; created_at: string }[] }; + +type ChatBotHistoryProps = { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + onApiCall: typeof fetch; + startNewConversation: VoidFunction; + loadConversation: (id: string) => Promise; + conversationId?: string; +}; + +const ChatBotHistory = ({ + isOpen, + setIsOpen, + children, + onApiCall, + conversationId, + startNewConversation, + loadConversation, +}: React.PropsWithChildren) => { + const [isLoading, setIsLoading] = React.useState(true); + const [conversations, setConversations] = React.useState([]); + const [error, setError] = React.useState(); + + React.useEffect(() => { + if (isOpen) { + setIsLoading(true); + setError(undefined); + void (async () => { + try { + const resp = await onApiCall('/v1/conversations'); + if (!resp.ok) { + throw Error(`Unexpected response code: ${resp.status}`); + } + const cnvs = (await resp.json()) as ConversationHistory; + setConversations( + cnvs.conversations + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)) + .map(({ conversation_id, created_at }) => ({ + id: conversation_id, + text: new Date(created_at).toLocaleString(), + })), + ); + } catch (e) { + setError(getErrorMessage(e)); + } finally { + setIsLoading(false); + } + })(); + } + }, [isOpen, onApiCall]); + + return ( + { + setIsOpen(!isOpen); + }} + isLoading={isLoading} + conversations={conversations} + onNewChat={startNewConversation} + onSelectActiveItem={(_, itemId) => { + itemId !== undefined && void loadConversation(`${itemId}`); + setIsOpen(!isOpen); + }} + activeItemId={conversationId} + errorState={ + error + ? { + bodyText: ( + + {error} + + ), + } + : undefined + } + emptyState={ + !isLoading && !conversations.length + ? { + bodyText: 'No conversation history', + } + : undefined + } + /> + ); +}; + +export default ChatBotHistory; diff --git a/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx b/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx index 54c1a25407..7b9a7c72be 100644 --- a/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx +++ b/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx @@ -7,6 +7,10 @@ import { ChatbotFooter, ChatbotFootnote, ChatbotHeader, + ChatbotHeaderActions, + ChatbotHeaderCloseButton, + ChatbotHeaderMain, + ChatbotHeaderMenu, ChatbotHeaderTitle, ChatbotWelcomePrompt, Message, @@ -14,17 +18,15 @@ import { MessageBox, MessageBoxHandle, } from '@patternfly-6/chatbot'; -import { Brand, 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 { Brand, EmptyState, Spinner } from '@patternfly-6/react-core'; import BotMessage from './BotMessage'; -import ConfirmNewChatModal from './ConfirmNewChatModal'; import { MESSAGE_BAR_ID, botRole, userRole, MsgProps } from './helpers'; import AIAlert from './AIAlert'; import LightSpeedLogo from '../../assets/lightspeed-logo.svg'; import UserAvatar from '../../assets/avatarimg.svg'; +import ChatBotHistory from './ChatBotHistory'; export type ChatBotWindowProps = { error?: string; @@ -39,6 +41,8 @@ export type ChatBotWindowProps = { isStreaming: boolean; announcement: string | undefined; openClusterDetails: (clusterId: string) => void; + isLoading: boolean; + loadConversation: (convId: string) => Promise; }; const ChatBotWindow = ({ @@ -54,20 +58,22 @@ const ChatBotWindow = ({ error, resetError, openClusterDetails, + isLoading, + loadConversation, }: ChatBotWindowProps) => { + const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); const [triggerScroll, setTriggerScroll] = React.useState(0); const [msg, setMsg] = React.useState(''); - 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.useLayoutEffect(() => { - if (!isConfirmModalOpen) { - msgBarRef.current?.focus(); + if (!isDrawerOpen) { + requestAnimationFrame(() => msgBarRef.current?.focus()); } - }, [isConfirmModalOpen]); + }, [isDrawerOpen, isLoading]); React.useEffect(() => { if (triggerScroll === 0) { @@ -81,7 +87,7 @@ const ChatBotWindow = ({ }); } } - }, [triggerScroll]); + }, [triggerScroll, isLoading]); const getVisibleHeight = () => { if (lastUserMsgRef.current && msgBoxRef.current) { @@ -94,131 +100,122 @@ const ChatBotWindow = ({ return ( - - - - - - - - - - ); -}; - -export default ConfirmNewChatModal; diff --git a/libs/chatbot/lib/hooks/use-message.ts b/libs/chatbot/lib/hooks/use-message.ts index b51fc465eb..26fc9aeb2d 100644 --- a/libs/chatbot/lib/hooks/use-message.ts +++ b/libs/chatbot/lib/hooks/use-message.ts @@ -1,6 +1,12 @@ import * as React from 'react'; import { ChatBotWindowProps } from '../components/ChatBot/ChatBotWindow'; -import { botRole, getErrorMessage, getToolAction, userRole } from '../components/ChatBot/helpers'; +import { + botRole, + getErrorMessage, + getToolAction, + MsgProps, + userRole, +} from '../components/ChatBot/helpers'; import { isEndStreamEvent, @@ -11,12 +17,53 @@ import { StreamEvent, } from '../components/ChatBot/types'; +type Conversation = { + chat_history: { + messages: { content: string; type: 'user' | 'assistant' }[]; + completed_at: string; + }[]; +}; + export const useMessages = ({ onApiCall }: { onApiCall: typeof fetch }) => { const [error, setError] = React.useState(); const [isStreaming, setIsStreaming] = React.useState(false); const [announcement, setAnnouncement] = React.useState(); const [messages, setMessages] = React.useState([]); const [conversationId, setConversationId] = React.useState(); + const [isLoading, setIsLoading] = React.useState(false); + + const loadConversation = React.useCallback( + async (convId: string) => { + setIsLoading(true); + setError(undefined); + setConversationId(convId); + setMessages([]); + try { + const resp = await onApiCall(`/v1/conversations/${convId}`); + if (!resp.ok) { + throw Error(`Unexpected response code: ${resp.status}`); + } + const conv = (await resp.json()) as Conversation; + + const msgs = conv.chat_history.flatMap(({ messages, completed_at }) => { + const timestamp = new Date(completed_at).toLocaleString(); + return messages.map(({ content, type }) => ({ + pfProps: { + content, + role: type === 'assistant' ? botRole : userRole, + timestamp, + }, + })); + }); + setMessages(msgs); + } catch (e) { + setError(getErrorMessage(e)); + } finally { + setIsLoading(false); + } + }, + [onApiCall], + ); const sentMessage = React.useCallback( async (message: string) => { @@ -178,5 +225,7 @@ export const useMessages = ({ onApiCall }: { onApiCall: typeof fetch }) => { announcement, error, resetError, + isLoading, + loadConversation, }; }; From 4e41f0dbab9b5a5fe20b2096ef03a1bf27c0a4fb Mon Sep 17 00:00:00 2001 From: rawagner Date: Mon, 25 Aug 2025 11:03:54 +0200 Subject: [PATCH 2/2] Add abort controller & improve type naming --- .../lib/components/ChatBot/ChatBotHistory.tsx | 55 +++++++++++-------- .../lib/components/ChatBot/ChatBotWindow.tsx | 2 +- libs/chatbot/lib/hooks/use-message.ts | 4 +- 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/libs/chatbot/lib/components/ChatBot/ChatBotHistory.tsx b/libs/chatbot/lib/components/ChatBot/ChatBotHistory.tsx index 05274a2e21..0895abfe6e 100644 --- a/libs/chatbot/lib/components/ChatBot/ChatBotHistory.tsx +++ b/libs/chatbot/lib/components/ChatBot/ChatBotHistory.tsx @@ -32,31 +32,38 @@ const ChatBotHistory = ({ const [error, setError] = React.useState(); React.useEffect(() => { - if (isOpen) { - setIsLoading(true); - setError(undefined); - void (async () => { - try { - const resp = await onApiCall('/v1/conversations'); - if (!resp.ok) { - throw Error(`Unexpected response code: ${resp.status}`); - } - const cnvs = (await resp.json()) as ConversationHistory; - setConversations( - cnvs.conversations - .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)) - .map(({ conversation_id, created_at }) => ({ - id: conversation_id, - text: new Date(created_at).toLocaleString(), - })), - ); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setIsLoading(false); - } - })(); + if (!isOpen) { + return; } + const abortController = new AbortController(); + setIsLoading(true); + setError(undefined); + void (async () => { + try { + const resp = await onApiCall('/v1/conversations'); + if (!resp.ok) { + throw Error(`Unexpected response code: ${resp.status}`); + } + const cnvs = (await resp.json()) as ConversationHistory; + setConversations( + cnvs.conversations + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)) + .map(({ conversation_id, created_at }) => ({ + id: conversation_id, + text: new Date(created_at).toLocaleString(), + })), + ); + } catch (e) { + // aborting fetch trows 'AbortError', we can ignore it + if (abortController.signal.aborted) { + return; + } + setError(getErrorMessage(e)); + } finally { + setIsLoading(false); + } + })(); + return () => abortController.abort(); }, [isOpen, onApiCall]); return ( diff --git a/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx b/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx index 7b9a7c72be..b8841f1c04 100644 --- a/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx +++ b/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx @@ -198,7 +198,7 @@ const ChatBotWindow = ({ id={MESSAGE_BAR_ID} onSendMessage={() => { void sentMessage(msg); - setTriggerScroll(triggerScroll + 1); + setTriggerScroll((prev) => prev + 1); setMsg(''); }} isSendButtonDisabled={isStreaming || !msg.trim() || isLoading} diff --git a/libs/chatbot/lib/hooks/use-message.ts b/libs/chatbot/lib/hooks/use-message.ts index 26fc9aeb2d..aeb44178c7 100644 --- a/libs/chatbot/lib/hooks/use-message.ts +++ b/libs/chatbot/lib/hooks/use-message.ts @@ -17,7 +17,7 @@ import { StreamEvent, } from '../components/ChatBot/types'; -type Conversation = { +type ConversationHistory = { chat_history: { messages: { content: string; type: 'user' | 'assistant' }[]; completed_at: string; @@ -43,7 +43,7 @@ export const useMessages = ({ onApiCall }: { onApiCall: typeof fetch }) => { if (!resp.ok) { throw Error(`Unexpected response code: ${resp.status}`); } - const conv = (await resp.json()) as Conversation; + const conv = (await resp.json()) as ConversationHistory; const msgs = conv.chat_history.flatMap(({ messages, completed_at }) => { const timestamp = new Date(completed_at).toLocaleString();