mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Security Solution] Fix AI Assistant overlay in Serverless pages (#166437)
## Summary
issue: https://github.com/elastic/kibana/issues/166436

### Bug
The bug was caused by the `AssistantOverlay` being rendered inside the
`SecuritySolutionPageWrapper`, which is a "styling" wrapper that is not
used by all the pages in the Security Solution application, the
serverless-specific pages for instance, use their own page wrapper for
styling.
### Changes
#### Fix
This PR fixes the bug by rendering the `AssistantOverlay` in the
`HomePage`, along with other similar components such as the `HelpMenu`
or the `TopValuesPopover`, which are components that need to be always
present in the application layout.
#### Cleaning
The assistant provider configuration logic has also been moved from the
`App` component to a new `provider.tsx` component in the
_public/assistant_ directory, this way we don't pollute the main
Security `App` component with assistant-specific logic.
The `isAssistantEnabled` prop has been removed from the `Assistant`
component. We don't need to check the availability and pass it by props
everywhere we use the assistant, it can be retrieved from the assistant
context.
---------
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
b2057ac148
commit
1dee16e061
18 changed files with 253 additions and 186 deletions
|
@ -23,7 +23,7 @@ describe('AssistantOverlay', () => {
|
|||
it('renders when isAssistantEnabled prop is true and keyboard shortcut is pressed', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestProviders providerContext={{ assistantTelemetry }}>
|
||||
<AssistantOverlay isAssistantEnabled={true} />
|
||||
<AssistantOverlay />
|
||||
</TestProviders>
|
||||
);
|
||||
fireEvent.keyDown(document, { key: ';', ctrlKey: true });
|
||||
|
@ -34,7 +34,7 @@ describe('AssistantOverlay', () => {
|
|||
it('modal closes when close button is clicked', () => {
|
||||
const { getByLabelText, queryByTestId } = render(
|
||||
<TestProviders>
|
||||
<AssistantOverlay isAssistantEnabled={true} />
|
||||
<AssistantOverlay />
|
||||
</TestProviders>
|
||||
);
|
||||
fireEvent.keyDown(document, { key: ';', ctrlKey: true });
|
||||
|
@ -47,7 +47,7 @@ describe('AssistantOverlay', () => {
|
|||
it('Assistant invoked from shortcut tracking happens on modal open only (not close)', () => {
|
||||
render(
|
||||
<TestProviders providerContext={{ assistantTelemetry }}>
|
||||
<AssistantOverlay isAssistantEnabled={true} />
|
||||
<AssistantOverlay />
|
||||
</TestProviders>
|
||||
);
|
||||
fireEvent.keyDown(document, { key: ';', ctrlKey: true });
|
||||
|
@ -63,7 +63,7 @@ describe('AssistantOverlay', () => {
|
|||
it('modal closes when shortcut is pressed and modal is already open', () => {
|
||||
const { queryByTestId } = render(
|
||||
<TestProviders>
|
||||
<AssistantOverlay isAssistantEnabled={true} />
|
||||
<AssistantOverlay />
|
||||
</TestProviders>
|
||||
);
|
||||
fireEvent.keyDown(document, { key: ';', ctrlKey: true });
|
||||
|
@ -75,7 +75,7 @@ describe('AssistantOverlay', () => {
|
|||
it('modal does not open when incorrect shortcut is pressed', () => {
|
||||
const { queryByTestId } = render(
|
||||
<TestProviders>
|
||||
<AssistantOverlay isAssistantEnabled={true} />
|
||||
<AssistantOverlay />
|
||||
</TestProviders>
|
||||
);
|
||||
fireEvent.keyDown(document, { key: 'a', ctrlKey: true });
|
||||
|
|
|
@ -22,15 +22,12 @@ const StyledEuiModal = styled(EuiModal)`
|
|||
min-width: 95vw;
|
||||
min-height: 25vh;
|
||||
`;
|
||||
interface Props {
|
||||
isAssistantEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal container for Elastic AI Assistant conversations, receiving the page contents as context, plus whatever
|
||||
* component currently has focus and any specific context it may provide through the SAssInterface.
|
||||
*/
|
||||
export const AssistantOverlay = React.memo<Props>(({ isAssistantEnabled }) => {
|
||||
export const AssistantOverlay = React.memo(() => {
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [conversationId, setConversationId] = useState<string | undefined>(
|
||||
WELCOME_CONVERSATION_TITLE
|
||||
|
@ -103,11 +100,7 @@ export const AssistantOverlay = React.memo<Props>(({ isAssistantEnabled }) => {
|
|||
<>
|
||||
{isModalVisible && (
|
||||
<StyledEuiModal onClose={handleCloseModal} data-test-subj="ai-assistant-modal">
|
||||
<Assistant
|
||||
isAssistantEnabled={isAssistantEnabled}
|
||||
conversationId={conversationId}
|
||||
promptContextId={promptContextId}
|
||||
/>
|
||||
<Assistant conversationId={conversationId} promptContextId={promptContextId} />
|
||||
</StyledEuiModal>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -22,7 +22,7 @@ import { WELCOME_CONVERSATION_TITLE } from './use_conversation/translations';
|
|||
import { useLocalStorage } from 'react-use';
|
||||
import { PromptEditor } from './prompt_editor';
|
||||
import { QuickPrompts } from './quick_prompts/quick_prompts';
|
||||
import { TestProviders } from '../mock/test_providers/test_providers';
|
||||
import { mockAssistantAvailability, TestProviders } from '../mock/test_providers/test_providers';
|
||||
|
||||
jest.mock('../connectorland/use_load_connectors');
|
||||
jest.mock('../connectorland/connector_setup');
|
||||
|
@ -46,10 +46,10 @@ const getInitialConversations = (): Record<string, Conversation> => ({
|
|||
},
|
||||
});
|
||||
|
||||
const renderAssistant = (extraProps = {}) =>
|
||||
const renderAssistant = (extraProps = {}, providerProps = {}) =>
|
||||
render(
|
||||
<TestProviders getInitialConversations={getInitialConversations}>
|
||||
<Assistant isAssistantEnabled {...extraProps} />
|
||||
<TestProviders getInitialConversations={getInitialConversations} {...providerProps}>
|
||||
<Assistant {...extraProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
@ -110,9 +110,10 @@ describe('Assistant', () => {
|
|||
data: connectors,
|
||||
} as unknown as UseQueryResult<ActionConnector[], IHttpFetchError>);
|
||||
|
||||
const { getByLabelText } = render(
|
||||
<TestProviders
|
||||
getInitialConversations={() => ({
|
||||
const { getByLabelText } = renderAssistant(
|
||||
{},
|
||||
{
|
||||
getInitialConversations: () => ({
|
||||
[WELCOME_CONVERSATION_TITLE]: {
|
||||
id: WELCOME_CONVERSATION_TITLE,
|
||||
messages: [],
|
||||
|
@ -124,10 +125,8 @@ describe('Assistant', () => {
|
|||
apiConfig: {},
|
||||
excludeFromLastConversationStorage: true,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Assistant isAssistantEnabled />
|
||||
</TestProviders>
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
expect(persistToLocalStorage).toHaveBeenCalled();
|
||||
|
@ -176,4 +175,16 @@ describe('Assistant', () => {
|
|||
expect(persistToLocalStorage).toHaveBeenLastCalledWith(WELCOME_CONVERSATION_TITLE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when not authorized', () => {
|
||||
it('should be disabled', async () => {
|
||||
const { queryByTestId } = renderAssistant(
|
||||
{},
|
||||
{
|
||||
assistantAvailability: { ...mockAssistantAvailability, isAssistantEnabled: false },
|
||||
}
|
||||
);
|
||||
expect(queryByTestId('prompt-textarea')).toHaveProperty('disabled');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -51,7 +51,6 @@ import { ConnectorMissingCallout } from '../connectorland/connector_missing_call
|
|||
|
||||
export interface Props {
|
||||
conversationId?: string;
|
||||
isAssistantEnabled: boolean;
|
||||
promptContextId?: string;
|
||||
shouldRefocusPrompt?: boolean;
|
||||
showTitle?: boolean;
|
||||
|
@ -64,7 +63,6 @@ export interface Props {
|
|||
*/
|
||||
const AssistantComponent: React.FC<Props> = ({
|
||||
conversationId,
|
||||
isAssistantEnabled,
|
||||
promptContextId = '',
|
||||
shouldRefocusPrompt = false,
|
||||
showTitle = true,
|
||||
|
@ -73,6 +71,7 @@ const AssistantComponent: React.FC<Props> = ({
|
|||
const {
|
||||
assistantTelemetry,
|
||||
augmentMessageCodeBlocks,
|
||||
assistantAvailability: { isAssistantEnabled },
|
||||
conversations,
|
||||
defaultAllow,
|
||||
defaultAllowReplacement,
|
||||
|
|
|
@ -29,7 +29,7 @@ window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
|||
|
||||
const mockGetInitialConversations = () => ({});
|
||||
|
||||
const mockAssistantAvailability: AssistantAvailability = {
|
||||
export const mockAssistantAvailability: AssistantAvailability = {
|
||||
hasAssistantPrivilege: false,
|
||||
hasConnectorsAllPrivilege: true,
|
||||
hasConnectorsReadPrivilege: true,
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
// happens in the root of your app. Optionally provide a custom title for the assistant:
|
||||
|
||||
/** provides context (from the app) to the assistant, and injects Kibana services, like `http` */
|
||||
export { AssistantProvider } from './impl/assistant_context';
|
||||
export { AssistantProvider, useAssistantContext } from './impl/assistant_context';
|
||||
|
||||
// Step 2: Add the `AssistantOverlay` component to your app. This component displays the assistant
|
||||
// overlay in a modal, bound to a shortcut key:
|
||||
|
|
|
@ -5,10 +5,9 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { AssistantProvider } from '@kbn/elastic-assistant';
|
||||
import type { History } from 'history';
|
||||
import type { FC } from 'react';
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import type { Store, Action } from 'redux';
|
||||
import { Provider as ReduxStoreProvider } from 'react-redux';
|
||||
|
||||
|
@ -21,28 +20,18 @@ import { CellActionsProvider } from '@kbn/cell-actions';
|
|||
|
||||
import { NavigationProvider } from '@kbn/security-solution-navigation';
|
||||
import { UpsellingProvider } from '../common/components/upselling_provider';
|
||||
import { useAssistantTelemetry } from '../assistant/use_assistant_telemetry';
|
||||
import { getComments } from '../assistant/get_comments';
|
||||
import { augmentMessageCodeBlocks, LOCAL_STORAGE_KEY } from '../assistant/helpers';
|
||||
import { useConversationStore } from '../assistant/use_conversation_store';
|
||||
import { ManageUserInfo } from '../detections/components/user_info';
|
||||
import { DEFAULT_DARK_MODE, APP_NAME, APP_ID } from '../../common/constants';
|
||||
import { DEFAULT_DARK_MODE, APP_NAME } from '../../common/constants';
|
||||
import { ErrorToastDispatcher } from '../common/components/error_toast_dispatcher';
|
||||
import { MlCapabilitiesProvider } from '../common/components/ml/permissions/ml_capabilities_provider';
|
||||
import { GlobalToaster, ManageGlobalToaster } from '../common/components/toasters';
|
||||
import { KibanaContextProvider, useKibana, useUiSetting$ } from '../common/lib/kibana';
|
||||
import type { State } from '../common/store';
|
||||
import { ASSISTANT_TITLE } from './translations';
|
||||
import type { StartServices } from '../types';
|
||||
import { PageRouter } from './routes';
|
||||
import { UserPrivilegesProvider } from '../common/components/user_privileges/user_privileges_context';
|
||||
import { ReactQueryClientProvider } from '../common/containers/query_client/query_client_provider';
|
||||
import { DEFAULT_ALLOW, DEFAULT_ALLOW_REPLACEMENT } from '../assistant/content/anonymization';
|
||||
import { PROMPT_CONTEXTS } from '../assistant/content/prompt_contexts';
|
||||
import { BASE_SECURITY_QUICK_PROMPTS } from '../assistant/content/quick_prompts';
|
||||
import { BASE_SECURITY_SYSTEM_PROMPTS } from '../assistant/content/prompts/system';
|
||||
import { useAnonymizationStore } from '../assistant/use_anonymization_store';
|
||||
import { useAssistantAvailability } from '../assistant/use_assistant_availability';
|
||||
import { AssistantProvider } from '../assistant/provider';
|
||||
|
||||
interface StartAppComponent {
|
||||
children: React.ReactNode;
|
||||
|
@ -65,29 +54,12 @@ const StartAppComponent: FC<StartAppComponent> = ({
|
|||
const {
|
||||
i18n,
|
||||
application: { capabilities },
|
||||
http,
|
||||
triggersActionsUi: { actionTypeRegistry },
|
||||
uiActions,
|
||||
upselling,
|
||||
} = services;
|
||||
|
||||
const assistantAvailability = useAssistantAvailability();
|
||||
const { conversations, setConversations } = useConversationStore();
|
||||
const { defaultAllow, defaultAllowReplacement, setDefaultAllow, setDefaultAllowReplacement } =
|
||||
useAnonymizationStore();
|
||||
|
||||
const getInitialConversation = useCallback(() => {
|
||||
return conversations;
|
||||
}, [conversations]);
|
||||
|
||||
const nameSpace = `${APP_ID}.${LOCAL_STORAGE_KEY}`;
|
||||
|
||||
const [darkMode] = useUiSetting$<boolean>(DEFAULT_DARK_MODE);
|
||||
|
||||
const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = useKibana().services.docLinks;
|
||||
|
||||
const assistantTelemetry = useAssistantTelemetry();
|
||||
|
||||
return (
|
||||
<EuiErrorBoundary>
|
||||
<i18n.Context>
|
||||
|
@ -95,29 +67,7 @@ const StartAppComponent: FC<StartAppComponent> = ({
|
|||
<ReduxStoreProvider store={store}>
|
||||
<KibanaThemeProvider theme$={theme$}>
|
||||
<EuiThemeProvider darkMode={darkMode}>
|
||||
<AssistantProvider
|
||||
actionTypeRegistry={actionTypeRegistry}
|
||||
augmentMessageCodeBlocks={augmentMessageCodeBlocks}
|
||||
assistantAvailability={assistantAvailability}
|
||||
assistantLangChain={false}
|
||||
assistantTelemetry={assistantTelemetry}
|
||||
defaultAllow={defaultAllow}
|
||||
defaultAllowReplacement={defaultAllowReplacement}
|
||||
docLinks={{ ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }}
|
||||
baseAllow={DEFAULT_ALLOW}
|
||||
baseAllowReplacement={DEFAULT_ALLOW_REPLACEMENT}
|
||||
basePromptContexts={Object.values(PROMPT_CONTEXTS)}
|
||||
baseQuickPrompts={BASE_SECURITY_QUICK_PROMPTS}
|
||||
baseSystemPrompts={BASE_SECURITY_SYSTEM_PROMPTS}
|
||||
getInitialConversations={getInitialConversation}
|
||||
getComments={getComments}
|
||||
http={http}
|
||||
nameSpace={nameSpace}
|
||||
setConversations={setConversations}
|
||||
setDefaultAllow={setDefaultAllow}
|
||||
setDefaultAllowReplacement={setDefaultAllowReplacement}
|
||||
title={ASSISTANT_TITLE}
|
||||
>
|
||||
<AssistantProvider>
|
||||
<MlCapabilitiesProvider>
|
||||
<UserPrivilegesProvider kibanaCapabilities={capabilities}>
|
||||
<ManageUserInfo>
|
||||
|
|
|
@ -26,7 +26,6 @@ import { TimelineId } from '../../../../common/types/timeline';
|
|||
import { createStore } from '../../../common/store';
|
||||
import { kibanaObservable } from '@kbn/timelines-plugin/public/mock';
|
||||
import { sourcererPaths } from '../../../common/containers/sourcerer';
|
||||
import { useAssistantAvailability } from '../../../assistant/use_assistant_availability';
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
|
@ -49,15 +48,6 @@ jest.mock('react-reverse-portal', () => ({
|
|||
createHtmlPortalNode: () => ({ unmount: jest.fn() }),
|
||||
}));
|
||||
|
||||
jest.mock('../../../assistant/use_assistant_availability');
|
||||
|
||||
jest.mocked(useAssistantAvailability).mockReturnValue({
|
||||
hasAssistantPrivilege: false,
|
||||
hasConnectorsAllPrivilege: true,
|
||||
hasConnectorsReadPrivilege: true,
|
||||
isAssistantEnabled: true,
|
||||
});
|
||||
|
||||
describe('global header', () => {
|
||||
const mockSetHeaderActionMenu = jest.fn();
|
||||
const state = {
|
||||
|
@ -183,18 +173,11 @@ describe('global header', () => {
|
|||
expect(queryByTestId('sourcerer-trigger')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows AI Assistant header link if user has necessary privileges', () => {
|
||||
it('shows AI Assistant header link', () => {
|
||||
(useLocation as jest.Mock).mockReturnValue([
|
||||
{ pageName: SecurityPageName.overview, detailName: undefined },
|
||||
]);
|
||||
|
||||
jest.mocked(useAssistantAvailability).mockReturnValue({
|
||||
hasAssistantPrivilege: true,
|
||||
hasConnectorsAllPrivilege: true,
|
||||
hasConnectorsReadPrivilege: true,
|
||||
isAssistantEnabled: true,
|
||||
});
|
||||
|
||||
const { findByTestId } = render(
|
||||
<TestProviders store={store}>
|
||||
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
|
||||
|
@ -203,25 +186,4 @@ describe('global header', () => {
|
|||
|
||||
waitFor(() => expect(findByTestId('assistantHeaderLink')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('does not show AI Assistant header link if user does not have necessary privileges', () => {
|
||||
(useLocation as jest.Mock).mockReturnValue([
|
||||
{ pageName: SecurityPageName.overview, detailName: undefined },
|
||||
]);
|
||||
|
||||
jest.mocked(useAssistantAvailability).mockReturnValue({
|
||||
hasAssistantPrivilege: false,
|
||||
hasConnectorsAllPrivilege: true,
|
||||
hasConnectorsReadPrivilege: true,
|
||||
isAssistantEnabled: true,
|
||||
});
|
||||
|
||||
const { findByTestId } = render(
|
||||
<TestProviders store={store}>
|
||||
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
waitFor(() => expect(findByTestId('assistantHeaderLink')).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
|
|
@ -27,8 +27,7 @@ import { timelineSelectors } from '../../../timelines/store/timeline';
|
|||
import { useShallowEqualSelector } from '../../../common/hooks/use_selector';
|
||||
import { getScopeFromPath, showSourcererByPath } from '../../../common/containers/sourcerer';
|
||||
import { useAddIntegrationsUrl } from '../../../common/hooks/use_add_integrations_url';
|
||||
import { AssistantHeaderLink } from './assistant_header_link';
|
||||
import { useAssistantAvailability } from '../../../assistant/use_assistant_availability';
|
||||
import { AssistantHeaderLink } from '../../../assistant/header_link';
|
||||
|
||||
const BUTTON_ADD_DATA = i18n.translate('xpack.securitySolution.globalHeader.buttonAddData', {
|
||||
defaultMessage: 'Add integrations',
|
||||
|
@ -54,8 +53,6 @@ export const GlobalHeader = React.memo(
|
|||
|
||||
const { href, onClick } = useAddIntegrationsUrl();
|
||||
|
||||
const { hasAssistantPrivilege } = useAssistantAvailability();
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderActionMenu((element) => {
|
||||
const mount = toMountPoint(<OutPortal node={portalNode} />, { theme$: theme.theme$ });
|
||||
|
@ -91,7 +88,7 @@ export const GlobalHeader = React.memo(
|
|||
{showSourcerer && !showTimeline && (
|
||||
<Sourcerer scope={sourcererScope} data-test-subj="sourcerer" />
|
||||
)}
|
||||
{hasAssistantPrivilege && <AssistantHeaderLink />}
|
||||
<AssistantHeaderLink />
|
||||
</EuiHeaderLinks>
|
||||
</EuiHeaderSectionItem>
|
||||
</EuiHeaderSection>
|
||||
|
|
|
@ -29,6 +29,7 @@ import { useUpdateExecutionContext } from '../../common/hooks/use_update_executi
|
|||
import { useUpgradeSecurityPackages } from '../../detection_engine/rule_management/logic/use_upgrade_security_packages';
|
||||
import { useSetupDetectionEngineHealthApi } from '../../detection_engine/rule_monitoring';
|
||||
import { TopValuesPopover } from '../components/top_values_popover/top_values_popover';
|
||||
import { AssistantOverlay } from '../../assistant/overlay';
|
||||
|
||||
interface HomePageProps {
|
||||
children: React.ReactNode;
|
||||
|
@ -63,6 +64,7 @@ const HomePageComponent: React.FC<HomePageProps> = ({ children, setHeaderActionM
|
|||
</DragDropContextWrapper>
|
||||
<HelpMenu />
|
||||
<TopValuesPopover />
|
||||
<AssistantOverlay />
|
||||
</>
|
||||
</TourContextProvider>
|
||||
</ConsoleManager>
|
||||
|
|
|
@ -7,10 +7,6 @@
|
|||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const ASSISTANT_TITLE = i18n.translate('xpack.securitySolution.assistant.title', {
|
||||
defaultMessage: 'Elastic AI Assistant',
|
||||
});
|
||||
|
||||
export const OVERVIEW = i18n.translate('xpack.securitySolution.navigation.overview', {
|
||||
defaultMessage: 'Overview',
|
||||
});
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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 { AssistantHeaderLink } from './header_link';
|
||||
|
||||
const mockShowAssistantOverlay = jest.fn();
|
||||
const mockAssistantAvailability = jest.fn(() => ({
|
||||
hasAssistantPrivilege: true,
|
||||
}));
|
||||
jest.mock('@kbn/elastic-assistant/impl/assistant_context', () => ({
|
||||
useAssistantContext: () => ({
|
||||
assistantAvailability: mockAssistantAvailability(),
|
||||
showAssistantOverlay: mockShowAssistantOverlay,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('AssistantHeaderLink', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render the header link text', () => {
|
||||
const { queryByText, queryByTestId } = render(<AssistantHeaderLink />);
|
||||
expect(queryByTestId('assistantHeaderLink')).toBeInTheDocument();
|
||||
expect(queryByText('AI Assistant')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render the header link if not authorized', () => {
|
||||
mockAssistantAvailability.mockReturnValueOnce({ hasAssistantPrivilege: false });
|
||||
|
||||
const { queryByText, queryByTestId } = render(<AssistantHeaderLink />);
|
||||
expect(queryByTestId('assistantHeaderLink')).not.toBeInTheDocument();
|
||||
expect(queryByText('AI Assistant')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call the assistant overlay to show on click', () => {
|
||||
const { queryByTestId } = render(<AssistantHeaderLink />);
|
||||
queryByTestId('assistantHeaderLink')?.click();
|
||||
expect(mockShowAssistantOverlay).toHaveBeenCalledTimes(1);
|
||||
expect(mockShowAssistantOverlay).toHaveBeenCalledWith({ showOverlay: true });
|
||||
});
|
||||
});
|
|
@ -14,43 +14,42 @@ import { AssistantAvatar } from '@kbn/elastic-assistant';
|
|||
|
||||
const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0;
|
||||
|
||||
const TOOLTIP_CONTENT = i18n.translate(
|
||||
'xpack.securitySolution.globalHeader.assistantHeaderLinkShortcutTooltip',
|
||||
{
|
||||
values: { keyboardShortcut: isMac ? '⌘ ;' : 'Ctrl ;' },
|
||||
defaultMessage: 'Keyboard shortcut {keyboardShortcut}',
|
||||
}
|
||||
);
|
||||
const LINK_LABEL = i18n.translate('xpack.securitySolution.globalHeader.assistantHeaderLink', {
|
||||
defaultMessage: 'AI Assistant',
|
||||
});
|
||||
|
||||
/**
|
||||
* Elastic AI Assistant header link
|
||||
*/
|
||||
export const AssistantHeaderLink = React.memo(() => {
|
||||
const { showAssistantOverlay } = useAssistantContext();
|
||||
|
||||
const keyboardShortcut = isMac ? '⌘ ;' : 'Ctrl ;';
|
||||
|
||||
const tooltipContent = i18n.translate(
|
||||
'xpack.securitySolution.globalHeader.assistantHeaderLinkShortcutTooltip',
|
||||
{
|
||||
values: { keyboardShortcut },
|
||||
defaultMessage: 'Keyboard shortcut {keyboardShortcut}',
|
||||
}
|
||||
);
|
||||
export const AssistantHeaderLink = () => {
|
||||
const { showAssistantOverlay, assistantAvailability } = useAssistantContext();
|
||||
|
||||
const showOverlay = useCallback(
|
||||
() => showAssistantOverlay({ showOverlay: true }),
|
||||
[showAssistantOverlay]
|
||||
);
|
||||
|
||||
if (!assistantAvailability.hasAssistantPrivilege) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiToolTip content={tooltipContent}>
|
||||
<EuiToolTip content={TOOLTIP_CONTENT}>
|
||||
<EuiHeaderLink data-test-subj="assistantHeaderLink" color="primary" onClick={showOverlay}>
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<AssistantAvatar size="xs" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
{i18n.translate('xpack.securitySolution.globalHeader.assistantHeaderLink', {
|
||||
defaultMessage: 'AI Assistant',
|
||||
})}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>{LINK_LABEL}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiHeaderLink>
|
||||
</EuiToolTip>
|
||||
);
|
||||
});
|
||||
|
||||
AssistantHeaderLink.displayName = 'AssistantHeaderLink';
|
||||
};
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* 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 { AssistantOverlay } from './overlay';
|
||||
|
||||
const mockAssistantAvailability = jest.fn(() => ({
|
||||
hasAssistantPrivilege: true,
|
||||
}));
|
||||
jest.mock('@kbn/elastic-assistant', () => ({
|
||||
AssistantOverlay: () => <div data-test-subj="assistantOverlay" />,
|
||||
useAssistantContext: () => ({
|
||||
assistantAvailability: mockAssistantAvailability(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('AssistantOverlay', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render the header link text', () => {
|
||||
const { queryByTestId } = render(<AssistantOverlay />);
|
||||
expect(queryByTestId('assistantOverlay')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render the header link if not authorized', () => {
|
||||
mockAssistantAvailability.mockReturnValueOnce({ hasAssistantPrivilege: false });
|
||||
|
||||
const { queryByTestId } = render(<AssistantOverlay />);
|
||||
expect(queryByTestId('assistantOverlay')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* 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 {
|
||||
AssistantOverlay as ElasticAssistantOverlay,
|
||||
useAssistantContext,
|
||||
} from '@kbn/elastic-assistant';
|
||||
|
||||
export const AssistantOverlay: React.FC = () => {
|
||||
const { assistantAvailability } = useAssistantContext();
|
||||
if (!assistantAvailability.hasAssistantPrivilege) {
|
||||
return null;
|
||||
}
|
||||
return <ElasticAssistantOverlay />;
|
||||
};
|
|
@ -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, { useCallback } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { AssistantProvider as ElasticAssistantProvider } from '@kbn/elastic-assistant';
|
||||
import { useKibana } from '../common/lib/kibana';
|
||||
import { useAssistantTelemetry } from './use_assistant_telemetry';
|
||||
import { getComments } from './get_comments';
|
||||
import { augmentMessageCodeBlocks, LOCAL_STORAGE_KEY } from './helpers';
|
||||
import { useConversationStore } from './use_conversation_store';
|
||||
import { DEFAULT_ALLOW, DEFAULT_ALLOW_REPLACEMENT } from './content/anonymization';
|
||||
import { PROMPT_CONTEXTS } from './content/prompt_contexts';
|
||||
import { BASE_SECURITY_QUICK_PROMPTS } from './content/quick_prompts';
|
||||
import { BASE_SECURITY_SYSTEM_PROMPTS } from './content/prompts/system';
|
||||
import { useAnonymizationStore } from './use_anonymization_store';
|
||||
import { useAssistantAvailability } from './use_assistant_availability';
|
||||
import { APP_ID } from '../../common/constants';
|
||||
|
||||
const ASSISTANT_TITLE = i18n.translate('xpack.securitySolution.assistant.title', {
|
||||
defaultMessage: 'Elastic AI Assistant',
|
||||
});
|
||||
|
||||
/**
|
||||
* This component configures the Elastic AI Assistant context provider for the Security Solution app.
|
||||
*/
|
||||
export const AssistantProvider: React.FC = ({ children }) => {
|
||||
const {
|
||||
http,
|
||||
triggersActionsUi: { actionTypeRegistry },
|
||||
docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION },
|
||||
} = useKibana().services;
|
||||
|
||||
const { conversations, setConversations } = useConversationStore();
|
||||
const getInitialConversation = useCallback(() => {
|
||||
return conversations;
|
||||
}, [conversations]);
|
||||
|
||||
const assistantAvailability = useAssistantAvailability();
|
||||
const assistantTelemetry = useAssistantTelemetry();
|
||||
|
||||
const { defaultAllow, defaultAllowReplacement, setDefaultAllow, setDefaultAllowReplacement } =
|
||||
useAnonymizationStore();
|
||||
|
||||
const nameSpace = `${APP_ID}.${LOCAL_STORAGE_KEY}`;
|
||||
|
||||
return (
|
||||
<ElasticAssistantProvider
|
||||
actionTypeRegistry={actionTypeRegistry}
|
||||
augmentMessageCodeBlocks={augmentMessageCodeBlocks}
|
||||
assistantAvailability={assistantAvailability}
|
||||
assistantLangChain={false}
|
||||
assistantTelemetry={assistantTelemetry}
|
||||
defaultAllow={defaultAllow}
|
||||
defaultAllowReplacement={defaultAllowReplacement}
|
||||
docLinks={{ ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }}
|
||||
baseAllow={DEFAULT_ALLOW}
|
||||
baseAllowReplacement={DEFAULT_ALLOW_REPLACEMENT}
|
||||
basePromptContexts={Object.values(PROMPT_CONTEXTS)}
|
||||
baseQuickPrompts={BASE_SECURITY_QUICK_PROMPTS}
|
||||
baseSystemPrompts={BASE_SECURITY_SYSTEM_PROMPTS}
|
||||
getInitialConversations={getInitialConversation}
|
||||
getComments={getComments}
|
||||
http={http}
|
||||
nameSpace={nameSpace}
|
||||
setConversations={setConversations}
|
||||
setDefaultAllow={setDefaultAllow}
|
||||
setDefaultAllowReplacement={setDefaultAllowReplacement}
|
||||
title={ASSISTANT_TITLE}
|
||||
>
|
||||
{children}
|
||||
</ElasticAssistantProvider>
|
||||
);
|
||||
};
|
|
@ -5,13 +5,11 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { AssistantOverlay } from '@kbn/elastic-assistant';
|
||||
import classNames from 'classnames';
|
||||
import React, { useEffect } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import type { CommonProps } from '@elastic/eui';
|
||||
|
||||
import { useAssistantAvailability } from '../../../assistant/use_assistant_availability';
|
||||
import { useGlobalFullScreen } from '../../containers/use_full_screen';
|
||||
import { AppGlobalStyle } from '../page';
|
||||
|
||||
|
@ -42,7 +40,6 @@ interface SecuritySolutionPageWrapperProps {
|
|||
const SecuritySolutionPageWrapperComponent: React.FC<
|
||||
SecuritySolutionPageWrapperProps & CommonProps
|
||||
> = ({ children, className, style, noPadding, noTimeline, ...otherProps }) => {
|
||||
const { isAssistantEnabled, hasAssistantPrivilege } = useAssistantAvailability();
|
||||
const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen();
|
||||
useEffect(() => {
|
||||
setGlobalFullScreen(false); // exit full screen mode on page load
|
||||
|
@ -59,7 +56,6 @@ const SecuritySolutionPageWrapperComponent: React.FC<
|
|||
<Wrapper className={classes} style={style} {...otherProps}>
|
||||
{children}
|
||||
<AppGlobalStyle />
|
||||
{hasAssistantPrivilege && <AssistantOverlay isAssistantEnabled={isAssistantEnabled} />}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -100,33 +100,19 @@ interface BasicTimelineTab {
|
|||
}
|
||||
|
||||
const AssistantTab: React.FC<{
|
||||
isAssistantEnabled: boolean;
|
||||
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
|
||||
rowRenderers: RowRenderer[];
|
||||
timelineId: TimelineId;
|
||||
shouldRefocusPrompt: boolean;
|
||||
setConversationId: Dispatch<SetStateAction<string>>;
|
||||
}> = memo(
|
||||
({
|
||||
isAssistantEnabled,
|
||||
renderCellValue,
|
||||
rowRenderers,
|
||||
timelineId,
|
||||
shouldRefocusPrompt,
|
||||
setConversationId,
|
||||
}) => (
|
||||
<Suspense fallback={<EuiSkeletonText lines={10} />}>
|
||||
<AssistantTabContainer>
|
||||
<Assistant
|
||||
isAssistantEnabled={isAssistantEnabled}
|
||||
conversationId={TIMELINE_CONVERSATION_TITLE}
|
||||
setConversationId={setConversationId}
|
||||
shouldRefocusPrompt={shouldRefocusPrompt}
|
||||
/>
|
||||
</AssistantTabContainer>
|
||||
</Suspense>
|
||||
)
|
||||
);
|
||||
}> = memo(({ shouldRefocusPrompt, setConversationId }) => (
|
||||
<Suspense fallback={<EuiSkeletonText lines={10} />}>
|
||||
<AssistantTabContainer>
|
||||
<Assistant
|
||||
conversationId={TIMELINE_CONVERSATION_TITLE}
|
||||
setConversationId={setConversationId}
|
||||
shouldRefocusPrompt={shouldRefocusPrompt}
|
||||
/>
|
||||
</AssistantTabContainer>
|
||||
</Suspense>
|
||||
));
|
||||
|
||||
AssistantTab.displayName = 'AssistantTab';
|
||||
|
||||
|
@ -147,7 +133,7 @@ const ActiveTimelineTab = memo<ActiveTimelineTabProps>(
|
|||
showTimeline,
|
||||
}) => {
|
||||
const isDiscoverInTimelineEnabled = useIsExperimentalFeatureEnabled('discoverInTimeline');
|
||||
const { hasAssistantPrivilege, isAssistantEnabled } = useAssistantAvailability();
|
||||
const { hasAssistantPrivilege } = useAssistantAvailability();
|
||||
const getTab = useCallback(
|
||||
(tab: TimelineTabs) => {
|
||||
switch (tab) {
|
||||
|
@ -235,10 +221,6 @@ const ActiveTimelineTab = memo<ActiveTimelineTabProps>(
|
|||
{(activeTimelineTab === TimelineTabs.securityAssistant ||
|
||||
hasTimelineConversationStarted) && (
|
||||
<AssistantTab
|
||||
isAssistantEnabled={isAssistantEnabled}
|
||||
renderCellValue={renderCellValue}
|
||||
rowRenderers={rowRenderers}
|
||||
timelineId={timelineId}
|
||||
setConversationId={setConversationId}
|
||||
shouldRefocusPrompt={
|
||||
showTimeline && activeTimelineTab === TimelineTabs.securityAssistant
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue