Assistant refactor (#162079)

This commit is contained in:
Steph Milovic 2023-07-25 10:31:04 -06:00 committed by GitHub
parent f1fca32d3d
commit 06fabab55b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 1082 additions and 284 deletions

View file

@ -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(<AssistantHeader {...testProps} />, {
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(
<AssistantHeader
{...testProps}
currentConversation={{ ...emptyWelcomeConvo, replacements: {} }}
/>,
{
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(
<AssistantHeader
{...testProps}
currentConversation={alertConvo}
selectedConversationId={alertConvo.id}
/>,
{
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(
<AssistantHeader
{...testProps}
currentConversation={alertConvo}
selectedConversationId={alertConvo.id}
showAnonymizedValues
/>,
{
wrapper: TestProviders,
}
);
expect(getByTestId('showAnonymizedValues')).toHaveAttribute('aria-checked', 'true');
});
});

View file

@ -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<DocLinksStart, 'links'>;
isDisabled: boolean;
isSettingsModalVisible: boolean;
onConversationSelected: (cId: string) => void;
onToggleShowAnonymizedValues: (e: EuiSwitchEvent) => void;
selectedConversationId: string;
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedConversationId: React.Dispatch<React.SetStateAction<string>>;
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<Props> = ({
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 (
<>
<EuiFlexGroup
css={css`
width: 100%;
`}
alignItems={'center'}
justifyContent={'spaceBetween'}
>
<EuiFlexItem grow={false}>
<AssistantTitle {...currentTitle} docLinks={docLinks} />
</EuiFlexItem>
<EuiFlexItem
grow={false}
css={css`
width: 335px;
`}
>
<ConversationSelector
defaultConnectorId={defaultConnectorId}
defaultProvider={defaultProvider}
selectedConversationId={selectedConversationId}
onConversationSelected={onConversationSelected}
shouldDisableKeyboardShortcut={shouldDisableKeyboardShortcut}
isDisabled={isDisabled}
/>
<>
<EuiSpacer size={'s'} />
<EuiFlexGroup alignItems="center" gutterSize="none" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiToolTip
content={i18n.SHOW_ANONYMIZED_TOOLTIP}
position="left"
repositionOnScroll={true}
>
<EuiSwitch
data-test-subj="showAnonymizedValues"
checked={showAnonymizedValuesChecked}
compressed={true}
disabled={currentConversation.replacements == null}
label={i18n.SHOW_ANONYMIZED}
onChange={onToggleShowAnonymizedValues}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AssistantSettingsButton
defaultConnectorId={defaultConnectorId}
defaultProvider={defaultProvider}
isDisabled={isDisabled}
isSettingsModalVisible={isSettingsModalVisible}
selectedConversation={currentConversation}
setIsSettingsModalVisible={setIsSettingsModalVisible}
setSelectedConversationId={setSelectedConversationId}
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin={'m'} />
</>
);
};

View file

@ -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(<AssistantTitle {...testProps} />);
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(<AssistantTitle {...testProps} />, {
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'
);
});
});

View file

@ -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<DocLinksStart, 'links'>;
}> = ({ 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 (
<EuiModalHeaderTitle>
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon type={currentTitle.titleIcon} size="xl" />
<EuiIcon data-test-subj="titleIcon" type={titleIcon} size="xl" />
</EuiFlexItem>
<EuiFlexItem grow={false}>{currentTitle.title}</EuiFlexItem>
<EuiFlexItem grow={false}>{title}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
button={
<EuiButtonIcon
aria-label={i18n.TOOLTIP_ARIA_LABEL}
data-test-subj="tooltipIcon"
iconSize="l"
iconType="iInCircle"
onClick={onButtonClick}
@ -78,7 +86,7 @@ export const AssistantTitle: FunctionComponent<{
closePopover={closePopover}
anchorPosition="upCenter"
>
<EuiText grow={false} css={{ maxWidth: '400px' }}>
<EuiText data-test-subj="tooltipContent" grow={false} css={{ maxWidth: '400px' }}>
<h4>{i18n.TOOLTIP_TITLE}</h4>
<p>{content}</p>
</EuiText>

View file

@ -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: <div>{'Connector Prompt'}</div>,
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(<BlockBotCallToAction {...testProps} />);
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(<BlockBotCallToAction {...props} />);
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(<BlockBotCallToAction {...props} />);
expect(container.firstChild).toBeNull();
expect(queryByTestId('connector-prompt')).not.toBeInTheDocument();
expect(queryByTestId('upgrade-buttons')).not.toBeInTheDocument();
});
});

View file

@ -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<Props> = ({
connectorPrompt,
http,
isAssistantEnabled,
isWelcomeSetup,
}) => {
const basePath = http.basePath.get();
return !isAssistantEnabled ? (
<EuiFlexGroup
justifyContent="spaceAround"
css={css`
width: 100%;
`}
>
<EuiFlexItem grow={false}>{<UpgradeButtons basePath={basePath} />}</EuiFlexItem>
</EuiFlexGroup>
) : isWelcomeSetup ? (
<EuiFlexGroup
css={css`
width: 100%;
`}
>
<EuiFlexItem data-test-subj="connector-prompt">{connectorPrompt}</EuiFlexItem>
</EuiFlexGroup>
) : null;
};

View file

@ -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(<ChatActions {...testProps} />);
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(<ChatActions {...testProps} />);
fireEvent.click(getByTestId('clear-chat'));
expect(onChatCleared).toHaveBeenCalled();
});
it('onSendMessage function is called when send message button is clicked', () => {
const { getByTestId } = render(<ChatActions {...testProps} />);
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(<ChatActions {...props} />);
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(<ChatActions {...props} />);
expect(within(getByTestId('submit-chat')).getByRole('progressbar')).toBeInTheDocument();
});
});

View file

@ -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<Props> = ({
isDisabled,
isLoading,
onChatCleared,
onSendMessage,
}) => {
return (
<EuiFlexGroup
css={css`
position: absolute;
`}
direction="column"
gutterSize="xs"
>
<EuiFlexItem grow={false}>
<EuiToolTip position="right" content={CLEAR_CHAT}>
<EuiButtonIcon
aria-label={CLEAR_CHAT}
color="danger"
data-test-subj="clear-chat"
display="base"
iconType="cross"
isDisabled={isDisabled}
onClick={onChatCleared}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip position="right" content={SUBMIT_MESSAGE}>
<EuiButtonIcon
aria-label={SUBMIT_MESSAGE}
data-test-subj="submit-chat"
color="primary"
display="base"
iconType="returnKey"
isDisabled={isDisabled}
isLoading={isLoading}
onClick={onSendMessage}
/>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -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(<ChatSend {...testProps} />, {
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(<ChatSend {...testProps} userPrompt={promptText} />, {
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(<ChatSend {...testProps} userPrompt={promptText} isDisabled />, {
wrapper: TestProviders,
});
expect(getByTestId('prompt-textarea')).toHaveTextContent('');
});
});

View file

@ -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<Props> = ({
isDisabled,
userPrompt,
shouldRefocusPrompt,
...rest
}) => {
const {
handleButtonSendMessage,
handleOnChatCleared,
handlePromptChange,
handleSendMessage,
isLoading,
} = useChatSend(rest);
// For auto-focusing prompt within timeline
const promptTextAreaRef = useRef<HTMLTextAreaElement>(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 (
<EuiFlexGroup
gutterSize="none"
css={css`
width: 100%;
`}
>
<EuiFlexItem>
<PromptTextArea
onPromptSubmit={handleSendMessage}
ref={promptTextAreaRef}
handlePromptChange={handlePromptChange}
value={promptValue}
isDisabled={isDisabled}
/>
</EuiFlexItem>
<EuiFlexItem
css={css`
left: -34px;
position: relative;
top: 11px;
`}
grow={false}
>
<ChatActions
onChatCleared={handleOnChatCleared}
isDisabled={isDisabled}
isLoading={isLoading}
onSendMessage={onSendMessage}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -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}`);
});
});
});

View file

@ -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<string, SelectedPromptContext>;
setEditingSystemPromptId: React.Dispatch<React.SetStateAction<string | undefined>>;
setPromptTextPreview: React.Dispatch<React.SetStateAction<string>>;
setSelectedPromptContexts: React.Dispatch<
React.SetStateAction<Record<string, SelectedPromptContext>>
>;
setUserPrompt: React.Dispatch<React.SetStateAction<string | null>>;
}
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<string, string>) =>
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,
};
};

View file

@ -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);
});
});

View file

@ -27,7 +27,7 @@ export const getMessageFromRawResponse = (rawResponse: string): Message => {
}
};
export const getWelcomeConversation = (
export const getBlockBotConversation = (
conversation: Conversation,
isAssistantEnabled: boolean
): Conversation => {

View file

@ -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<Props> = ({
[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<Props> = ({
// 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<Props> = ({
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<HTMLDivElement | null>(null);
@ -219,15 +210,6 @@ const AssistantComponent: React.FC<Props> = ({
}, []);
// End drill in `Add To Timeline` action
// For auto-focusing prompt within timeline
const promptTextAreaRef = useRef<HTMLTextAreaElement>(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<Props> = ({
useEffect(() => {
setTimeout(() => {
bottomRef.current?.scrollIntoView({ behavior: 'auto' });
promptTextAreaRef?.current?.focus();
}, 0);
}, [currentConversation.messages.length, selectedPromptContextsCount]);
////
@ -260,90 +241,10 @@ const AssistantComponent: React.FC<Props> = ({
[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<string, string>) =>
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<Props> = ({
currentConversation.messages,
promptContexts,
promptContextId,
handleSendMessage,
conversationId,
selectedConversationId,
selectedPromptContexts,
@ -442,11 +342,11 @@ const AssistantComponent: React.FC<Props> = ({
`}
/>
{currentConversation.messages.length !== 0 &&
Object.keys(selectedPromptContexts).length > 0 && <EuiSpacer size={'m'} />}
{currentConversation.messages.length !== 0 && selectedPromptContextsCount > 0 && (
<EuiSpacer size={'m'} />
)}
{(currentConversation.messages.length === 0 ||
Object.keys(selectedPromptContexts).length > 0) && (
{(currentConversation.messages.length === 0 || selectedPromptContextsCount > 0) && (
<PromptEditor
conversation={currentConversation}
editingSystemPromptId={editingSystemPromptId}
@ -473,6 +373,7 @@ const AssistantComponent: React.FC<Props> = ({
promptContexts,
promptTextPreview,
selectedPromptContexts,
selectedPromptContextsCount,
showAnonymizedValues,
]
);
@ -504,73 +405,21 @@ const AssistantComponent: React.FC<Props> = ({
`}
>
{showTitle && (
<>
<EuiFlexGroup
css={css`
width: 100%;
`}
alignItems={'center'}
justifyContent={'spaceBetween'}
>
<EuiFlexItem grow={false}>
<AssistantTitle currentTitle={currentTitle} docLinks={docLinks} />
</EuiFlexItem>
<EuiFlexItem
grow={false}
css={css`
width: 335px;
`}
>
<ConversationSelector
defaultConnectorId={defaultConnectorId}
defaultProvider={defaultProvider}
selectedConversationId={selectedConversationId}
onConversationSelected={handleOnConversationSelected}
shouldDisableKeyboardShortcut={shouldDisableConversationSelectorHotkeys}
isDisabled={isDisabled}
/>
<>
<EuiSpacer size={'s'} />
<EuiFlexGroup alignItems="center" gutterSize="none" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiToolTip
content={i18n.SHOW_ANONYMIZED_TOOLTIP}
position="left"
repositionOnScroll={true}
>
<EuiSwitch
checked={
currentConversation.replacements != null &&
Object.keys(currentConversation.replacements).length > 0 &&
showAnonymizedValues
}
compressed={true}
disabled={currentConversation.replacements == null}
label={i18n.SHOW_ANONYMIZED}
onChange={onToggleShowAnonymizedValues}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AssistantSettingsButton
defaultConnectorId={defaultConnectorId}
defaultProvider={defaultProvider}
isDisabled={isDisabled}
isSettingsModalVisible={isSettingsModalVisible}
selectedConversation={currentConversation}
setIsSettingsModalVisible={setIsSettingsModalVisible}
setSelectedConversationId={setSelectedConversationId}
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin={'m'} />
</>
<AssistantHeader
currentConversation={currentConversation}
currentTitle={currentTitle}
defaultConnectorId={defaultConnectorId}
defaultProvider={defaultProvider}
docLinks={docLinks}
isDisabled={isDisabled}
isSettingsModalVisible={isSettingsModalVisible}
onConversationSelected={handleOnConversationSelected}
onToggleShowAnonymizedValues={onToggleShowAnonymizedValues}
selectedConversationId={selectedConversationId}
setIsSettingsModalVisible={setIsSettingsModalVisible}
setSelectedConversationId={setSelectedConversationId}
showAnonymizedValues={showAnonymizedValues}
/>
)}
{/* Create portals for each EuiCodeBlock to add the `Investigate in Timeline` action */}
@ -612,87 +461,26 @@ const AssistantComponent: React.FC<Props> = ({
flex-direction: column;
`}
>
{!isAssistantEnabled ? (
<EuiFlexGroup
justifyContent="spaceAround"
css={css`
width: 100%;
`}
>
<EuiFlexItem grow={false}>
{<UpgradeButtons basePath={http.basePath.get()} />}
</EuiFlexItem>
</EuiFlexGroup>
) : (
isWelcomeSetup && (
<EuiFlexGroup
css={css`
width: 100%;
`}
>
<EuiFlexItem>{connectorPrompt}</EuiFlexItem>
</EuiFlexGroup>
)
)}
<EuiFlexGroup
gutterSize="none"
css={css`
width: 100%;
`}
>
<EuiFlexItem>
<PromptTextArea
onPromptSubmit={handleSendMessage}
ref={promptTextAreaRef}
handlePromptChange={handlePromptChange}
value={isSendingDisabled ? '' : userPrompt ?? ''}
isDisabled={isSendingDisabled}
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
css={css`
left: -34px;
position: relative;
top: 11px;
`}
>
<EuiFlexGroup
direction="column"
gutterSize="xs"
css={css`
position: absolute;
`}
>
<EuiFlexItem grow={false}>
<EuiToolTip position="right" content={i18n.CLEAR_CHAT}>
<EuiButtonIcon
display="base"
iconType="cross"
isDisabled={isSendingDisabled}
aria-label={i18n.CLEAR_CHAT}
color="danger"
onClick={handleOnChatCleared}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip position="right" content={i18n.SUBMIT_MESSAGE}>
<EuiButtonIcon
display="base"
iconType="returnKey"
isDisabled={isSendingDisabled}
aria-label={i18n.SUBMIT_MESSAGE}
color="primary"
onClick={handleButtonSendMessage}
isLoading={isLoading}
/>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<BlockBotCallToAction
connectorPrompt={connectorPrompt}
http={http}
isAssistantEnabled={isAssistantEnabled}
isWelcomeSetup={isWelcomeSetup}
/>
<ChatSend
allSystemPrompts={allSystemPrompts}
currentConversation={currentConversation}
isDisabled={isSendingDisabled}
shouldRefocusPrompt={shouldRefocusPrompt}
setPromptTextPreview={setPromptTextPreview}
userPrompt={userPrompt}
setUserPrompt={setUserPrompt}
editingSystemPromptId={editingSystemPromptId}
http={http}
setEditingSystemPromptId={setEditingSystemPromptId}
selectedPromptContexts={selectedPromptContexts}
setSelectedPromptContexts={setSelectedPromptContexts}
/>
{!isDisabled && (
<QuickPrompts
setInput={setUserPrompt}

View file

@ -0,0 +1,71 @@
/*
* 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 { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/gen_ai/constants';
import { Conversation } from '../..';
export const alertConvo: Conversation = {
id: 'Alert summary',
isDefault: true,
messages: [
{
content:
'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\nCONTEXT:\n"""\ndestination.ip,67bf8338-261a-4de6-b43e-d30b59e884a7\nhost.name,0b2e352b-35fc-47bd-a8d4-43019ed38a25\nkibana.alert.rule.name,critical hosts\nsource.ip,94277492-11f8-493b-9c52-c1c9ecd330d2\n"""\n\nEvaluate the event from the context above and format your output neatly in markdown syntax for my Elastic Security case.\nAdd your description, recommended actions and bulleted triage steps. Use the MITRE ATT&CK data provided to add more context and recommendations from MITRE, and hyperlink to the relevant pages on MITRE\'s website. Be sure to include the user and host risk score data from the context. Your response should include steps that point to Elastic Security specific features, including endpoint response actions, the Elastic Agent OSQuery manager integration (with example osquery queries), timelines and entity analytics and link to all the relevant Elastic Security documentation.',
role: 'user',
timestamp: '7/18/2023, 10:39:11 AM',
},
],
apiConfig: {
connectorId: 'c29c28a0-20fe-11ee-9306-a1f4d42ec542',
provider: OpenAiProviderType.OpenAi,
},
replacements: {
'94277492-11f8-493b-9c52-c1c9ecd330d2': '192.168.0.4',
'67bf8338-261a-4de6-b43e-d30b59e884a7': '192.168.0.1',
'0b2e352b-35fc-47bd-a8d4-43019ed38a25': 'Stephs-MacBook-Pro.local',
},
};
export const emptyWelcomeConvo: Conversation = {
id: 'Welcome',
isDefault: true,
theme: {
title: 'Elastic AI Assistant',
titleIcon: 'logoSecurity',
assistant: {
name: 'Elastic AI Assistant',
icon: 'logoSecurity',
},
system: {
icon: 'logoElastic',
},
user: {},
},
messages: [],
apiConfig: {
connectorId: 'c29c28a0-20fe-11ee-9306-a1f4d42ec542',
provider: OpenAiProviderType.OpenAi,
},
};
export const welcomeConvo: Conversation = {
...emptyWelcomeConvo,
messages: [
{
content:
'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\nhow do i write host.name: * in EQL?',
role: 'user',
timestamp: '7/17/2023, 1:00:36 PM',
},
{
role: 'assistant',
content:
"In EQL (Event Query Language), you can write the equivalent of `host.name: *` using the `exists` operator. Here's how you can write it:\n\n```\nexists(host.name)\n```\n\nThis query will match all events where the `host.name` field exists, effectively giving you the same result as `host.name: *`.",
timestamp: '7/17/2023, 1:00:40 PM',
},
],
};

View file

@ -21,3 +21,13 @@ You have the personality of a mutant superhero who says "bub" a lot.`,
name: 'Mock superhero system prompt',
promptType: 'system',
};
export const defaultSystemPrompt: Prompt = {
id: 'default-system-prompt',
content:
'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:',
name: 'Default system prompt',
promptType: 'system',
isDefault: true,
isNewConversationDefault: true,
};

View file

@ -11,7 +11,7 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import * as i18n from './translations';
export const UpgradeButtonsComponent = ({ basePath }: { basePath: string }) => (
<EuiFlexGroup gutterSize="s" wrap={true}>
<EuiFlexGroup gutterSize="s" wrap={true} data-test-subj="upgrade-buttons">
<EuiFlexItem grow={false}>
<EuiButton
href="https://www.elastic.co/subscriptions"