From 578fb89ea890434c6efd812d944eae6483f15b5e Mon Sep 17 00:00:00 2001 From: Andrew Macri Date: Thu, 25 May 2023 19:34:07 -0600 Subject: [PATCH] [Security Solution] Security Assistant: Reduces the public API surface & improves code coverage (#15) ## [Security Solution] Security Assistant: Reduces the public API surface & improves code coverage - Reduces the public API surface - improves code coverage --- .../assistant/context_pills/index.test.tsx | 122 ++++++++++++ .../impl/assistant/context_pills/index.tsx | 1 + .../impl/assistant/helpers.ts | 62 ------ .../impl/assistant/index.tsx | 5 +- .../impl/assistant/prompt/helpers.test.ts | 178 ++++++++++++++++++ .../impl/assistant/prompt/helpers.ts | 60 ++++++ .../impl/assistant/prompt_context/types.ts | 37 ++-- .../assistant/prompt_editor/helpers.test.tsx | 28 +++ .../assistant/prompt_editor/index.test.tsx | 108 +++++++++++ .../impl/assistant/prompt_editor/index.tsx | 30 ++- .../selected_prompt_contexts/index.test.tsx | 143 ++++++++++++++ .../selected_prompt_contexts/index.tsx | 52 +++-- .../selected_prompt_contexts/translations.ts | 15 ++ .../system_prompt/helpers.test.tsx | 60 ++++++ .../prompt_editor/system_prompt/helpers.tsx | 8 +- .../system_prompt/index.test.tsx | 121 ++++++++++++ .../prompt_editor/system_prompt/index.tsx | 126 +++++-------- .../select_system_prompt/index.test.tsx | 117 ++++++++++++ .../select_system_prompt/index.tsx | 108 +++++++++++ .../system_prompt/translations.ts | 4 +- .../impl/assistant/translations.ts | 3 + .../assistant/use_assistant_overlay/index.tsx | 81 +++++--- .../impl/assistant_context/index.test.tsx | 14 +- .../impl/assistant_context/index.tsx | 26 ++- .../impl/connectorland/translations.ts | 43 +---- .../content/prompts/welcome/translations.ts | 49 +---- .../impl/magic_button/index.tsx | 47 +++++ .../impl/mock/api_config/index.ts | 20 ++ .../impl/mock/prompt_context/index.ts | 24 +++ .../impl/mock/system_prompt/index.ts | 23 +++ .../mock/test_providers/test_providers.tsx | 51 +++++ .../impl/mock/user_prompt/index.ts | 16 ++ .../impl}/new_chat/index.tsx | 19 +- .../impl}/new_chat/translations.ts | 9 +- .../use_assistant_context_registry/index.tsx | 21 --- .../packages/kbn-elastic-assistant/index.ts | 52 ++--- .../kbn-elastic-assistant/tsconfig.json | 11 +- .../components/event_details/translations.ts | 2 +- .../pages/rule_management/super_header.tsx | 46 ++--- .../event_details/expandable_event.tsx | 10 +- .../side_panel/event_details/index.tsx | 22 +-- .../plugins/security_solution/tsconfig.json | 2 +- 42 files changed, 1531 insertions(+), 445 deletions(-) create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/context_pills/index.test.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.test.ts create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/helpers.test.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.test.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/selected_prompt_contexts/index.test.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/selected_prompt_contexts/translations.ts create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/helpers.test.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.test.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/select_system_prompt/index.test.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/select_system_prompt/index.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/magic_button/index.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/mock/api_config/index.ts create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/mock/prompt_context/index.ts create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/mock/system_prompt/index.ts create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/mock/user_prompt/index.ts rename x-pack/{plugins/security_solution/public/assistant => packages/kbn-elastic-assistant/impl}/new_chat/index.tsx (62%) rename x-pack/{plugins/security_solution/public/assistant => packages/kbn-elastic-assistant/impl}/new_chat/translations.ts (55%) delete mode 100644 x-pack/packages/kbn-elastic-assistant/impl/use_assistant_context_registry/index.tsx 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 0000000000000..75e3d4e015d45 --- /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 index c5dd57b8e3eb2..525b2bf85b583 100644 --- 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 @@ -49,6 +49,7 @@ const ContextPillsComponent: React.FC = ({ { }; } }; - -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, id }) => { - const promptContext = await getPromptContext(); - - return `\n\n${SYSTEM_PROMPT_CONTEXT_NON_I18N(promptContext)}\n\n`; - }) - ); - - return { - content: `${ - isNewChat ? `${selectedSystemPrompt?.content ?? ''}` : `${promptContextsContent}\n\n` - } - -${promptContextsContent} - -${promptText}`, - role: 'user', // we are combining the system and user messages into one message - timestamp: new Date().toLocaleString(), - }; -} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx index c44e8b27158d7..5233ef650a5c9 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx @@ -23,7 +23,7 @@ import styled from 'styled-components'; import { createPortal } from 'react-dom'; import { css } from '@emotion/react'; -import { getCombinedMessage, getMessageFromRawResponse } from './helpers'; +import { getMessageFromRawResponse } from './helpers'; import { SettingsPopover } from './settings_popover'; import { useAssistantContext } from '../assistant_context'; @@ -36,7 +36,7 @@ import { useSendMessages } from './use_send_messages'; import type { Message } from '../assistant_context/types'; import { ConversationSelector } from './conversation_selector'; import { PromptEditor } from './prompt_editor'; -import { getDefaultSystemPrompt, getSuperheroPrompt } from './prompt/helpers'; +import { getCombinedMessage, getDefaultSystemPrompt, getSuperheroPrompt } from './prompt/helpers'; import * as i18n from './translations'; import type { Prompt } from './types'; import { getPromptById } from './prompt_editor/helpers'; @@ -47,6 +47,7 @@ import { WELCOME_CONVERSATION_ID } from './use_conversation/sample_conversations const CommentsContainer = styled.div` max-height: 600px; + max-width: 100%; overflow-y: scroll; `; 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 0000000000000..6896c153b563e --- /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 answers questions about Elastic Security. If you don't know the answer, don't try to make one up. +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 answers questions about Elastic Security. If you don't know the answer, don't try to make one up. +You have the personality of a mutant superhero who says \"bub\" a lot. +Use the following context to answer questions:`, + id: 'CB9FA555-B59F-4F71-AFF9-8A891AC5BC28', + name: 'superhero 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 index 916ed2e9587e8..40e5b41805af0 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts @@ -5,14 +5,74 @@ * 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, 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 index ef0b5f929f784..f9dac27233451 100644 --- 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 @@ -7,6 +7,9 @@ import type { ReactNode } from 'react'; +/** + * helps the Elastic Assistant display the most relevant user prompts + */ export type PromptContextCategory = | 'alert' | 'alerts' @@ -16,41 +19,47 @@ export type PromptContextCategory = | string; /** - * This interface is used to pass context to the Security Assistant, - * for the purpose of building prompts + * 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 | etc` + * The category of data, e.g. `alert | alerts | event | events | string` * - * `category` helps the Security Assistant display the most relevant prompts + * `category` helps the Elastic Assistant display the most relevant user prompts */ category: PromptContextCategory; /** - * The Security Assistant will display this **short**, static description - * of the prompt context to the user + * The Elastic Assistant will display this **short**, static description + * in the context pill */ description: string; /** - * The Security Assistant will invoke this function to retrieve the context, - * which will be included in a prompt + * 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; - /** - * An optional user prompt that's filled in, but not sent, when the Security Assistant opens - */ - suggestedUserPrompt?: string; - /** * A unique identifier for this prompt context */ id: string; + /** + * An optional user prompt that's filled in, but not sent, when the Elastic Assistant opens + */ + suggestedUserPrompt?: string; /** - * The Security Assistant will display this tooltip content when the user hovers over the context + * The Elastic Assistant will display this tooltip when the user hovers over the context pill */ tooltip: ReactNode; } 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 0000000000000..c055d9bd6bb95 --- /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/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.test.tsx new file mode 100644 index 0000000000000..b9e7bb513c03f --- /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 index 28f05f7ad862b..593623478acad 100644 --- 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 @@ -13,12 +13,11 @@ import styled from 'styled-components'; import type { PromptContext } from '../prompt_context/types'; import { SystemPrompt } from './system_prompt'; import type { Prompt } from '../types'; -import { getPromptById } from './helpers'; import * as i18n from './translations'; import { SelectedPromptContexts } from './selected_prompt_contexts'; -interface Props { +export interface Props { isNewConversation: boolean; promptContexts: Record; promptTextPreview: string; @@ -43,15 +42,6 @@ const PromptEditorComponent: React.FC = ({ setSelectedSystemPromptId, systemPrompts, }) => { - const selectedSystemPrompt = useMemo( - () => - getPromptById({ - id: selectedSystemPromptId ?? '', - prompts: systemPrompts, - }), - [selectedSystemPromptId, systemPrompts] - ); - const commentBody = useMemo( () => ( <> @@ -70,8 +60,7 @@ const PromptEditorComponent: React.FC = ({ setSelectedPromptContextIds={setSelectedPromptContextIds} /> - - {selectedSystemPrompt != null && <>{'\n'}} + {promptTextPreview} @@ -81,7 +70,6 @@ const PromptEditorComponent: React.FC = ({ promptContexts, promptTextPreview, selectedPromptContextIds, - selectedSystemPrompt, selectedSystemPromptId, setSelectedPromptContextIds, setSelectedSystemPromptId, @@ -94,11 +82,19 @@ const PromptEditorComponent: React.FC = ({ { children: commentBody, event: ( - + {i18n.EDITING_PROMPT} ), - timelineAvatar: , + timelineAvatar: ( + + ), timelineAvatarAriaLabel: i18n.YOU, username: i18n.YOU, }, @@ -106,7 +102,7 @@ const PromptEditorComponent: React.FC = ({ [commentBody] ); - return ; + return ; }; PromptEditorComponent.displayName = '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 0000000000000..4ab4b708a68c8 --- /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 index 4be6d706bbe37..58388925bb478 100644 --- 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 @@ -8,10 +8,11 @@ import { EuiAccordion, EuiButtonIcon, + EuiCodeBlock, EuiFlexGroup, EuiFlexItem, EuiSpacer, - EuiText, + EuiToolTip, } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; @@ -20,12 +21,14 @@ 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 PromptContextText = styled(EuiText)` - white-space: pre-line; +const PromptContextContainer = styled.div` + max-width: 60vw; + overflow-x: auto; `; -interface Props { +export interface Props { isNewConversation: boolean; promptContexts: Record; selectedPromptContextIds: string[]; @@ -43,7 +46,7 @@ const SelectedPromptContextsComponent: React.FC = ({ [promptContexts, selectedPromptContextIds] ); - const [accordianContent, setAccordianContent] = useState>({}); + const [accordionContent, setAccordionContent] = useState>({}); const unselectPromptContext = useCallback( (unselectedId: string) => { @@ -53,17 +56,17 @@ const SelectedPromptContextsComponent: React.FC = ({ ); useEffect(() => { - const fetchAccordianContent = async () => { - const newAccordianContent = await Promise.all( + const fetchAccordionContent = async () => { + const newAccordionContent = await Promise.all( selectedPromptContexts.map(async ({ getPromptContext, id }) => ({ [id]: await getPromptContext(), })) ); - setAccordianContent(newAccordianContent.reduce((acc, curr) => ({ ...acc, ...curr }), {})); + setAccordionContent(newAccordionContent.reduce((acc, curr) => ({ ...acc, ...curr }), {})); }; - fetchAccordianContent(); + fetchAccordionContent(); }, [selectedPromptContexts]); if (isEmpty(promptContexts)) { @@ -71,28 +74,37 @@ const SelectedPromptContextsComponent: React.FC = ({ } return ( - + {selectedPromptContexts.map(({ description, id }) => ( - - {isNewConversation || selectedPromptContexts.length > 1 ? : null} + + {isNewConversation || selectedPromptContexts.length > 1 ? ( + + ) : null} unselectPromptContext(id)} /> + + unselectPromptContext(id)} + /> + } id={id} paddingSize="s" > - - {id != null && accordianContent[id] != null - ? SYSTEM_PROMPT_CONTEXT_NON_I18N(accordianContent[id]) - : ''} - + + + {id != null && accordionContent[id] != null + ? SYSTEM_PROMPT_CONTEXT_NON_I18N(accordionContent[id]) + : ''} + + ))} - - {} ); }; 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 0000000000000..2905e2a40dc6c --- /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 0000000000000..a3292cb9b269c --- /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 index eb0d4b9a25af1..9d81bd4d413d2 100644 --- 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 @@ -30,16 +30,17 @@ export const getOptionFromPrompt = ({ overflow: hidden; `} color="subdued" + data-test-subj="inputDisplay" > {content} ), dropdownDisplay: ( <> - {name} + {name} - +

{content}

@@ -47,4 +48,5 @@ export const getOptionFromPrompt = ({ ), }); -export const getOptions = (prompts: Prompt[]) => prompts.map(getOptionFromPrompt); +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 0000000000000..6610ba836de70 --- /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 index c9739cafa0d31..fbe83ce2c94e7 100644 --- 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 @@ -5,24 +5,15 @@ * 2.0. */ -import { css } from '@emotion/react'; -import { - EuiButtonIcon, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiSuperSelect, - EuiText, - EuiToolTip, -} from '@elastic/eui'; +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 { getOptions } 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; @@ -40,21 +31,12 @@ const SystemPromptComponent: React.FC = ({ systemPrompts, }) => { const [showSelectSystemPrompt, setShowSelectSystemPrompt] = React.useState(false); - const options = useMemo(() => getOptions(systemPrompts), [systemPrompts]); const selectedPrompt: Prompt | undefined = useMemo( () => getPromptById({ prompts: systemPrompts, id: selectedSystemPromptId ?? '' }), [systemPrompts, selectedSystemPromptId] ); - const onChange = useCallback( - (value) => { - setSelectedSystemPromptId(value); - setShowSelectSystemPrompt(false); - }, - [setSelectedSystemPromptId] - ); - const clearSystemPrompt = useCallback(() => { setSelectedSystemPromptId(null); setShowSelectSystemPrompt(false); @@ -62,71 +44,55 @@ const SystemPromptComponent: React.FC = ({ const onShowSelectSystemPrompt = useCallback(() => setShowSelectSystemPrompt(true), []); - if (selectedPrompt == null || showSelectSystemPrompt) { - return ( - - - {showSelectSystemPrompt && ( - - - - )} - - - - - - - - - ); - } - return ( - - - - {selectedPrompt?.content ?? ''} - - - - - - - - +
+ {selectedPrompt == null || showSelectSystemPrompt ? ( + + ) : ( + + + + {selectedPrompt?.content ?? ''} + - - - - + + + + + + + + + + + + + - - + )} +
); }; 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 0000000000000..affd07e26eb68 --- /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 0000000000000..180f7202c2d2b --- /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 index dd0312475bfc1..075e017bc56f0 100644 --- 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 @@ -14,8 +14,8 @@ export const ADD_SYSTEM_PROMPT_TOOLTIP = i18n.translate( } ); -export const CLEAR_SYSTEM_PROMPT_TOOLTIP = i18n.translate( - 'xpack.elasticAssistant.assistant.firstPromptEditor.clearSystemPromptTooltip', +export const CLEAR_SYSTEM_PROMPT = i18n.translate( + 'xpack.elasticAssistant.assistant.firstPromptEditor.clearSystemPrompt', { defaultMessage: 'Clear system prompt', } diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/translations.ts index 17917caaf4a26..1213fd79c817a 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/translations.ts @@ -37,12 +37,14 @@ export const CHAT_COMPLETION_FETCH_FAILURE = i18n.translate( export const SETTINGS_TITLE = i18n.translate('xpack.elasticAssistant.assistant.settingsTitle', { defaultMessage: 'Assistant Settings', }); + export const SETTINGS_TEMPERATURE_TITLE = i18n.translate( 'xpack.elasticAssistant.assistant.settings.temperatureTitle', { defaultMessage: 'Temperature', } ); + export const SETTINGS_TEMPERATURE_HELP_TEXT = i18n.translate( 'xpack.elasticAssistant.assistant.settings.temperatureHelpTextTitle', { @@ -70,6 +72,7 @@ export const SETTINGS_MODEL_TITLE = i18n.translate( defaultMessage: 'Model', } ); + export const SETTINGS_MODEL_HELP_TEXT_TITLE = i18n.translate( 'xpack.elasticAssistant.assistant.settings.modelHelpTextTitle', { 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 index 35371b423ccdb..f2c075ca829ed 100644 --- 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 @@ -5,52 +5,77 @@ * 2.0. */ -import React, { useCallback, useMemo } from 'react'; -import { EuiButtonEmpty } from '@elastic/eui'; -import { css } from '@emotion/react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useAssistantContext } from '../../assistant_context'; +import { getUniquePromptContextId } from '../../assistant_context/helpers'; +import type { PromptContext } from '../prompt_context/types'; interface Props { + promptContext?: Omit; promptContextId?: string; conversationId?: string; } interface UseAssistantOverlay { - showSecurityAssistantOverlay: (showOverlay: boolean) => void; - MagicButton: JSX.Element; + showAssistantOverlay: (show: boolean) => void; + promptContextId: string; } export const useAssistantOverlay = ({ - promptContextId, conversationId, + promptContext, + promptContextId, }: Props): UseAssistantOverlay => { - const { showAssistantOverlay } = useAssistantContext(); + // create a unique prompt context id if one is not provided: + const _promptContextId = useMemo( + () => promptContextId ?? getUniquePromptContextId(), + [promptContextId] + ); - const showSecurityAssistantOverlay = useCallback( + const _promptContextRef = useRef( + promptContext != null + ? { + ...promptContext, + id: _promptContextId, + } + : undefined + ); + + // 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) => { - showAssistantOverlay({ showOverlay, promptContextId, conversationId }); + assistantContextShowOverlay({ + showOverlay, + promptContextId: _promptContextId, + conversationId, + }); }, - [conversationId, promptContextId, showAssistantOverlay] + [assistantContextShowOverlay, _promptContextId, conversationId] ); - // Button state - const showOverlay = useCallback(() => { - showSecurityAssistantOverlay(true); - }, [showSecurityAssistantOverlay]); - - const MagicButton = useMemo( - () => ( - - {'🪄✨'} - - ), - [showOverlay] + useEffect( + () => () => unRegisterPromptContext(_promptContextId), + [_promptContextId, unRegisterPromptContext] ); - return { MagicButton, showSecurityAssistantOverlay }; + if ( + promptContext != null && + (_promptContextRef.current?.category !== promptContext?.category || + _promptContextRef.current?.description !== promptContext?.description || + _promptContextRef.current?.getPromptContext !== promptContext?.getPromptContext || + _promptContextRef.current?.suggestedUserPrompt !== promptContext?.suggestedUserPrompt || + _promptContextRef.current?.tooltip !== promptContext?.tooltip) + ) { + _promptContextRef.current = { ...promptContext, id: _promptContextId }; + registerPromptContext(_promptContextRef.current); + } + + return { promptContextId: _promptContextId, showAssistantOverlay }; }; 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 index d4eb71fdeac65..564d57ddd39fa 100644 --- 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 @@ -12,15 +12,17 @@ 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 mockHttp = httpServiceMock.createStartContract({ basePath: '/test' }); const actionTypeRegistry = actionTypeRegistryMock.create(); +const mockGetInitialConversations = jest.fn(() => ({})); +const mockGetComments = jest.fn(() => []); +const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' }); const ContextWrapper: React.FC = ({ children }) => ( @@ -29,9 +31,7 @@ const ContextWrapper: React.FC = ({ children }) => ( ); describe('AssistantContext', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); + beforeEach(() => jest.clearAllMocks()); test('it throws an error when useAssistantContext hook is used without a SecurityAssistantContext', () => { const { result } = renderHook(useAssistantContext); @@ -48,6 +48,6 @@ describe('AssistantContext', () => { const path = '/path/to/resource'; await http.fetch(path); - expect(mockHttp).toBeCalledWith(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 index 185d3ddd068d7..c565796e694fe 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx @@ -86,23 +86,33 @@ export const AssistantProvider: React.FC = ({ /** * Prompt contexts are used to provide components a way to register and make their data available to the assistant. */ - const [promptContexts, setQueryContexts] = useState>({}); + const [promptContexts, setPromptContexts] = useState>({}); const registerPromptContext: RegisterPromptContext = useCallback( (promptContext: PromptContext) => { - setQueryContexts((prevPromptContexts) => - updatePromptContexts({ - prevPromptContexts, - promptContext, - }) - ); + setPromptContexts((prevPromptContexts) => { + if (prevPromptContexts[promptContext.id] == null) { + return updatePromptContexts({ + prevPromptContexts, + promptContext, + }); + } else { + return prevPromptContexts; + } + }); }, [] ); const unRegisterPromptContext: UnRegisterPromptContext = useCallback( (queryContextId: string) => - setQueryContexts((prevQueryContexts) => omit(queryContextId, prevQueryContexts)), + setPromptContexts((prevPromptContexts) => { + if (prevPromptContexts[queryContextId] == null) { + return prevPromptContexts; + } else { + return omit(queryContextId, prevPromptContexts); + } + }), [] ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts index 50192c36c74a2..0923d2a34b91d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; export const LOAD_ACTIONS_ERROR_MESSAGE = i18n.translate( - 'xpack.securitySolution.securityAssistant.connectors.useLoadActionTypes.errorMessage', + 'xpack.elasticAssistant.connectors.useLoadActionTypes.errorMessage', { defaultMessage: 'Welcome to your Elastic Assistant! I am your 100% open-source portal into your Elastic Life. ', @@ -16,7 +16,7 @@ export const LOAD_ACTIONS_ERROR_MESSAGE = i18n.translate( ); export const LOAD_CONNECTORS_ERROR_MESSAGE = i18n.translate( - 'xpack.securitySolution.securityAssistant.connectors.useLoadConnectors.errorMessage', + 'xpack.elasticAssistant.connectors.useLoadConnectors.errorMessage', { defaultMessage: 'Welcome to your Elastic Assistant! I am your 100% open-source portal into your Elastic Life. ', @@ -24,46 +24,13 @@ export const LOAD_CONNECTORS_ERROR_MESSAGE = i18n.translate( ); export const WELCOME_SECURITY = i18n.translate( - 'xpack.securitySolution.securityAssistant.content.prompts.welcome.welcomeSecurityPrompt', + 'xpack.elasticAssistant.content.prompts.welcome.welcomeSecurityPrompt', { defaultMessage: 'Welcome to your Elastic Assistant! I am your 100% open-source portal into Elastic Security. ', } ); -export const THEN_SUMMARIZE_SUGGESTED_KQL_AND_EQL_QUERIES = i18n.translate( - 'xpack.securitySolution.securityAssistant.content.prompts.user.thenSummarizeSuggestedKqlAndEqlQueries', - { - defaultMessage: 'then summarize a list of suggested Elasticsearch KQL and EQL queries', - } -); - -export const FINALLY_SUGGEST_INVESTIGATION_GUIDE_AND_FORMAT_AS_MARKDOWN = i18n.translate( - 'xpack.securitySolution.securityAssistant.content.prompts.user.finallySuggestInvestigationGuideAndFormatAsMarkdown', - { - defaultMessage: 'Finally, suggest an investigation guide, and format it as markdown', - } -); - -export const UPDATE_PREPACKAGED_RULES_AND_TIMELINES_MSG = ( - updateRules: number, - updateTimelines: number -) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.updatePrePackagedRulesAndTimelinesMsg', - { - values: { updateRules, updateTimelines }, - defaultMessage: - 'You can update {updateRules} Elastic prebuilt {updateRules, plural, =1 {rule} other {rules}} and {updateTimelines} Elastic prebuilt {updateTimelines, plural, =1 {timeline} other {timelines}}. Note that this will reload deleted Elastic prebuilt rules.', - } - ); - -export const CONNECTOR_SELECTOR_LABEL = i18n.translate( - 'xpack.elasticAssistant.assistant.connectors.connectorSelector.labelTitle', - { - defaultMessage: 'Connector', - } -); export const CONNECTOR_SELECTOR_TITLE = i18n.translate( 'xpack.elasticAssistant.assistant.connectors.connectorSelector.ariaLabel', { @@ -93,14 +60,14 @@ export const ADD_CONNECTOR_DESCRIPTION = i18n.translate( ); export const CONNECTOR_ADDED_TITLE = i18n.translate( - 'xpack.elasticAssistant.assistant.connectors.addConnectorButton.title', + 'xpack.elasticAssistant.assistant.connectors.addConnectorButton.connectorAddedTitle', { defaultMessage: 'Generative AI Connector added!', } ); export const CONNECTOR_ADDED_DESCRIPTION = i18n.translate( - 'xpack.elasticAssistant.assistant.connectors.addConnectorButton.description', + 'xpack.elasticAssistant.assistant.connectors.addConnectorButton.connectorAddedDescription', { defaultMessage: 'Ready to continue the conversation...', } 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 index cd89d06576c26..a636966e631fd 100644 --- 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 @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; export const WELCOME_GENERAL = i18n.translate( - 'xpack.securitySolution.securityAssistant.content.prompts.welcome.welcomeGeneralPrompt', + 'xpack.elasticAssistant.securityAssistant.content.prompts.welcome.welcomeGeneralPrompt', { defaultMessage: 'Welcome to your Elastic Assistant! I am your [100% open-source](https://github.com/elastic/kibana/pull/156933) portal into your Elastic Life. In time, I will be able to answer questions and provide assistance over all your information in Elastic, and oh-so much more. Till then, I hope this early preview will open your minds to the possibilities of what we can create when we work together, in the open. Cheers! ![elasticheart](https://github.com/elastic/kibana/assets/2946766/ce3effae-1f01-4300-80f6-d66ff2c27c53)', @@ -16,7 +16,7 @@ export const WELCOME_GENERAL = i18n.translate( ); export const WELCOME_GENERAL_2 = i18n.translate( - 'xpack.securitySolution.securityAssistant.content.prompts.welcome.welcomeGeneral2Prompt', + 'xpack.elasticAssistant.securityAssistant.content.prompts.welcome.welcomeGeneral2Prompt', { defaultMessage: "So 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`](https://azure.microsoft.com/en-us/products/cognitive-services/openai-service) or [`OpenAI API`](https://platform.openai.com/) account, but you better believe you'll be able to [deploy _your own_ models within your Elastic Cloud instance](https://www.elastic.co/blog/may-2023-launch-announcement) and use those here in the future... 😉", @@ -24,52 +24,9 @@ export const WELCOME_GENERAL_2 = i18n.translate( ); export const WELCOME_GENERAL_3 = i18n.translate( - 'xpack.securitySolution.securityAssistant.content.prompts.welcome.welcomeGeneral3Prompt', + 'xpack.elasticAssistant.securityAssistant.content.prompts.welcome.welcomeGeneral3Prompt', { defaultMessage: 'Go ahead and click the add connector button below, or just press the `space-bar` for that quick action! 💨', } ); - -export const WELCOME_NO_CONNECTOR_PRIVILEGES = i18n.translate( - 'xpack.securitySolution.securityAssistant.content.prompts.welcome.welcomeNoConnectorPrivilegesPrompt', - { - defaultMessage: - "So it looks like you don't have connector access (you know, RBAC) 😔. Go ahead and do that `Contact your administrator` thing and they can either give you access to create connectors, or they can add one for you to use.", - } -); - -export const WELCOME_SECURITY = i18n.translate( - 'xpack.securitySolution.securityAssistant.content.prompts.welcome.welcomeSecurityPrompt', - { - defaultMessage: - 'Welcome to your Elastic Assistant! I am your 100% open-source portal into Elastic Security. ', - } -); - -export const THEN_SUMMARIZE_SUGGESTED_KQL_AND_EQL_QUERIES = i18n.translate( - 'xpack.securitySolution.securityAssistant.content.prompts.user.thenSummarizeSuggestedKqlAndEqlQueries', - { - defaultMessage: 'then summarize a list of suggested Elasticsearch KQL and EQL queries', - } -); - -export const FINALLY_SUGGEST_INVESTIGATION_GUIDE_AND_FORMAT_AS_MARKDOWN = i18n.translate( - 'xpack.securitySolution.securityAssistant.content.prompts.user.finallySuggestInvestigationGuideAndFormatAsMarkdown', - { - defaultMessage: 'Finally, suggest an investigation guide, and format it as markdown', - } -); - -export const UPDATE_PREPACKAGED_RULES_AND_TIMELINES_MSG = ( - updateRules: number, - updateTimelines: number -) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.updatePrePackagedRulesAndTimelinesMsg', - { - values: { updateRules, updateTimelines }, - defaultMessage: - 'You can update {updateRules} Elastic prebuilt {updateRules, plural, =1 {rule} other {rules}} and {updateTimelines} Elastic prebuilt {updateTimelines, plural, =1 {timeline} other {timelines}}. Note that this will reload deleted Elastic prebuilt rules.', - } - ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/magic_button/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/magic_button/index.tsx new file mode 100644 index 0000000000000..5a447a1aac6c9 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/magic_button/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 { EuiButtonEmpty } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useCallback, useMemo } from 'react'; + +import { PromptContext } from '../assistant/prompt_context/types'; +import { useAssistantOverlay } from '../assistant/use_assistant_overlay'; + +const MagicButtonComponent: React.FC<{ + promptContext?: Omit; + promptContextId?: string; + conversationId?: string; +}> = ({ conversationId, promptContext, promptContextId }) => { + const { showAssistantOverlay } = useAssistantOverlay({ + conversationId, + promptContextId, + promptContext, + }); + + const showOverlay = useCallback(() => { + showAssistantOverlay(true); + }, [showAssistantOverlay]); + + return useMemo( + () => ( + + {'🪄✨'} + + ), + [showOverlay] + ); +}; + +MagicButtonComponent.displayName = 'MagicButtonComponent'; + +export const MagicButton = React.memo(MagicButtonComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/mock/api_config/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/mock/api_config/index.ts new file mode 100644 index 0000000000000..5ab0d26e2ac7a --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/mock/api_config/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { AssistantUiSettings } from '../../assistant/helpers'; + +export const mockApiConfig: AssistantUiSettings = { + virusTotal: { + apiKey: 'mock', + baseUrl: 'https://www.virustotal.com/api/v3', + }, + openAI: { + apiKey: 'mock', + baseUrl: + 'https://example.com/openai/deployments/example/chat/completions?api-version=2023-03-15-preview', + }, +}; 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 0000000000000..289fff2c633e5 --- /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 0000000000000..bfd0187b5ede4 --- /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 0000000000000..9ceda348795ae --- /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 0000000000000..5bc23b0d680e3 --- /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/plugins/security_solution/public/assistant/new_chat/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.tsx similarity index 62% rename from x-pack/plugins/security_solution/public/assistant/new_chat/index.tsx rename to x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.tsx index 01d93e3201f93..04ea60de290d0 100644 --- a/x-pack/plugins/security_solution/public/assistant/new_chat/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.tsx @@ -6,22 +6,27 @@ */ import { EuiButtonEmpty } from '@elastic/eui'; -import { useAssistantOverlay } from '@kbn/elastic-assistant'; 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'; const NewChatComponent: React.FC<{ - promptContextId: string; -}> = ({ promptContextId }) => { - const { showSecurityAssistantOverlay } = useAssistantOverlay({ + promptContext?: Omit; + promptContextId?: string; + conversationId?: string; +}> = ({ conversationId, promptContext, promptContextId }) => { + const { showAssistantOverlay } = useAssistantOverlay({ + conversationId, promptContextId, - conversationId: 'alertSummary', + promptContext, }); const showOverlay = useCallback(() => { - showSecurityAssistantOverlay(true); - }, [showSecurityAssistantOverlay]); + showAssistantOverlay(true); + }, [showAssistantOverlay]); return useMemo( () => ( diff --git a/x-pack/plugins/security_solution/public/assistant/new_chat/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/translations.ts similarity index 55% rename from x-pack/plugins/security_solution/public/assistant/new_chat/translations.ts rename to x-pack/packages/kbn-elastic-assistant/impl/new_chat/translations.ts index adc1f772b19dd..5bc87dc1a261c 100644 --- a/x-pack/plugins/security_solution/public/assistant/new_chat/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/translations.ts @@ -7,13 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const NEW_CHAT = i18n.translate('xpack.securitySolution.assistant.newChat.newChatButton', { +export const NEW_CHAT = i18n.translate('xpack.elasticAssistant.assistant.newChat.newChatButton', { defaultMessage: 'New chat', }); - -export const ERROR_FETCHING_SECURITY_ASSISTANT_QUERY = i18n.translate( - 'xpack.securitySolution.assistant.newChat.errorFetchingSecurityAssistantQuery', - { - defaultMessage: 'Error fetching security assistant query', - } -); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/use_assistant_context_registry/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/use_assistant_context_registry/index.tsx deleted file mode 100644 index 5cc24342737fd..0000000000000 --- a/x-pack/packages/kbn-elastic-assistant/impl/use_assistant_context_registry/index.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useEffect } from 'react'; - -import { useAssistantContext } from '../assistant_context'; -import { PromptContext } from '../assistant/prompt_context/types'; - -export const useAssistantContextRegistry = (promptContext: PromptContext) => { - const { registerPromptContext, unRegisterPromptContext } = useAssistantContext(); - - useEffect(() => { - registerPromptContext(promptContext); - - return () => unRegisterPromptContext(promptContext.id); - }, [promptContext, registerPromptContext, unRegisterPromptContext]); -}; diff --git a/x-pack/packages/kbn-elastic-assistant/index.ts b/x-pack/packages/kbn-elastic-assistant/index.ts index 6eee65bf8d514..3a4a7ab6f7899 100644 --- a/x-pack/packages/kbn-elastic-assistant/index.ts +++ b/x-pack/packages/kbn-elastic-assistant/index.ts @@ -5,21 +5,39 @@ * 2.0. */ -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'; +// Use any of the following 3 options to show the assistant overlay: +/** OPTION1: an icon button that shows the assistant overlay */ +export { MagicButton } from './impl/magic_button'; + +/** OPTION2: an icon button with text that shows the assistant overlay */ +export { NewChat } from './impl/new_chat'; +/** OPTION3: this hook shows the assistant overlay */ +export { useAssistantOverlay } from './impl/assistant/use_assistant_overlay'; + +// Use the following component to embed the assistant directly in a page, with no overlay: +/** this component renders the Assistant without the overlay to, for example, render it in a Timeline tab */ +export { Assistant } from './impl/assistant'; + +// 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'; + /** a helper that enriches content returned from a query with action buttons */ export { analyzeMarkdown } from './impl/assistant/use_conversation/helpers'; -/** this component renders the Assistant without the overlay to, for example, render it in a Timeline tab */ -export { Assistant } from './impl/assistant'; +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'; /** modal container for Security Assistant conversations */ export { AssistantOverlay } from './impl/assistant/assistant_overlay'; @@ -33,22 +51,4 @@ export type { AssistantUiSettings } from './impl/assistant/helpers'; /** serialized conversations */ export type { Conversation, Message } from './impl/assistant_context/types'; -/** generates unique IDs for contexts provided to the assistant */ -export { getUniquePromptContextId } from './impl/assistant_context/helpers'; - export type { PromptContext } from './impl/assistant/prompt_context/types'; - -/** i18n translations of system prompts */ -export * as SYSTEM_PROMPTS from './impl/content/prompts/system/translations'; - -/** this hook shows the assistant overlay */ -export { useAssistantOverlay } from './impl/assistant/use_assistant_overlay'; - -/** i18n translations of user prompts */ -export * as USER_PROMPTS from './impl/content/prompts/user/translations'; - -/** - * this hook registers a prompt context with the assistant, and automatically - * unregisters it when the component unmounts - */ -export { useAssistantContextRegistry } from './impl/use_assistant_context_registry'; diff --git a/x-pack/packages/kbn-elastic-assistant/tsconfig.json b/x-pack/packages/kbn-elastic-assistant/tsconfig.json index 293c9819fffc9..d23b8d6008e31 100644 --- a/x-pack/packages/kbn-elastic-assistant/tsconfig.json +++ b/x-pack/packages/kbn-elastic-assistant/tsconfig.json @@ -19,6 +19,15 @@ "@kbn/core-http-browser", "@kbn/kibana-utils-plugin", "@kbn/i18n", - "@kbn/timelines-plugin" + "@kbn/timelines-plugin", + "@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/plugins/security_solution/public/common/components/event_details/translations.ts b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts index 51f9148843f75..cc0923d725554 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 @@ -181,7 +181,7 @@ export const ALERT_SUMMARY_CONTEXT_DESCRIPTION = (view: string) => export const ALERT_SUMMARY_VIEW_CONTEXT_TOOLTIP = i18n.translate( 'xpack.securitySolution.alertSummaryView.alertSummaryViewContextTooltip', { - defaultMessage: 'Use this alert for context', + defaultMessage: 'Add this alert as context', } ); 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 index f9701e6cbe11d..f02ead5336e76 100644 --- 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 @@ -5,13 +5,7 @@ * 2.0. */ -import { - getUniquePromptContextId, - useAssistantContextRegistry, - useAssistantOverlay, -} from '@kbn/elastic-assistant'; -import type { PromptContext } from '@kbn/elastic-assistant'; -import type { ReactNode } from 'react'; +import { MagicButton } from '@kbn/elastic-assistant'; import React, { useCallback, useMemo } from 'react'; import { useRulesTableContext } from '../../components/rules_table/rules_table/rules_table_context'; @@ -20,7 +14,7 @@ import * as i18n from '../../../../detections/pages/detection_engine/rules/trans import { getPromptContextFromDetectionRules } from '../../../../assistant/helpers'; -export const SuperHeader: React.FC<{ children: ReactNode }> = React.memo(({ children }) => { +export const SuperHeader: React.FC<{ children: React.ReactNode }> = React.memo(({ children }) => { const memoizedChildren = useMemo(() => children, [children]); // Rules state const { @@ -32,38 +26,28 @@ export const SuperHeader: React.FC<{ children: ReactNode }> = React.memo(({ chil [rules, selectedRuleIds] ); - // Add Rules promptContext - const promptContextId = useMemo(() => getUniquePromptContextId(), []); - - const { MagicButton } = useAssistantOverlay({ - conversationId: 'detectionRules', - promptContextId: 'testing', - }); - const getPromptContext = useCallback( async () => getPromptContextFromDetectionRules(selectedRules), [selectedRules] ); - const promptContext: PromptContext = useMemo( - () => ({ - category: 'detection-rules', - description: i18n.RULE_MANAGEMENT_CONTEXT_DESCRIPTION, - id: promptContextId, - getPromptContext, - suggestedUserPrompt: i18n.EXPLAIN_THEN_SUMMARIZE_RULE_DETAILS, - tooltip: i18n.RULE_MANAGEMENT_CONTEXT_TOOLTIP, - }), - [getPromptContext, promptContextId] - ); - - useAssistantContextRegistry(promptContext); - return ( - {i18n.PAGE_TITLE} {MagicButton} + {i18n.PAGE_TITLE}{' '} + {selectedRules.length > 0 && ( + + )} } > 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 563971b929e62..e7f57b29dc523 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,7 @@ * 2.0. */ +import { NewChat } from '@kbn/elastic-assistant'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; import { isEmpty } from 'lodash/fp'; import { @@ -21,7 +22,6 @@ import { import React from 'react'; import styled from 'styled-components'; -import { NewChat } from '../../../../assistant/new_chat'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { getAlertDetailsUrl } from '../../../../common/components/link_to'; import { @@ -148,11 +148,9 @@ export const ExpandableEventTitle = React.memo( {promptContextId != null && ( - <> - - - - + + + )} {isAlert && alertDetailsLink && ( 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 7c0df44daa3c7..bc3f75a3c478b 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,12 +5,7 @@ * 2.0. */ -import type { PromptContext } from '@kbn/elastic-assistant'; -import { - getUniquePromptContextId, - useAssistantContextRegistry, - USER_PROMPTS, -} from '@kbn/elastic-assistant'; +import { USER_PROMPTS, useAssistantOverlay } from '@kbn/elastic-assistant'; import { EuiSpacer, EuiFlyoutBody } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; @@ -94,28 +89,23 @@ const EventDetailsPanelComponent: React.FC = ({ const view = useMemo(() => (isFlyoutView ? SUMMARY_VIEW : TIMELINE_VIEW), [isFlyoutView]); - const promptContextId = useMemo(() => getUniquePromptContextId(), []); - const getPromptContext = useCallback( async () => getPromptContextFromEventDetailsItem(detailsData ?? []), [detailsData] ); - const promptContext: PromptContext = useMemo( - () => ({ + const { promptContextId } = useAssistantOverlay({ + conversationId: 'alertSummary', + promptContext: { category: isAlert ? 'alert' : 'event', description: isAlert ? ALERT_SUMMARY_CONTEXT_DESCRIPTION(view) : EVENT_SUMMARY_CONTEXT_DESCRIPTION(view), - id: promptContextId, getPromptContext, suggestedUserPrompt: USER_PROMPTS.EXPLAIN_THEN_SUMMARIZE_SUGGEST_INVESTIGATION_GUIDE_NON_I18N, tooltip: isAlert ? ALERT_SUMMARY_VIEW_CONTEXT_TOOLTIP : EVENT_SUMMARY_VIEW_CONTEXT_TOOLTIP, - }), - [getPromptContext, isAlert, promptContextId, view] - ); - - useAssistantContextRegistry(promptContext); + }, + }); const header = useMemo( () => diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index 9700d3e5baca2..67874e97b2867 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -158,6 +158,6 @@ "@kbn/ecs", "@kbn/url-state", "@kbn/ml-anomaly-utils", - "@kbn/shared-ux-file-types" + "@kbn/core-http-browser-mocks" ] }