[Security Solution] Elastic Security Assistant (#156933)

## [Security Solution] Elastic Security Assistant

The _Elastic Security Assistant_ has entered the chat, integrating generative AI and large language models (LLMs) into the workflows of Elastic Security users.

Bring your alerts, events, rules, and data quality checks into the conversation.

<31d65c78-5692-4817-b726-820c5df0801c>

This PR merges a feature branch developed by @spong and @andrew-goldstein , seeded by @jamesspi 's prototype of the assistant. Connectivity to LLMs is provided the [Generative AI Connector](<https://github.com/elastic/kibana/pull/157228>) , developed by @stephmilovic . This PR includes:

- A new reusable Kibana package containing the assistant: `x-pack/packages/kbn-elastic-assistant`
  - See the `How to embed the Assistant in other parts of Kibana` for details
- Assistant integration into Elastic Security Solution workflows (e.g. alerts, cases, Timeline, rules, data quality)

### An assistant trained on the Elastic stack and Elastic Security

The [Generative AI Connector](<https://github.com/elastic/kibana/pull/157228>) connects the assistant to OpenAI and Azure OpenAI models trained with knowledge of the Elastic stack and the Elastic Security solution, including:

- The Elastic open [Detection Rules](https://github.com/elastic/detection-rules)
- The [Elastic Common Schema (ECS)](https://www.elastic.co/guide/en/ecs/current/index.html)
- Elastic query languages, including [KQL](https://www.elastic.co/guide/en/kibana/current/kuery-query.html), [EQL](https://www.elastic.co/guide/en/elasticsearch/reference/current/eql-syntax.html), and the [Elastic Query DSL](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html)
- [Elasticsearch API documentation](https://www.elastic.co/guide/en/elasticsearch/reference/8.8/rest-apis.html)

This training enables the assistant to offer fully interactive chat experiences that include:

- alert summarization
- interactive query generation
- workflow suggestions
- generating ingestion configurations that conform to the Elastic Common Schema
- your imagination

using context from Elastic Security.

### Take action from your conversations

The Actions (from assistant response):

- Send KQL to Timeline
- Send EQL to Timeline
- Send Elasticsearch DSL to Timeline
- Send Note to timeline
- Create new case
- Add to existing case
- Copy to clipboard

### Components architecture diagram

![Untitled Diagram drawio (1)](8f446313-629f-4646-b44d-0cb0ca74aeaf)

### How to embed the Assistant in other parts of Kibana

Follow the general instructions in `x-pack/packages/kbn-elastic-assistant/index.ts` to integrate the assistant into a Kibana app.

#### Step 1 - Wrap your Kibana app in the `AssistantProvider` component

```ts
// Step 1: Wrap your Kibana app in the `AssistantProvider` component. This typically
// happens in the root of your app. Optionally provide a custom title for the assistant:

/** provides context (from the app) to the assistant, and injects Kibana services, like `http` */
export { AssistantProvider } from './impl/assistant_context';
```

#### Step 2: Add the `AssistantOverlay` component to your app

```ts
// Step 2: Add the `AssistantOverlay` component to your app. This component displays the assistant
// overlay in a modal, bound to a shortcut key:

/** modal overlay for Elastic Assistant conversations */
export { AssistantOverlay } from './impl/assistant/assistant_overlay';

// In addition to the `AssistantOverlay`, or as an alternative, you may use the `Assistant` component
// to display the assistant without the modal overlay:

/** this component renders the Assistant without the modal overlay to, for example, render it in a Timeline tab */
export { Assistant } from './impl/assistant';
```

#### Step 3: Wherever you want to bring context into the assistant, use the any combination of the following

```ts
// Step 3: Wherever you want to bring context into the assistant, use the any combination of the following
// components and hooks:
// - `NewChat` component
// - `NewChatById` component
// - `useAssistantOverlay` hook

/**
 * `NewChat` displays a _New chat_ icon button, providing all the context
 * necessary to start a new chat. You may optionally style the button icon,
 * or override the default _New chat_ text with custom content, like `🪄`
 *
 * USE THIS WHEN: All the data necessary to start a new chat is available
 * in the same part of the React tree as the _New chat_ button.
 */
export { NewChat } from './impl/new_chat';

/**
 * `NewChatByID` displays a _New chat_ icon button by providing only the `promptContextId`
 * of a context that was (already) registered by the `useAssistantOverlay` hook. You may
 * optionally style the button icon, or override the default _New chat_ text with custom
 * content, like {'🪄'}
 *
 * USE THIS WHEN: all the data necessary to start a new chat is NOT available
 * in the same part of the React tree as the _New chat_ button. When paired
 * with the `useAssistantOverlay` hook, this option enables context to be be
 * registered where the data is available, and then the _New chat_ button can be displayed
 * in another part of the tree.
 */
export { NewChatById } from './impl/new_chat_by_id';

/**
 * `useAssistantOverlay` is a hook that registers context with the assistant overlay, and
 * returns an optional `showAssistantOverlay` function to display the assistant overlay.
 * As an alterative to using the `showAssistantOverlay` returned from this hook, you may
 * use the `NewChatById` component and pass it the `promptContextId` returned by this hook.
 *
 * USE THIS WHEN: You want to register context in one part of the tree, and then show
 * a _New chat_ button in another part of the tree without passing around the data, or when
 * you want to build a custom `New chat` button with features not not provided by the
 * `NewChat` component.
 */
export { useAssistantOverlay } from './impl/assistant/use_assistant_overlay';
```

Co-authored-by: Garrett Spong <garrett.spong@elastic.co>
Co-authored-by: Andrew Macri <andrew.macri@elastic.co>
This commit is contained in:
Garrett Spong 2023-06-02 15:19:10 -06:00 committed by GitHub
parent 18e145ccb6
commit 4e38817a4d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
147 changed files with 7872 additions and 95 deletions

View file

@ -982,6 +982,7 @@ module.exports = {
// front end and common typescript and javascript files only
files: [
'x-pack/plugins/ecs_data_quality_dashboard/common/**/*.{js,mjs,ts,tsx}',
'x-pack/packages/kbn-elastic-assistant/**/*.{js,mjs,ts,tsx}',
'x-pack/packages/security-solution/**/*.{js,mjs,ts,tsx}',
'x-pack/plugins/security_solution/public/**/*.{js,mjs,ts,tsx}',
'x-pack/plugins/security_solution/common/**/*.{js,mjs,ts,tsx}',
@ -1009,6 +1010,7 @@ module.exports = {
// This should be a very small set as most linter rules are useful for tests as well.
files: [
'x-pack/plugins/ecs_data_quality_dashboard/**/*.{ts,tsx}',
'x-pack/packages/kbn-elastic-assistant/**/*.{ts,tsx}',
'x-pack/packages/security-solution/**/*.{ts,tsx}',
'x-pack/plugins/security_solution/**/*.{ts,tsx}',
'x-pack/plugins/timelines/**/*.{ts,tsx}',
@ -1016,6 +1018,7 @@ module.exports = {
],
excludedFiles: [
'x-pack/plugins/ecs_data_quality_dashboard/**/*.{test,mock,test_helper}.{ts,tsx}',
'x-pack/packages/kbn-elastic-assistant/**/*.{test,mock,test_helper}.{ts,tsx}',
'x-pack/packages/security-solution/**/*.{test,mock,test_helper}.{ts,tsx}',
'x-pack/plugins/security_solution/**/*.{test,mock,test_helper}.{ts,tsx}',
'x-pack/plugins/timelines/**/*.{test,mock,test_helper}.{ts,tsx}',
@ -1029,6 +1032,7 @@ module.exports = {
// typescript only for front and back end
files: [
'x-pack/plugins/ecs_data_quality_dashboard/**/*.{ts,tsx}',
'x-pack/packages/kbn-elastic-assistant/**/*.{ts,tsx}',
'x-pack/packages/security-solution/**/*.{ts,tsx}',
'x-pack/plugins/security_solution/**/*.{ts,tsx}',
'x-pack/plugins/timelines/**/*.{ts,tsx}',
@ -1061,6 +1065,7 @@ module.exports = {
// typescript and javascript for front and back end
files: [
'x-pack/plugins/ecs_data_quality_dashboard/**/*.{js,mjs,ts,tsx}',
'x-pack/packages/kbn-elastic-assistant/**/*.{js,mjs,ts,tsx}',
'x-pack/packages/security-solution/**/*.{js,mjs,ts,tsx}',
'x-pack/plugins/security_solution/**/*.{js,mjs,ts,tsx}',
'x-pack/plugins/timelines/**/*.{js,mjs,ts,tsx}',

1
.github/CODEOWNERS vendored
View file

@ -321,6 +321,7 @@ packages/kbn-ebt-tools @elastic/kibana-core
packages/kbn-ecs @elastic/kibana-core @elastic/security-threat-hunting-investigations
x-pack/packages/security-solution/ecs_data_quality_dashboard @elastic/security-threat-hunting-investigations
x-pack/plugins/ecs_data_quality_dashboard @elastic/security-threat-hunting-investigations
x-pack/packages/kbn-elastic-assistant @elastic/security-solution
test/plugin_functional/plugins/elasticsearch_client_plugin @elastic/kibana-core
x-pack/test/plugin_api_integration/plugins/elasticsearch_client @elastic/kibana-core
x-pack/plugins/embeddable_enhanced @elastic/kibana-presentation

View file

@ -361,6 +361,7 @@
"@kbn/ecs": "link:packages/kbn-ecs",
"@kbn/ecs-data-quality-dashboard": "link:x-pack/packages/security-solution/ecs_data_quality_dashboard",
"@kbn/ecs-data-quality-dashboard-plugin": "link:x-pack/plugins/ecs_data_quality_dashboard",
"@kbn/elastic-assistant": "link:x-pack/packages/kbn-elastic-assistant",
"@kbn/elasticsearch-client-plugin": "link:test/plugin_functional/plugins/elasticsearch_client_plugin",
"@kbn/elasticsearch-client-xpack-plugin": "link:x-pack/test/plugin_api_integration/plugins/elasticsearch_client",
"@kbn/embeddable-enhanced-plugin": "link:x-pack/plugins/embeddable_enhanced",

View file

@ -16,6 +16,7 @@ module.exports = {
/src[\/\\]plugins[\/\\](kibana_react)[\/\\]/,
/x-pack[\/\\]plugins[\/\\](apm|beats_management|cases|fleet|infra|lists|observability|observability_shared|exploratory_view|osquery|security_solution|timelines|synthetics|ux)[\/\\]/,
/x-pack[\/\\]test[\/\\]plugin_functional[\/\\]plugins[\/\\]resolver_test[\/\\]/,
/x-pack[\/\\]packages[\/\\]elastic_assistant[\/\\]/,
/x-pack[\/\\]packages[\/\\]security-solution[\/\\]ecs_data_quality_dashboard[\/\\]/,
],
};

View file

@ -636,6 +636,8 @@
"@kbn/ecs-data-quality-dashboard/*": ["x-pack/packages/security-solution/ecs_data_quality_dashboard/*"],
"@kbn/ecs-data-quality-dashboard-plugin": ["x-pack/plugins/ecs_data_quality_dashboard"],
"@kbn/ecs-data-quality-dashboard-plugin/*": ["x-pack/plugins/ecs_data_quality_dashboard/*"],
"@kbn/elastic-assistant": ["x-pack/packages/kbn-elastic-assistant"],
"@kbn/elastic-assistant/*": ["x-pack/packages/kbn-elastic-assistant/*"],
"@kbn/elasticsearch-client-plugin": ["test/plugin_functional/plugins/elasticsearch_client_plugin"],
"@kbn/elasticsearch-client-plugin/*": ["test/plugin_functional/plugins/elasticsearch_client_plugin/*"],
"@kbn/elasticsearch-client-xpack-plugin": ["x-pack/test/plugin_api_integration/plugins/elasticsearch_client"],

View file

@ -21,6 +21,7 @@
"xpack.dashboard": "plugins/dashboard_enhanced",
"xpack.discover": "plugins/discover_enhanced",
"xpack.crossClusterReplication": "plugins/cross_cluster_replication",
"xpack.elasticAssistant": "packages/kbn-elastic-assistant",
"xpack.embeddableEnhanced": "plugins/embeddable_enhanced",
"xpack.endpoint": "plugins/endpoint",
"xpack.enterpriseSearch": "plugins/enterprise_search",

View file

@ -0,0 +1,20 @@
# @kbn/elastic-assistant
The `Elastic Assistant` is a user interface for interacting with generative AIs, like `ChatGPT`.
This package provides:
- Components for rendering the `Elastic Assistant`
- Hooks for passing context (for example, fields in an alert) to the `Elastic Assistant`, enabling users to include this content in their queries
## Maintainers
Maintained by the Security Solution team
## Running unit tests with code coverage
To (interactively) run unit tests with code coverage, run the following command:
```sh
cd $KIBANA_HOME && node scripts/jest --watch x-pack/packages/kbn-elastic-assistant --coverage
```

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 { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common';
import { HttpSetup } from '@kbn/core-http-browser';
import type { Message } from '../assistant_context/types';
import { Conversation } from '../assistant_context/types';
import { API_ERROR } from './translations';
export interface FetchConnectorExecuteAction {
apiConfig: Conversation['apiConfig'];
http: HttpSetup;
messages: Message[];
signal?: AbortSignal | undefined;
}
export const fetchConnectorExecuteAction = async ({
http,
messages,
apiConfig,
signal,
}: FetchConnectorExecuteAction): Promise<string> => {
const outboundMessages = messages.map((msg) => ({
role: msg.role,
content: msg.content,
}));
const body =
apiConfig?.provider === OpenAiProviderType.OpenAi
? {
model: 'gpt-3.5-turbo',
messages: outboundMessages,
n: 1,
stop: null,
temperature: 0.2,
}
: {
messages: outboundMessages,
};
const requestBody = {
params: {
subActionParams: {
body: JSON.stringify(body),
},
subAction: 'test',
},
};
try {
// TODO: Find return type for this API
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = await http.fetch<any>(
`/api/actions/connector/${apiConfig?.connectorId}/_execute`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
signal,
}
);
const data = response.data;
if (response.status !== 'ok') {
return API_ERROR;
}
if (data.choices && data.choices.length > 0 && data.choices[0].message.content) {
const result = data.choices[0].message.content.trim();
return result;
} else {
return API_ERROR;
}
} catch (error) {
return API_ERROR;
}
};

View file

@ -0,0 +1,90 @@
/*
* 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, useState } from 'react';
import { EuiModal } from '@elastic/eui';
import useEvent from 'react-use/lib/useEvent';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { ShowAssistantOverlayProps, useAssistantContext } from '../../assistant_context';
import { Assistant } from '..';
import { WELCOME_CONVERSATION_TITLE } from '../use_conversation/translations';
const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0;
const StyledEuiModal = styled(EuiModal)`
min-width: 1200px;
max-height: 100%;
height: 100%;
`;
/**
* Modal container for Security Assistant conversations, receiving the page contents as context, plus whatever
* component currently has focus and any specific context it may provide through the SAssInterface.
*/
export const AssistantOverlay: React.FC = React.memo(() => {
const [isModalVisible, setIsModalVisible] = useState(false);
const [conversationId, setConversationId] = useState<string | undefined>(
WELCOME_CONVERSATION_TITLE
);
const [promptContextId, setPromptContextId] = useState<string | undefined>();
const { setShowAssistantOverlay } = useAssistantContext();
// Bind `showAssistantOverlay` in SecurityAssistantContext to this modal instance
const showOverlay = useCallback(
() =>
({
showOverlay: so,
promptContextId: pid,
conversationId: cid,
}: ShowAssistantOverlayProps) => {
setIsModalVisible(so);
setPromptContextId(pid);
setConversationId(cid);
},
[setIsModalVisible]
);
useEffect(() => {
setShowAssistantOverlay(showOverlay);
}, [setShowAssistantOverlay, showOverlay]);
// Register keyboard listener to show the modal when cmd + ; is pressed
const onKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === ';' && (isMac ? event.metaKey : event.ctrlKey)) {
event.preventDefault();
setIsModalVisible(!isModalVisible);
}
},
[isModalVisible]
);
useEvent('keydown', onKeyDown);
// Modal control functions
const cleanupAndCloseModal = useCallback(() => {
setIsModalVisible(false);
setPromptContextId(undefined);
setConversationId(conversationId);
}, [conversationId]);
const handleCloseModal = useCallback(() => {
cleanupAndCloseModal();
}, [cleanupAndCloseModal]);
return (
<>
{isModalVisible && (
<StyledEuiModal onClose={handleCloseModal}>
<Assistant conversationId={conversationId} promptContextId={promptContextId} />
</StyledEuiModal>
)}
</>
);
});
AssistantOverlay.displayName = 'AssistantOverlay';

View file

@ -0,0 +1,15 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const CANCEL_BUTTON = i18n.translate(
'xpack.elasticAssistant.assistant.overlay.CancelButton',
{
defaultMessage: 'Cancel',
}
);

View file

@ -0,0 +1,122 @@
/*
* 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, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TestProviders } from '../../mock/test_providers/test_providers';
import type { PromptContext } from '../prompt_context/types';
import { ContextPills } from '.';
const mockPromptContexts: Record<string, PromptContext> = {
context1: {
category: 'alert',
description: 'Context 1',
getPromptContext: () => Promise.resolve('Context 1 data'),
id: 'context1',
tooltip: 'Context 1 tooltip',
},
context2: {
category: 'event',
description: 'Context 2',
getPromptContext: () => Promise.resolve('Context 2 data'),
id: 'context2',
tooltip: 'Context 2 tooltip',
},
};
describe('ContextPills', () => {
beforeEach(() => jest.clearAllMocks());
it('renders the context pill descriptions', () => {
render(
<TestProviders>
<ContextPills
promptContexts={mockPromptContexts}
selectedPromptContextIds={[]}
setSelectedPromptContextIds={jest.fn()}
/>
</TestProviders>
);
Object.values(mockPromptContexts).forEach(({ id, description }) => {
expect(screen.getByTestId(`pillButton-${id}`)).toHaveTextContent(description);
});
});
it('invokes setSelectedPromptContextIds() when the prompt is NOT already selected', () => {
const context = mockPromptContexts.context1;
const setSelectedPromptContextIds = jest.fn();
render(
<TestProviders>
<ContextPills
promptContexts={mockPromptContexts}
selectedPromptContextIds={[]} // <-- the prompt is NOT selected
setSelectedPromptContextIds={setSelectedPromptContextIds}
/>
</TestProviders>
);
userEvent.click(screen.getByTestId(`pillButton-${context.id}`));
expect(setSelectedPromptContextIds).toBeCalled();
});
it('it does NOT invoke setSelectedPromptContextIds() when the prompt is already selected', () => {
const context = mockPromptContexts.context1;
const setSelectedPromptContextIds = jest.fn();
render(
<TestProviders>
<ContextPills
promptContexts={mockPromptContexts}
selectedPromptContextIds={[context.id]} // <-- the context is already selected
setSelectedPromptContextIds={setSelectedPromptContextIds}
/>
</TestProviders>
);
// NOTE: this test uses `fireEvent` instead of `userEvent` to bypass the disabled button:
fireEvent.click(screen.getByTestId(`pillButton-${context.id}`));
expect(setSelectedPromptContextIds).not.toBeCalled();
});
it('disables selected context pills', () => {
const context = mockPromptContexts.context1;
render(
<TestProviders>
<ContextPills
promptContexts={mockPromptContexts}
selectedPromptContextIds={[context.id]} // <-- context1 is selected
setSelectedPromptContextIds={jest.fn()}
/>
</TestProviders>
);
expect(screen.getByTestId(`pillButton-${context.id}`)).toBeDisabled();
});
it("does NOT disable context pills that aren't selected", () => {
const context = mockPromptContexts.context1;
render(
<TestProviders>
<ContextPills
promptContexts={mockPromptContexts}
selectedPromptContextIds={['context2']} // context1 is NOT selected
setSelectedPromptContextIds={jest.fn()}
/>
</TestProviders>
);
expect(screen.getByTestId(`pillButton-${context.id}`)).not.toBeDisabled();
});
});

View file

@ -0,0 +1,66 @@
/*
* 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 { EuiButton, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { sortBy } from 'lodash/fp';
import React, { useCallback, useMemo } from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import type { PromptContext } from '../prompt_context/types';
const PillButton = styled(EuiButton)`
margin-right: ${({ theme }) => theme.eui.euiSizeXS};
`;
interface Props {
promptContexts: Record<string, PromptContext>;
selectedPromptContextIds: string[];
setSelectedPromptContextIds: React.Dispatch<React.SetStateAction<string[]>>;
}
const ContextPillsComponent: React.FC<Props> = ({
promptContexts,
selectedPromptContextIds,
setSelectedPromptContextIds,
}) => {
const sortedPromptContexts = useMemo(
() => sortBy('description', Object.values(promptContexts)),
[promptContexts]
);
const selectPromptContext = useCallback(
(id: string) => {
if (!selectedPromptContextIds.includes(id)) {
setSelectedPromptContextIds((prev) => [...prev, id]);
}
},
[selectedPromptContextIds, setSelectedPromptContextIds]
);
return (
<EuiFlexGroup gutterSize="none" wrap>
{sortedPromptContexts.map(({ description, id, getPromptContext, tooltip }) => (
<EuiFlexItem grow={false} key={id}>
<EuiToolTip content={tooltip}>
<PillButton
data-test-subj={`pillButton-${id}`}
disabled={selectedPromptContextIds.includes(id)}
iconSide="left"
iconType="plus"
onClick={() => selectPromptContext(id)}
>
{description}
</PillButton>
</EuiToolTip>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
};
export const ContextPills = React.memo(ContextPillsComponent);

View file

@ -0,0 +1,142 @@
/*
* 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 { EuiButtonIcon, EuiFormRow, EuiSuperSelect, EuiToolTip } from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import useEvent from 'react-use/lib/useEvent';
import { css } from '@emotion/react';
import { useAssistantContext } from '../../assistant_context';
import * as i18n from './translations';
import { DEFAULT_CONVERSATION_TITLE } from '../use_conversation/translations';
const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0;
interface Props {
conversationId?: string;
onSelectionChange?: (value: string) => void;
shouldDisableKeyboardShortcut?: () => boolean;
isDisabled?: boolean;
}
const getPreviousConversationId = (conversationIds: string[], selectedConversationId: string) => {
return conversationIds.indexOf(selectedConversationId) === 0
? conversationIds[conversationIds.length - 1]
: conversationIds[conversationIds.indexOf(selectedConversationId) - 1];
};
function getNextConversationId(conversationIds: string[], selectedConversationId: string) {
return conversationIds.indexOf(selectedConversationId) + 1 >= conversationIds.length
? conversationIds[0]
: conversationIds[conversationIds.indexOf(selectedConversationId) + 1];
}
export const ConversationSelector: React.FC<Props> = React.memo(
({
conversationId = DEFAULT_CONVERSATION_TITLE,
onSelectionChange,
shouldDisableKeyboardShortcut = () => false,
isDisabled = false,
}) => {
const [selectedConversationId, setSelectedConversationId] = useState<string>(conversationId);
const { conversations } = useAssistantContext();
const conversationIds = useMemo(() => Object.keys(conversations), [conversations]);
const conversationOptions = conversationIds.map((id) => ({ value: id, inputDisplay: id }));
const onChange = useCallback((value: string) => {
setSelectedConversationId(value ?? DEFAULT_CONVERSATION_TITLE);
}, []);
const onLeftArrowClick = useCallback(() => {
const prevId = getPreviousConversationId(conversationIds, selectedConversationId);
setSelectedConversationId(prevId);
}, [conversationIds, selectedConversationId]);
const onRightArrowClick = useCallback(() => {
const nextId = getNextConversationId(conversationIds, selectedConversationId);
setSelectedConversationId(nextId);
}, [conversationIds, selectedConversationId]);
// Register keyboard listener for quick conversation switching
const onKeyDown = useCallback(
(event: KeyboardEvent) => {
if (isDisabled || conversationIds.length <= 1) {
return;
}
if (
event.key === 'ArrowLeft' &&
(isMac ? event.metaKey : event.ctrlKey) &&
!shouldDisableKeyboardShortcut()
) {
event.preventDefault();
onLeftArrowClick();
}
if (
event.key === 'ArrowRight' &&
(isMac ? event.metaKey : event.ctrlKey) &&
!shouldDisableKeyboardShortcut()
) {
event.preventDefault();
onRightArrowClick();
}
},
[
conversationIds.length,
isDisabled,
onLeftArrowClick,
onRightArrowClick,
shouldDisableKeyboardShortcut,
]
);
useEvent('keydown', onKeyDown);
useEffect(() => {
onSelectionChange?.(selectedConversationId);
}, [onSelectionChange, selectedConversationId]);
return (
<EuiFormRow
label={i18n.SELECTED_CONVERSATION_LABEL}
display="rowCompressed"
css={css`
min-width: 300px;
`}
>
<EuiSuperSelect
options={conversationOptions}
valueOfSelected={selectedConversationId}
onChange={onChange}
compressed={true}
disabled={isDisabled}
aria-label={i18n.CONVERSATION_SELECTOR_ARIA_LABEL}
prepend={
<EuiToolTip content={`${i18n.PREVIOUS_CONVERSATION_TITLE} (⌘ + ←)`} display="block">
<EuiButtonIcon
iconType="arrowLeft"
aria-label={i18n.PREVIOUS_CONVERSATION_TITLE}
onClick={onLeftArrowClick}
disabled={isDisabled || conversationIds.length <= 1}
/>
</EuiToolTip>
}
append={
<EuiToolTip content={`${i18n.NEXT_CONVERSATION_TITLE} (⌘ + →)`} display="block">
<EuiButtonIcon
iconType="arrowRight"
aria-label={i18n.NEXT_CONVERSATION_TITLE}
onClick={onRightArrowClick}
disabled={isDisabled || conversationIds.length <= 1}
/>
</EuiToolTip>
}
/>
</EuiFormRow>
);
}
);
ConversationSelector.displayName = 'ConversationSelector';

View file

@ -0,0 +1,36 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const SELECTED_CONVERSATION_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelector.defaultConversationTitle',
{
defaultMessage: 'Selected conversation',
}
);
export const CONVERSATION_SELECTOR_ARIA_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelector.ariaLabel',
{
defaultMessage: 'Conversation selector',
}
);
export const PREVIOUS_CONVERSATION_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelector.previousConversationTitle',
{
defaultMessage: 'Previous conversation',
}
);
export const NEXT_CONVERSATION_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelector.nextConversationTitle',
{
defaultMessage: 'Next conversation',
}
);

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Message } from '../assistant_context/types';
export const getMessageFromRawResponse = (rawResponse: string): Message => {
const dateTimeString = new Date().toLocaleString(); // TODO: Pull from response
if (rawResponse) {
return {
role: 'assistant',
content: rawResponse,
timestamp: dateTimeString,
};
} else {
return {
role: 'assistant',
content: 'Error: Response from LLM API is empty or undefined.',
timestamp: dateTimeString,
};
}
};

View file

@ -0,0 +1,474 @@
/*
* 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, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiButtonIcon,
EuiHorizontalRule,
EuiCommentList,
EuiToolTip,
EuiSplitPanel,
EuiCallOut,
EuiIcon,
EuiTitle,
} from '@elastic/eui';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { createPortal } from 'react-dom';
import { css } from '@emotion/react';
import { getMessageFromRawResponse } from './helpers';
import { SettingsPopover } from './settings_popover';
import { useAssistantContext } from '../assistant_context';
import { ContextPills } from './context_pills';
import { PromptTextArea } from './prompt_textarea';
import type { PromptContext } from './prompt_context/types';
import { useConversation } from './use_conversation';
import { CodeBlockDetails } from './use_conversation/helpers';
import { useSendMessages } from './use_send_messages';
import type { Message } from '../assistant_context/types';
import { ConversationSelector } from './conversation_selector';
import { PromptEditor } from './prompt_editor';
import { getCombinedMessage, getDefaultSystemPrompt, getSuperheroPrompt } from './prompt/helpers';
import * as i18n from './translations';
import type { Prompt } from './types';
import { getPromptById } from './prompt_editor/helpers';
import { QuickPrompts } from './quick_prompts/quick_prompts';
import { useLoadConnectors } from '../connectorland/use_load_connectors';
import { ConnectorSetup } from '../connectorland/connector_setup';
import { WELCOME_CONVERSATION_TITLE } from './use_conversation/translations';
import { BASE_CONVERSATIONS } from './use_conversation/sample_conversations';
const CommentsContainer = styled.div`
max-height: 600px;
max-width: 100%;
overflow-y: scroll;
`;
const ChatOptionsFlexItem = styled(EuiFlexItem)`
left: -34px;
position: relative;
top: 11px;
`;
const StyledCommentList = styled(EuiCommentList)`
margin-right: 20px;
`;
export interface Props {
promptContextId?: string;
conversationId?: string;
showTitle?: boolean;
shouldRefocusPrompt?: boolean;
}
/**
* Renders a chat window with a prompt input and a chat history, along with
* quick prompts for common actions, settings, and prompt context providers.
*/
const AssistantComponent: React.FC<Props> = ({
promptContextId = '',
showTitle = true,
conversationId = WELCOME_CONVERSATION_TITLE,
shouldRefocusPrompt = false,
}) => {
const {
actionTypeRegistry,
augmentMessageCodeBlocks,
conversations,
getComments,
http,
promptContexts,
title,
} = useAssistantContext();
const [selectedPromptContextIds, setSelectedPromptContextIds] = useState<string[]>([]);
const { appendMessage, clearConversation, createConversation } = useConversation();
const { isLoading, sendMessages } = useSendMessages();
const [selectedConversationId, setSelectedConversationId] = useState<string>(conversationId);
const currentConversation = useMemo(
() => conversations[selectedConversationId] ?? createConversation({ conversationId }),
[conversationId, conversations, createConversation, selectedConversationId]
);
// 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(
() => conversations[selectedConversationId] ?? BASE_CONVERSATIONS[WELCOME_CONVERSATION_TITLE],
[conversations, selectedConversationId]
);
const { data: connectors, refetch: refetchConnectors } = useLoadConnectors({ http });
const isWelcomeSetup = (connectors?.length ?? 0) === 0;
const currentTitle: { title: string | JSX.Element; titleIcon: string } =
isWelcomeSetup && welcomeConversation.theme?.title && welcomeConversation.theme?.titleIcon
? { title: welcomeConversation.theme?.title, titleIcon: welcomeConversation.theme?.titleIcon }
: { title, titleIcon: 'logoSecurity' };
const bottomRef = useRef<HTMLDivElement | null>(null);
const lastCommentRef = useRef<HTMLDivElement | null>(null);
const [promptTextPreview, setPromptTextPreview] = useState<string>('');
const [systemPrompts] = useState<Prompt[]>([getDefaultSystemPrompt(), getSuperheroPrompt()]);
const [selectedSystemPromptId, setSelectedSystemPromptId] = useState<string | null>(
getDefaultSystemPrompt().id
);
const [autoPopulatedOnce, setAutoPopulatedOnce] = useState<boolean>(false);
const [suggestedUserPrompt, setSuggestedUserPrompt] = useState<string | null>(null);
const [showMissingConnectorCallout, setShowMissingConnectorCallout] = useState<boolean>(false);
const [messageCodeBlocks, setMessageCodeBlocks] = useState<CodeBlockDetails[][]>(
augmentMessageCodeBlocks(currentConversation)
);
const [_, setCodeBlockControlsVisible] = useState(false);
useLayoutEffect(() => {
setMessageCodeBlocks(augmentMessageCodeBlocks(currentConversation));
}, [augmentMessageCodeBlocks, currentConversation]);
const isSendingDisabled = useMemo(() => {
return isWelcomeSetup || showMissingConnectorCallout;
}, [showMissingConnectorCallout, isWelcomeSetup]);
// Fixes initial render not showing buttons as code block controls are added to the DOM really late
useEffect(() => {
const updateElements = () => {
const elements = document.querySelectorAll('.euiCodeBlock__controls');
setCodeBlockControlsVisible(elements.length > 0);
};
updateElements(); // Initial update
const observer = new MutationObserver(updateElements);
observer.observe(document.body, { subtree: true, childList: true });
return () => {
observer.disconnect(); // Clean up the observer if component unmounts
};
}, []);
// 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' });
}, []);
useEffect(() => {
setTimeout(() => {
bottomRef.current?.scrollIntoView({ behavior: 'auto' });
promptTextAreaRef?.current?.focus();
}, 0);
}, [currentConversation.messages.length, selectedPromptContextIds.length]);
////
// Handles sending latest user prompt to API
const handleSendMessage = useCallback(
async (promptText) => {
const message = await getCombinedMessage({
isNewChat: currentConversation.messages.length === 0,
promptContexts,
promptText,
selectedPromptContextIds,
selectedSystemPrompt: getPromptById({
id: selectedSystemPromptId ?? '',
prompts: systemPrompts,
}),
});
const updatedMessages = appendMessage({
conversationId: selectedConversationId,
message,
});
// Reset prompt context selection and preview before sending:
setSelectedPromptContextIds([]);
setPromptTextPreview('');
const rawResponse = await sendMessages({
http,
apiConfig: currentConversation.apiConfig,
messages: updatedMessages,
});
const responseMessage: Message = getMessageFromRawResponse(rawResponse);
appendMessage({ conversationId: selectedConversationId, message: responseMessage });
},
[
appendMessage,
currentConversation.apiConfig,
currentConversation.messages.length,
http,
promptContexts,
selectedConversationId,
selectedPromptContextIds,
selectedSystemPromptId,
sendMessages,
systemPrompts,
]
);
const handleButtonSendMessage = useCallback(() => {
handleSendMessage(promptTextAreaRef.current?.value?.trim() ?? '');
}, [handleSendMessage, promptTextAreaRef]);
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
codeBlockContainers.forEach((e) => (e.style.minHeight = '75px'));
////
const comments = getComments({ currentConversation, lastCommentRef });
useEffect(() => {
// Adding `conversationId !== selectedConversationId` to prevent auto-run still executing after changing selected conversation
if (currentConversation.messages.length || conversationId !== selectedConversationId) {
return;
}
if (autoPopulatedOnce) {
return;
}
const promptContext: PromptContext | undefined = promptContexts[promptContextId];
if (promptContext != null) {
setAutoPopulatedOnce(true);
// select this prompt context
if (!selectedPromptContextIds.includes(promptContext.id)) {
setSelectedPromptContextIds((prev) => [...prev, promptContext.id]);
}
if (promptContext?.suggestedUserPrompt != null) {
setSuggestedUserPrompt(promptContext.suggestedUserPrompt);
}
}
}, [
currentConversation.messages,
promptContexts,
promptContextId,
handleSendMessage,
conversationId,
selectedConversationId,
selectedPromptContextIds,
autoPopulatedOnce,
]);
// Show missing connector callout if no connectors are configured
useEffect(() => {
const connectorExists =
connectors?.some(
(connector) => connector.id === currentConversation.apiConfig?.connectorId
) ?? false;
setShowMissingConnectorCallout(!connectorExists);
}, [connectors, currentConversation]);
return (
<EuiSplitPanel.Outer
grow={false}
css={css`
width: 100%;
`}
>
<EuiSplitPanel.Inner grow={false}>
{showTitle && (
<>
<EuiFlexGroup alignItems={'center'} justifyContent={'spaceBetween'}>
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems={'center'}>
<EuiFlexItem grow={false}>
<EuiIcon type={currentTitle.titleIcon} size="xl" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiTitle>
<span>{currentTitle.title}</span>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ConversationSelector
conversationId={selectedConversationId}
onSelectionChange={(id) => setSelectedConversationId(id)}
shouldDisableKeyboardShortcut={shouldDisableConversationSelectorHotkeys}
isDisabled={isWelcomeSetup}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin={'m'} />
{!isWelcomeSetup && showMissingConnectorCallout && (
<>
<EuiCallOut
color="danger"
iconType="controlsVertical"
size="m"
title={i18n.MISSING_CONNECTOR_CALLOUT_TITLE}
>
<p>{i18n.MISSING_CONNECTOR_CALLOUT_DESCRIPTION}</p>
</EuiCallOut>
<EuiSpacer size={'s'} />
</>
)}
</>
)}
{/* Create portals for each EuiCodeBlock to add the `Investigate in Timeline` action */}
{messageCodeBlocks.map((codeBlocks: CodeBlockDetails[]) => {
return codeBlocks.map((codeBlock: CodeBlockDetails) => {
const element: Element = codeBlock.controlContainer as Element;
return codeBlock.controlContainer != null ? (
createPortal(codeBlock.button, element)
) : (
<></>
);
});
})}
{!isWelcomeSetup && (
<>
<ContextPills
promptContexts={promptContexts}
selectedPromptContextIds={selectedPromptContextIds}
setSelectedPromptContextIds={setSelectedPromptContextIds}
/>
{Object.keys(promptContexts).length > 0 && <EuiSpacer size={'s'} />}
</>
)}
{isWelcomeSetup && (
<ConnectorSetup
actionTypeRegistry={actionTypeRegistry}
http={http}
refetchConnectors={refetchConnectors}
isConnectorConfigured={!!connectors?.length}
/>
)}
{!isWelcomeSetup && (
<CommentsContainer className="eui-scrollBar">
<>
<StyledCommentList comments={comments} />
<div ref={bottomRef} />
<EuiSpacer size={'m'} />
{(currentConversation.messages.length === 0 ||
selectedPromptContextIds.length > 0) && (
<PromptEditor
isNewConversation={currentConversation.messages.length === 0}
promptContexts={promptContexts}
promptTextPreview={promptTextPreview}
selectedPromptContextIds={selectedPromptContextIds}
selectedSystemPromptId={selectedSystemPromptId}
setSelectedPromptContextIds={setSelectedPromptContextIds}
setSelectedSystemPromptId={setSelectedSystemPromptId}
systemPrompts={systemPrompts}
/>
)}
</>
</CommentsContainer>
)}
<EuiSpacer />
<EuiFlexGroup gutterSize="none">
<EuiFlexItem>
<PromptTextArea
onPromptSubmit={handleSendMessage}
ref={promptTextAreaRef}
handlePromptChange={setPromptTextPreview}
value={isWelcomeSetup ? '' : suggestedUserPrompt ?? ''}
isDisabled={isWelcomeSetup}
/>
</EuiFlexItem>
<ChatOptionsFlexItem grow={false}>
<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={isWelcomeSetup}
aria-label={i18n.CLEAR_CHAT}
color="danger"
onClick={() => {
setPromptTextPreview('');
clearConversation(selectedConversationId);
setSelectedSystemPromptId(getDefaultSystemPrompt().id);
setSelectedPromptContextIds([]);
setSuggestedUserPrompt('');
}}
/>
</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>
<EuiFlexItem grow={true}>
<SettingsPopover
actionTypeRegistry={actionTypeRegistry}
conversation={currentConversation}
isDisabled={isWelcomeSetup}
http={http}
/>
</EuiFlexItem>
</EuiFlexGroup>
</ChatOptionsFlexItem>
</EuiFlexGroup>
</EuiSplitPanel.Inner>
{!isWelcomeSetup && (
<EuiSplitPanel.Inner
grow={false}
color="subdued"
css={css`
padding: 8px;
`}
>
<QuickPrompts setInput={setSuggestedUserPrompt} />
</EuiSplitPanel.Inner>
)}
</EuiSplitPanel.Outer>
);
};
AssistantComponent.displayName = 'AssistantComponent';
export const Assistant = React.memo(AssistantComponent);

View file

@ -0,0 +1,178 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Message } from '../../assistant_context/types';
import {
getCombinedMessage,
getDefaultSystemPrompt,
getSuperheroPrompt,
getSystemMessages,
} from './helpers';
import { mockSystemPrompt } from '../../mock/system_prompt';
import { mockAlertPromptContext, mockEventPromptContext } from '../../mock/prompt_context';
describe('helpers', () => {
describe('getSystemMessages', () => {
it('should return an empty array if isNewChat is false', () => {
const result = getSystemMessages({
isNewChat: false,
selectedSystemPrompt: mockSystemPrompt,
});
expect(result).toEqual([]);
});
it('should return an empty array if selectedSystemPrompt is undefined', () => {
const result = getSystemMessages({ isNewChat: true, selectedSystemPrompt: undefined });
expect(result).toEqual([]);
});
describe('when isNewChat is true and selectedSystemPrompt is defined', () => {
let result: Message[];
beforeEach(() => {
result = getSystemMessages({ isNewChat: true, selectedSystemPrompt: mockSystemPrompt });
});
it('should return a message with the content of the selectedSystemPrompt', () => {
expect(result[0].content).toBe(mockSystemPrompt.content);
});
it('should return a message with the role "system"', () => {
expect(result[0].role).toBe('system');
});
it('should return a message with a valid timestamp', () => {
const timestamp = new Date(result[0].timestamp);
expect(timestamp instanceof Date && !isNaN(timestamp.valueOf())).toBe(true);
});
});
});
describe('getCombinedMessage', () => {
const mockPromptContexts = {
[mockAlertPromptContext.id]: mockAlertPromptContext,
[mockEventPromptContext.id]: mockEventPromptContext,
};
it('returns correct content for a new chat with a system prompt', async () => {
const message: Message = await getCombinedMessage({
isNewChat: true,
promptContexts: mockPromptContexts,
promptText: 'User prompt text',
selectedPromptContextIds: [mockAlertPromptContext.id],
selectedSystemPrompt: mockSystemPrompt,
});
expect(message.content)
.toEqual(`You are a helpful, expert assistant who answers questions about Elastic Security.
CONTEXT:
"""
alert data
"""
User prompt text`);
});
it('returns correct content for a new chat WITHOUT a system prompt', async () => {
const message: Message = await getCombinedMessage({
isNewChat: true,
promptContexts: mockPromptContexts,
promptText: 'User prompt text',
selectedPromptContextIds: [mockAlertPromptContext.id],
selectedSystemPrompt: undefined, // <-- no system prompt
});
expect(message.content).toEqual(`
CONTEXT:
"""
alert data
"""
User prompt text`);
});
it('returns the correct content for an existing chat', async () => {
const message: Message = await getCombinedMessage({
isNewChat: false,
promptContexts: mockPromptContexts,
promptText: 'User prompt text',
selectedPromptContextIds: [mockAlertPromptContext.id],
selectedSystemPrompt: mockSystemPrompt,
});
expect(message.content).toEqual(`CONTEXT:
"""
alert data
"""
CONTEXT:
"""
alert data
"""
User prompt text`);
});
test('getCombinedMessage returns the expected role', async () => {
const message: Message = await getCombinedMessage({
isNewChat: true,
promptContexts: mockPromptContexts,
promptText: 'User prompt text',
selectedPromptContextIds: [mockAlertPromptContext.id],
selectedSystemPrompt: mockSystemPrompt,
});
expect(message.role).toBe('user');
});
test('getCombinedMessage returns a valid timestamp', async () => {
const message: Message = await getCombinedMessage({
isNewChat: true,
promptContexts: mockPromptContexts,
promptText: 'User prompt text',
selectedPromptContextIds: [mockAlertPromptContext.id],
selectedSystemPrompt: mockSystemPrompt,
});
expect(Date.parse(message.timestamp)).not.toBeNaN();
});
});
describe('getDefaultSystemPrompt', () => {
it('returns the expected prompt', () => {
const prompt = getDefaultSystemPrompt();
expect(prompt).toEqual({
content: `You are a helpful, expert assistant who only answers questions about Elastic Security. Do not answer questions unrelated to Elastic Security.
Use the following context to answer questions:`,
id: 'default-system-prompt',
name: 'default system prompt',
promptType: 'system',
});
});
});
describe('getSuperheroPrompt', () => {
it('returns the expected prompt', () => {
const prompt = getSuperheroPrompt();
expect(prompt).toEqual({
content: `You are a helpful, expert assistant who only answers questions about Elastic Security. Do not answer questions unrelated to Elastic Security.
Provide the most detailed and relevant answer possible, as if you were relaying this information back to a cyber security expert.
Use the following context to answer questions:`,
id: 'CB9FA555-B59F-4F71-AFF9-8A891AC5BC28',
name: 'Enhanced system prompt',
promptType: 'system',
});
});
});
});

View file

@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Message } from '../../assistant_context/types';
import {
DEFAULT_SYSTEM_PROMPT_NON_I18N,
DEFAULT_SYSTEM_PROMPT_NAME,
SUPERHERO_SYSTEM_PROMPT_NON_I18N,
SUPERHERO_SYSTEM_PROMPT_NAME,
SYSTEM_PROMPT_CONTEXT_NON_I18N,
} from '../../content/prompts/system/translations';
import type { PromptContext } from '../prompt_context/types';
import type { Prompt } from '../types';
export const getSystemMessages = ({
isNewChat,
selectedSystemPrompt,
}: {
isNewChat: boolean;
selectedSystemPrompt: Prompt | undefined;
}): Message[] => {
if (!isNewChat || selectedSystemPrompt == null) {
return [];
}
const message: Message = {
content: selectedSystemPrompt.content,
role: 'system',
timestamp: new Date().toLocaleString(),
};
return [message];
};
export async function getCombinedMessage({
isNewChat,
promptContexts,
promptText,
selectedPromptContextIds,
selectedSystemPrompt,
}: {
isNewChat: boolean;
promptContexts: Record<string, PromptContext>;
promptText: string;
selectedPromptContextIds: string[];
selectedSystemPrompt: Prompt | undefined;
}): Promise<Message> {
const selectedPromptContexts = selectedPromptContextIds.reduce<PromptContext[]>((acc, id) => {
const promptContext = promptContexts[id];
return promptContext != null ? [...acc, promptContext] : acc;
}, []);
const promptContextsContent = await Promise.all(
selectedPromptContexts.map(async ({ getPromptContext }) => {
const promptContext = await getPromptContext();
return `${SYSTEM_PROMPT_CONTEXT_NON_I18N(promptContext)}`;
})
);
return {
content: `${isNewChat ? `${selectedSystemPrompt?.content ?? ''}` : `${promptContextsContent}`}
${promptContextsContent}
${promptText}`,
role: 'user', // we are combining the system and user messages into one message
timestamp: new Date().toLocaleString(),
};
}
export const getDefaultSystemPrompt = (): Prompt => ({
id: 'default-system-prompt',
content: DEFAULT_SYSTEM_PROMPT_NON_I18N,
name: DEFAULT_SYSTEM_PROMPT_NAME,
promptType: 'system',
});
export const getSuperheroPrompt = (): Prompt => ({
id: 'CB9FA555-B59F-4F71-AFF9-8A891AC5BC28',
content: SUPERHERO_SYSTEM_PROMPT_NON_I18N,
name: SUPERHERO_SYSTEM_PROMPT_NAME,
promptType: 'system',
});

View file

@ -0,0 +1,78 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ReactNode } from 'react';
/**
* helps the Elastic Assistant display the most relevant user prompts
*/
export type PromptContextCategory =
| 'alert'
| 'alerts'
| 'event'
| 'events'
| 'data-quality-index-summary'
| string;
/**
* This interface is used to pass context to the Elastic Assistant,
* for the purpose of building prompts. Examples of context include:
* - a single alert
* - multiple alerts
* - a single event
* - multiple events
* - markdown
* - csv
* - anything else that the LLM can interpret
*/
export interface PromptContext {
/**
* The category of data, e.g. `alert | alerts | event | events | string`
*
* `category` helps the Elastic Assistant display the most relevant user prompts
*/
category: PromptContextCategory;
/**
* The Elastic Assistant will display this **short**, static description
* in the context pill
*/
description: string;
/**
* The Elastic Assistant will invoke this function to retrieve the context data,
* which will be included in a prompt (e.g. the contents of an alert or an event)
*/
getPromptContext: () => Promise<string>;
/**
* A unique identifier for this prompt context
*/
id: string;
/**
* An optional user prompt that's filled in, but not sent, when the Elastic Assistant opens
*/
suggestedUserPrompt?: string;
/**
* The Elastic Assistant will display this tooltip when the user hovers over the context pill
*/
tooltip: ReactNode;
}
/**
* This interface is used to pass a default or base set of contexts to the Elastic Assistant when
* initializing it. This is used to provide 'category' options when users create Quick Prompts.
* Also, useful for collating all of a solutions' prompts in one place.
*
* e.g. see Security Solution's x-pack/plugins/security_solution/public/assistant/prompt_contexts/index.tsx
*/
export type PromptContextTemplate = Omit<PromptContext, 'id' | 'getPromptContext'>;
export type UnRegisterPromptContext = (promptContextId: string) => void;
export type RegisterPromptContext = (promptContext: PromptContext) => void;

View file

@ -0,0 +1,28 @@
/*
* 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 { getPromptById } from './helpers';
import { mockSystemPrompt, mockSuperheroSystemPrompt } from '../../mock/system_prompt';
import type { Prompt } from '../types';
describe('helpers', () => {
describe('getPromptById', () => {
const prompts: Prompt[] = [mockSystemPrompt, mockSuperheroSystemPrompt];
it('returns the correct prompt by id', () => {
const result = getPromptById({ prompts, id: mockSuperheroSystemPrompt.id });
expect(result).toEqual(prompts[1]);
});
it('returns undefined if the prompt is not found', () => {
const result = getPromptById({ prompts, id: 'does-not-exist' });
expect(result).toBeUndefined();
});
});
});

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Prompt } from '../types';
export const getPromptById = ({
prompts,
id,
}: {
prompts: Prompt[];
id: string;
}): Prompt | undefined => prompts.find((p) => p.id === id);

View file

@ -0,0 +1,108 @@
/*
* 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, screen, waitFor } from '@testing-library/react';
import { mockAlertPromptContext, mockEventPromptContext } from '../../mock/prompt_context';
import { mockSystemPrompt } from '../../mock/system_prompt';
import { TestProviders } from '../../mock/test_providers/test_providers';
import { PromptEditor, Props } from '.';
const defaultProps: Props = {
isNewConversation: true,
promptContexts: {
[mockAlertPromptContext.id]: mockAlertPromptContext,
[mockEventPromptContext.id]: mockEventPromptContext,
},
promptTextPreview: 'Preview text',
selectedPromptContextIds: [],
selectedSystemPromptId: null,
setSelectedPromptContextIds: jest.fn(),
setSelectedSystemPromptId: jest.fn(),
systemPrompts: [mockSystemPrompt],
};
describe('PromptEditorComponent', () => {
beforeEach(() => jest.clearAllMocks());
it('renders the system prompt selector when isNewConversation is true', async () => {
render(
<TestProviders>
<PromptEditor {...defaultProps} />
</TestProviders>
);
await waitFor(() => {
expect(screen.getByTestId('selectSystemPrompt')).toBeInTheDocument();
});
});
it('does NOT render the system prompt selector when isNewConversation is false', async () => {
render(
<TestProviders>
<PromptEditor {...defaultProps} isNewConversation={false} />
</TestProviders>
);
await waitFor(() => {
expect(screen.queryByTestId('selectSystemPrompt')).not.toBeInTheDocument();
});
});
it('renders the selected prompt contexts', async () => {
const selectedPromptContextIds = [mockAlertPromptContext.id, mockEventPromptContext.id];
render(
<TestProviders>
<PromptEditor {...defaultProps} selectedPromptContextIds={selectedPromptContextIds} />
</TestProviders>
);
await waitFor(() => {
selectedPromptContextIds.forEach((id) =>
expect(screen.queryByTestId(`selectedPromptContext-${id}`)).toBeInTheDocument()
);
});
});
it('renders the expected preview text', async () => {
render(
<TestProviders>
<PromptEditor {...defaultProps} />
</TestProviders>
);
await waitFor(() => {
expect(screen.getByTestId('previewText')).toHaveTextContent('Preview text');
});
});
it('renders an "editing prompt" `EuiComment` event', async () => {
render(
<TestProviders>
<PromptEditor {...defaultProps} />
</TestProviders>
);
await waitFor(() => {
expect(screen.getByTestId('eventText')).toHaveTextContent('editing prompt');
});
});
it('renders the user avatar', async () => {
render(
<TestProviders>
<PromptEditor {...defaultProps} />
</TestProviders>
);
await waitFor(() => {
expect(screen.getByTestId('userAvatar')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,110 @@
/*
* 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 { EuiAvatar, EuiCommentList, EuiText } from '@elastic/eui';
import React, { useMemo } from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import type { PromptContext } from '../prompt_context/types';
import { SystemPrompt } from './system_prompt';
import type { Prompt } from '../types';
import * as i18n from './translations';
import { SelectedPromptContexts } from './selected_prompt_contexts';
export interface Props {
isNewConversation: boolean;
promptContexts: Record<string, PromptContext>;
promptTextPreview: string;
selectedPromptContextIds: string[];
selectedSystemPromptId: string | null;
setSelectedPromptContextIds: React.Dispatch<React.SetStateAction<string[]>>;
setSelectedSystemPromptId: React.Dispatch<React.SetStateAction<string | null>>;
systemPrompts: Prompt[];
}
const PreviewText = styled(EuiText)`
white-space: pre-line;
`;
const PromptEditorComponent: React.FC<Props> = ({
isNewConversation,
promptContexts,
promptTextPreview,
selectedPromptContextIds,
selectedSystemPromptId,
setSelectedPromptContextIds,
setSelectedSystemPromptId,
systemPrompts,
}) => {
const commentBody = useMemo(
() => (
<>
{isNewConversation && (
<SystemPrompt
selectedSystemPromptId={selectedSystemPromptId}
setSelectedSystemPromptId={setSelectedSystemPromptId}
systemPrompts={systemPrompts}
/>
)}
<SelectedPromptContexts
isNewConversation={isNewConversation}
promptContexts={promptContexts}
selectedPromptContextIds={selectedPromptContextIds}
setSelectedPromptContextIds={setSelectedPromptContextIds}
/>
<PreviewText color="subdued" data-test-subj="previewText">
{promptTextPreview}
</PreviewText>
</>
),
[
isNewConversation,
promptContexts,
promptTextPreview,
selectedPromptContextIds,
selectedSystemPromptId,
setSelectedPromptContextIds,
setSelectedSystemPromptId,
systemPrompts,
]
);
const comments = useMemo(
() => [
{
children: commentBody,
event: (
<EuiText data-test-subj="eventText" size="xs">
<i>{i18n.EDITING_PROMPT}</i>
</EuiText>
),
timelineAvatar: (
<EuiAvatar
data-test-subj="userAvatar"
name="user"
size="l"
color="subdued"
iconType="userAvatar"
/>
),
timelineAvatarAriaLabel: i18n.YOU,
username: i18n.YOU,
},
],
[commentBody]
);
return <EuiCommentList aria-label={i18n.COMMENTS_LIST_ARIA_LABEL} comments={comments} />;
};
PromptEditorComponent.displayName = 'PromptEditorComponent';
export const PromptEditor = React.memo(PromptEditorComponent);

View file

@ -0,0 +1,143 @@
/*
* 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, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { mockAlertPromptContext, mockEventPromptContext } from '../../../mock/prompt_context';
import { TestProviders } from '../../../mock/test_providers/test_providers';
import { Props, SelectedPromptContexts } from '.';
const defaultProps: Props = {
isNewConversation: false,
promptContexts: {
[mockAlertPromptContext.id]: mockAlertPromptContext,
[mockEventPromptContext.id]: mockEventPromptContext,
},
selectedPromptContextIds: [],
setSelectedPromptContextIds: jest.fn(),
};
describe('SelectedPromptContexts', () => {
beforeEach(() => jest.clearAllMocks());
it('it does NOT render the selected prompt contexts when promptContexts is empty', async () => {
render(
<TestProviders>
<SelectedPromptContexts {...defaultProps} promptContexts={{}} />
</TestProviders>
);
await waitFor(() => {
expect(screen.queryByTestId('selectedPromptContexts')).not.toBeInTheDocument();
});
});
it('it does NOT render a spacer when isNewConversation is false and selectedPromptContextIds.length is 1', async () => {
render(
<TestProviders>
<SelectedPromptContexts
{...defaultProps}
isNewConversation={false} // <--
selectedPromptContextIds={[mockAlertPromptContext.id]} // <-- length 1
/>
</TestProviders>
);
await waitFor(() => {
expect(screen.queryByTestId('spacer')).not.toBeInTheDocument();
});
});
it('it renders a spacer when isNewConversation is true and selectedPromptContextIds.length is 1', async () => {
render(
<TestProviders>
<SelectedPromptContexts
{...defaultProps}
isNewConversation={true} // <--
selectedPromptContextIds={[mockAlertPromptContext.id]} // <-- length 1
/>
</TestProviders>
);
await waitFor(() => {
expect(screen.getByTestId('spacer')).toBeInTheDocument();
});
});
it('it renders a spacer for each selected prompt context when isNewConversation is false and selectedPromptContextIds.length is 2', async () => {
render(
<TestProviders>
<SelectedPromptContexts
{...defaultProps}
isNewConversation={false} // <--
selectedPromptContextIds={[mockAlertPromptContext.id, mockEventPromptContext.id]} // <-- length 2
/>
</TestProviders>
);
await waitFor(() => {
expect(screen.getAllByTestId('spacer')).toHaveLength(2);
});
});
it('renders the selected prompt contexts', async () => {
const selectedPromptContextIds = [mockAlertPromptContext.id, mockEventPromptContext.id];
render(
<TestProviders>
<SelectedPromptContexts
{...defaultProps}
selectedPromptContextIds={selectedPromptContextIds}
/>
</TestProviders>
);
await waitFor(() => {
selectedPromptContextIds.forEach((id) =>
expect(screen.getByTestId(`selectedPromptContext-${id}`)).toBeInTheDocument()
);
});
});
it('removes a prompt context when the remove button is clicked', async () => {
const setSelectedPromptContextIds = jest.fn();
const promptContextId = mockAlertPromptContext.id;
render(
<SelectedPromptContexts
{...defaultProps}
selectedPromptContextIds={[promptContextId, mockEventPromptContext.id]}
setSelectedPromptContextIds={setSelectedPromptContextIds}
/>
);
userEvent.click(screen.getByTestId(`removePromptContext-${promptContextId}`));
await waitFor(() => {
expect(setSelectedPromptContextIds).toHaveBeenCalled();
});
});
it('displays the correct accordion content', async () => {
render(
<SelectedPromptContexts
{...defaultProps}
selectedPromptContextIds={[mockAlertPromptContext.id]}
/>
);
userEvent.click(screen.getByText(mockAlertPromptContext.description));
const codeBlock = screen.getByTestId('promptCodeBlock');
await waitFor(() => {
expect(codeBlock).toHaveTextContent('alert data');
});
});
});

View file

@ -0,0 +1,121 @@
/*
* 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 {
EuiAccordion,
EuiButtonIcon,
EuiCodeBlock,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiToolTip,
} from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { SYSTEM_PROMPT_CONTEXT_NON_I18N } from '../../../content/prompts/system/translations';
import type { PromptContext } from '../../prompt_context/types';
import * as i18n from './translations';
const PromptContextContainer = styled.div`
max-width: 60vw;
overflow-x: auto;
`;
export interface Props {
isNewConversation: boolean;
promptContexts: Record<string, PromptContext>;
selectedPromptContextIds: string[];
setSelectedPromptContextIds: React.Dispatch<React.SetStateAction<string[]>>;
}
const SelectedPromptContextsComponent: React.FC<Props> = ({
isNewConversation,
promptContexts,
selectedPromptContextIds,
setSelectedPromptContextIds,
}) => {
const selectedPromptContexts = useMemo(
() => selectedPromptContextIds.map((id) => promptContexts[id]),
[promptContexts, selectedPromptContextIds]
);
const [accordionContent, setAccordionContent] = useState<Record<string, string>>({});
const unselectPromptContext = useCallback(
(unselectedId: string) => {
setSelectedPromptContextIds((prev) => prev.filter((id) => id !== unselectedId));
},
[setSelectedPromptContextIds]
);
useEffect(() => {
const abortController = new AbortController();
const fetchAccordionContent = async () => {
const newAccordionContent = await Promise.all(
selectedPromptContexts.map(async ({ getPromptContext, id }) => ({
[id]: await getPromptContext(),
}))
);
if (!abortController.signal.aborted) {
setAccordionContent(newAccordionContent.reduce((acc, curr) => ({ ...acc, ...curr }), {}));
}
};
fetchAccordionContent();
return () => {
abortController.abort();
};
}, [selectedPromptContexts]);
if (isEmpty(promptContexts)) {
return null;
}
return (
<EuiFlexGroup data-test-subj="selectedPromptContexts" direction="column" gutterSize="none">
{selectedPromptContexts.map(({ description, id }) => (
<EuiFlexItem data-test-subj={`selectedPromptContext-${id}`} grow={false} key={id}>
{isNewConversation || selectedPromptContexts.length > 1 ? (
<EuiSpacer data-test-subj="spacer" />
) : null}
<EuiAccordion
buttonContent={description}
extraAction={
<EuiToolTip content={i18n.REMOVE_CONTEXT}>
<EuiButtonIcon
aria-label={i18n.REMOVE_CONTEXT}
data-test-subj={`removePromptContext-${id}`}
iconType="cross"
onClick={() => unselectPromptContext(id)}
/>
</EuiToolTip>
}
id={id}
paddingSize="s"
>
<PromptContextContainer>
<EuiCodeBlock data-test-subj="promptCodeBlock" isCopyable>
{id != null && accordionContent[id] != null
? SYSTEM_PROMPT_CONTEXT_NON_I18N(accordionContent[id])
: ''}
</EuiCodeBlock>
</PromptContextContainer>
</EuiAccordion>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
};
SelectedPromptContextsComponent.displayName = 'SelectedPromptContextsComponent';
export const SelectedPromptContexts = React.memo(SelectedPromptContextsComponent);

View file

@ -0,0 +1,15 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const REMOVE_CONTEXT = i18n.translate(
'xpack.elasticAssistant.assistant.promptEditor.selectedPromotContexts.removeContextTooltip',
{
defaultMessage: 'Remove context',
}
);

View file

@ -0,0 +1,60 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { mockSuperheroSystemPrompt, mockSystemPrompt } from '../../../mock/system_prompt';
import { TestProviders } from '../../../mock/test_providers/test_providers';
import { getOptions, getOptionFromPrompt } from './helpers';
describe('helpers', () => {
describe('getOptionFromPrompt', () => {
it('returns an EuiSuperSelectOption with the correct value', () => {
const option = getOptionFromPrompt(mockSystemPrompt);
expect(option.value).toBe(mockSystemPrompt.id);
});
it('returns an EuiSuperSelectOption with the correct inputDisplay', () => {
const option = getOptionFromPrompt(mockSystemPrompt);
render(<>{option.inputDisplay}</>);
expect(screen.getByTestId('inputDisplay')).toHaveTextContent(mockSystemPrompt.content);
});
it('shows the expected name in the dropdownDisplay', () => {
const option = getOptionFromPrompt(mockSystemPrompt);
render(<TestProviders>{option.dropdownDisplay}</TestProviders>);
expect(screen.getByTestId('name')).toHaveTextContent(mockSystemPrompt.name);
});
it('shows the expected prompt content in the dropdownDisplay', () => {
const option = getOptionFromPrompt(mockSystemPrompt);
render(<TestProviders>{option.dropdownDisplay}</TestProviders>);
expect(screen.getByTestId('content')).toHaveTextContent(mockSystemPrompt.content);
});
});
describe('getOptions', () => {
it('should return an array of EuiSuperSelectOption with the correct values', () => {
const prompts = [mockSystemPrompt, mockSuperheroSystemPrompt];
const promptIds = prompts.map(({ id }) => id);
const options = getOptions(prompts);
const optionValues = options.map(({ value }) => value);
expect(optionValues).toEqual(promptIds);
});
});
});

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 { EuiText, EuiToolTip } from '@elastic/eui';
import type { EuiSuperSelectOption } from '@elastic/eui';
import React from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { css } from '@emotion/react';
import type { Prompt } from '../../types';
const Strong = styled.strong`
margin-right: ${({ theme }) => theme.eui.euiSizeS};
`;
export const getOptionFromPrompt = ({
content,
id,
name,
}: Prompt): EuiSuperSelectOption<string> => ({
value: id,
inputDisplay: (
<EuiText
css={css`
overflow: hidden;
`}
color="subdued"
data-test-subj="inputDisplay"
>
{content}
</EuiText>
),
dropdownDisplay: (
<>
<Strong data-test-subj="name">{name}</Strong>
<EuiToolTip content={content}>
<EuiText color="subdued" data-test-subj="content" size="s">
<p>{content}</p>
</EuiText>
</EuiToolTip>
</>
),
});
export const getOptions = (prompts: Prompt[]): Array<EuiSuperSelectOption<string>> =>
prompts.map(getOptionFromPrompt);

View file

@ -0,0 +1,121 @@
/*
* 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, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { mockSystemPrompt, mockSuperheroSystemPrompt } from '../../../mock/system_prompt';
import { SystemPrompt } from '.';
describe('SystemPrompt', () => {
beforeEach(() => jest.clearAllMocks());
describe('when selectedSystemPromptId is null', () => {
const selectedSystemPromptId = null;
beforeEach(() => {
render(
<SystemPrompt
selectedSystemPromptId={selectedSystemPromptId}
setSelectedSystemPromptId={jest.fn()}
systemPrompts={[mockSystemPrompt]}
/>
);
});
it('renders the system prompt select', () => {
expect(screen.getByTestId('selectSystemPrompt')).toBeInTheDocument();
});
it('does NOT render the system prompt text', () => {
expect(screen.queryByTestId('systemPromptText')).not.toBeInTheDocument();
});
it('does NOT render the edit button', () => {
expect(screen.queryByTestId('edit')).not.toBeInTheDocument();
});
it('does NOT render the clear button', () => {
expect(screen.queryByTestId('clear')).not.toBeInTheDocument();
});
});
describe('when selectedSystemPromptId is NOT null', () => {
const selectedSystemPromptId = mockSystemPrompt.id;
beforeEach(() => {
render(
<SystemPrompt
selectedSystemPromptId={selectedSystemPromptId}
setSelectedSystemPromptId={jest.fn()}
systemPrompts={[mockSystemPrompt]}
/>
);
});
it('does NOT render the system prompt select', () => {
expect(screen.queryByTestId('selectSystemPrompt')).not.toBeInTheDocument();
});
it('renders the system prompt text', () => {
expect(screen.getByTestId('systemPromptText')).toHaveTextContent(mockSystemPrompt.content);
});
it('renders the edit button', () => {
expect(screen.getByTestId('edit')).toBeInTheDocument();
});
it('renders the clear button', () => {
expect(screen.getByTestId('clear')).toBeInTheDocument();
});
});
it('shows the system prompt select when the edit button is clicked', () => {
render(
<SystemPrompt
selectedSystemPromptId={mockSystemPrompt.id}
setSelectedSystemPromptId={jest.fn()}
systemPrompts={[mockSystemPrompt, mockSuperheroSystemPrompt]}
/>
);
userEvent.click(screen.getByTestId('edit'));
expect(screen.getByTestId('selectSystemPrompt')).toBeInTheDocument();
});
it('clears the selected system prompt when the clear button is clicked', () => {
const setSelectedSystemPromptId = jest.fn();
render(
<SystemPrompt
selectedSystemPromptId={mockSystemPrompt.id}
setSelectedSystemPromptId={setSelectedSystemPromptId}
systemPrompts={[mockSystemPrompt, mockSuperheroSystemPrompt]}
/>
);
userEvent.click(screen.getByTestId('clear'));
expect(setSelectedSystemPromptId).toHaveBeenCalledWith(null);
});
it('shows the system prompt select when system prompt text is clicked', () => {
render(
<SystemPrompt
selectedSystemPromptId={mockSystemPrompt.id}
setSelectedSystemPromptId={jest.fn()}
systemPrompts={[mockSystemPrompt, mockSuperheroSystemPrompt]}
/>
);
fireEvent.click(screen.getByTestId('systemPromptText'));
expect(screen.getByTestId('selectSystemPrompt')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,101 @@
/*
* 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 { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { getPromptById } from '../helpers';
import * as i18n from './translations';
import type { Prompt } from '../../types';
import { SelectSystemPrompt } from './select_system_prompt';
const SystemPromptText = styled(EuiText)`
white-space: pre-line;
`;
interface Props {
selectedSystemPromptId: string | null;
setSelectedSystemPromptId: React.Dispatch<React.SetStateAction<string | null>>;
systemPrompts: Prompt[];
}
const SystemPromptComponent: React.FC<Props> = ({
selectedSystemPromptId,
setSelectedSystemPromptId,
systemPrompts,
}) => {
const [showSelectSystemPrompt, setShowSelectSystemPrompt] = React.useState<boolean>(false);
const selectedPrompt: Prompt | undefined = useMemo(
() => getPromptById({ prompts: systemPrompts, id: selectedSystemPromptId ?? '' }),
[systemPrompts, selectedSystemPromptId]
);
const clearSystemPrompt = useCallback(() => {
setSelectedSystemPromptId(null);
setShowSelectSystemPrompt(false);
}, [setSelectedSystemPromptId]);
const onShowSelectSystemPrompt = useCallback(() => setShowSelectSystemPrompt(true), []);
return (
<div data-test-subj="systemPrompt">
{selectedPrompt == null || showSelectSystemPrompt ? (
<SelectSystemPrompt
selectedPrompt={selectedPrompt}
setSelectedSystemPromptId={setSelectedSystemPromptId}
setShowSelectSystemPrompt={setShowSelectSystemPrompt}
showSelectSystemPrompt={showSelectSystemPrompt}
systemPrompts={systemPrompts}
/>
) : (
<EuiFlexGroup alignItems="flexStart" gutterSize="none">
<EuiFlexItem grow>
<SystemPromptText
color="subdued"
data-test-subj="systemPromptText"
onClick={onShowSelectSystemPrompt}
>
{selectedPrompt?.content ?? ''}
</SystemPromptText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={false}>
<EuiToolTip content={i18n.SELECT_A_SYSTEM_PROMPT}>
<EuiButtonIcon
aria-label={i18n.SELECT_A_SYSTEM_PROMPT}
data-test-subj="edit"
iconType="documentEdit"
onClick={onShowSelectSystemPrompt}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content={i18n.CLEAR_SYSTEM_PROMPT}>
<EuiButtonIcon
aria-label={i18n.CLEAR_SYSTEM_PROMPT}
data-test-subj="clear"
iconType="cross"
onClick={clearSystemPrompt}
/>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
)}
</div>
);
};
SystemPromptComponent.displayName = 'SystemPromptComponent';
export const SystemPrompt = React.memo(SystemPromptComponent);

View file

@ -0,0 +1,117 @@
/*
* 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 userEvent from '@testing-library/user-event';
import { mockSystemPrompt, mockSuperheroSystemPrompt } from '../../../../mock/system_prompt';
import { Props, SelectSystemPrompt } from '.';
const props: Props = {
selectedPrompt: undefined,
setSelectedSystemPromptId: jest.fn(),
setShowSelectSystemPrompt: jest.fn(),
showSelectSystemPrompt: false,
systemPrompts: [mockSystemPrompt, mockSuperheroSystemPrompt],
};
describe('SelectSystemPrompt', () => {
beforeEach(() => jest.clearAllMocks());
it('renders the prompt super select when showSelectSystemPrompt is true', () => {
const { getByTestId } = render(<SelectSystemPrompt {...props} showSelectSystemPrompt={true} />);
expect(getByTestId('promptSuperSelect')).toBeInTheDocument();
});
it('does NOT render the prompt super select when showSelectSystemPrompt is false', () => {
const { queryByTestId } = render(
<SelectSystemPrompt {...props} showSelectSystemPrompt={false} />
);
expect(queryByTestId('promptSuperSelect')).not.toBeInTheDocument();
});
it('renders the clear system prompt button when showSelectSystemPrompt is true', () => {
const { getByTestId } = render(<SelectSystemPrompt {...props} showSelectSystemPrompt={true} />);
expect(getByTestId('clearSystemPrompt')).toBeInTheDocument();
});
it('does NOT render the clear system prompt button when showSelectSystemPrompt is false', () => {
const { queryByTestId } = render(
<SelectSystemPrompt {...props} showSelectSystemPrompt={false} />
);
expect(queryByTestId('clearSystemPrompt')).not.toBeInTheDocument();
});
it('renders the add system prompt button when showSelectSystemPrompt is false', () => {
const { getByTestId } = render(
<SelectSystemPrompt {...props} showSelectSystemPrompt={false} />
);
expect(getByTestId('addSystemPrompt')).toBeInTheDocument();
});
it('does NOT render the add system prompt button when showSelectSystemPrompt is true', () => {
const { queryByTestId } = render(
<SelectSystemPrompt {...props} showSelectSystemPrompt={true} />
);
expect(queryByTestId('addSystemPrompt')).not.toBeInTheDocument();
});
it('clears the selected system prompt id when the clear button is clicked', () => {
const setSelectedSystemPromptId = jest.fn();
const { getByTestId } = render(
<SelectSystemPrompt
{...props}
setSelectedSystemPromptId={setSelectedSystemPromptId}
showSelectSystemPrompt={true}
/>
);
userEvent.click(getByTestId('clearSystemPrompt'));
expect(setSelectedSystemPromptId).toHaveBeenCalledWith(null);
});
it('hides the select when the clear button is clicked', () => {
const setShowSelectSystemPrompt = jest.fn();
const { getByTestId } = render(
<SelectSystemPrompt
{...props}
setShowSelectSystemPrompt={setShowSelectSystemPrompt}
showSelectSystemPrompt={true}
/>
);
userEvent.click(getByTestId('clearSystemPrompt'));
expect(setShowSelectSystemPrompt).toHaveBeenCalledWith(false);
});
it('shows the select when the add button is clicked', () => {
const setShowSelectSystemPrompt = jest.fn();
const { getByTestId } = render(
<SelectSystemPrompt
{...props}
setShowSelectSystemPrompt={setShowSelectSystemPrompt}
showSelectSystemPrompt={false}
/>
);
userEvent.click(getByTestId('addSystemPrompt'));
expect(setShowSelectSystemPrompt).toHaveBeenCalledWith(true);
});
});

View file

@ -0,0 +1,108 @@
/*
* 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 { css } from '@emotion/react';
import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiSuperSelect,
EuiToolTip,
} from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { getOptions } from '../helpers';
import * as i18n from '../translations';
import type { Prompt } from '../../../types';
export interface Props {
selectedPrompt: Prompt | undefined;
setSelectedSystemPromptId: React.Dispatch<React.SetStateAction<string | null>>;
setShowSelectSystemPrompt: React.Dispatch<React.SetStateAction<boolean>>;
showSelectSystemPrompt: boolean;
systemPrompts: Prompt[];
}
const SelectSystemPromptComponent: React.FC<Props> = ({
selectedPrompt,
setSelectedSystemPromptId,
setShowSelectSystemPrompt,
showSelectSystemPrompt,
systemPrompts,
}) => {
const options = useMemo(() => getOptions(systemPrompts), [systemPrompts]);
const onChange = useCallback(
(value) => {
setSelectedSystemPromptId(value);
setShowSelectSystemPrompt(false);
},
[setSelectedSystemPromptId, setShowSelectSystemPrompt]
);
const clearSystemPrompt = useCallback(() => {
setSelectedSystemPromptId(null);
setShowSelectSystemPrompt(false);
}, [setSelectedSystemPromptId, setShowSelectSystemPrompt]);
const onShowSelectSystemPrompt = useCallback(
() => setShowSelectSystemPrompt(true),
[setShowSelectSystemPrompt]
);
return (
<EuiFlexGroup data-test-subj="selectSystemPrompt" gutterSize="none">
<EuiFlexItem>
{showSelectSystemPrompt && (
<EuiFormRow
css={css`
min-width: 100%;
`}
>
<EuiSuperSelect
data-test-subj="promptSuperSelect"
fullWidth={true}
hasDividers
itemLayoutAlign="top"
onChange={onChange}
options={options}
placeholder={i18n.SELECT_A_SYSTEM_PROMPT}
valueOfSelected={selectedPrompt?.id}
/>
</EuiFormRow>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
{showSelectSystemPrompt ? (
<EuiToolTip content={i18n.CLEAR_SYSTEM_PROMPT}>
<EuiButtonIcon
aria-label={i18n.CLEAR_SYSTEM_PROMPT}
data-test-subj="clearSystemPrompt"
iconType="cross"
onClick={clearSystemPrompt}
/>
</EuiToolTip>
) : (
<EuiToolTip content={i18n.ADD_SYSTEM_PROMPT_TOOLTIP}>
<EuiButtonIcon
aria-label={i18n.ADD_SYSTEM_PROMPT_TOOLTIP}
data-test-subj="addSystemPrompt"
iconType="plus"
onClick={onShowSelectSystemPrompt}
/>
</EuiToolTip>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
};
SelectSystemPromptComponent.displayName = 'SelectSystemPromptComponent';
export const SelectSystemPrompt = React.memo(SelectSystemPromptComponent);

View file

@ -0,0 +1,29 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const ADD_SYSTEM_PROMPT_TOOLTIP = i18n.translate(
'xpack.elasticAssistant.assistant.firstPromptEditor.addSystemPromptTooltip',
{
defaultMessage: 'Add system prompt',
}
);
export const CLEAR_SYSTEM_PROMPT = i18n.translate(
'xpack.elasticAssistant.assistant.firstPromptEditor.clearSystemPrompt',
{
defaultMessage: 'Clear system prompt',
}
);
export const SELECT_A_SYSTEM_PROMPT = i18n.translate(
'xpack.elasticAssistant.assistant.firstPromptEditor.selectASystemPromptPlaceholder',
{
defaultMessage: 'Select a system prompt',
}
);

View file

@ -0,0 +1,26 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const COMMENTS_LIST_ARIA_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.firstPromptEditor.commentsListAriaLabel',
{
defaultMessage: 'List of comments',
}
);
export const EDITING_PROMPT = i18n.translate(
'xpack.elasticAssistant.assistant.firstPromptEditor.editingPromptLabel',
{
defaultMessage: 'editing prompt',
}
);
export const YOU = i18n.translate('xpack.elasticAssistant.assistant.firstPromptEditor.youLabel', {
defaultMessage: 'You',
});

View file

@ -0,0 +1,76 @@
/*
* 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 { EuiTextArea } from '@elastic/eui';
import React, { useCallback, useEffect, forwardRef } from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import * as i18n from './translations';
export interface Props extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
handlePromptChange?: (value: string) => void;
isDisabled?: boolean;
onPromptSubmit: (value: string) => void;
value: string;
}
const StyledTextArea = styled(EuiTextArea)`
min-height: 125px;
padding-right: 42px;
`;
export const PromptTextArea = forwardRef<HTMLTextAreaElement, Props>(
({ isDisabled = false, value, onPromptSubmit, handlePromptChange, ...props }, ref) => {
const [currentValue, setCurrentValue] = React.useState(value);
const onChangeCallback = useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
setCurrentValue(event.target.value);
if (handlePromptChange) {
handlePromptChange(event.target.value);
}
},
[handlePromptChange]
);
const onKeyDown = useCallback(
(event) => {
if (event.key === 'Enter' && !event.shiftKey && currentValue.trim().length > 0) {
event.preventDefault();
onPromptSubmit(event.target.value?.trim());
setCurrentValue('');
} else if (event.key === 'Enter' && !event.shiftKey && currentValue.trim().length === 0) {
event.preventDefault();
event.stopPropagation();
}
},
[currentValue, onPromptSubmit]
);
useEffect(() => {
setCurrentValue(value);
}, [value]);
return (
<StyledTextArea
className="eui-scrollBar"
inputRef={ref}
id={'prompt-textarea'}
data-test-subj={'prompt-textarea'}
fullWidth
autoFocus
disabled={isDisabled}
placeholder={i18n.PROMPT_PLACEHOLDER}
value={currentValue}
onChange={onChangeCallback}
onKeyDown={onKeyDown}
/>
);
}
);
PromptTextArea.displayName = 'PromptTextArea';

View file

@ -0,0 +1,15 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const PROMPT_PLACEHOLDER = i18n.translate(
'xpack.elasticAssistant.assistant.promptPlaceholder',
{
defaultMessage: "Ask me anything from 'summarize this alert' to 'help me build a query...'",
}
);

View file

@ -0,0 +1,225 @@
/*
* 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, useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiFormRow,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiColorPicker,
useColorPickerState,
EuiTextArea,
} from '@elastic/eui';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { EuiSetColorMethod } from '@elastic/eui/src/services/color_picker/color_picker';
import { PromptContextTemplate } from '../../../..';
import * as i18n from './translations';
import { QuickPrompt } from '../types';
import { QuickPromptSelector } from '../quick_prompt_selector/quick_prompt_selector';
import { PromptContextSelector } from '../prompt_context_selector/prompt_context_selector';
const StyledEuiModal = styled(EuiModal)``;
const DEFAULT_COLOR = '#D36086';
interface Props {
promptContexts: PromptContextTemplate[];
quickPrompts: QuickPrompt[];
onQuickPromptsChange: (quickPrompts: QuickPrompt[]) => void;
}
/**
* Modal for adding/removing quick prompts. Configure name, color, prompt and category.
*/
export const AddQuickPromptModal: React.FC<Props> = React.memo(
({ promptContexts, quickPrompts, onQuickPromptsChange }) => {
const [isModalVisible, setIsModalVisible] = useState(false);
// Local state for quick prompts (returned to parent on save via onQuickPromptsChange())
const [updatedQuickPrompts, setUpdatedQuickPrompts] = useState<QuickPrompt[]>(quickPrompts);
// Form options
const [selectedQuickPrompt, setSelectedQuickPrompt] = useState<QuickPrompt>();
// Prompt
const [prompt, setPrompt] = useState('');
const handlePromptTextChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setPrompt(e.target.value);
}, []);
// Color
const [color, setColor, errors] = useColorPickerState(DEFAULT_COLOR);
const handleColorChange = useCallback<EuiSetColorMethod>(
(text, { hex, isValid }) => {
if (selectedQuickPrompt != null) {
setSelectedQuickPrompt({
...selectedQuickPrompt,
color: text,
});
}
setColor(text, { hex, isValid });
},
[selectedQuickPrompt, setColor]
);
// Prompt Contexts/Categories
const [selectedPromptContexts, setSelectedPromptContexts] = useState<PromptContextTemplate[]>(
[]
);
const onPromptContextSelectionChange = useCallback((pc: PromptContextTemplate[]) => {
setSelectedPromptContexts(pc);
}, []);
// When top level quick prompt selection changes
const onQuickPromptSelectionChange = useCallback(
(quickPrompt?: QuickPrompt | string) => {
const newQuickPrompt: QuickPrompt | undefined =
typeof quickPrompt === 'string'
? {
title: quickPrompt ?? '',
prompt: '',
color: DEFAULT_COLOR,
categories: [],
}
: quickPrompt;
setSelectedQuickPrompt(newQuickPrompt);
setPrompt(newQuickPrompt?.prompt ?? '');
setColor(newQuickPrompt?.color ?? DEFAULT_COLOR, {
hex: newQuickPrompt?.color ?? DEFAULT_COLOR,
isValid: true,
});
// Map back to PromptContextTemplate's from QuickPrompt.categories
setSelectedPromptContexts(
promptContexts.filter((bpc) =>
newQuickPrompt?.categories?.some((cat) => bpc?.category === cat)
) ?? []
);
},
[promptContexts, setColor]
);
const onQuickPromptDeleted = useCallback((title: string) => {
setUpdatedQuickPrompts((prev) => prev.filter((qp) => qp.title !== title));
}, []);
// Modal control functions
const cleanupAndCloseModal = useCallback(() => {
setIsModalVisible(false);
}, []);
const handleCloseModal = useCallback(() => {
cleanupAndCloseModal();
}, [cleanupAndCloseModal]);
const handleSave = useCallback(() => {
onQuickPromptsChange(updatedQuickPrompts);
cleanupAndCloseModal();
}, [cleanupAndCloseModal, onQuickPromptsChange, updatedQuickPrompts]);
// useEffects
// Update quick prompts on any field change since editing is in place
useEffect(() => {
if (selectedQuickPrompt != null) {
setUpdatedQuickPrompts((prev) => {
const alreadyExists = prev.some((qp) => qp.title === selectedQuickPrompt.title);
if (alreadyExists) {
return prev.map((qp) => {
const categories = selectedPromptContexts.map((pc) => pc.category);
if (qp.title === selectedQuickPrompt.title) {
return {
...qp,
color,
prompt,
categories,
};
}
return qp;
});
} else {
return [
...prev,
{
...selectedQuickPrompt,
color,
prompt,
categories: selectedPromptContexts.map((pc) => pc.category),
},
];
}
});
}
}, [color, prompt, selectedPromptContexts, selectedQuickPrompt]);
// Reset local state on modal open
useEffect(() => {
if (isModalVisible) {
setUpdatedQuickPrompts(quickPrompts);
}
}, [isModalVisible, quickPrompts]);
return (
<>
<EuiButtonEmpty onClick={() => setIsModalVisible(true)} iconType="plus" size="xs">
{i18n.ADD_QUICK_PROMPT}
</EuiButtonEmpty>
{isModalVisible && (
<StyledEuiModal onClose={handleCloseModal} initialFocus=".quickPromptSelector">
<EuiModalHeader>
<EuiModalHeaderTitle>{i18n.ADD_QUICK_PROMPT_MODAL_TITLE}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiFormRow label={i18n.QUICK_PROMPT_NAME}>
<QuickPromptSelector
onQuickPromptDeleted={onQuickPromptDeleted}
onQuickPromptSelectionChange={onQuickPromptSelectionChange}
quickPrompts={updatedQuickPrompts}
selectedQuickPrompt={selectedQuickPrompt}
/>
</EuiFormRow>
<EuiFormRow label={i18n.QUICK_PROMPT_PROMPT} fullWidth>
<EuiTextArea onChange={handlePromptTextChange} value={prompt} />
</EuiFormRow>
<EuiFormRow label={i18n.QUICK_PROMPT_BADGE_COLOR} isInvalid={!!errors} error={errors}>
<EuiColorPicker onChange={handleColorChange} color={color} isInvalid={!!errors} />
</EuiFormRow>
<EuiFormRow
label={i18n.QUICK_PROMPT_CATEGORIES}
helpText={i18n.QUICK_PROMPT_CATEGORIES_HELP_TEXT}
>
<PromptContextSelector
onPromptContextSelectionChange={onPromptContextSelectionChange}
promptContexts={promptContexts}
selectedPromptContexts={selectedPromptContexts}
/>
</EuiFormRow>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={handleCloseModal}>{i18n.CANCEL}</EuiButtonEmpty>
<EuiButton type="submit" onClick={handleSave} fill>
{i18n.SAVE}
</EuiButton>
</EuiModalFooter>
</StyledEuiModal>
)}
</>
);
}
);
AddQuickPromptModal.displayName = 'AddQuickPromptModal';

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 { i18n } from '@kbn/i18n';
export const ADD_QUICK_PROMPT = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.addQuickPromptTitle',
{
defaultMessage: 'Add quick prompt...',
}
);
export const ADD_QUICK_PROMPT_MODAL_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.modalTitle',
{
defaultMessage: 'Add/Modify Quick Prompt',
}
);
export const QUICK_PROMPT_NAME = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.nameLabel',
{
defaultMessage: 'Name',
}
);
export const QUICK_PROMPT_PROMPT = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.promptLabel',
{
defaultMessage: 'Prompt',
}
);
export const QUICK_PROMPT_BADGE_COLOR = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.badgeColorLabel',
{
defaultMessage: 'Badge color',
}
);
export const QUICK_PROMPT_CATEGORIES = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.categoriesLabel',
{
defaultMessage: 'Categories',
}
);
export const QUICK_PROMPT_CATEGORIES_HELP_TEXT = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.categoriesHelpText',
{
defaultMessage:
'Select the Prompt Contexts that this Quick Prompt will be available for. Selecting none will make this Quick Prompt available at all times.',
}
);
export const CANCEL = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.modalCancelButtonTitle',
{
defaultMessage: 'Cancel',
}
);
export const SAVE = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.modalSaveButtonTitle',
{
defaultMessage: 'Save',
}
);

View file

@ -0,0 +1,99 @@
/*
* 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, useMemo } from 'react';
import { EuiComboBox, EuiComboBoxOptionOption, EuiHighlight } from '@elastic/eui';
import { PromptContextTemplate } from '../../../..';
import * as i18n from './translations';
interface Props {
onPromptContextSelectionChange: (promptContexts: PromptContextTemplate[]) => void;
promptContexts: PromptContextTemplate[];
selectedPromptContexts?: PromptContextTemplate[];
}
export type PromptContextSelectorOption = EuiComboBoxOptionOption<{ category: string }>;
/**
* Selector for choosing multiple Prompt Context Categories
*/
export const PromptContextSelector: React.FC<Props> = React.memo(
({ onPromptContextSelectionChange, promptContexts, selectedPromptContexts = [] }) => {
// ComboBox options
const options = useMemo<PromptContextSelectorOption[]>(
() =>
promptContexts.map((pc) => ({
value: {
category: pc.category,
},
label: pc.description,
})),
[promptContexts]
);
const selectedOptions = useMemo<PromptContextSelectorOption[]>(() => {
return selectedPromptContexts != null
? selectedPromptContexts.map((pc) => ({
value: {
category: pc.category,
},
label: pc.description,
}))
: [];
}, [selectedPromptContexts]);
const handleSelectionChange = useCallback(
(promptContextSelectorOption: PromptContextSelectorOption[]) => {
const newPromptSelection = promptContexts.filter((pc) =>
promptContextSelectorOption.some((qpso) => pc.description === qpso.label)
);
onPromptContextSelectionChange(newPromptSelection);
},
[onPromptContextSelectionChange, promptContexts]
);
// Callback for when user selects a prompt context
const onChange = useCallback(
(newOptions: PromptContextSelectorOption[]) => {
if (newOptions.length === 0) {
handleSelectionChange([]);
} else if (options.findIndex((o) => o.label === newOptions?.[0].label) !== -1) {
handleSelectionChange(newOptions);
}
},
[handleSelectionChange, options]
);
const renderOption: (
option: PromptContextSelectorOption,
searchValue: string,
OPTION_CONTENT_CLASSNAME: string
) => React.ReactNode = (option, searchValue, contentClassName) => {
const { label, value } = option;
return (
<span className={contentClassName}>
<EuiHighlight search={searchValue}>{label}</EuiHighlight>
&nbsp;
<span>{`(${value?.category})`}</span>
</span>
);
};
return (
<EuiComboBox
aria-label={i18n.PROMPT_CONTEXT_SELECTOR}
placeholder={i18n.PROMPT_CONTEXT_SELECTOR_PLACEHOLDER}
options={options}
selectedOptions={selectedOptions}
onChange={onChange}
renderOption={renderOption}
/>
);
}
);
PromptContextSelector.displayName = 'PromptContextSelector';

View file

@ -0,0 +1,22 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const PROMPT_CONTEXT_SELECTOR = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.promptContextSelector.ariaLabel',
{
defaultMessage: 'Select Prompt Context categories this Quick Prompt will be available for.',
}
);
export const PROMPT_CONTEXT_SELECTOR_PLACEHOLDER = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.promptContextSelector.placeholderLabel',
{
defaultMessage: '(Quick Prompt will always be visible).',
}
);

View file

@ -0,0 +1,185 @@
/*
* 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, useMemo, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiButtonIcon,
EuiToolTip,
EuiHealth,
EuiHighlight,
EuiComboBox,
EuiComboBoxOptionOption,
} from '@elastic/eui';
import { css } from '@emotion/react';
import * as i18n from './translations';
import { QuickPrompt } from '../types';
interface Props {
onQuickPromptDeleted: (quickPromptTitle: string) => void;
onQuickPromptSelectionChange: (quickPrompt?: QuickPrompt | string) => void;
quickPrompts: QuickPrompt[];
selectedQuickPrompt?: QuickPrompt;
}
export type QuickPromptSelectorOption = EuiComboBoxOptionOption<{ isDefault: boolean }>;
/**
* Selector for choosing and deleting Quick Prompts
*/
export const QuickPromptSelector: React.FC<Props> = React.memo(
({ quickPrompts, onQuickPromptDeleted, onQuickPromptSelectionChange, selectedQuickPrompt }) => {
// Form options
const [options, setOptions] = useState<QuickPromptSelectorOption[]>(
quickPrompts.map((qp) => ({
value: {
isDefault: qp.isDefault ?? false,
},
label: qp.title,
color: qp.color,
}))
);
const selectedOptions = useMemo<QuickPromptSelectorOption[]>(() => {
return selectedQuickPrompt
? [
{
value: {
isDefault: true,
},
label: selectedQuickPrompt.title,
color: selectedQuickPrompt.color,
},
]
: [];
}, [selectedQuickPrompt]);
const handleSelectionChange = useCallback(
(quickPromptSelectorOption: QuickPromptSelectorOption[]) => {
const newQuickPrompt =
quickPromptSelectorOption.length === 0
? undefined
: quickPrompts.find((qp) => qp.title === quickPromptSelectorOption[0]?.label) ??
quickPromptSelectorOption[0]?.label;
onQuickPromptSelectionChange(newQuickPrompt);
},
[onQuickPromptSelectionChange, quickPrompts]
);
// Callback for when user types to create a new quick prompt
const onCreateOption = useCallback(
(searchValue, flattenedOptions = []) => {
if (!searchValue || !searchValue.trim().toLowerCase()) {
return;
}
const normalizedSearchValue = searchValue.trim().toLowerCase();
const optionExists =
flattenedOptions.findIndex(
(option: QuickPromptSelectorOption) =>
option.label.trim().toLowerCase() === normalizedSearchValue
) === -1;
const newOption = {
value: searchValue,
label: searchValue,
};
if (optionExists) {
setOptions([...options, newOption]);
}
handleSelectionChange([newOption]);
},
[handleSelectionChange, options]
);
// Callback for when user selects a quick prompt
const onChange = useCallback(
(newOptions: QuickPromptSelectorOption[]) => {
if (newOptions.length === 0) {
handleSelectionChange([]);
} else if (options.findIndex((o) => o.label === newOptions?.[0].label) !== -1) {
handleSelectionChange(newOptions);
}
},
[handleSelectionChange, options]
);
// Callback for when user deletes a quick prompt
const onDelete = useCallback(
(label: string) => {
setOptions(options.filter((o) => o.label !== label));
if (selectedOptions?.[0]?.label === label) {
handleSelectionChange([]);
}
onQuickPromptDeleted(label);
},
[handleSelectionChange, onQuickPromptDeleted, options, selectedOptions]
);
const renderOption: (
option: QuickPromptSelectorOption,
searchValue: string,
OPTION_CONTENT_CLASSNAME: string
) => React.ReactNode = (option, searchValue, contentClassName) => {
const { color, label, value } = option;
return (
<EuiFlexGroup
alignItems="center"
justifyContent="spaceBetween"
className={'parentFlexGroup'}
>
<EuiFlexItem grow={false}>
<EuiHealth color={color}>
<span className={contentClassName}>
<EuiHighlight search={searchValue}>{label}</EuiHighlight>
</span>
</EuiHealth>
</EuiFlexItem>
{!value?.isDefault && (
<EuiFlexItem grow={false}>
<EuiToolTip position="right" content={i18n.DELETE_QUICK_PROMPT_}>
<EuiButtonIcon
iconType="cross"
aria-label={i18n.DELETE_QUICK_PROMPT_}
color="danger"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onDelete(label);
}}
css={css`
visibility: hidden;
.parentFlexGroup:hover & {
visibility: visible;
}
`}
/>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};
return (
<EuiComboBox
aria-label={i18n.QUICK_PROMPT_SELECTOR}
placeholder={i18n.QUICK_PROMPT_SELECTOR}
customOptionText={`${i18n.CUSTOM_OPTION_TEXT} {searchValue}`}
singleSelection={true}
options={options}
selectedOptions={selectedOptions}
onChange={onChange}
onCreateOption={onCreateOption}
renderOption={renderOption}
/>
);
}
);
QuickPromptSelector.displayName = 'QuickPromptSelector';

View file

@ -0,0 +1,29 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const QUICK_PROMPT_SELECTOR = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.quickPromptSelector.ariaLabel',
{
defaultMessage: 'Select to edit, or type to create new',
}
);
export const DELETE_QUICK_PROMPT_ = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.quickPromptSelector.deletePromptTitle',
{
defaultMessage: 'Delete Quick Prompt',
}
);
export const CUSTOM_OPTION_TEXT = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.quickPromptSelector.customOptionText',
{
defaultMessage: 'Create new Quick Prompt named',
}
);

View file

@ -0,0 +1,122 @@
/*
* 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, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiPopover } from '@elastic/eui';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { useLocalStorage } from 'react-use';
import { QuickPrompt } from '../../..';
import * as i18n from './translations';
import { AddQuickPromptModal } from './add_quick_prompt_modal/add_quick_prompt_modal';
import { useAssistantContext } from '../../assistant_context';
const QuickPromptsFlexGroup = styled(EuiFlexGroup)`
margin: 16px;
`;
export const QUICK_PROMPT_LOCAL_STORAGE_KEY = 'quickPrompts';
const COUNT_BEFORE_OVERFLOW = 5;
interface QuickPromptsProps {
setInput: (input: string) => void;
}
/**
* Component displaying a horizontal list of quick prompts, with callback for retrieving the selected quick prompt
* text, and support for adding new quick prompts and editing existing. Also supports overflow of quick prompts,
* and localstorage for storing new and edited prompts.
*/
export const QuickPrompts: React.FC<QuickPromptsProps> = React.memo(({ setInput }) => {
const { basePromptContexts, baseQuickPrompts, nameSpace } = useAssistantContext();
// Local storage for all quick prompts, prefixed by assistant nameSpace
const [localStorageQuickPrompts, setLocalStorageQuickPrompts] = useLocalStorage(
`${nameSpace}.${QUICK_PROMPT_LOCAL_STORAGE_KEY}`,
baseQuickPrompts
);
const [quickPrompts, setQuickPrompts] = useState(localStorageQuickPrompts ?? []);
// Overflow state
const [isOverflowPopoverOpen, setIsOverflowPopoverOpen] = useState(false);
const toggleOverflowPopover = useCallback(
() => setIsOverflowPopoverOpen(!isOverflowPopoverOpen),
[isOverflowPopoverOpen]
);
const closeOverflowPopover = useCallback(() => setIsOverflowPopoverOpen(false), []);
const onClickOverflowQuickPrompt = useCallback(
(prompt: string) => {
setInput(prompt);
closeOverflowPopover();
},
[closeOverflowPopover, setInput]
);
// Callback for manage modal, saves to local storage on change
const onQuickPromptsChange = useCallback(
(newQuickPrompts: QuickPrompt[]) => {
setLocalStorageQuickPrompts(newQuickPrompts);
setQuickPrompts(newQuickPrompts);
},
[setLocalStorageQuickPrompts]
);
return (
<QuickPromptsFlexGroup gutterSize="s" alignItems="center">
{quickPrompts.slice(0, COUNT_BEFORE_OVERFLOW).map((badge, index) => (
<EuiFlexItem key={index} grow={false}>
<EuiBadge
color={badge.color}
onClick={() => setInput(badge.prompt)}
onClickAriaLabel={badge.title}
>
{badge.title}
</EuiBadge>
</EuiFlexItem>
))}
{quickPrompts.length > COUNT_BEFORE_OVERFLOW && (
<EuiFlexItem grow={false}>
<EuiPopover
button={
<EuiBadge
color={'hollow'}
iconType={'boxesHorizontal'}
onClick={toggleOverflowPopover}
onClickAriaLabel={i18n.QUICK_PROMPT_OVERFLOW_ARIA}
/>
}
isOpen={isOverflowPopoverOpen}
closePopover={closeOverflowPopover}
anchorPosition="rightUp"
>
<EuiFlexGroup direction="column" gutterSize="s">
{quickPrompts.slice(COUNT_BEFORE_OVERFLOW).map((badge, index) => (
<EuiFlexItem key={index} grow={false}>
<EuiBadge
color={badge.color}
onClick={() => onClickOverflowQuickPrompt(badge.prompt)}
onClickAriaLabel={badge.title}
>
{badge.title}
</EuiBadge>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiPopover>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<AddQuickPromptModal
promptContexts={basePromptContexts}
quickPrompts={quickPrompts}
onQuickPromptsChange={onQuickPromptsChange}
/>
</EuiFlexItem>
</QuickPromptsFlexGroup>
);
});
QuickPrompts.displayName = 'QuickPrompts';

View file

@ -0,0 +1,29 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const QUICK_PROMPT_OVERFLOW_ARIA = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.overflowAriaTitle',
{
defaultMessage: 'Show overflow Quick Prompts',
}
);
export const CANCEL = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.modalCancelButtonTitle',
{
defaultMessage: 'Cancel',
}
);
export const SAVE = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.modalSaveButtonTitle',
{
defaultMessage: 'Save',
}
);

View file

@ -0,0 +1,25 @@
/*
* 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 { PromptContext } from '../../..';
/**
* A QuickPrompt is a badge that is displayed below the Assistant's input field. They provide
* a quick way for users to insert prompts as templates into the Assistant's input field. If no
* categories are provided they will always display with the assistant, however categories can be
* supplied to only display the QuickPrompt when the Assistant is registered with corresponding
* PromptContext's containing the same category.
*
* isDefault: If true, this QuickPrompt cannot be deleted by the user
*/
export interface QuickPrompt {
title: string;
prompt: string;
color: string;
categories?: Array<PromptContext['category']>;
isDefault?: boolean;
}

View file

@ -0,0 +1,97 @@
/*
* 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 {
EuiButtonIcon,
EuiFormRow,
EuiPopover,
EuiPopoverTitle,
EuiLink,
EuiToolTip,
} from '@elastic/eui';
import React, { useCallback, useRef, useState } from 'react';
import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public';
import { HttpSetup } from '@kbn/core-http-browser';
import { FormattedMessage } from '@kbn/i18n-react';
import { Conversation } from '../..';
import * as i18n from './translations';
import { ConnectorSelector } from '../connectorland/connector_selector';
export interface SettingsPopoverProps {
actionTypeRegistry: ActionTypeRegistryContract;
conversation: Conversation;
http: HttpSetup;
isDisabled?: boolean;
}
export const SettingsPopover: React.FC<SettingsPopoverProps> = React.memo(
({ actionTypeRegistry, conversation, http, isDisabled = false }) => {
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
// So we can hide the settings popover when the connector modal is displayed
const popoverPanelRef = useRef<HTMLElement | null>(null);
const closeSettingsHandler = useCallback(() => {
setIsSettingsOpen(false);
}, []);
// Hide settings panel when modal is visible (to keep visual clutter minimal)
const onConnectorModalVisibilityChange = useCallback((isVisible: boolean) => {
if (popoverPanelRef.current) {
popoverPanelRef.current.style.visibility = isVisible ? 'hidden' : 'visible';
}
}, []);
return (
<EuiPopover
button={
<EuiToolTip position="right" content={i18n.SETTINGS_TITLE}>
<EuiButtonIcon
disabled={isDisabled}
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
iconType="controlsVertical"
aria-label={i18n.SETTINGS_TITLE}
data-test-subj="assistant-settings-button"
/>
</EuiToolTip>
}
isOpen={isSettingsOpen}
closePopover={closeSettingsHandler}
anchorPosition="rightCenter"
panelRef={(el) => (popoverPanelRef.current = el)}
>
<EuiPopoverTitle>{i18n.SETTINGS_TITLE}</EuiPopoverTitle>
<div style={{ width: '300px' }}>
<EuiFormRow
data-test-subj="model-field"
label={i18n.SETTINGS_CONNECTOR_TITLE}
helpText={
<EuiLink
href={`${http.basePath.get()}/app/management/insightsAndAlerting/triggersActionsConnectors/connectors`}
target="_blank"
external
>
<FormattedMessage
id="xpack.elasticAssistant.assistant.settings.connectorHelpTextTitle"
defaultMessage="Kibana Connector to make requests with"
/>
</EuiLink>
}
>
<ConnectorSelector
actionTypeRegistry={actionTypeRegistry}
conversation={conversation}
http={http}
onConnectorModalVisibilityChange={onConnectorModalVisibilityChange}
/>
</EuiFormRow>
</div>
</EuiPopover>
);
}
);
SettingsPopover.displayName = 'SettingPopover';

View file

@ -0,0 +1,30 @@
/*
* 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, screen, waitFor } from '@testing-library/react';
import { StreamingText } from '.';
describe('StreamingText', () => {
it('renders text with a streaming effect', async () => {
const text = 'Stream stream stream your boat...';
const chunkSize = 5;
const delay = 50;
render(<StreamingText text={text} chunkSize={chunkSize} delay={delay} />);
let displayedText = '';
const expectedChunks = Math.ceil(text.length / chunkSize);
for (let i = 0; i < expectedChunks; i++) {
displayedText += text.substring(i * chunkSize, (i + 1) * chunkSize);
await waitFor(() => {
expect(screen.getByText(displayedText)).toBeInTheDocument();
});
}
});
});

View file

@ -0,0 +1,63 @@
/*
* 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, { useEffect, useState } from 'react';
export interface StreamingTextProps {
text: string;
children?: (text: string, isStreamingComplete: boolean) => React.ReactNode;
chunkSize?: number;
delay?: number;
onStreamingComplete?: () => void;
}
export const StreamingText: React.FC<StreamingTextProps> = React.memo<StreamingTextProps>(
({ text, children, chunkSize = 5, delay = 100, onStreamingComplete }) => {
const [displayText, setDisplayText] = useState<string>(delay === 0 ? text : '');
const [isStreamingComplete, setIsStreamingComplete] = useState<boolean>(delay === 0);
useEffect(() => {
if (delay === 0) {
onStreamingComplete?.();
}
// Only run on initial render so onStreamingComplete is only called once if delay is 0
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (isStreamingComplete || delay === 0) {
return;
}
let currentPos = 0;
setIsStreamingComplete(false);
const interval = setInterval(() => {
if (currentPos >= text.length) {
onStreamingComplete?.();
setIsStreamingComplete(true);
clearInterval(interval);
return;
}
const chunk = text.substring(currentPos, currentPos + chunkSize);
setDisplayText((prevText) => prevText + chunk);
currentPos += chunkSize;
}, delay);
return () => {
clearInterval(interval);
};
}, [text, chunkSize, delay, onStreamingComplete, isStreamingComplete]);
if (children) {
return <div>{children(displayText, isStreamingComplete)}</div>;
} else {
return <div>{displayText}</div>;
}
}
);
StreamingText.displayName = 'StreamingText';

View file

@ -0,0 +1,54 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const CLEAR_CHAT = i18n.translate('xpack.elasticAssistant.assistant.clearChat', {
defaultMessage: 'Clear chat',
});
export const DEFAULT_ASSISTANT_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.defaultAssistantTitle',
{
defaultMessage: 'Elastic Assistant',
}
);
export const MISSING_CONNECTOR_CALLOUT_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.missingConnectorCalloutTitle',
{
defaultMessage: 'The current conversation is missing a connector configuration',
}
);
export const MISSING_CONNECTOR_CALLOUT_DESCRIPTION = i18n.translate(
'xpack.elasticAssistant.assistant.missingConnectorCalloutDescription',
{
defaultMessage: 'Select a connector from the conversation settings to continue',
}
);
// Settings
export const SETTINGS_TITLE = i18n.translate('xpack.elasticAssistant.assistant.settingsTitle', {
defaultMessage: 'Conversation settings',
});
export const SETTINGS_CONNECTOR_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.settings.connectorTitle',
{
defaultMessage: 'Connector',
}
);
export const SUBMIT_MESSAGE = i18n.translate('xpack.elasticAssistant.assistant.submitMessage', {
defaultMessage: 'Submit message',
});
export const API_ERROR = i18n.translate('xpack.elasticAssistant.assistant.apiErrorTitle', {
defaultMessage:
'An error occurred sending your message. If the problem persists, please test the connector configuration.',
});

View file

@ -0,0 +1,15 @@
/*
* 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.
*/
export type PromptType = 'system' | 'user';
export interface Prompt {
id: string;
content: string;
name: string;
promptType: PromptType;
}

View file

@ -0,0 +1,102 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { useAssistantOverlay } from '.';
const mockUseAssistantContext = {
registerPromptContext: jest.fn(),
showAssistantOverlay: jest.fn(),
unRegisterPromptContext: jest.fn(),
};
jest.mock('../../assistant_context', () => {
const original = jest.requireActual('../../assistant_context');
return {
...original,
useAssistantContext: () => mockUseAssistantContext,
};
});
describe('useAssistantOverlay', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('calls registerPromptContext with the expected context', async () => {
const category = 'event';
const description = 'test description';
const getPromptContext = jest.fn(() => Promise.resolve('test data'));
const id = 'test-id';
const suggestedUserPrompt = 'test user prompt';
const tooltip = 'test tooltip';
renderHook(() =>
useAssistantOverlay(
category,
null,
description,
getPromptContext,
id,
suggestedUserPrompt,
tooltip
)
);
expect(mockUseAssistantContext.registerPromptContext).toHaveBeenCalledWith({
category,
description,
getPromptContext,
id,
suggestedUserPrompt,
tooltip,
});
});
it('calls unRegisterPromptContext on unmount', () => {
const { unmount } = renderHook(() =>
useAssistantOverlay(
'event',
null,
'description',
() => Promise.resolve('data'),
'id',
null,
'tooltip'
)
);
unmount();
expect(mockUseAssistantContext.unRegisterPromptContext).toHaveBeenCalledWith('id');
});
it('calls `showAssistantOverlay` from the assistant context', () => {
const { result } = renderHook(() =>
useAssistantOverlay(
'event',
'conversation-id',
'description',
() => Promise.resolve('data'),
'id',
null,
'tooltip'
)
);
act(() => {
result.current.showAssistantOverlay(true);
});
expect(mockUseAssistantContext.showAssistantOverlay).toHaveBeenCalledWith({
showOverlay: true,
promptContextId: 'id',
conversationId: 'conversation-id',
});
});
});

View file

@ -0,0 +1,135 @@
/*
* 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 { useCallback, useEffect, useMemo } from 'react';
import { useAssistantContext } from '../../assistant_context';
import { getUniquePromptContextId } from '../../assistant_context/helpers';
import type { PromptContext } from '../prompt_context/types';
interface UseAssistantOverlay {
showAssistantOverlay: (show: boolean) => void;
promptContextId: string;
}
/**
* `useAssistantOverlay` is a hook that registers context with the assistant overlay, and
* returns an optional `showAssistantOverlay` function to display the assistant overlay.
* As an alterative to using the `showAssistantOverlay` returned from this hook, you may
* use the `NewChatById` component and pass it the `promptContextId` returned by this hook.
*
* USE THIS WHEN: You want to register context in one part of the tree, and then show
* a _New chat_ button in another part of the tree without passing around the data, or when
* you want to build a custom `New chat` button with features not not provided by the
* `NewChat` component.
*/
export const useAssistantOverlay = (
/**
* The category of data, e.g. `alert | alerts | event | events | string`
*
* `category` helps the assistant display the most relevant user prompts
*/
category: PromptContext['category'],
/**
* optionally automatically add this context to a specific conversation when the assistant is displayed
*/
conversationId: string | null,
/**
* The assistant will display this **short**, static description
* in the context pill
*/
description: PromptContext['description'],
/**
* The assistant will invoke this function to retrieve the context data,
* which will be included in a prompt (e.g. the contents of an alert or an event)
*/
getPromptContext: PromptContext['getPromptContext'],
/**
* Optionally provide a unique identifier for this prompt context, or accept the uuid default.
*/
id: PromptContext['id'] | null,
/**
* An optional user prompt that's filled in, but not sent, when the Elastic Assistant opens
*/
suggestedUserPrompt: PromptContext['suggestedUserPrompt'] | null,
/**
* The assistant will display this tooltip when the user hovers over the context pill
*/
tooltip: PromptContext['tooltip']
): UseAssistantOverlay => {
// memoize the props so that we can use them in the effect below:
const _category: PromptContext['category'] = useMemo(() => category, [category]);
const _description: PromptContext['description'] = useMemo(() => description, [description]);
const _getPromptContext: PromptContext['getPromptContext'] = useMemo(
() => getPromptContext,
[getPromptContext]
);
const promptContextId: PromptContext['id'] = useMemo(
() => id ?? getUniquePromptContextId(),
[id]
);
const _suggestedUserPrompt: PromptContext['suggestedUserPrompt'] = useMemo(
() => suggestedUserPrompt ?? undefined,
[suggestedUserPrompt]
);
const _tooltip = useMemo(() => tooltip, [tooltip]);
// the assistant context is used to show/hide the assistant overlay:
const {
registerPromptContext,
showAssistantOverlay: assistantContextShowOverlay,
unRegisterPromptContext,
} = useAssistantContext();
// proxy show / hide calls to assistant context, using our internal prompt context id:
const showAssistantOverlay = useCallback(
(showOverlay: boolean) => {
if (promptContextId != null) {
assistantContextShowOverlay({
showOverlay,
promptContextId,
conversationId: conversationId ?? undefined,
});
}
},
[assistantContextShowOverlay, conversationId, promptContextId]
);
useEffect(() => {
unRegisterPromptContext(promptContextId); // a noop if the current prompt context id is not registered
const newContext: PromptContext = {
category: _category,
description: _description,
getPromptContext: _getPromptContext,
id: promptContextId,
suggestedUserPrompt: _suggestedUserPrompt,
tooltip: _tooltip,
};
registerPromptContext(newContext);
return () => unRegisterPromptContext(promptContextId);
}, [
_category,
_description,
_getPromptContext,
_suggestedUserPrompt,
_tooltip,
promptContextId,
registerPromptContext,
unRegisterPromptContext,
]);
return { promptContextId, showAssistantOverlay };
};

View file

@ -0,0 +1,60 @@
/*
* 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';
export interface CodeBlockDetails {
type: QueryType;
content: string;
start: number;
end: number;
controlContainer?: React.ReactNode;
button?: React.ReactNode;
}
export type QueryType = 'eql' | 'kql' | 'dsl' | 'json' | 'no-type';
/**
* `analyzeMarkdown` is a helper that enriches content returned from a query
* with action buttons
*
* Returns a list of code block details for each code block in the markdown,
* including the type of code block and the content of the code block.
*
* @param markdown
*/
export const analyzeMarkdown = (markdown: string): CodeBlockDetails[] => {
const codeBlockRegex = /```(\w+)?\s([\s\S]*?)```/g;
const matches = [...markdown.matchAll(codeBlockRegex)];
// If your codeblocks aren't getting tagged with the right language, add keywords to the array.
const types = {
eql: ['Event Query Language', 'EQL sequence query'],
kql: ['Kibana Query Language', 'KQL Query'],
dsl: ['Elasticsearch QueryDSL', 'Elasticsearch Query DSL', 'Elasticsearch DSL'],
};
const result: CodeBlockDetails[] = matches.map((match) => {
let type = match[1] || 'no-type';
if (type === 'no-type' || type === 'json') {
const start = match.index || 0;
const precedingText = markdown.slice(0, start);
for (const [typeKey, keywords] of Object.entries(types)) {
if (keywords.some((kw) => precedingText.includes(kw))) {
type = typeKey;
break;
}
}
}
const content = match[2].trim();
const start = match.index || 0;
const end = start + match[0].length;
return { type: type as QueryType, content, start, end };
});
return result;
};

View file

@ -0,0 +1,192 @@
/*
* 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 { useCallback } from 'react';
import { useAssistantContext } from '../../assistant_context';
import { Conversation, Message } from '../../assistant_context/types';
import * as i18n from './translations';
import { ELASTIC_SECURITY_ASSISTANT, ELASTIC_SECURITY_ASSISTANT_TITLE } from './translations';
export const DEFAULT_CONVERSATION_STATE: Conversation = {
id: i18n.DEFAULT_CONVERSATION_TITLE,
messages: [],
apiConfig: {},
theme: {
title: ELASTIC_SECURITY_ASSISTANT_TITLE,
titleIcon: 'logoSecurity',
assistant: {
name: ELASTIC_SECURITY_ASSISTANT,
icon: 'logoSecurity',
},
system: {
icon: 'logoElastic',
},
user: {},
},
};
interface AppendMessageProps {
conversationId: string;
message: Message;
}
interface CreateConversationProps {
conversationId: string;
messages?: Message[];
}
interface SetApiConfigProps {
conversationId: string;
apiConfig: Conversation['apiConfig'];
}
interface SetConversationProps {
conversation: Conversation;
}
interface UseConversation {
appendMessage: ({ conversationId: string, message: Message }: AppendMessageProps) => Message[];
clearConversation: (conversationId: string) => void;
createConversation: ({
conversationId,
messages,
}: CreateConversationProps) => Conversation | undefined;
setApiConfig: ({ conversationId, apiConfig }: SetApiConfigProps) => void;
setConversation: ({ conversation }: SetConversationProps) => void;
}
export const useConversation = (): UseConversation => {
const { setConversations } = useAssistantContext();
/**
* Append a message to the conversation[] for a given conversationId
*/
const appendMessage = useCallback(
({ conversationId, message }: AppendMessageProps): Message[] => {
let messages: Message[] = [];
setConversations((prev: Record<string, Conversation>) => {
const prevConversation: Conversation | undefined = prev[conversationId];
if (prevConversation != null) {
messages = [...prevConversation.messages, message];
const newConversation = {
...prevConversation,
messages,
};
return {
...prev,
[conversationId]: newConversation,
};
} else {
return prev;
}
});
return messages;
},
[setConversations]
);
/**
* Clear the messages[] for a given conversationId
*/
const clearConversation = useCallback(
(conversationId: string) => {
setConversations((prev: Record<string, Conversation>) => {
const prevConversation: Conversation | undefined = prev[conversationId];
if (prevConversation != null) {
const newConversation = {
...prevConversation,
messages: [],
};
return {
...prev,
[conversationId]: newConversation,
};
} else {
return prev;
}
});
},
[setConversations]
);
/**
* Create a new conversation with the given conversationId, and optionally add messages
*/
const createConversation = useCallback(
({ conversationId, messages }: CreateConversationProps): Conversation | undefined => {
let newConversation: Conversation | undefined;
setConversations((prev: Record<string, Conversation>) => {
const prevConversation: Conversation | undefined = prev[conversationId];
if (prevConversation != null) {
throw new Error('Conversation already exists!');
} else {
newConversation = {
...DEFAULT_CONVERSATION_STATE,
id: conversationId,
messages: messages != null ? messages : [],
};
return {
...prev,
[conversationId]: {
...newConversation,
},
};
}
});
return newConversation;
},
[setConversations]
);
/**
* Update the apiConfig for a given conversationId
*/
const setApiConfig = useCallback(
({ conversationId, apiConfig }: SetApiConfigProps): void => {
setConversations((prev: Record<string, Conversation>) => {
const prevConversation: Conversation | undefined = prev[conversationId];
if (prevConversation != null) {
const updatedConversation = {
...prevConversation,
apiConfig,
};
return {
...prev,
[conversationId]: updatedConversation,
};
} else {
return prev;
}
});
},
[setConversations]
);
/**
* Set/overwrite an existing conversation (behaves as createConversation if not already existing)
*/
const setConversation = useCallback(
({ conversation }: SetConversationProps): void => {
setConversations((prev: Record<string, Conversation>) => {
return {
...prev,
[conversation.id]: conversation,
};
});
},
[setConversations]
);
return { appendMessage, clearConversation, createConversation, setApiConfig, setConversation };
};

View file

@ -0,0 +1,137 @@
/*
* 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 { Conversation } from '../../assistant_context/types';
import * as i18n from '../../content/prompts/welcome/translations';
import {
DEFAULT_CONVERSATION_TITLE,
ELASTIC_SECURITY_ASSISTANT,
ELASTIC_SECURITY_ASSISTANT_TITLE,
WELCOME_CONVERSATION_TITLE,
} from './translations';
export const BASE_CONVERSATIONS: Record<string, Conversation> = {
[DEFAULT_CONVERSATION_TITLE]: {
id: DEFAULT_CONVERSATION_TITLE,
messages: [
{
role: 'user',
content: 'Can you provide a sample KQL query please? (Kibana)',
timestamp: '5/12/2023, 12:04:07 AM',
},
{
role: 'assistant',
content:
'Certainly! Here\'s a sample KQL (Kibana Query Language) query for a fictional web server log data:\n\n```\nresponse.keyword: "200" AND extension.keyword: "html" AND NOT (geo.src: "US" OR geo.src: "CA")\n```\n\nThis query will return all documents where:\n\n1. The `response` field has a value of "200" (HTTP status code for a successful request).\n2. The `extension` field has a value of "html" (indicating an HTML file was requested).\n3. The `geo.src` field is not "US" or "CA" (meaning the request did not come from the United States or Canada).\n\nYou can use this query in Kibana\'s Discover or Dashboard panels to filter the data based on these conditions.',
timestamp: '5/12/2023, 12:04:21 AM',
},
{
role: 'user',
content:
'What about an EQL sequence query for 4 failed logons followed by a successful login?',
timestamp: '5/12/2023, 12:08:31 AM',
},
{
role: 'assistant',
content:
'Here\'s an EQL (Event Query Language) sequence query to detect 4 failed logons followed by a successful login for a fictional logon event data:\n\n```eql\nsequence by user.name with maxspan=5m\n [authentication where result == "failure" and event.action == "logon_attempt"]{4}\n [authentication where result == "success" and event.action == "logon_attempt"]\n```\n\nThis query will return sequences where:\n\n1. The events are grouped by the `user.name` field.\n2. The maximum time span between the first and last event in the sequence is 5 minutes (`maxspan=5m`).\n3. There are 4 consecutive `authentication` events with a `result` field value of "failure" and an `event.action` field value of "logon_attempt".\n4. After the 4 failed logon attempts, there\'s an `authentication` event with a `result` field value of "success" and an `event.action` field value of "logon_attempt".\n\nYou can use this EQL query in Elasticsearch or Kibana to detect potential brute force attacks or suspicious logon behavior.',
timestamp: '5/12/2023, 12:08:46 AM',
},
{
role: 'user',
content: "Can you fix the markdown please, it's not rendering",
timestamp: '5/12/2023, 12:09:00 AM',
},
{
role: 'assistant',
content:
'Apologies for the formatting issue. Here\'s the corrected EQL sequence query:\n\n```\nsequence by user.name with maxspan=5m\n [authentication where result == "failure" and event.action == "logon_attempt"]{4}\n [authentication where result == "success" and event.action == "logon_attempt"]\n```\n\nThis query will return sequences where:\n\n1. The events are grouped by the `user.name` field.\n2. The maximum time span between the first and last event in the sequence is 5 minutes (`maxspan=5m`).\n3. There are 4 consecutive `authentication` events with a `result` field value of "failure" and an `event.action` field value of "logon_attempt".\n4. After the 4 failed logon attempts, there\'s an `authentication` event with a `result` field value of "success" and an `event.action` field value of "logon_attempt".\n\nYou can use this EQL query in Elasticsearch or Kibana to detect potential brute force attacks or suspicious logon behavior.',
timestamp: '5/12/2023, 12:09:16 AM',
},
{
role: 'user',
content:
'Can you response with a message that contains both the first KQL query you sent me, and also the EQL sequence query you sent?',
timestamp: '5/12/2023, 12:11:49 AM',
},
{
role: 'assistant',
content:
'Certainly! Here are both the KQL and EQL queries in a single response:\n\n**KQL Query (Kibana Query Language):**\n\n```\nresponse.keyword: "200" AND extension.keyword: "html" AND NOT (geo.src: "US" OR geo.src: "CA")\n```\n\nThis KQL query returns all documents where:\n\n1. The `response` field has a value of "200" (HTTP status code for a successful request).\n2. The `extension` field has a value of "html" (indicating an HTML file was requested).\n3. The `geo.src` field is not "US" or "CA" (meaning the request did not come from the United States or Canada).\n\nYou can use this query in Kibana\'s Discover or Dashboard panels to filter the data based on these conditions.\n\n**EQL Sequence Query (Event Query Language):**\n\n```\nsequence by user.name with maxspan=5m\n [authentication where result == "failure" and event.action == "logon_attempt"]{4}\n [authentication where result == "success" and event.action == "logon_attempt"]\n```\n\nThis EQL query returns sequences where:\n\n1. The events are grouped by the `user.name` field.\n2. The maximum time span between the first and last event in the sequence is 5 minutes (`maxspan=5m`).\n3. There are 4 consecutive `authentication` events with a `result` field value of "failure" and an `event.action` field value of "logon_attempt".\n4. After the 4 failed logon attempts, there\'s an `authentication` event with a `result` field value of "success" and an `event.action` field value of "logon_attempt".\n\nYou can use this EQL query in Elasticsearch or Kibana to detect potential brute force attacks or suspicious logon behavior.',
timestamp: '5/12/2023, 12:12:09 AM',
},
{
role: 'user',
content:
'Last but not least, could you write an Elasticsearch QueryDSL for filtering events where `event.action` is `logon_attempt`',
timestamp: '5/12/2023, 12:30:38 AM',
},
{
role: 'assistant',
content:
'Certainly! Here\'s an Elasticsearch QueryDSL for filtering events where `file.name` is `fake_behavior.exe`:\n\n```json\n{\n "match_phrase": {\n "file.name": {\n "query": "fake_behavior.exe"\n }\n }\n}\n```\n\nThis QueryDSL is searching for documents where the file.name field matches the exact phrase "fake_behavior.exe". It is useful for finding documents that have a specific file name in an Elasticsearch index.',
timestamp: '5/12/2023, 12:30:49 AM',
},
],
apiConfig: {},
},
timeline: {
id: 'timeline',
messages: [],
apiConfig: {},
},
[WELCOME_CONVERSATION_TITLE]: {
id: WELCOME_CONVERSATION_TITLE,
theme: {
title: ELASTIC_SECURITY_ASSISTANT_TITLE,
titleIcon: 'logoSecurity',
assistant: {
name: ELASTIC_SECURITY_ASSISTANT,
icon: 'logoSecurity',
},
system: {
icon: 'logoElastic',
},
user: {},
},
messages: [
{
role: 'assistant',
content: i18n.WELCOME_GENERAL,
timestamp: '',
presentation: {
delay: 2 * 1000,
stream: true,
},
},
{
role: 'assistant',
content: i18n.WELCOME_GENERAL_2,
timestamp: '',
presentation: {
delay: 1000,
stream: true,
},
},
{
role: 'assistant',
content: i18n.WELCOME_GENERAL_3,
timestamp: '',
presentation: {
delay: 1000,
stream: true,
},
},
// {
// role: 'assistant',
// content: i18n.WELCOME_NO_CONNECTOR_PRIVILEGES,
// timestamp: '',
// },
],
apiConfig: {},
},
};

View file

@ -0,0 +1,35 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const WELCOME_CONVERSATION_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.useConversation.welcomeConversationTitle',
{
defaultMessage: 'Welcome',
}
);
export const DEFAULT_CONVERSATION_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.useConversation.defaultConversationTitle',
{
defaultMessage: 'Default',
}
);
export const ELASTIC_SECURITY_ASSISTANT_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.useConversation.elasticSecurityAssistantTitle',
{
defaultMessage: 'Elastic Security Assistant',
}
);
export const ELASTIC_SECURITY_ASSISTANT = i18n.translate(
'xpack.elasticAssistant.assistant.useConversation.elasticSecurityAssistantName',
{
defaultMessage: 'Assistant',
}
);

View file

@ -0,0 +1,42 @@
/*
* 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 { useCallback, useState } from 'react';
import { HttpSetup } from '@kbn/core-http-browser';
import { Conversation, Message } from '../../assistant_context/types';
import { fetchConnectorExecuteAction } from '../api';
interface SendMessagesProps {
http: HttpSetup;
messages: Message[];
apiConfig: Conversation['apiConfig'];
}
interface UseSendMessages {
isLoading: boolean;
sendMessages: ({ apiConfig, http, messages }: SendMessagesProps) => Promise<string>;
}
export const useSendMessages = (): UseSendMessages => {
const [isLoading, setIsLoading] = useState(false);
const sendMessages = useCallback(async ({ apiConfig, http, messages }: SendMessagesProps) => {
setIsLoading(true);
try {
return await fetchConnectorExecuteAction({
http,
messages,
apiConfig,
});
} finally {
setIsLoading(false);
}
}, []);
return { isLoading, sendMessages };
};

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { v4 } from 'uuid';
import type { PromptContext } from '../assistant/prompt_context/types';
export const getUniquePromptContextId = (): string => v4();
export const updatePromptContexts = ({
prevPromptContexts,
promptContext,
}: {
prevPromptContexts: Record<string, PromptContext>;
promptContext: PromptContext;
}): Record<string, PromptContext> => ({
...prevPromptContexts,
[promptContext.id]: {
...promptContext,
},
});

View file

@ -0,0 +1,53 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { AssistantProvider, useAssistantContext } from '.';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock';
const actionTypeRegistry = actionTypeRegistryMock.create();
const mockGetInitialConversations = jest.fn(() => ({}));
const mockGetComments = jest.fn(() => []);
const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' });
const ContextWrapper: React.FC = ({ children }) => (
<AssistantProvider
actionTypeRegistry={actionTypeRegistry}
augmentMessageCodeBlocks={jest.fn()}
getInitialConversations={mockGetInitialConversations}
getComments={mockGetComments}
http={mockHttp}
setConversations={jest.fn()}
>
{children}
</AssistantProvider>
);
describe('AssistantContext', () => {
beforeEach(() => jest.clearAllMocks());
test('it throws an error when useAssistantContext hook is used without a SecurityAssistantContext', () => {
const { result } = renderHook(useAssistantContext);
expect(result.error).toEqual(
new Error('useAssistantContext must be used within a AssistantProvider')
);
});
test('it should return the httpFetch function', async () => {
const { result } = renderHook(useAssistantContext, { wrapper: ContextWrapper });
const http = await result.current.http;
const path = '/path/to/resource';
await http.fetch(path);
expect(mockHttp.fetch).toBeCalledWith(path);
});
});

View file

@ -0,0 +1,217 @@
/*
* 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 { EuiCommentProps } from '@elastic/eui';
import type { HttpSetup } from '@kbn/core-http-browser';
import { omit } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public';
import { updatePromptContexts } from './helpers';
import type {
PromptContext,
RegisterPromptContext,
UnRegisterPromptContext,
} from '../assistant/prompt_context/types';
import type { Conversation } from './types';
import { DEFAULT_ASSISTANT_TITLE } from '../assistant/translations';
import { CodeBlockDetails } from '../assistant/use_conversation/helpers';
import { PromptContextTemplate } from '../assistant/prompt_context/types';
import { QuickPrompt } from '../assistant/quick_prompts/types';
export interface ShowAssistantOverlayProps {
showOverlay: boolean;
promptContextId?: string;
conversationId?: string;
}
type ShowAssistantOverlay = ({
showOverlay,
promptContextId,
conversationId,
}: ShowAssistantOverlayProps) => void;
interface AssistantProviderProps {
actionTypeRegistry: ActionTypeRegistryContract;
augmentMessageCodeBlocks: (currentConversation: Conversation) => CodeBlockDetails[][];
basePromptContexts?: PromptContextTemplate[];
baseQuickPrompts?: QuickPrompt[];
children: React.ReactNode;
getComments: ({
currentConversation,
lastCommentRef,
}: {
currentConversation: Conversation;
lastCommentRef: React.MutableRefObject<HTMLDivElement | null>;
}) => EuiCommentProps[];
http: HttpSetup;
getInitialConversations: () => Record<string, Conversation>;
nameSpace?: string;
setConversations: React.Dispatch<React.SetStateAction<Record<string, Conversation>>>;
title?: string;
}
interface UseAssistantContext {
actionTypeRegistry: ActionTypeRegistryContract;
augmentMessageCodeBlocks: (currentConversation: Conversation) => CodeBlockDetails[][];
basePromptContexts: PromptContextTemplate[];
baseQuickPrompts: QuickPrompt[];
conversationIds: string[];
conversations: Record<string, Conversation>;
getComments: ({
currentConversation,
lastCommentRef,
}: {
currentConversation: Conversation;
lastCommentRef: React.MutableRefObject<HTMLDivElement | null>;
}) => EuiCommentProps[];
http: HttpSetup;
promptContexts: Record<string, PromptContext>;
nameSpace: string;
registerPromptContext: RegisterPromptContext;
setConversations: React.Dispatch<React.SetStateAction<Record<string, Conversation>>>;
setShowAssistantOverlay: (showAssistantOverlay: ShowAssistantOverlay) => void;
showAssistantOverlay: ShowAssistantOverlay;
title: string;
unRegisterPromptContext: UnRegisterPromptContext;
}
const AssistantContext = React.createContext<UseAssistantContext | undefined>(undefined);
export const AssistantProvider: React.FC<AssistantProviderProps> = ({
actionTypeRegistry,
augmentMessageCodeBlocks,
basePromptContexts = [],
baseQuickPrompts = [],
children,
getComments,
http,
getInitialConversations,
nameSpace = 'elasticAssistantDefault',
setConversations,
title = DEFAULT_ASSISTANT_TITLE,
}) => {
/**
* Prompt contexts are used to provide components a way to register and make their data available to the assistant.
*/
const [promptContexts, setPromptContexts] = useState<Record<string, PromptContext>>({});
const registerPromptContext: RegisterPromptContext = useCallback(
(promptContext: PromptContext) => {
setPromptContexts((prevPromptContexts) => {
if (promptContext != null && prevPromptContexts[promptContext.id] == null) {
return updatePromptContexts({
prevPromptContexts,
promptContext,
});
} else {
return prevPromptContexts;
}
});
},
[]
);
const unRegisterPromptContext: UnRegisterPromptContext = useCallback(
(queryContextId: string) =>
setPromptContexts((prevPromptContexts) => {
if (prevPromptContexts[queryContextId] == null) {
return prevPromptContexts;
} else {
return omit(queryContextId, prevPromptContexts);
}
}),
[]
);
/**
* Global Assistant Overlay actions
*/
const [showAssistantOverlay, setShowAssistantOverlay] = useState<ShowAssistantOverlay>(
(showAssistant) => {}
);
const [conversations, setConversationsInternal] = useState(getInitialConversations());
const conversationIds = useMemo(() => Object.keys(conversations).sort(), [conversations]);
// TODO: This is a fix for conversations not loading out of localstorage. Also re-introduces our cascading render issue (as it loops back in localstorage)
useEffect(() => {
setConversationsInternal(getInitialConversations());
}, [getInitialConversations]);
const onConversationsUpdated = useCallback<
React.Dispatch<React.SetStateAction<Record<string, Conversation>>>
>(
(
newConversations:
| Record<string, Conversation>
| ((prev: Record<string, Conversation>) => Record<string, Conversation>)
) => {
if (typeof newConversations === 'function') {
const updater = newConversations;
setConversationsInternal((prevValue) => {
const newValue = updater(prevValue);
setConversations(newValue);
return newValue;
});
} else {
setConversations(newConversations);
setConversationsInternal(newConversations);
}
},
[setConversations]
);
const value = useMemo(
() => ({
actionTypeRegistry,
augmentMessageCodeBlocks,
basePromptContexts,
baseQuickPrompts,
conversationIds,
conversations,
getComments,
http,
promptContexts,
nameSpace,
registerPromptContext,
setConversations: onConversationsUpdated,
setShowAssistantOverlay,
showAssistantOverlay,
title,
unRegisterPromptContext,
}),
[
actionTypeRegistry,
augmentMessageCodeBlocks,
basePromptContexts,
baseQuickPrompts,
conversationIds,
conversations,
getComments,
http,
promptContexts,
nameSpace,
registerPromptContext,
onConversationsUpdated,
showAssistantOverlay,
title,
unRegisterPromptContext,
]
);
return <AssistantContext.Provider value={value}>{children}</AssistantContext.Provider>;
};
export const useAssistantContext = () => {
const context = React.useContext(AssistantContext);
if (context == null) {
throw new Error('useAssistantContext must be used within a AssistantProvider');
}
return context;
};

View file

@ -0,0 +1,58 @@
/*
* 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';
export type ConversationRole = 'system' | 'user' | 'assistant';
export interface MessagePresentation {
delay?: number;
stream?: boolean;
}
export interface Message {
role: ConversationRole;
content: string;
timestamp: string;
presentation?: MessagePresentation;
}
export interface ConversationTheme {
title?: JSX.Element | string;
titleIcon?: string;
user?: {
name?: string;
icon?: string;
};
assistant?: {
name?: string;
icon?: string;
};
system?: {
name?: string;
icon?: string;
};
}
/**
* Complete state to reconstruct a conversation instance.
* Includes all messages, connector configured, and relevant UI state.
*
*/
export interface Conversation {
apiConfig: {
connectorId?: string;
provider?: OpenAiProviderType;
};
id: string;
messages: Message[];
theme?: ConversationTheme;
}
export interface OpenAIConfig {
temperature: number;
model: string;
}

View file

@ -0,0 +1,44 @@
/*
* 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 { EuiCard, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui';
import { GenAiLogo } from '@kbn/stack-connectors-plugin/public/common';
import * as i18n from '../translations';
export interface ConnectorButtonProps {
setIsConnectorModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
connectorAdded?: boolean;
}
/**
* Simple button component for adding a connector. Note: component is basic and does not handle connector
* add logic. Must pass in `setIsConnectorModalVisible`, see ConnectorSetup component if wanting to manage
* connector add logic.
*/
export const ConnectorButton: React.FC<ConnectorButtonProps> = React.memo<ConnectorButtonProps>(
({ setIsConnectorModalVisible, connectorAdded = false }) => {
return (
<EuiFlexGroup gutterSize="l" justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiCard
layout="horizontal"
icon={<EuiIcon size="xl" type={GenAiLogo} />}
title={connectorAdded ? i18n.CONNECTOR_ADDED_TITLE : i18n.ADD_CONNECTOR_TITLE}
isDisabled={connectorAdded}
description={
connectorAdded ? i18n.CONNECTOR_ADDED_DESCRIPTION : i18n.ADD_CONNECTOR_DESCRIPTION
}
onClick={() => setIsConnectorModalVisible(true)}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
);
ConnectorButton.displayName = 'ConnectorButton';

View file

@ -0,0 +1,180 @@
/*
* 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 { EuiButtonEmpty, EuiSuperSelect, EuiText } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import {
ActionConnector,
ActionTypeRegistryContract,
} from '@kbn/triggers-actions-ui-plugin/public';
import { HttpSetup } from '@kbn/core-http-browser';
import { ActionConnectorProps } from '@kbn/triggers-actions-ui-plugin/public/types';
import { ConnectorAddModal } from '@kbn/triggers-actions-ui-plugin/public/common/constants';
import {
GEN_AI_CONNECTOR_ID,
OpenAiProviderType,
} from '@kbn/stack-connectors-plugin/public/common';
import { css } from '@emotion/react';
import { Conversation } from '../../assistant_context/types';
import { useLoadConnectors } from '../use_load_connectors';
import { useConversation } from '../../assistant/use_conversation';
import * as i18n from '../translations';
import { useLoadActionTypes } from '../use_load_action_types';
export const ADD_NEW_CONNECTOR = 'ADD_NEW_CONNECTOR';
interface Props {
actionTypeRegistry: ActionTypeRegistryContract;
conversation: Conversation;
http: HttpSetup;
onConnectorModalVisibilityChange?: (isVisible: boolean) => void;
}
interface Config {
apiProvider: string;
}
export const ConnectorSelector: React.FC<Props> = React.memo(
({ actionTypeRegistry, conversation, http, onConnectorModalVisibilityChange }) => {
const { setApiConfig } = useConversation();
// Connector Modal State
const [isConnectorModalVisible, setIsConnectorModalVisible] = useState<boolean>(false);
const { data: actionTypes } = useLoadActionTypes({ http });
const actionType = actionTypes?.find((at) => at.id === GEN_AI_CONNECTOR_ID) ?? {
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'platinum',
supportedFeatureIds: ['general'],
id: '.gen-ai',
name: 'Generative AI',
enabled: true,
};
const {
data: connectors,
isLoading: isLoadingActionTypes,
isFetching: isFetchingActionTypes,
refetch: refetchConnectors,
} = useLoadConnectors({ http });
const isLoading = isLoadingActionTypes || isFetchingActionTypes;
const addNewConnectorOption = useMemo(() => {
return {
value: ADD_NEW_CONNECTOR,
inputDisplay: i18n.ADD_NEW_CONNECTOR,
dropdownDisplay: (
<React.Fragment key={ADD_NEW_CONNECTOR}>
<EuiButtonEmpty
iconType="plus"
size="xs"
css={css`
width: 100%;
`}
>
{i18n.ADD_NEW_CONNECTOR}
</EuiButtonEmpty>
</React.Fragment>
),
};
}, []);
const connectorOptions = useMemo(() => {
return (
connectors?.map((connector) => {
const apiProvider: string | undefined = (
connector as ActionConnectorProps<Config, unknown>
)?.config?.apiProvider;
return {
value: connector.id,
inputDisplay: connector.name,
dropdownDisplay: (
<React.Fragment key={connector.id}>
<strong>{connector.name}</strong>
{apiProvider && (
<EuiText size="s" color="subdued">
<p>{apiProvider}</p>
</EuiText>
)}
</React.Fragment>
),
};
}) ?? []
);
}, [connectors]);
const cleanupAndCloseModal = useCallback(() => {
onConnectorModalVisibilityChange?.(false);
setIsConnectorModalVisible(false);
}, [onConnectorModalVisibilityChange]);
const onChange = useCallback(
(connectorId: string) => {
if (connectorId === ADD_NEW_CONNECTOR) {
onConnectorModalVisibilityChange?.(true);
setIsConnectorModalVisible(true);
return;
}
const apiProvider = (
connectors?.find((c) => c.id === connectorId) as ActionConnectorProps<Config, unknown>
)?.config.apiProvider as OpenAiProviderType;
setApiConfig({
conversationId: conversation.id,
apiConfig: {
...conversation.apiConfig,
connectorId,
provider: apiProvider,
},
});
},
[
connectors,
conversation.apiConfig,
conversation.id,
setApiConfig,
onConnectorModalVisibilityChange,
]
);
return (
<>
<EuiSuperSelect
options={[...connectorOptions, addNewConnectorOption]}
valueOfSelected={conversation.apiConfig.connectorId ?? ''}
onChange={onChange}
compressed={true}
isLoading={isLoading}
aria-label={i18n.CONNECTOR_SELECTOR_TITLE}
/>
{isConnectorModalVisible && (
<ConnectorAddModal
actionType={actionType}
onClose={cleanupAndCloseModal}
postSaveEventHandler={(savedAction: ActionConnector) => {
setApiConfig({
conversationId: conversation.id,
apiConfig: {
...conversation.apiConfig,
connectorId: savedAction.id,
provider: (savedAction as ActionConnectorProps<Config, unknown>)?.config
.apiProvider as OpenAiProviderType,
},
});
refetchConnectors?.();
cleanupAndCloseModal();
}}
actionTypeRegistry={actionTypeRegistry}
/>
)}
</>
);
}
);
ConnectorSelector.displayName = 'ConnectorSelector';

View file

@ -0,0 +1,33 @@
/*
* 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 { Conversation } from '../../assistant_context/types';
/**
* Removes all presentation data from the conversation
* @param conversation
*/
export const clearPresentationData = (conversation: Conversation): Conversation => {
const { messages, ...restConversation } = conversation;
return {
...restConversation,
messages: messages.map((message) => {
const { presentation, ...restMessages } = message;
return {
...restMessages,
presentation: undefined,
};
}),
};
};
/**
* Returns true if the conversation has no presentation data
* @param conversation
*/
export const conversationHasNoPresentationData = (conversation: Conversation): boolean =>
!conversation.messages.some((message) => message.presentation !== undefined);

View file

@ -0,0 +1,261 @@
/*
* 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, useRef, useState } from 'react';
import type { EuiCommentProps } from '@elastic/eui';
import {
EuiAvatar,
EuiBadge,
EuiCommentList,
EuiMarkdownFormat,
EuiText,
EuiTextAlign,
} from '@elastic/eui';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { ConnectorAddModal } from '@kbn/triggers-actions-ui-plugin/public/common/constants';
import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public';
import { HttpSetup } from '@kbn/core-http-browser';
import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public';
import {
GEN_AI_CONNECTOR_ID,
OpenAiProviderType,
} from '@kbn/stack-connectors-plugin/public/common';
import { ActionConnectorProps } from '@kbn/triggers-actions-ui-plugin/public/types';
import { BASE_CONVERSATIONS, Conversation, Message } from '../../..';
import { useLoadActionTypes } from '../use_load_action_types';
import { StreamingText } from '../../assistant/streaming_text';
import { ConnectorButton } from '../connector_button';
import { useConversation } from '../../assistant/use_conversation';
import { clearPresentationData, conversationHasNoPresentationData } from './helpers';
import * as i18n from '../translations';
import { useAssistantContext } from '../../assistant_context';
import { WELCOME_CONVERSATION_TITLE } from '../../assistant/use_conversation/translations';
const MESSAGE_INDEX_BEFORE_CONNECTOR = 2;
const CommentsContainer = styled.div`
max-height: 600px;
overflow-y: scroll;
`;
const StyledCommentList = styled(EuiCommentList)`
margin-right: 20px;
`;
const ConnectorButtonWrapper = styled.div`
margin-top: 20px;
`;
const SkipEuiText = styled(EuiText)`
margin-top: 20px;
`;
interface Config {
apiProvider: string;
}
export interface ConnectorSetupProps {
isConnectorConfigured: boolean;
actionTypeRegistry: ActionTypeRegistryContract;
conversation?: Conversation;
http: HttpSetup;
onSetupComplete?: () => void;
refetchConnectors?: () => void;
}
export const ConnectorSetup: React.FC<ConnectorSetupProps> = React.memo<ConnectorSetupProps>(
({
actionTypeRegistry,
conversation = BASE_CONVERSATIONS[WELCOME_CONVERSATION_TITLE],
http,
isConnectorConfigured = false,
onSetupComplete,
refetchConnectors,
}) => {
const { appendMessage, setApiConfig, setConversation } = useConversation();
const lastCommentRef = useRef<HTMLDivElement | null>(null);
const bottomRef = useRef<HTMLDivElement | null>(null);
// Access all conversations so we can add connector to all on initial setup
const { conversations } = useAssistantContext();
const [isConnectorModalVisible, setIsConnectorModalVisible] = useState<boolean>(false);
const [showAddConnectorButton, setShowAddConnectorButton] = useState<boolean>(() => {
// If no presentation data on messages, default to showing add connector button so it doesn't delay render and flash on screen
return conversationHasNoPresentationData(conversation);
});
const { data: actionTypes } = useLoadActionTypes({ http });
const actionType = actionTypes?.find((at) => at.id === GEN_AI_CONNECTOR_ID) ?? {
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'platinum',
supportedFeatureIds: ['general'],
id: '.gen-ai',
name: 'Generative AI',
enabled: true,
};
// User constants
const userName = conversation.theme?.user?.name ?? i18n.CONNECTOR_SETUP_USER_YOU;
const assistantName =
conversation.theme?.assistant?.name ?? i18n.CONNECTOR_SETUP_USER_ASSISTANT;
const [currentMessageIndex, setCurrentMessageIndex] = useState(
// If connector is configured or conversation has already been replayed show all messages immediately
isConnectorConfigured || conversationHasNoPresentationData(conversation)
? MESSAGE_INDEX_BEFORE_CONNECTOR
: 0
);
// Once streaming of previous message is complete, proceed to next message
const onHandleMessageStreamingComplete = useCallback(() => {
const timeoutId = setTimeout(
() => setCurrentMessageIndex(currentMessageIndex + 1),
conversation.messages[currentMessageIndex].presentation?.delay ?? 0
);
return () => clearTimeout(timeoutId);
}, [conversation.messages, currentMessageIndex]);
// Show button to add connector after last message has finished streaming
const onHandleLastMessageStreamingComplete = useCallback(() => {
setShowAddConnectorButton(true);
onSetupComplete?.();
setConversation({ conversation: clearPresentationData(conversation) });
}, [conversation, onSetupComplete, setConversation]);
// Show button to add connector after last message has finished streaming
const handleSkipSetup = useCallback(() => {
setCurrentMessageIndex(MESSAGE_INDEX_BEFORE_CONNECTOR);
}, [setCurrentMessageIndex]);
// Create EuiCommentProps[] from conversation messages
const commentBody = useCallback(
(message: Message, index: number, length: number) => {
// If timestamp is not set, set it to current time (will update conversation at end of setup)
if (conversation.messages[index].timestamp.length === 0) {
conversation.messages[index].timestamp = new Date().toLocaleString();
}
const isLastMessage = index === length - 1;
const enableStreaming =
(message.presentation?.stream ?? false) && currentMessageIndex !== length - 1;
return (
<StreamingText
text={message.content}
delay={enableStreaming ? 50 : 0}
onStreamingComplete={
isLastMessage
? onHandleLastMessageStreamingComplete
: onHandleMessageStreamingComplete
}
>
{(streamedText, isStreamingComplete) => (
<EuiText>
<EuiMarkdownFormat className={`message-${index}`}>{streamedText}</EuiMarkdownFormat>
{isLastMessage && isStreamingComplete && <span ref={lastCommentRef} />}
</EuiText>
)}
</StreamingText>
);
},
[
conversation.messages,
currentMessageIndex,
onHandleLastMessageStreamingComplete,
onHandleMessageStreamingComplete,
]
);
return (
<>
<CommentsContainer className="eui-scrollBar">
<StyledCommentList
comments={conversation.messages
.slice(0, currentMessageIndex + 1)
.map((message, index) => {
const isUser = message.role === 'user';
const commentProps: EuiCommentProps = {
username: isUser ? userName : assistantName,
children: commentBody(message, index, conversation.messages.length),
timelineAvatar: (
<EuiAvatar
name={i18n.CONNECTOR_SETUP_USER_ASSISTANT}
size="l"
color="subdued"
iconType={conversation?.theme?.assistant?.icon ?? 'logoElastic'}
/>
),
timestamp: `${i18n.CONNECTOR_SETUP_TIMESTAMP_AT}: ${message.timestamp}`,
};
return commentProps;
})}
/>
<div ref={bottomRef} />
</CommentsContainer>
{(showAddConnectorButton || isConnectorConfigured) && (
<ConnectorButtonWrapper>
<ConnectorButton
setIsConnectorModalVisible={setIsConnectorModalVisible}
connectorAdded={isConnectorConfigured}
/>
</ConnectorButtonWrapper>
)}
{!showAddConnectorButton && (
<SkipEuiText color="subdued" size={'xs'}>
<EuiTextAlign textAlign="center">
<EuiBadge
color="hollow"
onClick={handleSkipSetup}
onClickAriaLabel={i18n.CONNECTOR_SETUP_SKIP}
>
{i18n.CONNECTOR_SETUP_SKIP}
</EuiBadge>
</EuiTextAlign>
</SkipEuiText>
)}
{isConnectorModalVisible && (
<ConnectorAddModal
actionType={actionType}
onClose={() => setIsConnectorModalVisible(false)}
postSaveEventHandler={(savedAction: ActionConnector) => {
// Add connector to all conversations
Object.values(conversations).forEach((c) => {
setApiConfig({
conversationId: c.id,
apiConfig: {
...c.apiConfig,
connectorId: savedAction.id,
provider: (savedAction as ActionConnectorProps<Config, unknown>)?.config
.apiProvider as OpenAiProviderType,
},
});
});
refetchConnectors?.();
setIsConnectorModalVisible(false);
appendMessage({
conversationId: conversation.id,
message: {
role: 'assistant',
content: 'Connector setup complete!',
timestamp: new Date().toLocaleString(),
},
});
}}
actionTypeRegistry={actionTypeRegistry}
/>
)}
</>
);
}
);
ConnectorSetup.displayName = 'ConnectorSetup';

View file

@ -0,0 +1,102 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const LOAD_ACTIONS_ERROR_MESSAGE = i18n.translate(
'xpack.elasticAssistant.connectors.useLoadActionTypes.errorMessage',
{
defaultMessage:
'Welcome to your Elastic Assistant! I am your 100% open-source portal into your Elastic Life. ',
}
);
export const LOAD_CONNECTORS_ERROR_MESSAGE = i18n.translate(
'xpack.elasticAssistant.connectors.useLoadConnectors.errorMessage',
{
defaultMessage:
'Welcome to your Elastic Assistant! I am your 100% open-source portal into your Elastic Life. ',
}
);
export const WELCOME_SECURITY = i18n.translate(
'xpack.elasticAssistant.content.prompts.welcome.welcomeSecurityPrompt',
{
defaultMessage:
'Welcome to your Elastic Assistant! I am your 100% open-source portal into Elastic Security. ',
}
);
export const CONNECTOR_SELECTOR_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.connectors.connectorSelector.ariaLabel',
{
defaultMessage: 'Conversation Selector',
}
);
export const ADD_NEW_CONNECTOR = i18n.translate(
'xpack.elasticAssistant.assistant.connectors.connectorSelector.newConnectorOptions',
{
defaultMessage: 'Add new Connector...',
}
);
export const ADD_CONNECTOR_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.connectors.addConnectorButton.title',
{
defaultMessage: 'Add Generative AI Connector',
}
);
export const ADD_CONNECTOR_DESCRIPTION = i18n.translate(
'xpack.elasticAssistant.assistant.connectors.addConnectorButton.description',
{
defaultMessage: 'Configure a connector to continue the conversation',
}
);
export const CONNECTOR_ADDED_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.connectors.addConnectorButton.connectorAddedTitle',
{
defaultMessage: 'Generative AI Connector added!',
}
);
export const CONNECTOR_ADDED_DESCRIPTION = i18n.translate(
'xpack.elasticAssistant.assistant.connectors.addConnectorButton.connectorAddedDescription',
{
defaultMessage: 'Ready to continue the conversation...',
}
);
export const CONNECTOR_SETUP_USER_YOU = i18n.translate(
'xpack.elasticAssistant.assistant.connectors.setup.userYouTitle',
{
defaultMessage: 'You',
}
);
export const CONNECTOR_SETUP_USER_ASSISTANT = i18n.translate(
'xpack.elasticAssistant.assistant.connectors.setup.userAssistantTitle',
{
defaultMessage: 'Assistant',
}
);
export const CONNECTOR_SETUP_TIMESTAMP_AT = i18n.translate(
'xpack.elasticAssistant.assistant.connectors.setup.timestampAtTitle',
{
defaultMessage: 'at',
}
);
export const CONNECTOR_SETUP_SKIP = i18n.translate(
'xpack.elasticAssistant.assistant.connectors.setup.skipTitle',
{
defaultMessage: 'Click to skip...',
}
);

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import type { UserProfile } from '@kbn/security-plugin/common';
import type { ServerError } from '@kbn/cases-plugin/public/types';
import { loadActionTypes } from '@kbn/triggers-actions-ui-plugin/public/common/constants';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { ActionType } from '@kbn/actions-plugin/common';
import { HttpSetup } from '@kbn/core-http-browser';
import { IToasts } from '@kbn/core-notifications-browser';
import { GeneralConnectorFeatureId } from '@kbn/actions-plugin/common';
import * as i18n from '../translations';
/**
* Cache expiration in ms -- 1 minute, useful if connector is deleted/access removed
*/
const STALE_TIME = 1000 * 60;
const QUERY_KEY = ['elastic-assistant, load-action-types'];
export interface Props {
http: HttpSetup;
toasts?: IToasts;
}
export const useLoadActionTypes = ({
http,
toasts,
}: Props): UseQueryResult<ActionType[], IHttpFetchError> => {
return useQuery(
QUERY_KEY,
async () => {
const queryResult = await loadActionTypes({ http, featureId: GeneralConnectorFeatureId });
const sortedData = queryResult.sort((a, b) => a.name.localeCompare(b.name));
return sortedData;
},
{
retry: false,
keepPreviousData: true,
staleTime: STALE_TIME,
onError: (error: ServerError) => {
if (error.name !== 'AbortError') {
toasts?.addError(
error.body && error.body.message ? new Error(error.body.message) : error,
{
title: i18n.LOAD_ACTIONS_ERROR_MESSAGE,
}
);
}
},
}
);
};
export type UseSuggestUserProfiles = UseQueryResult<UserProfile[], ServerError>;

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import type { ServerError } from '@kbn/cases-plugin/public/types';
import { loadAllActions as loadConnectors } from '@kbn/triggers-actions-ui-plugin/public/common/constants';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public';
import { HttpSetup } from '@kbn/core-http-browser';
import { IToasts } from '@kbn/core-notifications-browser';
import * as i18n from '../translations';
/**
* Cache expiration in ms -- 1 minute, useful if connector is deleted/access removed
*/
// const STALE_TIME = 1000 * 60;
const QUERY_KEY = ['elastic-assistant, load-connectors'];
export interface Props {
http: HttpSetup;
toasts?: IToasts;
}
export const useLoadConnectors = ({
http,
toasts,
}: Props): UseQueryResult<ActionConnector[], IHttpFetchError> => {
return useQuery(
QUERY_KEY,
async () => {
const queryResult = await loadConnectors({ http });
const filteredData = queryResult.filter(
(connector) => !connector.isMissingSecrets && connector.actionTypeId === '.gen-ai'
);
return filteredData;
},
{
retry: false,
keepPreviousData: true,
// staleTime: STALE_TIME,
onError: (error: ServerError) => {
if (error.name !== 'AbortError') {
toasts?.addError(
error.body && error.body.message ? new Error(error.body.message) : error,
{
title: i18n.LOAD_CONNECTORS_ERROR_MESSAGE,
}
);
}
},
}
);
};

View file

@ -0,0 +1,63 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const YOU_ARE_A_HELPFUL_EXPERT_ASSISTANT = i18n.translate(
'xpack.elasticAssistant.assistant.content.prompts.system.youAreAHelpfulExpertAssistant',
{
defaultMessage:
'You are a helpful, expert assistant who only answers questions about Elastic Security.',
}
);
export const USE_THE_FOLLOWING_CONTEXT_TO_ANSWER = i18n.translate(
'xpack.elasticAssistant.assistant.content.prompts.system.useTheFollowingContextToAnswer',
{
defaultMessage: 'Use the following context to answer questions:',
}
);
export const IF_YOU_DONT_KNOW_THE_ANSWER = i18n.translate(
'xpack.elasticAssistant.assistant.content.prompts.system.ifYouDontKnowTheAnswer',
{
defaultMessage: 'Do not answer questions unrelated to Elastic Security.',
}
);
export const SUPERHERO_PERSONALITY = i18n.translate(
'xpack.elasticAssistant.assistant.content.prompts.system.superheroPersonality',
{
defaultMessage:
'Provide the most detailed and relevant answer possible, as if you were relaying this information back to a cyber security expert.',
}
);
export const DEFAULT_SYSTEM_PROMPT_NON_I18N = `${YOU_ARE_A_HELPFUL_EXPERT_ASSISTANT} ${IF_YOU_DONT_KNOW_THE_ANSWER}
${USE_THE_FOLLOWING_CONTEXT_TO_ANSWER}`;
export const DEFAULT_SYSTEM_PROMPT_NAME = i18n.translate(
'xpack.elasticAssistant.assistant.content.prompts.system.defaultSystemPromptName',
{
defaultMessage: 'default system prompt',
}
);
export const SUPERHERO_SYSTEM_PROMPT_NON_I18N = `${YOU_ARE_A_HELPFUL_EXPERT_ASSISTANT} ${IF_YOU_DONT_KNOW_THE_ANSWER}
${SUPERHERO_PERSONALITY}
${USE_THE_FOLLOWING_CONTEXT_TO_ANSWER}`;
export const SUPERHERO_SYSTEM_PROMPT_NAME = i18n.translate(
'xpack.elasticAssistant.assistant.content.prompts.system.superheroSystemPromptName',
{
defaultMessage: 'Enhanced system prompt',
}
);
export const SYSTEM_PROMPT_CONTEXT_NON_I18N = (context: string) => {
return `CONTEXT:\n"""\n${context}\n"""`;
};

View file

@ -0,0 +1,26 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const THEN_SUMMARIZE_SUGGESTED_KQL_AND_EQL_QUERIES = i18n.translate(
'xpack.elasticAssistant.assistant.content.prompts.user.thenSummarizeSuggestedKqlAndEqlQueries',
{
defaultMessage:
'Evaluate the event from the context above and format your output neatly in markdown syntax for my Elastic Security case.',
}
);
export const FINALLY_SUGGEST_INVESTIGATION_GUIDE_AND_FORMAT_AS_MARKDOWN = i18n.translate(
'xpack.elasticAssistant.assistant.content.prompts.user.finallySuggestInvestigationGuideAndFormatAsMarkdown',
{
defaultMessage: `Add 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.`,
}
);
export const EXPLAIN_THEN_SUMMARIZE_SUGGEST_INVESTIGATION_GUIDE_NON_I18N = `${THEN_SUMMARIZE_SUGGESTED_KQL_AND_EQL_QUERIES}
${FINALLY_SUGGEST_INVESTIGATION_GUIDE_AND_FORMAT_AS_MARKDOWN}`;

View file

@ -0,0 +1,32 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const WELCOME_GENERAL = i18n.translate(
'xpack.elasticAssistant.securityAssistant.content.prompts.welcome.welcomeGeneralPrompt',
{
defaultMessage:
'Welcome to your Elastic Assistant! I am your 100% open-code portal into your Elastic life. In time, I will be able to answer questions and provide assistance across all your information in Elastic, and oh-so much more. Till then, I hope this early preview will open your mind to the possibilities of what we can create when we work together, in the open. Cheers!',
}
);
export const WELCOME_GENERAL_2 = i18n.translate(
'xpack.elasticAssistant.securityAssistant.content.prompts.welcome.welcomeGeneral2Prompt',
{
defaultMessage:
"First things first, we'll need to set up a Generative AI Connector to get this chat experience going! With the Generative AI Connector, you'll be able to configure access to either an Azure OpenAI Service or OpenAI API account, but you better believe you'll be able to deploy your own models within your Elastic Cloud instance and use those here in the future... 😉",
}
);
export const WELCOME_GENERAL_3 = i18n.translate(
'xpack.elasticAssistant.securityAssistant.content.prompts.welcome.welcomeGeneral3Prompt',
{
defaultMessage:
'Go ahead and click the add connector button below to continue the conversation!',
}
);

View file

@ -0,0 +1,24 @@
/*
* 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 { PromptContext } from '../../assistant/prompt_context/types';
export const mockAlertPromptContext: PromptContext = {
category: 'alert',
description: 'An alert pill description',
getPromptContext: () => Promise.resolve('alert data'),
id: 'mock-alert-prompt-context-1',
tooltip: 'Add this alert as context',
};
export const mockEventPromptContext: PromptContext = {
category: 'event',
description: 'An event pill description',
getPromptContext: () => Promise.resolve('event data'),
id: 'mock-event-prompt-context-1',
tooltip: 'Add this event as context',
};

View file

@ -0,0 +1,23 @@
/*
* 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 { Prompt } from '../../assistant/types';
export const mockSystemPrompt: Prompt = {
id: 'mock-system-prompt-1',
content: 'You are a helpful, expert assistant who answers questions about Elastic Security.',
name: 'Mock system prompt',
promptType: 'system',
};
export const mockSuperheroSystemPrompt: Prompt = {
id: 'mock-superhero-system-prompt-1',
content: `You are a helpful, expert assistant who answers questions about Elastic Security.
You have the personality of a mutant superhero who says "bub" a lot.`,
name: 'Mock superhero system prompt',
promptType: 'system',
};

View file

@ -0,0 +1,51 @@
/*
* 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 { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { I18nProvider } from '@kbn/i18n-react';
import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock';
import { euiDarkVars } from '@kbn/ui-theme';
import React from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
import { ThemeProvider } from 'styled-components';
import { AssistantProvider } from '../../assistant_context';
interface Props {
children: React.ReactNode;
}
window.scrollTo = jest.fn();
/** A utility for wrapping children in the providers required to run tests */
export const TestProvidersComponent: React.FC<Props> = ({ children }) => {
const actionTypeRegistry = actionTypeRegistryMock.create();
const mockGetInitialConversations = jest.fn(() => ({}));
const mockGetComments = jest.fn(() => []);
const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' });
return (
<I18nProvider>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<AssistantProvider
actionTypeRegistry={actionTypeRegistry}
augmentMessageCodeBlocks={jest.fn()}
getComments={mockGetComments}
getInitialConversations={mockGetInitialConversations}
setConversations={jest.fn()}
http={mockHttp}
>
{children}
</AssistantProvider>
</ThemeProvider>
</I18nProvider>
);
};
TestProvidersComponent.displayName = 'TestProvidersComponent';
export const TestProviders = React.memo(TestProvidersComponent);

View file

@ -0,0 +1,16 @@
/*
* 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 { Prompt } from '../../assistant/types';
export const mockUserPrompt: Prompt = {
id: 'mock-user-prompt-1',
content: `Explain the meaning from the context above, then summarize a list of suggested Elasticsearch KQL and EQL queries.
Finally, suggest an investigation guide, and format it as markdown.`,
name: 'Mock user prompt',
promptType: 'user',
};

View file

@ -0,0 +1,82 @@
/*
* 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, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Props, NewChat } from '.';
const mockUseAssistantOverlay = {
showAssistantOverlay: jest.fn(),
};
jest.mock('../assistant/use_assistant_overlay', () => ({
useAssistantOverlay: () => mockUseAssistantOverlay,
}));
const defaultProps: Props = {
category: 'alert',
description: 'Test description',
getPromptContext: () => Promise.resolve('Test prompt context'),
tooltip: 'Test tooltip',
};
describe('NewChat', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('renders the default New Chat button with a discuss icon', () => {
render(<NewChat {...defaultProps} />);
const newChatButton = screen.getByTestId('newChat');
expect(newChatButton.querySelector('[data-euiicon-type="discuss"]')).toBeInTheDocument();
});
it('renders the default "New Chat" text when children are NOT provided', () => {
render(<NewChat {...defaultProps} />);
const newChatButton = screen.getByTestId('newChat');
expect(newChatButton.textContent).toContain('New chat');
});
it('renders custom children', () => {
render(<NewChat {...defaultProps}>{'🪄✨'}</NewChat>);
const newChatButton = screen.getByTestId('newChat');
expect(newChatButton.textContent).toContain('🪄✨');
});
it('renders custom icons', () => {
render(<NewChat {...defaultProps} iconType="help" />);
const newChatButton = screen.getByTestId('newChat');
expect(newChatButton.querySelector('[data-euiicon-type="help"]')).toBeInTheDocument();
});
it('does NOT render an icon when iconType is null', () => {
render(<NewChat {...defaultProps} iconType={null} />);
const newChatButton = screen.getByTestId('newChat');
expect(newChatButton.querySelector('.euiButtonContent__icon')).not.toBeInTheDocument();
});
it('calls showAssistantOverlay on click', () => {
render(<NewChat {...defaultProps} />);
const newChatButton = screen.getByTestId('newChat');
userEvent.click(newChatButton);
expect(mockUseAssistantOverlay.showAssistantOverlay).toHaveBeenCalledWith(true);
});
});

View file

@ -0,0 +1,79 @@
/*
* 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 { EuiButtonEmpty } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { PromptContext } from '../assistant/prompt_context/types';
import { useAssistantOverlay } from '../assistant/use_assistant_overlay';
import * as i18n from './translations';
export type Props = Omit<PromptContext, 'id'> & {
children?: React.ReactNode;
/** Optionally automatically add this context to a conversation when the assistant is shown */
conversationId?: string;
/** Defaults to `discuss`. If null, the button will not have an icon */
iconType?: string | null;
/** Optionally specify a well known ID, or default to a UUID */
promptContextId?: string;
};
const NewChatComponent: React.FC<Props> = ({
category,
children = i18n.NEW_CHAT,
conversationId,
description,
getPromptContext,
iconType,
promptContextId,
suggestedUserPrompt,
tooltip,
}) => {
const { showAssistantOverlay } = useAssistantOverlay(
category,
conversationId ?? null,
description,
getPromptContext,
promptContextId ?? null,
suggestedUserPrompt,
tooltip
);
const showOverlay = useCallback(() => {
showAssistantOverlay(true);
}, [showAssistantOverlay]);
const icon = useMemo(() => {
if (iconType === null) {
return undefined;
}
return iconType ?? 'discuss';
}, [iconType]);
return useMemo(
() => (
<EuiButtonEmpty data-test-subj="newChat" onClick={showOverlay} iconType={icon}>
{children}
</EuiButtonEmpty>
),
[children, icon, showOverlay]
);
};
NewChatComponent.displayName = 'NewChatComponent';
/**
* `NewChat` displays a _New chat_ icon button, providing all the context
* necessary to start a new chat. You may optionally style the button icon,
* or override the default _New chat_ text with custom content, like `🪄✨`
*
* USE THIS WHEN: All the data necessary to start a new chat is available
* in the same part of the React tree as the _New chat_ button.
*/
export const NewChat = React.memo(NewChatComponent);

View file

@ -0,0 +1,12 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const NEW_CHAT = i18n.translate('xpack.elasticAssistant.assistant.newChat.newChatButton', {
defaultMessage: 'New chat',
});

View file

@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NewChatById } from '.';
const mockUseAssistantContext = {
showAssistantOverlay: jest.fn(),
};
jest.mock('../assistant_context', () => ({
useAssistantContext: () => mockUseAssistantContext,
}));
describe('NewChatById', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('renders the default New Chat button with a discuss icon', () => {
render(<NewChatById />);
const newChatButton = screen.getByTestId('newChatById');
expect(newChatButton.querySelector('[data-euiicon-type="discuss"]')).toBeInTheDocument();
});
it('renders the default "New Chat" text when children are NOT provided', () => {
render(<NewChatById />);
const newChatButton = screen.getByTestId('newChatById');
expect(newChatButton.textContent).toContain('New chat');
});
it('renders custom children', async () => {
render(<NewChatById>{'🪄✨'}</NewChatById>);
const newChatButton = screen.getByTestId('newChatById');
expect(newChatButton.textContent).toContain('🪄✨');
});
it('renders custom icons', async () => {
render(<NewChatById iconType="help" />);
const newChatButton = screen.getByTestId('newChatById');
expect(newChatButton.querySelector('[data-euiicon-type="help"]')).toBeInTheDocument();
});
it('does NOT render an icon when iconType is null', () => {
render(<NewChatById iconType={null} />);
const newChatButton = screen.getByTestId('newChatById');
expect(newChatButton.querySelector('.euiButtonContent__icon')).not.toBeInTheDocument();
});
it('calls showAssistantOverlay on click', () => {
const conversationId = 'test-conversation-id';
const promptContextId = 'test-prompt-context-id';
render(<NewChatById conversationId={conversationId} promptContextId={promptContextId} />);
const newChatButton = screen.getByTestId('newChatById');
userEvent.click(newChatButton);
expect(mockUseAssistantContext.showAssistantOverlay).toHaveBeenCalledWith({
conversationId,
promptContextId,
showOverlay: true,
});
});
});

View file

@ -0,0 +1,76 @@
/*
* 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 { EuiButtonEmpty } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { useAssistantContext } from '../assistant_context';
import * as i18n from './translations';
export interface Props {
children?: React.ReactNode;
/** Optionally automatically add this context to a conversation when the assistant is shown */
conversationId?: string;
/** Defaults to `discuss`. If null, the button will not have an icon */
iconType?: string | null;
/** Optionally specify a well known ID, or default to a UUID */
promptContextId?: string;
}
const NewChatByIdComponent: React.FC<Props> = ({
children = i18n.NEW_CHAT,
conversationId,
iconType,
promptContextId,
}) => {
const { showAssistantOverlay } = useAssistantContext();
// proxy show / hide calls to assistant context, using our internal prompt context id:
const showOverlay = useCallback(
() =>
showAssistantOverlay({
conversationId,
promptContextId,
showOverlay: true,
}),
[conversationId, promptContextId, showAssistantOverlay]
);
const icon = useMemo(() => {
if (iconType === null) {
return undefined;
}
return iconType ?? 'discuss';
}, [iconType]);
return useMemo(
() => (
<EuiButtonEmpty data-test-subj="newChatById" iconType={icon} onClick={showOverlay}>
{children}
</EuiButtonEmpty>
),
[children, icon, showOverlay]
);
};
NewChatByIdComponent.displayName = 'NewChatByIdComponent';
/**
* `NewChatByID` displays a _New chat_ icon button by providing only the `promptContextId`
* of a context that was (already) registered by the `useAssistantOverlay` hook. You may
* optionally style the button icon, or override the default _New chat_ text with custom
* content, like {'🪄✨'}
*
* USE THIS WHEN: all the data necessary to start a new chat is NOT available
* in the same part of the React tree as the _New chat_ button. When paired
* with the `useAssistantOverlay` hook, this option enables context to be be
* registered where the data is available, and then the _New chat_ button can be displayed
* in another part of the tree.
*/
export const NewChatById = React.memo(NewChatByIdComponent);

View file

@ -0,0 +1,15 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const NEW_CHAT = i18n.translate(
'xpack.elasticAssistant.assistant.newChatById.newChatByIdButton',
{
defaultMessage: 'New chat',
}
);

View file

@ -0,0 +1,120 @@
/*
* 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.
*/
// To integrate the assistant into a Kibana app, perform the following three steps:
// Step 1: Wrap your Kibana app in the `AssistantProvider` component. This typically
// happens in the root of your app. Optionally provide a custom title for the assistant:
/** provides context (from the app) to the assistant, and injects Kibana services, like `http` */
export { AssistantProvider } from './impl/assistant_context';
// Step 2: Add the `AssistantOverlay` component to your app. This component displays the assistant
// overlay in a modal, bound to a shortcut key:
/** modal overlay for Elastic Assistant conversations */
export { AssistantOverlay } from './impl/assistant/assistant_overlay';
// In addition to the `AssistantOverlay`, or as an alternative, you may use the `Assistant` component
// to display the assistant without the modal overlay:
/** this component renders the Assistant without the modal overlay to, for example, render it in a Timeline tab */
export { Assistant } from './impl/assistant';
// Step 3: Wherever you want to bring context into the assistant, use the any combination of the following
// components and hooks:
// - `NewChat` component
// - `NewChatById` component
// - `useAssistantOverlay` hook
/**
* `NewChat` displays a _New chat_ icon button, providing all the context
* necessary to start a new chat. You may optionally style the button icon,
* or override the default _New chat_ text with custom content, like `🪄✨`
*
* USE THIS WHEN: All the data necessary to start a new chat is available
* in the same part of the React tree as the _New chat_ button.
*/
export { NewChat } from './impl/new_chat';
/**
* `NewChatByID` displays a _New chat_ icon button by providing only the `promptContextId`
* of a context that was (already) registered by the `useAssistantOverlay` hook. You may
* optionally style the button icon, or override the default _New chat_ text with custom
* content, like {'🪄✨'}
*
* USE THIS WHEN: all the data necessary to start a new chat is NOT available
* in the same part of the React tree as the _New chat_ button. When paired
* with the `useAssistantOverlay` hook, this option enables context to be be
* registered where the data is available, and then the _New chat_ button can be displayed
* in another part of the tree.
*/
export { NewChatById } from './impl/new_chat_by_id';
/**
* `useAssistantOverlay` is a hook that registers context with the assistant overlay, and
* returns an optional `showAssistantOverlay` function to display the assistant overlay.
* As an alterative to using the `showAssistantOverlay` returned from this hook, you may
* use the `NewChatById` component and pass it the `promptContextId` returned by this hook.
*
* USE THIS WHEN: You want to register context in one part of the tree, and then show
* a _New chat_ button in another part of the tree without passing around the data, or when
* you want to build a custom `New chat` button with features not not provided by the
* `NewChat` component.
*/
export { useAssistantOverlay } from './impl/assistant/use_assistant_overlay';
/** a helper that enriches content returned from a query with action buttons */
export { analyzeMarkdown } from './impl/assistant/use_conversation/helpers';
// Sample content is exported with the following:
/** sample content */
export { BASE_CONVERSATIONS } from './impl/assistant/use_conversation/sample_conversations';
/** i18n translations of system prompts */
export * as SYSTEM_PROMPTS from './impl/content/prompts/system/translations';
/** i18n translations of user prompts */
export * as USER_PROMPTS from './impl/content/prompts/user/translations';
export type {
/** for rendering results in a code block */
CodeBlockDetails,
/** the type of query that will be executed for a code block */
QueryType,
} from './impl/assistant/use_conversation/helpers';
/** serialized conversations */
export type { Conversation, Message } from './impl/assistant_context/types';
/**
* This interface is used to pass context to the assistant,
* for the purpose of building prompts. Examples of context include:
* - a single alert
* - multiple alerts
* - a single event
* - multiple events
* - markdown
* - csv
* - anything else that the LLM can interpret
*/
export type { PromptContext } from './impl/assistant/prompt_context/types';
/**
* This interface is used to pass a default or base set of contexts to the Elastic Assistant when
* initializing it. This is used to provide 'category' options when users create Quick Prompts.
* Also, useful for collating all of a solutions' prompts in one place.
*
* e.g. see Security Solution's x-pack/plugins/security_solution/public/assistant/prompt_contexts/index.tsx
*/
export type { PromptContextTemplate } from './impl/assistant/prompt_context/types';
/**
* This interface is used to pass a default or base set of Quick Prompts to the Elastic Assistant that
* can be displayed when corresponding PromptContext's are registered.
*/
export type { QuickPrompt } from './impl/assistant/quick_prompts/types';

View file

@ -0,0 +1,23 @@
/*
* 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.
*/
module.exports = {
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/packages/kbn_elastic_assistant_impl',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/packages/kbn-elastic-assistant/impl/**/*.{ts,tsx}',
'!<rootDir>/x-pack/packages/kbn-elastic-assistant/impl/{__test__,__snapshots__,__examples__,*mock*,tests,test_helpers,integration_tests,types}/**/*',
'!<rootDir>/x-pack/packages/kbn-elastic-assistant/impl/*mock*.{ts,tsx}',
'!<rootDir>/x-pack/packages/kbn-elastic-assistant/impl/*.test.{ts,tsx}',
'!<rootDir>/x-pack/packages/kbn-elastic-assistant/impl/*.d.ts',
'!<rootDir>/x-pack/packages/kbn-elastic-assistant/impl/*.config.ts',
],
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/x-pack/packages/kbn-elastic-assistant'],
};

View file

@ -0,0 +1,5 @@
{
"id": "@kbn/elastic-assistant",
"owner": "@elastic/security-solution",
"type": "shared-browser"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/elastic-assistant",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0"
}

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
// eslint-disable-next-line import/no-extraneous-dependencies
import '@testing-library/jest-dom';

View file

@ -0,0 +1,31 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/core-http-browser",
"@kbn/i18n",
"@kbn/stack-connectors-plugin",
"@kbn/triggers-actions-ui-plugin",
"@kbn/core-http-browser-mocks",
"@kbn/security-plugin",
"@kbn/cases-plugin",
"@kbn/actions-plugin",
"@kbn/core-notifications-browser",
"@kbn/i18n-react",
"@kbn/ui-theme",
]
}

View file

@ -55,6 +55,7 @@ const defaultProps: Props = {
formatNumber,
getGroupByFieldsOnClick: jest.fn(),
ilmPhases,
isAssistantEnabled: true,
openCreateCaseFlyout: jest.fn(),
patternIndexNames,
patternRollups,

View file

@ -41,6 +41,7 @@ export interface Props {
groupByField1: string;
};
ilmPhases: string[];
isAssistantEnabled: boolean;
openCreateCaseFlyout: ({
comments,
headerContent,
@ -69,6 +70,7 @@ const DataQualityDetailsComponent: React.FC<Props> = ({
formatNumber,
getGroupByFieldsOnClick,
ilmPhases,
isAssistantEnabled,
openCreateCaseFlyout,
patternIndexNames,
patternRollups,
@ -105,6 +107,7 @@ const DataQualityDetailsComponent: React.FC<Props> = ({
formatNumber={formatNumber}
getGroupByFieldsOnClick={getGroupByFieldsOnClick}
ilmPhases={ilmPhases}
isAssistantEnabled={isAssistantEnabled}
openCreateCaseFlyout={openCreateCaseFlyout}
patterns={patterns}
theme={theme}

View file

@ -55,6 +55,7 @@ const defaultProps: Props = {
formatNumber,
getGroupByFieldsOnClick: jest.fn(),
ilmPhases,
isAssistantEnabled: true,
openCreateCaseFlyout: jest.fn(),
patternIndexNames,
patternRollups,

View file

@ -39,6 +39,7 @@ export interface Props {
groupByField1: string;
};
ilmPhases: string[];
isAssistantEnabled: boolean;
openCreateCaseFlyout: ({
comments,
headerContent,
@ -69,6 +70,7 @@ const IndicesDetailsComponent: React.FC<Props> = ({
formatNumber,
getGroupByFieldsOnClick,
ilmPhases,
isAssistantEnabled,
openCreateCaseFlyout,
patternIndexNames,
patternRollups,
@ -90,6 +92,7 @@ const IndicesDetailsComponent: React.FC<Props> = ({
getGroupByFieldsOnClick={getGroupByFieldsOnClick}
ilmPhases={ilmPhases}
indexNames={patternIndexNames[pattern]}
isAssistantEnabled={isAssistantEnabled}
openCreateCaseFlyout={openCreateCaseFlyout}
pattern={pattern}
patternRollup={patternRollups[pattern]}

View file

@ -35,6 +35,7 @@ describe('IndexInvalidValues', () => {
formatNumber={formatNumber}
getGroupByFieldsOnClick={jest.fn()}
ilmPhases={[]}
isAssistantEnabled={true}
lastChecked={''}
openCreateCaseFlyout={jest.fn()}
patterns={[]}
@ -63,6 +64,7 @@ describe('IndexInvalidValues', () => {
formatNumber={formatNumber}
getGroupByFieldsOnClick={jest.fn()}
ilmPhases={ilmPhases}
isAssistantEnabled={true}
lastChecked={''}
openCreateCaseFlyout={jest.fn()}
patterns={patterns}
@ -88,6 +90,7 @@ describe('IndexInvalidValues', () => {
formatNumber={formatNumber}
getGroupByFieldsOnClick={jest.fn()}
ilmPhases={ilmPhases}
isAssistantEnabled={true}
lastChecked={''}
openCreateCaseFlyout={jest.fn()}
patterns={patterns}

View file

@ -40,6 +40,7 @@ interface Props {
groupByField1: string;
};
ilmPhases: string[];
isAssistantEnabled: boolean;
lastChecked: string;
openCreateCaseFlyout: ({
comments,
@ -60,6 +61,7 @@ const BodyComponent: React.FC<Props> = ({
formatNumber,
getGroupByFieldsOnClick,
ilmPhases,
isAssistantEnabled,
lastChecked,
openCreateCaseFlyout,
patterns,
@ -112,6 +114,7 @@ const BodyComponent: React.FC<Props> = ({
formatNumber={formatNumber}
getGroupByFieldsOnClick={getGroupByFieldsOnClick}
ilmPhases={ilmPhases}
isAssistantEnabled={isAssistantEnabled}
openCreateCaseFlyout={openCreateCaseFlyout}
patterns={patterns}
patternIndexNames={patternIndexNames}

View file

@ -111,6 +111,7 @@ const defaultProps: Props = {
getGroupByFieldsOnClick: jest.fn(),
ilmPhase: 'hot',
indexName: 'auditbeat-custom-index-1',
isAssistantEnabled: true,
openCreateCaseFlyout: jest.fn(),
pattern,
patternRollup,

View file

@ -60,6 +60,7 @@ export interface Props {
};
ilmPhase: IlmPhase | undefined;
indexName: string;
isAssistantEnabled: boolean;
openCreateCaseFlyout: ({
comments,
headerContent,
@ -82,6 +83,7 @@ const IndexPropertiesComponent: React.FC<Props> = ({
getGroupByFieldsOnClick,
ilmPhase,
indexName,
isAssistantEnabled,
openCreateCaseFlyout,
pattern,
patternRollup,
@ -143,6 +145,7 @@ const IndexPropertiesComponent: React.FC<Props> = ({
docsCount,
getGroupByFieldsOnClick,
ilmPhase,
isAssistantEnabled,
indexName,
onAddToNewCase,
partitionedFieldMetadata: partitionedFieldMetadata ?? EMPTY_METADATA,
@ -161,6 +164,7 @@ const IndexPropertiesComponent: React.FC<Props> = ({
getGroupByFieldsOnClick,
ilmPhase,
indexName,
isAssistantEnabled,
onAddToNewCase,
partitionedFieldMetadata,
pattern,

View file

@ -46,6 +46,7 @@ const defaultProps = {
getGroupByFieldsOnClick: jest.fn(),
ilmPhases: ['hot', 'warm', 'unmanaged'],
indexNames: undefined,
isAssistantEnabled: true,
openCreateCaseFlyout: jest.fn(),
patternRollup: undefined,
selectedIndex: null,

View file

@ -76,6 +76,7 @@ interface Props {
};
ilmPhases: string[];
indexNames: string[] | undefined;
isAssistantEnabled: boolean;
openCreateCaseFlyout: ({
comments,
headerContent,
@ -106,6 +107,7 @@ const PatternComponent: React.FC<Props> = ({
getGroupByFieldsOnClick,
indexNames,
ilmPhases,
isAssistantEnabled,
openCreateCaseFlyout,
pattern,
patternRollup,
@ -150,6 +152,7 @@ const PatternComponent: React.FC<Props> = ({
getGroupByFieldsOnClick={getGroupByFieldsOnClick}
ilmPhase={ilmExplain != null ? getIlmPhase(ilmExplain[indexName]) : undefined}
indexName={indexName}
isAssistantEnabled={isAssistantEnabled}
openCreateCaseFlyout={openCreateCaseFlyout}
pattern={pattern}
patternRollup={patternRollup}
@ -168,6 +171,7 @@ const PatternComponent: React.FC<Props> = ({
formatNumber,
getGroupByFieldsOnClick,
ilmExplain,
isAssistantEnabled,
itemIdToExpandedRowMap,
openCreateCaseFlyout,
pattern,

View file

@ -77,6 +77,7 @@ describe('helpers', () => {
getGroupByFieldsOnClick: jest.fn(),
ilmPhase: 'unmanaged',
indexName: 'auditbeat-custom-index-1',
isAssistantEnabled: true,
onAddToNewCase: jest.fn(),
partitionedFieldMetadata: mockPartitionedFieldMetadata,
pattern: 'auditbeat-*',

View file

@ -66,6 +66,7 @@ export const getTabs = ({
getGroupByFieldsOnClick,
ilmPhase,
indexName,
isAssistantEnabled,
onAddToNewCase,
partitionedFieldMetadata,
pattern,
@ -94,6 +95,7 @@ export const getTabs = ({
};
ilmPhase: IlmPhase | undefined;
indexName: string;
isAssistantEnabled: boolean;
onAddToNewCase: (markdownComments: string[]) => void;
partitionedFieldMetadata: PartitionedFieldMetadata;
pattern: string;
@ -113,6 +115,7 @@ export const getTabs = ({
getGroupByFieldsOnClick={getGroupByFieldsOnClick}
ilmPhase={ilmPhase}
indexName={indexName}
isAssistantEnabled={isAssistantEnabled}
onAddToNewCase={onAddToNewCase}
partitionedFieldMetadata={partitionedFieldMetadata}
pattern={pattern}
@ -140,6 +143,7 @@ export const getTabs = ({
formatNumber={formatNumber}
ilmPhase={ilmPhase}
indexName={indexName}
isAssistantEnabled={isAssistantEnabled}
onAddToNewCase={onAddToNewCase}
partitionedFieldMetadata={partitionedFieldMetadata}
patternDocsCount={patternDocsCount}

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { NewChat } from '@kbn/elastic-assistant';
import {
copyToClipboard,
EuiButton,
@ -33,8 +34,14 @@ import {
INCOMPATIBLE_FIELD_MAPPINGS_TABLE_TITLE,
INCOMPATIBLE_FIELD_VALUES_TABLE_TITLE,
} from './translations';
import { COPIED_RESULTS_TOAST_TITLE } from '../../../translations';
import {
COPIED_RESULTS_TOAST_TITLE,
DATA_QUALITY_PROMPT_CONTEXT_PILL,
DATA_QUALITY_PROMPT_CONTEXT_PILL_TOOLTIP,
DATA_QUALITY_SUGGESTED_USER_PROMPT,
} from '../../../translations';
import type { IlmPhase, PartitionedFieldMetadata } from '../../../types';
import { DATA_QUALITY_DASHBOARD_CONVERSATION_ID } from '../summary_tab/callout_summary/translations';
interface Props {
addSuccessToast: (toast: { title: string }) => void;
@ -44,6 +51,7 @@ interface Props {
formatNumber: (value: number | undefined) => string;
ilmPhase: IlmPhase | undefined;
indexName: string;
isAssistantEnabled: boolean;
onAddToNewCase: (markdownComments: string[]) => void;
partitionedFieldMetadata: PartitionedFieldMetadata;
patternDocsCount: number;
@ -58,6 +66,7 @@ const IncompatibleTabComponent: React.FC<Props> = ({
formatNumber,
ilmPhase,
indexName,
isAssistantEnabled,
onAddToNewCase,
partitionedFieldMetadata,
patternDocsCount,
@ -108,6 +117,8 @@ const IncompatibleTabComponent: React.FC<Props> = ({
});
}, [addSuccessToast, markdownComments]);
const getPromptContext = useCallback(async () => markdownComments.join('\n'), [markdownComments]);
return (
<div data-test-subj="incompatibleTab">
{showInvalidCallout(partitionedFieldMetadata.incompatible) ? (
@ -129,6 +140,19 @@ const IncompatibleTabComponent: React.FC<Props> = ({
{i18n.COPY_TO_CLIPBOARD}
</CopyToClipboardButton>
</EuiFlexItem>
{isAssistantEnabled && (
<EuiFlexItem grow={false}>
<NewChat
category="data-quality-dashboard"
conversationId={DATA_QUALITY_DASHBOARD_CONVERSATION_ID}
description={DATA_QUALITY_PROMPT_CONTEXT_PILL(indexName)}
getPromptContext={getPromptContext}
suggestedUserPrompt={DATA_QUALITY_SUGGESTED_USER_PROMPT}
tooltip={DATA_QUALITY_PROMPT_CONTEXT_PILL_TOOLTIP}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</IncompatibleCallout>

Some files were not shown because too many files have changed in this diff Show more