Migrate Security AI Assistant to Flyout (#176657)

## Summary

Migrate current Security Assistant from Modal into the Flyout. Currently
it's hidden behind the feature flag, which is intended to be enabled by
default for `8.14`.

<img width="3006" alt="Zrzut ekranu 2024-04-13 o 11 12 57"
src="978cf3da-fec3-4397-a91f-2f2f78fe2c68">

<img width="3006" alt="Zrzut ekranu 2024-04-13 o 11 13 05"
src="6f37bc06-5209-42ea-8b5f-9e633a73d18d">

<img width="3006" alt="Zrzut ekranu 2024-04-13 o 11 13 30"
src="a7603cd2-9fe2-40e7-b9cb-78015a29dc53">

<img width="3007" alt="Zrzut ekranu 2024-04-13 o 11 13 44"
src="c224ec3b-c5cb-4b55-b23e-69e4feb33222">

<img width="3008" alt="Zrzut ekranu 2024-04-13 o 11 13 53"
src="d7ab1ae3-fe89-49f7-b7c9-41c71fbebc83">

<img width="3008" alt="Zrzut ekranu 2024-04-13 o 11 14 03"
src="5f67d669-9351-474c-b994-560663fe353c">

<img width="3008" alt="Zrzut ekranu 2024-04-13 o 11 14 28"
src="d9d1fb21-db45-41fe-a825-4d2ca068210b">

<img width="3008" alt="Zrzut ekranu 2024-04-13 o 11 14 48"
src="da41e837-b117-4637-8b22-f887900d8773">

<img width="3008" alt="Zrzut ekranu 2024-04-13 o 11 15 57"
src="61779d53-2046-4924-b738-bce9a7174181">

<img width="3008" alt="Zrzut ekranu 2024-04-13 o 11 17 23"
src="660156b2-e826-45c1-abef-1fc9c46c7e2c">

<img width="3008" alt="Zrzut ekranu 2024-04-13 o 11 20 18"
src="74711755-8305-407f-b4fb-eae396839e6b">

<img width="3008" alt="Zrzut ekranu 2024-04-13 o 11 20 37"
src="a09c30f4-d2d5-41ab-922d-a04f664782ff">

<img width="3008" alt="Zrzut ekranu 2024-04-13 o 11 20 50"
src="662fa464-ff8e-4511-aca3-102089df90d0">

<img width="3008" alt="Zrzut ekranu 2024-04-13 o 11 23 59"
src="6c94b77d-b74c-4cd3-be57-4cf30682dd48">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Garrett Spong <garrett.spong@elastic.co>
Co-authored-by: Garrett Spong <spong@users.noreply.github.com>
This commit is contained in:
Patryk Kopyciński 2024-04-16 03:37:47 +02:00 committed by GitHub
parent 11bcb0de92
commit b20ca74821
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
99 changed files with 3672 additions and 1034 deletions

View file

@ -29,6 +29,7 @@
"xpack.crossClusterReplication": "plugins/cross_cluster_replication",
"xpack.elasticAssistant": "packages/kbn-elastic-assistant",
"xpack.elasticAssistantCommon": "packages/kbn-elastic-assistant-common",
"xpack.elasticAssistantPlugin": "plugins/elastic_assistant",
"xpack.ecsDataQualityDashboard": "plugins/ecs_data_quality_dashboard",
"xpack.embeddableEnhanced": "plugins/embeddable_enhanced",
"xpack.endpoint": "plugins/endpoint",

View file

@ -10,11 +10,9 @@ import { act, renderHook } from '@testing-library/react-hooks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import React from 'react';
import {
UseFetchAnonymizationFieldsParams,
useFetchAnonymizationFields,
} from './use_fetch_anonymization_fields';
import { useFetchAnonymizationFields } from './use_fetch_anonymization_fields';
import { HttpSetup } from '@kbn/core-http-browser';
import { useAssistantContext } from '../../../assistant_context';
const statusResponse = { assistantModelEvaluation: true, assistantStreamingEnabled: false };
@ -22,10 +20,7 @@ const http = {
fetch: jest.fn().mockResolvedValue(statusResponse),
} as unknown as HttpSetup;
const defaultProps = {
http,
isAssistantEnabled: true,
} as unknown as UseFetchAnonymizationFieldsParams;
jest.mock('../../../assistant_context');
const createWrapper = () => {
const queryClient = new QueryClient();
@ -36,28 +31,31 @@ const createWrapper = () => {
};
describe('useFetchAnonymizationFields', () => {
(useAssistantContext as jest.Mock).mockReturnValue({
http,
assistantAvailability: {
isAssistantEnabled: true,
},
});
it(`should make http request to fetch anonymization fields`, async () => {
renderHook(() => useFetchAnonymizationFields(defaultProps), {
renderHook(() => useFetchAnonymizationFields(), {
wrapper: createWrapper(),
});
await act(async () => {
const { waitForNextUpdate } = renderHook(() => useFetchAnonymizationFields(defaultProps));
const { waitForNextUpdate } = renderHook(() => useFetchAnonymizationFields());
await waitForNextUpdate();
expect(defaultProps.http.fetch).toHaveBeenCalledWith(
'/api/elastic_assistant/anonymization_fields/_find',
{
method: 'GET',
query: {
page: 1,
per_page: 1000,
},
version: '2023-10-31',
signal: undefined,
}
);
expect(http.fetch).toHaveBeenCalledWith('/api/elastic_assistant/anonymization_fields/_find', {
method: 'GET',
query: {
page: 1,
per_page: 1000,
},
version: '2023-10-31',
signal: undefined,
});
expect(defaultProps.http.fetch).toHaveBeenCalled();
expect(http.fetch).toHaveBeenCalled();
});
});
});

View file

@ -6,16 +6,14 @@
*/
import { FindAnonymizationFieldsResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/find_anonymization_fields_route.gen';
import { HttpSetup } from '@kbn/core/public';
import { useQuery } from '@tanstack/react-query';
import {
API_VERSIONS,
ELASTIC_AI_ASSISTANT_ANONYMIZATION_FIELDS_URL_FIND,
} from '@kbn/elastic-assistant-common';
import { useAssistantContext } from '../../../assistant_context';
export interface UseFetchAnonymizationFieldsParams {
http: HttpSetup;
isAssistantEnabled: boolean;
signal?: AbortSignal | undefined;
}
@ -28,36 +26,49 @@ export interface UseFetchAnonymizationFieldsParams {
*
* @returns {useQuery} hook for getting the status of the anonymization fields
*/
export const useFetchAnonymizationFields = ({
http,
signal,
isAssistantEnabled,
}: UseFetchAnonymizationFieldsParams) => {
const query = {
page: 1,
per_page: 1000, // Continue use in-memory paging till the new design will be ready
};
const cachingKeys = [
ELASTIC_AI_ASSISTANT_ANONYMIZATION_FIELDS_URL_FIND,
query.page,
query.per_page,
API_VERSIONS.public.v1,
];
const QUERY = {
page: 1,
per_page: 1000, // Continue use in-memory paging till the new design will be ready
};
return useQuery([cachingKeys, query], async () => {
if (!isAssistantEnabled) {
return { page: 0, perPage: 0, total: 0, data: [] };
}
const res = await http.fetch<FindAnonymizationFieldsResponse>(
ELASTIC_AI_ASSISTANT_ANONYMIZATION_FIELDS_URL_FIND,
{
export const CACHING_KEYS = [
ELASTIC_AI_ASSISTANT_ANONYMIZATION_FIELDS_URL_FIND,
QUERY.page,
QUERY.per_page,
API_VERSIONS.public.v1,
];
export const useFetchAnonymizationFields = (payload?: UseFetchAnonymizationFieldsParams) => {
const {
assistantAvailability: { isAssistantEnabled },
http,
} = useAssistantContext();
return useQuery<FindAnonymizationFieldsResponse, unknown, FindAnonymizationFieldsResponse>(
CACHING_KEYS,
async () =>
http.fetch(ELASTIC_AI_ASSISTANT_ANONYMIZATION_FIELDS_URL_FIND, {
method: 'GET',
version: API_VERSIONS.public.v1,
query,
signal,
}
);
return res;
});
query: QUERY,
signal: payload?.signal,
}),
{
initialData: {
data: [],
page: 1,
perPage: 5,
total: 0,
},
placeholderData: {
data: [],
page: 1,
perPage: 5,
total: 0,
},
keepPreviousData: true,
enabled: isAssistantEnabled,
}
);
};

View file

@ -60,7 +60,7 @@ export const getConversationById = async ({
export interface PostConversationParams {
http: HttpSetup;
conversation: Conversation;
conversation: Partial<Conversation>;
toasts?: IToasts;
signal?: AbortSignal | undefined;
}

View file

@ -38,42 +38,39 @@ export interface UseFetchCurrentUserConversationsParams {
*
* @returns {useQuery} hook for getting the status of the conversations
*/
const query = {
page: 1,
perPage: 100,
};
export const CONVERSATIONS_QUERY_KEYS = [
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_FIND,
query.page,
query.perPage,
API_VERSIONS.public.v1,
];
export const useFetchCurrentUserConversations = ({
http,
onFetch,
signal,
refetchOnWindowFocus = true,
isAssistantEnabled,
}: UseFetchCurrentUserConversationsParams) => {
const query = {
page: 1,
perPage: 100,
};
const cachingKeys = [
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_FIND,
query.page,
query.perPage,
API_VERSIONS.public.v1,
];
return useQuery(
[cachingKeys, query],
async () => {
if (!isAssistantEnabled) {
return {};
}
const res = await http.fetch<FetchConversationsResponse>(
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_FIND,
{
method: 'GET',
version: API_VERSIONS.public.v1,
query,
signal,
}
);
return onFetch(res);
},
{ refetchOnWindowFocus }
}: UseFetchCurrentUserConversationsParams) =>
useQuery(
CONVERSATIONS_QUERY_KEYS,
async () =>
http.fetch<FetchConversationsResponse>(ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_FIND, {
method: 'GET',
version: API_VERSIONS.public.v1,
query,
signal,
}),
{
select: (data) => onFetch(data),
keepPreviousData: true,
initialData: { page: 1, perPage: 100, total: 0, data: [] },
refetchOnWindowFocus,
enabled: isAssistantEnabled,
}
);
};

View file

@ -0,0 +1,131 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import styled from '@emotion/styled';
import { euiThemeVars } from '@kbn/ui-theme';
import { AssistantAvatar } from './assistant_avatar/assistant_avatar';
const Container = styled.div`
display: inline-block;
position: relative;
width: 56px;
height: 56px;
display: flex;
justify-content: center;
align-items: center;
:before,
:after {
content: '';
position: absolute;
}
`;
const Animation = styled.div`
width: 99%;
height: 99%;
border-radius: 50px;
z-index: 1;
position: relative;
&:before,
&:after {
content: '';
position: absolute;
width: 100%;
height: 100%;
border: inherit;
top: 0;
left: 0;
z-index: 0;
border: 1px solid ${euiThemeVars.euiColorPrimary};
border-radius: inherit;
animation: 4s cubic-bezier(0.42, 0, 0.37, 1) 0.5s infinite normal none running pulsing;
}
&:after {
animation: 4s cubic-bezier(0.42, 0, 0.37, 1) 0.5s infinite normal none running pulsing1;
}
@keyframes pulsing {
0% {
opacity: 1;
transform: scaleY(1) scaleX(1);
}
20% {
opacity: 0.5;
}
70% {
opacity: 0.2;
transform: scaleY(2) scaleX(2);
}
80% {
opacity: 0;
transform: scaleY(2) scaleX(2);
}
90% {
opacity: 0;
transform: scaleY(1) scaleX(1);
}
}
@keyframes pulsing1 {
0% {
opacity: 1;
transform: scaleY(1) scaleX(1);
}
15% {
opacity: 1;
transform: scaleY(1) scaleX(1);
}
40% {
opacity: 0.5;
}
70% {
opacity: 0.2;
transform: scaleY(1.5) scaleX(1.5);
}
80% {
opacity: 0;
transform: scaleY(1.5) scaleX(1.5);
}
90% {
opacity: 0;
transform: scaleY(1) scaleX(1);
}
}
`;
const AvatarWrapper = styled.span`
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 2;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
:before,
:after {
content: '';
position: absolute;
}
`;
export const AssistantAnimatedIcon = React.memo(() => (
<Container>
<Animation />
<AvatarWrapper>
<AssistantAvatar size="m" />
</AvatarWrapper>
</Container>
));
AssistantAnimatedIcon.displayName = 'AssistantAnimatedIcon';

View file

@ -0,0 +1,269 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, useMemo, useCallback } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
EuiContextMenu,
EuiButtonIcon,
EuiPanel,
EuiConfirmModal,
EuiToolTip,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { DocLinksStart } from '@kbn/core-doc-links-browser';
import { isEmpty } from 'lodash';
import { Conversation } from '../../..';
import { AssistantTitle } from '../assistant_title';
import { ConnectorSelectorInline } from '../../connectorland/connector_selector_inline/connector_selector_inline';
import { FlyoutNavigation } from '../assistant_overlay/flyout_navigation';
import { AssistantSettingsButton } from '../settings/assistant_settings_button';
import * as i18n from './translations';
import { AIConnector } from '../../connectorland/connector_selector';
interface OwnProps {
selectedConversation: Conversation | undefined;
defaultConnector?: AIConnector;
docLinks: Omit<DocLinksStart, 'links'>;
isDisabled: boolean;
isSettingsModalVisible: boolean;
onToggleShowAnonymizedValues: () => void;
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
showAnonymizedValues: boolean;
onChatCleared: () => void;
onCloseFlyout?: () => void;
chatHistoryVisible?: boolean;
setChatHistoryVisible?: React.Dispatch<React.SetStateAction<boolean>>;
onConversationSelected: ({ cId, cTitle }: { cId: string; cTitle: string }) => void;
conversations: Record<string, Conversation>;
refetchConversationsState: () => Promise<void>;
onConversationCreate: () => Promise<void>;
}
type Props = OwnProps;
/**
* Renders the header of the Elastic AI Assistant.
* Provide a user interface for selecting and managing conversations,
* toggling the display of anonymized values, and accessing the assistant settings.
*/
export const AssistantHeaderFlyout: React.FC<Props> = ({
selectedConversation,
defaultConnector,
docLinks,
isDisabled,
isSettingsModalVisible,
onToggleShowAnonymizedValues,
setIsSettingsModalVisible,
showAnonymizedValues,
onChatCleared,
chatHistoryVisible,
setChatHistoryVisible,
onCloseFlyout,
onConversationSelected,
conversations,
refetchConversationsState,
onConversationCreate,
}) => {
const showAnonymizedValuesChecked = useMemo(
() =>
selectedConversation?.replacements != null &&
Object.keys(selectedConversation?.replacements).length > 0 &&
showAnonymizedValues,
[selectedConversation?.replacements, showAnonymizedValues]
);
const selectedConnectorId = useMemo(
() => selectedConversation?.apiConfig?.connectorId,
[selectedConversation?.apiConfig?.connectorId]
);
const [isPopoverOpen, setPopover] = useState(false);
const onButtonClick = useCallback(() => {
setPopover(!isPopoverOpen);
}, [isPopoverOpen]);
const closePopover = useCallback(() => {
setPopover(false);
}, []);
const [isResetConversationModalVisible, setIsResetConversationModalVisible] = useState(false);
const closeDestroyModal = useCallback(() => setIsResetConversationModalVisible(false), []);
const showDestroyModal = useCallback(() => setIsResetConversationModalVisible(true), []);
const onConversationChange = useCallback(
(updatedConversation) => {
onConversationSelected({
cId: updatedConversation.id,
cTitle: updatedConversation.title,
});
},
[onConversationSelected]
);
const panels = useMemo(
() => [
{
id: 0,
items: [
{
name: i18n.RESET_CONVERSATION,
css: css`
color: ${euiThemeVars.euiColorDanger};
`,
onClick: showDestroyModal,
icon: 'refresh',
},
],
},
],
[showDestroyModal]
);
const handleReset = useCallback(() => {
onChatCleared();
closeDestroyModal();
closePopover();
}, [onChatCleared, closeDestroyModal, closePopover]);
return (
<>
<FlyoutNavigation
isExpanded={!!chatHistoryVisible}
setIsExpanded={setChatHistoryVisible}
onConversationCreate={onConversationCreate}
>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<AssistantSettingsButton
defaultConnector={defaultConnector}
isDisabled={isDisabled}
isSettingsModalVisible={isSettingsModalVisible}
selectedConversationId={
!isEmpty(selectedConversation?.id)
? selectedConversation?.id
: selectedConversation?.title
}
setIsSettingsModalVisible={setIsSettingsModalVisible}
onConversationSelected={onConversationSelected}
conversations={conversations}
refetchConversationsState={refetchConversationsState}
isFlyoutMode={true}
/>
</EuiFlexItem>
{onCloseFlyout && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
aria-label="xxx"
iconType="cross"
color="text"
size="xs"
onClick={onCloseFlyout}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</FlyoutNavigation>
<EuiPanel
hasShadow={false}
paddingSize="m"
css={css`
padding-top: ${euiThemeVars.euiSizeS};
padding-bottom: ${euiThemeVars.euiSizeS};
`}
>
<EuiFlexGroup alignItems={'center'} justifyContent={'spaceBetween'} gutterSize="s">
<EuiFlexItem
css={css`
overflow: hidden;
`}
>
<AssistantTitle
docLinks={docLinks}
title={selectedConversation?.title}
selectedConversation={selectedConversation}
onChange={onConversationChange}
isFlyoutMode={true}
refetchConversationsState={refetchConversationsState}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" alignItems={'center'}>
<EuiFlexItem>
<ConnectorSelectorInline
isDisabled={isDisabled || selectedConversation === undefined}
selectedConnectorId={selectedConnectorId}
selectedConversation={selectedConversation}
isFlyoutMode={true}
onConnectorSelected={onConversationChange}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
content={
showAnonymizedValuesChecked ? i18n.SHOW_REAL_VALUES : i18n.SHOW_ANONYMIZED
}
>
<EuiButtonIcon
css={css`
border-radius: 50%;
`}
display="base"
data-test-subj="showAnonymizedValues"
isSelected={showAnonymizedValuesChecked}
aria-label={
showAnonymizedValuesChecked ? i18n.SHOW_ANONYMIZED : i18n.SHOW_REAL_VALUES
}
iconType={showAnonymizedValuesChecked ? 'eye' : 'eyeClosed'}
onClick={onToggleShowAnonymizedValues}
isDisabled={isEmpty(selectedConversation?.replacements)}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem>
<EuiPopover
button={
<EuiButtonIcon
aria-label="test"
iconType="boxesVertical"
onClick={onButtonClick}
/>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenu initialPanelId={0} panels={panels} />
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
{isResetConversationModalVisible && (
<EuiConfirmModal
title={i18n.RESET_CONVERSATION}
onCancel={closeDestroyModal}
onConfirm={handleReset}
cancelButtonText={i18n.CANCEL_BUTTON_TEXT}
confirmButtonText={i18n.RESET_BUTTON_TEXT}
buttonColor="danger"
defaultFocusedButton="confirm"
>
<p>{i18n.CLEAR_CHAT_CONFIRMATION}</p>
</EuiConfirmModal>
)}
</>
);
};

View file

@ -14,7 +14,6 @@ import { useLoadConnectors } from '../../connectorland/use_load_connectors';
import { mockConnectors } from '../../mock/connectors';
const onConversationSelected = jest.fn();
const setCurrentConversation = jest.fn();
const mockConversations = {
[alertConvo.title]: alertConvo,
[welcomeConvo.title]: welcomeConvo,
@ -32,7 +31,6 @@ const testProps = {
onToggleShowAnonymizedValues: jest.fn(),
selectedConversationId: emptyWelcomeConvo.id,
setIsSettingsModalVisible: jest.fn(),
setCurrentConversation,
onConversationDeleted: jest.fn(),
showAnonymizedValues: false,
conversations: mockConversations,
@ -121,7 +119,6 @@ describe('AssistantHeader', () => {
await act(async () => {
fireEvent.click(getByTestId('connectorId'));
});
expect(setCurrentConversation).toHaveBeenCalledWith(alertConvo);
expect(onConversationSelected).toHaveBeenCalledWith({
cId: alertConvo.id,
cTitle: alertConvo.title,

View file

@ -12,37 +12,33 @@ import {
EuiHorizontalRule,
EuiSpacer,
EuiSwitch,
EuiSwitchEvent,
EuiToolTip,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { DocLinksStart } from '@kbn/core-doc-links-browser';
import { FindAnonymizationFieldsResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/find_anonymization_fields_route.gen';
import { isEmpty } from 'lodash';
import { AIConnector } from '../../connectorland/connector_selector';
import { Conversation } from '../../..';
import { AssistantTitle } from '../assistant_title';
import { ConversationSelector } from '../conversations/conversation_selector';
import { AssistantSettingsButton } from '../settings/assistant_settings_button';
import * as i18n from '../translations';
import * as i18n from './translations';
interface OwnProps {
currentConversation: Conversation;
currentConversation?: Conversation;
defaultConnector?: AIConnector;
docLinks: Omit<DocLinksStart, 'links'>;
isDisabled: boolean;
isSettingsModalVisible: boolean;
onConversationSelected: ({ cId, cTitle }: { cId: string; cTitle: string }) => void;
onConversationDeleted: (conversationId: string) => void;
onToggleShowAnonymizedValues: (e: EuiSwitchEvent) => void;
onToggleShowAnonymizedValues: () => void;
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
setCurrentConversation: React.Dispatch<React.SetStateAction<Conversation>>;
shouldDisableKeyboardShortcut?: () => boolean;
showAnonymizedValues: boolean;
title: string | JSX.Element;
title: string;
conversations: Record<string, Conversation>;
refetchConversationsState: () => Promise<void>;
anonymizationFields: FindAnonymizationFieldsResponse;
refetchAnonymizationFieldsResults: () => Promise<FindAnonymizationFieldsResponse | undefined>;
}
type Props = OwnProps;
@ -64,29 +60,31 @@ export const AssistantHeader: React.FC<Props> = ({
shouldDisableKeyboardShortcut,
showAnonymizedValues,
title,
setCurrentConversation,
conversations,
refetchConversationsState,
anonymizationFields,
refetchAnonymizationFieldsResults,
}) => {
const showAnonymizedValuesChecked = useMemo(
() =>
currentConversation.replacements != null &&
Object.keys(currentConversation.replacements).length > 0 &&
currentConversation?.replacements != null &&
Object.keys(currentConversation?.replacements).length > 0 &&
showAnonymizedValues,
[currentConversation.replacements, showAnonymizedValues]
[currentConversation?.replacements, showAnonymizedValues]
);
const onConversationChange = useCallback(
(updatedConversation) => {
setCurrentConversation(updatedConversation);
onConversationSelected({
cId: updatedConversation.id,
cTitle: updatedConversation.title,
});
},
[onConversationSelected, setCurrentConversation]
[onConversationSelected]
);
const selectedConversationId = useMemo(
() =>
!isEmpty(currentConversation?.id) ? currentConversation?.id : currentConversation?.title,
[currentConversation?.id, currentConversation?.title]
);
return (
<>
<EuiFlexGroup
@ -103,6 +101,8 @@ export const AssistantHeader: React.FC<Props> = ({
selectedConversation={currentConversation}
onChange={onConversationChange}
title={title}
isFlyoutMode={false}
refetchConversationsState={refetchConversationsState}
/>
</EuiFlexItem>
@ -114,7 +114,7 @@ export const AssistantHeader: React.FC<Props> = ({
>
<ConversationSelector
defaultConnector={defaultConnector}
selectedConversationTitle={currentConversation.title}
selectedConversationId={selectedConversationId}
onConversationSelected={onConversationSelected}
shouldDisableKeyboardShortcut={shouldDisableKeyboardShortcut}
isDisabled={isDisabled}
@ -135,7 +135,7 @@ export const AssistantHeader: React.FC<Props> = ({
data-test-subj="showAnonymizedValues"
checked={showAnonymizedValuesChecked}
compressed={true}
disabled={currentConversation.replacements == null}
disabled={isEmpty(currentConversation?.replacements)}
label={i18n.SHOW_ANONYMIZED}
onChange={onToggleShowAnonymizedValues}
/>
@ -147,13 +147,12 @@ export const AssistantHeader: React.FC<Props> = ({
defaultConnector={defaultConnector}
isDisabled={isDisabled}
isSettingsModalVisible={isSettingsModalVisible}
selectedConversation={currentConversation}
selectedConversationId={selectedConversationId}
setIsSettingsModalVisible={setIsSettingsModalVisible}
onConversationSelected={onConversationSelected}
conversations={conversations}
refetchConversationsState={refetchConversationsState}
anonymizationFields={anonymizationFields}
refetchAnonymizationFieldsResults={refetchAnonymizationFieldsResults}
isFlyoutMode={false}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const ANONYMIZED_VALUES = i18n.translate(
'xpack.elasticAssistant.assistant.settings.anonymizedValues',
{
defaultMessage: 'Anonymized values',
}
);
export const RESET_CONVERSATION = i18n.translate(
'xpack.elasticAssistant.assistant.settings.resetConversation',
{
defaultMessage: 'Reset conversation',
}
);
export const CONNECTOR_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.settings.connectorTitle',
{
defaultMessage: 'Connector',
}
);
export const SHOW_ANONYMIZED = i18n.translate(
'xpack.elasticAssistant.assistant.settings.showAnonymizedToggleLabel',
{
defaultMessage: 'Show anonymized',
}
);
export const SHOW_REAL_VALUES = i18n.translate(
'xpack.elasticAssistant.assistant.settings.showAnonymizedToggleRealValuesLabel',
{
defaultMessage: 'Show real values',
}
);
export const SHOW_ANONYMIZED_TOOLTIP = i18n.translate(
'xpack.elasticAssistant.assistant.settings.showAnonymizedTooltip',
{
defaultMessage: 'Show the anonymized values sent to and from the assistant',
}
);
export const CANCEL_BUTTON_TEXT = i18n.translate(
'xpack.elasticAssistant.assistant.resetConversationModal.cancelButtonText',
{
defaultMessage: 'Cancel',
}
);
export const RESET_BUTTON_TEXT = i18n.translate(
'xpack.elasticAssistant.assistant.resetConversationModal.resetButtonText',
{
defaultMessage: 'Reset',
}
);
export const CLEAR_CHAT_CONFIRMATION = i18n.translate(
'xpack.elasticAssistant.assistant.resetConversationModal.clearChatConfirmation',
{
defaultMessage:
'Are you sure you want to clear the current chat? All conversation data will be lost.',
}
);

View file

@ -0,0 +1,114 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useCallback, useMemo } from 'react';
import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiButtonIcon, EuiButtonEmpty } from '@elastic/eui';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { i18n } from '@kbn/i18n';
import { euiThemeVars } from '@kbn/ui-theme';
import { NEW_CHAT } from '../conversations/conversation_sidepanel/translations';
export interface FlyoutNavigationProps {
isExpanded: boolean;
setIsExpanded?: (value: boolean) => void;
children: React.ReactNode;
onConversationCreate?: () => Promise<void>;
}
const VerticalSeparator = styled.div`
:before {
content: '';
height: 100%;
border-left: 1px solid ${euiThemeVars.euiColorLightShade};
}
`;
/**
* Navigation menu on the right panel only, with expand/collapse button and option to
* pass in a list of actions to be displayed on top.
*/
export const FlyoutNavigation = memo<FlyoutNavigationProps>(
({ isExpanded, setIsExpanded, children, onConversationCreate }) => {
const onToggle = useCallback(
() => setIsExpanded && setIsExpanded(!isExpanded),
[isExpanded, setIsExpanded]
);
const toggleButton = useMemo(
() => (
<EuiButtonIcon
onClick={onToggle}
iconType={isExpanded ? 'arrowEnd' : 'arrowStart'}
size="xs"
aria-label={
isExpanded
? i18n.translate(
'xpack.elasticAssistant.flyout.right.header.collapseDetailButtonAriaLabel',
{
defaultMessage: 'Hide chats',
}
)
: i18n.translate(
'xpack.elasticAssistant.flyout.right.header.expandDetailButtonAriaLabel',
{
defaultMessage: 'Show chats',
}
)
}
/>
),
[isExpanded, onToggle]
);
return (
<EuiPanel
hasShadow={false}
borderRadius="none"
paddingSize="s"
grow={false}
css={css`
border-bottom: 1px solid ${euiThemeVars.euiColorLightShade};
`}
>
<EuiFlexGroup
direction="row"
justifyContent="spaceBetween"
alignItems="center"
gutterSize="none"
>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" alignItems="center">
{setIsExpanded && <EuiFlexItem grow={false}>{toggleButton}</EuiFlexItem>}
{onConversationCreate && (
<>
<EuiFlexItem grow={false}>
<VerticalSeparator />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="xs"
color="primary"
iconType="newChat"
onClick={onConversationCreate}
>
{NEW_CHAT}
</EuiButtonEmpty>
</EuiFlexItem>
</>
)}
</EuiFlexGroup>
</EuiFlexItem>
{children && <EuiFlexItem grow={false}>{children}</EuiFlexItem>}
</EuiFlexGroup>
</EuiPanel>
);
}
);
FlyoutNavigation.displayName = 'FlyoutNavigation';

View file

@ -24,7 +24,7 @@ describe('AssistantOverlay', () => {
it('renders when isAssistantEnabled prop is true and keyboard shortcut is pressed', () => {
const { getByTestId } = render(
<TestProviders providerContext={{ assistantTelemetry }}>
<AssistantOverlay />
<AssistantOverlay isFlyoutMode={false} />
</TestProviders>
);
fireEvent.keyDown(document, { key: ';', ctrlKey: true });
@ -35,7 +35,7 @@ describe('AssistantOverlay', () => {
it('modal closes when close button is clicked', () => {
const { getByLabelText, queryByTestId } = render(
<TestProviders>
<AssistantOverlay />
<AssistantOverlay isFlyoutMode={false} />
</TestProviders>
);
fireEvent.keyDown(document, { key: ';', ctrlKey: true });
@ -48,7 +48,7 @@ describe('AssistantOverlay', () => {
it('Assistant invoked from shortcut tracking happens on modal open only (not close)', () => {
render(
<TestProviders providerContext={{ assistantTelemetry }}>
<AssistantOverlay />
<AssistantOverlay isFlyoutMode={false} />
</TestProviders>
);
fireEvent.keyDown(document, { key: ';', ctrlKey: true });
@ -64,7 +64,7 @@ describe('AssistantOverlay', () => {
it('modal closes when shortcut is pressed and modal is already open', () => {
const { queryByTestId } = render(
<TestProviders>
<AssistantOverlay />
<AssistantOverlay isFlyoutMode={false} />
</TestProviders>
);
fireEvent.keyDown(document, { key: ';', ctrlKey: true });
@ -76,7 +76,7 @@ describe('AssistantOverlay', () => {
it('modal does not open when incorrect shortcut is pressed', () => {
const { queryByTestId } = render(
<TestProviders>
<AssistantOverlay />
<AssistantOverlay isFlyoutMode={false} />
</TestProviders>
);
fireEvent.keyDown(document, { key: 'a', ctrlKey: true });

View file

@ -5,14 +5,19 @@
* 2.0.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { EuiModal } from '@elastic/eui';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { EuiModal, EuiFlyoutResizable, useEuiTheme } from '@elastic/eui';
import useEvent from 'react-use/lib/useEvent';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { ShowAssistantOverlayProps, useAssistantContext } from '../../assistant_context';
import { Assistant } from '..';
import { css } from '@emotion/react';
import {
ShowAssistantOverlayProps,
useAssistantContext,
UserAvatar,
} from '../../assistant_context';
import { Assistant, CONVERSATION_SIDE_PANEL_WIDTH } from '..';
import { WELCOME_CONVERSATION_TITLE } from '../use_conversation/translations';
const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0;
@ -27,15 +32,23 @@ const StyledEuiModal = styled(EuiModal)`
* Modal container for Elastic AI Assistant conversations, receiving the page contents as context, plus whatever
* component currently has focus and any specific context it may provide through the SAssInterface.
*/
export const AssistantOverlay = React.memo(() => {
export interface Props {
isFlyoutMode: boolean;
currentUserAvatar?: UserAvatar;
}
export const AssistantOverlay = React.memo<Props>(({ isFlyoutMode, currentUserAvatar }) => {
const { euiTheme } = useEuiTheme();
const [isModalVisible, setIsModalVisible] = useState(false);
const [conversationTitle, setConversationTitle] = useState<string | undefined>(
WELCOME_CONVERSATION_TITLE
);
const [promptContextId, setPromptContextId] = useState<string | undefined>();
const { assistantTelemetry, setShowAssistantOverlay, getLastConversationTitle } =
const { assistantTelemetry, setShowAssistantOverlay, getLastConversationId } =
useAssistantContext();
const [chatHistoryVisible, setChatHistoryVisible] = useState(false);
// Bind `showAssistantOverlay` in SecurityAssistantContext to this modal instance
const showOverlay = useCallback(
() =>
@ -44,7 +57,7 @@ export const AssistantOverlay = React.memo(() => {
promptContextId: pid,
conversationTitle: cTitle,
}: ShowAssistantOverlayProps) => {
const newConversationTitle = getLastConversationTitle(cTitle);
const newConversationTitle = getLastConversationId(cTitle);
if (so)
assistantTelemetry?.reportAssistantInvoked({
conversationId: newConversationTitle,
@ -55,7 +68,7 @@ export const AssistantOverlay = React.memo(() => {
setPromptContextId(pid);
setConversationTitle(newConversationTitle);
},
[assistantTelemetry, getLastConversationTitle]
[assistantTelemetry, getLastConversationId]
);
useEffect(() => {
setShowAssistantOverlay(showOverlay);
@ -65,15 +78,15 @@ export const AssistantOverlay = React.memo(() => {
const handleShortcutPress = useCallback(() => {
// Try to restore the last conversation on shortcut pressed
if (!isModalVisible) {
setConversationTitle(getLastConversationTitle());
setConversationTitle(getLastConversationId());
assistantTelemetry?.reportAssistantInvoked({
invokedBy: 'shortcut',
conversationId: getLastConversationTitle(),
conversationId: getLastConversationId(),
});
}
setIsModalVisible(!isModalVisible);
}, [isModalVisible, getLastConversationTitle, assistantTelemetry]);
}, [isModalVisible, getLastConversationId, assistantTelemetry]);
// Register keyboard listener to show the modal when cmd + ; is pressed
const onKeyDown = useCallback(
@ -98,11 +111,67 @@ export const AssistantOverlay = React.memo(() => {
cleanupAndCloseModal();
}, [cleanupAndCloseModal]);
const toggleChatHistory = useCallback(() => {
setChatHistoryVisible((prev) => {
if (flyoutRef?.current) {
const currentValue = parseInt(flyoutRef.current.style.inlineSize.split('px')[0], 10);
flyoutRef.current.style.inlineSize = `${
prev
? currentValue - CONVERSATION_SIDE_PANEL_WIDTH
: currentValue + CONVERSATION_SIDE_PANEL_WIDTH
}px`;
}
return !prev;
});
}, []);
const flyoutRef = useRef<HTMLDivElement>();
if (!isModalVisible) return null;
if (isFlyoutMode) {
return (
<EuiFlyoutResizable
ref={flyoutRef}
css={css`
max-inline-size: calc(100% - 20px);
min-inline-size: 400px;
> div {
height: 100%;
}
`}
onClose={handleCloseModal}
data-test-subj="ai-assistant-flyout"
paddingSize="none"
hideCloseButton
// EUI TODO: This z-index override of EuiOverlayMask is a workaround, and ideally should be resolved with a cleaner UI/UX flow long-term
maskProps={{ style: `z-index: ${(euiTheme.levels.flyout as number) + 3}` }} // we need this flyout to be above the timeline flyout (which has a z-index of 1002)
>
<Assistant
conversationTitle={conversationTitle}
promptContextId={promptContextId}
onCloseFlyout={handleCloseModal}
isFlyoutMode={isFlyoutMode}
chatHistoryVisible={chatHistoryVisible}
setChatHistoryVisible={toggleChatHistory}
currentUserAvatar={currentUserAvatar}
/>
</EuiFlyoutResizable>
);
}
return (
<>
{isModalVisible && (
<StyledEuiModal onClose={handleCloseModal} data-test-subj="ai-assistant-modal">
<Assistant conversationTitle={conversationTitle} promptContextId={promptContextId} />
<Assistant
conversationTitle={conversationTitle}
promptContextId={promptContextId}
chatHistoryVisible={chatHistoryVisible}
setChatHistoryVisible={toggleChatHistory}
currentUserAvatar={currentUserAvatar}
/>
</StyledEuiModal>
)}
</>

View file

@ -14,7 +14,9 @@ const testProps = {
title: 'Test Title',
docLinks: { ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', DOC_LINK_VERSION: '7.15' },
selectedConversation: undefined,
isFlyoutMode: false,
onChange: jest.fn(),
refetchConversationsState: jest.fn(),
};
describe('AssistantTitle', () => {

View file

@ -5,11 +5,12 @@
* 2.0.
*/
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiInlineEditTitle,
EuiLink,
EuiModalHeaderTitle,
EuiPopover,
@ -18,10 +19,13 @@ import {
} from '@elastic/eui';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
import * as i18n from '../translations';
import type { Conversation } from '../../..';
import { ConnectorSelectorInline } from '../../connectorland/connector_selector_inline/connector_selector_inline';
import { AssistantAvatar } from '../assistant_avatar/assistant_avatar';
import { useConversation } from '../use_conversation';
import { NEW_CHAT } from '../conversations/conversation_sidepanel/translations';
/**
* Renders a header title, a tooltip button, and a popover with
@ -29,11 +33,25 @@ import { AssistantAvatar } from '../assistant_avatar/assistant_avatar';
*/
export const AssistantTitle: React.FC<{
isDisabled?: boolean;
title: string | JSX.Element;
title?: string;
docLinks: Omit<DocLinksStart, 'links'>;
selectedConversation: Conversation | undefined;
isFlyoutMode: boolean;
onChange: (updatedConversation: Conversation) => void;
}> = ({ isDisabled = false, title, docLinks, selectedConversation, onChange }) => {
refetchConversationsState: () => Promise<void>;
}> = ({
isDisabled = false,
title,
docLinks,
selectedConversation,
isFlyoutMode,
onChange,
refetchConversationsState,
}) => {
const [newTitle, setNewTitle] = useState(title);
const [newTitleError, setNewTitleError] = useState(false);
const { updateConversationTitle } = useConversation();
const selectedConnectorId = selectedConversation?.apiConfig?.connectorId;
const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks;
@ -71,52 +89,127 @@ export const AssistantTitle: React.FC<{
const onButtonClick = useCallback(() => setIsPopoverOpen((isOpen: boolean) => !isOpen), []);
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
const handleUpdateTitle = useCallback(
async (updatedTitle: string) => {
setNewTitleError(false);
if (selectedConversation) {
await updateConversationTitle({
conversationId: selectedConversation.id,
updatedTitle,
});
await refetchConversationsState();
}
},
[refetchConversationsState, selectedConversation, updateConversationTitle]
);
useEffect(() => {
// Reset the title when the prop changes
setNewTitle(title);
}, [title]);
if (isFlyoutMode) {
return (
<EuiFlexGroup gutterSize="m" alignItems="center">
<EuiFlexItem grow={false}>
<AssistantAvatar data-test-subj="titleIcon" size={isFlyoutMode ? 's' : 'm'} />
</EuiFlexItem>
<EuiFlexItem
css={css`
overflow: hidden;
`}
>
<EuiInlineEditTitle
heading="h2"
inputAriaLabel="Edit text inline"
value={newTitle ?? NEW_CHAT}
size="xs"
isInvalid={!!newTitleError}
isReadOnly={selectedConversation?.isDefault}
onChange={(e) => setNewTitle(e.currentTarget.nodeValue || '')}
onCancel={() => setNewTitle(title)}
onSave={handleUpdateTitle}
editModeProps={{
formRowProps: {
fullWidth: true,
},
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
return (
<EuiModalHeaderTitle>
<EuiFlexGroup gutterSize="m">
<EuiFlexGroup gutterSize="m" alignItems="center">
<EuiFlexItem grow={false}>
<AssistantAvatar data-test-subj="titleIcon" size={'m'} />
<AssistantAvatar data-test-subj="titleIcon" size={isFlyoutMode ? 's' : 'm'} />
</EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="none" justifyContent="center">
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size={'s'}>
<h3>{title}</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
button={
<EuiButtonIcon
aria-label={i18n.TOOLTIP_ARIA_LABEL}
data-test-subj="tooltipIcon"
iconType="iInCircle"
onClick={onButtonClick}
/>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
anchorPosition="rightUp"
>
<EuiText data-test-subj="tooltipContent" grow={false} css={{ maxWidth: '400px' }}>
<EuiText size={'s'}>
<p>{content}</p>
<EuiFlexItem
css={css`
overflow: hidden;
`}
>
<EuiFlexGroup
direction={isFlyoutMode ? 'row' : 'column'}
gutterSize="none"
justifyContent={isFlyoutMode ? 'spaceBetween' : 'center'}
>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size={'s'}>
<h3>{title}</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
css={
isFlyoutMode &&
css`
display: inline-flex;
`
}
button={
<EuiButtonIcon
aria-label={i18n.TOOLTIP_ARIA_LABEL}
data-test-subj="tooltipIcon"
iconType="iInCircle"
onClick={onButtonClick}
/>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
anchorPosition="rightUp"
>
<EuiText
data-test-subj="tooltipContent"
grow={false}
css={{ maxWidth: '400px' }}
>
<EuiText size={'s'}>
<p>{content}</p>
</EuiText>
</EuiText>
</EuiText>
</EuiPopover>
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{!isFlyoutMode && (
<EuiFlexItem grow={false}>
<ConnectorSelectorInline
isDisabled={isDisabled || selectedConversation === undefined}
selectedConnectorId={selectedConnectorId}
selectedConversation={selectedConversation}
isFlyoutMode={isFlyoutMode}
onConnectorSelected={onChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ConnectorSelectorInline
isDisabled={isDisabled || selectedConversation === undefined}
selectedConnectorId={selectedConnectorId}
selectedConversation={selectedConversation}
onConnectorSelected={onChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
)}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalHeaderTitle>
);

View file

@ -16,6 +16,8 @@ const testProps = {
isLoading: false,
onChatCleared,
onSendMessage,
isFlyoutMode: false,
promptValue: 'prompt',
};
describe('ChatActions', () => {

View file

@ -5,14 +5,15 @@
* 2.0.
*/
import React from 'react';
import { css } from '@emotion/react';
import React, { useCallback, useRef } from 'react';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { CLEAR_CHAT, SUBMIT_MESSAGE } from '../translations';
interface OwnProps {
isDisabled: boolean;
isLoading: boolean;
isFlyoutMode: boolean;
promptValue?: string;
onChatCleared: () => void;
onSendMessage: () => void;
}
@ -27,37 +28,48 @@ export const ChatActions: React.FC<Props> = ({
isLoading,
onChatCleared,
onSendMessage,
isFlyoutMode,
promptValue,
}) => {
const submitTooltipRef = useRef<EuiToolTip | null>(null);
const closeTooltip = useCallback(() => {
submitTooltipRef?.current?.hideToolTip();
}, []);
return (
<EuiFlexGroup
css={css`
position: absolute;
`}
direction="column"
gutterSize="xs"
>
<EuiFlexGroup direction="column" gutterSize="xs">
{!isFlyoutMode && (
<EuiFlexItem grow={false}>
<EuiToolTip position="right" content={CLEAR_CHAT}>
<EuiButtonIcon
aria-label={CLEAR_CHAT}
color="danger"
data-test-subj="clear-chat"
display="base"
iconType="cross"
isDisabled={isDisabled}
onClick={onChatCleared}
/>
</EuiToolTip>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiToolTip position="right" content={CLEAR_CHAT}>
<EuiButtonIcon
aria-label={CLEAR_CHAT}
color="danger"
data-test-subj="clear-chat"
display="base"
iconType="cross"
isDisabled={isDisabled}
onClick={onChatCleared}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip position="right" content={SUBMIT_MESSAGE}>
<EuiToolTip
ref={submitTooltipRef}
position="right"
content={SUBMIT_MESSAGE}
display="block"
onMouseOut={closeTooltip}
>
<EuiButtonIcon
aria-label={SUBMIT_MESSAGE}
data-test-subj="submit-chat"
color="primary"
display="base"
iconType="returnKey"
isDisabled={isDisabled}
display={isFlyoutMode && promptValue?.length ? 'fill' : 'base'}
size={isFlyoutMode ? 'm' : 'xs'}
iconType={isFlyoutMode ? 'kqlFunction' : 'returnKey'}
isDisabled={isDisabled || !promptValue?.length}
isLoading={isLoading}
onClick={onSendMessage}
/>

View file

@ -27,6 +27,7 @@ const testProps: Props = {
isDisabled: false,
shouldRefocusPrompt: false,
userPrompt: '',
isFlyoutMode: false,
};
describe('ChatSend', () => {
beforeEach(() => {

View file

@ -8,14 +8,17 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { css } from '@emotion/react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { euiThemeVars } from '@kbn/ui-theme';
import { UseChatSend } from './use_chat_send';
import { ChatActions } from '../chat_actions';
import { PromptTextArea } from '../prompt_textarea';
import { useAutosizeTextArea } from './use_autosize_textarea';
export interface Props extends Omit<UseChatSend, 'abortStream'> {
isDisabled: boolean;
shouldRefocusPrompt: boolean;
userPrompt: string | null;
isFlyoutMode: boolean;
}
/**
@ -29,6 +32,7 @@ export const ChatSend: React.FC<Props> = ({
handleSendMessage,
isDisabled,
isLoading,
isFlyoutMode,
shouldRefocusPrompt,
userPrompt,
}) => {
@ -45,28 +49,45 @@ export const ChatSend: React.FC<Props> = ({
handleButtonSendMessage(promptTextAreaRef.current?.value?.trim() ?? '');
}, [handleButtonSendMessage, promptTextAreaRef]);
useAutosizeTextArea(promptTextAreaRef?.current, promptValue);
return (
<EuiFlexGroup
gutterSize="none"
alignItems={isFlyoutMode ? 'flexEnd' : 'flexStart'}
css={css`
width: 100%;
position: relative;
`}
>
<EuiFlexItem>
<EuiFlexItem
css={css`
width: 100%;
`}
>
<PromptTextArea
onPromptSubmit={handleSendMessage}
ref={promptTextAreaRef}
handlePromptChange={handlePromptChange}
value={promptValue}
isDisabled={isDisabled}
isFlyoutMode={isFlyoutMode}
/>
</EuiFlexItem>
<EuiFlexItem
css={css`
left: -34px;
position: relative;
top: 11px;
`}
css={
isFlyoutMode
? css`
right: 0;
position: absolute;
margin-right: ${euiThemeVars.euiSizeS};
margin-bottom: ${euiThemeVars.euiSizeS};
`
: css`
left: -34px;
position: relative;
top: 11px;
`
}
grow={false}
>
<ChatActions
@ -74,6 +95,8 @@ export const ChatSend: React.FC<Props> = ({
isDisabled={isDisabled}
isLoading={isLoading}
onSendMessage={onSendMessage}
isFlyoutMode={isFlyoutMode}
promptValue={promptValue}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -0,0 +1,23 @@
/*
* 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 { useEffect } from 'react';
// Updates the height of a <textarea> when the value changes.
export const useAutosizeTextArea = (textAreaRef: HTMLTextAreaElement | null, value: string) => {
useEffect(() => {
if (textAreaRef) {
// We need to reset the height momentarily to get the correct scrollHeight for the textarea
textAreaRef.style.height = '0px';
const scrollHeight = textAreaRef.scrollHeight;
// We then set the height directly, outside of the render loop
// Trying to set this with state or a ref will product an incorrect value.
textAreaRef.style.height = `${scrollHeight}px`;
}
}, [textAreaRef, value]);
};

View file

@ -155,7 +155,7 @@ describe('use chat send', () => {
await waitFor(() => {
expect(reportAssistantMessageSent).toHaveBeenNthCalledWith(1, {
conversationId: testProps.currentConversation.title,
conversationId: testProps.currentConversation?.title,
role: 'user',
isEnabledKnowledgeBase: false,
isEnabledRAGAlerts: false,
@ -164,7 +164,7 @@ describe('use chat send', () => {
provider: 'OpenAI',
});
expect(reportAssistantMessageSent).toHaveBeenNthCalledWith(2, {
conversationId: testProps.currentConversation.title,
conversationId: testProps.currentConversation?.title,
role: 'assistant',
isEnabledKnowledgeBase: false,
isEnabledRAGAlerts: false,

View file

@ -19,7 +19,7 @@ import { getDefaultSystemPrompt } from '../use_conversation/helpers';
export interface UseChatSendProps {
allSystemPrompts: Prompt[];
currentConversation: Conversation;
currentConversation?: Conversation;
editingSystemPromptId: string | undefined;
http: HttpSetup;
selectedPromptContexts: Record<string, SelectedPromptContext>;
@ -29,7 +29,7 @@ export interface UseChatSendProps {
React.SetStateAction<Record<string, SelectedPromptContext>>
>;
setUserPrompt: React.Dispatch<React.SetStateAction<string | null>>;
setCurrentConversation: React.Dispatch<React.SetStateAction<Conversation>>;
setCurrentConversation: React.Dispatch<React.SetStateAction<Conversation | undefined>>;
}
export interface UseChatSend {
@ -76,7 +76,7 @@ export const useChatSend = ({
// Handles sending latest user prompt to API
const handleSendMessage = useCallback(
async (promptText: string) => {
if (!currentConversation.apiConfig) {
if (!currentConversation?.apiConfig) {
toasts?.addError(
new Error('The conversation needs a connector configured in order to send a message.'),
{
@ -165,7 +165,7 @@ export const useChatSend = ({
);
const handleRegenerateResponse = useCallback(async () => {
if (!currentConversation.apiConfig) {
if (!currentConversation?.apiConfig) {
toasts?.addError(
new Error('The conversation needs a connector configured in order to send a message.'),
{
@ -215,9 +215,11 @@ export const useChatSend = ({
setPromptTextPreview('');
setUserPrompt('');
setSelectedPromptContexts({});
const updatedConversation = await clearConversation(currentConversation);
if (updatedConversation) {
setCurrentConversation(updatedConversation);
if (currentConversation) {
const updatedConversation = await clearConversation(currentConversation);
if (updatedConversation) {
setCurrentConversation(updatedConversation);
}
}
setEditingSystemPromptId(defaultSystemPromptId);
}, [

View file

@ -33,6 +33,7 @@ const mockPromptContexts: Record<string, PromptContext> = {
const defaultProps = {
anonymizationFields: { total: 0, page: 1, perPage: 1000, data: [] },
promptContexts: mockPromptContexts,
isFlyoutMode: false,
};
describe('ContextPills', () => {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { sortBy } from 'lodash/fp';
import React, { useCallback, useMemo } from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
@ -26,6 +26,7 @@ interface Props {
setSelectedPromptContexts: React.Dispatch<
React.SetStateAction<Record<string, SelectedPromptContext>>
>;
isFlyoutMode: boolean;
}
const ContextPillsComponent: React.FC<Props> = ({
@ -33,6 +34,7 @@ const ContextPillsComponent: React.FC<Props> = ({
promptContexts,
selectedPromptContexts,
setSelectedPromptContexts,
isFlyoutMode,
}) => {
const sortedPromptContexts = useMemo(
() => sortBy('description', Object.values(promptContexts)),
@ -41,7 +43,7 @@ const ContextPillsComponent: React.FC<Props> = ({
const selectPromptContext = useCallback(
async (id: string) => {
if (selectedPromptContexts[id] == null && promptContexts[id] != null) {
if (selectedPromptContexts[id] == null && promptContexts[id] != null && anonymizationFields) {
const newSelectedPromptContext = await getNewSelectedPromptContext({
anonymizationFields,
promptContext: promptContexts[id],
@ -58,10 +60,21 @@ const ContextPillsComponent: React.FC<Props> = ({
return (
<EuiFlexGroup gutterSize="none" wrap>
{sortedPromptContexts.map(({ description, id, getPromptContext, tooltip }) => {
{sortedPromptContexts.map(({ description, id, tooltip }) => {
// Workaround for known issue where tooltip won't dismiss after button state is changed once clicked
// See: https://github.com/elastic/eui/issues/6488#issuecomment-1379656704
const button = (
const button = isFlyoutMode ? (
<EuiButtonEmpty
data-test-subj={`pillButton-${id}`}
disabled={selectedPromptContexts[id] != null}
iconSide="left"
iconType="plusInCircle"
size="s"
onClick={() => selectPromptContext(id)}
>
{description}
</EuiButtonEmpty>
) : (
<PillButton
data-test-subj={`pillButton-${id}`}
disabled={selectedPromptContexts[id] != null}

View file

@ -45,7 +45,7 @@ const onConversationDeleted = jest.fn();
const defaultProps = {
isDisabled: false,
onConversationSelected,
selectedConversationTitle: 'Welcome',
selectedConversationId: 'Welcome',
defaultConnectorId: '123',
defaultProvider: OpenAiProviderType.OpenAi,
conversations: mockConversations,
@ -154,7 +154,7 @@ describe('Conversation selector', () => {
<TestProviders>
<ConversationSelector
{...{ ...defaultProps, conversations: mockConversationsWithCustom }}
selectedConversationTitle={customConvo.title}
selectedConversationId={customConvo.title}
/>
</TestProviders>
);
@ -169,46 +169,6 @@ describe('Conversation selector', () => {
});
});
it('Left arrow selects first conversation', () => {
const { getByTestId } = render(
<TestProviders>
<ConversationSelector
{...{ ...defaultProps, conversations: mockConversationsWithCustom }}
/>
</TestProviders>
);
fireEvent.keyDown(getByTestId('comboBoxSearchInput'), {
key: 'ArrowLeft',
ctrlKey: true,
code: 'ArrowLeft',
charCode: 27,
});
expect(onConversationSelected).toHaveBeenCalledWith({
cId: '',
cTitle: alertConvo.title,
});
});
it('Right arrow selects last conversation', () => {
const { getByTestId } = render(
<TestProviders>
<ConversationSelector {...defaultProps} conversations={mockConversationsWithCustom} />
</TestProviders>
);
fireEvent.keyDown(getByTestId('comboBoxSearchInput'), {
key: 'ArrowRight',
ctrlKey: true,
code: 'ArrowRight',
charCode: 26,
});
expect(onConversationSelected).toHaveBeenCalledWith({
cId: '',
cTitle: customConvo.title,
});
});
it('Right arrow does nothing when ctrlKey is false', () => {
const { getByTestId } = render(
<TestProviders>

View file

@ -16,7 +16,6 @@ import {
EuiToolTip,
} from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import useEvent from 'react-use/lib/useEvent';
import { css } from '@emotion/react';
import { getGenAiConfig } from '../../../connectorland/helpers';
@ -28,11 +27,9 @@ import { DEFAULT_CONVERSATION_TITLE } from '../../use_conversation/translations'
import { useConversation } from '../../use_conversation';
import { SystemPromptSelectorOption } from '../../prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector';
const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0;
interface Props {
defaultConnector?: AIConnector;
selectedConversationTitle: string | undefined;
selectedConversationId: string | undefined;
onConversationSelected: ({ cId, cTitle }: { cId: string; cTitle: string }) => void;
onConversationDeleted: (conversationId: string) => void;
shouldDisableKeyboardShortcut?: () => boolean;
@ -40,22 +37,16 @@ interface Props {
conversations: Record<string, Conversation>;
}
const getPreviousConversationTitle = (
conversationTitles: string[],
selectedConversationTitle: string
) => {
return conversationTitles.indexOf(selectedConversationTitle) === 0
? conversationTitles[conversationTitles.length - 1]
: conversationTitles[conversationTitles.indexOf(selectedConversationTitle) - 1];
const getPreviousConversationId = (conversationIds: string[], selectedConversationId: string) => {
return conversationIds.indexOf(selectedConversationId) === 0
? conversationIds[conversationIds.length - 1]
: conversationIds[conversationIds.indexOf(selectedConversationId) - 1];
};
const getNextConversationTitle = (
conversationTitles: string[],
selectedConversationTitle: string
) => {
return conversationTitles.indexOf(selectedConversationTitle) + 1 >= conversationTitles.length
? conversationTitles[0]
: conversationTitles[conversationTitles.indexOf(selectedConversationTitle) + 1];
const getNextConversationId = (conversationIds: string[], selectedConversationId: string) => {
return conversationIds.indexOf(selectedConversationId) + 1 >= conversationIds.length
? conversationIds[0]
: conversationIds[conversationIds.indexOf(selectedConversationId) + 1];
};
const getConvoId = (cId: string, cTitle: string): string => (cId === cTitle ? '' : cId);
@ -66,7 +57,7 @@ export type ConversationSelectorOption = EuiComboBoxOptionOption<{
export const ConversationSelector: React.FC<Props> = React.memo(
({
selectedConversationTitle = DEFAULT_CONVERSATION_TITLE,
selectedConversationId = DEFAULT_CONVERSATION_TITLE,
defaultConnector,
onConversationSelected,
onConversationDeleted,
@ -77,7 +68,7 @@ export const ConversationSelector: React.FC<Props> = React.memo(
const { allSystemPrompts } = useAssistantContext();
const { createConversation } = useConversation();
const conversationTitles = useMemo(() => Object.keys(conversations), [conversations]);
const conversationIds = useMemo(() => Object.keys(conversations), [conversations]);
const conversationOptions = useMemo<ConversationSelectorOption[]>(() => {
return Object.values(conversations).map((conversation) => ({
value: { isDefault: conversation.isDefault ?? false },
@ -87,7 +78,7 @@ export const ConversationSelector: React.FC<Props> = React.memo(
}, [conversations]);
const [selectedOptions, setSelectedOptions] = useState<ConversationSelectorOption[]>(() => {
return conversationOptions.filter((c) => c.label === selectedConversationTitle) ?? [];
return conversationOptions.filter((c) => c.id === selectedConversationId) ?? [];
});
// Callback for when user types to create a new system prompt
@ -133,7 +124,7 @@ export const ConversationSelector: React.FC<Props> = React.memo(
onConversationSelected(
createdConversation
? { cId: '', cTitle: createdConversation.title }
? { cId: createdConversation.id, cTitle: createdConversation.title }
: { cId: '', cTitle: DEFAULT_CONVERSATION_TITLE }
);
},
@ -142,25 +133,25 @@ export const ConversationSelector: React.FC<Props> = React.memo(
// Callback for when user deletes a conversation
const onDelete = useCallback(
(deletedTitle: string) => {
onConversationDeleted(deletedTitle);
if (selectedConversationTitle === deletedTitle) {
const prevConversationTitle = getPreviousConversationTitle(
conversationTitles,
selectedConversationTitle
(conversationId: string) => {
onConversationDeleted(conversationId);
if (selectedConversationId === conversationId) {
const prevConversationId = getPreviousConversationId(
conversationIds,
selectedConversationId
);
onConversationSelected({
cId: getConvoId(conversations[prevConversationTitle].id, prevConversationTitle),
cTitle: prevConversationTitle,
cId: getConvoId(conversations[prevConversationId].id, prevConversationId),
cTitle: prevConversationId,
});
}
},
[
selectedConversationTitle,
selectedConversationId,
onConversationDeleted,
onConversationSelected,
conversationTitles,
conversationIds,
conversations,
]
);
@ -179,66 +170,32 @@ export const ConversationSelector: React.FC<Props> = React.memo(
);
const onLeftArrowClick = useCallback(() => {
const prevTitle = getPreviousConversationTitle(conversationTitles, selectedConversationTitle);
const prevId = getPreviousConversationId(conversationIds, selectedConversationId);
onConversationSelected({
cId: getConvoId(conversations[prevTitle].id, prevTitle),
cTitle: prevTitle,
cId: getConvoId(prevId, conversations[prevId]?.title),
cTitle: conversations[prevId]?.title,
});
}, [conversationTitles, selectedConversationTitle, onConversationSelected, conversations]);
}, [conversationIds, selectedConversationId, onConversationSelected, conversations]);
const onRightArrowClick = useCallback(() => {
const nextTitle = getNextConversationTitle(conversationTitles, selectedConversationTitle);
const nextId = getNextConversationId(conversationIds, selectedConversationId);
onConversationSelected({
cId: getConvoId(conversations[nextTitle].id, nextTitle),
cTitle: nextTitle,
cId: getConvoId(nextId, conversations[nextId]?.title),
cTitle: conversations[nextId]?.title,
});
}, [conversationTitles, selectedConversationTitle, onConversationSelected, conversations]);
// Register keyboard listener for quick conversation switching
const onKeyDown = useCallback(
(event: KeyboardEvent) => {
if (isDisabled || conversationTitles.length <= 1) {
return;
}
if (
event.key === 'ArrowLeft' &&
(isMac ? event.metaKey : event.ctrlKey) &&
!shouldDisableKeyboardShortcut()
) {
event.preventDefault();
onLeftArrowClick();
}
if (
event.key === 'ArrowRight' &&
(isMac ? event.metaKey : event.ctrlKey) &&
!shouldDisableKeyboardShortcut()
) {
event.preventDefault();
onRightArrowClick();
}
},
[
conversationTitles.length,
isDisabled,
onLeftArrowClick,
onRightArrowClick,
shouldDisableKeyboardShortcut,
]
);
useEvent('keydown', onKeyDown);
}, [conversationIds, selectedConversationId, onConversationSelected, conversations]);
useEffect(() => {
setSelectedOptions(conversationOptions.filter((c) => c.label === selectedConversationTitle));
}, [conversationOptions, selectedConversationTitle]);
setSelectedOptions(conversationOptions.filter((c) => c.id === selectedConversationId));
}, [conversationOptions, selectedConversationId]);
const renderOption: (
option: ConversationSelectorOption,
searchValue: string,
OPTION_CONTENT_CLASSNAME: string
) => React.ReactNode = (option, searchValue, contentClassName) => {
const { label, value } = option;
const { label, id, value } = option;
return (
<EuiFlexGroup
@ -265,7 +222,7 @@ export const ConversationSelector: React.FC<Props> = React.memo(
{label}
</EuiHighlight>
</EuiFlexItem>
{!value?.isDefault && (
{!value?.isDefault && id && (
<EuiFlexItem grow={false} component={'span'}>
<EuiToolTip position="right" content={i18n.DELETE_CONVERSATION}>
<EuiButtonIcon
@ -274,7 +231,7 @@ export const ConversationSelector: React.FC<Props> = React.memo(
color="danger"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onDelete(label ?? '');
onDelete(id);
}}
data-test-subj="delete-option"
css={css`
@ -313,22 +270,22 @@ export const ConversationSelector: React.FC<Props> = React.memo(
compressed={true}
isDisabled={isDisabled}
prepend={
<EuiToolTip content={`${i18n.PREVIOUS_CONVERSATION_TITLE} (⌘ + ←)`} display="block">
<EuiToolTip content={`${i18n.PREVIOUS_CONVERSATION_TITLE}`} display="block">
<EuiButtonIcon
iconType="arrowLeft"
aria-label={i18n.PREVIOUS_CONVERSATION_TITLE}
onClick={onLeftArrowClick}
disabled={isDisabled || conversationTitles.length <= 1}
disabled={isDisabled || conversationIds.length <= 1}
/>
</EuiToolTip>
}
append={
<EuiToolTip content={`${i18n.NEXT_CONVERSATION_TITLE} (⌘ + →)`} display="block">
<EuiToolTip content={`${i18n.NEXT_CONVERSATION_TITLE}`} display="block">
<EuiButtonIcon
iconType="arrowRight"
aria-label={i18n.NEXT_CONVERSATION_TITLE}
onClick={onRightArrowClick}
disabled={isDisabled || conversationTitles.length <= 1}
disabled={isDisabled || conversationIds.length <= 1}
/>
</EuiToolTip>
}

View file

@ -68,7 +68,10 @@ export const ConversationSelectorSettings: React.FC<Props> = React.memo(
isDisabled,
shouldDisableKeyboardShortcut = () => false,
}) => {
const conversationTitles = useMemo(() => Object.keys(conversations), [conversations]);
const conversationTitles = useMemo(
() => Object.values(conversations).map((c) => c.title),
[conversations]
);
const [conversationOptions, setConversationOptions] = useState<
ConversationSelectorSettingsOption[]

View file

@ -15,9 +15,9 @@ import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/c
import { mockConnectors } from '../../../mock/connectors';
const mockConvos = {
[welcomeConvo.title]: { ...welcomeConvo, id: '1234' },
[alertConvo.title]: { ...alertConvo, id: '12345' },
[customConvo.title]: { ...customConvo, id: '123' },
'1234': { ...welcomeConvo, id: '1234' },
'12345': { ...alertConvo, id: '12345' },
'123': { ...customConvo, id: '123' },
};
const onSelectedConversationChange = jest.fn();
@ -31,7 +31,7 @@ const testProps = {
defaultProvider: OpenAiProviderType.OpenAi,
http: { basePath: { get: jest.fn() } },
onSelectedConversationChange,
selectedConversation: mockConvos[welcomeConvo.title],
selectedConversation: mockConvos['1234'],
setConversationSettings,
conversationsSettingsBulkActions: {},
setConversationsSettingsBulkActions,
@ -45,7 +45,7 @@ jest.mock('../../../connectorland/use_load_connectors', () => ({
}),
}));
const mockConvo = mockConvos[alertConvo.title];
const mockConvo = mockConvos['12345'];
jest.mock('../conversation_selector_settings', () => ({
// @ts-ignore
ConversationSelectorSettings: ({ onConversationDeleted, onConversationSelectionChange }) => (
@ -127,8 +127,8 @@ describe('ConversationSettings', () => {
fireEvent.click(getByTestId('change-sp'));
expect(setConversationSettings).toHaveBeenLastCalledWith({
...mockConvos,
[welcomeConvo.title]: {
...mockConvos[welcomeConvo.title],
[mockConvos['1234'].id]: {
...mockConvos['1234'],
apiConfig: {
...welcomeConvo.apiConfig,
defaultSystemPromptId: 'mock-superhero-system-prompt-1',
@ -149,7 +149,7 @@ describe('ConversationSettings', () => {
fireEvent.click(getByTestId('change-sp'));
expect(setConversationSettings).toHaveBeenLastCalledWith({
...mockConvos,
[mockConvo.title]: {
'not-the-right-id': {
...mockConvo,
id: 'not-the-right-id',
apiConfig: {
@ -214,7 +214,7 @@ describe('ConversationSettings', () => {
</TestProviders>
);
fireEvent.click(getByTestId('delete-convo'));
const { [customConvo.title]: _, ...rest } = mockConvos;
const { '123': _, ...rest } = mockConvos;
expect(setConversationSettings).toHaveBeenLastCalledWith(rest);
});
it('Selecting a new connector updates the conversation', () => {
@ -226,8 +226,8 @@ describe('ConversationSettings', () => {
fireEvent.click(getByTestId('change-connector'));
expect(setConversationSettings).toHaveBeenLastCalledWith({
...mockConvos,
[welcomeConvo.title]: {
...mockConvos[welcomeConvo.title],
[mockConvos['1234'].id]: {
...mockConvos['1234'],
apiConfig: {
actionTypeId: mockConnector.actionTypeId,
connectorId: mockConnector.id,
@ -238,8 +238,8 @@ describe('ConversationSettings', () => {
});
expect(setConversationsSettingsBulkActions).toHaveBeenLastCalledWith({
update: {
[welcomeConvo.title]: {
...mockConvos[welcomeConvo.title],
[mockConvos['1234'].id]: {
...mockConvos['1234'],
apiConfig: {
actionTypeId: mockConnector.actionTypeId,
connectorId: mockConnector.id,
@ -259,8 +259,8 @@ describe('ConversationSettings', () => {
fireEvent.click(getByTestId('change-model'));
expect(setConversationSettings).toHaveBeenLastCalledWith({
...mockConvos,
[welcomeConvo.title]: {
...mockConvos[welcomeConvo.title],
[mockConvos['1234'].id]: {
...mockConvos['1234'],
apiConfig: {
...welcomeConvo.apiConfig,
model: 'MODEL_GPT_4',
@ -269,8 +269,8 @@ describe('ConversationSettings', () => {
});
expect(setConversationsSettingsBulkActions).toHaveBeenLastCalledWith({
update: {
[welcomeConvo.title]: {
...mockConvos[welcomeConvo.title],
[mockConvos['1234'].id]: {
...mockConvos['1234'],
apiConfig: {
...welcomeConvo.apiConfig,
model: 'MODEL_GPT_4',
@ -325,7 +325,7 @@ describe('ConversationSettings', () => {
fireEvent.click(getByTestId('change-connector'));
expect(setConversationSettings).toHaveBeenLastCalledWith({
...mockConvos,
[mockConvo.title]: {
'not-the-right-id': {
...mockConvo,
id: 'not-the-right-id',
apiConfig: {

View file

@ -50,6 +50,7 @@ export interface ConversationSettingsProps {
React.SetStateAction<ConversationsBulkActions>
>;
isDisabled?: boolean;
isFlyoutMode: boolean;
}
/**
@ -65,6 +66,7 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
conversationSettings,
http,
isDisabled = false,
isFlyoutMode,
setAssistantStreamingEnabled,
setConversationSettings,
conversationsSettingsBulkActions,
@ -82,6 +84,14 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
http,
});
const selectedConversationId = useMemo(
() =>
selectedConversation?.id === ''
? selectedConversation.title
: (selectedConversation?.id as string),
[selectedConversation]
);
// Conversation callbacks
// When top level conversation selection changes
const onConversationSelectionChange = useCallback(
@ -124,7 +134,7 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
setConversationSettings((prev) => {
return {
...prev,
[newSelectedConversation.title]: newSelectedConversation,
[newSelectedConversation.id]: newSelectedConversation,
};
});
}
@ -144,9 +154,10 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
const onConversationDeleted = useCallback(
(conversationTitle: string) => {
const conversationId = conversationSettings[conversationTitle].id;
const conversationId =
Object.values(conversationSettings).find((c) => c.title === conversationTitle)?.id ?? '';
const updatedConversationSettings = { ...conversationSettings };
delete updatedConversationSettings[conversationTitle];
delete updatedConversationSettings[conversationId];
setConversationSettings(updatedConversationSettings);
setConversationsSettingsBulkActions({
@ -176,22 +187,22 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
};
setConversationSettings({
...conversationSettings,
[updatedConversation.title]: updatedConversation,
[updatedConversation.id]: updatedConversation,
});
if (selectedConversation.id !== '') {
setConversationsSettingsBulkActions({
...conversationsSettingsBulkActions,
update: {
...(conversationsSettingsBulkActions.update ?? {}),
[updatedConversation.title]: {
[updatedConversation.id]: {
...updatedConversation,
...(conversationsSettingsBulkActions.update
? conversationsSettingsBulkActions.update[updatedConversation.title] ?? {}
? conversationsSettingsBulkActions.update[updatedConversation.id] ?? {}
: {}),
apiConfig: {
...updatedConversation.apiConfig,
...((conversationsSettingsBulkActions.update
? conversationsSettingsBulkActions.update[updatedConversation.title] ?? {}
? conversationsSettingsBulkActions.update[updatedConversation.id] ?? {}
: {}
).apiConfig ?? {}),
defaultSystemPromptId: systemPromptId,
@ -204,7 +215,7 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
...conversationsSettingsBulkActions,
create: {
...(conversationsSettingsBulkActions.create ?? {}),
[updatedConversation.title]: updatedConversation,
[updatedConversation.id]: updatedConversation,
},
});
}
@ -248,22 +259,22 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
};
setConversationSettings({
...conversationSettings,
[selectedConversation.title]: updatedConversation,
[selectedConversationId]: updatedConversation,
});
if (selectedConversation.id !== '') {
setConversationsSettingsBulkActions({
...conversationsSettingsBulkActions,
update: {
...(conversationsSettingsBulkActions.update ?? {}),
[updatedConversation.title]: {
[updatedConversation.id]: {
...updatedConversation,
...(conversationsSettingsBulkActions.update
? conversationsSettingsBulkActions.update[updatedConversation.title] ?? {}
? conversationsSettingsBulkActions.update[updatedConversation.id] ?? {}
: {}),
apiConfig: {
...updatedConversation.apiConfig,
...((conversationsSettingsBulkActions.update
? conversationsSettingsBulkActions.update[updatedConversation.title] ?? {}
? conversationsSettingsBulkActions.update[updatedConversation.id] ?? {}
: {}
).apiConfig ?? {}),
connectorId: connector?.id,
@ -279,7 +290,7 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
...conversationsSettingsBulkActions,
create: {
...(conversationsSettingsBulkActions.create ?? {}),
[updatedConversation.title]: updatedConversation,
[updatedConversation.id]: updatedConversation,
},
});
}
@ -289,6 +300,7 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
conversationSettings,
conversationsSettingsBulkActions,
selectedConversation,
selectedConversationId,
setConversationSettings,
setConversationsSettingsBulkActions,
]
@ -312,22 +324,22 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
};
setConversationSettings({
...conversationSettings,
[updatedConversation.title]: updatedConversation,
[updatedConversation.id]: updatedConversation,
});
if (selectedConversation.id !== '') {
setConversationsSettingsBulkActions({
...conversationsSettingsBulkActions,
update: {
...(conversationsSettingsBulkActions.update ?? {}),
[updatedConversation.title]: {
[updatedConversation.id]: {
...updatedConversation,
...(conversationsSettingsBulkActions.update
? conversationsSettingsBulkActions.update[updatedConversation.title] ?? {}
? conversationsSettingsBulkActions.update[updatedConversation.id] ?? {}
: {}),
apiConfig: {
...updatedConversation.apiConfig,
...((conversationsSettingsBulkActions.update
? conversationsSettingsBulkActions.update[updatedConversation.title] ?? {}
? conversationsSettingsBulkActions.update[updatedConversation.id] ?? {}
: {}
).apiConfig ?? {}),
model,
@ -340,7 +352,7 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
...conversationsSettingsBulkActions,
create: {
...(conversationsSettingsBulkActions.create ?? {}),
[updatedConversation.title]: updatedConversation,
[updatedConversation.id]: updatedConversation,
},
});
}
@ -388,6 +400,7 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
showTitles={true}
isSettingsModalVisible={true}
setIsSettingsModalVisible={noop} // noop, already in settings
isFlyoutMode={isFlyoutMode}
/>
</EuiFormRow>

View file

@ -0,0 +1,267 @@
/*
* 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 {
EuiComboBoxOptionOption,
EuiFlexGroup,
EuiFlexItem,
EuiButton,
EuiListGroup,
EuiListGroupItem,
EuiPanel,
EuiConfirmModal,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import useEvent from 'react-use/lib/useEvent';
import { css } from '@emotion/react';
import { isEmpty, findIndex, orderBy } from 'lodash';
import { Conversation } from '../../../..';
import * as i18n from './translations';
const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0;
interface Props {
currentConversation?: Conversation;
onConversationSelected: ({ cId, cTitle }: { cId: string; cTitle: string }) => void;
shouldDisableKeyboardShortcut?: () => boolean;
isDisabled?: boolean;
conversations: Record<string, Conversation>;
onConversationDeleted: (conversationId: string) => void;
onConversationCreate: () => void;
refetchConversationsState: () => Promise<void>;
}
const getCurrentConversationIndex = (
conversationList: Conversation[],
currentConversation: Conversation
) =>
findIndex(conversationList, (c) =>
!isEmpty(c.id) ? c.id === currentConversation?.id : c.title === currentConversation?.title
);
const getPreviousConversation = (
conversationList: Conversation[],
currentConversation?: Conversation
) => {
const conversationIndex = currentConversation
? getCurrentConversationIndex(conversationList, currentConversation)
: 0;
return !conversationIndex
? conversationList[conversationList.length - 1]
: conversationList[conversationIndex - 1];
};
const getNextConversation = (
conversationList: Conversation[],
currentConversation?: Conversation
) => {
const conversationIndex = currentConversation
? getCurrentConversationIndex(conversationList, currentConversation)
: 0;
return conversationIndex >= conversationList.length - 1
? conversationList[0]
: conversationList[conversationIndex + 1];
};
export type ConversationSelectorOption = EuiComboBoxOptionOption<{
isDefault: boolean;
}>;
export const ConversationSidePanel = React.memo<Props>(
({
currentConversation,
onConversationSelected,
shouldDisableKeyboardShortcut = () => false,
isDisabled = false,
conversations,
onConversationDeleted,
onConversationCreate,
}) => {
const [deleteConversationItem, setDeleteConversationItem] = useState<Conversation | null>(null);
const conversationList = useMemo(
() =>
orderBy(Object.values(conversations), 'updatedAt', 'desc').sort((a, b) =>
a.id.length > b.id.length ? -1 : 1
),
[conversations]
);
const conversationIds = useMemo(() => Object.keys(conversations), [conversations]);
// Callback for when user deletes a conversation
const onDelete = useCallback(
(conversation: Conversation) => {
if (currentConversation?.id === conversation.id) {
const previousConversation = getNextConversation(conversationList, conversation);
onConversationSelected({
cId: previousConversation.id,
cTitle: previousConversation.title,
});
}
onConversationDeleted(conversation.id);
},
[currentConversation?.id, onConversationDeleted, conversationList, onConversationSelected]
);
const onArrowUpClick = useCallback(() => {
const previousConversation = getPreviousConversation(conversationList, currentConversation);
onConversationSelected({
cId: previousConversation.id,
cTitle: previousConversation.title,
});
}, [conversationList, currentConversation, onConversationSelected]);
const onArrowDownClick = useCallback(() => {
const nextConversation = getNextConversation(conversationList, currentConversation);
onConversationSelected({
cId: nextConversation.id,
cTitle: nextConversation.title,
});
}, [conversationList, currentConversation, onConversationSelected]);
// Register keyboard listener for quick conversation switching
const onKeyDown = useCallback(
(event: KeyboardEvent) => {
if (isDisabled || conversationIds.length <= 1) {
return;
}
if (
event.key === 'ArrowUp' &&
(isMac ? event.metaKey : event.ctrlKey) &&
!shouldDisableKeyboardShortcut()
) {
event.preventDefault();
onArrowUpClick();
}
if (
event.key === 'ArrowDown' &&
(isMac ? event.metaKey : event.ctrlKey) &&
!shouldDisableKeyboardShortcut()
) {
event.preventDefault();
onArrowDownClick();
}
},
[
conversationIds.length,
isDisabled,
onArrowUpClick,
onArrowDownClick,
shouldDisableKeyboardShortcut,
]
);
const handleCloseModal = useCallback(() => {
setDeleteConversationItem(null);
}, []);
const handleDelete = useCallback(() => {
if (deleteConversationItem) {
setDeleteConversationItem(null);
onDelete(deleteConversationItem);
}
}, [deleteConversationItem, onDelete]);
useEvent('keydown', onKeyDown);
return (
<>
<EuiFlexGroup
direction="column"
justifyContent="spaceBetween"
gutterSize="none"
css={css`
overflow: hidden;
`}
>
<EuiFlexItem
css={css`
overflow: auto;
`}
>
<EuiPanel hasShadow={false} borderRadius="none">
<EuiListGroup
size="xs"
css={css`
padding: 0;
`}
>
{conversationList.map((conversation) => (
<EuiListGroupItem
key={conversation.id + conversation.title}
onClick={() =>
onConversationSelected({ cId: conversation.id, cTitle: conversation.title })
}
label={conversation.title}
isActive={
!isEmpty(conversation.id)
? conversation.id === currentConversation?.id
: conversation.title === currentConversation?.title
}
extraAction={{
color: 'danger',
onClick: () => setDeleteConversationItem(conversation),
iconType: 'trash',
iconSize: 's',
disabled: conversation.isDefault,
'aria-label': i18n.DELETE_CONVERSATION_ARIA_LABEL,
'data-test-subj': 'delete-option',
}}
/>
))}
</EuiListGroup>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPanel
hasShadow={false}
hasBorder
borderRadius="none"
paddingSize="m"
css={css`
border-left: 0;
border-right: 0;
border-bottom: 0;
padding-top: 12px;
padding-bottom: 12px;
`}
>
<EuiButton
color="primary"
fill
iconType="discuss"
onClick={onConversationCreate}
fullWidth
size="s"
>
{i18n.NEW_CHAT}
</EuiButton>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
{deleteConversationItem && (
<EuiConfirmModal
title={i18n.DELETE_CONVERSATION_TITLE}
onCancel={handleCloseModal}
onConfirm={handleDelete}
cancelButtonText={i18n.CANCEL_BUTTON_TEXT}
confirmButtonText={i18n.DELETE_BUTTON_TEXT}
buttonColor="danger"
defaultFocusedButton="confirm"
/>
)}
</>
);
}
);
ConversationSidePanel.displayName = 'ConversationSidePanel';

View file

@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { useController } from 'react-hook-form';
import { EuiFieldText, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import deepEqual from 'fast-deep-equal';
interface TitleFieldProps {
conversationIds?: string[];
euiFieldProps?: Record<string, unknown>;
}
const TitleFieldComponent = ({ conversationIds, euiFieldProps }: TitleFieldProps) => {
const {
field: { onChange, value, name: fieldName },
fieldState: { error },
} = useController({
name: 'title',
defaultValue: '',
rules: {
required: {
message: i18n.translate(
'xpack.elasticAssistant.conversationSidepanel.titleField.titleIsRequired',
{
defaultMessage: 'Title is required',
}
),
value: true,
},
validate: (text: string) => {
if (conversationIds?.includes(value)) {
return i18n.translate(
'xpack.elasticAssistant.conversationSidepanel.titleField.uniqueTitle',
{
defaultMessage: 'Title must be unique',
}
);
}
},
},
});
const hasError = useMemo(() => !!error?.message, [error?.message]);
return (
<EuiFormRow
label={i18n.translate('xpack.elasticAssistant.conversationSidepanel.titleFieldLabel', {
defaultMessage: 'Title',
})}
error={error?.message}
isInvalid={hasError}
fullWidth
>
<EuiFieldText
isInvalid={hasError}
onChange={onChange}
value={value}
name={fieldName}
fullWidth
data-test-subj="input"
{...euiFieldProps}
/>
</EuiFormRow>
);
};
export const TitleField = React.memo(TitleFieldComponent, deepEqual);

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const SELECTED_CONVERSATION_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelector.defaultConversationTitle',
{
defaultMessage: 'Conversations',
}
);
export const NEXT_CONVERSATION_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelector.nextConversationTitle',
{
defaultMessage: 'Next conversation',
}
);
export const DELETE_CONVERSATION_ARIA_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.sidePanel.deleteConversationAriaLabel',
{
defaultMessage: 'Delete conversation',
}
);
export const NEW_CHAT = i18n.translate(
'xpack.elasticAssistant.assistant.conversations.sidePanel.newChatButtonLabel',
{
defaultMessage: 'New chat',
}
);
export const DELETE_CONVERSATION_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.deleteConversationModal.deleteConversationTitle',
{
defaultMessage: 'Delete this conversation',
}
);
export const CANCEL_BUTTON_TEXT = i18n.translate(
'xpack.elasticAssistant.assistant.deleteConversationModal.cancelButtonText',
{
defaultMessage: 'Cancel',
}
);
export const DELETE_BUTTON_TEXT = i18n.translate(
'xpack.elasticAssistant.assistant.deleteConversationModal.deleteButtonText',
{
defaultMessage: 'Delete',
}
);

View file

@ -22,11 +22,12 @@ const defaultConversation = {
replacements: {},
title: 'conversation_id',
};
const isFlyoutMode = false;
describe('helpers', () => {
describe('isAssistantEnabled = false', () => {
const isAssistantEnabled = false;
it('When no conversation history, return only enterprise messaging', () => {
const result = getBlockBotConversation(defaultConversation, isAssistantEnabled);
const result = getBlockBotConversation(defaultConversation, isAssistantEnabled, isFlyoutMode);
expect(result.messages).toEqual(enterpriseMessaging);
expect(result.messages.length).toEqual(1);
});
@ -46,7 +47,7 @@ describe('helpers', () => {
},
],
};
const result = getBlockBotConversation(conversation, isAssistantEnabled);
const result = getBlockBotConversation(conversation, isAssistantEnabled, isFlyoutMode);
expect(result.messages.length).toEqual(2);
});
@ -55,7 +56,7 @@ describe('helpers', () => {
...defaultConversation,
messages: enterpriseMessaging,
};
const result = getBlockBotConversation(conversation, isAssistantEnabled);
const result = getBlockBotConversation(conversation, isAssistantEnabled, isFlyoutMode);
expect(result.messages.length).toEqual(1);
expect(result.messages).toEqual(enterpriseMessaging);
});
@ -76,7 +77,7 @@ describe('helpers', () => {
},
],
};
const result = getBlockBotConversation(conversation, isAssistantEnabled);
const result = getBlockBotConversation(conversation, isAssistantEnabled, isFlyoutMode);
expect(result.messages.length).toEqual(3);
});
});
@ -84,7 +85,7 @@ describe('helpers', () => {
describe('isAssistantEnabled = true', () => {
const isAssistantEnabled = true;
it('when no conversation history, returns the welcome conversation', () => {
const result = getBlockBotConversation(defaultConversation, isAssistantEnabled);
const result = getBlockBotConversation(defaultConversation, isAssistantEnabled, isFlyoutMode);
expect(result.messages.length).toEqual(3);
});
it('returns a conversation history with the welcome conversation appended', () => {
@ -102,7 +103,7 @@ describe('helpers', () => {
},
],
};
const result = getBlockBotConversation(conversation, isAssistantEnabled);
const result = getBlockBotConversation(conversation, isAssistantEnabled, isFlyoutMode);
expect(result.messages.length).toEqual(4);
});
});
@ -144,7 +145,7 @@ describe('helpers', () => {
expect(result).toBe(connectors[0]);
});
it('should return undefined if there are multiple connectors', () => {
it('should return the connector id if there are multiple connectors', () => {
const connectors: AIConnector[] = [
defaultConnector,
{
@ -158,7 +159,7 @@ describe('helpers', () => {
},
];
const result = getDefaultConnector(connectors);
expect(result).toBeUndefined();
expect(result).toBe(connectors[0]);
});
});
@ -204,12 +205,12 @@ describe('helpers', () => {
replacements: {},
};
const baseConversations = {
conversation1: {
conversation_1: {
...defaultProps,
title: 'Conversation 1',
id: 'conversation_1',
},
conversation2: {
conversation_2: {
...defaultProps,
title: 'Conversation 2',
id: 'conversation_2',
@ -242,22 +243,22 @@ describe('helpers', () => {
const result = mergeBaseWithPersistedConversations(baseConversations, moreData);
expect(result).toEqual({
conversation1: {
conversation_1: {
title: 'Conversation 1',
id: 'conversation_1',
...defaultProps,
},
conversation2: {
conversation_2: {
title: 'Conversation 2',
id: 'conversation_2',
...defaultProps,
},
'Conversation 3': {
conversation_3: {
title: 'Conversation 3',
id: 'conversation_3',
...defaultProps,
},
'Conversation 4': {
conversation_4: {
title: 'Conversation 4',
id: 'conversation_4',
...defaultProps,
@ -279,73 +280,17 @@ describe('helpers', () => {
const result = mergeBaseWithPersistedConversations({}, conversationsData);
expect(result).toEqual({
'Conversation 1': {
conversation_1: {
...defaultProps,
title: 'Conversation 1',
id: 'conversation_1',
},
'Conversation 2': {
conversation_2: {
...defaultProps,
title: 'Conversation 2',
id: 'conversation_2',
},
});
});
it('should handle and merge conversations with duplicate titles', () => {
const result = mergeBaseWithPersistedConversations(
{
'Conversation 1': {
title: 'Conversation 1',
id: 'conversation1',
...defaultProps,
},
},
{
page: 1,
perPage: 10,
total: 1,
data: [
{
title: 'Conversation 1',
id: 'conversation1',
...defaultProps,
messages: [
{
content: 'Message 3',
role: 'user' as const,
timestamp: '2024-02-14T22:29:43.862Z',
},
{
content: 'Message 4',
role: 'user' as const,
timestamp: '2024-02-14T22:29:43.862Z',
},
],
},
],
}
);
expect(result).toEqual({
'Conversation 1': {
title: 'Conversation 1',
id: 'conversation1',
...defaultProps,
messages: [
{
content: 'Message 3',
role: 'user',
timestamp: '2024-02-14T22:29:43.862Z',
},
{
content: 'Message 4',
role: 'user',
timestamp: '2024-02-14T22:29:43.862Z',
},
],
},
});
});
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { merge } from 'lodash/fp';
import { isEmpty, some } from 'lodash';
import { AIConnector } from '../connectorland/connector_selector';
import { FetchConnectorExecuteResponse, FetchConversationsResponse } from './api';
import { Conversation } from '../..';
@ -41,19 +41,24 @@ export const mergeBaseWithPersistedConversations = (
baseConversations: Record<string, Conversation>,
conversationsData: FetchConversationsResponse
): Record<string, Conversation> => {
const userConversations = (conversationsData?.data ?? []).reduce<Record<string, Conversation>>(
(transformed, conversation) => {
transformed[conversation.title] = conversation;
return transformed;
},
{}
);
return merge(baseConversations, userConversations);
return [...(conversationsData?.data ?? []), ...Object.values(baseConversations)].reduce<
Record<string, Conversation>
>((transformed, conversation) => {
if (!isEmpty(conversation.id)) {
transformed[conversation.id] = conversation;
} else {
if (!some(Object.values(transformed), ['title', conversation.title])) {
transformed[conversation.title] = conversation;
}
}
return transformed;
}, {});
};
export const getBlockBotConversation = (
conversation: Conversation,
isAssistantEnabled: boolean
isAssistantEnabled: boolean,
isFlyoutMode: boolean
): Conversation => {
if (!isAssistantEnabled) {
if (
@ -71,7 +76,7 @@ export const getBlockBotConversation = (
return {
...conversation,
messages: [...conversation.messages, ...WELCOME_CONVERSATION.messages],
messages: [...conversation.messages, ...(!isFlyoutMode ? WELCOME_CONVERSATION.messages : [])],
};
};
@ -81,7 +86,14 @@ export const getBlockBotConversation = (
*/
export const getDefaultConnector = (
connectors: AIConnector[] | undefined
): AIConnector | undefined => (connectors?.length === 1 ? connectors[0] : undefined);
): AIConnector | undefined => {
const validConnectors = connectors?.filter((connector) => !connector.isMissingSecrets);
if (validConnectors?.length) {
return validConnectors[0];
}
return undefined;
};
interface OptionalRequestParams {
alertsIndexPattern?: string;

View file

@ -14,8 +14,7 @@ import type { IHttpFetchError } from '@kbn/core/public';
import { useLoadConnectors } from '../connectorland/use_load_connectors';
import { useConnectorSetup } from '../connectorland/connector_setup';
import { UseQueryResult } from '@tanstack/react-query';
import { WELCOME_CONVERSATION_TITLE } from './use_conversation/translations';
import { DefinedUseQueryResult, UseQueryResult } from '@tanstack/react-query';
import { useLocalStorage, useSessionStorage } from 'react-use';
import { PromptEditor } from './prompt_editor';
@ -26,6 +25,7 @@ import { Conversation } from '../assistant_context/types';
import * as all from './chat_send/use_chat_send';
import { useConversation } from './use_conversation';
import { AIConnector } from '../connectorland/connector_selector';
import { omit } from 'lodash';
jest.mock('../connectorland/use_load_connectors');
jest.mock('../connectorland/connector_setup');
@ -40,21 +40,21 @@ jest.mock('./use_conversation');
const renderAssistant = (extraProps = {}, providerProps = {}) =>
render(
<TestProviders>
<Assistant {...extraProps} />
<Assistant chatHistoryVisible={false} setChatHistoryVisible={jest.fn()} {...extraProps} />
</TestProviders>
);
const mockData = {
Welcome: {
id: 'Welcome Id',
welcome_id: {
id: 'welcome_id',
title: 'Welcome',
category: 'assistant',
messages: [],
apiConfig: { connectorId: '123' },
replacements: {},
},
'electric sheep': {
id: 'electric sheep id',
electric_sheep_id: {
id: 'electric_sheep_id',
category: 'assistant',
title: 'electric sheep',
messages: [],
@ -65,8 +65,9 @@ const mockData = {
const mockDeleteConvo = jest.fn();
const mockUseConversation = {
getConversation: jest.fn(),
getDefaultConversation: jest.fn().mockReturnValue(mockData.Welcome),
getDefaultConversation: jest.fn().mockReturnValue(mockData.welcome_id),
deleteConversation: mockDeleteConvo,
setApiConfig: jest.fn().mockResolvedValue({}),
};
describe('Assistant', () => {
@ -87,7 +88,7 @@ describe('Assistant', () => {
},
];
jest.mocked(useLoadConnectors).mockReturnValue({
isSuccess: true,
isFetched: true,
data: connectors,
} as unknown as UseQueryResult<AIConnector[], IHttpFetchError>);
@ -98,13 +99,14 @@ describe('Assistant', () => {
isLoading: false,
data: {
...mockData,
Welcome: {
...mockData.Welcome,
welcome_id: {
...mockData.welcome_id,
apiConfig: { newProp: true },
},
},
}),
} as unknown as UseQueryResult<Record<string, Conversation>, unknown>);
isFetched: true,
} as unknown as DefinedUseQueryResult<Record<string, Conversation>, unknown>);
});
let persistToLocalStorage: jest.Mock;
@ -134,7 +136,36 @@ describe('Assistant', () => {
renderAssistant({ setConversationTitle });
expect(chatSendSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
currentConversation: mockData.welcome_id,
})
);
fireEvent.click(screen.getByTestId('settings'));
jest.mocked(useFetchCurrentUserConversations).mockReturnValue({
data: {
...mockData,
welcome_id: {
...mockData.welcome_id,
apiConfig: { newProp: true },
},
},
isLoading: false,
refetch: jest.fn().mockResolvedValue({
isLoading: false,
data: {
...mockData,
welcome_id: {
...mockData.welcome_id,
apiConfig: { newProp: true },
},
},
}),
isFetched: true,
} as unknown as DefinedUseQueryResult<Record<string, Conversation>, unknown>);
await act(async () => {
fireEvent.click(screen.getByTestId('save-button'));
});
@ -144,7 +175,7 @@ describe('Assistant', () => {
currentConversation: {
apiConfig: { newProp: true },
category: 'assistant',
id: 'Welcome Id',
id: mockData.welcome_id.id,
messages: [],
title: 'Welcome',
replacements: {},
@ -154,15 +185,15 @@ describe('Assistant', () => {
});
it('should refetchConversationsState after settings save button click, but do not update convos when refetch returns bad results', async () => {
const { Welcome, ...rest } = mockData;
jest.mocked(useFetchCurrentUserConversations).mockReturnValue({
data: mockData,
isLoading: false,
refetch: jest.fn().mockResolvedValue({
isLoading: false,
data: rest,
data: omit(mockData, 'welcome_id'),
}),
} as unknown as UseQueryResult<Record<string, Conversation>, unknown>);
isFetched: true,
} as unknown as DefinedUseQueryResult<Record<string, Conversation>, unknown>);
const chatSendSpy = jest.spyOn(all, 'useChatSend');
const setConversationTitle = jest.fn();
@ -179,7 +210,7 @@ describe('Assistant', () => {
apiConfig: { connectorId: '123' },
replacements: {},
category: 'assistant',
id: 'Welcome Id',
id: mockData.welcome_id.id,
messages: [],
title: 'Welcome',
},
@ -201,16 +232,21 @@ describe('Assistant', () => {
await act(async () => {
fireEvent.click(deleteButton);
});
expect(mockDeleteConvo).toHaveBeenCalledWith('Welcome Id');
expect(mockDeleteConvo).toHaveBeenCalledWith(mockData.welcome_id.id);
});
});
describe('when selected conversation changes and some connectors are loaded', () => {
it('should persist the conversation title to local storage', async () => {
it('should persist the conversation id to local storage', async () => {
const getConversation = jest.fn().mockResolvedValue(mockData.electric_sheep_id);
(useConversation as jest.Mock).mockReturnValue({
...mockUseConversation,
getConversation,
});
renderAssistant();
expect(persistToLocalStorage).toHaveBeenCalled();
expect(persistToLocalStorage).toHaveBeenLastCalledWith(WELCOME_CONVERSATION_TITLE);
expect(persistToLocalStorage).toHaveBeenLastCalledWith(mockData.welcome_id.id);
const previousConversationButton = screen.getByLabelText('Previous conversation');
@ -219,27 +255,40 @@ describe('Assistant', () => {
fireEvent.click(previousConversationButton);
});
expect(persistToLocalStorage).toHaveBeenLastCalledWith('electric sheep');
expect(persistToLocalStorage).toHaveBeenLastCalledWith('electric_sheep_id');
});
it('should not persist the conversation id to local storage when excludeFromLastConversationStorage flag is indicated', async () => {
const conversation = {
...mockData.electric_sheep_id,
excludeFromLastConversationStorage: true,
};
const getConversation = jest.fn().mockResolvedValue(conversation);
(useConversation as jest.Mock).mockReturnValue({
...mockUseConversation,
getConversation,
});
jest.mocked(useFetchCurrentUserConversations).mockReturnValue({
data: {
...mockData,
'electric sheep': {
...mockData['electric sheep'],
excludeFromLastConversationStorage: true,
},
electric_sheep_id: conversation,
},
isLoading: false,
refetch: jest.fn(),
} as unknown as UseQueryResult<Record<string, Conversation>, unknown>);
refetch: jest.fn().mockResolvedValue({
isLoading: false,
data: {
...mockData,
electric_sheep_id: conversation,
},
}),
isFetched: true,
} as unknown as DefinedUseQueryResult<Record<string, Conversation>, unknown>);
const { getByLabelText } = renderAssistant();
expect(persistToLocalStorage).toHaveBeenCalled();
expect(persistToLocalStorage).toHaveBeenLastCalledWith(WELCOME_CONVERSATION_TITLE);
expect(persistToLocalStorage).toHaveBeenLastCalledWith(mockData.welcome_id.id);
const previousConversationButton = getByLabelText('Previous conversation');
@ -248,9 +297,14 @@ describe('Assistant', () => {
await act(async () => {
fireEvent.click(previousConversationButton);
});
expect(persistToLocalStorage).toHaveBeenLastCalledWith(WELCOME_CONVERSATION_TITLE);
expect(persistToLocalStorage).toHaveBeenLastCalledWith(mockData.welcome_id.id);
});
it('should call the setConversationTitle callback if it is defined and the conversation id changes', async () => {
const getConversation = jest.fn().mockResolvedValue(mockData.electric_sheep_id);
(useConversation as jest.Mock).mockReturnValue({
...mockUseConversation,
getConversation,
});
const setConversationTitle = jest.fn();
renderAssistant({ setConversationTitle });
@ -264,11 +318,26 @@ describe('Assistant', () => {
it('should fetch current conversation when id has value', async () => {
const getConversation = jest
.fn()
.mockResolvedValue({ ...mockData['electric sheep'], title: 'updated title' });
.mockResolvedValue({ ...mockData.electric_sheep_id, title: 'updated title' });
(useConversation as jest.Mock).mockReturnValue({
...mockUseConversation,
getConversation,
});
jest.mocked(useFetchCurrentUserConversations).mockReturnValue({
data: {
...mockData,
electric_sheep_id: { ...mockData.electric_sheep_id, title: 'updated title' },
},
isLoading: false,
refetch: jest.fn().mockResolvedValue({
isLoading: false,
data: {
...mockData,
electric_sheep_id: { ...mockData.electric_sheep_id, title: 'updated title' },
},
}),
isFetched: true,
} as unknown as DefinedUseQueryResult<Record<string, Conversation>, unknown>);
renderAssistant();
const previousConversationButton = screen.getByLabelText('Previous conversation');
@ -276,16 +345,16 @@ describe('Assistant', () => {
fireEvent.click(previousConversationButton);
});
expect(getConversation).toHaveBeenCalledWith('electric sheep id');
expect(getConversation).toHaveBeenCalledWith('electric_sheep_id');
expect(persistToLocalStorage).toHaveBeenLastCalledWith('updated title');
expect(persistToLocalStorage).toHaveBeenLastCalledWith('electric_sheep_id');
});
it('should refetch all conversations when id is empty', async () => {
it.skip('should refetch all conversations when id is empty', async () => {
const chatSendSpy = jest.spyOn(all, 'useChatSend');
jest.mocked(useFetchCurrentUserConversations).mockReturnValue({
data: {
...mockData,
'electric sheep': { ...mockData['electric sheep'], id: '' },
'electric sheep': { ...mockData.electric_sheep_id, id: '', apiConfig: { newProp: true } },
},
isLoading: false,
refetch: jest.fn().mockResolvedValue({
@ -293,12 +362,14 @@ describe('Assistant', () => {
data: {
...mockData,
'electric sheep': {
...mockData['electric sheep'],
...mockData.electric_sheep_id,
id: '',
apiConfig: { newProp: true },
},
},
}),
} as unknown as UseQueryResult<Record<string, Conversation>, unknown>);
isFetched: true,
} as unknown as DefinedUseQueryResult<Record<string, Conversation>, unknown>);
renderAssistant();
const previousConversationButton = screen.getByLabelText('Previous conversation');
@ -308,7 +379,8 @@ describe('Assistant', () => {
expect(chatSendSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
currentConversation: {
...mockData['electric sheep'],
...mockData.electric_sheep_id,
id: '',
apiConfig: { newProp: true },
},
})
@ -321,7 +393,7 @@ describe('Assistant', () => {
renderAssistant();
expect(persistToLocalStorage).toHaveBeenCalled();
expect(persistToLocalStorage).toHaveBeenLastCalledWith(WELCOME_CONVERSATION_TITLE);
expect(persistToLocalStorage).toHaveBeenLastCalledWith(mockData.welcome_id.id);
});
});

View file

@ -39,6 +39,7 @@ const defaultProps: Props = {
selectedPromptContexts: {},
setIsSettingsModalVisible: jest.fn(),
setSelectedPromptContexts: jest.fn(),
isFlyoutMode: false,
};
describe('PromptEditorComponent', () => {

View file

@ -30,6 +30,7 @@ export interface Props {
setSelectedPromptContexts: React.Dispatch<
React.SetStateAction<Record<string, SelectedPromptContext>>
>;
isFlyoutMode: boolean;
}
const PreviewText = styled(EuiText)`
@ -47,6 +48,7 @@ const PromptEditorComponent: React.FC<Props> = ({
selectedPromptContexts,
setIsSettingsModalVisible,
setSelectedPromptContexts,
isFlyoutMode,
}) => {
const commentBody = useMemo(
() => (
@ -58,6 +60,7 @@ const PromptEditorComponent: React.FC<Props> = ({
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
isSettingsModalVisible={isSettingsModalVisible}
setIsSettingsModalVisible={setIsSettingsModalVisible}
isFlyoutMode={isFlyoutMode}
/>
)}
@ -66,6 +69,8 @@ const PromptEditorComponent: React.FC<Props> = ({
promptContexts={promptContexts}
selectedPromptContexts={selectedPromptContexts}
setSelectedPromptContexts={setSelectedPromptContexts}
currentReplacements={conversation?.replacements}
isFlyoutMode={isFlyoutMode}
/>
<PreviewText color="subdued" data-test-subj="previewText">
@ -84,6 +89,7 @@ const PromptEditorComponent: React.FC<Props> = ({
selectedPromptContexts,
setIsSettingsModalVisible,
setSelectedPromptContexts,
isFlyoutMode,
]
);

View file

@ -22,6 +22,8 @@ const defaultProps: Props = {
},
selectedPromptContexts: {},
setSelectedPromptContexts: jest.fn(),
currentReplacements: {},
isFlyoutMode: false,
};
const mockSelectedAlertPromptContext: SelectedPromptContext = {

View file

@ -18,6 +18,9 @@ import React, { useCallback } from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { Conversation } from '../../../assistant_context/types';
import { DataAnonymizationEditor } from '../../../data_anonymization_editor';
import type { PromptContext, SelectedPromptContext } from '../../prompt_context/types';
import * as i18n from './translations';
@ -29,6 +32,8 @@ export interface Props {
setSelectedPromptContexts: React.Dispatch<
React.SetStateAction<Record<string, SelectedPromptContext>>
>;
currentReplacements: Conversation['replacements'] | undefined;
isFlyoutMode: boolean;
}
export const EditorContainer = styled.div<{
@ -44,6 +49,8 @@ const SelectedPromptContextsComponent: React.FC<Props> = ({
promptContexts,
selectedPromptContexts,
setSelectedPromptContexts,
currentReplacements,
isFlyoutMode,
}) => {
const [accordionState, setAccordionState] = React.useState<'closed' | 'open'>('closed');
@ -64,17 +71,22 @@ const SelectedPromptContextsComponent: React.FC<Props> = ({
}
return (
<EuiFlexGroup data-test-subj="selectedPromptContexts" direction="column" gutterSize="none">
<EuiFlexGroup
data-test-subj="selectedPromptContexts"
direction="column"
gutterSize={isFlyoutMode ? 's' : 'none'}
>
{Object.keys(selectedPromptContexts)
.sort()
.map((id) => (
<EuiFlexItem data-test-subj={`selectedPromptContext-${id}`} grow={false} key={id}>
{isNewConversation || Object.keys(selectedPromptContexts).length > 1 ? (
{!isFlyoutMode &&
(isNewConversation || Object.keys(selectedPromptContexts).length > 1) ? (
<EuiSpacer data-test-subj="spacer" />
) : null}
<EuiAccordion
buttonContent={promptContexts[id]?.description}
forceState={accordionState}
{...(!isFlyoutMode && { forceState: accordionState })}
extraAction={
<EuiToolTip content={i18n.REMOVE_CONTEXT}>
<EuiButtonIcon
@ -86,15 +98,43 @@ const SelectedPromptContextsComponent: React.FC<Props> = ({
</EuiToolTip>
}
id={id}
onToggle={onToggle}
{...(!isFlyoutMode && { onToggle })}
paddingSize="s"
{...(isFlyoutMode
? {
css: css`
background: ${euiThemeVars.euiPageBackgroundColor};
border-radius: ${euiThemeVars.euiBorderRadius};
> div:first-child {
color: ${euiThemeVars.euiColorPrimary};
padding: ${euiThemeVars.euiFormControlPadding};
}
`,
borders: 'all',
arrowProps: {
color: 'primary',
},
}
: {})}
>
<EditorContainer $accordionState={accordionState}>
{isFlyoutMode ? (
<DataAnonymizationEditor
currentReplacements={currentReplacements}
selectedPromptContext={selectedPromptContexts[id]}
setSelectedPromptContexts={setSelectedPromptContexts}
isFlyoutMode={isFlyoutMode}
/>
</EditorContainer>
) : (
<EditorContainer $accordionState={accordionState}>
<DataAnonymizationEditor
currentReplacements={currentReplacements}
selectedPromptContext={selectedPromptContexts[id]}
setSelectedPromptContexts={setSelectedPromptContexts}
isFlyoutMode={isFlyoutMode}
/>
</EditorContainer>
)}
</EuiAccordion>
</EuiFlexItem>
))}

View file

@ -51,7 +51,7 @@ describe('helpers', () => {
const prompts = [mockSystemPrompt, mockSuperheroSystemPrompt];
const promptIds = prompts.map(({ id }) => id);
const options = getOptions({ prompts });
const options = getOptions({ prompts, isFlyoutMode: false });
const optionValues = options.map(({ value }) => value);
expect(optionValues).toEqual(promptIds);

View file

@ -25,9 +25,12 @@ export const getOptionFromPrompt = ({
id,
name,
showTitles = false,
isFlyoutMode,
}: Prompt & { showTitles?: boolean }): EuiSuperSelectOption<string> => ({
value: id,
inputDisplay: (
inputDisplay: isFlyoutMode ? (
name
) : (
<EuiText
color="subdued"
data-test-subj="systemPromptText"
@ -59,9 +62,11 @@ export const getOptionFromPrompt = ({
interface GetOptionsProps {
prompts: Prompt[] | undefined;
showTitles?: boolean;
isFlyoutMode: boolean;
}
export const getOptions = ({
prompts,
showTitles = false,
isFlyoutMode = false,
}: GetOptionsProps): Array<EuiSuperSelectOption<string>> =>
prompts?.map((p) => getOptionFromPrompt({ ...p, showTitles })) ?? [];
prompts?.map((p) => getOptionFromPrompt({ ...p, showTitles, isFlyoutMode })) ?? [];

View file

@ -90,6 +90,7 @@ describe('SystemPrompt', () => {
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
setIsSettingsModalVisible={setIsSettingsModalVisible}
isFlyoutMode={false}
/>
);
});
@ -120,6 +121,7 @@ describe('SystemPrompt', () => {
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
setIsSettingsModalVisible={setIsSettingsModalVisible}
isFlyoutMode={false}
/>
);
});
@ -154,6 +156,7 @@ describe('SystemPrompt', () => {
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
setIsSettingsModalVisible={setIsSettingsModalVisible}
isFlyoutMode={false}
/>
</TestProviders>
);
@ -200,6 +203,7 @@ describe('SystemPrompt', () => {
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
setIsSettingsModalVisible={setIsSettingsModalVisible}
isFlyoutMode={false}
/>
</TestProviders>
);
@ -260,6 +264,7 @@ describe('SystemPrompt', () => {
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
setIsSettingsModalVisible={setIsSettingsModalVisible}
isFlyoutMode={false}
/>
</TestProviders>
);
@ -327,6 +332,7 @@ describe('SystemPrompt', () => {
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
setIsSettingsModalVisible={setIsSettingsModalVisible}
isFlyoutMode={false}
/>
</TestProviders>
);
@ -409,6 +415,7 @@ describe('SystemPrompt', () => {
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
setIsSettingsModalVisible={setIsSettingsModalVisible}
isFlyoutMode={false}
/>
</TestProviders>
);
@ -478,6 +485,7 @@ describe('SystemPrompt', () => {
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
setIsSettingsModalVisible={setIsSettingsModalVisible}
isFlyoutMode={false}
/>
</TestProviders>
);
@ -496,6 +504,7 @@ describe('SystemPrompt', () => {
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
setIsSettingsModalVisible={setIsSettingsModalVisible}
isFlyoutMode={false}
/>
</TestProviders>
);

View file

@ -21,6 +21,7 @@ interface Props {
isSettingsModalVisible: boolean;
onSystemPromptSelectionChange: (systemPromptId: string | undefined) => void;
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
isFlyoutMode: boolean;
}
const SystemPromptComponent: React.FC<Props> = ({
@ -29,6 +30,7 @@ const SystemPromptComponent: React.FC<Props> = ({
isSettingsModalVisible,
onSystemPromptSelectionChange,
setIsSettingsModalVisible,
isFlyoutMode,
}) => {
const { allSystemPrompts } = useAssistantContext();
@ -53,6 +55,25 @@ const SystemPromptComponent: React.FC<Props> = ({
const handleEditSystemPrompt = useCallback(() => setIsEditing(true), []);
if (isFlyoutMode) {
return (
<SelectSystemPrompt
allSystemPrompts={allSystemPrompts}
clearSelectedSystemPrompt={handleClearSystemPrompt}
conversation={conversation}
data-test-subj="systemPrompt"
isClearable={true}
isEditing={true}
setIsEditing={setIsEditing}
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
selectedPrompt={selectedPrompt}
setIsSettingsModalVisible={setIsSettingsModalVisible}
isFlyoutMode={isFlyoutMode}
/>
);
}
return (
<div>
{selectedPrompt == null || isEditing ? (
@ -69,6 +90,7 @@ const SystemPromptComponent: React.FC<Props> = ({
selectedPrompt={selectedPrompt}
setIsEditing={setIsEditing}
setIsSettingsModalVisible={setIsSettingsModalVisible}
isFlyoutMode={isFlyoutMode}
/>
) : (
<EuiFlexGroup alignItems="flexStart" gutterSize="none">

View file

@ -25,8 +25,9 @@ const props: Props = {
],
conversation: undefined,
isSettingsModalVisible: false,
selectedPrompt: undefined,
selectedPrompt: { id: 'default-system-prompt', content: '', name: '', promptType: 'system' },
setIsSettingsModalVisible: jest.fn(),
isFlyoutMode: false,
};
const mockUseAssistantContext = {

View file

@ -26,6 +26,7 @@ import { useAssistantContext } from '../../../../assistant_context';
import { useConversation } from '../../../use_conversation';
import { SYSTEM_PROMPTS_TAB } from '../../../settings/assistant_settings';
import { TEST_IDS } from '../../../constants';
import { PROMPT_CONTEXT_SELECTOR_PREFIX } from '../../../quick_prompts/prompt_context_selector/translations';
export interface Props {
allSystemPrompts: Prompt[];
@ -42,6 +43,7 @@ export interface Props {
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
showTitles?: boolean;
onSystemPromptSelectionChange?: (promptId: string | undefined) => void;
isFlyoutMode: boolean;
}
const ADD_NEW_SYSTEM_PROMPT = 'ADD_NEW_SYSTEM_PROMPT';
@ -61,11 +63,15 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
setIsEditing,
setIsSettingsModalVisible,
showTitles = false,
isFlyoutMode = false,
}) => {
const { setSelectedSettingsTab } = useAssistantContext();
const { setApiConfig } = useConversation();
const [isOpenLocal, setIsOpenLocal] = useState<boolean>(isOpen);
const [valueOfSelected, setValueOfSelected] = useState<string | undefined>(
selectedPrompt?.id ?? allSystemPrompts?.[0]?.id
);
const handleOnBlur = useCallback(() => setIsOpenLocal(false), []);
// Write the selected system prompt to the conversation config
@ -106,8 +112,8 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
// SuperSelect State/Actions
const options = useMemo(
() => getOptions({ prompts: allSystemPrompts, showTitles }),
[allSystemPrompts, showTitles]
() => getOptions({ prompts: allSystemPrompts, showTitles, isFlyoutMode }),
[allSystemPrompts, showTitles, isFlyoutMode]
);
const onChange = useCallback(
@ -123,6 +129,7 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
} else {
setSelectedSystemPrompt(allSystemPrompts.find((sp) => sp.id === selectedSystemPromptId));
}
setValueOfSelected(selectedSystemPromptId);
setIsEditing?.(false);
},
[
@ -139,6 +146,7 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
setSelectedSystemPrompt(undefined);
setIsEditing?.(false);
clearSelectedSystemPrompt?.();
setValueOfSelected(undefined);
}, [clearSelectedSystemPrompt, setIsEditing, setSelectedSystemPrompt]);
const onShowSelectSystemPrompt = useCallback(() => {
@ -147,7 +155,14 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
}, [setIsEditing]);
return (
<EuiFlexGroup data-test-subj="selectSystemPrompt" gutterSize="none">
<EuiFlexGroup
data-test-subj="selectSystemPrompt"
gutterSize="none"
alignItems="center"
css={css`
position: relative;
`}
>
<EuiFlexItem
css={css`
max-width: 100%;
@ -174,20 +189,63 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
onBlur={handleOnBlur}
options={[...options, addNewSystemPrompt]}
placeholder={i18n.SELECT_A_SYSTEM_PROMPT}
valueOfSelected={selectedPrompt?.id ?? allSystemPrompts[0]?.id}
valueOfSelected={valueOfSelected}
prepend={
isFlyoutMode && !isSettingsModalVisible ? PROMPT_CONTEXT_SELECTOR_PREFIX : undefined
}
css={
isFlyoutMode &&
css`
padding-right: 56px !important;
`
}
/>
</EuiFormRow>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
{isEditing && isClearable && (
<EuiFlexItem
grow={false}
css={
isFlyoutMode
? css`
position: absolute;
right: 36px;
`
: undefined
}
>
{isEditing && isClearable && selectedPrompt && (
<EuiToolTip content={i18n.CLEAR_SYSTEM_PROMPT}>
<EuiButtonIcon
aria-label={i18n.CLEAR_SYSTEM_PROMPT}
data-test-subj="clearSystemPrompt"
iconType="cross"
onClick={clearSystemPrompt}
css={
isFlyoutMode
? // mimic EuiComboBox clear button
css`
inline-size: 16px;
block-size: 16px;
border-radius: 16px;
background: ${euiThemeVars.euiColorMediumShade};
:hover:not(:disabled) {
background: ${euiThemeVars.euiColorMediumShade};
transform: none;
}
> svg {
width: 8px;
height: 8px;
stroke-width: 2px;
fill: #fff;
stroke: #fff;
}
`
: undefined
}
/>
</EuiToolTip>
)}

View file

@ -7,9 +7,8 @@
import { EuiTextArea } from '@elastic/eui';
import React, { useCallback, useEffect, forwardRef } from 'react';
import { css } from '@emotion/react';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import * as i18n from './translations';
export interface Props extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
@ -17,15 +16,11 @@ export interface Props extends React.TextareaHTMLAttributes<HTMLTextAreaElement>
isDisabled?: boolean;
onPromptSubmit: (value: string) => void;
value: string;
isFlyoutMode: boolean;
}
const StyledTextArea = styled(EuiTextArea)`
min-height: 125px;
padding-right: 42px;
`;
export const PromptTextArea = forwardRef<HTMLTextAreaElement, Props>(
({ isDisabled = false, value, onPromptSubmit, handlePromptChange, ...props }, ref) => {
({ isDisabled = false, value, onPromptSubmit, handlePromptChange, isFlyoutMode }, ref) => {
const onChangeCallback = useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
handlePromptChange(event.target.value);
@ -52,18 +47,25 @@ export const PromptTextArea = forwardRef<HTMLTextAreaElement, Props>(
}, [handlePromptChange, value]);
return (
<StyledTextArea
<EuiTextArea
css={css`
padding-right: 64px !important;
min-height: ${!isFlyoutMode ? '125px' : '64px'};
max-height: ${!isFlyoutMode ? 'auto' : '350px'};
`}
className="eui-scrollBar"
inputRef={ref}
id={'prompt-textarea'}
data-test-subj={'prompt-textarea'}
fullWidth
autoFocus
resize="none"
disabled={isDisabled}
placeholder={i18n.PROMPT_PLACEHOLDER}
value={value}
onChange={onChangeCallback}
onKeyDown={onKeyDown}
rows={isFlyoutMode ? 1 : 6}
/>
);
}

View file

@ -14,6 +14,13 @@ export const PROMPT_CONTEXT_SELECTOR = i18n.translate(
}
);
export const PROMPT_CONTEXT_SELECTOR_PREFIX = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.promptContextSelector.prefixLabel',
{
defaultMessage: 'Select Prompt',
}
);
export const PROMPT_CONTEXT_SELECTOR_PLACEHOLDER = i18n.translate(
'xpack.elasticAssistant.assistant.quickPrompts.promptContextSelector.placeholderLabel',
{

View file

@ -19,6 +19,7 @@ const testProps = {
setInput,
setIsSettingsModalVisible,
trackPrompt,
isFlyoutMode: false,
};
const setSelectedSettingsTab = jest.fn();
const mockUseAssistantContext = {

View file

@ -6,19 +6,22 @@
*/
import React, { useCallback, useMemo, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiPopover, EuiButtonEmpty } from '@elastic/eui';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import {
EuiFlexGroup,
EuiFlexItem,
EuiBadge,
EuiPopover,
EuiButtonIcon,
EuiButtonEmpty,
} from '@elastic/eui';
import { useMeasure } from 'react-use';
import { css } from '@emotion/react';
import { QuickPrompt } from '../../..';
import * as i18n from './translations';
import { useAssistantContext } from '../../assistant_context';
import { QUICK_PROMPTS_TAB } from '../settings/assistant_settings';
const QuickPromptsFlexGroup = styled(EuiFlexGroup)`
margin: 16px;
`;
export const KNOWLEDGE_BASE_CATEGORY = 'knowledge-base';
const COUNT_BEFORE_OVERFLOW = 5;
@ -26,6 +29,7 @@ interface QuickPromptsProps {
setInput: (input: string) => void;
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
trackPrompt: (prompt: string) => void;
isFlyoutMode: boolean;
}
/**
@ -34,7 +38,9 @@ interface QuickPromptsProps {
* and localstorage for storing new and edited prompts.
*/
export const QuickPrompts: React.FC<QuickPromptsProps> = React.memo(
({ setInput, setIsSettingsModalVisible, trackPrompt }) => {
({ setInput, setIsSettingsModalVisible, trackPrompt, isFlyoutMode }) => {
const [quickPromptsContainerRef, { width }] = useMeasure();
const { allQuickPrompts, knowledgeBase, promptContexts, setSelectedSettingsTab } =
useAssistantContext();
@ -89,50 +95,95 @@ export const QuickPrompts: React.FC<QuickPromptsProps> = React.memo(
setSelectedSettingsTab(QUICK_PROMPTS_TAB);
}, [setIsSettingsModalVisible, setSelectedSettingsTab]);
const quickPrompts = useMemo(() => {
const visibleCount = isFlyoutMode ? Math.floor(width / 120) : COUNT_BEFORE_OVERFLOW;
const visibleItems = contextFilteredQuickPrompts.slice(0, visibleCount);
const overflowItems = contextFilteredQuickPrompts.slice(visibleCount);
return { visible: visibleItems, overflow: overflowItems };
}, [contextFilteredQuickPrompts, isFlyoutMode, width]);
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={() => onClickAddQuickPrompt(badge)}
onClickAriaLabel={badge.title}
>
{badge.title}
</EuiBadge>
</EuiFlexItem>
))}
{contextFilteredQuickPrompts.length > COUNT_BEFORE_OVERFLOW && (
<EuiFlexItem grow={false}>
<EuiPopover
button={
<EuiFlexGroup
gutterSize="s"
alignItems="center"
justifyContent={isFlyoutMode ? 'spaceBetween' : 'flexStart'}
css={
!isFlyoutMode &&
css`
margin: 16px;
`
}
>
<EuiFlexItem
css={css`
overflow: hidden;
`}
>
<EuiFlexGroup
ref={quickPromptsContainerRef}
gutterSize="s"
alignItems="center"
wrap={false}
>
{quickPrompts.visible.map((badge, index) => (
<EuiFlexItem
grow={false}
key={index}
css={css`
overflow: hidden;
`}
>
<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)}
onClickAriaLabel={badge.title}
>
{badge.title}
</EuiBadge>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiPopover>
</EuiFlexItem>
)}
color={badge.color}
onClick={() => onClickAddQuickPrompt(badge)}
onClickAriaLabel={badge.title}
>
{badge.title}
</EuiBadge>
</EuiFlexItem>
))}
{quickPrompts.overflow.length > 0 && (
<EuiFlexItem grow={false}>
<EuiPopover
button={
isFlyoutMode ? (
<EuiButtonIcon
color={'primary'}
iconType={'boxesHorizontal'}
onClick={toggleOverflowPopover}
/>
) : (
<EuiBadge
color={'hollow'}
iconType={'boxesHorizontal'}
onClick={toggleOverflowPopover}
onClickAriaLabel={i18n.QUICK_PROMPT_OVERFLOW_ARIA}
/>
)
}
isOpen={isOverflowPopoverOpen}
closePopover={closeOverflowPopover}
anchorPosition="rightUp"
>
<EuiFlexGroup direction="column" gutterSize="s">
{quickPrompts.overflow.map((badge, index) => (
<EuiFlexItem key={index} grow={false}>
<EuiBadge
color={badge.color}
onClick={() => onClickOverflowQuickPrompt(badge)}
onClickAriaLabel={badge.title}
>
{badge.title}
</EuiBadge>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiPopover>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="addQuickPrompt"
@ -143,7 +194,7 @@ export const QuickPrompts: React.FC<QuickPromptsProps> = React.memo(
{i18n.ADD_QUICK_PROMPT}
</EuiButtonEmpty>
</EuiFlexItem>
</QuickPromptsFlexGroup>
</EuiFlexGroup>
);
}
);

View file

@ -20,6 +20,7 @@ import {
import React from 'react';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants';
import { MOCK_QUICK_PROMPTS } from '../../mock/quick_prompt';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const mockConversations = {
[alertConvo.title]: alertConvo,
@ -39,6 +40,9 @@ const mockContext = {
http: {},
modelEvaluatorEnabled: true,
selectedSettingsTab: 'CONVERSATIONS_TAB',
assistantAvailability: {
isAssistantEnabled: true,
},
};
const onClose = jest.fn();
const onSave = jest.fn().mockResolvedValue(() => {});
@ -47,9 +51,10 @@ const onConversationSelected = jest.fn();
const testProps = {
defaultConnectorId: '123',
defaultProvider: OpenAiProviderType.OpenAi,
selectedConversation: welcomeConvo,
selectedConversationId: welcomeConvo.title,
onClose,
onSave,
isFlyoutMode: false,
onConversationSelected,
conversations: {},
anonymizationFields: { total: 0, page: 1, perPage: 1000, data: [] },
@ -76,6 +81,12 @@ jest.mock('./use_settings_updater/use_settings_updater', () => {
};
});
const queryClient = new QueryClient();
const wrapper = (props: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{props.children}</QueryClientProvider>
);
describe('AssistantSettings', () => {
beforeEach(() => {
jest.clearAllMocks();
@ -83,7 +94,9 @@ describe('AssistantSettings', () => {
});
it('saves changes', async () => {
const { getByTestId } = render(<AssistantSettings {...testProps} />);
const { getByTestId } = render(<AssistantSettings {...testProps} />, {
wrapper,
});
await act(async () => {
fireEvent.click(getByTestId('save-button'));
@ -94,7 +107,10 @@ describe('AssistantSettings', () => {
it('saves changes and updates selected conversation when selected conversation has been deleted', async () => {
const { getByTestId } = render(
<AssistantSettings {...testProps} selectedConversation={customConvo} />
<AssistantSettings {...testProps} selectedConversationId={customConvo.title} />,
{
wrapper,
}
);
await act(async () => {
fireEvent.click(getByTestId('save-button'));
@ -105,7 +121,9 @@ describe('AssistantSettings', () => {
});
it('on close is called when settings modal closes', () => {
const { getByTestId } = render(<AssistantSettings {...testProps} />);
const { getByTestId } = render(<AssistantSettings {...testProps} />, {
wrapper,
});
fireEvent.click(getByTestId('cancel-button'));
expect(onClose).toHaveBeenCalled();
});
@ -123,7 +141,9 @@ describe('AssistantSettings', () => {
...mockContext,
selectedSettingsTab: tab === CONVERSATIONS_TAB ? ANONYMIZATION_TAB : CONVERSATIONS_TAB,
}));
const { getByTestId } = render(<AssistantSettings {...testProps} />);
const { getByTestId } = render(<AssistantSettings {...testProps} />, {
wrapper,
});
fireEvent.click(getByTestId(`${tab}-button`));
expect(setSelectedSettingsTab).toHaveBeenCalledWith(tab);
});
@ -132,7 +152,9 @@ describe('AssistantSettings', () => {
...mockContext,
selectedSettingsTab: tab,
}));
const { getByTestId } = render(<AssistantSettings {...testProps} />);
const { getByTestId } = render(<AssistantSettings {...testProps} />, {
wrapper,
});
expect(getByTestId(`${tab}-tab`)).toBeInTheDocument();
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
@ -23,7 +23,6 @@ import {
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { css } from '@emotion/react';
import { FindAnonymizationFieldsResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/find_anonymization_fields_route.gen';
import { AIConnector } from '../../connectorland/connector_selector';
import { Conversation, Prompt, QuickPrompt } from '../../..';
import * as i18n from './translations';
@ -38,6 +37,7 @@ import {
QuickPromptSettings,
SystemPromptSettings,
} from '.';
import { useFetchAnonymizationFields } from '../api/anonymization_fields/use_fetch_anonymization_fields';
const StyledEuiModal = styled(EuiModal)`
width: 800px;
@ -63,12 +63,11 @@ interface Props {
onClose: (
event?: React.KeyboardEvent<HTMLDivElement> | React.MouseEvent<HTMLButtonElement>
) => void;
isFlyoutMode: boolean;
onSave: (success: boolean) => Promise<void>;
selectedConversation: Conversation;
selectedConversationId?: string;
onConversationSelected: ({ cId, cTitle }: { cId: string; cTitle: string }) => void;
conversations: Record<string, Conversation>;
anonymizationFields: FindAnonymizationFieldsResponse;
refetchAnonymizationFieldsResults: () => Promise<FindAnonymizationFieldsResponse | undefined>;
}
/**
@ -80,20 +79,23 @@ export const AssistantSettings: React.FC<Props> = React.memo(
defaultConnector,
onClose,
onSave,
selectedConversation: defaultSelectedConversation,
selectedConversationId: defaultSelectedConversationId,
onConversationSelected,
conversations,
anonymizationFields,
refetchAnonymizationFieldsResults,
isFlyoutMode,
}) => {
const {
actionTypeRegistry,
modelEvaluatorEnabled,
http,
toasts,
selectedSettingsTab,
setSelectedSettingsTab,
} = useAssistantContext();
const { data: anonymizationFields, refetch: refetchAnonymizationFieldsResults } =
useFetchAnonymizationFields();
const {
conversationSettings,
setConversationSettings,
@ -116,19 +118,17 @@ export const AssistantSettings: React.FC<Props> = React.memo(
// Local state for saving previously selected items so tab switching is friendlier
// Conversation Selection State
const [selectedConversation, setSelectedConversation] = useState<Conversation | undefined>(
() => {
return conversationSettings[defaultSelectedConversation.title];
}
const [selectedConversationId, setSelectedConversationId] = useState<string | undefined>(
defaultSelectedConversationId
);
const onHandleSelectedConversationChange = useCallback((conversation?: Conversation) => {
setSelectedConversation(conversation);
setSelectedConversationId(conversation?.id);
}, []);
useEffect(() => {
if (selectedConversation != null) {
setSelectedConversation(conversationSettings[selectedConversation.title]);
}
}, [conversationSettings, selectedConversation]);
const selectedConversation = useMemo(
() => (selectedConversationId ? conversationSettings[selectedConversationId] : undefined),
[conversationSettings, selectedConversationId]
);
// Quick Prompt Selection State
const [selectedQuickPrompt, setSelectedQuickPrompt] = useState<QuickPrompt | undefined>();
@ -157,7 +157,8 @@ export const AssistantSettings: React.FC<Props> = React.memo(
const handleSave = useCallback(async () => {
// If the selected conversation is deleted, we need to select a new conversation to prevent a crash creating a conversation that already exists
const isSelectedConversationDeleted =
conversationSettings[defaultSelectedConversation.title] == null;
defaultSelectedConversationId &&
conversationSettings[defaultSelectedConversationId] == null;
const newSelectedConversation: Conversation | undefined =
Object.values(conversationSettings)[0];
if (isSelectedConversationDeleted && newSelectedConversation != null) {
@ -167,22 +168,27 @@ export const AssistantSettings: React.FC<Props> = React.memo(
});
}
const saveResult = await saveSettings();
toasts?.addSuccess({
iconType: 'check',
title: i18n.SETTINGS_UPDATED_TOAST_TITLE,
});
if (
(anonymizationFieldsBulkActions?.create?.length ?? 0) > 0 ||
(anonymizationFieldsBulkActions?.update?.length ?? 0) > 0 ||
(anonymizationFieldsBulkActions?.delete?.ids?.length ?? 0) > 0
) {
refetchAnonymizationFieldsResults();
await refetchAnonymizationFieldsResults();
}
onSave(saveResult);
await onSave(saveResult);
}, [
anonymizationFieldsBulkActions,
conversationSettings,
defaultSelectedConversation.title,
defaultSelectedConversationId,
onConversationSelected,
onSave,
refetchAnonymizationFieldsResults,
saveSettings,
toasts,
]);
return (
@ -319,6 +325,7 @@ export const AssistantSettings: React.FC<Props> = React.memo(
setAssistantStreamingEnabled={setUpdatedAssistantStreamingEnabled}
onSelectedConversationChange={onHandleSelectedConversationChange}
http={http}
isFlyoutMode={isFlyoutMode}
/>
)}
{selectedSettingsTab === QUICK_PROMPTS_TAB && (

View file

@ -22,6 +22,7 @@ const testProps = {
isSettingsModalVisible: false,
selectedConversation: welcomeConvo,
setIsSettingsModalVisible,
isFlyoutMode: false,
onConversationSelected,
conversations: {},
refetchConversationsState: jest.fn(),
@ -63,7 +64,7 @@ describe('AssistantSettingsButton', () => {
expect(setIsSettingsModalVisible).toHaveBeenCalledWith(true);
});
it('Settings modal is visble and calls correct actions per click', () => {
it('Settings modal is visible and calls correct actions per click', () => {
const { getByTestId } = render(
<AssistantSettingsButton {...testProps} isSettingsModalVisible />
);

View file

@ -8,7 +8,6 @@
import React, { useCallback } from 'react';
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { FindAnonymizationFieldsResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/find_anonymization_fields_route.gen';
import { AIConnector } from '../../connectorland/connector_selector';
import { Conversation } from '../../..';
import { AssistantSettings, CONVERSATIONS_TAB } from './assistant_settings';
@ -18,14 +17,13 @@ import { useAssistantContext } from '../../assistant_context';
interface Props {
defaultConnector?: AIConnector;
isSettingsModalVisible: boolean;
selectedConversation: Conversation;
selectedConversationId?: string;
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
onConversationSelected: ({ cId, cTitle }: { cId: string; cTitle: string }) => void;
isDisabled?: boolean;
isFlyoutMode: boolean;
conversations: Record<string, Conversation>;
refetchConversationsState: () => Promise<void>;
anonymizationFields: FindAnonymizationFieldsResponse;
refetchAnonymizationFieldsResults: () => Promise<FindAnonymizationFieldsResponse | undefined>;
}
/**
@ -37,12 +35,11 @@ export const AssistantSettingsButton: React.FC<Props> = React.memo(
isDisabled = false,
isSettingsModalVisible,
setIsSettingsModalVisible,
selectedConversation,
selectedConversationId,
isFlyoutMode,
onConversationSelected,
conversations,
refetchConversationsState,
anonymizationFields,
refetchAnonymizationFieldsResults,
}) => {
const { toasts, setSelectedSettingsTab } = useAssistantContext();
@ -84,19 +81,19 @@ export const AssistantSettingsButton: React.FC<Props> = React.memo(
isDisabled={isDisabled}
iconType="gear"
size="xs"
{...(isFlyoutMode ? { color: 'text' } : {})}
/>
</EuiToolTip>
{isSettingsModalVisible && (
<AssistantSettings
defaultConnector={defaultConnector}
selectedConversation={selectedConversation}
selectedConversationId={selectedConversationId}
onConversationSelected={onConversationSelected}
onClose={handleCloseModal}
onSave={handleSave}
isFlyoutMode={isFlyoutMode}
conversations={conversations}
anonymizationFields={anonymizationFields}
refetchAnonymizationFieldsResults={refetchAnonymizationFieldsResults}
/>
)}
</>

View file

@ -18,20 +18,6 @@ export const DEFAULT_ASSISTANT_TITLE = i18n.translate(
}
);
export const SHOW_ANONYMIZED = i18n.translate(
'xpack.elasticAssistant.assistant.settings.showAnonymizedToggleLabel',
{
defaultMessage: 'Show anonymized',
}
);
export const SHOW_ANONYMIZED_TOOLTIP = i18n.translate(
'xpack.elasticAssistant.assistant.settings.showAnonymizedTooltip',
{
defaultMessage: 'Show the anonymized values sent to and from the assistant',
}
);
export const SUBMIT_MESSAGE = i18n.translate('xpack.elasticAssistant.assistant.submitMessage', {
defaultMessage: 'Submit message',
});
@ -53,3 +39,33 @@ export const DOCUMENTATION = i18n.translate(
defaultMessage: 'documentation',
}
);
export const EMPTY_SCREEN_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.emptyScreen.title',
{
defaultMessage: 'How I can help you?',
}
);
export const EMPTY_SCREEN_DESCRIPTION = i18n.translate(
'xpack.elasticAssistant.assistant.emptyScreen.description',
{
defaultMessage:
'Ask me anything from "Summarize this alert" to "Help me build a query" using the following system prompt:',
}
);
export const WELCOME_SCREEN_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.welcomeScreen.title',
{
defaultMessage: 'Welcome to Security AI Assistant!',
}
);
export const WELCOME_SCREEN_DESCRIPTION = i18n.translate(
'xpack.elasticAssistant.assistant.welcomeScreen.description',
{
defaultMessage:
"First things first, we'll need to set up a Generative AI Connector to get this chat experience going!",
}
);

View file

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

View file

@ -6,7 +6,6 @@
*/
import { useCallback } from 'react';
import { ApiConfig } from '@kbn/elastic-assistant-common';
import { useAssistantContext } from '../../assistant_context';
import { Conversation, ClientMessage } from '../../assistant_context/types';
@ -31,6 +30,9 @@ export const DEFAULT_CONVERSATION_STATE: Conversation = {
interface CreateConversationProps {
cTitle: string;
messages?: ClientMessage[];
conversationIds?: string[];
apiConfig?: Conversation['apiConfig'];
isFlyoutMode: boolean;
}
interface SetApiConfigProps {
@ -38,6 +40,11 @@ interface SetApiConfigProps {
apiConfig: ApiConfig;
}
interface UpdateConversationTitleProps {
conversationId: string;
updatedTitle: string;
}
interface UseConversation {
clearConversation: (conversation: Conversation) => Promise<Conversation | undefined>;
getDefaultConversation: ({ cTitle, messages }: CreateConversationProps) => Conversation;
@ -47,8 +54,12 @@ interface UseConversation {
conversation,
apiConfig,
}: SetApiConfigProps) => Promise<Conversation | undefined>;
createConversation: (conversation: Conversation) => Promise<Conversation | undefined>;
createConversation: (conversation: Partial<Conversation>) => Promise<Conversation | undefined>;
getConversation: (conversationId: string) => Promise<Conversation | undefined>;
updateConversationTitle: ({
conversationId,
updatedTitle,
}: UpdateConversationTitleProps) => Promise<Conversation>;
}
export const useConversation = (): UseConversation => {
@ -107,10 +118,13 @@ export const useConversation = (): UseConversation => {
* Create a new conversation with the given conversationId, and optionally add messages
*/
const getDefaultConversation = useCallback(
({ cTitle, messages }: CreateConversationProps): Conversation => {
({ cTitle, messages, isFlyoutMode }: CreateConversationProps): Conversation => {
const newConversation: Conversation =
cTitle === i18n.WELCOME_CONVERSATION_TITLE
? WELCOME_CONVERSATION
? {
...WELCOME_CONVERSATION,
messages: !isFlyoutMode ? WELCOME_CONVERSATION.messages : [],
}
: {
...DEFAULT_CONVERSATION_STATE,
id: '',
@ -126,7 +140,7 @@ export const useConversation = (): UseConversation => {
* Create a new conversation with the given conversation
*/
const createConversation = useCallback(
async (conversation: Conversation): Promise<Conversation | undefined> => {
async (conversation: Partial<Conversation>): Promise<Conversation | undefined> => {
return createConversationApi({ http, conversation, toasts });
},
[http, toasts]
@ -174,12 +188,23 @@ export const useConversation = (): UseConversation => {
[http, toasts]
);
const updateConversationTitle = useCallback(
({ conversationId, updatedTitle }: UpdateConversationTitleProps): Promise<Conversation> =>
updateConversation({
http,
conversationId,
title: updatedTitle,
}),
[http]
);
return {
clearConversation,
getDefaultConversation,
deleteConversation,
removeLastMessage,
setApiConfig,
updateConversationTitle,
createConversation,
getConversation,
};

View file

@ -43,6 +43,7 @@ export const WELCOME_CONVERSATION: Conversation = {
},
],
replacements: {},
excludeFromLastConversationStorage: true,
};
export const enterpriseMessaging: ClientMessage[] = [

View file

@ -10,7 +10,7 @@ import { KnowledgeBaseConfig } from '../assistant/types';
export const DEFAULT_ASSISTANT_NAMESPACE = 'elasticAssistantDefault';
export const QUICK_PROMPT_LOCAL_STORAGE_KEY = 'quickPrompts';
export const SYSTEM_PROMPT_LOCAL_STORAGE_KEY = 'systemPrompts';
export const LAST_CONVERSATION_TITLE_LOCAL_STORAGE_KEY = 'lastConversationTitle';
export const LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY = 'lastConversationId';
export const KNOWLEDGE_BASE_LOCAL_STORAGE_KEY = 'knowledgeBase';
export const STREAMING_LOCAL_STORAGE_KEY = 'streaming';
export const TRACE_OPTIONS_SESSION_STORAGE_KEY = 'traceOptions';

View file

@ -36,22 +36,22 @@ describe('AssistantContext', () => {
expect(result.current.http.fetch).toBeCalledWith(path);
});
test('getLastConversationTitle defaults to provided id', async () => {
test('getLastConversationId defaults to provided id', async () => {
const { result } = renderHook(useAssistantContext, { wrapper: TestProviders });
const id = result.current.getLastConversationTitle('123');
const id = result.current.getLastConversationId('123');
expect(id).toEqual('123');
});
test('getLastConversationTitle uses local storage id when no id is provided ', async () => {
test('getLastConversationId uses local storage id when no id is provided ', async () => {
const { result } = renderHook(useAssistantContext, { wrapper: TestProviders });
const id = result.current.getLastConversationTitle();
const id = result.current.getLastConversationId();
expect(id).toEqual('456');
});
test('getLastConversationTitle defaults to Welcome when no local storage id and no id is provided ', async () => {
test('getLastConversationId defaults to Welcome when no local storage id and no id is provided ', async () => {
(useLocalStorage as jest.Mock).mockReturnValue([undefined, jest.fn()]);
const { result } = renderHook(useAssistantContext, { wrapper: TestProviders });
const id = result.current.getLastConversationTitle();
const id = result.current.getLastConversationId();
expect(id).toEqual('Welcome');
});
});

View file

@ -31,7 +31,7 @@ import {
DEFAULT_ASSISTANT_NAMESPACE,
DEFAULT_KNOWLEDGE_BASE_SETTINGS,
KNOWLEDGE_BASE_LOCAL_STORAGE_KEY,
LAST_CONVERSATION_TITLE_LOCAL_STORAGE_KEY,
LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY,
QUICK_PROMPT_LOCAL_STORAGE_KEY,
STREAMING_LOCAL_STORAGE_KEY,
SYSTEM_PROMPT_LOCAL_STORAGE_KEY,
@ -70,13 +70,15 @@ export interface AssistantProviderProps {
children: React.ReactNode;
getComments: (commentArgs: {
abortStream: () => void;
currentConversation: Conversation;
currentConversation?: Conversation;
isEnabledLangChain: boolean;
isFetchingResponse: boolean;
refetchCurrentConversation: () => void;
regenerateMessage: (conversationId: string) => void;
showAnonymizedValues: boolean;
setIsStreaming: (isStreaming: boolean) => void;
currentUserAvatar?: UserAvatar;
isFlyoutMode: boolean;
}) => EuiCommentProps[];
http: HttpSetup;
baseConversations: Record<string, Conversation>;
@ -85,6 +87,12 @@ export interface AssistantProviderProps {
toasts?: IToasts;
}
export interface UserAvatar {
color: string;
imageUrl?: string;
initials: string;
}
export interface UseAssistantContext {
actionTypeRegistry: ActionTypeRegistryContract;
alertsIndexPattern: string | undefined;
@ -105,17 +113,19 @@ export interface UseAssistantContext {
baseConversations: Record<string, Conversation>;
getComments: (commentArgs: {
abortStream: () => void;
currentConversation: Conversation;
currentConversation?: Conversation;
isEnabledLangChain: boolean;
isFetchingResponse: boolean;
refetchCurrentConversation: () => void;
regenerateMessage: () => void;
showAnonymizedValues: boolean;
currentUserAvatar?: UserAvatar;
setIsStreaming: (isStreaming: boolean) => void;
isFlyoutMode: boolean;
}) => EuiCommentProps[];
http: HttpSetup;
knowledgeBase: KnowledgeBaseConfig;
getLastConversationTitle: (conversationTitle?: string) => string;
getLastConversationId: (conversationTitle?: string) => string;
promptContexts: Record<string, PromptContext>;
modelEvaluatorEnabled: boolean;
nameSpace: string;
@ -125,7 +135,7 @@ export interface UseAssistantContext {
setAllSystemPrompts: React.Dispatch<React.SetStateAction<Prompt[] | undefined>>;
setAssistantStreamingEnabled: React.Dispatch<React.SetStateAction<boolean | undefined>>;
setKnowledgeBase: React.Dispatch<React.SetStateAction<KnowledgeBaseConfig | undefined>>;
setLastConversationTitle: React.Dispatch<React.SetStateAction<string | undefined>>;
setLastConversationId: React.Dispatch<React.SetStateAction<string | undefined>>;
setSelectedSettingsTab: React.Dispatch<React.SetStateAction<SettingsTabs>>;
setShowAssistantOverlay: (showAssistantOverlay: ShowAssistantOverlay) => void;
showAssistantOverlay: ShowAssistantOverlay;
@ -191,8 +201,8 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
baseSystemPrompts
);
const [localStorageLastConversationTitle, setLocalStorageLastConversationTitle] =
useLocalStorage<string>(`${nameSpace}.${LAST_CONVERSATION_TITLE_LOCAL_STORAGE_KEY}`);
const [localStorageLastConversationId, setLocalStorageLastConversationId] =
useLocalStorage<string>(`${nameSpace}.${LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY}`);
/**
* Local storage for knowledge base configuration, prefixed by assistant nameSpace
@ -256,13 +266,13 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
*/
const [selectedSettingsTab, setSelectedSettingsTab] = useState<SettingsTabs>(CONVERSATIONS_TAB);
const getLastConversationTitle = useCallback(
const getLastConversationId = useCallback(
// if a conversationId has been provided, use that
// if not, check local storage
// last resort, go to welcome conversation
(conversationTitle?: string) =>
conversationTitle ?? localStorageLastConversationTitle ?? WELCOME_CONVERSATION_TITLE,
[localStorageLastConversationTitle]
(conversationId?: string) =>
conversationId ?? localStorageLastConversationId ?? WELCOME_CONVERSATION_TITLE,
[localStorageLastConversationId]
);
// Fetch assistant capabilities
@ -306,8 +316,8 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
toasts,
traceOptions: sessionStorageTraceOptions,
unRegisterPromptContext,
getLastConversationTitle,
setLastConversationTitle: setLocalStorageLastConversationTitle,
getLastConversationId,
setLastConversationId: setLocalStorageLastConversationId,
baseConversations,
}),
[
@ -342,8 +352,8 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
toasts,
sessionStorageTraceOptions,
unRegisterPromptContext,
getLastConversationTitle,
setLocalStorageLastConversationTitle,
getLastConversationId,
setLocalStorageLastConversationId,
baseConversations,
]
);

View file

@ -21,7 +21,7 @@ export interface ClientMessage extends Omit<Message, 'content' | 'reader'> {
}
export interface ConversationTheme {
title?: JSX.Element | string;
title?: string;
titleIcon?: string;
user?: {
name?: string;

View file

@ -20,6 +20,7 @@ interface Props {
onSaveConnector: (connector: ActionConnector) => void;
onSelectActionType: (actionType: ActionType) => void;
selectedActionType: ActionType | null;
actionTypeSelectorInline?: boolean;
}
export const AddConnectorModal: React.FC<Props> = React.memo(
({
@ -29,22 +30,26 @@ export const AddConnectorModal: React.FC<Props> = React.memo(
onSaveConnector,
onSelectActionType,
selectedActionType,
}) =>
!selectedActionType ? (
actionTypeSelectorInline = false,
}) => (
<>
<ActionTypeSelectorModal
actionTypes={actionTypes}
actionTypeRegistry={actionTypeRegistry}
onClose={onClose}
onSelect={onSelectActionType}
actionTypeSelectorInline={actionTypeSelectorInline}
/>
) : (
<ConnectorAddModal
actionType={selectedActionType}
actionTypeRegistry={actionTypeRegistry}
onClose={onClose}
postSaveEventHandler={onSaveConnector}
/>
)
{selectedActionType && (
<ConnectorAddModal
actionType={selectedActionType}
actionTypeRegistry={actionTypeRegistry}
onClose={onClose}
postSaveEventHandler={onSaveConnector}
/>
)}
</>
)
);
AddConnectorModal.displayName = 'AddConnectorModal';

View file

@ -30,6 +30,7 @@ describe('connectorMissingCallout', () => {
isConnectorConfigured={false}
isSettingsModalVisible={false}
setIsSettingsModalVisible={jest.fn()}
isFlyoutMode={false}
/>
</TestProviders>
);
@ -44,6 +45,7 @@ describe('connectorMissingCallout', () => {
isConnectorConfigured={true}
isSettingsModalVisible={false}
setIsSettingsModalVisible={jest.fn()}
isFlyoutMode={false}
/>
</TestProviders>
);
@ -68,6 +70,7 @@ describe('connectorMissingCallout', () => {
isConnectorConfigured={true}
isSettingsModalVisible={false}
setIsSettingsModalVisible={jest.fn()}
isFlyoutMode={false}
/>
</TestProviders>
);

View file

@ -7,8 +7,10 @@
import React, { useCallback } from 'react';
import { EuiCallOut, EuiLink } from '@elastic/eui';
import { euiLightVars } from '@kbn/ui-theme';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
import * as i18n from '../translations';
import { useAssistantContext } from '../../assistant_context';
import { CONVERSATIONS_TAB } from '../../assistant/settings/assistant_settings';
@ -18,6 +20,7 @@ interface Props {
isConnectorConfigured: boolean;
isSettingsModalVisible: boolean;
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
isFlyoutMode: boolean;
}
/**
@ -28,7 +31,7 @@ interface Props {
* TODO: Add setting for 'default connector' so we can auto-resolve and not even show this
*/
export const ConnectorMissingCallout: React.FC<Props> = React.memo(
({ isConnectorConfigured, isSettingsModalVisible, setIsSettingsModalVisible }) => {
({ isConnectorConfigured, isSettingsModalVisible, setIsSettingsModalVisible, isFlyoutMode }) => {
const { assistantAvailability, setSelectedSettingsTab } = useAssistantContext();
const onConversationSettingsClicked = useCallback(() => {
@ -52,9 +55,15 @@ export const ConnectorMissingCallout: React.FC<Props> = React.memo(
iconType="controlsVertical"
size="m"
title={i18n.MISSING_CONNECTOR_CALLOUT_TITLE}
css={
isFlyoutMode &&
css`
padding-left: ${euiLightVars.euiPanelPaddingModifiers.paddingMedium} !important;
padding-right: ${euiLightVars.euiPanelPaddingModifiers.paddingMedium} !important;
`
}
>
<p>
{' '}
<FormattedMessage
defaultMessage="Select a connector above or from the {link} to continue"
id="xpack.elasticAssistant.assistant.connectors.connectorMissingCallout.calloutDescription"

View file

@ -6,7 +6,7 @@
*/
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSuperSelect, EuiText } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import React, { Suspense, useCallback, useMemo, useState } from 'react';
import { ActionConnector, ActionType } from '@kbn/triggers-actions-ui-plugin/public';
@ -169,17 +169,21 @@ export const ConnectorSelector: React.FC<Props> = React.memo(
isOpen={modalForceOpen}
onChange={onChange}
options={allConnectorOptions}
valueOfSelected={selectedConnectorId ?? ''}
valueOfSelected={selectedConnectorId}
popoverProps={{ panelMinWidth: 400, anchorPosition: 'downRight' }}
/>
{isConnectorModalVisible && (
<AddConnectorModal
actionTypeRegistry={actionTypeRegistry}
actionTypes={actionTypes}
onClose={() => setIsConnectorModalVisible(false)}
onSaveConnector={onSaveConnector}
onSelectActionType={(actionType: ActionType) => setSelectedActionType(actionType)}
selectedActionType={selectedActionType}
/>
// Crashing management app otherwise
<Suspense fallback>
<AddConnectorModal
actionTypeRegistry={actionTypeRegistry}
actionTypes={actionTypes}
onClose={cleanupAndCloseModal}
onSaveConnector={onSaveConnector}
onSelectActionType={(actionType: ActionType) => setSelectedActionType(actionType)}
selectedActionType={selectedActionType}
/>
</Suspense>
)}
</>
);

View file

@ -44,6 +44,7 @@ const defaultProps = {
actionTypeRegistry,
onClose,
onSelect,
actionTypeSelectorInline: false,
};
describe('ActionTypeSelectorModal', () => {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { useMemo } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
@ -26,6 +26,7 @@ interface Props {
actionTypeRegistry: ActionTypeRegistryContract;
onClose: () => void;
onSelect: (actionType: ActionType) => void;
actionTypeSelectorInline: boolean;
}
const itemClassName = css`
.euiKeyPadMenuItem__label {
@ -34,21 +35,12 @@ const itemClassName = css`
}
`;
export const ActionTypeSelectorModal = ({
actionTypes,
actionTypeRegistry,
onClose,
onSelect,
}: Props) =>
actionTypes && actionTypes.length > 0 ? (
<EuiModal onClose={onClose} data-test-subj="action-type-selector-modal">
<EuiModalHeader>
<EuiModalHeaderTitle>{i18n.INLINE_CONNECTOR_PLACEHOLDER}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
export const ActionTypeSelectorModal = React.memo(
({ actionTypes, actionTypeRegistry, onClose, onSelect, actionTypeSelectorInline }: Props) => {
const content = useMemo(
() => (
<EuiFlexGroup>
{actionTypes.map((actionType: ActionType) => {
{actionTypes?.map((actionType: ActionType) => {
const fullAction = actionTypeRegistry.get(actionType.id);
return (
<EuiFlexItem data-test-subj="action-option" key={actionType.id} grow={false}>
@ -66,6 +58,24 @@ export const ActionTypeSelectorModal = ({
);
})}
</EuiFlexGroup>
</EuiModalBody>
</EuiModal>
) : null;
),
[actionTypeRegistry, actionTypes, onSelect]
);
if (!actionTypes?.length) return null;
if (actionTypeSelectorInline) return <>{content}</>;
return (
<EuiModal onClose={onClose} data-test-subj="action-type-selector-modal">
<EuiModalHeader>
<EuiModalHeaderTitle>{i18n.INLINE_CONNECTOR_PLACEHOLDER}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>{content}</EuiModalBody>
</EuiModal>
);
}
);
ActionTypeSelectorModal.displayName = 'ActionTypeSelectorModal';

View file

@ -74,6 +74,7 @@ describe('ConnectorSelectorInline', () => {
isDisabled={false}
selectedConnectorId={undefined}
selectedConversation={undefined}
isFlyoutMode={false}
onConnectorSelected={jest.fn()}
/>
</TestProviders>
@ -88,6 +89,7 @@ describe('ConnectorSelectorInline', () => {
isDisabled={false}
selectedConnectorId={'missing-connector-id'}
selectedConversation={defaultConvo}
isFlyoutMode={false}
onConnectorSelected={jest.fn()}
/>
</TestProviders>
@ -101,6 +103,7 @@ describe('ConnectorSelectorInline', () => {
isDisabled={false}
selectedConnectorId={'missing-connector-id'}
selectedConversation={defaultConvo}
isFlyoutMode={false}
onConnectorSelected={jest.fn()}
/>
</TestProviders>
@ -117,6 +120,7 @@ describe('ConnectorSelectorInline', () => {
isDisabled={false}
selectedConnectorId={'missing-connector-id'}
selectedConversation={defaultConvo}
isFlyoutMode={false}
onConnectorSelected={jest.fn()}
/>
</TestProviders>
@ -149,6 +153,7 @@ describe('ConnectorSelectorInline', () => {
isDisabled={false}
selectedConnectorId={'missing-connector-id'}
selectedConversation={defaultConvo}
isFlyoutMode={false}
onConnectorSelected={jest.fn()}
/>
</TestProviders>

View file

@ -9,6 +9,7 @@ import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui
import React, { useCallback, useState } from 'react';
import { css } from '@emotion/css';
import { euiThemeVars } from '@kbn/ui-theme';
import { AIConnector, ConnectorSelector } from '../connector_selector';
import { Conversation } from '../../..';
import { useLoadConnectors } from '../use_load_connectors';
@ -23,16 +24,13 @@ interface Props {
isDisabled?: boolean;
selectedConnectorId?: string;
selectedConversation?: Conversation;
isFlyoutMode: boolean;
onConnectorSelected: (conversation: Conversation) => void;
}
const inputContainerClassName = css`
height: 32px;
.euiSuperSelect {
width: 400px;
}
.euiSuperSelectControl {
border: none;
box-shadow: none;
@ -47,9 +45,9 @@ const inputContainerClassName = css`
`;
const inputDisplayClassName = css`
margin-right: 8px;
overflow: hidden;
text-overflow: ellipsis;
max-width: 400px;
`;
const placeholderButtonClassName = css`
@ -66,7 +64,13 @@ const placeholderButtonClassName = css`
* A compact wrapper of the ConnectorSelector component used in the Settings modal.
*/
export const ConnectorSelectorInline: React.FC<Props> = React.memo(
({ isDisabled = false, selectedConnectorId, selectedConversation, onConnectorSelected }) => {
({
isDisabled = false,
selectedConnectorId,
selectedConversation,
isFlyoutMode,
onConnectorSelected,
}) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const { assistantAvailability, http } = useAssistantContext();
const { setApiConfig } = useConversation();
@ -116,6 +120,45 @@ export const ConnectorSelectorInline: React.FC<Props> = React.memo(
[selectedConversation, setApiConfig, onConnectorSelected]
);
if (isFlyoutMode) {
return (
<EuiFlexGroup
alignItems="center"
className={inputContainerClassName}
direction="row"
gutterSize="xs"
justifyContent={'flexStart'}
responsive={false}
>
{!isFlyoutMode && (
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
{i18n.INLINE_CONNECTOR_LABEL}
</EuiText>
</EuiFlexItem>
)}
<EuiFlexItem>
<ConnectorSelector
displayFancy={(displayText) => (
<EuiText
className={inputDisplayClassName}
size="s"
color={euiThemeVars.euiColorPrimaryText}
>
{displayText}
</EuiText>
)}
isOpen={isOpen}
isDisabled={localIsDisabled}
selectedConnectorId={selectedConnectorId}
setIsOpen={setIsOpen}
onConnectorSelectionChange={onChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
return (
<EuiFlexGroup
alignItems="center"
@ -134,7 +177,7 @@ export const ConnectorSelectorInline: React.FC<Props> = React.memo(
{isOpen ? (
<ConnectorSelector
displayFancy={(displayText) => (
<EuiText className={inputDisplayClassName} size="xs">
<EuiText css={inputDisplayClassName} size="s">
{displayText}
</EuiText>
)}
@ -154,7 +197,7 @@ export const ConnectorSelectorInline: React.FC<Props> = React.memo(
iconType="arrowDown"
isDisabled={localIsDisabled}
onClick={onConnectorClick}
size="xs"
size={'xs'}
>
{selectedConnectorName}
</EuiButtonEmpty>

View file

@ -8,8 +8,8 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { EuiCommentProps } from '@elastic/eui';
import { EuiAvatar, EuiBadge, EuiMarkdownFormat, EuiText, EuiTextAlign } from '@elastic/eui';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import styled from '@emotion/styled';
import { css } from '@emotion/react';
import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public/common/constants';
import { ActionType } from '@kbn/triggers-actions-ui-plugin/public';
@ -31,24 +31,30 @@ const ConnectorButtonWrapper = styled.div`
margin-bottom: 10px;
`;
const SkipEuiText = styled(EuiText)`
margin-top: 20px;
`;
export interface ConnectorSetupProps {
conversation?: Conversation;
isFlyoutMode?: boolean;
onSetupComplete?: () => void;
onConversationUpdate: ({ cId, cTitle }: { cId: string; cTitle: string }) => Promise<void>;
}
export const useConnectorSetup = ({
conversation = WELCOME_CONVERSATION,
conversation: defaultConversation,
isFlyoutMode,
onSetupComplete,
onConversationUpdate,
}: ConnectorSetupProps): {
comments: EuiCommentProps[];
prompt: React.ReactElement;
} => {
const conversation = useMemo(
() =>
defaultConversation || {
...WELCOME_CONVERSATION,
messages: !isFlyoutMode ? WELCOME_CONVERSATION.messages : [],
},
[defaultConversation, isFlyoutMode]
);
const { setApiConfig } = useConversation();
const bottomRef = useRef<HTMLDivElement | null>(null);
// Access all conversations so we can add connector to all on initial setup
@ -197,9 +203,26 @@ export const useConnectorSetup = ({
[conversation, onConversationUpdate, refetchConnectors, setApiConfig]
);
const handleClose = useCallback(() => {
setSelectedActionType(null);
setIsConnectorModalVisible(false);
}, []);
return {
comments,
prompt: (
comments: isFlyoutMode ? [] : comments,
prompt: isFlyoutMode ? (
<div data-test-subj="prompt">
<AddConnectorModal
actionTypeRegistry={actionTypeRegistry}
actionTypes={actionTypes}
onClose={handleClose}
onSaveConnector={onSaveConnector}
onSelectActionType={setSelectedActionType}
selectedActionType={selectedActionType}
actionTypeSelectorInline={true}
/>
</div>
) : (
<div data-test-subj="prompt">
{showAddConnectorButton && (
<ConnectorButtonWrapper>
@ -207,7 +230,17 @@ export const useConnectorSetup = ({
</ConnectorButtonWrapper>
)}
{!showAddConnectorButton && (
<SkipEuiText color="subdued" size={'xs'}>
<EuiText
color="subdued"
size={'xs'}
css={
!isFlyoutMode
? css`
margin-top: 20px;
`
: null
}
>
<EuiTextAlign textAlign="center">
<EuiBadge
color="hollow"
@ -218,15 +251,15 @@ export const useConnectorSetup = ({
{i18n.CONNECTOR_SETUP_SKIP}
</EuiBadge>
</EuiTextAlign>
</SkipEuiText>
</EuiText>
)}
{isConnectorModalVisible && (
<AddConnectorModal
actionTypeRegistry={actionTypeRegistry}
actionTypes={actionTypes}
onClose={() => setIsConnectorModalVisible(false)}
onClose={handleClose}
onSaveConnector={onSaveConnector}
onSelectActionType={(actionType: ActionType) => setSelectedActionType(actionType)}
onSelectActionType={setSelectedActionType}
selectedActionType={selectedActionType}
/>
)}

View file

@ -10,6 +10,7 @@ import type { EuiSearchBarProps, EuiTableSelectionType } from '@elastic/eui';
import React, { useCallback, useMemo, useState, useRef } from 'react';
import { FindAnonymizationFieldsResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/find_anonymization_fields_route.gen';
import styled from '@emotion/styled';
import { getColumns } from './get_columns';
import { getRows } from './get_rows';
import { Toolbar } from './toolbar';
@ -19,6 +20,12 @@ import { useAssistantContext } from '../../assistant_context';
export const DEFAULT_PAGE_SIZE = 10;
const Wrapper = styled.div`
> div > .euiSpacer {
block-size: 16px;
}
`;
const defaultSort: SortConfig = {
sort: {
direction: 'asc',
@ -119,20 +126,22 @@ const ContextEditorComponent: React.FC<Props> = ({
);
return (
<EuiInMemoryTable
allowNeutralSort={false}
childrenBetween={hasUpdateAIAssistantAnonymization ? toolbar : undefined}
columns={columns}
compressed={true}
data-test-subj="contextEditor"
isSelectable={true}
itemId={FIELDS.FIELD}
items={rows}
pagination={pagination}
search={search}
selection={selectionValue}
sorting={defaultSort}
/>
<Wrapper>
<EuiInMemoryTable
allowNeutralSort={false}
childrenBetween={hasUpdateAIAssistantAnonymization ? toolbar : undefined}
columns={columns}
compressed={true}
data-test-subj="contextEditor"
isSelectable={true}
itemId={FIELDS.FIELD}
items={rows}
pagination={pagination}
search={search}
selection={selectionValue}
sorting={defaultSort}
/>
</Wrapper>
);
};

View file

@ -26,6 +26,7 @@ export const FIELDS = {
ANONYMIZED: 'anonymized',
DENIED: 'denied',
FIELD: 'field',
ID: 'id',
RAW_VALUES: 'rawValues',
};

View file

@ -0,0 +1,121 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiHorizontalRule } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { i18n as I18n } from '@kbn/i18n';
import { AnonymizedData } from '@kbn/elastic-assistant-common/impl/data_anonymization/types';
import { SelectedPromptContextEditorModal } from '../context_editor_modal';
import { SelectedPromptContextPreview } from '../context_preview';
import { getStats } from '../get_stats';
import { AllowedStat } from '../stats/allowed_stat';
import { AnonymizedStat } from '../stats/anonymized_stat';
import { SelectedPromptContext } from '../../assistant/prompt_context/types';
import { BatchUpdateListItem } from '../context_editor/types';
interface ContextEditorFlyoutComponentProps {
selectedPromptContext: SelectedPromptContext;
currentReplacements?: AnonymizedData['replacements'];
onListUpdated: (updates: BatchUpdateListItem[]) => void;
isDataAnonymizable: boolean;
}
const ContextEditorFlyoutComponent: React.FC<ContextEditorFlyoutComponentProps> = ({
selectedPromptContext,
currentReplacements,
onListUpdated,
isDataAnonymizable,
}) => {
const [editModalVisible, setEditModalVisible] = useState(false);
const [showRealValues, setShowRealValues] = useState<boolean>(false);
const openEditModal = useCallback(() => setEditModalVisible(true), []);
const closeEditModal = useCallback(() => {
setEditModalVisible(false);
}, []);
const { allowed, anonymized, total } = useMemo(
() =>
getStats({
anonymizationFields: selectedPromptContext.contextAnonymizationFields?.data,
rawData: selectedPromptContext.rawData,
}),
[selectedPromptContext]
);
const handleToggleShowRealValues = useCallback(() => {
setShowRealValues((prevValue) => !prevValue);
}, []);
return (
<>
<EuiFlexGroup
alignItems="center"
justifyContent="spaceBetween"
data-test-subj="summary"
gutterSize="m"
>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<AllowedStat allowed={allowed} total={total} inline />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AnonymizedStat
anonymized={anonymized}
isDataAnonymizable={isDataAnonymizable}
inline
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="xs"
iconType={showRealValues ? 'eye' : 'eyeClosed'}
onClick={handleToggleShowRealValues}
>
{I18n.translate('xpack.elasticAssistant.dataAnonymizationEditor.hideRealValues', {
defaultMessage: 'Show anonymized',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty size="xs" iconType="documentEdit" onClick={openEditModal}>
{I18n.translate('xpack.elasticAssistant.dataAnonymizationEditor.editButton', {
defaultMessage: 'Edit',
})}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin="xs" />
<SelectedPromptContextPreview
showRealValues={showRealValues}
selectedPromptContext={selectedPromptContext}
currentReplacements={currentReplacements}
onToggleShowAnonymizedValues={handleToggleShowRealValues}
/>
{editModalVisible && (
<SelectedPromptContextEditorModal
promptContext={selectedPromptContext}
onClose={closeEditModal}
onSave={onListUpdated}
/>
)}
</>
);
};
ContextEditorFlyoutComponent.displayName = 'ContextEditorFlyoutComponent';
export const ContextEditorFlyout = React.memo(ContextEditorFlyoutComponent);

View file

@ -0,0 +1,242 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo, useState } from 'react';
import {
EuiModal,
EuiModalHeader,
EuiModalHeaderTitle,
EuiSpacer,
EuiText,
EuiHorizontalRule,
EuiModalBody,
EuiModalFooter,
EuiButtonEmpty,
EuiButton,
EuiCheckbox,
EuiFlexGroup,
EuiFlexItem,
useGeneratedHtmlId,
EuiFormRow,
} from '@elastic/eui';
import { i18n as I18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import {
AnonymizationFieldResponse,
PerformBulkActionRequestBody,
} from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen';
import { find, uniqBy } from 'lodash';
import { ContextEditor } from '../context_editor';
import { Stats } from '../stats';
import * as i18n from '../../data_anonymization/settings/anonymization_settings/translations';
import { SelectedPromptContext } from '../../assistant/prompt_context/types';
import { BatchUpdateListItem } from '../context_editor/types';
import { updateSelectedPromptContext, getIsDataAnonymizable } from '../helpers';
import { useAssistantContext } from '../../assistant_context';
import { bulkUpdateAnonymizationFields } from '../../assistant/api/anonymization_fields/bulk_update_anonymization_fields';
import { useFetchAnonymizationFields } from '../../assistant/api/anonymization_fields/use_fetch_anonymization_fields';
export interface Props {
onClose: () => void;
onSave: (updates: BatchUpdateListItem[]) => void;
promptContext: SelectedPromptContext;
}
const SelectedPromptContextEditorModalComponent = ({ onClose, onSave, promptContext }: Props) => {
const { http, toasts } = useAssistantContext();
const [checked, setChecked] = useState(false);
const checkboxId = useGeneratedHtmlId({ prefix: 'updateSettingPresetsCheckbox' });
const { data: anonymizationFields, refetch: anonymizationFieldsRefetch } =
useFetchAnonymizationFields();
const [contextUpdates, setContextUpdates] = React.useState<BatchUpdateListItem[]>([]);
const [selectedPromptContext, setSelectedPromptContext] = React.useState(promptContext);
const [anonymizationFieldsBulkActions, setAnonymizationFieldsBulkActions] =
useState<PerformBulkActionRequestBody>({
create: [],
update: [],
delete: {},
});
const isDataAnonymizable = useMemo<boolean>(
() => getIsDataAnonymizable(selectedPromptContext.rawData),
[selectedPromptContext.rawData]
);
const handleSave = useCallback(async () => {
if (onSave) {
onSave(contextUpdates);
}
try {
await bulkUpdateAnonymizationFields(http, anonymizationFieldsBulkActions, toasts);
anonymizationFieldsRefetch();
} catch (e) {
/* empty */
}
onClose();
}, [
anonymizationFieldsBulkActions,
anonymizationFieldsRefetch,
contextUpdates,
http,
onClose,
onSave,
toasts,
]);
const onListUpdated = useCallback(
(updates: BatchUpdateListItem[]) => {
setContextUpdates((prev) => [...prev, ...updates]);
setAnonymizationFieldsBulkActions((prev) => {
return updates.reduce<PerformBulkActionRequestBody>(
(acc, item) => {
const persistedField = find(anonymizationFields.data, ['field', item.field]) as
| AnonymizationFieldResponse
| undefined;
if (persistedField) {
acc.update?.push({
id: persistedField.id,
...(item.update === 'allow' || item.update === 'defaultAllow'
? { allowed: item.operation === 'add' }
: {}),
...(item.update === 'allowReplacement' || item.update === 'defaultAllowReplacement'
? { anonymized: item.operation === 'add' }
: {}),
});
} else {
acc.create?.push({
field: item.field,
allowed: item.operation === 'add',
anonymized: item.operation === 'add',
});
acc.create = uniqBy(acc.create, 'field');
}
return acc;
},
{ create: prev.create ?? [], update: prev.update ?? [] }
);
});
setSelectedPromptContext((prev) =>
updates.reduce<SelectedPromptContext>(
(acc, { field, operation, update }) =>
updateSelectedPromptContext({
field,
operation,
selectedPromptContext: acc,
update,
}),
prev
)
);
},
[anonymizationFields]
);
const onChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setChecked(e.target.checked);
}, []);
return (
<EuiModal onClose={onClose}>
<EuiModalHeader
css={css`
flex-direction: column;
align-items: flex-start;
padding-bottom: 0;
`}
>
<EuiModalHeaderTitle>{i18n.SETTINGS_TITLE}</EuiModalHeaderTitle>
<EuiSpacer size="xs" />
<EuiText size={'xs'}>{i18n.SETTINGS_DESCRIPTION}</EuiText>
<EuiHorizontalRule margin="s" />
</EuiModalHeader>
<EuiModalBody>
<Stats
isDataAnonymizable={isDataAnonymizable}
anonymizationFields={selectedPromptContext.contextAnonymizationFields?.data}
rawData={selectedPromptContext.rawData}
/>
<EuiSpacer size="s" />
<ContextEditor
anonymizationFields={
selectedPromptContext.contextAnonymizationFields ?? {
total: 0,
page: 1,
perPage: 1000,
data: [],
}
}
onListUpdated={onListUpdated}
rawData={selectedPromptContext.rawData as Record<string, string[]>}
/>
</EuiModalBody>
<EuiModalFooter
css={css`
background: ${euiThemeVars.euiColorLightestShade};
padding-block: 16px;
`}
>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiFormRow
helpText={I18n.translate(
'xpack.elasticAssistant.dataAnonymizationEditor.updatePresetsCheckboxHelpText',
{
defaultMessage:
'Apply new anonymization settings for current & future conversations.',
}
)}
>
<EuiCheckbox
id={checkboxId}
label={I18n.translate(
'xpack.elasticAssistant.dataAnonymizationEditor.updatePresetsCheckboxLabel',
{
defaultMessage: 'Update presets',
}
)}
checked={checked}
onChange={onChange}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem>
<EuiButtonEmpty onClick={onClose} size="s">
{I18n.translate('xpack.elasticAssistant.dataAnonymizationEditor.closeButton', {
defaultMessage: 'Close',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton onClick={handleSave} fill size="s">
{I18n.translate('xpack.elasticAssistant.dataAnonymizationEditor.saveButton', {
defaultMessage: 'Save',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
);
};
SelectedPromptContextEditorModalComponent.displayName = 'SelectedPromptContextEditor';
export const SelectedPromptContextEditorModal = React.memo(
SelectedPromptContextEditorModalComponent
);

View file

@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { EuiText } from '@elastic/eui';
import { getAnonymizedValue } from '@kbn/elastic-assistant-common';
import { getAnonymizedData } from '@kbn/elastic-assistant-common/impl/data_anonymization/get_anonymized_data';
import { getAnonymizedValues } from '@kbn/elastic-assistant-common/impl/data_anonymization/get_anonymized_values';
import { euiThemeVars } from '@kbn/ui-theme';
import { css } from '@emotion/react';
import { AnonymizedData } from '@kbn/elastic-assistant-common/impl/data_anonymization/types';
import styled from '@emotion/styled';
import { SelectedPromptContext } from '../assistant/prompt_context/types';
const Strong = styled.strong<{ showRealValues: boolean }>`
color: ${(props) =>
props.showRealValues ? euiThemeVars.euiColorSuccess : euiThemeVars.euiColorAccent};
cursor: pointer;
`;
export interface Props {
selectedPromptContext: SelectedPromptContext;
showRealValues: boolean;
currentReplacements: AnonymizedData['replacements'] | undefined;
onToggleShowAnonymizedValues: () => void;
}
const SelectedPromptContextPreviewComponent = ({
selectedPromptContext,
currentReplacements,
showRealValues,
onToggleShowAnonymizedValues,
}: Props) => {
const data = useMemo(
() =>
getAnonymizedData({
anonymizationFields: selectedPromptContext.contextAnonymizationFields?.data ?? [],
getAnonymizedValue,
getAnonymizedValues,
rawData: selectedPromptContext.rawData as Record<string, string[]>,
currentReplacements,
}),
[currentReplacements, selectedPromptContext]
);
return (
<EuiText
color="subdued"
size="xs"
css={css`
max-height: 240px;
overflow: auto;
`}
>
<code>
<>
{Object.entries(data.anonymizedData).map(([key, value]) => (
<div key={key}>
{`${key},`}
{data.replacements[value[0]] ? (
<Strong showRealValues={showRealValues} onClick={onToggleShowAnonymizedValues}>
{showRealValues ? data.replacements[value[0]] : value}
</Strong>
) : (
value
)}
</div>
))}
</>
</code>
</EuiText>
);
};
SelectedPromptContextPreviewComponent.displayName = 'SelectedPromptContextPreview';
export const SelectedPromptContextPreview = React.memo(SelectedPromptContextPreviewComponent);

View file

@ -44,6 +44,8 @@ describe('DataAnonymizationEditor', () => {
<DataAnonymizationEditor
selectedPromptContext={mockSelectedPromptContext}
setSelectedPromptContexts={jest.fn()}
currentReplacements={{}}
isFlyoutMode={false}
/>
</TestProviders>
);
@ -58,6 +60,8 @@ describe('DataAnonymizationEditor', () => {
<DataAnonymizationEditor
selectedPromptContext={mockSelectedPromptContext}
setSelectedPromptContexts={jest.fn()}
currentReplacements={{}}
isFlyoutMode={false}
/>
</TestProviders>
);
@ -71,6 +75,8 @@ describe('DataAnonymizationEditor', () => {
<DataAnonymizationEditor
selectedPromptContext={mockSelectedPromptContext}
setSelectedPromptContexts={jest.fn()}
currentReplacements={{}}
isFlyoutMode={false}
/>
</TestProviders>
);
@ -98,6 +104,8 @@ describe('DataAnonymizationEditor', () => {
<DataAnonymizationEditor
selectedPromptContext={selectedPromptContextWithAnonymized}
setSelectedPromptContexts={setSelectedPromptContexts}
currentReplacements={{}}
isFlyoutMode={false}
/>
</TestProviders>
);

View file

@ -5,16 +5,16 @@
* 2.0.
*/
import { EuiSpacer } from '@elastic/eui';
import { EuiPanel, EuiSpacer } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import styled from '@emotion/styled';
import { AnonymizedData } from '@kbn/elastic-assistant-common/impl/data_anonymization/types';
import type { SelectedPromptContext } from '../assistant/prompt_context/types';
import { ContextEditor } from './context_editor';
import { BatchUpdateListItem } from './context_editor/types';
import { getIsDataAnonymizable, updateSelectedPromptContext } from './helpers';
import { ReadOnlyContextViewer } from './read_only_context_viewer';
import { ContextEditorFlyout } from './context_editor_flyout';
import { ContextEditor } from './context_editor';
import { Stats } from './stats';
const EditorContainer = styled.div`
@ -26,11 +26,15 @@ export interface Props {
setSelectedPromptContexts: React.Dispatch<
React.SetStateAction<Record<string, SelectedPromptContext>>
>;
currentReplacements: AnonymizedData['replacements'] | undefined;
isFlyoutMode: boolean;
}
const DataAnonymizationEditorComponent: React.FC<Props> = ({
selectedPromptContext,
setSelectedPromptContexts,
currentReplacements,
isFlyoutMode,
}) => {
const isDataAnonymizable = useMemo<boolean>(
() => getIsDataAnonymizable(selectedPromptContext.rawData),
@ -58,6 +62,25 @@ const DataAnonymizationEditorComponent: React.FC<Props> = ({
[selectedPromptContext, setSelectedPromptContexts]
);
if (isFlyoutMode) {
return (
<EditorContainer data-test-subj="dataAnonymizationEditor">
<EuiPanel hasShadow={false} paddingSize="m">
{typeof selectedPromptContext.rawData === 'string' ? (
<ReadOnlyContextViewer rawData={selectedPromptContext.rawData} />
) : (
<ContextEditorFlyout
selectedPromptContext={selectedPromptContext}
onListUpdated={onListUpdated}
currentReplacements={currentReplacements}
isDataAnonymizable={isDataAnonymizable}
/>
)}
</EuiPanel>
</EditorContainer>
);
}
return (
<EditorContainer data-test-subj="dataAnonymizationEditor">
<Stats

View file

@ -8,20 +8,32 @@
import { EuiStat, EuiToolTip } from '@elastic/eui';
import React, { useMemo } from 'react';
import { euiThemeVars } from '@kbn/ui-theme';
import { css } from '@emotion/react';
import { TITLE_SIZE } from '../constants';
import * as i18n from './translations';
interface Props {
allowed: number;
total: number;
inline?: boolean;
}
const AllowedStatComponent: React.FC<Props> = ({ allowed, total }) => {
const AllowedStatComponent: React.FC<Props> = ({ allowed, total, inline }) => {
const tooltipContent = useMemo(() => i18n.ALLOWED_TOOLTIP({ allowed, total }), [allowed, total]);
return (
<EuiToolTip content={tooltipContent}>
<EuiStat
css={
inline
? css`
display: flex;
align-items: center;
gap: ${euiThemeVars.euiSizeXS};
`
: null
}
data-test-subj="allowedStat"
description={i18n.ALLOWED}
reverse

View file

@ -18,7 +18,6 @@ import { TestProviders } from '../../../mock/test_providers/test_providers';
const defaultProps = {
anonymized: 0,
isDataAnonymizable: false,
showIcon: false,
};
describe('AnonymizedStat', () => {
@ -32,26 +31,6 @@ describe('AnonymizedStat', () => {
expect(screen.getByTestId('anonymizedFieldsStat')).toHaveTextContent('0Anonymized');
});
it('shows the anonymization icon when showIcon is true', () => {
render(
<TestProviders>
<AnonymizedStat {...defaultProps} showIcon={true} />
</TestProviders>
);
expect(screen.getByTestId('anonymizationIcon')).toBeInTheDocument();
});
it('does NOT show the anonymization icon when showIcon is false', () => {
render(
<TestProviders>
<AnonymizedStat {...defaultProps} showIcon={false} />
</TestProviders>
);
expect(screen.queryByTestId('anonymizationIcon')).not.toBeInTheDocument();
});
it('shows the correct tooltip content when anonymized is 0 and isDataAnonymizable is false', async () => {
render(
<EuiToolTip content={getTooltipContent({ anonymized: 0, isDataAnonymizable: false })}>

View file

@ -5,32 +5,22 @@
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiStat, EuiText, EuiToolTip } from '@elastic/eui';
import { EuiStat, EuiText, EuiToolTip } from '@elastic/eui';
import React, { useMemo } from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { getColor, getTooltipContent } from './helpers';
import { TITLE_SIZE } from '../constants';
import * as i18n from './translations';
const ANONYMIZATION_ICON = 'eyeClosed';
const AnonymizationIconFlexItem = styled(EuiFlexItem)`
margin-right: ${({ theme }) => theme.eui.euiSizeS};
`;
interface Props {
anonymized: number;
isDataAnonymizable: boolean;
showIcon?: boolean;
inline?: boolean;
}
const AnonymizedStatComponent: React.FC<Props> = ({
anonymized,
isDataAnonymizable,
showIcon = false,
}) => {
const AnonymizedStatComponent: React.FC<Props> = ({ anonymized, isDataAnonymizable, inline }) => {
const color = useMemo(() => getColor(isDataAnonymizable), [isDataAnonymizable]);
const tooltipContent = useMemo(
@ -40,31 +30,25 @@ const AnonymizedStatComponent: React.FC<Props> = ({
const description = useMemo(
() => (
<EuiFlexGroup alignItems="center" gutterSize="none">
{showIcon && (
<AnonymizationIconFlexItem grow={false}>
<EuiIcon
color={color}
data-test-subj="anonymizationIcon"
size="m"
type={ANONYMIZATION_ICON}
/>
</AnonymizationIconFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiText color={color} data-test-subj="description" size="s">
{i18n.ANONYMIZED_FIELDS}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiText color={color} data-test-subj="description" size="s">
{i18n.ANONYMIZED_FIELDS}
</EuiText>
),
[color, showIcon]
[color]
);
return (
<EuiToolTip content={tooltipContent}>
<EuiStat
css={
inline
? css`
display: flex;
align-items: center;
gap: ${euiThemeVars.euiSizeXS};
`
: null
}
data-test-subj="anonymizedFieldsStat"
description={description}
reverse

View file

@ -8,19 +8,31 @@
import { EuiStat, EuiToolTip } from '@elastic/eui';
import React, { useMemo } from 'react';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { TITLE_SIZE } from '../constants';
import * as i18n from './translations';
interface Props {
total: number;
inline?: boolean;
}
const AvailableStatComponent: React.FC<Props> = ({ total }) => {
const AvailableStatComponent: React.FC<Props> = ({ total, inline }) => {
const tooltipContent = useMemo(() => i18n.AVAILABLE_TOOLTIP(total), [total]);
return (
<EuiToolTip content={tooltipContent}>
<EuiStat
css={
inline
? css`
display: flex;
align-items: center;
gap: ${euiThemeVars.euiSizeXS};
`
: null
}
data-test-subj="availableStat"
description={i18n.AVAILABLE}
reverse

View file

@ -24,9 +24,15 @@ interface Props {
isDataAnonymizable: boolean;
anonymizationFields?: AnonymizationFieldResponse[];
rawData?: string | Record<string, string[]>;
inline?: boolean;
}
const StatsComponent: React.FC<Props> = ({ isDataAnonymizable, anonymizationFields, rawData }) => {
const StatsComponent: React.FC<Props> = ({
isDataAnonymizable,
anonymizationFields,
rawData,
inline,
}) => {
const { allowed, anonymized, total } = useMemo(
() =>
getStats({
@ -40,17 +46,21 @@ const StatsComponent: React.FC<Props> = ({ isDataAnonymizable, anonymizationFiel
<EuiFlexGroup alignItems="center" data-test-subj="stats" gutterSize="none">
{isDataAnonymizable && (
<StatFlexItem grow={false}>
<AllowedStat allowed={allowed} total={total} />
<AllowedStat allowed={allowed} total={total} inline={inline} />
</StatFlexItem>
)}
<StatFlexItem grow={false}>
<AnonymizedStat anonymized={anonymized} isDataAnonymizable={isDataAnonymizable} />
<AnonymizedStat
anonymized={anonymized}
isDataAnonymizable={isDataAnonymizable}
inline={inline}
/>
</StatFlexItem>
{isDataAnonymizable && (
<StatFlexItem grow={false}>
<AvailableStat total={total} />
<AvailableStat total={total} inline={inline} />
</StatFlexItem>
)}
</EuiFlexGroup>

View file

@ -232,7 +232,7 @@ export const KnowledgeBaseSettings: React.FC<Props> = React.memo(
padding-left: 5px;
`}
>
<EuiFlexItem>
<EuiFlexItem grow={false}>
<div>
<EuiHealth color={elserHealth}>{i18n.KNOWLEDGE_BASE_ELSER_LABEL}</EuiHealth>
<EuiText
@ -271,7 +271,7 @@ export const KnowledgeBaseSettings: React.FC<Props> = React.memo(
</EuiText>
</div>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexItem grow={false}>
<div>
<EuiHealth color={knowledgeBaseHealth}>{i18n.KNOWLEDGE_BASE_LABEL}</EuiHealth>
<EuiText

View file

@ -12,6 +12,7 @@ export const mockSystemPrompt: Prompt = {
content: 'You are a helpful, expert assistant who answers questions about Elastic Security.',
name: 'Mock system prompt',
promptType: 'system',
isFlyoutMode: false,
};
export const mockSuperheroSystemPrompt: Prompt = {

View file

@ -80,6 +80,7 @@ export const transformToCreateScheme = (
{ allowed, anonymized, field }: AnonymizationFieldCreateProps
): CreateAnonymizationFieldSchema => {
return {
'@timestamp': createdAt,
updated_at: createdAt,
field,
created_at: createdAt,

View file

@ -23,7 +23,7 @@ export interface Props {
actionTypeId: string;
logger: Logger;
}
interface StaticResponse {
export interface StaticResponse {
connector_id: string;
data: string;
status: string;

View file

@ -19,13 +19,14 @@ import {
replaceAnonymizedValuesWithOriginalValues,
} from '@kbn/elastic-assistant-common';
import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common';
import { i18n } from '@kbn/i18n';
import { getLlmType } from './utils';
import { StaticReturnType } from '../lib/langchain/executors/types';
import {
INVOKE_ASSISTANT_ERROR_EVENT,
INVOKE_ASSISTANT_SUCCESS_EVENT,
} from '../lib/telemetry/event_based_telemetry';
import { executeAction } from '../lib/executor';
import { executeAction, StaticResponse } from '../lib/executor';
import { POST_ACTIONS_CONNECTOR_EXECUTE } from '../../common/constants';
import { getLangChainMessages } from '../lib/langchain/helpers';
import { buildResponse } from '../lib/build_response';
@ -107,6 +108,11 @@ export const postActionsConnectorExecuteRoute = (
};
}
const connectorId = decodeURIComponent(request.params.connectorId);
// get the actions plugin start contract from the request context:
const actions = (await context.elasticAssistant).actions;
if (conversationId) {
const conversation = await conversationsDataClient?.getConversation({
id: conversationId,
@ -158,6 +164,66 @@ export const postActionsConnectorExecuteRoute = (
});
}
const NEW_CHAT = i18n.translate('xpack.elasticAssistantPlugin.server.newChat', {
defaultMessage: 'New chat',
});
if (conversation?.title === NEW_CHAT && prevMessages) {
try {
const autoTitle = (await executeAction({
actions,
request,
connectorId,
actionTypeId,
params: {
subAction: 'invokeAI',
subActionParams: {
model: request.body.model,
messages: [
{
role: 'assistant',
content: i18n.translate(
'xpack.elasticAssistantPlugin.server.autoTitlePromptDescription',
{
defaultMessage:
'You are a helpful assistant for Elastic Security. Assume the following message is the start of a conversation between you and a user; give this conversation a title based on the content below. DO NOT UNDER ANY CIRCUMSTANCES wrap this title in single or double quotes. This title is shown in a list of conversations to the user, so title it for the user, not for you.',
}
),
},
newMessage ?? prevMessages?.[0],
],
...(actionTypeId === '.gen-ai'
? { n: 1, stop: null, temperature: 0.2 }
: { temperature: 0, stopSequences: [] }),
},
},
logger,
})) as unknown as StaticResponse; // TODO: Use function overloads in executeAction to avoid this cast when sending subAction: 'invokeAI',
if (autoTitle.status === 'ok') {
try {
// This regular expression captures a string enclosed in single or double quotes.
// It extracts the string content without the quotes.
// Example matches:
// - "Hello, World!" => Captures: Hello, World!
// - 'Another Example' => Captures: Another Example
// - JustTextWithoutQuotes => Captures: JustTextWithoutQuotes
const match = autoTitle.data.match(/^["']?([^"']+)["']?$/);
const title = match ? match[1] : autoTitle.data;
await conversationsDataClient?.updateConversation({
conversationUpdateProps: {
id: conversationId,
title,
},
});
} catch (e) {
logger.warn(`Failed to update conversation with generated title: ${e.message}`);
}
}
} catch (e) {
/* empty */
}
}
onLlmResponse = async (
content: string,
traceData: Message['traceData'] = {},
@ -189,11 +255,6 @@ export const postActionsConnectorExecuteRoute = (
};
}
const connectorId = decodeURIComponent(request.params.connectorId);
// get the actions plugin start contract from the request context:
const actions = (await context.elasticAssistant).actions;
// if not langchain, call execute action directly and return the response:
if (!request.body.isEnabledKnowledgeBase && !request.body.isEnabledRAGAlerts) {
logger.debug('Executing via actions framework directly');

View file

@ -9,11 +9,7 @@ import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-m
import { requestContextMock } from '../../__mocks__/request_context';
import { serverMock } from '../../__mocks__/server';
import { createConversationRoute } from './create_route';
import {
getBasicEmptySearchResponse,
getEmptyFindResult,
getFindConversationsResultWithSingleHit,
} from '../../__mocks__/response';
import { getBasicEmptySearchResponse, getEmptyFindResult } from '../../__mocks__/response';
import { getCreateConversationRequest, requestMock } from '../../__mocks__/request';
import {
getCreateConversationSchemaMock,
@ -72,22 +68,6 @@ describe('Create conversation route', () => {
});
describe('unhappy paths', () => {
test('returns a duplicate error if conversation_id already exists', async () => {
clients.elasticAssistant.getAIAssistantConversationsDataClient.findDocuments.mockResolvedValue(
Promise.resolve(getFindConversationsResultWithSingleHit())
);
const response = await server.inject(
getCreateConversationRequest(),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(409);
expect(response.body).toEqual({
message: expect.stringContaining('already exists'),
status_code: 409,
});
});
test('catches error if creation throws', async () => {
clients.elasticAssistant.getAIAssistantConversationsDataClient.createConversation.mockImplementation(
async () => {

View file

@ -58,18 +58,6 @@ export const createConversationRoute = (router: ElasticAssistantPluginRouter): v
});
}
const result = await dataClient?.findDocuments({
perPage: 100,
page: 1,
filter: `users:{ id: "${authenticatedUser?.profile_uid}" } AND title:${request.body.title}`,
fields: ['title'],
});
if (result?.data != null && result.total > 0) {
return assistantResponse.error({
statusCode: 409,
body: `conversation title: "${request.body.title}" already exists`,
});
}
const createdConversation = await dataClient?.createConversation({
conversation: request.body,
authenticatedUser,

View file

@ -44,6 +44,7 @@
"@kbn/security-plugin-types-common",
"@kbn/ml-response-stream",
"@kbn/data-plugin",
"@kbn/i18n",
],
"exclude": [
"target/**/*",

View file

@ -233,6 +233,11 @@ export const allowedExperimentalValues = Object.freeze({
*/
malwareOnWriteScanOptionAvailable: false,
/**
* Enables Security AI Assistant's Flyout mode
*/
aiAssistantFlyoutMode: false,
/**
* Enables the new modal for the value list items
*/

View file

@ -23,9 +23,10 @@ import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experime
interface Props {
message: ClientMessage;
isFlyoutMode: boolean;
}
const CommentActionsComponent: React.FC<Props> = ({ message }) => {
const CommentActionsComponent: React.FC<Props> = ({ message, isFlyoutMode }) => {
const toasts = useToasts();
const { cases } = useKibana().services;
const dispatch = useDispatch();
@ -64,7 +65,9 @@ const CommentActionsComponent: React.FC<Props> = ({ message }) => {
});
const onAddToExistingCase = useCallback(() => {
showAssistantOverlay({ showOverlay: false });
if (!isFlyoutMode) {
showAssistantOverlay({ showOverlay: false });
}
selectCaseModal.open({
getAttachments: () => [
@ -75,7 +78,7 @@ const CommentActionsComponent: React.FC<Props> = ({ message }) => {
},
],
});
}, [content, selectCaseModal, showAssistantOverlay]);
}, [content, isFlyoutMode, selectCaseModal, showAssistantOverlay]);
// Note: This feature is behind the `isModelEvaluationEnabled` FF. If ever released, this URL should be configurable
// as APM data may not go to the same cluster where the Kibana instance is running

View file

@ -39,6 +39,7 @@ const testProps = {
isFetchingResponse: false,
currentConversation,
showAnonymizedValues,
isFlyoutMode: false,
};
describe('getComments', () => {
it('Does not add error state message has no error', () => {

View file

@ -13,10 +13,20 @@ import React from 'react';
import { AssistantAvatar } from '@kbn/elastic-assistant';
import type { Replacements } from '@kbn/elastic-assistant-common';
import { replaceAnonymizedValuesWithOriginalValues } from '@kbn/elastic-assistant-common';
import styled from '@emotion/styled';
import type { UserAvatar } from '@kbn/elastic-assistant/impl/assistant_context';
import { StreamComment } from './stream';
import { CommentActions } from '../comment_actions';
import * as i18n from './translations';
// Matches EuiAvatar L
const SpinnerWrapper = styled.div`
width: 40px;
height: 40px;
display: flex;
justify-content: center;
`;
export interface ContentMessage extends ClientMessage {
content: string;
}
@ -50,17 +60,23 @@ export const getComments = ({
refetchCurrentConversation,
regenerateMessage,
showAnonymizedValues,
isFlyoutMode,
currentUserAvatar,
setIsStreaming,
}: {
abortStream: () => void;
currentConversation: Conversation;
currentConversation?: Conversation;
isEnabledLangChain: boolean;
isFetchingResponse: boolean;
refetchCurrentConversation: () => void;
regenerateMessage: (conversationId: string) => void;
showAnonymizedValues: boolean;
isFlyoutMode: boolean;
currentUserAvatar?: UserAvatar;
setIsStreaming: (isStreaming: boolean) => void;
}): EuiCommentProps[] => {
if (!currentConversation) return [];
const regenerateMessageOfConversation = () => {
regenerateMessage(currentConversation.id);
};
@ -71,7 +87,11 @@ export const getComments = ({
? [
{
username: i18n.ASSISTANT,
timelineAvatar: <EuiLoadingSpinner size="xl" />,
timelineAvatar: (
<SpinnerWrapper>
<EuiLoadingSpinner size="xl" />
</SpinnerWrapper>
),
timestamp: '...',
children: (
<StreamComment
@ -92,6 +112,23 @@ export const getComments = ({
]
: [];
const UserAvatar = () => {
if (currentUserAvatar) {
return (
<EuiAvatar
name="user"
size="l"
color={currentUserAvatar?.color ?? 'subdued'}
{...(currentUserAvatar?.imageUrl
? { imageUrl: currentUserAvatar.imageUrl as string }
: { initials: currentUserAvatar?.initials })}
/>
);
}
return <EuiAvatar name="user" size="l" color="subdued" iconType="userAvatar" />;
};
return [
...currentConversation.messages.map((message, index) => {
const isLastComment = index === currentConversation.messages.length - 1;
@ -100,7 +137,7 @@ export const getComments = ({
const messageProps = {
timelineAvatar: isUser ? (
<EuiAvatar name="user" size="l" color="subdued" iconType="userAvatar" />
<UserAvatar />
) : (
<EuiAvatar name="machine" size="l" color="subdued" iconType={AssistantAvatar} />
),
@ -150,7 +187,7 @@ export const getComments = ({
return {
...messageProps,
actions: <CommentActions message={transformedMessage} />,
actions: <CommentActions message={transformedMessage} isFlyoutMode={isFlyoutMode} />,
children: (
<StreamComment
actionTypeId={actionTypeId}

View file

@ -156,7 +156,7 @@ const getPluginDependencies = () => {
export function MessageText({ loading, content, index }: Props) {
const containerClassName = css`
overflow-wrap: break-word;
overflow-wrap: anywhere;
`;
const { parsingPluginList, processingPluginList } = getPluginDependencies();
@ -169,6 +169,7 @@ export function MessageText({ loading, content, index }: Props) {
data-test-subj={'messageText'}
parsingPluginList={parsingPluginList}
processingPluginList={processingPluginList}
textSize="s"
>
{`${content}${loading ? CURSOR : ''}`}
</EuiMarkdownFormat>

View file

@ -7,6 +7,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { AssistantOverlay } from './overlay';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const mockAssistantAvailability = jest.fn(() => ({
hasAssistantPrivilege: true,
@ -18,20 +19,32 @@ jest.mock('@kbn/elastic-assistant', () => ({
}),
}));
jest.mock('../common/hooks/use_experimental_features');
describe('AssistantOverlay', () => {
const queryClient = new QueryClient();
beforeEach(() => {
jest.clearAllMocks();
});
it('should render the header link text', () => {
const { queryByTestId } = render(<AssistantOverlay />);
const { queryByTestId } = render(
<QueryClientProvider client={queryClient}>
<AssistantOverlay />
</QueryClientProvider>
);
expect(queryByTestId('assistantOverlay')).toBeInTheDocument();
});
it('should not render the header link if not authorized', () => {
mockAssistantAvailability.mockReturnValueOnce({ hasAssistantPrivilege: false });
const { queryByTestId } = render(<AssistantOverlay />);
const { queryByTestId } = render(
<QueryClientProvider client={queryClient}>
<AssistantOverlay />
</QueryClientProvider>
);
expect(queryByTestId('assistantOverlay')).not.toBeInTheDocument();
});
});

View file

@ -9,11 +9,38 @@ import {
AssistantOverlay as ElasticAssistantOverlay,
useAssistantContext,
} from '@kbn/elastic-assistant';
import { useQuery } from '@tanstack/react-query';
import type { UserAvatar } from '@kbn/elastic-assistant/impl/assistant_context';
import { useIsExperimentalFeatureEnabled } from '../common/hooks/use_experimental_features';
import { useKibana } from '../common/lib/kibana';
export const AssistantOverlay: React.FC = () => {
const { services } = useKibana();
const { data: currentUserAvatar } = useQuery({
queryKey: ['currentUserAvatar'],
queryFn: () =>
services.security?.userProfiles.getCurrent<{ avatar: UserAvatar }>({
dataPath: 'avatar',
}),
select: (data) => {
return data.data.avatar;
},
keepPreviousData: true,
refetchOnWindowFocus: false,
});
const { assistantAvailability } = useAssistantContext();
const aiAssistantFlyoutMode = useIsExperimentalFeatureEnabled('aiAssistantFlyoutMode');
if (!assistantAvailability.hasAssistantPrivilege) {
return null;
}
return <ElasticAssistantOverlay />;
return (
<ElasticAssistantOverlay
isFlyoutMode={aiAssistantFlyoutMode}
currentUserAvatar={currentUserAvatar}
/>
);
};

View file

@ -112,6 +112,7 @@ const ActiveTimelineTab = memo<ActiveTimelineTabProps>(
}) => {
const { hasAssistantPrivilege } = useAssistantAvailability();
const isEsqlSettingEnabled = useKibana().services.configSettings.ESQLEnabled;
const aiAssistantFlyoutMode = useIsExperimentalFeatureEnabled('aiAssistantFlyoutMode');
const getTab = useCallback(
(tab: TimelineTabs) => {
switch (tab) {
@ -215,7 +216,7 @@ const ActiveTimelineTab = memo<ActiveTimelineTabProps>(
>
{isGraphOrNotesTabs && getTab(activeTimelineTab)}
</HideShowContainer>
{hasAssistantPrivilege ? getAssistantTab() : null}
{hasAssistantPrivilege && !aiAssistantFlyoutMode ? getAssistantTab() : null}
</>
);
}
@ -268,6 +269,7 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
timelineDescription,
}) => {
const isEsqlTabInTimelineDisabled = useIsExperimentalFeatureEnabled('timelineEsqlTabDisabled');
const aiAssistantFlyoutMode = useIsExperimentalFeatureEnabled('aiAssistantFlyoutMode');
const isEsqlSettingEnabled = useKibana().services.configSettings.ESQLEnabled;
const { hasAssistantPrivilege } = useAssistantAvailability();
const dispatch = useDispatch();
@ -464,7 +466,7 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
</div>
)}
</StyledEuiTab>
{hasAssistantPrivilege && (
{hasAssistantPrivilege && !aiAssistantFlyoutMode && (
<StyledEuiTab
data-test-subj={`timelineTabs-${TimelineTabs.securityAssistant}`}
onClick={setSecurityAssistantAsActiveTab}