From 6b65e909356a8f9e9f29db28ac8c41edae9959e1 Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Tue, 13 Jun 2023 12:06:29 -0600 Subject: [PATCH] [Security Solution] Adds support for custom Security Assistant SystemPrompts and Conversations (#159365) ## Summary

Adds the following new abilities to the Security Assistant: - Adds ability to create/delete custom SystemPrompts - Configurable `Name`, `Prompt`, `Default Conversations`, and `Default for New Conversations` - Introduces `System Prompt` setting within `Conversation Settings` - Adds ability to create/delete custom Conversations - Create conversation in-line within the Conversation selector by just typing the new conversation name and pressing enter - Applies configured SystemPrompt and default connector on conversation creation - Extracts `baseSystemPrompts` so they can be provided to the AssistantContextProvider on a per solution basis. The consolidates assistant dependency defaults to the `x-pack/plugins/security_solution/public/assistant/content` and `x-pack/packages/kbn-elastic-assistant/impl/content` directories respectively. - All Security SystemPrompts now organized in `BASE_SECURITY_SYSTEM_PROMPTS` - All Security Conversations organized in `BASE_SECURITY_CONVERSATIONS` See epic https://github.com/elastic/security-team/issues/6775 (internal) for additional details. ### Checklist Delete any items that are not applicable to this PR. - [X] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [X] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../assistant/conversation_selector/index.tsx | 170 +++++++++++++- .../conversation_selector/translations.ts | 21 ++ .../conversation_settings_popover.tsx} | 46 +++- .../impl/assistant/index.tsx | 43 ++-- .../impl/assistant/prompt/helpers.test.ts | 36 +-- .../impl/assistant/prompt/helpers.ts | 22 +- .../assistant/prompt_editor/index.test.tsx | 5 +- .../impl/assistant/prompt_editor/index.tsx | 22 +- .../system_prompt/helpers.test.tsx | 4 +- .../prompt_editor/system_prompt/helpers.tsx | 24 +- .../system_prompt/index.test.tsx | 88 +++---- .../prompt_editor/system_prompt/index.tsx | 76 +++--- .../select_system_prompt/index.test.tsx | 105 +++++---- .../select_system_prompt/index.tsx | 148 +++++++++--- .../conversation_multi_selector.tsx | 74 ++++++ .../system_prompt_modal.tsx | 219 ++++++++++++++++++ .../system_prompt_selector.tsx | 219 ++++++++++++++++++ .../system_prompt_selector/translations.ts | 29 +++ .../system_prompt_modal/translations.ts | 70 ++++++ .../system_prompt/translations.ts | 7 + .../add_quick_prompt_modal.tsx | 6 +- .../add_quick_prompt_modal/translations.ts | 2 +- .../quick_prompt_selector.tsx | 4 +- .../assistant/quick_prompts/quick_prompts.tsx | 24 +- .../impl/assistant/translations.ts | 14 ++ .../impl/assistant/types.ts | 2 + .../impl/assistant/use_conversation/index.tsx | 29 ++- .../impl/assistant_context/constants.tsx | 10 + .../impl/assistant_context/index.tsx | 43 +++- .../impl/assistant_context/types.tsx | 3 + .../connector_selector/index.tsx | 26 +-- .../impl/content/prompts/system/index.tsx | 32 +++ .../content/prompts/system/translations.ts | 2 +- .../packages/kbn-elastic-assistant/index.ts | 3 + .../security_solution/public/app/app.tsx | 2 + .../assistant/content/conversations/index.tsx | 12 + .../content/prompt_contexts/index.tsx | 20 +- .../content/prompts/system/index.tsx | 35 +++ .../content/prompts/system/translations.ts | 63 +++++ .../content/prompts/user/translations.ts | 26 +++ .../side_panel/event_details/index.tsx | 11 +- 41 files changed, 1477 insertions(+), 320 deletions(-) rename x-pack/packages/kbn-elastic-assistant/impl/assistant/{settings_popover.tsx => conversation_settings_popover/conversation_settings_popover.tsx} (63%) create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/conversation_multi_selector/conversation_multi_selector.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_modal.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/translations.ts create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/translations.ts create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/content/prompts/system/index.tsx create mode 100644 x-pack/plugins/security_solution/public/assistant/content/prompts/system/index.tsx create mode 100644 x-pack/plugins/security_solution/public/assistant/content/prompts/system/translations.ts create mode 100644 x-pack/plugins/security_solution/public/assistant/content/prompts/user/translations.ts 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 index f7af69f42cd82..116a46ccd02f9 100644 --- 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 @@ -5,19 +5,34 @@ * 2.0. */ -import { EuiButtonIcon, EuiFormRow, EuiSuperSelect, EuiToolTip } from '@elastic/eui'; +import { + EuiButtonIcon, + EuiComboBox, + EuiComboBoxOptionOption, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiHighlight, + 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 { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/gen_ai/constants'; +import { Conversation } from '../../..'; import { useAssistantContext } from '../../assistant_context'; import * as i18n from './translations'; import { DEFAULT_CONVERSATION_TITLE } from '../use_conversation/translations'; +import { useConversation } from '../use_conversation'; +import { SystemPromptSelectorOption } from '../prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector'; const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; interface Props { conversationId?: string; + defaultConnectorId?: string; + defaultProvider?: OpenAiProviderType; onSelectionChange?: (value: string) => void; shouldDisableKeyboardShortcut?: () => boolean; isDisabled?: boolean; @@ -29,28 +44,104 @@ const getPreviousConversationId = (conversationIds: string[], selectedConversati : conversationIds[conversationIds.indexOf(selectedConversationId) - 1]; }; -function getNextConversationId(conversationIds: string[], selectedConversationId: string) { +const getNextConversationId = (conversationIds: string[], selectedConversationId: string) => { return conversationIds.indexOf(selectedConversationId) + 1 >= conversationIds.length ? conversationIds[0] : conversationIds[conversationIds.indexOf(selectedConversationId) + 1]; -} +}; + +export type ConversationSelectorOption = EuiComboBoxOptionOption<{ + isDefault: boolean; +}>; export const ConversationSelector: React.FC = React.memo( ({ conversationId = DEFAULT_CONVERSATION_TITLE, + defaultConnectorId, + defaultProvider, onSelectionChange, shouldDisableKeyboardShortcut = () => false, isDisabled = false, }) => { + const { allSystemPrompts } = useAssistantContext(); + + const { deleteConversation, setConversation } = useConversation(); 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 conversationOptions = useMemo(() => { + return Object.values(conversations).map((conversation) => ({ + value: { isDefault: conversation.isDefault ?? false }, + label: conversation.id, + })); + }, [conversations]); + + const [selectedOptions, setSelectedOptions] = useState(() => { + return conversationOptions.filter((c) => c.label === selectedConversationId) ?? []; + }); + + // Callback for when user types to create a new system prompt + const onCreateOption = useCallback( + (searchValue, flattenedOptions = []) => { + if (!searchValue || !searchValue.trim().toLowerCase()) { + return; + } + + const normalizedSearchValue = searchValue.trim().toLowerCase(); + const defaultSystemPrompt = allSystemPrompts.find( + (systemPrompt) => systemPrompt.isNewConversationDefault + ); + const optionExists = + flattenedOptions.findIndex( + (option: SystemPromptSelectorOption) => + option.label.trim().toLowerCase() === normalizedSearchValue + ) !== -1; + + if (!optionExists) { + const newConversation: Conversation = { + id: searchValue, + messages: [], + apiConfig: { + connectorId: defaultConnectorId, + provider: defaultProvider, + defaultSystemPrompt, + }, + }; + setConversation({ conversation: newConversation }); + } + setSelectedConversationId(searchValue); + }, + [allSystemPrompts, defaultConnectorId, defaultProvider, setConversation] + ); + + // Callback for when user deletes a conversation + const onDelete = useCallback( + (cId: string) => { + if (selectedConversationId === cId) { + setSelectedConversationId(getPreviousConversationId(conversationIds, cId)); + } + setTimeout(() => { + deleteConversation(cId); + }, 0); + // onSystemPromptDeleted(cId); + }, + [conversationIds, deleteConversation, selectedConversationId] + ); + + const onChange = useCallback( + (newOptions: ConversationSelectorOption[]) => { + if (newOptions.length === 0) { + setSelectedOptions([]); + // handleSelectionChange([]); + } else if (conversationOptions.findIndex((o) => o.label === newOptions?.[0].label) !== -1) { + setSelectedConversationId(newOptions?.[0].label); + } + // setSelectedConversationId(value ?? DEFAULT_CONVERSATION_TITLE); + }, + [conversationOptions] + ); - const onChange = useCallback((value: string) => { - setSelectedConversationId(value ?? DEFAULT_CONVERSATION_TITLE); - }, []); const onLeftArrowClick = useCallback(() => { const prevId = getPreviousConversationId(conversationIds, selectedConversationId); setSelectedConversationId(prevId); @@ -96,7 +187,57 @@ export const ConversationSelector: React.FC = React.memo( useEffect(() => { onSelectionChange?.(selectedConversationId); - }, [onSelectionChange, selectedConversationId]); + setSelectedOptions(conversationOptions.filter((c) => c.label === selectedConversationId)); + }, [conversationOptions, onSelectionChange, selectedConversationId]); + + const renderOption: ( + option: ConversationSelectorOption, + searchValue: string, + OPTION_CONTENT_CLASSNAME: string + ) => React.ReactNode = (option, searchValue, contentClassName) => { + const { label, value } = option; + return ( + + + + {label} + + + {!value?.isDefault && ( + + + { + e.stopPropagation(); + onDelete(label); + }} + css={css` + visibility: hidden; + .parentFlexGroup:hover & { + visibility: visible; + } + `} + /> + + + )} + + ); + }; return ( = React.memo( min-width: 300px; `} > - = React.memo( +export const ConversationSettingsPopover: 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 provider = useMemo(() => { + return conversation.apiConfig?.provider; + }, [conversation.apiConfig]); + + const selectedPrompt: Prompt | undefined = useMemo( + () => conversation?.apiConfig.defaultSystemPrompt, + [conversation] + ); + const closeSettingsHandler = useCallback(() => { setIsSettingsOpen(false); }, []); // Hide settings panel when modal is visible (to keep visual clutter minimal) - const onConnectorModalVisibilityChange = useCallback((isVisible: boolean) => { + const onDescendantModalVisibilityChange = useCallback((isVisible: boolean) => { if (popoverPanelRef.current) { popoverPanelRef.current.style.visibility = isVisible ? 'hidden' : 'visible'; } @@ -86,7 +97,24 @@ export const SettingsPopover: React.FC = React.memo( actionTypeRegistry={actionTypeRegistry} conversation={conversation} http={http} - onConnectorModalVisibilityChange={onConnectorModalVisibilityChange} + onConnectorModalVisibilityChange={onDescendantModalVisibilityChange} + /> + + + {provider === OpenAiProviderType.OpenAi && <>} + + + @@ -94,4 +122,4 @@ export const SettingsPopover: React.FC = React.memo( ); } ); -SettingsPopover.displayName = 'SettingPopover'; +ConversationSettingsPopover.displayName = 'ConversationSettingsPopover'; 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 3a7ff3fb85c6a..4c6d237e6cdfa 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx @@ -25,9 +25,11 @@ import styled from 'styled-components'; import { createPortal } from 'react-dom'; import { css } from '@emotion/react'; +import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/gen_ai/constants'; +import { ActionConnectorProps } from '@kbn/triggers-actions-ui-plugin/public/types'; import { getMessageFromRawResponse } from './helpers'; -import { SettingsPopover } from './settings_popover'; +import { ConversationSettingsPopover } from './conversation_settings_popover/conversation_settings_popover'; import { useAssistantContext } from '../assistant_context'; import { ContextPills } from './context_pills'; import { PromptTextArea } from './prompt_textarea'; @@ -38,10 +40,8 @@ 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 { getCombinedMessage } 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'; @@ -109,6 +109,14 @@ const AssistantComponent: React.FC = ({ ); const { data: connectors, refetch: refetchConnectors } = useLoadConnectors({ http }); + const defaultConnectorId = useMemo(() => connectors?.[0]?.id, [connectors]); + const defaultProvider = useMemo( + () => + (connectors?.[0] as ActionConnectorProps<{ apiProvider: OpenAiProviderType }, unknown>) + ?.config?.apiProvider, + [connectors] + ); + const isWelcomeSetup = (connectors?.length ?? 0) === 0; const currentTitle: { title: string | JSX.Element; titleIcon: string } = isWelcomeSetup && welcomeConversation.theme?.title && welcomeConversation.theme?.titleIcon @@ -119,10 +127,6 @@ const AssistantComponent: React.FC = ({ 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); @@ -186,10 +190,7 @@ const AssistantComponent: React.FC = ({ promptContexts, promptText, selectedPromptContextIds, - selectedSystemPrompt: getPromptById({ - id: selectedSystemPromptId ?? '', - prompts: systemPrompts, - }), + selectedSystemPrompt: currentConversation.apiConfig.defaultSystemPrompt, }); const updatedMessages = appendMessage({ @@ -217,9 +218,7 @@ const AssistantComponent: React.FC = ({ promptContexts, selectedConversationId, selectedPromptContextIds, - selectedSystemPromptId, sendMessages, - systemPrompts, ] ); @@ -306,9 +305,16 @@ const AssistantComponent: React.FC = ({ - + setSelectedConversationId(id)} shouldDisableKeyboardShortcut={shouldDisableConversationSelectorHotkeys} isDisabled={isWelcomeSetup} @@ -380,10 +386,8 @@ const AssistantComponent: React.FC = ({ promptContexts={promptContexts} promptTextPreview={promptTextPreview} selectedPromptContextIds={selectedPromptContextIds} - selectedSystemPromptId={selectedSystemPromptId} + conversation={currentConversation} setSelectedPromptContextIds={setSelectedPromptContextIds} - setSelectedSystemPromptId={setSelectedSystemPromptId} - systemPrompts={systemPrompts} /> )} @@ -422,7 +426,6 @@ const AssistantComponent: React.FC = ({ onClick={() => { setPromptTextPreview(''); clearConversation(selectedConversationId); - setSelectedSystemPromptId(getDefaultSystemPrompt().id); setSelectedPromptContextIds([]); setSuggestedUserPrompt(''); }} @@ -443,7 +446,7 @@ const AssistantComponent: React.FC = ({ - { - 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 index 40e5b41805af0..10dd5f62a2a89 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 @@ -6,13 +6,7 @@ */ 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 { SYSTEM_PROMPT_CONTEXT_NON_I18N } from '../../content/prompts/system/translations'; import type { PromptContext } from '../prompt_context/types'; import type { Prompt } from '../types'; @@ -72,17 +66,3 @@ ${promptText}`, 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_editor/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.test.tsx index b9e7bb513c03f..8aa50c7f86224 100644 --- 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 @@ -9,11 +9,11 @@ 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 = { + conversation: undefined, isNewConversation: true, promptContexts: { [mockAlertPromptContext.id]: mockAlertPromptContext, @@ -21,10 +21,7 @@ const defaultProps: Props = { }, promptTextPreview: 'Preview text', selectedPromptContextIds: [], - selectedSystemPromptId: null, setSelectedPromptContextIds: jest.fn(), - setSelectedSystemPromptId: jest.fn(), - systemPrompts: [mockSystemPrompt], }; describe('PromptEditorComponent', () => { 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 16e62c44c3a00..3de97f30593ca 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 @@ -10,22 +10,20 @@ import React, { useMemo } from 'react'; // eslint-disable-next-line @kbn/eslint/module_migration import styled from 'styled-components'; +import { Conversation } from '../../..'; 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 { + conversation: Conversation | undefined; isNewConversation: boolean; promptContexts: Record; promptTextPreview: string; selectedPromptContextIds: string[]; - selectedSystemPromptId: string | null; setSelectedPromptContextIds: React.Dispatch>; - setSelectedSystemPromptId: React.Dispatch>; - systemPrompts: Prompt[]; } const PreviewText = styled(EuiText)` @@ -33,25 +31,17 @@ const PreviewText = styled(EuiText)` `; const PromptEditorComponent: React.FC = ({ + conversation, isNewConversation, promptContexts, promptTextPreview, selectedPromptContextIds, - selectedSystemPromptId, setSelectedPromptContextIds, - setSelectedSystemPromptId, - systemPrompts, }) => { const commentBody = useMemo( () => ( <> - {isNewConversation && ( - - )} + {isNewConversation && } = ({ ), [ + conversation, isNewConversation, promptContexts, promptTextPreview, selectedPromptContextIds, - selectedSystemPromptId, setSelectedPromptContextIds, - setSelectedSystemPromptId, - systemPrompts, ] ); 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 index a3292cb9b269c..92e371440e373 100644 --- 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 @@ -26,7 +26,7 @@ describe('helpers', () => { render(<>{option.inputDisplay}); - expect(screen.getByTestId('inputDisplay')).toHaveTextContent(mockSystemPrompt.content); + expect(screen.getByTestId('systemPromptText')).toHaveTextContent(mockSystemPrompt.content); }); it('shows the expected name in the dropdownDisplay', () => { @@ -51,7 +51,7 @@ describe('helpers', () => { const prompts = [mockSystemPrompt, mockSuperheroSystemPrompt]; const promptIds = prompts.map(({ id }) => id); - const options = getOptions(prompts); + 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 9d81bd4d413d2..5d73070c9440c 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 @@ -22,17 +22,22 @@ export const getOptionFromPrompt = ({ content, id, name, -}: Prompt): EuiSuperSelectOption => ({ + showTitles = false, +}: Prompt & { showTitles?: boolean }): EuiSuperSelectOption => ({ value: id, inputDisplay: ( - {content} + {showTitles ? name : content} ), dropdownDisplay: ( @@ -48,5 +53,12 @@ export const getOptionFromPrompt = ({ ), }); -export const getOptions = (prompts: Prompt[]): Array> => - prompts.map(getOptionFromPrompt); +interface GetOptionsProps { + prompts: Prompt[] | undefined; + showTitles?: boolean; +} +export const getOptions = ({ + prompts, + showTitles = false, +}: GetOptionsProps): Array> => + prompts?.map((p) => getOptionFromPrompt({ ...p, showTitles })) ?? []; 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 index 6610ba836de70..ccd2fc7b11c44 100644 --- 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 @@ -9,23 +9,50 @@ 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 { mockSystemPrompt } from '../../../mock/system_prompt'; import { SystemPrompt } from '.'; +import { BASE_CONVERSATIONS, Conversation } from '../../../..'; +import { DEFAULT_CONVERSATION_TITLE } from '../../use_conversation/translations'; + +const mockUseAssistantContext = { + setConversations: jest.fn(), +}; +jest.mock('../../../assistant_context', () => { + const original = jest.requireActual('../../../assistant_context'); + + return { + ...original, + useAssistantContext: () => mockUseAssistantContext, + }; +}); + +const mockUseConversation = { + setApiConfig: jest.fn(), +}; +jest.mock('../../use_conversation', () => { + const original = jest.requireActual('../../use_conversation'); + + return { + ...original, + useConversation: () => mockUseConversation, + }; +}); + +const BASE_CONVERSATION: Conversation = { + ...BASE_CONVERSATIONS[DEFAULT_CONVERSATION_TITLE], + apiConfig: { + defaultSystemPrompt: mockSystemPrompt, + }, +}; describe('SystemPrompt', () => { beforeEach(() => jest.clearAllMocks()); - describe('when selectedSystemPromptId is null', () => { - const selectedSystemPromptId = null; + describe('when conversation is undefined', () => { + const conversation = undefined; beforeEach(() => { - render( - - ); + render(); }); it('renders the system prompt select', () => { @@ -45,17 +72,9 @@ describe('SystemPrompt', () => { }); }); - describe('when selectedSystemPromptId is NOT null', () => { - const selectedSystemPromptId = mockSystemPrompt.id; - + describe('when conversation is NOT null', () => { beforeEach(() => { - render( - - ); + render(); }); it('does NOT render the system prompt select', () => { @@ -76,13 +95,7 @@ describe('SystemPrompt', () => { }); it('shows the system prompt select when the edit button is clicked', () => { - render( - - ); + render(); userEvent.click(screen.getByTestId('edit')); @@ -90,29 +103,16 @@ describe('SystemPrompt', () => { }); it('clears the selected system prompt when the clear button is clicked', () => { - const setSelectedSystemPromptId = jest.fn(); - - render( - - ); + const apiConfig = { apiConfig: { defaultSystemPrompt: undefined }, conversationId: 'Default' }; + render(); userEvent.click(screen.getByTestId('clear')); - expect(setSelectedSystemPromptId).toHaveBeenCalledWith(null); + expect(mockUseConversation.setApiConfig).toHaveBeenCalledWith(apiConfig); }); it('shows the system prompt select when system prompt text is clicked', () => { - render( - - ); + render(); fireEvent.click(screen.getByTestId('systemPromptText')); 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 fbe83ce2c94e7..ba7f5ffd73c62 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 @@ -7,63 +7,71 @@ 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 { css } from '@emotion/react'; +import { Conversation } from '../../../..'; import * as i18n from './translations'; import type { Prompt } from '../../types'; import { SelectSystemPrompt } from './select_system_prompt'; - -const SystemPromptText = styled(EuiText)` - white-space: pre-line; -`; +import { useConversation } from '../../use_conversation'; interface Props { - selectedSystemPromptId: string | null; - setSelectedSystemPromptId: React.Dispatch>; - systemPrompts: Prompt[]; + conversation: Conversation | undefined; } -const SystemPromptComponent: React.FC = ({ - selectedSystemPromptId, - setSelectedSystemPromptId, - systemPrompts, -}) => { - const [showSelectSystemPrompt, setShowSelectSystemPrompt] = React.useState(false); +const SystemPromptComponent: React.FC = ({ conversation }) => { + const { setApiConfig } = useConversation(); const selectedPrompt: Prompt | undefined = useMemo( - () => getPromptById({ prompts: systemPrompts, id: selectedSystemPromptId ?? '' }), - [systemPrompts, selectedSystemPromptId] + () => conversation?.apiConfig.defaultSystemPrompt, + [conversation] ); + const [isEditing, setIsEditing] = React.useState(false); - const clearSystemPrompt = useCallback(() => { - setSelectedSystemPromptId(null); - setShowSelectSystemPrompt(false); - }, [setSelectedSystemPromptId]); + const handleClearSystemPrompt = useCallback(() => { + if (conversation) { + setApiConfig({ + conversationId: conversation.id, + apiConfig: { + ...conversation.apiConfig, + defaultSystemPrompt: undefined, + }, + }); + } + }, [conversation, setApiConfig]); - const onShowSelectSystemPrompt = useCallback(() => setShowSelectSystemPrompt(true), []); + const handleEditSystemPrompt = useCallback(() => setIsEditing(true), []); return ( -
- {selectedPrompt == null || showSelectSystemPrompt ? ( +
+ {selectedPrompt == null || isEditing ? ( ) : ( - {selectedPrompt?.content ?? ''} - + @@ -73,7 +81,7 @@ const SystemPromptComponent: React.FC = ({ aria-label={i18n.SELECT_A_SYSTEM_PROMPT} data-test-subj="edit" iconType="documentEdit" - onClick={onShowSelectSystemPrompt} + onClick={handleEditSystemPrompt} /> @@ -84,7 +92,7 @@ const SystemPromptComponent: React.FC = ({ aria-label={i18n.CLEAR_SYSTEM_PROMPT} data-test-subj="clear" iconType="cross" - onClick={clearSystemPrompt} + onClick={handleClearSystemPrompt} /> 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 index affd07e26eb68..761afa6a1579d 100644 --- 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 @@ -9,109 +9,132 @@ 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 = { + conversation: undefined, selectedPrompt: undefined, - setSelectedSystemPromptId: jest.fn(), - setShowSelectSystemPrompt: jest.fn(), - showSelectSystemPrompt: false, - systemPrompts: [mockSystemPrompt, mockSuperheroSystemPrompt], }; +const mockUseAssistantContext = { + allSystemPrompts: [ + { + id: 'default-system-prompt', + content: 'default', + name: 'default', + promptType: 'system', + isDefault: true, + isNewConversationDefault: true, + }, + { + id: 'CB9FA555-B59F-4F71-AFF9-8A891AC5BC28', + content: 'superhero', + name: 'superhero', + promptType: 'system', + isDefault: true, + }, + ], + setAllSystemPrompts: jest.fn(), +}; +jest.mock('../../../../assistant_context', () => { + const original = jest.requireActual('../../../../assistant_context'); + + return { + ...original, + useAssistantContext: () => mockUseAssistantContext, + }; +}); + describe('SelectSystemPrompt', () => { beforeEach(() => jest.clearAllMocks()); - it('renders the prompt super select when showSelectSystemPrompt is true', () => { - const { getByTestId } = render(); + it('renders the prompt super select when isEditing is true', () => { + const { getByTestId } = render(); expect(getByTestId('promptSuperSelect')).toBeInTheDocument(); }); - it('does NOT render the prompt super select when showSelectSystemPrompt is false', () => { - const { queryByTestId } = render( - - ); + it('does NOT render the prompt super select when isEditing is false', () => { + const { queryByTestId } = render(); expect(queryByTestId('promptSuperSelect')).not.toBeInTheDocument(); }); - it('renders the clear system prompt button when showSelectSystemPrompt is true', () => { - const { getByTestId } = render(); + it('does NOT render the clear system prompt button when isEditing is true', () => { + const { queryByTestId } = render(); - expect(getByTestId('clearSystemPrompt')).toBeInTheDocument(); + expect(queryByTestId('clearSystemPrompt')).not.toBeInTheDocument(); }); - it('does NOT render the clear system prompt button when showSelectSystemPrompt is false', () => { - const { queryByTestId } = render( - + it('renders the clear system prompt button when isEditing is true AND isClearable is true', () => { + const { getByTestId } = render( + ); + expect(getByTestId('clearSystemPrompt')).toBeInTheDocument(); + }); + + it('does NOT render the clear system prompt button when isEditing is false', () => { + const { queryByTestId } = render(); + expect(queryByTestId('clearSystemPrompt')).not.toBeInTheDocument(); }); - it('renders the add system prompt button when showSelectSystemPrompt is false', () => { - const { getByTestId } = render( - - ); + it('renders the add system prompt button when isEditing 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( - - ); + it('does NOT render the add system prompt button when isEditing 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(); + it('clears the selected system prompt when the clear button is clicked', () => { + const clearSelectedSystemPrompt = jest.fn(); const { getByTestId } = render( ); userEvent.click(getByTestId('clearSystemPrompt')); - expect(setSelectedSystemPromptId).toHaveBeenCalledWith(null); + expect(clearSelectedSystemPrompt).toHaveBeenCalledTimes(1); }); it('hides the select when the clear button is clicked', () => { - const setShowSelectSystemPrompt = jest.fn(); + const setIsEditing = jest.fn(); const { getByTestId } = render( ); userEvent.click(getByTestId('clearSystemPrompt')); - expect(setShowSelectSystemPrompt).toHaveBeenCalledWith(false); + expect(setIsEditing).toHaveBeenCalledWith(false); }); it('shows the select when the add button is clicked', () => { - const setShowSelectSystemPrompt = jest.fn(); + const setIsEditing = jest.fn(); const { getByTestId } = render( - + ); userEvent.click(getByTestId('addSystemPrompt')); - expect(setShowSelectSystemPrompt).toHaveBeenCalledWith(true); + expect(setIsEditing).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 index 180f7202c2d2b..449c9f4ec0022 100644 --- 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 @@ -7,6 +7,7 @@ import { css } from '@emotion/react'; import { + EuiButtonEmpty, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, @@ -14,51 +15,134 @@ import { EuiSuperSelect, EuiToolTip, } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; +import { Conversation } from '../../../../..'; import { getOptions } from '../helpers'; import * as i18n from '../translations'; import type { Prompt } from '../../../types'; +import { useAssistantContext } from '../../../../assistant_context'; +import { useConversation } from '../../../use_conversation'; +import { SystemPromptModal } from '../system_prompt_modal/system_prompt_modal'; export interface Props { + conversation: Conversation | undefined; selectedPrompt: Prompt | undefined; - setSelectedSystemPromptId: React.Dispatch>; - setShowSelectSystemPrompt: React.Dispatch>; - showSelectSystemPrompt: boolean; - systemPrompts: Prompt[]; + clearSelectedSystemPrompt?: () => void; + fullWidth?: boolean; + isClearable?: boolean; + isEditing?: boolean; + isOpen?: boolean; + onSystemPromptModalVisibilityChange?: (isVisible: boolean) => void; + setIsEditing?: React.Dispatch>; + showTitles?: boolean; } +const ADD_NEW_SYSTEM_PROMPT = 'ADD_NEW_SYSTEM_PROMPT'; + const SelectSystemPromptComponent: React.FC = ({ + conversation, selectedPrompt, - setSelectedSystemPromptId, - setShowSelectSystemPrompt, - showSelectSystemPrompt, - systemPrompts, + clearSelectedSystemPrompt, + fullWidth = true, + isClearable = false, + isEditing = false, + isOpen = false, + onSystemPromptModalVisibilityChange, + setIsEditing, + showTitles = false, }) => { - const options = useMemo(() => getOptions(systemPrompts), [systemPrompts]); + const { allSystemPrompts, setAllSystemPrompts } = useAssistantContext(); + const { setApiConfig } = useConversation(); + + const [isOpenLocal, setIsOpenLocal] = useState(isOpen); + const handleOnBlur = useCallback(() => setIsOpenLocal(false), []); + + // Write the selected system prompt to the conversation config + const setSelectedSystemPrompt = useCallback( + (prompt: Prompt | undefined) => { + if (conversation) { + setApiConfig({ + conversationId: conversation.id, + apiConfig: { + ...conversation.apiConfig, + defaultSystemPrompt: prompt, + }, + }); + } + }, + [conversation, setApiConfig] + ); + + // Connector Modal State + const [isSystemPromptModalVisible, setIsSystemPromptModalVisible] = useState(false); + const addNewSystemPrompt = useMemo(() => { + return { + value: ADD_NEW_SYSTEM_PROMPT, + inputDisplay: i18n.ADD_NEW_SYSTEM_PROMPT, + dropdownDisplay: ( + + + + {i18n.ADD_NEW_SYSTEM_PROMPT} + + + + {/* Right offset to compensate for 'selected' icon of EuiSuperSelect since native footers aren't supported*/} +
+ + + ), + }; + }, []); + // Callback for modal onSave, saves to local storage on change + const onSystemPromptsChange = useCallback( + (newSystemPrompts: Prompt[]) => { + setAllSystemPrompts(newSystemPrompts); + setIsSystemPromptModalVisible(false); + onSystemPromptModalVisibilityChange?.(false); + }, + [onSystemPromptModalVisibilityChange, setAllSystemPrompts] + ); + + // SuperSelect State/Actions + const options = useMemo( + () => getOptions({ prompts: allSystemPrompts, showTitles }), + [allSystemPrompts, showTitles] + ); const onChange = useCallback( - (value) => { - setSelectedSystemPromptId(value); - setShowSelectSystemPrompt(false); + (selectedSystemPromptId) => { + if (selectedSystemPromptId === ADD_NEW_SYSTEM_PROMPT) { + onSystemPromptModalVisibilityChange?.(true); + setIsSystemPromptModalVisible(true); + return; + } + setSelectedSystemPrompt(allSystemPrompts.find((sp) => sp.id === selectedSystemPromptId)); + setIsEditing?.(false); }, - [setSelectedSystemPromptId, setShowSelectSystemPrompt] + [allSystemPrompts, onSystemPromptModalVisibilityChange, setIsEditing, setSelectedSystemPrompt] ); const clearSystemPrompt = useCallback(() => { - setSelectedSystemPromptId(null); - setShowSelectSystemPrompt(false); - }, [setSelectedSystemPromptId, setShowSelectSystemPrompt]); + setSelectedSystemPrompt(undefined); + setIsEditing?.(false); + clearSelectedSystemPrompt?.(); + }, [clearSelectedSystemPrompt, setIsEditing, setSelectedSystemPrompt]); - const onShowSelectSystemPrompt = useCallback( - () => setShowSelectSystemPrompt(true), - [setShowSelectSystemPrompt] - ); + const onShowSelectSystemPrompt = useCallback(() => { + setIsEditing?.(true); + setIsOpenLocal(true); + }, [setIsEditing]); return ( - - {showSelectSystemPrompt && ( + + {isEditing && ( = ({ > @@ -79,7 +165,7 @@ const SelectSystemPromptComponent: React.FC = ({ - {showSelectSystemPrompt ? ( + {isEditing && isClearable && ( = ({ onClick={clearSystemPrompt} /> - ) : ( + )} + {!isEditing && ( = ({ )} + {isSystemPromptModalVisible && ( + setIsSystemPromptModalVisible(false)} + onSystemPromptsChange={onSystemPromptsChange} + systemPrompts={allSystemPrompts} + /> + )} ); }; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/conversation_multi_selector/conversation_multi_selector.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/conversation_multi_selector/conversation_multi_selector.tsx new file mode 100644 index 0000000000000..77be34910b987 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/conversation_multi_selector/conversation_multi_selector.tsx @@ -0,0 +1,74 @@ +/* + * 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 } from '@elastic/eui'; + +import { Conversation } from '../../../../../..'; +import * as i18n from '../translations'; + +interface Props { + onConversationSelectionChange: (conversations: Conversation[]) => void; + conversations: Conversation[]; + selectedConversations?: Conversation[]; +} + +/** + * Selector for choosing multiple Conversations + */ +export const ConversationMultiSelector: React.FC = React.memo( + ({ onConversationSelectionChange, conversations, selectedConversations = [] }) => { + // ComboBox options + const options = useMemo( + () => + conversations.map((conversation) => ({ + label: conversation.id, + })), + [conversations] + ); + const selectedOptions = useMemo(() => { + return selectedConversations != null + ? selectedConversations.map((conversation) => ({ + label: conversation.id, + })) + : []; + }, [selectedConversations]); + + const handleSelectionChange = useCallback( + (conversationMultiSelectorOption: EuiComboBoxOptionOption[]) => { + const newConversationSelection = conversations.filter((conversation) => + conversationMultiSelectorOption.some((cmso) => conversation.id === cmso.label) + ); + onConversationSelectionChange(newConversationSelection); + }, + [onConversationSelectionChange, conversations] + ); + + // Callback for when user selects a conversation + const onChange = useCallback( + (newOptions: EuiComboBoxOptionOption[]) => { + if (newOptions.length === 0) { + handleSelectionChange([]); + } else if (options.findIndex((o) => o.label === newOptions?.[0].label) !== -1) { + handleSelectionChange(newOptions); + } + }, + [handleSelectionChange, options] + ); + + return ( + + ); + } +); + +ConversationMultiSelector.displayName = 'ConversationMultiSelector'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_modal.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_modal.tsx new file mode 100644 index 0000000000000..193e6f8e98a34 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_modal.tsx @@ -0,0 +1,219 @@ +/* + * 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, + EuiTextArea, + EuiCheckbox, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; + +// eslint-disable-next-line @kbn/eslint/module_migration +import styled from 'styled-components'; + +import { Conversation, Prompt } from '../../../../..'; +import * as i18n from './translations'; +import { useAssistantContext } from '../../../../assistant_context'; +import { ConversationMultiSelector } from './conversation_multi_selector/conversation_multi_selector'; +import { + SYSTEM_PROMPT_SELECTOR_CLASSNAME, + SystemPromptSelector, +} from './system_prompt_selector/system_prompt_selector'; + +const StyledEuiModal = styled(EuiModal)` + min-width: 400px; + max-width: 400px; + max-height: 80vh; +`; + +interface Props { + systemPrompts: Prompt[]; + onClose: ( + event?: React.KeyboardEvent | React.MouseEvent + ) => void; + onSystemPromptsChange: (systemPrompts: Prompt[]) => void; +} + +/** + * Modal for adding/removing system prompts. Configure name, prompt and default conversations. + */ +export const SystemPromptModal: React.FC = React.memo( + ({ systemPrompts, onClose, onSystemPromptsChange }) => { + const { conversations } = useAssistantContext(); + // Local state for quick prompts (returned to parent on save via onSystemPromptsChange()) + const [updatedSystemPrompts, setUpdatedSystemPrompts] = useState(systemPrompts); + + // Form options + const [selectedSystemPrompt, setSelectedSystemPrompt] = useState(); + // Prompt + const [prompt, setPrompt] = useState(''); + const handlePromptTextChange = useCallback((e: React.ChangeEvent) => { + setPrompt(e.target.value); + }, []); + // Conversations this system prompt should be a default for + const [selectedConversations, setSelectedConversations] = useState([]); + const onConversationSelectionChange = useCallback((newConversations: Conversation[]) => { + setSelectedConversations(newConversations); + }, []); + // Whether this system prompt should be the default for new conversations + const [isNewConversationDefault, setIsNewConversationDefault] = useState(false); + const handleNewConversationDefaultChange = useCallback( + (e) => { + setIsNewConversationDefault(e.target.checked); + if (selectedSystemPrompt != null) { + setUpdatedSystemPrompts((prev) => { + return prev.map((pp) => ({ + ...pp, + isNewConversationDefault: selectedSystemPrompt.id === pp.id && e.target.checked, + })); + }); + setSelectedSystemPrompt((prev) => + prev != null ? { ...prev, isNewConversationDefault: e.target.checked } : prev + ); + } + }, + [selectedSystemPrompt] + ); + + // When top level system prompt selection changes + const onSystemPromptSelectionChange = useCallback( + (systemPrompt?: Prompt | string) => { + const newPrompt: Prompt | undefined = + typeof systemPrompt === 'string' + ? { + id: systemPrompt ?? '', + content: '', + name: systemPrompt ?? '', + promptType: 'system', + } + : systemPrompt; + + setSelectedSystemPrompt(newPrompt); + setPrompt(newPrompt?.content ?? ''); + setIsNewConversationDefault(newPrompt?.isNewConversationDefault ?? false); + // Find all conversations that have this system prompt as a default + setSelectedConversations( + newPrompt != null + ? Object.values(conversations).filter( + (conversation) => conversation?.apiConfig.defaultSystemPrompt?.id === newPrompt?.id + ) + : [] + ); + }, + [conversations] + ); + + const onSystemPromptDeleted = useCallback((id: string) => { + setUpdatedSystemPrompts((prev) => prev.filter((sp) => sp.id !== id)); + }, []); + + const handleSave = useCallback(() => { + onSystemPromptsChange(updatedSystemPrompts); + }, [onSystemPromptsChange, updatedSystemPrompts]); + + // useEffects + // Update system prompts on any field change since editing is in place + useEffect(() => { + if (selectedSystemPrompt != null) { + setUpdatedSystemPrompts((prev) => { + const alreadyExists = prev.some((sp) => sp.id === selectedSystemPrompt.id); + if (alreadyExists) { + return prev.map((sp) => { + if (sp.id === selectedSystemPrompt.id) { + return { + ...sp, + content: prompt, + promptType: 'system', + }; + } + return sp; + }); + } else { + return [ + ...prev, + { + ...selectedSystemPrompt, + content: prompt, + promptType: 'system', + }, + ]; + } + }); + } + }, [prompt, selectedSystemPrompt]); + + return ( + + + {i18n.ADD_SYSTEM_PROMPT_MODAL_TITLE} + + + + + + + + + + + + + + + + + {i18n.SYSTEM_PROMPT_DEFAULT_NEW_CONVERSATION} + + + + + } + checked={isNewConversationDefault} + onChange={handleNewConversationDefaultChange} + compressed + /> + + + + + {i18n.CANCEL} + + + {i18n.SAVE} + + + + ); + } +); + +SystemPromptModal.displayName = 'SystemPromptModal'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector.tsx new file mode 100644 index 0000000000000..dddefab1a77e5 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector.tsx @@ -0,0 +1,219 @@ +/* + * 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, + EuiHighlight, + EuiComboBox, + EuiComboBoxOptionOption, + EuiIcon, +} from '@elastic/eui'; + +import { css } from '@emotion/react'; +import { Prompt } from '../../../../../..'; +import * as i18n from './translations'; +import { SYSTEM_PROMPT_DEFAULT_NEW_CONVERSATION } from '../translations'; + +export const SYSTEM_PROMPT_SELECTOR_CLASSNAME = 'systemPromptSelector'; + +interface Props { + onSystemPromptDeleted: (systemPromptTitle: string) => void; + onSystemPromptSelectionChange: (systemPrompt?: Prompt | string) => void; + systemPrompts: Prompt[]; + selectedSystemPrompt?: Prompt; +} + +export type SystemPromptSelectorOption = EuiComboBoxOptionOption<{ + isDefault: boolean; + isNewConversationDefault: boolean; +}>; + +/** + * Selector for choosing and deleting System Prompts + */ +export const SystemPromptSelector: React.FC = React.memo( + ({ + systemPrompts, + onSystemPromptDeleted, + onSystemPromptSelectionChange, + selectedSystemPrompt, + }) => { + // Form options + const [options, setOptions] = useState( + systemPrompts.map((sp) => ({ + value: { + isDefault: sp.isDefault ?? false, + isNewConversationDefault: sp.isNewConversationDefault ?? false, + }, + label: sp.name, + })) + ); + const selectedOptions = useMemo(() => { + return selectedSystemPrompt + ? [ + { + value: { + isDefault: selectedSystemPrompt.isDefault ?? false, + isNewConversationDefault: selectedSystemPrompt.isNewConversationDefault ?? false, + }, + label: selectedSystemPrompt.name, + }, + ] + : []; + }, [selectedSystemPrompt]); + + const handleSelectionChange = useCallback( + (systemPromptSelectorOption: SystemPromptSelectorOption[]) => { + const newSystemPrompt = + systemPromptSelectorOption.length === 0 + ? undefined + : systemPrompts.find((sp) => sp.name === systemPromptSelectorOption[0]?.label) ?? + systemPromptSelectorOption[0]?.label; + onSystemPromptSelectionChange(newSystemPrompt); + }, + [onSystemPromptSelectionChange, systemPrompts] + ); + + // Callback for when user types to create a new system prompt + const onCreateOption = useCallback( + (searchValue, flattenedOptions = []) => { + if (!searchValue || !searchValue.trim().toLowerCase()) { + return; + } + + const normalizedSearchValue = searchValue.trim().toLowerCase(); + const optionExists = + flattenedOptions.findIndex( + (option: SystemPromptSelectorOption) => + 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: SystemPromptSelectorOption[]) => { + 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([]); + } + onSystemPromptDeleted(label); + }, + [handleSelectionChange, onSystemPromptDeleted, options, selectedOptions] + ); + + const renderOption: ( + option: SystemPromptSelectorOption, + searchValue: string, + OPTION_CONTENT_CLASSNAME: string + ) => React.ReactNode = (option, searchValue, contentClassName) => { + const { label, value } = option; + return ( + + + + + + + {label} + + + + {value?.isNewConversationDefault && ( + + + + + + )} + + + + {!value?.isDefault && ( + + + { + e.stopPropagation(); + onDelete(label); + }} + css={css` + visibility: hidden; + .parentFlexGroup:hover & { + visibility: visible; + } + `} + /> + + + )} + + ); + }; + + return ( + + ); + } +); + +SystemPromptSelector.displayName = 'SystemPromptSelector'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/translations.ts new file mode 100644 index 0000000000000..34652bdef8656 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_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 SYSTEM_PROMPT_SELECTOR = i18n.translate( + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.systemPromptSelector.ariaLabel', + { + defaultMessage: 'Select to edit, or type to create new', + } +); + +export const DELETE_SYSTEM_PROMPT = i18n.translate( + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.systemPromptSelector.deletePromptTitle', + { + defaultMessage: 'Delete System Prompt', + } +); + +export const CUSTOM_OPTION_TEXT = i18n.translate( + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.systemPromptSelector.customOptionText', + { + defaultMessage: 'Create new System Prompt named', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/translations.ts new file mode 100644 index 0000000000000..c57e84cd54693 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/translations.ts @@ -0,0 +1,70 @@ +/* + * 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 = i18n.translate( + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.addSystemPromptTitle', + { + defaultMessage: 'Add system prompt...', + } +); +export const ADD_SYSTEM_PROMPT_MODAL_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.modalTitle', + { + defaultMessage: 'System Prompts', + } +); + +export const SYSTEM_PROMPT_NAME = i18n.translate( + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.nameLabel', + { + defaultMessage: 'Name', + } +); + +export const SYSTEM_PROMPT_PROMPT = i18n.translate( + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.promptLabel', + { + defaultMessage: 'Prompt', + } +); + +export const SYSTEM_PROMPT_DEFAULT_CONVERSATIONS = i18n.translate( + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.defaultConversationsLabel', + { + defaultMessage: 'Default conversations', + } +); + +export const SYSTEM_PROMPT_DEFAULT_NEW_CONVERSATION = i18n.translate( + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.defaultNewConversationTitle', + { + defaultMessage: 'Use as default for all new conversations', + } +); + +export const SYSTEM_PROMPT_DEFAULT_CONVERSATIONS_HELP_TEXT = i18n.translate( + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.defaultConversationsHelpText', + { + defaultMessage: 'Conversations that should use this System Prompt by default', + } +); + +export const CANCEL = i18n.translate( + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.slCancelButtonTitle', + { + defaultMessage: 'Cancel', + } +); + +export const SAVE = i18n.translate( + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.slSaveButtonTitle', + { + defaultMessage: 'Save', + } +); 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 075e017bc56f0..ec4202f19b324 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 @@ -27,3 +27,10 @@ export const SELECT_A_SYSTEM_PROMPT = i18n.translate( defaultMessage: 'Select a system prompt', } ); + +export const ADD_NEW_SYSTEM_PROMPT = i18n.translate( + 'xpack.elasticAssistant.assistant.firstPromptEditor.addNewSystemPrompt', + { + defaultMessage: 'Add new system prompt...', + } +); 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 index 99fa4ed8bc96c..db5243b3a1c12 100644 --- 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 @@ -30,7 +30,11 @@ 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 StyledEuiModal = styled(EuiModal)` + min-width: 400px; + max-width: 400px; + max-height: 80vh; +`; const DEFAULT_COLOR = '#D36086'; 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 index 5e0c7112dea8f..72ada2e7ad0fd 100644 --- 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 @@ -16,7 +16,7 @@ export const ADD_QUICK_PROMPT = i18n.translate( export const ADD_QUICK_PROMPT_MODAL_TITLE = i18n.translate( 'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.modalTitle', { - defaultMessage: 'Add/Modify Quick Prompt', + defaultMessage: 'Quick Prompts', } ); 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 index 2d481ad141ca4..712af58e1cfba 100644 --- 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 @@ -83,14 +83,14 @@ export const QuickPromptSelector: React.FC = React.memo( flattenedOptions.findIndex( (option: QuickPromptSelectorOption) => option.label.trim().toLowerCase() === normalizedSearchValue - ) === -1; + ) !== -1; const newOption = { value: searchValue, label: searchValue, }; - if (optionExists) { + if (!optionExists) { setOptions([...options, newOption]); } handleSelectionChange([newOption]); 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 index a88c541b312f0..d66ed24d2426a 100644 --- 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 @@ -10,7 +10,6 @@ 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'; @@ -20,8 +19,6 @@ 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; @@ -33,18 +30,12 @@ interface QuickPromptsProps { * and localstorage for storing new and edited prompts. */ export const QuickPrompts: React.FC = React.memo(({ setInput }) => { - const { basePromptContexts, baseQuickPrompts, nameSpace, promptContexts } = 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 ?? []); + const { allQuickPrompts, basePromptContexts, promptContexts, setAllQuickPrompts } = + useAssistantContext(); const contextFilteredQuickPrompts = useMemo(() => { const registeredPromptContextTitles = Object.values(promptContexts).map((pc) => pc.category); - return quickPrompts.filter((quickPrompt) => { + return allQuickPrompts.filter((quickPrompt) => { // Return quick prompt as match if it has no categories, otherwise ensure category exists in registered prompt contexts if (quickPrompt.categories == null || quickPrompt.categories.length === 0) { return true; @@ -54,7 +45,7 @@ export const QuickPrompts: React.FC = React.memo(({ setInput }); } }); - }, [quickPrompts, promptContexts]); + }, [allQuickPrompts, promptContexts]); // Overflow state const [isOverflowPopoverOpen, setIsOverflowPopoverOpen] = useState(false); @@ -74,10 +65,9 @@ export const QuickPrompts: React.FC = React.memo(({ setInput // Callback for manage modal, saves to local storage on change const onQuickPromptsChange = useCallback( (newQuickPrompts: QuickPrompt[]) => { - setLocalStorageQuickPrompts(newQuickPrompts); - setQuickPrompts(newQuickPrompts); + setAllQuickPrompts(newQuickPrompts); }, - [setLocalStorageQuickPrompts] + [setAllQuickPrompts] ); return ( @@ -126,7 +116,7 @@ export const QuickPrompts: React.FC = React.memo(({ setInput 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 5690b7361f2b0..faf9a4d21c3c8 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/translations.ts @@ -44,6 +44,20 @@ export const SETTINGS_CONNECTOR_TITLE = i18n.translate( } ); +export const SETTINGS_PROMPT_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.promptTitle', + { + defaultMessage: 'System Prompt', + } +); + +export const SETTINGS_PROMPT_HELP_TEXT_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.promptHelpTextTitle', + { + defaultMessage: 'Context provided before every conversation', + } +); + export const SUBMIT_MESSAGE = i18n.translate('xpack.elasticAssistant.assistant.submitMessage', { defaultMessage: 'Submit message', }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts index 9c7c2058e4b42..6e3d6a346dd69 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts @@ -12,4 +12,6 @@ export interface Prompt { content: string; name: string; promptType: PromptType; + isDefault?: boolean; + isNewConversationDefault?: boolean; } 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 index 8c2af801af60f..7fa03f713ee8d 100644 --- 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 @@ -56,6 +56,7 @@ interface UseConversation { conversationId, messages, }: CreateConversationProps) => Conversation | undefined; + deleteConversation: (conversationId: string) => void; setApiConfig: ({ conversationId, apiConfig }: SetApiConfigProps) => void; setConversation: ({ conversation }: SetConversationProps) => void; } @@ -147,6 +148,25 @@ export const useConversation = (): UseConversation => { [setConversations] ); + /** + * Delete the conversation with the given conversationId + */ + const deleteConversation = useCallback( + (conversationId: string): Conversation | undefined => { + let deletedConversation: Conversation | undefined; + setConversations((prev: Record) => { + const { [conversationId]: prevConversation, ...updatedConversations } = prev; + deletedConversation = prevConversation; + if (prevConversation != null) { + return updatedConversations; + } + return prev; + }); + return deletedConversation; + }, + [setConversations] + ); + /** * Update the apiConfig for a given conversationId */ @@ -188,5 +208,12 @@ export const useConversation = (): UseConversation => { [setConversations] ); - return { appendMessage, clearConversation, createConversation, setApiConfig, setConversation }; + return { + appendMessage, + clearConversation, + createConversation, + deleteConversation, + setApiConfig, + setConversation, + }; }; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx new file mode 100644 index 0000000000000..bfe62a2848a9f --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx @@ -0,0 +1,10 @@ +/* + * 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 const DEFAULT_ASSISTANT_NAMESPACE = 'elasticAssistantDefault'; +export const QUICK_PROMPT_LOCAL_STORAGE_KEY = 'quickPrompts'; +export const SYSTEM_PROMPT_LOCAL_STORAGE_KEY = 'systemPrompts'; 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 a11ad21237237..a460a60748386 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 @@ -11,6 +11,7 @@ import { omit } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public'; +import { useLocalStorage } from 'react-use'; import { updatePromptContexts } from './helpers'; import type { PromptContext, @@ -22,6 +23,13 @@ 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'; +import { Prompt } from '../assistant/types'; +import { BASE_SYSTEM_PROMPTS } from '../content/prompts/system'; +import { + DEFAULT_ASSISTANT_NAMESPACE, + QUICK_PROMPT_LOCAL_STORAGE_KEY, + SYSTEM_PROMPT_LOCAL_STORAGE_KEY, +} from './constants'; export interface ShowAssistantOverlayProps { showOverlay: boolean; @@ -39,6 +47,7 @@ interface AssistantProviderProps { augmentMessageCodeBlocks: (currentConversation: Conversation) => CodeBlockDetails[][]; basePromptContexts?: PromptContextTemplate[]; baseQuickPrompts?: QuickPrompt[]; + baseSystemPrompts?: Prompt[]; children: React.ReactNode; getComments: ({ currentConversation, @@ -57,8 +66,11 @@ interface AssistantProviderProps { interface UseAssistantContext { actionTypeRegistry: ActionTypeRegistryContract; augmentMessageCodeBlocks: (currentConversation: Conversation) => CodeBlockDetails[][]; + allQuickPrompts: QuickPrompt[]; + allSystemPrompts: Prompt[]; basePromptContexts: PromptContextTemplate[]; baseQuickPrompts: QuickPrompt[]; + baseSystemPrompts: Prompt[]; conversationIds: string[]; conversations: Record; getComments: ({ @@ -72,6 +84,8 @@ interface UseAssistantContext { promptContexts: Record; nameSpace: string; registerPromptContext: RegisterPromptContext; + setAllQuickPrompts: React.Dispatch>; + setAllSystemPrompts: React.Dispatch>; setConversations: React.Dispatch>>; setShowAssistantOverlay: (showAssistantOverlay: ShowAssistantOverlay) => void; showAssistantOverlay: ShowAssistantOverlay; @@ -86,14 +100,31 @@ export const AssistantProvider: React.FC = ({ augmentMessageCodeBlocks, basePromptContexts = [], baseQuickPrompts = [], + baseSystemPrompts = BASE_SYSTEM_PROMPTS, children, getComments, http, getInitialConversations, - nameSpace = 'elasticAssistantDefault', + nameSpace = DEFAULT_ASSISTANT_NAMESPACE, setConversations, title = DEFAULT_ASSISTANT_TITLE, }) => { + /** + * Local storage for all quick prompts, prefixed by assistant nameSpace + */ + const [localStorageQuickPrompts, setLocalStorageQuickPrompts] = useLocalStorage( + `${nameSpace}.${QUICK_PROMPT_LOCAL_STORAGE_KEY}`, + baseQuickPrompts + ); + + /** + * Local storage for all system prompts, prefixed by assistant nameSpace + */ + const [localStorageSystemPrompts, setLocalStorageSystemPrompts] = useLocalStorage( + `${nameSpace}.${SYSTEM_PROMPT_LOCAL_STORAGE_KEY}`, + baseSystemPrompts + ); + /** * Prompt contexts are used to provide components a way to register and make their data available to the assistant. */ @@ -169,8 +200,11 @@ export const AssistantProvider: React.FC = ({ () => ({ actionTypeRegistry, augmentMessageCodeBlocks, + allQuickPrompts: localStorageQuickPrompts ?? [], + allSystemPrompts: localStorageSystemPrompts ?? [], basePromptContexts, baseQuickPrompts, + baseSystemPrompts, conversationIds, conversations, getComments, @@ -178,6 +212,8 @@ export const AssistantProvider: React.FC = ({ promptContexts, nameSpace, registerPromptContext, + setAllQuickPrompts: setLocalStorageQuickPrompts, + setAllSystemPrompts: setLocalStorageSystemPrompts, setConversations: onConversationsUpdated, setShowAssistantOverlay, showAssistantOverlay, @@ -189,14 +225,19 @@ export const AssistantProvider: React.FC = ({ augmentMessageCodeBlocks, basePromptContexts, baseQuickPrompts, + baseSystemPrompts, conversationIds, conversations, getComments, http, + localStorageQuickPrompts, + localStorageSystemPrompts, promptContexts, nameSpace, registerPromptContext, onConversationsUpdated, + setLocalStorageQuickPrompts, + setLocalStorageSystemPrompts, showAssistantOverlay, title, unRegisterPromptContext, 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 index 33af928a8e900..e237b8855f731 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx @@ -6,6 +6,7 @@ */ import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/gen_ai/constants'; +import { Prompt } from '../assistant/types'; export type ConversationRole = 'system' | 'user' | 'assistant'; @@ -45,11 +46,13 @@ export interface ConversationTheme { export interface Conversation { apiConfig: { connectorId?: string; + defaultSystemPrompt?: Prompt; provider?: OpenAiProviderType; }; id: string; messages: Message[]; theme?: ConversationTheme; + isDefault?: boolean; } export interface OpenAIConfig { 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 index 3d6e4a3db29dc..c9c514a4f4254 100644 --- 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 @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiButtonEmpty, EuiSuperSelect, EuiText } from '@elastic/eui'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSuperSelect, EuiText } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; import { @@ -20,7 +20,6 @@ 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'; @@ -69,17 +68,17 @@ export const ConnectorSelector: React.FC = React.memo( value: ADD_NEW_CONNECTOR, inputDisplay: i18n.ADD_NEW_CONNECTOR, dropdownDisplay: ( - - - {i18n.ADD_NEW_CONNECTOR} - - + + + + {i18n.ADD_NEW_CONNECTOR} + + + + {/* Right offset to compensate for 'selected' icon of EuiSuperSelect since native footers aren't supported*/} +
+ + ), }; }, []); @@ -147,6 +146,7 @@ export const ConnectorSelector: React.FC = React.memo( = ({ augmentMessageCodeBlocks={augmentMessageCodeBlocks} basePromptContexts={Object.values(PROMPT_CONTEXTS)} baseQuickPrompts={BASE_SECURITY_QUICK_PROMPTS} + baseSystemPrompts={BASE_SECURITY_SYSTEM_PROMPTS} getInitialConversations={getInitialConversation} getComments={getComments} http={http} 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 index 927810e02606d..261906baa9150 100644 --- a/x-pack/plugins/security_solution/public/assistant/content/conversations/index.tsx +++ b/x-pack/plugins/security_solution/public/assistant/content/conversations/index.tsx @@ -22,30 +22,42 @@ import { EVENT_SUMMARY_CONVERSATION_ID, } from '../../../common/components/event_details/translations'; import { ELASTIC_SECURITY_ASSISTANT } from '../../comment_actions/translations'; +import { TIMELINE_CONVERSATION_TITLE } from './translations'; export const BASE_SECURITY_CONVERSATIONS: Record = { [ALERT_SUMMARY_CONVERSATION_ID]: { id: ALERT_SUMMARY_CONVERSATION_ID, + isDefault: true, messages: [], apiConfig: {}, }, [DATA_QUALITY_DASHBOARD_CONVERSATION_ID]: { id: DATA_QUALITY_DASHBOARD_CONVERSATION_ID, + isDefault: true, messages: [], apiConfig: {}, }, [DETECTION_RULES_CONVERSATION_ID]: { id: DETECTION_RULES_CONVERSATION_ID, + isDefault: true, messages: [], apiConfig: {}, }, [EVENT_SUMMARY_CONVERSATION_ID]: { id: EVENT_SUMMARY_CONVERSATION_ID, + isDefault: true, + messages: [], + apiConfig: {}, + }, + [TIMELINE_CONVERSATION_TITLE]: { + id: TIMELINE_CONVERSATION_TITLE, + isDefault: true, messages: [], apiConfig: {}, }, [WELCOME_CONVERSATION_TITLE]: { id: WELCOME_CONVERSATION_TITLE, + isDefault: true, theme: { title: ELASTIC_SECURITY_ASSISTANT_TITLE, titleIcon: 'logoSecurity', 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 index b42df2c2cee08..000eb3bdd8065 100644 --- 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 @@ -6,11 +6,11 @@ */ import type { PromptContext, PromptContextTemplate } from '@kbn/elastic-assistant'; -import { USER_PROMPTS } from '@kbn/elastic-assistant'; import * as i18nDataQuality from '@kbn/ecs-data-quality-dashboard'; -import * as i18nEventDetails from '../../../common/components/event_details/translations'; -import * as i18nDetections from '../../../detections/pages/detection_engine/rules/translations'; import * as i18n from './translations'; +import * as i18nDetections from '../../../detections/pages/detection_engine/rules/translations'; +import * as i18nEventDetails from '../../../common/components/event_details/translations'; +import * as i18nUserPrompts from '../prompts/user/translations'; export const PROMPT_CONTEXT_ALERT_CATEGORY = 'alert'; export const PROMPT_CONTEXT_EVENT_CATEGORY = 'event'; @@ -27,25 +27,27 @@ export const PROMPT_CONTEXTS: Record { + return `CONTEXT:\n"""\n${context}\n"""`; +}; diff --git a/x-pack/plugins/security_solution/public/assistant/content/prompts/user/translations.ts b/x-pack/plugins/security_solution/public/assistant/content/prompts/user/translations.ts new file mode 100644 index 0000000000000..8892444351832 --- /dev/null +++ b/x-pack/plugins/security_solution/public/assistant/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.securitySolution.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.securitySolution.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/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 eda57d0a49222..a1f6d45834f34 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,7 +5,7 @@ * 2.0. */ -import { USER_PROMPTS, useAssistantOverlay } from '@kbn/elastic-assistant'; +import { useAssistantOverlay } from '@kbn/elastic-assistant'; import { EuiSpacer, EuiFlyoutBody } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; @@ -35,6 +35,11 @@ import { SUMMARY_VIEW, TIMELINE_VIEW, } from '../../../../common/components/event_details/translations'; +import { + PROMPT_CONTEXT_ALERT_CATEGORY, + PROMPT_CONTEXT_EVENT_CATEGORY, + PROMPT_CONTEXTS, +} from '../../../../assistant/content/prompt_contexts'; interface EventDetailsPanelProps { browserFields: BrowserFields; @@ -108,7 +113,9 @@ const EventDetailsPanelComponent: React.FC = ({ isAlert ? ALERT_SUMMARY_CONTEXT_DESCRIPTION(view) : EVENT_SUMMARY_CONTEXT_DESCRIPTION(view), getPromptContext, null, - USER_PROMPTS.EXPLAIN_THEN_SUMMARIZE_SUGGEST_INVESTIGATION_GUIDE_NON_I18N, + isAlert + ? PROMPT_CONTEXTS[PROMPT_CONTEXT_ALERT_CATEGORY].suggestedUserPrompt + : PROMPT_CONTEXTS[PROMPT_CONTEXT_EVENT_CATEGORY].suggestedUserPrompt, isAlert ? ALERT_SUMMARY_VIEW_CONTEXT_TOOLTIP : EVENT_SUMMARY_VIEW_CONTEXT_TOOLTIP );