diff --git a/x-pack/plugins/apm/public/components/app/error_group_details/error_sampler/error_sample_contextual_insight.tsx b/x-pack/plugins/apm/public/components/app/error_group_details/error_sampler/error_sample_contextual_insight.tsx index 5137713457d5ca..806ca0e59684b2 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_details/error_sampler/error_sample_contextual_insight.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_details/error_sampler/error_sample_contextual_insight.tsx @@ -54,27 +54,27 @@ export function ErrorSampleContextualInsight({ role: MessageRole.User, content: `I'm an SRE. I am looking at an exception and trying to understand what it means. - Your task is to describe what the error means and what it could be caused by. - - The error occurred on a service called ${serviceName}, which is a ${runtimeName} service written in ${languageName}. The - runtime version is ${runtimeVersion}. - - The request it occurred for is called ${transactionName}. - - ${ - logStacktrace - ? `The log stacktrace: - ${logStacktrace}` - : '' - } - - ${ - exceptionStacktrace - ? `The exception stacktrace: - ${exceptionStacktrace}` - : '' - } - `, +Your task is to describe what the error means and what it could be caused by. + +The error occurred on a service called ${serviceName}, which is a ${runtimeName} service written in ${languageName}. The +runtime version is ${runtimeVersion}. + +The request it occurred for is called ${transactionName}. + +${ + logStacktrace + ? `The log stacktrace: +${logStacktrace}` + : '' +} + +${ + exceptionStacktrace + ? `The exception stacktrace: +${exceptionStacktrace}` + : '' +} +`, }, }, ]; diff --git a/x-pack/plugins/observability_ai_assistant/common/types.ts b/x-pack/plugins/observability_ai_assistant/common/types.ts index 5cdabdceb5a2c9..134bdc7b606e17 100644 --- a/x-pack/plugins/observability_ai_assistant/common/types.ts +++ b/x-pack/plugins/observability_ai_assistant/common/types.ts @@ -12,7 +12,6 @@ export enum MessageRole { Assistant = 'assistant', User = 'user', Function = 'function', - Event = 'event', Elastic = 'elastic', } @@ -21,6 +20,7 @@ export interface Message { message: { content?: string; name?: string; + event?: string; role: MessageRole; function_call?: { name: string; diff --git a/x-pack/plugins/observability_ai_assistant/public/application.tsx b/x-pack/plugins/observability_ai_assistant/public/application.tsx index d0a104ed976f8e..869b805e3d3d9d 100644 --- a/x-pack/plugins/observability_ai_assistant/public/application.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/application.tsx @@ -37,6 +37,7 @@ export function Application({ ; + +const Template: ComponentStory = (props: ChatBodyProps) => { + return ( +
+ +
+ ); +}; + +const defaultProps: ChatBodyProps = { + initialConversation: { + '@timestamp': new Date().toISOString(), + conversation: { + title: 'My conversation', + }, + labels: {}, + numeric_labels: {}, + messages: [], + }, + connectors: { + connectors: [ + { + id: 'foo', + referencedByCount: 1, + actionTypeId: 'foo', + name: 'GPT-v8-ultra', + isPreconfigured: true, + isDeprecated: false, + isSystemAction: false, + }, + ], + loading: false, + error: undefined, + selectedConnector: 'foo', + selectConnector: () => {}, + }, + currentUser: { + username: 'elastic', + }, + chat: { + loading: false, + abort: () => {}, + generate: async () => { + return {} as any; + }, + }, +}; + +export const ChatBody = Template.bind({}); +ChatBody.args = defaultProps; diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_body.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_body.tsx new file mode 100644 index 00000000000000..92b8d85a9dc117 --- /dev/null +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_body.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiPanel } from '@elastic/eui'; +import { css } from '@emotion/css'; +import type { AuthenticatedUser } from '@kbn/security-plugin/common'; +import React from 'react'; +import { type ConversationCreateRequest } from '../../../common/types'; +import type { UseChatResult } from '../../hooks/use_chat'; +import type { UseGenAIConnectorsResult } from '../../hooks/use_genai_connectors'; +import { useTimeline } from '../../hooks/use_timeline'; +import { ChatHeader } from './chat_header'; +import { ChatPromptEditor } from './chat_prompt_editor'; +import { ChatTimeline } from './chat_timeline'; + +const containerClassName = css` + max-height: 100%; +`; + +const timelineClassName = css` + overflow-y: auto; +`; + +export function ChatBody({ + initialConversation, + connectors, + currentUser, + chat, +}: { + initialConversation?: ConversationCreateRequest; + connectors: UseGenAIConnectorsResult; + currentUser?: Pick; + chat: UseChatResult; +}) { + const timeline = useTimeline({ + initialConversation, + connectors, + currentUser, + chat, + }); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_flyout.stories.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_flyout.stories.tsx deleted file mode 100644 index 1036011dcfc534..00000000000000 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_flyout.stories.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { ComponentStory } from '@storybook/react'; -import { ChatFlyout as Component, ChatFlyoutProps } from './chat_flyout'; -import { buildConversation } from '../../utils/builders'; - -export default { - component: Component, - title: 'app/Organisms/ChatFlyout', - argTypes: {}, -}; - -const Template: ComponentStory = (props: ChatFlyoutProps) => { - return ; -}; - -const defaultProps = { - conversation: buildConversation(), - connectors: { - connectors: [ - { - id: 'foo', - referencedByCount: 1, - actionTypeId: 'foo', - name: 'GPT-v8-ultra', - isPreconfigured: true, - isDeprecated: false, - isSystemAction: false, - }, - ], - loading: false, - error: undefined, - selectedConnector: 'foo', - selectConnector: () => {}, - }, -}; - -export const ChatFlyout = Template.bind({}); -ChatFlyout.args = defaultProps; diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_flyout.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_flyout.tsx index 1f74e9747a4565..437476d3552bab 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_flyout.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_flyout.tsx @@ -4,46 +4,37 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { EuiFlyout, EuiFlyoutBody, EuiFlyoutFooter, EuiFlyoutHeader } from '@elastic/eui'; -import { euiThemeVars } from '@kbn/ui-theme'; -import React, { useState } from 'react'; -import { ConversationCreateRequest } from '../../../common/types'; -import { UseGenAIConnectorsResult } from '../../hooks/use_genai_connectors'; -import { ChatHeader } from './chat_header'; -import { ChatPromptEditor } from './chat_prompt_editor'; -import { ChatTimeline } from './chat_timeline'; - -export interface ChatFlyoutProps { - conversation: ConversationCreateRequest; - connectors: UseGenAIConnectorsResult; -} - -export function ChatFlyout({ conversation, connectors }: ChatFlyoutProps) { - const { - conversation: { title }, - messages, - } = conversation; - - const [isOpen, setIsOpen] = useState(true); - - const handleSubmit = (prompt: string) => {}; +import { EuiFlyout } from '@elastic/eui'; +import React from 'react'; +import type { ConversationCreateRequest } from '../../../common/types'; +import { useChat } from '../../hooks/use_chat'; +import { useCurrentUser } from '../../hooks/use_current_user'; +import { useGenAIConnectors } from '../../hooks/use_genai_connectors'; +import { ChatBody } from './chat_body'; + +export function ChatFlyout({ + initialConversation, + isOpen, + onClose, +}: { + initialConversation: ConversationCreateRequest; + isOpen: boolean; + onClose: () => void; +}) { + const chat = useChat(); + + const connectors = useGenAIConnectors(); + + const currentUser = useCurrentUser(); return isOpen ? ( - setIsOpen(false)} size="m"> - - - - - - - - - - - + + ) : null; } 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 8145ec43f4ebc4..388dab83f3d336 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,25 +5,26 @@ * 2.0. */ -import React, { useState } from 'react'; -import { noop } from 'lodash'; -import { i18n } from '@kbn/i18n'; import { EuiButtonIcon, EuiComment, EuiContextMenuItem, EuiContextMenuPanel, + EuiFlexGroup, + EuiFlexItem, EuiPopover, } from '@elastic/eui'; -import type { AuthenticatedUser } from '@kbn/security-plugin/common'; -import { useKibana } from '../../hooks/use_kibana'; -import { MessageRole, Message } from '../../../common/types'; -import { ChatItemAvatar } from './chat_item_avatar'; -import { ChatItemTitle } from './chat_item_title'; -import { ChatItemControls } from './chat_item_controls'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { MessageRole } from '../../../common/types'; +import { Feedback, FeedbackButtons } from '../feedback_buttons'; import { MessagePanel } from '../message_panel/message_panel'; import { MessageText } from '../message_panel/message_text'; -import { Feedback } from '../feedback_buttons'; +import { RegenerateResponseButton } from '../regenerate_response_button'; +import { StopGeneratingButton } from '../stop_generating_button'; +import { ChatItemAvatar } from './chat_item_avatar'; +import { ChatItemTitle } from './chat_item_title'; +import { ChatTimelineItem } from './chat_timeline'; export interface ChatItemAction { id: string; @@ -32,126 +33,81 @@ export interface ChatItemAction { handler: () => void; } -export interface ChatItemProps { - currentUser: AuthenticatedUser | undefined; - dateFormat: string; - index: number; - isLoading: boolean; - message: Message; - onEditMessage?: (id: string) => void; +export interface ChatItemProps extends ChatTimelineItem { + onEditSubmit: (content: string) => void; onFeedbackClick: (feedback: Feedback) => void; - onRegenerateMessage?: (id: string) => void; + onRegenerateClick: () => void; + onStopGeneratingClick: () => void; } export function ChatItem({ + title, + content, + canEdit, + canGiveFeedback, + canRegenerate, + role, + loading, + error, currentUser, - dateFormat, - index, - isLoading, - message, + onEditSubmit, + onRegenerateClick, + onStopGeneratingClick, 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); + const [_, setEditing] = useState(false); + + const actions: ChatItemAction[] = canEdit + ? [ + { + id: 'edit', + label: i18n.translate('xpack.observabilityAiAssistant.chatTimeline.actions.editMessage', { + defaultMessage: 'Edit message', + }), + handler: () => { + setEditing(false); + setIsActionsPopover(false); + }, }, - }, - ], - [MessageRole.System]: [], - [MessageRole.Event]: [], - [MessageRole.Elastic]: [], - }; + ] + : []; + + let controls: React.ReactNode; - const canReceiveFeedback = [ - MessageRole.Assistant, - MessageRole.Elastic, - MessageRole.Function, - ].includes(message.message.role); + const displayFeedback = !error && canGiveFeedback; + const displayRegenerate = !loading && canRegenerate; - const canRegenerateResponse = message.message.role === MessageRole.Assistant; + if (loading) { + controls = ; + } else if (displayFeedback || displayRegenerate) { + controls = ( + + {displayFeedback ? ( + + + + ) : null} + {displayRegenerate ? ( + + + + ) : null} + + ); + } return ( ( + items={actions.map(({ id, icon, label, handler }) => ( {label} @@ -184,28 +140,21 @@ export function ChatItem({ ) : null } - message={message} - index={index} - dateFormat={dateFormat} + title={title} /> } - timelineAvatar={} - username={getRoleTranslation(message.message.role)} + timelineAvatar={} + username={getRoleTranslation(role)} > - {message.message.content ? ( + {content !== undefined || error || loading ? ( } - controls={ - canReceiveFeedback || canRegenerateResponse ? ( - onRegenerateMessage?.(message['@timestamp'])} - /> + body={ + content !== undefined || loading ? ( + ) : null } + error={error} + controls={controls} /> ) : null} diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item_avatar.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item_avatar.tsx index 0fb624bab89089..6afa522d426d2c 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item_avatar.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item_avatar.tsx @@ -13,7 +13,7 @@ import { AssistantAvatar } from '../assistant_avatar'; import { MessageRole } from '../../../common/types'; interface ChatAvatarProps { - currentUser?: AuthenticatedUser | undefined; + currentUser?: Pick | undefined; role: MessageRole; } diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item_controls.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item_controls.tsx deleted file mode 100644 index ea6bc9bc1b4281..00000000000000 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item_controls.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React from 'react'; -import { MessageRole } from '../../../common'; -import { Feedback, FeedbackButtons } from '../feedback_buttons'; -import { RegenerateResponseButton } from '../regenerate_response_button'; - -interface ChatItemControls { - role: MessageRole; - onFeedbackClick: (feedback: Feedback) => void; - onRegenerateClick: () => void; - canReceiveFeedback: boolean; - canRegenerateResponse: boolean; -} - -export function ChatItemControls({ - role, - onFeedbackClick, - onRegenerateClick, - canReceiveFeedback, - canRegenerateResponse, -}: ChatItemControls) { - return canReceiveFeedback || canRegenerateResponse ? ( - <> - - - {canReceiveFeedback ? : null} - - - {canRegenerateResponse ? : null} - - - - ) : null; -} 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 a3f7b2a99747e1..2749ef3635f40a 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,82 +5,18 @@ * 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'; +import React, { ReactNode } from 'react'; interface ChatItemTitleProps { actionsTrigger?: ReactNode; - dateFormat: string; - index: number; - message: Message; + title: string; } -export function ChatItemTitle({ actionsTrigger, dateFormat, index, message }: ChatItemTitleProps) { - let content: string = ''; - - switch (message.message.role) { - case MessageRole.User: - if (index === 0) { - content = i18n.translate( - 'xpack.observabilityAiAssistant.chatTimeline.messages.user.createdNewConversation', - { - defaultMessage: 'created a new conversation on {date}', - values: { - date: moment(message['@timestamp']).format(dateFormat), - }, - } - ); - } else { - content = i18n.translate( - 'xpack.observabilityAiAssistant.chatTimeline.messages.user.addedPrompt', - { - defaultMessage: 'added a message on {date}', - values: { - date: moment(message['@timestamp']).format(dateFormat), - }, - } - ); - } - break; - - case MessageRole.Assistant: - case MessageRole.Elastic: - case MessageRole.Function: - content = i18n.translate( - 'xpack.observabilityAiAssistant.chatTimeline.messages.elasticAssistant.responded', - { - defaultMessage: 'responded on {date}', - values: { - date: moment(message['@timestamp']).format(dateFormat), - }, - } - ); - break; - - case MessageRole.System: - 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: - content = ''; - break; - } +export function ChatItemTitle({ actionsTrigger, title }: ChatItemTitleProps) { return ( <> - {content} - + {title} {actionsTrigger ? (
{actionsTrigger} diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_prompt_editor.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_prompt_editor.tsx index 6fdb74e90c1396..6e06590ed36210 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_prompt_editor.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_prompt_editor.tsx @@ -19,10 +19,15 @@ import { i18n } from '@kbn/i18n'; import { useFunctions, Func } from '../../hooks/use_functions'; export interface ChatPromptEditorProps { - onSubmitPrompt: (prompt: string) => void; + disabled: boolean; + loading: boolean; + onSubmit: (message: { + content?: string; + function_call?: { name: string; args?: string }; + }) => Promise; } -export function ChatPromptEditor({ onSubmitPrompt }: ChatPromptEditorProps) { +export function ChatPromptEditor({ onSubmit, disabled, loading }: ChatPromptEditorProps) { const functions = useFunctions(); const [prompt, setPrompt] = useState(''); @@ -33,7 +38,15 @@ export function ChatPromptEditor({ onSubmitPrompt }: ChatPromptEditorProps) { }; const handleSubmit = () => { - onSubmitPrompt(prompt); + const currentPrompt = prompt; + setPrompt(''); + onSubmit({ content: currentPrompt }) + .then(() => { + setPrompt(''); + }) + .catch(() => { + setPrompt(currentPrompt); + }); }; const handleClickFunctionList = () => { @@ -79,11 +92,13 @@ export function ChatPromptEditor({ onSubmitPrompt }: ChatPromptEditorProps) { defaultMessage: 'Press ‘space’ or ‘$’ for function recommendations', })} onChange={handleChange} + onSubmit={handleSubmit} /> = (props: ChatTimelineProps) => return ( <> - index <= count)} /> + index <= count)} /> setCount(count >= 0 && count < props.messages.length - 1 ? count + 1 : 0)} + onClick={() => setCount(count >= 0 && count < props.items.length - 1 ? count + 1 : 0)} > Add message @@ -48,30 +46,15 @@ const Template: ComponentStory = (props: ChatTimelineProps) => ); }; -const currentDate = new Date(); - -const defaultProps = { - messages: [ - buildMessage({ - '@timestamp': String(new Date(currentDate.getTime())), - message: buildSystemInnerMessage(), - }), - buildMessage({ - '@timestamp': String(new Date(currentDate.getTime() + 1000)), - message: buildUserInnerMessage(), - }), - buildMessage({ - '@timestamp': String(new Date(currentDate.getTime() + 2000)), - message: buildAssistantInnerMessage(), - }), - buildMessage({ - '@timestamp': String(new Date(currentDate.getTime() + 3000)), - message: buildUserInnerMessage({ content: 'How does it work?' }), - }), - buildMessage({ - '@timestamp': String(new Date(currentDate.getTime() + 4000)), - message: buildElasticInnerMessage({ - content: `The way functions work depends on whether we are talking about mathematical functions or programming functions. Let's explore both: +const defaultProps: ComponentProps = { + items: [ + buildChatInitItem(), + buildSystemChatItem(), + buildUserChatItem(), + buildAssistantChatItem(), + buildUserChatItem({ content: 'How does it work?' }), + buildAssistantChatItem({ + 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: @@ -82,9 +65,12 @@ const defaultProps = { 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.`, - }), }), ], + onEdit: () => {}, + onFeedback: () => {}, + onRegenerate: () => {}, + onStopGenerating: () => {}, }; export const ChatTimeline = Template.bind({}); 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 31a19162083066..bfadafa7496a16 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 @@ -5,37 +5,62 @@ * 2.0. */ -import React from 'react'; import { EuiCommentList } from '@elastic/eui'; -import { useKibana } from '../../hooks/use_kibana'; -import { useCurrentUser } from '../../hooks/use_current_user'; -import { Message } from '../../../common/types'; +import type { AuthenticatedUser } from '@kbn/security-plugin/common'; +import React from 'react'; +import { MessageRole } from '../../../common/types'; +import type { Feedback } from '../feedback_buttons'; import { ChatItem } from './chat_item'; -export interface ChatTimelineProps { - messages: Message[]; - onEditMessage?: (id: string) => void; +export interface ChatTimelineItem { + id: string; + title: string; + role: MessageRole; + content?: string; + function_call?: { + name: string; + args?: string; + trigger?: MessageRole; + }; + loading: boolean; + error?: any; + canEdit: boolean; + canRegenerate: boolean; + canGiveFeedback: boolean; + currentUser?: Pick; } -export function ChatTimeline({ messages = [], onEditMessage }: ChatTimelineProps) { - const { uiSettings } = useKibana().services; - const currentUser = useCurrentUser(); - - const dateFormat = uiSettings?.get('dateFormat'); - - const handleFeedback = () => {}; +export interface ChatTimelineProps { + items: ChatTimelineItem[]; + onEdit: (item: ChatTimelineItem, content: string) => void; + onFeedback: (item: ChatTimelineItem, feedback: Feedback) => void; + onRegenerate: (item: ChatTimelineItem) => void; + onStopGenerating: () => void; +} +export function ChatTimeline({ + items = [], + onEdit, + onFeedback, + onRegenerate, + onStopGenerating, +}: ChatTimelineProps) { return ( - {messages.map((message, index) => ( + {items.map((item) => ( { + onFeedback(item, feedback); + }} + onRegenerateClick={() => { + onRegenerate(item); + }} + onEditSubmit={(content) => { + onEdit(item, content); + }} + onStopGeneratingClick={onStopGenerating} /> ))} diff --git a/x-pack/plugins/observability_ai_assistant/public/components/insight/insight.tsx b/x-pack/plugins/observability_ai_assistant/public/components/insight/insight.tsx index 4f9442efeaa708..2082095c625996 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/insight/insight.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/insight/insight.tsx @@ -4,9 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; -import type { Message } from '../../../common/types'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { type ConversationCreateRequest, type Message, MessageRole } from '../../../common/types'; import { useChat } from '../../hooks/use_chat'; import { useGenAIConnectors } from '../../hooks/use_genai_connectors'; import { ConnectorSelectorBase } from '../connector_selector/connector_selector_base'; @@ -16,30 +17,83 @@ import { InsightBase } from './insight_base'; import { InsightMissingCredentials } from './insight_missing_credentials'; import { StopGeneratingButton } from '../stop_generating_button'; import { RegenerateResponseButton } from '../regenerate_response_button'; +import { StartChatButton } from '../start_chat_button'; +import { ChatFlyout } from '../chat/chat_flyout'; function ChatContent({ messages, connectorId }: { messages: Message[]; connectorId: string }) { - const chat = useChat({ messages, connectorId }); + const chat = useChat(); + + const { generate } = chat; + + useEffect(() => { + generate({ messages, connectorId }).catch(() => { + // error is handled in chat, and we don't do anything with the full response for now. + }); + }, [generate, messages, connectorId]); + + const initialConversation = useMemo(() => { + const time = new Date().toISOString(); + return { + '@timestamp': time, + messages: chat.content + ? messages.concat({ + '@timestamp': time, + message: { + role: MessageRole.Assistant, + content: chat.content, + }, + }) + : messages, + conversation: { + title: '', + }, + labels: {}, + numeric_labels: {}, + }; + }, [messages, chat.content]); + + const [isOpen, setIsOpen] = useState(false); return ( - } - error={chat.error} - controls={ - chat.loading ? ( - { - chat.abort(); - }} - /> - ) : ( - { - chat.regenerate(); - }} - /> - ) - } - /> + <> + } + error={chat.error} + controls={ + chat.loading ? ( + { + chat.abort(); + }} + /> + ) : ( + + + { + generate({ messages, connectorId }); + }} + /> + + + { + setIsOpen(() => true); + }} + /> + + + ) + } + /> + { + setIsOpen(() => false); + }} + initialConversation={initialConversation} + /> + ); } diff --git a/x-pack/plugins/observability_ai_assistant/public/components/page_template.tsx b/x-pack/plugins/observability_ai_assistant/public/components/page_template.tsx index 34da81618289a1..dbbb2db235f199 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/page_template.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/page_template.tsx @@ -8,10 +8,12 @@ import { css } from '@emotion/css'; import React from 'react'; import { useKibana } from '../hooks/use_kibana'; -const pageSectionClassName = css` +const pageSectionContentClassName = css` width: 100%; display: flex; flex-grow: 1; + padding-top: 0; + padding-bottom: 0; `; export function ObservabilityAIAssistantPageTemplate({ children }: { children: React.ReactNode }) { @@ -31,7 +33,7 @@ export function ObservabilityAIAssistantPageTemplate({ children }: { children: R alignment: 'horizontalCenter', restrictWidth: true, contentProps: { - className: pageSectionClassName, + className: pageSectionContentClassName, }, }} > diff --git a/x-pack/plugins/observability_ai_assistant/public/components/start_chat_button.tsx b/x-pack/plugins/observability_ai_assistant/public/components/start_chat_button.tsx index b40586c5cfe358..dedbc827af4b2e 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/start_chat_button.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/start_chat_button.tsx @@ -5,10 +5,10 @@ * 2.0. */ import React from 'react'; -import { EuiButton, EuiButtonProps } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -export function StartChatButton(props: Partial) { +export function StartChatButton(props: React.ComponentProps) { return ( {i18n.translate('xpack.observabilityAiAssistant.insight.response.startChat', { diff --git a/x-pack/plugins/observability_ai_assistant/public/hooks/__storybook_mocks__/use_chat_conversation.ts b/x-pack/plugins/observability_ai_assistant/public/hooks/__storybook_mocks__/use_chat_conversation.ts deleted file mode 100644 index 67416974e20e13..00000000000000 --- a/x-pack/plugins/observability_ai_assistant/public/hooks/__storybook_mocks__/use_chat_conversation.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Message } from '../../../common'; -import { - buildAssistantInnerMessage, - buildElasticInnerMessage, - buildMessage, - buildSystemInnerMessage, - buildUserInnerMessage, -} from '../../utils/builders'; - -interface UseChatConversationProps { - conversationId?: string; -} - -export function useChatConversation({ conversationId }: UseChatConversationProps): { - title: string; - messages: Message[]; -} { - const currentDate = new Date(); - - return { - title: 'Event name', - messages: [ - buildMessage({ - '@timestamp': String(new Date(currentDate.getTime())), - message: buildSystemInnerMessage(), - }), - buildMessage({ - '@timestamp': String(new Date(currentDate.getTime() + 1000)), - message: buildUserInnerMessage(), - }), - buildMessage({ - '@timestamp': String(new Date(currentDate.getTime() + 2000)), - 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)), - message: buildUserInnerMessage({ content: 'How does it work?' }), - }), - buildMessage({ - '@timestamp': String(new Date(currentDate.getTime() + 4000)), - 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/hooks/use_chat.test.ts b/x-pack/plugins/observability_ai_assistant/public/hooks/use_chat.test.ts index 0e8c51ce55f733..62b42a9ea758a8 100644 --- a/x-pack/plugins/observability_ai_assistant/public/hooks/use_chat.test.ts +++ b/x-pack/plugins/observability_ai_assistant/public/hooks/use_chat.test.ts @@ -24,8 +24,17 @@ const mockUseObservabilityAIAssistant = useObservabilityAIAssistant as jest.Mock typeof useObservabilityAIAssistant >; +const mockChat = jest.fn(); + +mockUseObservabilityAIAssistant.mockImplementation( + () => + ({ + chat: mockChat, + } as unknown as ObservabilityAIAssistantService) +); + function mockDeltas(deltas: Array>) { - return mockResponse( + mockResponse( Promise.resolve( new Observable((subscriber) => { async function simulateDelays() { @@ -54,11 +63,7 @@ function mockDeltas(deltas: Array>) { } function mockResponse(response: Promise) { - mockUseObservabilityAIAssistant.mockReturnValue({ - chat: jest.fn().mockImplementation(() => { - return response; - }), - } as unknown as ObservabilityAIAssistantService); + mockChat.mockReturnValueOnce(response); } describe('useChat', () => { @@ -70,10 +75,11 @@ describe('useChat', () => { it('returns the result of the chat API', async () => { mockDeltas([{ content: 'testContent' }]); - const { result, waitFor } = renderHook( - ({ messages, connectorId }) => useChat({ messages, connectorId }), - { initialProps: { messages: [], connectorId: 'myConnectorId' } } - ); + const { result, waitFor } = renderHook(() => useChat()); + + act(() => { + result.current.generate({ messages: [], connectorId: 'myConnectorId' }); + }); expect(result.current.loading).toBeTruthy(); expect(result.current.error).toBeUndefined(); @@ -87,13 +93,18 @@ describe('useChat', () => { it('handles 4xx and 5xx', async () => { mockResponse(Promise.reject(new Error())); - const { result, waitFor } = renderHook( - ({ messages, connectorId }) => useChat({ messages, connectorId }), - { initialProps: { messages: [], connectorId: 'myConnectorId' } } - ); + const { result, waitFor } = renderHook(() => useChat()); + + const catchMock = jest.fn(); + + act(() => { + result.current.generate({ messages: [], connectorId: 'myConnectorId' }).catch(catchMock); + }); await waitFor(() => result.current.loading === false, WAIT_OPTIONS); + expect(catchMock).toHaveBeenCalled(); + expect(result.current.error).toBeInstanceOf(Error); expect(result.current.content).toBeUndefined(); @@ -112,10 +123,11 @@ describe('useChat', () => { ) ); - const { result, waitFor } = renderHook( - ({ messages, connectorId }) => useChat({ messages, connectorId }), - { initialProps: { messages: [], connectorId: 'myConnectorId' } } - ); + const { result, waitFor } = renderHook(() => useChat()); + + act(() => { + result.current.generate({ messages: [], connectorId: 'myConnectorId' }).catch(() => {}); + }); await waitFor(() => result.current.loading === false, WAIT_OPTIONS); @@ -135,10 +147,11 @@ describe('useChat', () => { ) ); - const { result, waitFor, unmount } = renderHook( - ({ messages, connectorId }) => useChat({ messages, connectorId }), - { initialProps: { messages: [], connectorId: 'myConnectorId' } } - ); + const { result, waitFor, unmount } = renderHook(() => useChat()); + + act(() => { + result.current.generate({ messages: [], connectorId: 'myConnectorId' }); + }); await waitFor(() => result.current.content === 'foo', WAIT_OPTIONS); @@ -156,18 +169,21 @@ describe('useChat', () => { ) ); - const { result, waitFor, rerender } = renderHook( - ({ messages, connectorId }) => useChat({ messages, connectorId }), - { initialProps: { messages: [], connectorId: 'myConnectorId' } } - ); + const { result, waitFor } = renderHook(() => useChat()); + + act(() => { + result.current.generate({ messages: [], connectorId: 'myConnectorId' }); + }); await waitFor(() => result.current.content === 'foo', WAIT_OPTIONS); mockDeltas([{ content: 'bar' }]); - rerender({ messages: [], connectorId: 'bar' }); + act(() => { + result.current.generate({ messages: [], connectorId: 'myConnectorId' }); + }); - await waitFor(() => result.current.loading === false); + await waitFor(() => result.current.loading === false, WAIT_OPTIONS); expect(mockUseKibana().services.notifications?.showErrorDialog).not.toHaveBeenCalled(); @@ -187,10 +203,11 @@ describe('useChat', () => { }, ]); - const { result, waitForNextUpdate } = renderHook( - ({ messages, connectorId }) => useChat({ messages, connectorId }), - { initialProps: { messages: [], connectorId: 'myConnectorId' } } - ); + const { result, waitForNextUpdate } = renderHook(() => useChat()); + + act(() => { + result.current.generate({ messages: [], connectorId: 'myConnectorId' }); + }); await waitForNextUpdate(WAIT_OPTIONS); @@ -206,6 +223,9 @@ describe('useChat', () => { }); it('handles user aborts', async () => { + const thenMock = jest.fn(); + const catchMock = jest.fn(); + mockResponse( Promise.resolve( new Observable((subscriber) => { @@ -214,10 +234,13 @@ describe('useChat', () => { ) ); - const { result, waitForNextUpdate } = renderHook( - ({ messages, connectorId }) => useChat({ messages, connectorId }), - { initialProps: { messages: [], connectorId: 'myConnectorId' } } - ); + const { result, waitForNextUpdate, waitFor } = renderHook(() => useChat()); + + act(() => { + result.current + .generate({ messages: [], connectorId: 'myConnectorId' }) + .then(thenMock, catchMock); + }); await waitForNextUpdate(WAIT_OPTIONS); @@ -225,11 +248,24 @@ describe('useChat', () => { result.current.abort(); }); + await waitFor(() => thenMock.mock.calls.length > 0); + expect(mockUseKibana().services.notifications?.showErrorDialog).not.toHaveBeenCalled(); expect(result.current.content).toBe('foo'); expect(result.current.loading).toBe(false); expect(result.current.error).toBeInstanceOf(AbortError); + + expect(thenMock).toHaveBeenCalledWith({ + aborted: true, + content: 'foo', + function_call: { + args: '', + name: '', + }, + }); + + expect(catchMock).not.toHaveBeenCalled(); }); it('handles user regenerations', async () => { @@ -241,16 +277,17 @@ describe('useChat', () => { ) ); - const { result, waitForNextUpdate } = renderHook( - ({ messages, connectorId }) => useChat({ messages, connectorId }), - { initialProps: { messages: [], connectorId: 'myConnectorId' } } - ); + const { result, waitForNextUpdate } = renderHook(() => useChat()); + + act(() => { + result.current.generate({ messages: [], connectorId: 'myConnectorId' }); + }); await waitForNextUpdate(WAIT_OPTIONS); act(() => { mockDeltas([{ content: 'bar' }]); - result.current.regenerate(); + result.current.generate({ messages: [], connectorId: 'mySecondConnectorId' }); }); await waitForNextUpdate(WAIT_OPTIONS); diff --git a/x-pack/plugins/observability_ai_assistant/public/hooks/use_chat.ts b/x-pack/plugins/observability_ai_assistant/public/hooks/use_chat.ts index 36a957cc1d660e..32232c011d9895 100644 --- a/x-pack/plugins/observability_ai_assistant/public/hooks/use_chat.ts +++ b/x-pack/plugins/observability_ai_assistant/public/hooks/use_chat.ts @@ -5,34 +5,40 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { AbortError } from '@kbn/kibana-utils-plugin/common'; import { clone } from 'lodash'; import { useCallback, useEffect, useRef, useState } from 'react'; import { concatMap, delay, of } from 'rxjs'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { i18n } from '@kbn/i18n'; -import { AbortError } from '@kbn/kibana-utils-plugin/common'; import type { Message } from '../../common/types'; import { useObservabilityAIAssistant } from './use_observability_ai_assistant'; interface MessageResponse { content?: string; function_call?: { - name?: string; + name: string; args?: string; }; } -export function useChat({ messages, connectorId }: { messages: Message[]; connectorId: string }): { +export interface UseChatResult { content?: string; function_call?: { - name?: string; + name: string; args?: string; }; loading: boolean; error?: Error; abort: () => void; - regenerate: () => void; -} { + generate: (options: { messages: Message[]; connectorId: string }) => Promise<{ + content?: string; + function_call?: { name: string; args?: string }; + aborted?: boolean; + }>; +} + +export function useChat(): UseChatResult { const assistant = useObservabilityAIAssistant(); const { @@ -47,82 +53,88 @@ export function useChat({ messages, connectorId }: { messages: Message[]; connec const controllerRef = useRef(new AbortController()); - const regenerate = useCallback(() => { - controllerRef.current.abort(); + const generate = useCallback( + ({ messages, connectorId }: { messages: Message[]; connectorId: string }) => { + controllerRef.current.abort(); - const controller = (controllerRef.current = new AbortController()); - - setResponse(undefined); - setError(undefined); - setLoading(true); - - const partialResponse = { - content: '', - function_call: { - name: '', - args: '', - }, - }; - - assistant - .chat({ messages, connectorId, signal: controller.signal }) - .then((response$) => { - return new Promise((resolve, reject) => { - const subscription = response$ - .pipe(concatMap((value) => of(value).pipe(delay(50)))) - .subscribe({ - next: (chunk) => { - if (controller.signal.aborted) { - return; - } - partialResponse.content += chunk.choices[0].delta.content ?? ''; - partialResponse.function_call.name += - chunk.choices[0].delta.function_call?.name ?? ''; - partialResponse.function_call.args += - chunk.choices[0].delta.function_call?.args ?? ''; - setResponse(clone(partialResponse)); - }, - error: (err) => { - reject(err); - }, - complete: () => { - resolve(); - }, + const controller = (controllerRef.current = new AbortController()); + + setResponse(undefined); + setError(undefined); + setLoading(true); + + const partialResponse = { + content: '', + function_call: { + name: '', + args: '', + }, + }; + + return assistant + .chat({ messages, connectorId, signal: controller.signal }) + .then((response$) => { + return new Promise((resolve, reject) => { + const subscription = response$ + .pipe(concatMap((value) => of(value).pipe(delay(50)))) + .subscribe({ + next: (chunk) => { + if (controller.signal.aborted) { + return; + } + partialResponse.content += chunk.choices[0].delta.content ?? ''; + partialResponse.function_call.name += + chunk.choices[0].delta.function_call?.name ?? ''; + partialResponse.function_call.args += + chunk.choices[0].delta.function_call?.args ?? ''; + setResponse(clone(partialResponse)); + }, + error: (err) => { + reject(err); + }, + complete: () => { + resolve(); + }, + }); + + controller.signal.addEventListener('abort', () => { + subscription.unsubscribe(); + reject(new AbortError()); }); - - controllerRef.current.signal.addEventListener('abort', () => { - subscription.unsubscribe(); - reject(new AbortError()); }); + }) + .then(() => { + return Promise.resolve(partialResponse); + }) + .catch((err) => { + if (controller.signal.aborted) { + return Promise.resolve({ + ...partialResponse, + aborted: true, + }); + } + notifications?.showErrorDialog({ + title: i18n.translate('xpack.observabilityAiAssistant.failedToLoadChatTitle', { + defaultMessage: 'Failed to load chat', + }), + error: err, + }); + setError(err); + throw err; + }) + .finally(() => { + if (controller.signal.aborted) { + return; + } + setLoading(false); }); - }) - .catch((err) => { - if (controller.signal.aborted) { - return; - } - notifications?.showErrorDialog({ - title: i18n.translate('xpack.observabilityAiAssistant.failedToLoadChatTitle', { - defaultMessage: 'Failed to load chat', - }), - error: err, - }); - setError(err); - }) - .finally(() => { - if (controller.signal.aborted) { - return; - } - setLoading(false); - }); - - return () => { - controller.abort(); - }; - }, [messages, connectorId, assistant, notifications]); + }, + [assistant, notifications] + ); useEffect(() => { - return regenerate(); - }, [regenerate]); + controllerRef.current.abort(); + }, []); return { ...response, @@ -133,6 +145,6 @@ export function useChat({ messages, connectorId }: { messages: Message[]; connec setError(new AbortError()); controllerRef.current.abort(); }, - regenerate, + generate, }; } diff --git a/x-pack/plugins/observability_ai_assistant/public/hooks/use_chat_conversation.ts b/x-pack/plugins/observability_ai_assistant/public/hooks/use_chat_conversation.ts deleted file mode 100644 index 6dcc20cdf62dcd..00000000000000 --- a/x-pack/plugins/observability_ai_assistant/public/hooks/use_chat_conversation.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Message } from '../../common'; - -interface UseChatConversationProps { - conversationId?: string; -} - -export function useChatConversation({ conversationId }: UseChatConversationProps): { - title: string; - messages: Message[]; -} { - return { title: '', messages: [] }; -} diff --git a/x-pack/plugins/observability_ai_assistant/public/hooks/use_current_user.ts b/x-pack/plugins/observability_ai_assistant/public/hooks/use_current_user.ts index 14098e8fe09b5c..8e8f437a87fb20 100644 --- a/x-pack/plugins/observability_ai_assistant/public/hooks/use_current_user.ts +++ b/x-pack/plugins/observability_ai_assistant/public/hooks/use_current_user.ts @@ -5,26 +5,26 @@ * 2.0. */ -import { useState, useEffect } from 'react'; import { AuthenticatedUser } from '@kbn/security-plugin/common/model'; -import { useKibana } from './use_kibana'; +import { useEffect, useState } from 'react'; +import { useObservabilityAIAssistant } from './use_observability_ai_assistant'; export function useCurrentUser() { - const { security } = useKibana().services; + const service = useObservabilityAIAssistant(); const [user, setUser] = useState(); useEffect(() => { const getCurrentUser = async () => { try { - const authenticatedUser = await security?.authc.getCurrentUser(); + const authenticatedUser = await service.getCurrentUser(); setUser(authenticatedUser); } catch { setUser(undefined); } }; getCurrentUser(); - }, [security?.authc]); + }, [service]); return user; } diff --git a/x-pack/plugins/observability_ai_assistant/public/hooks/use_observability_ai_assistant_params.ts b/x-pack/plugins/observability_ai_assistant/public/hooks/use_observability_ai_assistant_params.ts new file mode 100644 index 00000000000000..0fa7fb39ffc069 --- /dev/null +++ b/x-pack/plugins/observability_ai_assistant/public/hooks/use_observability_ai_assistant_params.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { type PathsOf, type TypeOf, useParams } from '@kbn/typed-react-router-config'; +import type { ObservabilityAIAssistantRoutes } from '../routes/config'; + +export function useObservabilityAIAssistantParams< + TPath extends PathsOf +>(path: TPath): TypeOf { + return useParams(path)! as TypeOf; +} diff --git a/x-pack/plugins/observability_ai_assistant/public/hooks/use_timeline.test.ts b/x-pack/plugins/observability_ai_assistant/public/hooks/use_timeline.test.ts new file mode 100644 index 00000000000000..c8645e6b888855 --- /dev/null +++ b/x-pack/plugins/observability_ai_assistant/public/hooks/use_timeline.test.ts @@ -0,0 +1,356 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { + act, + renderHook, + type Renderer, + type RenderHookResult, +} from '@testing-library/react-hooks'; +import { merge } from 'lodash'; +import { DeepPartial } from 'utility-types'; +import { MessageRole } from '../../common'; +import { createNewConversation, useTimeline, UseTimelineResult } from './use_timeline'; + +type HookProps = Parameters[0]; + +const WAIT_OPTIONS = { timeout: 5000 }; + +describe('useTimeline', () => { + let hookResult: RenderHookResult>; + + describe('with an empty conversation', () => { + beforeAll(() => { + hookResult = renderHook((props) => useTimeline(props), { + initialProps: { + connectors: {}, + chat: {}, + } as HookProps, + }); + }); + it('renders the correct timeline items', () => { + expect(hookResult.result.current.items.length).toEqual(1); + + expect(hookResult.result.current.items[0]).toEqual({ + canEdit: false, + canRegenerate: false, + canGiveFeedback: false, + role: MessageRole.User, + title: 'started a conversation', + loading: false, + id: expect.any(String), + }); + }); + }); + + describe('with an existing conversation', () => { + beforeAll(() => { + hookResult = renderHook((props) => useTimeline(props), { + initialProps: { + initialConversation: { + messages: [ + { + '@timestamp': new Date().toISOString(), + message: { + role: MessageRole.User, + content: 'Hello', + }, + }, + { + '@timestamp': new Date().toISOString(), + message: { + role: MessageRole.Assistant, + content: 'Goodbye', + }, + }, + ], + }, + connectors: { + selectedConnector: 'foo', + }, + chat: {}, + } as HookProps, + }); + }); + it('renders the correct timeline items', () => { + expect(hookResult.result.current.items.length).toEqual(3); + + expect(hookResult.result.current.items[1]).toEqual({ + canEdit: true, + canRegenerate: false, + canGiveFeedback: false, + role: MessageRole.User, + content: 'Hello', + loading: false, + id: expect.any(String), + title: '', + }); + + expect(hookResult.result.current.items[2]).toEqual({ + canEdit: false, + canRegenerate: true, + canGiveFeedback: true, + role: MessageRole.Assistant, + content: 'Goodbye', + loading: false, + id: expect.any(String), + title: '', + }); + }); + }); + + describe('when submitting a new prompt', () => { + const createChatSimulator = (initialProps?: DeepPartial) => { + let resolve: (data: { content?: string; aborted?: boolean }) => void; + let reject: (error: Error) => void; + + const abort = () => { + resolve({ + content: props.chat.content, + aborted: true, + }); + rerender({ + chat: { + loading: false, + }, + }); + }; + + let props = merge( + { + initialConversation: createNewConversation(), + connectors: { + selectedConnector: 'myConnector', + }, + chat: { + loading: true, + content: undefined, + abort, + generate: () => { + const promise = new Promise((innerResolve, innerReject) => { + resolve = (...args) => { + innerResolve(...args); + }; + reject = (...args) => { + innerReject(...args); + }; + }); + + rerender({ + chat: { + content: '', + loading: true, + error: undefined, + function_call: undefined, + }, + }); + return promise; + }, + }, + } as unknown as HookProps, + { + ...initialProps, + } + ); + + hookResult = renderHook((nextProps) => useTimeline(nextProps), { + initialProps: props, + }); + + function rerender(nextProps: DeepPartial) { + props = merge({}, props, nextProps) as HookProps; + hookResult.rerender(props); + } + + return { + next: (nextValue: { content?: string }) => { + rerender({ + chat: { + content: nextValue.content, + }, + }); + }, + complete: () => { + resolve({ + content: props.chat.content, + }); + rerender({ + chat: { + loading: false, + }, + }); + }, + abort, + }; + }; + + describe("and it's loading", () => { + it('adds two items of which the last one is loading', async () => { + const simulator = createChatSimulator(); + + act(() => { + hookResult.result.current.onSubmit({ content: 'Hello' }); + }); + + expect(hookResult.result.current.items[0].role).toEqual(MessageRole.User); + expect(hookResult.result.current.items[1].role).toEqual(MessageRole.User); + + expect(hookResult.result.current.items[2].role).toEqual(MessageRole.Assistant); + + expect(hookResult.result.current.items[1]).toMatchObject({ + role: MessageRole.User, + content: 'Hello', + loading: false, + }); + + expect(hookResult.result.current.items[2]).toMatchObject({ + role: MessageRole.Assistant, + content: '', + loading: true, + canRegenerate: false, + canGiveFeedback: false, + }); + + expect(hookResult.result.current.items.length).toBe(3); + + expect(hookResult.result.current.items[2]).toMatchObject({ + role: MessageRole.Assistant, + content: '', + loading: true, + canRegenerate: false, + canGiveFeedback: false, + }); + + act(() => { + simulator.next({ content: 'Goodbye' }); + }); + + expect(hookResult.result.current.items[2]).toMatchObject({ + role: MessageRole.Assistant, + content: 'Goodbye', + loading: true, + canRegenerate: false, + canGiveFeedback: false, + }); + + act(() => { + simulator.complete(); + }); + + await hookResult.waitForNextUpdate(WAIT_OPTIONS); + + expect(hookResult.result.current.items[2]).toMatchObject({ + role: MessageRole.Assistant, + content: 'Goodbye', + loading: false, + canRegenerate: true, + canGiveFeedback: true, + }); + }); + + describe('and it being aborted', () => { + let simulator: ReturnType; + + beforeEach(async () => { + simulator = createChatSimulator(); + + act(() => { + hookResult.result.current.onSubmit({ content: 'Hello' }); + simulator.next({ content: 'My partial' }); + simulator.abort(); + }); + + await hookResult.waitForNextUpdate(WAIT_OPTIONS); + }); + + it('adds the partial response', async () => { + expect(hookResult.result.current.items.length).toBe(3); + + expect(hookResult.result.current.items[2]).toEqual({ + canEdit: false, + canRegenerate: true, + canGiveFeedback: true, + content: 'My partial', + id: expect.any(String), + loading: false, + title: '', + role: MessageRole.Assistant, + }); + }); + + describe('and it being regenerated', () => { + beforeEach(() => { + act(() => { + hookResult.result.current.onRegenerate(hookResult.result.current.items[2]); + }); + }); + + it('updates the last item in the array to be loading', () => { + expect(hookResult.result.current.items[2]).toEqual({ + canEdit: false, + canRegenerate: false, + canGiveFeedback: false, + content: '', + id: expect.any(String), + loading: true, + title: '', + role: MessageRole.Assistant, + }); + }); + + describe('and it is regenerated again', () => { + beforeEach(async () => { + act(() => { + hookResult.result.current.onStopGenerating(); + }); + + await hookResult.waitForNextUpdate(WAIT_OPTIONS); + + act(() => { + hookResult.result.current.onRegenerate(hookResult.result.current.items[2]); + }); + }); + + it('updates the last item to be not loading again', async () => { + expect(hookResult.result.current.items.length).toBe(3); + + expect(hookResult.result.current.items[2]).toEqual({ + canEdit: false, + canRegenerate: false, + canGiveFeedback: false, + content: '', + id: expect.any(String), + loading: true, + title: '', + role: MessageRole.Assistant, + }); + + act(() => { + simulator.next({ content: 'Regenerated' }); + simulator.complete(); + }); + + await hookResult.waitForNextUpdate(WAIT_OPTIONS); + + expect(hookResult.result.current.items.length).toBe(3); + + expect(hookResult.result.current.items[2]).toEqual({ + canEdit: false, + canRegenerate: true, + canGiveFeedback: true, + content: 'Regenerated', + id: expect.any(String), + loading: false, + title: '', + role: MessageRole.Assistant, + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/observability_ai_assistant/public/hooks/use_timeline.ts b/x-pack/plugins/observability_ai_assistant/public/hooks/use_timeline.ts new file mode 100644 index 00000000000000..916168b10b4a10 --- /dev/null +++ b/x-pack/plugins/observability_ai_assistant/public/hooks/use_timeline.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AuthenticatedUser } from '@kbn/security-plugin/common'; +import { omit } from 'lodash'; +import { useMemo, useState } from 'react'; +import { MessageRole, type ConversationCreateRequest, type Message } from '../../common/types'; +import type { ChatPromptEditorProps } from '../components/chat/chat_prompt_editor'; +import type { ChatTimelineProps } from '../components/chat/chat_timeline'; +import { getTimelineItemsfromConversation } from '../utils/get_timeline_items_from_conversation'; +import type { UseChatResult } from './use_chat'; +import type { UseGenAIConnectorsResult } from './use_genai_connectors'; + +export function createNewConversation(): ConversationCreateRequest { + return { + '@timestamp': new Date().toISOString(), + messages: [], + conversation: { + title: '', + }, + labels: {}, + numeric_labels: {}, + }; +} + +export type UseTimelineResult = Pick< + ChatTimelineProps, + 'onEdit' | 'onFeedback' | 'onRegenerate' | 'onStopGenerating' | 'items' +> & + Pick; + +export function useTimeline({ + initialConversation, + connectors, + currentUser, + chat, +}: { + initialConversation?: ConversationCreateRequest; + connectors: UseGenAIConnectorsResult; + currentUser?: Pick; + chat: UseChatResult; +}): UseTimelineResult { + const connectorId = connectors.selectedConnector; + + const hasConnector = !!connectorId; + + const [conversation, setConversation] = useState(initialConversation || createNewConversation()); + + const conversationItems = useMemo(() => { + return getTimelineItemsfromConversation({ + conversation, + currentUser, + hasConnector, + }); + }, [conversation, currentUser, hasConnector]); + + const items = useMemo(() => { + if (chat.loading) { + return conversationItems.concat({ + id: '', + canEdit: false, + canRegenerate: !chat.loading, + canGiveFeedback: !chat.loading, + role: MessageRole.Assistant, + title: '', + content: chat.content ?? '', + loading: chat.loading, + currentUser, + }); + } + + return conversationItems; + }, [conversationItems, chat.content, chat.loading, currentUser]); + + function getNextMessage( + role: MessageRole, + response: Awaited> + ) { + const nextMessage: Message = { + '@timestamp': new Date().toISOString(), + message: { + role, + content: response.content, + ...omit(response, 'function_call'), + ...(response.function_call && response.function_call.name + ? { + function_call: { + ...response.function_call, + args: response.function_call.args + ? JSON.parse(response.function_call.args) + : undefined, + trigger: MessageRole.Assistant, + }, + } + : {}), + }, + }; + + return nextMessage; + } + + return { + items, + onEdit: (item, content) => {}, + onFeedback: (item, feedback) => {}, + onRegenerate: (item) => { + const indexOf = items.indexOf(item); + + const messages = conversation.messages.slice(0, indexOf - 1); + + setConversation((conv) => ({ ...conv, messages })); + + chat + .generate({ + messages, + connectorId: connectors.selectedConnector!, + }) + .then((response) => { + setConversation((conv) => ({ + ...conv, + messages: conv.messages.concat(getNextMessage(MessageRole.Assistant, response)), + })); + }); + }, + onStopGenerating: () => { + chat.abort(); + }, + onSubmit: async ({ content }) => { + if (connectorId) { + const nextMessage = getNextMessage(MessageRole.User, { content }); + + setConversation((conv) => ({ ...conv, messages: conv.messages.concat(nextMessage) })); + + const response = await chat.generate({ + messages: conversation.messages.concat(nextMessage), + connectorId, + }); + + setConversation((conv) => ({ + ...conv, + messages: conv.messages.concat(getNextMessage(MessageRole.Assistant, response)), + })); + } + }, + }; +} diff --git a/x-pack/plugins/observability_ai_assistant/public/plugin.ts b/x-pack/plugins/observability_ai_assistant/public/plugin.ts deleted file mode 100644 index 607f54a3c6da70..00000000000000 --- a/x-pack/plugins/observability_ai_assistant/public/plugin.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; -import type { Logger } from '@kbn/logging'; -import { createService } from './service/create_service'; -import type { - ConfigSchema, - ObservabilityAIAssistantPluginSetup, - ObservabilityAIAssistantPluginSetupDependencies, - ObservabilityAIAssistantPluginStart, - ObservabilityAIAssistantPluginStartDependencies, -} from './types'; - -export class ObservabilityAIAssistantPlugin - implements - Plugin< - ObservabilityAIAssistantPluginSetup, - ObservabilityAIAssistantPluginStart, - ObservabilityAIAssistantPluginSetupDependencies, - ObservabilityAIAssistantPluginStartDependencies - > -{ - logger: Logger; - constructor(context: PluginInitializerContext) { - this.logger = context.logger.get(); - } - setup(): ObservabilityAIAssistantPluginSetup { - return {}; - } - - start(coreStart: CoreStart): ObservabilityAIAssistantPluginStart { - return createService(coreStart); - } -} diff --git a/x-pack/plugins/observability_ai_assistant/public/plugin.tsx b/x-pack/plugins/observability_ai_assistant/public/plugin.tsx index 21676291160051..d469ea33837724 100644 --- a/x-pack/plugins/observability_ai_assistant/public/plugin.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/plugin.tsx @@ -11,6 +11,7 @@ import { DEFAULT_APP_CATEGORIES, type Plugin, type PluginInitializerContext, + CoreStart, } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import type { Logger } from '@kbn/logging'; @@ -45,8 +46,6 @@ export class ObservabilityAIAssistantPlugin coreSetup: CoreSetup, pluginsSetup: ObservabilityAIAssistantPluginSetupDependencies ): ObservabilityAIAssistantPluginSetup { - const service = (this.service = createService(coreSetup)); - coreSetup.application.register({ id: 'observabilityAIAssistant', title: i18n.translate('xpack.observabilityAiAssistant.appTitle', { @@ -66,7 +65,7 @@ export class ObservabilityAIAssistantPlugin }, ], - async mount(appMountParameters: AppMountParameters) { + mount: async (appMountParameters: AppMountParameters) => { // Load application bundle and Get start services const [{ Application }, [coreStart, pluginsStart]] = await Promise.all([ import('./application'), @@ -76,7 +75,7 @@ export class ObservabilityAIAssistantPlugin ReactDOM.render( , @@ -91,7 +90,10 @@ export class ObservabilityAIAssistantPlugin return {}; } - start(): ObservabilityAIAssistantPluginStart { - return this.service!; + start( + coreStart: CoreStart, + pluginsStart: ObservabilityAIAssistantPluginStartDependencies + ): ObservabilityAIAssistantPluginStart { + return (this.service = createService({ coreStart, securityStart: pluginsStart.security })); } } diff --git a/x-pack/plugins/observability_ai_assistant/public/routes/config.tsx b/x-pack/plugins/observability_ai_assistant/public/routes/config.tsx index 5d6f18d5299d52..c33996402cd044 100644 --- a/x-pack/plugins/observability_ai_assistant/public/routes/config.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/routes/config.tsx @@ -19,7 +19,6 @@ import { ConversationView } from './conversations/conversation_view'; const observabilityAIAssistantRoutes = { '/': { element: , - params: t.type({}), }, '/conversations': { element: ( @@ -27,14 +26,19 @@ const observabilityAIAssistantRoutes = { ), - params: t.type({}), children: { '/conversations/new': { - params: t.type({}), + element: , + }, + '/conversations/:conversationId': { + params: t.type({ + path: t.type({ + conversationId: t.string, + }), + }), element: , }, '/conversations': { - params: t.type({}), element: <>, }, }, diff --git a/x-pack/plugins/observability_ai_assistant/public/routes/conversations/conversation_view.tsx b/x-pack/plugins/observability_ai_assistant/public/routes/conversations/conversation_view.tsx index 42c18374643a30..61052b9456dc13 100644 --- a/x-pack/plugins/observability_ai_assistant/public/routes/conversations/conversation_view.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/routes/conversations/conversation_view.tsx @@ -4,33 +4,29 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React from 'react'; -import { ChatHeader } from '../../components/chat/chat_header'; -import { ChatPromptEditor } from '../../components/chat/chat_prompt_editor'; -import { ChatTimeline } from '../../components/chat/chat_timeline'; +import { ChatBody } from '../../components/chat/chat_body'; +import { useChat } from '../../hooks/use_chat'; +import { useCurrentUser } from '../../hooks/use_current_user'; import { useGenAIConnectors } from '../../hooks/use_genai_connectors'; export function ConversationView() { const connectors = useGenAIConnectors(); + const chat = useChat(); + + const currentUser = useCurrentUser(); + return ( - - - - - - - - - {}} /> - - - {}} /> - - + ); diff --git a/x-pack/plugins/observability_ai_assistant/public/service/create_service.test.ts b/x-pack/plugins/observability_ai_assistant/public/service/create_service.test.ts index 010503e7abf4fa..2da55a7a8f4fdd 100644 --- a/x-pack/plugins/observability_ai_assistant/public/service/create_service.test.ts +++ b/x-pack/plugins/observability_ai_assistant/public/service/create_service.test.ts @@ -4,10 +4,12 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { CoreStart } from '@kbn/core/public'; +import type { CoreStart } from '@kbn/core/public'; import { ReadableStream } from 'stream/web'; -import { ObservabilityAIAssistantService } from '../types'; +import type { AuthenticatedUser } from '@kbn/security-plugin/common'; +import type { ObservabilityAIAssistantService } from '../types'; import { createService } from './create_service'; +import { SecurityPluginStart } from '@kbn/security-plugin/public'; describe('createService', () => { describe('chat', () => { @@ -41,10 +43,17 @@ describe('createService', () => { beforeEach(() => { service = createService({ - http: { - post: httpPostSpy, - }, - } as unknown as CoreStart); + coreStart: { + http: { + post: httpPostSpy, + }, + } as unknown as CoreStart, + securityStart: { + authc: { + getCurrentUser: () => Promise.resolve({ username: 'elastic' } as AuthenticatedUser), + }, + } as unknown as SecurityPluginStart, + }); }); afterEach(() => { diff --git a/x-pack/plugins/observability_ai_assistant/public/service/create_service.ts b/x-pack/plugins/observability_ai_assistant/public/service/create_service.ts index 78ab34730484f2..a00352a39d0ce8 100644 --- a/x-pack/plugins/observability_ai_assistant/public/service/create_service.ts +++ b/x-pack/plugins/observability_ai_assistant/public/service/create_service.ts @@ -5,15 +5,22 @@ * 2.0. */ -import type { CoreSetup, HttpResponse } from '@kbn/core/public'; +import type { CoreStart, HttpResponse } from '@kbn/core/public'; +import { SecurityPluginStart } from '@kbn/security-plugin/public'; import { filter, map } from 'rxjs'; import type { Message } from '../../common'; import { createCallObservabilityAIAssistantAPI } from '../api'; import type { CreateChatCompletionResponseChunk, ObservabilityAIAssistantService } from '../types'; import { readableStreamReaderIntoObservable } from '../utils/readable_stream_reader_into_observable'; -export function createService(coreSetup: CoreSetup): ObservabilityAIAssistantService { - const client = createCallObservabilityAIAssistantAPI(coreSetup); +export function createService({ + coreStart, + securityStart, +}: { + coreStart: CoreStart; + securityStart: SecurityPluginStart; +}): ObservabilityAIAssistantService { + const client = createCallObservabilityAIAssistantAPI(coreStart); return { isEnabled: () => { @@ -52,10 +59,6 @@ export function createService(coreSetup: CoreSetup): ObservabilityAIAssistantSer throw new Error('Could not get reader from response'); } - signal.addEventListener('abort', () => { - reader.cancel(); - }); - return readableStreamReaderIntoObservable(reader).pipe( map((line) => line.substring(6)), filter((line) => !!line && line !== '[DONE]'), @@ -64,5 +67,6 @@ export function createService(coreSetup: CoreSetup): ObservabilityAIAssistantSer ); }, callApi: client, + getCurrentUser: () => securityStart.authc.getCurrentUser(), }; } diff --git a/x-pack/plugins/observability_ai_assistant/public/types.ts b/x-pack/plugins/observability_ai_assistant/public/types.ts index 468ee5ba2b8b0f..bd51b6e63dbf62 100644 --- a/x-pack/plugins/observability_ai_assistant/public/types.ts +++ b/x-pack/plugins/observability_ai_assistant/public/types.ts @@ -4,7 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public'; +import type { + AuthenticatedUser, + SecurityPluginSetup, + SecurityPluginStart, +} from '@kbn/security-plugin/public'; import type { TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart, @@ -39,6 +43,7 @@ export interface ObservabilityAIAssistantService { signal: AbortSignal; }) => Promise>; callApi: ObservabilityAIAssistantAPIClient; + getCurrentUser: () => Promise; } export interface ObservabilityAIAssistantPluginStart extends ObservabilityAIAssistantService {} 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 1eaf0f83adc831..33dddc215749d8 100644 --- a/x-pack/plugins/observability_ai_assistant/public/utils/builders.ts +++ b/x-pack/plugins/observability_ai_assistant/public/utils/builders.ts @@ -5,69 +5,64 @@ * 2.0. */ -import { cloneDeep } from 'lodash'; -import { Conversation, Message, MessageRole } from '../../common/types'; +import { uniqueId } from 'lodash'; +import { MessageRole } from '../../common/types'; +import { ChatTimelineItem } from '../components/chat/chat_timeline'; -const currentDate = new Date(); +type ChatItemBuildProps = Partial & Pick; -const baseMessage: Message = { - '@timestamp': String(new Date(currentDate.getTime())), - message: { - content: 'foo', - name: 'bar', - role: MessageRole.User, - }, -}; - -export function buildMessage(params: Partial = {}): Message { - return cloneDeep({ ...baseMessage, ...params }); +export function buildChatItem(params: ChatItemBuildProps): ChatTimelineItem { + return { + id: uniqueId(), + title: 'My title', + canEdit: false, + canGiveFeedback: false, + canRegenerate: params.role === MessageRole.User, + currentUser: { + username: 'elastic', + }, + loading: false, + ...params, + }; } -export function buildSystemInnerMessage( - params: Partial = {} -): Message['message'] { - return cloneDeep({ - ...{ role: MessageRole.System, ...params }, +export function buildSystemChatItem(params?: Omit) { + return buildChatItem({ + role: MessageRole.System, + ...params, }); } -export function buildUserInnerMessage( - params: Partial = {} -): Message['message'] { - return cloneDeep({ - ...{ content: "What's a function?", role: MessageRole.User, ...params }, +export function buildChatInitItem() { + return buildChatItem({ + role: MessageRole.User, + title: 'started a conversation', }); } -export function buildAssistantInnerMessage( - params: Partial = {} -): Message['message'] { - return cloneDeep({ - ...{ - 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.`, - role: MessageRole.Assistant, - data: { key: 'value', nestedData: { foo: 'bar' } }, - ...params, - }, +export function buildUserChatItem(params?: Omit) { + return buildChatItem({ + role: MessageRole.User, + content: "What's a function?", + canEdit: true, + ...params, }); } -export function buildElasticInnerMessage( - params: Partial = {} -): Message['message'] { - return cloneDeep({ - ...{ role: MessageRole.Elastic, data: { key: 'value', nestedData: { foo: 'bar' } }, ...params }, +export function buildAssistantChatItem(params?: Omit) { + return buildChatItem({ + role: MessageRole.Assistant, + 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.`, + canRegenerate: true, + canGiveFeedback: true, + ...params, }); } -export function buildFunctionInnerMessage( - params: Partial = {} -): Message['message'] { - return cloneDeep({ - ...{ - role: MessageRole.Function, - }, +export function buildFunctionInnerMessage(params: Omit) { + return buildChatItem({ + role: MessageRole.Function, function_call: { name: 'leftpad', args: '{ foo: "bar" }', @@ -77,27 +72,8 @@ export function buildFunctionInnerMessage( }); } -const baseConversation: Conversation = { - '@timestamp': String(Date.now()), - user: { - id: 'foo', - name: 'bar', - }, - conversation: { - id: 'conversation-foo', - title: 'Conversation title', - last_updated: String(Date.now()), - }, - messages: [ - buildMessage({ message: buildSystemInnerMessage() }), - buildMessage({ message: buildUserInnerMessage() }), - buildMessage({ message: buildAssistantInnerMessage() }), - ], - labels: { foo: 'bar' }, - numeric_labels: { foo: 1 }, - namespace: 'baz', -}; - -export function buildConversation(params: Partial = {}): Conversation { - return cloneDeep({ ...baseConversation, ...params }); +export function buildTimelineItems() { + return { + items: [buildSystemChatItem(), buildUserChatItem(), buildAssistantChatItem()], + }; } diff --git a/x-pack/plugins/observability_ai_assistant/public/utils/get_timeline_items_from_conversation.ts b/x-pack/plugins/observability_ai_assistant/public/utils/get_timeline_items_from_conversation.ts new file mode 100644 index 00000000000000..d45379540c1d38 --- /dev/null +++ b/x-pack/plugins/observability_ai_assistant/public/utils/get_timeline_items_from_conversation.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { v4 } from 'uuid'; +import { i18n } from '@kbn/i18n'; +import type { AuthenticatedUser } from '@kbn/security-plugin/common'; +import { MessageRole } from '../../common'; +import type { ConversationCreateRequest } from '../../common/types'; +import type { ChatTimelineItem } from '../components/chat/chat_timeline'; + +export function getTimelineItemsfromConversation({ + currentUser, + conversation, + hasConnector, +}: { + currentUser?: Pick; + conversation: ConversationCreateRequest; + hasConnector: boolean; +}): ChatTimelineItem[] { + return [ + { + id: v4(), + role: MessageRole.User, + title: i18n.translate('xpack.observabilityAiAssistant.conversationStartTitle', { + defaultMessage: 'started a conversation', + }), + canEdit: false, + canGiveFeedback: false, + canRegenerate: false, + loading: false, + currentUser, + }, + ...conversation.messages.map((message) => ({ + id: v4(), + role: message.message.role, + title: message.message.role === MessageRole.System ? 'added a system prompt' : '', + content: + message.message.role === MessageRole.System ? undefined : message.message.content || '', + canEdit: + hasConnector && + (message.message.role === MessageRole.User || + message.message.role === MessageRole.Function), + canGiveFeedback: message.message.role === MessageRole.Assistant, + canRegenerate: hasConnector && message.message.role === MessageRole.Assistant, + loading: false, + currentUser, + })), + ]; +} diff --git a/x-pack/plugins/observability_ai_assistant/public/utils/readable_stream_reader_into_observable.ts b/x-pack/plugins/observability_ai_assistant/public/utils/readable_stream_reader_into_observable.ts index f65e0fbd7ee7fb..bd9b235d7425d0 100644 --- a/x-pack/plugins/observability_ai_assistant/public/utils/readable_stream_reader_into_observable.ts +++ b/x-pack/plugins/observability_ai_assistant/public/utils/readable_stream_reader_into_observable.ts @@ -13,7 +13,7 @@ export function readableStreamReaderIntoObservable( return new Observable((subscriber) => { let lineBuffer: string = ''; - async function read() { + async function read(): Promise { const { done, value } = await readableStreamReader.read(); if (done) { if (lineBuffer) { @@ -35,13 +35,13 @@ export function readableStreamReaderIntoObservable( subscriber.next(line); } - read(); + return read(); } read().catch((err) => subscriber.error(err)); return () => { - readableStreamReader.cancel(); + readableStreamReader.cancel().catch(() => {}); }; }).pipe(share()); } diff --git a/x-pack/plugins/observability_ai_assistant/server/routes/runtime_types.ts b/x-pack/plugins/observability_ai_assistant/server/routes/runtime_types.ts index 49dcb8ec1a9303..7fb2ed98fa1a74 100644 --- a/x-pack/plugins/observability_ai_assistant/server/routes/runtime_types.ts +++ b/x-pack/plugins/observability_ai_assistant/server/routes/runtime_types.ts @@ -23,7 +23,6 @@ export const messageRt: t.Type = t.type({ role: t.union([ t.literal(MessageRole.System), t.literal(MessageRole.Assistant), - t.literal(MessageRole.Event), t.literal(MessageRole.Function), t.literal(MessageRole.User), t.literal(MessageRole.Elastic), @@ -32,6 +31,7 @@ export const messageRt: t.Type = t.type({ t.partial({ content: t.string, name: t.string, + event: t.string, function_call: t.intersection([ t.type({ name: t.string, diff --git a/x-pack/plugins/observability_ai_assistant/server/service/client/index.ts b/x-pack/plugins/observability_ai_assistant/server/service/client/index.ts index 920bddee2a176d..2afa7b9be3448b 100644 --- a/x-pack/plugins/observability_ai_assistant/server/service/client/index.ts +++ b/x-pack/plugins/observability_ai_assistant/server/service/client/index.ts @@ -116,22 +116,21 @@ export class ObservabilityAIAssistantClient implements IObservabilityAIAssistant connectorId: string; }): Promise => { const messagesForOpenAI: ChatCompletionRequestMessage[] = compact( - messages.map((message) => { - if (message.message.role === MessageRole.Event) { - return undefined; - } - const role = - message.message.role === MessageRole.Elastic ? MessageRole.User : message.message.role; - - return { - role, - content: message.message.content, - function_call: isEmpty(message.message.function_call?.name) - ? undefined - : omit(message.message.function_call, 'trigger'), - name: message.message.name, - }; - }) + messages + .filter((message) => message.message.content || message.message.function_call?.name) + .map((message) => { + const role = + message.message.role === MessageRole.Elastic ? MessageRole.User : message.message.role; + + return { + role, + content: message.message.content, + function_call: isEmpty(message.message.function_call?.name) + ? undefined + : omit(message.message.function_call, 'trigger'), + name: message.message.name, + }; + }) ); const connector = await this.dependencies.actionsClient.get({ diff --git a/x-pack/plugins/observability_ai_assistant/server/service/conversation_component_template.ts b/x-pack/plugins/observability_ai_assistant/server/service/conversation_component_template.ts index b758d702d2c437..ce3a8d991e2243 100644 --- a/x-pack/plugins/observability_ai_assistant/server/service/conversation_component_template.ts +++ b/x-pack/plugins/observability_ai_assistant/server/service/conversation_component_template.ts @@ -66,6 +66,7 @@ export const conversationComponentTemplate: ClusterComponentTemplate['component_ type: 'object', properties: { content: text, + event: text, role: keyword, data: { type: 'object',