Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D
avcResults: `https://www.elastic.co/blog/elastic-security-av-comparatives-business-test`,
bidirectionalIntegrations: `${ELASTIC_DOCS}solutions/security/endpoint-response-actions/third-party-response-actions`,
trustedApps: `${ELASTIC_DOCS}solutions/security/manage-elastic-defend/trusted-applications`,
elasticAiFeatures: `${ELASTIC_DOCS}solutions/security/ai`,
eventFilters: `${ELASTIC_DOCS}solutions/security/manage-elastic-defend/event-filters`,
blocklist: `${ELASTIC_DOCS}solutions/security/manage-elastic-defend/blocklist`,
threatIntelInt: `${ELASTIC_DOCS}solutions/security/get-started/enable-threat-intelligence-integrations`,
Expand Down Expand Up @@ -457,6 +458,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D
: `https://www.elastic.co/blog/security-prebuilt-rules-editing`,
createEsqlRuleType: `${ELASTIC_DOCS}solutions/security/detect-and-alert/create-detection-rule#create-esql-rule`,
ruleUiAdvancedParams: `${ELASTIC_DOCS}solutions/security/detect-and-alert/create-detection-rule#rule-ui-advanced-params`,
thirdPartyLlmProviders: `${ELASTIC_DOCS}solutions/security/ai/set-up-connectors-for-large-language-models-llm`,
entityAnalytics: {
riskScorePrerequisites: `${ELASTIC_DOCS}solutions/security/advanced-entity-analytics/entity-risk-scoring-requirements`,
entityRiskScoring: `${ELASTIC_DOCS}solutions/security/advanced-entity-analytics/entity-risk-scoring`,
Expand Down
2 changes: 2 additions & 0 deletions src/platform/packages/shared/kbn-doc-links/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,9 @@ export interface DocLinks {
readonly artifactControl: string;
readonly avcResults: string;
readonly bidirectionalIntegrations: string;
readonly thirdPartyLlmProviders: string;
readonly trustedApps: string;
readonly elasticAiFeatures: string;
readonly eventFilters: string;
readonly eventMerging: string;
readonly blocklist: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render } from '@testing-library/react';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { ElasticLlmCallout } from './elastic_llm_callout';
import { TestProviders } from '../../mock/test_providers/test_providers';

jest.mock('react-use/lib/useLocalStorage');

describe('ElasticLlmCallout', () => {
const defaultProps = {
showEISCallout: true,
};

beforeEach(() => {
jest.clearAllMocks();
(useLocalStorage as jest.Mock).mockReturnValue([false, jest.fn()]);
});

it('should not render when showEISCallout is false', () => {
const { queryByTestId } = render(<ElasticLlmCallout showEISCallout={false} />, {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
expect(queryByTestId('elasticLlmCallout')).not.toBeInTheDocument();
});

it('should not render when tour is completed', () => {
(useLocalStorage as jest.Mock).mockReturnValue([true, jest.fn()]);
const { queryByTestId } = render(
<TestProviders>
<ElasticLlmCallout {...defaultProps} />
</TestProviders>,
{
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
}
);

expect(queryByTestId('elasticLlmCallout')).not.toBeInTheDocument();
});

it('should render links', () => {
const { queryByTestId } = render(
<TestProviders>
<ElasticLlmCallout {...defaultProps} />
</TestProviders>,
{ wrapper: ({ children }) => <TestProviders>{children}</TestProviders> }
);
expect(queryByTestId('elasticLlmUsageCostLink')).toHaveTextContent('additional costs incur');
expect(queryByTestId('elasticLlmConnectorLink')).toHaveTextContent('connector');
});

it('should show callout when showEISCallout changes to true', () => {
const { rerender, queryByTestId } = render(
<TestProviders>
<ElasticLlmCallout showEISCallout={false} />
</TestProviders>,
{ wrapper: ({ children }) => <TestProviders>{children}</TestProviders> }
);
expect(queryByTestId('elasticLlmCallout')).not.toBeInTheDocument();

rerender(<ElasticLlmCallout showEISCallout={true} />);
expect(queryByTestId('elasticLlmCallout')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useEffect, useState } from 'react';
import useLocalStorage from 'react-use/lib/useLocalStorage';

import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiCallOut, EuiLink, useEuiTheme } from '@elastic/eui';
import { useAssistantContext } from '../../assistant_context';
import { NEW_FEATURES_TOUR_STORAGE_KEYS } from '../../tour/const';
import { useTourStorageKey } from '../../tour/common/hooks/use_tour_storage_key';

export const ElasticLlmCallout = ({ showEISCallout }: { showEISCallout: boolean }) => {
const {
getUrlForApp,
docLinks: {
links: {
observability: { elasticManagedLlmUsageCost: ELASTIC_LLM_USAGE_COST_LINK },
},
},
} = useAssistantContext();
const { euiTheme } = useEuiTheme();
const tourStorageKey = useTourStorageKey(
NEW_FEATURES_TOUR_STORAGE_KEYS.CONVERSATION_CONNECTOR_ELASTIC_LLM
);
const [tourCompleted, setTourCompleted] = useLocalStorage<boolean>(tourStorageKey, false);
const [showCallOut, setShowCallOut] = useState<boolean>(showEISCallout);

const onDismiss = useCallback(() => {
setShowCallOut(false);
setTourCompleted(true);
}, [setTourCompleted]);

useEffect(() => {
if (showEISCallout && !tourCompleted) {
setShowCallOut(true);
} else {
setShowCallOut(false);
}
}, [showEISCallout, tourCompleted]);

if (!showCallOut) {
return null;
}

return (
<EuiCallOut
data-test-subj="elasticLlmCallout"
onDismiss={onDismiss}
iconType="iInCircle"
title={i18n.translate('xpack.elasticAssistant.assistant.connectors.elasticLlmCallout.title', {
defaultMessage: 'You are now using the Elastic Managed LLM connector',
})}
size="s"
css={css`
padding: ${euiTheme.size.s} !important;
`}
>
<p>
<FormattedMessage
id="xpack.elasticAssistant.assistant.connectors.tour.elasticLlmDescription"
defaultMessage="Elastic AI Assistant and other AI features are powered by an LLM. The Elastic Managed LLM connector is used by default ({costLink}) when no custom connectors are available. You can configure a {customConnector} if you prefer."
values={{
costLink: (
<EuiLink
data-test-subj="elasticLlmUsageCostLink"
href={ELASTIC_LLM_USAGE_COST_LINK}
target="_blank"
rel="noopener noreferrer"
external
>
<FormattedMessage
id="xpack.elasticAssistant.assistant.eisCallout.extraCost.label"
defaultMessage="additional costs incur"
/>
</EuiLink>
),
customConnector: (
<EuiLink
data-test-subj="elasticLlmConnectorLink"
href={getUrlForApp('management', {
path: `/insightsAndAlerting/triggersActionsConnectors/connectors`,
})}
target="_blank"
rel="noopener noreferrer"
external
>
<FormattedMessage
id="xpack.elasticAssistant.assistant.eisCallout.connector.label"
defaultMessage="custom connector"
/>
</EuiLink>
),
}}
/>
</p>
</EuiCallOut>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { AssistantConversationBanner } from '.';
import { Conversation, useAssistantContext } from '../../..';
import { customConvo } from '../../mock/conversation';
import { AIConnector } from '../../connectorland/connector_selector';

jest.mock('../../..');

jest.mock('../../connectorland/connector_missing_callout', () => ({
ConnectorMissingCallout: () => <div data-test-subj="connector-missing-callout" />,
}));

jest.mock('./elastic_llm_callout', () => ({
ElasticLlmCallout: () => <div data-test-subj="elastic-llm-callout" />,
}));

describe('AssistantConversationBanner', () => {
const setIsSettingsModalVisible = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
});

it('renders ConnectorMissingCallout when shouldShowMissingConnectorCallout is true', () => {
(useAssistantContext as jest.Mock).mockReturnValue({ inferenceEnabled: true });

render(
<AssistantConversationBanner
isSettingsModalVisible={false}
setIsSettingsModalVisible={setIsSettingsModalVisible}
shouldShowMissingConnectorCallout={true}
currentConversation={undefined}
connectors={[]}
/>
);

expect(screen.getByTestId('connector-missing-callout')).toBeInTheDocument();
});

it('renders ElasticLlmCallout when Elastic LLM is enabled', () => {
(useAssistantContext as jest.Mock).mockReturnValue({ inferenceEnabled: true });
const mockConnectors = [
{ id: 'mockLLM', actionTypeId: '.inference', isPreconfigured: true },
] as AIConnector[];

const mockConversation = {
...customConvo,
id: 'mockConversation',
apiConfig: {
connectorId: 'mockLLM',
actionTypeId: '.inference',
},
} as Conversation;

render(
<AssistantConversationBanner
isSettingsModalVisible={false}
setIsSettingsModalVisible={setIsSettingsModalVisible}
shouldShowMissingConnectorCallout={false}
currentConversation={mockConversation}
connectors={mockConnectors}
/>
);

expect(screen.getByTestId('elastic-llm-callout')).toBeInTheDocument();
});

it('renders nothing when no conditions are met', () => {
(useAssistantContext as jest.Mock).mockReturnValue({ inferenceEnabled: false });

const { container } = render(
<AssistantConversationBanner
isSettingsModalVisible={false}
setIsSettingsModalVisible={setIsSettingsModalVisible}
shouldShowMissingConnectorCallout={false}
currentConversation={undefined}
connectors={[]}
/>
);

expect(container).toBeEmptyDOMElement();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React, { useMemo } from 'react';
import { Conversation, useAssistantContext } from '../../..';
import { isElasticManagedLlmConnector } from '../../connectorland/helpers';
import { ConnectorMissingCallout } from '../../connectorland/connector_missing_callout';
import { ElasticLlmCallout } from './elastic_llm_callout';
import { AIConnector } from '../../connectorland/connector_selector';

export const AssistantConversationBanner = React.memo(
({
isSettingsModalVisible,
setIsSettingsModalVisible,
shouldShowMissingConnectorCallout,
currentConversation,
connectors,
}: {
isSettingsModalVisible: boolean;
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
shouldShowMissingConnectorCallout: boolean;
currentConversation: Conversation | undefined;
connectors: AIConnector[] | undefined;
}) => {
const { inferenceEnabled } = useAssistantContext();
const showEISCallout = useMemo(() => {
if (inferenceEnabled && currentConversation && currentConversation.id !== '') {
if (currentConversation?.apiConfig?.connectorId) {
return connectors?.some(
(c) =>
c.id === currentConversation.apiConfig?.connectorId && isElasticManagedLlmConnector(c)
);
}
}
}, [inferenceEnabled, currentConversation, connectors]);
if (shouldShowMissingConnectorCallout) {
return (
<ConnectorMissingCallout
isConnectorConfigured={(connectors?.length ?? 0) > 0}
isSettingsModalVisible={isSettingsModalVisible}
setIsSettingsModalVisible={setIsSettingsModalVisible}
/>
);
}

if (showEISCallout) {
return <ElasticLlmCallout showEISCallout={showEISCallout} />;
}

return null;
}
);

AssistantConversationBanner.displayName = 'AssistantConversationBanner';
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import { AssistantSettingsModal } from '../settings/assistant_settings_modal';
import { AIConnector } from '../../connectorland/connector_selector';
import { SettingsContextMenu } from '../settings/settings_context_menu/settings_context_menu';
import * as i18n from './translations';
import { ElasticLLMCostAwarenessTour } from '../../tour/elastic_llm';
import { NEW_FEATURES_TOUR_STORAGE_KEYS } from '../../tour/const';

interface OwnProps {
selectedConversation: Conversation | undefined;
Expand Down Expand Up @@ -166,12 +168,18 @@ export const AssistantHeader: React.FC<Props> = ({
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" alignItems={'center'}>
<EuiFlexItem>
<ConnectorSelectorInline
isDisabled={isDisabled || selectedConversation === undefined}
<ElasticLLMCostAwarenessTour
isDisabled={isDisabled}
selectedConnectorId={selectedConnectorId}
selectedConversation={selectedConversation}
onConnectorSelected={onConversationChange}
/>
storageKey={NEW_FEATURES_TOUR_STORAGE_KEYS.ELASTIC_LLM_USAGE_ASSISTANT_HEADER}
>
<ConnectorSelectorInline
isDisabled={isDisabled || selectedConversation === undefined}
selectedConnectorId={selectedConnectorId}
selectedConversation={selectedConversation}
onConnectorSelected={onConversationChange}
/>
</ElasticLLMCostAwarenessTour>
</EuiFlexItem>
<EuiFlexItem id={AI_ASSISTANT_SETTINGS_MENU_CONTAINER_ID}>
<SettingsContextMenu
Expand Down
Loading