mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
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:
parent
11bcb0de92
commit
b20ca74821
99 changed files with 3672 additions and 1034 deletions
|
@ -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",
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -60,7 +60,7 @@ export const getConversationById = async ({
|
|||
|
||||
export interface PostConversationParams {
|
||||
http: HttpSetup;
|
||||
conversation: Conversation;
|
||||
conversation: Partial<Conversation>;
|
||||
toasts?: IToasts;
|
||||
signal?: AbortSignal | undefined;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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';
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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.',
|
||||
}
|
||||
);
|
|
@ -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';
|
|
@ -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 });
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -16,6 +16,8 @@ const testProps = {
|
|||
isLoading: false,
|
||||
onChatCleared,
|
||||
onSendMessage,
|
||||
isFlyoutMode: false,
|
||||
promptValue: 'prompt',
|
||||
};
|
||||
|
||||
describe('ChatActions', () => {
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -27,6 +27,7 @@ const testProps: Props = {
|
|||
isDisabled: false,
|
||||
shouldRefocusPrompt: false,
|
||||
userPrompt: '',
|
||||
isFlyoutMode: false,
|
||||
};
|
||||
describe('ChatSend', () => {
|
||||
beforeEach(() => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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]);
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}, [
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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[]
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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';
|
|
@ -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);
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -39,6 +39,7 @@ const defaultProps: Props = {
|
|||
selectedPromptContexts: {},
|
||||
setIsSettingsModalVisible: jest.fn(),
|
||||
setSelectedPromptContexts: jest.fn(),
|
||||
isFlyoutMode: false,
|
||||
};
|
||||
|
||||
describe('PromptEditorComponent', () => {
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -22,6 +22,8 @@ const defaultProps: Props = {
|
|||
},
|
||||
selectedPromptContexts: {},
|
||||
setSelectedPromptContexts: jest.fn(),
|
||||
currentReplacements: {},
|
||||
isFlyoutMode: false,
|
||||
};
|
||||
|
||||
const mockSelectedAlertPromptContext: SelectedPromptContext = {
|
||||
|
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 })) ?? [];
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -19,6 +19,7 @@ const testProps = {
|
|||
setInput,
|
||||
setIsSettingsModalVisible,
|
||||
trackPrompt,
|
||||
isFlyoutMode: false,
|
||||
};
|
||||
const setSelectedSettingsTab = jest.fn();
|
||||
const mockUseAssistantContext = {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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 />
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -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!",
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -43,6 +43,7 @@ export const WELCOME_CONVERSATION: Conversation = {
|
|||
},
|
||||
],
|
||||
replacements: {},
|
||||
excludeFromLastConversationStorage: true,
|
||||
};
|
||||
|
||||
export const enterpriseMessaging: ClientMessage[] = [
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -44,6 +44,7 @@ const defaultProps = {
|
|||
actionTypeRegistry,
|
||||
onClose,
|
||||
onSelect,
|
||||
actionTypeSelectorInline: false,
|
||||
};
|
||||
|
||||
describe('ActionTypeSelectorModal', () => {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -26,6 +26,7 @@ export const FIELDS = {
|
|||
ANONYMIZED: 'anonymized',
|
||||
DENIED: 'denied',
|
||||
FIELD: 'field',
|
||||
ID: 'id',
|
||||
RAW_VALUES: 'rawValues',
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
|
@ -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
|
||||
);
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 })}>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -80,6 +80,7 @@ export const transformToCreateScheme = (
|
|||
{ allowed, anonymized, field }: AnonymizationFieldCreateProps
|
||||
): CreateAnonymizationFieldSchema => {
|
||||
return {
|
||||
'@timestamp': createdAt,
|
||||
updated_at: createdAt,
|
||||
field,
|
||||
created_at: createdAt,
|
||||
|
|
|
@ -23,7 +23,7 @@ export interface Props {
|
|||
actionTypeId: string;
|
||||
logger: Logger;
|
||||
}
|
||||
interface StaticResponse {
|
||||
export interface StaticResponse {
|
||||
connector_id: string;
|
||||
data: string;
|
||||
status: string;
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -44,6 +44,7 @@
|
|||
"@kbn/security-plugin-types-common",
|
||||
"@kbn/ml-response-stream",
|
||||
"@kbn/data-plugin",
|
||||
"@kbn/i18n",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -39,6 +39,7 @@ const testProps = {
|
|||
isFetchingResponse: false,
|
||||
currentConversation,
|
||||
showAnonymizedValues,
|
||||
isFlyoutMode: false,
|
||||
};
|
||||
describe('getComments', () => {
|
||||
it('Does not add error state message has no error', () => {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue