diff --git a/.eslintrc.js b/.eslintrc.js index 0dfe1901290fe59..bc2816e8406814c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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}', diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4146a63d6829c17..d3e11c9a85a8793 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/package.json b/package.json index 1403da10f6ed8ce..56590da62470524 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/kbn-babel-preset/styled_components_files.js b/packages/kbn-babel-preset/styled_components_files.js index 7c164cbc2edc192..125f859be347b4a 100644 --- a/packages/kbn-babel-preset/styled_components_files.js +++ b/packages/kbn-babel-preset/styled_components_files.js @@ -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[\/\\]/, ], }; diff --git a/tsconfig.base.json b/tsconfig.base.json index 43d7d7fb421bf6d..59fe2db4e956571 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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"], diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 4a3b06e8e4a1ebd..a05ec32a0b11354 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -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", diff --git a/x-pack/packages/kbn-elastic-assistant/README.md b/x-pack/packages/kbn-elastic-assistant/README.md new file mode 100644 index 000000000000000..0d0a848e536c7f4 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/README.md @@ -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 +``` diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx new file mode 100644 index 000000000000000..798da471e32d8c9 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { 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 => { + 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( + `/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; + } +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx new file mode 100644 index 000000000000000..ed8ae782946b48f --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx @@ -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( + WELCOME_CONVERSATION_TITLE + ); + const [promptContextId, setPromptContextId] = useState(); + 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 && ( + + + + )} + + ); +}); + +AssistantOverlay.displayName = 'AssistantOverlay'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/translations.ts new file mode 100644 index 000000000000000..086f87147287c9c --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/translations.ts @@ -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', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/context_pills/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/context_pills/index.test.tsx new file mode 100644 index 000000000000000..75e3d4e015d45b4 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/context_pills/index.test.tsx @@ -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 = { + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + // 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( + + + + ); + + expect(screen.getByTestId(`pillButton-${context.id}`)).toBeDisabled(); + }); + + it("does NOT disable context pills that aren't selected", () => { + const context = mockPromptContexts.context1; + + render( + + + + ); + + expect(screen.getByTestId(`pillButton-${context.id}`)).not.toBeDisabled(); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/context_pills/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/context_pills/index.tsx new file mode 100644 index 000000000000000..a8522a75b5adb8e --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/context_pills/index.tsx @@ -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; + selectedPromptContextIds: string[]; + setSelectedPromptContextIds: React.Dispatch>; +} + +const ContextPillsComponent: React.FC = ({ + 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 ( + + {sortedPromptContexts.map(({ description, id, getPromptContext, tooltip }) => ( + + + selectPromptContext(id)} + > + {description} + + + + ))} + + ); +}; + +export const ContextPills = React.memo(ContextPillsComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversation_selector/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversation_selector/index.tsx new file mode 100644 index 000000000000000..f7af69f42cd8292 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversation_selector/index.tsx @@ -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 = React.memo( + ({ + conversationId = DEFAULT_CONVERSATION_TITLE, + onSelectionChange, + shouldDisableKeyboardShortcut = () => false, + isDisabled = false, + }) => { + const [selectedConversationId, setSelectedConversationId] = useState(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 ( + + + + + } + append={ + + + + } + /> + + ); + } +); + +ConversationSelector.displayName = 'ConversationSelector'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversation_selector/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversation_selector/translations.ts new file mode 100644 index 000000000000000..96f9ce12ce31581 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversation_selector/translations.ts @@ -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', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.ts new file mode 100644 index 000000000000000..8cf798660004136 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.ts @@ -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, + }; + } +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx new file mode 100644 index 000000000000000..3a7ff3fb85c6af6 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx @@ -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 = ({ + promptContextId = '', + showTitle = true, + conversationId = WELCOME_CONVERSATION_TITLE, + shouldRefocusPrompt = false, +}) => { + const { + actionTypeRegistry, + augmentMessageCodeBlocks, + conversations, + getComments, + http, + promptContexts, + title, + } = useAssistantContext(); + const [selectedPromptContextIds, setSelectedPromptContextIds] = useState([]); + + const { appendMessage, clearConversation, createConversation } = useConversation(); + const { isLoading, sendMessages } = useSendMessages(); + + const [selectedConversationId, setSelectedConversationId] = useState(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(null); + const lastCommentRef = useRef(null); + + const [promptTextPreview, setPromptTextPreview] = useState(''); + const [systemPrompts] = useState([getDefaultSystemPrompt(), getSuperheroPrompt()]); + const [selectedSystemPromptId, setSelectedSystemPromptId] = useState( + getDefaultSystemPrompt().id + ); + const [autoPopulatedOnce, setAutoPopulatedOnce] = useState(false); + const [suggestedUserPrompt, setSuggestedUserPrompt] = useState(null); + + const [showMissingConnectorCallout, setShowMissingConnectorCallout] = useState(false); + + const [messageCodeBlocks, setMessageCodeBlocks] = useState( + 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(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 ( + + + {showTitle && ( + <> + + + + + + + + + {currentTitle.title} + + + + + + setSelectedConversationId(id)} + shouldDisableKeyboardShortcut={shouldDisableConversationSelectorHotkeys} + isDisabled={isWelcomeSetup} + /> + + + + {!isWelcomeSetup && showMissingConnectorCallout && ( + <> + +

{i18n.MISSING_CONNECTOR_CALLOUT_DESCRIPTION}

+
+ + + )} + + )} + + {/* 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 && ( + <> + + {Object.keys(promptContexts).length > 0 && } + + )} + + {isWelcomeSetup && ( + + )} + + {!isWelcomeSetup && ( + + <> + +
+ + + + {(currentConversation.messages.length === 0 || + selectedPromptContextIds.length > 0) && ( + + )} + + + )} + + + + + + + + + + + + + { + setPromptTextPreview(''); + clearConversation(selectedConversationId); + setSelectedSystemPromptId(getDefaultSystemPrompt().id); + setSelectedPromptContextIds([]); + setSuggestedUserPrompt(''); + }} + /> + + + + + + + + + + + + + + + {!isWelcomeSetup && ( + + + + )} + + ); +}; + +AssistantComponent.displayName = 'AssistantComponent'; + +export const Assistant = React.memo(AssistantComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.test.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.test.ts new file mode 100644 index 000000000000000..7dee322ebc570d1 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.test.ts @@ -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', + }); + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts new file mode 100644 index 000000000000000..40e5b41805af0db --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts @@ -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; + promptText: string; + selectedPromptContextIds: string[]; + selectedSystemPrompt: Prompt | undefined; +}): Promise { + const selectedPromptContexts = selectedPromptContextIds.reduce((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', +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_context/types.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_context/types.ts new file mode 100644 index 000000000000000..9c9b807281665a9 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_context/types.ts @@ -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; + + /** + * 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; + +export type UnRegisterPromptContext = (promptContextId: string) => void; + +export type RegisterPromptContext = (promptContext: PromptContext) => void; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/helpers.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/helpers.test.tsx new file mode 100644 index 000000000000000..c055d9bd6bb956b --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/helpers.test.tsx @@ -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(); + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/helpers.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/helpers.tsx new file mode 100644 index 000000000000000..7b3e91147635f74 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/helpers.tsx @@ -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); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.test.tsx new file mode 100644 index 000000000000000..b9e7bb513c03f78 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.test.tsx @@ -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( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('selectSystemPrompt')).toBeInTheDocument(); + }); + }); + + it('does NOT render the system prompt selector when isNewConversation is false', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.queryByTestId('selectSystemPrompt')).not.toBeInTheDocument(); + }); + }); + + it('renders the selected prompt contexts', async () => { + const selectedPromptContextIds = [mockAlertPromptContext.id, mockEventPromptContext.id]; + + render( + + + + ); + + await waitFor(() => { + selectedPromptContextIds.forEach((id) => + expect(screen.queryByTestId(`selectedPromptContext-${id}`)).toBeInTheDocument() + ); + }); + }); + + it('renders the expected preview text', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('previewText')).toHaveTextContent('Preview text'); + }); + }); + + it('renders an "editing prompt" `EuiComment` event', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('eventText')).toHaveTextContent('editing prompt'); + }); + }); + + it('renders the user avatar', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('userAvatar')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.tsx new file mode 100644 index 000000000000000..16e62c44c3a00b2 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.tsx @@ -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; + promptTextPreview: string; + selectedPromptContextIds: string[]; + selectedSystemPromptId: string | null; + setSelectedPromptContextIds: React.Dispatch>; + setSelectedSystemPromptId: React.Dispatch>; + systemPrompts: Prompt[]; +} + +const PreviewText = styled(EuiText)` + white-space: pre-line; +`; + +const PromptEditorComponent: React.FC = ({ + isNewConversation, + promptContexts, + promptTextPreview, + selectedPromptContextIds, + selectedSystemPromptId, + setSelectedPromptContextIds, + setSelectedSystemPromptId, + systemPrompts, +}) => { + const commentBody = useMemo( + () => ( + <> + {isNewConversation && ( + + )} + + + + + {promptTextPreview} + + + ), + [ + isNewConversation, + promptContexts, + promptTextPreview, + selectedPromptContextIds, + selectedSystemPromptId, + setSelectedPromptContextIds, + setSelectedSystemPromptId, + systemPrompts, + ] + ); + + const comments = useMemo( + () => [ + { + children: commentBody, + event: ( + + {i18n.EDITING_PROMPT} + + ), + timelineAvatar: ( + + ), + timelineAvatarAriaLabel: i18n.YOU, + username: i18n.YOU, + }, + ], + [commentBody] + ); + + return ; +}; + +PromptEditorComponent.displayName = 'PromptEditorComponent'; + +export const PromptEditor = React.memo(PromptEditorComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/selected_prompt_contexts/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/selected_prompt_contexts/index.test.tsx new file mode 100644 index 000000000000000..4ab4b708a68c8f8 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/selected_prompt_contexts/index.test.tsx @@ -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( + + + + ); + + 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( + + + + ); + + await waitFor(() => { + expect(screen.queryByTestId('spacer')).not.toBeInTheDocument(); + }); + }); + + it('it renders a spacer when isNewConversation is true and selectedPromptContextIds.length is 1', async () => { + render( + + + + ); + + 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( + + + + ); + + await waitFor(() => { + expect(screen.getAllByTestId('spacer')).toHaveLength(2); + }); + }); + + it('renders the selected prompt contexts', async () => { + const selectedPromptContextIds = [mockAlertPromptContext.id, mockEventPromptContext.id]; + + render( + + + + ); + + 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( + + ); + + userEvent.click(screen.getByTestId(`removePromptContext-${promptContextId}`)); + + await waitFor(() => { + expect(setSelectedPromptContextIds).toHaveBeenCalled(); + }); + }); + + it('displays the correct accordion content', async () => { + render( + + ); + + userEvent.click(screen.getByText(mockAlertPromptContext.description)); + + const codeBlock = screen.getByTestId('promptCodeBlock'); + + await waitFor(() => { + expect(codeBlock).toHaveTextContent('alert data'); + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/selected_prompt_contexts/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/selected_prompt_contexts/index.tsx new file mode 100644 index 000000000000000..eca303284d1a863 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/selected_prompt_contexts/index.tsx @@ -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; + selectedPromptContextIds: string[]; + setSelectedPromptContextIds: React.Dispatch>; +} + +const SelectedPromptContextsComponent: React.FC = ({ + isNewConversation, + promptContexts, + selectedPromptContextIds, + setSelectedPromptContextIds, +}) => { + const selectedPromptContexts = useMemo( + () => selectedPromptContextIds.map((id) => promptContexts[id]), + [promptContexts, selectedPromptContextIds] + ); + + const [accordionContent, setAccordionContent] = useState>({}); + + 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 ( + + {selectedPromptContexts.map(({ description, id }) => ( + + {isNewConversation || selectedPromptContexts.length > 1 ? ( + + ) : null} + + unselectPromptContext(id)} + /> + + } + id={id} + paddingSize="s" + > + + + {id != null && accordionContent[id] != null + ? SYSTEM_PROMPT_CONTEXT_NON_I18N(accordionContent[id]) + : ''} + + + + + ))} + + ); +}; + +SelectedPromptContextsComponent.displayName = 'SelectedPromptContextsComponent'; +export const SelectedPromptContexts = React.memo(SelectedPromptContextsComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/selected_prompt_contexts/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/selected_prompt_contexts/translations.ts new file mode 100644 index 000000000000000..2905e2a40dc6c42 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/selected_prompt_contexts/translations.ts @@ -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', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/helpers.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/helpers.test.tsx new file mode 100644 index 000000000000000..a3292cb9b269c4d --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/helpers.test.tsx @@ -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({option.dropdownDisplay}); + + expect(screen.getByTestId('name')).toHaveTextContent(mockSystemPrompt.name); + }); + + it('shows the expected prompt content in the dropdownDisplay', () => { + const option = getOptionFromPrompt(mockSystemPrompt); + + render({option.dropdownDisplay}); + + 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); + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/helpers.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/helpers.tsx new file mode 100644 index 000000000000000..9d81bd4d413d2f3 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/helpers.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { 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 => ({ + value: id, + inputDisplay: ( + + {content} + + ), + dropdownDisplay: ( + <> + {name} + + + +

{content}

+
+
+ + ), +}); + +export const getOptions = (prompts: Prompt[]): Array> => + prompts.map(getOptionFromPrompt); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.test.tsx new file mode 100644 index 000000000000000..6610ba836de7093 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.test.tsx @@ -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( + + ); + }); + + 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( + + ); + }); + + 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( + + ); + + 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( + + ); + + userEvent.click(screen.getByTestId('clear')); + + expect(setSelectedSystemPromptId).toHaveBeenCalledWith(null); + }); + + it('shows the system prompt select when system prompt text is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('systemPromptText')); + + expect(screen.getByTestId('selectSystemPrompt')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.tsx new file mode 100644 index 000000000000000..fbe83ce2c94e7ce --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.tsx @@ -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>; + systemPrompts: Prompt[]; +} + +const SystemPromptComponent: React.FC = ({ + selectedSystemPromptId, + setSelectedSystemPromptId, + systemPrompts, +}) => { + const [showSelectSystemPrompt, setShowSelectSystemPrompt] = React.useState(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 ( +
+ {selectedPrompt == null || showSelectSystemPrompt ? ( + + ) : ( + + + + {selectedPrompt?.content ?? ''} + + + + + + + + + + + + + + + + + + + )} +
+ ); +}; + +SystemPromptComponent.displayName = 'SystemPromptComponent'; + +export const SystemPrompt = React.memo(SystemPromptComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/select_system_prompt/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/select_system_prompt/index.test.tsx new file mode 100644 index 000000000000000..affd07e26eb68d6 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/select_system_prompt/index.test.tsx @@ -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(); + + expect(getByTestId('promptSuperSelect')).toBeInTheDocument(); + }); + + it('does NOT render the prompt super select when showSelectSystemPrompt is false', () => { + const { queryByTestId } = render( + + ); + + expect(queryByTestId('promptSuperSelect')).not.toBeInTheDocument(); + }); + + it('renders the clear system prompt button when showSelectSystemPrompt is true', () => { + const { getByTestId } = render(); + + expect(getByTestId('clearSystemPrompt')).toBeInTheDocument(); + }); + + it('does NOT render the clear system prompt button when showSelectSystemPrompt is false', () => { + const { queryByTestId } = render( + + ); + + expect(queryByTestId('clearSystemPrompt')).not.toBeInTheDocument(); + }); + + it('renders the add system prompt button when showSelectSystemPrompt is false', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('addSystemPrompt')).toBeInTheDocument(); + }); + + it('does NOT render the add system prompt button when showSelectSystemPrompt is true', () => { + const { queryByTestId } = render( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + userEvent.click(getByTestId('addSystemPrompt')); + + expect(setShowSelectSystemPrompt).toHaveBeenCalledWith(true); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/select_system_prompt/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/select_system_prompt/index.tsx new file mode 100644 index 000000000000000..180f7202c2d2bb8 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/select_system_prompt/index.tsx @@ -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>; + setShowSelectSystemPrompt: React.Dispatch>; + showSelectSystemPrompt: boolean; + systemPrompts: Prompt[]; +} + +const SelectSystemPromptComponent: React.FC = ({ + 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 ( + + + {showSelectSystemPrompt && ( + + + + )} + + + + {showSelectSystemPrompt ? ( + + + + ) : ( + + + + )} + + + ); +}; + +SelectSystemPromptComponent.displayName = 'SelectSystemPromptComponent'; + +export const SelectSystemPrompt = React.memo(SelectSystemPromptComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/translations.ts new file mode 100644 index 000000000000000..075e017bc56f01c --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/translations.ts @@ -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', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/translations.ts new file mode 100644 index 000000000000000..567754482bfb30e --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/translations.ts @@ -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', +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_textarea/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_textarea/index.tsx new file mode 100644 index 000000000000000..9d322c1b4c1f6ac --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_textarea/index.tsx @@ -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 { + 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( + ({ isDisabled = false, value, onPromptSubmit, handlePromptChange, ...props }, ref) => { + const [currentValue, setCurrentValue] = React.useState(value); + + const onChangeCallback = useCallback( + (event: React.ChangeEvent) => { + 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 ( + + ); + } +); +PromptTextArea.displayName = 'PromptTextArea'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_textarea/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_textarea/translations.ts new file mode 100644 index 000000000000000..1450daa426f3c57 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_textarea/translations.ts @@ -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...'", + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/add_quick_prompt_modal/add_quick_prompt_modal.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/add_quick_prompt_modal/add_quick_prompt_modal.tsx new file mode 100644 index 000000000000000..99fa4ed8bc96cf7 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/add_quick_prompt_modal/add_quick_prompt_modal.tsx @@ -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 = 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(quickPrompts); + + // Form options + const [selectedQuickPrompt, setSelectedQuickPrompt] = useState(); + // Prompt + const [prompt, setPrompt] = useState(''); + const handlePromptTextChange = useCallback((e: React.ChangeEvent) => { + setPrompt(e.target.value); + }, []); + // Color + const [color, setColor, errors] = useColorPickerState(DEFAULT_COLOR); + const handleColorChange = useCallback( + (text, { hex, isValid }) => { + if (selectedQuickPrompt != null) { + setSelectedQuickPrompt({ + ...selectedQuickPrompt, + color: text, + }); + } + setColor(text, { hex, isValid }); + }, + [selectedQuickPrompt, setColor] + ); + // Prompt Contexts/Categories + const [selectedPromptContexts, setSelectedPromptContexts] = useState( + [] + ); + 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 ( + <> + setIsModalVisible(true)} iconType="plus" size="xs"> + {i18n.ADD_QUICK_PROMPT} + + {isModalVisible && ( + + + {i18n.ADD_QUICK_PROMPT_MODAL_TITLE} + + + + + + + + + + + + + + + + + + + + + + {i18n.CANCEL} + + + {i18n.SAVE} + + + + )} + + ); + } +); + +AddQuickPromptModal.displayName = 'AddQuickPromptModal'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/add_quick_prompt_modal/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/add_quick_prompt_modal/translations.ts new file mode 100644 index 000000000000000..5e0c7112dea8f0c --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/add_quick_prompt_modal/translations.ts @@ -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', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/prompt_context_selector/prompt_context_selector.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/prompt_context_selector/prompt_context_selector.tsx new file mode 100644 index 000000000000000..760bfc3c5e30d70 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/prompt_context_selector/prompt_context_selector.tsx @@ -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 = React.memo( + ({ onPromptContextSelectionChange, promptContexts, selectedPromptContexts = [] }) => { + // ComboBox options + const options = useMemo( + () => + promptContexts.map((pc) => ({ + value: { + category: pc.category, + }, + label: pc.description, + })), + [promptContexts] + ); + const selectedOptions = useMemo(() => { + 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 ( + + {label} +   + {`(${value?.category})`} + + ); + }; + + return ( + + ); + } +); + +PromptContextSelector.displayName = 'PromptContextSelector'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/prompt_context_selector/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/prompt_context_selector/translations.ts new file mode 100644 index 000000000000000..80c837e11832fa7 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/prompt_context_selector/translations.ts @@ -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).', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_selector/quick_prompt_selector.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_selector/quick_prompt_selector.tsx new file mode 100644 index 000000000000000..2d481ad141ca485 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_selector/quick_prompt_selector.tsx @@ -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 = React.memo( + ({ quickPrompts, onQuickPromptDeleted, onQuickPromptSelectionChange, selectedQuickPrompt }) => { + // Form options + const [options, setOptions] = useState( + quickPrompts.map((qp) => ({ + value: { + isDefault: qp.isDefault ?? false, + }, + label: qp.title, + color: qp.color, + })) + ); + const selectedOptions = useMemo(() => { + 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 ( + + + + + {label} + + + + {!value?.isDefault && ( + + + { + e.stopPropagation(); + onDelete(label); + }} + css={css` + visibility: hidden; + .parentFlexGroup:hover & { + visibility: visible; + } + `} + /> + + + )} + + ); + }; + + return ( + + ); + } +); + +QuickPromptSelector.displayName = 'QuickPromptSelector'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_selector/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_selector/translations.ts new file mode 100644 index 000000000000000..9cff7417c181b43 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_selector/translations.ts @@ -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', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.tsx new file mode 100644 index 000000000000000..536154ab845ae6e --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.tsx @@ -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 = 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 ( + + {quickPrompts.slice(0, COUNT_BEFORE_OVERFLOW).map((badge, index) => ( + + setInput(badge.prompt)} + onClickAriaLabel={badge.title} + > + {badge.title} + + + ))} + {quickPrompts.length > COUNT_BEFORE_OVERFLOW && ( + + + } + isOpen={isOverflowPopoverOpen} + closePopover={closeOverflowPopover} + anchorPosition="rightUp" + > + + {quickPrompts.slice(COUNT_BEFORE_OVERFLOW).map((badge, index) => ( + + onClickOverflowQuickPrompt(badge.prompt)} + onClickAriaLabel={badge.title} + > + {badge.title} + + + ))} + + + + )} + + + + + ); +}); +QuickPrompts.displayName = 'QuickPrompts'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/translations.ts new file mode 100644 index 000000000000000..e2d3fd5ef782477 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/translations.ts @@ -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', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/types.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/types.tsx new file mode 100644 index 000000000000000..c0688f432e7ddeb --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/types.tsx @@ -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; + isDefault?: boolean; +} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings_popover.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings_popover.tsx new file mode 100644 index 000000000000000..12f46f933b33c1e --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings_popover.tsx @@ -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 = 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(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 ( + + setIsSettingsOpen(!isSettingsOpen)} + iconType="controlsVertical" + aria-label={i18n.SETTINGS_TITLE} + data-test-subj="assistant-settings-button" + /> + + } + isOpen={isSettingsOpen} + closePopover={closeSettingsHandler} + anchorPosition="rightCenter" + panelRef={(el) => (popoverPanelRef.current = el)} + > + {i18n.SETTINGS_TITLE} +
+ + + + } + > + + +
+
+ ); + } +); +SettingsPopover.displayName = 'SettingPopover'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/streaming_text/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/streaming_text/index.test.tsx new file mode 100644 index 000000000000000..9ba5258dfe54dff --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/streaming_text/index.test.tsx @@ -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(); + + 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(); + }); + } + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/streaming_text/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/streaming_text/index.tsx new file mode 100644 index 000000000000000..33dca8c6e360971 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/streaming_text/index.tsx @@ -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 = React.memo( + ({ text, children, chunkSize = 5, delay = 100, onStreamingComplete }) => { + const [displayText, setDisplayText] = useState(delay === 0 ? text : ''); + const [isStreamingComplete, setIsStreamingComplete] = useState(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
{children(displayText, isStreamingComplete)}
; + } else { + return
{displayText}
; + } + } +); +StreamingText.displayName = 'StreamingText'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/translations.ts new file mode 100644 index 000000000000000..5690b7361f2b0ff --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/translations.ts @@ -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.', +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts new file mode 100644 index 000000000000000..9c7c2058e4b42ad --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts @@ -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; +} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_assistant_overlay/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_assistant_overlay/index.test.tsx new file mode 100644 index 000000000000000..4329cc45b565c24 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_assistant_overlay/index.test.tsx @@ -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', + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_assistant_overlay/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_assistant_overlay/index.tsx new file mode 100644 index 000000000000000..f804ec5178ca27f --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_assistant_overlay/index.tsx @@ -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 }; +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/helpers.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/helpers.ts new file mode 100644 index 000000000000000..57f87e4d03bd1c4 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/helpers.ts @@ -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; +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/index.tsx new file mode 100644 index 000000000000000..8c2af801af60fe6 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/index.tsx @@ -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) => { + 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) => { + 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) => { + 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) => { + 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) => { + return { + ...prev, + [conversation.id]: conversation, + }; + }); + }, + [setConversations] + ); + + return { appendMessage, clearConversation, createConversation, setApiConfig, setConversation }; +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/sample_conversations.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/sample_conversations.tsx new file mode 100644 index 000000000000000..3fc41160d5bbfcd --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/sample_conversations.tsx @@ -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 = { + [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: {}, + }, +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/translations.ts new file mode 100644 index 000000000000000..48271c8b5062901 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/translations.ts @@ -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', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_send_messages/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_send_messages/index.tsx new file mode 100644 index 000000000000000..3a3b72c25386281 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_send_messages/index.tsx @@ -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; +} + +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 }; +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/helpers.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/helpers.ts new file mode 100644 index 000000000000000..f285f8b1f3c4159 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/helpers.ts @@ -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; + promptContext: PromptContext; +}): Record => ({ + ...prevPromptContexts, + [promptContext.id]: { + ...promptContext, + }, +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.test.tsx new file mode 100644 index 000000000000000..564d57ddd39faa8 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.test.tsx @@ -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 }) => ( + + {children} + +); + +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); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx new file mode 100644 index 000000000000000..a11ad21237237ce --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx @@ -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; + }) => EuiCommentProps[]; + http: HttpSetup; + getInitialConversations: () => Record; + nameSpace?: string; + setConversations: React.Dispatch>>; + title?: string; +} + +interface UseAssistantContext { + actionTypeRegistry: ActionTypeRegistryContract; + augmentMessageCodeBlocks: (currentConversation: Conversation) => CodeBlockDetails[][]; + basePromptContexts: PromptContextTemplate[]; + baseQuickPrompts: QuickPrompt[]; + conversationIds: string[]; + conversations: Record; + getComments: ({ + currentConversation, + lastCommentRef, + }: { + currentConversation: Conversation; + lastCommentRef: React.MutableRefObject; + }) => EuiCommentProps[]; + http: HttpSetup; + promptContexts: Record; + nameSpace: string; + registerPromptContext: RegisterPromptContext; + setConversations: React.Dispatch>>; + setShowAssistantOverlay: (showAssistantOverlay: ShowAssistantOverlay) => void; + showAssistantOverlay: ShowAssistantOverlay; + title: string; + unRegisterPromptContext: UnRegisterPromptContext; +} + +const AssistantContext = React.createContext(undefined); + +export const AssistantProvider: React.FC = ({ + 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>({}); + + 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( + (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>> + >( + ( + newConversations: + | Record + | ((prev: Record) => Record) + ) => { + 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 {children}; +}; + +export const useAssistantContext = () => { + const context = React.useContext(AssistantContext); + + if (context == null) { + throw new Error('useAssistantContext must be used within a AssistantProvider'); + } + + return context; +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx new file mode 100644 index 000000000000000..33af928a8e90099 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx @@ -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; +} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_button/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_button/index.tsx new file mode 100644 index 000000000000000..51150264c79885a --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_button/index.tsx @@ -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>; + 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 = React.memo( + ({ setIsConnectorModalVisible, connectorAdded = false }) => { + return ( + + + } + 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)} + /> + + + ); + } +); +ConnectorButton.displayName = 'ConnectorButton'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/index.tsx new file mode 100644 index 000000000000000..3d6e4a3db29dc8c --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/index.tsx @@ -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 = React.memo( + ({ actionTypeRegistry, conversation, http, onConnectorModalVisibilityChange }) => { + const { setApiConfig } = useConversation(); + + // Connector Modal State + const [isConnectorModalVisible, setIsConnectorModalVisible] = useState(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: ( + + + {i18n.ADD_NEW_CONNECTOR} + + + ), + }; + }, []); + + const connectorOptions = useMemo(() => { + return ( + connectors?.map((connector) => { + const apiProvider: string | undefined = ( + connector as ActionConnectorProps + )?.config?.apiProvider; + return { + value: connector.id, + inputDisplay: connector.name, + dropdownDisplay: ( + + {connector.name} + {apiProvider && ( + +

{apiProvider}

+
+ )} +
+ ), + }; + }) ?? [] + ); + }, [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.apiProvider as OpenAiProviderType; + setApiConfig({ + conversationId: conversation.id, + apiConfig: { + ...conversation.apiConfig, + connectorId, + provider: apiProvider, + }, + }); + }, + [ + connectors, + conversation.apiConfig, + conversation.id, + setApiConfig, + onConnectorModalVisibilityChange, + ] + ); + + return ( + <> + + {isConnectorModalVisible && ( + { + setApiConfig({ + conversationId: conversation.id, + apiConfig: { + ...conversation.apiConfig, + connectorId: savedAction.id, + provider: (savedAction as ActionConnectorProps)?.config + .apiProvider as OpenAiProviderType, + }, + }); + refetchConnectors?.(); + cleanupAndCloseModal(); + }} + actionTypeRegistry={actionTypeRegistry} + /> + )} + + ); + } +); + +ConnectorSelector.displayName = 'ConnectorSelector'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_setup/helpers.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_setup/helpers.tsx new file mode 100644 index 000000000000000..cb11ca51047f0e9 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_setup/helpers.tsx @@ -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); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_setup/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_setup/index.tsx new file mode 100644 index 000000000000000..6229419a397cfc1 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_setup/index.tsx @@ -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 = React.memo( + ({ + actionTypeRegistry, + conversation = BASE_CONVERSATIONS[WELCOME_CONVERSATION_TITLE], + http, + isConnectorConfigured = false, + onSetupComplete, + refetchConnectors, + }) => { + const { appendMessage, setApiConfig, setConversation } = useConversation(); + const lastCommentRef = useRef(null); + const bottomRef = useRef(null); + + // Access all conversations so we can add connector to all on initial setup + const { conversations } = useAssistantContext(); + + const [isConnectorModalVisible, setIsConnectorModalVisible] = useState(false); + const [showAddConnectorButton, setShowAddConnectorButton] = useState(() => { + // 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 ( + + {(streamedText, isStreamingComplete) => ( + + {streamedText} + {isLastMessage && isStreamingComplete && } + + )} + + ); + }, + [ + conversation.messages, + currentMessageIndex, + onHandleLastMessageStreamingComplete, + onHandleMessageStreamingComplete, + ] + ); + + return ( + <> + + { + const isUser = message.role === 'user'; + + const commentProps: EuiCommentProps = { + username: isUser ? userName : assistantName, + children: commentBody(message, index, conversation.messages.length), + timelineAvatar: ( + + ), + timestamp: `${i18n.CONNECTOR_SETUP_TIMESTAMP_AT}: ${message.timestamp}`, + }; + return commentProps; + })} + /> +
+ + {(showAddConnectorButton || isConnectorConfigured) && ( + + + + )} + {!showAddConnectorButton && ( + + + + {i18n.CONNECTOR_SETUP_SKIP} + + + + )} + + {isConnectorModalVisible && ( + 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 + .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'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts new file mode 100644 index 000000000000000..c9eb7797e362c95 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts @@ -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...', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/use_load_action_types/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/use_load_action_types/index.tsx new file mode 100644 index 000000000000000..796ece694664ad6 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/use_load_action_types/index.tsx @@ -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 => { + 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; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/use_load_connectors/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/use_load_connectors/index.tsx new file mode 100644 index 000000000000000..9f67285ac3dc917 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/use_load_connectors/index.tsx @@ -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 => { + 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, + } + ); + } + }, + } + ); +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/system/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/system/translations.ts new file mode 100644 index 000000000000000..dda0c9844c33f6c --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/system/translations.ts @@ -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"""`; +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/user/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/user/translations.ts new file mode 100644 index 000000000000000..28cda1f9414a843 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/user/translations.ts @@ -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}`; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/welcome/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/welcome/translations.ts new file mode 100644 index 000000000000000..6a6b1253ca2b1ab --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/welcome/translations.ts @@ -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!', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/mock/prompt_context/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/mock/prompt_context/index.ts new file mode 100644 index 000000000000000..289fff2c633e595 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/mock/prompt_context/index.ts @@ -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', +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/mock/system_prompt/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/mock/system_prompt/index.ts new file mode 100644 index 000000000000000..bfd0187b5ede4f7 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/mock/system_prompt/index.ts @@ -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', +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx b/x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx new file mode 100644 index 000000000000000..9ceda348795ae70 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx @@ -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 = ({ children }) => { + const actionTypeRegistry = actionTypeRegistryMock.create(); + const mockGetInitialConversations = jest.fn(() => ({})); + const mockGetComments = jest.fn(() => []); + const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' }); + + return ( + + ({ eui: euiDarkVars, darkMode: true })}> + + {children} + + + + ); +}; + +TestProvidersComponent.displayName = 'TestProvidersComponent'; + +export const TestProviders = React.memo(TestProvidersComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/mock/user_prompt/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/mock/user_prompt/index.ts new file mode 100644 index 000000000000000..5bc23b0d680e36f --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/mock/user_prompt/index.ts @@ -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', +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.test.tsx new file mode 100644 index 000000000000000..26327c523ce6b85 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.test.tsx @@ -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(); + + 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(); + + const newChatButton = screen.getByTestId('newChat'); + + expect(newChatButton.textContent).toContain('New chat'); + }); + + it('renders custom children', () => { + render({'🪄✨'}); + + const newChatButton = screen.getByTestId('newChat'); + + expect(newChatButton.textContent).toContain('🪄✨'); + }); + + it('renders custom icons', () => { + render(); + + const newChatButton = screen.getByTestId('newChat'); + + expect(newChatButton.querySelector('[data-euiicon-type="help"]')).toBeInTheDocument(); + }); + + it('does NOT render an icon when iconType is null', () => { + render(); + + const newChatButton = screen.getByTestId('newChat'); + + expect(newChatButton.querySelector('.euiButtonContent__icon')).not.toBeInTheDocument(); + }); + + it('calls showAssistantOverlay on click', () => { + render(); + + const newChatButton = screen.getByTestId('newChat'); + + userEvent.click(newChatButton); + + expect(mockUseAssistantOverlay.showAssistantOverlay).toHaveBeenCalledWith(true); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.tsx new file mode 100644 index 000000000000000..3f6b7afc14c719e --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.tsx @@ -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 & { + 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 = ({ + 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( + () => ( + + {children} + + ), + [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); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/new_chat/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/translations.ts new file mode 100644 index 000000000000000..5bc87dc1a261c3a --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/translations.ts @@ -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', +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/new_chat_by_id/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/new_chat_by_id/index.test.tsx new file mode 100644 index 000000000000000..c2ff10fa22a2071 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/new_chat_by_id/index.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import 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(); + + 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(); + + const newChatButton = screen.getByTestId('newChatById'); + + expect(newChatButton.textContent).toContain('New chat'); + }); + + it('renders custom children', async () => { + render({'🪄✨'}); + + const newChatButton = screen.getByTestId('newChatById'); + + expect(newChatButton.textContent).toContain('🪄✨'); + }); + + it('renders custom icons', async () => { + render(); + + const newChatButton = screen.getByTestId('newChatById'); + + expect(newChatButton.querySelector('[data-euiicon-type="help"]')).toBeInTheDocument(); + }); + + it('does NOT render an icon when iconType is null', () => { + render(); + + 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(); + const newChatButton = screen.getByTestId('newChatById'); + + userEvent.click(newChatButton); + + expect(mockUseAssistantContext.showAssistantOverlay).toHaveBeenCalledWith({ + conversationId, + promptContextId, + showOverlay: true, + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/new_chat_by_id/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/new_chat_by_id/index.tsx new file mode 100644 index 000000000000000..721d40118695a7c --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/new_chat_by_id/index.tsx @@ -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 = ({ + 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( + () => ( + + {children} + + ), + [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); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/new_chat_by_id/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/new_chat_by_id/translations.ts new file mode 100644 index 000000000000000..373aae6297eee19 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/new_chat_by_id/translations.ts @@ -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', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/index.ts b/x-pack/packages/kbn-elastic-assistant/index.ts new file mode 100644 index 000000000000000..b238d070af61c42 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/index.ts @@ -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'; diff --git a/x-pack/packages/kbn-elastic-assistant/jest.config.js b/x-pack/packages/kbn-elastic-assistant/jest.config.js new file mode 100644 index 000000000000000..46d4ca8b91eb4fb --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/jest.config.js @@ -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: + '/target/kibana-coverage/jest/x-pack/packages/kbn_elastic_assistant_impl', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/packages/kbn-elastic-assistant/impl/**/*.{ts,tsx}', + '!/x-pack/packages/kbn-elastic-assistant/impl/{__test__,__snapshots__,__examples__,*mock*,tests,test_helpers,integration_tests,types}/**/*', + '!/x-pack/packages/kbn-elastic-assistant/impl/*mock*.{ts,tsx}', + '!/x-pack/packages/kbn-elastic-assistant/impl/*.test.{ts,tsx}', + '!/x-pack/packages/kbn-elastic-assistant/impl/*.d.ts', + '!/x-pack/packages/kbn-elastic-assistant/impl/*.config.ts', + ], + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/x-pack/packages/kbn-elastic-assistant'], +}; diff --git a/x-pack/packages/kbn-elastic-assistant/kibana.jsonc b/x-pack/packages/kbn-elastic-assistant/kibana.jsonc new file mode 100644 index 000000000000000..9371fcdfe3a8a95 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "id": "@kbn/elastic-assistant", + "owner": "@elastic/security-solution", + "type": "shared-browser" +} diff --git a/x-pack/packages/kbn-elastic-assistant/package.json b/x-pack/packages/kbn-elastic-assistant/package.json new file mode 100644 index 000000000000000..7bc41b6dffe6491 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/elastic-assistant", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0" +} \ No newline at end of file diff --git a/x-pack/packages/kbn-elastic-assistant/setup_tests.ts b/x-pack/packages/kbn-elastic-assistant/setup_tests.ts new file mode 100644 index 000000000000000..72e0edd0d07f7af --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/setup_tests.ts @@ -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'; diff --git a/x-pack/packages/kbn-elastic-assistant/tsconfig.json b/x-pack/packages/kbn-elastic-assistant/tsconfig.json new file mode 100644 index 000000000000000..f460832b040d743 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/tsconfig.json @@ -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", + ] +} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/index.test.tsx index 3278ec3a1def35d..71e63d361a0bc57 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/index.test.tsx @@ -55,6 +55,7 @@ const defaultProps: Props = { formatNumber, getGroupByFieldsOnClick: jest.fn(), ilmPhases, + isAssistantEnabled: true, openCreateCaseFlyout: jest.fn(), patternIndexNames, patternRollups, diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/index.tsx index 3c996dd095dc881..45bb717c5270678 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/index.tsx @@ -41,6 +41,7 @@ export interface Props { groupByField1: string; }; ilmPhases: string[]; + isAssistantEnabled: boolean; openCreateCaseFlyout: ({ comments, headerContent, @@ -69,6 +70,7 @@ const DataQualityDetailsComponent: React.FC = ({ formatNumber, getGroupByFieldsOnClick, ilmPhases, + isAssistantEnabled, openCreateCaseFlyout, patternIndexNames, patternRollups, @@ -105,6 +107,7 @@ const DataQualityDetailsComponent: React.FC = ({ formatNumber={formatNumber} getGroupByFieldsOnClick={getGroupByFieldsOnClick} ilmPhases={ilmPhases} + isAssistantEnabled={isAssistantEnabled} openCreateCaseFlyout={openCreateCaseFlyout} patterns={patterns} theme={theme} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/indices_details/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/indices_details/index.test.tsx index 2b21154d70b3ada..8251b16c389b0d2 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/indices_details/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/indices_details/index.test.tsx @@ -55,6 +55,7 @@ const defaultProps: Props = { formatNumber, getGroupByFieldsOnClick: jest.fn(), ilmPhases, + isAssistantEnabled: true, openCreateCaseFlyout: jest.fn(), patternIndexNames, patternRollups, diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/indices_details/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/indices_details/index.tsx index 9b59a78430e1c12..3f9ef7575b38566 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/indices_details/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/indices_details/index.tsx @@ -39,6 +39,7 @@ export interface Props { groupByField1: string; }; ilmPhases: string[]; + isAssistantEnabled: boolean; openCreateCaseFlyout: ({ comments, headerContent, @@ -69,6 +70,7 @@ const IndicesDetailsComponent: React.FC = ({ formatNumber, getGroupByFieldsOnClick, ilmPhases, + isAssistantEnabled, openCreateCaseFlyout, patternIndexNames, patternRollups, @@ -90,6 +92,7 @@ const IndicesDetailsComponent: React.FC = ({ getGroupByFieldsOnClick={getGroupByFieldsOnClick} ilmPhases={ilmPhases} indexNames={patternIndexNames[pattern]} + isAssistantEnabled={isAssistantEnabled} openCreateCaseFlyout={openCreateCaseFlyout} pattern={pattern} patternRollup={patternRollups[pattern]} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/index.test.tsx index 242229870de2522..6e9c5861e6baeb6 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/index.test.tsx @@ -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} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/index.tsx index 69de3b8c110e584..ca43cf1e7d40c16 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/index.tsx @@ -40,6 +40,7 @@ interface Props { groupByField1: string; }; ilmPhases: string[]; + isAssistantEnabled: boolean; lastChecked: string; openCreateCaseFlyout: ({ comments, @@ -60,6 +61,7 @@ const BodyComponent: React.FC = ({ formatNumber, getGroupByFieldsOnClick, ilmPhases, + isAssistantEnabled, lastChecked, openCreateCaseFlyout, patterns, @@ -112,6 +114,7 @@ const BodyComponent: React.FC = ({ formatNumber={formatNumber} getGroupByFieldsOnClick={getGroupByFieldsOnClick} ilmPhases={ilmPhases} + isAssistantEnabled={isAssistantEnabled} openCreateCaseFlyout={openCreateCaseFlyout} patterns={patterns} patternIndexNames={patternIndexNames} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/index_properties/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/index_properties/index.test.tsx index c578ec8c91f961c..ef0868fe3233be9 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/index_properties/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/index_properties/index.test.tsx @@ -111,6 +111,7 @@ const defaultProps: Props = { getGroupByFieldsOnClick: jest.fn(), ilmPhase: 'hot', indexName: 'auditbeat-custom-index-1', + isAssistantEnabled: true, openCreateCaseFlyout: jest.fn(), pattern, patternRollup, diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/index_properties/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/index_properties/index.tsx index ca9eda507f8ddb3..ac16b9dfbcccf1e 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/index_properties/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/index_properties/index.tsx @@ -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 = ({ getGroupByFieldsOnClick, ilmPhase, indexName, + isAssistantEnabled, openCreateCaseFlyout, pattern, patternRollup, @@ -143,6 +145,7 @@ const IndexPropertiesComponent: React.FC = ({ docsCount, getGroupByFieldsOnClick, ilmPhase, + isAssistantEnabled, indexName, onAddToNewCase, partitionedFieldMetadata: partitionedFieldMetadata ?? EMPTY_METADATA, @@ -161,6 +164,7 @@ const IndexPropertiesComponent: React.FC = ({ getGroupByFieldsOnClick, ilmPhase, indexName, + isAssistantEnabled, onAddToNewCase, partitionedFieldMetadata, pattern, diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/pattern/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/pattern/index.test.tsx index d9b002a63dc6885..d06866544a6bf5a 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/pattern/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/pattern/index.test.tsx @@ -46,6 +46,7 @@ const defaultProps = { getGroupByFieldsOnClick: jest.fn(), ilmPhases: ['hot', 'warm', 'unmanaged'], indexNames: undefined, + isAssistantEnabled: true, openCreateCaseFlyout: jest.fn(), patternRollup: undefined, selectedIndex: null, diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/pattern/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/pattern/index.tsx index cb430d75ef12e38..3213c1c1dbef9c8 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/pattern/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/pattern/index.tsx @@ -76,6 +76,7 @@ interface Props { }; ilmPhases: string[]; indexNames: string[] | undefined; + isAssistantEnabled: boolean; openCreateCaseFlyout: ({ comments, headerContent, @@ -106,6 +107,7 @@ const PatternComponent: React.FC = ({ getGroupByFieldsOnClick, indexNames, ilmPhases, + isAssistantEnabled, openCreateCaseFlyout, pattern, patternRollup, @@ -150,6 +152,7 @@ const PatternComponent: React.FC = ({ 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 = ({ formatNumber, getGroupByFieldsOnClick, ilmExplain, + isAssistantEnabled, itemIdToExpandedRowMap, openCreateCaseFlyout, pattern, diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/helpers.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/helpers.test.tsx index c0dc6a8aaafe223..fe436309dae78e8 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/helpers.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/helpers.test.tsx @@ -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-*', diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/helpers.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/helpers.tsx index c0cbebd45cb8d5f..c04a7a861d17f8a 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/helpers.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/helpers.tsx @@ -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} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/incompatible_tab/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/incompatible_tab/index.tsx index 2fa4fcdb4f8ed38..e0e26f3928bdefd 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/incompatible_tab/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/incompatible_tab/index.tsx @@ -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 = ({ formatNumber, ilmPhase, indexName, + isAssistantEnabled, onAddToNewCase, partitionedFieldMetadata, patternDocsCount, @@ -108,6 +117,8 @@ const IncompatibleTabComponent: React.FC = ({ }); }, [addSuccessToast, markdownComments]); + const getPromptContext = useCallback(async () => markdownComments.join('\n'), [markdownComments]); + return (
{showInvalidCallout(partitionedFieldMetadata.incompatible) ? ( @@ -129,6 +140,19 @@ const IncompatibleTabComponent: React.FC = ({ {i18n.COPY_TO_CLIPBOARD} + + {isAssistantEnabled && ( + + + + )} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/callout_summary/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/callout_summary/index.tsx index 6d36fdd50370abe..ba3aebc501010fc 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/callout_summary/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/callout_summary/index.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { NewChat } from '@kbn/elastic-assistant'; import { copyToClipboard, EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; @@ -15,8 +16,14 @@ import { getMarkdownComments } from '../helpers'; import { showInvalidCallout } from '../../incompatible_tab/helpers'; import { CopyToClipboardButton } from '../../styles'; import * as i18n from '../../../index_properties/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 './translations'; interface Props { addSuccessToast: (toast: { title: string }) => void; @@ -26,6 +33,7 @@ interface Props { formatNumber: (value: number | undefined) => string; ilmPhase: IlmPhase | undefined; indexName: string; + isAssistantEnabled: boolean; onAddToNewCase: (markdownComment: string[]) => void; partitionedFieldMetadata: PartitionedFieldMetadata; pattern: string; @@ -41,6 +49,7 @@ const CalloutSummaryComponent: React.FC = ({ formatNumber, ilmPhase, indexName, + isAssistantEnabled, onAddToNewCase, partitionedFieldMetadata, pattern, @@ -86,6 +95,8 @@ const CalloutSummaryComponent: React.FC = ({ }); }, [addSuccessToast, markdownComments]); + const getPromptContext = useCallback(async () => markdownComments.join('\n'), [markdownComments]); + const showActions = showInvalidCallout(partitionedFieldMetadata.incompatible) || showMissingTimestampCallout(partitionedFieldMetadata.ecsCompliant); @@ -122,6 +133,19 @@ const CalloutSummaryComponent: React.FC = ({ {i18n.COPY_TO_CLIPBOARD} + + {isAssistantEnabled && ( + + + + )} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/callout_summary/translations.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/callout_summary/translations.ts new file mode 100644 index 000000000000000..07337268b7d8c9c --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/callout_summary/translations.ts @@ -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 DATA_QUALITY_DASHBOARD_CONVERSATION_ID = i18n.translate( + 'securitySolutionPackages.ecsDataQualityDashboard.incompatibleTab.dataQualityDashboardConversationId', + { + defaultMessage: 'Data Quality dashboard', + } +); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/index.tsx index c830ac2f6c7be2c..8f27a409c745546 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/index.tsx @@ -42,6 +42,7 @@ interface Props { }; ilmPhase: IlmPhase | undefined; indexName: string; + isAssistantEnabled: boolean; onAddToNewCase: (markdownComments: string[]) => void; partitionedFieldMetadata: PartitionedFieldMetadata; pattern: string; @@ -60,6 +61,7 @@ const SummaryTabComponent: React.FC = ({ getGroupByFieldsOnClick, ilmPhase, indexName, + isAssistantEnabled, onAddToNewCase, partitionedFieldMetadata, pattern, @@ -77,6 +79,7 @@ const SummaryTabComponent: React.FC = ({ docsCount={docsCount} ilmPhase={ilmPhase} indexName={indexName} + isAssistantEnabled={isAssistantEnabled} onAddToNewCase={onAddToNewCase} partitionedFieldMetadata={partitionedFieldMetadata} pattern={pattern} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/index.test.tsx index 17f7e830750afea..44bc2458990e930 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/index.test.tsx @@ -27,6 +27,7 @@ describe('DataQualityPanel', () => { getGroupByFieldsOnClick={jest.fn()} httpFetch={jest.fn()} ilmPhases={ilmPhases} + isAssistantEnabled={true} lastChecked={''} openCreateCaseFlyout={jest.fn()} patterns={[]} @@ -60,6 +61,7 @@ describe('DataQualityPanel', () => { getGroupByFieldsOnClick={jest.fn()} httpFetch={jest.fn()} ilmPhases={ilmPhases} + isAssistantEnabled={true} lastChecked={''} openCreateCaseFlyout={jest.fn()} patterns={[]} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/index.tsx index 758cad54f7caaeb..026be1f05d0d83c 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/index.tsx @@ -42,6 +42,7 @@ interface Props { }; httpFetch: HttpHandler; ilmPhases: string[]; + isAssistantEnabled: boolean; lastChecked: string; openCreateCaseFlyout: ({ comments, @@ -64,6 +65,7 @@ const DataQualityPanelComponent: React.FC = ({ getGroupByFieldsOnClick, httpFetch, ilmPhases, + isAssistantEnabled, lastChecked, openCreateCaseFlyout, patterns, @@ -91,6 +93,7 @@ const DataQualityPanelComponent: React.FC = ({ formatNumber={formatNumber} getGroupByFieldsOnClick={getGroupByFieldsOnClick} ilmPhases={ilmPhases} + isAssistantEnabled={isAssistantEnabled} lastChecked={lastChecked} openCreateCaseFlyout={openCreateCaseFlyout} patterns={patterns} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx index 3aba964c6878326..7c9933942b27017 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx @@ -5,7 +5,9 @@ * 2.0. */ +import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { AssistantProvider } from '@kbn/elastic-assistant'; import { I18nProvider } from '@kbn/i18n-react'; import { euiDarkVars } from '@kbn/ui-theme'; import React from 'react'; @@ -22,11 +24,24 @@ window.scrollTo = jest.fn(); /** A utility for wrapping children in the providers required to run tests */ export const TestProvidersComponent: React.FC = ({ children }) => { const http = httpServiceMock.createSetupContract({ basePath: '/test' }); + const actionTypeRegistry = actionTypeRegistryMock.create(); + const mockGetInitialConversations = jest.fn(() => ({})); + const mockGetComments = jest.fn(() => []); + const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' }); return ( ({ eui: euiDarkVars, darkMode: true })}> - {children} + + {children} + ); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/translations.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/translations.ts index 18c14d5036b6b1c..f0e6ab6fba137d5 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/translations.ts +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/translations.ts @@ -63,6 +63,19 @@ export const COPY_TO_CLIPBOARD = i18n.translate( } ); +export const DATA_QUALITY_PROMPT_CONTEXT_PILL = (indexName: string) => + i18n.translate('securitySolutionPackages.ecsDataQualityDashboard.dataQualityPromptContextPill', { + values: { indexName }, + defaultMessage: 'Data Quality ({indexName})', + }); + +export const DATA_QUALITY_PROMPT_CONTEXT_PILL_TOOLTIP = i18n.translate( + 'securitySolutionPackages.ecsDataQualityDashboard.dataQualityPromptContextPillTooltip', + { + defaultMessage: 'Add this Data Quality report as context', + } +); + /** The subtitle displayed on the Data Quality dashboard */ export const DATA_QUALITY_SUBTITLE: string = i18n.translate( 'securitySolutionPackages.ecsDataQualityDashboard.securitySolutionPackages.ecsDataQualityDashboardSubtitle', @@ -71,6 +84,13 @@ export const DATA_QUALITY_SUBTITLE: string = i18n.translate( } ); +export const DATA_QUALITY_SUGGESTED_USER_PROMPT = i18n.translate( + 'securitySolutionPackages.ecsDataQualityDashboard.dataQualitySuggestedUserPrompt', + { + defaultMessage: 'Explain how to fix issues step by step, and provide API calls.', + } +); + export const DATA_QUALITY_TITLE = i18n.translate( 'securitySolutionPackages.ecsDataQualityDashboard.securitySolutionPackages.ecsDataQualityDashboardTitle', { diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/tsconfig.json b/x-pack/packages/security-solution/ecs_data_quality_dashboard/tsconfig.json index 8e6edbf56af820c..b033af7aa5a0cb7 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/tsconfig.json +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/tsconfig.json @@ -22,5 +22,7 @@ "@kbn/ui-theme", "@kbn/core-http-browser", "@kbn/core-http-browser-mocks", + "@kbn/elastic-assistant", + "@kbn/triggers-actions-ui-plugin", ] } diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 0e44190bfab78d0..e313796fe9559af 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -113,6 +113,11 @@ export const allowedExperimentalValues = Object.freeze({ */ securityFlyoutEnabled: false, + /** + * Enables the Elastic Security Assistant + */ + assistantEnabled: false, + /** * Keep DEPRECATED experimental flags that are documented to prevent failed upgrades. * https://www.elastic.co/guide/en/security/current/user-risk-score.html diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index ba4a8a13501fa0b..31b7dd22c8d2106 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -444,6 +444,7 @@ export enum TimelineTabs { pinned = 'pinned', eql = 'eql', session = 'session', + securityAssistant = 'securityAssistant', } /** diff --git a/x-pack/plugins/security_solution/kibana.jsonc b/x-pack/plugins/security_solution/kibana.jsonc index a929c15b4864142..b279ea77e0bac36 100644 --- a/x-pack/plugins/security_solution/kibana.jsonc +++ b/x-pack/plugins/security_solution/kibana.jsonc @@ -43,6 +43,7 @@ "controls", "dataViews", "savedObjectsManagement", + "stackConnectors", ], "optionalPlugins": [ "cloudExperiments", diff --git a/x-pack/plugins/security_solution/public/app/app.tsx b/x-pack/plugins/security_solution/public/app/app.tsx index a986d21d2f3567d..a1aa0ecfb813dc9 100644 --- a/x-pack/plugins/security_solution/public/app/app.tsx +++ b/x-pack/plugins/security_solution/public/app/app.tsx @@ -5,9 +5,10 @@ * 2.0. */ +import { AssistantProvider } from '@kbn/elastic-assistant'; import type { History } from 'history'; import type { FC } from 'react'; -import React, { memo } from 'react'; +import React, { memo, useCallback } from 'react'; import type { Store, Action } from 'redux'; import { Provider as ReduxStoreProvider } from 'react-redux'; @@ -17,18 +18,24 @@ import type { AppLeaveHandler, AppMountParameters } from '@kbn/core/public'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; import { CellActionsProvider } from '@kbn/cell-actions'; + +import { getComments } from '../assistant/get_comments'; +import { augmentMessageCodeBlocks, LOCAL_STORAGE_KEY } from '../assistant/helpers'; +import { useConversationStore } from '../assistant/use_conversation_store'; import { ManageUserInfo } from '../detections/components/user_info'; -import { DEFAULT_DARK_MODE, APP_NAME } from '../../common/constants'; +import { DEFAULT_DARK_MODE, APP_NAME, APP_ID } from '../../common/constants'; import { ErrorToastDispatcher } from '../common/components/error_toast_dispatcher'; import { MlCapabilitiesProvider } from '../common/components/ml/permissions/ml_capabilities_provider'; import { GlobalToaster, ManageGlobalToaster } from '../common/components/toasters'; import { KibanaContextProvider, useKibana, useUiSetting$ } from '../common/lib/kibana'; import type { State } from '../common/store'; - +import { ASSISTANT_TITLE } from './translations'; import type { StartServices } from '../types'; import { PageRouter } from './routes'; import { UserPrivilegesProvider } from '../common/components/user_privileges/user_privileges_context'; import { ReactQueryClientProvider } from '../common/containers/query_client/query_client_provider'; +import { PROMPT_CONTEXTS } from '../assistant/content/prompt_contexts'; +import { BASE_SECURITY_QUICK_PROMPTS } from '../assistant/content/quick_prompts'; interface StartAppComponent { children: React.ReactNode; @@ -50,8 +57,18 @@ const StartAppComponent: FC = ({ const { i18n, application: { capabilities }, + http, + triggersActionsUi: { actionTypeRegistry }, uiActions, } = useKibana().services; + + const { conversations, setConversations } = useConversationStore(); + const getInitialConversation = useCallback(() => { + return conversations; + }, [conversations]); + + const nameSpace = `${APP_ID}.${LOCAL_STORAGE_KEY}`; + const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE); return ( @@ -60,25 +77,38 @@ const StartAppComponent: FC = ({ - - - - - - + + + + + - {children} - - - - - - + + {children} + + + + + + + diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts index 3ea42f8229ea407..1c75cb7bba595cf 100644 --- a/x-pack/plugins/security_solution/public/app/translations.ts +++ b/x-pack/plugins/security_solution/public/app/translations.ts @@ -7,6 +7,10 @@ import { i18n } from '@kbn/i18n'; +export const ASSISTANT_TITLE = i18n.translate('xpack.securitySolution.assistant.title', { + defaultMessage: 'Elastic Security Assistant', +}); + export const OVERVIEW = i18n.translate('xpack.securitySolution.navigation.overview', { defaultMessage: 'Overview', }); diff --git a/x-pack/plugins/security_solution/public/assistant/comment_actions/index.tsx b/x-pack/plugins/security_solution/public/assistant/comment_actions/index.tsx new file mode 100644 index 000000000000000..6a782f6d58d8423 --- /dev/null +++ b/x-pack/plugins/security_solution/public/assistant/comment_actions/index.tsx @@ -0,0 +1,106 @@ +/* + * 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, EuiCopy, EuiToolTip } from '@elastic/eui'; +import { CommentType } from '@kbn/cases-plugin/common'; +import type { Message } from '@kbn/elastic-assistant'; +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; + +import { useKibana, useToasts } from '../../common/lib/kibana'; +import type { Note } from '../../common/lib/note'; +import { appActions } from '../../common/store/actions'; +import { TimelineId } from '../../../common/types'; +import { updateAndAssociateNode } from '../../timelines/components/notes/helpers'; +import { timelineActions } from '../../timelines/store/timeline'; +import * as i18n from './translations'; + +interface Props { + message: Message; +} + +const CommentActionsComponent: React.FC = ({ message }) => { + const toasts = useToasts(); + const { cases } = useKibana().services; + const dispatch = useDispatch(); + + const associateNote = useCallback( + (noteId: string) => dispatch(timelineActions.addNote({ id: TimelineId.active, noteId })), + [dispatch] + ); + + const updateNote = useCallback( + (note: Note) => dispatch(appActions.updateNote({ note })), + [dispatch] + ); + + const onAddNoteToTimeline = useCallback(() => { + updateAndAssociateNode({ + associateNote, + newNote: message.content, + updateNewNote: () => {}, + updateNote, + user: '', // TODO: attribute assistant messages + }); + + toasts.addSuccess(i18n.ADDED_NOTE_TO_TIMELINE); + }, [associateNote, message.content, toasts, updateNote]); + + // Attach to case support + const selectCaseModal = cases.hooks.useCasesAddToExistingCaseModal({ + onClose: () => {}, + onSuccess: () => {}, + }); + + const onAddToExistingCase = useCallback(() => { + selectCaseModal.open({ + getAttachments: () => [ + { + comment: message.content, + type: CommentType.user, + owner: i18n.ELASTIC_SECURITY_ASSISTANT, + }, + ], + }); + }, [message.content, selectCaseModal]); + + return ( + <> + + + + + + + + + + + {(copy) => ( + + )} + + + + ); +}; + +export const CommentActions = React.memo(CommentActionsComponent); diff --git a/x-pack/plugins/security_solution/public/assistant/comment_actions/translations.ts b/x-pack/plugins/security_solution/public/assistant/comment_actions/translations.ts new file mode 100644 index 000000000000000..878fbab69d6afda --- /dev/null +++ b/x-pack/plugins/security_solution/public/assistant/comment_actions/translations.ts @@ -0,0 +1,50 @@ +/* + * 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 ADDED_NOTE_TO_TIMELINE = i18n.translate( + 'xpack.securitySolution.assistant.commentActions.addedNoteToTimelineToast', + { + defaultMessage: 'Added note to timeline', + } +); + +export const ADD_MESSAGE_CONTENT_AS_TIMELINE_NOTE = i18n.translate( + 'xpack.securitySolution.assistant.commentActions.addMessageContentAsTimelineNoteAriaLabel', + { + defaultMessage: 'Add message content as a timeline note', + } +); + +export const ADD_NOTE_TO_TIMELINE = i18n.translate( + 'xpack.securitySolution.assistant.commentActions.addNoteToTimelineTooltip', + { + defaultMessage: 'Add note to timeline', + } +); + +export const ADD_TO_CASE_EXISTING_CASE = i18n.translate( + 'xpack.securitySolution.assistant.commentActions.addToCaseExistingCase', + { + defaultMessage: 'Add to existing case', + } +); + +export const ELASTIC_SECURITY_ASSISTANT = i18n.translate( + 'xpack.securitySolution.assistant.commentActions.elasticSecurityAssistantTitle', + { + defaultMessage: 'Elastic Security Assistant', + } +); + +export const COPY_TO_CLIPBOARD = i18n.translate( + 'xpack.securitySolution.assistant.commentActions.copyToClipboard', + { + defaultMessage: 'Copy to clipboard', + } +); diff --git a/x-pack/plugins/security_solution/public/assistant/content/conversations/index.tsx b/x-pack/plugins/security_solution/public/assistant/content/conversations/index.tsx new file mode 100644 index 000000000000000..927810e02606d8f --- /dev/null +++ b/x-pack/plugins/security_solution/public/assistant/content/conversations/index.tsx @@ -0,0 +1,92 @@ +/* + * 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 { + ELASTIC_SECURITY_ASSISTANT_TITLE, + WELCOME_CONVERSATION_TITLE, +} from '@kbn/elastic-assistant/impl/assistant/use_conversation/translations'; +import type { Conversation } from '@kbn/elastic-assistant'; +import { + WELCOME_GENERAL, + WELCOME_GENERAL_2, + WELCOME_GENERAL_3, +} from '@kbn/elastic-assistant/impl/content/prompts/welcome/translations'; +import { DATA_QUALITY_DASHBOARD_CONVERSATION_ID } from '@kbn/ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/callout_summary/translations'; +import { DETECTION_RULES_CONVERSATION_ID } from '../../../detections/pages/detection_engine/rules/translations'; +import { + ALERT_SUMMARY_CONVERSATION_ID, + EVENT_SUMMARY_CONVERSATION_ID, +} from '../../../common/components/event_details/translations'; +import { ELASTIC_SECURITY_ASSISTANT } from '../../comment_actions/translations'; + +export const BASE_SECURITY_CONVERSATIONS: Record = { + [ALERT_SUMMARY_CONVERSATION_ID]: { + id: ALERT_SUMMARY_CONVERSATION_ID, + messages: [], + apiConfig: {}, + }, + [DATA_QUALITY_DASHBOARD_CONVERSATION_ID]: { + id: DATA_QUALITY_DASHBOARD_CONVERSATION_ID, + messages: [], + apiConfig: {}, + }, + [DETECTION_RULES_CONVERSATION_ID]: { + id: DETECTION_RULES_CONVERSATION_ID, + messages: [], + apiConfig: {}, + }, + [EVENT_SUMMARY_CONVERSATION_ID]: { + id: EVENT_SUMMARY_CONVERSATION_ID, + 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: WELCOME_GENERAL, + timestamp: '', + presentation: { + delay: 2 * 1000, + stream: true, + }, + }, + { + role: 'assistant', + content: WELCOME_GENERAL_2, + timestamp: '', + presentation: { + delay: 1000, + stream: true, + }, + }, + { + role: 'assistant', + content: WELCOME_GENERAL_3, + timestamp: '', + presentation: { + delay: 1000, + stream: true, + }, + }, + ], + apiConfig: {}, + }, +}; diff --git a/x-pack/plugins/security_solution/public/assistant/content/conversations/translations.ts b/x-pack/plugins/security_solution/public/assistant/content/conversations/translations.ts new file mode 100644 index 000000000000000..4cdb1b3f27e5427 --- /dev/null +++ b/x-pack/plugins/security_solution/public/assistant/content/conversations/translations.ts @@ -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 TIMELINE_CONVERSATION_TITLE = i18n.translate( + 'xpack.securitySolution.assistant.conversations.timelineConversationTitle', + { + defaultMessage: 'Timeline', + } +); diff --git a/x-pack/plugins/security_solution/public/assistant/content/prompt_contexts/index.tsx b/x-pack/plugins/security_solution/public/assistant/content/prompt_contexts/index.tsx new file mode 100644 index 000000000000000..c5d036dc9cc0277 --- /dev/null +++ b/x-pack/plugins/security_solution/public/assistant/content/prompt_contexts/index.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PromptContext, PromptContextTemplate } from '@kbn/elastic-assistant'; +import { USER_PROMPTS } from '@kbn/elastic-assistant'; +import * as i18n from '../../../common/components/event_details/translations'; +import * as i18nDetections from '../../../detections/pages/detection_engine/rules/translations'; +import { SUMMARY_VIEW } from '../../../common/components/event_details/translations'; + +export const PROMPT_CONTEXT_ALERT_CATEGORY = 'alert'; +export const PROMPT_CONTEXT_EVENT_CATEGORY = 'event'; +export const PROMPT_CONTEXT_DETECTION_RULES_CATEGORY = 'detection-rules'; + +/** + * Global list of PromptContexts intended to be used throughout Security Solution. + * Useful if wanting to see all available PromptContexts in one place, or if needing + * a unique set of categories to reference since the PromptContexts available on + * useAssistantContext are dynamic (not globally registered). + */ +export const PROMPT_CONTEXTS: Record = { + /** + * Alert summary view context, made available on the alert details flyout + */ + PROMPT_CONTEXT_ALERT_CATEGORY: { + category: PROMPT_CONTEXT_ALERT_CATEGORY, + suggestedUserPrompt: USER_PROMPTS.EXPLAIN_THEN_SUMMARIZE_SUGGEST_INVESTIGATION_GUIDE_NON_I18N, + description: i18n.ALERT_SUMMARY_CONTEXT_DESCRIPTION(SUMMARY_VIEW), + tooltip: i18n.ALERT_SUMMARY_VIEW_CONTEXT_TOOLTIP, + }, + /** + * Event summary view context, made available from Timeline events + */ + PROMPT_CONTEXT_EVENT_CATEGORY: { + category: PROMPT_CONTEXT_EVENT_CATEGORY, + suggestedUserPrompt: USER_PROMPTS.EXPLAIN_THEN_SUMMARIZE_SUGGEST_INVESTIGATION_GUIDE_NON_I18N, + description: i18n.EVENT_SUMMARY_CONTEXT_DESCRIPTION('view'), + tooltip: i18n.EVENT_SUMMARY_VIEW_CONTEXT_TOOLTIP, + }, + /** + * Detection Rules context, made available on the Rule Management page when rules are selected + */ + PROMPT_CONTEXT_DETECTION_RULES_CATEGORY: { + category: PROMPT_CONTEXT_DETECTION_RULES_CATEGORY, + suggestedUserPrompt: i18nDetections.EXPLAIN_THEN_SUMMARIZE_RULE_DETAILS, + description: i18nDetections.RULE_MANAGEMENT_CONTEXT_DESCRIPTION, + tooltip: i18nDetections.RULE_MANAGEMENT_CONTEXT_TOOLTIP, + }, +}; diff --git a/x-pack/plugins/security_solution/public/assistant/content/quick_prompts/index.tsx b/x-pack/plugins/security_solution/public/assistant/content/quick_prompts/index.tsx new file mode 100644 index 000000000000000..c35598f8898baad --- /dev/null +++ b/x-pack/plugins/security_solution/public/assistant/content/quick_prompts/index.tsx @@ -0,0 +1,61 @@ +/* + * 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 { QuickPrompt } from '@kbn/elastic-assistant'; +import * as i18n from './translations'; +import { + PROMPT_CONTEXT_ALERT_CATEGORY, + PROMPT_CONTEXT_DETECTION_RULES_CATEGORY, + PROMPT_CONTEXT_EVENT_CATEGORY, +} from '../prompt_contexts'; + +/** + * Global list of QuickPrompts intended to be used throughout Security Solution. + * Useful if wanting to see all available QuickPrompts in one place, or if needing + * to reference when constructing a new chat window to include a QuickPrompt. + */ +export const BASE_SECURITY_QUICK_PROMPTS: QuickPrompt[] = [ + { + title: i18n.ALERT_SUMMARIZATION_TITLE, + prompt: i18n.ALERT_SUMMARIZATION_PROMPT, + color: '#F68FBE', + categories: [PROMPT_CONTEXT_ALERT_CATEGORY], + isDefault: true, + }, + { + title: i18n.RULE_CREATION_TITLE, + prompt: i18n.RULE_CREATION_PROMPT, + categories: [PROMPT_CONTEXT_DETECTION_RULES_CATEGORY], + color: '#7DDED8', + isDefault: true, + }, + { + title: i18n.WORKFLOW_ANALYSIS_TITLE, + prompt: i18n.WORKFLOW_ANALYSIS_PROMPT, + color: '#36A2EF', + isDefault: true, + }, + { + title: i18n.THREAT_INVESTIGATION_GUIDES_TITLE, + prompt: i18n.THREAT_INVESTIGATION_GUIDES_PROMPT, + categories: [PROMPT_CONTEXT_EVENT_CATEGORY], + color: '#F3D371', + isDefault: true, + }, + { + title: i18n.SPL_QUERY_CONVERSION_TITLE, + prompt: i18n.SPL_QUERY_CONVERSION_PROMPT, + color: '#BADA55', + isDefault: true, + }, + { + title: i18n.AUTOMATION_TITLE, + prompt: i18n.AUTOMATION_PROMPT, + color: '#FFA500', + isDefault: true, + }, +]; diff --git a/x-pack/plugins/security_solution/public/assistant/content/quick_prompts/translations.ts b/x-pack/plugins/security_solution/public/assistant/content/quick_prompts/translations.ts new file mode 100644 index 000000000000000..1d122b0169be2e7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/assistant/content/quick_prompts/translations.ts @@ -0,0 +1,98 @@ +/* + * 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 ALERT_SUMMARIZATION_TITLE = i18n.translate( + 'xpack.securitySolution.assistant.quickPrompts.alertSummarizationTitle', + { + defaultMessage: 'Alert summarization', + } +); + +export const ALERT_SUMMARIZATION_PROMPT = i18n.translate( + 'xpack.securitySolution.assistant.quickPrompts.alertSummarizationPrompt', + { + defaultMessage: + 'As an expert in security operations and incident response, provide a breakdown of the attached alert and summarize what it might mean for my organization.', + } +); + +export const RULE_CREATION_TITLE = i18n.translate( + 'xpack.securitySolution.assistant.quickPrompts.ruleCreationTitle', + { + defaultMessage: 'Query generation', + } +); + +export const RULE_CREATION_PROMPT = i18n.translate( + 'xpack.securitySolution.assistant.quickPrompts.ruleCreationPrompt', + { + defaultMessage: + 'As an expert user of Elastic Security, please generate an accurate and valid EQL query to detect the use case below. Your response should be formatted to be able to use immediately in an Elastic Security timeline or detection rule. If Elastic Security already has a prebuilt rule for the use case, or a similar one, please provide a link to it and describe it.', + } +); + +export const WORKFLOW_ANALYSIS_TITLE = i18n.translate( + 'xpack.securitySolution.assistant.quickPrompts.workflowAnalysisTitle', + { + defaultMessage: 'Workflow suggestions', + } +); + +export const WORKFLOW_ANALYSIS_PROMPT = i18n.translate( + 'xpack.securitySolution.assistant.quickPrompts.workflowAnalysisPrompt', + { + defaultMessage: + 'As an expert user of Elastic Security, please suggest a workflow, with step by step instructions on how to:', + } +); + +export const THREAT_INVESTIGATION_GUIDES_TITLE = i18n.translate( + 'xpack.securitySolution.assistant.quickPrompts.threatInvestigationGuidesTitle', + { + defaultMessage: 'Custom data ingestion helper', + } +); + +export const THREAT_INVESTIGATION_GUIDES_PROMPT = i18n.translate( + 'xpack.securitySolution.assistant.quickPrompts.threatInvestigationGuidesPrompt', + { + defaultMessage: + 'As an expert user of Elastic Security, Elastic Agent, and Ingest pipelines, please list accurate and formatted, step by step instructions on how to ingest the following data using Elastic Agent and Fleet in Kibana and convert it to the Elastic Common Schema:', + } +); + +export const SPL_QUERY_CONVERSION_TITLE = i18n.translate( + 'xpack.securitySolution.assistant.quickPrompts.splQueryConversionTitle', + { + defaultMessage: 'Query conversion', + } +); + +export const SPL_QUERY_CONVERSION_PROMPT = i18n.translate( + 'xpack.securitySolution.assistant.quickPrompts.splQueryConversionPrompt', + { + defaultMessage: + 'I have the following query from a previous SIEM platform. As an expert user of Elastic Security, please suggest an Elastic EQL equivalent. I should be able to copy it immediately into an Elastic security timeline.', + } +); + +export const AUTOMATION_TITLE = i18n.translate( + 'xpack.securitySolution.assistant.quickPrompts.AutomationTitle', + { + defaultMessage: 'Agent integration advice', + } +); + +export const AUTOMATION_PROMPT = i18n.translate( + 'xpack.securitySolution.assistant.quickPrompts.AutomationPrompt', + { + defaultMessage: + 'Which Fleet enabled Elastic Agent integration should I use to collect logs and events from:', + } +); diff --git a/x-pack/plugins/security_solution/public/assistant/get_comments/index.tsx b/x-pack/plugins/security_solution/public/assistant/get_comments/index.tsx new file mode 100644 index 000000000000000..fd0de680ee84cb3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/assistant/get_comments/index.tsx @@ -0,0 +1,47 @@ +/* + * 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 { EuiCommentProps } from '@elastic/eui'; +import type { Conversation } from '@kbn/elastic-assistant'; +import { EuiAvatar, EuiMarkdownFormat, EuiText } from '@elastic/eui'; +import React from 'react'; + +import { CommentActions } from '../comment_actions'; +import * as i18n from './translations'; + +export const getComments = ({ + currentConversation, + lastCommentRef, +}: { + currentConversation: Conversation; + lastCommentRef: React.MutableRefObject; +}): EuiCommentProps[] => + currentConversation.messages.map((message, index) => { + const isUser = message.role === 'user'; + + return { + actions: , + children: + index !== currentConversation.messages.length - 1 ? ( + + {message.content} + + ) : ( + + {message.content} + + + ), + timelineAvatar: isUser ? ( + + ) : ( + + ), + timestamp: i18n.AT(message.timestamp), + username: isUser ? i18n.YOU : i18n.ASSISTANT, + }; + }); diff --git a/x-pack/plugins/security_solution/public/assistant/get_comments/translations.ts b/x-pack/plugins/security_solution/public/assistant/get_comments/translations.ts new file mode 100644 index 000000000000000..2b83d580ef2cdbb --- /dev/null +++ b/x-pack/plugins/security_solution/public/assistant/get_comments/translations.ts @@ -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 ASSISTANT = i18n.translate('xpack.securitySolution.assistant.getComments.assistant', { + defaultMessage: 'Assistant', +}); + +export const AT = (timestamp: string) => + i18n.translate('xpack.securitySolution.assistant.getComments.at', { + defaultMessage: 'at: {timestamp}', + values: { timestamp }, + }); + +export const YOU = i18n.translate('xpack.securitySolution.assistant.getComments.you', { + defaultMessage: 'You', +}); diff --git a/x-pack/plugins/security_solution/public/assistant/helpers.tsx b/x-pack/plugins/security_solution/public/assistant/helpers.tsx new file mode 100644 index 000000000000000..896bb5b13cd6596 --- /dev/null +++ b/x-pack/plugins/security_solution/public/assistant/helpers.tsx @@ -0,0 +1,92 @@ +/* + * 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 { EuiIcon, EuiToolTip } from '@elastic/eui'; +import { analyzeMarkdown } from '@kbn/elastic-assistant'; +import type { Conversation, CodeBlockDetails } from '@kbn/elastic-assistant'; +import React from 'react'; + +import type { TimelineEventsDetailsItem } from '../../common/search_strategy'; +import type { Rule } from '../detection_engine/rule_management/logic'; +import { SendToTimelineButton } from './send_to_timeline'; + +export const LOCAL_STORAGE_KEY = `securityAssistant`; + +export interface QueryField { + field: string; + values: string; +} + +export const SECURITY_ASSISTANT_UI_SETTING_KEY = 'securityAssistant'; + +export const getPromptContextFromDetectionRules = (rules: Rule[]): string => { + const data = rules.map((rule) => `Rule Name:${rule.name}\nRule Description:${rule.description}`); + + return data.join('\n\n'); +}; + +export const getAllFields = (data: TimelineEventsDetailsItem[]): QueryField[] => + data + .filter(({ field }) => !field.startsWith('signal.')) + .map(({ field, values }) => ({ field, values: values?.join(',') ?? '' })); + +export const getFieldsAsCsv = (queryFields: QueryField[]): string => + queryFields.map(({ field, values }) => `${field},${values}`).join('\n'); + +export const getPromptContextFromEventDetailsItem = (data: TimelineEventsDetailsItem[]): string => { + const allFields = getAllFields(data); + + return getFieldsAsCsv(allFields); +}; + +/** + * Augments the messages in a conversation with code block details, including + * the start and end indices of the code block in the message, the type of the + * code block, and the button to add the code block to the timeline. + * + * @param currentConversation + */ +export const augmentMessageCodeBlocks = ( + currentConversation: Conversation +): CodeBlockDetails[][] => { + const cbd = currentConversation.messages.map(({ content }) => analyzeMarkdown(content)); + + return cbd.map((codeBlocks, messageIndex) => + codeBlocks.map((codeBlock, codeBlockIndex) => ({ + ...codeBlock, + controlContainer: document.querySelectorAll( + `.message-${messageIndex} .euiCodeBlock__controls` + )[codeBlockIndex], + button: ( + + + + + + ), + })) + ); +}; diff --git a/x-pack/plugins/security_solution/public/assistant/jest.config.js b/x-pack/plugins/security_solution/public/assistant/jest.config.js new file mode 100644 index 000000000000000..184141b299f4a79 --- /dev/null +++ b/x-pack/plugins/security_solution/public/assistant/jest.config.js @@ -0,0 +1,19 @@ +/* + * 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 = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/x-pack/plugins/security_solution/public/assistant'], + coverageDirectory: + '/target/kibana-coverage/jest/x-pack/plugins/security_solution/public/assistant', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/plugins/security_solution/public/assistant/**/*.{ts,tsx}', + ], + moduleNameMapper: require('../../server/__mocks__/module_name_map'), +}; diff --git a/x-pack/plugins/security_solution/public/assistant/send_to_timeline/index.tsx b/x-pack/plugins/security_solution/public/assistant/send_to_timeline/index.tsx new file mode 100644 index 000000000000000..0c745359ef8e945 --- /dev/null +++ b/x-pack/plugins/security_solution/public/assistant/send_to_timeline/index.tsx @@ -0,0 +1,204 @@ +/* + * 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 { EuiButton, EuiButtonEmpty } from '@elastic/eui'; +import type { Filter } from '@kbn/es-query'; +import { useDispatch } from 'react-redux'; + +import { useDeepEqualSelector } from '../../common/hooks/use_selector'; +import { sourcererSelectors } from '../../common/store'; +import { sourcererActions } from '../../common/store/actions'; +import { inputsActions } from '../../common/store/inputs'; +import { InputsModelId } from '../../common/store/inputs/constants'; +import type { TimeRange } from '../../common/store/inputs/model'; +import { SourcererScopeName } from '../../common/store/sourcerer/model'; +import { TimelineTabs, TimelineId, TimelineType } from '../../../common/types'; +import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../detections/components/alerts_table/translations'; +import type { DataProvider } from '../../timelines/components/timeline/data_providers/data_provider'; +import { useCreateTimeline } from '../../timelines/components/timeline/properties/use_create_timeline'; +import { + applyKqlFilterQuery, + setActiveTabTimeline, + setFilters, + updateDataView, + updateEqlOptions, +} from '../../timelines/store/timeline/actions'; + +export interface SendToTimelineButtonProps { + asEmptyButton: boolean; + dataProviders: DataProvider[] | null; + filters?: Filter[] | null; + timeRange?: TimeRange; + keepDataView?: boolean; + isDisabled?: boolean; +} + +export const SendToTimelineButton: React.FunctionComponent = ({ + asEmptyButton, + children, + dataProviders, + filters, + timeRange, + keepDataView, + ...rest +}) => { + const dispatch = useDispatch(); + + const getDataViewsSelector = useMemo( + () => sourcererSelectors.getSourcererDataViewsSelector(), + [] + ); + const { defaultDataView, signalIndexName } = useDeepEqualSelector((state) => + getDataViewsSelector(state) + ); + + const hasTemplateProviders = + dataProviders && dataProviders.find((provider) => provider.type === 'template'); + + const clearTimeline = useCreateTimeline({ + timelineId: TimelineId.active, + timelineType: hasTemplateProviders ? TimelineType.template : TimelineType.default, + }); + + const configureAndOpenTimeline = useCallback(() => { + if (dataProviders || filters) { + // Reset the current timeline + if (timeRange) { + clearTimeline({ + timeRange, + }); + } else { + clearTimeline(); + } + if (dataProviders) { + // Ensure Security Solution Default DataView is selected (so it's not just alerts) + dispatch( + updateDataView({ + id: TimelineId.active, + dataViewId: 'security-solution-default', + indexNames: ['logs-*'], + }) + ); + + // Added temporary queryType to dataproviders to support EQL/DSL + switch (dataProviders[0].queryType) { + case 'eql': + // is EQL + dispatch( + updateEqlOptions({ + id: TimelineId.active, + field: 'query', + value: dataProviders[0].kqlQuery, + }) + ); + dispatch( + setActiveTabTimeline({ + id: TimelineId.active, + activeTab: TimelineTabs.eql, + }) + ); + break; + case 'kql': + // is KQL + dispatch( + applyKqlFilterQuery({ + id: TimelineId.active, + filterQuery: { + kuery: { + kind: 'kuery', + expression: dataProviders[0].kqlQuery, + }, + serializedQuery: dataProviders[0].kqlQuery, + }, + }) + ); + dispatch( + setActiveTabTimeline({ + id: TimelineId.active, + activeTab: TimelineTabs.query, + }) + ); + break; + case 'dsl': + const filter = { + meta: { + type: 'custom', + disabled: false, + negate: false, + alias: dataProviders[0].name, + key: 'query', + value: dataProviders[0].kqlQuery, + }, + query: JSON.parse(dataProviders[0].kqlQuery), + }; + dispatch(setFilters({ id: TimelineId.active, filters: [filter] })); + dispatch( + setActiveTabTimeline({ + id: TimelineId.active, + activeTab: TimelineTabs.query, + }) + ); + break; + } + } + // Use filters if more than a certain amount of ids for dom performance. + if (filters) { + dispatch( + setFilters({ + id: TimelineId.active, + filters, + }) + ); + } + // Only show detection alerts + // (This is required so the timeline event count matches the prevalence count) + if (!keepDataView) { + dispatch( + sourcererActions.setSelectedDataView({ + id: SourcererScopeName.timeline, + selectedDataViewId: defaultDataView.id, + selectedPatterns: [signalIndexName || ''], + }) + ); + } + // Unlock the time range from the global time range + dispatch(inputsActions.removeLinkTo([InputsModelId.timeline, InputsModelId.global])); + } + }, [ + dataProviders, + clearTimeline, + dispatch, + defaultDataView.id, + signalIndexName, + filters, + timeRange, + keepDataView, + ]); + + return asEmptyButton ? ( + + {children} + + ) : ( + + {children} + + ); +}; + +SendToTimelineButton.displayName = 'SendToTimelineButton'; diff --git a/x-pack/plugins/security_solution/public/assistant/use_conversation_store/index.tsx b/x-pack/plugins/security_solution/public/assistant/use_conversation_store/index.tsx new file mode 100644 index 000000000000000..04321df16ea2951 --- /dev/null +++ b/x-pack/plugins/security_solution/public/assistant/use_conversation_store/index.tsx @@ -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 type { Conversation } from '@kbn/elastic-assistant'; + +import { useLocalStorage } from '../../common/components/local_storage'; +import { LOCAL_STORAGE_KEY } from '../helpers'; +import { BASE_SECURITY_CONVERSATIONS } from '../content/conversations'; + +export interface UseConversationStore { + conversations: Record; + setConversations: React.Dispatch>>; +} + +export const useConversationStore = (): UseConversationStore => { + const [conversations, setConversations] = useLocalStorage>({ + defaultValue: BASE_SECURITY_CONVERSATIONS, + key: LOCAL_STORAGE_KEY, + isInvalidDefault: (valueFromStorage) => { + return !valueFromStorage; + }, + }); + + return { + conversations, + setConversations, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx index 03327cea9ba1e68..5704eab502ed8b3 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx @@ -7,12 +7,10 @@ import React, { useMemo } from 'react'; -import type { BrowserFields } from '../../../../common/search_strategy/index_fields'; -import { SummaryView } from './summary_view'; - import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; - +import type { BrowserFields } from '../../../../common/search_strategy/index_fields'; import { getSummaryRows } from './get_alert_summary_rows'; +import { SummaryView } from './summary_view'; const AlertSummaryViewComponent: React.FC<{ browserFields: BrowserFields; @@ -30,7 +28,7 @@ const AlertSummaryViewComponent: React.FC<{ ); return ( - + ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx index 7fac3083e0b8ba8..e465bfb37407ecb 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx @@ -53,6 +53,7 @@ const enrichedHostIpData: AlertSummaryRow['description'] = { }; const mockCount = 90019001; + jest.mock('../../containers/alerts/use_alert_prevalence', () => ({ useAlertPrevalence: () => ({ loading: false, diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts index 7de58571da846aa..9280b26b96d0835 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts @@ -163,3 +163,51 @@ export const ENDPOINT_COMMANDS = Object.freeze({ defaultMessage: 'executed command {command}', }), }); + +export const SUMMARY_VIEW = i18n.translate('xpack.securitySolution.eventDetails.summaryView', { + defaultMessage: 'summary', +}); + +export const TIMELINE_VIEW = i18n.translate('xpack.securitySolution.eventDetails.timelineView', { + defaultMessage: 'Timeline', +}); + +export const ALERT_SUMMARY_CONVERSATION_ID = i18n.translate( + 'xpack.securitySolution.alertSummaryView.alertSummaryViewConversationId', + { + defaultMessage: 'Alert summary', + } +); + +export const ALERT_SUMMARY_CONTEXT_DESCRIPTION = (view: string) => + i18n.translate('xpack.securitySolution.alertSummaryView.alertSummaryViewContextDescription', { + defaultMessage: 'Alert (from {view})', + values: { view }, + }); + +export const ALERT_SUMMARY_VIEW_CONTEXT_TOOLTIP = i18n.translate( + 'xpack.securitySolution.alertSummaryView.alertSummaryViewContextTooltip', + { + defaultMessage: 'Add this alert as context', + } +); + +export const EVENT_SUMMARY_CONVERSATION_ID = i18n.translate( + 'xpack.securitySolution.alertSummaryView.eventSummaryViewConversationId', + { + defaultMessage: 'Event summary', + } +); + +export const EVENT_SUMMARY_CONTEXT_DESCRIPTION = (view: string) => + i18n.translate('xpack.securitySolution.alertSummaryView.eventSummaryViewContextDescription', { + defaultMessage: 'Event (from {view})', + values: { view }, + }); + +export const EVENT_SUMMARY_VIEW_CONTEXT_TOOLTIP = i18n.translate( + 'xpack.securitySolution.alertSummaryView.eventSummaryViewContextTooltip', + { + defaultMessage: 'Add this event as context', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/local_storage/index.tsx b/x-pack/plugins/security_solution/public/common/components/local_storage/index.tsx index 2e496e5fa10d702..5fcde971ae14694 100644 --- a/x-pack/plugins/security_solution/public/common/components/local_storage/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/local_storage/index.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import type { Dispatch, SetStateAction } from 'react'; import { useCallback, useEffect, useState } from 'react'; import { APP_ID } from '../../../../common/constants'; @@ -23,7 +24,7 @@ export const useLocalStorage = ({ key, plugin = APP_ID, isInvalidDefault, -}: Props): [T, (value: T) => void] => { +}: Props): [T, Dispatch>, Dispatch>] => { const { storage } = useKibana().services; const [initialized, setInitialized] = useState(false); const [_value, _setValue] = useState(defaultValue); @@ -37,10 +38,18 @@ export const useLocalStorage = ({ }, [defaultValue, isInvalidDefault, key, plugin, storage]); const setValue = useCallback( - (value: T) => { - storage.set(`${plugin}.${key}`, value); - - _setValue(value); + (value: T | ((prev: T) => T)) => { + if (typeof value === 'function') { + const updater = value as (prev: T) => T; + _setValue((prevValue) => { + const newValue = updater(prevValue); + storage.set(`${plugin}.${key}`, newValue); + return newValue; + }); + } else { + storage.set(`${plugin}.${key}`, value); + _setValue(value); + } }, [key, plugin, storage] ); @@ -52,5 +61,5 @@ export const useLocalStorage = ({ } }, [initialized, readValueFromLocalStorage]); - return [_value, setValue]; + return [_value, setValue, setInitialized]; }; diff --git a/x-pack/plugins/security_solution/public/common/components/page_wrapper/index.tsx b/x-pack/plugins/security_solution/public/common/components/page_wrapper/index.tsx index 0ece17f9dd297cb..6cf4167ea6d6e70 100644 --- a/x-pack/plugins/security_solution/public/common/components/page_wrapper/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/page_wrapper/index.tsx @@ -5,12 +5,14 @@ * 2.0. */ +import { AssistantOverlay } from '@kbn/elastic-assistant'; import classNames from 'classnames'; import React, { useEffect } from 'react'; import styled from 'styled-components'; import type { CommonProps } from '@elastic/eui'; import { useGlobalFullScreen } from '../../containers/use_full_screen'; +import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; import { AppGlobalStyle } from '../page'; const Wrapper = styled.div` @@ -41,6 +43,7 @@ interface SecuritySolutionPageWrapperProps { const SecuritySolutionPageWrapperComponent: React.FC< SecuritySolutionPageWrapperProps & CommonProps > = ({ children, className, style, noPadding, noTimeline, ...otherProps }) => { + const isAssistantEnabled = useIsExperimentalFeatureEnabled('assistantEnabled'); const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); useEffect(() => { setGlobalFullScreen(false); // exit full screen mode on page load @@ -57,6 +60,7 @@ const SecuritySolutionPageWrapperComponent: React.FC< {children} + {isAssistantEnabled && } ); }; diff --git a/x-pack/plugins/security_solution/public/common/mock/storybook_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/storybook_providers.tsx index dc92b1892541d23..c40fd2b6cebf34b 100644 --- a/x-pack/plugins/security_solution/public/common/mock/storybook_providers.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/storybook_providers.tsx @@ -5,15 +5,18 @@ * 2.0. */ +import { AssistantProvider } from '@kbn/elastic-assistant'; import { euiLightVars } from '@kbn/ui-theme'; import React from 'react'; import { Provider as ReduxStoreProvider } from 'react-redux'; import { BehaviorSubject, Subject } from 'rxjs'; import { ThemeProvider } from 'styled-components'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; import type { CoreStart } from '@kbn/core/public'; import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public'; import { I18nProvider } from '@kbn/i18n-react'; import { CellActionsProvider } from '@kbn/cell-actions'; +import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; import { createStore } from '../store'; import { mockGlobalState } from './global_state'; import { SUB_PLUGINS_REDUCER } from './utils'; @@ -52,13 +55,27 @@ const KibanaReactContext = createKibanaReactContext(coreMock); */ export const StorybookProviders: React.FC = ({ children }) => { const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + const actionTypeRegistry = actionTypeRegistryMock.create(); + const mockGetInitialConversations = jest.fn(() => ({})); + const mockGetComments = jest.fn(() => []); + const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' }); + return ( Promise.resolve([])}> ({ eui: euiLightVars, darkMode: false })}> - {children} + + {children} + diff --git a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx index d0901071063ab8a..89f5a6351411f66 100644 --- a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { AssistantProvider } from '@kbn/elastic-assistant'; import { euiDarkVars } from '@kbn/ui-theme'; import { I18nProvider } from '@kbn/i18n-react'; @@ -16,11 +17,13 @@ import type { Store } from 'redux'; import { BehaviorSubject } from 'rxjs'; import { ThemeProvider } from 'styled-components'; import type { Capabilities } from '@kbn/core/public'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { Action } from '@kbn/ui-actions-plugin/public'; import { CellActionsProvider } from '@kbn/cell-actions'; import { ExpandableFlyoutProvider } from '@kbn/expandable-flyout'; +import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; import { ConsoleManager } from '../../management/components/console'; import type { State } from '../store'; import { createStore } from '../store'; @@ -61,22 +64,36 @@ export const TestProvidersComponent: React.FC = ({ cellActions = [], }) => { const queryClient = new QueryClient(); + const actionTypeRegistry = actionTypeRegistryMock.create(); + const mockGetInitialConversations = jest.fn(() => ({})); + const mockGetComments = jest.fn(() => []); + const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' }); + return ( ({ eui: euiDarkVars, darkMode: true })}> - - - - Promise.resolve(cellActions)} - > - {children} - - - - + + + + + Promise.resolve(cellActions)} + > + {children} + + + + + @@ -93,28 +110,46 @@ const TestProvidersWithPrivilegesComponent: React.FC = ({ store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage), onDragEnd = jest.fn(), cellActions = [], -}) => ( - - - - ({ eui: euiDarkVars, darkMode: true })}> - - Promise.resolve(cellActions)}> - {children} - - - - - - -); +}) => { + const actionTypeRegistry = actionTypeRegistryMock.create(); + const mockGetInitialConversations = jest.fn(() => ({})); + const mockGetComments = jest.fn(() => []); + const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' }); + + return ( + + + + ({ eui: euiDarkVars, darkMode: true })}> + + + Promise.resolve(cellActions)} + > + {children} + + + + + + + + ); +}; export const TestProviders = React.memo(TestProvidersComponent); export const TestProvidersWithPrivileges = React.memo(TestProvidersWithPrivilegesComponent); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx index 03231f2030eb2b9..1e2ae5b88fba7f5 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx @@ -10,7 +10,6 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; import { APP_UI_ID } from '../../../../../common/constants'; import { SecurityPageName } from '../../../../app/types'; -import { HeaderPage } from '../../../../common/components/header_page'; import { ImportDataModal } from '../../../../common/components/import_data_modal'; import { SecuritySolutionLinkButton } from '../../../../common/components/links'; import { getDetectionEngineUrl } from '../../../../common/components/link_to/redirect_to_detection_engine'; @@ -43,6 +42,7 @@ import * as i18n from '../../../../detections/pages/detection_engine/rules/trans import { useInvalidateFetchRuleManagementFiltersQuery } from '../../../rule_management/api/hooks/use_fetch_rule_management_filters_query'; import { MaintenanceWindowCallout } from '../../components/maintenance_window_callout/maintenance_window_callout'; +import { SuperHeader } from './super_header'; const RulesPageComponent: React.FC = () => { const [isImportModalVisible, showImportModal, hideImportModal] = useBoolState(); @@ -114,7 +114,7 @@ const RulesPageComponent: React.FC = () => { - + @@ -155,7 +155,7 @@ const RulesPageComponent: React.FC = () => { - + {(prePackagedRuleStatus === 'ruleNeedUpdate' || prePackagedTimelineStatus === 'timelineNeedUpdate') && ( diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/super_header.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/super_header.tsx new file mode 100644 index 000000000000000..e7bdaca00f2d112 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/super_header.tsx @@ -0,0 +1,61 @@ +/* + * 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 { NewChat } from '@kbn/elastic-assistant'; +import React, { useCallback, useMemo } from 'react'; + +import { getPromptContextFromDetectionRules } from '../../../../assistant/helpers'; +import { HeaderPage } from '../../../../common/components/header_page'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { useRulesTableContext } from '../../components/rules_table/rules_table/rules_table_context'; +import * as i18n from '../../../../detections/pages/detection_engine/rules/translations'; + +export const SuperHeader: React.FC<{ children: React.ReactNode }> = React.memo(({ children }) => { + const isAssistantEnabled = useIsExperimentalFeatureEnabled('assistantEnabled'); + const memoizedChildren = useMemo(() => children, [children]); + // Rules state + const { + state: { rules, selectedRuleIds }, + } = useRulesTableContext(); + + const selectedRules = useMemo( + () => rules.filter((rule) => selectedRuleIds.includes(rule.id)), + [rules, selectedRuleIds] + ); + + const getPromptContext = useCallback( + async () => getPromptContextFromDetectionRules(selectedRules), + [selectedRules] + ); + + return ( + + {i18n.PAGE_TITLE}{' '} + {isAssistantEnabled && selectedRules.length > 0 && ( + + {'🪄✨'} + + )} + + } + > + {memoizedChildren} + + ); +}); + +SuperHeader.displayName = 'NewChatComponent'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index 487a6176969d191..3ad119a67c4ac50 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -1140,3 +1140,33 @@ export const SAVED_QUERY_LOAD_ERROR_TOAST = i18n.translate( defaultMessage: 'Failed to load the saved query', } ); + +// Prompt Context i18n +export const RULE_MANAGEMENT_CONTEXT_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleManagement.ruleManagementContextDescription', + { + defaultMessage: 'Selected Detection Rules', + } +); + +export const EXPLAIN_THEN_SUMMARIZE_RULE_DETAILS = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleManagement.explainThenSummarizeRuleDetails', + { + defaultMessage: + "Please explain the selected rules above. For each rule, highlight why they are relevant, the query as published on Elastic's detection rules repository and an in-depth explanation of it, and what they typically mean for an organization if detected.", + } +); + +export const DETECTION_RULES_CONVERSATION_ID = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleManagement.detectionRulesConversationId', + { + defaultMessage: 'Detection Rules', + } +); + +export const RULE_MANAGEMENT_CONTEXT_TOOLTIP = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleManagement.ruleManagementContextTooltip', + { + defaultMessage: 'Add this alert as context', + } +); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/reponse_actions_history.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/reponse_actions_history.cy.ts index a93562cd57785ed..b829e6e21c58590 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/reponse_actions_history.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/reponse_actions_history.cy.ts @@ -31,7 +31,8 @@ describe('Response actions history page', () => { } }); - it('retains expanded action details on page reload', () => { + // Flakey, example build failure: https://buildkite.com/elastic/kibana-pull-request/builds/132245 + it.skip('retains expanded action details on page reload', () => { cy.visit(`/app/security/administration/response_actions_history`); cy.getByTestSubj('response-actions-list-expand-button').eq(3).click(); // 4th row on 1st page cy.getByTestSubj('response-actions-list-details-tray').should('exist'); diff --git a/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx b/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx index a04c24ea2045b21..f27c239320ddc1c 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx @@ -38,6 +38,7 @@ import { useLocalStorage } from '../../common/components/local_storage'; import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { DEFAULT_BYTES_FORMAT, DEFAULT_NUMBER_FORMAT } from '../../../common/constants'; import { useSourcererDataView } from '../../common/containers/sourcerer'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; import { KibanaServices, useGetUserCasesPermissions, @@ -128,6 +129,7 @@ const renderOption = ( ); const DataQualityComponent: React.FC = () => { + const isAssistantEnabled = useIsExperimentalFeatureEnabled('assistantEnabled'); const httpFetch = KibanaServices.get().http.fetch; const theme = useTheme(); const toasts = useToasts(); @@ -235,6 +237,7 @@ const DataQualityComponent: React.FC = () => { getGroupByFieldsOnClick={getGroupByFieldsOnClick} httpFetch={httpFetch} ilmPhases={ilmPhases} + isAssistantEnabled={isAssistantEnabled} lastChecked={lastChecked} openCreateCaseFlyout={openCreateCaseFlyout} patterns={alertsAndSelectedPatterns} diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap index c5bc7821278b580..8304dd664ee6337 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap @@ -68,6 +68,13 @@ Array [ />
+
+
+
, @@ -181,7 +188,15 @@ exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should >
+ > +
+
+
+
diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx index 41ffaa529d0fdaf..5f4e7a087b02ecd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx @@ -5,6 +5,8 @@ * 2.0. */ +import { NewChatById } from '@kbn/elastic-assistant'; +import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; import { isEmpty } from 'lodash/fp'; import { EuiButtonIcon, @@ -20,7 +22,6 @@ import { import React from 'react'; import styled from 'styled-components'; -import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { getAlertDetailsUrl } from '../../../../common/components/link_to'; import { @@ -32,6 +33,10 @@ import type { BrowserFields } from '../../../../common/containers/source'; import { EventDetails } from '../../../../common/components/event_details/event_details'; import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline'; import * as i18n from './translations'; +import { + ALERT_SUMMARY_CONVERSATION_ID, + EVENT_SUMMARY_CONVERSATION_ID, +} from '../../../../common/components/event_details/translations'; import { PreferenceFormattedDate } from '../../../../common/components/formatted_date'; import { SecurityPageName } from '../../../../../common/constants'; import { useGetAlertDetailsFlyoutLink } from './use_get_alert_details_flyout_link'; @@ -58,6 +63,7 @@ interface ExpandableEventTitleProps { eventIndex: string; isAlert: boolean; loading: boolean; + promptContextId?: string; ruleName?: string; timestamp: string; handleOnEventClosed?: HandleOnEventClosed; @@ -80,7 +86,17 @@ const StyledEuiFlexItem = styled(EuiFlexItem)` `; export const ExpandableEventTitle = React.memo( - ({ eventId, eventIndex, isAlert, loading, handleOnEventClosed, ruleName, timestamp }) => { + ({ + eventId, + eventIndex, + isAlert, + loading, + handleOnEventClosed, + promptContextId, + ruleName, + timestamp, + }) => { + const isAssistantEnabled = useIsExperimentalFeatureEnabled('assistantEnabled'); const isAlertDetailsPageEnabled = useIsExperimentalFeatureEnabled('alertDetailsPageEnabled'); const { onClick } = useGetSecuritySolutionLinkProps()({ deepLinkId: SecurityPageName.alerts, @@ -134,19 +150,35 @@ export const ExpandableEventTitle = React.memo( /> )} - {isAlert && alertDetailsLink && ( - - {(copy) => ( - - {i18n.SHARE_ALERT} - + + + {isAssistantEnabled && promptContextId != null && ( + + + )} - - )} + {isAlert && alertDetailsLink && ( + + + {(copy) => ( + + {i18n.SHARE_ALERT} + + )} + + + )} + + diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx index eac84c185369363..8b3d50d849c4bf4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx @@ -18,6 +18,7 @@ interface FlyoutHeaderComponentProps { isHostIsolationPanelOpen: boolean; isolateAction: 'isolateHost' | 'unisolateHost'; loading: boolean; + promptContextId?: string; ruleName: string; showAlertDetails: () => void; timestamp: string; @@ -30,6 +31,7 @@ const FlyoutHeaderContentComponent = ({ isHostIsolationPanelOpen, isolateAction, loading, + promptContextId, ruleName, showAlertDetails, timestamp, @@ -44,6 +46,7 @@ const FlyoutHeaderContentComponent = ({ eventIndex={eventIndex} isAlert={isAlert} loading={loading} + promptContextId={promptContextId} ruleName={ruleName} timestamp={timestamp} /> @@ -60,6 +63,7 @@ const FlyoutHeaderComponent = ({ isHostIsolationPanelOpen, isolateAction, loading, + promptContextId, ruleName, showAlertDetails, timestamp, @@ -73,6 +77,7 @@ const FlyoutHeaderComponent = ({ isHostIsolationPanelOpen={isHostIsolationPanelOpen} isolateAction={isolateAction} loading={loading} + promptContextId={promptContextId} ruleName={ruleName} showAlertDetails={showAlertDetails} timestamp={timestamp} diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index 957bd82e675c15f..eda57d0a4922247 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -5,11 +5,14 @@ * 2.0. */ +import { USER_PROMPTS, useAssistantOverlay } from '@kbn/elastic-assistant'; import { EuiSpacer, EuiFlyoutBody } from '@elastic/eui'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import deepEqual from 'fast-deep-equal'; import type { EntityType } from '@kbn/timelines-plugin/common'; + +import { getPromptContextFromEventDetailsItem } from '../../../../assistant/helpers'; import type { BrowserFields } from '../../../../common/containers/source'; import { ExpandableEvent, ExpandableEventTitle } from './expandable_event'; import { useTimelineEventsDetails } from '../../../containers/details'; @@ -18,9 +21,20 @@ import type { RunTimeMappings } from '../../../../common/store/sourcerer/model'; import { useHostIsolationTools } from './use_host_isolation_tools'; import { FlyoutBody, FlyoutHeader, FlyoutFooter } from './flyout'; import { useBasicDataFromDetailsData, getAlertIndexAlias } from './helpers'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { useSpaceId } from '../../../../common/hooks/use_space_id'; import { EndpointIsolateSuccess } from '../../../../common/components/endpoint/host_isolation'; import { HostIsolationPanel } from '../../../../detections/components/host_isolation'; +import { + ALERT_SUMMARY_CONVERSATION_ID, + ALERT_SUMMARY_CONTEXT_DESCRIPTION, + ALERT_SUMMARY_VIEW_CONTEXT_TOOLTIP, + EVENT_SUMMARY_CONVERSATION_ID, + EVENT_SUMMARY_CONTEXT_DESCRIPTION, + EVENT_SUMMARY_VIEW_CONTEXT_TOOLTIP, + SUMMARY_VIEW, + TIMELINE_VIEW, +} from '../../../../common/components/event_details/translations'; interface EventDetailsPanelProps { browserFields: BrowserFields; @@ -39,6 +53,8 @@ interface EventDetailsPanelProps { isReadOnly?: boolean; } +const useAssistantNoop = () => ({ promptContextId: undefined }); + const EventDetailsPanelComponent: React.FC = ({ browserFields, entityType = 'events', // Default to events so only alerts have to pass entityType in @@ -51,6 +67,9 @@ const EventDetailsPanelComponent: React.FC = ({ scopeId, isReadOnly, }) => { + const isAssistantEnabled = useIsExperimentalFeatureEnabled('assistantEnabled'); + // TODO: changing feature flags requires a hard refresh to take effect, but this temporary workaround technically violates the rules of hooks: + const useAssistant = isAssistantEnabled ? useAssistantOverlay : useAssistantNoop; const currentSpaceId = useSpaceId(); const { indexName } = expandedEvent; const eventIndex = getAlertIndexAlias(indexName, currentSpaceId) ?? indexName; @@ -76,6 +95,23 @@ const EventDetailsPanelComponent: React.FC = ({ const { alertId, isAlert, hostName, ruleName, timestamp } = useBasicDataFromDetailsData(detailsData); + const view = useMemo(() => (isFlyoutView ? SUMMARY_VIEW : TIMELINE_VIEW), [isFlyoutView]); + + const getPromptContext = useCallback( + async () => getPromptContextFromEventDetailsItem(detailsData ?? []), + [detailsData] + ); + + const { promptContextId } = useAssistant( + isAlert ? 'alert' : 'event', + isAlert ? ALERT_SUMMARY_CONVERSATION_ID : EVENT_SUMMARY_CONVERSATION_ID, + isAlert ? ALERT_SUMMARY_CONTEXT_DESCRIPTION(view) : EVENT_SUMMARY_CONTEXT_DESCRIPTION(view), + getPromptContext, + null, + USER_PROMPTS.EXPLAIN_THEN_SUMMARIZE_SUGGEST_INVESTIGATION_GUIDE_NON_I18N, + isAlert ? ALERT_SUMMARY_VIEW_CONTEXT_TOOLTIP : EVENT_SUMMARY_VIEW_CONTEXT_TOOLTIP + ); + const header = useMemo( () => isFlyoutView || isHostIsolationPanelOpen ? ( @@ -89,6 +125,7 @@ const EventDetailsPanelComponent: React.FC = ({ ruleName={ruleName} showAlertDetails={showAlertDetails} timestamp={timestamp} + promptContextId={promptContextId} /> ) : ( = ({ ruleName={ruleName} timestamp={timestamp} handleOnEventClosed={handleOnEventClosed} + promptContextId={promptContextId} /> ), [ + isFlyoutView, + isHostIsolationPanelOpen, expandedEvent.eventId, eventIndex, - handleOnEventClosed, isAlert, - isFlyoutView, - isHostIsolationPanelOpen, isolateAction, loading, ruleName, showAlertDetails, timestamp, + handleOnEventClosed, + promptContextId, ] ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_provider.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_provider.ts index 3ed66744c760fc8..2778874c688e57c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_provider.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_provider.ts @@ -4,6 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import type { QueryType } from '@kbn/elastic-assistant'; + import type { PrimitiveOrArrayOfPrimitives } from '../../../../common/lib/kuery'; /** Represents the Timeline data providers */ @@ -67,6 +69,8 @@ export interface DataProvider { * Array of multiple values for a field */ valueArray?: string[] | null; + + queryType?: QueryType; } export type DataProvidersAnd = Pick>; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx index 661e38aaa3f40d1..ad20dbf1732d0f4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx @@ -6,6 +6,8 @@ */ import { EuiBadge, EuiSkeletonText, EuiTabs, EuiTab } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { Assistant } from '@kbn/elastic-assistant'; import { isEmpty } from 'lodash/fp'; import React, { lazy, memo, Suspense, useCallback, useEffect, useMemo } from 'react'; import { useDispatch } from 'react-redux'; @@ -33,7 +35,9 @@ import { getEventIdToNoteIdsSelector, } from './selectors'; import * as i18n from './translations'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { useLicense } from '../../../../common/hooks/use_license'; +import { TIMELINE_CONVERSATION_TITLE } from '../../../../assistant/content/conversations/translations'; const HideShowContainer = styled.div.attrs<{ $isVisible: boolean; isOverflowYScroll: boolean }>( ({ $isVisible = false, isOverflowYScroll = false }) => ({ @@ -46,6 +50,11 @@ const HideShowContainer = styled.div.attrs<{ $isVisible: boolean; isOverflowYScr flex: 1; `; +const AssistantTabContainer = styled.div` + overflow-y: auto; + width: 100%; +`; + const QueryTabContent = lazy(() => import('../query_tab_content')); const EqlTabContent = lazy(() => import('../eql_tab_content')); const GraphTabContent = lazy(() => import('../graph_tab_content')); @@ -130,10 +139,39 @@ const PinnedTab: React.FC<{ )); PinnedTab.displayName = 'PinnedTab'; -type ActiveTimelineTabProps = BasicTimelineTab & { activeTimelineTab: TimelineTabs }; +const AssistantTab: React.FC<{ + renderCellValue: (props: CellValueElementProps) => React.ReactNode; + rowRenderers: RowRenderer[]; + timelineId: TimelineId; + shouldRefocusPrompt: boolean; +}> = memo(({ renderCellValue, rowRenderers, timelineId, shouldRefocusPrompt }) => ( + }> + + + + +)); + +AssistantTab.displayName = 'AssistantTab'; + +type ActiveTimelineTabProps = BasicTimelineTab & { + activeTimelineTab: TimelineTabs; + showTimeline: boolean; +}; const ActiveTimelineTab = memo( - ({ activeTimelineTab, renderCellValue, rowRenderers, timelineId, timelineType }) => { + ({ + activeTimelineTab, + renderCellValue, + rowRenderers, + timelineId, + timelineType, + showTimeline, + }) => { + const isAssistantEnabled = useIsExperimentalFeatureEnabled('assistantEnabled'); const getTab = useCallback( (tab: TimelineTabs) => { switch (tab) { @@ -202,6 +240,25 @@ const ActiveTimelineTab = memo( > {isGraphOrNotesTabs && getTab(activeTimelineTab)} + {isAssistantEnabled && ( + + )} ); } @@ -239,6 +296,7 @@ const TabsContentComponent: React.FC = ({ sessionViewConfig, timelineDescription, }) => { + const isAssistantEnabled = useIsExperimentalFeatureEnabled('assistantEnabled'); const dispatch = useDispatch(); const getActiveTab = useMemo(() => getActiveTabSelector(), []); const getShowTimeline = useMemo(() => getShowTimelineSelector(), []); @@ -309,6 +367,10 @@ const TabsContentComponent: React.FC = ({ setActiveTab(TimelineTabs.session); }, [setActiveTab]); + const setSecurityAssistantAsActiveTab = useCallback(() => { + setActiveTab(TimelineTabs.securityAssistant); + }, [setActiveTab]); + useEffect(() => { if (!graphEventId && activeTab === TimelineTabs.graph) { setQueryAsActiveTab(); @@ -389,6 +451,17 @@ const TabsContentComponent: React.FC = ({ )} + {isAssistantEnabled && ( + + {i18n.SECURITY_ASSISTANT} + + )} )} @@ -399,6 +472,7 @@ const TabsContentComponent: React.FC = ({ timelineId={timelineId} timelineType={timelineType} timelineDescription={timelineDescription} + showTimeline={showTimeline} /> ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/translations.ts index e3a53675389b73e..73baeb60ce4f470 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/translations.ts @@ -25,6 +25,13 @@ export const ANALYZER_TAB = i18n.translate( } ); +export const ASSISTANT_TAB = i18n.translate( + 'xpack.securitySolution.timeline.tabs.assistantTabTitle', + { + defaultMessage: 'Security assistant', + } +); + export const NOTES_TAB = i18n.translate( 'xpack.securitySolution.timeline.tabs.notesTabTimelineTitle', { @@ -39,6 +46,13 @@ export const PINNED_TAB = i18n.translate( } ); +export const SECURITY_ASSISTANT = i18n.translate( + 'xpack.securitySolution.timeline.tabs.securityAssistantTimelineTitle', + { + defaultMessage: 'Security Assistant', + } +); + export const SESSION_TAB = i18n.translate( 'xpack.securitySolution.timeline.tabs.sessionTabTimelineTitle', { diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index 04e915797a7d431..46ba02332e5c38c 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -85,11 +85,13 @@ "@kbn/kibana-react-plugin", "@kbn/core-chrome-browser", "@kbn/ecs-data-quality-dashboard", + "@kbn/elastic-assistant", "@kbn/data-views-plugin", "@kbn/datemath", "@kbn/ui-theme", "@kbn/securitysolution-list-api", "@kbn/core-http-browser", + "@kbn/core-http-browser-mocks", "@kbn/securitysolution-list-hooks", "@kbn/securitysolution-exception-list-components", "@kbn/rison", diff --git a/yarn.lock b/yarn.lock index bd274c9f906d1ba..62d8af3407ffa74 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4019,6 +4019,10 @@ version "0.0.0" uid "" +"@kbn/elastic-assistant@link:x-pack/packages/kbn-elastic-assistant": + version "0.0.0" + uid "" + "@kbn/elasticsearch-client-plugin@link:test/plugin_functional/plugins/elasticsearch_client_plugin": version "0.0.0" uid ""