Skip to content
Merged
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
51 changes: 37 additions & 14 deletions libs/chatbot/lib/components/ChatBot/BotMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Message>;
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;

Expand Down Expand Up @@ -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 {
Expand All @@ -104,7 +106,7 @@ const BotMessage = ({
setIsNegativeFeedback(true);
}
});
}, [message.content, onFeedbackSubmit, messageIndex]);
}, [message.pfProps.content, onFeedbackSubmit, messageIndex]);

const userFeedbackFormConfig = React.useMemo<UserFeedbackProps | undefined>(() => {
return isNegativeFeedback
Expand All @@ -131,16 +133,37 @@ const BotMessage = ({
}, [isNegativeFeedback, onFeedbackSubmit, messageIndex]);

return (
<>
<Message
{...message}
actions={isLoading ? undefined : actions}
userFeedbackForm={userFeedbackFormConfig}
extraContent={{
afterMainContent: isLoading && <MsgLoading />,
}}
/>
</>
<Message
{...message.pfProps}
actions={isLoading ? undefined : actions}
userFeedbackForm={userFeedbackFormConfig}
extraContent={{
afterMainContent: isLoading ? (
<MsgLoading />
) : message.actions?.length ? (
<Stack hasGutter>
{message.actions.map(({ title, url }, idx) => (
<StackItem key={idx}>
<Button
onClick={() => {
try {
saveAs(url);
} catch (error) {
// eslint-disable-next-line
console.error('Download failed: ', error);
}
}}
variant="secondary"
icon={<DownloadIcon />}
>
{title}
</Button>
</StackItem>
))}
</Stack>
) : undefined,
}}
/>
);
};

Expand Down
107 changes: 65 additions & 42 deletions libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -67,7 +71,6 @@ const ChatBotWindow = ({
}: ChatBotWindowProps) => {
const [msg, setMsg] = React.useState('');
const [error, setError] = React.useState<string>();
const [isLoading, setIsLoading] = React.useState(false);
const [isStreaming, setIsStreaming] = React.useState(false);
const [announcement, setAnnouncement] = React.useState<string>();
const [isAlertVisible, setIsAlertVisible] = React.useState(
Expand Down Expand Up @@ -109,24 +112,40 @@ const ChatBotWindow = ({

const handleSend = async (message: string | number) => {
setError(undefined);
setIsLoading(true);
setIsStreaming(true);
let reader: ReadableStreamDefaultReader<Uint8Array> | 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({
Expand All @@ -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) {
Expand All @@ -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],
},
];
});
}
}
}
}
Expand All @@ -231,7 +256,6 @@ const ChatBotWindow = ({
setError(getErrorMessage(e));
} finally {
setIsStreaming(false);
setIsLoading(false);
}
};

Expand All @@ -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: {
Expand Down Expand Up @@ -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 (
<BotMessage
Expand All @@ -356,9 +380,8 @@ const ChatBotWindow = ({
);
}

return <Message key={messageKey} {...message} />;
return <Message key={messageKey} {...message.pfProps} />;
})}
{isLoading && <Message isLoading role={botRole} avatar={AIAvatar} />}
{error && (
<ChatbotAlert
variant="danger"
Expand All @@ -375,7 +398,7 @@ const ChatBotWindow = ({
<MessageBar
id={MESSAGE_BAR_ID}
onSendMessage={() => void handleSend(msg)}
isSendButtonDisabled={isLoading || isStreaming || !msg.trim()}
isSendButtonDisabled={isStreaming || !msg.trim()}
hasAttachButton={false}
onChange={(_, value) => setMsg(`${value}`)}
/>
Expand Down
64 changes: 61 additions & 3 deletions libs/chatbot/lib/components/ChatBot/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import isString from 'lodash-es/isString.js';
import { Message } from '@patternfly-6/chatbot';

export type MsgProps = React.ComponentProps<typeof Message>;
export type MsgProps = {
pfProps: React.ComponentProps<typeof Message>;
actions?: { title: string; url: string }[];
};

export const MESSAGE_BAR_ID = 'assisted-chatbot__message-bar';
export const botRole = 'bot';
Expand Down Expand Up @@ -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;
};
Loading
Loading