diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx index fbaa00504afbc..95085ac8ef8d6 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx @@ -50,6 +50,9 @@ interface OwnProps { } type Props = OwnProps; + +export const AI_ASSISTANT_SETTINGS_MENU_CONTAINER_ID = 'aiAssistantSettingsMenuContainer'; + /** * Renders the header of the Elastic AI Assistant. * Provide a user interface for selecting and managing conversations, @@ -170,7 +173,7 @@ export const AssistantHeader: React.FC = ({ onConnectorSelected={onConversationChange} /> - + diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_header/translations.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_header/translations.ts index 5589b6a274531..d38eb73b28939 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_header/translations.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_header/translations.ts @@ -66,7 +66,7 @@ export const SHOW_REAL_VALUES = i18n.translate( export const ANONYMIZE_VALUES = i18n.translate( 'xpack.elasticAssistant.assistant.settings.anonymizeValues', { - defaultMessage: 'Show anonymize values', + defaultMessage: 'Show anonymized values', } ); @@ -89,7 +89,7 @@ const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; export const ANONYMIZE_VALUES_TOOLTIP = i18n.translate( 'xpack.elasticAssistant.assistant.settings.anonymizeValues.tooltip', { - values: { keyboardShortcut: isMac ? '⌥ a' : 'Alt a' }, + values: { keyboardShortcut: isMac ? '⌥ + a' : 'Alt + a' }, defaultMessage: 'Toggle to reveal or hide field values in your chat stream. The data sent to the LLM is still anonymized based on settings in the Anonymization panel. Keyboard shortcut: {keyboardShortcut}', } @@ -98,7 +98,7 @@ export const ANONYMIZE_VALUES_TOOLTIP = i18n.translate( export const SHOW_CITATIONS_TOOLTIP = i18n.translate( 'xpack.elasticAssistant.assistant.settings.showCitationsLabel.tooltip', { - values: { keyboardShortcut: isMac ? '⌥ c' : 'Alt c' }, + values: { keyboardShortcut: isMac ? '⌥ + c' : 'Alt + c' }, defaultMessage: 'Keyboard shortcut: {keyboardShortcut}', } ); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/index.tsx index 0ca54a878e813..399739099109c 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/index.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/index.tsx @@ -50,6 +50,7 @@ import { ConnectorMissingCallout } from '../connectorland/connector_missing_call import { ConversationSidePanel } from './conversations/conversation_sidepanel'; import { SelectedPromptContexts } from './prompt_editor/selected_prompt_contexts'; import { AssistantHeader } from './assistant_header'; +import { AnonymizedValuesAndCitationsTour } from '../tour/anonymized_values_and_citations_tour'; export const CONVERSATION_SIDE_PANEL_WIDTH = 220; @@ -448,196 +449,201 @@ const AssistantComponent: React.FC = ({ ); return ( - - {chatHistoryVisible && ( - - - + <> + {contentReferencesEnabled && ( + )} - - - + {chatHistoryVisible && ( + - + + )} + + + - - - - {/* Create portals for each EuiCodeBlock to add the `Investigate in Timeline` action */} - {createCodeBlockPortals()} - - div { - display: flex; - flex-direction: column; - align-items: stretch; - - > .euiFlyoutBody__banner { - overflow-x: unset; - } + max-width: 100%; + `} + > + + + + {/* Create portals for each EuiCodeBlock to add the `Investigate in Timeline` action */} + {createCodeBlockPortals()} + + .euiFlyoutBody__overflowContent { + > div { display: flex; - flex: 1; - overflow: auto; + flex-direction: column; + align-items: stretch; + + > .euiFlyoutBody__banner { + overflow-x: unset; + } + + > .euiFlyoutBody__overflowContent { + display: flex; + flex: 1; + overflow: auto; + } } + `} + banner={ + !isDisabled && + showMissingConnectorCallout && + isFetchedConnectors && ( + 0} + isSettingsModalVisible={isSettingsModalVisible} + setIsSettingsModalVisible={setIsSettingsModalVisible} + /> + ) } - `} - banner={ - !isDisabled && - showMissingConnectorCallout && - isFetchedConnectors && ( - 0} - isSettingsModalVisible={isSettingsModalVisible} - setIsSettingsModalVisible={setIsSettingsModalVisible} - /> - ) - } - > - - - - + + + - {!isDisabled && - Object.keys(promptContexts).length !== selectedPromptContextsCount && ( - - - <> - - {Object.keys(promptContexts).length > 0 && } - + + {!isDisabled && + Object.keys(promptContexts).length !== selectedPromptContextsCount && ( + + + <> + + {Object.keys(promptContexts).length > 0 && } + + + + )} + + + {Object.keys(selectedPromptContexts).length ? ( + + - - )} + ) : null} - - {Object.keys(selectedPromptContexts).length ? ( - - ) : null} - - - - - - - - {!isDisabled && ( - - + - )} - - - - - - + + {!isDisabled && ( + + + + )} + + + + + + + ); }; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/mock/conversation.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/mock/conversation.ts index bde74fe8d744f..5721e4788154b 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/mock/conversation.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/mock/conversation.ts @@ -6,7 +6,7 @@ */ import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants'; -import { Conversation } from '../..'; +import { ClientMessage, Conversation } from '../..'; export const alertConvo: Conversation = { id: '', @@ -33,6 +33,20 @@ export const alertConvo: Conversation = { }, }; +export const messageWithContentReferences: ClientMessage = { + content: 'You have 1 alert.{reference(abcde)}', + role: 'user', + timestamp: '2023-03-19T18:59:18.174Z', + metadata: { + contentReferences: { + abcde: { + id: 'abcde', + type: 'SecurityAlertsPage', + }, + }, + }, +}; + export const emptyWelcomeConvo: Conversation = { id: '', title: 'Welcome', @@ -47,6 +61,11 @@ export const emptyWelcomeConvo: Conversation = { }, }; +export const conversationWithContentReferences: Conversation = { + ...emptyWelcomeConvo, + messages: [messageWithContentReferences], +}; + export const welcomeConvo: Conversation = { ...emptyWelcomeConvo, messages: [ diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/index.test.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/index.test.tsx new file mode 100644 index 0000000000000..179b473e55804 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/index.test.tsx @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import { AnonymizedValuesAndCitationsTour } from '.'; +import React from 'react'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { + alertConvo, + conversationWithContentReferences, + welcomeConvo, +} from '../../mock/conversation'; +import { I18nProvider } from '@kbn/i18n-react'; +import { TourState } from '../knowledge_base'; + +jest.mock('react-use/lib/useLocalStorage', () => jest.fn()); + +jest.mock('lodash', () => ({ + ...jest.requireActual('lodash'), + throttle: jest.fn().mockImplementation((fn) => fn), +})); + +const mockGetItem = jest.fn(); +Object.defineProperty(window, 'localStorage', { + value: { + getItem: (...args: string[]) => mockGetItem(...args), + }, +}); + +const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + +
+
+ {children} +
+ +); + +describe('AnonymizedValuesAndCitationsTour', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + it('renders tour when there are content references', async () => { + (useLocalStorage as jest.Mock).mockReturnValue([false, jest.fn()]); + + mockGetItem.mockReturnValue( + JSON.stringify({ + currentTourStep: 2, + isTourActive: true, + } as TourState) + ); + + render(, { + wrapper: Wrapper, + }); + + jest.runAllTimers(); + + await waitFor(() => { + expect(screen.getByTestId('anonymizedValuesAndCitationsTourStep')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('anonymizedValuesAndCitationsTourStepPanel')).toBeInTheDocument(); + }); + + it('renders tour when there are replacements', async () => { + (useLocalStorage as jest.Mock).mockReturnValue([false, jest.fn()]); + + mockGetItem.mockReturnValue( + JSON.stringify({ + currentTourStep: 2, + isTourActive: true, + } as TourState) + ); + + render(, { + wrapper: Wrapper, + }); + + jest.runAllTimers(); + + await waitFor(() => { + expect(screen.getByTestId('anonymizedValuesAndCitationsTourStep')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('anonymizedValuesAndCitationsTourStepPanel')).toBeInTheDocument(); + }); + + it('does not render tour if it has already been shown', async () => { + (useLocalStorage as jest.Mock).mockReturnValue([true, jest.fn()]); + + mockGetItem.mockReturnValue( + JSON.stringify({ + currentTourStep: 2, + isTourActive: true, + } as TourState) + ); + + render(, { + wrapper: Wrapper, + }); + + jest.runAllTimers(); + + await waitFor(() => { + expect(screen.getByTestId('anonymizedValuesAndCitationsTourStep')).toBeInTheDocument(); + }); + + expect( + screen.queryByTestId('anonymizedValuesAndCitationsTourStepPanel') + ).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()]); + + mockGetItem.mockReturnValue( + JSON.stringify({ + currentTourStep: 1, + isTourActive: true, + } as TourState) + ); + + render(, { + wrapper: Wrapper, + }); + + jest.runAllTimers(); + + await waitFor(() => { + expect(screen.getByTestId('anonymizedValuesAndCitationsTourStep')).toBeInTheDocument(); + }); + + expect( + screen.queryByTestId('anonymizedValuesAndCitationsTourStepPanel') + ).not.toBeInTheDocument(); + }); + + it('does not render tour if there are no content references or replacements', async () => { + (useLocalStorage as jest.Mock).mockReturnValue([false, jest.fn()]); + + mockGetItem.mockReturnValue( + JSON.stringify({ + currentTourStep: 2, + isTourActive: true, + } as TourState) + ); + + render(, { + wrapper: Wrapper, + }); + + jest.runAllTimers(); + + await waitFor(() => { + expect(screen.getByTestId('anonymizedValuesAndCitationsTourStep')).toBeInTheDocument(); + }); + + expect( + screen.queryByTestId('anonymizedValuesAndCitationsTourStepPanel') + ).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/index.tsx new file mode 100644 index 0000000000000..da2bbc7469b46 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/index.tsx @@ -0,0 +1,91 @@ +/* + * 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 { EuiTourStep } from '@elastic/eui'; +import React, { useCallback, useEffect, useState } from 'react'; +import { isEmpty, throttle } from 'lodash'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { Conversation } from '../../assistant_context/types'; +import { NEW_FEATURES_TOUR_STORAGE_KEYS } from '../const'; +import { anonymizedValuesAndCitationsTourStep1 } from './step_config'; +import { TourState } from '../knowledge_base'; + +interface Props { + conversation: Conversation | undefined; +} + +// 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); + if (value) { + return JSON.parse(value) as TourState; + } + return undefined; +}, 5000); + +export const AnonymizedValuesAndCitationsTour: React.FC = ({ conversation }) => { + const [tourCompleted, setTourCompleted] = useLocalStorage( + NEW_FEATURES_TOUR_STORAGE_KEYS.ANONYMIZED_VALUES_AND_CITATIONS, + false + ); + + const [showTour, setShowTour] = useState(false); + + useEffect(() => { + if (showTour || !conversation || tourCompleted) { + return; + } + + const knowledgeBaseTourState = getKnowledgeBaseTourStateThrottled(); + + // 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) { + return; + } + + const containsContentReferences = conversation.messages.some( + (message) => !isEmpty(message.metadata?.contentReferences) + ); + const containsReplacements = !isEmpty(conversation.replacements); + + if (containsContentReferences || containsReplacements) { + const timer = setTimeout(() => { + setShowTour(true); + }, 1000); + + return () => { + clearTimeout(timer); + }; + } + }, [conversation, tourCompleted, showTour]); + + const finishTour = useCallback(() => { + setTourCompleted(true); + setShowTour(false); + }, [setTourCompleted, setShowTour]); + + return ( + + ); +}; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/step_config.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/step_config.tsx new file mode 100644 index 0000000000000..dad5326f0be2c --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/step_config.tsx @@ -0,0 +1,50 @@ +/* + * 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 { EuiText, EuiSpacer } from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { AI_ASSISTANT_SETTINGS_MENU_CONTAINER_ID } from '../../assistant/assistant_header'; + +const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; + +export const anonymizedValuesAndCitationsTourStep1 = { + title: ( + + ), + subTitle: ( + + ), + anchor: `#${AI_ASSISTANT_SETTINGS_MENU_CONTAINER_ID}`, + content: ( + + {str}, + }} + /> + + {str}, + }} + /> + + ), +}; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/const.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/const.ts index 1c79500792ba6..91380d73cca5e 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/const.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/const.ts @@ -7,4 +7,6 @@ export const NEW_FEATURES_TOUR_STORAGE_KEYS = { KNOWLEDGE_BASE: 'elasticAssistant.knowledgeBase.newFeaturesTour.v8.16', + ANONYMIZED_VALUES_AND_CITATIONS: + 'elasticAssistant.anonymizedValuesAndCitationsTourCompleted.v8.18', }; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/knowledge_base/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/knowledge_base/index.tsx index 8d71b4491a2fd..bc2c8b344fbfd 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/knowledge_base/index.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/knowledge_base/index.tsx @@ -20,7 +20,7 @@ import { NEW_FEATURES_TOUR_STORAGE_KEYS } from '../const'; import { knowledgeBaseTourStepOne, tourConfig } from './step_config'; import * as i18n from './translations'; -interface TourState { +export interface TourState { currentTourStep: number; isTourActive: boolean; } diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/content_reference_parser.ts b/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/content_reference_parser.ts index 74ff7803ce3e0..247cd10fb45c4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/content_reference_parser.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/content_reference_parser.ts @@ -69,7 +69,7 @@ export const ContentReferenceParser: Plugin = function ContentReferenceParser() const contentReferenceId = readArg('(', ')'); - const closeChar = value[index++]; + const closeChar = value[index]; if (closeChar !== '}') return false; const now = eat.now();