diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md index 4cbe9aa25..21602a500 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md @@ -131,15 +131,23 @@ This demo displays a ChatBot in a static, inline drawer. This demo includes: ``` +### Primary color background + +This demo displays an embedded ChatBot with a [primary background color](/design-foundations/colors#background-colors). This example includes the same features as the [Embedded ChatBot demo](/patternfly-ai/chatbot/overview/demo/#embedded-chatbot)—the only differences are that the background color is adjusted via the `isPrimary` prop and some of the sample Messages have changed. You can use the same logic to adjust the background color in any ChatBot layout. + +```js file="./WhiteEmbeddedChatbot.tsx" isFullscreen + +``` + ### Display mode switcher This demo showcases how the ChatBot can be rendered in different display modes to suit various application layouts. It demonstrates how to dynamically change the page structure in response to the user's selection. This demo includes: 1. The ability to switch between overlay, drawer, and fullscreen modes using the [``](/patternfly-ai/chatbot/ui#header-options) in the header. 2. A conditional page layout that renders the ChatBot for each display mode option: - - **Overlay:** As a floating window on top of the page content. - - **Drawer:** Inside an inline PatternFly `` as a side panel. - - **Fullscreen:** As a top-level component that covers the entire screen for an embedded experience. + - **Overlay:** As a floating window on top of the page content. + - **Drawer:** Inside an inline PatternFly `` as a side panel. + - **Fullscreen:** As a top-level component that covers the entire screen for an embedded experience. 3. Logic to show or hide the `` button, which is only present in the default overlay mode. 4. A [basic ChatBot](#basic-chatbot) with a header, welcome prompt, and message bar to populate the different layouts. @@ -170,7 +178,7 @@ Your code structure should look like this: ### Chat transcripts -This demo illustrates how you could add downloadable transcripts to your ChatBot, which outline conversation details in a Markdown file. This approach allows users to easily share information from a conversation with others. +This demo illustrates how you could add downloadable transcripts to your ChatBot, which outline conversation details in a Markdown file. This approach allows users to easily share information from a conversation with others. A message transcript includes details from a single chat message. To download a sample message transcript in this demo, click the "Download" action under a bot message. diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/WhiteEmbeddedChatbot.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/WhiteEmbeddedChatbot.tsx new file mode 100644 index 000000000..4731d8cdb --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/WhiteEmbeddedChatbot.tsx @@ -0,0 +1,437 @@ +import { useEffect, useRef, useState, FunctionComponent, MouseEvent } from 'react'; + +import { + Bullseye, + Brand, + DropdownList, + DropdownItem, + Page, + Masthead, + MastheadMain, + MastheadBrand, + MastheadLogo, + PageSidebarBody, + PageSidebar, + MastheadToggle, + PageToggleButton, + SkipToContent +} from '@patternfly/react-core'; + +import Chatbot, { ChatbotDisplayMode } from '@patternfly/chatbot/dist/dynamic/Chatbot'; +import ChatbotContent from '@patternfly/chatbot/dist/dynamic/ChatbotContent'; +import ChatbotWelcomePrompt from '@patternfly/chatbot/dist/dynamic/ChatbotWelcomePrompt'; +import ChatbotFooter, { ChatbotFootnote } from '@patternfly/chatbot/dist/dynamic/ChatbotFooter'; +import MessageBar from '@patternfly/chatbot/dist/dynamic/MessageBar'; +import MessageBox from '@patternfly/chatbot/dist/dynamic/MessageBox'; +import Message, { MessageProps } from '@patternfly/chatbot/dist/dynamic/Message'; +import ChatbotConversationHistoryNav, { + Conversation +} from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav'; +import ChatbotHeader, { + ChatbotHeaderMenu, + ChatbotHeaderMain, + ChatbotHeaderTitle, + ChatbotHeaderActions, + ChatbotHeaderSelectorDropdown +} from '@patternfly/chatbot/dist/dynamic/ChatbotHeader'; + +import PFHorizontalLogoColor from '../UI/PF-HorizontalLogo-Color.svg'; +import PFHorizontalLogoReverse from '../UI/PF-HorizontalLogo-Reverse.svg'; +import { BarsIcon } from '@patternfly/react-icons'; +import userAvatar from '../Messages/user_avatar.svg'; +import patternflyAvatar from '../Messages/patternfly_avatar.jpg'; +import '@patternfly/react-core/dist/styles/base.css'; +import '@patternfly/chatbot/dist/css/main.css'; + +const footnoteProps = { + label: 'ChatBot uses AI. Check for mistakes.', + popover: { + title: 'Verify information', + description: `While ChatBot strives for accuracy, AI is experimental and can make mistakes. We cannot guarantee that all information provided by ChatBot is up to date or without error. You should always verify responses using reliable sources, especially for crucial information and decision making.`, + bannerImage: { + src: 'https://cdn.dribbble.com/userupload/10651749/file/original-8a07b8e39d9e8bf002358c66fce1223e.gif', + alt: 'Example image for footnote popover' + }, + cta: { + label: 'Dismiss', + onClick: () => { + alert('Do something!'); + } + }, + link: { + label: 'View AI policy', + url: 'https://www.redhat.com/' + } + } +}; + +const markdown = `A paragraph with *emphasis* and **strong importance**. + +> A block quote with ~strikethrough~ and a URL: https://reactjs.org. + +Here is an inline code - \`() => void\` + +Here is some YAML code: + +~~~yaml +apiVersion: helm.openshift.io/v1beta1/ +kind: HelmChartRepository +metadata: + name: azure-sample-repo0oooo00ooo +spec: + connectionConfig: + url: https://raw.githubusercontent.com/Azure-Samples/helm-charts/master/docs +~~~ + +Here is some JavaScript code: + +~~~js +const MessageLoading = () => ( +
+ + Loading message + +
+); + +export default MessageLoading; + +~~~ +`; + +// It's important to set a date and timestamp prop since the Message components re-render. +// The timestamps re-render with them. +const date = new Date(); + +const initialMessages: MessageProps[] = [ + { + id: '1', + role: 'user', + content: 'Hello, can you give me an example of what you can do?', + name: 'User', + avatar: userAvatar, + timestamp: date.toLocaleString(), + avatarProps: { isBordered: true } + }, + { + id: '2', + role: 'bot', + content: markdown, + name: 'Bot', + avatar: patternflyAvatar, + timestamp: date.toLocaleString(), + actions: { + // eslint-disable-next-line no-console + positive: { onClick: () => console.log('Good response') }, + // eslint-disable-next-line no-console + negative: { onClick: () => console.log('Bad response') }, + // eslint-disable-next-line no-console + copy: { onClick: () => console.log('Copy') }, + // eslint-disable-next-line no-console + download: { onClick: () => console.log('Download') }, + // eslint-disable-next-line no-console + listen: { onClick: () => console.log('Listen') } + } + } +]; + +const welcomePrompts = [ + { + title: 'Set up account', + message: 'Choose the necessary settings and preferences for your account.' + }, + { + title: 'Troubleshoot issue', + message: 'Find documentation and instructions to resolve your issue.' + } +]; + +const initialConversations = { + Today: [{ id: '1', text: 'Hello, can you give me an example of what you can do?' }], + 'This month': [ + { + id: '2', + text: 'Enterprise Linux installation and setup' + }, + { id: '3', text: 'Troubleshoot system crash' } + ], + March: [ + { id: '4', text: 'Ansible security and updates' }, + { id: '5', text: 'Red Hat certification' }, + { id: '6', text: 'Lightspeed user documentation' } + ], + February: [ + { id: '7', text: 'Crashing pod assistance' }, + { id: '8', text: 'OpenShift AI pipelines' }, + { id: '9', text: 'Updating subscription plan' }, + { id: '10', text: 'Red Hat licensing options' } + ], + January: [ + { id: '11', text: 'RHEL system performance' }, + { id: '12', text: 'Manage user accounts' } + ] +}; + +export const EmbeddedChatbotDemo: FunctionComponent = () => { + const [messages, setMessages] = useState(initialMessages); + const [selectedModel, setSelectedModel] = useState('Granite 7B'); + const [isSendButtonDisabled, setIsSendButtonDisabled] = useState(false); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [conversations, setConversations] = useState( + initialConversations + ); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [announcement, setAnnouncement] = useState(); + const scrollToBottomRef = useRef(null); + const historyRef = useRef(null); + + const displayMode = ChatbotDisplayMode.embedded; + // Auto-scrolls to the latest message + useEffect(() => { + // don't scroll the first load - in this demo, we know we start with two messages + if (messages.length > 2) { + scrollToBottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + } + }, [messages]); + + const onSelectModel = (_event: MouseEvent | undefined, value: string | number | undefined) => { + setSelectedModel(value as string); + }; + + // you will likely want to come up with your own unique id function; this is for demo purposes only + const generateId = () => { + const id = Date.now() + Math.random(); + return id.toString(); + }; + + const handleSend = (message: string) => { + setIsSendButtonDisabled(true); + const newMessages: MessageProps[] = []; + // We can't use structuredClone since messages contains functions, but we can't mutate + // items that are going into state or the UI won't update correctly + messages.forEach((message) => newMessages.push(message)); + // It's important to set a timestamp prop since the Message components re-render. + // The timestamps re-render with them. + const date = new Date(); + newMessages.push({ + id: generateId(), + role: 'user', + content: message, + name: 'User', + avatar: userAvatar, + timestamp: date.toLocaleString(), + avatarProps: { isBordered: true } + }); + newMessages.push({ + id: generateId(), + role: 'bot', + content: 'API response goes here', + name: 'Bot', + avatar: patternflyAvatar, + isLoading: true, + timestamp: date.toLocaleString() + }); + setMessages(newMessages); + // make announcement to assistive devices that new messages have been added + setAnnouncement(`Message from User: ${message}. Message from Bot is loading.`); + + // this is for demo purposes only; in a real situation, there would be an API response we would wait for + setTimeout(() => { + const loadedMessages: MessageProps[] = []; + // we can't use structuredClone since messages contains functions, but we can't mutate + // items that are going into state or the UI won't update correctly + newMessages.forEach((message) => loadedMessages.push(message)); + loadedMessages.pop(); + loadedMessages.push({ + id: generateId(), + role: 'bot', + content: 'API response goes here', + name: 'Bot', + avatar: patternflyAvatar, + isLoading: false, + actions: { + // eslint-disable-next-line no-console + positive: { onClick: () => console.log('Good response') }, + // eslint-disable-next-line no-console + negative: { onClick: () => console.log('Bad response') }, + // eslint-disable-next-line no-console + copy: { onClick: () => console.log('Copy') }, + // eslint-disable-next-line no-console + download: { onClick: () => console.log('Download') }, + // eslint-disable-next-line no-console + listen: { onClick: () => console.log('Listen') } + }, + timestamp: date.toLocaleString() + }); + setMessages(loadedMessages); + // make announcement to assistive devices that new message has loaded + setAnnouncement(`Message from Bot: API response goes here`); + setIsSendButtonDisabled(false); + }, 5000); + }; + + const findMatchingItems = (targetValue: string) => { + let filteredConversations = Object.entries(initialConversations).reduce((acc, [key, items]) => { + const filteredItems = items.filter((item) => item.text.toLowerCase().includes(targetValue.toLowerCase())); + if (filteredItems.length > 0) { + acc[key] = filteredItems; + } + return acc; + }, {}); + + // append message if no items are found + if (Object.keys(filteredConversations).length === 0) { + filteredConversations = [{ id: '13', noIcon: true, text: 'No results found' }]; + } + return filteredConversations; + }; + + const horizontalLogo = ( + + + + + ); + + const masthead = ( + + + + setIsSidebarOpen(!isSidebarOpen)} + id="fill-nav-toggle" + > + + + + + + Logo + + + + + ); + + const sidebar = ( + + Navigation + + ); + + const skipToChatbot = (event: MouseEvent) => { + event.preventDefault(); + if (historyRef.current) { + historyRef.current.focus(); + } + }; + + const skipToContent = ( + /* You can also add a SkipToContent for your main content here */ + + Skip to chatbot + + ); + + return ( + + + { + setIsDrawerOpen(!isDrawerOpen); + setConversations(initialConversations); + }} + isDrawerOpen={isDrawerOpen} + setIsDrawerOpen={setIsDrawerOpen} + activeItemId="1" + // eslint-disable-next-line no-console + onSelectActiveItem={(e, selectedItem) => console.log(`Selected history item with id ${selectedItem}`)} + conversations={conversations} + onNewChat={() => { + setIsDrawerOpen(!isDrawerOpen); + setMessages([]); + setConversations(initialConversations); + }} + handleTextInputChange={(value: string) => { + if (value === '') { + setConversations(initialConversations); + } + // this is where you would perform search on the items in the drawer + // and update the state + const newConversations: { [key: string]: Conversation[] } = findMatchingItems(value); + setConversations(newConversations); + }} + drawerContent={ + <> + + + setIsDrawerOpen(!isDrawerOpen)} + /> + {horizontalLogo} + + + + + + Granite 7B + + + Llama 3.0 + + + Mistral 3B + + + + + + + {/* Update the announcement prop on MessageBox whenever a new message is sent + so that users of assistive devices receive sufficient context */} + + + {/* This code block enables scrolling to the top of the last message. + You can instead choose to move the div with scrollToBottomRef on it below + the map of messages, so that users are forced to scroll to the bottom. + If you are using streaming, you will want to take a different approach; + see: https://github.com/patternfly/chatbot/issues/201#issuecomment-2400725173 */} + {messages.map((message, index) => { + if (index === messages.length - 1) { + return ( + <> +
+ + + ); + } + return ; + })} +
+
+ + + + + + } + >
+
+
+ ); +}; diff --git a/packages/module/patternfly-docs/generated/patternfly-ai/chatbot/overview/demo/primary-color-background.png b/packages/module/patternfly-docs/generated/patternfly-ai/chatbot/overview/demo/primary-color-background.png new file mode 100644 index 000000000..1e54e7cd7 Binary files /dev/null and b/packages/module/patternfly-docs/generated/patternfly-ai/chatbot/overview/demo/primary-color-background.png differ diff --git a/packages/module/src/ChatbotFooter/ChatbotFooter.scss b/packages/module/src/ChatbotFooter/ChatbotFooter.scss index e07680184..e0da89388 100644 --- a/packages/module/src/ChatbotFooter/ChatbotFooter.scss +++ b/packages/module/src/ChatbotFooter/ChatbotFooter.scss @@ -11,6 +11,10 @@ flex-direction: column; row-gap: var(--pf-chatbot__footer--RowGap); position: relative; // this is so focus ring on parent chatbot doesn't include footer + + &.pf-m-primary { + background-color: var(--pf-t--global--background--color--primary--default); + } } .pf-chatbot__footer-container { padding: 0 var(--pf-t--global--spacer--lg) var(--pf-t--global--spacer--lg) var(--pf-t--global--spacer--lg); diff --git a/packages/module/src/ChatbotFooter/ChatbotFooter.test.tsx b/packages/module/src/ChatbotFooter/ChatbotFooter.test.tsx index f1cc2ea58..161eb9218 100644 --- a/packages/module/src/ChatbotFooter/ChatbotFooter.test.tsx +++ b/packages/module/src/ChatbotFooter/ChatbotFooter.test.tsx @@ -15,10 +15,19 @@ describe('ChatbotFooter', () => { it('should handle isCompact', () => { render( - + Chatbot Content ); expect(screen.getByTestId('footer')).toHaveClass('pf-m-compact'); }); + + it('should handle isPrimary', () => { + render( + + Chatbot Content + + ); + expect(screen.getByTestId('footer')).toHaveClass('pf-m-primary'); + }); }); diff --git a/packages/module/src/ChatbotFooter/ChatbotFooter.tsx b/packages/module/src/ChatbotFooter/ChatbotFooter.tsx index 1f2e0f2d3..3960829f1 100644 --- a/packages/module/src/ChatbotFooter/ChatbotFooter.tsx +++ b/packages/module/src/ChatbotFooter/ChatbotFooter.tsx @@ -13,20 +13,27 @@ import type { HTMLProps, FunctionComponent } from 'react'; import { Divider } from '@patternfly/react-core'; export interface ChatbotFooterProps extends HTMLProps { - /** Children for the Footer that supports MessageBar and FootNote components*/ + /** Children for the footer - supports MessageBar and FootNote components*/ children?: React.ReactNode; - /** Custom classname for the Footer component */ + /** Custom classname for the footer component */ className?: string; + /** Sets footer to compact styling. */ isCompact?: boolean; + /** Sets background color to primary */ + isPrimary?: boolean; } export const ChatbotFooter: FunctionComponent = ({ children, className, isCompact, + isPrimary, ...props }: ChatbotFooterProps) => ( -
+
{children}
diff --git a/packages/module/src/MessageBar/MessageBar.scss b/packages/module/src/MessageBar/MessageBar.scss index ee0a38b6c..dd7b3f206 100644 --- a/packages/module/src/MessageBar/MessageBar.scss +++ b/packages/module/src/MessageBar/MessageBar.scss @@ -26,6 +26,10 @@ overflow: hidden; + &.pf-m-primary { + box-shadow: inset 0 0 0 1px var(--pf-t--global--border--color--default); + } + &:hover { box-shadow: inset 0 0 0 1px var(--pf-t--global--border--color--default); } diff --git a/packages/module/src/MessageBar/MessageBar.test.tsx b/packages/module/src/MessageBar/MessageBar.test.tsx index 84cf51fa6..ca2077af5 100644 --- a/packages/module/src/MessageBar/MessageBar.test.tsx +++ b/packages/module/src/MessageBar/MessageBar.test.tsx @@ -387,4 +387,8 @@ describe('Message bar', () => { ref.current?.focus(); expect(document.activeElement).toBe(screen.getByRole('textbox')); }); + it('should handle isPrimary', () => { + const { container } = render(); + expect(container.querySelector('.pf-m-primary')).toBeTruthy(); + }); }); diff --git a/packages/module/src/MessageBar/MessageBar.tsx b/packages/module/src/MessageBar/MessageBar.tsx index 037c6b9aa..fe6a60a0e 100644 --- a/packages/module/src/MessageBar/MessageBar.tsx +++ b/packages/module/src/MessageBar/MessageBar.tsx @@ -104,6 +104,8 @@ export interface MessageBarProps extends Omit { isCompact?: boolean; /** Ref applied to message bar textarea, for use with focus or other custom behaviors */ innerRef?: React.Ref; + /** Sets background color to primary */ + isPrimary?: boolean; } export const MessageBarBase: FunctionComponent = ({ @@ -134,6 +136,7 @@ export const MessageBarBase: FunctionComponent = ({ validator, dropzoneProps, innerRef, + isPrimary, ...props }: MessageBarProps) => { // Text Input @@ -453,7 +456,11 @@ export const MessageBarBase: FunctionComponent = ({ ); } - return
{messageBarContents}
; + return ( +
+ {messageBarContents} +
+ ); }; const MessageBar = forwardRef((props: MessageBarProps, ref: Ref) => (