From 83a31bfcc0ce02004c1a6517891aedf629bbe99e Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Fri, 14 Jul 2023 17:42:57 -0600 Subject: [PATCH] [Security Solution] [Elastic AI Assistant] Fixes System Prompt not sending as part of the conversation (#161920) ## Summary Resolves System Prompt not sending issues: https://github.com/elastic/kibana/issues/161809 Also resolves: - [X] Not being able to delete really long Conversation, System Prompt, and Quick Prompt names - [X] Fix user/all System Prompts being overridden on refresh - [X] Conversation without default System Prompt not healed if it is initial conversation when Assistant opens (Timeline) - [X] New conversation created from Conversations Settings not getting a connector by default - [X] Current conversation not selected by default when settings gear is clicked (and other assistant instances exist) - [X] Sent to Timeline action sends anonymized values instead of actual plaintext - [X] Clicking Submit does not clear the text area - [X] Remove System Prompt Tooltip - [X] Fixes confusion when System or Quick Prompt is empty by adding a placeholder value - [X] Shows (empty prompt) in System Prompt selector when the Prompt content is empty - [X] Fixes connector error callout flashing on initial load - [X] Shows `(empty prompt)` text within Prompt Editor when prompt content is empty to prevent confusion ### Checklist Delete any items that are not applicable to this PR. - [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 --- .../conversation_selector/index.tsx | 36 ++-- .../conversation_selector_settings/index.tsx | 12 +- .../conversation_settings.tsx | 43 ++--- .../conversation_settings/helpers.tsx | 33 ---- .../impl/assistant/helpers.test.ts | 80 ++++++++- .../impl/assistant/helpers.ts | 10 ++ .../impl/assistant/index.tsx | 125 +++++++++---- .../assistant/prompt_editor/index.test.tsx | 8 +- .../impl/assistant/prompt_editor/index.tsx | 22 ++- .../prompt_editor/system_prompt/helpers.tsx | 7 +- .../system_prompt/index.test.tsx | 99 ++++++++-- .../prompt_editor/system_prompt/index.tsx | 49 +++-- .../select_system_prompt/index.test.tsx | 2 + .../select_system_prompt/index.tsx | 9 +- .../system_prompt_selector.tsx | 40 +++-- .../system_prompt_settings.tsx | 1 + .../system_prompt_modal/translations.ts | 7 + .../system_prompt/translations.ts | 7 + .../impl/assistant/prompt_textarea/index.tsx | 23 +-- .../quick_prompt_selector.tsx | 22 ++- .../quick_prompt_settings.tsx | 1 + .../quick_prompt_settings/translations.ts | 7 + .../assistant/quick_prompts/quick_prompts.tsx | 170 +++++++++--------- .../assistant/settings/assistant_settings.tsx | 7 + .../settings/assistant_settings_button.tsx | 20 ++- .../impl/assistant/types.ts | 2 +- .../use_conversation/helpers.test.ts | 156 +++++++++++++++- .../assistant/use_conversation/helpers.ts | 23 +++ .../impl/assistant/use_conversation/index.tsx | 26 ++- .../impl/assistant_context/index.tsx | 13 -- .../connector_missing_callout/index.tsx | 72 ++++---- .../public/assistant/helpers.tsx | 29 ++- 32 files changed, 830 insertions(+), 331 deletions(-) delete mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/helpers.tsx diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.tsx index 77e0c703ea4e..4891df533fdf 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.tsx @@ -33,7 +33,7 @@ interface Props { defaultConnectorId?: string; defaultProvider?: OpenAiProviderType; selectedConversationId: string | undefined; - setSelectedConversationId: React.Dispatch>; + onConversationSelected: (conversationId: string) => void; shouldDisableKeyboardShortcut?: () => boolean; isDisabled?: boolean; } @@ -59,7 +59,7 @@ export const ConversationSelector: React.FC = React.memo( selectedConversationId = DEFAULT_CONVERSATION_TITLE, defaultConnectorId, defaultProvider, - setSelectedConversationId, + onConversationSelected, shouldDisableKeyboardShortcut = () => false, isDisabled = false, }) => { @@ -109,14 +109,14 @@ export const ConversationSelector: React.FC = React.memo( }; setConversation({ conversation: newConversation }); } - setSelectedConversationId(searchValue); + onConversationSelected(searchValue); }, [ allSystemPrompts, defaultConnectorId, defaultProvider, setConversation, - setSelectedConversationId, + onConversationSelected, ] ); @@ -124,13 +124,13 @@ export const ConversationSelector: React.FC = React.memo( const onDelete = useCallback( (cId: string) => { if (selectedConversationId === cId) { - setSelectedConversationId(getPreviousConversationId(conversationIds, cId)); + onConversationSelected(getPreviousConversationId(conversationIds, cId)); } setTimeout(() => { deleteConversation(cId); }, 0); }, - [conversationIds, deleteConversation, selectedConversationId, setSelectedConversationId] + [conversationIds, deleteConversation, selectedConversationId, onConversationSelected] ); const onChange = useCallback( @@ -138,20 +138,20 @@ export const ConversationSelector: React.FC = React.memo( if (newOptions.length === 0) { setSelectedOptions([]); } else if (conversationOptions.findIndex((o) => o.label === newOptions?.[0].label) !== -1) { - setSelectedConversationId(newOptions?.[0].label); + onConversationSelected(newOptions?.[0].label); } }, - [conversationOptions, setSelectedConversationId] + [conversationOptions, onConversationSelected] ); const onLeftArrowClick = useCallback(() => { const prevId = getPreviousConversationId(conversationIds, selectedConversationId); - setSelectedConversationId(prevId); - }, [conversationIds, selectedConversationId, setSelectedConversationId]); + onConversationSelected(prevId); + }, [conversationIds, selectedConversationId, onConversationSelected]); const onRightArrowClick = useCallback(() => { const nextId = getNextConversationId(conversationIds, selectedConversationId); - setSelectedConversationId(nextId); - }, [conversationIds, selectedConversationId, setSelectedConversationId]); + onConversationSelected(nextId); + }, [conversationIds, selectedConversationId, onConversationSelected]); // Register keyboard listener for quick conversation switching const onKeyDown = useCallback( @@ -200,11 +200,17 @@ export const ConversationSelector: React.FC = React.memo( return ( - + = React.memo( return ( - + void; selectedConversation: Conversation | undefined; @@ -43,6 +46,8 @@ export const ConversationSettings: React.FC = React.m ({ actionTypeRegistry, allSystemPrompts, + defaultConnectorId, + defaultProvider, selectedConversation, onSelectedConversationChange, conversationSettings, @@ -50,14 +55,14 @@ export const ConversationSettings: React.FC = React.m setUpdatedConversationSettings, isDisabled = false, }) => { - // Defaults const defaultSystemPrompt = useMemo(() => { - return ( - allSystemPrompts.find((systemPrompt) => systemPrompt.isNewConversationDefault) ?? - allSystemPrompts[0] - ); + return getDefaultSystemPrompt({ allSystemPrompts, conversation: undefined }); }, [allSystemPrompts]); + const selectedSystemPrompt = useMemo(() => { + return getDefaultSystemPrompt({ allSystemPrompts, conversation: selectedConversation }); + }, [allSystemPrompts, selectedConversation]); + // Conversation callbacks // When top level conversation selection changes const onConversationSelectionChange = useCallback( @@ -68,8 +73,8 @@ export const ConversationSettings: React.FC = React.m id: c ?? '', messages: [], apiConfig: { - connectorId: undefined, - provider: undefined, + connectorId: defaultConnectorId, + provider: defaultProvider, defaultSystemPromptId: defaultSystemPrompt?.id, }, } @@ -86,7 +91,13 @@ export const ConversationSettings: React.FC = React.m onSelectedConversationChange(newSelectedConversation); }, - [defaultSystemPrompt?.id, onSelectedConversationChange, setUpdatedConversationSettings] + [ + defaultConnectorId, + defaultProvider, + defaultSystemPrompt?.id, + onSelectedConversationChange, + setUpdatedConversationSettings, + ] ); const onConversationDeleted = useCallback( @@ -102,18 +113,8 @@ export const ConversationSettings: React.FC = React.m [setUpdatedConversationSettings] ); - const selectedSystemPrompt = useMemo( - () => - getDefaultSystemPromptFromConversation({ - allSystemPrompts, - conversation: selectedConversation, - defaultSystemPrompt, - }), - [allSystemPrompts, defaultSystemPrompt, selectedConversation] - ); - const handleOnSystemPromptSelectionChange = useCallback( - (systemPromptId?: string) => { + (systemPromptId?: string | undefined) => { if (selectedConversation != null) { setUpdatedConversationSettings((prev) => ({ ...prev, @@ -215,6 +216,8 @@ export const ConversationSettings: React.FC = React.m onSystemPromptSelectionChange={handleOnSystemPromptSelectionChange} selectedPrompt={selectedSystemPrompt} showTitles={true} + isSettingsModalVisible={true} + setIsSettingsModalVisible={noop} // noop, already in settings /> diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/helpers.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/helpers.tsx deleted file mode 100644 index 5d0cc04aa963..000000000000 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/helpers.tsx +++ /dev/null @@ -1,33 +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 { Conversation, Prompt } from '../../../..'; - -/** - * Returns a conversation's default system prompt, or the default system prompt if the conversation does not have one. - * @param allSystemPrompts - * @param conversation - * @param defaultSystemPrompt - */ -export const getDefaultSystemPromptFromConversation = ({ - allSystemPrompts, - conversation, - defaultSystemPrompt, -}: { - conversation: Conversation | undefined; - allSystemPrompts: Prompt[]; - defaultSystemPrompt: Prompt; -}) => { - const convoDefaultSystemPromptId = conversation?.apiConfig.defaultSystemPromptId; - if (convoDefaultSystemPromptId && allSystemPrompts) { - return ( - allSystemPrompts.find((prompt) => prompt.id === convoDefaultSystemPromptId) ?? - defaultSystemPrompt - ); - } - return allSystemPrompts.find((prompt) => prompt.isNewConversationDefault) ?? defaultSystemPrompt; -}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.test.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.test.ts index f5565a1b4b0f..a6d13bbbfd54 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.test.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.test.ts @@ -5,8 +5,9 @@ * 2.0. */ -import { getWelcomeConversation } from './helpers'; +import { getDefaultConnector, getWelcomeConversation } from './helpers'; import { enterpriseMessaging } from './use_conversation/sample_conversations'; +import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; describe('getWelcomeConversation', () => { describe('isAssistantEnabled = false', () => { @@ -112,4 +113,81 @@ describe('getWelcomeConversation', () => { expect(result.messages.length).toEqual(4); }); }); + + describe('getDefaultConnector', () => { + it('should return undefined if connectors array is undefined', () => { + const connectors = undefined; + const result = getDefaultConnector(connectors); + + expect(result).toBeUndefined(); + }); + + it('should return undefined if connectors array is empty', () => { + const connectors: Array, Record>> = + []; + const result = getDefaultConnector(connectors); + + expect(result).toBeUndefined(); + }); + + it('should return the connector id if there is only one connector', () => { + const connectors: Array, Record>> = [ + { + actionTypeId: '.gen-ai', + isPreconfigured: false, + isDeprecated: false, + referencedByCount: 0, + isMissingSecrets: false, + isSystemAction: false, + secrets: {}, + id: 'c5f91dc0-2197-11ee-aded-897192c5d6f5', + name: 'OpenAI', + config: { + apiProvider: 'OpenAI', + apiUrl: 'https://api.openai.com/v1/chat/completions', + }, + }, + ]; + const result = getDefaultConnector(connectors); + + expect(result).toBe(connectors[0]); + }); + + it('should return undefined if there are multiple connectors', () => { + const connectors: Array, Record>> = [ + { + actionTypeId: '.gen-ai', + isPreconfigured: false, + isDeprecated: false, + referencedByCount: 0, + isMissingSecrets: false, + isSystemAction: false, + secrets: {}, + id: 'c5f91dc0-2197-11ee-aded-897192c5d6f5', + name: 'OpenAI', + config: { + apiProvider: 'OpenAI 1', + apiUrl: 'https://api.openai.com/v1/chat/completions', + }, + }, + { + actionTypeId: '.gen-ai', + isPreconfigured: false, + isDeprecated: false, + referencedByCount: 0, + isMissingSecrets: false, + isSystemAction: false, + secrets: {}, + id: 'c7f91dc0-2197-11ee-aded-897192c5d633', + name: 'OpenAI', + config: { + apiProvider: 'OpenAI 2', + apiUrl: 'https://api.openai.com/v1/chat/completions', + }, + }, + ]; + const result = getDefaultConnector(connectors); + expect(result).toBeUndefined(); + }); + }); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.ts index 65dba7f85307..7662c749a793 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; import { Conversation } from '../..'; import type { Message } from '../assistant_context/types'; import { enterpriseMessaging, WELCOME_CONVERSATION } from './use_conversation/sample_conversations'; @@ -49,3 +50,12 @@ export const getWelcomeConversation = ( messages: [...conversation.messages, ...WELCOME_CONVERSATION.messages], }; }; + +/** + * Returns a default connector if there is only one connector + * @param connectors + */ +export const getDefaultConnector = ( + connectors: Array, Record>> | undefined +): ActionConnector, Record> | undefined => + connectors?.length === 1 ? connectors[0] : undefined; 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 68db74b63776..fd6057e8a2e1 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx @@ -29,7 +29,7 @@ import { ActionConnectorProps } from '@kbn/triggers-actions-ui-plugin/public/typ import { WELCOME_CONVERSATION_TITLE } from './use_conversation/translations'; import { AssistantTitle } from './assistant_title'; import { UpgradeButtons } from '../upgrade/upgrade_buttons'; -import { getMessageFromRawResponse, getWelcomeConversation } from './helpers'; +import { getDefaultConnector, getMessageFromRawResponse, getWelcomeConversation } from './helpers'; import { useAssistantContext } from '../assistant_context'; import { ContextPills } from './context_pills'; @@ -37,7 +37,7 @@ import { getNewSelectedPromptContext } from '../data_anonymization/get_new_selec import { PromptTextArea } from './prompt_textarea'; import type { PromptContext, SelectedPromptContext } from './prompt_context/types'; import { useConversation } from './use_conversation'; -import { CodeBlockDetails } from './use_conversation/helpers'; +import { CodeBlockDetails, getDefaultSystemPrompt } from './use_conversation/helpers'; import { useSendMessages } from './use_send_messages'; import type { Message } from '../assistant_context/types'; import { ConversationSelector } from './conversations/conversation_selector'; @@ -103,11 +103,15 @@ const AssistantComponent: React.FC = ({ isSuccess: areConnectorsFetched, refetch: refetchConnectors, } = useLoadConnectors({ http }); - const defaultConnectorId = useMemo(() => connectors?.[0]?.id, [connectors]); + const defaultConnectorId = useMemo(() => getDefaultConnector(connectors)?.id, [connectors]); const defaultProvider = useMemo( () => - (connectors?.[0] as ActionConnectorProps<{ apiProvider: OpenAiProviderType }, unknown>) - ?.config?.apiProvider, + ( + getDefaultConnector(connectors) as ActionConnectorProps< + { apiProvider: OpenAiProviderType }, + unknown + > + )?.config?.apiProvider, [connectors] ); @@ -145,6 +149,9 @@ const AssistantComponent: React.FC = ({ [currentConversation, isAssistantEnabled] ); + // Settings modal state (so it isn't shared between assistant instances like Timeline) + const [isSettingsModalVisible, setIsSettingsModalVisible] = useState(false); + // Remember last selection for reuse after keyboard shortcut is pressed. // Clear it if there is no connectors useEffect(() => { @@ -178,7 +185,7 @@ const AssistantComponent: React.FC = ({ const [promptTextPreview, setPromptTextPreview] = useState(''); const [autoPopulatedOnce, setAutoPopulatedOnce] = useState(false); - const [suggestedUserPrompt, setSuggestedUserPrompt] = useState(null); + const [userPrompt, setUserPrompt] = useState(null); const [showMissingConnectorCallout, setShowMissingConnectorCallout] = useState(false); @@ -234,13 +241,29 @@ const AssistantComponent: React.FC = ({ //// // - const selectedSystemPrompt = useMemo(() => { - if (currentConversation.apiConfig.defaultSystemPromptId) { - return allSystemPrompts.find( - (prompt) => prompt.id === currentConversation.apiConfig.defaultSystemPromptId + const selectedSystemPrompt = useMemo( + () => getDefaultSystemPrompt({ allSystemPrompts, conversation: currentConversation }), + [allSystemPrompts, currentConversation] + ); + + const [editingSystemPromptId, setEditingSystemPromptId] = useState( + selectedSystemPrompt?.id + ); + + const handleOnConversationSelected = useCallback( + (cId: string) => { + setSelectedConversationId(cId); + setEditingSystemPromptId( + getDefaultSystemPrompt({ allSystemPrompts, conversation: conversations[cId] })?.id ); - } - }, [allSystemPrompts, currentConversation.apiConfig.defaultSystemPromptId]); + }, + [allSystemPrompts, conversations] + ); + + const handlePromptChange = useCallback((prompt: string) => { + setPromptTextPreview(prompt); + setUserPrompt(prompt); + }, []); // Handles sending latest user prompt to API const handleSendMessage = useCallback( @@ -251,13 +274,15 @@ const AssistantComponent: React.FC = ({ replacements: newReplacements, }); + const systemPrompt = allSystemPrompts.find((prompt) => prompt.id === editingSystemPromptId); + const message = await getCombinedMessage({ isNewChat: currentConversation.messages.length === 0, currentReplacements: currentConversation.replacements, onNewReplacements, promptText, selectedPromptContexts, - selectedSystemPrompt, + selectedSystemPrompt: systemPrompt, }); const updatedMessages = appendMessage({ @@ -278,23 +303,42 @@ const AssistantComponent: React.FC = ({ appendMessage({ conversationId: selectedConversationId, message: responseMessage }); }, [ - selectedSystemPrompt, - appendMessage, - appendReplacements, - currentConversation.apiConfig, + allSystemPrompts, currentConversation.messages.length, currentConversation.replacements, - http, - selectedConversationId, + currentConversation.apiConfig, selectedPromptContexts, + appendMessage, + selectedConversationId, sendMessages, + http, + appendReplacements, + editingSystemPromptId, ] ); const handleButtonSendMessage = useCallback(() => { handleSendMessage(promptTextAreaRef.current?.value?.trim() ?? ''); + setUserPrompt(''); }, [handleSendMessage, promptTextAreaRef]); + const handleOnSystemPromptSelectionChange = useCallback((systemPromptId?: string) => { + setEditingSystemPromptId(systemPromptId); + }, []); + + const handleOnChatCleared = useCallback(() => { + const defaultSystemPromptId = getDefaultSystemPrompt({ + allSystemPrompts, + conversation: conversations[selectedConversationId], + })?.id; + + setPromptTextPreview(''); + setUserPrompt(''); + setSelectedPromptContexts({}); + clearConversation(selectedConversationId); + setEditingSystemPromptId(defaultSystemPromptId); + }, [allSystemPrompts, clearConversation, conversations, selectedConversationId]); + const shouldDisableConversationSelectorHotkeys = useCallback(() => { const promptTextAreaHasFocus = document.activeElement === promptTextAreaRef.current; return promptTextAreaHasFocus; @@ -347,7 +391,7 @@ const AssistantComponent: React.FC = ({ } if (promptContext.suggestedUserPrompt != null) { - setSuggestedUserPrompt(promptContext.suggestedUserPrompt); + setUserPrompt(promptContext.suggestedUserPrompt); } } }, [ @@ -398,16 +442,21 @@ const AssistantComponent: React.FC = ({ `} /> - + {currentConversation.messages.length !== 0 && + Object.keys(selectedPromptContexts).length > 0 && } {(currentConversation.messages.length === 0 || Object.keys(selectedPromptContexts).length > 0) && ( )} @@ -417,7 +466,10 @@ const AssistantComponent: React.FC = ({ ), [ currentConversation, + editingSystemPromptId, getComments, + handleOnSystemPromptSelectionChange, + isSettingsModalVisible, promptContexts, promptTextPreview, selectedPromptContexts, @@ -474,7 +526,7 @@ const AssistantComponent: React.FC = ({ defaultConnectorId={defaultConnectorId} defaultProvider={defaultProvider} selectedConversationId={selectedConversationId} - setSelectedConversationId={setSelectedConversationId} + onConversationSelected={handleOnConversationSelected} shouldDisableKeyboardShortcut={shouldDisableConversationSelectorHotkeys} isDisabled={isDisabled} /> @@ -504,8 +556,12 @@ const AssistantComponent: React.FC = ({ @@ -536,12 +592,15 @@ const AssistantComponent: React.FC = ({ {comments} - {!isDisabled && showMissingConnectorCallout && ( + {!isDisabled && showMissingConnectorCallout && areConnectorsFetched && ( <> - + @@ -585,8 +644,8 @@ const AssistantComponent: React.FC = ({ @@ -614,12 +673,7 @@ const AssistantComponent: React.FC = ({ isDisabled={isSendingDisabled} aria-label={i18n.CLEAR_CHAT} color="danger" - onClick={() => { - setPromptTextPreview(''); - clearConversation(selectedConversationId); - setSelectedPromptContexts({}); - setSuggestedUserPrompt(''); - }} + onClick={handleOnChatCleared} /> @@ -639,7 +693,12 @@ const AssistantComponent: React.FC = ({ - {!isDisabled && } + {!isDisabled && ( + + )} ); 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 b671d225bac4..96abbd4dbb51 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 @@ -29,20 +29,24 @@ const mockSelectedEventPromptContext: SelectedPromptContext = { const defaultProps: Props = { conversation: undefined, + editingSystemPromptId: undefined, isNewConversation: true, + isSettingsModalVisible: false, + onSystemPromptSelectionChange: jest.fn(), promptContexts: { [mockAlertPromptContext.id]: mockAlertPromptContext, [mockEventPromptContext.id]: mockEventPromptContext, }, promptTextPreview: 'Preview text', selectedPromptContexts: {}, + setIsSettingsModalVisible: jest.fn(), setSelectedPromptContexts: jest.fn(), }; describe('PromptEditorComponent', () => { beforeEach(() => jest.clearAllMocks()); - it('renders the system prompt viewer when isNewConversation is true', async () => { + it('renders the system prompt selector when isNewConversation is true', async () => { render( @@ -50,7 +54,7 @@ describe('PromptEditorComponent', () => { ); await waitFor(() => { - expect(screen.getByTestId('systemPromptText')).toBeInTheDocument(); + expect(screen.getByTestId('selectSystemPrompt')).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 a35259c0655a..fce32aad1694 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 @@ -19,10 +19,14 @@ import { SelectedPromptContexts } from './selected_prompt_contexts'; export interface Props { conversation: Conversation | undefined; + editingSystemPromptId: string | undefined; isNewConversation: boolean; + isSettingsModalVisible: boolean; promptContexts: Record; promptTextPreview: string; + onSystemPromptSelectionChange: (systemPromptId: string | undefined) => void; selectedPromptContexts: Record; + setIsSettingsModalVisible: React.Dispatch>; setSelectedPromptContexts: React.Dispatch< React.SetStateAction> >; @@ -34,16 +38,28 @@ const PreviewText = styled(EuiText)` const PromptEditorComponent: React.FC = ({ conversation, + editingSystemPromptId, isNewConversation, + isSettingsModalVisible, promptContexts, promptTextPreview, + onSystemPromptSelectionChange, selectedPromptContexts, + setIsSettingsModalVisible, setSelectedPromptContexts, }) => { const commentBody = useMemo( () => ( <> - {isNewConversation && } + {isNewConversation && ( + + )} = ({ ), [ conversation, + editingSystemPromptId, isNewConversation, + isSettingsModalVisible, + onSystemPromptSelectionChange, promptContexts, promptTextPreview, selectedPromptContexts, + setIsSettingsModalVisible, setSelectedPromptContexts, ] ); 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 5d73070c9440..0e530668a30b 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 @@ -12,7 +12,9 @@ import React from 'react'; import styled from 'styled-components'; import { css } from '@emotion/react'; +import { isEmpty } from 'lodash/fp'; import type { Prompt } from '../../types'; +import { EMPTY_PROMPT } from './translations'; const Strong = styled.strong` margin-right: ${({ theme }) => theme.eui.euiSizeS}; @@ -44,9 +46,10 @@ export const getOptionFromPrompt = ({ <> {name} - + {/* Empty content tooltip gets around :hover styles from SuperSelectOptionButton */} + -

{content}

+ {isEmpty(content) ?

{EMPTY_PROMPT}

:

{content}

}
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 18f298acf253..519f7815c964 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 @@ -60,6 +60,11 @@ jest.mock('../../use_conversation', () => { }); describe('SystemPrompt', () => { + const editingSystemPromptId = undefined; + const isSettingsModalVisible = false; + const onSystemPromptSelectionChange = jest.fn(); + const setIsSettingsModalVisible = jest.fn(); + beforeEach(() => { jest.clearAllMocks(); @@ -72,33 +77,49 @@ describe('SystemPrompt', () => { }); }); - describe('when conversation is undefined and default prompt is used', () => { + describe('when conversation is undefined', () => { const conversation = undefined; beforeEach(() => { - render(); + render( + + ); }); - it('does render the system prompt fallback text', () => { - expect(screen.getByTestId('systemPromptText')).toBeInTheDocument(); + it('renders the system prompt select', () => { + expect(screen.getByTestId('selectSystemPrompt')).toBeInTheDocument(); }); - it('does NOT render the system prompt select', () => { - expect(screen.queryByTestId('selectSystemPrompt')).not.toBeInTheDocument(); + it('does NOT render the system prompt text', () => { + expect(screen.queryByTestId('systemPromptText')).not.toBeInTheDocument(); }); - it('does render the edit button', () => { - expect(screen.getByTestId('edit')).toBeInTheDocument(); + it('does NOT render the edit button', () => { + expect(screen.queryByTestId('edit')).not.toBeInTheDocument(); }); - it('does render the clear button', () => { - expect(screen.getByTestId('clear')).toBeInTheDocument(); + it('does NOT render the clear button', () => { + expect(screen.queryByTestId('clear')).not.toBeInTheDocument(); }); }); describe('when conversation is NOT null', () => { beforeEach(() => { - render(); + render( + + ); }); it('does NOT render the system prompt select', () => { @@ -125,7 +146,13 @@ describe('SystemPrompt', () => { const customPromptText = 'custom prompt text'; render( - + ); userEvent.click(screen.getByTestId('edit')); @@ -165,7 +192,13 @@ describe('SystemPrompt', () => { const customPromptText = 'custom prompt text'; render( - + ); userEvent.click(screen.getByTestId('edit')); @@ -219,7 +252,13 @@ describe('SystemPrompt', () => { const customPromptText = 'custom prompt text'; render( - + ); userEvent.click(screen.getByTestId('edit')); @@ -280,7 +319,13 @@ describe('SystemPrompt', () => { it('should save new prompt correctly when prompt is removed from selected conversation', async () => { render( - + ); userEvent.click(screen.getByTestId('edit')); @@ -351,7 +396,13 @@ describe('SystemPrompt', () => { render( - + ); userEvent.click(screen.getByTestId('edit')); @@ -413,7 +464,13 @@ describe('SystemPrompt', () => { it('shows the system prompt select when the edit button is clicked', () => { render( - + ); @@ -425,7 +482,13 @@ describe('SystemPrompt', () => { it('shows the system prompt select when system prompt text is clicked', () => { render( - + ); 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 8520f8da55c2..2893ed39ea2e 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 @@ -6,38 +6,50 @@ */ import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { css } from '@emotion/react'; +import { isEmpty } from 'lodash/fp'; import { useAssistantContext } from '../../../assistant_context'; -import { Conversation, Prompt } from '../../../..'; +import { Conversation } from '../../../..'; import * as i18n from './translations'; import { SelectSystemPrompt } from './select_system_prompt'; interface Props { conversation: Conversation | undefined; + editingSystemPromptId: string | undefined; + isSettingsModalVisible: boolean; + onSystemPromptSelectionChange: (systemPromptId: string | undefined) => void; + setIsSettingsModalVisible: React.Dispatch>; } -const SystemPromptComponent: React.FC = ({ conversation }) => { +const SystemPromptComponent: React.FC = ({ + conversation, + editingSystemPromptId, + isSettingsModalVisible, + onSystemPromptSelectionChange, + setIsSettingsModalVisible, +}) => { const { allSystemPrompts } = useAssistantContext(); - const [selectedPrompt, setSelectedPrompt] = useState( - allSystemPrompts?.find((p) => p.id === conversation?.apiConfig.defaultSystemPromptId) ?? - allSystemPrompts?.[0] - ); - - useEffect(() => { - setSelectedPrompt( - allSystemPrompts?.find((p) => p.id === conversation?.apiConfig.defaultSystemPromptId) ?? - allSystemPrompts?.[0] - ); - }, [allSystemPrompts, conversation]); + const selectedPrompt = useMemo(() => { + if (editingSystemPromptId !== undefined) { + return ( + allSystemPrompts?.find((p) => p.id === editingSystemPromptId) ?? + allSystemPrompts?.find((p) => p.id === conversation?.apiConfig.defaultSystemPromptId) + ); + } else { + return undefined; + } + }, [allSystemPrompts, conversation?.apiConfig.defaultSystemPromptId, editingSystemPromptId]); const [isEditing, setIsEditing] = React.useState(false); const handleClearSystemPrompt = useCallback(() => { - setSelectedPrompt(undefined); - }, []); + if (conversation) { + onSystemPromptSelectionChange(undefined); + } + }, [conversation, onSystemPromptSelectionChange]); const handleEditSystemPrompt = useCallback(() => setIsEditing(true), []); @@ -52,8 +64,11 @@ const SystemPromptComponent: React.FC = ({ conversation }) => { isClearable={true} isEditing={isEditing} isOpen={isEditing} + isSettingsModalVisible={isSettingsModalVisible} + onSystemPromptSelectionChange={onSystemPromptSelectionChange} selectedPrompt={selectedPrompt} setIsEditing={setIsEditing} + setIsSettingsModalVisible={setIsSettingsModalVisible} /> ) : ( @@ -70,7 +85,7 @@ const SystemPromptComponent: React.FC = ({ conversation }) => { } `} > - {selectedPrompt?.content ?? ''} + {isEmpty(selectedPrompt?.content) ? i18n.EMPTY_PROMPT : 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 index 97eb724eaffb..cb1050c895b8 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 @@ -24,7 +24,9 @@ const props: Props = { }, ], conversation: undefined, + isSettingsModalVisible: false, selectedPrompt: undefined, + setIsSettingsModalVisible: jest.fn(), }; const mockUseAssistantContext = { 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 cd82cd0aac65..1976586f64b7 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 @@ -37,9 +37,11 @@ export interface Props { isEditing?: boolean; isDisabled?: boolean; isOpen?: boolean; + isSettingsModalVisible: boolean; setIsEditing?: React.Dispatch>; + setIsSettingsModalVisible: React.Dispatch>; showTitles?: boolean; - onSystemPromptSelectionChange?: (promptId: string) => void; + onSystemPromptSelectionChange?: (promptId: string | undefined) => void; } const ADD_NEW_SYSTEM_PROMPT = 'ADD_NEW_SYSTEM_PROMPT'; @@ -54,12 +56,13 @@ const SelectSystemPromptComponent: React.FC = ({ isEditing = false, isDisabled = false, isOpen = false, + isSettingsModalVisible, onSystemPromptSelectionChange, setIsEditing, + setIsSettingsModalVisible, showTitles = false, }) => { - const { isSettingsModalVisible, setIsSettingsModalVisible, setSelectedSettingsTab } = - useAssistantContext(); + const { setSelectedSettingsTab } = useAssistantContext(); const { setApiConfig } = useConversation(); const [isOpenLocal, setIsOpenLocal] = useState(isOpen); 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 index acc99e06e85a..3beb0c6f6a1c 100644 --- 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 @@ -148,25 +148,33 @@ export const SystemPromptSelector: React.FC = React.memo( alignItems="center" className={'parentFlexGroup'} component={'span'} - gutterSize={'none'} justifyContent="spaceBetween" data-test-subj="systemPromptOptionSelector" > - + - - - - {label} - - + + + {label} + {value?.isNewConversationDefault && ( @@ -179,7 +187,7 @@ export const SystemPromptSelector: React.FC = React.memo( {!value?.isDefault && ( - + = React.memo( data-test-subj={TEST_IDS.SYSTEM_PROMPT_MODAL.PROMPT_TEXT} disabled={selectedSystemPrompt == null} onChange={handlePromptContentChange} + placeholder={i18n.SYSTEM_PROMPT_PROMPT_PLACEHOLDER} value={promptContent} compressed fullWidth 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 index 6b7283977b1e..99852b06e056 100644 --- 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 @@ -41,6 +41,13 @@ export const SYSTEM_PROMPT_PROMPT = i18n.translate( } ); +export const SYSTEM_PROMPT_PROMPT_PLACEHOLDER = i18n.translate( + 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.promptPlaceholder', + { + defaultMessage: 'Enter a System Prompt', + } +); + export const SYSTEM_PROMPT_DEFAULT_CONVERSATIONS = i18n.translate( 'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.defaultConversationsLabel', { 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 ec4202f19b32..91f31bb7ebd2 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 @@ -34,3 +34,10 @@ export const ADD_NEW_SYSTEM_PROMPT = i18n.translate( defaultMessage: 'Add new system prompt...', } ); + +export const EMPTY_PROMPT = i18n.translate( + 'xpack.elasticAssistant.assistant.firstPromptEditor.emptyPrompt', + { + defaultMessage: '(empty prompt)', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_textarea/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_textarea/index.tsx index 9d322c1b4c1f..cf3453b99624 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_textarea/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_textarea/index.tsx @@ -13,7 +13,7 @@ import styled from 'styled-components'; import * as i18n from './translations'; export interface Props extends React.TextareaHTMLAttributes { - handlePromptChange?: (value: string) => void; + handlePromptChange: (value: string) => void; isDisabled?: boolean; onPromptSubmit: (value: string) => void; value: string; @@ -26,35 +26,30 @@ const StyledTextArea = styled(EuiTextArea)` export const PromptTextArea = forwardRef( ({ isDisabled = false, value, onPromptSubmit, handlePromptChange, ...props }, ref) => { - const [currentValue, setCurrentValue] = React.useState(value); - const onChangeCallback = useCallback( (event: React.ChangeEvent) => { - setCurrentValue(event.target.value); - if (handlePromptChange) { - handlePromptChange(event.target.value); - } + handlePromptChange(event.target.value); }, [handlePromptChange] ); const onKeyDown = useCallback( (event) => { - if (event.key === 'Enter' && !event.shiftKey && currentValue.trim().length > 0) { + if (event.key === 'Enter' && !event.shiftKey && value.trim().length > 0) { event.preventDefault(); onPromptSubmit(event.target.value?.trim()); - setCurrentValue(''); - } else if (event.key === 'Enter' && !event.shiftKey && currentValue.trim().length === 0) { + handlePromptChange(''); + } else if (event.key === 'Enter' && !event.shiftKey && value.trim().length === 0) { event.preventDefault(); event.stopPropagation(); } }, - [currentValue, onPromptSubmit] + [value, onPromptSubmit, handlePromptChange] ); useEffect(() => { - setCurrentValue(value); - }, [value]); + handlePromptChange(value); + }, [handlePromptChange, value]); return ( ( autoFocus disabled={isDisabled} placeholder={i18n.PROMPT_PLACEHOLDER} - value={currentValue} + value={value} onChange={onChangeCallback} onKeyDown={onKeyDown} /> 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 8b753a7002ef..187701aa148b 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 @@ -138,14 +138,24 @@ export const QuickPromptSelector: React.FC = React.memo( return ( - - - - {label} - + + + {label} {!value?.isDefault && ( diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_settings.tsx index 53a7431f3dc3..1de53aa018a8 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_settings.tsx @@ -198,6 +198,7 @@ export const QuickPromptSettings: React.FC = React.memo( disabled={selectedQuickPrompt == null} fullWidth onChange={handlePromptChange} + placeholder={i18n.QUICK_PROMPT_PROMPT_PLACEHOLDER} value={prompt} css={css` min-height: 150px; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/translations.ts index e388f2fe1d88..dd134dc03457 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/translations.ts @@ -42,6 +42,13 @@ export const QUICK_PROMPT_PROMPT = i18n.translate( } ); +export const QUICK_PROMPT_PROMPT_PLACEHOLDER = i18n.translate( + 'xpack.elasticAssistant.assistant.quickPrompts.settings.promptPlaceholder', + { + defaultMessage: 'Enter a Quick Prompt', + } +); + export const QUICK_PROMPT_BADGE_COLOR = i18n.translate( 'xpack.elasticAssistant.assistant.quickPrompts.settings.badgeColorLabel', { 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 f4b858ee796f..d6f382f5e1fd 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 @@ -21,6 +21,7 @@ const QuickPromptsFlexGroup = styled(EuiFlexGroup)` const COUNT_BEFORE_OVERFLOW = 5; interface QuickPromptsProps { setInput: (input: string) => void; + setIsSettingsModalVisible: React.Dispatch>; } /** @@ -28,95 +29,96 @@ interface QuickPromptsProps { * text, and support for adding new quick prompts and editing existing. Also supports overflow of quick prompts, * and localstorage for storing new and edited prompts. */ -export const QuickPrompts: React.FC = React.memo(({ setInput }) => { - const { allQuickPrompts, promptContexts, setIsSettingsModalVisible, setSelectedSettingsTab } = - useAssistantContext(); +export const QuickPrompts: React.FC = React.memo( + ({ setInput, setIsSettingsModalVisible }) => { + const { allQuickPrompts, promptContexts, setSelectedSettingsTab } = useAssistantContext(); - const contextFilteredQuickPrompts = useMemo(() => { - const registeredPromptContextTitles = Object.values(promptContexts).map((pc) => pc.category); - 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; - } else { - return quickPrompt.categories.some((category) => { - return registeredPromptContextTitles.includes(category); - }); - } - }); - }, [allQuickPrompts, promptContexts]); + const contextFilteredQuickPrompts = useMemo(() => { + const registeredPromptContextTitles = Object.values(promptContexts).map((pc) => pc.category); + 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; + } else { + return quickPrompt.categories.some((category) => { + return registeredPromptContextTitles.includes(category); + }); + } + }); + }, [allQuickPrompts, promptContexts]); - // Overflow state - const [isOverflowPopoverOpen, setIsOverflowPopoverOpen] = useState(false); - const toggleOverflowPopover = useCallback( - () => setIsOverflowPopoverOpen(!isOverflowPopoverOpen), - [isOverflowPopoverOpen] - ); - const closeOverflowPopover = useCallback(() => setIsOverflowPopoverOpen(false), []); + // Overflow state + const [isOverflowPopoverOpen, setIsOverflowPopoverOpen] = useState(false); + const toggleOverflowPopover = useCallback( + () => setIsOverflowPopoverOpen(!isOverflowPopoverOpen), + [isOverflowPopoverOpen] + ); + const closeOverflowPopover = useCallback(() => setIsOverflowPopoverOpen(false), []); - const onClickOverflowQuickPrompt = useCallback( - (prompt: string) => { - setInput(prompt); - closeOverflowPopover(); - }, - [closeOverflowPopover, setInput] - ); + const onClickOverflowQuickPrompt = useCallback( + (prompt: string) => { + setInput(prompt); + closeOverflowPopover(); + }, + [closeOverflowPopover, setInput] + ); - const showQuickPromptSettings = useCallback(() => { - setIsSettingsModalVisible(true); - setSelectedSettingsTab(QUICK_PROMPTS_TAB); - }, [setIsSettingsModalVisible, setSelectedSettingsTab]); + const showQuickPromptSettings = useCallback(() => { + setIsSettingsModalVisible(true); + setSelectedSettingsTab(QUICK_PROMPTS_TAB); + }, [setIsSettingsModalVisible, setSelectedSettingsTab]); - return ( - - {contextFilteredQuickPrompts.slice(0, COUNT_BEFORE_OVERFLOW).map((badge, index) => ( - - setInput(badge.prompt)} - onClickAriaLabel={badge.title} - > - {badge.title} - - - ))} - {contextFilteredQuickPrompts.length > COUNT_BEFORE_OVERFLOW && ( + return ( + + {contextFilteredQuickPrompts.slice(0, COUNT_BEFORE_OVERFLOW).map((badge, index) => ( + + setInput(badge.prompt)} + onClickAriaLabel={badge.title} + > + {badge.title} + + + ))} + {contextFilteredQuickPrompts.length > COUNT_BEFORE_OVERFLOW && ( + + + } + isOpen={isOverflowPopoverOpen} + closePopover={closeOverflowPopover} + anchorPosition="rightUp" + > + + {contextFilteredQuickPrompts.slice(COUNT_BEFORE_OVERFLOW).map((badge, index) => ( + + onClickOverflowQuickPrompt(badge.prompt)} + onClickAriaLabel={badge.title} + > + {badge.title} + + + ))} + + + + )} - - } - isOpen={isOverflowPopoverOpen} - closePopover={closeOverflowPopover} - anchorPosition="rightUp" - > - - {contextFilteredQuickPrompts.slice(COUNT_BEFORE_OVERFLOW).map((badge, index) => ( - - onClickOverflowQuickPrompt(badge.prompt)} - onClickAriaLabel={badge.title} - > - {badge.title} - - - ))} - - + + {i18n.ADD_QUICK_PROMPT} + - )} - - - {i18n.ADD_QUICK_PROMPT} - - - - ); -}); + + ); + } +); QuickPrompts.displayName = 'QuickPrompts'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx index 7fb8d0cff7c3..96a4de67e171 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx @@ -23,6 +23,7 @@ import { // eslint-disable-next-line @kbn/eslint/module_migration import styled from 'styled-components'; import { css } from '@emotion/react'; +import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/gen_ai/constants'; import { Conversation, Prompt, QuickPrompt } from '../../..'; import * as i18n from './translations'; import { useAssistantContext } from '../../assistant_context'; @@ -54,6 +55,8 @@ export type SettingsTabs = | typeof FUNCTIONS_TAB | typeof ADVANCED_TAB; interface Props { + defaultConnectorId?: string; + defaultProvider?: OpenAiProviderType; onClose: ( event?: React.KeyboardEvent | React.MouseEvent ) => void; @@ -68,6 +71,8 @@ interface Props { */ export const AssistantSettings: React.FC = React.memo( ({ + defaultConnectorId, + defaultProvider, onClose, onSave, selectedConversation: defaultSelectedConversation, @@ -244,6 +249,8 @@ export const AssistantSettings: React.FC = React.memo( > {selectedSettingsTab === CONVERSATIONS_TAB && ( >; setSelectedConversationId: React.Dispatch>; isDisabled?: boolean; } @@ -23,9 +28,16 @@ interface Props { * Gear button that opens the assistant settings modal */ export const AssistantSettingsButton: React.FC = React.memo( - ({ isDisabled = false, selectedConversation, setSelectedConversationId }) => { - const { isSettingsModalVisible, setIsSettingsModalVisible, setSelectedSettingsTab } = - useAssistantContext(); + ({ + defaultConnectorId, + defaultProvider, + isDisabled = false, + isSettingsModalVisible, + setIsSettingsModalVisible, + selectedConversation, + setSelectedConversationId, + }) => { + const { setSelectedSettingsTab } = useAssistantContext(); // Modal control functions const cleanupAndCloseModal = useCallback(() => { @@ -60,6 +72,8 @@ export const AssistantSettingsButton: React.FC = React.memo( {isSettingsModalVisible && ( { - it('should identify dsl Query successfully.', () => { - const result = analyzeMarkdown(markDownWithDSLQuery); - expect(result[0].type).toBe('dsl'); +describe('useConversation helpers', () => { + describe('analyzeMarkdown', () => { + it('should identify dsl Query successfully.', () => { + const result = analyzeMarkdown(markDownWithDSLQuery); + expect(result[0].type).toBe('dsl'); + }); + it('should identify kql Query successfully.', () => { + const result = analyzeMarkdown(markDownWithKQLQuery); + expect(result[0].type).toBe('kql'); + }); }); - it('should identify kql Query successfully.', () => { - const result = analyzeMarkdown(markDownWithKQLQuery); - expect(result[0].type).toBe('kql'); + + describe('getDefaultSystemPrompt', () => { + const allSystemPrompts: Prompt[] = [ + { + id: '1', + content: 'Prompt 1', + name: 'Prompt 1', + promptType: 'user', + }, + { + id: '2', + content: 'Prompt 2', + name: 'Prompt 2', + promptType: 'user', + isNewConversationDefault: true, + }, + { + id: '3', + content: 'Prompt 3', + name: 'Prompt 3', + promptType: 'user', + }, + ]; + const allSystemPromptsNoDefault: Prompt[] = allSystemPrompts.filter( + ({ isNewConversationDefault }) => isNewConversationDefault !== true + ); + const conversation: Conversation = { + apiConfig: { + defaultSystemPromptId: '3', + }, + id: '1', + messages: [], + }; + + test('should return the conversation system prompt if it exists', () => { + const result = getDefaultSystemPrompt({ allSystemPrompts, conversation }); + + expect(result).toEqual(allSystemPrompts[2]); + }); + + test('should return the default (starred) isNewConversationDefault system prompt if conversation system prompt does not exist', () => { + const conversationWithoutSystemPrompt: Conversation = { + apiConfig: {}, + id: '1', + messages: [], + }; + const result = getDefaultSystemPrompt({ + allSystemPrompts, + conversation: conversationWithoutSystemPrompt, + }); + + expect(result).toEqual(allSystemPrompts[1]); + }); + + test('should return the default (starred) isNewConversationDefault system prompt if conversation system prompt does not exist within all system prompts', () => { + const conversationWithoutSystemPrompt: Conversation = { + apiConfig: {}, + id: '4', // this id does not exist within allSystemPrompts + messages: [], + }; + const result = getDefaultSystemPrompt({ + allSystemPrompts, + conversation: conversationWithoutSystemPrompt, + }); + + expect(result).toEqual(allSystemPrompts[1]); + }); + + test('should return the first prompt if both conversation system prompt and default new system prompt do not exist', () => { + const conversationWithoutSystemPrompt: Conversation = { + apiConfig: {}, + id: '1', + messages: [], + }; + const result = getDefaultSystemPrompt({ + allSystemPrompts: allSystemPromptsNoDefault, + conversation: conversationWithoutSystemPrompt, + }); + + expect(result).toEqual(allSystemPromptsNoDefault[0]); + }); + + test('should return undefined if conversation system prompt does not exist and there are no system prompts', () => { + const conversationWithoutSystemPrompt: Conversation = { + apiConfig: {}, + id: '1', + messages: [], + }; + const result = getDefaultSystemPrompt({ + allSystemPrompts: [], + conversation: conversationWithoutSystemPrompt, + }); + + expect(result).toEqual(undefined); + }); + + test('should return undefined if conversation system prompt does not exist within all system prompts', () => { + const conversationWithoutSystemPrompt: Conversation = { + apiConfig: {}, + id: '4', // this id does not exist within allSystemPrompts + messages: [], + }; + const result = getDefaultSystemPrompt({ + allSystemPrompts: allSystemPromptsNoDefault, + conversation: conversationWithoutSystemPrompt, + }); + + expect(result).toEqual(allSystemPromptsNoDefault[0]); + }); + + test('should return (starred) isNewConversationDefault system prompt if conversation is undefined', () => { + const result = getDefaultSystemPrompt({ + allSystemPrompts, + conversation: undefined, + }); + + expect(result).toEqual(allSystemPrompts[1]); + }); + + test('should return the first system prompt if the conversation is undefined and isNewConversationDefault is not present in system prompts', () => { + const result = getDefaultSystemPrompt({ + allSystemPrompts: allSystemPromptsNoDefault, + conversation: undefined, + }); + + expect(result).toEqual(allSystemPromptsNoDefault[0]); + }); + + test('should return undefined if conversation is undefined and no system prompts are provided', () => { + const result = getDefaultSystemPrompt({ + allSystemPrompts: [], + conversation: undefined, + }); + + expect(result).toEqual(undefined); + }); }); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/helpers.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/helpers.ts index 63e8a76f378e..d9a98aa5a450 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/helpers.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/helpers.ts @@ -6,6 +6,8 @@ */ import React from 'react'; +import { Prompt } from '../types'; +import { Conversation } from '../../assistant_context/types'; export interface CodeBlockDetails { type: QueryType; @@ -64,3 +66,24 @@ export const analyzeMarkdown = (markdown: string): CodeBlockDetails[] => { return result; }; + +/** + * Returns the default system prompt for a given conversation + * + * @param allSystemPrompts All available System Prompts + * @param conversation Conversation to get the default system prompt for + */ +export const getDefaultSystemPrompt = ({ + allSystemPrompts, + conversation, +}: { + allSystemPrompts: Prompt[]; + conversation: Conversation | undefined; +}): Prompt | undefined => { + const conversationSystemPrompt = allSystemPrompts.find( + (prompt) => prompt.id === conversation?.apiConfig?.defaultSystemPromptId + ); + const defaultNewSystemPrompt = allSystemPrompts.find((prompt) => prompt.isNewConversationDefault); + + return conversationSystemPrompt ?? defaultNewSystemPrompt ?? allSystemPrompts?.[0]; +}; 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 217613b239de..c7f8b2336cb6 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 @@ -11,6 +11,7 @@ import { useAssistantContext } from '../../assistant_context'; import { Conversation, Message } from '../../assistant_context/types'; import * as i18n from './translations'; import { ELASTIC_AI_ASSISTANT, ELASTIC_AI_ASSISTANT_TITLE } from './translations'; +import { getDefaultSystemPrompt } from './helpers'; export const DEFAULT_CONVERSATION_STATE: Conversation = { id: i18n.DEFAULT_CONVERSATION_TITLE, @@ -68,7 +69,7 @@ interface UseConversation { } export const useConversation = (): UseConversation => { - const { setConversations } = useAssistantContext(); + const { allSystemPrompts, setConversations } = useAssistantContext(); /** * Append a message to the conversation[] for a given conversationId @@ -135,10 +136,18 @@ export const useConversation = (): UseConversation => { (conversationId: string) => { setConversations((prev: Record) => { const prevConversation: Conversation | undefined = prev[conversationId]; + const defaultSystemPromptId = getDefaultSystemPrompt({ + allSystemPrompts, + conversation: prevConversation, + })?.id; if (prevConversation != null) { - const newConversation = { + const newConversation: Conversation = { ...prevConversation, + apiConfig: { + ...prevConversation.apiConfig, + defaultSystemPromptId, + }, messages: [], replacements: undefined, }; @@ -152,7 +161,7 @@ export const useConversation = (): UseConversation => { } }); }, - [setConversations] + [allSystemPrompts, setConversations] ); /** @@ -160,8 +169,17 @@ export const useConversation = (): UseConversation => { */ const createConversation = useCallback( ({ conversationId, messages }: CreateConversationProps): Conversation => { + const defaultSystemPromptId = getDefaultSystemPrompt({ + allSystemPrompts, + conversation: undefined, + })?.id; + const newConversation: Conversation = { ...DEFAULT_CONVERSATION_STATE, + apiConfig: { + ...DEFAULT_CONVERSATION_STATE.apiConfig, + defaultSystemPromptId, + }, id: conversationId, messages: messages != null ? messages : [], }; @@ -180,7 +198,7 @@ export const useConversation = (): UseConversation => { }); return newConversation; }, - [setConversations] + [allSystemPrompts, setConversations] ); /** 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 f0a9b552a2be..0cd01e745718 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 @@ -101,7 +101,6 @@ export interface UseAssistantContext { showAnonymizedValues: boolean; }) => EuiCommentProps[]; http: HttpSetup; - isSettingsModalVisible: boolean; localStorageLastConversationId: string | undefined; promptContexts: Record; nameSpace: string; @@ -112,7 +111,6 @@ export interface UseAssistantContext { setConversations: React.Dispatch>>; setDefaultAllow: React.Dispatch>; setDefaultAllowReplacement: React.Dispatch>; - setIsSettingsModalVisible: React.Dispatch>; setLastConversationId: React.Dispatch>; setSelectedSettingsTab: React.Dispatch>; setShowAssistantOverlay: (showAssistantOverlay: ShowAssistantOverlay) => void; @@ -160,12 +158,6 @@ export const AssistantProvider: React.FC = ({ baseSystemPrompts ); - // if basePrompt has been updated, the localstorage should be accordingly updated - // if it exists - useEffect(() => { - setLocalStorageSystemPrompts(baseSystemPrompts); - }, [baseSystemPrompts, setLocalStorageSystemPrompts]); - const [localStorageLastConversationId, setLocalStorageLastConversationId] = useLocalStorage(`${nameSpace}.${LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY}`); @@ -212,7 +204,6 @@ export const AssistantProvider: React.FC = ({ /** * Settings State */ - const [isSettingsModalVisible, setIsSettingsModalVisible] = useState(false); const [selectedSettingsTab, setSelectedSettingsTab] = useState(CONVERSATIONS_TAB); const [conversations, setConversationsInternal] = useState(getInitialConversations()); @@ -264,7 +255,6 @@ export const AssistantProvider: React.FC = ({ docLinks, getComments, http, - isSettingsModalVisible, promptContexts, nameSpace, registerPromptContext, @@ -274,7 +264,6 @@ export const AssistantProvider: React.FC = ({ setConversations: onConversationsUpdated, setDefaultAllow, setDefaultAllowReplacement, - setIsSettingsModalVisible, setSelectedSettingsTab, setShowAssistantOverlay, showAssistantOverlay, @@ -298,7 +287,6 @@ export const AssistantProvider: React.FC = ({ docLinks, getComments, http, - isSettingsModalVisible, localStorageLastConversationId, localStorageQuickPrompts, localStorageSystemPrompts, @@ -309,7 +297,6 @@ export const AssistantProvider: React.FC = ({ selectedSettingsTab, setDefaultAllow, setDefaultAllowReplacement, - setIsSettingsModalVisible, setLocalStorageLastConversationId, setLocalStorageQuickPrompts, setLocalStorageSystemPrompts, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_missing_callout/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_missing_callout/index.tsx index 99e8d98b438b..671cac8ea01c 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_missing_callout/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_missing_callout/index.tsx @@ -13,6 +13,11 @@ import * as i18n from '../translations'; import { useAssistantContext } from '../../assistant_context'; import { CONVERSATIONS_TAB } from '../../assistant/settings/assistant_settings'; +interface Props { + isSettingsModalVisible: boolean; + setIsSettingsModalVisible: React.Dispatch>; +} + /** * Error callout to be displayed when there is no connector configured for a conversation. Includes deep-link * to conversation settings to quickly resolve. @@ -20,39 +25,40 @@ import { CONVERSATIONS_TAB } from '../../assistant/settings/assistant_settings'; * TODO: Add 'quick fix' button to just pick a connector * TODO: Add setting for 'default connector' so we can auto-resolve and not even show this */ -export const ConnectorMissingCallout: React.FC = React.memo(() => { - const { isSettingsModalVisible, setIsSettingsModalVisible, setSelectedSettingsTab } = - useAssistantContext(); +export const ConnectorMissingCallout: React.FC = React.memo( + ({ isSettingsModalVisible, setIsSettingsModalVisible }) => { + const { setSelectedSettingsTab } = useAssistantContext(); - const onConversationSettingsClicked = useCallback(() => { - if (!isSettingsModalVisible) { - setIsSettingsModalVisible(true); - setSelectedSettingsTab(CONVERSATIONS_TAB); - } - }, [isSettingsModalVisible, setIsSettingsModalVisible, setSelectedSettingsTab]); + const onConversationSettingsClicked = useCallback(() => { + if (!isSettingsModalVisible) { + setIsSettingsModalVisible(true); + setSelectedSettingsTab(CONVERSATIONS_TAB); + } + }, [isSettingsModalVisible, setIsSettingsModalVisible, setSelectedSettingsTab]); - return ( - -

- {' '} - - {i18n.MISSING_CONNECTOR_CONVERSATION_SETTINGS_LINK} - - ), - }} - /> -

-
- ); -}); + return ( + +

+ {' '} + + {i18n.MISSING_CONNECTOR_CONVERSATION_SETTINGS_LINK} + + ), + }} + /> +

+
+ ); + } +); ConnectorMissingCallout.displayName = 'ConnectorMissingCallout'; diff --git a/x-pack/plugins/security_solution/public/assistant/helpers.tsx b/x-pack/plugins/security_solution/public/assistant/helpers.tsx index 8e397399dea9..6915796ca89c 100644 --- a/x-pack/plugins/security_solution/public/assistant/helpers.tsx +++ b/x-pack/plugins/security_solution/public/assistant/helpers.tsx @@ -51,6 +51,26 @@ export const getPromptContextFromEventDetailsItem = (data: TimelineEventsDetails const sendToTimelineEligibleQueryTypes: Array = ['kql', 'dsl', 'eql']; +/** + * Returns message contents with replacements applied. + * + * @param message + * @param replacements + */ +export const getMessageContentWithReplacements = ({ + messageContent, + replacements, +}: { + messageContent: string; + replacements: Record | undefined; +}): string => + replacements != null + ? Object.keys(replacements).reduce( + (acc, replacement) => acc.replaceAll(replacement, replacements[replacement]), + messageContent + ) + : messageContent; + /** * Augments the messages in a conversation with code block details, including * the start and end indices of the code block in the message, the type of the @@ -61,7 +81,14 @@ const sendToTimelineEligibleQueryTypes: Array = ['kql' export const augmentMessageCodeBlocks = ( currentConversation: Conversation ): CodeBlockDetails[][] => { - const cbd = currentConversation.messages.map(({ content }) => analyzeMarkdown(content)); + const cbd = currentConversation.messages.map(({ content }) => + analyzeMarkdown( + getMessageContentWithReplacements({ + messageContent: content, + replacements: currentConversation.replacements, + }) + ) + ); const output = cbd.map((codeBlocks, messageIndex) => codeBlocks.map((codeBlock, codeBlockIndex) => {