diff --git a/libs/chatbot/lib/components/ChatBot/ChatBotHistory.tsx b/libs/chatbot/lib/components/ChatBot/ChatBotHistory.tsx new file mode 100644 index 0000000000..0895abfe6e --- /dev/null +++ b/libs/chatbot/lib/components/ChatBot/ChatBotHistory.tsx @@ -0,0 +1,108 @@ +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) { + 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 ( + { + 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..b8841f1c04 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..aeb44178c7 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 ConversationHistory = { + 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 ConversationHistory; + + 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, }; };