diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.test.tsx new file mode 100644 index 0000000000000..59dbbd9105487 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.test.tsx @@ -0,0 +1,85 @@ +/* + * 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 { render } from '@testing-library/react'; +import { AssistantHeader } from '.'; +import { TestProviders } from '../../mock/test_providers/test_providers'; +import { alertConvo, emptyWelcomeConvo } from '../../mock/conversation'; + +const testProps = { + currentConversation: emptyWelcomeConvo, + currentTitle: { + title: 'Test Title', + titleIcon: 'logoSecurity', + }, + docLinks: { + ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', + DOC_LINK_VERSION: 'master', + }, + isDisabled: false, + isSettingsModalVisible: false, + onConversationSelected: jest.fn(), + onToggleShowAnonymizedValues: jest.fn(), + selectedConversationId: emptyWelcomeConvo.id, + setIsSettingsModalVisible: jest.fn(), + setSelectedConversationId: jest.fn(), + showAnonymizedValues: false, +}; + +describe('AssistantHeader', () => { + it('showAnonymizedValues is not checked when currentConversation.replacements is null', () => { + const { getByText, getByTestId } = render(, { + wrapper: TestProviders, + }); + expect(getByText('Test Title')).toBeInTheDocument(); + expect(getByTestId('showAnonymizedValues')).toHaveAttribute('aria-checked', 'false'); + }); + + it('showAnonymizedValues is not checked when currentConversation.replacements is empty', () => { + const { getByText, getByTestId } = render( + , + { + wrapper: TestProviders, + } + ); + expect(getByText('Test Title')).toBeInTheDocument(); + expect(getByTestId('showAnonymizedValues')).toHaveAttribute('aria-checked', 'false'); + }); + + it('showAnonymizedValues is not checked when currentConversation.replacements has values and showAnonymizedValues is false', () => { + const { getByTestId } = render( + , + { + wrapper: TestProviders, + } + ); + expect(getByTestId('showAnonymizedValues')).toHaveAttribute('aria-checked', 'false'); + }); + + it('showAnonymizedValues is checked when currentConversation.replacements has values and showAnonymizedValues is true', () => { + const { getByTestId } = render( + , + { + wrapper: TestProviders, + } + ); + expect(getByTestId('showAnonymizedValues')).toHaveAttribute('aria-checked', 'true'); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx new file mode 100644 index 0000000000000..eb05133c70835 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx @@ -0,0 +1,139 @@ +/* + * 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, { useMemo } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiSpacer, + EuiSwitch, + EuiSwitchEvent, + EuiToolTip, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { DocLinksStart } from '@kbn/core-doc-links-browser'; +import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/gen_ai/constants'; +import { Conversation } from '../../..'; +import { AssistantTitle } from '../assistant_title'; +import { ConversationSelector } from '../conversations/conversation_selector'; +import { AssistantSettingsButton } from '../settings/assistant_settings_button'; +import * as i18n from '../translations'; + +interface OwnProps { + currentConversation: Conversation; + currentTitle: { title: string | JSX.Element; titleIcon: string }; + defaultConnectorId?: string; + defaultProvider?: OpenAiProviderType; + docLinks: Omit; + isDisabled: boolean; + isSettingsModalVisible: boolean; + onConversationSelected: (cId: string) => void; + onToggleShowAnonymizedValues: (e: EuiSwitchEvent) => void; + selectedConversationId: string; + setIsSettingsModalVisible: React.Dispatch>; + setSelectedConversationId: React.Dispatch>; + shouldDisableKeyboardShortcut?: () => boolean; + showAnonymizedValues: boolean; +} + +type Props = OwnProps; +/** + * Renders the header of the Elastic AI Assistant. + * Provide a user interface for selecting and managing conversations, + * toggling the display of anonymized values, and accessing the assistant settings. + */ +export const AssistantHeader: React.FC = ({ + currentConversation, + currentTitle, + defaultConnectorId, + defaultProvider, + docLinks, + isDisabled, + isSettingsModalVisible, + onConversationSelected, + onToggleShowAnonymizedValues, + selectedConversationId, + setIsSettingsModalVisible, + setSelectedConversationId, + shouldDisableKeyboardShortcut, + showAnonymizedValues, +}) => { + const showAnonymizedValuesChecked = useMemo( + () => + currentConversation.replacements != null && + Object.keys(currentConversation.replacements).length > 0 && + showAnonymizedValues, + [currentConversation.replacements, showAnonymizedValues] + ); + return ( + <> + + + + + + + + + <> + + + + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.test.tsx new file mode 100644 index 0000000000000..9bf50e5f084aa --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.test.tsx @@ -0,0 +1,37 @@ +/* + * 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 { render, fireEvent } from '@testing-library/react'; +import { AssistantTitle } from '.'; +import { TestProviders } from '../../mock/test_providers/test_providers'; + +const testProps = { + title: 'Test Title', + titleIcon: 'globe', + docLinks: { ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', DOC_LINK_VERSION: '7.15' }, +}; +describe('AssistantTitle', () => { + it('the component renders correctly with valid props', () => { + const { getByText, container } = render(); + expect(getByText('Test Title')).toBeInTheDocument(); + expect(container.querySelector('[data-euiicon-type="globe"]')).not.toBeNull(); + }); + + it('clicking on the popover button opens the popover with the correct link', () => { + const { getByTestId, queryByTestId } = render(, { + wrapper: TestProviders, + }); + expect(queryByTestId('tooltipContent')).not.toBeInTheDocument(); + fireEvent.click(getByTestId('tooltipIcon')); + expect(getByTestId('tooltipContent')).toBeInTheDocument(); + expect(getByTestId('externalDocumentationLink')).toHaveAttribute( + 'href', + 'https://www.elastic.co/guide/en/security/7.15/security-assistant.html' + ); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.tsx index 150eacd37a616..719a02aaee132 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { FunctionComponent, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { EuiButtonIcon, EuiFlexGroup, @@ -20,10 +20,15 @@ import type { DocLinksStart } from '@kbn/core-doc-links-browser'; import { FormattedMessage } from '@kbn/i18n-react'; import * as i18n from '../translations'; -export const AssistantTitle: FunctionComponent<{ - currentTitle: { title: string | JSX.Element; titleIcon: string }; +/** + * Renders a header title with an icon, a tooltip button, and a popover with + * information about the assistant feature and access to documentation. + */ +export const AssistantTitle: React.FC<{ + title: string | JSX.Element; + titleIcon: string; docLinks: Omit; -}> = ({ currentTitle, docLinks }) => { +}> = ({ title, titleIcon, docLinks }) => { const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks; const url = `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/security-assistant.html`; @@ -54,21 +59,24 @@ export const AssistantTitle: FunctionComponent<{ ), [documentationLink] ); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const onButtonClick = () => setIsPopoverOpen((isOpen: boolean) => !isOpen); - const closePopover = () => setIsPopoverOpen(false); + const onButtonClick = useCallback(() => setIsPopoverOpen((isOpen: boolean) => !isOpen), []); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + return ( - + - {currentTitle.title} + {title} - +

{i18n.TOOLTIP_TITLE}

{content}

diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/block_bot/cta.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/block_bot/cta.test.tsx new file mode 100644 index 0000000000000..72881ac0bdc9c --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/block_bot/cta.test.tsx @@ -0,0 +1,48 @@ +/* + * 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 { render } from '@testing-library/react'; +import { BlockBotCallToAction } from './cta'; +import { HttpSetup } from '@kbn/core-http-browser'; + +const testProps = { + connectorPrompt:
{'Connector Prompt'}
, + http: { basePath: { get: jest.fn(() => 'http://localhost:5601') } } as unknown as HttpSetup, + isAssistantEnabled: false, + isWelcomeSetup: false, +}; + +describe('BlockBotCallToAction', () => { + it('UpgradeButtons is rendered when isAssistantEnabled is false and isWelcomeSetup is false', () => { + const { getByTestId, queryByTestId } = render(); + expect(getByTestId('upgrade-buttons')).toBeInTheDocument(); + expect(queryByTestId('connector-prompt')).not.toBeInTheDocument(); + }); + + it('connectorPrompt is rendered when isAssistantEnabled is true and isWelcomeSetup is true', () => { + const props = { + ...testProps, + isAssistantEnabled: true, + isWelcomeSetup: true, + }; + const { getByTestId, queryByTestId } = render(); + expect(getByTestId('connector-prompt')).toBeInTheDocument(); + expect(queryByTestId('upgrade-buttons')).not.toBeInTheDocument(); + }); + + it('null is returned when isAssistantEnabled is true and isWelcomeSetup is false', () => { + const props = { + ...testProps, + isAssistantEnabled: true, + isWelcomeSetup: false, + }; + const { container, queryByTestId } = render(); + expect(container.firstChild).toBeNull(); + expect(queryByTestId('connector-prompt')).not.toBeInTheDocument(); + expect(queryByTestId('upgrade-buttons')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/block_bot/cta.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/block_bot/cta.tsx new file mode 100644 index 0000000000000..afdb8071eea3c --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/block_bot/cta.tsx @@ -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 React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { HttpSetup } from '@kbn/core-http-browser'; +import { UpgradeButtons } from '../../upgrade/upgrade_buttons'; + +interface OwnProps { + connectorPrompt: React.ReactElement; + http: HttpSetup; + isAssistantEnabled: boolean; + isWelcomeSetup: boolean; +} + +type Props = OwnProps; + +/** + * Provides a call-to-action for users to upgrade their subscription or set up a connector + * depending on the isAssistantEnabled and isWelcomeSetup props. + */ +export const BlockBotCallToAction: React.FC = ({ + connectorPrompt, + http, + isAssistantEnabled, + isWelcomeSetup, +}) => { + const basePath = http.basePath.get(); + return !isAssistantEnabled ? ( + + {} + + ) : isWelcomeSetup ? ( + + {connectorPrompt} + + ) : null; +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_actions/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_actions/index.test.tsx new file mode 100644 index 0000000000000..b32febc9a0826 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_actions/index.test.tsx @@ -0,0 +1,62 @@ +/* + * 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 { render, fireEvent, within } from '@testing-library/react'; +import { ChatActions } from '.'; + +const onChatCleared = jest.fn(); +const onSendMessage = jest.fn(); +const testProps = { + isDisabled: false, + isLoading: false, + onChatCleared, + onSendMessage, +}; + +describe('ChatActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('the component renders with all props', () => { + const { getByTestId } = render(); + expect(getByTestId('clear-chat')).toHaveAttribute('aria-label', 'Clear chat'); + expect(getByTestId('submit-chat')).toHaveAttribute('aria-label', 'Submit message'); + }); + + it('onChatCleared function is called when clear chat button is clicked', () => { + const { getByTestId } = render(); + fireEvent.click(getByTestId('clear-chat')); + expect(onChatCleared).toHaveBeenCalled(); + }); + + it('onSendMessage function is called when send message button is clicked', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('submit-chat')); + expect(onSendMessage).toHaveBeenCalled(); + }); + + it('buttons are disabled when isDisabled prop is true', () => { + const props = { + ...testProps, + isDisabled: true, + }; + const { getByTestId } = render(); + expect(getByTestId('clear-chat')).toBeDisabled(); + expect(getByTestId('submit-chat')).toBeDisabled(); + }); + + it('send message button is in loading state when isLoading prop is true', () => { + const props = { + ...testProps, + isLoading: true, + }; + const { getByTestId } = render(); + expect(within(getByTestId('submit-chat')).getByRole('progressbar')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_actions/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_actions/index.tsx new file mode 100644 index 0000000000000..7bb2eec51a9ce --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_actions/index.tsx @@ -0,0 +1,68 @@ +/* + * 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 { css } from '@emotion/react'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { CLEAR_CHAT, SUBMIT_MESSAGE } from '../translations'; + +interface OwnProps { + isDisabled: boolean; + isLoading: boolean; + onChatCleared: () => void; + onSendMessage: () => void; +} + +type Props = OwnProps; +/** + * Renders two EuiButtonIcon components with tooltips for clearing the chat and submitting a message, + * while handling the disabled and loading states of the buttons. + */ +export const ChatActions: React.FC = ({ + isDisabled, + isLoading, + onChatCleared, + onSendMessage, +}) => { + return ( + + + + + + + + + + + + + ); +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/index.test.tsx new file mode 100644 index 0000000000000..bd54136fae4f1 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/index.test.tsx @@ -0,0 +1,86 @@ +/* + * 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 { fireEvent, render, waitFor } from '@testing-library/react'; +import { ChatSend, Props } from '.'; +import { TestProviders } from '../../mock/test_providers/test_providers'; +import { useChatSend } from './use_chat_send'; +import { defaultSystemPrompt, mockSystemPrompt } from '../../mock/system_prompt'; +import { emptyWelcomeConvo } from '../../mock/conversation'; +import { HttpSetup } from '@kbn/core-http-browser'; + +jest.mock('./use_chat_send'); + +const testProps: Props = { + selectedPromptContexts: {}, + allSystemPrompts: [defaultSystemPrompt, mockSystemPrompt], + currentConversation: emptyWelcomeConvo, + http: { + basePath: { + basePath: '/mfg', + serverBasePath: '/mfg', + }, + anonymousPaths: {}, + externalUrl: {}, + } as unknown as HttpSetup, + editingSystemPromptId: defaultSystemPrompt.id, + setEditingSystemPromptId: () => {}, + setPromptTextPreview: () => {}, + setSelectedPromptContexts: () => {}, + setUserPrompt: () => {}, + isDisabled: false, + shouldRefocusPrompt: false, + userPrompt: '', +}; +const handleButtonSendMessage = jest.fn(); +const handleOnChatCleared = jest.fn(); +const handlePromptChange = jest.fn(); +const handleSendMessage = jest.fn(); +const chatSend = { + handleButtonSendMessage, + handleOnChatCleared, + handlePromptChange, + handleSendMessage, + isLoading: false, +}; + +describe('ChatSend', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useChatSend as jest.Mock).mockReturnValue(chatSend); + }); + it('the prompt updates when the text area changes', async () => { + const { getByTestId } = render(, { + wrapper: TestProviders, + }); + const promptTextArea = getByTestId('prompt-textarea'); + const promptText = 'valid prompt text'; + fireEvent.change(promptTextArea, { target: { value: promptText } }); + expect(handlePromptChange).toHaveBeenCalledWith(promptText); + }); + + it('a message is sent when send button is clicked', async () => { + const promptText = 'valid prompt text'; + const { getByTestId } = render(, { + wrapper: TestProviders, + }); + expect(getByTestId('prompt-textarea')).toHaveTextContent(promptText); + fireEvent.click(getByTestId('submit-chat')); + await waitFor(() => { + expect(handleButtonSendMessage).toHaveBeenCalledWith(promptText); + }); + }); + + it('promptValue is set to empty string if isDisabled=true', async () => { + const promptText = 'valid prompt text'; + const { getByTestId } = render(, { + wrapper: TestProviders, + }); + expect(getByTestId('prompt-textarea')).toHaveTextContent(''); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/index.tsx new file mode 100644 index 0000000000000..88db2e124ceab --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/index.tsx @@ -0,0 +1,84 @@ +/* + * 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, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { css } from '@emotion/react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { useChatSend, UseChatSendProps } from './use_chat_send'; +import { ChatActions } from '../chat_actions'; +import { PromptTextArea } from '../prompt_textarea'; + +export interface Props extends UseChatSendProps { + isDisabled: boolean; + shouldRefocusPrompt: boolean; + userPrompt: string | null; +} + +/** + * Renders the user input prompt text area. + * Allows the user to clear the chat and switch between different system prompts. + */ +export const ChatSend: React.FC = ({ + isDisabled, + userPrompt, + shouldRefocusPrompt, + ...rest +}) => { + const { + handleButtonSendMessage, + handleOnChatCleared, + handlePromptChange, + handleSendMessage, + isLoading, + } = useChatSend(rest); + // For auto-focusing prompt within timeline + const promptTextAreaRef = useRef(null); + useEffect(() => { + if (shouldRefocusPrompt && promptTextAreaRef.current) { + promptTextAreaRef?.current.focus(); + } + }, [shouldRefocusPrompt]); + const promptValue = useMemo(() => (isDisabled ? '' : userPrompt ?? ''), [isDisabled, userPrompt]); + + const onSendMessage = useCallback(() => { + handleButtonSendMessage(promptTextAreaRef.current?.value?.trim() ?? ''); + }, [handleButtonSendMessage, promptTextAreaRef]); + + return ( + + + + + + + + + ); +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.test.tsx new file mode 100644 index 0000000000000..5f2f5a4a0c400 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.test.tsx @@ -0,0 +1,109 @@ +/* + * 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 { HttpSetup } from '@kbn/core-http-browser'; +import { useSendMessages } from '../use_send_messages'; +import { useConversation } from '../use_conversation'; +import { emptyWelcomeConvo, welcomeConvo } from '../../mock/conversation'; +import { defaultSystemPrompt, mockSystemPrompt } from '../../mock/system_prompt'; +import { useChatSend, UseChatSendProps } from './use_chat_send'; +import { renderHook } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react'; + +jest.mock('../use_send_messages'); +jest.mock('../use_conversation'); + +const setEditingSystemPromptId = jest.fn(); +const setPromptTextPreview = jest.fn(); +const setSelectedPromptContexts = jest.fn(); +const setUserPrompt = jest.fn(); +const sendMessages = jest.fn(); +const appendMessage = jest.fn(); +const appendReplacements = jest.fn(); +const clearConversation = jest.fn(); + +export const testProps: UseChatSendProps = { + selectedPromptContexts: {}, + allSystemPrompts: [defaultSystemPrompt, mockSystemPrompt], + currentConversation: emptyWelcomeConvo, + http: { + basePath: { + basePath: '/mfg', + serverBasePath: '/mfg', + }, + anonymousPaths: {}, + externalUrl: {}, + } as unknown as HttpSetup, + editingSystemPromptId: defaultSystemPrompt.id, + setEditingSystemPromptId, + setPromptTextPreview, + setSelectedPromptContexts, + setUserPrompt, +}; +const robotMessage = 'Response message from the robot'; +describe('use chat send', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useSendMessages as jest.Mock).mockReturnValue({ + isLoading: false, + sendMessages: sendMessages.mockReturnValue(robotMessage), + }); + (useConversation as jest.Mock).mockReturnValue({ + appendMessage, + appendReplacements, + clearConversation, + }); + }); + it('handleOnChatCleared clears the conversation', () => { + const { result } = renderHook(() => useChatSend(testProps)); + result.current.handleOnChatCleared(); + expect(clearConversation).toHaveBeenCalled(); + expect(setPromptTextPreview).toHaveBeenCalledWith(''); + expect(setUserPrompt).toHaveBeenCalledWith(''); + expect(setSelectedPromptContexts).toHaveBeenCalledWith({}); + expect(clearConversation).toHaveBeenCalledWith(testProps.currentConversation.id); + expect(setEditingSystemPromptId).toHaveBeenCalledWith(defaultSystemPrompt.id); + }); + it('handlePromptChange updates prompt successfully', () => { + const { result } = renderHook(() => useChatSend(testProps)); + result.current.handlePromptChange('new prompt'); + expect(setPromptTextPreview).toHaveBeenCalledWith('new prompt'); + expect(setUserPrompt).toHaveBeenCalledWith('new prompt'); + }); + it('handleButtonSendMessage sends message with context prompt when a valid prompt text is provided', async () => { + const promptText = 'prompt text'; + const { result } = renderHook(() => useChatSend(testProps)); + result.current.handleButtonSendMessage(promptText); + expect(setUserPrompt).toHaveBeenCalledWith(''); + + await waitFor(() => { + expect(sendMessages).toHaveBeenCalled(); + const appendMessageSend = appendMessage.mock.calls[0][0]; + const appendMessageResponse = appendMessage.mock.calls[1][0]; + expect(appendMessageSend.message.content).toEqual( + `You are a helpful, expert assistant who answers questions about Elastic Security. Do not answer questions unrelated to Elastic Security.\nIf you answer a question related to KQL or EQL, it should be immediately usable within an Elastic Security timeline; please always format the output correctly with back ticks. Any answer provided for Query DSL should also be usable in a security timeline. This means you should only ever include the "filter" portion of the query.\nUse the following context to answer questions:\n\n\n\n${promptText}` + ); + expect(appendMessageSend.message.role).toEqual('user'); + expect(appendMessageResponse.message.content).toEqual(robotMessage); + expect(appendMessageResponse.message.role).toEqual('assistant'); + }); + }); + it('handleButtonSendMessage sends message with only provided prompt text and context already exists in convo history', async () => { + const promptText = 'prompt text'; + const { result } = renderHook(() => + useChatSend({ ...testProps, currentConversation: welcomeConvo }) + ); + + result.current.handleButtonSendMessage(promptText); + expect(setUserPrompt).toHaveBeenCalledWith(''); + + await waitFor(() => { + expect(sendMessages).toHaveBeenCalled(); + expect(appendMessage.mock.calls[0][0].message.content).toEqual(`\n\n${promptText}`); + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.tsx new file mode 100644 index 0000000000000..3e1c194097888 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.tsx @@ -0,0 +1,151 @@ +/* + * 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, { useCallback } from 'react'; +import { HttpSetup } from '@kbn/core-http-browser'; +import { SelectedPromptContext } from '../prompt_context/types'; +import { useSendMessages } from '../use_send_messages'; +import { useConversation } from '../use_conversation'; +import { getCombinedMessage } from '../prompt/helpers'; +import { Conversation, Message, Prompt } from '../../..'; +import { getMessageFromRawResponse } from '../helpers'; +import { getDefaultSystemPrompt } from '../use_conversation/helpers'; + +export interface UseChatSendProps { + allSystemPrompts: Prompt[]; + currentConversation: Conversation; + editingSystemPromptId: string | undefined; + http: HttpSetup; + selectedPromptContexts: Record; + setEditingSystemPromptId: React.Dispatch>; + setPromptTextPreview: React.Dispatch>; + setSelectedPromptContexts: React.Dispatch< + React.SetStateAction> + >; + setUserPrompt: React.Dispatch>; +} + +interface UseChatSend { + handleButtonSendMessage: (m: string) => void; + handleOnChatCleared: () => void; + handlePromptChange: (prompt: string) => void; + handleSendMessage: (promptText: string) => void; + isLoading: boolean; +} + +/** + * handles sending messages to an API and updating the conversation state. + * Provides a set of functions that can be used to handle user input, send messages to an API, + * and update the conversation state based on the API response. + */ +export const useChatSend = ({ + allSystemPrompts, + currentConversation, + editingSystemPromptId, + http, + selectedPromptContexts, + setEditingSystemPromptId, + setPromptTextPreview, + setSelectedPromptContexts, + setUserPrompt, +}: UseChatSendProps): UseChatSend => { + const { isLoading, sendMessages } = useSendMessages(); + const { appendMessage, appendReplacements, clearConversation } = useConversation(); + + const handlePromptChange = (prompt: string) => { + setPromptTextPreview(prompt); + setUserPrompt(prompt); + }; + + // Handles sending latest user prompt to API + const handleSendMessage = useCallback( + async (promptText: string) => { + const onNewReplacements = (newReplacements: Record) => + appendReplacements({ + conversationId: currentConversation.id, + replacements: newReplacements, + }); + + const systemPrompt = allSystemPrompts.find((prompt) => prompt.id === editingSystemPromptId); + + const message = await getCombinedMessage({ + isNewChat: currentConversation.messages.length === 0, + currentReplacements: currentConversation.replacements, + onNewReplacements, + promptText, + selectedPromptContexts, + selectedSystemPrompt: systemPrompt, + }); + + const updatedMessages = appendMessage({ + conversationId: currentConversation.id, + message, + }); + + // Reset prompt context selection and preview before sending: + setSelectedPromptContexts({}); + setPromptTextPreview(''); + + const rawResponse = await sendMessages({ + http, + apiConfig: currentConversation.apiConfig, + messages: updatedMessages, + }); + const responseMessage: Message = getMessageFromRawResponse(rawResponse); + appendMessage({ conversationId: currentConversation.id, message: responseMessage }); + }, + [ + allSystemPrompts, + currentConversation, + selectedPromptContexts, + appendMessage, + setSelectedPromptContexts, + setPromptTextPreview, + sendMessages, + http, + appendReplacements, + editingSystemPromptId, + ] + ); + + const handleButtonSendMessage = useCallback( + (message: string) => { + handleSendMessage(message); + setUserPrompt(''); + }, + [handleSendMessage, setUserPrompt] + ); + + const handleOnChatCleared = useCallback(() => { + const defaultSystemPromptId = getDefaultSystemPrompt({ + allSystemPrompts, + conversation: currentConversation, + })?.id; + + setPromptTextPreview(''); + setUserPrompt(''); + setSelectedPromptContexts({}); + clearConversation(currentConversation.id); + setEditingSystemPromptId(defaultSystemPromptId); + }, [ + allSystemPrompts, + clearConversation, + currentConversation, + setEditingSystemPromptId, + setPromptTextPreview, + setSelectedPromptContexts, + setUserPrompt, + ]); + + return { + handleButtonSendMessage, + handleOnChatCleared, + handlePromptChange, + handleSendMessage, + isLoading, + }; +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.test.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.test.ts index d6a2f471516e2..02f554e7cf925 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.test.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.test.ts @@ -5,11 +5,11 @@ * 2.0. */ -import { getDefaultConnector, getWelcomeConversation } from './helpers'; +import { getDefaultConnector, getBlockBotConversation } from './helpers'; import { enterpriseMessaging } from './use_conversation/sample_conversations'; import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; -describe('getWelcomeConversation', () => { +describe('getBlockBotConversation', () => { describe('isAssistantEnabled = false', () => { const isAssistantEnabled = false; it('When no conversation history, return only enterprise messaging', () => { @@ -19,7 +19,7 @@ describe('getWelcomeConversation', () => { messages: [], apiConfig: {}, }; - const result = getWelcomeConversation(conversation, isAssistantEnabled); + const result = getBlockBotConversation(conversation, isAssistantEnabled); expect(result.messages).toEqual(enterpriseMessaging); expect(result.messages.length).toEqual(1); }); @@ -41,7 +41,7 @@ describe('getWelcomeConversation', () => { ], apiConfig: {}, }; - const result = getWelcomeConversation(conversation, isAssistantEnabled); + const result = getBlockBotConversation(conversation, isAssistantEnabled); expect(result.messages.length).toEqual(2); }); @@ -52,7 +52,7 @@ describe('getWelcomeConversation', () => { messages: enterpriseMessaging, apiConfig: {}, }; - const result = getWelcomeConversation(conversation, isAssistantEnabled); + const result = getBlockBotConversation(conversation, isAssistantEnabled); expect(result.messages.length).toEqual(1); expect(result.messages).toEqual(enterpriseMessaging); }); @@ -75,7 +75,7 @@ describe('getWelcomeConversation', () => { ], apiConfig: {}, }; - const result = getWelcomeConversation(conversation, isAssistantEnabled); + const result = getBlockBotConversation(conversation, isAssistantEnabled); expect(result.messages.length).toEqual(3); }); }); @@ -89,7 +89,7 @@ describe('getWelcomeConversation', () => { messages: [], apiConfig: {}, }; - const result = getWelcomeConversation(conversation, isAssistantEnabled); + const result = getBlockBotConversation(conversation, isAssistantEnabled); expect(result.messages.length).toEqual(3); }); it('returns a conversation history with the welcome conversation appended', () => { @@ -109,7 +109,7 @@ describe('getWelcomeConversation', () => { ], apiConfig: {}, }; - const result = getWelcomeConversation(conversation, isAssistantEnabled); + const result = getBlockBotConversation(conversation, isAssistantEnabled); expect(result.messages.length).toEqual(4); }); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.ts index 7662c749a7931..b01c9001e8319 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.ts @@ -27,7 +27,7 @@ export const getMessageFromRawResponse = (rawResponse: string): Message => { } }; -export const getWelcomeConversation = ( +export const getBlockBotConversation = ( conversation: Conversation, isAssistantEnabled: boolean ): Conversation => { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx index fd6057e8a2e16..2268f8751e64d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx @@ -10,12 +10,8 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, - EuiButtonIcon, - EuiHorizontalRule, EuiCommentList, - EuiToolTip, EuiSwitchEvent, - EuiSwitch, EuiModalFooter, EuiModalHeader, EuiModalBody, @@ -26,28 +22,22 @@ import { css } from '@emotion/react'; import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/gen_ai/constants'; import { ActionConnectorProps } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { ChatSend } from './chat_send'; +import { BlockBotCallToAction } from './block_bot/cta'; +import { AssistantHeader } from './assistant_header'; import { WELCOME_CONVERSATION_TITLE } from './use_conversation/translations'; -import { AssistantTitle } from './assistant_title'; -import { UpgradeButtons } from '../upgrade/upgrade_buttons'; -import { getDefaultConnector, getMessageFromRawResponse, getWelcomeConversation } from './helpers'; +import { getDefaultConnector, getBlockBotConversation } from './helpers'; import { useAssistantContext } from '../assistant_context'; import { ContextPills } from './context_pills'; import { getNewSelectedPromptContext } from '../data_anonymization/get_new_selected_prompt_context'; -import { PromptTextArea } from './prompt_textarea'; import type { PromptContext, SelectedPromptContext } from './prompt_context/types'; import { useConversation } from './use_conversation'; import { CodeBlockDetails, getDefaultSystemPrompt } from './use_conversation/helpers'; -import { useSendMessages } from './use_send_messages'; -import type { Message } from '../assistant_context/types'; -import { ConversationSelector } from './conversations/conversation_selector'; import { PromptEditor } from './prompt_editor'; -import { getCombinedMessage } from './prompt/helpers'; -import * as i18n from './translations'; import { QuickPrompts } from './quick_prompts/quick_prompts'; import { useLoadConnectors } from '../connectorland/use_load_connectors'; import { useConnectorSetup } from '../connectorland/connector_setup'; -import { AssistantSettingsButton } from './settings/assistant_settings_button'; import { ConnectorMissingCallout } from '../connectorland/connector_missing_callout'; export interface Props { @@ -93,9 +83,7 @@ const AssistantComponent: React.FC = ({ [selectedPromptContexts] ); - const { appendMessage, appendReplacements, clearConversation, createConversation } = - useConversation(); - const { isLoading, sendMessages } = useSendMessages(); + const { createConversation } = useConversation(); // Connector details const { @@ -144,8 +132,8 @@ const AssistantComponent: React.FC = ({ // Welcome conversation is a special 'setup' case when no connector exists, mostly extracted to `ConnectorSetup` component, // but currently a bit of state is littered throughout the assistant component. TODO: clean up/isolate this state - const welcomeConversation = useMemo( - () => getWelcomeConversation(currentConversation, isAssistantEnabled), + const blockBotConversation = useMemo( + () => getBlockBotConversation(currentConversation, isAssistantEnabled), [currentConversation, isAssistantEnabled] ); @@ -171,13 +159,16 @@ const AssistantComponent: React.FC = ({ onSetupComplete: () => { bottomRef.current?.scrollIntoView({ behavior: 'auto' }); }, - conversation: welcomeConversation, + conversation: blockBotConversation, isConnectorConfigured: !!connectors?.length, }); const currentTitle: { title: string | JSX.Element; titleIcon: string } = - isWelcomeSetup && welcomeConversation.theme?.title && welcomeConversation.theme?.titleIcon - ? { title: welcomeConversation.theme?.title, titleIcon: welcomeConversation.theme?.titleIcon } + isWelcomeSetup && blockBotConversation.theme?.title && blockBotConversation.theme?.titleIcon + ? { + title: blockBotConversation.theme?.title, + titleIcon: blockBotConversation.theme?.titleIcon, + } : { title, titleIcon: 'logoSecurity' }; const bottomRef = useRef(null); @@ -219,15 +210,6 @@ const AssistantComponent: React.FC = ({ }, []); // End drill in `Add To Timeline` action - // For auto-focusing prompt within timeline - const promptTextAreaRef = useRef(null); - - useEffect(() => { - if (shouldRefocusPrompt && promptTextAreaRef.current) { - promptTextAreaRef?.current.focus(); - } - }, [shouldRefocusPrompt]); - // Scroll to bottom on conversation change useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'auto' }); @@ -235,7 +217,6 @@ const AssistantComponent: React.FC = ({ useEffect(() => { setTimeout(() => { bottomRef.current?.scrollIntoView({ behavior: 'auto' }); - promptTextAreaRef?.current?.focus(); }, 0); }, [currentConversation.messages.length, selectedPromptContextsCount]); //// @@ -260,90 +241,10 @@ const AssistantComponent: React.FC = ({ [allSystemPrompts, conversations] ); - const handlePromptChange = useCallback((prompt: string) => { - setPromptTextPreview(prompt); - setUserPrompt(prompt); - }, []); - - // Handles sending latest user prompt to API - const handleSendMessage = useCallback( - async (promptText) => { - const onNewReplacements = (newReplacements: Record) => - appendReplacements({ - conversationId: selectedConversationId, - replacements: newReplacements, - }); - - const systemPrompt = allSystemPrompts.find((prompt) => prompt.id === editingSystemPromptId); - - const message = await getCombinedMessage({ - isNewChat: currentConversation.messages.length === 0, - currentReplacements: currentConversation.replacements, - onNewReplacements, - promptText, - selectedPromptContexts, - selectedSystemPrompt: systemPrompt, - }); - - const updatedMessages = appendMessage({ - conversationId: selectedConversationId, - message, - }); - - // Reset prompt context selection and preview before sending: - setSelectedPromptContexts({}); - setPromptTextPreview(''); - - const rawResponse = await sendMessages({ - http, - apiConfig: currentConversation.apiConfig, - messages: updatedMessages, - }); - const responseMessage: Message = getMessageFromRawResponse(rawResponse); - appendMessage({ conversationId: selectedConversationId, message: responseMessage }); - }, - [ - allSystemPrompts, - currentConversation.messages.length, - currentConversation.replacements, - currentConversation.apiConfig, - selectedPromptContexts, - appendMessage, - selectedConversationId, - sendMessages, - http, - appendReplacements, - editingSystemPromptId, - ] - ); - - const handleButtonSendMessage = useCallback(() => { - handleSendMessage(promptTextAreaRef.current?.value?.trim() ?? ''); - setUserPrompt(''); - }, [handleSendMessage, promptTextAreaRef]); - const handleOnSystemPromptSelectionChange = useCallback((systemPromptId?: string) => { setEditingSystemPromptId(systemPromptId); }, []); - const handleOnChatCleared = useCallback(() => { - const defaultSystemPromptId = getDefaultSystemPrompt({ - allSystemPrompts, - conversation: conversations[selectedConversationId], - })?.id; - - setPromptTextPreview(''); - setUserPrompt(''); - setSelectedPromptContexts({}); - clearConversation(selectedConversationId); - setEditingSystemPromptId(defaultSystemPromptId); - }, [allSystemPrompts, clearConversation, conversations, selectedConversationId]); - - const shouldDisableConversationSelectorHotkeys = useCallback(() => { - const promptTextAreaHasFocus = document.activeElement === promptTextAreaRef.current; - return promptTextAreaHasFocus; - }, [promptTextAreaRef]); - // Add min-height to all codeblocks so timeline icon doesn't overflow const codeBlockContainers = [...document.getElementsByClassName('euiCodeBlock')]; // @ts-ignore-expect-error @@ -398,7 +299,6 @@ const AssistantComponent: React.FC = ({ currentConversation.messages, promptContexts, promptContextId, - handleSendMessage, conversationId, selectedConversationId, selectedPromptContexts, @@ -442,11 +342,11 @@ const AssistantComponent: React.FC = ({ `} /> - {currentConversation.messages.length !== 0 && - Object.keys(selectedPromptContexts).length > 0 && } + {currentConversation.messages.length !== 0 && selectedPromptContextsCount > 0 && ( + + )} - {(currentConversation.messages.length === 0 || - Object.keys(selectedPromptContexts).length > 0) && ( + {(currentConversation.messages.length === 0 || selectedPromptContextsCount > 0) && ( = ({ promptContexts, promptTextPreview, selectedPromptContexts, + selectedPromptContextsCount, showAnonymizedValues, ] ); @@ -504,73 +405,21 @@ const AssistantComponent: React.FC = ({ `} > {showTitle && ( - <> - - - - - - - - - <> - - - - - 0 && - showAnonymizedValues - } - compressed={true} - disabled={currentConversation.replacements == null} - label={i18n.SHOW_ANONYMIZED} - onChange={onToggleShowAnonymizedValues} - /> - - - - - - - - - - - - + )} {/* Create portals for each EuiCodeBlock to add the `Investigate in Timeline` action */} @@ -612,87 +461,26 @@ const AssistantComponent: React.FC = ({ flex-direction: column; `} > - {!isAssistantEnabled ? ( - - - {} - - - ) : ( - isWelcomeSetup && ( - - {connectorPrompt} - - ) - )} - - - - - - - - - - - - - - - - - - - - + + {!isDisabled && ( ( - +