diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithToolResponse.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithToolResponse.tsx new file mode 100644 index 000000000..6ae583dd0 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithToolResponse.tsx @@ -0,0 +1,135 @@ +import { useState, FunctionComponent, MouseEvent as ReactMouseEvent } from 'react'; +import Message from '@patternfly/chatbot/dist/dynamic/Message'; +import patternflyAvatar from './patternfly_avatar.jpg'; +import { CopyIcon, WrenchIcon } from '@patternfly/react-icons'; +import { + Button, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + ExpandableSection, + ExpandableSectionVariant, + Flex, + FlexItem, + Label +} from '@patternfly/react-core'; + +export const MessageWithToolResponseExample: FunctionComponent = () => { + const [isExpanded, setIsExpanded] = useState(false); + + const onToggle = (_event: ReactMouseEvent, isExpanded: boolean) => { + setIsExpanded(isExpanded); + }; + + return ( + + + + + + + + + toolName + + + + + Execution time: + 0.12 seconds + + + + + + + + + ), + cardBody: ( + <> + + + Parameters + + + Optional description text for parameters. + + + + + + + + + + + + + + + + + + + + + Response + + + Descriptive text about the tool response, including completion status, details on the data that was + processed, or anything else relevant to the use case. + + + + + + ) + }} + /> + ); +}; diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md index 3b9139095..caef87e76 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md @@ -34,7 +34,7 @@ import Message from '@patternfly/chatbot/dist/dynamic/Message'; import MessageDivider from '@patternfly/chatbot/dist/dynamic/MessageDivider'; import { rehypeCodeBlockToggle } from '@patternfly/chatbot/dist/esm/Message/Plugins/rehypeCodeBlockToggle'; import SourcesCard from '@patternfly/chatbot/dist/dynamic/SourcesCard'; -import { ArrowCircleDownIcon, ArrowRightIcon, CheckCircleIcon, CubeIcon, CubesIcon, DownloadIcon, InfoCircleIcon, OutlinedQuestionCircleIcon, RedoIcon, RobotIcon } from '@patternfly/react-icons'; +import { ArrowCircleDownIcon, ArrowRightIcon, CheckCircleIcon, CopyIcon, CubeIcon, CubesIcon, DownloadIcon, InfoCircleIcon, OutlinedQuestionCircleIcon, RedoIcon, RobotIcon, WrenchIcon } from '@patternfly/react-icons'; import patternflyAvatar from './patternfly_avatar.jpg'; import AttachmentEdit from '@patternfly/chatbot/dist/dynamic/AttachmentEdit'; import FileDetails from '@patternfly/chatbot/dist/dynamic/FileDetails'; @@ -178,6 +178,14 @@ The API for a source requires a link at minimum, but we strongly recommend provi ``` +### Messages with tool responses + +If you are using [model context protocol (MCP)](https://www.redhat.com/en/blog/model-context-protocol-discover-missing-link-ai-integration), you may find it useful to display information on tool responses as part of a message. Passing `toolResponse` to `` allows you to display a card with an optional subheading and body, as well as custom card content. Content is intentionally left fully customizable for now as this is an evolving area. + +```js file="./MessageWithToolResponse.tsx" + +``` + ### Messages with quick start tiles [Quick start](/extensions/quick-starts/) tiles can be added to messages via the `quickStarts` prop. Users can initiate the quick start from a link within the message tile. diff --git a/packages/module/src/Message/Message.test.tsx b/packages/module/src/Message/Message.test.tsx index e4bc49833..c64c191f3 100644 --- a/packages/module/src/Message/Message.test.tsx +++ b/packages/module/src/Message/Message.test.tsx @@ -675,6 +675,28 @@ describe('Message', () => { ); expect(screen.getAllByRole('img')[1]).toHaveAttribute('src', 'test.png'); }); + it('should handle tool response correctly', async () => { + render( + + ); + expect(screen.getByRole('button', { name: /Tool response: Name/i })).toBeTruthy(); + expect(screen.getByText('Thought for 3 seconds')).toBeTruthy(); + expect(screen.getByText('Lorem ipsum dolor sit amet')).toBeTruthy(); + expect(screen.getByText('Card title')).toBeTruthy(); + expect(screen.getByText('Card body')).toBeTruthy(); + }); it('should handle block quote correctly', () => { render(); expect(screen.getByText(/Blockquotes can also be nested.../)).toBeTruthy(); diff --git a/packages/module/src/Message/Message.tsx b/packages/module/src/Message/Message.tsx index bdc547d68..9e28d4f1e 100644 --- a/packages/module/src/Message/Message.tsx +++ b/packages/module/src/Message/Message.tsx @@ -49,6 +49,7 @@ import LinkMessage from './LinkMessage/LinkMessage'; import ErrorMessage from './ErrorMessage/ErrorMessage'; import MessageInput from './MessageInput'; import { rehypeMoveImagesOutOfParagraphs } from './Plugins/rehypeMoveImagesOutOfParagraphs'; +import ToolResponse, { ToolResponseProps } from '../ToolResponse'; export interface MessageAttachment { /** Name of file attached to the message */ @@ -189,6 +190,8 @@ export interface MessageProps extends Omit, 'role'> { isMarkdownDisabled?: boolean; /** Allows passing additional props down to markdown parser react-markdown, such as allowedElements and disallowedElements. See https://github.com/remarkjs/react-markdown?tab=readme-ov-file#options for options */ reactMarkdownProps?: Options; + /** Props for tool response card */ + toolResponse?: ToolResponseProps; } export const MessageBase: FunctionComponent = ({ @@ -230,6 +233,7 @@ export const MessageBase: FunctionComponent = ({ isCompact, isMarkdownDisabled, reactMarkdownProps, + toolResponse, ...props }: MessageProps) => { const [messageText, setMessageText] = useState(content); @@ -376,6 +380,7 @@ export const MessageBase: FunctionComponent = ({
{renderMessage()} {afterMainContent && <>{afterMainContent}} + {toolResponse && } {!isLoading && sources && } {quickStarts && quickStarts.quickStart && ( { + const defaultProps = { + toggleContent: 'Tool response: toolName', + cardTitle: 'Title', + cardBody: 'Body' + }; + + it('should render with required props only', () => { + render(); + expect(screen.getByText('Title')).toBeTruthy(); + expect(screen.getByText('Body')).toBeTruthy(); + expect(screen.getByText('Tool response: toolName')).toBeTruthy(); + }); + + it('should render subheading when provided', () => { + const subheading = 'Tool execution result'; + render(); + expect(screen.getByText(subheading)).toBeTruthy(); + }); + + it('should render body content when provided', () => { + const body = 'This is the tool response body content'; + render(); + expect(screen.getByText(body)).toBeTruthy(); + }); + + it('should render with complex content including React elements', () => { + const body = ( +
+

Complex body content

+
    +
  • Item 1
  • +
  • Item 2
  • +
+
+ ); + const cardTitle = API Response; + const cardBody = ( +
+ {"{ status: 'success' }"} +
+ ); + + render(); + expect(screen.getByText('Complex body content')).toBeTruthy(); + expect(screen.getByText('Item 1')).toBeTruthy(); + expect(screen.getByText('Item 2')).toBeTruthy(); + expect(screen.getByText('API Response')).toBeTruthy(); + expect(screen.getByText("{ status: 'success' }")).toBeTruthy(); + }); + + it('should apply custom className from cardProps', () => { + const { container } = render( + + ); + expect(container.querySelector('.custom-tool-response-class')).toBeTruthy(); + }); + + it('should pass through expandableSectionProps', () => { + render(); + expect(document.querySelector('.custom-expandable-class')).toBeTruthy(); + }); + + it('should pass through toolResponseCardProps', () => { + render(); + expect(document.querySelector('.custom-card-class')).toBeTruthy(); + }); + + it('should not render subheading span when subheading is not provided', () => { + const { container } = render(); + const subheadingContainer = container.querySelector('.pf-chatbot__tool-response-subheading'); + expect(subheadingContainer).toBeFalsy(); + }); +}); diff --git a/packages/module/src/ToolResponse/ToolResponse.tsx b/packages/module/src/ToolResponse/ToolResponse.tsx new file mode 100644 index 000000000..62eb8bd3e --- /dev/null +++ b/packages/module/src/ToolResponse/ToolResponse.tsx @@ -0,0 +1,95 @@ +// ============================================================================ +// Tool Response Card +// ============================================================================ +import { + Card, + CardBody, + CardBodyProps, + CardProps, + CardTitle, + CardTitleProps, + Divider, + DividerProps, + ExpandableSection, + ExpandableSectionProps +} from '@patternfly/react-core'; +import { useState, type FunctionComponent } from 'react'; + +export interface ToolResponseProps { + /** Toggle content shown for expandable section */ + toggleContent: React.ReactNode; + /** Additional props passed to expandable section */ + expandableSectionProps?: Omit; + /** Subheading rendered inside expandable section */ + subheading?: string; + /** Body text rendered inside expandable section */ + body?: React.ReactNode | string; + /** Content passed into tool response card body */ + cardBody: React.ReactNode; + /** Content passed into tool response card title */ + cardTitle: React.ReactNode; + /** Additional props passed to main card */ + cardProps?: CardProps; + /** Additional props passed to main card body */ + cardBodyProps?: CardBodyProps; + /** Additional props passed to tool response card */ + toolResponseCardProps?: CardProps; + /** Additional props passed to tool response card body */ + toolResponseCardBodyProps?: CardBodyProps; + /** Additional props passed to tool response card divider */ + toolResponseCardDividerProps?: DividerProps; + /** Additional props passed to tool response card title */ + toolResponseCardTitleProps?: CardTitleProps; +} + +export const ToolResponse: FunctionComponent = ({ + body, + cardProps, + expandableSectionProps, + subheading, + cardBody, + cardTitle, + cardBodyProps, + toggleContent, + toolResponseCardBodyProps, + toolResponseCardDividerProps, + toolResponseCardProps, + toolResponseCardTitleProps +}: ToolResponseProps) => { + const [isExpanded, setIsExpanded] = useState(true); + + const onToggle = (_event: React.MouseEvent, isExpanded: boolean) => { + setIsExpanded(isExpanded); + }; + + return ( + + + +
+ {subheading && ( +
+ {subheading} +
+ )} + {body &&
{body}
} + + {cardTitle} + + {cardBody} + +
+
+
+
+ ); +}; + +export default ToolResponse; diff --git a/packages/module/src/ToolResponse/index.ts b/packages/module/src/ToolResponse/index.ts new file mode 100644 index 000000000..343a49848 --- /dev/null +++ b/packages/module/src/ToolResponse/index.ts @@ -0,0 +1,3 @@ +export { default } from './ToolResponse'; + +export * from './ToolResponse'; diff --git a/packages/module/src/index.ts b/packages/module/src/index.ts index 98167b41d..b80359696 100644 --- a/packages/module/src/index.ts +++ b/packages/module/src/index.ts @@ -84,5 +84,8 @@ export * from './SourcesCard'; export { default as TermsOfUse } from './TermsOfUse'; export * from './TermsOfUse'; +export { default as ToolResponse } from './ToolResponse'; +export * from './ToolResponse'; + export { default as tracking } from './tracking'; export * from './tracking'; diff --git a/packages/module/src/main.scss b/packages/module/src/main.scss index 699a59bd1..8be2f9622 100644 --- a/packages/module/src/main.scss +++ b/packages/module/src/main.scss @@ -33,6 +33,7 @@ @import './SourcesCard/SourcesCard.scss'; @import './SourceDetailsMenuItem/SourceDetailsMenuItem'; @import './TermsOfUse/TermsOfUse'; +@import './ToolResponse/ToolResponse'; .ws-full-page-utils { left: 0 !important;