mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
# 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:
parent
9799132aae
commit
cae09d0785
47 changed files with 1320 additions and 179 deletions
|
@ -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`,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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>
|
||||
<ConnectorSelectorInline
|
||||
isDisabled={isDisabled || selectedConversation === undefined}
|
||||
<ElasticLLMCostAwarenessTour
|
||||
isDisabled={isDisabled}
|
||||
selectedConnectorId={selectedConnectorId}
|
||||
selectedConversation={selectedConversation}
|
||||
onConnectorSelected={onConversationChange}
|
||||
/>
|
||||
storageKey={NEW_FEATURES_TOUR_STORAGE_KEYS.ELASTIC_LLM_USAGE_ASSISTANT_HEADER}
|
||||
>
|
||||
<ConnectorSelectorInline
|
||||
isDisabled={isDisabled || selectedConversation === undefined}
|
||||
selectedConnectorId={selectedConnectorId}
|
||||
selectedConversation={selectedConversation}
|
||||
onConnectorSelected={onConversationChange}
|
||||
/>
|
||||
</ElasticLLMCostAwarenessTour>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem id={AI_ASSISTANT_SETTINGS_MENU_CONTAINER_ID}>
|
||||
<SettingsContextMenu
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,15 +348,13 @@ export const SettingsContextMenu: React.FC<Params> = React.memo(
|
|||
<>
|
||||
<EuiPopover
|
||||
button={
|
||||
<KnowledgeBaseTour>
|
||||
<EuiButtonIcon
|
||||
aria-label={AI_ASSISTANT_MENU}
|
||||
isDisabled={isDisabled}
|
||||
iconType="boxesVertical"
|
||||
onClick={onButtonClick}
|
||||
data-test-subj="chat-context-menu"
|
||||
/>
|
||||
</KnowledgeBaseTour>
|
||||
<EuiButtonIcon
|
||||
aria-label={AI_ASSISTANT_MENU}
|
||||
isDisabled={isDisabled}
|
||||
iconType="boxesVertical"
|
||||
onClick={onButtonClick}
|
||||
data-test-subj="chat-context-menu"
|
||||
/>
|
||||
}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={closePopover}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 }))
|
||||
);
|
||||
|
|
|
@ -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<AIConnector[], IHttpFetchError> => {
|
||||
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<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
|
||||
))))
|
||||
? [
|
||||
{
|
||||
...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,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
);
|
||||
});
|
|
@ -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]}`;
|
||||
};
|
|
@ -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',
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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 />,
|
||||
};
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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(
|
||||
() =>
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "構成",
|
||||
|
|
|
@ -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": "配置",
|
||||
|
|
|
@ -21,8 +21,7 @@
|
|||
"stackConnectors",
|
||||
],
|
||||
"requiredBundles": [
|
||||
"kibanaUtils",
|
||||
"esUiShared",
|
||||
"kibanaUtils"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="flexStart">
|
||||
<EuiFlexItem grow={false} css={{ margin: euiTheme.size.xxs }}>
|
||||
<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>
|
||||
);
|
||||
|
|
|
@ -112,5 +112,6 @@ export function getConnectorType(): InferenceConnector {
|
|||
},
|
||||
actionConnectorFields: lazy(() => import('./connector')),
|
||||
actionParamsFields: lazy(() => import('./params')),
|
||||
actionReadOnlyExtraComponent: lazy(() => import('./usage_cost_message')),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 };
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
@ -100,12 +116,19 @@ const HeaderComponent: React.FC<Props> = ({
|
|||
`}
|
||||
grow={false}
|
||||
>
|
||||
<ConnectorSelectorInline
|
||||
onConnectorSelected={noop}
|
||||
onConnectorIdSelected={onConnectorIdSelected}
|
||||
<ElasticLLMCostAwarenessTour
|
||||
isDisabled={isEISCostTourDisabled}
|
||||
selectedConnectorId={connectorId}
|
||||
stats={stats}
|
||||
/>
|
||||
zIndex={999} // Should lower than the flyout
|
||||
storageKey={NEW_FEATURES_TOUR_STORAGE_KEYS.ELASTIC_LLM_USAGE_ATTACK_DISCOVERY}
|
||||
>
|
||||
<ConnectorSelectorInline
|
||||
onConnectorSelected={noop}
|
||||
onConnectorIdSelected={onConnectorIdSelected}
|
||||
selectedConnectorId={connectorId}
|
||||
stats={stats}
|
||||
/>
|
||||
</ElasticLLMCostAwarenessTour>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
|
||||
|
|
|
@ -262,6 +262,7 @@ const AttackDiscoveryPageComponent: React.FC = () => {
|
|||
openFlyout={openFlyout}
|
||||
setLocalStorageAttackDiscoveryMaxAlerts={setLocalStorageAttackDiscoveryMaxAlerts}
|
||||
stats={stats}
|
||||
showFlyout={showFlyout}
|
||||
/>
|
||||
<EuiSpacer size="m" />
|
||||
</HeaderPage>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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';
|
|
@ -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 ? (
|
||||
|
|
|
@ -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,51 +73,83 @@ 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
|
||||
css={css`
|
||||
height: 100%;
|
||||
`}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
direction="column"
|
||||
gutterSize="s"
|
||||
>
|
||||
<EuiFlexItem grow={false} justifyContent="center">
|
||||
<EuiText>{i18n.SELECTED_PROVIDER}</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem justifyContent="center">
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
css={css`
|
||||
height: 32px;
|
||||
`}
|
||||
direction="column"
|
||||
justifyContent="center"
|
||||
responsive={false}
|
||||
gutterSize="xs"
|
||||
>
|
||||
{selectedConnector && (
|
||||
<EuiCard
|
||||
css={css`
|
||||
height: 100%;
|
||||
`}
|
||||
hasBorder
|
||||
betaBadgeProps={betaBadgeProps}
|
||||
titleElement="p"
|
||||
title={
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
direction="column"
|
||||
gutterSize={selectedConnector ? 'xl' : 'l'}
|
||||
>
|
||||
<EuiFlexItem grow={false} justifyContent="center">
|
||||
<EuiText>{i18n.SELECTED_PROVIDER}</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem justifyContent="center">
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
css={css`
|
||||
height: ${euiTheme.size.xl};
|
||||
`}
|
||||
direction="column"
|
||||
justifyContent="center"
|
||||
responsive={false}
|
||||
gutterSize="xs"
|
||||
>
|
||||
{selectedConnector && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon
|
||||
size="xxl"
|
||||
color="text"
|
||||
type={actionTypeRegistry.get(selectedConnector.actionTypeId).iconClass}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon
|
||||
size="xxl"
|
||||
color="text"
|
||||
type={actionTypeRegistry.get(selectedConnector.actionTypeId).iconClass}
|
||||
<ConnectorSelector
|
||||
connectors={connectorOptions}
|
||||
selectedId={selectedConnectorId}
|
||||
onChange={onConnectorSelectionChange}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<ConnectorSelector
|
||||
connectors={connectorOptions}
|
||||
selectedId={selectedConnectorId}
|
||||
onChange={onConnectorSelectionChange}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue