[Security Solution][Security Assistant] Fixes relationship between system prompts & conversations (#161039)

## Summary
This PR handles bugs 
- elastic/security-team#6977 
- https://github.com/elastic/security-team/issues/6978 
- elastic/security-team#6979.

Currently, below operations between System Prompts and Conversarions do
not work.

1. When a prompt is set as default for all conversation, it should be
automatically selected for any new conversation user creates.
2. When a new prompt is creates and set as default for all conversation,
it should be automatically selected for any new conversation user
creates.
3. When a prompt is edited such that, it is default for only certain
conversation, it should be automatically selected for that conversation.
4. When a prompt is edited such that conversations are removed to have
that default prompt, it should be automatically removed from
conversation default system prompt list.

In addition to above scenarios, this PR also handles one more bug.

Consider below interface of Conversation which has a property
`apiConfig.defaultSystemPrompt` is of type Prompt. It has been changed
from `defaultSystemPrompt?: Prompt` to `defaultSystemPrompt?: string`
where it will store `promptId` instead of complete prompt.

The current model was posing a problem where, if a prompt was updated,
all its copies in `Conversation` were needed to be updated leading to
inconsistencies. This is now resolved.

```typescript
export interface Conversation {
  apiConfig: {
    connectorId?: string;
    defaultSystemPrompt?: Prompt;
    provider?: OpenAiProviderType;
  };
  id: string;
  messages: Message[];
  replacements?: Record<string, string>;
  theme?: ConversationTheme;
  isDefault?: boolean;
}

```
This commit is contained in:
Jatin Kathuria 2023-07-06 15:09:49 -07:00 committed by GitHub
parent f82588ba5e
commit 75bd6dd854
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 500 additions and 58 deletions

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.
*/
export const TEST_IDS = {
SYSTEM_PROMPT_SELECTOR: 'systemPromptSelector',
CONVERSATIONS_MULTISELECTOR: 'conversationMultiSelector',
ADD_SYSTEM_PROMPT: 'addSystemPrompt',
PROMPT_SUPERSELECT: 'promptSuperSelect',
CONVERSATIONS_MULTISELECTOR_OPTION: (id: string) => `conversationMultiSelectorOption-${id}`,
SYSTEM_PROMPT_MODAL: {
ID: 'systemPromptModal',
PROMPT_TEXT: 'systemPromptModalPromptText',
TOGGLE_ALL_DEFAULT_CONVERSATIONS: 'systemPromptModalToggleDefaultConversations',
SAVE: 'systemPromptModalSave',
CANCEL: 'systemPromptModalCancel',
},
};

View file

@ -105,7 +105,7 @@ export const ConversationSelector: React.FC<Props> = React.memo(
apiConfig: {
connectorId: defaultConnectorId,
provider: defaultProvider,
defaultSystemPrompt,
defaultSystemPromptId: defaultSystemPrompt?.id,
},
};
setConversation({ conversation: newConversation });

View file

@ -29,10 +29,11 @@ export interface ConversationSettingsPopoverProps {
conversation: Conversation;
http: HttpSetup;
isDisabled?: boolean;
allSystemPrompts: Prompt[];
}
export const ConversationSettingsPopover: React.FC<ConversationSettingsPopoverProps> = React.memo(
({ actionTypeRegistry, conversation, http, isDisabled = false }) => {
({ 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);
@ -41,10 +42,13 @@ export const ConversationSettingsPopover: React.FC<ConversationSettingsPopoverPr
return conversation.apiConfig?.provider;
}, [conversation.apiConfig]);
const selectedPrompt: Prompt | undefined = useMemo(
() => conversation?.apiConfig.defaultSystemPrompt,
[conversation]
);
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);

View file

@ -79,6 +79,7 @@ const AssistantComponent: React.FC<Props> = ({
http,
promptContexts,
title,
allSystemPrompts,
} = useAssistantContext();
const [selectedPromptContexts, setSelectedPromptContexts] = useState<
Record<string, SelectedPromptContext>
@ -170,6 +171,7 @@ const AssistantComponent: React.FC<Props> = ({
// For auto-focusing prompt within timeline
const promptTextAreaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (shouldRefocusPrompt && promptTextAreaRef.current) {
promptTextAreaRef?.current.focus();
@ -187,6 +189,15 @@ const AssistantComponent: React.FC<Props> = ({
}, 0);
}, [currentConversation.messages.length, selectedPromptContextsCount]);
////
//
const selectedSystemPrompt = useMemo(() => {
if (currentConversation.apiConfig.defaultSystemPromptId) {
return allSystemPrompts.find(
(prompt) => prompt.id === currentConversation.apiConfig.defaultSystemPromptId
);
}
}, [allSystemPrompts, currentConversation.apiConfig.defaultSystemPromptId]);
// Handles sending latest user prompt to API
const handleSendMessage = useCallback(
@ -203,7 +214,7 @@ const AssistantComponent: React.FC<Props> = ({
onNewReplacements,
promptText,
selectedPromptContexts,
selectedSystemPrompt: currentConversation.apiConfig.defaultSystemPrompt,
selectedSystemPrompt,
});
const updatedMessages = appendMessage({
@ -224,6 +235,7 @@ const AssistantComponent: React.FC<Props> = ({
appendMessage({ conversationId: selectedConversationId, message: responseMessage });
},
[
selectedSystemPrompt,
appendMessage,
appendReplacements,
currentConversation.apiConfig,
@ -561,6 +573,7 @@ const AssistantComponent: React.FC<Props> = ({
conversation={currentConversation}
isDisabled={isWelcomeSetup}
http={http}
allSystemPrompts={allSystemPrompts}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -6,23 +6,43 @@
*/
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { mockSystemPrompt } from '../../../mock/system_prompt';
import { SystemPrompt } from '.';
import { BASE_CONVERSATIONS, Conversation } from '../../../..';
import { DEFAULT_CONVERSATION_TITLE } from '../../use_conversation/translations';
import { Prompt } from '../../types';
import { TestProviders } from '../../../mock/test_providers/test_providers';
import { TEST_IDS } from '../../constants';
import { useAssistantContext } from '../../../assistant_context';
const BASE_CONVERSATION: Conversation = {
...BASE_CONVERSATIONS[DEFAULT_CONVERSATION_TITLE],
apiConfig: {
defaultSystemPromptId: mockSystemPrompt.id,
},
};
const mockConversations = {
[DEFAULT_CONVERSATION_TITLE]: BASE_CONVERSATION,
};
const mockSystemPrompts: Prompt[] = [mockSystemPrompt];
const mockUseAssistantContext = {
conversations: mockConversations,
setConversations: jest.fn(),
setAllSystemPrompts: jest.fn(),
allSystemPrompts: mockSystemPrompts,
};
jest.mock('../../../assistant_context', () => {
const original = jest.requireActual('../../../assistant_context');
return {
...original,
useAssistantContext: () => mockUseAssistantContext,
useAssistantContext: jest.fn().mockImplementation(() => mockUseAssistantContext),
};
});
@ -38,15 +58,18 @@ jest.mock('../../use_conversation', () => {
};
});
const BASE_CONVERSATION: Conversation = {
...BASE_CONVERSATIONS[DEFAULT_CONVERSATION_TITLE],
apiConfig: {
defaultSystemPrompt: mockSystemPrompt,
},
};
describe('SystemPrompt', () => {
beforeEach(() => jest.clearAllMocks());
beforeEach(() => {
jest.clearAllMocks();
jest.mock('../../../assistant_context', () => {
const original = jest.requireActual('../../../assistant_context');
return {
...original,
useAssistantContext: jest.fn().mockImplementation(() => mockUseAssistantContext),
};
});
});
describe('when conversation is undefined', () => {
const conversation = undefined;
@ -94,8 +117,303 @@ describe('SystemPrompt', () => {
});
});
describe('when a new prompt is saved', () => {
it('should save new prompt correctly', async () => {
const customPromptName = 'custom prompt';
const customPromptText = 'custom prompt text';
render(
<TestProviders>
<SystemPrompt conversation={BASE_CONVERSATION} />
</TestProviders>
);
userEvent.click(screen.getByTestId('edit'));
userEvent.click(screen.getByTestId(TEST_IDS.ADD_SYSTEM_PROMPT));
expect(screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.ID)).toBeVisible();
userEvent.type(
within(screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_SELECTOR)).getByTestId('comboBoxInput'),
`${customPromptName}[Enter]`
);
userEvent.type(
screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.PROMPT_TEXT),
customPromptText
);
userEvent.click(screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.SAVE));
await waitFor(() => {
expect(mockUseAssistantContext.setAllSystemPrompts).toHaveBeenCalledTimes(1);
expect(mockUseAssistantContext.setAllSystemPrompts).toHaveBeenNthCalledWith(1, [
mockSystemPrompt,
{
id: customPromptName,
content: customPromptText,
name: customPromptName,
promptType: 'system',
},
]);
expect(screen.queryByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.ID)).not.toBeInTheDocument();
});
});
it('should save new prompt as a default prompt', async () => {
const customPromptName = 'custom prompt';
const customPromptText = 'custom prompt text';
render(
<TestProviders>
<SystemPrompt conversation={BASE_CONVERSATION} />
</TestProviders>
);
userEvent.click(screen.getByTestId('edit'));
userEvent.click(screen.getByTestId(TEST_IDS.ADD_SYSTEM_PROMPT));
expect(screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.ID)).toBeVisible();
userEvent.type(
within(screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_SELECTOR)).getByTestId('comboBoxInput'),
`${customPromptName}[Enter]`
);
userEvent.type(
screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.PROMPT_TEXT),
customPromptText
);
userEvent.click(
screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.TOGGLE_ALL_DEFAULT_CONVERSATIONS)
);
await waitFor(() => {
expect(
screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.TOGGLE_ALL_DEFAULT_CONVERSATIONS)
).toBeChecked();
});
userEvent.click(screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.SAVE));
await waitFor(() => {
expect(mockUseAssistantContext.setAllSystemPrompts).toHaveBeenCalledTimes(1);
expect(mockUseAssistantContext.setAllSystemPrompts).toHaveBeenNthCalledWith(1, [
{
...mockSystemPrompt,
isNewConversationDefault: false,
},
{
id: customPromptName,
content: customPromptText,
name: customPromptName,
promptType: 'system',
isNewConversationDefault: true,
},
]);
expect(screen.queryByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.ID)).not.toBeInTheDocument();
});
});
it('should save new prompt as a default prompt for selected conversations', async () => {
const customPromptName = 'custom prompt';
const customPromptText = 'custom prompt text';
render(
<TestProviders>
<SystemPrompt conversation={BASE_CONVERSATION} />
</TestProviders>
);
userEvent.click(screen.getByTestId('edit'));
userEvent.click(screen.getByTestId(TEST_IDS.ADD_SYSTEM_PROMPT));
expect(screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.ID)).toBeVisible();
userEvent.type(
within(screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_SELECTOR)).getByTestId('comboBoxInput'),
`${customPromptName}[Enter]`
);
userEvent.type(
screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.PROMPT_TEXT),
customPromptText
);
userEvent.click(
within(screen.getByTestId(TEST_IDS.CONVERSATIONS_MULTISELECTOR)).getByTestId(
'comboBoxInput'
)
);
await waitFor(() => {
expect(
screen.getByTestId(
TEST_IDS.CONVERSATIONS_MULTISELECTOR_OPTION(DEFAULT_CONVERSATION_TITLE)
)
).toBeVisible();
});
// select Default Conversation
userEvent.click(
screen.getByTestId(TEST_IDS.CONVERSATIONS_MULTISELECTOR_OPTION(DEFAULT_CONVERSATION_TITLE))
);
userEvent.click(screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.SAVE));
await waitFor(() => {
expect(screen.queryByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.ID)).not.toBeInTheDocument();
});
expect(mockUseAssistantContext.setAllSystemPrompts).toHaveBeenCalledTimes(1);
expect(mockUseAssistantContext.setConversations).toHaveBeenCalledTimes(1);
expect(mockUseAssistantContext.setConversations).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
[DEFAULT_CONVERSATION_TITLE]: expect.objectContaining({
id: DEFAULT_CONVERSATION_TITLE,
apiConfig: expect.objectContaining({
defaultSystemPromptId: customPromptName,
}),
}),
})
);
});
it('should save new prompt correctly when prompt is removed from selected conversation', async () => {
render(
<TestProviders>
<SystemPrompt conversation={BASE_CONVERSATION} />
</TestProviders>
);
userEvent.click(screen.getByTestId('edit'));
userEvent.click(screen.getByTestId(TEST_IDS.ADD_SYSTEM_PROMPT));
expect(screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.ID)).toBeVisible();
userEvent.type(
within(screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_SELECTOR)).getByTestId('comboBoxInput'),
`${mockSystemPrompt.name}[Enter]`
);
expect(
within(screen.getByTestId(TEST_IDS.CONVERSATIONS_MULTISELECTOR)).getByText(
DEFAULT_CONVERSATION_TITLE
)
).toBeVisible();
userEvent.click(
within(screen.getByTestId(TEST_IDS.CONVERSATIONS_MULTISELECTOR)).getByTestId(
'comboBoxClearButton'
)
);
userEvent.click(screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.SAVE));
await waitFor(() => {
expect(screen.queryByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.ID)).toBeFalsy();
});
expect(mockUseAssistantContext.setAllSystemPrompts).toHaveBeenCalledTimes(1);
expect(mockUseAssistantContext.setConversations).toHaveBeenCalledTimes(1);
expect(mockUseAssistantContext.setConversations).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
[DEFAULT_CONVERSATION_TITLE]: expect.objectContaining({
id: DEFAULT_CONVERSATION_TITLE,
apiConfig: expect.objectContaining({
defaultSystemPromptId: undefined,
}),
}),
})
);
});
it('should save new prompt correctly when prompt is removed from a conversation and linked to another conversation in a single transaction', async () => {
const secondMockConversation: Conversation = {
id: 'second',
apiConfig: {
defaultSystemPromptId: undefined,
},
messages: [],
};
const localMockConversations: Record<string, Conversation> = {
[DEFAULT_CONVERSATION_TITLE]: BASE_CONVERSATION,
[secondMockConversation.id]: secondMockConversation,
};
const localMockUseAssistantContext = {
conversations: localMockConversations,
setConversations: jest.fn(),
setAllSystemPrompts: jest.fn(),
allSystemPrompts: mockSystemPrompts,
hero: 'abc',
};
(useAssistantContext as jest.Mock).mockImplementation(() => ({
...localMockUseAssistantContext,
}));
render(
<TestProviders>
<SystemPrompt conversation={BASE_CONVERSATION} />
</TestProviders>
);
userEvent.click(screen.getByTestId('edit'));
userEvent.click(screen.getByTestId(TEST_IDS.ADD_SYSTEM_PROMPT));
expect(screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.ID)).toBeVisible();
userEvent.type(
within(screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_SELECTOR)).getByTestId('comboBoxInput'),
`${mockSystemPrompt.name}[Enter]`
);
expect(
within(screen.getByTestId(TEST_IDS.CONVERSATIONS_MULTISELECTOR)).getByText(
DEFAULT_CONVERSATION_TITLE
)
).toBeVisible();
// removed selected conversation
userEvent.click(
within(screen.getByTestId(TEST_IDS.CONVERSATIONS_MULTISELECTOR)).getByTestId(
'comboBoxClearButton'
)
);
// add `second` conversation
userEvent.type(
within(screen.getByTestId(TEST_IDS.CONVERSATIONS_MULTISELECTOR)).getByTestId(
'comboBoxInput'
),
'second[Enter]'
);
userEvent.click(screen.getByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.SAVE));
await waitFor(() => {
expect(screen.queryByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.ID)).toBeFalsy();
});
expect(localMockUseAssistantContext.setAllSystemPrompts).toHaveBeenCalledTimes(1);
expect(localMockUseAssistantContext.setConversations).toHaveBeenCalledTimes(1);
expect(localMockUseAssistantContext.setConversations).toHaveBeenNthCalledWith(1, {
[DEFAULT_CONVERSATION_TITLE]: expect.objectContaining({
id: DEFAULT_CONVERSATION_TITLE,
apiConfig: expect.objectContaining({
defaultSystemPromptId: undefined,
}),
}),
[secondMockConversation.id]: {
...secondMockConversation,
apiConfig: {
defaultSystemPromptId: mockSystemPrompt.id,
},
},
});
});
});
it('shows the system prompt select when the edit button is clicked', () => {
render(<SystemPrompt conversation={BASE_CONVERSATION} />);
render(
<TestProviders>
<SystemPrompt conversation={BASE_CONVERSATION} />
</TestProviders>
);
userEvent.click(screen.getByTestId('edit'));
@ -103,7 +421,10 @@ describe('SystemPrompt', () => {
});
it('clears the selected system prompt when the clear button is clicked', () => {
const apiConfig = { apiConfig: { defaultSystemPrompt: undefined }, conversationId: 'Default' };
const apiConfig = {
apiConfig: { defaultSystemPromptId: undefined },
conversationId: 'Default',
};
render(<SystemPrompt conversation={BASE_CONVERSATION} />);
userEvent.click(screen.getByTestId('clear'));
@ -112,7 +433,11 @@ describe('SystemPrompt', () => {
});
it('shows the system prompt select when system prompt text is clicked', () => {
render(<SystemPrompt conversation={BASE_CONVERSATION} />);
render(
<TestProviders>
<SystemPrompt conversation={BASE_CONVERSATION} />
</TestProviders>
);
fireEvent.click(screen.getByTestId('systemPromptText'));

View file

@ -9,9 +9,9 @@ import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@
import React, { useCallback, useMemo } from 'react';
import { css } from '@emotion/react';
import { useAssistantContext } from '../../../assistant_context';
import { Conversation } from '../../../..';
import * as i18n from './translations';
import type { Prompt } from '../../types';
import { SelectSystemPrompt } from './select_system_prompt';
import { useConversation } from '../../use_conversation';
@ -20,12 +20,14 @@ interface Props {
}
const SystemPromptComponent: React.FC<Props> = ({ conversation }) => {
const { allSystemPrompts } = useAssistantContext();
const { setApiConfig } = useConversation();
const selectedPrompt: Prompt | undefined = useMemo(
() => conversation?.apiConfig.defaultSystemPrompt,
[conversation]
const selectedPrompt = useMemo(
() => allSystemPrompts?.find((p) => p.id === conversation?.apiConfig.defaultSystemPromptId),
[allSystemPrompts, conversation]
);
const [isEditing, setIsEditing] = React.useState<boolean>(false);
const handleClearSystemPrompt = useCallback(() => {
@ -34,7 +36,7 @@ const SystemPromptComponent: React.FC<Props> = ({ conversation }) => {
conversationId: conversation.id,
apiConfig: {
...conversation.apiConfig,
defaultSystemPrompt: undefined,
defaultSystemPromptId: undefined,
},
});
}

View file

@ -10,6 +10,7 @@ import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Props, SelectSystemPrompt } from '.';
import { TEST_IDS } from '../../../constants';
const props: Props = {
conversation: undefined,
@ -51,13 +52,13 @@ describe('SelectSystemPrompt', () => {
it('renders the prompt super select when isEditing is true', () => {
const { getByTestId } = render(<SelectSystemPrompt {...props} isEditing={true} />);
expect(getByTestId('promptSuperSelect')).toBeInTheDocument();
expect(getByTestId(TEST_IDS.PROMPT_SUPERSELECT)).toBeInTheDocument();
});
it('does NOT render the prompt super select when isEditing is false', () => {
const { queryByTestId } = render(<SelectSystemPrompt {...props} isEditing={false} />);
expect(queryByTestId('promptSuperSelect')).not.toBeInTheDocument();
expect(queryByTestId(TEST_IDS.PROMPT_SUPERSELECT)).not.toBeInTheDocument();
});
it('does NOT render the clear system prompt button when isEditing is true', () => {

View file

@ -25,6 +25,7 @@ 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 { TEST_IDS } from '../../../constants';
export interface Props {
conversation: Conversation | undefined;
@ -53,7 +54,9 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
setIsEditing,
showTitles = false,
}) => {
const { allSystemPrompts, setAllSystemPrompts } = useAssistantContext();
const { allSystemPrompts, setAllSystemPrompts, conversations, setConversations } =
useAssistantContext();
const { setApiConfig } = useConversation();
const [isOpenLocal, setIsOpenLocal] = useState<boolean>(isOpen);
@ -67,7 +70,7 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
conversationId: conversation.id,
apiConfig: {
...conversation.apiConfig,
defaultSystemPrompt: prompt,
defaultSystemPromptId: prompt?.id,
},
});
}
@ -84,7 +87,7 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
dropdownDisplay: (
<EuiFlexGroup gutterSize="none" key={ADD_NEW_SYSTEM_PROMPT}>
<EuiFlexItem grow={true}>
<EuiButtonEmpty iconType="plus" size="xs">
<EuiButtonEmpty iconType="plus" size="xs" data-test-subj="addSystemPrompt">
{i18n.ADD_NEW_SYSTEM_PROMPT}
</EuiButtonEmpty>
</EuiFlexItem>
@ -99,12 +102,25 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
// Callback for modal onSave, saves to local storage on change
const onSystemPromptsChange = useCallback(
(newSystemPrompts: Prompt[]) => {
(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]
[onSystemPromptModalVisibilityChange, setAllSystemPrompts, conversations, setConversations]
);
// SuperSelect State/Actions
@ -112,6 +128,7 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
() => getOptions({ prompts: allSystemPrompts, showTitles }),
[allSystemPrompts, showTitles]
);
const onChange = useCallback(
(selectedSystemPromptId) => {
if (selectedSystemPromptId === ADD_NEW_SYSTEM_PROMPT) {
@ -153,7 +170,7 @@ 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 }}
data-test-subj="promptSuperSelect"
data-test-subj={TEST_IDS.PROMPT_SUPERSELECT}
fullWidth={fullWidth}
hasDividers
itemLayoutAlign="top"

View file

@ -8,11 +8,12 @@
import React, { useCallback, useMemo } from 'react';
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { TEST_IDS } from '../../../../constants';
import { Conversation } from '../../../../../..';
import * as i18n from '../translations';
interface Props {
onConversationSelectionChange: (conversations: Conversation[]) => void;
onConversationSelectionChange: (currentPromptConversations: Conversation[]) => void;
conversations: Conversation[];
selectedConversations?: Conversation[];
}
@ -27,6 +28,7 @@ export const ConversationMultiSelector: React.FC<Props> = React.memo(
() =>
conversations.map((conversation) => ({
label: conversation.id,
'data-test-subj': TEST_IDS.CONVERSATIONS_MULTISELECTOR_OPTION(conversation.id),
})),
[conversations]
);
@ -62,6 +64,7 @@ export const ConversationMultiSelector: React.FC<Props> = React.memo(
return (
<EuiComboBox
data-test-subj={TEST_IDS.CONVERSATIONS_MULTISELECTOR}
aria-label={i18n.SYSTEM_PROMPT_DEFAULT_CONVERSATIONS}
options={options}
selectedOptions={selectedOptions}

View file

@ -33,6 +33,7 @@ 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;
@ -45,7 +46,7 @@ interface Props {
onClose: (
event?: React.KeyboardEvent<HTMLDivElement> | React.MouseEvent<HTMLButtonElement>
) => void;
onSystemPromptsChange: (systemPrompts: Prompt[]) => void;
onSystemPromptsChange: (systemPrompts: Prompt[], newConversation?: Conversation[]) => void;
}
/**
@ -66,23 +67,58 @@ export const SystemPromptModal: React.FC<Props> = React.memo(
}, []);
// Conversations this system prompt should be a default for
const [selectedConversations, setSelectedConversations] = useState<Conversation[]>([]);
const onConversationSelectionChange = useCallback((newConversations: Conversation[]) => {
setSelectedConversations(newConversations);
}, []);
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) => {
setIsNewConversationDefault(e.target.checked);
const isChecked = e.target.checked;
setIsNewConversationDefault(isChecked);
if (selectedSystemPrompt != null) {
setUpdatedSystemPrompts((prev) => {
return prev.map((pp) => ({
...pp,
isNewConversationDefault: selectedSystemPrompt.id === pp.id && e.target.checked,
}));
return prev.map((pp) => {
return {
...pp,
isNewConversationDefault: selectedSystemPrompt.id === pp.id && isChecked,
};
});
});
setSelectedSystemPrompt((prev) =>
prev != null ? { ...prev, isNewConversationDefault: e.target.checked } : prev
prev != null ? { ...prev, isNewConversationDefault: isChecked } : prev
);
}
},
@ -106,13 +142,13 @@ export const SystemPromptModal: React.FC<Props> = React.memo(
setPrompt(newPrompt?.content ?? '');
setIsNewConversationDefault(newPrompt?.isNewConversationDefault ?? false);
// Find all conversations that have this system prompt as a default
setSelectedConversations(
const currenlySelectedConversations =
newPrompt != null
? Object.values(conversations).filter(
(conversation) => conversation?.apiConfig.defaultSystemPrompt?.id === newPrompt?.id
(conversation) => conversation?.apiConfig.defaultSystemPromptId === newPrompt?.id
)
: []
);
: [];
setSelectedConversations(currenlySelectedConversations);
},
[conversations]
);
@ -122,8 +158,9 @@ export const SystemPromptModal: React.FC<Props> = React.memo(
}, []);
const handleSave = useCallback(() => {
onSystemPromptsChange(updatedSystemPrompts);
}, [onSystemPromptsChange, updatedSystemPrompts]);
const updatedConversations = getUpdatedConversationWithPrompts();
onSystemPromptsChange(updatedSystemPrompts, updatedConversations);
}, [onSystemPromptsChange, updatedSystemPrompts, getUpdatedConversationWithPrompts]);
// useEffects
// Update system prompts on any field change since editing is in place
@ -157,7 +194,11 @@ export const SystemPromptModal: React.FC<Props> = React.memo(
}, [prompt, selectedSystemPrompt]);
return (
<StyledEuiModal onClose={onClose} initialFocus={`.${SYSTEM_PROMPT_SELECTOR_CLASSNAME}`}>
<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>
@ -173,7 +214,11 @@ export const SystemPromptModal: React.FC<Props> = React.memo(
</EuiFormRow>
<EuiFormRow label={i18n.SYSTEM_PROMPT_PROMPT}>
<EuiTextArea onChange={handlePromptTextChange} value={prompt} />
<EuiTextArea
data-test-subj={TEST_IDS.SYSTEM_PROMPT_MODAL.PROMPT_TEXT}
onChange={handlePromptTextChange}
value={prompt}
/>
</EuiFormRow>
<EuiFormRow
@ -189,6 +234,7 @@ export const SystemPromptModal: React.FC<Props> = React.memo(
<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>
@ -205,9 +251,16 @@ export const SystemPromptModal: React.FC<Props> = React.memo(
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onClose}>{i18n.CANCEL}</EuiButtonEmpty>
<EuiButtonEmpty onClick={onClose} data-test-subj={TEST_IDS.SYSTEM_PROMPT_MODAL.CANCEL}>
{i18n.CANCEL}
</EuiButtonEmpty>
<EuiButton type="submit" onClick={handleSave} fill>
<EuiButton
type="submit"
onClick={handleSave}
fill
data-test-subj={TEST_IDS.SYSTEM_PROMPT_MODAL.SAVE}
>
{i18n.SAVE}
</EuiButton>
</EuiModalFooter>

View file

@ -18,6 +18,7 @@ import {
} from '@elastic/eui';
import { css } from '@emotion/react';
import { TEST_IDS } from '../../../../constants';
import { Prompt } from '../../../../../..';
import * as i18n from './translations';
import { SYSTEM_PROMPT_DEFAULT_NEW_CONVERSATION } from '../translations';
@ -54,6 +55,7 @@ export const SystemPromptSelector: React.FC<Props> = React.memo(
isNewConversationDefault: sp.isNewConversationDefault ?? false,
},
label: sp.name,
'data-test-subj': `${TEST_IDS.SYSTEM_PROMPT_SELECTOR}-${sp.id}`,
}))
);
const selectedOptions = useMemo<SystemPromptSelectorOption[]>(() => {
@ -146,6 +148,7 @@ export const SystemPromptSelector: React.FC<Props> = React.memo(
component={'span'}
gutterSize={'none'}
justifyContent="spaceBetween"
data-test-subj="systemPromptOptionSelector"
>
<EuiFlexItem grow={1} component={'span'}>
<EuiFlexGroup alignItems="center" component={'span'} gutterSize={'s'}>
@ -201,6 +204,7 @@ 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}
placeholder={i18n.SYSTEM_PROMPT_SELECTOR}
customOptionText={`${i18n.CUSTOM_OPTION_TEXT} {searchValue}`}

View file

@ -47,7 +47,7 @@ export const SETTINGS_CONNECTOR_TITLE = i18n.translate(
export const SETTINGS_PROMPT_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.settings.promptTitle',
{
defaultMessage: 'System Prompt',
defaultMessage: 'System prompt',
}
);

View file

@ -6,7 +6,6 @@
*/
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/gen_ai/constants';
import { Prompt } from '../assistant/types';
export type ConversationRole = 'system' | 'user' | 'assistant';
@ -46,7 +45,7 @@ export interface ConversationTheme {
export interface Conversation {
apiConfig: {
connectorId?: string;
defaultSystemPrompt?: Prompt;
defaultSystemPromptId?: string;
provider?: OpenAiProviderType;
};
id: string;