[Security Solution] [Elastic AI Assistant] Fixes System Prompt not sending as part of the conversation (#161920)

## Summary

Resolves System Prompt not sending issues:
https://github.com/elastic/kibana/issues/161809

Also resolves:
- [X] Not being able to delete really long Conversation, System Prompt,
and Quick Prompt names
- [X] Fix user/all System Prompts being overridden on refresh
- [X] Conversation without default System Prompt not healed if it is
initial conversation when Assistant opens (Timeline)
- [X] New conversation created from Conversations Settings not getting a
connector by default
- [X] Current conversation not selected by default when settings gear is
clicked (and other assistant instances exist)
- [X] Sent to Timeline action sends anonymized values instead of actual
plaintext
- [X] Clicking Submit does not clear the text area
- [X] Remove System Prompt Tooltip
- [X] Fixes confusion when System or Quick Prompt is empty by adding a
placeholder value
- [X] Shows (empty prompt) in System Prompt selector when the Prompt
content is empty
- [X] Fixes connector error callout flashing on initial load
- [X] Shows `(empty prompt)` text within Prompt Editor when prompt
content is empty to prevent confusion

### Checklist

Delete any items that are not applicable to this PR.

- [X] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Garrett Spong 2023-07-14 17:42:57 -06:00 committed by GitHub
parent 8a56a2bbaa
commit 83a31bfcc0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 830 additions and 331 deletions

View file

@ -33,7 +33,7 @@ interface Props {
defaultConnectorId?: string;
defaultProvider?: OpenAiProviderType;
selectedConversationId: string | undefined;
setSelectedConversationId: React.Dispatch<React.SetStateAction<string>>;
onConversationSelected: (conversationId: string) => void;
shouldDisableKeyboardShortcut?: () => boolean;
isDisabled?: boolean;
}
@ -59,7 +59,7 @@ export const ConversationSelector: React.FC<Props> = React.memo(
selectedConversationId = DEFAULT_CONVERSATION_TITLE,
defaultConnectorId,
defaultProvider,
setSelectedConversationId,
onConversationSelected,
shouldDisableKeyboardShortcut = () => false,
isDisabled = false,
}) => {
@ -109,14 +109,14 @@ export const ConversationSelector: React.FC<Props> = React.memo(
};
setConversation({ conversation: newConversation });
}
setSelectedConversationId(searchValue);
onConversationSelected(searchValue);
},
[
allSystemPrompts,
defaultConnectorId,
defaultProvider,
setConversation,
setSelectedConversationId,
onConversationSelected,
]
);
@ -124,13 +124,13 @@ export const ConversationSelector: React.FC<Props> = React.memo(
const onDelete = useCallback(
(cId: string) => {
if (selectedConversationId === cId) {
setSelectedConversationId(getPreviousConversationId(conversationIds, cId));
onConversationSelected(getPreviousConversationId(conversationIds, cId));
}
setTimeout(() => {
deleteConversation(cId);
}, 0);
},
[conversationIds, deleteConversation, selectedConversationId, setSelectedConversationId]
[conversationIds, deleteConversation, selectedConversationId, onConversationSelected]
);
const onChange = useCallback(
@ -138,20 +138,20 @@ export const ConversationSelector: React.FC<Props> = React.memo(
if (newOptions.length === 0) {
setSelectedOptions([]);
} else if (conversationOptions.findIndex((o) => o.label === newOptions?.[0].label) !== -1) {
setSelectedConversationId(newOptions?.[0].label);
onConversationSelected(newOptions?.[0].label);
}
},
[conversationOptions, setSelectedConversationId]
[conversationOptions, onConversationSelected]
);
const onLeftArrowClick = useCallback(() => {
const prevId = getPreviousConversationId(conversationIds, selectedConversationId);
setSelectedConversationId(prevId);
}, [conversationIds, selectedConversationId, setSelectedConversationId]);
onConversationSelected(prevId);
}, [conversationIds, selectedConversationId, onConversationSelected]);
const onRightArrowClick = useCallback(() => {
const nextId = getNextConversationId(conversationIds, selectedConversationId);
setSelectedConversationId(nextId);
}, [conversationIds, selectedConversationId, setSelectedConversationId]);
onConversationSelected(nextId);
}, [conversationIds, selectedConversationId, onConversationSelected]);
// Register keyboard listener for quick conversation switching
const onKeyDown = useCallback(
@ -200,11 +200,17 @@ export const ConversationSelector: React.FC<Props> = React.memo(
return (
<EuiFlexGroup
alignItems="center"
justifyContent="spaceBetween"
component={'span'}
className={'parentFlexGroup'}
component={'span'}
justifyContent="spaceBetween"
>
<EuiFlexItem grow={false} component={'span'} css={css``}>
<EuiFlexItem
component={'span'}
grow={false}
css={css`
width: calc(100% - 60px);
`}
>
<EuiHighlight
search={searchValue}
css={css`

View file

@ -167,11 +167,17 @@ export const ConversationSelectorSettings: React.FC<Props> = React.memo(
return (
<EuiFlexGroup
alignItems="center"
justifyContent="spaceBetween"
component={'span'}
className={'parentFlexGroup'}
component={'span'}
justifyContent="spaceBetween"
>
<EuiFlexItem grow={false} component={'span'} css={css``}>
<EuiFlexItem
component={'span'}
grow={false}
css={css`
width: calc(100% - 60px);
`}
>
<EuiHighlight
search={searchValue}
css={css`

View file

@ -12,6 +12,7 @@ import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/publ
import { HttpSetup } from '@kbn/core-http-browser';
import { FormattedMessage } from '@kbn/i18n-react';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common';
import { noop } from 'lodash/fp';
import { Conversation, Prompt } from '../../../..';
import * as i18n from './translations';
import * as i18nModel from '../../../connectorland/models/model_selector/translations';
@ -21,12 +22,14 @@ import { SelectSystemPrompt } from '../../prompt_editor/system_prompt/select_sys
import { ModelSelector } from '../../../connectorland/models/model_selector/model_selector';
import { UseAssistantContext } from '../../../assistant_context';
import { ConversationSelectorSettings } from '../conversation_selector_settings';
import { getDefaultSystemPromptFromConversation } from './helpers';
import { getDefaultSystemPrompt } from '../../use_conversation/helpers';
export interface ConversationSettingsProps {
actionTypeRegistry: ActionTypeRegistryContract;
allSystemPrompts: Prompt[];
conversationSettings: UseAssistantContext['conversations'];
defaultConnectorId?: string;
defaultProvider?: OpenAiProviderType;
http: HttpSetup;
onSelectedConversationChange: (conversation?: Conversation) => void;
selectedConversation: Conversation | undefined;
@ -43,6 +46,8 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
({
actionTypeRegistry,
allSystemPrompts,
defaultConnectorId,
defaultProvider,
selectedConversation,
onSelectedConversationChange,
conversationSettings,
@ -50,14 +55,14 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
setUpdatedConversationSettings,
isDisabled = false,
}) => {
// Defaults
const defaultSystemPrompt = useMemo(() => {
return (
allSystemPrompts.find((systemPrompt) => systemPrompt.isNewConversationDefault) ??
allSystemPrompts[0]
);
return getDefaultSystemPrompt({ allSystemPrompts, conversation: undefined });
}, [allSystemPrompts]);
const selectedSystemPrompt = useMemo(() => {
return getDefaultSystemPrompt({ allSystemPrompts, conversation: selectedConversation });
}, [allSystemPrompts, selectedConversation]);
// Conversation callbacks
// When top level conversation selection changes
const onConversationSelectionChange = useCallback(
@ -68,8 +73,8 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
id: c ?? '',
messages: [],
apiConfig: {
connectorId: undefined,
provider: undefined,
connectorId: defaultConnectorId,
provider: defaultProvider,
defaultSystemPromptId: defaultSystemPrompt?.id,
},
}
@ -86,7 +91,13 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
onSelectedConversationChange(newSelectedConversation);
},
[defaultSystemPrompt?.id, onSelectedConversationChange, setUpdatedConversationSettings]
[
defaultConnectorId,
defaultProvider,
defaultSystemPrompt?.id,
onSelectedConversationChange,
setUpdatedConversationSettings,
]
);
const onConversationDeleted = useCallback(
@ -102,18 +113,8 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
[setUpdatedConversationSettings]
);
const selectedSystemPrompt = useMemo(
() =>
getDefaultSystemPromptFromConversation({
allSystemPrompts,
conversation: selectedConversation,
defaultSystemPrompt,
}),
[allSystemPrompts, defaultSystemPrompt, selectedConversation]
);
const handleOnSystemPromptSelectionChange = useCallback(
(systemPromptId?: string) => {
(systemPromptId?: string | undefined) => {
if (selectedConversation != null) {
setUpdatedConversationSettings((prev) => ({
...prev,
@ -215,6 +216,8 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
onSystemPromptSelectionChange={handleOnSystemPromptSelectionChange}
selectedPrompt={selectedSystemPrompt}
showTitles={true}
isSettingsModalVisible={true}
setIsSettingsModalVisible={noop} // noop, already in settings
/>
</EuiFormRow>

View file

@ -1,33 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Conversation, Prompt } from '../../../..';
/**
* Returns a conversation's default system prompt, or the default system prompt if the conversation does not have one.
* @param allSystemPrompts
* @param conversation
* @param defaultSystemPrompt
*/
export const getDefaultSystemPromptFromConversation = ({
allSystemPrompts,
conversation,
defaultSystemPrompt,
}: {
conversation: Conversation | undefined;
allSystemPrompts: Prompt[];
defaultSystemPrompt: Prompt;
}) => {
const convoDefaultSystemPromptId = conversation?.apiConfig.defaultSystemPromptId;
if (convoDefaultSystemPromptId && allSystemPrompts) {
return (
allSystemPrompts.find((prompt) => prompt.id === convoDefaultSystemPromptId) ??
defaultSystemPrompt
);
}
return allSystemPrompts.find((prompt) => prompt.isNewConversationDefault) ?? defaultSystemPrompt;
};

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import { getWelcomeConversation } from './helpers';
import { getDefaultConnector, getWelcomeConversation } from './helpers';
import { enterpriseMessaging } from './use_conversation/sample_conversations';
import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public';
describe('getWelcomeConversation', () => {
describe('isAssistantEnabled = false', () => {
@ -112,4 +113,81 @@ describe('getWelcomeConversation', () => {
expect(result.messages.length).toEqual(4);
});
});
describe('getDefaultConnector', () => {
it('should return undefined if connectors array is undefined', () => {
const connectors = undefined;
const result = getDefaultConnector(connectors);
expect(result).toBeUndefined();
});
it('should return undefined if connectors array is empty', () => {
const connectors: Array<ActionConnector<Record<string, unknown>, Record<string, unknown>>> =
[];
const result = getDefaultConnector(connectors);
expect(result).toBeUndefined();
});
it('should return the connector id if there is only one connector', () => {
const connectors: Array<ActionConnector<Record<string, unknown>, Record<string, unknown>>> = [
{
actionTypeId: '.gen-ai',
isPreconfigured: false,
isDeprecated: false,
referencedByCount: 0,
isMissingSecrets: false,
isSystemAction: false,
secrets: {},
id: 'c5f91dc0-2197-11ee-aded-897192c5d6f5',
name: 'OpenAI',
config: {
apiProvider: 'OpenAI',
apiUrl: 'https://api.openai.com/v1/chat/completions',
},
},
];
const result = getDefaultConnector(connectors);
expect(result).toBe(connectors[0]);
});
it('should return undefined if there are multiple connectors', () => {
const connectors: Array<ActionConnector<Record<string, unknown>, Record<string, unknown>>> = [
{
actionTypeId: '.gen-ai',
isPreconfigured: false,
isDeprecated: false,
referencedByCount: 0,
isMissingSecrets: false,
isSystemAction: false,
secrets: {},
id: 'c5f91dc0-2197-11ee-aded-897192c5d6f5',
name: 'OpenAI',
config: {
apiProvider: 'OpenAI 1',
apiUrl: 'https://api.openai.com/v1/chat/completions',
},
},
{
actionTypeId: '.gen-ai',
isPreconfigured: false,
isDeprecated: false,
referencedByCount: 0,
isMissingSecrets: false,
isSystemAction: false,
secrets: {},
id: 'c7f91dc0-2197-11ee-aded-897192c5d633',
name: 'OpenAI',
config: {
apiProvider: 'OpenAI 2',
apiUrl: 'https://api.openai.com/v1/chat/completions',
},
},
];
const result = getDefaultConnector(connectors);
expect(result).toBeUndefined();
});
});
});

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public';
import { Conversation } from '../..';
import type { Message } from '../assistant_context/types';
import { enterpriseMessaging, WELCOME_CONVERSATION } from './use_conversation/sample_conversations';
@ -49,3 +50,12 @@ export const getWelcomeConversation = (
messages: [...conversation.messages, ...WELCOME_CONVERSATION.messages],
};
};
/**
* Returns a default connector if there is only one connector
* @param connectors
*/
export const getDefaultConnector = (
connectors: Array<ActionConnector<Record<string, unknown>, Record<string, unknown>>> | undefined
): ActionConnector<Record<string, unknown>, Record<string, unknown>> | undefined =>
connectors?.length === 1 ? connectors[0] : undefined;

View file

@ -29,7 +29,7 @@ import { ActionConnectorProps } from '@kbn/triggers-actions-ui-plugin/public/typ
import { WELCOME_CONVERSATION_TITLE } from './use_conversation/translations';
import { AssistantTitle } from './assistant_title';
import { UpgradeButtons } from '../upgrade/upgrade_buttons';
import { getMessageFromRawResponse, getWelcomeConversation } from './helpers';
import { getDefaultConnector, getMessageFromRawResponse, getWelcomeConversation } from './helpers';
import { useAssistantContext } from '../assistant_context';
import { ContextPills } from './context_pills';
@ -37,7 +37,7 @@ import { getNewSelectedPromptContext } from '../data_anonymization/get_new_selec
import { PromptTextArea } from './prompt_textarea';
import type { PromptContext, SelectedPromptContext } from './prompt_context/types';
import { useConversation } from './use_conversation';
import { CodeBlockDetails } from './use_conversation/helpers';
import { CodeBlockDetails, getDefaultSystemPrompt } from './use_conversation/helpers';
import { useSendMessages } from './use_send_messages';
import type { Message } from '../assistant_context/types';
import { ConversationSelector } from './conversations/conversation_selector';
@ -103,11 +103,15 @@ const AssistantComponent: React.FC<Props> = ({
isSuccess: areConnectorsFetched,
refetch: refetchConnectors,
} = useLoadConnectors({ http });
const defaultConnectorId = useMemo(() => connectors?.[0]?.id, [connectors]);
const defaultConnectorId = useMemo(() => getDefaultConnector(connectors)?.id, [connectors]);
const defaultProvider = useMemo(
() =>
(connectors?.[0] as ActionConnectorProps<{ apiProvider: OpenAiProviderType }, unknown>)
?.config?.apiProvider,
(
getDefaultConnector(connectors) as ActionConnectorProps<
{ apiProvider: OpenAiProviderType },
unknown
>
)?.config?.apiProvider,
[connectors]
);
@ -145,6 +149,9 @@ const AssistantComponent: React.FC<Props> = ({
[currentConversation, isAssistantEnabled]
);
// Settings modal state (so it isn't shared between assistant instances like Timeline)
const [isSettingsModalVisible, setIsSettingsModalVisible] = useState(false);
// Remember last selection for reuse after keyboard shortcut is pressed.
// Clear it if there is no connectors
useEffect(() => {
@ -178,7 +185,7 @@ const AssistantComponent: React.FC<Props> = ({
const [promptTextPreview, setPromptTextPreview] = useState<string>('');
const [autoPopulatedOnce, setAutoPopulatedOnce] = useState<boolean>(false);
const [suggestedUserPrompt, setSuggestedUserPrompt] = useState<string | null>(null);
const [userPrompt, setUserPrompt] = useState<string | null>(null);
const [showMissingConnectorCallout, setShowMissingConnectorCallout] = useState<boolean>(false);
@ -234,13 +241,29 @@ const AssistantComponent: React.FC<Props> = ({
////
//
const selectedSystemPrompt = useMemo(() => {
if (currentConversation.apiConfig.defaultSystemPromptId) {
return allSystemPrompts.find(
(prompt) => prompt.id === currentConversation.apiConfig.defaultSystemPromptId
const selectedSystemPrompt = useMemo(
() => getDefaultSystemPrompt({ allSystemPrompts, conversation: currentConversation }),
[allSystemPrompts, currentConversation]
);
const [editingSystemPromptId, setEditingSystemPromptId] = useState<string | undefined>(
selectedSystemPrompt?.id
);
const handleOnConversationSelected = useCallback(
(cId: string) => {
setSelectedConversationId(cId);
setEditingSystemPromptId(
getDefaultSystemPrompt({ allSystemPrompts, conversation: conversations[cId] })?.id
);
}
}, [allSystemPrompts, currentConversation.apiConfig.defaultSystemPromptId]);
},
[allSystemPrompts, conversations]
);
const handlePromptChange = useCallback((prompt: string) => {
setPromptTextPreview(prompt);
setUserPrompt(prompt);
}, []);
// Handles sending latest user prompt to API
const handleSendMessage = useCallback(
@ -251,13 +274,15 @@ const AssistantComponent: React.FC<Props> = ({
replacements: newReplacements,
});
const systemPrompt = allSystemPrompts.find((prompt) => prompt.id === editingSystemPromptId);
const message = await getCombinedMessage({
isNewChat: currentConversation.messages.length === 0,
currentReplacements: currentConversation.replacements,
onNewReplacements,
promptText,
selectedPromptContexts,
selectedSystemPrompt,
selectedSystemPrompt: systemPrompt,
});
const updatedMessages = appendMessage({
@ -278,23 +303,42 @@ const AssistantComponent: React.FC<Props> = ({
appendMessage({ conversationId: selectedConversationId, message: responseMessage });
},
[
selectedSystemPrompt,
appendMessage,
appendReplacements,
currentConversation.apiConfig,
allSystemPrompts,
currentConversation.messages.length,
currentConversation.replacements,
http,
selectedConversationId,
currentConversation.apiConfig,
selectedPromptContexts,
appendMessage,
selectedConversationId,
sendMessages,
http,
appendReplacements,
editingSystemPromptId,
]
);
const handleButtonSendMessage = useCallback(() => {
handleSendMessage(promptTextAreaRef.current?.value?.trim() ?? '');
setUserPrompt('');
}, [handleSendMessage, promptTextAreaRef]);
const handleOnSystemPromptSelectionChange = useCallback((systemPromptId?: string) => {
setEditingSystemPromptId(systemPromptId);
}, []);
const handleOnChatCleared = useCallback(() => {
const defaultSystemPromptId = getDefaultSystemPrompt({
allSystemPrompts,
conversation: conversations[selectedConversationId],
})?.id;
setPromptTextPreview('');
setUserPrompt('');
setSelectedPromptContexts({});
clearConversation(selectedConversationId);
setEditingSystemPromptId(defaultSystemPromptId);
}, [allSystemPrompts, clearConversation, conversations, selectedConversationId]);
const shouldDisableConversationSelectorHotkeys = useCallback(() => {
const promptTextAreaHasFocus = document.activeElement === promptTextAreaRef.current;
return promptTextAreaHasFocus;
@ -347,7 +391,7 @@ const AssistantComponent: React.FC<Props> = ({
}
if (promptContext.suggestedUserPrompt != null) {
setSuggestedUserPrompt(promptContext.suggestedUserPrompt);
setUserPrompt(promptContext.suggestedUserPrompt);
}
}
}, [
@ -398,16 +442,21 @@ const AssistantComponent: React.FC<Props> = ({
`}
/>
<EuiSpacer size={'m'} />
{currentConversation.messages.length !== 0 &&
Object.keys(selectedPromptContexts).length > 0 && <EuiSpacer size={'m'} />}
{(currentConversation.messages.length === 0 ||
Object.keys(selectedPromptContexts).length > 0) && (
<PromptEditor
conversation={currentConversation}
editingSystemPromptId={editingSystemPromptId}
isNewConversation={currentConversation.messages.length === 0}
isSettingsModalVisible={isSettingsModalVisible}
promptContexts={promptContexts}
promptTextPreview={promptTextPreview}
onSystemPromptSelectionChange={handleOnSystemPromptSelectionChange}
selectedPromptContexts={selectedPromptContexts}
setIsSettingsModalVisible={setIsSettingsModalVisible}
setSelectedPromptContexts={setSelectedPromptContexts}
/>
)}
@ -417,7 +466,10 @@ const AssistantComponent: React.FC<Props> = ({
),
[
currentConversation,
editingSystemPromptId,
getComments,
handleOnSystemPromptSelectionChange,
isSettingsModalVisible,
promptContexts,
promptTextPreview,
selectedPromptContexts,
@ -474,7 +526,7 @@ const AssistantComponent: React.FC<Props> = ({
defaultConnectorId={defaultConnectorId}
defaultProvider={defaultProvider}
selectedConversationId={selectedConversationId}
setSelectedConversationId={setSelectedConversationId}
onConversationSelected={handleOnConversationSelected}
shouldDisableKeyboardShortcut={shouldDisableConversationSelectorHotkeys}
isDisabled={isDisabled}
/>
@ -504,8 +556,12 @@ const AssistantComponent: React.FC<Props> = ({
<EuiFlexItem grow={false}>
<AssistantSettingsButton
defaultConnectorId={defaultConnectorId}
defaultProvider={defaultProvider}
isDisabled={isDisabled}
isSettingsModalVisible={isSettingsModalVisible}
selectedConversation={currentConversation}
setIsSettingsModalVisible={setIsSettingsModalVisible}
setSelectedConversationId={setSelectedConversationId}
/>
</EuiFlexItem>
@ -536,12 +592,15 @@ const AssistantComponent: React.FC<Props> = ({
<EuiModalBody>
{comments}
{!isDisabled && showMissingConnectorCallout && (
{!isDisabled && showMissingConnectorCallout && areConnectorsFetched && (
<>
<EuiSpacer />
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<ConnectorMissingCallout />
<ConnectorMissingCallout
isSettingsModalVisible={isSettingsModalVisible}
setIsSettingsModalVisible={setIsSettingsModalVisible}
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
@ -585,8 +644,8 @@ const AssistantComponent: React.FC<Props> = ({
<PromptTextArea
onPromptSubmit={handleSendMessage}
ref={promptTextAreaRef}
handlePromptChange={setPromptTextPreview}
value={isSendingDisabled ? '' : suggestedUserPrompt ?? ''}
handlePromptChange={handlePromptChange}
value={isSendingDisabled ? '' : userPrompt ?? ''}
isDisabled={isSendingDisabled}
/>
</EuiFlexItem>
@ -614,12 +673,7 @@ const AssistantComponent: React.FC<Props> = ({
isDisabled={isSendingDisabled}
aria-label={i18n.CLEAR_CHAT}
color="danger"
onClick={() => {
setPromptTextPreview('');
clearConversation(selectedConversationId);
setSelectedPromptContexts({});
setSuggestedUserPrompt('');
}}
onClick={handleOnChatCleared}
/>
</EuiToolTip>
</EuiFlexItem>
@ -639,7 +693,12 @@ const AssistantComponent: React.FC<Props> = ({
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
{!isDisabled && <QuickPrompts setInput={setSuggestedUserPrompt} />}
{!isDisabled && (
<QuickPrompts
setInput={setUserPrompt}
setIsSettingsModalVisible={setIsSettingsModalVisible}
/>
)}
</EuiModalFooter>
</>
);

View file

@ -29,20 +29,24 @@ const mockSelectedEventPromptContext: SelectedPromptContext = {
const defaultProps: Props = {
conversation: undefined,
editingSystemPromptId: undefined,
isNewConversation: true,
isSettingsModalVisible: false,
onSystemPromptSelectionChange: jest.fn(),
promptContexts: {
[mockAlertPromptContext.id]: mockAlertPromptContext,
[mockEventPromptContext.id]: mockEventPromptContext,
},
promptTextPreview: 'Preview text',
selectedPromptContexts: {},
setIsSettingsModalVisible: jest.fn(),
setSelectedPromptContexts: jest.fn(),
};
describe('PromptEditorComponent', () => {
beforeEach(() => jest.clearAllMocks());
it('renders the system prompt viewer when isNewConversation is true', async () => {
it('renders the system prompt selector when isNewConversation is true', async () => {
render(
<TestProviders>
<PromptEditor {...defaultProps} />
@ -50,7 +54,7 @@ describe('PromptEditorComponent', () => {
);
await waitFor(() => {
expect(screen.getByTestId('systemPromptText')).toBeInTheDocument();
expect(screen.getByTestId('selectSystemPrompt')).toBeInTheDocument();
});
});

View file

@ -19,10 +19,14 @@ import { SelectedPromptContexts } from './selected_prompt_contexts';
export interface Props {
conversation: Conversation | undefined;
editingSystemPromptId: string | undefined;
isNewConversation: boolean;
isSettingsModalVisible: boolean;
promptContexts: Record<string, PromptContext>;
promptTextPreview: string;
onSystemPromptSelectionChange: (systemPromptId: string | undefined) => void;
selectedPromptContexts: Record<string, SelectedPromptContext>;
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedPromptContexts: React.Dispatch<
React.SetStateAction<Record<string, SelectedPromptContext>>
>;
@ -34,16 +38,28 @@ const PreviewText = styled(EuiText)`
const PromptEditorComponent: React.FC<Props> = ({
conversation,
editingSystemPromptId,
isNewConversation,
isSettingsModalVisible,
promptContexts,
promptTextPreview,
onSystemPromptSelectionChange,
selectedPromptContexts,
setIsSettingsModalVisible,
setSelectedPromptContexts,
}) => {
const commentBody = useMemo(
() => (
<>
{isNewConversation && <SystemPrompt conversation={conversation} />}
{isNewConversation && (
<SystemPrompt
conversation={conversation}
editingSystemPromptId={editingSystemPromptId}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
isSettingsModalVisible={isSettingsModalVisible}
setIsSettingsModalVisible={setIsSettingsModalVisible}
/>
)}
<SelectedPromptContexts
isNewConversation={isNewConversation}
@ -59,10 +75,14 @@ const PromptEditorComponent: React.FC<Props> = ({
),
[
conversation,
editingSystemPromptId,
isNewConversation,
isSettingsModalVisible,
onSystemPromptSelectionChange,
promptContexts,
promptTextPreview,
selectedPromptContexts,
setIsSettingsModalVisible,
setSelectedPromptContexts,
]
);

View file

@ -12,7 +12,9 @@ import React from 'react';
import styled from 'styled-components';
import { css } from '@emotion/react';
import { isEmpty } from 'lodash/fp';
import type { Prompt } from '../../types';
import { EMPTY_PROMPT } from './translations';
const Strong = styled.strong`
margin-right: ${({ theme }) => theme.eui.euiSizeS};
@ -44,9 +46,10 @@ export const getOptionFromPrompt = ({
<>
<Strong data-test-subj="name">{name}</Strong>
<EuiToolTip content={content}>
{/* Empty content tooltip gets around :hover styles from SuperSelectOptionButton */}
<EuiToolTip content={undefined}>
<EuiText color="subdued" data-test-subj="content" size="s">
<p>{content}</p>
{isEmpty(content) ? <p>{EMPTY_PROMPT}</p> : <p>{content}</p>}
</EuiText>
</EuiToolTip>
</>

View file

@ -60,6 +60,11 @@ jest.mock('../../use_conversation', () => {
});
describe('SystemPrompt', () => {
const editingSystemPromptId = undefined;
const isSettingsModalVisible = false;
const onSystemPromptSelectionChange = jest.fn();
const setIsSettingsModalVisible = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
@ -72,33 +77,49 @@ describe('SystemPrompt', () => {
});
});
describe('when conversation is undefined and default prompt is used', () => {
describe('when conversation is undefined', () => {
const conversation = undefined;
beforeEach(() => {
render(<SystemPrompt conversation={conversation} />);
render(
<SystemPrompt
conversation={conversation}
editingSystemPromptId={editingSystemPromptId}
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
setIsSettingsModalVisible={setIsSettingsModalVisible}
/>
);
});
it('does render the system prompt fallback text', () => {
expect(screen.getByTestId('systemPromptText')).toBeInTheDocument();
it('renders the system prompt select', () => {
expect(screen.getByTestId('selectSystemPrompt')).toBeInTheDocument();
});
it('does NOT render the system prompt select', () => {
expect(screen.queryByTestId('selectSystemPrompt')).not.toBeInTheDocument();
it('does NOT render the system prompt text', () => {
expect(screen.queryByTestId('systemPromptText')).not.toBeInTheDocument();
});
it('does render the edit button', () => {
expect(screen.getByTestId('edit')).toBeInTheDocument();
it('does NOT render the edit button', () => {
expect(screen.queryByTestId('edit')).not.toBeInTheDocument();
});
it('does render the clear button', () => {
expect(screen.getByTestId('clear')).toBeInTheDocument();
it('does NOT render the clear button', () => {
expect(screen.queryByTestId('clear')).not.toBeInTheDocument();
});
});
describe('when conversation is NOT null', () => {
beforeEach(() => {
render(<SystemPrompt conversation={BASE_CONVERSATION} />);
render(
<SystemPrompt
conversation={BASE_CONVERSATION}
editingSystemPromptId={BASE_CONVERSATION.id}
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
setIsSettingsModalVisible={setIsSettingsModalVisible}
/>
);
});
it('does NOT render the system prompt select', () => {
@ -125,7 +146,13 @@ describe('SystemPrompt', () => {
const customPromptText = 'custom prompt text';
render(
<TestProviders>
<SystemPrompt conversation={BASE_CONVERSATION} />
<SystemPrompt
conversation={BASE_CONVERSATION}
editingSystemPromptId={editingSystemPromptId}
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
setIsSettingsModalVisible={setIsSettingsModalVisible}
/>
</TestProviders>
);
userEvent.click(screen.getByTestId('edit'));
@ -165,7 +192,13 @@ describe('SystemPrompt', () => {
const customPromptText = 'custom prompt text';
render(
<TestProviders>
<SystemPrompt conversation={BASE_CONVERSATION} />
<SystemPrompt
conversation={BASE_CONVERSATION}
editingSystemPromptId={editingSystemPromptId}
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
setIsSettingsModalVisible={setIsSettingsModalVisible}
/>
</TestProviders>
);
userEvent.click(screen.getByTestId('edit'));
@ -219,7 +252,13 @@ describe('SystemPrompt', () => {
const customPromptText = 'custom prompt text';
render(
<TestProviders>
<SystemPrompt conversation={BASE_CONVERSATION} />
<SystemPrompt
conversation={BASE_CONVERSATION}
editingSystemPromptId={editingSystemPromptId}
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
setIsSettingsModalVisible={setIsSettingsModalVisible}
/>
</TestProviders>
);
userEvent.click(screen.getByTestId('edit'));
@ -280,7 +319,13 @@ describe('SystemPrompt', () => {
it('should save new prompt correctly when prompt is removed from selected conversation', async () => {
render(
<TestProviders>
<SystemPrompt conversation={BASE_CONVERSATION} />
<SystemPrompt
conversation={BASE_CONVERSATION}
editingSystemPromptId={editingSystemPromptId}
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
setIsSettingsModalVisible={setIsSettingsModalVisible}
/>
</TestProviders>
);
userEvent.click(screen.getByTestId('edit'));
@ -351,7 +396,13 @@ describe('SystemPrompt', () => {
render(
<TestProviders>
<SystemPrompt conversation={BASE_CONVERSATION} />
<SystemPrompt
conversation={BASE_CONVERSATION}
editingSystemPromptId={editingSystemPromptId}
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
setIsSettingsModalVisible={setIsSettingsModalVisible}
/>
</TestProviders>
);
userEvent.click(screen.getByTestId('edit'));
@ -413,7 +464,13 @@ describe('SystemPrompt', () => {
it('shows the system prompt select when the edit button is clicked', () => {
render(
<TestProviders>
<SystemPrompt conversation={BASE_CONVERSATION} />
<SystemPrompt
conversation={BASE_CONVERSATION}
editingSystemPromptId={BASE_CONVERSATION.id}
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
setIsSettingsModalVisible={setIsSettingsModalVisible}
/>
</TestProviders>
);
@ -425,7 +482,13 @@ describe('SystemPrompt', () => {
it('shows the system prompt select when system prompt text is clicked', () => {
render(
<TestProviders>
<SystemPrompt conversation={BASE_CONVERSATION} />
<SystemPrompt
conversation={BASE_CONVERSATION}
editingSystemPromptId={BASE_CONVERSATION.id}
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
setIsSettingsModalVisible={setIsSettingsModalVisible}
/>
</TestProviders>
);

View file

@ -6,38 +6,50 @@
*/
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useMemo } from 'react';
import { css } from '@emotion/react';
import { isEmpty } from 'lodash/fp';
import { useAssistantContext } from '../../../assistant_context';
import { Conversation, Prompt } from '../../../..';
import { Conversation } from '../../../..';
import * as i18n from './translations';
import { SelectSystemPrompt } from './select_system_prompt';
interface Props {
conversation: Conversation | undefined;
editingSystemPromptId: string | undefined;
isSettingsModalVisible: boolean;
onSystemPromptSelectionChange: (systemPromptId: string | undefined) => void;
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
}
const SystemPromptComponent: React.FC<Props> = ({ conversation }) => {
const SystemPromptComponent: React.FC<Props> = ({
conversation,
editingSystemPromptId,
isSettingsModalVisible,
onSystemPromptSelectionChange,
setIsSettingsModalVisible,
}) => {
const { allSystemPrompts } = useAssistantContext();
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 selectedPrompt = useMemo(() => {
if (editingSystemPromptId !== undefined) {
return (
allSystemPrompts?.find((p) => p.id === editingSystemPromptId) ??
allSystemPrompts?.find((p) => p.id === conversation?.apiConfig.defaultSystemPromptId)
);
} else {
return undefined;
}
}, [allSystemPrompts, conversation?.apiConfig.defaultSystemPromptId, editingSystemPromptId]);
const [isEditing, setIsEditing] = React.useState<boolean>(false);
const handleClearSystemPrompt = useCallback(() => {
setSelectedPrompt(undefined);
}, []);
if (conversation) {
onSystemPromptSelectionChange(undefined);
}
}, [conversation, onSystemPromptSelectionChange]);
const handleEditSystemPrompt = useCallback(() => setIsEditing(true), []);
@ -52,8 +64,11 @@ const SystemPromptComponent: React.FC<Props> = ({ conversation }) => {
isClearable={true}
isEditing={isEditing}
isOpen={isEditing}
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
selectedPrompt={selectedPrompt}
setIsEditing={setIsEditing}
setIsSettingsModalVisible={setIsSettingsModalVisible}
/>
) : (
<EuiFlexGroup alignItems="flexStart" gutterSize="none">
@ -70,7 +85,7 @@ const SystemPromptComponent: React.FC<Props> = ({ conversation }) => {
}
`}
>
{selectedPrompt?.content ?? ''}
{isEmpty(selectedPrompt?.content) ? i18n.EMPTY_PROMPT : selectedPrompt?.content}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>

View file

@ -24,7 +24,9 @@ const props: Props = {
},
],
conversation: undefined,
isSettingsModalVisible: false,
selectedPrompt: undefined,
setIsSettingsModalVisible: jest.fn(),
};
const mockUseAssistantContext = {

View file

@ -37,9 +37,11 @@ export interface Props {
isEditing?: boolean;
isDisabled?: boolean;
isOpen?: boolean;
isSettingsModalVisible: boolean;
setIsEditing?: React.Dispatch<React.SetStateAction<boolean>>;
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
showTitles?: boolean;
onSystemPromptSelectionChange?: (promptId: string) => void;
onSystemPromptSelectionChange?: (promptId: string | undefined) => void;
}
const ADD_NEW_SYSTEM_PROMPT = 'ADD_NEW_SYSTEM_PROMPT';
@ -54,12 +56,13 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
isEditing = false,
isDisabled = false,
isOpen = false,
isSettingsModalVisible,
onSystemPromptSelectionChange,
setIsEditing,
setIsSettingsModalVisible,
showTitles = false,
}) => {
const { isSettingsModalVisible, setIsSettingsModalVisible, setSelectedSettingsTab } =
useAssistantContext();
const { setSelectedSettingsTab } = useAssistantContext();
const { setApiConfig } = useConversation();
const [isOpenLocal, setIsOpenLocal] = useState<boolean>(isOpen);

View file

@ -148,25 +148,33 @@ export const SystemPromptSelector: React.FC<Props> = React.memo(
alignItems="center"
className={'parentFlexGroup'}
component={'span'}
gutterSize={'none'}
justifyContent="spaceBetween"
data-test-subj="systemPromptOptionSelector"
>
<EuiFlexItem grow={1} component={'span'}>
<EuiFlexItem
grow={false}
component={'span'}
css={css`
width: calc(100% - 60px);
`}
>
<EuiFlexGroup alignItems="center" component={'span'} gutterSize={'s'}>
<EuiFlexItem grow={false} component={'span'}>
<span className={contentClassName}>
<EuiHighlight
search={searchValue}
css={css`
overflow: hidden;
text-overflow: ellipsis;
max-width: 70%;
`}
>
{label}
</EuiHighlight>
</span>
<EuiFlexItem
component={'span'}
grow={false}
css={css`
max-width: 100%;
`}
>
<EuiHighlight
search={searchValue}
css={css`
overflow: hidden;
text-overflow: ellipsis;
`}
>
{label}
</EuiHighlight>
</EuiFlexItem>
{value?.isNewConversationDefault && (
<EuiFlexItem grow={false} component={'span'}>
@ -179,7 +187,7 @@ export const SystemPromptSelector: React.FC<Props> = React.memo(
</EuiFlexItem>
{!value?.isDefault && (
<EuiFlexItem grow={2} component={'span'}>
<EuiFlexItem grow={false} component={'span'}>
<EuiToolTip position="right" content={i18n.DELETE_SYSTEM_PROMPT}>
<EuiButtonIcon
iconType="cross"

View file

@ -216,6 +216,7 @@ export const SystemPromptSettings: React.FC<Props> = React.memo(
data-test-subj={TEST_IDS.SYSTEM_PROMPT_MODAL.PROMPT_TEXT}
disabled={selectedSystemPrompt == null}
onChange={handlePromptContentChange}
placeholder={i18n.SYSTEM_PROMPT_PROMPT_PLACEHOLDER}
value={promptContent}
compressed
fullWidth

View file

@ -41,6 +41,13 @@ export const SYSTEM_PROMPT_PROMPT = i18n.translate(
}
);
export const SYSTEM_PROMPT_PROMPT_PLACEHOLDER = i18n.translate(
'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.promptPlaceholder',
{
defaultMessage: 'Enter a System Prompt',
}
);
export const SYSTEM_PROMPT_DEFAULT_CONVERSATIONS = i18n.translate(
'xpack.elasticAssistant.assistant.promptEditor.systemPrompt.settings.defaultConversationsLabel',
{

View file

@ -34,3 +34,10 @@ export const ADD_NEW_SYSTEM_PROMPT = i18n.translate(
defaultMessage: 'Add new system prompt...',
}
);
export const EMPTY_PROMPT = i18n.translate(
'xpack.elasticAssistant.assistant.firstPromptEditor.emptyPrompt',
{
defaultMessage: '(empty prompt)',
}
);

View file

@ -13,7 +13,7 @@ import styled from 'styled-components';
import * as i18n from './translations';
export interface Props extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
handlePromptChange?: (value: string) => void;
handlePromptChange: (value: string) => void;
isDisabled?: boolean;
onPromptSubmit: (value: string) => void;
value: string;
@ -26,35 +26,30 @@ const StyledTextArea = styled(EuiTextArea)`
export const PromptTextArea = forwardRef<HTMLTextAreaElement, Props>(
({ isDisabled = false, value, onPromptSubmit, handlePromptChange, ...props }, ref) => {
const [currentValue, setCurrentValue] = React.useState(value);
const onChangeCallback = useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
setCurrentValue(event.target.value);
if (handlePromptChange) {
handlePromptChange(event.target.value);
}
handlePromptChange(event.target.value);
},
[handlePromptChange]
);
const onKeyDown = useCallback(
(event) => {
if (event.key === 'Enter' && !event.shiftKey && currentValue.trim().length > 0) {
if (event.key === 'Enter' && !event.shiftKey && value.trim().length > 0) {
event.preventDefault();
onPromptSubmit(event.target.value?.trim());
setCurrentValue('');
} else if (event.key === 'Enter' && !event.shiftKey && currentValue.trim().length === 0) {
handlePromptChange('');
} else if (event.key === 'Enter' && !event.shiftKey && value.trim().length === 0) {
event.preventDefault();
event.stopPropagation();
}
},
[currentValue, onPromptSubmit]
[value, onPromptSubmit, handlePromptChange]
);
useEffect(() => {
setCurrentValue(value);
}, [value]);
handlePromptChange(value);
}, [handlePromptChange, value]);
return (
<StyledTextArea
@ -66,7 +61,7 @@ export const PromptTextArea = forwardRef<HTMLTextAreaElement, Props>(
autoFocus
disabled={isDisabled}
placeholder={i18n.PROMPT_PLACEHOLDER}
value={currentValue}
value={value}
onChange={onChangeCallback}
onKeyDown={onKeyDown}
/>

View file

@ -138,14 +138,24 @@ export const QuickPromptSelector: React.FC<Props> = React.memo(
return (
<EuiFlexGroup
alignItems="center"
justifyContent="spaceBetween"
className={'parentFlexGroup'}
component={'span'}
justifyContent="spaceBetween"
>
<EuiFlexItem grow={false}>
<EuiHealth color={color}>
<span className={contentClassName}>
<EuiHighlight search={searchValue}>{label}</EuiHighlight>
</span>
<EuiFlexItem
component={'span'}
grow={false}
css={css`
width: calc(100% - 60px);
`}
>
<EuiHealth
color={color}
css={css`
overflow: hidden;
`}
>
<EuiHighlight search={searchValue}>{label}</EuiHighlight>
</EuiHealth>
</EuiFlexItem>
{!value?.isDefault && (

View file

@ -198,6 +198,7 @@ export const QuickPromptSettings: React.FC<Props> = React.memo<Props>(
disabled={selectedQuickPrompt == null}
fullWidth
onChange={handlePromptChange}
placeholder={i18n.QUICK_PROMPT_PROMPT_PLACEHOLDER}
value={prompt}
css={css`
min-height: 150px;

View file

@ -42,6 +42,13 @@ export const QUICK_PROMPT_PROMPT = i18n.translate(
}
);
export const QUICK_PROMPT_PROMPT_PLACEHOLDER = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.settings.promptPlaceholder',
{
defaultMessage: 'Enter a Quick Prompt',
}
);
export const QUICK_PROMPT_BADGE_COLOR = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.settings.badgeColorLabel',
{

View file

@ -21,6 +21,7 @@ const QuickPromptsFlexGroup = styled(EuiFlexGroup)`
const COUNT_BEFORE_OVERFLOW = 5;
interface QuickPromptsProps {
setInput: (input: string) => void;
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
}
/**
@ -28,95 +29,96 @@ interface QuickPromptsProps {
* text, and support for adding new quick prompts and editing existing. Also supports overflow of quick prompts,
* and localstorage for storing new and edited prompts.
*/
export const QuickPrompts: React.FC<QuickPromptsProps> = React.memo(({ setInput }) => {
const { allQuickPrompts, promptContexts, setIsSettingsModalVisible, setSelectedSettingsTab } =
useAssistantContext();
export const QuickPrompts: React.FC<QuickPromptsProps> = React.memo(
({ setInput, setIsSettingsModalVisible }) => {
const { allQuickPrompts, promptContexts, setSelectedSettingsTab } = useAssistantContext();
const contextFilteredQuickPrompts = useMemo(() => {
const registeredPromptContextTitles = Object.values(promptContexts).map((pc) => pc.category);
return allQuickPrompts.filter((quickPrompt) => {
// Return quick prompt as match if it has no categories, otherwise ensure category exists in registered prompt contexts
if (quickPrompt.categories == null || quickPrompt.categories.length === 0) {
return true;
} else {
return quickPrompt.categories.some((category) => {
return registeredPromptContextTitles.includes(category);
});
}
});
}, [allQuickPrompts, promptContexts]);
const contextFilteredQuickPrompts = useMemo(() => {
const registeredPromptContextTitles = Object.values(promptContexts).map((pc) => pc.category);
return allQuickPrompts.filter((quickPrompt) => {
// Return quick prompt as match if it has no categories, otherwise ensure category exists in registered prompt contexts
if (quickPrompt.categories == null || quickPrompt.categories.length === 0) {
return true;
} else {
return quickPrompt.categories.some((category) => {
return registeredPromptContextTitles.includes(category);
});
}
});
}, [allQuickPrompts, promptContexts]);
// Overflow state
const [isOverflowPopoverOpen, setIsOverflowPopoverOpen] = useState(false);
const toggleOverflowPopover = useCallback(
() => setIsOverflowPopoverOpen(!isOverflowPopoverOpen),
[isOverflowPopoverOpen]
);
const closeOverflowPopover = useCallback(() => setIsOverflowPopoverOpen(false), []);
// Overflow state
const [isOverflowPopoverOpen, setIsOverflowPopoverOpen] = useState(false);
const toggleOverflowPopover = useCallback(
() => setIsOverflowPopoverOpen(!isOverflowPopoverOpen),
[isOverflowPopoverOpen]
);
const closeOverflowPopover = useCallback(() => setIsOverflowPopoverOpen(false), []);
const onClickOverflowQuickPrompt = useCallback(
(prompt: string) => {
setInput(prompt);
closeOverflowPopover();
},
[closeOverflowPopover, setInput]
);
const onClickOverflowQuickPrompt = useCallback(
(prompt: string) => {
setInput(prompt);
closeOverflowPopover();
},
[closeOverflowPopover, setInput]
);
const showQuickPromptSettings = useCallback(() => {
setIsSettingsModalVisible(true);
setSelectedSettingsTab(QUICK_PROMPTS_TAB);
}, [setIsSettingsModalVisible, setSelectedSettingsTab]);
const showQuickPromptSettings = useCallback(() => {
setIsSettingsModalVisible(true);
setSelectedSettingsTab(QUICK_PROMPTS_TAB);
}, [setIsSettingsModalVisible, setSelectedSettingsTab]);
return (
<QuickPromptsFlexGroup gutterSize="s" alignItems="center">
{contextFilteredQuickPrompts.slice(0, COUNT_BEFORE_OVERFLOW).map((badge, index) => (
<EuiFlexItem key={index} grow={false}>
<EuiBadge
color={badge.color}
onClick={() => setInput(badge.prompt)}
onClickAriaLabel={badge.title}
>
{badge.title}
</EuiBadge>
</EuiFlexItem>
))}
{contextFilteredQuickPrompts.length > COUNT_BEFORE_OVERFLOW && (
return (
<QuickPromptsFlexGroup gutterSize="s" alignItems="center">
{contextFilteredQuickPrompts.slice(0, COUNT_BEFORE_OVERFLOW).map((badge, index) => (
<EuiFlexItem key={index} grow={false}>
<EuiBadge
color={badge.color}
onClick={() => setInput(badge.prompt)}
onClickAriaLabel={badge.title}
>
{badge.title}
</EuiBadge>
</EuiFlexItem>
))}
{contextFilteredQuickPrompts.length > COUNT_BEFORE_OVERFLOW && (
<EuiFlexItem grow={false}>
<EuiPopover
button={
<EuiBadge
color={'hollow'}
iconType={'boxesHorizontal'}
onClick={toggleOverflowPopover}
onClickAriaLabel={i18n.QUICK_PROMPT_OVERFLOW_ARIA}
/>
}
isOpen={isOverflowPopoverOpen}
closePopover={closeOverflowPopover}
anchorPosition="rightUp"
>
<EuiFlexGroup direction="column" gutterSize="s">
{contextFilteredQuickPrompts.slice(COUNT_BEFORE_OVERFLOW).map((badge, index) => (
<EuiFlexItem key={index} grow={false}>
<EuiBadge
color={badge.color}
onClick={() => onClickOverflowQuickPrompt(badge.prompt)}
onClickAriaLabel={badge.title}
>
{badge.title}
</EuiBadge>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiPopover>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiPopover
button={
<EuiBadge
color={'hollow'}
iconType={'boxesHorizontal'}
onClick={toggleOverflowPopover}
onClickAriaLabel={i18n.QUICK_PROMPT_OVERFLOW_ARIA}
/>
}
isOpen={isOverflowPopoverOpen}
closePopover={closeOverflowPopover}
anchorPosition="rightUp"
>
<EuiFlexGroup direction="column" gutterSize="s">
{contextFilteredQuickPrompts.slice(COUNT_BEFORE_OVERFLOW).map((badge, index) => (
<EuiFlexItem key={index} grow={false}>
<EuiBadge
color={badge.color}
onClick={() => onClickOverflowQuickPrompt(badge.prompt)}
onClickAriaLabel={badge.title}
>
{badge.title}
</EuiBadge>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiPopover>
<EuiButtonEmpty onClick={showQuickPromptSettings} iconType="plus" size="xs">
{i18n.ADD_QUICK_PROMPT}
</EuiButtonEmpty>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={showQuickPromptSettings} iconType="plus" size="xs">
{i18n.ADD_QUICK_PROMPT}
</EuiButtonEmpty>
</EuiFlexItem>
</QuickPromptsFlexGroup>
);
});
</QuickPromptsFlexGroup>
);
}
);
QuickPrompts.displayName = 'QuickPrompts';

View file

@ -23,6 +23,7 @@ import {
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { css } from '@emotion/react';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/gen_ai/constants';
import { Conversation, Prompt, QuickPrompt } from '../../..';
import * as i18n from './translations';
import { useAssistantContext } from '../../assistant_context';
@ -54,6 +55,8 @@ export type SettingsTabs =
| typeof FUNCTIONS_TAB
| typeof ADVANCED_TAB;
interface Props {
defaultConnectorId?: string;
defaultProvider?: OpenAiProviderType;
onClose: (
event?: React.KeyboardEvent<HTMLDivElement> | React.MouseEvent<HTMLButtonElement>
) => void;
@ -68,6 +71,8 @@ interface Props {
*/
export const AssistantSettings: React.FC<Props> = React.memo(
({
defaultConnectorId,
defaultProvider,
onClose,
onSave,
selectedConversation: defaultSelectedConversation,
@ -244,6 +249,8 @@ export const AssistantSettings: React.FC<Props> = React.memo(
>
{selectedSettingsTab === CONVERSATIONS_TAB && (
<ConversationSettings
defaultConnectorId={defaultConnectorId}
defaultProvider={defaultProvider}
conversationSettings={conversationSettings}
setUpdatedConversationSettings={setUpdatedConversationSettings}
allSystemPrompts={systemPromptSettings}

View file

@ -8,13 +8,18 @@
import React, { useCallback } from 'react';
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/gen_ai/constants';
import { Conversation } from '../../..';
import { AssistantSettings, CONVERSATIONS_TAB } from './assistant_settings';
import * as i18n from './translations';
import { useAssistantContext } from '../../assistant_context';
interface Props {
defaultConnectorId?: string;
defaultProvider?: OpenAiProviderType;
isSettingsModalVisible: boolean;
selectedConversation: Conversation;
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedConversationId: React.Dispatch<React.SetStateAction<string>>;
isDisabled?: boolean;
}
@ -23,9 +28,16 @@ interface Props {
* 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();
({
defaultConnectorId,
defaultProvider,
isDisabled = false,
isSettingsModalVisible,
setIsSettingsModalVisible,
selectedConversation,
setSelectedConversationId,
}) => {
const { setSelectedSettingsTab } = useAssistantContext();
// Modal control functions
const cleanupAndCloseModal = useCallback(() => {
@ -60,6 +72,8 @@ export const AssistantSettingsButton: React.FC<Props> = React.memo(
{isSettingsModalVisible && (
<AssistantSettings
defaultConnectorId={defaultConnectorId}
defaultProvider={defaultProvider}
selectedConversation={selectedConversation}
setSelectedConversationId={setSelectedConversationId}
onClose={handleCloseModal}

View file

@ -12,6 +12,6 @@ export interface Prompt {
content: string;
name: string;
promptType: PromptType;
isDefault?: boolean;
isDefault?: boolean; // TODO: Should be renamed to isImmutable as this flag is used to prevent users from deleting prompts
isNewConversationDefault?: boolean;
}

View file

@ -5,7 +5,8 @@
* 2.0.
*/
import { analyzeMarkdown } from './helpers';
import { analyzeMarkdown, getDefaultSystemPrompt } from './helpers';
import { Conversation, Prompt } from '../../..';
const tilde = '`';
const codeDelimiter = '```';
@ -52,13 +53,152 @@ ${codeDelimiter}
This query will filter the events based on the condition that the ${tilde}user.name${tilde} field should exactly match the value \"9dcc9960-78cf-4ef6-9a2e-dbd5816daa60\".`;
describe('analyzeMarkdown', () => {
it('should identify dsl Query successfully.', () => {
const result = analyzeMarkdown(markDownWithDSLQuery);
expect(result[0].type).toBe('dsl');
describe('useConversation helpers', () => {
describe('analyzeMarkdown', () => {
it('should identify dsl Query successfully.', () => {
const result = analyzeMarkdown(markDownWithDSLQuery);
expect(result[0].type).toBe('dsl');
});
it('should identify kql Query successfully.', () => {
const result = analyzeMarkdown(markDownWithKQLQuery);
expect(result[0].type).toBe('kql');
});
});
it('should identify kql Query successfully.', () => {
const result = analyzeMarkdown(markDownWithKQLQuery);
expect(result[0].type).toBe('kql');
describe('getDefaultSystemPrompt', () => {
const allSystemPrompts: Prompt[] = [
{
id: '1',
content: 'Prompt 1',
name: 'Prompt 1',
promptType: 'user',
},
{
id: '2',
content: 'Prompt 2',
name: 'Prompt 2',
promptType: 'user',
isNewConversationDefault: true,
},
{
id: '3',
content: 'Prompt 3',
name: 'Prompt 3',
promptType: 'user',
},
];
const allSystemPromptsNoDefault: Prompt[] = allSystemPrompts.filter(
({ isNewConversationDefault }) => isNewConversationDefault !== true
);
const conversation: Conversation = {
apiConfig: {
defaultSystemPromptId: '3',
},
id: '1',
messages: [],
};
test('should return the conversation system prompt if it exists', () => {
const result = getDefaultSystemPrompt({ allSystemPrompts, conversation });
expect(result).toEqual(allSystemPrompts[2]);
});
test('should return the default (starred) isNewConversationDefault system prompt if conversation system prompt does not exist', () => {
const conversationWithoutSystemPrompt: Conversation = {
apiConfig: {},
id: '1',
messages: [],
};
const result = getDefaultSystemPrompt({
allSystemPrompts,
conversation: conversationWithoutSystemPrompt,
});
expect(result).toEqual(allSystemPrompts[1]);
});
test('should return the default (starred) isNewConversationDefault system prompt if conversation system prompt does not exist within all system prompts', () => {
const conversationWithoutSystemPrompt: Conversation = {
apiConfig: {},
id: '4', // this id does not exist within allSystemPrompts
messages: [],
};
const result = getDefaultSystemPrompt({
allSystemPrompts,
conversation: conversationWithoutSystemPrompt,
});
expect(result).toEqual(allSystemPrompts[1]);
});
test('should return the first prompt if both conversation system prompt and default new system prompt do not exist', () => {
const conversationWithoutSystemPrompt: Conversation = {
apiConfig: {},
id: '1',
messages: [],
};
const result = getDefaultSystemPrompt({
allSystemPrompts: allSystemPromptsNoDefault,
conversation: conversationWithoutSystemPrompt,
});
expect(result).toEqual(allSystemPromptsNoDefault[0]);
});
test('should return undefined if conversation system prompt does not exist and there are no system prompts', () => {
const conversationWithoutSystemPrompt: Conversation = {
apiConfig: {},
id: '1',
messages: [],
};
const result = getDefaultSystemPrompt({
allSystemPrompts: [],
conversation: conversationWithoutSystemPrompt,
});
expect(result).toEqual(undefined);
});
test('should return undefined if conversation system prompt does not exist within all system prompts', () => {
const conversationWithoutSystemPrompt: Conversation = {
apiConfig: {},
id: '4', // this id does not exist within allSystemPrompts
messages: [],
};
const result = getDefaultSystemPrompt({
allSystemPrompts: allSystemPromptsNoDefault,
conversation: conversationWithoutSystemPrompt,
});
expect(result).toEqual(allSystemPromptsNoDefault[0]);
});
test('should return (starred) isNewConversationDefault system prompt if conversation is undefined', () => {
const result = getDefaultSystemPrompt({
allSystemPrompts,
conversation: undefined,
});
expect(result).toEqual(allSystemPrompts[1]);
});
test('should return the first system prompt if the conversation is undefined and isNewConversationDefault is not present in system prompts', () => {
const result = getDefaultSystemPrompt({
allSystemPrompts: allSystemPromptsNoDefault,
conversation: undefined,
});
expect(result).toEqual(allSystemPromptsNoDefault[0]);
});
test('should return undefined if conversation is undefined and no system prompts are provided', () => {
const result = getDefaultSystemPrompt({
allSystemPrompts: [],
conversation: undefined,
});
expect(result).toEqual(undefined);
});
});
});

View file

@ -6,6 +6,8 @@
*/
import React from 'react';
import { Prompt } from '../types';
import { Conversation } from '../../assistant_context/types';
export interface CodeBlockDetails {
type: QueryType;
@ -64,3 +66,24 @@ export const analyzeMarkdown = (markdown: string): CodeBlockDetails[] => {
return result;
};
/**
* Returns the default system prompt for a given conversation
*
* @param allSystemPrompts All available System Prompts
* @param conversation Conversation to get the default system prompt for
*/
export const getDefaultSystemPrompt = ({
allSystemPrompts,
conversation,
}: {
allSystemPrompts: Prompt[];
conversation: Conversation | undefined;
}): Prompt | undefined => {
const conversationSystemPrompt = allSystemPrompts.find(
(prompt) => prompt.id === conversation?.apiConfig?.defaultSystemPromptId
);
const defaultNewSystemPrompt = allSystemPrompts.find((prompt) => prompt.isNewConversationDefault);
return conversationSystemPrompt ?? defaultNewSystemPrompt ?? allSystemPrompts?.[0];
};

View file

@ -11,6 +11,7 @@ import { useAssistantContext } from '../../assistant_context';
import { Conversation, Message } from '../../assistant_context/types';
import * as i18n from './translations';
import { ELASTIC_AI_ASSISTANT, ELASTIC_AI_ASSISTANT_TITLE } from './translations';
import { getDefaultSystemPrompt } from './helpers';
export const DEFAULT_CONVERSATION_STATE: Conversation = {
id: i18n.DEFAULT_CONVERSATION_TITLE,
@ -68,7 +69,7 @@ interface UseConversation {
}
export const useConversation = (): UseConversation => {
const { setConversations } = useAssistantContext();
const { allSystemPrompts, setConversations } = useAssistantContext();
/**
* Append a message to the conversation[] for a given conversationId
@ -135,10 +136,18 @@ export const useConversation = (): UseConversation => {
(conversationId: string) => {
setConversations((prev: Record<string, Conversation>) => {
const prevConversation: Conversation | undefined = prev[conversationId];
const defaultSystemPromptId = getDefaultSystemPrompt({
allSystemPrompts,
conversation: prevConversation,
})?.id;
if (prevConversation != null) {
const newConversation = {
const newConversation: Conversation = {
...prevConversation,
apiConfig: {
...prevConversation.apiConfig,
defaultSystemPromptId,
},
messages: [],
replacements: undefined,
};
@ -152,7 +161,7 @@ export const useConversation = (): UseConversation => {
}
});
},
[setConversations]
[allSystemPrompts, setConversations]
);
/**
@ -160,8 +169,17 @@ export const useConversation = (): UseConversation => {
*/
const createConversation = useCallback(
({ conversationId, messages }: CreateConversationProps): Conversation => {
const defaultSystemPromptId = getDefaultSystemPrompt({
allSystemPrompts,
conversation: undefined,
})?.id;
const newConversation: Conversation = {
...DEFAULT_CONVERSATION_STATE,
apiConfig: {
...DEFAULT_CONVERSATION_STATE.apiConfig,
defaultSystemPromptId,
},
id: conversationId,
messages: messages != null ? messages : [],
};
@ -180,7 +198,7 @@ export const useConversation = (): UseConversation => {
});
return newConversation;
},
[setConversations]
[allSystemPrompts, setConversations]
);
/**

View file

@ -101,7 +101,6 @@ export interface UseAssistantContext {
showAnonymizedValues: boolean;
}) => EuiCommentProps[];
http: HttpSetup;
isSettingsModalVisible: boolean;
localStorageLastConversationId: string | undefined;
promptContexts: Record<string, PromptContext>;
nameSpace: string;
@ -112,7 +111,6 @@ export interface UseAssistantContext {
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;
@ -160,12 +158,6 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
baseSystemPrompts
);
// if basePrompt has been updated, the localstorage should be accordingly updated
// if it exists
useEffect(() => {
setLocalStorageSystemPrompts(baseSystemPrompts);
}, [baseSystemPrompts, setLocalStorageSystemPrompts]);
const [localStorageLastConversationId, setLocalStorageLastConversationId] =
useLocalStorage<string>(`${nameSpace}.${LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY}`);
@ -212,7 +204,6 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
/**
* Settings State
*/
const [isSettingsModalVisible, setIsSettingsModalVisible] = useState(false);
const [selectedSettingsTab, setSelectedSettingsTab] = useState<SettingsTabs>(CONVERSATIONS_TAB);
const [conversations, setConversationsInternal] = useState(getInitialConversations());
@ -264,7 +255,6 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
docLinks,
getComments,
http,
isSettingsModalVisible,
promptContexts,
nameSpace,
registerPromptContext,
@ -274,7 +264,6 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
setConversations: onConversationsUpdated,
setDefaultAllow,
setDefaultAllowReplacement,
setIsSettingsModalVisible,
setSelectedSettingsTab,
setShowAssistantOverlay,
showAssistantOverlay,
@ -298,7 +287,6 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
docLinks,
getComments,
http,
isSettingsModalVisible,
localStorageLastConversationId,
localStorageQuickPrompts,
localStorageSystemPrompts,
@ -309,7 +297,6 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
selectedSettingsTab,
setDefaultAllow,
setDefaultAllowReplacement,
setIsSettingsModalVisible,
setLocalStorageLastConversationId,
setLocalStorageQuickPrompts,
setLocalStorageSystemPrompts,

View file

@ -13,6 +13,11 @@ import * as i18n from '../translations';
import { useAssistantContext } from '../../assistant_context';
import { CONVERSATIONS_TAB } from '../../assistant/settings/assistant_settings';
interface Props {
isSettingsModalVisible: boolean;
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
}
/**
* Error callout to be displayed when there is no connector configured for a conversation. Includes deep-link
* to conversation settings to quickly resolve.
@ -20,39 +25,40 @@ import { CONVERSATIONS_TAB } from '../../assistant/settings/assistant_settings';
* TODO: Add 'quick fix' button to just pick a connector
* TODO: Add setting for 'default connector' so we can auto-resolve and not even show this
*/
export const ConnectorMissingCallout: React.FC = React.memo(() => {
const { isSettingsModalVisible, setIsSettingsModalVisible, setSelectedSettingsTab } =
useAssistantContext();
export const ConnectorMissingCallout: React.FC<Props> = React.memo(
({ isSettingsModalVisible, setIsSettingsModalVisible }) => {
const { setSelectedSettingsTab } = useAssistantContext();
const onConversationSettingsClicked = useCallback(() => {
if (!isSettingsModalVisible) {
setIsSettingsModalVisible(true);
setSelectedSettingsTab(CONVERSATIONS_TAB);
}
}, [isSettingsModalVisible, setIsSettingsModalVisible, setSelectedSettingsTab]);
const onConversationSettingsClicked = useCallback(() => {
if (!isSettingsModalVisible) {
setIsSettingsModalVisible(true);
setSelectedSettingsTab(CONVERSATIONS_TAB);
}
}, [isSettingsModalVisible, setIsSettingsModalVisible, setSelectedSettingsTab]);
return (
<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>
);
});
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

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