Skip to content

Commit

Permalink
[Security Solution] Adds support for custom Security Assistant System…
Browse files Browse the repository at this point in the history
…Prompts and Conversations (#159365)

## Summary


<p align="center">
  <img width="700" src="https://github.com/elastic/kibana/assets/2946766/3edf2101-718c-4716-80f6-c8377e66f0b9" />
</p> 




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 elastic/security-team#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
  • Loading branch information
spong authored Jun 13, 2023
1 parent eff5049 commit 6b65e90
Show file tree
Hide file tree
Showing 41 changed files with 1,477 additions and 320 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Props> = React.memo(
({
conversationId = DEFAULT_CONVERSATION_TITLE,
defaultConnectorId,
defaultProvider,
onSelectionChange,
shouldDisableKeyboardShortcut = () => false,
isDisabled = false,
}) => {
const { allSystemPrompts } = useAssistantContext();

const { deleteConversation, setConversation } = useConversation();
const [selectedConversationId, setSelectedConversationId] = useState<string>(conversationId);

const { conversations } = useAssistantContext();
const conversationIds = useMemo(() => Object.keys(conversations), [conversations]);
const conversationOptions = conversationIds.map((id) => ({ value: id, inputDisplay: id }));
const conversationOptions = useMemo<ConversationSelectorOption[]>(() => {
return Object.values(conversations).map((conversation) => ({
value: { isDefault: conversation.isDefault ?? false },
label: conversation.id,
}));
}, [conversations]);

const [selectedOptions, setSelectedOptions] = useState<ConversationSelectorOption[]>(() => {
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);
Expand Down Expand Up @@ -96,7 +187,57 @@ export const ConversationSelector: React.FC<Props> = 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 (
<EuiFlexGroup
alignItems="center"
justifyContent="spaceBetween"
component={'span'}
className={'parentFlexGroup'}
>
<EuiFlexItem grow={false} component={'span'} css={css``}>
<EuiHighlight
search={searchValue}
css={css`
overflow: hidden;
text-overflow: ellipsis;
`}
>
{label}
</EuiHighlight>
</EuiFlexItem>
{!value?.isDefault && (
<EuiFlexItem grow={false} component={'span'}>
<EuiToolTip position="right" content={i18n.DELETE_CONVERSATION}>
<EuiButtonIcon
iconType="cross"
aria-label={i18n.DELETE_CONVERSATION}
color="danger"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onDelete(label);
}}
css={css`
visibility: hidden;
.parentFlexGroup:hover & {
visibility: visible;
}
`}
/>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};

return (
<EuiFormRow
Expand All @@ -106,13 +247,18 @@ export const ConversationSelector: React.FC<Props> = React.memo(
min-width: 300px;
`}
>
<EuiSuperSelect
<EuiComboBox
aria-label={i18n.CONVERSATION_SELECTOR_ARIA_LABEL}
customOptionText={`${i18n.CONVERSATION_SELECTOR_CUSTOM_OPTION_TEXT} {searchValue}`}
placeholder={i18n.CONVERSATION_SELECTOR_PLACE_HOLDER}
singleSelection={{ asPlainText: true }}
options={conversationOptions}
valueOfSelected={selectedConversationId}
selectedOptions={selectedOptions}
onChange={onChange}
onCreateOption={onCreateOption}
renderOption={renderOption}
compressed={true}
disabled={isDisabled}
aria-label={i18n.CONVERSATION_SELECTOR_ARIA_LABEL}
isDisabled={isDisabled}
prepend={
<EuiToolTip content={`${i18n.PREVIOUS_CONVERSATION_TITLE} (⌘ + ←)`} display="block">
<EuiButtonIcon
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ export const CONVERSATION_SELECTOR_ARIA_LABEL = i18n.translate(
}
);

export const CONVERSATION_SELECTOR_PLACE_HOLDER = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelector.placeholderTitle',
{
defaultMessage: 'Select or type to create new...',
}
);

export const CONVERSATION_SELECTOR_CUSTOM_OPTION_TEXT = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelector.CustomOptionTextTitle',
{
defaultMessage: 'Create new conversation:',
}
);

export const PREVIOUS_CONVERSATION_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelector.previousConversationTitle',
{
Expand All @@ -34,3 +48,10 @@ export const NEXT_CONVERSATION_TITLE = i18n.translate(
defaultMessage: 'Next conversation',
}
);

export const DELETE_CONVERSATION = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelector.deleteConversationTitle',
{
defaultMessage: 'Delete conversation',
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -13,34 +13,45 @@ import {
EuiLink,
EuiToolTip,
} from '@elastic/eui';
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';

import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public';
import { HttpSetup } from '@kbn/core-http-browser';
import { FormattedMessage } from '@kbn/i18n-react';
import { Conversation } from '../..';
import * as i18n from './translations';
import { ConnectorSelector } from '../connectorland/connector_selector';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common';
import { Conversation, Prompt } from '../../..';
import * as i18n from '../translations';
import { ConnectorSelector } from '../../connectorland/connector_selector';
import { SelectSystemPrompt } from '../prompt_editor/system_prompt/select_system_prompt';

export interface SettingsPopoverProps {
export interface ConversationSettingsPopoverProps {
actionTypeRegistry: ActionTypeRegistryContract;
conversation: Conversation;
http: HttpSetup;
isDisabled?: boolean;
}

export const SettingsPopover: React.FC<SettingsPopoverProps> = React.memo(
export const ConversationSettingsPopover: React.FC<ConversationSettingsPopoverProps> = React.memo(
({ actionTypeRegistry, conversation, http, isDisabled = false }) => {
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
// So we can hide the settings popover when the connector modal is displayed
const popoverPanelRef = useRef<HTMLElement | null>(null);

const 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';
}
Expand Down Expand Up @@ -86,12 +97,29 @@ export const SettingsPopover: React.FC<SettingsPopoverProps> = React.memo(
actionTypeRegistry={actionTypeRegistry}
conversation={conversation}
http={http}
onConnectorModalVisibilityChange={onConnectorModalVisibilityChange}
onConnectorModalVisibilityChange={onDescendantModalVisibilityChange}
/>
</EuiFormRow>

{provider === OpenAiProviderType.OpenAi && <></>}

<EuiFormRow
data-test-subj="prompt-field"
label={i18n.SETTINGS_PROMPT_TITLE}
helpText={i18n.SETTINGS_PROMPT_HELP_TEXT_TITLE}
>
<SelectSystemPrompt
conversation={conversation}
fullWidth={false}
isEditing={true}
onSystemPromptModalVisibilityChange={onDescendantModalVisibilityChange}
selectedPrompt={selectedPrompt}
showTitles={true}
/>
</EuiFormRow>
</div>
</EuiPopover>
);
}
);
SettingsPopover.displayName = 'SettingPopover';
ConversationSettingsPopover.displayName = 'ConversationSettingsPopover';
Loading

0 comments on commit 6b65e90

Please sign in to comment.