diff --git a/src/platform/packages/shared/kbn-doc-links/src/get_doc_links.ts b/src/platform/packages/shared/kbn-doc-links/src/get_doc_links.ts index afa2d6f9ef186..a056cdb1ab4ab 100644 --- a/src/platform/packages/shared/kbn-doc-links/src/get_doc_links.ts +++ b/src/platform/packages/shared/kbn-doc-links/src/get_doc_links.ts @@ -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`, @@ -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`, diff --git a/src/platform/packages/shared/kbn-doc-links/src/types.ts b/src/platform/packages/shared/kbn-doc-links/src/types.ts index 738d93cba250b..0ca85aac045f1 100644 --- a/src/platform/packages/shared/kbn-doc-links/src/types.ts +++ b/src/platform/packages/shared/kbn-doc-links/src/types.ts @@ -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; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_conversation_banner/elastic_llm_callout.test.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_conversation_banner/elastic_llm_callout.test.tsx new file mode 100644 index 0000000000000..a02632fc602a6 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_conversation_banner/elastic_llm_callout.test.tsx @@ -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(, { + wrapper: ({ children }) => {children}, + }); + expect(queryByTestId('elasticLlmCallout')).not.toBeInTheDocument(); + }); + + it('should not render when tour is completed', () => { + (useLocalStorage as jest.Mock).mockReturnValue([true, jest.fn()]); + const { queryByTestId } = render( + + + , + { + wrapper: ({ children }) => {children}, + } + ); + + expect(queryByTestId('elasticLlmCallout')).not.toBeInTheDocument(); + }); + + it('should render links', () => { + const { queryByTestId } = render( + + + , + { wrapper: ({ children }) => {children} } + ); + 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( + + + , + { wrapper: ({ children }) => {children} } + ); + expect(queryByTestId('elasticLlmCallout')).not.toBeInTheDocument(); + + rerender(); + expect(queryByTestId('elasticLlmCallout')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_conversation_banner/elastic_llm_callout.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_conversation_banner/elastic_llm_callout.tsx new file mode 100644 index 0000000000000..ced17fe6c5e2b --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_conversation_banner/elastic_llm_callout.tsx @@ -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(tourStorageKey, false); + const [showCallOut, setShowCallOut] = useState(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 ( + +

+ + + + ), + customConnector: ( + + + + ), + }} + /> +

+
+ ); +}; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_conversation_banner/index.test.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_conversation_banner/index.test.tsx new file mode 100644 index 0000000000000..d9b20478dadc5 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_conversation_banner/index.test.tsx @@ -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: () =>
, +})); + +jest.mock('./elastic_llm_callout', () => ({ + ElasticLlmCallout: () =>
, +})); + +describe('AssistantConversationBanner', () => { + const setIsSettingsModalVisible = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders ConnectorMissingCallout when shouldShowMissingConnectorCallout is true', () => { + (useAssistantContext as jest.Mock).mockReturnValue({ inferenceEnabled: true }); + + render( + + ); + + 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( + + ); + + 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( + + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_conversation_banner/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_conversation_banner/index.tsx new file mode 100644 index 0000000000000..12b739d70de8e --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_conversation_banner/index.tsx @@ -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>; + 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 ( + 0} + isSettingsModalVisible={isSettingsModalVisible} + setIsSettingsModalVisible={setIsSettingsModalVisible} + /> + ); + } + + if (showEISCallout) { + return ; + } + + return null; + } +); + +AssistantConversationBanner.displayName = 'AssistantConversationBanner'; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx index 1134b79fa09b5..b3e666ba03dbd 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx @@ -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; @@ -166,12 +168,18 @@ export const AssistantHeader: React.FC = ({ - + storageKey={NEW_FEATURES_TOUR_STORAGE_KEYS.ELASTIC_LLM_USAGE_ASSISTANT_HEADER} + > + + = ({ `} banner={ !isDisabled && - showMissingConnectorCallout && isFetchedConnectors && ( - 0} + ) } diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/settings/settings_context_menu/settings_context_menu.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/settings/settings_context_menu/settings_context_menu.tsx index 919325a46881c..7c245ebd72aa2 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/settings/settings_context_menu/settings_context_menu.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/settings/settings_context_menu/settings_context_menu.tsx @@ -26,7 +26,6 @@ import { } from '@elastic/eui'; import { css } from '@emotion/react'; 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'; @@ -353,15 +352,13 @@ export const SettingsContextMenu: React.FC = React.memo( <> - - + } isOpen={isPopoverOpen} closePopover={closePopover} diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.tsx index f81faca3c2972..7ae556ef8d20d 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.tsx @@ -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'; @@ -57,6 +57,9 @@ type ShowAssistantOverlay = ({ promptContextId, conversationTitle, }: ShowAssistantOverlayProps) => void; + +type GetUrlForApp = ApplicationStart['getUrlForApp']; + export interface AssistantProviderProps { actionTypeRegistry: ActionTypeRegistryContract; alertsIndexPattern?: string; @@ -68,14 +71,15 @@ export interface AssistantProviderProps { ) => CodeBlockDetails[][]; basePath: string; basePromptContexts?: PromptContextTemplate[]; - docLinks: Omit; + docLinks: DocLinksStart; children: React.ReactNode; + getUrlForApp: GetUrlForApp; getComments: GetAssistantMessages; http: HttpSetup; inferenceEnabled?: boolean; baseConversations: Record; nameSpace?: string; - navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise; + navigateToApp: ApplicationStart['navigateToApp']; title?: string; toasts?: IToasts; currentAppId: string; @@ -101,16 +105,17 @@ export interface UseAssistantContext { currentConversation: Conversation, showAnonymizedValues: boolean ) => CodeBlockDetails[][]; - docLinks: Omit; + docLinks: DocLinksStart; basePath: string; baseConversations: Record; currentUserAvatar?: UserAvatar; getComments: GetAssistantMessages; + getUrlForApp: GetUrlForApp; http: HttpSetup; inferenceEnabled: boolean; knowledgeBase: KnowledgeBaseConfig; promptContexts: Record; - navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise; + navigateToApp: ApplicationStart['navigateToApp']; nameSpace: string; registerPromptContext: RegisterPromptContext; selectedSettingsTab: SettingsTabs | null; @@ -153,6 +158,7 @@ export const AssistantProvider: React.FC = ({ basePromptContexts = [], children, getComments, + getUrlForApp, http, inferenceEnabled = false, baseConversations, @@ -294,6 +300,7 @@ export const AssistantProvider: React.FC = ({ currentUserAvatar, docLinks, getComments, + getUrlForApp, http, inferenceEnabled, knowledgeBase: { @@ -344,6 +351,7 @@ export const AssistantProvider: React.FC = ({ currentUserAvatar, docLinks, getComments, + getUrlForApp, http, inferenceEnabled, localStorageKnowledgeBase, diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/helpers.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/helpers.tsx index eed597548b862..65917d4941a66 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/helpers.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/helpers.tsx @@ -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; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/use_load_connectors/index.test.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/use_load_connectors/index.test.tsx index 396a9e89fc1c9..e1c7fa93116c5 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/use_load_connectors/index.test.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/use_load_connectors/index.test.tsx @@ -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 })) ); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/use_load_connectors/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/use_load_connectors/index.tsx index 6b26be1277927..078a5c483d3e4 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/use_load_connectors/index.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/use_load_connectors/index.tsx @@ -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,42 +35,30 @@ export const useLoadConnectors = ({ toasts, inferenceEnabled = false, }: Props): UseQueryResult => { - if (inferenceEnabled) { - actionTypes.push('.inference'); - } + 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, 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 - )))) - ? [ - { - ...connector, - apiProvider: - !connector.isPreconfigured && - !connector.isSystemAction && - connector?.config?.apiProvider - ? (connector?.config?.apiProvider as OpenAiProviderType) - : undefined, - }, - ] - : []), - ], - Promise.resolve([]) - ); + const connectors = await loadConnectors({ http }); + return connectors.reduce((acc: AIConnector[], connector) => { + if (!connector.isMissingSecrets && actionTypes.includes(connector.actionTypeId)) { + acc.push({ + ...connector, + apiProvider: + !connector.isPreconfigured && + !connector.isSystemAction && + connector?.config?.apiProvider + ? (connector?.config?.apiProvider as OpenAiProviderType) + : undefined, + }); + } + return acc; + }, []); }, { retry: false, diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.test.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.test.tsx index 7ae8966599adc..4ddace9fc2add 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.test.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.test.tsx @@ -21,7 +21,7 @@ import { import { useSettingsUpdater } from '../../assistant/settings/use_settings_updater/use_settings_updater'; import { useUpdateKnowledgeBaseEntries } from '../../assistant/api/knowledge_base/entries/use_update_knowledge_base_entries'; import { MOCK_QUICK_PROMPTS } from '../../mock/quick_prompt'; -import { useAssistantContext } from '../../..'; +import { AssistantSpaceIdProvider, useAssistantContext } from '../../..'; import { I18nProvider } from '@kbn/i18n-react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useKnowledgeBaseIndices } from '../../assistant/api/knowledge_base/use_knowledge_base_indices'; @@ -69,9 +69,11 @@ const Wrapper = ({ history?: History; }) => ( - - {children} - + + + {children} + + ); describe('KnowledgeBaseSettingsManagement', () => { diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx index 0b1cfd4a054ba..df44ed3aef3a2 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx @@ -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'; import { AssistantSpaceIdProvider } from '../../assistant/use_space_aware_context'; @@ -54,6 +55,7 @@ export const TestProvidersComponent: React.FC = ({ const mockGetComments = jest.fn(() => []); const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' }); const mockNavigateToApp = jest.fn(); + const mockGetUrlForApp = jest.fn(); const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -79,11 +81,9 @@ export const TestProvidersComponent: React.FC = ({ 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} diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/index.test.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/index.test.tsx index 179b473e55804..0ee6263d4a2fd 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/index.test.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/index.test.tsx @@ -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 }) => ( - +
{children}
- + ); 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({ diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/index.tsx index 83b718d999768..326588f9cad50 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/index.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/index.tsx @@ -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 = ({ conversation }) => { - const [tourCompleted, setTourCompleted] = useLocalStorage( - 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(tourStorageKey, false); + + const eisLLMUsageCostTourStorageKey = useTourStorageKey( + NEW_FEATURES_TOUR_STORAGE_KEYS.ELASTIC_LLM_USAGE_ASSISTANT_HEADER + ); + const [eisLLMUsageCostTourState] = useLocalStorage( + eisLLMUsageCostTourStorageKey, + tourDefaultConfig ); const [showTour, setShowTour] = useState(false); @@ -46,10 +58,13 @@ export const AnonymizedValuesAndCitationsTour: React.FC = ({ 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 = ({ conversation clearTimeout(timer); }; } - }, [conversation, tourCompleted, showTour]); + }, [ + conversation, + tourCompleted, + showTour, + kbTourStorageKey, + eisLLMUsageCostTourStorageKey, + eisLLMUsageCostTourState?.isTourActive, + eisLLMUsageCostTourState?.currentTourStep, + ]); const finishTour = useCallback(() => { setTourCompleted(true); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/common/hooks/use_tour_storage_key.test.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/common/hooks/use_tour_storage_key.test.tsx new file mode 100644 index 0000000000000..cafc48c4aa1ed --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/common/hooks/use_tour_storage_key.test.tsx @@ -0,0 +1,78 @@ +/* + * 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 spaceId = 'default'; + const { result } = renderHook(() => useTourStorageKey(featureKey), { + wrapper: ({ children }) => {children}, + }); + expect(result.current).toBe(`${expectedStorageKey}.${spaceId}`); + } + ); +}); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/common/hooks/use_tour_storage_key.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/common/hooks/use_tour_storage_key.ts new file mode 100644 index 0000000000000..1e8e32866a82b --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/common/hooks/use_tour_storage_key.ts @@ -0,0 +1,22 @@ +/* + * 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 { useAssistantSpaceId } from '../../../assistant/use_space_aware_context'; +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) => { + const spaceId = useAssistantSpaceId(); + return `${NEW_TOUR_FEATURES_TOUR_STORAGE_KEYS[featureKey]}.${spaceId}`; +}; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/const.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/const.ts index 91380d73cca5e..d6724d2a9eb37 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/const.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/const.ts @@ -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 = { 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', }; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/elastic_llm/index.test.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/elastic_llm/index.test.tsx new file mode 100644 index 0000000000000..dd2a5e12f61d7 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/elastic_llm/index.test.tsx @@ -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 }) => ( + {children} +); + +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( + +
+ , + { + 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( + , + { + 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( + , + { + wrapper: Wrapper, + } + ); + + act(() => { + jest.runAllTimers(); + }); + + await waitFor(() => { + expect(queryByTestId('elasticLLMTourStepPanel')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/elastic_llm/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/elastic_llm/index.tsx new file mode 100644 index 0000000000000..80be8ed1e6dcb --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/elastic_llm/index.tsx @@ -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 = ({ + 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( + 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 ( + {elasticLLMTourStep1.content}} + // Open the tour step after flyout is open + isStepOpen={isTimerExhausted} + maxWidth={384} + onFinish={finishTour} + panelProps={{ + 'data-test-subj': `elasticLLMTourStepPanel`, + }} + step={1} + stepsTotal={1} + title={ + + {elasticLLMTourStep1.title} + + } + subtitle={ + + {elasticLLMTourStep1.subTitle} + + } + footerAction={[ + + {ELASTIC_LLM_TOUR_FINISH_TOUR} + , + ]} + panelStyle={{ + fontSize: euiTheme.size.m, + }} + zIndex={zIndex} + > + {wrapper ? ( +
+ {children} +
+ ) : ( + children + )} +
+ ); +}; + +export const ElasticLLMCostAwarenessTour = React.memo(ElasticLLMCostAwarenessTourComponent); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/elastic_llm/messages.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/elastic_llm/messages.tsx new file mode 100644 index 0000000000000..51d058dc74032 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/elastic_llm/messages.tsx @@ -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 ( + + {i18n.ELASTIC_LLM_USAGE_COSTS} + + ), + learnMore: ( + + {i18n.ELASTIC_LLM_TOUR_LEARN_MORE} + + ), + }} + /> + ); +}; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/elastic_llm/step_config.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/elastic_llm/step_config.tsx new file mode 100644 index 0000000000000..48cd87387476f --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/elastic_llm/step_config.tsx @@ -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: , +}; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/elastic_llm/translations.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/elastic_llm/translations.ts new file mode 100644 index 0000000000000..e039a7660b3f4 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/elastic_llm/translations.ts @@ -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', + } +); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/knowledge_base/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/knowledge_base/index.tsx index bc2c8b344fbfd..30e49895b3aec 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/knowledge_base/index.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/knowledge_base/index.tsx @@ -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( - NEW_FEATURES_TOUR_STORAGE_KEYS.KNOWLEDGE_BASE, - tourConfig - ); + const tourStorageKey = useTourStorageKey(NEW_FEATURES_TOUR_STORAGE_KEYS.KNOWLEDGE_BASE); + const [tourState, setTourState] = useLocalStorage(tourStorageKey, tourConfig); const advanceToVideoStep = useCallback( () => diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/tsconfig.json b/x-pack/platform/packages/shared/kbn-elastic-assistant/tsconfig.json index a0c9dee5da6b6..1e89e1db32eb7 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/tsconfig.json +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/tsconfig.json @@ -41,6 +41,5 @@ "@kbn/product-doc-base-plugin", "@kbn/spaces-plugin", "@kbn/shared-ux-router", - "@kbn/inference-endpoint-ui-common", ] } diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index b8289adbf3f98..b3c027752c1b1 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -47983,10 +47983,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", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index f2fabb54d3aed..cff86fd7d7288 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -47943,10 +47943,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": "構成", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index e07d349e20fcb..c267dfd560580 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -48019,10 +48019,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": "配置", diff --git a/x-pack/platform/plugins/shared/automatic_import/kibana.jsonc b/x-pack/platform/plugins/shared/automatic_import/kibana.jsonc index ac93c97c76466..e15d94f74c720 100644 --- a/x-pack/platform/plugins/shared/automatic_import/kibana.jsonc +++ b/x-pack/platform/plugins/shared/automatic_import/kibana.jsonc @@ -21,8 +21,7 @@ "stackConnectors", ], "requiredBundles": [ - "kibanaUtils", - "esUiShared", + "kibanaUtils" ] } } diff --git a/x-pack/platform/plugins/shared/automatic_import/public/components/create_integration/create_automatic_import/steps/connector_step/connector_step.tsx b/x-pack/platform/plugins/shared/automatic_import/public/components/create_integration/create_automatic_import/steps/connector_step/connector_step.tsx index 8b770c08746f5..71dad40832ac3 100644 --- a/x-pack/platform/plugins/shared/automatic_import/public/components/create_integration/create_automatic_import/steps/connector_step/connector_step.tsx +++ b/x-pack/platform/plugins/shared/automatic_import/public/components/create_integration/create_automatic_import/steps/connector_step/connector_step.tsx @@ -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,54 @@ import * as i18n from './translations'; */ const AllowedActionTypeIds = ['.bedrock', '.gen-ai', '.gemini']; +const ElasticLLMNewIntegrationMessage = React.memo(() => { + const { + docLinks: { + links: { + securitySolution: { + thirdPartyLlmProviders: THIRD_PARTY_LLM_LINK, + llmPerformanceMatrix: LLM_PERFORMANCE_LINK, + }, + observability: { elasticManagedLlmUsageCost: ELASTIC_LLM_USAGE_COST_LINK }, + }, + }, + } = useKibana().services; + + return ( + + {ELASTIC_LLM_USAGE_COSTS} + + ), + thirdParty: ( + + {ELASTIC_LLM_THIRD_PARTY} + + ), + performance: ( + + {ELASTIC_LLM_TOUR_PERFORMANCE} + + ), + }} + /> + ); +}); +ElasticLLMNewIntegrationMessage.displayName = 'ElasticLLMNewIntegrationMessage'; + interface ConnectorStepProps { connector: AIConnector | undefined; } + export const ConnectorStep = React.memo(({ connector }) => { const { euiTheme } = useEuiTheme(); const { http, notifications, triggersActionsUi } = useKibana().services; @@ -119,14 +171,22 @@ export const ConnectorStep = React.memo(({ connector }) => { - - - - - - {i18n.SUPPORTED_MODELS_INFO} - - + + + + + + + + + {inferenceEnabled && isElasticManagedLlmConnector(connector) ? ( + + ) : ( + i18n.SUPPORTED_MODELS_INFO + )} + + + ); diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inference/inference.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inference/inference.tsx index b5d90cab4a18a..e65cdf187cba5 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inference/inference.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inference/inference.tsx @@ -112,5 +112,6 @@ export function getConnectorType(): InferenceConnector { }, actionConnectorFields: lazy(() => import('./connector')), actionParamsFields: lazy(() => import('./params')), + actionReadOnlyExtraComponent: lazy(() => import('./usage_cost_message')), }; } diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inference/usage_cost_message.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inference/usage_cost_message.tsx new file mode 100644 index 0000000000000..030783ff1e3d2 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inference/usage_cost_message.tsx @@ -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 ( + + + + + ), + usageCost: ( + + + + ), + }} + /> + + ); +}; +// eslint-disable-next-line import/no-default-export +export { UsageCostMessage as default }; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/inference/index.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/inference/index.ts index 2cdad7d23197a..7c9ab3ffef26b 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/inference/index.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/inference/index.ts @@ -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, diff --git a/x-pack/solutions/security/packages/ecs-data-quality-dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx b/x-pack/solutions/security/packages/ecs-data-quality-dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx index 8aaf7012ebf17..7c96396e002d5 100644 --- a/x-pack/solutions/security/packages/ecs-data-quality-dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx +++ b/x-pack/solutions/security/packages/ecs-data-quality-dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx @@ -13,7 +13,7 @@ import React from 'react'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; 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 = ({ error: () => {}, }, }); + const chrome = chromeServiceMock.createStartContract(); chrome.getChromeStyle$.mockReturnValue(of('classic')); @@ -80,10 +81,7 @@ const TestExternalProvidersComponent: React.FC = ({ 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 = ({ }} currentAppId={'securitySolutionUI'} userProfileService={jest.fn() as unknown as UserProfileService} + getUrlForApp={jest.fn()} chrome={chrome} > {children} diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx index 374ec85c4db65..bd56466db5bdc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx @@ -136,12 +136,12 @@ export const createBasePrompts = async (notifications: NotificationsStart, http: */ export const AssistantProvider: FC> = ({ 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> = ({ 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} diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/stack_management/management_settings.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/stack_management/management_settings.tsx index 766bbffb5e9a7..8427d2ee59170 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/stack_management/management_settings.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/stack_management/management_settings.tsx @@ -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; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx index 2124c38722ec0..a4700be41e694 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx @@ -14,6 +14,9 @@ import { TestProviders } from '../../../common/mock'; import { Header } from '.'; jest.mock('../../../assistant/use_assistant_availability'); +jest.mock('../../../common/hooks/use_space_id', () => ({ + useSpaceId: jest.fn().mockReturnValue('default'), +})); const defaultProps = { stats: null, @@ -27,6 +30,7 @@ const defaultProps = { onConnectorIdSelected: jest.fn(), openFlyout: jest.fn(), setLocalStorageAttackDiscoveryMaxAlerts: jest.fn(), + showFlyout: false, }; describe('Actions', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/header/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/header/index.tsx index 8b299eaab7025..3546226e21e73 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/header/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/header/index.tsx @@ -15,13 +15,20 @@ import { useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; -import { ConnectorSelectorInline } from '@kbn/elastic-assistant'; +import { + AssistantSpaceIdProvider, + 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'; +import { useSpaceId } from '../../../common/hooks/use_space_id'; interface Props { connectorId: string | undefined; @@ -35,6 +42,7 @@ interface Props { openFlyout: () => void; setLocalStorageAttackDiscoveryMaxAlerts: React.Dispatch>; stats: AttackDiscoveryStats | null; + showFlyout: boolean; } const HeaderComponent: React.FC = ({ @@ -49,11 +57,25 @@ const HeaderComponent: React.FC = ({ openFlyout, setLocalStorageAttackDiscoveryMaxAlerts, stats, + showFlyout, }) => { const { euiTheme } = useEuiTheme(); const disabled = connectorId == null; const [didCancel, setDidCancel] = useState(false); + const { inferenceEnabled } = useAssistantContext(); + const spaceId = useSpaceId(); + + const [isEISCostTourDisabled, setIsEISCostTourDisabled] = useState( + !connectorsAreConfigured || !spaceId || !inferenceEnabled || showFlyout + ); + useEffect(() => { + if (!connectorsAreConfigured || !spaceId || !inferenceEnabled || showFlyout) { + setIsEISCostTourDisabled(true); + } else { + setIsEISCostTourDisabled(false); + } + }, [connectorsAreConfigured, inferenceEnabled, isEISCostTourDisabled, showFlyout, spaceId]); const handleCancel = useCallback(() => { setDidCancel(true); @@ -100,12 +122,23 @@ const HeaderComponent: React.FC = ({ `} grow={false} > - + {spaceId && ( + + + + + + )} )} diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/index.tsx index 83337a6531910..71fe39e89271b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/index.tsx @@ -262,6 +262,7 @@ const AttackDiscoveryPageComponent: React.FC = () => { openFlyout={openFlyout} setLocalStorageAttackDiscoveryMaxAlerts={setLocalStorageAttackDiscoveryMaxAlerts} stats={stats} + showFlyout={showFlyout} /> diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.test.tsx index 97f156cfc535f..731a5fb41183a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.test.tsx @@ -24,6 +24,9 @@ jest.mock('react-router', () => ({ })); jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../sourcerer/containers'); +jest.mock('../../../../common/hooks/use_space_id', () => ({ + useSpaceId: jest.fn().mockReturnValue('default'), +})); const defaultProps = { alertsPreviewStackBy0: 'defaultAlertPreview', diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.tsx index a750593fe9552..aa168dc1d687c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiTab, EuiTabs, EuiText, EuiSpacer } from '@elastic/eui'; +import { EuiTab, EuiTabs, EuiText, EuiSpacer, EuiFlexItem, EuiFlexGroup } 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'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx index c793f7722780a..42859be0efed3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx @@ -9,10 +9,11 @@ import { httpServiceMock } from '@kbn/core-http-browser-mocks'; import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; import React from 'react'; import type { AssistantAvailability } from '@kbn/elastic-assistant'; -import { AssistantProvider } from '@kbn/elastic-assistant'; +import { AssistantProvider, AssistantSpaceIdProvider } 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 = ({ 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} @@ -67,7 +66,7 @@ export const MockAssistantProviderComponent: React.FC = ({ userProfileService={mockUserProfileService} chrome={chrome} > - {children} + {children} ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/ai_feature_message.tsx b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/ai_feature_message.tsx new file mode 100644 index 0000000000000..95afdbfba313c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/ai_feature_message.tsx @@ -0,0 +1,65 @@ +/* + * 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, + thirdPartyLlmProviders: THIRD_PARTY_LLM_LINK, + }, + observability: { elasticManagedLlmUsageCost: ELASTIC_LLM_USAGE_COST_LINK }, + }, + }, + } = useKibana().services; + + return ( + + {ELASTIC_LLM_AI_FEATURES} + + ), + usageCost: ( + + {ELASTIC_LLM_USAGE_COSTS} + + ), + thirdParty: ( + + {ELASTIC_LLM_THIRD_PARTY} + + ), + }} + /> + ); +}); + +ElasticAIFeatureMessage.displayName = 'ElasticAIFeatureMessage'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/assistant_card.tsx b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/assistant_card.tsx index 37f3b314dbbc6..751bc28d7ddd3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/assistant_card.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/assistant_card.tsx @@ -16,8 +16,12 @@ import { 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'; @@ -31,6 +35,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 = ({ isCardComplete, @@ -137,6 +142,11 @@ export const AssistantCard: OnboardingCardComponent = ({ [currentConversation, setApiConfig, onConversationChange, setSelectedConnectorId] ); + const isEISConnectorAvailable = useMemo( + () => connectors?.some((c) => isElasticManagedLlmConnector(c)) ?? false, + [connectors] + ); + if (!checkCompleteMetadata) { return ( @@ -155,7 +165,13 @@ export const AssistantCard: OnboardingCardComponent = ({ {canExecuteConnectors ? ( - {i18n.ASSISTANT_CARD_DESCRIPTION} + + {isEISConnectorAvailable ? ( + + ) : ( + i18n.ASSISTANT_CARD_DESCRIPTION + )} + {isIntegrationsCardAvailable && !isIntegrationsCardComplete ? ( diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/connector_selector_panel.tsx b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/connector_selector_panel.tsx index 2008b86d1c284..835469ef7fb67 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/connector_selector_panel.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/connector_selector_panel.tsx @@ -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( ({ connectors, selectedConnectorId, onConnectorSelected }) => { const { actionTypeRegistry } = useKibana().services.triggersActionsUi; - + const { euiTheme } = useEuiTheme(); const selectedConnector = useMemo( () => connectors.find((connector) => connector.id === selectedConnectorId), [connectors, selectedConnectorId] @@ -72,51 +73,83 @@ export const ConnectorSelectorPanel = React.memo( [connectors, onConnectorSelected] ); + const betaBadgeProps = useMemo(() => { + if (!selectedConnector || !isElasticManagedLlmConnector(selectedConnector)) { + return; + } + + return { + label: ( + + {i18n.PRECONFIGURED_CONNECTOR_LABEL} + + ), + title: i18n.PRECONFIGURED_CONNECTOR_LABEL, + color: 'subdued' as const, + css: css` + height: ${euiTheme.base * 1.25}px; + `, + }; + }, [euiTheme, selectedConnector]); + return ( - - - - {i18n.SELECTED_PROVIDER} - - - - {selectedConnector && ( + + + {i18n.SELECTED_PROVIDER} + + + + {selectedConnector && ( + + + + )} - - )} - - - - - - - + + + + } + /> ); } ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/translations.ts index c12788eafbfa2..703f943b908f8 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/translations.ts @@ -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', + } +);