From 889f1a865bf793d4854fbf6513e3b634d6c8fab9 Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Tue, 25 Jul 2023 20:13:44 +0200 Subject: [PATCH] Add Actions menu to ChatItems --- .../public/components/chat/chat_item.tsx | 227 ++++++++++++++---- .../components/chat/chat_item_title.tsx | 49 +++- .../components/chat/chat_timeline.stories.tsx | 19 +- .../public/components/chat/chat_timeline.tsx | 9 +- .../hooks/__storybook_mocks__/use_kibana.ts | 6 + .../public/utils/builders.ts | 2 +- 6 files changed, 242 insertions(+), 70 deletions(-) diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item.tsx index 8b0a5ee28d594..7b89073a22b09 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item.tsx @@ -5,89 +5,212 @@ * 2.0. */ -import React from 'react'; +import React, { useState } from 'react'; +import { noop } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { css } from '@emotion/react'; -import { EuiText, EuiComment } from '@elastic/eui'; +import { + EuiButtonIcon, + EuiComment, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiPopover, +} from '@elastic/eui'; import type { AuthenticatedUser } from '@kbn/security-plugin/common'; import { MessageRole, Message } from '../../../common/types'; import { ChatItemAvatar } from './chat_item_avatar'; import { ChatItemTitle } from './chat_item_title'; import { MessagePanel } from '../message_panel/message_panel'; import { FeedbackButtons, Feedback } from '../feedback_buttons'; +import { MessageText } from '../message_panel/message_text'; +import { useKibana } from '../../hooks/use_kibana'; -const roleMap = { - [MessageRole.User]: i18n.translate( - 'xpack.observabilityAiAssistant.chatTimeline.messages.userLabel', - { defaultMessage: 'You' } - ), - [MessageRole.System]: i18n.translate( - 'xpack.observabilityAiAssistant.chatTimeline.messages.systemLabel', - { defaultMessage: 'System' } - ), - [MessageRole.Assistant]: i18n.translate( - 'xpack.observabilityAiAssistant.chatTimeline.messages.assistantLabel', - { defaultMessage: 'Elastic Assistant' } - ), - [MessageRole.Function]: i18n.translate( - 'xpack.observabilityAiAssistant.chatTimeline.messages.functionLabel', - { defaultMessage: 'Elastic Assistant' } - ), - [MessageRole.Event]: i18n.translate( - 'xpack.observabilityAiAssistant.chatTimeline.messages.functionLabel', - { defaultMessage: 'Elastic Assistant' } - ), - [MessageRole.Elastic]: i18n.translate( - 'xpack.observabilityAiAssistant.chatTimeline.messages.functionLabel', - { defaultMessage: 'Elastic Assistant' } - ), -}; +export interface ChatItemAction { + id: string; + label: string; + icon?: string; + handler: () => void; +} export interface ChatItemProps { currentUser: AuthenticatedUser | undefined; dateFormat: string; index: number; + isLoading: boolean; message: Message; + onEditMessage?: (id: string) => void; onFeedbackClick: (feedback: Feedback) => void; + onRegenerateMessage?: (id: string) => void; } export function ChatItem({ currentUser, dateFormat, index, + isLoading, message, onFeedbackClick, + onEditMessage, + onRegenerateMessage, }: ChatItemProps) { + const { + notifications: { toasts }, + } = useKibana().services; + const [isActionsPopoverOpen, setIsActionsPopover] = useState(false); + + const handleClickActions = () => { + setIsActionsPopover(!isActionsPopoverOpen); + }; + + const actionsMap: Record = { + [MessageRole.User]: [ + { + id: 'edit', + label: i18n.translate('xpack.observabilityAiAssistant.chatTimeline.actions.editMessage', { + defaultMessage: 'Edit message', + }), + handler: () => { + onEditMessage?.(message['@timestamp']); + setIsActionsPopover(false); + }, + }, + ], + [MessageRole.Function]: [ + { + id: 'edit', + label: i18n.translate('xpack.observabilityAiAssistant.chatTimeline.actions.editFunction', { + defaultMessage: 'Edit function', + }), + handler: () => { + onEditMessage?.(message['@timestamp']); + setIsActionsPopover(false); + }, + }, + ], + [MessageRole.Assistant]: [ + { + id: 'copy', + label: i18n.translate('xpack.observabilityAiAssistant.chatTimeline.actions.copyMessage', { + defaultMessage: 'Copy message', + }), + handler: message.message.content + ? async () => { + try { + await navigator.clipboard.writeText(message.message.content || ''); + toasts.addSuccess( + i18n.translate( + 'xpack.observabilityAiAssistant.chatTimeline.actions.copyMessageSuccess', + { + defaultMessage: 'Copied to clipboard', + } + ) + ); + setIsActionsPopover(false); + } catch (error) { + toasts.addError( + error, + i18n.translate( + 'xpack.observabilityAiAssistant.chatTimeline.actions.copyMessageError', + { + defaultMessage: 'Error while copying to clipboard', + } + ) + ); + setIsActionsPopover(false); + } + } + : noop, + }, + { + id: 'regenerate', + label: i18n.translate('xpack.observabilityAiAssistant.chatTimeline.actions.regenerate', { + defaultMessage: 'Regenerate response', + }), + handler: () => { + onRegenerateMessage?.(message['@timestamp']); + setIsActionsPopover(false); + }, + }, + ], + [MessageRole.System]: [], + [MessageRole.Event]: [], + [MessageRole.Elastic]: [], + }; + return ( } - timelineAvatar={} - username={roleMap[message.message.role]} - css={ - message.message.role !== MessageRole.User - ? css` - .euiCommentEvent__body { - padding: 0; - } - ` - : '' + event={ + + } + panelPaddingSize="s" + closePopover={handleClickActions} + isOpen={isActionsPopoverOpen} + > + ( + + {label} + + ))} + /> + + ) : null + } + message={message} + index={index} + dateFormat={dateFormat} + /> } + timelineAvatar={} + username={getRoleTranslation(message.message.role)} > {message.message.content ? ( - <> - {message.message.role === MessageRole.User ? ( - -

{message.message.content}

-
- ) : ( - } - /> - )} - + } + controls={ + message.message.role !== MessageRole.User ? ( + + ) : null + } + /> ) : null}
); } + +const getRoleTranslation = (role: MessageRole) => { + if (role === MessageRole.User) { + return i18n.translate('xpack.observabilityAiAssistant.chatTimeline.messages.user.label', { + defaultMessage: 'You', + }); + } + + if (role === MessageRole.System) { + return i18n.translate('xpack.observabilityAiAssistant.chatTimeline.messages.system.label', { + defaultMessage: 'System', + }); + } + + return i18n.translate( + 'xpack.observabilityAiAssistant.chatTimeline.messages.elasticAssistant.label', + { + defaultMessage: 'Elastic Assistant', + } + ); +}; diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item_title.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item_title.tsx index 393886fbef800..a3f7b2a99747e 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item_title.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item_title.tsx @@ -5,21 +5,26 @@ * 2.0. */ +import React, { ReactNode } from 'react'; import moment from 'moment'; import { i18n } from '@kbn/i18n'; +import { euiThemeVars } from '@kbn/ui-theme'; import { Message, MessageRole } from '../../../common/types'; interface ChatItemTitleProps { + actionsTrigger?: ReactNode; dateFormat: string; index: number; message: Message; } -export function ChatItemTitle({ dateFormat, index, message }: ChatItemTitleProps) { +export function ChatItemTitle({ actionsTrigger, dateFormat, index, message }: ChatItemTitleProps) { + let content: string = ''; + switch (message.message.role) { case MessageRole.User: if (index === 0) { - return i18n.translate( + content = i18n.translate( 'xpack.observabilityAiAssistant.chatTimeline.messages.user.createdNewConversation', { defaultMessage: 'created a new conversation on {date}', @@ -29,21 +34,22 @@ export function ChatItemTitle({ dateFormat, index, message }: ChatItemTitleProps } ); } else { - return i18n.translate( + content = i18n.translate( 'xpack.observabilityAiAssistant.chatTimeline.messages.user.addedPrompt', { - defaultMessage: 'added a prompt on {date}', + defaultMessage: 'added a message on {date}', values: { date: moment(message['@timestamp']).format(dateFormat), }, } ); } + break; case MessageRole.Assistant: case MessageRole.Elastic: case MessageRole.Function: - return i18n.translate( + content = i18n.translate( 'xpack.observabilityAiAssistant.chatTimeline.messages.elasticAssistant.responded', { defaultMessage: 'responded on {date}', @@ -52,17 +58,34 @@ export function ChatItemTitle({ dateFormat, index, message }: ChatItemTitleProps }, } ); + break; case MessageRole.System: - return i18n.translate('xpack.observabilityAiAssistant.chatTimeline.messages.system.added', { - defaultMessage: 'added {thing} on {date}', - values: { - date: moment(message['@timestamp']).format(dateFormat), - thing: message.message.content, - }, - }); + content = i18n.translate( + 'xpack.observabilityAiAssistant.chatTimeline.messages.system.added', + { + defaultMessage: 'added {thing} on {date}', + values: { + date: moment(message['@timestamp']).format(dateFormat), + thing: message.message.content, + }, + } + ); + break; default: - return ''; + content = ''; + break; } + return ( + <> + {content} + + {actionsTrigger ? ( +
+ {actionsTrigger} +
+ ) : null} + + ); } diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_timeline.stories.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_timeline.stories.tsx index dea53f07da744..c4efd7d0e7206 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_timeline.stories.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_timeline.stories.tsx @@ -62,7 +62,10 @@ const defaultProps = { }), buildMessage({ '@timestamp': String(new Date(currentDate.getTime() + 2000)), - message: buildAssistantInnerMessage(), + message: buildAssistantInnerMessage({ + content: `In computer programming and mathematics, a function is a fundamental concept that represents a relationship between input values and output values. It takes one or more input values (also known as arguments or parameters) and processes them to produce a result, which is the output of the function. The input values are passed to the function, and the function performs a specific set of operations or calculations on those inputs to produce the desired output. + A function is often defined with a name, which serves as an identifier to call and use the function in the code. It can be thought of as a reusable block of code that can be executed whenever needed, and it helps in organizing code and making it more modular and maintainable.`, + }), }), buildMessage({ '@timestamp': String(new Date(currentDate.getTime() + 3000)), @@ -70,7 +73,19 @@ const defaultProps = { }), buildMessage({ '@timestamp': String(new Date(currentDate.getTime() + 4000)), - message: buildElasticInnerMessage({ content: 'Here you go.' }), + message: buildElasticInnerMessage({ + content: `The way functions work depends on whether we are talking about mathematical functions or programming functions. Let's explore both: + + Mathematical Functions: + In mathematics, a function maps input values to corresponding output values based on a specific rule or expression. The general process of how a mathematical function works can be summarized as follows: + Step 1: Input - You provide an input value to the function, denoted as 'x' in the notation f(x). This value represents the independent variable. + + Step 2: Processing - The function takes the input value and applies a specific rule or algorithm to it. This rule is defined by the function itself and varies depending on the function's expression. + + Step 3: Output - After processing the input, the function produces an output value, denoted as 'f(x)' or 'y'. This output represents the dependent variable and is the result of applying the function's rule to the input. + + Step 4: Uniqueness - A well-defined mathematical function ensures that each input value corresponds to exactly one output value. In other words, the function should yield the same output for the same input whenever it is called.`, + }), }), ], }; diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_timeline.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_timeline.tsx index dc5052b41360a..31a1916208306 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_timeline.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_timeline.tsx @@ -14,14 +14,17 @@ import { ChatItem } from './chat_item'; export interface ChatTimelineProps { messages: Message[]; + onEditMessage?: (id: string) => void; } -export function ChatTimeline({ messages = [] }: ChatTimelineProps) { +export function ChatTimeline({ messages = [], onEditMessage }: ChatTimelineProps) { const { uiSettings } = useKibana().services; const currentUser = useCurrentUser(); const dateFormat = uiSettings?.get('dateFormat'); + const handleFeedback = () => {}; + return ( {messages.map((message, index) => ( @@ -29,8 +32,10 @@ export function ChatTimeline({ messages = [] }: ChatTimelineProps) { currentUser={currentUser} dateFormat={dateFormat} index={index} + isLoading={false} message={message} - onFeedbackClick={() => {}} + onFeedbackClick={handleFeedback} + onEditMessage={onEditMessage} /> ))} diff --git a/x-pack/plugins/observability_ai_assistant/public/hooks/__storybook_mocks__/use_kibana.ts b/x-pack/plugins/observability_ai_assistant/public/hooks/__storybook_mocks__/use_kibana.ts index 8fdfe6d29c20b..41239c6e4af1a 100644 --- a/x-pack/plugins/observability_ai_assistant/public/hooks/__storybook_mocks__/use_kibana.ts +++ b/x-pack/plugins/observability_ai_assistant/public/hooks/__storybook_mocks__/use_kibana.ts @@ -15,6 +15,12 @@ export function useKibana() { } }, }, + notifications: { + toasts: { + addSuccess: () => {}, + addError: () => {}, + }, + }, }, }; } diff --git a/x-pack/plugins/observability_ai_assistant/public/utils/builders.ts b/x-pack/plugins/observability_ai_assistant/public/utils/builders.ts index 26206802fca4e..4b8e48cbbc1a2 100644 --- a/x-pack/plugins/observability_ai_assistant/public/utils/builders.ts +++ b/x-pack/plugins/observability_ai_assistant/public/utils/builders.ts @@ -33,7 +33,7 @@ export function buildUserInnerMessage( params: Partial = {} ): Message['message'] { return cloneDeep({ - ...{ content: "What's this function?", role: MessageRole.User, ...params }, + ...{ content: "What's a function?", role: MessageRole.User, ...params }, }); }