diff --git a/libs/chatbot/lib/components/ChatBot/BotMessage.tsx b/libs/chatbot/lib/components/ChatBot/BotMessage.tsx index 6c1d6bcc21..ea6ee11afa 100644 --- a/libs/chatbot/lib/components/ChatBot/BotMessage.tsx +++ b/libs/chatbot/lib/components/ChatBot/BotMessage.tsx @@ -2,8 +2,10 @@ import * as React from 'react'; import { Message } from '@patternfly-6/chatbot'; import MessageLoading from '@patternfly-6/chatbot/dist/cjs/Message/MessageLoading'; import { UserFeedbackProps } from '@patternfly-6/chatbot/dist/cjs/Message/UserFeedback/UserFeedback'; - -type MsgProps = React.ComponentProps; +import { MsgProps } from './helpers'; +import { Button, Stack, StackItem } from '@patternfly-6/react-core'; +import { saveAs } from 'file-saver'; +import { DownloadIcon } from '@patternfly-6/react-icons'; type SentimentActionClick = (isPositive: boolean) => void; @@ -86,7 +88,7 @@ const BotMessage = ({ }, [isNegativeFeedback, onScrollToBottom]); const actions = React.useMemo(() => { - return getActions(message.content || '', (positiveFeedback) => { + return getActions(message.pfProps.content || '', (positiveFeedback) => { if (positiveFeedback) { const submitPositiveFeedback = async () => { try { @@ -104,7 +106,7 @@ const BotMessage = ({ setIsNegativeFeedback(true); } }); - }, [message.content, onFeedbackSubmit, messageIndex]); + }, [message.pfProps.content, onFeedbackSubmit, messageIndex]); const userFeedbackFormConfig = React.useMemo(() => { return isNegativeFeedback @@ -131,16 +133,37 @@ const BotMessage = ({ }, [isNegativeFeedback, onFeedbackSubmit, messageIndex]); return ( - <> - , - }} - /> - + + ) : message.actions?.length ? ( + + {message.actions.map(({ title, url }, idx) => ( + + + + ))} + + ) : undefined, + }} + /> ); }; diff --git a/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx b/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx index ff6acc2a13..13178c1de5 100644 --- a/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx +++ b/libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx @@ -35,14 +35,18 @@ import { botRole, userRole, MsgProps, + getToolAction, } from './helpers'; import AIAvatar from '../../assets/rh-logo.svg'; import UserAvatar from '../../assets/avatarimg.svg'; - -type StreamEvent = - | { event: 'start'; data: { conversation_id: string } } - | { event: 'token'; data: { token: string; role: string } } - | { event: 'end' }; +import { + isEndStreamEvent, + isInferenceStreamEvent, + isStartStreamEvent, + isToolArgStreamEvent, + isToolResponseStreamEvent, + StreamEvent, +} from './types'; const CHAT_ALERT_LOCAL_STORAGE_KEY = 'assisted.hide.chat.alert'; @@ -67,7 +71,6 @@ const ChatBotWindow = ({ }: ChatBotWindowProps) => { const [msg, setMsg] = React.useState(''); const [error, setError] = React.useState(); - const [isLoading, setIsLoading] = React.useState(false); const [isStreaming, setIsStreaming] = React.useState(false); const [announcement, setAnnouncement] = React.useState(); const [isAlertVisible, setIsAlertVisible] = React.useState( @@ -109,24 +112,40 @@ const ChatBotWindow = ({ const handleSend = async (message: string | number) => { setError(undefined); - setIsLoading(true); + setIsStreaming(true); let reader: ReadableStreamDefaultReader | undefined = undefined; let eventEnded = false; + const timestamp = new Date().toLocaleString(); try { setMessages((msgs) => [ ...msgs, { - role: userRole, - content: `${message}`, - name: username, - avatar: UserAvatar, - timestamp: new Date().toLocaleString(), + pfProps: { + role: userRole, + content: `${message}`, + name: username, + avatar: UserAvatar, + timestamp, + }, }, ]); setAnnouncement(`Message from User: ${message}. Message from Bot is loading.`); let convId = ''; + setMessages((msgs) => [ + ...msgs, + { + pfProps: { + role: botRole, + content: '', + name: 'AI', + avatar: AIAvatar, + timestamp, + }, + }, + ]); + const resp = await onApiCall('/v1/streaming_query', { method: 'POST', body: JSON.stringify({ @@ -153,10 +172,9 @@ const ChatBotWindow = ({ reader = resp.body?.getReader(); const decoder = new TextDecoder(); - const timestamp = new Date().toLocaleString(); - let completeMsg = ''; let buffer = ''; + const toolArgs: { [key: number]: { [key: string]: string } } = {}; while (reader) { const { done, value } = await reader.read(); if (done) { @@ -175,41 +193,48 @@ const ChatBotWindow = ({ } } const ev = JSON.parse(data) as StreamEvent; - if (ev.event === 'end') { + if (isEndStreamEvent(ev)) { eventEnded = true; - } else if (ev.event === 'start') { + } else if (isStartStreamEvent(ev)) { convId = ev.data.conversation_id; - } else if (ev.event === 'token' && ev.data.role === 'inference' && !!ev.data.token) { - setIsLoading(false); - setIsStreaming(true); + } else if (isInferenceStreamEvent(ev)) { const token = ev.data.token; completeMsg = `${completeMsg}${token}`; setMessages((msgs) => { const lastMsg = msgs[msgs.length - 1]; - const msg = - lastMsg.timestamp === timestamp && lastMsg.role === botRole ? lastMsg : undefined; - if (!msg) { - return [ - ...msgs, - { - role: botRole, - content: token, - name: 'AI', - avatar: AIAvatar, - timestamp: timestamp, - }, - ]; - } - const allButLast = msgs.slice(0, -1); return [ ...allButLast, { - ...msg, - content: `${msg.content || ''}${token}`, + ...lastMsg, + pfProps: { + ...lastMsg.pfProps, + content: `${lastMsg.pfProps.content || ''}${token}`, + }, }, ]; }); + } else if (isToolArgStreamEvent(ev)) { + toolArgs[ev.data.id] = ev.data.token.arguments; + } else if (isToolResponseStreamEvent(ev)) { + const action = getToolAction({ + toolName: ev.data.token.tool_name, + response: ev.data.token.response, + args: toolArgs[ev.data.id], + }); + if (action) { + setMessages((msgs) => { + const lastMsg = msgs[msgs.length - 1]; + const allButLast = msgs.slice(0, -1); + return [ + ...allButLast, + { + ...lastMsg, + actions: lastMsg.actions ? [...lastMsg.actions, action] : [action], + }, + ]; + }); + } } } } @@ -231,7 +256,6 @@ const ChatBotWindow = ({ setError(getErrorMessage(e)); } finally { setIsStreaming(false); - setIsLoading(false); } }; @@ -249,7 +273,7 @@ const ChatBotWindow = ({ conversation_id: conversationId, user_question: getUserQuestionForBotAnswer(messages, botMessageIdx), user_feedback: req.userFeedback, - llm_response: messages[botMessageIdx].content || '', + llm_response: messages[botMessageIdx].pfProps.content || '', sentiment: req.sentiment, }), headers: { @@ -342,7 +366,7 @@ const ChatBotWindow = ({ )} {messages.map((message, index) => { const messageKey = conversationId ? `${conversationId}-${index}` : index; - const isBotMessage = message.role === botRole; + const isBotMessage = message.pfProps.role === botRole; if (isBotMessage) { return ( ; + return ; })} - {isLoading && } {error && ( void handleSend(msg)} - isSendButtonDisabled={isLoading || isStreaming || !msg.trim()} + isSendButtonDisabled={isStreaming || !msg.trim()} hasAttachButton={false} onChange={(_, value) => setMsg(`${value}`)} /> diff --git a/libs/chatbot/lib/components/ChatBot/helpers.ts b/libs/chatbot/lib/components/ChatBot/helpers.ts index f716c6151d..02c317b144 100644 --- a/libs/chatbot/lib/components/ChatBot/helpers.ts +++ b/libs/chatbot/lib/components/ChatBot/helpers.ts @@ -1,7 +1,10 @@ import isString from 'lodash-es/isString.js'; import { Message } from '@patternfly-6/chatbot'; -export type MsgProps = React.ComponentProps; +export type MsgProps = { + pfProps: React.ComponentProps; + actions?: { title: string; url: string }[]; +}; export const MESSAGE_BAR_ID = 'assisted-chatbot__message-bar'; export const botRole = 'bot'; @@ -37,10 +40,65 @@ export const getUserQuestionForBotAnswer = ( // Look backwards from the previous message to find the most recent user message for (let i = messageIndex - 1; i >= 0; i--) { const msg = messages[i]; - if (msg?.role === userRole && msg.content) { - return String(msg.content); + if (msg?.pfProps.role === userRole && msg.pfProps.content) { + return String(msg.pfProps.content); } } return undefined; }; + +type GetToolActionArgs = { + toolName: string; + response: string; + args?: { [key: string]: string }; +}; + +export const getToolAction = ({ + toolName, + response, + args, +}: GetToolActionArgs): { title: string; url: string } | undefined => { + switch (toolName) { + case 'cluster_iso_download_url': { + if (!response) { + return undefined; + } + let res: { url: string }[] | undefined = undefined; + try { + res = JSON.parse(response) as { + url: string; + }[]; + } catch { + return undefined; + } + + const url = res?.length ? res[0].url : undefined; + if (url) { + return { + title: 'Download ISO', + url, + }; + } + } + case 'cluster_credentials_download_url': { + if (!response) { + return undefined; + } + let res: { url: string } | undefined = undefined; + try { + res = JSON.parse(response) as { url: string }; + } catch { + return undefined; + } + + if (res?.url) { + return { + title: `Download ${args?.file_name || 'credentials'}`, + url: res.url, + }; + } + } + } + return undefined; +}; diff --git a/libs/chatbot/lib/components/ChatBot/types.ts b/libs/chatbot/lib/components/ChatBot/types.ts new file mode 100644 index 0000000000..7c8df38881 --- /dev/null +++ b/libs/chatbot/lib/components/ChatBot/types.ts @@ -0,0 +1,37 @@ +type StartStreamEvent = { event: 'start'; data: { conversation_id: string } }; +type EndStreamEvent = { event: 'end' }; +type InferenceStreamEvent = { event: 'token'; data: { token: string; role: 'inference' } }; +type ToolArgStreamEvent = { + event: 'tool_call'; + data: { + id: number; + token: { tool_name: string; arguments: { [key: string]: string } }; + role: string; + }; +}; +type ToolResponseStreamEvent = { + event: 'tool_call'; + data: { + id: number; + token: { tool_name: string; response: string }; + role: string; + }; +}; + +export type StreamEvent = + | StartStreamEvent + | EndStreamEvent + | InferenceStreamEvent + | ToolArgStreamEvent + | ToolResponseStreamEvent; + +export const isStartStreamEvent = (e: StreamEvent): e is StartStreamEvent => e.event === 'start'; +export const isEndStreamEvent = (e: StreamEvent): e is EndStreamEvent => e.event === 'end'; +export const isInferenceStreamEvent = (e: StreamEvent): e is InferenceStreamEvent => + e.event === 'token' && e.data.role === 'inference'; + +export const isToolArgStreamEvent = (e: StreamEvent): e is ToolArgStreamEvent => + e.event === 'tool_call' && typeof e.data.token === 'object' && 'arguments' in e.data.token; + +export const isToolResponseStreamEvent = (e: StreamEvent): e is ToolResponseStreamEvent => + e.event === 'tool_call' && typeof e.data.token === 'object' && 'response' in e.data.token; diff --git a/libs/chatbot/package.json b/libs/chatbot/package.json index b373cb0f26..303e7e9e35 100644 --- a/libs/chatbot/package.json +++ b/libs/chatbot/package.json @@ -38,6 +38,7 @@ "@patternfly-6/react-icons": "npm:@patternfly/react-icons@6.2.2", "@patternfly-6/react-styles": "npm:@patternfly/react-styles@6.2.2", "@patternfly-6/react-tokens": "npm:@patternfly/react-tokens@6.2.2", + "file-saver": "^2.0.2", "lodash-es": "^4.17.21" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index bdda8eb900..bee74c44f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -845,6 +845,7 @@ __metadata: "@types/node": ^18.14.6 "@types/react": ^18.0.0 "@types/react-dom": ^18.3.0 + file-saver: ^2.0.2 lodash-es: ^4.17.21 peerDependencies: react: ^18