[8.18][Security Assistant] EIS usage callout (#221566) (#224107)

# Backport

This will backport the following commits from `main` to `8.18`:
- [Security Assistant] EIS usage callout
#221566(https://github.com/elastic/kibana/pull/221566)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: florent-leborgne <florent.leborgne@elastic.co>
Co-authored-by: Viduni Wickramarachchi <viduni.ushanka@gmail.com>
This commit is contained in:
Angela Chuang 2025-06-17 09:25:54 +01:00 committed by GitHub
parent 9799132aae
commit cae09d0785
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1320 additions and 179 deletions

View file

@ -508,6 +508,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D
threatIntelInt: `${SECURITY_SOLUTION_DOCS}es-threat-intel-integrations.html`,
endpointArtifacts: `${SECURITY_SOLUTION_DOCS}endpoint-artifacts.html`,
eventMerging: `${SECURITY_SOLUTION_DOCS}endpoint-data-volume.html`,
elasticAiFeatures: `${DOCS_WEBSITE_URL}solutions/security/ai`,
policyResponseTroubleshooting: {
full_disk_access: `${SECURITY_SOLUTION_DOCS}deploy-elastic-endpoint.html#enable-fda-endpoint`,
macos_system_ext: `${SECURITY_SOLUTION_DOCS}deploy-elastic-endpoint.html#system-extension-endpoint`,

View file

@ -355,6 +355,7 @@ export interface DocLinks {
readonly avcResults: string;
readonly bidirectionalIntegrations: string;
readonly trustedApps: string;
readonly elasticAiFeatures: string;
readonly eventFilters: string;
readonly eventMerging: string;
readonly blocklist: string;

View file

@ -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();
});
});

View file

@ -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>
);
};

View file

@ -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();
});
});

View file

@ -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';

View file

@ -20,6 +20,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;
@ -158,12 +160,18 @@ export const AssistantHeader: React.FC<Props> = ({
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" alignItems={'center'}>
<EuiFlexItem>
<ElasticLLMCostAwarenessTour
isDisabled={isDisabled}
selectedConnectorId={selectedConnectorId}
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

View file

@ -46,7 +46,6 @@ import type { PromptContext, SelectedPromptContext } from './prompt_context/type
import { CodeBlockDetails } from './use_conversation/helpers';
import { QuickPrompts } from './quick_prompts/quick_prompts';
import { useLoadConnectors } from '../connectorland/use_load_connectors';
import { ConnectorMissingCallout } from '../connectorland/connector_missing_callout';
import { ConversationSidePanel } from './conversations/conversation_sidepanel';
import { SelectedPromptContexts } from './prompt_editor/selected_prompt_contexts';
import { AssistantHeader } from './assistant_header';
@ -56,6 +55,8 @@ import {
conversationContainsContentReferences,
} from './conversations/utils';
import { AssistantConversationBanner } from './assistant_conversation_banner';
export const CONVERSATION_SIDE_PANEL_WIDTH = 220;
const CommentContainer = styled('span')`
@ -535,12 +536,13 @@ const AssistantComponent: React.FC<Props> = ({
`}
banner={
!isDisabled &&
showMissingConnectorCallout &&
isFetchedConnectors && (
<ConnectorMissingCallout
isConnectorConfigured={(connectors?.length ?? 0) > 0}
<AssistantConversationBanner
isSettingsModalVisible={isSettingsModalVisible}
setIsSettingsModalVisible={setIsSettingsModalVisible}
shouldShowMissingConnectorCallout={showMissingConnectorCallout}
currentConversation={currentConversation}
connectors={connectors}
/>
)
}

View file

@ -26,7 +26,6 @@ import {
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { FormattedMessage } from '@kbn/i18n-react';
import { KnowledgeBaseTour } from '../../../tour/knowledge_base';
import { AnonymizationSettingsManagement } from '../../../data_anonymization/settings/anonymization_settings_management';
import { Conversation, useAssistantContext } from '../../../..';
import * as i18n from '../../assistant_header/translations';
@ -349,7 +348,6 @@ export const SettingsContextMenu: React.FC<Params> = React.memo(
<>
<EuiPopover
button={
<KnowledgeBaseTour>
<EuiButtonIcon
aria-label={AI_ASSISTANT_MENU}
isDisabled={isDisabled}
@ -357,7 +355,6 @@ export const SettingsContextMenu: React.FC<Params> = React.memo(
onClick={onButtonClick}
data-test-subj="chat-context-menu"
/>
</KnowledgeBaseTour>
}
isOpen={isPopoverOpen}
closePopover={closePopover}

View file

@ -14,7 +14,7 @@ import useLocalStorage from 'react-use/lib/useLocalStorage';
import useSessionStorage from 'react-use/lib/useSessionStorage';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import { AssistantFeatures, defaultAssistantFeatures } from '@kbn/elastic-assistant-common';
import { ChromeStart, NavigateToAppOptions, UserProfileService } from '@kbn/core/public';
import { ApplicationStart, ChromeStart, UserProfileService } from '@kbn/core/public';
import type { ProductDocBasePluginStart } from '@kbn/product-doc-base-plugin/public';
import { useQuery } from '@tanstack/react-query';
import { updatePromptContexts } from './helpers';
@ -59,6 +59,9 @@ type ShowAssistantOverlay = ({
promptContextId,
conversationTitle,
}: ShowAssistantOverlayProps) => void;
type GetUrlForApp = ApplicationStart['getUrlForApp'];
export interface AssistantProviderProps {
actionTypeRegistry: ActionTypeRegistryContract;
alertsIndexPattern?: string;
@ -70,14 +73,15 @@ export interface AssistantProviderProps {
) => CodeBlockDetails[][];
basePath: string;
basePromptContexts?: PromptContextTemplate[];
docLinks: Omit<DocLinksStart, 'links'>;
docLinks: DocLinksStart;
children: React.ReactNode;
getUrlForApp: GetUrlForApp;
getComments: GetAssistantMessages;
http: HttpSetup;
inferenceEnabled?: boolean;
baseConversations: Record<string, Conversation>;
nameSpace?: string;
navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise<void>;
navigateToApp: ApplicationStart['navigateToApp'];
title?: string;
toasts?: IToasts;
currentAppId: string;
@ -103,17 +107,18 @@ export interface UseAssistantContext {
currentConversation: Conversation,
showAnonymizedValues: boolean
) => CodeBlockDetails[][];
docLinks: Omit<DocLinksStart, 'links'>;
docLinks: DocLinksStart;
basePath: string;
baseConversations: Record<string, Conversation>;
currentUserAvatar?: UserAvatar;
getComments: GetAssistantMessages;
getUrlForApp: GetUrlForApp;
http: HttpSetup;
inferenceEnabled: boolean;
knowledgeBase: KnowledgeBaseConfig;
getLastConversationId: (conversationTitle?: string) => string;
promptContexts: Record<string, PromptContext>;
navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise<void>;
navigateToApp: ApplicationStart['navigateToApp'];
nameSpace: string;
registerPromptContext: RegisterPromptContext;
selectedSettingsTab: SettingsTabs | null;
@ -157,6 +162,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
basePromptContexts = [],
children,
getComments,
getUrlForApp,
http,
inferenceEnabled = false,
baseConversations,
@ -310,6 +316,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
currentUserAvatar,
docLinks,
getComments,
getUrlForApp,
http,
inferenceEnabled,
knowledgeBase: {
@ -362,6 +369,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
currentUserAvatar,
docLinks,
getComments,
getUrlForApp,
http,
inferenceEnabled,
localStorageKnowledgeBase,

View file

@ -13,6 +13,7 @@ import type {
import { ActionConnectorProps } from '@kbn/triggers-actions-ui-plugin/public/types';
import { PRECONFIGURED_CONNECTOR } from './translations';
import { AIConnector } from './connector_selector';
// aligns with OpenAiProviderType from '@kbn/stack-connectors-plugin/common/openai/types'
export enum OpenAiProviderType {
@ -71,3 +72,9 @@ export const getConnectorTypeTitle = (
return actionType;
};
export const isElasticManagedLlmConnector = (
connector:
| { actionTypeId: AIConnector['actionTypeId']; isPreconfigured: AIConnector['isPreconfigured'] }
| undefined
) => connector?.actionTypeId === '.inference' && connector?.isPreconfigured;

View file

@ -9,13 +9,6 @@ import { waitFor, renderHook } from '@testing-library/react';
import { useLoadConnectors, Props } from '.';
import { mockConnectors } from '../../mock/connectors';
import { TestProviders } from '../../mock/test_providers/test_providers';
import { isInferenceEndpointExists } from '@kbn/inference-endpoint-ui-common';
const mockedIsInferenceEndpointExists = isInferenceEndpointExists as jest.Mock;
jest.mock('@kbn/inference-endpoint-ui-common', () => ({
isInferenceEndpointExists: jest.fn(),
}));
const mockConnectorsAndExtras = [
...mockConnectors,
@ -64,7 +57,6 @@ const defaultProps = { http, toasts } as unknown as Props;
describe('useLoadConnectors', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedIsInferenceEndpointExists.mockResolvedValue(true);
});
it('should call api to load action types', async () => {
renderHook(() => useLoadConnectors(defaultProps), {
@ -100,11 +92,6 @@ describe('useLoadConnectors', () => {
await waitFor(() => {
expect(result.current.data).toStrictEqual(
mockConnectors
.filter(
(c) =>
c.actionTypeId !== '.inference' ||
(c.actionTypeId === '.inference' && c.isPreconfigured)
)
// @ts-ignore ts does not like config, but we define it in the mock data
.map((c) => ({ ...c, referencedByCount: 0, apiProvider: c?.config?.apiProvider }))
);

View file

@ -4,17 +4,15 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect } from 'react';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import type { ServerError } from '@kbn/cases-plugin/public/types';
import { loadAllActions as loadConnectors } from '@kbn/triggers-actions-ui-plugin/public/common/constants';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import { HttpSetup } from '@kbn/core-http-browser';
import { isInferenceEndpointExists } from '@kbn/inference-endpoint-ui-common';
import { IToasts } from '@kbn/core-notifications-browser';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants';
import { ActionConnector } from '@kbn/cases-plugin/public/containers/configure/types';
import { AIConnector } from '../connector_selector';
import * as i18n from '../translations';
@ -37,29 +35,19 @@ export const useLoadConnectors = ({
toasts,
inferenceEnabled = false,
}: Props): UseQueryResult<AIConnector[], IHttpFetchError> => {
if (inferenceEnabled) {
useEffect(() => {
if (inferenceEnabled && !actionTypes.includes('.inference')) {
actionTypes.push('.inference');
}
}, [inferenceEnabled]);
return useQuery(
QUERY_KEY,
async () => {
const queryResult = await loadConnectors({ http });
return queryResult.reduce(
async (acc: Promise<AIConnector[]>, connector) => [
...(await acc),
...(!connector.isMissingSecrets &&
actionTypes.includes(connector.actionTypeId) &&
// only include preconfigured .inference connectors
(connector.actionTypeId !== '.inference' ||
(connector.actionTypeId === '.inference' &&
connector.isPreconfigured &&
(await isInferenceEndpointExists(
http,
(connector as ActionConnector)?.config?.inferenceId
))))
? [
{
const connectors = await loadConnectors({ http });
return connectors.reduce((acc: AIConnector[], connector) => {
if (!connector.isMissingSecrets && actionTypes.includes(connector.actionTypeId)) {
acc.push({
...connector,
apiProvider:
!connector.isPreconfigured &&
@ -67,12 +55,10 @@ export const useLoadConnectors = ({
connector?.config?.apiProvider
? (connector?.config?.apiProvider as OpenAiProviderType)
: undefined,
},
]
: []),
],
Promise.resolve([])
);
});
}
return acc;
}, []);
},
{
retry: false,

View file

@ -16,6 +16,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { UserProfileService } from '@kbn/core/public';
import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks';
import { of } from 'rxjs';
import { docLinksServiceMock } from '@kbn/core/public/mocks';
import { AssistantProvider, AssistantProviderProps } from '../../assistant_context';
import { AssistantAvailability } from '../../assistant_context/types';
@ -53,6 +54,7 @@ export const TestProvidersComponent: React.FC<Props> = ({
const mockGetComments = jest.fn(() => []);
const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' });
const mockNavigateToApp = jest.fn();
const mockGetUrlForApp = jest.fn();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@ -78,11 +80,9 @@ export const TestProvidersComponent: React.FC<Props> = ({
assistantAvailability={assistantAvailability}
augmentMessageCodeBlocks={jest.fn().mockReturnValue([])}
basePath={'https://localhost:5601/kbn'}
docLinks={{
ELASTIC_WEBSITE_URL: 'https://www.elastic.co/',
DOC_LINK_VERSION: 'current',
}}
docLinks={docLinksServiceMock.createStartContract()}
getComments={mockGetComments}
getUrlForApp={mockGetUrlForApp}
http={mockHttp}
baseConversations={{}}
navigateToApp={mockNavigateToApp}

View file

@ -14,8 +14,8 @@ import {
conversationWithContentReferences,
welcomeConvo,
} from '../../mock/conversation';
import { I18nProvider } from '@kbn/i18n-react';
import { TourState } from '../knowledge_base';
import { TestProviders } from '../../mock/test_providers/test_providers';
jest.mock('react-use/lib/useLocalStorage', () => jest.fn());
@ -32,12 +32,12 @@ Object.defineProperty(window, 'localStorage', {
});
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<I18nProvider>
<TestProviders>
<div>
<div id="aiAssistantSettingsMenuContainer" />
{children}
</div>
</I18nProvider>
</TestProviders>
);
describe('AnonymizedValuesAndCitationsTour', () => {
@ -117,8 +117,8 @@ describe('AnonymizedValuesAndCitationsTour', () => {
).not.toBeInTheDocument();
});
it('does not render tour if the knowledge base tour is on step 1', async () => {
(useLocalStorage as jest.Mock).mockReturnValue([false, jest.fn()]);
it('does not render tour if the knowledge base tour or EIS tour is on step 1', async () => {
(useLocalStorage as jest.Mock).mockReturnValueOnce([false, jest.fn()]);
mockGetItem.mockReturnValue(
JSON.stringify({

View file

@ -17,6 +17,8 @@ import {
conversationContainsAnonymizedValues,
conversationContainsContentReferences,
} from '../../assistant/conversations/utils';
import { useTourStorageKey } from '../common/hooks/use_tour_storage_key';
import { EISUsageCostTourState, tourDefaultConfig } from '../elastic_llm/step_config';
interface Props {
conversation: Conversation | undefined;
@ -25,8 +27,8 @@ interface Props {
// Throttles reads from local storage to 1 every 5 seconds.
// This is to prevent excessive reading from local storage. It acts
// as a cache.
const getKnowledgeBaseTourStateThrottled = throttle(() => {
const value = localStorage.getItem(NEW_FEATURES_TOUR_STORAGE_KEYS.KNOWLEDGE_BASE);
const getKnowledgeBaseTourStateThrottled = throttle((tourStorageKey) => {
const value = localStorage.getItem(tourStorageKey);
if (value) {
return JSON.parse(value) as TourState;
}
@ -34,9 +36,19 @@ const getKnowledgeBaseTourStateThrottled = throttle(() => {
}, 5000);
export const AnonymizedValuesAndCitationsTour: React.FC<Props> = ({ conversation }) => {
const [tourCompleted, setTourCompleted] = useLocalStorage<boolean>(
NEW_FEATURES_TOUR_STORAGE_KEYS.ANONYMIZED_VALUES_AND_CITATIONS,
false
const kbTourStorageKey = useTourStorageKey(NEW_FEATURES_TOUR_STORAGE_KEYS.KNOWLEDGE_BASE);
const tourStorageKey = useTourStorageKey(
NEW_FEATURES_TOUR_STORAGE_KEYS.ANONYMIZED_VALUES_AND_CITATIONS
);
const [tourCompleted, setTourCompleted] = useLocalStorage<boolean>(tourStorageKey, false);
const eisLLMUsageCostTourStorageKey = useTourStorageKey(
NEW_FEATURES_TOUR_STORAGE_KEYS.ELASTIC_LLM_USAGE_ASSISTANT_HEADER
);
const [eisLLMUsageCostTourState] = useLocalStorage<EISUsageCostTourState>(
eisLLMUsageCostTourStorageKey,
tourDefaultConfig
);
const [showTour, setShowTour] = useState(false);
@ -46,10 +58,13 @@ export const AnonymizedValuesAndCitationsTour: React.FC<Props> = ({ conversation
return;
}
const knowledgeBaseTourState = getKnowledgeBaseTourStateThrottled();
const knowledgeBaseTourState = getKnowledgeBaseTourStateThrottled(kbTourStorageKey);
// If the knowledge base tour is active on this page (i.e. step 1), don't show this tour to prevent overlap.
if (knowledgeBaseTourState?.isTourActive && knowledgeBaseTourState?.currentTourStep === 1) {
// If the knowledge base tour or EIS tour is active on this page (i.e. step 1), don't show this tour to prevent overlap.
if (
(knowledgeBaseTourState?.isTourActive && knowledgeBaseTourState?.currentTourStep === 1) ||
(eisLLMUsageCostTourState?.isTourActive && eisLLMUsageCostTourState?.currentTourStep === 1)
) {
return;
}
@ -65,7 +80,15 @@ export const AnonymizedValuesAndCitationsTour: React.FC<Props> = ({ conversation
clearTimeout(timer);
};
}
}, [conversation, tourCompleted, showTour]);
}, [
conversation,
tourCompleted,
showTour,
kbTourStorageKey,
eisLLMUsageCostTourStorageKey,
eisLLMUsageCostTourState?.isTourActive,
eisLLMUsageCostTourState?.currentTourStep,
]);
const finishTour = useCallback(() => {
setTourCompleted(true);

View file

@ -0,0 +1,77 @@
/*
* 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 { renderHook } from '@testing-library/react';
import { TestProviders } from '../../../mock/test_providers/test_providers';
import { NEW_FEATURES_TOUR_STORAGE_KEYS, NEW_TOUR_FEATURES_TOUR_STORAGE_KEYS } from '../../const';
import { useTourStorageKey } from './use_tour_storage_key';
const featureNumber = Object.keys(NEW_FEATURES_TOUR_STORAGE_KEYS).length;
const testFeatures = [
{
featureKey: NEW_FEATURES_TOUR_STORAGE_KEYS.KNOWLEDGE_BASE,
expectedStorageKey:
NEW_TOUR_FEATURES_TOUR_STORAGE_KEYS[NEW_FEATURES_TOUR_STORAGE_KEYS.KNOWLEDGE_BASE],
},
{
featureKey: NEW_FEATURES_TOUR_STORAGE_KEYS.ANONYMIZED_VALUES_AND_CITATIONS,
expectedStorageKey:
NEW_TOUR_FEATURES_TOUR_STORAGE_KEYS[
NEW_FEATURES_TOUR_STORAGE_KEYS.ANONYMIZED_VALUES_AND_CITATIONS
],
},
{
featureKey: NEW_FEATURES_TOUR_STORAGE_KEYS.ELASTIC_LLM_USAGE_ATTACK_DISCOVERY,
expectedStorageKey:
NEW_TOUR_FEATURES_TOUR_STORAGE_KEYS[
NEW_FEATURES_TOUR_STORAGE_KEYS.ELASTIC_LLM_USAGE_ATTACK_DISCOVERY
],
},
{
featureKey: NEW_FEATURES_TOUR_STORAGE_KEYS.ELASTIC_LLM_USAGE_ATTACK_DISCOVERY_FLYOUT,
expectedStorageKey:
NEW_TOUR_FEATURES_TOUR_STORAGE_KEYS[
NEW_FEATURES_TOUR_STORAGE_KEYS.ELASTIC_LLM_USAGE_ATTACK_DISCOVERY_FLYOUT
],
},
{
featureKey: NEW_FEATURES_TOUR_STORAGE_KEYS.ELASTIC_LLM_USAGE_ASSISTANT_HEADER,
expectedStorageKey:
NEW_TOUR_FEATURES_TOUR_STORAGE_KEYS[
NEW_FEATURES_TOUR_STORAGE_KEYS.ELASTIC_LLM_USAGE_ASSISTANT_HEADER
],
},
{
featureKey: NEW_FEATURES_TOUR_STORAGE_KEYS.CONVERSATION_CONNECTOR_ELASTIC_LLM,
expectedStorageKey:
NEW_TOUR_FEATURES_TOUR_STORAGE_KEYS[
NEW_FEATURES_TOUR_STORAGE_KEYS.CONVERSATION_CONNECTOR_ELASTIC_LLM
],
},
];
describe('useTourStorageKey', () => {
test('testFeatures length should match the number of features', () => {
expect(testFeatures.length).toBe(featureNumber);
});
test.each(testFeatures)(
'should return the correct storage key with spaceId for feature $featureKey',
({
featureKey,
expectedStorageKey,
}: {
featureKey: NEW_FEATURES_TOUR_STORAGE_KEYS;
expectedStorageKey: string;
}) => {
const { result } = renderHook(() => useTourStorageKey(featureKey), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
expect(result.current).toBe(`${expectedStorageKey}`);
}
);
});

View file

@ -0,0 +1,20 @@
/*
* 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 {
NEW_TOUR_FEATURES_TOUR_STORAGE_KEYS,
type NEW_FEATURES_TOUR_STORAGE_KEYS,
} from '../../const';
/**
*
* @param featureKey The key of the feature for storage key
* @returns A unique storage key for the feature based on the space ID
*/
export const useTourStorageKey = (featureKey: NEW_FEATURES_TOUR_STORAGE_KEYS) => {
return `${NEW_TOUR_FEATURES_TOUR_STORAGE_KEYS[featureKey]}`;
};

View file

@ -5,8 +5,25 @@
* 2.0.
*/
export const NEW_FEATURES_TOUR_STORAGE_KEYS = {
export enum NEW_FEATURES_TOUR_STORAGE_KEYS {
KNOWLEDGE_BASE = 'KNOWLEDGE_BASE',
ANONYMIZED_VALUES_AND_CITATIONS = 'ANONYMIZED_VALUES_AND_CITATIONS',
ELASTIC_LLM_USAGE_ATTACK_DISCOVERY = 'ELASTIC_LLM_USAGE_ATTACK_DISCOVERY',
ELASTIC_LLM_USAGE_ATTACK_DISCOVERY_FLYOUT = 'ELASTIC_LLM_USAGE_ATTACK_DISCOVERY_FLYOUT',
ELASTIC_LLM_USAGE_ASSISTANT_HEADER = 'ELASTIC_LLM_USAGE_ASSISTANT_HEADER',
CONVERSATION_CONNECTOR_ELASTIC_LLM = 'CONVERSATION_CONNECTOR_ELASTIC_LLM',
}
export const NEW_TOUR_FEATURES_TOUR_STORAGE_KEYS: Record<NEW_FEATURES_TOUR_STORAGE_KEYS, string> = {
KNOWLEDGE_BASE: 'elasticAssistant.knowledgeBase.newFeaturesTour.v8.16',
ANONYMIZED_VALUES_AND_CITATIONS:
'elasticAssistant.anonymizedValuesAndCitationsTourCompleted.v8.18',
ELASTIC_LLM_USAGE_ATTACK_DISCOVERY:
'elasticAssistant.elasticLLM.costAwarenessTour.attackDiscovery.v8.19',
ELASTIC_LLM_USAGE_ATTACK_DISCOVERY_FLYOUT:
'elasticAssistant.elasticLLM.costAwarenessTour.attackDiscoveryFlyout.v8.19',
ELASTIC_LLM_USAGE_ASSISTANT_HEADER:
'elasticAssistant.elasticLLM.costAwarenessTour.assistantHeader.v8.19',
CONVERSATION_CONNECTOR_ELASTIC_LLM:
'elasticAssistant.elasticLLM.conversation.costAwarenessTour.v8.19',
};

View file

@ -0,0 +1,130 @@
/*
* 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 { render, waitFor } from '@testing-library/react';
import { ElasticLLMCostAwarenessTour } from '.';
import React from 'react';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { I18nProvider } from '@kbn/i18n-react';
import { act } from 'react-dom/test-utils';
import { useAssistantContext } from '../../assistant_context';
import { useLoadConnectors } from '../../connectorland/use_load_connectors';
import { NEW_FEATURES_TOUR_STORAGE_KEYS } from '../const';
import { docLinksServiceMock } from '@kbn/core/public/mocks';
jest.mock('react-use/lib/useLocalStorage', () => jest.fn());
jest.mock('../common/hooks/use_tour_storage_key');
jest.mock('../../assistant_context');
jest.mock('../../connectorland/use_load_connectors', () => ({
useLoadConnectors: jest.fn(),
}));
jest.mock('lodash', () => ({
...jest.requireActual('lodash'),
throttle: jest.fn().mockImplementation((fn) => fn),
}));
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<I18nProvider>{children}</I18nProvider>
);
describe('ElasticLLMCostAwarenessTour', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
(useAssistantContext as jest.Mock).mockReturnValue({
inferenceEnabled: true,
docLinks: docLinksServiceMock.createStartContract(),
});
(useLoadConnectors as jest.Mock).mockReturnValue({
data: [
{
id: '.inference',
actionTypeId: '.inference',
isPreconfigured: true,
},
],
});
});
it('renders tour when there are content references', async () => {
(useLocalStorage as jest.Mock).mockReturnValue([
{
currentTourStep: 1,
isTourActive: true,
},
jest.fn(),
]);
const { queryByTestId } = render(
<ElasticLLMCostAwarenessTour
isDisabled={false}
selectedConnectorId=".inference"
storageKey={NEW_FEATURES_TOUR_STORAGE_KEYS.ELASTIC_LLM_USAGE_ASSISTANT_HEADER}
>
<div data-test-subj="target" />
</ElasticLLMCostAwarenessTour>,
{
wrapper: Wrapper,
}
);
act(() => {
jest.runAllTimers();
});
await waitFor(() => {
expect(queryByTestId('elasticLLMTourStepPanel')).toBeInTheDocument();
});
});
it('does not render tour if it has already been shown', async () => {
(useLocalStorage as jest.Mock).mockReturnValue([true, jest.fn()]);
const { queryByTestId } = render(
<ElasticLLMCostAwarenessTour
isDisabled={false}
selectedConnectorId=".inference"
storageKey={NEW_FEATURES_TOUR_STORAGE_KEYS.ELASTIC_LLM_USAGE_ASSISTANT_HEADER}
/>,
{
wrapper: Wrapper,
}
);
act(() => {
jest.runAllTimers();
});
await waitFor(() => {
expect(queryByTestId('elasticLLMTourStepPanel')).not.toBeInTheDocument();
});
});
it('does not render tour if disabled', async () => {
(useLocalStorage as jest.Mock).mockReturnValue([false, jest.fn()]);
const { queryByTestId } = render(
<ElasticLLMCostAwarenessTour
isDisabled={true}
selectedConnectorId=".inference"
storageKey={NEW_FEATURES_TOUR_STORAGE_KEYS.ELASTIC_LLM_USAGE_ASSISTANT_HEADER}
/>,
{
wrapper: Wrapper,
}
);
act(() => {
jest.runAllTimers();
});
await waitFor(() => {
expect(queryByTestId('elasticLLMTourStepPanel')).not.toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,169 @@
/*
* 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 {
EuiButtonEmpty,
EuiText,
EuiTitle,
EuiTourStep,
EuiTourStepProps,
useEuiTheme,
} from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { css } from '@emotion/react';
import { NEW_FEATURES_TOUR_STORAGE_KEYS } from '../const';
import { EISUsageCostTourState, elasticLLMTourStep1, tourDefaultConfig } from './step_config';
import { ELASTIC_LLM_TOUR_FINISH_TOUR } from './translations';
import { useAssistantContext } from '../../assistant_context';
import { useLoadConnectors } from '../../connectorland/use_load_connectors';
import { isElasticManagedLlmConnector } from '../../connectorland/helpers';
import { useTourStorageKey } from '../common/hooks/use_tour_storage_key';
interface Props {
children?: EuiTourStepProps['children'];
isDisabled: boolean;
selectedConnectorId: string | undefined;
storageKey: NEW_FEATURES_TOUR_STORAGE_KEYS;
zIndex?: number;
wrapper?: boolean;
}
const ElasticLLMCostAwarenessTourComponent: React.FC<Props> = ({
children,
isDisabled,
selectedConnectorId,
storageKey,
zIndex,
wrapper = true, // Whether to wrap the children in a div with padding
}) => {
const { http, inferenceEnabled } = useAssistantContext();
const { euiTheme } = useEuiTheme();
const tourStorageKey = useTourStorageKey(storageKey);
const [tourState, setTourState] = useLocalStorage<EISUsageCostTourState>(
tourStorageKey,
tourDefaultConfig
);
const [showTour, setShowTour] = useState(!tourState?.isTourActive && !isDisabled);
const [isTimerExhausted, setIsTimerExhausted] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setIsTimerExhausted(true);
}, 1000);
return () => clearTimeout(timer);
}, []);
const finishTour = useCallback(() => {
setTourState((prev = tourDefaultConfig) => ({
...prev,
isTourActive: false,
}));
setShowTour(false);
}, [setTourState, setShowTour]);
const { data: aiConnectors } = useLoadConnectors({
http,
inferenceEnabled,
});
const isElasticLLMConnectorSelected = useMemo(
() =>
aiConnectors?.some((c) => isElasticManagedLlmConnector(c) && c.id === selectedConnectorId),
[aiConnectors, selectedConnectorId]
);
useEffect(() => {
if (
!inferenceEnabled ||
isDisabled ||
!tourState?.isTourActive ||
aiConnectors?.length === 0 ||
!isElasticLLMConnectorSelected
) {
setShowTour(false);
} else {
setShowTour(true);
}
}, [
tourState,
isDisabled,
children,
showTour,
inferenceEnabled,
aiConnectors?.length,
isElasticLLMConnectorSelected,
setTourState,
]);
if (!children) {
return null;
}
if (!showTour) {
return children;
}
return (
<EuiTourStep
id="elasticLLMTourStep"
css={css`
display: flex;
`}
content={<EuiText size="m">{elasticLLMTourStep1.content}</EuiText>}
// Open the tour step after flyout is open
isStepOpen={isTimerExhausted}
maxWidth={384}
onFinish={finishTour}
panelProps={{
'data-test-subj': `elasticLLMTourStepPanel`,
}}
step={1}
stepsTotal={1}
title={
<EuiTitle size="xs">
<span>{elasticLLMTourStep1.title}</span>
</EuiTitle>
}
subtitle={
<EuiTitle
size="xxs"
css={css`
color: ${euiTheme.colors.textSubdued};
`}
>
<span>{elasticLLMTourStep1.subTitle}</span>
</EuiTitle>
}
footerAction={[
<EuiButtonEmpty size="s" color="text" flush="right" onClick={finishTour}>
{ELASTIC_LLM_TOUR_FINISH_TOUR}
</EuiButtonEmpty>,
]}
panelStyle={{
fontSize: euiTheme.size.m,
}}
zIndex={zIndex}
>
{wrapper ? (
<div
style={{
paddingLeft: euiTheme.size.m,
}}
>
{children}
</div>
) : (
children
)}
</EuiTourStep>
);
};
export const ElasticLLMCostAwarenessTour = React.memo(ElasticLLMCostAwarenessTourComponent);

View file

@ -0,0 +1,48 @@
/*
* 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 { FormattedMessage } from '@kbn/i18n-react';
import { EuiLink } from '@elastic/eui';
import * as i18n from './translations';
import { useAssistantContext } from '../../assistant_context';
export const CostAwareness = () => {
const {
docLinks: {
links: {
observability: {
elasticManagedLlm: ELASTIC_LLM_LINK,
elasticManagedLlmUsageCost: ELASTIC_LLM_USAGE_COST_LINK,
},
},
},
} = useAssistantContext();
return (
<FormattedMessage
id="xpack.elasticAssistant.elasticLLM.tour.content"
defaultMessage="This new default LLM connector is optimized for Elastic AI features ({usageCost}). You can continue using existing LLM connectors if you prefer. {learnMore}"
values={{
usageCost: (
<EuiLink
href={ELASTIC_LLM_USAGE_COST_LINK}
target="_blank"
rel="noopener noreferrer"
external
>
{i18n.ELASTIC_LLM_USAGE_COSTS}
</EuiLink>
),
learnMore: (
<EuiLink href={ELASTIC_LLM_LINK} target="_blank" rel="noopener noreferrer" external>
{i18n.ELASTIC_LLM_TOUR_LEARN_MORE}
</EuiLink>
),
}}
/>
);
};

View file

@ -0,0 +1,26 @@
/*
* 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 * as i18n from './translations';
import { CostAwareness } from './messages';
export interface EISUsageCostTourState {
currentTourStep: number;
isTourActive: boolean;
}
export const tourDefaultConfig: EISUsageCostTourState = {
currentTourStep: 1,
isTourActive: true,
};
export const elasticLLMTourStep1 = {
title: i18n.ELASTIC_LLM_TOUR_TITLE,
subTitle: i18n.ELASTIC_LLM_TOUR_SUBTITLE,
content: <CostAwareness />,
};

View file

@ -0,0 +1,71 @@
/*
* 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 ELASTIC_LLM_TOUR_TITLE = i18n.translate(
'xpack.elasticAssistant.elasticLLM.tour.title',
{
defaultMessage: 'Elastic Managed LLM connector now available',
}
);
export const ELASTIC_LLM_TOUR_SUBTITLE = i18n.translate(
'xpack.elasticAssistant.elasticLLM.tour.subtitle',
{
defaultMessage: 'New AI feature!',
}
);
export const ELASTIC_LLM_TOUR_LEARN_MORE = i18n.translate(
'xpack.elasticAssistant.elasticLLM.tour.learnMore',
{
defaultMessage: 'Learn more',
}
);
export const ELASTIC_LLM_TOUR_FINISH_TOUR = i18n.translate(
'xpack.elasticAssistant.elasticLLM.tour.finishTour',
{
defaultMessage: 'Ok',
}
);
export const ELASTIC_LLM_AS_DEFAULT_CONNECTOR = i18n.translate(
'xpack.elasticAssistant.elasticLLM.tour.asDefaultConnector',
{
defaultMessage: 'Make Elastic Managed LLM the default connector',
}
);
export const ELASTIC_LLM_AI_FEATURES = i18n.translate(
'xpack.elasticAssistant.elasticLLM.tour.aiFeature',
{
defaultMessage: 'Elastic AI features',
}
);
export const ELASTIC_LLM_USAGE_COSTS = i18n.translate(
'xpack.elasticAssistant.elasticLLM.tour.usageCost',
{
defaultMessage: 'additional costs incur',
}
);
export const ELASTIC_LLM_THIRD_PARTY = i18n.translate(
'xpack.elasticAssistant.elasticLLM.tour.thirdParty',
{
defaultMessage: 'connect to a third party LLM provider',
}
);
export const ELASTIC_LLM_TOUR_PERFORMANCE = i18n.translate(
'xpack.elasticAssistant.elasticLLM.tour.performance',
{
defaultMessage: 'performance',
}
);

View file

@ -19,6 +19,7 @@ import { VideoToast } from './video_toast';
import { NEW_FEATURES_TOUR_STORAGE_KEYS } from '../const';
import { knowledgeBaseTourStepOne, tourConfig } from './step_config';
import * as i18n from './translations';
import { useTourStorageKey } from '../common/hooks/use_tour_storage_key';
export interface TourState {
currentTourStep: number;
@ -30,10 +31,8 @@ const KnowledgeBaseTourComp: React.FC<{
}> = ({ children, isKbSettingsPage = false }) => {
const { navigateToApp } = useAssistantContext();
const [tourState, setTourState] = useLocalStorage<TourState>(
NEW_FEATURES_TOUR_STORAGE_KEYS.KNOWLEDGE_BASE,
tourConfig
);
const tourStorageKey = useTourStorageKey(NEW_FEATURES_TOUR_STORAGE_KEYS.KNOWLEDGE_BASE);
const [tourState, setTourState] = useLocalStorage<TourState>(tourStorageKey, tourConfig);
const advanceToVideoStep = useCallback(
() =>

View file

@ -40,7 +40,6 @@
"@kbn/product-doc-base-plugin",
"@kbn/spaces-plugin",
"@kbn/shared-ux-router",
"@kbn/inference-endpoint-ui-common",
"@kbn/core-user-profile-browser-mocks",
]
}

View file

@ -50307,10 +50307,8 @@
"xpack.triggersActionsUI.sections.deprecatedTitleMessage": "(déclassé)",
"xpack.triggersActionsUI.sections.editConnectorForm.actionTypeDescription": "{connectorTypeDesc}",
"xpack.triggersActionsUI.sections.editConnectorForm.closeButtonLabel": "Fermer",
"xpack.triggersActionsUI.sections.editConnectorForm.descriptionText": "Ce connecteur est en lecture seule.",
"xpack.triggersActionsUI.sections.editConnectorForm.flyoutPreconfiguredTitle": "Modifier un connecteur",
"xpack.triggersActionsUI.sections.editConnectorForm.headerFormLabel": "Le formulaire comprend des erreurs",
"xpack.triggersActionsUI.sections.editConnectorForm.preconfiguredHelpLabel": "En savoir plus sur les connecteurs préconfigurés.",
"xpack.triggersActionsUI.sections.editConnectorForm.saveButtonLabel": "Enregistrer",
"xpack.triggersActionsUI.sections.editConnectorForm.saveButtonSavedLabel": "Modifications enregistrées",
"xpack.triggersActionsUI.sections.editConnectorForm.tabText": "Configuration",

View file

@ -50267,10 +50267,8 @@
"xpack.triggersActionsUI.sections.deprecatedTitleMessage": "(非推奨)",
"xpack.triggersActionsUI.sections.editConnectorForm.actionTypeDescription": "{connectorTypeDesc}",
"xpack.triggersActionsUI.sections.editConnectorForm.closeButtonLabel": "閉じる",
"xpack.triggersActionsUI.sections.editConnectorForm.descriptionText": "このコネクターは読み取り専用です。",
"xpack.triggersActionsUI.sections.editConnectorForm.flyoutPreconfiguredTitle": "コネクターを編集",
"xpack.triggersActionsUI.sections.editConnectorForm.headerFormLabel": "フォームにエラーがあります",
"xpack.triggersActionsUI.sections.editConnectorForm.preconfiguredHelpLabel": "あらかじめ構成されたコネクターの詳細をご覧ください。",
"xpack.triggersActionsUI.sections.editConnectorForm.saveButtonLabel": "保存",
"xpack.triggersActionsUI.sections.editConnectorForm.saveButtonSavedLabel": "変更が保存されました",
"xpack.triggersActionsUI.sections.editConnectorForm.tabText": "構成",

View file

@ -50350,10 +50350,8 @@
"xpack.triggersActionsUI.sections.deprecatedTitleMessage": "(已过时)",
"xpack.triggersActionsUI.sections.editConnectorForm.actionTypeDescription": "{connectorTypeDesc}",
"xpack.triggersActionsUI.sections.editConnectorForm.closeButtonLabel": "关闭",
"xpack.triggersActionsUI.sections.editConnectorForm.descriptionText": "此连接器为只读。",
"xpack.triggersActionsUI.sections.editConnectorForm.flyoutPreconfiguredTitle": "编辑连接器",
"xpack.triggersActionsUI.sections.editConnectorForm.headerFormLabel": "表单中存在错误",
"xpack.triggersActionsUI.sections.editConnectorForm.preconfiguredHelpLabel": "详细了解预配置的连接器。",
"xpack.triggersActionsUI.sections.editConnectorForm.saveButtonLabel": "保存",
"xpack.triggersActionsUI.sections.editConnectorForm.saveButtonSavedLabel": "已保存更改",
"xpack.triggersActionsUI.sections.editConnectorForm.tabText": "配置",

View file

@ -21,8 +21,7 @@
"stackConnectors",
],
"requiredBundles": [
"kibanaUtils",
"esUiShared",
"kibanaUtils"
]
}
}

View file

@ -19,6 +19,13 @@ import {
EuiIcon,
useEuiTheme,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import {
ELASTIC_LLM_USAGE_COSTS,
ELASTIC_LLM_THIRD_PARTY,
ELASTIC_LLM_TOUR_PERFORMANCE,
} from '@kbn/elastic-assistant/impl/tour/elastic_llm/translations';
import { isElasticManagedLlmConnector } from '@kbn/elastic-assistant/impl/connectorland/helpers';
import {
AuthorizationWrapper,
MissingPrivilegesTooltip,
@ -37,9 +44,47 @@ import * as i18n from './translations';
*/
const AllowedActionTypeIds = ['.bedrock', '.gen-ai', '.gemini'];
const ElasticLLMNewIntegrationMessage = React.memo(() => {
const {
docLinks: {
links: {
securitySolution: { llmPerformanceMatrix: LLM_PERFORMANCE_LINK },
observability: { elasticManagedLlmUsageCost: ELASTIC_LLM_USAGE_COST_LINK },
},
},
} = useKibana().services;
return (
<FormattedMessage
id="xpack.integrationAssistant.steps.connector.esLLM.supportedModelsInfo"
defaultMessage="The Elastic Managed LLM connector is selected by default. Review its {usageCost} or {thirdParty}. Model {performance} varies by task."
values={{
usageCost: (
<EuiLink
href={ELASTIC_LLM_USAGE_COST_LINK}
target="_blank"
rel="noopener noreferrer"
external
>
{ELASTIC_LLM_USAGE_COSTS}
</EuiLink>
),
thirdParty: ELASTIC_LLM_THIRD_PARTY,
performance: (
<EuiLink href={LLM_PERFORMANCE_LINK} target="_blank" rel="noopener noreferrer" external>
{ELASTIC_LLM_TOUR_PERFORMANCE}
</EuiLink>
),
}}
/>
);
});
ElasticLLMNewIntegrationMessage.displayName = 'ElasticLLMNewIntegrationMessage';
interface ConnectorStepProps {
connector: AIConnector | undefined;
}
export const ConnectorStep = React.memo<ConnectorStepProps>(({ connector }) => {
const { euiTheme } = useEuiTheme();
const { http, notifications, triggersActionsUi } = useKibana().services;
@ -119,14 +164,22 @@ export const ConnectorStep = React.memo<ConnectorStepProps>(({ connector }) => {
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiText size="s" color="subdued">
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="flexStart">
<EuiFlexItem grow={false} css={{ margin: euiTheme.size.xxs }}>
<EuiIcon type="iInCircle" />
</EuiFlexItem>
<EuiFlexItem>{i18n.SUPPORTED_MODELS_INFO}</EuiFlexItem>
</EuiFlexGroup>
<EuiText size="xs" color="subdued">
<EuiIcon type="iInCircle" size="s" className="eui-alignTop" />
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="xs" color="subdued">
{inferenceEnabled && isElasticManagedLlmConnector(connector) ? (
<ElasticLLMNewIntegrationMessage />
) : (
i18n.SUPPORTED_MODELS_INFO
)}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</StepContentWrapper>
</EuiForm>
);

View file

@ -112,5 +112,6 @@ export function getConnectorType(): InferenceConnector {
},
actionConnectorFields: lazy(() => import('./connector')),
actionParamsFields: lazy(() => import('./params')),
actionReadOnlyExtraComponent: lazy(() => import('./usage_cost_message')),
};
}

View file

@ -0,0 +1,54 @@
/*
* 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 { EuiLink, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useKibana } from '@kbn/triggers-actions-ui-plugin/public/common';
export const UsageCostMessage: React.FC = () => {
const { docLinks } = useKibana().services;
return (
<EuiText size="xs">
<FormattedMessage
id="xpack.stackConnectors.inference.elasticLLM.descriptionText"
defaultMessage="Learn more about {elasticLLM} and its {usageCost}."
values={{
elasticLLM: (
<EuiLink
data-test-subj="elasticManagedLlmLink"
href={docLinks.links.observability.elasticManagedLlm}
target="_blank"
rel="noopener noreferrer"
external
>
<FormattedMessage
id="xpack.stackConnectors.inference.elasticLLM.link"
defaultMessage="Elastic Managed LLM connector"
/>
</EuiLink>
),
usageCost: (
<EuiLink
href={docLinks.links.observability.elasticManagedLlmUsageCost}
target="_blank"
rel="noopener noreferrer"
external
>
<FormattedMessage
id="xpack.stackConnectors.inference.elasticLLM.usageCost.link"
defaultMessage="usage cost"
/>
</EuiLink>
),
}}
/>
</EuiText>
);
};
// eslint-disable-next-line import/no-default-export
export { UsageCostMessage as default };

View file

@ -4,7 +4,6 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import {
SubActionConnectorType,

View file

@ -12,7 +12,7 @@ import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
import { AssistantAvailability, AssistantProvider } from '@kbn/elastic-assistant';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Theme } from '@elastic/charts';
import { coreMock } from '@kbn/core/public/mocks';
import { coreMock, docLinksServiceMock } from '@kbn/core/public/mocks';
import { UserProfileService } from '@kbn/core/public';
import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks';
import { of } from 'rxjs';
@ -67,6 +67,7 @@ const TestExternalProvidersComponent: React.FC<TestExternalProvidersProps> = ({
error: () => {},
},
});
const chrome = chromeServiceMock.createStartContract();
chrome.getChromeStyle$.mockReturnValue(of('classic'));
@ -80,10 +81,7 @@ const TestExternalProvidersComponent: React.FC<TestExternalProvidersProps> = ({
assistantAvailability={mockAssistantAvailability}
augmentMessageCodeBlocks={jest.fn()}
basePath={'https://localhost:5601/kbn'}
docLinks={{
ELASTIC_WEBSITE_URL: 'https://www.elastic.co/',
DOC_LINK_VERSION: 'current',
}}
docLinks={docLinksServiceMock.createStartContract()}
getComments={mockGetComments}
http={mockHttp}
baseConversations={{}}
@ -93,6 +91,7 @@ const TestExternalProvidersComponent: React.FC<TestExternalProvidersProps> = ({
}}
currentAppId={'securitySolutionUI'}
userProfileService={jest.fn() as unknown as UserProfileService}
getUrlForApp={jest.fn()}
chrome={chrome}
>
{children}

View file

@ -136,12 +136,12 @@ export const createBasePrompts = async (notifications: NotificationsStart, http:
*/
export const AssistantProvider: FC<PropsWithChildren<unknown>> = ({ children }) => {
const {
application: { navigateToApp, currentAppId$ },
application: { navigateToApp, getUrlForApp, currentAppId$ },
http,
notifications,
storage,
triggersActionsUi: { actionTypeRegistry },
docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION },
docLinks,
userProfile,
chrome,
productDocBase,
@ -228,10 +228,11 @@ export const AssistantProvider: FC<PropsWithChildren<unknown>> = ({ children })
augmentMessageCodeBlocks={augmentMessageCodeBlocks}
assistantAvailability={assistantAvailability}
assistantTelemetry={assistantTelemetry}
docLinks={{ ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }}
docLinks={docLinks}
basePath={basePath}
basePromptContexts={Object.values(PROMPT_CONTEXTS)}
baseConversations={baseConversations}
getUrlForApp={getUrlForApp}
getComments={getComments}
http={http}
inferenceEnabled={inferenceEnabled}

View file

@ -35,6 +35,9 @@ jest.mock('@kbn/elastic-assistant/impl/assistant/use_conversation', () => ({
jest.mock('../../common/lib/kibana', () => ({
useKibana: jest.fn(),
}));
jest.mock('../../common/hooks/use_space_id', () => ({
useSpaceId: jest.fn().mockReturnValue('default'),
}));
const useAssistantContextMock = useAssistantContext as jest.Mock;
const useFetchCurrentUserConversationsMock = useFetchCurrentUserConversations as jest.Mock;

View file

@ -21,6 +21,7 @@ import type { FetchConversationsResponse } from '@kbn/elastic-assistant/impl/ass
import { SECURITY_AI_SETTINGS } from '@kbn/elastic-assistant/impl/assistant/settings/translations';
import { CONVERSATIONS_TAB } from '@kbn/elastic-assistant/impl/assistant/settings/const';
import type { SettingsTabs } from '@kbn/elastic-assistant/impl/assistant/settings/types';
import { useKibana } from '../../common/lib/kibana';
const defaultSelectedConversationId = WELCOME_CONVERSATION_TITLE;

View file

@ -27,10 +27,13 @@ const defaultProps = {
onConnectorIdSelected: jest.fn(),
openFlyout: jest.fn(),
setLocalStorageAttackDiscoveryMaxAlerts: jest.fn(),
showFlyout: false,
};
describe('Actions', () => {
beforeEach(() => {
jest.clearAllMocks();
(useAssistantAvailability as jest.Mock).mockReturnValue({
hasAssistantPrivilege: true,
isAssistantEnabled: true,

View file

@ -15,14 +15,15 @@ import {
useEuiTheme,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { ConnectorSelectorInline } from '@kbn/elastic-assistant';
import type { AttackDiscoveryStats } from '@kbn/elastic-assistant-common';
import { ConnectorSelectorInline, useAssistantContext } from '@kbn/elastic-assistant';
import { type AttackDiscoveryStats } from '@kbn/elastic-assistant-common';
import { noop } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ElasticLLMCostAwarenessTour } from '@kbn/elastic-assistant/impl/tour/elastic_llm';
import { NEW_FEATURES_TOUR_STORAGE_KEYS } from '@kbn/elastic-assistant/impl/tour/const';
import { StatusBell } from './status_bell';
import * as i18n from './translations';
interface Props {
connectorId: string | undefined;
connectorsAreConfigured: boolean;
@ -35,6 +36,7 @@ interface Props {
openFlyout: () => void;
setLocalStorageAttackDiscoveryMaxAlerts: React.Dispatch<React.SetStateAction<string | undefined>>;
stats: AttackDiscoveryStats | null;
showFlyout: boolean;
}
const HeaderComponent: React.FC<Props> = ({
@ -49,11 +51,25 @@ const HeaderComponent: React.FC<Props> = ({
openFlyout,
setLocalStorageAttackDiscoveryMaxAlerts,
stats,
showFlyout,
}) => {
const { euiTheme } = useEuiTheme();
const disabled = connectorId == null;
const [didCancel, setDidCancel] = useState(false);
const { inferenceEnabled } = useAssistantContext();
const [isEISCostTourDisabled, setIsEISCostTourDisabled] = useState<boolean>(
!connectorsAreConfigured || !inferenceEnabled || showFlyout
);
useEffect(() => {
if (!connectorsAreConfigured || !inferenceEnabled || showFlyout) {
setIsEISCostTourDisabled(true);
} else {
setIsEISCostTourDisabled(false);
}
}, [connectorsAreConfigured, inferenceEnabled, isEISCostTourDisabled, showFlyout]);
const handleCancel = useCallback(() => {
setDidCancel(true);
@ -99,6 +115,12 @@ const HeaderComponent: React.FC<Props> = ({
margin-right: ${euiTheme.size.s};
`}
grow={false}
>
<ElasticLLMCostAwarenessTour
isDisabled={isEISCostTourDisabled}
selectedConnectorId={connectorId}
zIndex={999} // Should lower than the flyout
storageKey={NEW_FEATURES_TOUR_STORAGE_KEYS.ELASTIC_LLM_USAGE_ATTACK_DISCOVERY}
>
<ConnectorSelectorInline
onConnectorSelected={noop}
@ -106,6 +128,7 @@ const HeaderComponent: React.FC<Props> = ({
selectedConnectorId={connectorId}
stats={stats}
/>
</ElasticLLMCostAwarenessTour>
</EuiFlexItem>
)}

View file

@ -262,6 +262,7 @@ const AttackDiscoveryPageComponent: React.FC = () => {
openFlyout={openFlyout}
setLocalStorageAttackDiscoveryMaxAlerts={setLocalStorageAttackDiscoveryMaxAlerts}
stats={stats}
showFlyout={showFlyout}
/>
<EuiSpacer size="m" />
</HeaderPage>

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiTab, EuiTabs, EuiText, EuiSpacer } from '@elastic/eui';
import { EuiTab, EuiTabs, EuiText, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { FilterManager } from '@kbn/data-plugin/public';
import type { Filter, Query } from '@kbn/es-query';
import React, { useMemo, useState } from 'react';

View file

@ -13,6 +13,7 @@ import { AssistantProvider } from '@kbn/elastic-assistant';
import type { UserProfileService } from '@kbn/core/public';
import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks';
import { of } from 'rxjs';
import { docLinksServiceMock } from '@kbn/core/public/mocks';
import { BASE_SECURITY_CONVERSATIONS } from '../../assistant/content/conversations';
interface Props {
@ -52,11 +53,9 @@ export const MockAssistantProviderComponent: React.FC<Props> = ({
assistantAvailability={assistantAvailability ?? defaultAssistantAvailability}
augmentMessageCodeBlocks={jest.fn(() => [])}
basePath={'https://localhost:5601/kbn'}
docLinks={{
ELASTIC_WEBSITE_URL: 'https://www.elastic.co/',
DOC_LINK_VERSION: 'current',
}}
docLinks={docLinksServiceMock.createStartContract()}
getComments={jest.fn(() => [])}
getUrlForApp={jest.fn()}
http={mockHttp}
navigateToApp={mockNavigateToApp}
baseConversations={BASE_SECURITY_CONVERSATIONS}

View file

@ -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 { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
import { EuiLink } from '@elastic/eui';
import {
ELASTIC_LLM_AI_FEATURES,
ELASTIC_LLM_THIRD_PARTY,
ELASTIC_LLM_USAGE_COSTS,
} from '@kbn/elastic-assistant/impl/tour/elastic_llm/translations';
import { useKibana } from '../../../../../common/lib/kibana';
export const ElasticAIFeatureMessage = React.memo(() => {
const {
docLinks: {
links: {
securitySolution: { elasticAiFeatures: ELASTIC_AI_FEATURES_LINK },
observability: { elasticManagedLlmUsageCost: ELASTIC_LLM_USAGE_COST_LINK },
},
},
} = useKibana().services;
return (
<FormattedMessage
id="xpack.securitySolution.onboarding.assistantCard.elasticAIFeature.content"
defaultMessage="{elasticAiFeatures} require an LLM connector. You can use the Elastic Managed LLM connector, which is available by default, or {thirdParty}. Learn more about Elastic Managed LLM connector's {usageCost}."
values={{
elasticAiFeatures: (
<EuiLink
href={ELASTIC_AI_FEATURES_LINK}
target="_blank"
rel="noopener noreferrer"
external
>
{ELASTIC_LLM_AI_FEATURES}
</EuiLink>
),
usageCost: (
<EuiLink
href={ELASTIC_LLM_USAGE_COST_LINK}
target="_blank"
rel="noopener noreferrer"
external
>
{ELASTIC_LLM_USAGE_COSTS}
</EuiLink>
),
thirdParty: ELASTIC_LLM_THIRD_PARTY,
}}
/>
);
});
ElasticAIFeatureMessage.displayName = 'ElasticAIFeatureMessage';

View file

@ -12,8 +12,12 @@ import { useAssistantContext, type Conversation } from '@kbn/elastic-assistant';
import { useCurrentConversation } from '@kbn/elastic-assistant/impl/assistant/use_current_conversation';
import { useDataStreamApis } from '@kbn/elastic-assistant/impl/assistant/use_data_stream_apis';
import { getDefaultConnector } from '@kbn/elastic-assistant/impl/assistant/helpers';
import { getGenAiConfig } from '@kbn/elastic-assistant/impl/connectorland/helpers';
import {
getGenAiConfig,
isElasticManagedLlmConnector,
} from '@kbn/elastic-assistant/impl/connectorland/helpers';
import { useConversation } from '@kbn/elastic-assistant/impl/assistant/use_conversation';
import { CenteredLoadingSpinner } from '../../../../../common/components/centered_loading_spinner';
import { OnboardingCardId } from '../../../../constants';
import type { OnboardingCardComponent } from '../../../../types';
@ -27,6 +31,7 @@ import { CardCallOut } from '../common/card_callout';
import { CardSubduedText } from '../common/card_subdued_text';
import type { AIConnector } from '../common/connectors/types';
import type { AssistantCardMetadata } from './types';
import { ElasticAIFeatureMessage } from './ai_feature_message';
export const AssistantCard: OnboardingCardComponent<AssistantCardMetadata> = ({
isCardComplete,
@ -131,6 +136,11 @@ export const AssistantCard: OnboardingCardComponent<AssistantCardMetadata> = ({
[currentConversation, setApiConfig, onConversationChange, setSelectedConnectorId]
);
const isEISConnectorAvailable = useMemo(
() => connectors?.some((c) => isElasticManagedLlmConnector(c)) ?? false,
[connectors]
);
if (!checkCompleteMetadata) {
return (
<OnboardingCardContentPanel>
@ -149,7 +159,13 @@ export const AssistantCard: OnboardingCardComponent<AssistantCardMetadata> = ({
{canExecuteConnectors ? (
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<CardSubduedText size="s">{i18n.ASSISTANT_CARD_DESCRIPTION}</CardSubduedText>
<CardSubduedText size="s">
{isEISConnectorAvailable ? (
<ElasticAIFeatureMessage />
) : (
i18n.ASSISTANT_CARD_DESCRIPTION
)}
</CardSubduedText>
</EuiFlexItem>
<EuiFlexItem>
{isIntegrationsCardAvailable && !isIntegrationsCardComplete ? (

View file

@ -6,12 +6,13 @@
*/
import React, { useMemo, useEffect, useCallback } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiPanel, EuiText } from '@elastic/eui';
import { EuiCard, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { ConnectorSelector } from '@kbn/security-solution-connectors';
import {
getActionTypeTitle,
getGenAiConfig,
isElasticManagedLlmConnector,
} from '@kbn/elastic-assistant/impl/connectorland/helpers';
import { useKibana } from '../../../../../../common/lib/kibana/kibana_react';
import type { AIConnector } from './types';
@ -26,7 +27,7 @@ interface ConnectorSelectorPanelProps {
export const ConnectorSelectorPanel = React.memo<ConnectorSelectorPanelProps>(
({ connectors, selectedConnectorId, onConnectorSelected }) => {
const { actionTypeRegistry } = useKibana().services.triggersActionsUi;
const { euiTheme } = useEuiTheme();
const selectedConnector = useMemo(
() => connectors.find((connector) => connector.id === selectedConnectorId),
[connectors, selectedConnectorId]
@ -72,16 +73,47 @@ export const ConnectorSelectorPanel = React.memo<ConnectorSelectorPanelProps>(
[connectors, onConnectorSelected]
);
const betaBadgeProps = useMemo(() => {
if (!selectedConnector || !isElasticManagedLlmConnector(selectedConnector)) {
return;
}
return {
label: (
<span
data-test-subj="connectorSelectorPanelBetaBadge"
css={css`
font-size: ${euiTheme.size.s};
font-weight: ${euiTheme.font.weight.bold};
line-height: ${euiTheme.base * 1.25}px;
vertical-align: top;
`}
>
{i18n.PRECONFIGURED_CONNECTOR_LABEL}
</span>
),
title: i18n.PRECONFIGURED_CONNECTOR_LABEL,
color: 'subdued' as const,
css: css`
height: ${euiTheme.base * 1.25}px;
`,
};
}, [euiTheme, selectedConnector]);
return (
<EuiPanel hasShadow={false} hasBorder>
<EuiFlexGroup
<EuiCard
css={css`
height: 100%;
`}
hasBorder
betaBadgeProps={betaBadgeProps}
titleElement="p"
title={
<EuiFlexGroup
alignItems="center"
justifyContent="center"
direction="column"
gutterSize="s"
gutterSize={selectedConnector ? 'xl' : 'l'}
>
<EuiFlexItem grow={false} justifyContent="center">
<EuiText>{i18n.SELECTED_PROVIDER}</EuiText>
@ -90,7 +122,7 @@ export const ConnectorSelectorPanel = React.memo<ConnectorSelectorPanelProps>(
<EuiFlexGroup
alignItems="center"
css={css`
height: 32px;
height: ${euiTheme.size.xl};
`}
direction="column"
justifyContent="center"
@ -116,7 +148,8 @@ export const ConnectorSelectorPanel = React.memo<ConnectorSelectorPanelProps>(
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
}
/>
);
}
);

View file

@ -35,3 +35,10 @@ export const REQUIRED_PRIVILEGES_CONNECTORS_ALL = i18n.translate(
'xpack.securitySolution.onboarding.assistantCard.requiredPrivileges.connectorsAll',
{ defaultMessage: 'Management > Actions & Connectors: All' }
);
export const PRECONFIGURED_CONNECTOR_LABEL = i18n.translate(
'xpack.securitySolution.onboarding.assistantCard.preconfiguredConnectorLabel',
{
defaultMessage: 'Pre-configured',
}
);