[Security Solution] [Elastic AI Assistant] Consolidates settings into a single modal (#160468)

## Summary

This PR fixes the disjointed settings across the assistant by combining
them all into a single settings modal. It also resolves the Connector
`Model` configuration not being available when using the `OpenAI`
variant of the GenAI Connector.

Additional issues resolved:
- [x] Clearing conversation doesn't restore default system prompt
- [X] Double repeated welcome prompt
- [X] Clicking skip button broken

Resolves: https://github.com/elastic/security-team/issues/7110
Resolves:
https://github.com/elastic/kibana/pull/161039#pullrequestreview-1517129764
Resolves:
https://github.com/elastic/kibana/pull/161027#pullrequestreview-1523018176

#### Conversations

<p align="center">
<img width="500"
src="80e271e8-d12a-4d00-b6eb-d63cda2d8017"
/>
</p> 

#### Quick Prompts

<p align="center">
<img width="500"
src="417c49c0-2029-49f1-a2f3-b9d0ae3690d3"
/>
</p> 

#### System Prompts

<p align="center">
<img width="500"
src="cc2bac93-bfba-49c1-b5b8-6a6efa1c0a92"
/>
</p> 

#### Anonymization

<p align="center">
<img width="500"
src="9a65683a-06cc-4cc7-9397-9db2633b20a3"
/>
</p> 









### 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)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [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
This commit is contained in:
Garrett Spong 2023-07-12 01:50:10 -06:00 committed by GitHub
parent 85a99c954f
commit b323923e65
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
66 changed files with 2470 additions and 1372 deletions

View file

@ -11,6 +11,7 @@ import { HttpSetup } from '@kbn/core-http-browser';
import type { Message } from '../assistant_context/types';
import { Conversation } from '../assistant_context/types';
import { API_ERROR } from './translations';
import { MODEL_GPT_3_5_TURBO } from '../connectorland/models/model_selector/model_selector';
export interface FetchConnectorExecuteAction {
apiConfig: Conversation['apiConfig'];
@ -33,7 +34,7 @@ export const fetchConnectorExecuteAction = async ({
const body =
apiConfig?.provider === OpenAiProviderType.OpenAi
? {
model: 'gpt-3.5-turbo',
model: apiConfig.model ?? MODEL_GPT_3_5_TURBO,
messages: outboundMessages,
n: 1,
stop: null,

View file

@ -60,7 +60,7 @@ export const AssistantOverlay = React.memo<Props>(({ isAssistantEnabled }) => {
const handleShortcutPress = useCallback(() => {
// Try to restore the last conversation on shortcut pressed
if (!isModalVisible) {
setConversationId(localStorageLastConversationId || WELCOME_CONVERSATION_TITLE);
setConversationId(localStorageLastConversationId ?? WELCOME_CONVERSATION_TITLE);
}
setIsModalVisible(!isModalVisible);

View file

@ -11,6 +11,7 @@ export const TEST_IDS = {
ADD_SYSTEM_PROMPT: 'addSystemPrompt',
PROMPT_SUPERSELECT: 'promptSuperSelect',
CONVERSATIONS_MULTISELECTOR_OPTION: (id: string) => `conversationMultiSelectorOption-${id}`,
SETTINGS_MODAL: 'settingsModal',
SYSTEM_PROMPT_MODAL: {
ID: 'systemPromptModal',
PROMPT_TEXT: 'systemPromptModalPromptText',

View file

@ -1,129 +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 {
EuiButtonIcon,
EuiFormRow,
EuiPopover,
EuiPopoverTitle,
EuiLink,
EuiToolTip,
} from '@elastic/eui';
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 { 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 ConversationSettingsPopoverProps {
actionTypeRegistry: ActionTypeRegistryContract;
conversation: Conversation;
http: HttpSetup;
isDisabled?: boolean;
allSystemPrompts: Prompt[];
}
export const ConversationSettingsPopover: React.FC<ConversationSettingsPopoverProps> = React.memo(
({ actionTypeRegistry, conversation, http, isDisabled = false, allSystemPrompts }) => {
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(() => {
const convoDefaultSystemPromptId = conversation?.apiConfig.defaultSystemPromptId;
if (convoDefaultSystemPromptId && allSystemPrompts) {
return allSystemPrompts.find((prompt) => prompt.id === convoDefaultSystemPromptId);
}
return allSystemPrompts.find((prompt) => prompt.isNewConversationDefault);
}, [conversation, allSystemPrompts]);
const closeSettingsHandler = useCallback(() => {
setIsSettingsOpen(false);
}, []);
// Hide settings panel when modal is visible (to keep visual clutter minimal)
const onDescendantModalVisibilityChange = useCallback((isVisible: boolean) => {
if (popoverPanelRef.current) {
popoverPanelRef.current.style.visibility = isVisible ? 'hidden' : 'visible';
}
}, []);
return (
<EuiPopover
button={
<EuiToolTip position="right" content={i18n.SETTINGS_TITLE}>
<EuiButtonIcon
disabled={isDisabled}
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
iconType="controlsVertical"
aria-label={i18n.SETTINGS_TITLE}
data-test-subj="assistant-settings-button"
/>
</EuiToolTip>
}
isOpen={isSettingsOpen}
closePopover={closeSettingsHandler}
anchorPosition="rightCenter"
panelRef={(el) => (popoverPanelRef.current = el)}
>
<EuiPopoverTitle>{i18n.SETTINGS_TITLE}</EuiPopoverTitle>
<div style={{ width: '300px' }}>
<EuiFormRow
data-test-subj="model-field"
label={i18n.SETTINGS_CONNECTOR_TITLE}
helpText={
<EuiLink
href={`${http.basePath.get()}/app/management/insightsAndAlerting/triggersActionsConnectors/connectors`}
target="_blank"
external
>
<FormattedMessage
id="xpack.elasticAssistant.assistant.settings.connectorHelpTextTitle"
defaultMessage="Kibana Connector to make requests with"
/>
</EuiLink>
}
>
<ConnectorSelector
actionTypeRegistry={actionTypeRegistry}
conversation={conversation}
http={http}
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>
);
}
);
ConversationSettingsPopover.displayName = 'ConversationSettingsPopover';

View file

@ -20,20 +20,20 @@ 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 { 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';
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;
selectedConversationId: string | undefined;
setSelectedConversationId: React.Dispatch<React.SetStateAction<string>>;
shouldDisableKeyboardShortcut?: () => boolean;
isDisabled?: boolean;
}
@ -56,17 +56,16 @@ export type ConversationSelectorOption = EuiComboBoxOptionOption<{
export const ConversationSelector: React.FC<Props> = React.memo(
({
conversationId = DEFAULT_CONVERSATION_TITLE,
selectedConversationId = DEFAULT_CONVERSATION_TITLE,
defaultConnectorId,
defaultProvider,
onSelectionChange,
setSelectedConversationId,
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]);
@ -112,7 +111,13 @@ export const ConversationSelector: React.FC<Props> = React.memo(
}
setSelectedConversationId(searchValue);
},
[allSystemPrompts, defaultConnectorId, defaultProvider, setConversation]
[
allSystemPrompts,
defaultConnectorId,
defaultProvider,
setConversation,
setSelectedConversationId,
]
);
// Callback for when user deletes a conversation
@ -124,32 +129,29 @@ export const ConversationSelector: React.FC<Props> = React.memo(
setTimeout(() => {
deleteConversation(cId);
}, 0);
// onSystemPromptDeleted(cId);
},
[conversationIds, deleteConversation, selectedConversationId]
[conversationIds, deleteConversation, selectedConversationId, setSelectedConversationId]
);
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]
[conversationOptions, setSelectedConversationId]
);
const onLeftArrowClick = useCallback(() => {
const prevId = getPreviousConversationId(conversationIds, selectedConversationId);
setSelectedConversationId(prevId);
}, [conversationIds, selectedConversationId]);
}, [conversationIds, selectedConversationId, setSelectedConversationId]);
const onRightArrowClick = useCallback(() => {
const nextId = getNextConversationId(conversationIds, selectedConversationId);
setSelectedConversationId(nextId);
}, [conversationIds, selectedConversationId]);
}, [conversationIds, selectedConversationId, setSelectedConversationId]);
// Register keyboard listener for quick conversation switching
const onKeyDown = useCallback(
@ -186,9 +188,8 @@ export const ConversationSelector: React.FC<Props> = React.memo(
useEvent('keydown', onKeyDown);
useEffect(() => {
onSelectionChange?.(selectedConversationId);
setSelectedOptions(conversationOptions.filter((c) => c.label === selectedConversationId));
}, [conversationOptions, onSelectionChange, selectedConversationId]);
}, [conversationOptions, selectedConversationId]);
const renderOption: (
option: ConversationSelectorOption,

View file

@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n';
export const SELECTED_CONVERSATION_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelector.defaultConversationTitle',
{
defaultMessage: 'Selected conversation',
defaultMessage: 'Conversations',
}
);

View file

@ -0,0 +1,251 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiButtonIcon,
EuiComboBox,
EuiComboBoxOptionOption,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiHighlight,
EuiToolTip,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { css } from '@emotion/react';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/gen_ai/constants';
import { Conversation, Prompt } from '../../../..';
import { UseAssistantContext } from '../../../assistant_context';
import * as i18n from './translations';
import { SystemPromptSelectorOption } from '../../prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector';
interface Props {
allSystemPrompts: Prompt[];
conversations: UseAssistantContext['conversations'];
onConversationDeleted: (conversationId: string) => void;
onConversationSelectionChange: (conversation?: Conversation | string) => void;
selectedConversationId?: string;
defaultConnectorId?: string;
defaultProvider?: OpenAiProviderType;
}
const getPreviousConversationId = (conversationIds: string[], selectedConversationId = '') => {
return conversationIds.indexOf(selectedConversationId) === 0
? conversationIds[conversationIds.length - 1]
: conversationIds[conversationIds.indexOf(selectedConversationId) - 1];
};
const getNextConversationId = (conversationIds: string[], selectedConversationId = '') => {
return conversationIds.indexOf(selectedConversationId) + 1 >= conversationIds.length
? conversationIds[0]
: conversationIds[conversationIds.indexOf(selectedConversationId) + 1];
};
export type ConversationSelectorSettingsOption = EuiComboBoxOptionOption<{
isDefault: boolean;
}>;
/**
* A disconnected variant of the ConversationSelector component that allows for
* modifiable settings without persistence. Also changes some styling and removes
* the keyboard shortcuts. Could be merged w/ ConversationSelector if refactored
* as a connected wrapper.
*/
export const ConversationSelectorSettings: React.FC<Props> = React.memo(
({
allSystemPrompts,
conversations,
onConversationDeleted,
onConversationSelectionChange,
selectedConversationId,
defaultConnectorId,
defaultProvider,
}) => {
const conversationIds = useMemo(() => Object.keys(conversations), [conversations]);
const [conversationOptions, setConversationOptions] = useState<
ConversationSelectorSettingsOption[]
>(() => {
return Object.values(conversations).map((conversation) => ({
value: { isDefault: conversation.isDefault ?? false },
label: conversation.id,
}));
});
const selectedOptions = useMemo<ConversationSelectorSettingsOption[]>(() => {
return selectedConversationId
? conversationOptions.filter((c) => c.label === selectedConversationId) ?? []
: [];
}, [conversationOptions, selectedConversationId]);
const handleSelectionChange = useCallback(
(conversationSelectorSettingsOption: ConversationSelectorSettingsOption[]) => {
const newConversation =
conversationSelectorSettingsOption.length === 0
? undefined
: Object.values(conversations).find(
(conversation) => conversation.id === conversationSelectorSettingsOption[0]?.label
) ?? conversationSelectorSettingsOption[0]?.label;
onConversationSelectionChange(newConversation);
},
[onConversationSelectionChange, conversations]
);
// Callback for when user types to create a new conversation
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) {
setConversationOptions([...conversationOptions, newOption]);
}
handleSelectionChange([newOption]);
},
[conversationOptions, handleSelectionChange]
);
// Callback for when a user selects a conversation
const onChange = useCallback(
(newOptions: ConversationSelectorSettingsOption[]) => {
if (newOptions.length === 0) {
handleSelectionChange([]);
} else if (conversationOptions.findIndex((o) => o.label === newOptions?.[0].label) !== -1) {
handleSelectionChange(newOptions);
}
},
[conversationOptions, handleSelectionChange]
);
// Callback for when user deletes a conversation
const onDelete = useCallback(
(label: string) => {
setConversationOptions(conversationOptions.filter((o) => o.label !== label));
if (selectedOptions?.[0]?.label === label) {
handleSelectionChange([]);
}
onConversationDeleted(label);
},
[conversationOptions, handleSelectionChange, onConversationDeleted, selectedOptions]
);
const onLeftArrowClick = useCallback(() => {
const prevId = getPreviousConversationId(conversationIds, selectedConversationId);
const previousOption = conversationOptions.filter((c) => c.label === prevId);
handleSelectionChange(previousOption);
}, [conversationIds, conversationOptions, handleSelectionChange, selectedConversationId]);
const onRightArrowClick = useCallback(() => {
const nextId = getNextConversationId(conversationIds, selectedConversationId);
const nextOption = conversationOptions.filter((c) => c.label === nextId);
handleSelectionChange(nextOption);
}, [conversationIds, conversationOptions, handleSelectionChange, selectedConversationId]);
const renderOption: (
option: ConversationSelectorSettingsOption,
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
label={i18n.SELECTED_CONVERSATION_LABEL}
display="rowCompressed"
css={css`
min-width: 300px;
`}
>
<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}
selectedOptions={selectedOptions}
onChange={onChange}
onCreateOption={onCreateOption}
renderOption={renderOption}
compressed={true}
prepend={
<EuiButtonIcon
iconType="arrowLeft"
aria-label={i18n.PREVIOUS_CONVERSATION_TITLE}
onClick={onLeftArrowClick}
disabled={conversationIds.length <= 1}
/>
}
append={
<EuiButtonIcon
iconType="arrowRight"
aria-label={i18n.NEXT_CONVERSATION_TITLE}
onClick={onRightArrowClick}
disabled={conversationIds.length <= 1}
/>
}
/>
</EuiFormRow>
);
}
);
ConversationSelectorSettings.displayName = 'ConversationSelectorSettings';

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const SELECTED_CONVERSATION_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelectorSettings.defaultConversationTitle',
{
defaultMessage: 'Conversations',
}
);
export const CONVERSATION_SELECTOR_ARIA_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelectorSettings.ariaLabel',
{
defaultMessage: 'Conversation selector',
}
);
export const CONVERSATION_SELECTOR_PLACE_HOLDER = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelectorSettings.placeholderTitle',
{
defaultMessage: 'Select or type to create new...',
}
);
export const CONVERSATION_SELECTOR_CUSTOM_OPTION_TEXT = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelectorSettings.CustomOptionTextTitle',
{
defaultMessage: 'Create new conversation:',
}
);
export const PREVIOUS_CONVERSATION_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelectorSettings.previousConversationTitle',
{
defaultMessage: 'Previous conversation',
}
);
export const NEXT_CONVERSATION_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelectorSettings.nextConversationTitle',
{
defaultMessage: 'Next conversation',
}
);
export const DELETE_CONVERSATION = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelectorSettings.deleteConversationTitle',
{
defaultMessage: 'Delete conversation',
}
);

View file

@ -0,0 +1,265 @@
/*
* 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 { EuiFormRow, EuiLink, EuiTitle, EuiText, EuiHorizontalRule, EuiSpacer } from '@elastic/eui';
import React, { useCallback, useMemo } 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 { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common';
import { Conversation, Prompt } from '../../../..';
import * as i18n from './translations';
import * as i18nModel from '../../../connectorland/models/model_selector/translations';
import { ConnectorSelector } from '../../../connectorland/connector_selector';
import { SelectSystemPrompt } from '../../prompt_editor/system_prompt/select_system_prompt';
import { ModelSelector } from '../../../connectorland/models/model_selector/model_selector';
import { UseAssistantContext } from '../../../assistant_context';
import { ConversationSelectorSettings } from '../conversation_selector_settings';
import { getDefaultSystemPromptFromConversation } from './helpers';
export interface ConversationSettingsProps {
actionTypeRegistry: ActionTypeRegistryContract;
allSystemPrompts: Prompt[];
conversationSettings: UseAssistantContext['conversations'];
http: HttpSetup;
onSelectedConversationChange: (conversation?: Conversation) => void;
selectedConversation: Conversation | undefined;
setUpdatedConversationSettings: React.Dispatch<
React.SetStateAction<UseAssistantContext['conversations']>
>;
isDisabled?: boolean;
}
/**
* Settings for adding/removing conversation and configuring default system prompt and connector.
*/
export const ConversationSettings: React.FC<ConversationSettingsProps> = React.memo(
({
actionTypeRegistry,
allSystemPrompts,
selectedConversation,
onSelectedConversationChange,
conversationSettings,
http,
setUpdatedConversationSettings,
isDisabled = false,
}) => {
// Defaults
const defaultSystemPrompt = useMemo(() => {
return (
allSystemPrompts.find((systemPrompt) => systemPrompt.isNewConversationDefault) ??
allSystemPrompts[0]
);
}, [allSystemPrompts]);
// Conversation callbacks
// When top level conversation selection changes
const onConversationSelectionChange = useCallback(
(c?: Conversation | string) => {
const isNew = typeof c === 'string';
const newSelectedConversation: Conversation | undefined = isNew
? {
id: c ?? '',
messages: [],
apiConfig: {
connectorId: undefined,
provider: undefined,
defaultSystemPromptId: defaultSystemPrompt?.id,
},
}
: c;
if (newSelectedConversation != null) {
setUpdatedConversationSettings((prev) => {
return {
...prev,
[newSelectedConversation.id]: newSelectedConversation,
};
});
}
onSelectedConversationChange(newSelectedConversation);
},
[defaultSystemPrompt?.id, onSelectedConversationChange, setUpdatedConversationSettings]
);
const onConversationDeleted = useCallback(
(conversationId: string) => {
setUpdatedConversationSettings((prev) => {
const { [conversationId]: prevConversation, ...updatedConversations } = prev;
if (prevConversation != null) {
return updatedConversations;
}
return prev;
});
},
[setUpdatedConversationSettings]
);
const selectedSystemPrompt = useMemo(
() =>
getDefaultSystemPromptFromConversation({
allSystemPrompts,
conversation: selectedConversation,
defaultSystemPrompt,
}),
[allSystemPrompts, defaultSystemPrompt, selectedConversation]
);
const handleOnSystemPromptSelectionChange = useCallback(
(systemPromptId?: string) => {
if (selectedConversation != null) {
setUpdatedConversationSettings((prev) => ({
...prev,
[selectedConversation.id]: {
...selectedConversation,
apiConfig: {
...selectedConversation.apiConfig,
defaultSystemPromptId: systemPromptId,
},
},
}));
}
},
[selectedConversation, setUpdatedConversationSettings]
);
const selectedConnectorId = useMemo(
() => selectedConversation?.apiConfig.connectorId,
[selectedConversation?.apiConfig.connectorId]
);
const selectedProvider = useMemo(
() => selectedConversation?.apiConfig.provider,
[selectedConversation?.apiConfig.provider]
);
const handleOnConnectorSelectionChange = useCallback(
(connectorId: string, provider: OpenAiProviderType) => {
if (selectedConversation != null) {
setUpdatedConversationSettings((prev) => ({
...prev,
[selectedConversation.id]: {
...selectedConversation,
apiConfig: {
...selectedConversation.apiConfig,
connectorId,
provider,
},
},
}));
}
},
[selectedConversation, setUpdatedConversationSettings]
);
const selectedModel = useMemo(
() => selectedConversation?.apiConfig.model,
[selectedConversation?.apiConfig.model]
);
const handleOnModelSelectionChange = useCallback(
(model?: string) => {
if (selectedConversation != null) {
setUpdatedConversationSettings((prev) => ({
...prev,
[selectedConversation.id]: {
...selectedConversation,
apiConfig: {
...selectedConversation.apiConfig,
model,
},
},
}));
}
},
[selectedConversation, setUpdatedConversationSettings]
);
return (
<>
<EuiTitle size={'s'}>
<h2>{i18n.SETTINGS_TITLE}</h2>
</EuiTitle>
<EuiSpacer size="xs" />
<EuiText size={'s'}>{i18n.SETTINGS_DESCRIPTION}</EuiText>
<EuiHorizontalRule margin={'s'} />
<ConversationSelectorSettings
selectedConversationId={selectedConversation?.id}
allSystemPrompts={allSystemPrompts}
conversations={conversationSettings}
onConversationDeleted={onConversationDeleted}
onConversationSelectionChange={onConversationSelectionChange}
/>
<EuiFormRow
data-test-subj="prompt-field"
display="rowCompressed"
fullWidth
label={i18n.SETTINGS_PROMPT_TITLE}
helpText={i18n.SETTINGS_PROMPT_HELP_TEXT_TITLE}
>
<SelectSystemPrompt
allSystemPrompts={allSystemPrompts}
compressed
conversation={selectedConversation}
isEditing={true}
isDisabled={selectedConversation == null}
onSystemPromptSelectionChange={handleOnSystemPromptSelectionChange}
selectedPrompt={selectedSystemPrompt}
showTitles={true}
/>
</EuiFormRow>
<EuiFormRow
data-test-subj="connector-field"
display="rowCompressed"
label={i18n.CONNECTOR_TITLE}
helpText={
<EuiLink
href={`${http.basePath.get()}/app/management/insightsAndAlerting/triggersActionsConnectors/connectors`}
target="_blank"
external
>
<FormattedMessage
id="xpack.elasticAssistant.assistant.settings.connectorHelpTextTitle"
defaultMessage="Kibana Connector to make requests with"
/>
</EuiLink>
}
>
<ConnectorSelector
actionTypeRegistry={actionTypeRegistry}
http={http}
isDisabled={selectedConversation == null}
onConnectorModalVisibilityChange={() => {}}
onConnectorSelectionChange={handleOnConnectorSelectionChange}
selectedConnectorId={selectedConnectorId}
/>
</EuiFormRow>
{selectedProvider === OpenAiProviderType.OpenAi && (
<EuiFormRow
data-test-subj="model-field"
display="rowCompressed"
label={i18nModel.MODEL_TITLE}
helpText={i18nModel.HELP_LABEL}
>
<ModelSelector
onModelSelectionChange={handleOnModelSelectionChange}
selectedModel={selectedModel}
/>
</EuiFormRow>
)}
</>
);
}
);
ConversationSettings.displayName = 'ConversationSettings';

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Conversation, 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;
};

View file

@ -0,0 +1,43 @@
/*
* 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 SETTINGS_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.conversations.settings.settingsTitle',
{
defaultMessage: 'Conversations',
}
);
export const SETTINGS_DESCRIPTION = i18n.translate(
'xpack.elasticAssistant.assistant.conversations.settings.settingsDescription',
{
defaultMessage: 'Create and manage conversations with the Elastic AI Assistant',
}
);
export const CONNECTOR_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.conversations.settings.connectorTitle',
{
defaultMessage: 'Connector',
}
);
export const SETTINGS_PROMPT_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.conversations.settings.promptTitle',
{
defaultMessage: 'System Prompt',
}
);
export const SETTINGS_PROMPT_HELP_TEXT_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.conversations.settings.promptHelpTextTitle',
{
defaultMessage: 'Context provided as part of every conversation',
}
);

View file

@ -5,7 +5,10 @@
* 2.0.
*/
import { BASE_CONVERSATIONS, Conversation } from '../..';
import type { Message } from '../assistant_context/types';
import { WELCOME_CONVERSATION_TITLE } from './use_conversation/translations';
import { enterpriseMessaging } from './use_conversation/sample_conversations';
export const getMessageFromRawResponse = (rawResponse: string): Message => {
const dateTimeString = new Date().toLocaleString(); // TODO: Pull from response
@ -23,3 +26,27 @@ export const getMessageFromRawResponse = (rawResponse: string): Message => {
};
}
};
export const getWelcomeConversation = (isAssistantEnabled: boolean): Conversation => {
const conversation = BASE_CONVERSATIONS[WELCOME_CONVERSATION_TITLE];
const doesConversationHaveMessages = conversation.messages.length > 0;
if (!isAssistantEnabled) {
if (
!doesConversationHaveMessages ||
conversation.messages[conversation.messages.length - 1].content !==
enterpriseMessaging[0].content
) {
return {
...conversation,
messages: [...conversation.messages, ...enterpriseMessaging],
};
}
return conversation;
}
return {
...conversation,
messages: BASE_CONVERSATIONS[WELCOME_CONVERSATION_TITLE].messages,
};
};

View file

@ -146,7 +146,7 @@ describe('Assistant', () => {
});
describe('when no connectors are loaded', () => {
it('should clear conversation id in local storage', async () => {
it('should set welcome conversation id in local storage', async () => {
const emptyConnectors: unknown[] = [];
jest.mocked(useLoadConnectors).mockReturnValue({
@ -157,7 +157,7 @@ describe('Assistant', () => {
renderAssistant();
expect(persistToLocalStorage).toHaveBeenCalled();
expect(persistToLocalStorage).toHaveBeenLastCalledWith('');
expect(persistToLocalStorage).toHaveBeenLastCalledWith(WELCOME_CONVERSATION_TITLE);
});
});
});

View file

@ -16,7 +16,6 @@ import {
EuiToolTip,
EuiSwitchEvent,
EuiSwitch,
EuiCallOut,
EuiModalFooter,
EuiModalHeader,
EuiModalBody,
@ -29,20 +28,18 @@ import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/gen_ai/c
import { ActionConnectorProps } from '@kbn/triggers-actions-ui-plugin/public/types';
import { AssistantTitle } from './assistant_title';
import { UpgradeButtons } from '../upgrade/upgrade_buttons';
import { getMessageFromRawResponse } from './helpers';
import { getMessageFromRawResponse, getWelcomeConversation } from './helpers';
import { ConversationSettingsPopover } from './conversation_settings_popover/conversation_settings_popover';
import { useAssistantContext } from '../assistant_context';
import { ContextPills } from './context_pills';
import { getNewSelectedPromptContext } from '../data_anonymization/get_new_selected_prompt_context';
import { SettingsPopover } from '../data_anonymization/settings/settings_popover';
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 { useSendMessages } from './use_send_messages';
import type { Message } from '../assistant_context/types';
import { ConversationSelector } from './conversation_selector';
import { ConversationSelector } from './conversations/conversation_selector';
import { PromptEditor } from './prompt_editor';
import { getCombinedMessage } from './prompt/helpers';
import * as i18n from './translations';
@ -50,7 +47,8 @@ import { QuickPrompts } from './quick_prompts/quick_prompts';
import { useLoadConnectors } from '../connectorland/use_load_connectors';
import { useConnectorSetup } from '../connectorland/connector_setup';
import { WELCOME_CONVERSATION_TITLE } from './use_conversation/translations';
import { BASE_CONVERSATIONS, enterpriseMessaging } from './use_conversation/sample_conversations';
import { AssistantSettingsButton } from './settings/assistant_settings_button';
import { ConnectorMissingCallout } from '../connectorland/connector_missing_callout';
export interface Props {
conversationId?: string;
@ -97,46 +95,7 @@ const AssistantComponent: React.FC<Props> = ({
useConversation();
const { isLoading, sendMessages } = useSendMessages();
const [selectedConversationId, setSelectedConversationId] = useState<string>(conversationId);
const currentConversation = useMemo(
() => conversations[selectedConversationId] ?? createConversation({ conversationId }),
[conversationId, conversations, createConversation, selectedConversationId]
);
// Welcome conversation is a special 'setup' case when no connector exists, mostly extracted to `ConnectorSetup` component,
// but currently a bit of state is littered throughout the assistant component. TODO: clean up/isolate this state
const welcomeConversation = useMemo(() => {
const conversation =
conversations[selectedConversationId] ?? BASE_CONVERSATIONS[WELCOME_CONVERSATION_TITLE];
const doesConversationHaveMessages = conversation.messages.length > 0;
if (!isAssistantEnabled) {
if (
!doesConversationHaveMessages ||
conversation.messages[conversation.messages.length - 1].content !==
enterpriseMessaging[0].content
) {
return {
...conversation,
messages: [...conversation.messages, ...enterpriseMessaging],
};
}
return conversation;
}
return doesConversationHaveMessages
? {
...conversation,
messages: [
...conversation.messages,
...BASE_CONVERSATIONS[WELCOME_CONVERSATION_TITLE].messages,
],
}
: {
...conversation,
messages: BASE_CONVERSATIONS[WELCOME_CONVERSATION_TITLE].messages,
};
}, [conversations, isAssistantEnabled, selectedConversationId]);
// Connector details
const {
data: connectors,
isSuccess: areConnectorsFetched,
@ -150,11 +109,30 @@ const AssistantComponent: React.FC<Props> = ({
[connectors]
);
// Welcome setup state
const isWelcomeSetup = useMemo(() => (connectors?.length ?? 0) === 0, [connectors?.length]);
const isDisabled = isWelcomeSetup || !isAssistantEnabled;
// Welcome conversation is a special 'setup' case when no connector exists, mostly extracted to `ConnectorSetup` component,
// but currently a bit of state is littered throughout the assistant component. TODO: clean up/isolate this state
const welcomeConversation = useMemo(
() => getWelcomeConversation(isAssistantEnabled),
[isAssistantEnabled]
);
const [selectedConversationId, setSelectedConversationId] = useState<string>(
isWelcomeSetup ? welcomeConversation.id : conversationId
);
const currentConversation = useMemo(
() => conversations[selectedConversationId] ?? createConversation({ conversationId }),
[conversationId, conversations, createConversation, selectedConversationId]
);
// Remember last selection for reuse after keyboard shortcut is pressed.
// Clear it if there is no connectors
useEffect(() => {
if (areConnectorsFetched && !connectors?.length) {
return setLastConversationId('');
return setLastConversationId(WELCOME_CONVERSATION_TITLE);
}
if (!currentConversation.excludeFromLastConversationStorage) {
@ -162,9 +140,6 @@ const AssistantComponent: React.FC<Props> = ({
}
}, [areConnectorsFetched, connectors?.length, currentConversation, setLastConversationId]);
const isWelcomeSetup = (connectors?.length ?? 0) === 0;
const isDisabled = isWelcomeSetup || !isAssistantEnabled;
const { comments: connectorComments, prompt: connectorPrompt } = useConnectorSetup({
actionTypeRegistry,
http,
@ -479,10 +454,10 @@ const AssistantComponent: React.FC<Props> = ({
`}
>
<ConversationSelector
conversationId={selectedConversationId}
defaultConnectorId={defaultConnectorId}
defaultProvider={defaultProvider}
onSelectionChange={(id) => setSelectedConversationId(id)}
selectedConversationId={selectedConversationId}
setSelectedConversationId={setSelectedConversationId}
shouldDisableKeyboardShortcut={shouldDisableConversationSelectorHotkeys}
isDisabled={isDisabled}
/>
@ -511,26 +486,17 @@ const AssistantComponent: React.FC<Props> = ({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SettingsPopover isDisabled={currentConversation.replacements == null} />
<AssistantSettingsButton
isDisabled={isDisabled}
selectedConversation={currentConversation}
setSelectedConversationId={setSelectedConversationId}
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin={'m'} />
{!isWelcomeSetup && showMissingConnectorCallout && (
<>
<EuiCallOut
color="danger"
iconType="controlsVertical"
size="m"
title={i18n.MISSING_CONNECTOR_CALLOUT_TITLE}
>
<p>{i18n.MISSING_CONNECTOR_CALLOUT_DESCRIPTION}</p>
</EuiCallOut>
<EuiSpacer size={'s'} />
</>
)}
</>
)}
@ -550,7 +516,20 @@ const AssistantComponent: React.FC<Props> = ({
</>
)}
</EuiModalHeader>
<EuiModalBody>{comments}</EuiModalBody>
<EuiModalBody>
{comments}
{!isWelcomeSetup && showMissingConnectorCallout && (
<>
<EuiSpacer />
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<ConnectorMissingCallout />
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
</EuiModalBody>
<EuiModalFooter
css={css`
align-items: flex-start;
@ -640,15 +619,6 @@ const AssistantComponent: React.FC<Props> = ({
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={true}>
<ConversationSettingsPopover
actionTypeRegistry={actionTypeRegistry}
conversation={currentConversation}
isDisabled={isDisabled}
http={http}
allSystemPrompts={allSystemPrompts}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -42,7 +42,7 @@ const defaultProps: Props = {
describe('PromptEditorComponent', () => {
beforeEach(() => jest.clearAllMocks());
it('renders the system prompt selector when isNewConversation is true', async () => {
it('renders the system prompt viewer when isNewConversation is true', async () => {
render(
<TestProviders>
<PromptEditor {...defaultProps} />
@ -50,7 +50,7 @@ describe('PromptEditorComponent', () => {
);
await waitFor(() => {
expect(screen.getByTestId('selectSystemPrompt')).toBeInTheDocument();
expect(screen.getByTestId('systemPromptText')).toBeInTheDocument();
});
});

View file

@ -71,27 +71,27 @@ describe('SystemPrompt', () => {
});
});
describe('when conversation is undefined', () => {
describe('when conversation is undefined and default prompt is used', () => {
const conversation = undefined;
beforeEach(() => {
render(<SystemPrompt conversation={conversation} />);
});
it('renders the system prompt select', () => {
expect(screen.getByTestId('selectSystemPrompt')).toBeInTheDocument();
it('does render the system prompt fallback text', () => {
expect(screen.getByTestId('systemPromptText')).toBeInTheDocument();
});
it('does NOT render the system prompt text', () => {
expect(screen.queryByTestId('systemPromptText')).not.toBeInTheDocument();
it('does NOT render the system prompt select', () => {
expect(screen.queryByTestId('selectSystemPrompt')).not.toBeInTheDocument();
});
it('does NOT render the edit button', () => {
expect(screen.queryByTestId('edit')).not.toBeInTheDocument();
it('does render the edit button', () => {
expect(screen.getByTestId('edit')).toBeInTheDocument();
});
it('does NOT render the clear button', () => {
expect(screen.queryByTestId('clear')).not.toBeInTheDocument();
it('does render the clear button', () => {
expect(screen.getByTestId('clear')).toBeInTheDocument();
});
});
@ -117,7 +117,8 @@ describe('SystemPrompt', () => {
});
});
describe('when a new prompt is saved', () => {
// TODO: To be implemented as part of the global settings tests instead of within the SystemPrompt component
describe.skip('when a new prompt is saved', () => {
it('should save new prompt correctly', async () => {
const customPromptName = 'custom prompt';
const customPromptText = 'custom prompt text';
@ -420,18 +421,6 @@ describe('SystemPrompt', () => {
expect(screen.getByTestId('selectSystemPrompt')).toBeInTheDocument();
});
it('clears the selected system prompt when the clear button is clicked', () => {
const apiConfig = {
apiConfig: { defaultSystemPromptId: undefined },
conversationId: 'Default',
};
render(<SystemPrompt conversation={BASE_CONVERSATION} />);
userEvent.click(screen.getByTestId('clear'));
expect(mockUseConversation.setApiConfig).toHaveBeenCalledWith(apiConfig);
});
it('shows the system prompt select when system prompt text is clicked', () => {
render(
<TestProviders>

View file

@ -6,14 +6,13 @@
*/
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { css } from '@emotion/react';
import { useAssistantContext } from '../../../assistant_context';
import { Conversation } from '../../../..';
import { Conversation, Prompt } from '../../../..';
import * as i18n from './translations';
import { SelectSystemPrompt } from './select_system_prompt';
import { useConversation } from '../../use_conversation';
interface Props {
conversation: Conversation | undefined;
@ -21,26 +20,24 @@ interface Props {
const SystemPromptComponent: React.FC<Props> = ({ conversation }) => {
const { allSystemPrompts } = useAssistantContext();
const { setApiConfig } = useConversation();
const selectedPrompt = useMemo(
() => allSystemPrompts?.find((p) => p.id === conversation?.apiConfig.defaultSystemPromptId),
[allSystemPrompts, conversation]
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | undefined>(
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 [isEditing, setIsEditing] = React.useState<boolean>(false);
const handleClearSystemPrompt = useCallback(() => {
if (conversation) {
setApiConfig({
conversationId: conversation.id,
apiConfig: {
...conversation.apiConfig,
defaultSystemPromptId: undefined,
},
});
}
}, [conversation, setApiConfig]);
setSelectedPrompt(undefined);
}, []);
const handleEditSystemPrompt = useCallback(() => setIsEditing(true), []);
@ -48,6 +45,7 @@ const SystemPromptComponent: React.FC<Props> = ({ conversation }) => {
<div>
{selectedPrompt == null || isEditing ? (
<SelectSystemPrompt
allSystemPrompts={allSystemPrompts}
clearSelectedSystemPrompt={handleClearSystemPrompt}
conversation={conversation}
data-test-subj="systemPrompt"

View file

@ -13,6 +13,16 @@ import { Props, SelectSystemPrompt } from '.';
import { TEST_IDS } from '../../../constants';
const props: Props = {
allSystemPrompts: [
{
id: 'default-system-prompt',
content: 'default',
name: 'default',
promptType: 'system',
isDefault: true,
isNewConversationDefault: true,
},
],
conversation: undefined,
selectedPrompt: undefined,
};

View file

@ -24,39 +24,42 @@ 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';
import { SYSTEM_PROMPTS_TAB } from '../../../settings/assistant_settings';
import { TEST_IDS } from '../../../constants';
export interface Props {
allSystemPrompts: Prompt[];
compressed?: boolean;
conversation: Conversation | undefined;
selectedPrompt: Prompt | undefined;
clearSelectedSystemPrompt?: () => void;
fullWidth?: boolean;
isClearable?: boolean;
isEditing?: boolean;
isDisabled?: boolean;
isOpen?: boolean;
onSystemPromptModalVisibilityChange?: (isVisible: boolean) => void;
setIsEditing?: React.Dispatch<React.SetStateAction<boolean>>;
showTitles?: boolean;
onSystemPromptSelectionChange?: (promptId: string) => void;
}
const ADD_NEW_SYSTEM_PROMPT = 'ADD_NEW_SYSTEM_PROMPT';
const SelectSystemPromptComponent: React.FC<Props> = ({
allSystemPrompts,
compressed = false,
conversation,
selectedPrompt,
clearSelectedSystemPrompt,
fullWidth = true,
isClearable = false,
isEditing = false,
isDisabled = false,
isOpen = false,
onSystemPromptModalVisibilityChange,
onSystemPromptSelectionChange,
setIsEditing,
showTitles = false,
}) => {
const { allSystemPrompts, setAllSystemPrompts, conversations, setConversations } =
const { isSettingsModalVisible, setIsSettingsModalVisible, setSelectedSettingsTab } =
useAssistantContext();
const { setApiConfig } = useConversation();
const [isOpenLocal, setIsOpenLocal] = useState<boolean>(isOpen);
@ -78,8 +81,6 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
[conversation, setApiConfig]
);
// Connector Modal State
const [isSystemPromptModalVisible, setIsSystemPromptModalVisible] = useState<boolean>(false);
const addNewSystemPrompt = useMemo(() => {
return {
value: ADD_NEW_SYSTEM_PROMPT,
@ -100,29 +101,6 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
};
}, []);
// Callback for modal onSave, saves to local storage on change
const onSystemPromptsChange = useCallback(
(newSystemPrompts: Prompt[], updatedConversations?: Conversation[]) => {
setAllSystemPrompts(newSystemPrompts);
setIsSystemPromptModalVisible(false);
onSystemPromptModalVisibilityChange?.(false);
if (updatedConversations && updatedConversations.length > 0) {
const updatedConversationObject = updatedConversations?.reduce<
Record<string, Conversation>
>((updatedObj, currentConv) => {
updatedObj[currentConv.id] = currentConv;
return updatedObj;
}, {});
setConversations({
...conversations,
...updatedConversationObject,
});
}
},
[onSystemPromptModalVisibilityChange, setAllSystemPrompts, conversations, setConversations]
);
// SuperSelect State/Actions
const options = useMemo(
() => getOptions({ prompts: allSystemPrompts, showTitles }),
@ -132,14 +110,26 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
const onChange = useCallback(
(selectedSystemPromptId) => {
if (selectedSystemPromptId === ADD_NEW_SYSTEM_PROMPT) {
onSystemPromptModalVisibilityChange?.(true);
setIsSystemPromptModalVisible(true);
setIsSettingsModalVisible(true);
setSelectedSettingsTab(SYSTEM_PROMPTS_TAB);
return;
}
setSelectedSystemPrompt(allSystemPrompts.find((sp) => sp.id === selectedSystemPromptId));
// Note: if callback is provided, this component does not persist. Extract to separate component
if (onSystemPromptSelectionChange != null) {
onSystemPromptSelectionChange(selectedSystemPromptId);
} else {
setSelectedSystemPrompt(allSystemPrompts.find((sp) => sp.id === selectedSystemPromptId));
}
setIsEditing?.(false);
},
[allSystemPrompts, onSystemPromptModalVisibilityChange, setIsEditing, setSelectedSystemPrompt]
[
allSystemPrompts,
onSystemPromptSelectionChange,
setIsEditing,
setIsSettingsModalVisible,
setSelectedSettingsTab,
setSelectedSystemPrompt,
]
);
const clearSystemPrompt = useCallback(() => {
@ -170,16 +160,18 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
// Limits popover z-index to prevent it from getting too high and covering tooltips.
// If the z-index is not defined, when a popover is opened, it sets the target z-index + 2000
popoverProps={{ zIndex: euiThemeVars.euiZLevel8 }}
compressed={compressed}
data-test-subj={TEST_IDS.PROMPT_SUPERSELECT}
fullWidth={fullWidth}
fullWidth
hasDividers
itemLayoutAlign="top"
isOpen={isOpenLocal && !isSystemPromptModalVisible}
disabled={isDisabled}
isOpen={isOpenLocal && !isSettingsModalVisible}
onChange={onChange}
onBlur={handleOnBlur}
options={[...options, addNewSystemPrompt]}
placeholder={i18n.SELECT_A_SYSTEM_PROMPT}
valueOfSelected={selectedPrompt?.id}
valueOfSelected={selectedPrompt?.id ?? allSystemPrompts[0].id}
/>
</EuiFormRow>
)}
@ -207,13 +199,6 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
</EuiToolTip>
)}
</EuiFlexItem>
{isSystemPromptModalVisible && (
<SystemPromptModal
onClose={() => setIsSystemPromptModalVisible(false)}
onSystemPromptsChange={onSystemPromptsChange}
systemPrompts={allSystemPrompts}
/>
)}
</EuiFlexGroup>
);
};

View file

@ -13,6 +13,7 @@ import { Conversation } from '../../../../../..';
import * as i18n from '../translations';
interface Props {
isDisabled?: boolean;
onConversationSelectionChange: (currentPromptConversations: Conversation[]) => void;
conversations: Conversation[];
selectedConversations?: Conversation[];
@ -22,7 +23,12 @@ interface Props {
* Selector for choosing multiple Conversations
*/
export const ConversationMultiSelector: React.FC<Props> = React.memo(
({ onConversationSelectionChange, conversations, selectedConversations = [] }) => {
({
conversations,
isDisabled = false,
onConversationSelectionChange,
selectedConversations = [],
}) => {
// ComboBox options
const options = useMemo<EuiComboBoxOptionOption[]>(
() =>
@ -64,8 +70,11 @@ export const ConversationMultiSelector: React.FC<Props> = React.memo(
return (
<EuiComboBox
data-test-subj={TEST_IDS.CONVERSATIONS_MULTISELECTOR}
aria-label={i18n.SYSTEM_PROMPT_DEFAULT_CONVERSATIONS}
compressed
data-test-subj={TEST_IDS.CONVERSATIONS_MULTISELECTOR}
isDisabled={isDisabled}
fullWidth
options={options}
selectedOptions={selectedOptions}
onChange={onChange}

View file

@ -1,272 +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 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';
import { TEST_IDS } from '../../../constants';
const StyledEuiModal = styled(EuiModal)`
min-width: 400px;
max-width: 400px;
max-height: 80vh;
`;
interface Props {
systemPrompts: Prompt[];
onClose: (
event?: React.KeyboardEvent<HTMLDivElement> | React.MouseEvent<HTMLButtonElement>
) => void;
onSystemPromptsChange: (systemPrompts: Prompt[], newConversation?: Conversation[]) => void;
}
/**
* Modal for adding/removing system prompts. Configure name, prompt and default conversations.
*/
export const SystemPromptModal: React.FC<Props> = React.memo(
({ systemPrompts, onClose, onSystemPromptsChange }) => {
const { conversations } = useAssistantContext();
// Local state for quick prompts (returned to parent on save via onSystemPromptsChange())
const [updatedSystemPrompts, setUpdatedSystemPrompts] = useState<Prompt[]>(systemPrompts);
// Form options
const [selectedSystemPrompt, setSelectedSystemPrompt] = useState<Prompt>();
// Prompt
const [prompt, setPrompt] = useState('');
const handlePromptTextChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setPrompt(e.target.value);
}, []);
// Conversations this system prompt should be a default for
const [selectedConversations, setSelectedConversations] = useState<Conversation[]>([]);
const onConversationSelectionChange = useCallback(
(currentPromptConversations: Conversation[]) => {
setSelectedConversations(currentPromptConversations);
},
[]
);
/*
* updatedConversationWithPrompts calculates the present of prompt for
* each conversation. Based on the values of selected conversation, it goes
* through each conversation adds/removed the selected prompt on each conversation.
*
* */
const getUpdatedConversationWithPrompts = useCallback(() => {
const currentPromptConversationIds = selectedConversations.map((convo) => convo.id);
const allConversations = Object.values(conversations).map((convo) => ({
...convo,
apiConfig: {
...convo.apiConfig,
defaultSystemPromptId: currentPromptConversationIds.includes(convo.id)
? selectedSystemPrompt?.id
: convo.apiConfig.defaultSystemPromptId === selectedSystemPrompt?.id
? // remove the the default System Prompt if it is assigned to a conversation
// but that conversation is not in the currentPromptConversationList
// This means conversation was removed in the current transaction
undefined
: // leave it as it is .. if that conversation was neither added nor removed.
convo.apiConfig.defaultSystemPromptId,
},
}));
return allConversations;
}, [selectedSystemPrompt, conversations, selectedConversations]);
// Whether this system prompt should be the default for new conversations
const [isNewConversationDefault, setIsNewConversationDefault] = useState(false);
const handleNewConversationDefaultChange = useCallback(
(e) => {
const isChecked = e.target.checked;
setIsNewConversationDefault(isChecked);
if (selectedSystemPrompt != null) {
setUpdatedSystemPrompts((prev) => {
return prev.map((pp) => {
return {
...pp,
isNewConversationDefault: selectedSystemPrompt.id === pp.id && isChecked,
};
});
});
setSelectedSystemPrompt((prev) =>
prev != null ? { ...prev, isNewConversationDefault: isChecked } : 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
const currenlySelectedConversations =
newPrompt != null
? Object.values(conversations).filter(
(conversation) => conversation?.apiConfig.defaultSystemPromptId === newPrompt?.id
)
: [];
setSelectedConversations(currenlySelectedConversations);
},
[conversations]
);
const onSystemPromptDeleted = useCallback((id: string) => {
setUpdatedSystemPrompts((prev) => prev.filter((sp) => sp.id !== id));
}, []);
const handleSave = useCallback(() => {
const updatedConversations = getUpdatedConversationWithPrompts();
onSystemPromptsChange(updatedSystemPrompts, updatedConversations);
}, [onSystemPromptsChange, updatedSystemPrompts, getUpdatedConversationWithPrompts]);
// 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 (
<StyledEuiModal
onClose={onClose}
initialFocus={`.${SYSTEM_PROMPT_SELECTOR_CLASSNAME}`}
data-test-subj={TEST_IDS.SYSTEM_PROMPT_MODAL.ID}
>
<EuiModalHeader>
<EuiModalHeaderTitle>{i18n.ADD_SYSTEM_PROMPT_MODAL_TITLE}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiFormRow label={i18n.SYSTEM_PROMPT_NAME}>
<SystemPromptSelector
onSystemPromptDeleted={onSystemPromptDeleted}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
systemPrompts={updatedSystemPrompts}
selectedSystemPrompt={selectedSystemPrompt}
/>
</EuiFormRow>
<EuiFormRow label={i18n.SYSTEM_PROMPT_PROMPT}>
<EuiTextArea
data-test-subj={TEST_IDS.SYSTEM_PROMPT_MODAL.PROMPT_TEXT}
onChange={handlePromptTextChange}
value={prompt}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.SYSTEM_PROMPT_DEFAULT_CONVERSATIONS}
helpText={i18n.SYSTEM_PROMPT_DEFAULT_CONVERSATIONS_HELP_TEXT}
>
<ConversationMultiSelector
onConversationSelectionChange={onConversationSelectionChange}
conversations={Object.values(conversations)}
selectedConversations={selectedConversations}
/>
</EuiFormRow>
<EuiFormRow>
<EuiCheckbox
id={'defaultNewConversation'}
data-test-subj={TEST_IDS.SYSTEM_PROMPT_MODAL.TOGGLE_ALL_DEFAULT_CONVERSATIONS}
label={
<EuiFlexGroup alignItems="center" gutterSize={'xs'}>
<EuiFlexItem>{i18n.SYSTEM_PROMPT_DEFAULT_NEW_CONVERSATION}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIcon type={isNewConversationDefault ? 'starFilled' : 'starEmpty'} />
</EuiFlexItem>
</EuiFlexGroup>
}
checked={isNewConversationDefault}
onChange={handleNewConversationDefaultChange}
compressed
/>
</EuiFormRow>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onClose} data-test-subj={TEST_IDS.SYSTEM_PROMPT_MODAL.CANCEL}>
{i18n.CANCEL}
</EuiButtonEmpty>
<EuiButton
type="submit"
onClick={handleSave}
fill
data-test-subj={TEST_IDS.SYSTEM_PROMPT_MODAL.SAVE}
>
{i18n.SAVE}
</EuiButton>
</EuiModalFooter>
</StyledEuiModal>
);
}
);
SystemPromptModal.displayName = 'SystemPromptModal';

View file

@ -29,6 +29,7 @@ interface Props {
onSystemPromptDeleted: (systemPromptTitle: string) => void;
onSystemPromptSelectionChange: (systemPrompt?: Prompt | string) => void;
systemPrompts: Prompt[];
autoFocus?: boolean;
selectedSystemPrompt?: Prompt;
}
@ -42,6 +43,7 @@ export type SystemPromptSelectorOption = EuiComboBoxOptionOption<{
*/
export const SystemPromptSelector: React.FC<Props> = React.memo(
({
autoFocus = false,
systemPrompts,
onSystemPromptDeleted,
onSystemPromptSelectionChange,
@ -203,9 +205,11 @@ export const SystemPromptSelector: React.FC<Props> = React.memo(
return (
<EuiComboBox
className={SYSTEM_PROMPT_SELECTOR_CLASSNAME}
data-test-subj={TEST_IDS.SYSTEM_PROMPT_SELECTOR}
aria-label={i18n.SYSTEM_PROMPT_SELECTOR}
className={SYSTEM_PROMPT_SELECTOR_CLASSNAME}
compressed
data-test-subj={TEST_IDS.SYSTEM_PROMPT_SELECTOR}
fullWidth
placeholder={i18n.SYSTEM_PROMPT_SELECTOR}
customOptionText={`${i18n.CUSTOM_OPTION_TEXT} {searchValue}`}
singleSelection={{ asPlainText: true }}
@ -214,7 +218,7 @@ export const SystemPromptSelector: React.FC<Props> = React.memo(
onChange={onChange}
onCreateOption={onCreateOption}
renderOption={renderOption}
autoFocus
autoFocus={autoFocus}
/>
);
}

View file

@ -10,7 +10,7 @@ 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',
defaultMessage: 'Select or type to create new...',
}
);

View file

@ -0,0 +1,264 @@
/*
* 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 {
EuiFormRow,
EuiTextArea,
EuiCheckbox,
EuiIcon,
EuiFlexGroup,
EuiFlexItem,
EuiTitle,
EuiText,
EuiHorizontalRule,
EuiSpacer,
} from '@elastic/eui';
import { keyBy } from 'lodash/fp';
import { css } from '@emotion/react';
import { Conversation, Prompt } from '../../../../..';
import * as i18n from './translations';
import { UseAssistantContext } from '../../../../assistant_context';
import { ConversationMultiSelector } from './conversation_multi_selector/conversation_multi_selector';
import { SystemPromptSelector } from './system_prompt_selector/system_prompt_selector';
import { TEST_IDS } from '../../../constants';
interface Props {
conversationSettings: UseAssistantContext['conversations'];
onSelectedSystemPromptChange: (systemPrompt?: Prompt) => void;
selectedSystemPrompt: Prompt | undefined;
setUpdatedSystemPromptSettings: React.Dispatch<React.SetStateAction<Prompt[]>>;
setUpdatedConversationSettings: React.Dispatch<
React.SetStateAction<UseAssistantContext['conversations']>
>;
systemPromptSettings: Prompt[];
}
/**
* Settings for adding/removing system prompts. Configure name, prompt and default conversations.
*/
export const SystemPromptSettings: React.FC<Props> = React.memo(
({
conversationSettings,
onSelectedSystemPromptChange,
selectedSystemPrompt,
setUpdatedSystemPromptSettings,
setUpdatedConversationSettings,
systemPromptSettings,
}) => {
// Prompt
const promptContent = useMemo(
() => selectedSystemPrompt?.content ?? '',
[selectedSystemPrompt?.content]
);
const handlePromptContentChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (selectedSystemPrompt != null) {
setUpdatedSystemPromptSettings((prev): Prompt[] => {
const alreadyExists = prev.some((sp) => sp.id === selectedSystemPrompt.id);
if (alreadyExists) {
return prev.map((sp): Prompt => {
if (sp.id === selectedSystemPrompt.id) {
return {
...sp,
content: e.target.value,
};
}
return sp;
});
}
return prev;
});
}
},
[selectedSystemPrompt, setUpdatedSystemPromptSettings]
);
// Conversations this system prompt should be a default for
const conversationOptions = useMemo(
() => Object.values(conversationSettings),
[conversationSettings]
);
const selectedConversations = useMemo(() => {
return selectedSystemPrompt != null
? Object.values(conversationSettings).filter(
(conversation) =>
conversation.apiConfig.defaultSystemPromptId === selectedSystemPrompt.id
)
: [];
}, [conversationSettings, selectedSystemPrompt]);
const handleConversationSelectionChange = useCallback(
(currentPromptConversations: Conversation[]) => {
const currentPromptConversationIds = currentPromptConversations.map((convo) => convo.id);
if (selectedSystemPrompt != null) {
setUpdatedConversationSettings((prev) =>
keyBy(
'id',
/*
* updatedConversationWithPrompts calculates the present of prompt for
* each conversation. Based on the values of selected conversation, it goes
* through each conversation adds/removed the selected prompt on each conversation.
*
* */
Object.values(prev).map((convo) => ({
...convo,
apiConfig: {
...convo.apiConfig,
defaultSystemPromptId: currentPromptConversationIds.includes(convo.id)
? selectedSystemPrompt?.id
: convo.apiConfig.defaultSystemPromptId === selectedSystemPrompt?.id
? // remove the default System Prompt if it is assigned to a conversation
// but that conversation is not in the currentPromptConversationList
// This means conversation was removed in the current transaction
undefined
: // leave it as it is .. if that conversation was neither added nor removed.
convo.apiConfig.defaultSystemPromptId,
},
}))
)
);
}
},
[selectedSystemPrompt, setUpdatedConversationSettings]
);
// Whether this system prompt should be the default for new conversations
const isNewConversationDefault = useMemo(
() => selectedSystemPrompt?.isNewConversationDefault ?? false,
[selectedSystemPrompt?.isNewConversationDefault]
);
const handleNewConversationDefaultChange = useCallback(
(e) => {
const isChecked = e.target.checked;
if (selectedSystemPrompt != null) {
setUpdatedSystemPromptSettings((prev) => {
return prev.map((pp) => {
return {
...pp,
isNewConversationDefault: selectedSystemPrompt.id === pp.id && isChecked,
};
});
});
}
},
[selectedSystemPrompt, setUpdatedSystemPromptSettings]
);
// When top level system prompt selection changes
const onSystemPromptSelectionChange = useCallback(
(systemPrompt?: Prompt | string) => {
const isNew = typeof systemPrompt === 'string';
const newSelectedSystemPrompt: Prompt | undefined = isNew
? {
id: systemPrompt ?? '',
content: '',
name: systemPrompt ?? '',
promptType: 'system',
}
: systemPrompt;
if (newSelectedSystemPrompt != null) {
setUpdatedSystemPromptSettings((prev) => {
const alreadyExists = prev.some((sp) => sp.id === newSelectedSystemPrompt.id);
if (!alreadyExists) {
return [...prev, newSelectedSystemPrompt];
}
return prev;
});
}
onSelectedSystemPromptChange(newSelectedSystemPrompt);
},
[onSelectedSystemPromptChange, setUpdatedSystemPromptSettings]
);
const onSystemPromptDeleted = useCallback(
(id: string) => {
setUpdatedSystemPromptSettings((prev) => prev.filter((sp) => sp.id !== id));
},
[setUpdatedSystemPromptSettings]
);
return (
<>
<EuiTitle size={'s'}>
<h2>{i18n.SETTINGS_TITLE}</h2>
</EuiTitle>
<EuiSpacer size="xs" />
<EuiText size={'s'}>{i18n.SETTINGS_DESCRIPTION}</EuiText>
<EuiHorizontalRule margin={'s'} />
<EuiFormRow display="rowCompressed" label={i18n.SYSTEM_PROMPT_NAME} fullWidth>
<SystemPromptSelector
onSystemPromptDeleted={onSystemPromptDeleted}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
systemPrompts={systemPromptSettings}
selectedSystemPrompt={selectedSystemPrompt}
/>
</EuiFormRow>
<EuiFormRow display="rowCompressed" label={i18n.SYSTEM_PROMPT_PROMPT} fullWidth>
<EuiTextArea
data-test-subj={TEST_IDS.SYSTEM_PROMPT_MODAL.PROMPT_TEXT}
disabled={selectedSystemPrompt == null}
onChange={handlePromptContentChange}
value={promptContent}
compressed
fullWidth
css={css`
min-height: 150px;
`}
/>
</EuiFormRow>
<EuiFormRow
display="rowCompressed"
fullWidth
helpText={i18n.SYSTEM_PROMPT_DEFAULT_CONVERSATIONS_HELP_TEXT}
label={i18n.SYSTEM_PROMPT_DEFAULT_CONVERSATIONS}
>
<ConversationMultiSelector
conversations={conversationOptions}
isDisabled={selectedSystemPrompt == null}
onConversationSelectionChange={handleConversationSelectionChange}
selectedConversations={selectedConversations}
/>
</EuiFormRow>
<EuiFormRow display="rowCompressed">
<EuiCheckbox
data-test-subj={TEST_IDS.SYSTEM_PROMPT_MODAL.TOGGLE_ALL_DEFAULT_CONVERSATIONS}
disabled={selectedSystemPrompt == null}
id={'defaultNewConversation'}
label={
<EuiFlexGroup alignItems="center" gutterSize={'xs'}>
<EuiFlexItem>{i18n.SYSTEM_PROMPT_DEFAULT_NEW_CONVERSATION}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIcon type={isNewConversationDefault ? 'starFilled' : 'starEmpty'} />
</EuiFlexItem>
</EuiFlexGroup>
}
checked={isNewConversationDefault}
onChange={handleNewConversationDefaultChange}
compressed
/>
</EuiFormRow>
</>
);
}
);
SystemPromptSettings.displayName = 'SystemPromptSettings';

View file

@ -7,49 +7,56 @@
import { i18n } from '@kbn/i18n';
export const ADD_SYSTEM_PROMPT = i18n.translate(
'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.addSystemPromptTitle',
export const SETTINGS_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.settingsTitle',
{
defaultMessage: 'Add system prompt...',
defaultMessage: 'System Prompts',
}
);
export const SETTINGS_DESCRIPTION = i18n.translate(
'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.settingsDescription',
{
defaultMessage:
'Create and manage System Prompts. System Prompts are configurable chunks of context that are always sent for a given conversations.',
}
);
export const ADD_SYSTEM_PROMPT_MODAL_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.modalTitle',
'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.modalTitle',
{
defaultMessage: 'System Prompts',
}
);
export const SYSTEM_PROMPT_NAME = i18n.translate(
'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.nameLabel',
'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.nameLabel',
{
defaultMessage: 'Name',
}
);
export const SYSTEM_PROMPT_PROMPT = i18n.translate(
'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.promptLabel',
'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.promptLabel',
{
defaultMessage: 'Prompt',
}
);
export const SYSTEM_PROMPT_DEFAULT_CONVERSATIONS = i18n.translate(
'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.defaultConversationsLabel',
'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.defaultConversationsLabel',
{
defaultMessage: 'Default conversations',
}
);
export const SYSTEM_PROMPT_DEFAULT_NEW_CONVERSATION = i18n.translate(
'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.systemPromptModal.defaultNewConversationTitle',
'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.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',
'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.defaultConversationsHelpText',
{
defaultMessage: 'Conversations that should use this System Prompt by default',
}

View file

@ -1,229 +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 React, { useCallback, useEffect, useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiFormRow,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiColorPicker,
useColorPickerState,
EuiTextArea,
} from '@elastic/eui';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { EuiSetColorMethod } from '@elastic/eui/src/services/color_picker/color_picker';
import { PromptContextTemplate } from '../../../..';
import * as i18n from './translations';
import { QuickPrompt } from '../types';
import { QuickPromptSelector } from '../quick_prompt_selector/quick_prompt_selector';
import { PromptContextSelector } from '../prompt_context_selector/prompt_context_selector';
const StyledEuiModal = styled(EuiModal)`
min-width: 400px;
max-width: 400px;
max-height: 80vh;
`;
const DEFAULT_COLOR = '#D36086';
interface Props {
promptContexts: PromptContextTemplate[];
quickPrompts: QuickPrompt[];
onQuickPromptsChange: (quickPrompts: QuickPrompt[]) => void;
}
/**
* Modal for adding/removing quick prompts. Configure name, color, prompt and category.
*/
export const AddQuickPromptModal: React.FC<Props> = React.memo(
({ promptContexts, quickPrompts, onQuickPromptsChange }) => {
const [isModalVisible, setIsModalVisible] = useState(false);
// Local state for quick prompts (returned to parent on save via onQuickPromptsChange())
const [updatedQuickPrompts, setUpdatedQuickPrompts] = useState<QuickPrompt[]>(quickPrompts);
// Form options
const [selectedQuickPrompt, setSelectedQuickPrompt] = useState<QuickPrompt>();
// Prompt
const [prompt, setPrompt] = useState('');
const handlePromptTextChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setPrompt(e.target.value);
}, []);
// Color
const [color, setColor, errors] = useColorPickerState(DEFAULT_COLOR);
const handleColorChange = useCallback<EuiSetColorMethod>(
(text, { hex, isValid }) => {
if (selectedQuickPrompt != null) {
setSelectedQuickPrompt({
...selectedQuickPrompt,
color: text,
});
}
setColor(text, { hex, isValid });
},
[selectedQuickPrompt, setColor]
);
// Prompt Contexts/Categories
const [selectedPromptContexts, setSelectedPromptContexts] = useState<PromptContextTemplate[]>(
[]
);
const onPromptContextSelectionChange = useCallback((pc: PromptContextTemplate[]) => {
setSelectedPromptContexts(pc);
}, []);
// When top level quick prompt selection changes
const onQuickPromptSelectionChange = useCallback(
(quickPrompt?: QuickPrompt | string) => {
const newQuickPrompt: QuickPrompt | undefined =
typeof quickPrompt === 'string'
? {
title: quickPrompt ?? '',
prompt: '',
color: DEFAULT_COLOR,
categories: [],
}
: quickPrompt;
setSelectedQuickPrompt(newQuickPrompt);
setPrompt(newQuickPrompt?.prompt ?? '');
setColor(newQuickPrompt?.color ?? DEFAULT_COLOR, {
hex: newQuickPrompt?.color ?? DEFAULT_COLOR,
isValid: true,
});
// Map back to PromptContextTemplate's from QuickPrompt.categories
setSelectedPromptContexts(
promptContexts.filter((bpc) =>
newQuickPrompt?.categories?.some((cat) => bpc?.category === cat)
) ?? []
);
},
[promptContexts, setColor]
);
const onQuickPromptDeleted = useCallback((title: string) => {
setUpdatedQuickPrompts((prev) => prev.filter((qp) => qp.title !== title));
}, []);
// Modal control functions
const cleanupAndCloseModal = useCallback(() => {
setIsModalVisible(false);
}, []);
const handleCloseModal = useCallback(() => {
cleanupAndCloseModal();
}, [cleanupAndCloseModal]);
const handleSave = useCallback(() => {
onQuickPromptsChange(updatedQuickPrompts);
cleanupAndCloseModal();
}, [cleanupAndCloseModal, onQuickPromptsChange, updatedQuickPrompts]);
// useEffects
// Update quick prompts on any field change since editing is in place
useEffect(() => {
if (selectedQuickPrompt != null) {
setUpdatedQuickPrompts((prev) => {
const alreadyExists = prev.some((qp) => qp.title === selectedQuickPrompt.title);
if (alreadyExists) {
return prev.map((qp) => {
const categories = selectedPromptContexts.map((pc) => pc.category);
if (qp.title === selectedQuickPrompt.title) {
return {
...qp,
color,
prompt,
categories,
};
}
return qp;
});
} else {
return [
...prev,
{
...selectedQuickPrompt,
color,
prompt,
categories: selectedPromptContexts.map((pc) => pc.category),
},
];
}
});
}
}, [color, prompt, selectedPromptContexts, selectedQuickPrompt]);
// Reset local state on modal open
useEffect(() => {
if (isModalVisible) {
setUpdatedQuickPrompts(quickPrompts);
}
}, [isModalVisible, quickPrompts]);
return (
<>
<EuiButtonEmpty onClick={() => setIsModalVisible(true)} iconType="plus" size="xs">
{i18n.ADD_QUICK_PROMPT}
</EuiButtonEmpty>
{isModalVisible && (
<StyledEuiModal onClose={handleCloseModal} initialFocus=".quickPromptSelector">
<EuiModalHeader>
<EuiModalHeaderTitle>{i18n.ADD_QUICK_PROMPT_MODAL_TITLE}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiFormRow label={i18n.QUICK_PROMPT_NAME}>
<QuickPromptSelector
onQuickPromptDeleted={onQuickPromptDeleted}
onQuickPromptSelectionChange={onQuickPromptSelectionChange}
quickPrompts={updatedQuickPrompts}
selectedQuickPrompt={selectedQuickPrompt}
/>
</EuiFormRow>
<EuiFormRow label={i18n.QUICK_PROMPT_PROMPT} fullWidth>
<EuiTextArea onChange={handlePromptTextChange} value={prompt} />
</EuiFormRow>
<EuiFormRow label={i18n.QUICK_PROMPT_BADGE_COLOR} isInvalid={!!errors} error={errors}>
<EuiColorPicker onChange={handleColorChange} color={color} isInvalid={!!errors} />
</EuiFormRow>
<EuiFormRow
label={i18n.QUICK_PROMPT_CATEGORIES}
helpText={i18n.QUICK_PROMPT_CATEGORIES_HELP_TEXT}
>
<PromptContextSelector
onPromptContextSelectionChange={onPromptContextSelectionChange}
promptContexts={promptContexts}
selectedPromptContexts={selectedPromptContexts}
/>
</EuiFormRow>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={handleCloseModal}>{i18n.CANCEL}</EuiButtonEmpty>
<EuiButton type="submit" onClick={handleSave} fill>
{i18n.SAVE}
</EuiButton>
</EuiModalFooter>
</StyledEuiModal>
)}
</>
);
}
);
AddQuickPromptModal.displayName = 'AddQuickPromptModal';

View file

@ -12,6 +12,7 @@ import { PromptContextTemplate } from '../../../..';
import * as i18n from './translations';
interface Props {
isDisabled?: boolean;
onPromptContextSelectionChange: (promptContexts: PromptContextTemplate[]) => void;
promptContexts: PromptContextTemplate[];
selectedPromptContexts?: PromptContextTemplate[];
@ -23,7 +24,7 @@ export type PromptContextSelectorOption = EuiComboBoxOptionOption<{ category: st
* Selector for choosing multiple Prompt Context Categories
*/
export const PromptContextSelector: React.FC<Props> = React.memo(
({ onPromptContextSelectionChange, promptContexts, selectedPromptContexts = [] }) => {
({ isDisabled, onPromptContextSelectionChange, promptContexts, selectedPromptContexts = [] }) => {
// ComboBox options
const options = useMemo<PromptContextSelectorOption[]>(
() =>
@ -85,6 +86,9 @@ export const PromptContextSelector: React.FC<Props> = React.memo(
return (
<EuiComboBox
aria-label={i18n.PROMPT_CONTEXT_SELECTOR}
compressed
fullWidth
isDisabled={isDisabled}
placeholder={i18n.PROMPT_CONTEXT_SELECTOR_PLACEHOLDER}
options={options}
selectedOptions={selectedOptions}

View file

@ -22,6 +22,7 @@ import * as i18n from './translations';
import { QuickPrompt } from '../types';
interface Props {
isDisabled?: boolean;
onQuickPromptDeleted: (quickPromptTitle: string) => void;
onQuickPromptSelectionChange: (quickPrompt?: QuickPrompt | string) => void;
quickPrompts: QuickPrompt[];
@ -34,7 +35,13 @@ export type QuickPromptSelectorOption = EuiComboBoxOptionOption<{ isDefault: boo
* Selector for choosing and deleting Quick Prompts
*/
export const QuickPromptSelector: React.FC<Props> = React.memo(
({ quickPrompts, onQuickPromptDeleted, onQuickPromptSelectionChange, selectedQuickPrompt }) => {
({
isDisabled = false,
quickPrompts,
onQuickPromptDeleted,
onQuickPromptSelectionChange,
selectedQuickPrompt,
}) => {
// Form options
const [options, setOptions] = useState<QuickPromptSelectorOption[]>(
quickPrompts.map((qp) => ({
@ -169,6 +176,8 @@ export const QuickPromptSelector: React.FC<Props> = React.memo(
return (
<EuiComboBox
aria-label={i18n.QUICK_PROMPT_SELECTOR}
compressed
isDisabled={isDisabled}
placeholder={i18n.QUICK_PROMPT_SELECTOR}
customOptionText={`${i18n.CUSTOM_OPTION_TEXT} {searchValue}`}
singleSelection={true}
@ -177,6 +186,7 @@ export const QuickPromptSelector: React.FC<Props> = React.memo(
onChange={onChange}
onCreateOption={onCreateOption}
renderOption={renderOption}
fullWidth
/>
);
}

View file

@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n';
export const QUICK_PROMPT_SELECTOR = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.quickPromptSelector.ariaLabel',
{
defaultMessage: 'Select to edit, or type to create new',
defaultMessage: 'Select or type to create new...',
}
);

View file

@ -0,0 +1,235 @@
/*
* 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 {
EuiFormRow,
EuiColorPicker,
EuiTextArea,
EuiTitle,
EuiText,
EuiHorizontalRule,
EuiSpacer,
} from '@elastic/eui';
import { EuiSetColorMethod } from '@elastic/eui/src/services/color_picker/color_picker';
import { css } from '@emotion/react';
import { PromptContextTemplate } from '../../../..';
import * as i18n from './translations';
import { QuickPrompt } from '../types';
import { QuickPromptSelector } from '../quick_prompt_selector/quick_prompt_selector';
import { PromptContextSelector } from '../prompt_context_selector/prompt_context_selector';
import { useAssistantContext } from '../../../assistant_context';
const DEFAULT_COLOR = '#D36086';
interface Props {
onSelectedQuickPromptChange: (quickPrompt?: QuickPrompt) => void;
quickPromptSettings: QuickPrompt[];
selectedQuickPrompt: QuickPrompt | undefined;
setUpdatedQuickPromptSettings: React.Dispatch<React.SetStateAction<QuickPrompt[]>>;
}
/**
* Settings adding/removing quick prompts. Configure name, color, prompt and category.
*/
export const QuickPromptSettings: React.FC<Props> = React.memo<Props>(
({
onSelectedQuickPromptChange,
quickPromptSettings,
selectedQuickPrompt,
setUpdatedQuickPromptSettings,
}) => {
const { basePromptContexts } = useAssistantContext();
// Prompt
const prompt = useMemo(() => selectedQuickPrompt?.prompt ?? '', [selectedQuickPrompt?.prompt]);
const handlePromptChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (selectedQuickPrompt != null) {
setUpdatedQuickPromptSettings((prev) => {
const alreadyExists = prev.some((qp) => qp.title === selectedQuickPrompt.title);
if (alreadyExists) {
return prev.map((qp) => {
if (qp.title === selectedQuickPrompt.title) {
return {
...qp,
prompt: e.target.value,
};
}
return qp;
});
}
return prev;
});
}
},
[selectedQuickPrompt, setUpdatedQuickPromptSettings]
);
// Color
const selectedColor = useMemo(
() => selectedQuickPrompt?.color ?? DEFAULT_COLOR,
[selectedQuickPrompt?.color]
);
const handleColorChange = useCallback<EuiSetColorMethod>(
(color, { hex, isValid }) => {
if (selectedQuickPrompt != null) {
setUpdatedQuickPromptSettings((prev) => {
const alreadyExists = prev.some((qp) => qp.title === selectedQuickPrompt.title);
if (alreadyExists) {
return prev.map((qp) => {
if (qp.title === selectedQuickPrompt.title) {
return {
...qp,
color,
};
}
return qp;
});
}
return prev;
});
}
},
[selectedQuickPrompt, setUpdatedQuickPromptSettings]
);
// Prompt Contexts
const selectedPromptContexts = useMemo(
() =>
basePromptContexts.filter((bpc) =>
selectedQuickPrompt?.categories?.some((cat) => bpc?.category === cat)
) ?? [],
[basePromptContexts, selectedQuickPrompt?.categories]
);
const onPromptContextSelectionChange = useCallback(
(pc: PromptContextTemplate[]) => {
if (selectedQuickPrompt != null) {
setUpdatedQuickPromptSettings((prev) => {
const alreadyExists = prev.some((qp) => qp.title === selectedQuickPrompt.title);
if (alreadyExists) {
return prev.map((qp) => {
if (qp.title === selectedQuickPrompt.title) {
return {
...qp,
categories: pc.map((p) => p.category),
};
}
return qp;
});
}
return prev;
});
}
},
[selectedQuickPrompt, setUpdatedQuickPromptSettings]
);
// When top level quick prompt selection changes
const onQuickPromptSelectionChange = useCallback(
(quickPrompt?: QuickPrompt | string) => {
const isNew = typeof quickPrompt === 'string';
const newSelectedQuickPrompt: QuickPrompt | undefined = isNew
? {
title: quickPrompt ?? '',
prompt: '',
color: DEFAULT_COLOR,
categories: [],
}
: quickPrompt;
if (newSelectedQuickPrompt != null) {
setUpdatedQuickPromptSettings((prev) => {
const alreadyExists = prev.some((qp) => qp.title === newSelectedQuickPrompt.title);
if (!alreadyExists) {
return [...prev, newSelectedQuickPrompt];
}
return prev;
});
}
onSelectedQuickPromptChange(newSelectedQuickPrompt);
},
[onSelectedQuickPromptChange, setUpdatedQuickPromptSettings]
);
const onQuickPromptDeleted = useCallback(
(title: string) => {
setUpdatedQuickPromptSettings((prev) => prev.filter((qp) => qp.title !== title));
},
[setUpdatedQuickPromptSettings]
);
return (
<>
<EuiTitle size={'s'}>
<h2>{i18n.SETTINGS_TITLE}</h2>
</EuiTitle>
<EuiSpacer size="xs" />
<EuiText size={'s'}>{i18n.SETTINGS_DESCRIPTION}</EuiText>
<EuiHorizontalRule margin={'s'} />
<EuiFormRow label={i18n.QUICK_PROMPT_NAME} display="rowCompressed" fullWidth>
<QuickPromptSelector
onQuickPromptDeleted={onQuickPromptDeleted}
onQuickPromptSelectionChange={onQuickPromptSelectionChange}
quickPrompts={quickPromptSettings}
selectedQuickPrompt={selectedQuickPrompt}
/>
</EuiFormRow>
<EuiFormRow label={i18n.QUICK_PROMPT_PROMPT} display="rowCompressed" fullWidth>
<EuiTextArea
compressed
disabled={selectedQuickPrompt == null}
fullWidth
onChange={handlePromptChange}
value={prompt}
css={css`
min-height: 150px;
`}
/>
</EuiFormRow>
<EuiFormRow
display="rowCompressed"
fullWidth
label={i18n.QUICK_PROMPT_CONTEXTS}
helpText={i18n.QUICK_PROMPT_CONTEXTS_HELP_TEXT}
>
<PromptContextSelector
isDisabled={selectedQuickPrompt == null}
onPromptContextSelectionChange={onPromptContextSelectionChange}
promptContexts={basePromptContexts}
selectedPromptContexts={selectedPromptContexts}
/>
</EuiFormRow>
<EuiFormRow display="rowCompressed" label={i18n.QUICK_PROMPT_BADGE_COLOR}>
<EuiColorPicker
color={selectedColor}
compressed
disabled={selectedQuickPrompt == null}
onChange={handleColorChange}
/>
</EuiFormRow>
</>
);
}
);
QuickPromptSettings.displayName = 'AddQuickPromptModal';

View file

@ -7,49 +7,57 @@
import { i18n } from '@kbn/i18n';
export const ADD_QUICK_PROMPT = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.addQuickPromptTitle',
export const SETTINGS_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.settings.settingsTitle',
{
defaultMessage: 'Add quick prompt...',
defaultMessage: 'Quick Prompts',
}
);
export const SETTINGS_DESCRIPTION = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.settings.settingsDescription',
{
defaultMessage:
'Create and manage Quick Prompts. Quick Prompts are shortcuts to common actions.',
}
);
export const ADD_QUICK_PROMPT_MODAL_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.modalTitle',
'xpack.elasticAssistant.assistant.quickPrompts.settings.modalTitle',
{
defaultMessage: 'Quick Prompts',
}
);
export const QUICK_PROMPT_NAME = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.nameLabel',
'xpack.elasticAssistant.assistant.quickPrompts.settings.nameLabel',
{
defaultMessage: 'Name',
}
);
export const QUICK_PROMPT_PROMPT = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.promptLabel',
'xpack.elasticAssistant.assistant.quickPrompts.settings.promptLabel',
{
defaultMessage: 'Prompt',
}
);
export const QUICK_PROMPT_BADGE_COLOR = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.badgeColorLabel',
'xpack.elasticAssistant.assistant.quickPrompts.settings.badgeColorLabel',
{
defaultMessage: 'Badge color',
}
);
export const QUICK_PROMPT_CATEGORIES = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.categoriesLabel',
export const QUICK_PROMPT_CONTEXTS = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.settings.contextsLabel',
{
defaultMessage: 'Categories',
defaultMessage: 'Contexts',
}
);
export const QUICK_PROMPT_CATEGORIES_HELP_TEXT = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptModal.categoriesHelpText',
export const QUICK_PROMPT_CONTEXTS_HELP_TEXT = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.settings.contextsHelpText',
{
defaultMessage:
'Select the Prompt Contexts that this Quick Prompt will be available for. Selecting none will make this Quick Prompt available at all times.',

View file

@ -6,14 +6,13 @@
*/
import React, { useCallback, useMemo, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiPopover } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiPopover, EuiButtonEmpty } from '@elastic/eui';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { QuickPrompt } from '../../..';
import * as i18n from './translations';
import { AddQuickPromptModal } from './add_quick_prompt_modal/add_quick_prompt_modal';
import { useAssistantContext } from '../../assistant_context';
import { QUICK_PROMPTS_TAB } from '../settings/assistant_settings';
const QuickPromptsFlexGroup = styled(EuiFlexGroup)`
margin: 16px;
@ -30,7 +29,7 @@ interface QuickPromptsProps {
* and localstorage for storing new and edited prompts.
*/
export const QuickPrompts: React.FC<QuickPromptsProps> = React.memo(({ setInput }) => {
const { allQuickPrompts, basePromptContexts, promptContexts, setAllQuickPrompts } =
const { allQuickPrompts, promptContexts, setIsSettingsModalVisible, setSelectedSettingsTab } =
useAssistantContext();
const contextFilteredQuickPrompts = useMemo(() => {
@ -62,13 +61,12 @@ export const QuickPrompts: React.FC<QuickPromptsProps> = React.memo(({ setInput
},
[closeOverflowPopover, setInput]
);
// Callback for manage modal, saves to local storage on change
const onQuickPromptsChange = useCallback(
(newQuickPrompts: QuickPrompt[]) => {
setAllQuickPrompts(newQuickPrompts);
},
[setAllQuickPrompts]
);
const showQuickPromptSettings = useCallback(() => {
setIsSettingsModalVisible(true);
setSelectedSettingsTab(QUICK_PROMPTS_TAB);
}, [setIsSettingsModalVisible, setSelectedSettingsTab]);
return (
<QuickPromptsFlexGroup gutterSize="s" alignItems="center">
{contextFilteredQuickPrompts.slice(0, COUNT_BEFORE_OVERFLOW).map((badge, index) => (
@ -114,11 +112,9 @@ export const QuickPrompts: React.FC<QuickPromptsProps> = React.memo(({ setInput
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<AddQuickPromptModal
promptContexts={basePromptContexts}
quickPrompts={allQuickPrompts}
onQuickPromptsChange={onQuickPromptsChange}
/>
<EuiButtonEmpty onClick={showQuickPromptSettings} iconType="plus" size="xs">
{i18n.ADD_QUICK_PROMPT}
</EuiButtonEmpty>
</EuiFlexItem>
</QuickPromptsFlexGroup>
);

View file

@ -7,6 +7,12 @@
import { i18n } from '@kbn/i18n';
export const ADD_QUICK_PROMPT = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.addQuickPromptTitle',
{
defaultMessage: 'Add quick prompt...',
}
);
export const QUICK_PROMPT_OVERFLOW_ARIA = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.overflowAriaTitle',
{

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFormRow, EuiTitle, EuiText, EuiHorizontalRule, EuiSpacer } from '@elastic/eui';
import * as i18n from './translations';
interface Props {
onAdvancedSettingsChange?: () => void;
}
/**
* Advanced Settings -- your catch-all container for settings that don't have a home elsewhere.
*/
export const AdvancedSettings: React.FC<Props> = React.memo(({ onAdvancedSettingsChange }) => {
return (
<>
<EuiTitle size={'s'}>
<h2>{i18n.SETTINGS_TITLE}</h2>
</EuiTitle>
<EuiSpacer size="xs" />
<EuiText size={'s'}>{i18n.SETTINGS_DESCRIPTION}</EuiText>
<EuiHorizontalRule margin={'s'} />
<EuiFormRow display="rowCompressed" label={'Disable LocalStorage'}>
<>{'Disable LocalStorage'}</>
</EuiFormRow>
<EuiFormRow display="rowCompressed" label={'Clear LocalStorage'}>
<>{'Clear LocalStorage'}</>
</EuiFormRow>
<EuiFormRow display="rowCompressed" label={'Reset Something Else'}>
<>{'Reset Something Else'}</>
</EuiFormRow>
</>
);
});
AdvancedSettings.displayName = 'AdvancedSettings';

View file

@ -0,0 +1,21 @@
/*
* 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 SETTINGS_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.settings.advancedSettings.settingsTitle',
{
defaultMessage: 'Advanced Settings',
}
);
export const SETTINGS_DESCRIPTION = i18n.translate(
'xpack.elasticAssistant.assistant.settings.advancedSettings.settingsDescription',
{
defaultMessage: "They're not further along, they just have a different set of problems.",
}
);

View file

@ -0,0 +1,315 @@
/*
* 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,
EuiIcon,
EuiModal,
EuiModalFooter,
EuiKeyPadMenu,
EuiKeyPadMenuItem,
EuiPage,
EuiPageBody,
EuiPageSidebar,
EuiSplitPanel,
} from '@elastic/eui';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { css } from '@emotion/react';
import { Conversation, Prompt, QuickPrompt } from '../../..';
import * as i18n from './translations';
import { useAssistantContext } from '../../assistant_context';
import { AnonymizationSettings } from '../../data_anonymization/settings/anonymization_settings';
import { QuickPromptSettings } from '../quick_prompts/quick_prompt_settings/quick_prompt_settings';
import { SystemPromptSettings } from '../prompt_editor/system_prompt/system_prompt_modal/system_prompt_settings';
import { AdvancedSettings } from './advanced_settings/advanced_settings';
import { ConversationSettings } from '../conversations/conversation_settings/conversation_settings';
import { TEST_IDS } from '../constants';
import { useSettingsUpdater } from './use_settings_updater/use_settings_updater';
const StyledEuiModal = styled(EuiModal)`
width: 800px;
height: 575px;
`;
export const CONVERSATIONS_TAB = 'CONVERSATION_TAB' as const;
export const QUICK_PROMPTS_TAB = 'QUICK_PROMPTS_TAB' as const;
export const SYSTEM_PROMPTS_TAB = 'SYSTEM_PROMPTS_TAB' as const;
export const ANONYMIZATION_TAB = 'ANONYMIZATION_TAB' as const;
export const FUNCTIONS_TAB = 'FUNCTIONS_TAB' as const;
export const ADVANCED_TAB = 'ADVANCED_TAB' as const;
export type SettingsTabs =
| typeof CONVERSATIONS_TAB
| typeof QUICK_PROMPTS_TAB
| typeof SYSTEM_PROMPTS_TAB
| typeof ANONYMIZATION_TAB
| typeof FUNCTIONS_TAB
| typeof ADVANCED_TAB;
interface Props {
onClose: (
event?: React.KeyboardEvent<HTMLDivElement> | React.MouseEvent<HTMLButtonElement>
) => void;
onSave: () => void;
selectedConversation: Conversation;
setSelectedConversationId: React.Dispatch<React.SetStateAction<string>>;
}
/**
* Modal for overall Assistant Settings, including conversation settings, quick prompts, system prompts,
* anonymization, functions (coming soon!), and advanced settings.
*/
export const AssistantSettings: React.FC<Props> = React.memo(
({
onClose,
onSave,
selectedConversation: defaultSelectedConversation,
setSelectedConversationId,
}) => {
const { actionTypeRegistry, http, selectedSettingsTab, setSelectedSettingsTab } =
useAssistantContext();
const {
conversationSettings,
defaultAllow,
defaultAllowReplacement,
quickPromptSettings,
systemPromptSettings,
setUpdatedConversationSettings,
setUpdatedDefaultAllow,
setUpdatedDefaultAllowReplacement,
setUpdatedQuickPromptSettings,
setUpdatedSystemPromptSettings,
saveSettings,
} = useSettingsUpdater();
// Local state for saving previously selected items so tab switching is friendlier
// Conversation Selection State
const [selectedConversation, setSelectedConversation] = useState<Conversation | undefined>(
() => {
return conversationSettings[defaultSelectedConversation.id];
}
);
const onHandleSelectedConversationChange = useCallback((conversation?: Conversation) => {
setSelectedConversation(conversation);
}, []);
useEffect(() => {
if (selectedConversation != null) {
setSelectedConversation(conversationSettings[selectedConversation.id]);
}
}, [conversationSettings, selectedConversation]);
// Quick Prompt Selection State
const [selectedQuickPrompt, setSelectedQuickPrompt] = useState<QuickPrompt | undefined>();
const onHandleSelectedQuickPromptChange = useCallback((quickPrompt?: QuickPrompt) => {
setSelectedQuickPrompt(quickPrompt);
}, []);
useEffect(() => {
if (selectedQuickPrompt != null) {
setSelectedQuickPrompt(
quickPromptSettings.find((q) => q.title === selectedQuickPrompt.title)
);
}
}, [quickPromptSettings, selectedQuickPrompt]);
// System Prompt Selection State
const [selectedSystemPrompt, setSelectedSystemPrompt] = useState<Prompt | undefined>();
const onHandleSelectedSystemPromptChange = useCallback((systemPrompt?: Prompt) => {
setSelectedSystemPrompt(systemPrompt);
}, []);
useEffect(() => {
if (selectedSystemPrompt != null) {
setSelectedSystemPrompt(systemPromptSettings.find((p) => p.id === selectedSystemPrompt.id));
}
}, [selectedSystemPrompt, systemPromptSettings]);
const handleSave = useCallback(() => {
// If the selected conversation is deleted, we need to select a new conversation to prevent a crash creating a conversation that already exists
const isSelectedConversationDeleted =
conversationSettings[defaultSelectedConversation.id] == null;
const newSelectedConversationId: string | undefined = Object.keys(conversationSettings)[0];
if (isSelectedConversationDeleted && newSelectedConversationId != null) {
setSelectedConversationId(conversationSettings[newSelectedConversationId].id);
}
saveSettings();
onSave();
}, [
conversationSettings,
defaultSelectedConversation.id,
onSave,
saveSettings,
setSelectedConversationId,
]);
return (
<StyledEuiModal data-test-subj={TEST_IDS.SETTINGS_MODAL} onClose={onClose}>
<EuiPage paddingSize="none">
<EuiPageSidebar
paddingSize="xs"
css={css`
min-inline-size: unset !important;
max-width: 104px;
`}
>
<EuiKeyPadMenu>
<EuiKeyPadMenuItem
id={CONVERSATIONS_TAB}
label={i18n.CONVERSATIONS_MENU_ITEM}
isSelected={selectedSettingsTab === CONVERSATIONS_TAB}
onClick={() => setSelectedSettingsTab(CONVERSATIONS_TAB)}
>
<>
<EuiIcon
type="editorComment"
size="xl"
css={css`
position: relative;
top: -10px;
`}
/>
<EuiIcon
type="editorComment"
size="l"
css={css`
position: relative;
transform: rotateY(180deg);
top: -7px;
`}
/>
</>
</EuiKeyPadMenuItem>
<EuiKeyPadMenuItem
id={QUICK_PROMPTS_TAB}
label={i18n.QUICK_PROMPTS_MENU_ITEM}
isSelected={selectedSettingsTab === QUICK_PROMPTS_TAB}
onClick={() => setSelectedSettingsTab(QUICK_PROMPTS_TAB)}
>
<>
<EuiIcon type="editorComment" size="xxl" />
<EuiIcon
type="bolt"
size="s"
color="warning"
css={css`
position: absolute;
top: 11px;
left: 14px;
`}
/>
</>
</EuiKeyPadMenuItem>
<EuiKeyPadMenuItem
id={SYSTEM_PROMPTS_TAB}
label={i18n.SYSTEM_PROMPTS_MENU_ITEM}
isSelected={selectedSettingsTab === SYSTEM_PROMPTS_TAB}
onClick={() => setSelectedSettingsTab(SYSTEM_PROMPTS_TAB)}
>
<EuiIcon type="editorComment" size="xxl" />
<EuiIcon
type="storage"
size="s"
color="success"
css={css`
position: absolute;
top: 11px;
left: 14px;
`}
/>
</EuiKeyPadMenuItem>
<EuiKeyPadMenuItem
id={ANONYMIZATION_TAB}
label={i18n.ANONYMIZATION_MENU_ITEM}
isSelected={selectedSettingsTab === ANONYMIZATION_TAB}
onClick={() => setSelectedSettingsTab(ANONYMIZATION_TAB)}
>
<EuiIcon type="eyeClosed" size="l" />
</EuiKeyPadMenuItem>
</EuiKeyPadMenu>
</EuiPageSidebar>
<EuiPageBody paddingSize="none" panelled={true}>
<EuiSplitPanel.Outer grow={true}>
<EuiSplitPanel.Inner
className="eui-scrollBar"
grow={true}
css={css`
max-height: 550px;
overflow-y: scroll;
`}
>
{selectedSettingsTab === CONVERSATIONS_TAB && (
<ConversationSettings
conversationSettings={conversationSettings}
setUpdatedConversationSettings={setUpdatedConversationSettings}
allSystemPrompts={systemPromptSettings}
actionTypeRegistry={actionTypeRegistry}
selectedConversation={selectedConversation}
onSelectedConversationChange={onHandleSelectedConversationChange}
http={http}
/>
)}
{selectedSettingsTab === QUICK_PROMPTS_TAB && (
<QuickPromptSettings
quickPromptSettings={quickPromptSettings}
onSelectedQuickPromptChange={onHandleSelectedQuickPromptChange}
selectedQuickPrompt={selectedQuickPrompt}
setUpdatedQuickPromptSettings={setUpdatedQuickPromptSettings}
/>
)}
{selectedSettingsTab === SYSTEM_PROMPTS_TAB && (
<SystemPromptSettings
conversationSettings={conversationSettings}
systemPromptSettings={systemPromptSettings}
onSelectedSystemPromptChange={onHandleSelectedSystemPromptChange}
selectedSystemPrompt={selectedSystemPrompt}
setUpdatedConversationSettings={setUpdatedConversationSettings}
setUpdatedSystemPromptSettings={setUpdatedSystemPromptSettings}
/>
)}
{selectedSettingsTab === ANONYMIZATION_TAB && (
<AnonymizationSettings
defaultAllow={defaultAllow}
defaultAllowReplacement={defaultAllowReplacement}
pageSize={5}
setUpdatedDefaultAllow={setUpdatedDefaultAllow}
setUpdatedDefaultAllowReplacement={setUpdatedDefaultAllowReplacement}
/>
)}
{selectedSettingsTab === FUNCTIONS_TAB && <></>}
{selectedSettingsTab === ADVANCED_TAB && <AdvancedSettings />}
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner
grow={false}
color="subdued"
css={css`
padding: 8px;
`}
>
<EuiModalFooter
css={css`
padding: 4px;
`}
>
<EuiButtonEmpty size="s" onClick={onClose}>
{i18n.CANCEL}
</EuiButtonEmpty>
<EuiButton size="s" type="submit" onClick={handleSave} fill>
{i18n.SAVE}
</EuiButton>
</EuiModalFooter>
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
</EuiPageBody>
</EuiPage>
</StyledEuiModal>
);
}
);
AssistantSettings.displayName = 'AssistantSettings';

View file

@ -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 } from 'react';
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { Conversation } from '../../..';
import { AssistantSettings, CONVERSATIONS_TAB } from './assistant_settings';
import * as i18n from './translations';
import { useAssistantContext } from '../../assistant_context';
interface Props {
selectedConversation: Conversation;
setSelectedConversationId: React.Dispatch<React.SetStateAction<string>>;
isDisabled?: boolean;
}
/**
* Gear button that opens the assistant settings modal
*/
export const AssistantSettingsButton: React.FC<Props> = React.memo(
({ isDisabled = false, selectedConversation, setSelectedConversationId }) => {
const { isSettingsModalVisible, setIsSettingsModalVisible, setSelectedSettingsTab } =
useAssistantContext();
// Modal control functions
const cleanupAndCloseModal = useCallback(() => {
setIsSettingsModalVisible(false);
}, [setIsSettingsModalVisible]);
const handleCloseModal = useCallback(() => {
cleanupAndCloseModal();
}, [cleanupAndCloseModal]);
const handleSave = useCallback(() => {
cleanupAndCloseModal();
}, [cleanupAndCloseModal]);
const handleShowConversationSettings = useCallback(() => {
setSelectedSettingsTab(CONVERSATIONS_TAB);
setIsSettingsModalVisible(true);
}, [setIsSettingsModalVisible, setSelectedSettingsTab]);
return (
<>
<EuiToolTip position="right" content={i18n.SETTINGS_TOOLTIP}>
<EuiButtonIcon
aria-label={i18n.SETTINGS}
data-test-subj="settings"
onClick={handleShowConversationSettings}
isDisabled={isDisabled}
iconType="gear"
size="xs"
/>
</EuiToolTip>
{isSettingsModalVisible && (
<AssistantSettings
selectedConversation={selectedConversation}
setSelectedConversationId={setSelectedConversationId}
onClose={handleCloseModal}
onSave={handleSave}
/>
)}
</>
);
}
);
AssistantSettingsButton.displayName = 'AssistantSettingsButton';

View file

@ -0,0 +1,85 @@
/*
* 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 SETTINGS = i18n.translate(
'xpack.elasticAssistant.assistant.settings.settingsAriaLabel',
{
defaultMessage: 'Settings',
}
);
export const SETTINGS_TOOLTIP = i18n.translate(
'xpack.elasticAssistant.assistant.settings.settingsTooltip',
{
defaultMessage: 'Settings',
}
);
export const CONVERSATIONS_MENU_ITEM = i18n.translate(
'xpack.elasticAssistant.assistant.settings.settingsConversationsMenuItemTitle',
{
defaultMessage: 'Conversations',
}
);
export const QUICK_PROMPTS_MENU_ITEM = i18n.translate(
'xpack.elasticAssistant.assistant.settings.settingsQuickPromptsMenuItemTitle',
{
defaultMessage: 'Quick Prompts',
}
);
export const SYSTEM_PROMPTS_MENU_ITEM = i18n.translate(
'xpack.elasticAssistant.assistant.settings.settingsSystemPromptsMenuItemTitle',
{
defaultMessage: 'System Prompts',
}
);
export const ANONYMIZATION_MENU_ITEM = i18n.translate(
'xpack.elasticAssistant.assistant.settings.settingsAnonymizationMenuItemTitle',
{
defaultMessage: 'Anonymization',
}
);
export const FUNCTIONS_MENU_ITEM = i18n.translate(
'xpack.elasticAssistant.assistant.settings.settingsFunctionsMenuItemTitle',
{
defaultMessage: 'Functions',
}
);
export const ADVANCED_MENU_ITEM = i18n.translate(
'xpack.elasticAssistant.assistant.settings.settingsAdvancedMenuItemTitle',
{
defaultMessage: 'Advanced',
}
);
export const ADD_SYSTEM_PROMPT_MODAL_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.settings.modalTitle',
{
defaultMessage: 'System Prompts',
}
);
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',
}
);

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useState } from 'react';
import { Prompt, QuickPrompt } from '../../../..';
import { UseAssistantContext, useAssistantContext } from '../../../assistant_context';
interface UseSettingsUpdater {
conversationSettings: UseAssistantContext['conversations'];
defaultAllow: string[];
defaultAllowReplacement: string[];
quickPromptSettings: QuickPrompt[];
resetSettings: () => void;
systemPromptSettings: Prompt[];
setUpdatedDefaultAllow: React.Dispatch<React.SetStateAction<string[]>>;
setUpdatedDefaultAllowReplacement: React.Dispatch<React.SetStateAction<string[]>>;
setUpdatedConversationSettings: React.Dispatch<
React.SetStateAction<UseAssistantContext['conversations']>
>;
setUpdatedQuickPromptSettings: React.Dispatch<React.SetStateAction<QuickPrompt[]>>;
setUpdatedSystemPromptSettings: React.Dispatch<React.SetStateAction<Prompt[]>>;
saveSettings: () => void;
}
export const useSettingsUpdater = (): UseSettingsUpdater => {
// Initial state from assistant context
const {
allQuickPrompts,
allSystemPrompts,
conversations,
defaultAllow,
defaultAllowReplacement,
setAllQuickPrompts,
setAllSystemPrompts,
setConversations,
setDefaultAllow,
setDefaultAllowReplacement,
} = useAssistantContext();
/**
* Pending updating state
*/
// Conversations
const [updatedConversationSettings, setUpdatedConversationSettings] =
useState<UseAssistantContext['conversations']>(conversations);
// Quick Prompts
const [updatedQuickPromptSettings, setUpdatedQuickPromptSettings] =
useState<QuickPrompt[]>(allQuickPrompts);
// System Prompts
const [updatedSystemPromptSettings, setUpdatedSystemPromptSettings] =
useState<Prompt[]>(allSystemPrompts);
// Anonymization
const [updatedDefaultAllow, setUpdatedDefaultAllow] = useState<string[]>(defaultAllow);
const [updatedDefaultAllowReplacement, setUpdatedDefaultAllowReplacement] =
useState<string[]>(defaultAllowReplacement);
/**
* Reset all pending settings
*/
const resetSettings = useCallback((): void => {
setUpdatedConversationSettings(conversations);
setUpdatedQuickPromptSettings(allQuickPrompts);
setUpdatedSystemPromptSettings(allSystemPrompts);
setUpdatedDefaultAllow(defaultAllow);
setUpdatedDefaultAllowReplacement(defaultAllowReplacement);
}, [allQuickPrompts, allSystemPrompts, conversations, defaultAllow, defaultAllowReplacement]);
/**
* Save all pending settings
*/
const saveSettings = useCallback((): void => {
setAllQuickPrompts(updatedQuickPromptSettings);
setAllSystemPrompts(updatedSystemPromptSettings);
setConversations(updatedConversationSettings);
setDefaultAllow(updatedDefaultAllow);
setDefaultAllowReplacement(updatedDefaultAllowReplacement);
}, [
setAllQuickPrompts,
setAllSystemPrompts,
setConversations,
setDefaultAllow,
setDefaultAllowReplacement,
updatedConversationSettings,
updatedDefaultAllow,
updatedDefaultAllowReplacement,
updatedQuickPromptSettings,
updatedSystemPromptSettings,
]);
return {
conversationSettings: updatedConversationSettings,
defaultAllow: updatedDefaultAllow,
defaultAllowReplacement: updatedDefaultAllowReplacement,
quickPromptSettings: updatedQuickPromptSettings,
resetSettings,
systemPromptSettings: updatedSystemPromptSettings,
saveSettings,
setUpdatedDefaultAllow,
setUpdatedDefaultAllowReplacement,
setUpdatedConversationSettings,
setUpdatedQuickPromptSettings,
setUpdatedSystemPromptSettings,
};
};

View file

@ -17,8 +17,10 @@ export interface StreamingTextProps {
export const StreamingText: React.FC<StreamingTextProps> = React.memo<StreamingTextProps>(
({ text, children, chunkSize = 5, delay = 100, onStreamingComplete }) => {
const [displayText, setDisplayText] = useState<string>(delay === 0 ? text : '');
const [isStreamingComplete, setIsStreamingComplete] = useState<boolean>(delay === 0);
const [displayText, setDisplayText] = useState<string>(delay > 0 ? '' : text);
const [isStreamingComplete, setIsStreamingComplete] = useState<boolean>(
delay == null || delay === 0
);
useEffect(() => {
if (delay === 0) {
@ -30,6 +32,7 @@ export const StreamingText: React.FC<StreamingTextProps> = React.memo<StreamingT
useEffect(() => {
if (isStreamingComplete || delay === 0) {
setDisplayText(text);
return;
}

View file

@ -18,46 +18,6 @@ export const DEFAULT_ASSISTANT_TITLE = i18n.translate(
}
);
export const MISSING_CONNECTOR_CALLOUT_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.missingConnectorCalloutTitle',
{
defaultMessage: 'The current conversation is missing a connector configuration',
}
);
export const MISSING_CONNECTOR_CALLOUT_DESCRIPTION = i18n.translate(
'xpack.elasticAssistant.assistant.missingConnectorCalloutDescription',
{
defaultMessage: 'Select a connector from the conversation settings to continue',
}
);
// Settings
export const SETTINGS_TITLE = i18n.translate('xpack.elasticAssistant.assistant.settingsTitle', {
defaultMessage: 'Conversation settings',
});
export const SETTINGS_CONNECTOR_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.settings.connectorTitle',
{
defaultMessage: 'Connector',
}
);
export const 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 SHOW_ANONYMIZED = i18n.translate(
'xpack.elasticAssistant.assistant.settings.showAnonymizedToggleLabel',
{

View file

@ -126,11 +126,6 @@ export const BASE_CONVERSATIONS: Record<string, Conversation> = {
stream: true,
},
},
// {
// role: 'assistant',
// content: i18n.WELCOME_NO_CONNECTOR_PRIVILEGES,
// timestamp: '',
// },
],
apiConfig: {},
},

View file

@ -32,6 +32,7 @@ import {
QUICK_PROMPT_LOCAL_STORAGE_KEY,
SYSTEM_PROMPT_LOCAL_STORAGE_KEY,
} from './constants';
import { CONVERSATIONS_TAB, SettingsTabs } from '../assistant/settings/assistant_settings';
export interface ShowAssistantOverlayProps {
showOverlay: boolean;
@ -74,7 +75,7 @@ interface AssistantProviderProps {
title?: string;
}
interface UseAssistantContext {
export interface UseAssistantContext {
actionTypeRegistry: ActionTypeRegistryContract;
augmentMessageCodeBlocks: (currentConversation: Conversation) => CodeBlockDetails[][];
allQuickPrompts: QuickPrompt[];
@ -100,16 +101,20 @@ interface UseAssistantContext {
showAnonymizedValues: boolean;
}) => EuiCommentProps[];
http: HttpSetup;
isSettingsModalVisible: boolean;
localStorageLastConversationId: string | undefined;
promptContexts: Record<string, PromptContext>;
nameSpace: string;
registerPromptContext: RegisterPromptContext;
selectedSettingsTab: SettingsTabs;
setAllQuickPrompts: React.Dispatch<React.SetStateAction<QuickPrompt[] | undefined>>;
setAllSystemPrompts: React.Dispatch<React.SetStateAction<Prompt[] | undefined>>;
setConversations: React.Dispatch<React.SetStateAction<Record<string, Conversation>>>;
setDefaultAllow: React.Dispatch<React.SetStateAction<string[]>>;
setDefaultAllowReplacement: React.Dispatch<React.SetStateAction<string[]>>;
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
setLastConversationId: React.Dispatch<React.SetStateAction<string | undefined>>;
setSelectedSettingsTab: React.Dispatch<React.SetStateAction<SettingsTabs>>;
setShowAssistantOverlay: (showAssistantOverlay: ShowAssistantOverlay) => void;
showAssistantOverlay: ShowAssistantOverlay;
title: string;
@ -204,6 +209,12 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
(showAssistant) => {}
);
/**
* Settings State
*/
const [isSettingsModalVisible, setIsSettingsModalVisible] = useState(false);
const [selectedSettingsTab, setSelectedSettingsTab] = useState<SettingsTabs>(CONVERSATIONS_TAB);
const [conversations, setConversationsInternal] = useState(getInitialConversations());
const conversationIds = useMemo(() => Object.keys(conversations).sort(), [conversations]);
@ -253,14 +264,18 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
docLinks,
getComments,
http,
isSettingsModalVisible,
promptContexts,
nameSpace,
registerPromptContext,
selectedSettingsTab,
setAllQuickPrompts: setLocalStorageQuickPrompts,
setAllSystemPrompts: setLocalStorageSystemPrompts,
setConversations: onConversationsUpdated,
setDefaultAllow,
setDefaultAllowReplacement,
setIsSettingsModalVisible,
setSelectedSettingsTab,
setShowAssistantOverlay,
showAssistantOverlay,
title,
@ -283,6 +298,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
docLinks,
getComments,
http,
isSettingsModalVisible,
localStorageLastConversationId,
localStorageQuickPrompts,
localStorageSystemPrompts,
@ -290,11 +306,14 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
onConversationsUpdated,
promptContexts,
registerPromptContext,
selectedSettingsTab,
setDefaultAllow,
setDefaultAllowReplacement,
setIsSettingsModalVisible,
setLocalStorageLastConversationId,
setLocalStorageQuickPrompts,
setLocalStorageSystemPrompts,
setSelectedSettingsTab,
showAssistantOverlay,
title,
unRegisterPromptContext,

View file

@ -47,6 +47,7 @@ export interface Conversation {
connectorId?: string;
defaultSystemPromptId?: string;
provider?: OpenAiProviderType;
model?: string;
};
id: string;
messages: Message[];

View file

@ -13,7 +13,6 @@ import * as i18n from '../translations';
export interface ConnectorButtonProps {
setIsConnectorModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
connectorAdded?: boolean;
}
/**
@ -22,18 +21,15 @@ export interface ConnectorButtonProps {
* connector add logic.
*/
export const ConnectorButton: React.FC<ConnectorButtonProps> = React.memo<ConnectorButtonProps>(
({ setIsConnectorModalVisible, connectorAdded = false }) => {
({ setIsConnectorModalVisible }) => {
return (
<EuiFlexGroup gutterSize="l" justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiCard
layout="horizontal"
icon={<EuiIcon size="xl" type={GenAiLogo} />}
title={connectorAdded ? i18n.CONNECTOR_ADDED_TITLE : i18n.ADD_CONNECTOR_TITLE}
isDisabled={connectorAdded}
description={
connectorAdded ? i18n.CONNECTOR_ADDED_DESCRIPTION : i18n.ADD_CONNECTOR_DESCRIPTION
}
title={i18n.ADD_CONNECTOR_TITLE}
description={i18n.ADD_CONNECTOR_DESCRIPTION}
onClick={() => setIsConnectorModalVisible(true)}
/>
</EuiFlexItem>

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback } from 'react';
import { EuiCallOut, EuiLink } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import * as i18n from '../translations';
import { useAssistantContext } from '../../assistant_context';
import { CONVERSATIONS_TAB } from '../../assistant/settings/assistant_settings';
/**
* Error callout to be displayed when there is no connector configured for a conversation. Includes deep-link
* to conversation settings to quickly resolve.
*
* 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();
const onConversationSettingsClicked = useCallback(() => {
if (!isSettingsModalVisible) {
setIsSettingsModalVisible(true);
setSelectedSettingsTab(CONVERSATIONS_TAB);
}
}, [isSettingsModalVisible, setIsSettingsModalVisible, setSelectedSettingsTab]);
return (
<EuiCallOut
color="danger"
iconType="controlsVertical"
size="m"
title={i18n.MISSING_CONNECTOR_CALLOUT_TITLE}
>
<p>
{' '}
<FormattedMessage
defaultMessage="Select a connector from the {link} to continue"
id="xpack.elasticAssistant.assistant.connectors.connectorMissingCallout.calloutDescription"
values={{
link: (
<EuiLink onClick={onConversationSettingsClicked}>
{i18n.MISSING_CONNECTOR_CONVERSATION_SETTINGS_LINK}
</EuiLink>
),
}}
/>
</p>
</EuiCallOut>
);
});
ConnectorMissingCallout.displayName = 'ConnectorMissingCallout';

View file

@ -20,17 +20,17 @@ import {
GEN_AI_CONNECTOR_ID,
OpenAiProviderType,
} from '@kbn/stack-connectors-plugin/public/common';
import { Conversation } from '../../assistant_context/types';
import { useLoadConnectors } from '../use_load_connectors';
import { useConversation } from '../../assistant/use_conversation';
import * as i18n from '../translations';
import { useLoadActionTypes } from '../use_load_action_types';
export const ADD_NEW_CONNECTOR = 'ADD_NEW_CONNECTOR';
interface Props {
actionTypeRegistry: ActionTypeRegistryContract;
conversation: Conversation;
http: HttpSetup;
isDisabled?: boolean;
onConnectorSelectionChange: (connectorId: string, provider: OpenAiProviderType) => void;
selectedConnectorId?: string;
onConnectorModalVisibilityChange?: (isVisible: boolean) => void;
}
@ -39,9 +39,14 @@ interface Config {
}
export const ConnectorSelector: React.FC<Props> = React.memo(
({ actionTypeRegistry, conversation, http, onConnectorModalVisibilityChange }) => {
const { setApiConfig } = useConversation();
({
actionTypeRegistry,
http,
isDisabled = false,
onConnectorModalVisibilityChange,
selectedConnectorId,
onConnectorSelectionChange,
}) => {
// Connector Modal State
const [isConnectorModalVisible, setIsConnectorModalVisible] = useState<boolean>(false);
const { data: actionTypes } = useLoadActionTypes({ http });
@ -124,49 +129,33 @@ export const ConnectorSelector: React.FC<Props> = React.memo(
const apiProvider = (
connectors?.find((c) => c.id === connectorId) as ActionConnectorProps<Config, unknown>
)?.config.apiProvider as OpenAiProviderType;
setApiConfig({
conversationId: conversation.id,
apiConfig: {
...conversation.apiConfig,
connectorId,
provider: apiProvider,
},
});
onConnectorSelectionChange(connectorId, apiProvider);
},
[
connectors,
conversation.apiConfig,
conversation.id,
setApiConfig,
onConnectorModalVisibilityChange,
]
[connectors, onConnectorSelectionChange, onConnectorModalVisibilityChange]
);
return (
<>
<EuiSuperSelect
options={[...connectorOptions, addNewConnectorOption]}
valueOfSelected={conversation.apiConfig.connectorId ?? ''}
hasDividers={true}
onChange={onChange}
compressed={true}
isLoading={isLoading}
aria-label={i18n.CONNECTOR_SELECTOR_TITLE}
compressed={true}
disabled={isDisabled}
hasDividers={true}
isLoading={isLoading}
onChange={onChange}
options={[...connectorOptions, addNewConnectorOption]}
valueOfSelected={selectedConnectorId ?? ''}
/>
{isConnectorModalVisible && (
<ConnectorAddModal
actionType={actionType}
onClose={cleanupAndCloseModal}
postSaveEventHandler={(savedAction: ActionConnector) => {
setApiConfig({
conversationId: conversation.id,
apiConfig: {
...conversation.apiConfig,
connectorId: savedAction.id,
provider: (savedAction as ActionConnectorProps<Config, unknown>)?.config
.apiProvider as OpenAiProviderType,
},
});
onConnectorSelectionChange(
savedAction.id,
(savedAction as ActionConnectorProps<Config, unknown>)?.config
.apiProvider as OpenAiProviderType
);
refetchConnectors?.();
cleanupAndCloseModal();
}}

View file

@ -30,10 +30,8 @@ import * as i18n from '../translations';
import { useAssistantContext } from '../../assistant_context';
import { WELCOME_CONVERSATION_TITLE } from '../../assistant/use_conversation/translations';
const MESSAGE_INDEX_BEFORE_CONNECTOR = 2;
const ConnectorButtonWrapper = styled.div`
margin-top: 20px;
margin-bottom: 10px;
`;
const SkipEuiText = styled(EuiText)`
@ -91,24 +89,40 @@ export const useConnectorSetup = ({
);
// User constants
const userName = conversation.theme?.user?.name ?? i18n.CONNECTOR_SETUP_USER_YOU;
const assistantName = conversation.theme?.assistant?.name ?? i18n.CONNECTOR_SETUP_USER_ASSISTANT;
const userName = useMemo(
() => conversation.theme?.user?.name ?? i18n.CONNECTOR_SETUP_USER_YOU,
[conversation.theme?.user?.name]
);
const assistantName = useMemo(
() => conversation.theme?.assistant?.name ?? i18n.CONNECTOR_SETUP_USER_ASSISTANT,
[conversation.theme?.assistant?.name]
);
const lastConversationMessageIndex = useMemo(
() => conversation.messages.length - 1,
[conversation.messages.length]
);
const [currentMessageIndex, setCurrentMessageIndex] = useState(
// If connector is configured or conversation has already been replayed show all messages immediately
isConnectorConfigured || conversationHasNoPresentationData(conversation)
? MESSAGE_INDEX_BEFORE_CONNECTOR
? lastConversationMessageIndex
: 0
);
const streamingTimeoutRef = useRef<number | undefined>(undefined);
// Once streaming of previous message is complete, proceed to next message
const onHandleMessageStreamingComplete = useCallback(() => {
const timeoutId = setTimeout(() => {
if (currentMessageIndex === lastConversationMessageIndex) {
clearTimeout(streamingTimeoutRef.current);
return;
}
streamingTimeoutRef.current = window.setTimeout(() => {
bottomRef.current?.scrollIntoView({ block: 'end' });
return setCurrentMessageIndex(currentMessageIndex + 1);
}, conversation.messages[currentMessageIndex]?.presentation?.delay ?? 0);
return () => clearTimeout(timeoutId);
}, [conversation.messages, currentMessageIndex]);
return () => clearTimeout(streamingTimeoutRef.current);
}, [conversation.messages, currentMessageIndex, lastConversationMessageIndex]);
// Show button to add connector after last message has finished streaming
const onHandleLastMessageStreamingComplete = useCallback(() => {
@ -120,8 +134,8 @@ export const useConnectorSetup = ({
// Show button to add connector after last message has finished streaming
const handleSkipSetup = useCallback(() => {
setCurrentMessageIndex(MESSAGE_INDEX_BEFORE_CONNECTOR);
}, [setCurrentMessageIndex]);
setCurrentMessageIndex(lastConversationMessageIndex);
}, [lastConversationMessageIndex]);
// Create EuiCommentProps[] from conversation messages
const commentBody = useCallback(
@ -195,12 +209,9 @@ export const useConnectorSetup = ({
comments,
prompt: (
<div data-test-subj="prompt">
{(showAddConnectorButton || isConnectorConfigured) && (
{showAddConnectorButton && (
<ConnectorButtonWrapper>
<ConnectorButton
setIsConnectorModalVisible={setIsConnectorModalVisible}
connectorAdded={isConnectorConfigured}
/>
<ConnectorButton setIsConnectorModalVisible={setIsConnectorModalVisible} />
</ConnectorButtonWrapper>
)}
{!showAddConnectorButton && (

View file

@ -0,0 +1,109 @@
/*
* 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 { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import * as i18n from './translations';
export const MODEL_GPT_3_5_TURBO = 'gpt-3.5-turbo';
export const MODEL_GPT_4 = 'gpt-4';
const DEFAULT_MODELS = [MODEL_GPT_3_5_TURBO, MODEL_GPT_4];
interface Props {
onModelSelectionChange?: (model?: string) => void;
models?: string[];
selectedModel?: string;
}
/**
* Selector for choosing and deleting models
*
* TODO: Pull from API once connector supports it `GET https://api.openai.com/v1/models` as models are added/deprecated
*/
export const ModelSelector: React.FC<Props> = React.memo(
({ models = DEFAULT_MODELS, onModelSelectionChange, selectedModel = DEFAULT_MODELS[0] }) => {
// Form options
const [options, setOptions] = useState<EuiComboBoxOptionOption[]>(
models.map((model) => ({
label: model,
}))
);
const selectedOptions = useMemo<EuiComboBoxOptionOption[]>(() => {
return selectedModel ? [{ label: selectedModel }] : [];
}, [selectedModel]);
const handleSelectionChange = useCallback(
(modelSelectorOption: EuiComboBoxOptionOption[]) => {
const newModel =
modelSelectorOption.length === 0
? undefined
: models.find((model) => model === modelSelectorOption[0]?.label) ??
modelSelectorOption[0]?.label;
onModelSelectionChange?.(newModel);
},
[onModelSelectionChange, models]
);
// Callback for when user types to create a new model
const onCreateOption = useCallback(
(searchValue, flattenedOptions = []) => {
if (!searchValue || !searchValue.trim().toLowerCase()) {
return;
}
const normalizedSearchValue = searchValue.trim().toLowerCase();
const optionExists =
flattenedOptions.findIndex(
(option: EuiComboBoxOptionOption) =>
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 model
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 (
<EuiComboBox
aria-label={i18n.HELP_LABEL}
compressed
isClearable={false}
placeholder={i18n.PLACEHOLDER_TEXT}
customOptionText={`${i18n.CUSTOM_OPTION_TEXT} {searchValue}`}
singleSelection={{ asPlainText: true }}
options={options}
selectedOptions={selectedOptions}
onChange={onChange}
onCreateOption={onCreateOption}
fullWidth
/>
);
}
);
ModelSelector.displayName = 'ModelSelector';

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const PLACEHOLDER_TEXT = i18n.translate(
'xpack.elasticAssistant.connectors.models.modelSelector.placeholderText',
{
defaultMessage: 'Select or type to create new...',
}
);
export const MODEL_TITLE = i18n.translate(
'xpack.elasticAssistant.connectors.models.modelSelector.modelTitle',
{
defaultMessage: 'Model',
}
);
export const HELP_LABEL = i18n.translate(
'xpack.elasticAssistant.connectors.models.modelSelector.helpLabel',
{
defaultMessage: 'Model to use for this connector',
}
);
export const CUSTOM_OPTION_TEXT = i18n.translate(
'xpack.elasticAssistant.connectors.models.modelSelector.customOptionText',
{
defaultMessage: 'Create new Model named',
}
);

View file

@ -100,3 +100,17 @@ export const CONNECTOR_SETUP_SKIP = i18n.translate(
defaultMessage: 'Click to skip...',
}
);
export const MISSING_CONNECTOR_CALLOUT_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.connectors.connectorMissingCallout.calloutTitle',
{
defaultMessage: 'The current conversation is missing a connector configuration',
}
);
export const MISSING_CONNECTOR_CONVERSATION_SETTINGS_LINK = i18n.translate(
'xpack.elasticAssistant.assistant.connectors.connectorMissingCallout.conversationSettingsLink',
{
defaultMessage: 'Conversation Settings',
}
);

View file

@ -10,6 +10,15 @@ import { render, fireEvent } from '@testing-library/react';
import { TestProviders } from '../../../mock/test_providers/test_providers';
import { AnonymizationSettings } from '.';
import type { Props } from '.';
const props: Props = {
defaultAllow: ['foo', 'bar', 'baz', '@baz'],
defaultAllowReplacement: ['bar'],
pageSize: 5,
setUpdatedDefaultAllow: jest.fn(),
setUpdatedDefaultAllowReplacement: jest.fn(),
};
const mockUseAssistantContext = {
allSystemPrompts: [
@ -47,14 +56,12 @@ jest.mock('../../../assistant_context', () => {
});
describe('AnonymizationSettings', () => {
const closeModal = jest.fn();
beforeEach(() => jest.clearAllMocks());
it('renders the editor', () => {
const { getByTestId } = render(
<TestProviders>
<AnonymizationSettings />
<AnonymizationSettings {...props} />
</TestProviders>
);
@ -64,11 +71,11 @@ describe('AnonymizationSettings', () => {
it('does NOT call `setDefaultAllow` when `Reset` is clicked, because only local state is reset until the user clicks save', () => {
const { getByTestId } = render(
<TestProviders>
<AnonymizationSettings />
<AnonymizationSettings {...props} />
</TestProviders>
);
fireEvent.click(getByTestId('reset'));
fireEvent.click(getByTestId('resetFields'));
expect(mockUseAssistantContext.setDefaultAllow).not.toHaveBeenCalled();
});
@ -76,11 +83,11 @@ describe('AnonymizationSettings', () => {
it('does NOT call `setDefaultAllowReplacement` when `Reset` is clicked, because only local state is reset until the user clicks save', () => {
const { getByTestId } = render(
<TestProviders>
<AnonymizationSettings />
<AnonymizationSettings {...props} />
</TestProviders>
);
fireEvent.click(getByTestId('reset'));
fireEvent.click(getByTestId('resetFields'));
expect(mockUseAssistantContext.setDefaultAllowReplacement).not.toHaveBeenCalled();
});
@ -88,7 +95,7 @@ describe('AnonymizationSettings', () => {
it('renders the expected allowed stat content', () => {
const { getByTestId } = render(
<TestProviders>
<AnonymizationSettings />
<AnonymizationSettings {...props} />
</TestProviders>
);
@ -100,7 +107,7 @@ describe('AnonymizationSettings', () => {
it('renders the expected anonymized stat content', () => {
const { getByTestId } = render(
<TestProviders>
<AnonymizationSettings />
<AnonymizationSettings {...props} />
</TestProviders>
);
@ -108,52 +115,4 @@ describe('AnonymizationSettings', () => {
`${mockUseAssistantContext.defaultAllowReplacement.length}Anonymized`
);
});
it('calls closeModal is called when the cancel button is clicked', () => {
const { getByTestId } = render(
<TestProviders>
<AnonymizationSettings closeModal={closeModal} />
</TestProviders>
);
fireEvent.click(getByTestId('cancel'));
expect(closeModal).toHaveBeenCalledTimes(1);
});
it('calls closeModal is called when the save button is clicked', () => {
const { getByTestId } = render(
<TestProviders>
<AnonymizationSettings closeModal={closeModal} />
</TestProviders>
);
fireEvent.click(getByTestId('cancel'));
expect(closeModal).toHaveBeenCalledTimes(1);
});
it('calls setDefaultAllow with the expected values when the save button is clicked', () => {
const { getByTestId } = render(
<TestProviders>
<AnonymizationSettings closeModal={closeModal} />
</TestProviders>
);
fireEvent.click(getByTestId('save'));
expect(mockUseAssistantContext.setDefaultAllow).toHaveBeenCalledWith(
mockUseAssistantContext.defaultAllow
);
});
it('calls setDefaultAllowReplacement with the expected values when the save button is clicked', () => {
const { getByTestId } = render(
<TestProviders>
<AnonymizationSettings closeModal={closeModal} />
</TestProviders>
);
fireEvent.click(getByTestId('save'));
expect(mockUseAssistantContext.setDefaultAllowReplacement).toHaveBeenCalledWith(
mockUseAssistantContext.defaultAllowReplacement
);
});
});

View file

@ -6,14 +6,14 @@
*/
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useMemo } from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
@ -23,90 +23,71 @@ import type { BatchUpdateListItem } from '../../../data_anonymization_editor/con
import { updateDefaults } from '../../../data_anonymization_editor/helpers';
import { AllowedStat } from '../../../data_anonymization_editor/stats/allowed_stat';
import { AnonymizedStat } from '../../../data_anonymization_editor/stats/anonymized_stat';
import { CANCEL, SAVE } from '../anonymization_settings_modal/translations';
import * as i18n from './translations';
const StatFlexItem = styled(EuiFlexItem)`
margin-right: ${({ theme }) => theme.eui.euiSizeL};
`;
interface Props {
closeModal?: () => void;
export interface Props {
defaultAllow: string[];
defaultAllowReplacement: string[];
pageSize?: number;
setUpdatedDefaultAllow: React.Dispatch<React.SetStateAction<string[]>>;
setUpdatedDefaultAllowReplacement: React.Dispatch<React.SetStateAction<string[]>>;
}
const AnonymizationSettingsComponent: React.FC<Props> = ({ closeModal }) => {
const {
baseAllow,
baseAllowReplacement,
defaultAllow,
defaultAllowReplacement,
setDefaultAllow,
setDefaultAllowReplacement,
} = useAssistantContext();
// Local state for default allow and default allow replacement to allow for intermediate changes
const [localDefaultAllow, setLocalDefaultAllow] = useState<string[]>(defaultAllow);
const [localDefaultAllowReplacement, setLocalDefaultAllowReplacement] =
useState<string[]>(defaultAllowReplacement);
const AnonymizationSettingsComponent: React.FC<Props> = ({
defaultAllow,
defaultAllowReplacement,
pageSize,
setUpdatedDefaultAllow,
setUpdatedDefaultAllowReplacement,
}) => {
const { baseAllow, baseAllowReplacement } = useAssistantContext();
const onListUpdated = useCallback(
(updates: BatchUpdateListItem[]) => {
updateDefaults({
defaultAllow: localDefaultAllow,
defaultAllowReplacement: localDefaultAllowReplacement,
setDefaultAllow: setLocalDefaultAllow,
setDefaultAllowReplacement: setLocalDefaultAllowReplacement,
defaultAllow,
defaultAllowReplacement,
setDefaultAllow: setUpdatedDefaultAllow,
setDefaultAllowReplacement: setUpdatedDefaultAllowReplacement,
updates,
});
},
[localDefaultAllow, localDefaultAllowReplacement]
[
defaultAllow,
defaultAllowReplacement,
setUpdatedDefaultAllow,
setUpdatedDefaultAllowReplacement,
]
);
const onReset = useCallback(() => {
setLocalDefaultAllow(baseAllow);
setLocalDefaultAllowReplacement(baseAllowReplacement);
}, [baseAllow, baseAllowReplacement]);
const onSave = useCallback(() => {
setDefaultAllow(localDefaultAllow);
setDefaultAllowReplacement(localDefaultAllowReplacement);
closeModal?.();
}, [
closeModal,
localDefaultAllow,
localDefaultAllowReplacement,
setDefaultAllow,
setDefaultAllowReplacement,
]);
setUpdatedDefaultAllow(baseAllow);
setUpdatedDefaultAllowReplacement(baseAllowReplacement);
}, [baseAllow, baseAllowReplacement, setUpdatedDefaultAllow, setUpdatedDefaultAllowReplacement]);
const anonymized: number = useMemo(() => {
const allowSet = new Set(localDefaultAllow);
const allowSet = new Set(defaultAllow);
return localDefaultAllowReplacement.reduce(
(acc, field) => (allowSet.has(field) ? acc + 1 : acc),
0
);
}, [localDefaultAllow, localDefaultAllowReplacement]);
return defaultAllowReplacement.reduce((acc, field) => (allowSet.has(field) ? acc + 1 : acc), 0);
}, [defaultAllow, defaultAllowReplacement]);
return (
<>
<EuiCallOut
data-test-subj="anonymizationSettingsCallout"
iconType="eyeClosed"
size="s"
title={i18n.CALLOUT_TITLE}
>
<p>{i18n.CALLOUT_PARAGRAPH1}</p>
<EuiButton data-test-subj="reset" onClick={onReset} size="s">
{i18n.RESET}
</EuiButton>
</EuiCallOut>
<EuiTitle size={'s'}>
<h2>{i18n.SETTINGS_TITLE}</h2>
</EuiTitle>
<EuiSpacer size="xs" />
<EuiText size={'xs'}>{i18n.SETTINGS_DESCRIPTION}</EuiText>
<EuiSpacer size="m" />
<EuiHorizontalRule margin={'s'} />
<EuiFlexGroup alignItems="center" data-test-subj="summary" gutterSize="none">
<StatFlexItem grow={false}>
<AllowedStat allowed={localDefaultAllow.length} total={localDefaultAllow.length} />
<AllowedStat allowed={defaultAllow.length} total={defaultAllow.length} />
</StatFlexItem>
<StatFlexItem grow={false}>
@ -117,27 +98,13 @@ const AnonymizationSettingsComponent: React.FC<Props> = ({ closeModal }) => {
<EuiSpacer size="s" />
<ContextEditor
allow={localDefaultAllow}
allowReplacement={localDefaultAllowReplacement}
allow={defaultAllow}
allowReplacement={defaultAllowReplacement}
onListUpdated={onListUpdated}
onReset={onReset}
rawData={null}
pageSize={pageSize}
/>
<EuiFlexGroup alignItems="center" gutterSize="xs" justifyContent="flexEnd">
{closeModal != null && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty data-test-subj="cancel" onClick={closeModal}>
{CANCEL}
</EuiButtonEmpty>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiButton fill data-test-subj="save" onClick={onSave} size="s">
{SAVE}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};

View file

@ -28,9 +28,16 @@ export const CALLOUT_TITLE = i18n.translate(
}
);
export const RESET = i18n.translate(
'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.resetButton',
export const SETTINGS_TITLE = i18n.translate(
'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.settingsTitle',
{
defaultMessage: 'Reset',
defaultMessage: 'Anonymization',
}
);
export const SETTINGS_DESCRIPTION = i18n.translate(
'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.settingsDescription',
{
defaultMessage:
"When adding Prompt Context throughout the Security App that may contain sensitive information, you can choose which fields are sent, and whether to enable anonymization for these fields. This will replace the field's value with a random string before sending the conversation. Helpful defaults are provided below.",
}
);

View file

@ -1,42 +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 React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import { AnonymizationSettingsModal } from '.';
import { TestProviders } from '../../../mock/test_providers/test_providers';
describe('AnonymizationSettingsModal', () => {
const closeModal = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
render(
<TestProviders>
<AnonymizationSettingsModal closeModal={closeModal} />
</TestProviders>
);
});
it('renders the anonymizationSettings', () => {
expect(screen.getByTestId('anonymizationSettingsCallout')).toBeInTheDocument();
});
it('calls closeModal when Cancel is clicked', () => {
fireEvent.click(screen.getByTestId('cancel'));
expect(closeModal).toHaveBeenCalledTimes(1);
});
it('calls closeModal when Save is clicked', () => {
fireEvent.click(screen.getByTestId('save'));
expect(closeModal).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,26 +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 { EuiModal, EuiModalBody, EuiModalHeader } from '@elastic/eui';
import React from 'react';
import { AnonymizationSettings } from '../anonymization_settings';
interface Props {
closeModal: () => void;
}
const AnonymizationSettingsModalComponent: React.FC<Props> = ({ closeModal }) => (
<EuiModal onClose={closeModal}>
<EuiModalHeader />
<EuiModalBody>
<AnonymizationSettings closeModal={closeModal} />
</EuiModalBody>
</EuiModal>
);
export const AnonymizationSettingsModal = React.memo(AnonymizationSettingsModalComponent);

View file

@ -1,29 +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 { i18n } from '@kbn/i18n';
export const ANONYMIZATION = i18n.translate(
'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettingsModal.anonymizationModalTitle',
{
defaultMessage: 'Anonymization',
}
);
export const CANCEL = i18n.translate(
'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettingsModal.cancelButton',
{
defaultMessage: 'Cancel',
}
);
export const SAVE = i18n.translate(
'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettingsModal.saveButton',
{
defaultMessage: 'Save',
}
);

View file

@ -1,39 +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 React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TestProviders } from '../../../mock/test_providers/test_providers';
import * as i18n from './translations';
import { SettingsPopover } from '.';
describe('SettingsPopover', () => {
beforeEach(() => {
render(
<TestProviders>
<SettingsPopover />
</TestProviders>
);
});
it('renders the settings button', () => {
const settingsButton = screen.getByTestId('settings');
expect(settingsButton).toBeInTheDocument();
});
it('opens the popover when the settings button is clicked', () => {
const settingsButton = screen.getByTestId('settings');
userEvent.click(settingsButton);
const popover = screen.queryByText(i18n.ANONYMIZATION);
expect(popover).toBeInTheDocument();
});
});

View file

@ -1,90 +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 {
EuiButtonIcon,
EuiContextMenu,
EuiContextMenuPanelDescriptor,
EuiPopover,
useGeneratedHtmlId,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { AnonymizationSettingsModal } from '../anonymization_settings_modal';
import * as i18n from './translations';
const SettingsPopoverComponent: React.FC<{ isDisabled?: boolean }> = ({ isDisabled = false }) => {
const [showAnonymizationSettingsModal, setShowAnonymizationSettingsModal] = useState(false);
const closeAnonymizationSettingsModal = useCallback(
() => setShowAnonymizationSettingsModal(false),
[]
);
const contextMenuPopoverId = useGeneratedHtmlId();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
const onButtonClick = useCallback(() => setIsPopoverOpen((prev) => !prev), []);
const button = useMemo(
() => (
<EuiButtonIcon
isDisabled={isDisabled}
aria-label={i18n.SETTINGS}
data-test-subj="settings"
iconType="gear"
onClick={onButtonClick}
/>
),
[isDisabled, onButtonClick]
);
const panels: EuiContextMenuPanelDescriptor[] = useMemo(
() => [
{
id: 0,
items: [
{
icon: 'eyeClosed',
name: i18n.ANONYMIZATION,
onClick: () => {
closePopover();
setShowAnonymizationSettingsModal(true);
},
},
],
size: 's',
width: 150,
},
],
[closePopover]
);
return (
<>
<EuiPopover
anchorPosition="downLeft"
button={button}
closePopover={closePopover}
id={contextMenuPopoverId}
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
<EuiContextMenu initialPanelId={0} panels={panels} size="s" />
</EuiPopover>
{showAnonymizationSettingsModal && (
<AnonymizationSettingsModal closeModal={closeAnonymizationSettingsModal} />
)}
</>
);
};
SettingsPopoverComponent.displayName = 'SettingsPopoverComponent';
export const SettingsPopover = React.memo(SettingsPopoverComponent);

View file

@ -1,22 +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 { i18n } from '@kbn/i18n';
export const ANONYMIZATION = i18n.translate(
'xpack.elasticAssistant.dataAnonymization.settings.settingsPopover.anonymizationMenuItem',
{
defaultMessage: 'Anonymization',
}
);
export const SETTINGS = i18n.translate(
'xpack.elasticAssistant.dataAnonymization.settings.settingsPopover.settingsAriaLabel',
{
defaultMessage: 'Settings',
}
);

View file

@ -17,11 +17,6 @@ import { BatchUpdateListItem, ContextEditorRow, FIELDS, SortConfig } from './typ
export const DEFAULT_PAGE_SIZE = 10;
const pagination = {
initialPageSize: DEFAULT_PAGE_SIZE,
pageSizeOptions: [5, DEFAULT_PAGE_SIZE, 25, 50],
};
const defaultSort: SortConfig = {
sort: {
direction: 'desc',
@ -33,7 +28,9 @@ export interface Props {
allow: string[];
allowReplacement: string[];
onListUpdated: (updates: BatchUpdateListItem[]) => void;
onReset?: () => void;
rawData: Record<string, string[]> | null;
pageSize?: number;
}
const search: EuiSearchBarProps = {
@ -58,7 +55,9 @@ const ContextEditorComponent: React.FC<Props> = ({
allow,
allowReplacement,
onListUpdated,
onReset,
rawData,
pageSize = DEFAULT_PAGE_SIZE,
}) => {
const [selected, setSelection] = useState<ContextEditorRow[]>([]);
const selectionValue: EuiTableSelectionType<ContextEditorRow> = useMemo(
@ -89,17 +88,25 @@ const ContextEditorComponent: React.FC<Props> = ({
setTimeout(() => setSelection(rows), 0); // updates selection in the component state
}, [rows]);
const pagination = useMemo(() => {
return {
initialPageSize: pageSize,
pageSizeOptions: [5, DEFAULT_PAGE_SIZE, 25, 50],
};
}, [pageSize]);
const toolbar = useMemo(
() => (
<Toolbar
onListUpdated={onListUpdated}
onlyDefaults={rawData == null}
onReset={onReset}
onSelectAll={onSelectAll}
selected={selected}
totalFields={rows.length}
/>
),
[onListUpdated, onSelectAll, rawData, rows.length, selected]
[onListUpdated, onReset, onSelectAll, rawData, rows.length, selected]
);
return (

View file

@ -40,6 +40,7 @@ describe('Toolbar', () => {
const defaultProps = {
onListUpdated: jest.fn(),
onlyDefaults: false,
onReset: jest.fn(),
onSelectAll: jest.fn(),
selected: [], // no rows selected
totalFields: 5,

View file

@ -15,6 +15,7 @@ import { BatchUpdateListItem, ContextEditorRow } from '../types';
export interface Props {
onListUpdated: (updates: BatchUpdateListItem[]) => void;
onlyDefaults: boolean;
onReset?: () => void;
onSelectAll: () => void;
selected: ContextEditorRow[];
totalFields: number;
@ -23,6 +24,7 @@ export interface Props {
const ToolbarComponent: React.FC<Props> = ({
onListUpdated,
onlyDefaults,
onReset,
onSelectAll,
selected,
totalFields,
@ -54,6 +56,28 @@ const ToolbarComponent: React.FC<Props> = ({
selected={selected}
/>
</EuiFlexItem>
{onReset != null && (
<EuiFlexItem grow={true}>
<EuiFlexGroup
alignItems="center"
data-test-subj="toolbarTrailingActions"
gutterSize="none"
justifyContent="flexEnd"
>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="resetFields"
iconType="eraser"
onClick={onReset}
size="xs"
>
{i18n.RESET}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
)}
</EuiFlexGroup>
);

View file

@ -116,6 +116,12 @@ export const SELECTED_FIELDS = (selected: number) =>
values: { selected },
defaultMessage: 'Selected {selected} fields',
});
export const RESET = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.resetButton',
{
defaultMessage: 'Reset',
}
);
export const UNANONYMIZE = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.unanonymizeAction',

View file

@ -41,7 +41,7 @@ export const FORMAT_OUTPUT_CORRECTLY = i18n.translate(
'xpack.securitySolution.assistant.content.prompts.system.outputFormatting',
{
defaultMessage:
'If you answer a question related to KQL or EQL, it should be immediately usable within an Elastic Security timeline, please always format the output correctly with back ticks. Any answer provided for Query DSL should also be usable in a security timeline. This means you should only ever include the "filter" portion of the query.',
'If you answer a question related to KQL or EQL, it should be immediately usable within an Elastic Security timeline; please always format the output correctly with back ticks. Any answer provided for Query DSL should also be usable in a security timeline. This means you should only ever include the "filter" portion of the query.',
}
);

View file

@ -25,13 +25,6 @@ export const ANALYZER_TAB = i18n.translate(
}
);
export const ASSISTANT_TAB = i18n.translate(
'xpack.securitySolution.timeline.tabs.assistantTabTitle',
{
defaultMessage: 'Security assistant',
}
);
export const NOTES_TAB = i18n.translate(
'xpack.securitySolution.timeline.tabs.notesTabTimelineTitle',
{
@ -49,7 +42,7 @@ export const PINNED_TAB = i18n.translate(
export const SECURITY_ASSISTANT = i18n.translate(
'xpack.securitySolution.timeline.tabs.securityAssistantTimelineTitle',
{
defaultMessage: 'Security Assistant',
defaultMessage: 'Elastic AI Assistant',
}
);