From 572e6656d1dcd0e5a54b026fcda9d0a277c8357f Mon Sep 17 00:00:00 2001 From: Kenneth Kreindler <42113355+KDKHD@users.noreply.github.com> Date: Mon, 3 Feb 2025 22:47:41 +0000 Subject: [PATCH] [Security Solution] [AI Assistant] security assistant content references tour (#208775) ## Summary Follow up to : https://github.com/elastic/kibana/pull/206683 This PR adds a tour that tells the user how to toggle citations on and off and how to show and hide anonymized values. ### How to test: - Enable feature flag: ```yaml # kibana.dev.yml xpack.securitySolution.enableExperimental: ['contentReferencesEnabled'] ``` - Launch the security AI assistant - Now we need to get the assistant to reply with a message that contains either anonymized values or citations. This is what triggers the tour. To do this ask it a question about one of your KB documents or an alert that contains anonymized properties or returns a citation. - Once the assistant stream ends, the tour should appear 1 second later (unless the knowledge base tour is open). The tour will only appear one time per browser. To make it appear again, clear the key `elasticAssistant.anonymizedValuesAndCitationsTourCompleted` from local storage. Also fixes a [typo](https://github.com/elastic/kibana/pull/208775/files#diff-e6ed566edfccebe7592cb2491ae0a601c2c54da879114e6100602b8b08099ca6R69). https://github.com/user-attachments/assets/97fca992-d39d-43e7-8e73-a11daf7549ca ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [x] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) ### Identify risks Does this PR introduce any risks? For example, consider risks like hard to test bugs, performance regression, potential of data loss. Describe the risk, its severity, and mitigation for each identified risk. Invite stakeholders and evaluate how to proceed before merging. - [ ] [See some risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) - [ ] ... --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Elastic Machine Co-authored-by: Steph Milovic --- .../impl/assistant/assistant_header/index.tsx | 5 +- .../assistant_header/translations.ts | 6 +- .../impl/assistant/index.tsx | 344 +++++++++--------- .../impl/mock/conversation.ts | 21 +- .../index.test.tsx | 169 +++++++++ .../index.tsx | 91 +++++ .../step_config.tsx | 50 +++ .../kbn-elastic-assistant/impl/tour/const.ts | 2 + .../impl/tour/knowledge_base/index.tsx | 2 +- .../content_reference_parser.ts | 2 +- 10 files changed, 516 insertions(+), 176 deletions(-) create mode 100644 x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/index.test.tsx create mode 100644 x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/index.tsx create mode 100644 x-pack/platform/packages/shared/kbn-elastic-assistant/impl/tour/anonymized_values_and_citations_tour/step_config.tsx 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();