Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,26 @@ In this example, file download is implemented with [file-saver](https://www.npmj
```js file="./ChatbotTranscripts.tsx" isFullscreen

```

### Slash commands

This demo adds slash command support to a fullscreen ChatBot. The following lifts are required to wire up this behavior:

1. **Controlled `MessageBar`:** Pass `value`, `onChange`, and `innerRef` props to `MessageBar` so the consumer owns the input state and has access to the underlying `<textarea>` ref (lines 81, 91, 360-368).
2. **Trigger detection in `onChange`:** In the `onChange` handler, check whether the newly typed character is `/` (at the start of input or after a space). If so, record the trigger position and open the command menu (lines 169-180).
3. **Filtering and dismissal:** As the user continues typing after `/`, extract the text between the trigger position and the cursor to filter the command list. If a space is typed before a command is selected, close the menu and treat the text as a normal message (lines 181-197).
4. **Custom `onKeyDown`:** Pass an `onKeyDown` handler to `MessageBar` to intercept `ArrowUp`/`ArrowDown` (navigate the menu), `Enter` (select the focused command), and `Escape` (dismiss the menu) while the menu is open (lines 200-224, 364).
5. **PatternFly `Menu` + `Popper`:** Render a `<Menu>` with `<MenuItem>` entries inside a `<Popper>`, using the textarea ref as `triggerRef` and `placement="top-start"` to position the menu above the input (lines 282-311, 353-359).
6. **Command selection callback:** On select, clear the `MessageBar` value, close the menu, and fire the desired action (in this demo, a simulated bot response after a 3-second delay). The command text is never sent as a user message (lines 120-167).

```js file="./ChatbotSlashCommands.tsx" isFullscreen

```

### Commands with message context

This demo extends the slash command pattern to support command parsing at send time. Selecting a command inserts its name into the `MessageBar`, where users can add context and select additional commands. When the message is sent, valid command tokens are parsed and executed while the remaining text is passed along as context; command requests are not added as user messages.

```js file="./ChatbotCommandParsing.tsx" isFullscreen

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,342 @@
import { FunctionComponent, useEffect, useRef, useState } from 'react';
import { Brand, Bullseye, Menu, MenuContent, MenuItem, MenuList, Popper, 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 ChatbotHeader, { ChatbotHeaderMain, ChatbotHeaderTitle } from '@patternfly/chatbot/dist/dynamic/ChatbotHeader';

import PFHorizontalLogoColor from '../UI/PF-HorizontalLogo-Color.svg';
import PFHorizontalLogoReverse from '../UI/PF-HorizontalLogo-Reverse.svg';
import userAvatar from '../Messages/user_avatar.svg';

interface Command {
id: string;
command: string;
description: string;
}

const commands: Command[] = [
{ id: 'skills', command: '/skills', description: 'List available skills' },
{ id: 'help', command: '/help', description: 'Show help information' },
{ id: 'summarize', command: '/summarize', description: 'Summarize the conversation' },
{ id: 'clear', command: '/clear', description: 'Clear conversation history' },
{ id: 'feedback', command: '/feedback', description: 'Provide feedback' },
{ id: 'export', command: '/export', description: 'Export conversation' }
];

const commandResponses: Record<string, string> = {
skills: 'Listed the available skills.',
help: 'Displayed the available slash commands.',
summarize: 'Summarized the conversation.',
clear: 'Cleared the conversation history.',
feedback: 'Opened the feedback flow.',
export: 'Exported the conversation.'
};

const footnoteProps = {
label: 'Always review AI-generated content prior to use.'
};

const welcomePrompts = [
{
title: 'Run a command with context',
message: 'Type / to choose a command, then describe what you want it to do'
},
{
title: 'Try multiple commands',
message: 'For example, type /summarize and /export with additional context'
}
];

const generateId = () => (Date.now() + Math.random()).toString();

const getCommandMatches = (value: string) => {
const matches = value.match(/\/(skills|help|summarize|clear|feedback|export)\b/gi) || [];
return matches.map((match) => match.slice(1).toLowerCase());
};

export const ChatbotCommandParsingDemo: FunctionComponent = () => {
const [messages, setMessages] = useState<MessageProps[]>([]);
const [message, setMessage] = useState('');
const [isSendButtonDisabled, setIsSendButtonDisabled] = useState(false);
const [announcement, setAnnouncement] = useState<string>();
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
const [triggerPosition, setTriggerPosition] = useState(-1);
const [filteredCommands, setFilteredCommands] = useState<Command[]>([]);
const [activeItemIndex, setActiveItemIndex] = useState(0);

const scrollToBottomRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const chatbotRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (messages.length > 0) {
scrollToBottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [messages]);

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
menuRef.current &&
!menuRef.current.contains(event.target as Node) &&
textareaRef.current &&
!textareaRef.current.contains(event.target as Node)
) {
setIsCommandMenuOpen(false);
setTriggerPosition(-1);
}
};

document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);

const handleCommandSelect = (command: Command) => {
const cursorPosition = textareaRef.current?.selectionStart || message.length;
const beforeTrigger = message.substring(0, triggerPosition);
const afterCursor = message.substring(cursorPosition);
const newMessage = `${beforeTrigger}${command.command} ${afterCursor}`;
const newCursorPosition = beforeTrigger.length + command.command.length + 1;

setMessage(newMessage);
setIsCommandMenuOpen(false);
setTriggerPosition(-1);
setFilteredCommands([]);

setTimeout(() => {
textareaRef.current?.focus();
textareaRef.current?.setSelectionRange(newCursorPosition, newCursorPosition);
}, 0);
};

const handleChange = (_event: React.ChangeEvent<HTMLTextAreaElement>, value: string | number) => {
const newValue = value.toString();
const cursorPosition = textareaRef.current?.selectionStart || newValue.length;
setMessage(newValue);

const lastChar = newValue[cursorPosition - 1];
const commandStart = newValue.lastIndexOf('/', cursorPosition - 1);
const isCommandStart = commandStart === 0 || newValue[commandStart - 1] === ' ';

if (lastChar === '/' && isCommandStart) {
setTriggerPosition(commandStart);
setFilteredCommands(commands);
setActiveItemIndex(0);
setIsCommandMenuOpen(true);
} else if (isCommandMenuOpen && triggerPosition >= 0) {
const searchTerm = newValue.substring(triggerPosition + 1, cursorPosition);
if (searchTerm.includes(' ') || cursorPosition < triggerPosition) {
setIsCommandMenuOpen(false);
setTriggerPosition(-1);
} else {
const filtered = commands.filter(
(command) =>
command.command.toLowerCase().includes(`/${searchTerm.toLowerCase()}`) ||
command.description.toLowerCase().includes(searchTerm.toLowerCase())
);
setFilteredCommands(filtered);
setActiveItemIndex(0);
}
}
};

const handleCommandExecution = (commandIds: string[], context: string) => {
const results = commandIds.map((commandId) => {
const result = commandResponses[commandId];
return `**/${commandId}**: ${result}`;
});
const contextLine = context ? `\n\nContext supplied: ${context}` : '';
const responseMessage: MessageProps = {
id: generateId(),
role: 'bot',
content: `${results.join('\n')}\n${contextLine}`,
name: 'Bot',
timestamp: new Date().toLocaleString(),
actions: { positive: {}, negative: {}, copy: {}, download: {} }
};
setMessages((previous) => [...previous, responseMessage]);
setAnnouncement(`Ran ${commandIds.length} command${commandIds.length === 1 ? '' : 's'}.`);
};

const handleSend = (value: string | number) => {
const input = value.toString().trim();
if (!input) {
return;
}

const commandIds = getCommandMatches(input);
if (commandIds.length > 0) {
const context = input.replace(/\/(skills|help|summarize|clear|feedback|export)\b/gi, '').trim();
setIsSendButtonDisabled(true);
setMessage('');
setAnnouncement(`Running ${commandIds.length} command${commandIds.length === 1 ? '' : 's'}.`);
setMessages((previous) => [
...previous,
{
id: generateId(),
role: 'bot',
content: `Running ${commandIds.map((id) => `/${id}`).join(', ')}...`,
name: 'Bot',
isLoading: true,
timestamp: new Date().toLocaleString()
}
]);

setTimeout(() => {
setMessages((previous) => previous.slice(0, -1));
handleCommandExecution(commandIds, context);
setIsSendButtonDisabled(false);
}, 2000);
return;
}

setMessages((previous) => [
...previous,
{
id: generateId(),
role: 'user',
content: input,
name: 'User',
avatar: userAvatar,
timestamp: new Date().toLocaleString(),
avatarProps: { isBordered: true }
}
]);
setMessage('');
};

const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!isCommandMenuOpen || filteredCommands.length === 0) {
if (event.key === 'Enter' && !event.shiftKey && !isSendButtonDisabled) {
event.preventDefault();
handleSend(message);
}
return;
}

switch (event.key) {
case 'ArrowDown':
event.preventDefault();
setActiveItemIndex((previous) => (previous + 1) % filteredCommands.length);
break;
case 'ArrowUp':
event.preventDefault();
setActiveItemIndex((previous) => (previous - 1 + filteredCommands.length) % filteredCommands.length);
break;
case 'Enter':
event.preventDefault();
handleCommandSelect(filteredCommands[activeItemIndex]);
break;
case 'Escape':
event.preventDefault();
setIsCommandMenuOpen(false);
setTriggerPosition(-1);
break;
}
};

const commandMenu = (
<Menu
ref={menuRef}
onSelect={(_event, itemId) => {
const command = filteredCommands.find((item) => item.id === itemId?.toString());
if (command) {
handleCommandSelect(command);
}
}}
>
<MenuContent>
<MenuList>
{filteredCommands.length > 0 ? (
filteredCommands.map((command, index) => (
<MenuItem
key={command.id}
itemId={command.id}
description={command.description}
isFocused={index === activeItemIndex}
>
{command.command}
</MenuItem>
))
) : (
<MenuItem isDisabled>No commands found</MenuItem>
)}
</MenuList>
</MenuContent>
</Menu>
);

const horizontalLogo = (
<Bullseye>
<Brand className="show-light" src={PFHorizontalLogoColor} alt="PatternFly" />
<Brand className="show-dark" src={PFHorizontalLogoReverse} alt="PatternFly" />
</Bullseye>
);

const handleSkipToContent = (event: React.MouseEvent) => {
event.preventDefault();
chatbotRef.current?.focus();
};

return (
<>
<SkipToContent onClick={handleSkipToContent} href="#">
Skip to chatbot
</SkipToContent>
<Chatbot isVisible displayMode={ChatbotDisplayMode.fullscreen} ref={chatbotRef}>
<ChatbotHeader>
<ChatbotHeaderMain>
<ChatbotHeaderTitle displayMode={ChatbotDisplayMode.fullscreen} showOnFullScreen={horizontalLogo} />
</ChatbotHeaderMain>
</ChatbotHeader>
<ChatbotContent>
<MessageBox announcement={announcement}>
<ChatbotWelcomePrompt
title="Hi, ChatBot User!"
description="Choose one or more commands, add context, and send the message to run them."
prompts={welcomePrompts}
/>
{messages.map((item, index) => {
if (index === messages.length - 1) {
return (
<div key={item.id}>
<div ref={scrollToBottomRef}></div>
<Message {...item} />
</div>
);
}
return <Message key={item.id} {...item} />;
})}
</MessageBox>
</ChatbotContent>
<ChatbotFooter>
<Popper
triggerRef={textareaRef}
popper={commandMenu}
isVisible={isCommandMenuOpen}
enableFlip
placement="top-start"
/>
<MessageBar
onSendMessage={handleSend}
value={message}
onChange={handleChange}
onKeyDown={handleKeyDown}
innerRef={textareaRef}
hasAttachButton={false}
isSendButtonDisabled={isSendButtonDisabled}
placeholder='Type a message or "/" for commands...'
/>
<ChatbotFootnote {...footnoteProps} />
</ChatbotFooter>
</Chatbot>
</>
);
};
Loading
Loading