diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 15de2959eece..c66928677ee8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -845,6 +845,7 @@ x-pack/platform/packages/shared/kbn-content-packs-schema @elastic/streams-progra x-pack/platform/packages/shared/kbn-data-forge @elastic/obs-ux-management-team x-pack/platform/packages/shared/kbn-elastic-assistant @elastic/security-generative-ai x-pack/platform/packages/shared/kbn-elastic-assistant-common @elastic/security-generative-ai +x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state @elastic/security-generative-ai x-pack/platform/packages/shared/kbn-entities-schema @elastic/obs-entities x-pack/platform/packages/shared/kbn-event-stacktrace @elastic/obs-ux-infra_services-team @elastic/obs-ux-logs-team x-pack/platform/packages/shared/kbn-inference-cli @elastic/appex-ai-infra @@ -1082,6 +1083,7 @@ x-pack/solutions/security/packages/upselling @elastic/security-threat-hunting-in x-pack/solutions/security/plugins/cloud_security_posture @elastic/kibana-cloud-security-posture x-pack/solutions/security/plugins/ecs_data_quality_dashboard @elastic/security-threat-hunting-investigations x-pack/solutions/security/plugins/elastic_assistant @elastic/security-generative-ai +x-pack/solutions/security/plugins/elastic_assistant_shared_state @elastic/security-generative-ai x-pack/solutions/security/plugins/lists @elastic/security-detection-engine x-pack/solutions/security/plugins/security_solution @elastic/security-solution x-pack/solutions/security/plugins/security_solution_ess @elastic/security-solution diff --git a/docs/extend/plugin-list.md b/docs/extend/plugin-list.md index b9f5471c8b9d..05110c68b9a1 100644 --- a/docs/extend/plugin-list.md +++ b/docs/extend/plugin-list.md @@ -131,7 +131,8 @@ mapped_pages: | [dataVisualizer](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/data_visualizer/README.md) | The data_visualizer plugin enables you to explore the fields in your data. | | [discoverEnhanced](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/discover_enhanced/README.md) | Contains the enhancements to the OSS discover app. | | [ecsDataQualityDashboard](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/plugins/ecs_data_quality_dashboard/README.md) | This plugin implements (server) APIs used to render the content of the Data Quality dashboard. | -| [elasticAssistant](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/plugins/elastic_assistant/README.md) | This plugin implements (only) server APIs for the Elastic AI Assistant. | +| [elasticAssistant](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/plugins/elastic_assistant/README.md) | This plugin implements server APIs for the Elastic AI Assistant. Furthermore, it registers the Elastic Assistant in the navigation bar. | +| [elasticAssistantSharedState](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/plugins/elastic_assistant_shared_state/README.md) | This plugin acts as a reactive bridge between the elastic assistant plugin and other plugins. It exposes an RxJS-based interface where: | | [embeddableAlertsTable](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/embeddable_alerts_table/README.md) | Embeddable wrapper for the alerts table | | [embeddableEnhanced](enhanced-embeddables-plugin.md) | Enhances Embeddables by registering a custom factory provider. The enhanced factory provider adds dynamic actions to every embeddables state, in order to support drilldowns. | | [encryptedSavedObjects](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/encrypted_saved_objects/README.md) | The purpose of this plugin is to provide a way to encrypt/decrypt attributes on the custom Saved Objects that works with security and spaces filtering. | diff --git a/package.json b/package.json index 55ee5205c49b..50c6134df432 100644 --- a/package.json +++ b/package.json @@ -495,6 +495,8 @@ "@kbn/elastic-assistant": "link:x-pack/platform/packages/shared/kbn-elastic-assistant", "@kbn/elastic-assistant-common": "link:x-pack/platform/packages/shared/kbn-elastic-assistant-common", "@kbn/elastic-assistant-plugin": "link:x-pack/solutions/security/plugins/elastic_assistant", + "@kbn/elastic-assistant-shared-state": "link:x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state", + "@kbn/elastic-assistant-shared-state-plugin": "link:x-pack/solutions/security/plugins/elastic_assistant_shared_state", "@kbn/elasticsearch-client-plugin": "link:src/platform/test/plugin_functional/plugins/elasticsearch_client_plugin", "@kbn/elasticsearch-client-xpack-plugin": "link:x-pack/platform/test/plugin_api_integration/plugins/elasticsearch_client", "@kbn/embeddable-alerts-table-plugin": "link:x-pack/platform/plugins/shared/embeddable_alerts_table", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index dcaff35bbe19..cb6698ae7f4a 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -39,6 +39,8 @@ pageLoadAssetSize: discover: 25000 discoverEnhanced: 42730 discoverShared: 17111 + elasticAssistant: 279115 + elasticAssistantSharedState: 19295 embeddable: 24000 embeddableAlertsTable: 19615 embeddableEnhanced: 22107 @@ -141,7 +143,7 @@ pageLoadAssetSize: searchQueryRules: 19708 searchSynonyms: 20262 security: 81771 - securitySolution: 99000 + securitySolution: 159812 securitySolutionEss: 36000 securitySolutionServerless: 62488 serverless: 16573 diff --git a/tsconfig.base.json b/tsconfig.base.json index 1a6e924e990c..e090fc2158e7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -846,6 +846,10 @@ "@kbn/elastic-assistant-common/*": ["x-pack/platform/packages/shared/kbn-elastic-assistant-common/*"], "@kbn/elastic-assistant-plugin": ["x-pack/solutions/security/plugins/elastic_assistant"], "@kbn/elastic-assistant-plugin/*": ["x-pack/solutions/security/plugins/elastic_assistant/*"], + "@kbn/elastic-assistant-shared-state": ["x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state"], + "@kbn/elastic-assistant-shared-state/*": ["x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/*"], + "@kbn/elastic-assistant-shared-state-plugin": ["x-pack/solutions/security/plugins/elastic_assistant_shared_state"], + "@kbn/elastic-assistant-shared-state-plugin/*": ["x-pack/solutions/security/plugins/elastic_assistant_shared_state/*"], "@kbn/elasticsearch-client-plugin": ["src/platform/test/plugin_functional/plugins/elasticsearch_client_plugin"], "@kbn/elasticsearch-client-plugin/*": ["src/platform/test/plugin_functional/plugins/elasticsearch_client_plugin/*"], "@kbn/elasticsearch-client-xpack-plugin": ["x-pack/platform/test/plugin_api_integration/plugins/elasticsearch_client"], diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/README.md b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/README.md new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/index.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/index.ts new file mode 100644 index 000000000000..50a02a992b51 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/index.ts @@ -0,0 +1,14 @@ +/* + * 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. + */ + +export { CommentsService } from './src/shared_state/comments_service'; +export type { CommentServiceActions } from './src/shared_state/comments_service'; +export { PromptContextService } from './src/shared_state/prompt_contexts'; +export { AssistantContextValueService } from './src/shared_state/assistant_context_value'; +export { AugmentMessageCodeBlocksService } from './src/shared_state/augment_message_code_blocks'; +export type { AugmentMessageCodeBlocks } from './src/shared_state/augment_message_code_blocks'; +export { SignalIndexService } from './src/shared_state/signal_index'; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/jest.config.js b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/jest.config.js new file mode 100644 index 000000000000..45acfdd2e310 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/jest.config.js @@ -0,0 +1,23 @@ +/* + * 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. + */ + +module.exports = { + coverageDirectory: + '/target/kibana-coverage/jest/x-pack/packages/kbn-elastic-assistant-shared-state', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/**/*.{ts,tsx}', + '!/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/{__test__,__snapshots__,__examples__,*mock*,tests,test_helpers,integration_tests,types}/**/*', + '!/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/*mock*.{ts,tsx}', + '!/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/*.test.{ts,tsx}', + '!/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/*.d.ts', + '!/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/*.config.ts', + ], + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state'], +}; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/kibana.jsonc b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/kibana.jsonc new file mode 100644 index 000000000000..503001e3eb83 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/kibana.jsonc @@ -0,0 +1,9 @@ +{ + "type": "shared-browser", + "id": "@kbn/elastic-assistant-shared-state", + "owner": [ + "@elastic/security-generative-ai" + ], + "group": "platform", + "visibility": "shared" +} \ No newline at end of file diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/package.json b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/package.json new file mode 100644 index 000000000000..88f8ee745204 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/elastic-assistant-shared-state", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0", + "sideEffects": false +} \ No newline at end of file diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/setup_tests.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/setup_tests.ts new file mode 100644 index 000000000000..72e0edd0d07f --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/setup_tests.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import '@testing-library/jest-dom'; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/assistant_context_value.test.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/assistant_context_value.test.ts new file mode 100644 index 000000000000..05a1c74a1827 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/assistant_context_value.test.ts @@ -0,0 +1,39 @@ +/* + * 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 { UseAssistantContext } from '@kbn/elastic-assistant/impl/assistant_context'; +import { AssistantContextValueService } from './assistant_context_value'; + +describe('AssistantContextValueService', () => { + it('start returns correct object', () => { + const service = new AssistantContextValueService(); + const result = service.start(); + + expect(result).toEqual({ + setAssistantContextValue: expect.any(Function), + getAssistantContextValue$: expect.any(Function), + }); + + const { setAssistantContextValue, getAssistantContextValue$ } = result; + + const values: Array = []; + getAssistantContextValue$().subscribe((value) => { + values.push(value); + }); + + setAssistantContextValue({ alertsIndexPattern: 'foo' } as UseAssistantContext); + const remove = setAssistantContextValue({ alertsIndexPattern: 'bar' } as UseAssistantContext); + remove(); + + expect(values).toEqual([ + undefined, + { alertsIndexPattern: 'foo' }, + { alertsIndexPattern: 'bar' }, + undefined, + ]); + }); +}); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/assistant_context_value.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/assistant_context_value.ts new file mode 100644 index 000000000000..2907c758ad0d --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/assistant_context_value.ts @@ -0,0 +1,33 @@ +/* + * 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 { UseAssistantContext } from '@kbn/elastic-assistant/impl/assistant_context'; +import { BehaviorSubject, ReplaySubject } from 'rxjs'; +import { takeUntil } from 'rxjs'; + +export class AssistantContextValueService { + private readonly stop$ = new ReplaySubject(1); + + public start() { + const assistantContextValue$ = new BehaviorSubject(undefined); + + return { + setAssistantContextValue: (assistantContextValue: UseAssistantContext) => { + assistantContextValue$.next(assistantContextValue); + return () => { + assistantContextValue$.next(undefined); + }; + }, + + getAssistantContextValue$: () => assistantContextValue$.pipe(takeUntil(this.stop$)), + }; + } + + public stop() { + this.stop$.next(); + } +} diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/augment_message_code_blocks.test.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/augment_message_code_blocks.test.ts new file mode 100644 index 000000000000..cb5435bba699 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/augment_message_code_blocks.test.ts @@ -0,0 +1,75 @@ +/* + * 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 { + AugmentMessageCodeBlocks, + AugmentMessageCodeBlocksService, + defaultValue, +} from './augment_message_code_blocks'; +import { Conversation } from '@kbn/elastic-assistant'; + +describe('AugmentMessageCodeBlocksService', () => { + it('start returns correct object', () => { + const service = new AugmentMessageCodeBlocksService(); + const result = service.start(); + + expect(result).toEqual({ + registerAugmentMessageCodeBlocks: expect.any(Function), + getAugmentMessageCodeBlocks$: expect.any(Function), + }); + }); + + it('registers and unregisters augment message code blocks', () => { + const service = new AugmentMessageCodeBlocksService(); + const { registerAugmentMessageCodeBlocks, getAugmentMessageCodeBlocks$ } = service.start(); + + const values: AugmentMessageCodeBlocks[] = []; + getAugmentMessageCodeBlocks$().subscribe((value) => { + values.push(value); + }); + + const mockConversation = {} as Conversation; + const mockAugmentMessageCodeBlocks: AugmentMessageCodeBlocks = { + mount: jest.fn(({ currentConversation, showAnonymizedValues }) => { + expect(currentConversation).toBe(mockConversation); + expect(showAnonymizedValues).toBe(true); + return jest.fn(); + }), + }; + + const unregister = registerAugmentMessageCodeBlocks(mockAugmentMessageCodeBlocks); + + values[1].mount({ + currentConversation: mockConversation, + showAnonymizedValues: true, + }); + + expect(mockAugmentMessageCodeBlocks.mount).toHaveBeenCalledWith({ + currentConversation: mockConversation, + showAnonymizedValues: true, + }); + + unregister(); + + expect(values).toEqual([defaultValue, mockAugmentMessageCodeBlocks, defaultValue]); + }); + + it('stops the service correctly', () => { + const service = new AugmentMessageCodeBlocksService(); + const { getAugmentMessageCodeBlocks$ } = service.start(); + + let completed = false; + getAugmentMessageCodeBlocks$().subscribe({ + complete: () => { + completed = true; + }, + }); + + service.stop(); + expect(completed).toBe(true); + }); +}); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/augment_message_code_blocks.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/augment_message_code_blocks.ts new file mode 100644 index 000000000000..0c0f7f541ed1 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/augment_message_code_blocks.ts @@ -0,0 +1,45 @@ +/* + * 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 { BehaviorSubject, ReplaySubject } from 'rxjs'; +import { takeUntil } from 'rxjs'; +import { Conversation } from '@kbn/elastic-assistant'; +import { UnmountCallback } from '@kbn/core-mount-utils-browser'; + +export interface AugmentMessageCodeBlocks { + mount: (args: { + currentConversation: Conversation; + showAnonymizedValues: boolean; + }) => UnmountCallback; +} + +export const defaultValue: AugmentMessageCodeBlocks = { + mount: () => () => {}, +}; + +export class AugmentMessageCodeBlocksService { + private readonly stop$ = new ReplaySubject(1); + + public start() { + const augmentMessageCodeBlocks$ = new BehaviorSubject(defaultValue); + + return { + registerAugmentMessageCodeBlocks: (augmentMessageCodeBlocks: AugmentMessageCodeBlocks) => { + augmentMessageCodeBlocks$.next(augmentMessageCodeBlocks); + return () => { + augmentMessageCodeBlocks$.next(defaultValue); + }; + }, + + getAugmentMessageCodeBlocks$: () => augmentMessageCodeBlocks$.pipe(takeUntil(this.stop$)), + }; + } + + public stop() { + this.stop$.next(); + } +} diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/comments_service.test.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/comments_service.test.ts new file mode 100644 index 000000000000..9807ae24547b --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/comments_service.test.ts @@ -0,0 +1,129 @@ +/* + * 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 { CommentsService, CommentServiceActions } from './comments_service'; +import { ClientMessage } from '@kbn/elastic-assistant'; +import { MountPoint } from '@kbn/core-mount-utils-browser'; + +describe('CommentsService', () => { + it('start returns correct object', () => { + const service = new CommentsService(); + const result = service.start(); + + expect(result).toEqual({ + registerActions: expect.any(Function), + getActions$: expect.any(Function), + }); + }); + + it('registers and unregisters comment service actions', () => { + const service = new CommentsService(); + const { registerActions, getActions$ } = service.start(); + + const values: CommentServiceActions[][] = []; + getActions$().subscribe((value) => { + values.push(value); + }); + + // Create mock comment service actions + const mockMessage = {} as ClientMessage; + const mockMountPoint = {} as MountPoint; + + const mockActions1: CommentServiceActions = { + order: 1, + mount: jest.fn(({ message }) => { + expect(message).toBe(mockMessage); + return mockMountPoint; + }), + }; + + const mockActions2: CommentServiceActions = { + order: 2, + mount: jest.fn(() => mockMountPoint), + }; + + // Register the actions + const unregister1 = registerActions(mockActions1); + const unregister2 = registerActions(mockActions2); + + // Test the mount function + const mountPoint = values[2][0].mount({ message: mockMessage }); + expect(mockActions1.mount).toHaveBeenCalledWith({ message: mockMessage }); + expect(mountPoint).toBe(mockMountPoint); + + // Check ordering + expect(values[0].length).toBe(0); + + expect(values[1].length).toBe(1); + expect(values[1][0]).toBe(mockActions1); + + expect(values[2].length).toBe(2); + expect(values[2][0]).toBe(mockActions1); + expect(values[2][1]).toBe(mockActions2); + + // Unregister first action + unregister1(); + expect(values[3].length).toBe(1); + expect(values[3][0]).toBe(mockActions2); + + // Unregister second action + unregister2(); + expect(values[4].length).toBe(0); + }); + + it('sorts actions by order', () => { + const service = new CommentsService(); + const { registerActions, getActions$ } = service.start(); + + const values: CommentServiceActions[][] = []; + getActions$().subscribe((value) => { + values.push(value); + }); + + const mockMountPoint = {} as MountPoint; + + const action3: CommentServiceActions = { + order: 3, + mount: jest.fn(() => mockMountPoint), + }; + + const action1: CommentServiceActions = { + order: 1, + mount: jest.fn(() => mockMountPoint), + }; + + const action2: CommentServiceActions = { + order: 2, + mount: jest.fn(() => mockMountPoint), + }; + + // Register the actions in random order + registerActions(action3); + registerActions(action1); + registerActions(action2); + + // Check that they are sorted by order + expect(values[3][0]).toBe(action1); + expect(values[3][1]).toBe(action2); + expect(values[3][2]).toBe(action3); + }); + + it('stops the service correctly', () => { + const service = new CommentsService(); + const { getActions$ } = service.start(); + + let completed = false; + getActions$().subscribe({ + complete: () => { + completed = true; + }, + }); + + service.stop(); + expect(completed).toBe(true); + }); +}); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/comments_service.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/comments_service.ts new file mode 100644 index 000000000000..59d512b8ffd4 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/comments_service.ts @@ -0,0 +1,45 @@ +/* + * 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 { MountPoint } from '@kbn/core-mount-utils-browser'; +import { BehaviorSubject, ReplaySubject } from 'rxjs'; +import { map, takeUntil } from 'rxjs'; +import { sortBy } from 'lodash'; +import { ClientMessage } from '@kbn/elastic-assistant'; + +export interface CommentServiceActions { + order?: number; + mount: (args: { message: ClientMessage }) => MountPoint; +} + +export class CommentsService { + private readonly stop$ = new ReplaySubject(1); + + public start() { + const actions$ = new BehaviorSubject>(new Set()); + + return { + registerActions: (actions: CommentServiceActions) => { + actions$.next(new Set([...actions$.value.values(), actions])); + return () => { + const newActions = new Set([...actions$.value.values()].filter((a) => a !== actions)); + actions$.next(newActions); + }; + }, + + getActions$: () => + actions$.pipe( + map((actions) => sortBy([...actions.values()], 'order')), + takeUntil(this.stop$) + ), + }; + } + + public stop() { + this.stop$.next(); + } +} diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/prompt_contexts.test.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/prompt_contexts.test.ts new file mode 100644 index 000000000000..1b49ea399836 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/prompt_contexts.test.ts @@ -0,0 +1,73 @@ +/* + * 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 { PromptContextService } from './prompt_contexts'; +import { PromptContextTemplate } from '@kbn/elastic-assistant'; + +describe('PromptContextService', () => { + it('start returns correct object', () => { + const service = new PromptContextService(); + const result = service.start(); + + expect(result).toEqual({ + setPromptContext: expect.any(Function), + getPromptContext$: expect.any(Function), + }); + }); + + it('sets and unsets prompt context', () => { + const service = new PromptContextService(); + const { setPromptContext, getPromptContext$ } = service.start(); + + const values: Array> = []; + getPromptContext$().subscribe((value) => { + values.push(value); + }); + + const mockPromptContext1: Record = { + test1: { + category: 'Test Context 1', + description: 'Test description 1', + tooltip: 'Test tooltip 1', + }, + }; + + const mockPromptContext2: Record = { + test2: { + category: 'Test Context 2', + description: 'Test description 2', + tooltip: 'Test tooltip 2', + }, + }; + + const unset1 = setPromptContext(mockPromptContext1); + setPromptContext(mockPromptContext2); + + unset1(); + + expect(values.length).toBe(4); + expect(values[0]).toEqual({}); + expect(values[1]).toBe(mockPromptContext1); + expect(values[2]).toBe(mockPromptContext2); + expect(values[3]).toEqual({}); + }); + + it('stops the service correctly', () => { + const service = new PromptContextService(); + const { getPromptContext$ } = service.start(); + + let completed = false; + getPromptContext$().subscribe({ + complete: () => { + completed = true; + }, + }); + + service.stop(); + expect(completed).toBe(true); + }); +}); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/prompt_contexts.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/prompt_contexts.ts new file mode 100644 index 000000000000..ddf7b29b6563 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/prompt_contexts.ts @@ -0,0 +1,33 @@ +/* + * 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 { BehaviorSubject, ReplaySubject } from 'rxjs'; +import { takeUntil } from 'rxjs'; +import { PromptContextTemplate } from '@kbn/elastic-assistant'; + +export class PromptContextService { + private readonly stop$ = new ReplaySubject(1); + + public start() { + const promptContext$ = new BehaviorSubject>({}); + + return { + setPromptContext: (promptContext: Record) => { + promptContext$.next(promptContext); + return () => { + promptContext$.next({}); + }; + }, + + getPromptContext$: () => promptContext$.pipe(takeUntil(this.stop$)), + }; + } + + public stop() { + this.stop$.next(); + } +} diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/signal_index.test.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/signal_index.test.ts new file mode 100644 index 000000000000..a9d19e443aac --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/signal_index.test.ts @@ -0,0 +1,74 @@ +/* + * 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 { SignalIndexService } from './signal_index'; + +describe('SignalIndexService', () => { + it('start returns correct object', () => { + const service = new SignalIndexService(); + const result = service.start(); + + expect(result).toEqual({ + setSignalIndex: expect.any(Function), + getSignalIndex$: expect.any(Function), + }); + }); + + it('sets and unsets signal index', () => { + const service = new SignalIndexService(); + const { setSignalIndex, getSignalIndex$ } = service.start(); + + const values: Array = []; + getSignalIndex$().subscribe((value) => { + values.push(value); + }); + + // Set signal index values + setSignalIndex('test-index-1'); + const unset2 = setSignalIndex('test-index-2'); + + // Unset the second signal index + unset2(); + + // Check that we got undefined, then our values, then undefined again after unsetting + expect(values).toEqual([undefined, 'test-index-1', 'test-index-2', undefined]); + }); + + it('handles undefined signal index', () => { + const service = new SignalIndexService(); + const { setSignalIndex, getSignalIndex$ } = service.start(); + + const values: Array = []; + getSignalIndex$().subscribe((value) => { + values.push(value); + }); + + // Set signal index to undefined + const unset = setSignalIndex(undefined); + + // Unset + unset(); + + // Check that we got undefined throughout + expect(values).toEqual([undefined, undefined, undefined]); + }); + + it('stops the service correctly', () => { + const service = new SignalIndexService(); + const { getSignalIndex$ } = service.start(); + + let completed = false; + getSignalIndex$().subscribe({ + complete: () => { + completed = true; + }, + }); + + service.stop(); + expect(completed).toBe(true); + }); +}); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/signal_index.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/signal_index.ts new file mode 100644 index 000000000000..092c44b79d73 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/shared_state/signal_index.ts @@ -0,0 +1,32 @@ +/* + * 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 { BehaviorSubject, ReplaySubject } from 'rxjs'; +import { takeUntil } from 'rxjs'; + +export class SignalIndexService { + private readonly stop$ = new ReplaySubject(1); + + public start() { + const signalIndex$ = new BehaviorSubject(undefined); + + return { + setSignalIndex: (signalIndex: string | undefined) => { + signalIndex$.next(signalIndex); + return () => { + signalIndex$.next(undefined); + }; + }, + + getSignalIndex$: () => signalIndex$.pipe(takeUntil(this.stop$)), + }; + } + + public stop() { + this.stop$.next(); + } +} diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/tsconfig.json b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/tsconfig.json new file mode 100644 index 000000000000..0b00cddd5aa1 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react", + "@kbn/ambient-ui-types", + "@emotion/react/types/css-prop" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + "../../../../../typings/emotion.d.ts" + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/elastic-assistant", + "@kbn/core-mount-utils-browser", + ] +} 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 dc9ad8503990..e9c494e0d7c0 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 @@ -25,7 +25,6 @@ import { EuiFlyoutBody, useEuiTheme, } from '@elastic/eui'; -import { createPortal } from 'react-dom'; import { css } from '@emotion/react'; import styled from '@emotion/styled'; @@ -41,7 +40,6 @@ import { useAssistantContext } from '../assistant_context'; import { ContextPills } from './context_pills'; import { getNewSelectedPromptContext } from '../data_anonymization/get_new_selected_prompt_context'; import type { PromptContext, SelectedPromptContext } from './prompt_context/types'; -import { CodeBlockDetails } from './use_conversation/helpers'; import { QuickPrompts } from './quick_prompts/quick_prompts'; import { useLoadConnectors } from '../connectorland/use_load_connectors'; import { ConversationSidePanel } from './conversations/conversation_sidepanel'; @@ -205,15 +203,19 @@ const AssistantComponent: React.FC = ({ const [autoPopulatedOnce, setAutoPopulatedOnce] = useState(false); - const [messageCodeBlocks, setMessageCodeBlocks] = useState(); const [_, setCodeBlockControlsVisible] = useState(false); useLayoutEffect(() => { + let unmountFunc = () => {}; if (currentConversation) { // need in order for code block controls to be added to the DOM setTimeout(() => { - setMessageCodeBlocks(augmentMessageCodeBlocks(currentConversation, showAnonymizedValues)); + unmountFunc = augmentMessageCodeBlocks.mount({ currentConversation, showAnonymizedValues }); }, 0); } + + return () => { + unmountFunc(); + }; }, [augmentMessageCodeBlocks, currentConversation, showAnonymizedValues]); // Keyboard shortcuts to toggle the visibility of content references and anonymized values @@ -372,26 +374,6 @@ const AssistantComponent: React.FC = ({ setUserPrompt, ]); - const createCodeBlockPortals = useCallback( - () => - messageCodeBlocks?.map((codeBlocks: CodeBlockDetails[], i: number) => { - return ( - - {codeBlocks.map((codeBlock: CodeBlockDetails, j: number) => { - const getElement = codeBlock.getControlContainer; - const element = getElement?.(); - return ( - - {element ? createPortal(codeBlock.button, element) : <>} - - ); - })} - - ); - }), - [messageCodeBlocks] - ); - const comments = useMemo( () => ( <> @@ -513,9 +495,6 @@ const AssistantComponent: React.FC = ({ setIsSettingsModalVisible={setIsSettingsModalVisible} setPaginationObserver={setPaginationObserver} /> - - {/* Create portals for each EuiCodeBlock to add the `Investigate in Timeline` action */} - {createCodeBlockPortals()} { }); }); - it('should register link in nav bar', () => { - render(); - expect(mockNavControls.registerRight).toHaveBeenCalledTimes(1); - }); - it('button has transparent background in project navigation', () => { - const { result: portalNode } = renderHook(() => - React.useMemo(() => createHtmlPortalNode(), []) - ); - mockGetChromeStyle.mockReturnValue(of('project')); - mockNavControls.registerRight.mockImplementation((chromeNavControl: ChromeNavControl) => { - chromeNavControl.mount(portalNode.current.element); - }); - const { queryByTestId } = render( <> - ); @@ -75,19 +54,10 @@ describe('AssistantNavLink', () => { }); it('button has opaque background in classic navigation', () => { - const { result: portalNode } = renderHook(() => - React.useMemo(() => createHtmlPortalNode(), []) - ); - mockGetChromeStyle.mockReturnValue(of('classic')); - mockNavControls.registerRight.mockImplementation((chromeNavControl: ChromeNavControl) => { - chromeNavControl.mount(portalNode.current.element); - }); - const { queryByTestId } = render( <> - ); @@ -95,17 +65,8 @@ describe('AssistantNavLink', () => { }); it('should render the header link text', () => { - const { result: portalNode } = renderHook(() => - React.useMemo(() => createHtmlPortalNode(), []) - ); - - mockNavControls.registerRight.mockImplementation((chromeNavControl: ChromeNavControl) => { - chromeNavControl.mount(portalNode.current.element); - }); - const { queryByText, queryByTestId } = render( <> - ); @@ -114,14 +75,6 @@ describe('AssistantNavLink', () => { }); it('should not render the header link if not authorized', () => { - const { result: portalNode } = renderHook(() => - React.useMemo(() => createHtmlPortalNode(), []) - ); - - mockNavControls.registerRight.mockImplementation((chromeNavControl: ChromeNavControl) => { - chromeNavControl.mount(portalNode.current.element); - }); - (useAssistantContext as jest.Mock).mockReturnValue({ ...mockAssistantContext, assistantAvailability: { @@ -131,7 +84,6 @@ describe('AssistantNavLink', () => { const { queryByText, queryByTestId } = render( <> - ); @@ -140,17 +92,8 @@ describe('AssistantNavLink', () => { }); it('should call the assistant overlay to show on click', () => { - const { result: portalNode } = renderHook(() => - React.useMemo(() => createHtmlPortalNode(), []) - ); - - mockNavControls.registerRight.mockImplementation((chromeNavControl: ChromeNavControl) => { - chromeNavControl.mount(portalNode.current.element); - }); - const { queryByTestId } = render( <> - ); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/assistant_nav_link.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/assistant_nav_link.tsx index 8545a1dc6e61..2b88284e3616 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/assistant_nav_link.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/assistant_nav_link.tsx @@ -7,13 +7,11 @@ import type { FC } from 'react'; import React, { useCallback, useEffect, useState } from 'react'; -import ReactDOM from 'react-dom'; -import { createHtmlPortalNode, OutPortal, InPortal } from 'react-reverse-portal'; import { EuiToolTip, EuiButton, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ChromeStyle } from '@kbn/core-chrome-browser'; import { AssistantIcon } from '@kbn/ai-assistant-icon'; -import { useAssistantContext } from '.'; +import { useAssistantContext } from '../..'; const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; @@ -29,9 +27,7 @@ const LINK_LABEL = i18n.translate('xpack.elasticAssistant.assistantContext.assis }); export const AssistantNavLink: FC = () => { - const { chrome, showAssistantOverlay, assistantAvailability, currentAppId } = - useAssistantContext(); - const portalNode = React.useMemo(() => createHtmlPortalNode(), []); + const { chrome, showAssistantOverlay, assistantAvailability } = useAssistantContext(); const [chromeStyle, setChromeStyle] = useState(undefined); // useObserverable would change the order of re-renders that are tested against closely. @@ -40,27 +36,6 @@ export const AssistantNavLink: FC = () => { return () => s.unsubscribe(); }, [chrome]); - useEffect(() => { - const registerPortalNode = () => { - chrome.navControls.registerRight({ - mount: (element: HTMLElement) => { - ReactDOM.render(, element); - return () => ReactDOM.unmountComponentAtNode(element); - }, - // right before the user profile - order: 1001, - }); - }; - - if ( - assistantAvailability.hasAssistantPrivilege && - chromeStyle && - currentAppId !== 'management' - ) { - registerPortalNode(); - } - }, [chrome, portalNode, assistantAvailability.hasAssistantPrivilege, chromeStyle, currentAppId]); - const showOverlay = useCallback( () => showAssistantOverlay({ showOverlay: true }), [showAssistantOverlay] @@ -73,22 +48,20 @@ export const AssistantNavLink: FC = () => { const EuiButtonBasicOrEmpty = chromeStyle === 'project' ? EuiButtonEmpty : EuiButton; return ( - - - - - - - - {LINK_LABEL} - - - - + + + + + + + {LINK_LABEL} + + + ); }; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.tsx index 04a0ae0250d6..fed795afe2b6 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.tsx @@ -14,7 +14,12 @@ 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 { ApplicationStart, ChromeStart, UserProfileService } from '@kbn/core/public'; +import { + ChromeStart, + UnmountCallback, + ApplicationStart, + 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'; @@ -30,7 +35,6 @@ import { GetAssistantMessages, } from './types'; import { DEFAULT_ASSISTANT_TITLE } from '../assistant/translations'; -import { CodeBlockDetails } from '../assistant/use_conversation/helpers'; import { PromptContextTemplate } from '../assistant/prompt_context/types'; import { KnowledgeBaseConfig, TraceOptions } from '../assistant/types'; import { @@ -44,7 +48,6 @@ import { } from './constants'; import { useCapabilities } from '../assistant/api/capabilities/use_capabilities'; import { ModalSettingsTabs } from '../assistant/settings/types'; -import { AssistantNavLink } from './assistant_nav_link'; export type SelectedConversation = { id: string } | { title: string }; @@ -68,14 +71,15 @@ export interface AssistantProviderProps { alertsIndexPattern?: string; assistantAvailability: AssistantAvailability; assistantTelemetry?: AssistantTelemetry; - augmentMessageCodeBlocks: ( - currentConversation: Conversation, - showAnonymizedValues: boolean - ) => CodeBlockDetails[][]; + augmentMessageCodeBlocks: { + mount: (args: { + currentConversation: Conversation; + showAnonymizedValues: boolean; + }) => UnmountCallback; + }; basePath: string; basePromptContexts?: PromptContextTemplate[]; docLinks: DocLinksStart; - children: React.ReactNode; getUrlForApp: GetUrlForApp; getComments: GetAssistantMessages; http: HttpSetup; @@ -103,10 +107,12 @@ export interface UseAssistantContext { assistantFeatures: Partial; assistantStreamingEnabled: boolean; assistantTelemetry?: AssistantTelemetry; - augmentMessageCodeBlocks: ( - currentConversation: Conversation, - showAnonymizedValues: boolean - ) => CodeBlockDetails[][]; + augmentMessageCodeBlocks: { + mount: (args: { + currentConversation: Conversation; + showAnonymizedValues: boolean; + }) => UnmountCallback; + }; docLinks: DocLinksStart; basePath: string; currentUserAvatar?: UserAvatar; @@ -148,37 +154,46 @@ export interface UseAssistantContext { const AssistantContext = React.createContext(undefined); -export const AssistantProvider: React.FC = ({ - actionTypeRegistry, - alertsIndexPattern, - assistantAvailability, - assistantTelemetry, - augmentMessageCodeBlocks, - docLinks, - basePath, - basePromptContexts = [], - children, - getComments, - getUrlForApp, - http, - inferenceEnabled = false, - navigateToApp, - nameSpace = DEFAULT_ASSISTANT_NAMESPACE, - productDocBase, - title = DEFAULT_ASSISTANT_TITLE, - toasts, - currentAppId, - userProfileService, - chrome, -}) => { - /** - * Session storage for traceOptions, including APM URL and LangSmith Project/API Key - */ +export const useAssistantContext = () => { + const context = React.useContext(AssistantContext); + + if (context == null) { + throw new Error('useAssistantContext must be used within a AssistantProvider'); + } + + return context; +}; + +export const useAssistantContextValue = (props: AssistantProviderProps): UseAssistantContext => { + const { + actionTypeRegistry, + alertsIndexPattern, + assistantAvailability, + assistantTelemetry, + augmentMessageCodeBlocks, + docLinks, + basePath, + basePromptContexts = [], + getComments, + getUrlForApp, + http, + inferenceEnabled = false, + navigateToApp, + nameSpace = DEFAULT_ASSISTANT_NAMESPACE, + productDocBase, + title = DEFAULT_ASSISTANT_TITLE, + toasts, + currentAppId, + userProfileService, + chrome, + } = props; + const defaultTraceOptions: TraceOptions = { apmUrl: `${http.basePath.serverBasePath}/app/apm`, langSmithProject: '', langSmithApiKey: '', }; + const [sessionStorageTraceOptions = defaultTraceOptions, setSessionStorageTraceOptions] = useSessionStorage( `${nameSpace}.${TRACE_OPTIONS_SESSION_STORAGE_KEY}`, @@ -380,20 +395,12 @@ export const AssistantProvider: React.FC = ({ ] ); - return ( - - - {children} - - ); + return value; }; -export const useAssistantContext = () => { - const context = React.useContext(AssistantContext); - - if (context == null) { - throw new Error('useAssistantContext must be used within a AssistantProvider'); - } - - return context; +export const AssistantProvider: React.FC<{ + children: React.ReactNode; + value: ReturnType; +}> = ({ children, value }) => { + return {children}; }; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/add_connector_modal/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/add_connector_modal/index.tsx index 47de3e84eb47..9d89cea829e5 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/add_connector_modal/index.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/add_connector_modal/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { Suspense } from 'react'; import { ActionType } from '@kbn/actions-plugin/common'; import { ConnectorAddModal } from '@kbn/triggers-actions-ui-plugin/public/common/constants'; import { @@ -33,13 +33,15 @@ export const AddConnectorModal: React.FC = React.memo( actionTypeSelectorInline = false, }) => ( <> - + + + {selectedActionType && ( = ({ const chrome = chromeServiceMock.createStartContract(); chrome.getChromeStyle$.mockReturnValue(of('classic')); + const docLinks = docLinksServiceMock.createStartContract(); + + const assistantProviderProps = { + actionTypeRegistry, + assistantAvailability, + augmentMessageCodeBlocks: { + mount: jest.fn().mockReturnValue(() => {}), + }, + basePath: 'https://localhost:5601/kbn', + docLinks, + getComments: mockGetComments, + getUrlForApp: mockGetUrlForApp, + http: mockHttp, + navigateToApp: mockNavigateToApp, + ...providerContext, + currentAppId: 'test', + productDocBase: { + installation: { getStatus: jest.fn(), install: jest.fn(), uninstall: jest.fn() }, + }, + userProfileService: jest.fn() as unknown as UserProfileService, + chrome, + }; return ( - + {children} - + @@ -106,3 +115,14 @@ export const TestProvidersComponent: React.FC = ({ TestProvidersComponent.displayName = 'TestProvidersComponent'; export const TestProviders = React.memo(TestProvidersComponent); + +const TestAssistantProviders = ({ + assistantProviderProps, + children, +}: { + assistantProviderProps: AssistantProviderProps; + children: React.ReactNode; +}) => { + const assistantContextValue = useAssistantContextValue(assistantProviderProps); + return {children}; +}; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/index.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant/index.ts index e4ac385e0784..bb37119cf22e 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/index.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/index.ts @@ -183,3 +183,20 @@ export { getNewSelectedPromptContext } from './impl/data_anonymization/get_new_s export { getCombinedMessage } from './impl/assistant/prompt/helpers'; export { useChatComplete } from './impl/assistant/api/chat_complete/use_chat_complete'; export { useFetchAnonymizationFields } from './impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields'; + +export interface UseAssistantAvailability { + // True when searchAiLake configurations is available + hasSearchAILakeConfigurations: boolean; + // True when user is Enterprise. When false, the Assistant is disabled and unavailable + isAssistantEnabled: boolean; + // When true, the Assistant is hidden and unavailable + hasAssistantPrivilege: boolean; + // When true, user has `All` privilege for `Connectors and Actions` (show/execute/delete/save ui capabilities) + hasConnectorsAllPrivilege: boolean; + // When true, user has `Read` privilege for `Connectors and Actions` (show/execute ui capabilities) + hasConnectorsReadPrivilege: boolean; + // When true, user has `Edit` privilege for `AnonymizationFields` + hasUpdateAIAssistantAnonymization: boolean; + // When true, user has `Edit` privilege for `Global Knowledge Base` + hasManageGlobalKnowledgeBase: boolean; +} diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/tsconfig.json b/x-pack/platform/packages/shared/kbn-elastic-assistant/tsconfig.json index dd245693986e..b6bcaf114936 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/tsconfig.json +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/tsconfig.json @@ -43,6 +43,6 @@ "@kbn/shared-ux-router", "@kbn/datemath", "@kbn/alerts-ui-shared", - "@kbn/deeplinks-security" + "@kbn/deeplinks-security", ] } diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 558bcf84ad43..396e4f1ffc8c 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -16043,7 +16043,7 @@ "xpack.elasticAssistant.anonymizedValuesAndCitations.tour.content.citedKnowledgeBaseEntries": "L'assistant d'intelligence artificielle peut désormais citer des sources dans ses réponses. Activez ou désactivez la fonctionnalité de citation à l'aide de ce menu ou de {keyboardShortcut}", "xpack.elasticAssistant.anonymizedValuesAndCitations.tour.subtitle": "Nouvelles améliorations !", "xpack.elasticAssistant.anonymizedValuesAndCitations.tour.title": "Citations et valeurs anonymisées", - "xpack.elasticAssistant.assistant.apiErrorTitle": "Une erreur s'est produite lors de l'envoi de votre message.", + "xpack.elasticAssistant.assistant.apiErrorTitle": "Une erreur s’est produite lors de l’envoi de votre message.", "xpack.elasticAssistant.assistant.assistantHeader.closeButtonLabel": "Fermer", "xpack.elasticAssistant.assistant.connectors.addConnectorButton.description": "Configurez un connecteur pour continuer la conversation", "xpack.elasticAssistant.assistant.connectors.addConnectorButton.missingPrivilegesDescription": "Veuillez contacter votre administrateur pour activer le connecteur d'intelligence artificielle générative.", @@ -16362,7 +16362,9 @@ "xpack.elasticAssistant.prompts.bulkActionspromptsError": "Erreur de la mise à jour des invites {error}", "xpack.elasticAssistant.prompts.getPromptsError": "Erreur lors de la récupération des invites", "xpack.elasticAssistant.securityAssistant.content.prompts.welcome.enterprisePrompt": "L'assistant d'IA d'Elastic n'est accessible qu'aux entreprises. Veuillez mettre votre licence à niveau pour bénéficier de cette fonctionnalité.", + "xpack.elasticAssistantPlugin.assistant.apiErrorTitle": "Une erreur s’est produite lors de l’envoi de votre message.", "xpack.elasticAssistantPlugin.assistant.quickPrompts.esqlQueryGenerationTitle": "Génération de requête ES|QL", + "xpack.elasticAssistantPlugin.assistant.title": "Assistant d'IA d'Elastic", "xpack.elasticAssistantPlugin.attackDiscovery.defaultAttackDiscoveryGraph.nodes.retriever.helpers.throwIfErrorCountsExceeded.maxGenerationAttemptsErrorMessage": "Nombre maximum de tentatives de génération ({generationAttempts}) atteint. Essayez d'envoyer un nombre d'alertes moins élevé à ce modèle.", "xpack.elasticAssistantPlugin.attackDiscovery.defaultAttackDiscoveryGraph.nodes.retriever.helpers.throwIfErrorCountsExceeded.maxHallucinationFailuresErrorMessage": "Nombre maximum d'échecs d'hallucinations ({hallucinationFailures}) atteint. Essayez d'envoyer un nombre d'alertes moins élevé à ce modèle.", "xpack.elasticAssistantPlugin.attackDiscovery.defaultAttackDiscoveryGraph.nodes.retriever.helpers.throwIfInvalidAnonymization.idFieldRequiredErrorMessage": "Vos paramètres d'anonymisation de Security AI sont configurés pour ne pas autoriser le champ _id. Le champ _id doit être autorisé à générer des découvertes d’attaque.", @@ -34839,9 +34841,9 @@ "xpack.securitySolution.agentStatus.actionStatus.multiplePendingActions": "{count} {count, plural, one {action} other {actions}} en attente", "xpack.securitySolution.agentStatus.actionStatus.tooltipPendingActions": "Actions en attente :", "xpack.securitySolution.agentTypeIntegration.integrationSectionLabel": "Intégration", - "xpack.securitySolution.aiAssistant.failedLoadingResponseText": "Échec de chargement de la réponse", - "xpack.securitySolution.aiAssistant.regenerateResponseButtonLabel": "Régénérer", - "xpack.securitySolution.aiAssistant.stopGeneratingButtonLabel": "Arrêter la génération", + + + "xpack.securitySolution.alertCountByRuleByStatus.alertsByRule": "Alertes par règle", "xpack.securitySolution.alertCountByRuleByStatus.count": "compte", "xpack.securitySolution.alertCountByRuleByStatus.noRuleAlerts": "Aucune alerte à afficher", @@ -35049,7 +35051,6 @@ "xpack.securitySolution.assignees.noAssigneesLabel": "Aucun utilisateur affecté", "xpack.securitySolution.assignees.selectableSearchPlaceholder": "Rechercher des utilisateurs", "xpack.securitySolution.assignees.totalUsersAssigned": "{total, plural, one {# utilisateur affecté sélectionné} other {# utilisateurs affectés sélectionnés}}", - "xpack.securitySolution.assistant.apiErrorTitle": "Une erreur s’est produite lors de l’envoi de votre message.", "xpack.securitySolution.assistant.commentActions.addedNoteToTimelineToast": "Note ajoutée à la chronologie", "xpack.securitySolution.assistant.commentActions.addMessageContentAsTimelineNoteAriaLabel": "Ajouter un contenu de message comme note de chronologie", "xpack.securitySolution.assistant.commentActions.addNoteToTimelineTooltip": "Ajouter une note à la chronologie", @@ -35061,14 +35062,14 @@ "xpack.securitySolution.assistant.content.promptContexts.viewTitle": "vue", "xpack.securitySolution.assistant.content.prompts.user.finallySuggestInvestigationGuideAndFormatAsMarkdown": "Ajoutez votre description, les actions que vous recommandez ainsi que les étapes de triage à puces. Utilisez les données \"MITRE ATT&CK\" fournies pour ajouter du contexte et des recommandations de MITRE ainsi que des liens hypertexte vers les pages pertinentes sur le site web de MITRE. Assurez-vous d'inclure les scores de risque de l'utilisateur et de l'hôte du contexte. Votre réponse doit inclure des étapes qui pointent vers les fonctionnalités spécifiques d'Elastic Security, y compris les actions de réponse du terminal, l'intégration OSQuery Manager d'Elastic Agent (avec des exemples de requêtes OSQuery), des analyses de timeline et d'entités, ainsi qu'un lien pour toute la documentation Elastic Security pertinente.", "xpack.securitySolution.assistant.content.prompts.user.thenSummarizeSuggestedKqlAndEqlQueries": "Évaluer l’événement depuis le contexte ci-dessus et formater soigneusement la sortie en syntaxe Markdown pour mon cas Elastic Security.", - "xpack.securitySolution.assistant.contentReferences.knowledgeBaseEntryReference.label": "Entrée de la base de connaissances", - "xpack.securitySolution.assistant.contentReferences.securityAlertReference.label": "Afficher l'alerte", - "xpack.securitySolution.assistant.contentReferences.securityAlertsPageReference.label": "Afficher les alertes", - "xpack.securitySolution.assistant.conversationMigrationStatus.title": "Les conversations de stockage local ont été persistées avec succès.", - "xpack.securitySolution.assistant.getComments.assistant": "Assistant", - "xpack.securitySolution.assistant.getComments.at": "à : {timestamp}", - "xpack.securitySolution.assistant.getComments.system": "Système", - "xpack.securitySolution.assistant.getComments.you": "Vous", + + + + + + + + "xpack.securitySolution.assistant.quickPrompts.alertSummarizationPrompt": "En tant qu’expert en opérations de sécurité et en réponses aux incidents, décomposer l’alerte jointe et résumer ce qu’elle peut impliquer pour mon organisation.", "xpack.securitySolution.assistant.quickPrompts.alertSummarizationTitle": "Synthèse de l’alerte", "xpack.securitySolution.assistant.quickPrompts.AutomationPrompt": "Quelle intégration d’Elastic Agent activée par Fleet dois-je utiliser pour collecter des logs et des évènements de :", @@ -35085,7 +35086,6 @@ "xpack.securitySolution.assistant.settings.breadcrumb.security": "Sécurité", "xpack.securitySolution.assistant.settings.breadcrumb.serverless.security": "Paramètres de l'assistant d'IA pour Security", "xpack.securitySolution.assistant.settings.breadcrumb.stackManagement": "Gestion de la Suite", - "xpack.securitySolution.assistant.title": "Assistant d'IA d'Elastic", "xpack.securitySolution.assistant.updateQueryInFormTooltip": "Mettre à jour la recherche dans le formulaire", "xpack.securitySolution.attackDiscovery.alertSelection.alertSelection.selectFewerAlertsLabel": "Envoyez moins d'alertes si la fenêtre contextuelle du modèle est petite, ou plus si elle est grande.", "xpack.securitySolution.attackDiscovery.attackDiscoveryPanel.actions.takeAction.addToExistingCaseButtonLabel": "Ajouter à un cas existant", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index a601b07c4441..6694ded3231d 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -16380,6 +16380,7 @@ "xpack.elasticAssistant.prompts.bulkActionspromptsError": "プロンプトの更新エラー{error}", "xpack.elasticAssistant.prompts.getPromptsError": "プロンプト取得エラー", "xpack.elasticAssistant.securityAssistant.content.prompts.welcome.enterprisePrompt": "Elastic AI Assistantはエンタープライズユーザーのみご利用いただけます。 この機能を使用するには、ライセンスをアップグレードしてください。", + "xpack.elasticAssistantPlugin.assistant.apiErrorTitle": "メッセージの送信中にエラーが発生しました。", "xpack.elasticAssistantPlugin.assistant.quickPrompts.esqlQueryGenerationTitle": "ES|QLクエリ生成", "xpack.elasticAssistantPlugin.attackDiscovery.defaultAttackDiscoveryGraph.nodes.retriever.helpers.throwIfErrorCountsExceeded.maxGenerationAttemptsErrorMessage": "最大生成試行回数({generationAttempts})に達しました。このモデルに送信するアラートの数を減らしてください。", "xpack.elasticAssistantPlugin.attackDiscovery.defaultAttackDiscoveryGraph.nodes.retriever.helpers.throwIfErrorCountsExceeded.maxHallucinationFailuresErrorMessage": "最大ハルシネーション失敗回数({hallucinationFailures})に達しました。このモデルに送信するアラートの数を減らしてください。", @@ -34874,9 +34875,9 @@ "xpack.securitySolution.agentStatus.actionStatus.multiplePendingActions": "{count}{count, plural, one {action} other {件のアクション}}が保留中です。", "xpack.securitySolution.agentStatus.actionStatus.tooltipPendingActions": "保留中のアクション:", "xpack.securitySolution.agentTypeIntegration.integrationSectionLabel": "統合", - "xpack.securitySolution.aiAssistant.failedLoadingResponseText": "応答の読み込みに失敗しました", - "xpack.securitySolution.aiAssistant.regenerateResponseButtonLabel": "再生成", - "xpack.securitySolution.aiAssistant.stopGeneratingButtonLabel": "生成を停止", + + + "xpack.securitySolution.alertCountByRuleByStatus.alertsByRule": "ルール別アラート", "xpack.securitySolution.alertCountByRuleByStatus.count": "カウント", "xpack.securitySolution.alertCountByRuleByStatus.noRuleAlerts": "表示するアラートがありません", @@ -35083,8 +35084,7 @@ "xpack.securitySolution.assignees.clearFilters": "フィルターを消去", "xpack.securitySolution.assignees.noAssigneesLabel": "担当者なし", "xpack.securitySolution.assignees.selectableSearchPlaceholder": "ユーザーの検索", - "xpack.securitySolution.assignees.totalUsersAssigned": "{total, plural, one {# assignee} other {# 人の担当者}}が選択されました", - "xpack.securitySolution.assistant.apiErrorTitle": "メッセージの送信中にエラーが発生しました。", + "xpack.securitySolution.assignees.totalUsersAssigned": "{total, plural, other {# 担当者}}が選択されました", "xpack.securitySolution.assistant.commentActions.addedNoteToTimelineToast": "メモをタイムラインに追加しました", "xpack.securitySolution.assistant.commentActions.addMessageContentAsTimelineNoteAriaLabel": "メッセージコンテンツをタイムラインメモとして追加", "xpack.securitySolution.assistant.commentActions.addNoteToTimelineTooltip": "メモをタイムラインに追加", @@ -35096,14 +35096,14 @@ "xpack.securitySolution.assistant.content.promptContexts.viewTitle": "表示", "xpack.securitySolution.assistant.content.prompts.user.finallySuggestInvestigationGuideAndFormatAsMarkdown": "説明、推奨されるアクション、箇条書きのトリアージステップを追加します。提供されたMITRE ATT&CKデータを使用して、MITREからのコンテキストや推奨事項を追加し、MITREのWebサイトの関連ページにハイパーリンクを貼ります。コンテキストのユーザーとホストのリスクスコアデータを必ず含めてください。回答には、エンドポイント対応アクション、ElasticエージェントOSQueryマネージャー統合(osqueryクエリーの例を付けて)、タイムライン、エンティティ分析など、Elasticセキュリティ固有の機能を指す手順を含め、関連するElasticセキュリティのドキュメントすべてにリンクしてください。", "xpack.securitySolution.assistant.content.prompts.user.thenSummarizeSuggestedKqlAndEqlQueries": "上記のコンテキストからイベントを評価し、Elasticセキュリティのケース用に、出力をマークダウン構文で正しく書式設定してください。", - "xpack.securitySolution.assistant.contentReferences.knowledgeBaseEntryReference.label": "ナレッジベースエントリ", - "xpack.securitySolution.assistant.contentReferences.securityAlertReference.label": "アラートを表示", - "xpack.securitySolution.assistant.contentReferences.securityAlertsPageReference.label": "アラートを表示", - "xpack.securitySolution.assistant.conversationMigrationStatus.title": "ローカルストレージ会話は正常に永続しました。", - "xpack.securitySolution.assistant.getComments.assistant": "アシスタント", - "xpack.securitySolution.assistant.getComments.at": "at: {timestamp}", - "xpack.securitySolution.assistant.getComments.system": "システム", - "xpack.securitySolution.assistant.getComments.you": "あなた", + + + + + + + + "xpack.securitySolution.assistant.quickPrompts.alertSummarizationPrompt": "セキュリティ運用とインシデント対応のエキスパートとして、添付されたアラートの内訳を説明し、それが私の組織にとって何を意味するのかを要約してください。", "xpack.securitySolution.assistant.quickPrompts.alertSummarizationTitle": "アラート要約", "xpack.securitySolution.assistant.quickPrompts.AutomationPrompt": "ログやイベントの収集には、どのFleet対応Elasticエージェント統合を使用すべきですか。", @@ -35120,7 +35120,7 @@ "xpack.securitySolution.assistant.settings.breadcrumb.security": "セキュリティ", "xpack.securitySolution.assistant.settings.breadcrumb.serverless.security": "AI Assistant for Security設定", "xpack.securitySolution.assistant.settings.breadcrumb.stackManagement": "スタック管理", - "xpack.securitySolution.assistant.title": "Elastic AI Assistant", + "xpack.securitySolution.assistant.updateQueryInFormTooltip": "フォームでクエリーを更新", "xpack.securitySolution.attackDiscovery.alertSelection.alertSelection.selectFewerAlertsLabel": "モデルのコンテキストウィンドウが小さい場合は送信されるアラート数が少なくなり、大きい場合は送信されるアラート数が多くなります。", "xpack.securitySolution.attackDiscovery.attackDiscoveryPanel.actions.takeAction.addToExistingCaseButtonLabel": "既存のケースに追加", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index f3870518006a..60c98eda3a72 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -16376,6 +16376,7 @@ "xpack.elasticAssistant.prompts.bulkActionspromptsError": "更新提示时出错 {error}", "xpack.elasticAssistant.prompts.getPromptsError": "提取提示时出错", "xpack.elasticAssistant.securityAssistant.content.prompts.welcome.enterprisePrompt": "Elastic AI 助手仅对企业用户可用。请升级许可证以使用此功能。", + "xpack.elasticAssistantPlugin.assistant.apiErrorTitle": "发送消息时出错。", "xpack.elasticAssistantPlugin.assistant.quickPrompts.esqlQueryGenerationTitle": "ES|QL 查询生成", "xpack.elasticAssistantPlugin.attackDiscovery.defaultAttackDiscoveryGraph.nodes.retriever.helpers.throwIfErrorCountsExceeded.maxGenerationAttemptsErrorMessage": "已达到最大生成尝试次数 ({generationAttempts})。尝试向此模型发送更少的告警。", "xpack.elasticAssistantPlugin.attackDiscovery.defaultAttackDiscoveryGraph.nodes.retriever.helpers.throwIfErrorCountsExceeded.maxHallucinationFailuresErrorMessage": "已达到最大幻觉失败次数 ({hallucinationFailures})。尝试向此模型发送更少的告警。", @@ -34863,9 +34864,9 @@ "xpack.securitySolution.agentStatus.actionStatus.multiplePendingActions": "{count} 个{count, plural, one {操作} other {操作}}待处理", "xpack.securitySolution.agentStatus.actionStatus.tooltipPendingActions": "未决操作:", "xpack.securitySolution.agentTypeIntegration.integrationSectionLabel": "集成", - "xpack.securitySolution.aiAssistant.failedLoadingResponseText": "无法加载响应", - "xpack.securitySolution.aiAssistant.regenerateResponseButtonLabel": "重新生成", - "xpack.securitySolution.aiAssistant.stopGeneratingButtonLabel": "停止生成", + + + "xpack.securitySolution.alertCountByRuleByStatus.alertsByRule": "按规则排列的告警", "xpack.securitySolution.alertCountByRuleByStatus.count": "计数", "xpack.securitySolution.alertCountByRuleByStatus.noRuleAlerts": "没有可显示的告警", @@ -35073,7 +35074,6 @@ "xpack.securitySolution.assignees.noAssigneesLabel": "无被分配人", "xpack.securitySolution.assignees.selectableSearchPlaceholder": "搜索用户", "xpack.securitySolution.assignees.totalUsersAssigned": "已选择 {total, plural, one {# 个被分配人} other {# 个被分配人}}", - "xpack.securitySolution.assistant.apiErrorTitle": "发送消息时出错。", "xpack.securitySolution.assistant.commentActions.addedNoteToTimelineToast": "已将备注添加到时间线", "xpack.securitySolution.assistant.commentActions.addMessageContentAsTimelineNoteAriaLabel": "将消息内容添加为时间线备注", "xpack.securitySolution.assistant.commentActions.addNoteToTimelineTooltip": "将备注添加到时间线", @@ -35085,14 +35085,14 @@ "xpack.securitySolution.assistant.content.promptContexts.viewTitle": "视图", "xpack.securitySolution.assistant.content.prompts.user.finallySuggestInvestigationGuideAndFormatAsMarkdown": "添加描述、建议操作和带项目符号的分类步骤。使用提供的 MITRE ATT&CK 数据以从 MITRE 添加更多上下文和建议,以及指向 MITRE 网站上的相关页面的超链接。确保包括上下文中的用户和主机风险分数数据。您的响应应包含指向 Elastic Security 特定功能的步骤,包括终端响应操作、Elastic 代理 OSQuery 管理器集成(带示例 osquery 查询)、时间线和实体分析,以及所有相关 Elastic Security 文档的链接。", "xpack.securitySolution.assistant.content.prompts.user.thenSummarizeSuggestedKqlAndEqlQueries": "评估来自上述上下文的事件,并以用于我的 Elastic Security 案例的 Markdown 语法对您的输出进行全面格式化。", - "xpack.securitySolution.assistant.contentReferences.knowledgeBaseEntryReference.label": "知识库条目", - "xpack.securitySolution.assistant.contentReferences.securityAlertReference.label": "查看告警", - "xpack.securitySolution.assistant.contentReferences.securityAlertsPageReference.label": "查看告警", - "xpack.securitySolution.assistant.conversationMigrationStatus.title": "已成功保持本地存储对话。", - "xpack.securitySolution.assistant.getComments.assistant": "助手", - "xpack.securitySolution.assistant.getComments.at": "时间:{timestamp}", - "xpack.securitySolution.assistant.getComments.system": "系统", - "xpack.securitySolution.assistant.getComments.you": "您", + + + + + + + + "xpack.securitySolution.assistant.quickPrompts.alertSummarizationPrompt": "作为安全运营和事件响应领域的专家,提供附加告警的细目并简要说明它对我所在组织可能的影响。", "xpack.securitySolution.assistant.quickPrompts.alertSummarizationTitle": "告警汇总", "xpack.securitySolution.assistant.quickPrompts.AutomationPrompt": "我应使用哪个启用 Fleet 的 Elastic 代理集成从以下项中收集日志和事件:", @@ -35109,7 +35109,7 @@ "xpack.securitySolution.assistant.settings.breadcrumb.security": "安全", "xpack.securitySolution.assistant.settings.breadcrumb.serverless.security": "适用于 Security 的 AI 助手设置", "xpack.securitySolution.assistant.settings.breadcrumb.stackManagement": "Stack Management", - "xpack.securitySolution.assistant.title": "Elastic AI 助手", + "xpack.securitySolution.assistant.updateQueryInFormTooltip": "在表单中更新查询", "xpack.securitySolution.attackDiscovery.alertSelection.alertSelection.selectFewerAlertsLabel": "如果此模型的上下文窗口较小,则发送更少告警;或者如果窗口更大,则发送更多告警。", "xpack.securitySolution.attackDiscovery.attackDiscoveryPanel.actions.takeAction.addToExistingCaseButtonLabel": "添加到现有案例", diff --git a/x-pack/solutions/security/packages/ecs-data-quality-dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx b/x-pack/solutions/security/packages/ecs-data-quality-dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx index 704a3f501a8f..b4a635ebc4bc 100644 --- a/x-pack/solutions/security/packages/ecs-data-quality-dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx +++ b/x-pack/solutions/security/packages/ecs-data-quality-dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx @@ -20,6 +20,10 @@ import { of } from 'rxjs'; import { I18nProvider } from '@kbn/i18n-react'; import { EuiThemeProvider } from '@elastic/eui'; +import { + AssistantProviderProps, + useAssistantContextValue, +} from '@kbn/elastic-assistant/impl/assistant_context'; import { DataQualityProvider, DataQualityProviderProps } from '../../data_quality_context'; import { ResultsRollupContext } from '../../contexts/results_rollup_context'; import { IndicesCheckContext } from '../../contexts/indices_check_context'; @@ -72,30 +76,36 @@ const TestExternalProvidersComponent: React.FC = ({ const chrome = chromeServiceMock.createStartContract(); chrome.getChromeStyle$.mockReturnValue(of('classic')); + const docLinks = docLinksServiceMock.createStartContract(); + + const assistantProviderProps = { + actionTypeRegistry, + assistantAvailability: mockAssistantAvailability, + augmentMessageCodeBlocks: { + mount: jest.fn().mockReturnValue(() => {}), + }, + basePath: 'https://localhost:5601/kbn', + docLinks, + getComments: mockGetComments, + http: mockHttp, + navigateToApp: mockNavigateToApp, + productDocBase: { + installation: { getStatus: jest.fn(), install: jest.fn(), uninstall: jest.fn() }, + }, + currentAppId: 'securitySolutionUI', + userProfileService: jest.fn() as unknown as UserProfileService, + getUrlForApp: jest.fn(), + chrome, + }; + return ( - + {children} - + @@ -107,6 +117,18 @@ TestExternalProvidersComponent.displayName = 'TestExternalProvidersComponent'; export const TestExternalProviders = React.memo(TestExternalProvidersComponent); +export const TestAssistantProvider = ({ + assistantProviderProps, + children, +}: { + assistantProviderProps: AssistantProviderProps; + children: React.ReactNode; +}) => { + const assistantContextValue = useAssistantContextValue(assistantProviderProps); + + return {children}; +}; + export interface TestDataQualityProvidersProps { children: React.ReactNode; dataQualityContextProps?: Partial; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/README.md b/x-pack/solutions/security/plugins/elastic_assistant/README.md index bb5d441cdaa1..98d79c9c1318 100755 --- a/x-pack/solutions/security/plugins/elastic_assistant/README.md +++ b/x-pack/solutions/security/plugins/elastic_assistant/README.md @@ -1,8 +1,8 @@ # Elastic AI Assistant -This plugin implements (only) server APIs for the `Elastic AI Assistant`. +This plugin implements server APIs for the `Elastic AI Assistant`. Furthermore, it registers the `Elastic Assistant` in the navigation bar. -This plugin does NOT contain UI components. See `x-pack/platform/packages/shared/kbn-elastic-assistant` for React components. +For further UI components, see `x-pack/platform/packages/shared/kbn-elastic-assistant`. ## Maintainers diff --git a/x-pack/solutions/security/plugins/elastic_assistant/common/constants.ts b/x-pack/solutions/security/plugins/elastic_assistant/common/constants.ts index a935af2afa2b..db19ab38d9ee 100755 --- a/x-pack/solutions/security/plugins/elastic_assistant/common/constants.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/common/constants.ts @@ -45,3 +45,5 @@ export const CAPABILITIES = `${BASE_PATH}/capabilities`; export const MINIMUM_AI_ASSISTANT_LICENSE = 'enterprise' as const; export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz' as const; +export const SECURITY_FEATURE_ID = 'siemV2' as const; +export const CASES_FEATURE_ID = 'securitySolutionCasesV3' as const; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/kibana.jsonc b/x-pack/solutions/security/plugins/elastic_assistant/kibana.jsonc index 51880066683f..0ec8e5d3d960 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/kibana.jsonc +++ b/x-pack/solutions/security/plugins/elastic_assistant/kibana.jsonc @@ -9,7 +9,7 @@ "description": "Server APIs for the Elastic AI Assistant", "plugin": { "id": "elasticAssistant", - "browser": false, + "browser": true, "server": true, "configPath": [ "xpack", @@ -28,7 +28,15 @@ "inference", "productDocBase", "spaces", - "security" + "security", + "stackConnectors", + "triggersActionsUi", + "elasticAssistantSharedState", + "aiAssistantManagementSelection", + ], + "requiredBundles": [ + "kibanaReact", + "kibanaUtils" ] }, "build": { @@ -38,4 +46,4 @@ "!**/knowledge_base/security_labs/*.encoded.md" ] } -} +} \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/index.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/index.ts new file mode 100644 index 000000000000..e08a960ac135 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/index.ts @@ -0,0 +1,10 @@ +/* + * 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 { ElasticAssistantPublicPlugin } from './plugin'; + +export const plugin = () => new ElasticAssistantPublicPlugin(); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/plugin.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/plugin.tsx new file mode 100644 index 000000000000..7f98be726f9c --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/plugin.tsx @@ -0,0 +1,118 @@ +/* + * 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 type { Plugin, CoreSetup, CoreStart } from '@kbn/core/public'; +import ReactDOM from 'react-dom'; +import React, { Suspense } from 'react'; +import { I18nProvider } from '@kbn/i18n-react'; +import { AssistantOverlay } from '@kbn/elastic-assistant'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; +import { AssistantNavLink } from '@kbn/elastic-assistant/impl/assistant_context/assistant_nav_link'; +import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme'; +import { + ElasticAssistantPublicPluginSetupDependencies, + ElasticAssistantPublicPluginStartDependencies, + StartServices, +} from './types'; +import { AssistantProvider } from './src/context/assistant_context/assistant_provider'; +import { KibanaContextProvider } from './src/context/typed_kibana_context/typed_kibana_context'; +import { licenseService } from './src/hooks/licence/use_licence'; +import { ReactQueryClientProvider } from './src/context/query_client_context/elastic_assistant_query_client_provider'; +import { AssistantSpaceIdProvider } from './src/context/assistant_space_id/assistant_space_id_provider'; +import { TelemetryService } from './src/common/lib/telemetry/telemetry_service'; + +export type ElasticAssistantPublicPluginSetup = ReturnType; +export type ElasticAssistantPublicPluginStart = ReturnType; + +export class ElasticAssistantPublicPlugin + implements + Plugin< + ElasticAssistantPublicPluginSetup, + ElasticAssistantPublicPluginStart, + ElasticAssistantPublicPluginSetupDependencies, + ElasticAssistantPublicPluginStartDependencies + > +{ + private readonly storage = new Storage(localStorage); + private readonly telemetry: TelemetryService = new TelemetryService(); + + public setup(coreSetup: CoreSetup) { + this.telemetry.setup({ analytics: coreSetup.analytics }); + return {}; + } + + public start(coreStart: CoreStart, dependencies: ElasticAssistantPublicPluginStartDependencies) { + const startServices = (): StartServices => { + const { ...startPlugins } = coreStart.security; + licenseService.start(dependencies.licensing.license$); + const telemetry = this.telemetry.start(); + + const services: StartServices = { + ...coreStart, + ...startPlugins, + licensing: dependencies.licensing, + triggersActionsUi: dependencies.triggersActionsUi, + security: dependencies.security, + telemetry, + productDocBase: dependencies.productDocBase, + storage: this.storage, + discover: dependencies.discover, + spaces: dependencies.spaces, + elasticAssistantSharedState: dependencies.elasticAssistantSharedState, + aiAssistantManagementSelection: dependencies.aiAssistantManagementSelection, + }; + return services; + }; + + coreStart.chrome.navControls.registerRight({ + order: 1001, + mount: (target) => { + const startService = startServices(); + return this.mountAIAssistantButton(target, coreStart, startService); + }, + }); + + return {}; + } + + private mountAIAssistantButton( + targetDomElement: HTMLElement, + coreStart: CoreStart, + services: StartServices + ) { + ReactDOM.render( + + + + + + + + + + + + + + + + , + targetDomElement + ); + + return () => ReactDOM.unmountComponentAtNode(targetDomElement); + } + + public stop() { + // Cleanup when plugin is stopped + } +} diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/constants.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/constants.ts new file mode 100644 index 000000000000..f4a5e1abc81b --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/constants.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + +export const ASSISTANT_FEATURE_ID = 'securitySolutionAssistant' as const; +export const SECURITY_FEATURE_ID = 'siemV2' as const; +export const ALERTS_PAGE_FILTER_OPEN = 'open'; +export const ALERTS_PAGE_FILTER_ACKNOWLEDGED = 'acknowledged'; +export const LOCAL_STORAGE_KEY = `securityAssistant`; +export const APP_ID = 'securitySolution' as const; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/ai_assistant/index.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/events/ai_assistant/index.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/ai_assistant/index.ts rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/events/ai_assistant/index.ts diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/ai_assistant/types.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/events/ai_assistant/types.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/ai_assistant/types.ts rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/events/ai_assistant/types.ts diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/events/telemetry_events.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/events/telemetry_events.ts new file mode 100644 index 000000000000..25a5f1dec212 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/events/telemetry_events.ts @@ -0,0 +1,9 @@ +/* + * 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 { assistantTelemetryEvents } from './ai_assistant'; + +export const telemetryEvents = [...assistantTelemetryEvents]; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/index.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/index.ts new file mode 100644 index 000000000000..3447b625a0fc --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export * from './telemetry_service'; +export * from './types'; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/telemetry_service.mock.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/telemetry_service.mock.ts new file mode 100644 index 000000000000..30b8a0c434c5 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/telemetry_service.mock.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export const createTelemetryServiceMock = () => ({ reportEvent: jest.fn() }); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/telemetry_service.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/telemetry_service.test.ts new file mode 100644 index 000000000000..a53a3dcf1e62 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/telemetry_service.test.ts @@ -0,0 +1,72 @@ +/* + * 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 { coreMock } from '@kbn/core/server/mocks'; +import { telemetryEvents } from './events/telemetry_events'; + +import { TelemetryService } from './telemetry_service'; +import { AssistantEventTypes } from './types'; + +describe('TelemetryService', () => { + let service: TelemetryService; + + beforeEach(() => { + service = new TelemetryService(); + }); + + const getSetupParams = () => { + const mockCoreStart = coreMock.createSetup(); + return { + analytics: mockCoreStart.analytics, + }; + }; + + describe('#setup()', () => { + it('should register all the custom events', () => { + const setupParams = getSetupParams(); + service.setup(setupParams); + + expect(setupParams.analytics.registerEventType).toHaveBeenCalledTimes(telemetryEvents.length); + + telemetryEvents.forEach((eventConfig, pos) => { + expect(setupParams.analytics.registerEventType).toHaveBeenNthCalledWith( + pos + 1, + eventConfig + ); + }); + }); + }); + + describe('#start()', () => { + it('should return the tracking method', () => { + const setupParams = getSetupParams(); + service.setup(setupParams); + const telemetry = service.start(); + + expect(telemetry).toHaveProperty('reportEvent'); + }); + }); + + describe('#assistantInvoked', () => { + it('should report event with correct properties', async () => { + const setupParams = getSetupParams(); + service.setup(setupParams); + const telemetry = service.start(); + + telemetry.reportEvent(AssistantEventTypes.AssistantInvoked, { + invokedBy: 'nav-bar', + }); + + expect(setupParams.analytics.reportEvent).toHaveBeenCalledTimes(1); + expect(setupParams.analytics.reportEvent).toHaveBeenCalledWith( + AssistantEventTypes.AssistantInvoked, + { + invokedBy: 'nav-bar', + } + ); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/telemetry_service.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/telemetry_service.ts new file mode 100644 index 000000000000..6e00687b4256 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/telemetry_service.ts @@ -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 type { AnalyticsServiceSetup } from '@kbn/core-analytics-server'; + +import type { + TelemetryEventTypeData, + TelemetryEventTypes, + TelemetryServiceSetupParams, +} from './types'; +import { telemetryEvents } from './events/telemetry_events'; + +export interface TelemetryServiceStart { + reportEvent: ( + eventType: T, + eventData: TelemetryEventTypeData + ) => void; +} +/** + * Service that interacts with the Core's analytics module + * to trigger custom event for Security Solution plugin features + */ +export class TelemetryService { + constructor(private analytics: AnalyticsServiceSetup | null = null) {} + + public setup({ analytics }: TelemetryServiceSetupParams) { + this.analytics = analytics; + telemetryEvents.forEach((eventConfig) => + analytics.registerEventType>(eventConfig) + ); + } + + public start(): TelemetryServiceStart { + const reportEvent = this.analytics?.reportEvent.bind(this.analytics); + + if (!this.analytics || !reportEvent) { + throw new Error( + 'The TelemetryService.setup() method has not been invoked, be sure to call it during the plugin setup.' + ); + } + + return { reportEvent }; + } +} diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/types.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/types.ts new file mode 100644 index 000000000000..e84a0d5353f0 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/common/lib/telemetry/types.ts @@ -0,0 +1,23 @@ +/* + * 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 type { AnalyticsServiceSetup } from '@kbn/core/public'; + +import type { AssistantEventTypes, AssistantTelemetryEventsMap } from './events/ai_assistant/types'; + +export * from './events/ai_assistant/types'; + +export interface TelemetryServiceSetupParams { + analytics: AnalyticsServiceSetup; +} + +// Combine all event type data +export type TelemetryEventTypeData = T extends AssistantEventTypes + ? AssistantTelemetryEventsMap[T] + : never; + +export type TelemetryEventTypes = AssistantEventTypes; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/comment_actions/base_comment_actions.test.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/comment_actions/base_comment_actions.test.tsx new file mode 100644 index 000000000000..1b8b4a5bd86f --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/comment_actions/base_comment_actions.test.tsx @@ -0,0 +1,56 @@ +/* + * 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 type { ClientMessage } from '@kbn/elastic-assistant'; +import { EuiCopy, EuiFlexItem } from '@elastic/eui'; +import { BaseCommentActions } from './base_comment_actions'; + +jest.mock('@elastic/eui', () => ({ + ...jest.requireActual('@elastic/eui'), + EuiCopy: jest.fn(), +})); + +describe('CommentActions', () => { + beforeEach(() => { + (EuiCopy as unknown as jest.Mock).mockClear(); + }); + + it.each([ + [`Only this should be copied!{reference(exampleReferenceId)}`, 'Only this should be copied!'], + [ + `Only this.{reference(exampleReferenceId)} should be copied!{reference(exampleReferenceId)}`, + 'Only this. should be copied!', + ], + [`{reference(exampleReferenceId)}`, ''], + ])("textToCopy is correct when input is '%s'", async (input, expected) => { + (EuiCopy as unknown as jest.Mock).mockReturnValue(null); + const message: ClientMessage = { + content: input, + role: 'assistant', + timestamp: '2025-01-08T10:47:34.578Z', + }; + render( + + +
{'Other actions'}
+
+
+ ); + + expect(EuiCopy).toHaveBeenCalledWith( + expect.objectContaining({ + textToCopy: expected, + }), + expect.anything() + ); + + expect(screen.getByTestId('copy-to-clipboard-action')).toBeInTheDocument(); + expect(screen.getByTestId('placeholder_actions')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/comment_actions/base_comment_actions.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/comment_actions/base_comment_actions.tsx new file mode 100644 index 000000000000..d1ed102cdd86 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/comment_actions/base_comment_actions.tsx @@ -0,0 +1,60 @@ +/* + * 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 { EuiButtonIcon, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import type { ClientMessage } from '@kbn/elastic-assistant'; +import React from 'react'; + +import { removeContentReferences } from '@kbn/elastic-assistant-common'; +import { i18n } from '@kbn/i18n'; + +export const COPY_TO_CLIPBOARD = i18n.translate( + 'xpack.elasticAssistantPlugin.assistant.commentActions.copyToClipboard', + { + defaultMessage: 'Copy to clipboard', + } +); + +interface Props { + message: ClientMessage; + children: React.ReactNode; +} + +/** + * Returns the content of the message compatible with a standard markdown renderer. + * + * Content references are removed as they can only be rendered by the assistant. + */ +function getSelfContainedContent(content: string): string { + return removeContentReferences(content).trim(); +} + +const BaseCommentActionsComponent: React.FC = ({ message, children }) => { + const content = message.content ?? ''; + + return ( + + {children} + + + + {(copy) => ( + + )} + + + + + ); +}; + +export const BaseCommentActions = React.memo(BaseCommentActionsComponent); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/comment_actions/comment_actions_mounter.test.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/comment_actions/comment_actions_mounter.test.tsx new file mode 100644 index 000000000000..100f1175b0ba --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/comment_actions/comment_actions_mounter.test.tsx @@ -0,0 +1,124 @@ +/* + * 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, waitFor } from '@testing-library/react'; +import type { ClientMessage } from '@kbn/elastic-assistant'; +import { EuiFlexItem } from '@elastic/eui'; +import { CommentActionsMounter } from './comment_actions_mounter'; +import { CommentsService } from '@kbn/elastic-assistant-shared-state'; + +// eslint-disable-next-line @kbn/eslint/module_migration +import { createRoot } from 'react-dom/client'; + +describe('CommentActionsMounter', () => { + it('multiple comment actions are mounted', async () => { + const message: ClientMessage = { + content: 'This is a test comment', + role: 'assistant', + timestamp: '2025-01-08T10:47:34.578Z', + }; + + const commentService = new CommentsService(); + + const start = commentService.start(); + + start.registerActions({ + order: 1, + mount: (args) => (target: HTMLElement) => { + const div = document.createElement('div'); + target.appendChild(div); + const root = createRoot(div); + root.render({'Hello'}); + return () => { + root.unmount(); + target.removeChild(div); + }; + }, + }); + + start.registerActions({ + order: 2, + mount: (args) => (target: HTMLElement) => { + const div = document.createElement('div'); + target.appendChild(div); + const root = createRoot(div); + root.render({'Bye'}); + return () => { + root.unmount(); + target.removeChild(div); + }; + }, + }); + + render(); + + expect(screen.getByTestId('copy-to-clipboard-action')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByTestId('placeholder_actions_1')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId('placeholder_actions_2')).toBeInTheDocument()); + + const placeholder1 = screen.getByTestId('placeholder_actions_1'); + const placeholder2 = screen.getByTestId('placeholder_actions_2'); + expect( + // eslint-disable-next-line no-bitwise + placeholder1.compareDocumentPosition(placeholder2) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it('multiple comment actions are mounted in reverse order', async () => { + const message: ClientMessage = { + content: 'This is a test comment', + role: 'assistant', + timestamp: '2025-01-08T10:47:34.578Z', + }; + + const commentService = new CommentsService(); + + const start = commentService.start(); + + start.registerActions({ + order: 2, + mount: (args) => (target: HTMLElement) => { + const div = document.createElement('div'); + target.appendChild(div); + const root = createRoot(div); + root.render({'Hello'}); + return () => { + root.unmount(); + target.removeChild(div); + }; + }, + }); + + start.registerActions({ + order: 1, + mount: (args) => (target: HTMLElement) => { + const div = document.createElement('div'); + target.appendChild(div); + const root = createRoot(div); + root.render({'Bye'}); + return () => { + root.unmount(); + target.removeChild(div); + }; + }, + }); + + render(); + + expect(screen.getByTestId('copy-to-clipboard-action')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByTestId('placeholder_actions_1')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId('placeholder_actions_2')).toBeInTheDocument()); + + const placeholder1 = screen.getByTestId('placeholder_actions_1'); + const placeholder2 = screen.getByTestId('placeholder_actions_2'); + expect( + // eslint-disable-next-line no-bitwise + placeholder1.compareDocumentPosition(placeholder2) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeFalsy(); + }); +}); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/comment_actions/comment_actions_mounter.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/comment_actions/comment_actions_mounter.tsx new file mode 100644 index 000000000000..9af69b061d00 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/comment_actions/comment_actions_mounter.tsx @@ -0,0 +1,41 @@ +/* + * 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 { Observable } from 'rxjs'; +import { CommentServiceActions } from '@kbn/elastic-assistant-shared-state'; +import { ClientMessage } from '@kbn/elastic-assistant'; +import useObservable from 'react-use/lib/useObservable'; +import React, { useEffect, useRef } from 'react'; +import { EuiFlexGroup } from '@elastic/eui'; +import { BaseCommentActions } from './base_comment_actions'; + +interface Props { + message: ClientMessage; + getActions$: Observable; +} + +export const CommentActionsMounter = ({ message, getActions$ }: Props) => { + const actions = useObservable(getActions$, []); + + const actionMountPointRef = useRef(null); + + useEffect(() => { + const mountPoint = actionMountPointRef.current; + const unmountActions = mountPoint + ? actions.map((action) => action.mount({ message })(mountPoint)) + : []; + return () => { + unmountActions.forEach((unmount) => unmount()); + }; + }, [actions, message]); + + return ( + + + + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/content_reference_button.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/content_reference_button.tsx similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/content_reference_button.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/content_reference_button.tsx diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/content_reference_component_factory.test.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/content_reference_component_factory.test.tsx similarity index 96% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/content_reference_component_factory.test.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/content_reference_component_factory.test.tsx index e87337802ed7..477567e0d993 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/content_reference_component_factory.test.tsx +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/content_reference_component_factory.test.tsx @@ -22,10 +22,13 @@ jest.mock('@kbn/elastic-assistant', () => ({ }, }), })); -jest.mock('../../../../common/lib/kibana', () => ({ +jest.mock('@kbn/security-solution-navigation', () => ({ useNavigation: jest.fn().mockReturnValue({ navigateTo: jest.fn(), }), +})); + +jest.mock('../../../../context/typed_kibana_context/typed_kibana_context', () => ({ useKibana: jest.fn().mockReturnValue({ services: { discover: { diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/content_reference_component_factory.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/content_reference_component_factory.tsx similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/content_reference_component_factory.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/content_reference_component_factory.tsx diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/esql_query_reference.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/esql_query_reference.tsx similarity index 94% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/esql_query_reference.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/esql_query_reference.tsx index 39b3fd74529d..1d9dffc093ba 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/esql_query_reference.tsx +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/esql_query_reference.tsx @@ -10,7 +10,7 @@ import React, { useCallback } from 'react'; import { EuiLink } from '@elastic/eui'; import type { ResolvedContentReferenceNode } from '../content_reference_parser'; import { PopoverReference } from './popover_reference'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana } from '../../../../context/typed_kibana_context/typed_kibana_context'; interface Props { contentReferenceNode: ResolvedContentReferenceNode; diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/href_reference.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/href_reference.tsx similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/href_reference.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/href_reference.tsx diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/knowledge_base_entry_reference.test.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/knowledge_base_entry_reference.test.tsx similarity index 95% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/knowledge_base_entry_reference.test.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/knowledge_base_entry_reference.test.tsx index 71c63862a8f2..8146661ae21d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/knowledge_base_entry_reference.test.tsx +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/knowledge_base_entry_reference.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; import { KnowledgeBaseEntryReference } from './knowledge_base_entry_reference'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana } from '../../../../context/typed_kibana_context/typed_kibana_context'; import { useAssistantContext } from '@kbn/elastic-assistant'; import type { ResolvedContentReferenceNode } from '../content_reference_parser'; import type { KnowledgeBaseEntryContentReference } from '@kbn/elastic-assistant-common'; @@ -17,7 +17,7 @@ import { SecurityPageName } from '@kbn/deeplinks-security'; // Mocks jest.mock('@kbn/elastic-assistant'); -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../context/typed_kibana_context/typed_kibana_context'); const mockNavigateToApp = jest.fn(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/knowledge_base_entry_reference.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/knowledge_base_entry_reference.tsx similarity index 95% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/knowledge_base_entry_reference.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/knowledge_base_entry_reference.tsx index 728b16a20eb9..d8d1e8c2b881 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/knowledge_base_entry_reference.tsx +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/knowledge_base_entry_reference.tsx @@ -12,7 +12,7 @@ import { useAssistantContext } from '@kbn/elastic-assistant'; import { KNOWLEDGE_BASE_ENTRY_REFERENCE_LABEL } from './translations'; import type { ResolvedContentReferenceNode } from '../content_reference_parser'; import { PopoverReference } from './popover_reference'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana } from '../../../../context/typed_kibana_context/typed_kibana_context'; import { openKnowledgeBasePageByEntryId } from './navigation_helpers'; interface Props { diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/link_to/helpers.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/link_to/helpers.test.ts new file mode 100644 index 000000000000..e41d7700d07d --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/link_to/helpers.test.ts @@ -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 { appendSearch } from './helpers'; + +describe('appendSearch', () => { + test('should return empty string if no parameter', () => { + expect(appendSearch()).toEqual(''); + }); + test('should return empty string if parameter is undefined', () => { + expect(appendSearch(undefined)).toEqual(''); + }); + test('should return parameter if parameter is defined', () => { + expect(appendSearch('helloWorld')).toEqual('?helloWorld'); + }); +}); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/link_to/helpers.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/link_to/helpers.ts new file mode 100644 index 000000000000..7c6a04cf1c10 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/link_to/helpers.ts @@ -0,0 +1,11 @@ +/* + * 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 { isEmpty } from 'lodash/fp'; + +export const appendSearch = (search?: string) => + isEmpty(search) ? '' : `${search?.startsWith('?') ? search : `?${search}`}`; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/link_to/redirect_to_detection_engine.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/link_to/redirect_to_detection_engine.tsx new file mode 100644 index 000000000000..fd11d7b81646 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/link_to/redirect_to_detection_engine.tsx @@ -0,0 +1,10 @@ +/* + * 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 { appendSearch } from './helpers'; + +export const getDetectionEngineUrl = (search?: string) => `${appendSearch(search)}`; diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/navigation_helpers.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/navigation_helpers.tsx similarity index 87% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/navigation_helpers.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/navigation_helpers.tsx index 9879eeee560d..107d420e827a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/navigation_helpers.tsx +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/navigation_helpers.tsx @@ -9,10 +9,13 @@ import { SecurityPageName } from '@kbn/deeplinks-security'; import { encode } from '@kbn/rison'; import type { ApplicationStart } from '@kbn/core-application-browser'; import { KNOWLEDGE_BASE_TAB } from '@kbn/elastic-assistant/impl/assistant/settings/const'; -import type { useNavigateToAlertsPageWithFilters } from '../../../../common/hooks/use_navigate_to_alerts_page_with_filters'; -import { URL_PARAM_KEY } from '../../../../common/hooks/constants'; -import { getDetectionEngineUrl } from '../../../../common/components/link_to'; -import { FILTER_ACKNOWLEDGED, FILTER_OPEN } from '../../../../../common/types'; +import type { useNavigateToAlertsPageWithFilters } from '../../../../hooks/navigate_to_alerts_page_with_filters/use_navigate_to_alerts_page_with_filters'; +import { URL_PARAM_KEY } from '../../../../hooks/navigate_to_alerts_page_with_filters/constants'; +import { getDetectionEngineUrl } from './link_to/redirect_to_detection_engine'; +import { + ALERTS_PAGE_FILTER_ACKNOWLEDGED, + ALERTS_PAGE_FILTER_OPEN, +} from '../../../../common/constants'; /** * Opens the AI4DSOC alert summary page, filtered by alertId */ @@ -65,7 +68,7 @@ export const openAlertsPageByAlertId = ( const openAlertSummaryPage = (navigateToApp: ApplicationStart['navigateToApp']) => { const kqlAppQuery = encode({ language: 'kuery', - query: `kibana.alert.workflow_status: ${FILTER_OPEN} OR kibana.alert.workflow_status: ${FILTER_ACKNOWLEDGED}`, + query: `kibana.alert.workflow_status: ${ALERTS_PAGE_FILTER_OPEN} OR kibana.alert.workflow_status: ${ALERTS_PAGE_FILTER_ACKNOWLEDGED}`, }); const urlParams = new URLSearchParams({ @@ -87,7 +90,7 @@ const openAlertsPage = ( ) => openAlertsPageWithFilters( { - selectedOptions: [FILTER_OPEN, FILTER_ACKNOWLEDGED], + selectedOptions: [ALERTS_PAGE_FILTER_OPEN, ALERTS_PAGE_FILTER_ACKNOWLEDGED], fieldName: 'kibana.alert.workflow_status', persist: false, }, diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/popover_reference.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/popover_reference.tsx similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/popover_reference.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/popover_reference.tsx diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/product_documentation_reference.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/product_documentation_reference.tsx similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/product_documentation_reference.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/product_documentation_reference.tsx diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/security_alert_reference.test.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/security_alert_reference.test.tsx similarity index 90% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/security_alert_reference.test.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/security_alert_reference.test.tsx index 4f3f9dbe97bd..f0cb62dea0d3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/security_alert_reference.test.tsx +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/security_alert_reference.test.tsx @@ -9,19 +9,19 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; import { SecurityAlertReference } from './security_alert_reference'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana } from '../../../../context/typed_kibana_context/typed_kibana_context'; import { useAssistantContext } from '@kbn/elastic-assistant'; import type { ResolvedContentReferenceNode } from '../content_reference_parser'; import type { SecurityAlertContentReference } from '@kbn/elastic-assistant-common'; import { SecurityPageName } from '@kbn/deeplinks-security'; -import { URL_PARAM_KEY } from '../../../../common/hooks/constants'; +import { URL_PARAM_KEY } from '../../../../hooks/navigate_to_alerts_page_with_filters/constants'; import { SECURITY_ALERT_REFERENCE_LABEL } from './translations'; import { encode } from '@kbn/rison'; -import { getDetectionEngineUrl } from '../../../../common/components/link_to'; +import { getDetectionEngineUrl } from './link_to/redirect_to_detection_engine'; // Mocks jest.mock('@kbn/elastic-assistant'); -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../context/typed_kibana_context/typed_kibana_context'); const mockNavigateToApp = jest.fn(); const mockUseKibana = useKibana as jest.Mock; diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/security_alert_reference.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/security_alert_reference.tsx similarity index 95% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/security_alert_reference.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/security_alert_reference.tsx index 29dce688d520..c139f4dd00a9 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/security_alert_reference.tsx +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/security_alert_reference.tsx @@ -12,9 +12,8 @@ import { useAssistantContext } from '@kbn/elastic-assistant'; import { SECURITY_ALERT_REFERENCE_LABEL } from './translations'; import type { ResolvedContentReferenceNode } from '../content_reference_parser'; import { PopoverReference } from './popover_reference'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana } from '../../../../context/typed_kibana_context/typed_kibana_context'; import { openAlertsPageByAlertId } from './navigation_helpers'; - interface Props { contentReferenceNode: ResolvedContentReferenceNode; } diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/security_alerts_page_reference.test.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/security_alerts_page_reference.test.tsx similarity index 81% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/security_alerts_page_reference.test.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/security_alerts_page_reference.test.tsx index 1c57191199de..b3e86f841bc7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/security_alerts_page_reference.test.tsx +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/security_alerts_page_reference.test.tsx @@ -9,22 +9,27 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; import { SecurityAlertsPageReference } from './security_alerts_page_reference'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana } from '../../../../context/typed_kibana_context/typed_kibana_context'; import { useAssistantContext } from '@kbn/elastic-assistant'; -import { useNavigateToAlertsPageWithFilters } from '../../../../common/hooks/use_navigate_to_alerts_page_with_filters'; +import { useNavigateToAlertsPageWithFilters } from '../../../../hooks/navigate_to_alerts_page_with_filters/use_navigate_to_alerts_page_with_filters'; import type { ResolvedContentReferenceNode } from '../content_reference_parser'; import type { SecurityAlertsPageContentReference } from '@kbn/elastic-assistant-common'; import { SecurityPageName } from '@kbn/deeplinks-security'; -import { URL_PARAM_KEY } from '../../../../common/hooks/constants'; +import { URL_PARAM_KEY } from '../../../../hooks/navigate_to_alerts_page_with_filters/constants'; import { SECURITY_ALERTS_PAGE_REFERENCE_LABEL } from './translations'; import { encode } from '@kbn/rison'; -import { getDetectionEngineUrl } from '../../../../common/components/link_to'; -import { FILTER_OPEN, FILTER_ACKNOWLEDGED } from '../../../../../common/types'; +import { getDetectionEngineUrl } from './link_to/redirect_to_detection_engine'; +import { + ALERTS_PAGE_FILTER_ACKNOWLEDGED, + ALERTS_PAGE_FILTER_OPEN, +} from '../../../../common/constants'; // Mocks jest.mock('@kbn/elastic-assistant'); -jest.mock('../../../../common/lib/kibana'); -jest.mock('../../../../common/hooks/use_navigate_to_alerts_page_with_filters'); +jest.mock('../../../../context/typed_kibana_context/typed_kibana_context'); +jest.mock( + '../../../../hooks/navigate_to_alerts_page_with_filters/use_navigate_to_alerts_page_with_filters' +); const mockNavigateToApp = jest.fn(); const mockOpenAlertsPageWithFilters = jest.fn(); @@ -70,7 +75,7 @@ describe('SecurityAlertsPageReference', () => { const kqlAppQuery = encode({ language: 'kuery', - query: `kibana.alert.workflow_status: ${FILTER_OPEN} OR kibana.alert.workflow_status: ${FILTER_ACKNOWLEDGED}`, + query: `kibana.alert.workflow_status: ${ALERTS_PAGE_FILTER_OPEN} OR kibana.alert.workflow_status: ${ALERTS_PAGE_FILTER_ACKNOWLEDGED}`, }); const urlParams = new URLSearchParams({ [URL_PARAM_KEY.appQuery]: kqlAppQuery, @@ -93,7 +98,7 @@ describe('SecurityAlertsPageReference', () => { fireEvent.click(screen.getByTestId('alertsReferenceLink')); expect(mockOpenAlertsPageWithFilters).toHaveBeenCalledWith( { - selectedOptions: [FILTER_OPEN, FILTER_ACKNOWLEDGED], + selectedOptions: [ALERTS_PAGE_FILTER_OPEN, ALERTS_PAGE_FILTER_ACKNOWLEDGED], fieldName: 'kibana.alert.workflow_status', persist: false, }, diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/security_alerts_page_reference.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/security_alerts_page_reference.tsx similarity index 91% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/security_alerts_page_reference.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/security_alerts_page_reference.tsx index ddd31837882f..8048b94ab687 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/security_alerts_page_reference.tsx +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/security_alerts_page_reference.tsx @@ -12,9 +12,9 @@ import { useAssistantContext } from '@kbn/elastic-assistant'; import type { ResolvedContentReferenceNode } from '../content_reference_parser'; import { PopoverReference } from './popover_reference'; import { SECURITY_ALERTS_PAGE_REFERENCE_LABEL } from './translations'; -import { useNavigateToAlertsPageWithFilters } from '../../../../common/hooks/use_navigate_to_alerts_page_with_filters'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useNavigateToAlertsPageWithFilters } from '../../../../hooks/navigate_to_alerts_page_with_filters/use_navigate_to_alerts_page_with_filters'; import { openAlertsPageByOpenAndAck } from './navigation_helpers'; +import { useKibana } from '../../../../context/typed_kibana_context/typed_kibana_context'; interface Props { contentReferenceNode: ResolvedContentReferenceNode; diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/translations.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/translations.ts similarity index 69% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/translations.ts rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/translations.ts index c5fe48787d4e..97d95d365526 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/components/translations.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/components/translations.ts @@ -8,21 +8,21 @@ import { i18n } from '@kbn/i18n'; export const SECURITY_ALERT_REFERENCE_LABEL = i18n.translate( - 'xpack.securitySolution.assistant.contentReferences.securityAlertReference.label', + 'xpack.elasticAssistantPlugin.assistant.contentReferences.securityAlertReference.label', { defaultMessage: 'View alert', } ); export const SECURITY_ALERTS_PAGE_REFERENCE_LABEL = i18n.translate( - 'xpack.securitySolution.assistant.contentReferences.securityAlertsPageReference.label', + 'xpack.elasticAssistantPlugin.assistant.contentReferences.securityAlertsPageReference.label', { defaultMessage: 'View alerts', } ); export const KNOWLEDGE_BASE_ENTRY_REFERENCE_LABEL = i18n.translate( - 'xpack.securitySolution.assistant.contentReferences.knowledgeBaseEntryReference.label', + 'xpack.elasticAssistantPlugin.assistant.contentReferences.knowledgeBaseEntryReference.label', { defaultMessage: 'Knowledge base entry', } diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/content_reference_parser.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/content_reference_parser.test.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/content_reference_parser.test.ts rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/content_reference_parser.test.ts 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/elastic_assistant/public/src/components/get_comments/content_reference/content_reference_parser.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/content_reference/content_reference_parser.ts rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/content_reference/content_reference_parser.ts diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/index.test.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/index.test.tsx similarity index 91% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/index.test.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/index.test.tsx index a8b8b4621225..d7f2f9fc0610 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/index.test.tsx +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/index.test.tsx @@ -42,11 +42,11 @@ const testProps = { }; describe('getComments', () => { it('Does not add error state message has no error', () => { - const result = getComments(testProps); + const result = getComments({ CommentActions: () => null })(testProps); expect(result[0].eventColor).toEqual(undefined); }); it('Adds error state when message has error', () => { - const result = getComments({ + const result = getComments({ CommentActions: () => null })({ ...testProps, currentConversation: { category: 'assistant', @@ -72,7 +72,7 @@ describe('getComments', () => { }); it('It transforms message timestamp from server side ISO format to local date string', () => { - const result = getComments(testProps); + const result = getComments({ CommentActions: () => null })(testProps); expect(result[0].timestamp).toEqual( `at: ${new Date('2024-03-19T18:59:18.174Z').toLocaleString()}` ); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/index.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/index.tsx new file mode 100644 index 000000000000..340e51cc1119 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/index.tsx @@ -0,0 +1,233 @@ +/* + * 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 type { ClientMessage, GetAssistantMessages } from '@kbn/elastic-assistant'; +import { EuiAvatar, EuiLoadingSpinner } from '@elastic/eui'; +import React from 'react'; + +import { AssistantAvatar } from '@kbn/ai-assistant-icon'; +import type { Replacements } from '@kbn/elastic-assistant-common'; +import { replaceAnonymizedValuesWithOriginalValues } from '@kbn/elastic-assistant-common'; +import styled from '@emotion/styled'; +import type { EuiPanelProps } from '@elastic/eui/src/components/panel'; +import { StreamComment } from './stream'; +import * as i18n from './translations'; + +// Matches EuiAvatar L +const SpinnerWrapper = styled.div` + width: 40px; + height: 40px; + display: flex; + justify-content: center; +`; + +export interface ContentMessage extends ClientMessage { + content: string; +} +const transformMessageWithReplacements = ({ + message, + content, + showAnonymizedValues, + replacements, +}: { + message: ClientMessage; + content: string; + showAnonymizedValues: boolean; + replacements: Replacements; +}): ContentMessage => { + return { + ...message, + content: showAnonymizedValues + ? content + : replaceAnonymizedValuesWithOriginalValues({ + messageContent: content, + replacements, + }), + }; +}; + +type GetComments = (args: { + CommentActions: React.FC<{ message: ClientMessage }>; +}) => GetAssistantMessages; + +export const getComments: GetComments = + (args) => + ({ + abortStream, + currentConversation, + isFetchingResponse, + refetchCurrentConversation, + regenerateMessage, + showAnonymizedValues, + currentUserAvatar, + setIsStreaming, + systemPromptContent, + contentReferencesVisible, + }) => { + if (!currentConversation) return []; + + const regenerateMessageOfConversation = () => { + regenerateMessage(currentConversation.id); + }; + + const extraLoadingComment = isFetchingResponse + ? [ + { + username: i18n.ASSISTANT, + timelineAvatar: ( + + + + ), + timestamp: '...', + children: ( + ({ content: '' } as unknown as ContentMessage)} + contentReferences={null} + messageRole="assistant" + isFetching + // we never need to append to a code block in the loading comment, which is what this index is used for + index={999} + /> + ), + }, + ] + : []; + + const UserAvatar = () => { + if (currentUserAvatar) { + return ( + + ); + } + + return ; + }; + + return [ + ...(systemPromptContent && currentConversation.messages.length + ? [ + { + username: i18n.SYSTEM, + timelineAvatar: , + timestamp: + currentConversation.messages[0].timestamp.length === 0 + ? new Date().toLocaleString() + : new Date(currentConversation.messages[0].timestamp).toLocaleString(), + children: ( + ({ content: '' } as unknown as ContentMessage)} + messageRole={'assistant'} + // we never need to append to a code block in the system comment, which is what this index is used for + index={999} + /> + ), + }, + ] + : []), + ...currentConversation.messages.map((message, index) => { + const isLastComment = index === currentConversation.messages.length - 1; + const isUser = message.role === 'user'; + const replacements = currentConversation.replacements; + + const messageProps = { + timelineAvatar: isUser ? ( + + ) : ( + + ), + timestamp: i18n.AT( + message.timestamp.length === 0 + ? new Date().toLocaleString() + : new Date(message.timestamp).toLocaleString() + ), + username: isUser ? i18n.YOU : i18n.ASSISTANT, + eventColor: message.isError ? ('danger' as EuiPanelProps['color']) : undefined, + }; + + const isControlsEnabled = isLastComment && !isUser; + + const transformMessage = (content: string) => + transformMessageWithReplacements({ + message, + content, + showAnonymizedValues, + replacements, + }); + + // message still needs to stream, no actions returned and replacements handled by streamer + if (!(message.content && message.content.length)) { + return { + ...messageProps, + children: ( + + ), + }; + } + + // transform message here so we can send correct message to CommentActions + const transformedMessage = transformMessage(message.content ?? ''); + + return { + ...messageProps, + actions: , + children: ( + + ), + }; + }), + ...extraLoadingComment, + ]; + }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/buttons/regenerate_response_button.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/buttons/regenerate_response_button.tsx similarity index 88% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/buttons/regenerate_response_button.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/buttons/regenerate_response_button.tsx index e9121a9ed9f2..3b5fdc935a41 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/buttons/regenerate_response_button.tsx +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/buttons/regenerate_response_button.tsx @@ -18,7 +18,7 @@ export function RegenerateResponseButton(props: Partial) { iconType="sparkles" {...props} > - {i18n.translate('xpack.securitySolution.aiAssistant.regenerateResponseButtonLabel', { + {i18n.translate('xpack.elasticAssistantPlugin.aiAssistant.regenerateResponseButtonLabel', { defaultMessage: 'Regenerate', })} diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/buttons/stop_generating_button.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/buttons/stop_generating_button.tsx similarity index 88% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/buttons/stop_generating_button.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/buttons/stop_generating_button.tsx index 5144e82b1125..2743b03b3ed0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/buttons/stop_generating_button.tsx +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/buttons/stop_generating_button.tsx @@ -19,7 +19,7 @@ export function StopGeneratingButton(props: Partial) { size="s" {...props} > - {i18n.translate('xpack.securitySolution.aiAssistant.stopGeneratingButtonLabel', { + {i18n.translate('xpack.elasticAssistantPlugin.aiAssistant.stopGeneratingButtonLabel', { defaultMessage: 'Stop generating', })} diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/failed_to_load_response.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/failed_to_load_response.tsx similarity index 89% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/failed_to_load_response.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/failed_to_load_response.tsx index 5161f5a2b029..9f5308b514e7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/failed_to_load_response.tsx +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/failed_to_load_response.tsx @@ -17,7 +17,7 @@ export function FailedToLoadResponse() { - {i18n.translate('xpack.securitySolution.aiAssistant.failedLoadingResponseText', { + {i18n.translate('xpack.elasticAssistantPlugin.aiAssistant.failedLoadingResponseText', { defaultMessage: 'Failed to load response', })} diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/index.test.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/index.test.tsx similarity index 86% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/index.test.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/index.test.tsx index d631cd7898a0..137dc9a5037c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/index.test.tsx +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/index.test.tsx @@ -6,16 +6,12 @@ */ import React from 'react'; -import type { UseQueryResult } from '@tanstack/react-query'; import { render, screen, fireEvent } from '@testing-library/react'; -import { useFetchConnectorsQuery } from '../../../detection_engine/rule_management/api/hooks/use_fetch_connectors_query'; + import { StreamComment } from '.'; import { useStream } from './use_stream'; -import type { Connector } from '@kbn/actions-plugin/server/application/connector/types'; -import type { AsApiContract } from '@kbn/actions-plugin/common'; const mockSetComplete = jest.fn(); -jest.mock('../../../detection_engine/rule_management/api/hooks/use_fetch_connectors_query'); jest.mock('./use_stream'); jest.mock('@kbn/elastic-assistant', () => ({ @@ -25,10 +21,13 @@ jest.mock('@kbn/elastic-assistant', () => ({ }, }), })); -jest.mock('../../../common/lib/kibana', () => ({ +jest.mock('@kbn/security-solution-navigation', () => ({ useNavigation: jest.fn().mockReturnValue({ navigateTo: jest.fn(), }), +})); + +jest.mock('../../../context/typed_kibana_context/typed_kibana_context', () => ({ useKibana: jest.fn().mockReturnValue({ services: { discover: { @@ -70,16 +69,6 @@ describe('StreamComment', () => { pendingMessage: 'Test Message', setComplete: mockSetComplete, }); - const connectors: unknown[] = [ - { - id: 'hi', - name: 'OpenAI connector', - actionTypeId: '.gen-ai', - }, - ]; - jest.mocked(useFetchConnectorsQuery).mockReturnValue({ - data: connectors, - } as unknown as UseQueryResult>, unknown>); }); it('renders content correctly', () => { render(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/index.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/index.tsx similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/index.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/index.tsx diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/message_panel.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/message_panel.tsx similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/message_panel.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/message_panel.tsx diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/message_text.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/message_text.tsx similarity index 95% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/message_text.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/message_text.tsx index 05e5168c2440..dcbccb6993a7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/message_text.tsx +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/message_text.tsx @@ -20,8 +20,8 @@ import { css } from '@emotion/react'; import type { Code, InlineCode, Parent, Text } from 'mdast'; import React, { useMemo } from 'react'; import type { Node } from 'unist'; -import { customCodeBlockLanguagePlugin } from '../custom_codeblock/custom_codeblock_markdown_plugin'; -import { CustomCodeBlock } from '../custom_codeblock/custom_code_block'; +import { customCodeBlockLanguagePlugin } from '@kbn/elastic-assistant/impl/get_comments/custom_codeblock/custom_codeblock_markdown_plugin'; +import { CustomCodeBlock } from '@kbn/elastic-assistant/impl/get_comments/custom_codeblock/custom_code_block'; import { contentReferenceParser } from '../content_reference/content_reference_parser'; import type { StreamingOrFinalContentReferences } from '../content_reference/components/content_reference_component_factory'; import { ContentReferenceComponentFactory } from '../content_reference/components/content_reference_component_factory'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/stream_observable.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/stream_observable.test.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/stream_observable.test.ts rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/stream_observable.test.ts diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/stream_observable.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/stream_observable.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/stream_observable.ts rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/stream_observable.ts diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/types.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/types.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/types.ts rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/types.ts diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/use_stream.test.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/use_stream.test.tsx similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/use_stream.test.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/use_stream.test.tsx diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/use_stream.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/use_stream.tsx similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/use_stream.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/stream/use_stream.tsx diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/translations.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/translations.ts similarity index 50% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/translations.ts rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/translations.ts index 62614dbfaf77..64b033471c9b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/translations.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/components/get_comments/translations.ts @@ -7,24 +7,27 @@ import { i18n } from '@kbn/i18n'; -export const SYSTEM = i18n.translate('xpack.securitySolution.assistant.getComments.system', { +export const SYSTEM = i18n.translate('xpack.elasticAssistantPlugin.assistant.getComments.system', { defaultMessage: 'System', }); -export const ASSISTANT = i18n.translate('xpack.securitySolution.assistant.getComments.assistant', { - defaultMessage: 'Assistant', -}); +export const ASSISTANT = i18n.translate( + 'xpack.elasticAssistantPlugin.assistant.getComments.assistant', + { + defaultMessage: 'Assistant', + } +); export const AT = (timestamp: string) => - i18n.translate('xpack.securitySolution.assistant.getComments.at', { + i18n.translate('xpack.elasticAssistantPlugin.assistant.getComments.at', { defaultMessage: 'at: {timestamp}', values: { timestamp }, }); -export const YOU = i18n.translate('xpack.securitySolution.assistant.getComments.you', { +export const YOU = i18n.translate('xpack.elasticAssistantPlugin.assistant.getComments.you', { defaultMessage: 'You', }); -export const API_ERROR = i18n.translate('xpack.securitySolution.assistant.apiErrorTitle', { +export const API_ERROR = i18n.translate('xpack.elasticAssistantPlugin.assistant.apiErrorTitle', { defaultMessage: 'An error occurred sending your message.', }); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/context/assistant_context/assistant_provider.test.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/context/assistant_context/assistant_provider.test.tsx new file mode 100644 index 000000000000..be2f7854093d --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/context/assistant_context/assistant_provider.test.tsx @@ -0,0 +1,111 @@ +/* + * 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 } from '@testing-library/react'; +import { AssistantProvider } from './assistant_provider'; +import React from 'react'; +import { ElasticAssistantTestProviders } from '../../utils/elastic_assistant_test_providers.mock'; +import { elasticAssistantSharedStateMock } from '@kbn/elastic-assistant-shared-state-plugin/public/mocks'; +import { firstValueFrom } from 'rxjs'; +import { + applicationServiceMock, + httpServiceMock, + notificationServiceMock, +} from '@kbn/core/public/mocks'; +import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; + +describe('AssistantProvider', () => { + it('renders assistant provider and pushes the assistant context value to the shared state', async () => { + const elasticAssistantSharedState = elasticAssistantSharedStateMock.createStartContract(); + const mockApplication = applicationServiceMock.createInternalStartContract(); + const actionTypeRegistry = actionTypeRegistryMock.create(); + const mockTriggersActionsUi = { actionTypeRegistry }; + const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' }); + const notifications = notificationServiceMock.createStartContract(); + + render( + +
{'Assistant Provider Test'}
+
, + { + wrapper: ({ children }) => ( + + {children} + + ), + } + ); + + const assistantContextValue = await firstValueFrom( + elasticAssistantSharedState.assistantContextValue.getAssistantContextValue$() + ); + expect(assistantContextValue).toBeDefined(); + expect(assistantContextValue).toEqual( + expect.objectContaining({ + actionTypeRegistry, + assistantAvailability: expect.objectContaining({ + hasAssistantPrivilege: expect.any(Boolean), + hasConnectorsAllPrivilege: expect.any(Boolean), + hasConnectorsReadPrivilege: expect.any(Boolean), + hasManageGlobalKnowledgeBase: expect.any(Boolean), + hasSearchAILakeConfigurations: expect.any(Boolean), + hasUpdateAIAssistantAnonymization: expect.any(Boolean), + isAssistantEnabled: expect.any(Boolean), + }), + assistantFeatures: expect.objectContaining({ + advancedEsqlGeneration: expect.any(Boolean), + assistantModelEvaluation: expect.any(Boolean), + defendInsights: expect.any(Boolean), + }), + assistantStreamingEnabled: expect.any(Boolean), + assistantTelemetry: expect.any(Object), + augmentMessageCodeBlocks: expect.objectContaining({ + mount: expect.any(Function), + }), + basePath: expect.any(String), + basePromptContexts: expect.any(Array), + codeBlockRef: expect.objectContaining({ current: expect.any(Function) }), + contentReferencesVisible: expect.any(Boolean), + currentAppId: expect.any(String), + docLinks: expect.objectContaining({ + DOC_LINK_VERSION: expect.any(String), + ELASTIC_WEBSITE_URL: expect.any(String), + }), + getComments: expect.any(Function), + http: expect.anything(), + inferenceEnabled: expect.any(Boolean), + knowledgeBase: expect.objectContaining({ + latestAlerts: expect.any(Number), + }), + nameSpace: expect.any(String), + navigateToApp: expect.any(Function), + promptContexts: expect.any(Object), + registerPromptContext: expect.any(Function), + setAssistantStreamingEnabled: expect.any(Function), + setContentReferencesVisible: expect.any(Function), + setKnowledgeBase: expect.any(Function), + setSelectedSettingsTab: expect.any(Function), + setShowAnonymizedValues: expect.any(Function), + setShowAssistantOverlay: expect.any(Function), + setTraceOptions: expect.any(Function), + showAnonymizedValues: expect.any(Boolean), + title: expect.any(String), + toasts: expect.any(Object), + traceOptions: expect.any(Object), + unRegisterPromptContext: expect.any(Function), + }) + ); + }); +}); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/context/assistant_context/assistant_provider.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/context/assistant_context/assistant_provider.tsx new file mode 100644 index 000000000000..3f9ca479e291 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/context/assistant_context/assistant_provider.tsx @@ -0,0 +1,125 @@ +/* + * 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, useMemo, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + ClientMessage, + AssistantProvider as ElasticAssistantProvider, +} from '@kbn/elastic-assistant'; +import type { IToasts } from '@kbn/core/public'; +import useObservable from 'react-use/lib/useObservable'; +import { useAssistantContextValue } from '@kbn/elastic-assistant/impl/assistant_context'; +import { getComments } from '../../components/get_comments'; +import { useKibana } from '../typed_kibana_context/typed_kibana_context'; +import { useInferenceEnabled } from '../../hooks/inference_enabled/use_inference_enabled'; +import { useAppToasts } from '../../hooks/toasts/use_app_toasts'; +import { useAssistantAvailability } from '../../hooks/assistant_availability/use_assistant_availability'; +import { useBasePath } from '../../hooks/base_path/use_base_path'; +import { CommentActionsMounter } from '../../components/comment_actions/comment_actions_mounter'; +import { useAssistantTelemetry } from '../../hooks/use_assistant_telemetry'; +import { useIsNavControlVisible } from '../../hooks/is_nav_control_visible/use_is_nav_control_visible'; + +const ASSISTANT_TITLE = i18n.translate('xpack.elasticAssistantPlugin.assistant.title', { + defaultMessage: 'Elastic AI Assistant', +}); + +/** + * This component configures the Elastic AI Assistant context provider for the Security Solution app. + */ +export function AssistantProvider({ children }: { children: React.ReactElement }) { + const { + application: { navigateToApp, currentAppId$, getUrlForApp }, + http, + triggersActionsUi: { actionTypeRegistry }, + docLinks, + userProfile, + chrome, + productDocBase, + elasticAssistantSharedState, + } = useKibana().services; + + const inferenceEnabled = useInferenceEnabled(); + + const basePath = useBasePath(); + const assistantAvailability = useAssistantAvailability(); + + const assistantTelemetry = useAssistantTelemetry(); + const currentAppId = useObservable(currentAppId$, ''); + const promptContext = useObservable( + elasticAssistantSharedState.promptContexts.getPromptContext$(), + {} + ); + const alertsIndexPattern = useObservable( + elasticAssistantSharedState.signalIndex.getSignalIndex$(), + undefined + ); + const augmentMessageCodeBlocks = useObservable( + elasticAssistantSharedState.augmentMessageCodeBlocks.getAugmentMessageCodeBlocks$(), + { + mount: () => () => {}, + } + ); + + const toasts = useAppToasts() as unknown as IToasts; // useAppToasts is the current, non-deprecated method of getting the toasts service in the Security Solution, but it doesn't return the IToasts interface (defined by core) + + const memoizedCommentActionsMounter = useCallback( + (args: { message: ClientMessage }) => { + return ( + + ); + }, + [elasticAssistantSharedState.comments] + ); + + const memoizedGetComments = useMemo(() => { + return getComments({ + CommentActions: memoizedCommentActionsMounter, + }); + }, [memoizedCommentActionsMounter]); + + const assistantContextValue = useAssistantContextValue({ + actionTypeRegistry, + alertsIndexPattern, + augmentMessageCodeBlocks, + assistantAvailability, + assistantTelemetry, + docLinks, + basePath, + basePromptContexts: Object.values(promptContext), + getComments: memoizedGetComments, + http, + inferenceEnabled, + navigateToApp, + productDocBase, + title: ASSISTANT_TITLE, + toasts, + currentAppId: currentAppId ?? 'securitySolutionUI', + userProfileService: userProfile, + chrome, + getUrlForApp, + }); + + useEffect(() => { + elasticAssistantSharedState.assistantContextValue.setAssistantContextValue( + assistantContextValue + ); + }, [assistantContextValue, elasticAssistantSharedState.assistantContextValue]); + + const { isVisible } = useIsNavControlVisible(); + + if (!isVisible) { + return null; + } + + return ( + {children} + ); +} diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/context/assistant_space_id/assistant_space_id_provider.test.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/context/assistant_space_id/assistant_space_id_provider.test.tsx new file mode 100644 index 000000000000..0d759253ebdd --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/context/assistant_space_id/assistant_space_id_provider.test.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { AssistantSpaceIdProvider } from './assistant_space_id_provider'; +import { useSpaceId } from '../../hooks/space_id/use_space_id'; +jest.mock('../../hooks/space_id/use_space_id'); +const mockUseSpaceId = useSpaceId as jest.MockedFunction; +jest.mock('@kbn/elastic-assistant', () => ({ + AssistantSpaceIdProvider: jest.fn(({ children }) => ( +
{children}
+ )), +})); + +describe('AssistantSpaceIdProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not render children when spaceId is undefined', () => { + mockUseSpaceId.mockReturnValue(undefined); + + const { container, queryByText, queryByTestId } = render( + +
{'Child Component'}
+
+ ); + + expect(container.firstChild).toBeNull(); + expect(queryByText('Child Component')).not.toBeInTheDocument(); + expect(queryByTestId('elastic-assistant-provider')).not.toBeInTheDocument(); + expect(queryByTestId('child-component')).not.toBeInTheDocument(); + }); + + it('should render ElasticAssistantSpaceIdProvider with children when spaceId is defined', () => { + const testSpaceId = 'test-space'; + mockUseSpaceId.mockReturnValue(testSpaceId); + + const { getByTestId, getByText } = render( + +
{'Child Component'}
+
+ ); + + expect(getByTestId('elastic-assistant-provider')).toBeInTheDocument(); + + expect(getByText('Child Component')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/context/assistant_space_id/assistant_space_id_provider.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/context/assistant_space_id/assistant_space_id_provider.tsx new file mode 100644 index 000000000000..67313fd75ee1 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/context/assistant_space_id/assistant_space_id_provider.tsx @@ -0,0 +1,24 @@ +/* + * 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 { AssistantSpaceIdProvider as ElasticAssistantSpaceIdProvider } from '@kbn/elastic-assistant'; +import React from 'react'; +import { useSpaceId } from '../../hooks/space_id/use_space_id'; + +export const AssistantSpaceIdProvider: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { + const spaceId = useSpaceId(); + + if (!spaceId) { + return null; + } + + return ( + {children} + ); +}; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/context/query_client_context/elastic_assistant_query_client_provider.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/context/query_client_context/elastic_assistant_query_client_provider.tsx new file mode 100644 index 000000000000..8d913a9f5478 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/context/query_client_context/elastic_assistant_query_client_provider.tsx @@ -0,0 +1,57 @@ +/* + * 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 type { PropsWithChildren } from 'react'; +import React, { memo, useMemo } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +type QueryClientOptionsProp = ConstructorParameters[0]; + +/** + * A security solution specific react-query query client that sets defaults + */ +export class ElasticAssistantQueryClient extends QueryClient { + constructor(options: QueryClientOptionsProp = {}) { + const optionsWithDefaults: QueryClientOptionsProp = { + ...options, + defaultOptions: { + ...(options.defaultOptions ?? {}), + queries: { + refetchIntervalInBackground: false, + refetchOnWindowFocus: false, + refetchOnMount: true, + keepPreviousData: true, + ...(options?.defaultOptions?.queries ?? {}), + }, + }, + }; + super(optionsWithDefaults); + } +} + +/** + * The default Security Solution Query Client. Can be imported and used from outside of React hooks + * and still benefit from ReactQuery features (like caching, etc) + * + * @see https://tanstack.com/query/v4/docs/reference/QueryClient + */ +export const elasticAssistantQueryClient = new ElasticAssistantQueryClient(); + +export type ReactQueryClientProviderProps = PropsWithChildren<{ + queryClient?: ElasticAssistantQueryClient; +}>; + +export const ReactQueryClientProvider = memo( + ({ queryClient, children }) => { + const client = useMemo(() => { + return queryClient || elasticAssistantQueryClient; + }, [queryClient]); + return {children}; + } +); + +ReactQueryClientProvider.displayName = 'ReactQueryClientProvider'; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/context/typed_kibana_context/typed_kibana_context.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/context/typed_kibana_context/typed_kibana_context.tsx new file mode 100644 index 000000000000..beb27853f5b4 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/context/typed_kibana_context/typed_kibana_context.tsx @@ -0,0 +1,13 @@ +/* + * 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 { KibanaContextProvider, useKibana } from '@kbn/kibana-react-plugin/public'; +import { StartServices } from '../../../types'; + +const useTypedKibana = () => useKibana(); + +export { KibanaContextProvider, useTypedKibana as useKibana }; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/assistant_availability/use_assistant_availability.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/assistant_availability/use_assistant_availability.test.ts new file mode 100644 index 000000000000..07e66144da26 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/assistant_availability/use_assistant_availability.test.ts @@ -0,0 +1,173 @@ +/* + * 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 { useAssistantAvailability } from './use_assistant_availability'; +import { useLicense } from '../licence/use_licence'; +import { useKibana } from '../../context/typed_kibana_context/typed_kibana_context'; +import { ASSISTANT_FEATURE_ID, SECURITY_FEATURE_ID } from '../../common/constants'; +import { LicenseService } from '../licence/license_service'; +import { renderHook } from '@testing-library/react'; +jest.mock('../licence/use_licence'); +jest.mock('../../context/typed_kibana_context/typed_kibana_context'); + +const mockUseLicense = useLicense as jest.MockedFunction; +const mockUseKibana = useKibana as jest.MockedFunction; + +describe('useAssistantAvailability', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns correct values when all privileges are available', () => { + mockUseLicense.mockReturnValue({ + isEnterprise: jest.fn().mockReturnValue(true), + } as unknown as LicenseService); + + mockUseKibana.mockReturnValue({ + services: { + application: { + capabilities: { + [ASSISTANT_FEATURE_ID]: { + 'ai-assistant': true, + updateAIAssistantAnonymization: true, + manageGlobalKnowledgeBaseAIAssistant: true, + }, + [SECURITY_FEATURE_ID]: { + configurations: true, + }, + actions: { + show: true, + execute: true, + save: true, + delete: true, + }, + }, + }, + }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useAssistantAvailability()); + + expect(result.current).toEqual({ + hasSearchAILakeConfigurations: true, + hasAssistantPrivilege: true, + hasConnectorsAllPrivilege: true, + hasConnectorsReadPrivilege: true, + isAssistantEnabled: true, + hasUpdateAIAssistantAnonymization: true, + hasManageGlobalKnowledgeBase: true, + }); + }); + + it('returns correct values when no privileges are available', () => { + mockUseLicense.mockReturnValue({ + isEnterprise: jest.fn().mockReturnValue(false), + } as unknown as LicenseService); + + mockUseKibana.mockReturnValue({ + services: { + application: { + capabilities: { + [ASSISTANT_FEATURE_ID]: { + 'ai-assistant': false, + updateAIAssistantAnonymization: false, + manageGlobalKnowledgeBaseAIAssistant: false, + }, + [SECURITY_FEATURE_ID]: { + configurations: false, + }, + actions: { + show: false, + execute: false, + save: false, + delete: false, + }, + }, + }, + }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useAssistantAvailability()); + + expect(result.current).toEqual({ + hasSearchAILakeConfigurations: false, + hasAssistantPrivilege: false, + hasConnectorsAllPrivilege: false, + hasConnectorsReadPrivilege: false, + isAssistantEnabled: false, + hasUpdateAIAssistantAnonymization: false, + hasManageGlobalKnowledgeBase: false, + }); + }); + + it('returns correct values when only read privileges are available', () => { + mockUseLicense.mockReturnValue({ + isEnterprise: jest.fn().mockReturnValue(true), + } as unknown as LicenseService); + + mockUseKibana.mockReturnValue({ + services: { + application: { + capabilities: { + [ASSISTANT_FEATURE_ID]: { + 'ai-assistant': true, + updateAIAssistantAnonymization: false, + manageGlobalKnowledgeBaseAIAssistant: false, + }, + [SECURITY_FEATURE_ID]: { + configurations: false, + }, + actions: { + show: true, + execute: true, + save: false, + delete: false, + }, + }, + }, + }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useAssistantAvailability()); + + expect(result.current).toEqual({ + hasSearchAILakeConfigurations: false, + hasAssistantPrivilege: true, + hasConnectorsAllPrivilege: false, + hasConnectorsReadPrivilege: true, + isAssistantEnabled: true, + hasUpdateAIAssistantAnonymization: false, + hasManageGlobalKnowledgeBase: false, + }); + }); + + it('handles missing capabilities gracefully', () => { + mockUseLicense.mockReturnValue({ + isEnterprise: jest.fn().mockReturnValue(true), + } as unknown as LicenseService); + + mockUseKibana.mockReturnValue({ + services: { + application: { + capabilities: {}, + }, + }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useAssistantAvailability()); + + expect(result.current).toEqual({ + hasSearchAILakeConfigurations: false, + hasAssistantPrivilege: false, + hasConnectorsAllPrivilege: false, + hasConnectorsReadPrivilege: false, + isAssistantEnabled: true, + hasUpdateAIAssistantAnonymization: false, + hasManageGlobalKnowledgeBase: false, + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/assistant_availability/use_assistant_availability.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/assistant_availability/use_assistant_availability.ts new file mode 100644 index 000000000000..54a99d76f590 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/assistant_availability/use_assistant_availability.ts @@ -0,0 +1,44 @@ +/* + * 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 type { UseAssistantAvailability } from '@kbn/elastic-assistant'; +import { ASSISTANT_FEATURE_ID, SECURITY_FEATURE_ID } from '../../common/constants'; +import { useKibana } from '../../context/typed_kibana_context/typed_kibana_context'; + +import { useLicense } from '../licence/use_licence'; + +export const useAssistantAvailability = (): UseAssistantAvailability => { + const isEnterprise = useLicense().isEnterprise(); + const capabilities = useKibana().services.application.capabilities; + + const hasAssistantPrivilege = capabilities[ASSISTANT_FEATURE_ID]?.['ai-assistant'] === true; + const hasUpdateAIAssistantAnonymization = + capabilities[ASSISTANT_FEATURE_ID]?.updateAIAssistantAnonymization === true; + const hasManageGlobalKnowledgeBase = + capabilities[ASSISTANT_FEATURE_ID]?.manageGlobalKnowledgeBaseAIAssistant === true; + const hasSearchAILakeConfigurations = capabilities[SECURITY_FEATURE_ID]?.configurations === true; + + // Connectors & Actions capabilities as defined in x-pack/plugins/actions/server/feature.ts + // `READ` ui capabilities defined as: { ui: ['show', 'execute'] } + const hasConnectorsReadPrivilege = + capabilities.actions?.show === true && capabilities.actions?.execute === true; + // `ALL` ui capabilities defined as: { ui: ['show', 'execute', 'save', 'delete'] } + const hasConnectorsAllPrivilege = + hasConnectorsReadPrivilege && + capabilities.actions?.delete === true && + capabilities.actions?.save === true; + + return { + hasSearchAILakeConfigurations, + hasAssistantPrivilege, + hasConnectorsAllPrivilege, + hasConnectorsReadPrivilege, + isAssistantEnabled: isEnterprise, + hasUpdateAIAssistantAnonymization, + hasManageGlobalKnowledgeBase, + }; +}; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/base_path/use_base_path.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/base_path/use_base_path.ts new file mode 100644 index 000000000000..f4b9fc9f0a98 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/base_path/use_base_path.ts @@ -0,0 +1,10 @@ +/* + * 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 { useKibana } from '../../context/typed_kibana_context/typed_kibana_context'; + +export const useBasePath = (): string => useKibana().services.http.basePath.get(); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/inference_enabled/use_inference_enabled.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/inference_enabled/use_inference_enabled.ts new file mode 100644 index 000000000000..1e30e3fe52fd --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/inference_enabled/use_inference_enabled.ts @@ -0,0 +1,23 @@ +/* + * 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 { useKibana } from '../../context/typed_kibana_context/typed_kibana_context'; + +export const useInferenceEnabled = () => { + const { + triggersActionsUi: { actionTypeRegistry }, + } = useKibana().services; + let inferenceEnabled = false; + try { + actionTypeRegistry.get('.inference'); + inferenceEnabled = true; + } catch (e) { + // swallow error + // inferenceEnabled will be false + } + return inferenceEnabled; +}; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/is_nav_control_visible/use_is_nav_control_visible.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/is_nav_control_visible/use_is_nav_control_visible.ts new file mode 100644 index 000000000000..b21becbefed7 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/is_nav_control_visible/use_is_nav_control_visible.ts @@ -0,0 +1,52 @@ +/* + * 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 { useEffect, useState } from 'react'; +import { combineLatest } from 'rxjs'; +import { DEFAULT_APP_CATEGORIES, type PublicAppInfo } from '@kbn/core/public'; +import { AIAssistantType } from '@kbn/ai-assistant-management-plugin/public'; +import { useKibana } from '../../context/typed_kibana_context/typed_kibana_context'; + +function getVisibility( + appId: string | undefined, + applications: ReadonlyMap, + preferredAssistantType: AIAssistantType +) { + // The "Global assistant" stack management setting for the security assistant still needs to be developed. + // In the meantime, while testing, show the Security assistant everywhere except in Observability. + + const categoryId = + (appId && applications.get(appId)?.category?.id) || DEFAULT_APP_CATEGORIES.kibana.id; + + return DEFAULT_APP_CATEGORIES.security.id === categoryId; +} + +export function useIsNavControlVisible() { + const { + application: { currentAppId$, applications$ }, + aiAssistantManagementSelection, + } = useKibana().services; + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + const appSubscription = combineLatest([ + currentAppId$, + applications$, + aiAssistantManagementSelection.aiAssistantType$, + ]).subscribe({ + next: ([appId, applications, preferredAssistantType]) => { + setIsVisible(getVisibility(appId, applications, preferredAssistantType)); + }, + }); + + return () => appSubscription.unsubscribe(); + }, [currentAppId$, applications$, aiAssistantManagementSelection.aiAssistantType$]); + + return { + isVisible, + }; +} diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/licence/license_service.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/licence/license_service.ts new file mode 100644 index 000000000000..688bda7f725f --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/licence/license_service.ts @@ -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 type { Observable, Subscription } from 'rxjs'; +import type { ILicense, LicenseType } from '@kbn/licensing-plugin/common/types'; + +// Generic license service class that works with the license observable +// Both server and client plugins instantiates a singleton version of this class +export class LicenseService { + private observable: Observable | null = null; + private subscription: Subscription | null = null; + private licenseInformation: ILicense | null = null; + + private updateInformation(licenseInformation: ILicense) { + this.licenseInformation = licenseInformation; + } + + public start(license$: Observable) { + this.observable = license$; + this.subscription = this.observable.subscribe(this.updateInformation.bind(this)); + } + + public stop() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + } + + public getLicenseInformation() { + return this.licenseInformation; + } + + public getLicenseInformation$() { + return this.observable; + } + + public getLicenseType() { + return this.licenseInformation && this.licenseInformation.type + ? this.licenseInformation.type + : ''; + } + + public getLicenseUID() { + return this.licenseInformation && this.licenseInformation.uid + ? this.licenseInformation.uid + : ''; + } + + public isAtLeast(level: LicenseType): boolean { + return isAtLeast(this.licenseInformation, level); + } + public isGoldPlus(): boolean { + return this.isAtLeast('gold'); + } + public isPlatinumPlus(): boolean { + return this.isAtLeast('platinum'); + } + public isEnterprise(): boolean { + return this.isAtLeast('enterprise'); + } +} + +export const isAtLeast = (license: ILicense | null, level: LicenseType): boolean => { + return !!license && license.isAvailable && license.isActive && license.hasAtLeast(level); +}; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/licence/use_licence.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/licence/use_licence.ts new file mode 100644 index 000000000000..9f1532d78759 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/licence/use_licence.ts @@ -0,0 +1,14 @@ +/* + * 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 { LicenseService } from './license_service'; + +export const licenseService = new LicenseService(); + +export function useLicense() { + return licenseService; +} diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/navigate_to_alerts_page_with_filters/constants.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/navigate_to_alerts_page_with_filters/constants.ts new file mode 100644 index 000000000000..34fc2b075107 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/navigate_to_alerts_page_with_filters/constants.ts @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export const URL_PARAM_KEY = { + pageFilter: 'pageFilters', + appQuery: 'query', +} as const; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/navigate_to_alerts_page_with_filters/format_page_filter_search_param.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/navigate_to_alerts_page_with_filters/format_page_filter_search_param.test.ts new file mode 100644 index 000000000000..76db5c587ff5 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/navigate_to_alerts_page_with_filters/format_page_filter_search_param.test.ts @@ -0,0 +1,41 @@ +/* + * 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 type { FilterControlConfig } from '@kbn/alerts-ui-shared'; +import { formatPageFilterSearchParam } from './format_page_filter_search_param'; + +describe('formatPageFilterSearchParam', () => { + it('returns the same data when all values are provided', () => { + const filter: FilterControlConfig = { + title: 'User', + fieldName: 'user.name', + selectedOptions: ['test_user'], + existsSelected: true, + exclude: true, + hideActionBar: true, + }; + + expect(formatPageFilterSearchParam([filter])).toEqual([filter]); + }); + + it('it sets default values when they are undefined', () => { + const filter: FilterControlConfig = { + fieldName: 'user.name', + }; + + expect(formatPageFilterSearchParam([filter])).toEqual([ + { + title: 'user.name', + selectedOptions: [], + fieldName: 'user.name', + existsSelected: false, + exclude: false, + hideActionBar: false, + }, + ]); + }); +}); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/navigate_to_alerts_page_with_filters/format_page_filter_search_param.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/navigate_to_alerts_page_with_filters/format_page_filter_search_param.ts new file mode 100644 index 000000000000..b8731ac220a1 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/navigate_to_alerts_page_with_filters/format_page_filter_search_param.ts @@ -0,0 +1,28 @@ +/* + * 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 type { FilterControlConfig } from '@kbn/alerts-ui-shared'; + +export const formatPageFilterSearchParam = (filters: FilterControlConfig[]) => { + return filters.map( + ({ + title, + fieldName, + selectedOptions = [], + existsSelected = false, + exclude = false, + hideActionBar = false, + }) => ({ + title: title ?? fieldName, + selectedOptions, + fieldName, + existsSelected, + exclude, + hideActionBar, + }) + ); +}; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/navigate_to_alerts_page_with_filters/use_navigate_to_alerts_page_with_filters.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/navigate_to_alerts_page_with_filters/use_navigate_to_alerts_page_with_filters.test.ts new file mode 100644 index 000000000000..6d7b11c169b6 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/navigate_to_alerts_page_with_filters/use_navigate_to_alerts_page_with_filters.test.ts @@ -0,0 +1,134 @@ +/* + * 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 { renderHook } from '@testing-library/react'; +import { SecurityPageName } from '@kbn/security-solution-navigation'; +import { useNavigateToAlertsPageWithFilters } from './use_navigate_to_alerts_page_with_filters'; + +const mockNavigateTo = jest.fn(); +jest.mock('@kbn/security-solution-navigation', () => ({ + ...jest.requireActual('@kbn/security-solution-navigation'), + useNavigation: () => ({ navigateTo: mockNavigateTo }), +})); + +describe('useNavigateToAlertsPageWithFilters', () => { + it('navigates to alerts page with single filter', () => { + const filter = { + title: 'test filter', + selectedOptions: ['test value'], + fieldName: 'test field', + exclude: false, + existsSelected: false, + }; + + const { + result: { current: navigateToAlertsPageWithFilters }, + } = renderHook(() => useNavigateToAlertsPageWithFilters()); + + navigateToAlertsPageWithFilters(filter); + + expect(mockNavigateTo).toHaveBeenCalledWith({ + deepLinkId: SecurityPageName.alerts, + path: "?pageFilters=!((exclude:!f,existsSelected:!f,fieldName:'test field',hideActionBar:!f,selectedOptions:!('test value'),title:'test filter'))", + openInNewTab: false, + }); + }); + + it('navigates to alerts page with multiple filter', () => { + const filters = [ + { + title: 'test filter 1', + selectedOptions: ['test value 1'], + fieldName: 'test field 1', + exclude: false, + existsSelected: false, + }, + { + title: 'test filter 2', + selectedOptions: ['test value 2'], + fieldName: 'test field 2', + exclude: true, + existsSelected: true, + hideActionBar: true, + }, + ]; + + const { + result: { current: navigateToAlertsPageWithFilters }, + } = renderHook(() => useNavigateToAlertsPageWithFilters()); + + navigateToAlertsPageWithFilters(filters); + + expect(mockNavigateTo).toHaveBeenCalledWith({ + deepLinkId: SecurityPageName.alerts, + path: "?pageFilters=!((exclude:!f,existsSelected:!f,fieldName:'test field 1',hideActionBar:!f,selectedOptions:!('test value 1'),title:'test filter 1'),(exclude:!t,existsSelected:!t,fieldName:'test field 2',hideActionBar:!t,selectedOptions:!('test value 2'),title:'test filter 2'))", + openInNewTab: false, + }); + }); + + it('navigates to alerts page when no filter is provided', () => { + const { + result: { current: navigateToAlertsPageWithFilters }, + } = renderHook(() => useNavigateToAlertsPageWithFilters()); + + navigateToAlertsPageWithFilters([]); + + expect(mockNavigateTo).toHaveBeenCalledWith( + expect.objectContaining({ deepLinkId: SecurityPageName.alerts }) + ); + }); + + it('navigates to alerts page in new tab', () => { + const filter = { + title: 'test filter', + selectedOptions: ['test value'], + fieldName: 'test field', + exclude: false, + existsSelected: false, + }; + const openInNewTab = true; + + const { + result: { current: navigateToAlertsPageWithFilters }, + } = renderHook(() => useNavigateToAlertsPageWithFilters()); + + navigateToAlertsPageWithFilters(filter, openInNewTab); + + expect(mockNavigateTo).toHaveBeenCalledWith({ + deepLinkId: SecurityPageName.alerts, + path: "?pageFilters=!((exclude:!f,existsSelected:!f,fieldName:'test field',hideActionBar:!f,selectedOptions:!('test value'),title:'test filter'))", + openInNewTab: true, + }); + }); + + it('navigates to alerts page with timerange', () => { + const filter = { + title: 'test filter', + selectedOptions: ['test value'], + fieldName: 'test field', + exclude: false, + existsSelected: false, + }; + + const timerange = + '(global:(timerange:(from:"2024-12-12T17:03:23.481Z",kind:absolute,to:"2025-01-04T07:59:59.999Z")))'; + + const openInNewTab = true; + + const { + result: { current: navigateToAlertsPageWithFilters }, + } = renderHook(() => useNavigateToAlertsPageWithFilters()); + + navigateToAlertsPageWithFilters(filter, openInNewTab, timerange); + + expect(mockNavigateTo).toHaveBeenCalledWith({ + deepLinkId: SecurityPageName.alerts, + path: `?pageFilters=!((exclude:!f,existsSelected:!f,fieldName:'test field',hideActionBar:!f,selectedOptions:!('test value'),title:'test filter'))&timerange=(global:(timerange:(from:"2024-12-12T17:03:23.481Z",kind:absolute,to:"2025-01-04T07:59:59.999Z")))`, + openInNewTab: true, + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/navigate_to_alerts_page_with_filters/use_navigate_to_alerts_page_with_filters.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/navigate_to_alerts_page_with_filters/use_navigate_to_alerts_page_with_filters.ts new file mode 100644 index 000000000000..1efb1fa2003e --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/navigate_to_alerts_page_with_filters/use_navigate_to_alerts_page_with_filters.ts @@ -0,0 +1,42 @@ +/* + * 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 { encode } from '@kbn/rison'; + +import type { FilterControlConfig } from '@kbn/alerts-ui-shared'; +import { SecurityPageName, useNavigation } from '@kbn/security-solution-navigation'; +import { URL_PARAM_KEY } from './constants'; +import { formatPageFilterSearchParam } from './format_page_filter_search_param'; + +export const useNavigateToAlertsPageWithFilters = () => { + const { navigateTo } = useNavigation(); + + return ( + /** + * Pass one or more filter control configurations to be applied to the alerts page filters + */ + filterItems: FilterControlConfig | FilterControlConfig[], + /** + * If true, the alerts page will be opened in a new tab + */ + openInNewTab = false, + /** + * Allows to customize the timerange url parameter. Should only be used in combination with the openInNewTab=true parameter + */ + timerange?: string + ) => { + const urlFilterParams = encode( + formatPageFilterSearchParam(Array.isArray(filterItems) ? filterItems : [filterItems]) + ); + const timerangePath = timerange ? `&timerange=${timerange}` : ''; + navigateTo({ + deepLinkId: SecurityPageName.alerts, + path: `?${URL_PARAM_KEY.pageFilter}=${urlFilterParams}${timerangePath}`, + openInNewTab, + }); + }; +}; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/space_id/use_space_id.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/space_id/use_space_id.ts new file mode 100644 index 000000000000..505c72ec7394 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/space_id/use_space_id.ts @@ -0,0 +1,23 @@ +/* + * 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 { useEffect, useState } from 'react'; +import { useKibana } from '../../context/typed_kibana_context/typed_kibana_context'; + +export const useSpaceId = () => { + const { spaces } = useKibana().services; + + const [spaceId, setSpaceId] = useState(); + + useEffect(() => { + if (spaces) { + spaces.getActiveSpace().then((space) => setSpaceId(space.id)); + } + }, [spaces]); + + return spaceId; +}; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/toasts/use_app_toasts.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/toasts/use_app_toasts.test.ts new file mode 100644 index 000000000000..006a8e792669 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/toasts/use_app_toasts.test.ts @@ -0,0 +1,492 @@ +/* + * 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 { renderHook } from '@testing-library/react'; +import type { IEsError } from '@kbn/search-errors'; +import type { KibanaError, SecurityAppError } from '@kbn/securitysolution-t-grid'; + +import type { MaybeESError } from './use_app_toasts'; +import { + appErrorToErrorStack, + convertErrorToEnumerable, + errorToErrorStack, + errorToErrorStackAdapter, + esErrorToErrorStack, + getStringifiedStack, + isEmptyObjectWhenStringified, + unknownToErrorStack, + useAppToasts, +} from './use_app_toasts'; +import { useToasts } from './use_toasts'; + +jest.mock('./use_toasts'); + +describe('useAppToasts', () => { + let addErrorMock: jest.Mock; + let addSuccessMock: jest.Mock; + let addWarningMock: jest.Mock; + let removeMock: jest.Mock; + + beforeEach(() => { + addErrorMock = jest.fn(); + addSuccessMock = jest.fn(); + addWarningMock = jest.fn(); + removeMock = jest.fn(); + (useToasts as jest.Mock).mockImplementation(() => ({ + addError: addErrorMock, + addSuccess: addSuccessMock, + addWarning: addWarningMock, + remove: removeMock, + })); + }); + + describe('useAppToasts', () => { + it('works normally with a regular error', async () => { + const error = new Error('regular error'); + const { result } = renderHook(() => useAppToasts()); + + result.current.addError(error, { title: 'title' }); + + expect(addErrorMock).toHaveBeenCalledWith(error, { title: 'title' }); + }); + + it('converts an unknown error to an Error', () => { + const unknownError = undefined; + + const { result } = renderHook(() => useAppToasts()); + + result.current.addError(unknownError, { title: 'title' }); + + expect(addErrorMock).toHaveBeenCalledWith(Error(`${undefined}`), { + title: 'title', + }); + }); + + it("uses a AppError's body.message as the toastMessage", async () => { + const kibanaApiError = { + message: 'Not Found', + body: { status_code: 404, message: 'Detailed Message' }, + }; + + const { result } = renderHook(() => useAppToasts()); + + result.current.addError(kibanaApiError, { title: 'title' }); + + expect(addErrorMock).toHaveBeenCalledWith(Error('Detailed Message (404)'), { + title: 'title', + }); + }); + + it("parses AppError's body in the stack trace", async () => { + const kibanaApiError = { + message: 'Not Found', + body: { status_code: 404, message: 'Detailed Message' }, + }; + + const { result } = renderHook(() => useAppToasts()); + + result.current.addError(kibanaApiError, { title: 'title' }); + const errorObj = addErrorMock.mock.calls[0][0]; + expect(errorObj.name).toEqual(''); + expect(JSON.parse(errorObj.stack)).toEqual({ + message: 'Not Found', + body: { status_code: 404, message: 'Detailed Message' }, + }); + }); + + it('works normally with a bsearch type error', async () => { + const error = { + message: 'some message', + attributes: {}, // empty object and should not show up in the output + err: { + statusCode: 400, + innerMessages: { somethingElse: 'message' }, + }, + } as unknown as IEsError; + const { result } = renderHook(() => useAppToasts()); + + result.current.addError(error, { title: 'title' }); + const expected = Error('some message (400)'); + expect(addErrorMock).toHaveBeenCalledWith(expected, { title: 'title' }); + }); + + it('parses a bsearch correctly in the stack and name', async () => { + const error = { + message: 'some message', + attributes: {}, // empty object and should not show up in the output + err: { + statusCode: 400, + innerMessages: { somethingElse: 'message' }, + }, + } as unknown as IEsError; + const { result } = renderHook(() => useAppToasts()); + result.current.addError(error, { title: 'title' }); + const errorObj = addErrorMock.mock.calls[0][0]; + expect(errorObj.name).toEqual('some message'); + expect(JSON.parse(errorObj.stack)).toEqual({ + statusCode: 400, + innerMessages: { + somethingElse: 'message', + }, + }); + }); + }); + + describe('errorToErrorStackAdapter', () => { + it('works normally with a regular error', async () => { + const error = new Error('regular error'); + const result = errorToErrorStackAdapter(error); + expect(result).toEqual(error); + }); + + it('has a stack on the error with name, message, and a stack call', async () => { + const error = new Error('regular error'); + const result = errorToErrorStackAdapter(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack.name).toEqual('Error'); + expect(parsedStack.message).toEqual('regular error'); + expect(parsedStack.stack).toEqual(expect.stringContaining('Error: regular error')); + }); + + it('converts an unknown error to an Error', () => { + const unknownError = undefined; + const result = errorToErrorStackAdapter(unknownError); + expect(result).toEqual(Error('undefined')); + }); + + it("uses a AppError's body.message", async () => { + const kibanaApiError = { + message: 'Not Found', + body: { status_code: 404, message: 'Detailed Message' }, + }; + const result = errorToErrorStackAdapter(kibanaApiError); + expect(result).toEqual(Error('Detailed Message (404)')); + }); + + it("parses AppError's body in the stack trace", async () => { + const kibanaApiError = { + message: 'Not Found', + body: { status_code: 404, message: 'Detailed Message' }, + }; + const result = errorToErrorStackAdapter(kibanaApiError); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack.message).toEqual('Not Found'); + expect(parsedStack.body).toEqual({ status_code: 404, message: 'Detailed Message' }); + }); + + it('works normally with a bsearch type error', async () => { + const error = { + message: 'some message', + attributes: {}, // empty object and should not show up in the output + err: { + statusCode: 400, + innerMessages: { somethingElse: 'message' }, + }, + } as unknown as IEsError; + const result = errorToErrorStackAdapter(error); + expect(result).toEqual(Error('some message (400)')); + }); + + it('parses a bsearch correctly in the stack and name', async () => { + const error = { + message: 'some message', + attributes: {}, // empty object and should not show up in the output + err: { + statusCode: 400, + innerMessages: { somethingElse: 'message' }, + }, + } as unknown as IEsError; + const result = errorToErrorStackAdapter(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual({ + statusCode: 400, + innerMessages: { + somethingElse: 'message', + }, + }); + }); + }); + + describe('esErrorToErrorStack', () => { + it('works with a IEsError that is not an EsError', async () => { + const error: IEsError = { + statusCode: 200, + message: 'a message', + }; + const result = esErrorToErrorStack(error); + expect(result).toEqual(Error('a message (200)')); + }); + + it('creates a stack trace of a IEsError that is not an EsError', async () => { + const error: IEsError = { + statusCode: 200, + message: 'a message', + }; + const result = esErrorToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual({ statusCode: 200, message: 'a message' }); + }); + + it('prefers the attributes reason if we have it for the message', async () => { + const error: IEsError = { + attributes: { error: { type: 'some type', reason: 'message we want' } }, + statusCode: 200, + message: 'message we do not want', + }; + const result = esErrorToErrorStack(error); + expect(result).toEqual(Error('message we want (200)')); + }); + + it('works with an EsError, by using the inner error and not outer error if available', async () => { + const error: MaybeESError = { + attributes: { error: { type: 'some type', reason: 'message we want' } }, + statusCode: 400, + err: { + statusCode: 200, + attributes: { error: { reason: 'attribute message we do not want' } }, + }, + message: 'main message we do not want', + }; + const result = esErrorToErrorStack(error); + expect(result).toEqual(Error('message we want (200)')); + }); + + it('creates a stack trace of a EsError and not the outer object', async () => { + const error: MaybeESError = { + attributes: { error: { type: 'some type', reason: 'message we do not want' } }, + statusCode: 400, + err: { + statusCode: 200, + attributes: { error: { reason: 'attribute message we do want' } }, + }, + message: 'main message we do not want', + }; + const result = esErrorToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual({ + statusCode: 200, + attributes: { error: { reason: 'attribute message we do want' } }, + }); + }); + }); + + describe('appErrorToErrorStack', () => { + it('works with a AppError that is a KibanaError', async () => { + const error: KibanaError = { + message: 'message', + name: 'some name', + body: { + message: 'a message', + statusCode: 200, + }, + }; + const result = appErrorToErrorStack(error); + expect(result).toEqual(Error('a message (200)')); + }); + + it('creates a stack trace of a KibanaError', async () => { + const error: KibanaError = { + message: 'message', + name: 'some name', + body: { + message: 'a message', + statusCode: 200, + }, + }; + const result = appErrorToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual({ + message: 'message', + name: 'some name', + body: { + message: 'a message', + statusCode: 200, + }, + }); + }); + + it('works with a AppError that is a SecurityAppError', async () => { + const error: SecurityAppError = { + message: 'message', + name: 'some name', + body: { + message: 'a message', + status_code: 200, + }, + }; + const result = appErrorToErrorStack(error); + expect(result).toEqual(Error('a message (200)')); + }); + + it('creates a stack trace of a SecurityAppError', async () => { + const error: SecurityAppError = { + message: 'message', + name: 'some name', + body: { + message: 'a message', + status_code: 200, + }, + }; + const result = appErrorToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual({ + message: 'message', + name: 'some name', + body: { + message: 'a message', + status_code: 200, + }, + }); + }); + }); + + describe('errorToErrorStack', () => { + it('works with an Error', async () => { + const error: Error = { + message: 'message', + name: 'some name', + }; + const result = errorToErrorStack(error); + expect(result).toEqual(Error('message')); + }); + + it('creates a stack trace of an Error', async () => { + const error: Error = { + message: 'message', + name: 'some name', + }; + const result = errorToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual({ + message: 'message', + name: 'some name', + }); + }); + }); + + describe('unknownToErrorStack', () => { + it('works with a string', async () => { + const error = 'error'; + const result = unknownToErrorStack(error); + expect(result).toEqual(Error('error')); + }); + + it('works with an object that has fields by using a stringification of it', async () => { + const error = { a: 1, b: 1 }; + const result = unknownToErrorStack(error); + expect(result).toEqual(Error(JSON.stringify(error, null, 2))); + }); + + it('works with an an array that has fields by using a stringification of it', async () => { + const error = [{ a: 1, b: 1 }]; + const result = unknownToErrorStack(error); + expect(result).toEqual(Error(JSON.stringify(error, null, 2))); + }); + + it('does create a stack error from a plain string of that string', async () => { + const error = 'error'; + const result = unknownToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual(error); + }); + + it('does create a stack with an object that has fields by using a stringification of it', async () => { + const error = { a: 1, b: 1 }; + const result = unknownToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual(error); + }); + + it('does create a stack with an an array that has fields by using a stringification of it', async () => { + const error = [{ a: 1, b: 1 }]; + const result = unknownToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual(error); + }); + }); + + describe('getStringifiedStack', () => { + it('works with an Error object', async () => { + const result = getStringifiedStack(new Error('message')); + const parsedResult = JSON.parse(result ?? ''); + expect(parsedResult.name).toEqual('Error'); + expect(parsedResult.message).toEqual('message'); + expect(parsedResult.stack).toEqual(expect.stringContaining('Error: message')); + }); + + it('works with a regular object', async () => { + const regularObject = { a: 'regular object' }; + const result = getStringifiedStack(regularObject); + const parsedResult = JSON.parse(result ?? ''); + expect(parsedResult).toEqual(regularObject); + }); + + it('returns undefined with a circular reference', async () => { + const circleRef = { a: {} }; + circleRef.a = circleRef; + const result = getStringifiedStack(circleRef); + expect(result).toEqual(undefined); + }); + + it('returns undefined if given an empty object', async () => { + const emptyObj = {}; + const result = getStringifiedStack(emptyObj); + expect(result).toEqual(undefined); + }); + + it('returns a string if given a string', async () => { + const stringValue = 'some value'; + const result = getStringifiedStack(stringValue); + expect(result).toEqual(`"${stringValue}"`); + }); + + it('returns an array if given an array', async () => { + const value = ['some value']; + const result = getStringifiedStack(value); + const parsedResult = JSON.parse(result ?? ''); + expect(parsedResult).toEqual(value); + }); + + it('removes top level empty objects if found to clean things up a bit', async () => { + const objectWithEmpties = { a: {}, b: { c: 1 }, d: {}, e: {} }; + const result = getStringifiedStack(objectWithEmpties); + const parsedResult = JSON.parse(result ?? ''); + expect(parsedResult).toEqual({ b: { c: 1 } }); + }); + }); + + describe('convertErrorToEnumerable', () => { + test('it will return a stringable Error object', () => { + const converted = convertErrorToEnumerable(new Error('message')); + // delete the stack off the converted for testing determinism + delete (converted as Error).stack; + expect(JSON.stringify(converted)).toEqual( + JSON.stringify({ name: 'Error', message: 'message' }) + ); + }); + + test('it will return a value not touched if it is not an error instances', () => { + const obj = { a: 1 }; + const converted = convertErrorToEnumerable(obj); + expect(converted).toBe(obj); + }); + }); + + describe('isEmptyObjectWhenStringified', () => { + test('it returns false when handed a non-object', () => { + expect(isEmptyObjectWhenStringified('string')).toEqual(false); + }); + + test('it returns false when handed a non-empty object', () => { + expect(isEmptyObjectWhenStringified({ a: 1 })).toEqual(false); + }); + + test('it returns true when handed an empty object', () => { + expect(isEmptyObjectWhenStringified({})).toEqual(true); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/toasts/use_app_toasts.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/toasts/use_app_toasts.ts new file mode 100644 index 000000000000..b65f622613fa --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/toasts/use_app_toasts.ts @@ -0,0 +1,241 @@ +/* + * 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 { useCallback, useRef, useMemo } from 'react'; +import { isString } from 'lodash/fp'; +import type { AppError } from '@kbn/securitysolution-t-grid'; +import { isAppError, isKibanaError, isSecurityAppError } from '@kbn/securitysolution-t-grid'; + +import { type IEsError, isEsError } from '@kbn/search-errors'; + +import type { ErrorToastOptions, ToastsStart, Toast } from '@kbn/core/public'; +import { useToasts } from './use_toasts'; + +export type UseAppToasts = Pick & { + api: ToastsStart; + addError: (error: unknown, options: ErrorToastOptions) => Toast; +}; + +/** + * This gives a better presentation of error data sent from the API (both general platform errors and app-specific errors). + * This uses platform's new Toasts service to prevent modal/toast z-index collision issues. + * This fixes some issues you can see with re-rendering since using a class such as notifications.toasts. + * This also has an adapter and transform for detecting if a bsearch's EsError is present and then adapts that to the + * Kibana error toaster model so that the network error message will be shown rather than a stack trace. + */ +export const useAppToasts = (): UseAppToasts => { + const toasts = useToasts(); + const addError = useRef(toasts.addError.bind(toasts)).current; + const addSuccess = useRef(toasts.addSuccess.bind(toasts)).current; + const addWarning = useRef(toasts.addWarning.bind(toasts)).current; + const remove = useRef(toasts.remove.bind(toasts)).current; + + const _addError = useCallback( + (error: unknown, options: ErrorToastOptions) => { + const adaptedError = errorToErrorStackAdapter(error); + return addError(adaptedError, options); + }, + [addError] + ); + + return useMemo( + () => ({ api: toasts, addError: _addError, addSuccess, addWarning, remove }), + [_addError, addSuccess, addWarning, remove, toasts] + ); +}; + +/** + * Given an error of one type vs. another type this tries to adapt + * the best it can to the existing error toaster which parses the .stack + * as its error when you click the button to show the full error message. + * @param error The error to adapt to. + * @returns The adapted toaster error message. + */ +export const errorToErrorStackAdapter = (error: unknown): Error => { + if (error != null && isEsError(error)) { + return esErrorToErrorStack(error); + } else if (isAppError(error)) { + return appErrorToErrorStack(error); + } else if (error instanceof Error) { + return errorToErrorStack(error); + } else { + return unknownToErrorStack(error); + } +}; + +/** + * See this file, we are not allowed to import files such as es_error. + * So instead we say maybe err is on there so that we can unwrap it and get + * our status code from it if possible within the error in our function. + * src/platform/plugins/shared/data/public/search/errors/es_error.tsx + */ +export type MaybeESError = IEsError & { err?: Record }; + +/** + * This attempts its best to map between an IEsError which comes from bsearch to a error_toaster + * See the file: src/core/public/notifications/toasts/error_toast.tsx + * + * NOTE: This is brittle at the moment from bsearch and the hope is that better support between + * the error message and formatting of bsearch and the error_toast.tsx from Kibana core will be + * supported in the future. However, for now, this is _hopefully_ temporary. + * + * Also see the file: + * x-pack/solutions/security/plugins/security_solution/public/app/home/setup.tsx + * + * Where this same technique of overriding and changing the stack is occurring. + */ +export const esErrorToErrorStack = (error: IEsError & MaybeESError): Error => { + const maybeUnWrapped = error.err != null ? error.err : error; + const statusCode = + error.err?.statusCode != null + ? `(${error.err.statusCode})` + : error.statusCode != null + ? `(${error.statusCode})` + : ''; + const stringifiedError = getStringifiedStack(maybeUnWrapped); + const adaptedError = new Error( + `${error.attributes?.error?.reason ?? error.message} ${statusCode}` + ); + adaptedError.name = error.attributes?.error?.reason ?? error.message; + if (stringifiedError != null) { + adaptedError.stack = stringifiedError; + } + return adaptedError; +}; + +/** + * This attempts its best to map between a Kibana application error which can come from backend + * REST API's that are typically of a particular format and form. + * + * The existing error_toaster code tries to consolidate network and software stack traces but really + * here and our toasters we are using them for network response errors so we can troubleshoot things + * as quick as possible. + * + * We override and use error.stack to be able to give _full_ network responses regardless of if they + * are from Kibana or if they are from elasticSearch since sometimes Kibana errors might wrap the errors. + * + * Sometimes the errors are wrapped from io-ts, Kibana Schema or something else and we want to show + * as full error messages as we can. + */ +export const appErrorToErrorStack = (error: AppError): Error => { + const statusCode = isKibanaError(error) + ? `(${error.body.statusCode})` + : isSecurityAppError(error) + ? `(${error.body.status_code})` + : ''; + const stringifiedError = getStringifiedStack(error); + const adaptedError = new Error( + `${String(error.body.message).trim() !== '' ? error.body.message : error.message} ${statusCode}` + ); + // Note although all the Typescript typings say that error.name is a string and exists, we still can encounter an undefined so we + // do an extra guard here and default to empty string if it is undefined + adaptedError.name = error.name != null ? error.name : ''; + if (stringifiedError != null) { + adaptedError.stack = stringifiedError; + } + return adaptedError; +}; + +/** + * Takes an error and tries to stringify it and use that as the stack for the error toaster + * @param error The error to convert into a message + * @returns The exception error to return back + */ +export const errorToErrorStack = (error: Error): Error => { + const stringifiedError = getStringifiedStack(error); + const adaptedError = new Error(error.message); + adaptedError.name = error.name; + if (stringifiedError != null) { + adaptedError.stack = stringifiedError; + } + return adaptedError; +}; + +/** + * Last ditch effort to take something unknown which could be a string, number, + * anything. This usually should not be called but just in case we do try our + * best to stringify it and give a message, name, and replace the stack of it. + * @param error The unknown error to convert into a message + * @returns The exception error to return back + */ +export const unknownToErrorStack = (error: unknown): Error => { + const stringifiedError = getStringifiedStack(error); + const message = isString(error) + ? error + : error instanceof Object && stringifiedError != null + ? stringifiedError + : String(error); + const adaptedError = new Error(message); + adaptedError.name = message; + if (stringifiedError != null) { + adaptedError.stack = stringifiedError; + } + return adaptedError; +}; + +/** + * Stringifies the error. However, since Errors can JSON.stringify into empty objects this will + * use a replacer to push those as enumerable properties so we can stringify them. + * @param error The error to get a string representation of + * @returns The string representation of the error + */ +export const getStringifiedStack = (error: unknown): string | undefined => { + try { + return JSON.stringify( + error, + (_, value) => { + const enumerable = convertErrorToEnumerable(value); + if (isEmptyObjectWhenStringified(enumerable)) { + return undefined; + } else { + return enumerable; + } + }, + 2 + ); + } catch (err) { + return undefined; + } +}; + +/** + * Converts an error if this is an error to have enumerable so it can stringified + * @param error The error which might not have enumerable properties. + * @returns Enumerable error + */ +export const convertErrorToEnumerable = (error: unknown): unknown => { + if (error instanceof Error) { + return { + ...error, + name: error.name, + message: error.message, + stack: error.stack, + }; + } else { + return error; + } +}; + +/** + * If the object strings into an empty object we shouldn't show it as it doesn't + * add value and sometimes different people/frameworks attach req,res,request,response + * objects which don't stringify into anything or can have circular references. + * @param item The item to see if we are empty or have a circular reference error with. + * @returns True if this is a good object to stringify, otherwise false + */ +export const isEmptyObjectWhenStringified = (item: unknown): boolean => { + if (item instanceof Object) { + try { + return JSON.stringify(item) === '{}'; + } catch (_) { + // Do nothing, return false if we have a circular reference or other oddness. + return false; + } + } else { + return false; + } +}; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/toasts/use_toasts.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/toasts/use_toasts.ts new file mode 100644 index 000000000000..89064d69019d --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/toasts/use_toasts.ts @@ -0,0 +1,12 @@ +/* + * 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 { StartServices } from '../../../types'; +import { useKibana } from '../../context/typed_kibana_context/typed_kibana_context'; + +export const useToasts = (): StartServices['notifications']['toasts'] => + useKibana().services.notifications.toasts; diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/use_assistant_telemetry/index.test.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/use_assistant_telemetry/index.test.tsx similarity index 88% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/use_assistant_telemetry/index.test.tsx rename to x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/use_assistant_telemetry/index.test.tsx index 72da1b1a3585..7d544ca55936 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/use_assistant_telemetry/index.test.tsx +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/use_assistant_telemetry/index.test.tsx @@ -7,8 +7,8 @@ import { renderHook } from '@testing-library/react'; import { useAssistantTelemetry } from '.'; +import { AssistantEventTypes } from '../../common/lib/telemetry/events/ai_assistant/types'; import { createTelemetryServiceMock } from '../../common/lib/telemetry/telemetry_service.mock'; -import { AssistantEventTypes } from '../../common/lib/telemetry'; const customId = `My Convo`; @@ -16,8 +16,8 @@ const mockedTelemetry = { ...createTelemetryServiceMock(), }; -jest.mock('../../common/lib/kibana', () => { - const original = jest.requireActual('../../common/lib/kibana'); +jest.mock('../../context/typed_kibana_context/typed_kibana_context', () => { + const original = jest.requireActual('../../context/typed_kibana_context/typed_kibana_context'); return { ...original, diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/use_assistant_telemetry/index.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/use_assistant_telemetry/index.tsx new file mode 100644 index 000000000000..90ccc1d46071 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/use_assistant_telemetry/index.tsx @@ -0,0 +1,53 @@ +/* + * 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 { type AssistantTelemetry } from '@kbn/elastic-assistant'; +import { useCallback, useMemo } from 'react'; +import { useKibana } from '../../context/typed_kibana_context/typed_kibana_context'; +import { + AssistantEventTypes, + ReportAssistantInvokedParams, + ReportAssistantMessageSentParams, + ReportAssistantQuickPromptParams, + ReportAssistantSettingToggledParams, +} from '../../common/lib/telemetry/events/ai_assistant/types'; + +export const useAssistantTelemetry = (): AssistantTelemetry => { + const { + services: { telemetry }, + } = useKibana(); + + const reportTelemetry = useCallback( + async ({ + eventType, + params, + }: { + eventType: AssistantEventTypes; + params: + | ReportAssistantInvokedParams + | ReportAssistantMessageSentParams + | ReportAssistantQuickPromptParams; + }) => { + telemetry.reportEvent(eventType, params); + }, + [telemetry] + ); + + return useMemo( + () => ({ + reportAssistantInvoked: (params: ReportAssistantInvokedParams) => + reportTelemetry({ eventType: AssistantEventTypes.AssistantInvoked, params }), + reportAssistantMessageSent: (params: ReportAssistantMessageSentParams) => + reportTelemetry({ eventType: AssistantEventTypes.AssistantMessageSent, params }), + reportAssistantQuickPrompt: (params: ReportAssistantQuickPromptParams) => + reportTelemetry({ eventType: AssistantEventTypes.AssistantQuickPrompt, params }), + reportAssistantSettingToggled: (params: ReportAssistantSettingToggledParams) => + telemetry.reportEvent(AssistantEventTypes.AssistantSettingToggled, params), + }), + [reportTelemetry, telemetry] + ); +}; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/utils/elastic_assistant_test_providers.mock.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/src/utils/elastic_assistant_test_providers.mock.tsx new file mode 100644 index 000000000000..835cf599b8e2 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/utils/elastic_assistant_test_providers.mock.tsx @@ -0,0 +1,49 @@ +/* + * 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 { applicationServiceMock, httpServiceMock } from '@kbn/core/public/mocks'; +import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; +import { elasticAssistantSharedStateMock } from '@kbn/elastic-assistant-shared-state-plugin/public/mocks'; +import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks'; +import React from 'react'; +import { ReactQueryClientProvider } from '../context/query_client_context/elastic_assistant_query_client_provider'; +import { KibanaContextProvider } from '../context/typed_kibana_context/typed_kibana_context'; +import { AIAssistantManagementSelectionPluginPublicStart } from '@kbn/ai-assistant-management-plugin/public'; + +interface Props { + children: React.ReactNode; + services?: React.ComponentProps['services']; +} + +export const ElasticAssistantTestProviders = ({ children, services }: Props) => { + const mockApplication = applicationServiceMock.createInternalStartContract(); + const mockTriggersActionsUi = { actionTypeRegistry: actionTypeRegistryMock.create() }; + const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' }); + const elasticAssistantSharedState = elasticAssistantSharedStateMock.createStartContract(); + const notifications = notificationServiceMock.createStartContract(); + + return ( + + + {children} + + + ); +}; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/types.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/types.ts new file mode 100644 index 000000000000..251a70c1b93f --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/types.ts @@ -0,0 +1,45 @@ +/* + * 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 { type MlPluginSetup } from '@kbn/ml-plugin/public'; +import { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/public'; +import { SecurityPluginStart } from '@kbn/security-plugin/public'; +import type { LicensingPluginSetup, LicensingPluginStart } from '@kbn/licensing-plugin/public'; +import type { + TriggersAndActionsUIPublicPluginStart as TriggersActionsStart, + TriggersAndActionsUIPublicPluginSetup, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { CoreStart } from '@kbn/core/public'; +import { ProductDocBasePluginStart } from '@kbn/product-doc-base-plugin/public'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; +import type { DiscoverStart } from '@kbn/discover-plugin/public'; +import { ElasticAssistantSharedStatePublicPluginStart } from '@kbn/elastic-assistant-shared-state-plugin/public'; +import { AIAssistantManagementSelectionPluginPublicStart } from '@kbn/ai-assistant-management-plugin/public'; +import { TelemetryServiceStart } from './src/common/lib/telemetry/telemetry_service'; + +export interface ElasticAssistantPublicPluginSetupDependencies { + ml: MlPluginSetup; + spaces?: SpacesPluginSetup; + licensing: LicensingPluginSetup; + triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; +} +export interface ElasticAssistantPublicPluginStartDependencies { + licensing: LicensingPluginStart; + triggersActionsUi: TriggersActionsStart; + spaces: SpacesPluginStart; + security: SecurityPluginStart; + productDocBase: ProductDocBasePluginStart; + discover: DiscoverStart; + elasticAssistantSharedState: ElasticAssistantSharedStatePublicPluginStart; + aiAssistantManagementSelection: AIAssistantManagementSelectionPluginPublicStart; +} + +export type StartServices = CoreStart & + ElasticAssistantPublicPluginStartDependencies & { + telemetry: TelemetryServiceStart; + storage: Storage; + }; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/tsconfig.json b/x-pack/solutions/security/plugins/elastic_assistant/tsconfig.json index 48d2eefcb3ce..5bedd13c5dbc 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/tsconfig.json +++ b/x-pack/solutions/security/plugins/elastic_assistant/tsconfig.json @@ -11,6 +11,7 @@ "scripts/**/*.ts", // must declare *.json explicitly per https://github.com/microsoft/TypeScript/issues/25636 "server/**/*.json", + "public/**/*", "../../../../../typings/**/*" ], "kbn_references": [ @@ -63,7 +64,27 @@ "@kbn/rule-data-utils", "@kbn/alerting-types", "@kbn/zod-helpers", + "@kbn/ai-security-labs-content", + "@kbn/i18n-react", + "@kbn/elastic-assistant", + "@kbn/kibana-utils-plugin", + "@kbn/elastic-assistant-shared-state", + "@kbn/ai-assistant-icon", + "@kbn/kibana-react-plugin", + "@kbn/alerts-ui-shared", + "@kbn/security-solution-navigation", + "@kbn/rison", + "@kbn/search-errors", + "@kbn/securitysolution-t-grid", + "@kbn/triggers-actions-ui-plugin", + "@kbn/discover-plugin", + "@kbn/elastic-assistant-shared-state-plugin", + "@kbn/core-notifications-browser-mocks", + "@kbn/ai-assistant-management-plugin", + "@kbn/react-kibana-context-theme", "@kbn/rule-registry-plugin", + "@kbn/deeplinks-security", + "@kbn/core-application-browser", "@kbn/ai-security-labs-content", "@kbn/inference-langchain" ], diff --git a/x-pack/solutions/security/plugins/elastic_assistant_shared_state/README.md b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/README.md new file mode 100644 index 000000000000..996619e3d5fb --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/README.md @@ -0,0 +1,21 @@ +# Elastic Assistant Shared State + +This plugin acts as a reactive bridge between the elastic assistant plugin and other plugins. It exposes an RxJS-based interface where: + +- Other plugin registers components, actions or state updates via observables. + +- The elastic assistant plugin subscribes to those updates by consuming the corresponding observables and using the values in to render the assistant. + +This decouples the plugins while enabling reactive updates across plugins. + +See where the RxJS values are consumed in the elastic assistant plugin: `x-pack/solutions/security/plugins/elastic_assistant/public/src/context/assistant_context/assistant_provider.tsx` + +## Maintainers + +Maintained by the Security Solution team - @elastic/security-generative-ai + +## Development + +### Testing + +To run the tests for this plugin, run `node scripts/jest --watch x-pack/solutions/security/plugins/elastic_assistant_shared_state/jest.config.js --coverage` from the Kibana root directory. \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/elastic_assistant_shared_state/jest.config.js b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/jest.config.js new file mode 100644 index 000000000000..2e03820988bb --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/jest.config.js @@ -0,0 +1,18 @@ +/* + * 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. + */ + +module.exports = { + collectCoverageFrom: [ + '/x-pack/solutions/security/plugins/elastic_assistant_shared_state/{common,lib,server}/**/*.{ts,tsx}', + ], + coverageDirectory: + '/target/kibana-coverage/jest/x-pack/solutions/security/plugins/elastic_assistant_shared_state', + coverageReporters: ['text', 'html'], + rootDir: '../../../../..', + roots: ['/x-pack/solutions/security/plugins/elastic_assistant_shared_state'], + preset: '@kbn/test', +}; diff --git a/x-pack/solutions/security/plugins/elastic_assistant_shared_state/kibana.jsonc b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/kibana.jsonc new file mode 100644 index 000000000000..4a05708521f1 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/kibana.jsonc @@ -0,0 +1,25 @@ +{ + "type": "plugin", + "id": "@kbn/elastic-assistant-shared-state-plugin", + "owner": [ + "@elastic/security-generative-ai" + ], + "group": "security", + "visibility": "private", + "description": "Provides client side context for the Elastic AI Assistant", + "plugin": { + "id": "elasticAssistantSharedState", + "browser": true, + "server": false, + "configPath": [ + "xpack", + "elasticAssistantSharedState" + ], + "requiredPlugins": [ + + ], + "requiredBundles": [ + + ], + } +} diff --git a/x-pack/solutions/security/plugins/elastic_assistant_shared_state/package.json b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/package.json new file mode 100644 index 000000000000..1376c1b1e787 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/package.json @@ -0,0 +1,9 @@ +{ + "author": "Elastic", + "name": "@kbn/elastic-assistant-shared-state-plugin", + "version": "1.0.0", + "private": true, + "license": "Elastic License 2.0", + "scripts": { + } +} diff --git a/x-pack/solutions/security/plugins/elastic_assistant_shared_state/public/index.ts b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/public/index.ts new file mode 100644 index 000000000000..e2204ac74b22 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/public/index.ts @@ -0,0 +1,12 @@ +/* + * 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 { ElasticAssistantSharedStatePublicPlugin } from './plugin'; + +export type { ElasticAssistantSharedStatePublicPluginStart } from './plugin'; + +export const plugin = () => new ElasticAssistantSharedStatePublicPlugin(); diff --git a/x-pack/solutions/security/plugins/elastic_assistant_shared_state/public/mocks/elastic_assistant_shared_state.mock.ts b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/public/mocks/elastic_assistant_shared_state.mock.ts new file mode 100644 index 000000000000..2396ce21b1a0 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/public/mocks/elastic_assistant_shared_state.mock.ts @@ -0,0 +1,30 @@ +/* + * 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 { + CommentsService, + PromptContextService, + AssistantContextValueService, + AugmentMessageCodeBlocksService, + SignalIndexService, +} from '@kbn/elastic-assistant-shared-state'; + +export const createStartContract = () => { + const commentService = new CommentsService(); + const promptContextService = new PromptContextService(); + const assistantContextValueService = new AssistantContextValueService(); + const augmentMessageCodeBlocksService = new AugmentMessageCodeBlocksService(); + const signalIndexService = new SignalIndexService(); + + return { + comments: commentService.start(), + promptContexts: promptContextService.start(), + assistantContextValue: assistantContextValueService.start(), + augmentMessageCodeBlocks: augmentMessageCodeBlocksService.start(), + signalIndex: signalIndexService.start(), + }; +}; diff --git a/x-pack/solutions/security/plugins/elastic_assistant_shared_state/public/mocks/index.ts b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/public/mocks/index.ts new file mode 100644 index 000000000000..71c052f43d6d --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/public/mocks/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { createStartContract } from './elastic_assistant_shared_state.mock'; + +const elasticAssistantSharedStateMock = { + createStartContract, +}; + +export { elasticAssistantSharedStateMock }; diff --git a/x-pack/solutions/security/plugins/elastic_assistant_shared_state/public/plugin.tsx b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/public/plugin.tsx new file mode 100644 index 000000000000..47cb36c98aee --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/public/plugin.tsx @@ -0,0 +1,79 @@ +/* + * 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 type { Plugin, CoreSetup } from '@kbn/core/public'; + +import { + CommentsService, + PromptContextService, + AssistantContextValueService, + AugmentMessageCodeBlocksService, + SignalIndexService, +} from '@kbn/elastic-assistant-shared-state'; +import { + ElasticAssistantSharedStatePublicPluginSetupDependencies, + ElasticAssistantSharedStatePublicPluginStartDependencies, +} from './types'; + +export type ElasticAssistantSharedStatePublicPluginSetup = ReturnType< + ElasticAssistantSharedStatePublicPlugin['setup'] +>; +export type ElasticAssistantSharedStatePublicPluginStart = ReturnType< + ElasticAssistantSharedStatePublicPlugin['start'] +>; + +export class ElasticAssistantSharedStatePublicPlugin + implements + Plugin< + ElasticAssistantSharedStatePublicPluginSetup, + ElasticAssistantSharedStatePublicPluginStart, + ElasticAssistantSharedStatePublicPluginSetupDependencies, + ElasticAssistantSharedStatePublicPluginStartDependencies + > +{ + private readonly commentService: CommentsService; + private readonly promptContextService: PromptContextService; + private readonly assistantContextValueService: AssistantContextValueService; + private readonly augmentMessageCodeBlocksService: AugmentMessageCodeBlocksService; + private readonly signalIndexService: SignalIndexService; + + constructor() { + this.commentService = new CommentsService(); + this.promptContextService = new PromptContextService(); + this.assistantContextValueService = new AssistantContextValueService(); + this.augmentMessageCodeBlocksService = new AugmentMessageCodeBlocksService(); + this.signalIndexService = new SignalIndexService(); + } + + public setup(core: CoreSetup) { + return {}; + } + + public start() { + const comments = this.commentService.start(); + const promptContexts = this.promptContextService.start(); + const assistantContextValue = this.assistantContextValueService.start(); + const augmentMessageCodeBlocks = this.augmentMessageCodeBlocksService.start(); + const signalIndex = this.signalIndexService.start(); + + return { + comments, + promptContexts, + assistantContextValue, + augmentMessageCodeBlocks, + signalIndex, + }; + } + + public stop() { + this.commentService.stop(); + this.promptContextService.stop(); + this.assistantContextValueService.stop(); + this.augmentMessageCodeBlocksService.stop(); + this.signalIndexService.stop(); + } +} diff --git a/x-pack/solutions/security/plugins/elastic_assistant_shared_state/public/types.ts b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/public/types.ts new file mode 100644 index 000000000000..6e33f311661c --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/public/types.ts @@ -0,0 +1,16 @@ +/* + * 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 { CoreStart } from '@kbn/core/public'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ElasticAssistantSharedStatePublicPluginSetupDependencies {} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ElasticAssistantSharedStatePublicPluginStartDependencies {} + +export type StartServices = CoreStart & ElasticAssistantSharedStatePublicPluginStartDependencies; diff --git a/x-pack/solutions/security/plugins/elastic_assistant_shared_state/tsconfig.json b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/tsconfig.json new file mode 100644 index 000000000000..454998b27389 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant_shared_state/tsconfig.json @@ -0,0 +1,18 @@ +{ + "esModuleInterop": true, + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + }, + "include": [ + "../../../../../typings/**/*", + "public/**/*", + ], + "kbn_references": [ + "@kbn/core", + "@kbn/elastic-assistant-shared-state", + ], + "exclude": [ + "target/**/*", + ] +} diff --git a/x-pack/solutions/security/plugins/security_solution/kibana.jsonc b/x-pack/solutions/security/plugins/security_solution/kibana.jsonc index 521e97d18bcf..defca19892e9 100644 --- a/x-pack/solutions/security/plugins/security_solution/kibana.jsonc +++ b/x-pack/solutions/security/plugins/security_solution/kibana.jsonc @@ -58,7 +58,8 @@ "inference", "discoverShared", "productDocBase", - "telemetry" + "telemetry", + "elasticAssistantSharedState", ], "optionalPlugins": [ "encryptedSavedObjects", diff --git a/x-pack/solutions/security/plugins/security_solution/public/app/app.tsx b/x-pack/solutions/security/plugins/security_solution/public/app/app.tsx index 3037526f3af8..2c8dbf8467be 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/app/app.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/app/app.tsx @@ -65,9 +65,9 @@ const StartAppComponent: FC = ({ children, history, store, th > - - {children} - + + {children} + diff --git a/x-pack/solutions/security/plugins/security_solution/public/app/home/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/app/home/index.tsx index a1d7ec1a5af5..4d70a00f8db2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/app/home/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/app/home/index.tsx @@ -24,7 +24,6 @@ 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'; import { useInitSourcerer } from '../../sourcerer/containers/use_init_sourcerer'; import { useInitDataViewManager } from '../../data_view_manager/hooks/use_init_data_view_manager'; import { useRestoreDataViewManagerStateFromURL } from '../../data_view_manager/hooks/use_sync_url_state'; @@ -73,7 +72,6 @@ const HomePageComponent: React.FC = ({ children }) => { - diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/comment_actions/comment_actions_portal.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/comment_actions/comment_actions_portal.tsx new file mode 100644 index 000000000000..7ceafc8f28b7 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/comment_actions/comment_actions_portal.tsx @@ -0,0 +1,70 @@ +/* + * 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, { useEffect, useState } from 'react'; +import ReactDOM from 'react-dom'; +import type { HtmlPortalNode } from 'react-reverse-portal'; +import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; +import type { CommentServiceActions } from '@kbn/elastic-assistant-shared-state'; +import { useKibana } from '../../common/lib/kibana'; +import { CommentActions } from '.'; + +interface PortalInfo { + args: Parameters[0]; + portalNode: HtmlPortalNode; + target: HTMLElement; +} + +export const CommentActionsPortal = () => { + const { elasticAssistantSharedState } = useKibana().services; + const [portals, setPortals] = useState>({}); + + useEffect(() => { + const unmountActions = elasticAssistantSharedState.comments.registerActions({ + mount: (args) => (target: HTMLElement) => { + const portalId = args.message.timestamp + args.message.content; + const portalNode = createHtmlPortalNode(); + + setPortals((prev) => ({ + ...prev, + [portalId]: { args, portalNode, target }, + })); + + return () => { + portalNode.unmount(); + setPortals((prev) => { + const next = { ...prev }; + delete next[portalId]; + return next; + }); + }; + }, + }); + + return () => { + unmountActions(); + }; + }, [elasticAssistantSharedState.comments]); + + return ( + <> + {/* InPortal: render the actual UI */} + {Object.entries(portals).map(([portalId, { args, portalNode }]) => ( + + + + ))} + + {/* OutPortal: mount in external DOM targets via createPortal */} + {Object.entries(portals).map(([portalId, { portalNode, target }]) => + target + ? ReactDOM.createPortal(, target) + : null + )} + + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/comment_actions/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/comment_actions/index.test.tsx index ac236e2c02ec..845483ad0232 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/comment_actions/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/comment_actions/index.test.tsx @@ -9,16 +9,10 @@ import React from 'react'; import { fireEvent, render } from '@testing-library/react'; import type { ClientMessage } from '@kbn/elastic-assistant'; import { createMockStore, mockGlobalState, TestProviders } from '../../common/mock'; -import { EuiCopy } from '@elastic/eui'; import { CommentActions } from '.'; import { updateAndAssociateNode } from '../../timelines/components/notes/helpers'; import { useKibana } from '../../common/lib/kibana'; -jest.mock('@elastic/eui', () => ({ - ...jest.requireActual('@elastic/eui'), - EuiCopy: jest.fn(), -})); - jest.mock('../../timelines/components/notes/helpers', () => ({ ...jest.requireActual('../../timelines/components/notes/helpers'), updateAndAssociateNode: jest.fn(), @@ -46,34 +40,6 @@ const Wrapper: React.FC = ({ children }) => { }; describe('CommentActions', () => { - beforeEach(() => { - (EuiCopy as unknown as jest.Mock).mockClear(); - }); - - it.each([ - [`Only this should be copied!{reference(exampleReferenceId)}`, 'Only this should be copied!'], - [ - `Only this.{reference(exampleReferenceId)} should be copied!{reference(exampleReferenceId)}`, - 'Only this. should be copied!', - ], - [`{reference(exampleReferenceId)}`, ''], - ])("textToCopy is correct when input is '%s'", async (input, expected) => { - (EuiCopy as unknown as jest.Mock).mockReturnValue(null); - const message: ClientMessage = { - content: input, - role: 'assistant', - timestamp: '2025-01-08T10:47:34.578Z', - }; - render(, { wrapper: Wrapper }); - - expect(EuiCopy).toHaveBeenCalledWith( - expect.objectContaining({ - textToCopy: expected, - }), - expect.anything() - ); - }); - it('content added to timeline is correct', () => { const message: ClientMessage = { content: `Only this should be copied! {reference(exampleReferenceId)}`, diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/comment_actions/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/comment_actions/index.tsx index f4de56a6ed11..baa21ca5c2a9 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/comment_actions/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/comment_actions/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiButtonIcon, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; import { AttachmentType } from '@kbn/cases-plugin/common'; import type { ClientMessage } from '@kbn/elastic-assistant'; import React, { useCallback } from 'react'; @@ -137,20 +137,6 @@ const CommentActionsComponent: React.FC = ({ message }) => { />
- - - - {(copy) => ( - - )} - - - ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/index.tsx deleted file mode 100644 index 36502f95b58f..000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/index.tsx +++ /dev/null @@ -1,228 +0,0 @@ -/* - * 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 type { ClientMessage, GetAssistantMessages } from '@kbn/elastic-assistant'; -import { EuiAvatar, EuiLoadingSpinner } from '@elastic/eui'; -import React from 'react'; - -import { AssistantAvatar } from '@kbn/ai-assistant-icon'; -import type { Replacements } from '@kbn/elastic-assistant-common'; -import { replaceAnonymizedValuesWithOriginalValues } from '@kbn/elastic-assistant-common'; -import styled from '@emotion/styled'; -import type { EuiPanelProps } from '@elastic/eui/src/components/panel'; -import { StreamComment } from './stream'; -import { CommentActions } from '../comment_actions'; -import * as i18n from './translations'; - -// Matches EuiAvatar L -const SpinnerWrapper = styled.div` - width: 40px; - height: 40px; - display: flex; - justify-content: center; -`; - -export interface ContentMessage extends ClientMessage { - content: string; -} -const transformMessageWithReplacements = ({ - message, - content, - showAnonymizedValues, - replacements, -}: { - message: ClientMessage; - content: string; - showAnonymizedValues: boolean; - replacements: Replacements; -}): ContentMessage => { - return { - ...message, - content: showAnonymizedValues - ? content - : replaceAnonymizedValuesWithOriginalValues({ - messageContent: content, - replacements, - }), - }; -}; - -export const getComments: GetAssistantMessages = ({ - abortStream, - currentConversation, - isFetchingResponse, - refetchCurrentConversation, - regenerateMessage, - showAnonymizedValues, - currentUserAvatar, - setIsStreaming, - systemPromptContent, - contentReferencesVisible, -}) => { - if (!currentConversation) return []; - - const regenerateMessageOfConversation = () => { - regenerateMessage(currentConversation.id); - }; - - const extraLoadingComment = isFetchingResponse - ? [ - { - username: i18n.ASSISTANT, - timelineAvatar: ( - - - - ), - timestamp: '...', - children: ( - ({ content: '' } as unknown as ContentMessage)} - contentReferences={null} - messageRole="assistant" - isFetching - // we never need to append to a code block in the loading comment, which is what this index is used for - index={999} - /> - ), - }, - ] - : []; - - const UserAvatar = () => { - if (currentUserAvatar) { - return ( - - ); - } - - return ; - }; - - return [ - ...(systemPromptContent && currentConversation.messages.length - ? [ - { - username: i18n.SYSTEM, - timelineAvatar: , - timestamp: - currentConversation.messages[0].timestamp.length === 0 - ? new Date().toLocaleString() - : new Date(currentConversation.messages[0].timestamp).toLocaleString(), - children: ( - ({ content: '' } as unknown as ContentMessage)} - messageRole={'assistant'} - // we never need to append to a code block in the system comment, which is what this index is used for - index={999} - /> - ), - }, - ] - : []), - ...currentConversation.messages.map((message, index) => { - const isLastComment = index === currentConversation.messages.length - 1; - const isUser = message.role === 'user'; - const replacements = currentConversation.replacements; - - const messageProps = { - timelineAvatar: isUser ? ( - - ) : ( - - ), - timestamp: i18n.AT( - message.timestamp.length === 0 - ? new Date().toLocaleString() - : new Date(message.timestamp).toLocaleString() - ), - username: isUser ? i18n.YOU : i18n.ASSISTANT, - eventColor: message.isError ? ('danger' as EuiPanelProps['color']) : undefined, - }; - - const isControlsEnabled = isLastComment && !isUser; - - const transformMessage = (content: string) => - transformMessageWithReplacements({ - message, - content, - showAnonymizedValues, - replacements, - }); - - // message still needs to stream, no actions returned and replacements handled by streamer - if (!(message.content && message.content.length)) { - return { - ...messageProps, - children: ( - - ), - }; - } - - // transform message here so we can send correct message to CommentActions - const transformedMessage = transformMessage(message.content ?? ''); - - return { - ...messageProps, - actions: , - children: ( - - ), - }; - }), - ...extraLoadingComment, - ]; -}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/helpers.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/helpers.tsx index b632ee281777..4267dc38eeed 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/helpers.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/helpers.tsx @@ -5,16 +5,9 @@ * 2.0. */ -import { EuiIcon } from '@elastic/eui'; -import type { CodeBlockDetails, Conversation } from '@kbn/elastic-assistant'; -import { analyzeMarkdown } from '@kbn/elastic-assistant'; -import React from 'react'; -import { replaceAnonymizedValuesWithOriginalValues } from '@kbn/elastic-assistant-common'; +import type { CodeBlockDetails } from '@kbn/elastic-assistant'; import type { TimelineEventsDetailsItem } from '../../common/search_strategy'; import type { Rule } from '../detection_engine/rule_management/logic'; -import { SendToTimelineButton } from './send_to_timeline'; -import { DETECTION_RULES_CREATE_FORM_CONVERSATION_ID } from '../detection_engine/rule_creation_ui/components/ai_assistant/translations'; -import { UpdateQueryInFormButton } from './update_query_in_form'; export const LOCAL_STORAGE_KEY = `securityAssistant`; @@ -29,78 +22,10 @@ export const getRawData = (data: TimelineEventsDetailsItem[]): Record !field.startsWith('signal.')) .reduce((acc, { field, values }) => ({ ...acc, [field]: values ?? [] }), {}); -const sendToTimelineEligibleQueryTypes: Array = [ +export const sendToTimelineEligibleQueryTypes: Array = [ 'kql', 'dsl', 'eql', 'esql', 'sql', // Models often put the code block language as sql, for esql, so adding this as a fallback ]; - -/** - * Augments the messages in a conversation with code block details, including - * the start and end indices of the code block in the message, the type of the - * code block, and the button to add the code block to the timeline. - * - * @param currentConversation - */ -export const augmentMessageCodeBlocks = ( - currentConversation: Conversation, - showAnonymizedValues: boolean -): CodeBlockDetails[][] => { - const cbd = currentConversation.messages.map(({ content }) => - analyzeMarkdown( - showAnonymizedValues - ? content ?? '' - : replaceAnonymizedValuesWithOriginalValues({ - messageContent: content ?? '', - replacements: currentConversation.replacements, - }) - ) - ); - - const output = cbd.map((codeBlocks, messageIndex) => - codeBlocks.map((codeBlock, codeBlockIndex) => { - return { - ...codeBlock, - getControlContainer: () => - document.querySelectorAll(`.message-${messageIndex} .euiCodeBlock__controls`)[ - codeBlockIndex - ], - button: ( - <> - {sendToTimelineEligibleQueryTypes.includes(codeBlock.type) ? ( - - - - ) : null} - {DETECTION_RULES_CREATE_FORM_CONVERSATION_ID === currentConversation.title ? ( - - ) : null} - - ), - }; - }) - ); - - return output; -}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/migrate_conversations_from_local_storage/create_conversation.test.ts similarity index 96% rename from x-pack/solutions/security/plugins/security_solution/public/assistant/provider.test.tsx rename to x-pack/solutions/security/plugins/security_solution/public/assistant/migrate_conversations_from_local_storage/create_conversation.test.ts index b935c17540dc..d884e9f2a1ef 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/migrate_conversations_from_local_storage/create_conversation.test.ts @@ -8,21 +8,10 @@ import { waitFor, renderHook } from '@testing-library/react'; import { httpServiceMock, type HttpSetupMock } from '@kbn/core-http-browser-mocks'; import type { Storage } from '@kbn/kibana-utils-plugin/public'; -import { createConversations } from './provider'; +import { createConversations } from './create_conversation'; import { coreMock } from '@kbn/core/public/mocks'; import { loadAllActions as loadConnectors } from '@kbn/triggers-actions-ui-plugin/public/common/constants'; -jest.mock('./use_assistant_availability'); - -jest.mock('@kbn/elastic-assistant/impl/assistant/api'); -jest.mock('../common/hooks/use_license', () => ({ - useLicense: () => ({ - isEnterprise: () => true, - }), - licenseService: { - isEnterprise: () => true, - }, -})); jest.mock('@kbn/triggers-actions-ui-plugin/public/common/constants'); let http: HttpSetupMock = coreMock.createSetup().http; export const mockConnectors = [ diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/migrate_conversations_from_local_storage/create_conversation.ts b/x-pack/solutions/security/plugins/security_solution/public/assistant/migrate_conversations_from_local_storage/create_conversation.ts new file mode 100644 index 000000000000..e69d718f0116 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/migrate_conversations_from_local_storage/create_conversation.ts @@ -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 type { NotificationsStart } from '@kbn/core-notifications-browser'; +import type { HttpSetup } from '@kbn/core-http-browser'; +import type { Message } from '@kbn/elastic-assistant-common'; +import { parse } from '@kbn/datemath'; +import { bulkUpdateConversations, type Conversation } from '@kbn/elastic-assistant'; +import { loadAllActions as loadConnectors } from '@kbn/triggers-actions-ui-plugin/public/common/constants'; +import { i18n } from '@kbn/i18n'; +import type { Storage } from '@kbn/kibana-utils-plugin/public'; +import { APP_ID } from '../../../common'; +import { LOCAL_STORAGE_KEY } from '../helpers'; + +const LOCAL_CONVERSATIONS_MIGRATION_STATUS_TOAST_TITLE = i18n.translate( + 'xpack.securitySolution.assistant.conversationMigrationStatus.title', + { + defaultMessage: 'Local storage conversations persisted successfully.', + } +); + +export const createConversations = async ( + notifications: NotificationsStart, + http: HttpSetup, + storage: Storage +) => { + // migrate conversations with messages from the local storage + // won't happen next time + const conversations = storage.get(`${APP_ID}.${LOCAL_STORAGE_KEY}`); + + if (conversations && Object.keys(conversations).length > 0) { + const conversationsToCreate = Object.values(conversations).filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (c: any) => c.messages && c.messages.length > 0 + ); + + const transformMessage = (m: Message) => { + const timestamp = parse(m.timestamp ?? '')?.toISOString(); + return { + ...m, + timestamp: timestamp == null ? new Date().toISOString() : timestamp, + }; + }; + const connectors = await loadConnectors({ http }); + + // post bulk create + const bulkResult = await bulkUpdateConversations( + http, + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + create: conversationsToCreate.reduce((res: Record, c: any) => { + // ensure actionTypeId is added to apiConfig from legacy conversation data + if (c.apiConfig && !c.apiConfig.actionTypeId) { + const selectedConnector = (connectors ?? []).find( + (connector) => connector.id === c.apiConfig.connectorId + ); + if (selectedConnector) { + c.apiConfig = { + ...c.apiConfig, + actionTypeId: selectedConnector.actionTypeId, + }; + } else { + c.apiConfig = undefined; + } + } + res[c.id] = { + ...c, + messages: (c.messages ?? []).map(transformMessage), + title: c.id, + replacements: c.replacements, + }; + return res; + }, {}), + }, + notifications.toasts + ); + if (bulkResult && bulkResult.success) { + storage.remove(`${APP_ID}.${LOCAL_STORAGE_KEY}`); + notifications.toasts?.addSuccess({ + iconType: 'check', + title: LOCAL_CONVERSATIONS_MIGRATION_STATUS_TOAST_TITLE, + }); + return true; + } + return false; + } +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/migrate_conversations_from_local_storage/use_migrate_conversation_from_local_storage.ts b/x-pack/solutions/security/plugins/security_solution/public/assistant/migrate_conversations_from_local_storage/use_migrate_conversation_from_local_storage.ts new file mode 100644 index 000000000000..faaaa3dafe97 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/migrate_conversations_from_local_storage/use_migrate_conversation_from_local_storage.ts @@ -0,0 +1,45 @@ +/* + * 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 { once } from 'lodash'; +import { useEffect } from 'react'; +import { getUserConversationsExist } from '@kbn/elastic-assistant'; +import { createConversations } from './create_conversation'; +import { useKibana } from '../../common/lib/kibana'; +import { licenseService } from '../../common/hooks/use_license'; +import { useAssistantAvailability } from '../use_assistant_availability'; + +export const useMigrateConversationsFromLocalStorage = () => { + const hasEnterpriseLicense = licenseService.isEnterprise(); + const assistantAvailability = useAssistantAvailability(); + const { http, notifications, storage } = useKibana().services; + + useEffect(() => { + const migrateConversationsFromLocalStorage = once(async () => { + if ( + hasEnterpriseLicense && + assistantAvailability.isAssistantEnabled && + assistantAvailability.hasAssistantPrivilege + ) { + const conversationsExist = await getUserConversationsExist({ + http, + }); + if (!conversationsExist) { + await createConversations(notifications, http, storage); + } + } + }); + migrateConversationsFromLocalStorage(); + }, [ + assistantAvailability.hasAssistantPrivilege, + assistantAvailability.isAssistantEnabled, + hasEnterpriseLicense, + http, + notifications, + storage, + ]); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx index 97c5d0c58c24..64e268c54a44 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx @@ -6,113 +6,25 @@ */ import type { FC, PropsWithChildren } from 'react'; import React, { useEffect } from 'react'; -import { parse } from '@kbn/datemath'; -import type { Storage } from '@kbn/kibana-utils-plugin/public'; -import { i18n } from '@kbn/i18n'; -import type { IToasts, NotificationsStart } from '@kbn/core-notifications-browser'; -import type { Conversation } from '@kbn/elastic-assistant'; +import type { NotificationsStart } from '@kbn/core-notifications-browser'; import { AssistantProvider as ElasticAssistantProvider, - bulkUpdateConversations, - getUserConversationsExist, getPrompts, bulkUpdatePrompts, } from '@kbn/elastic-assistant'; import { once } from 'lodash/fp'; import type { HttpSetup } from '@kbn/core-http-browser'; -import type { Message } from '@kbn/elastic-assistant-common'; -import { loadAllActions as loadConnectors } from '@kbn/triggers-actions-ui-plugin/public/common/constants'; import useObservable from 'react-use/lib/useObservable'; -import { APP_ID } from '../../common'; -import { useBasePath, useKibana } from '../common/lib/kibana'; -import { useAssistantTelemetry } from './use_assistant_telemetry'; -import { getComments } from './get_comments'; -import { LOCAL_STORAGE_KEY, augmentMessageCodeBlocks } from './helpers'; +import { useKibana } from '../common/lib/kibana'; +// import { getComments } from './get_comments'; import { BASE_SECURITY_QUICK_PROMPTS } from './content/quick_prompts'; -import { PROMPT_CONTEXTS } from './content/prompt_contexts'; import { useAssistantAvailability } from './use_assistant_availability'; -import { useAppToasts } from '../common/hooks/use_app_toasts'; -import { useSignalIndex } from '../detections/containers/detection_engine/alerts/use_signal_index'; import { licenseService } from '../common/hooks/use_license'; - -const ASSISTANT_TITLE = i18n.translate('xpack.securitySolution.assistant.title', { - defaultMessage: 'Elastic AI Assistant', -}); - -const LOCAL_CONVERSATIONS_MIGRATION_STATUS_TOAST_TITLE = i18n.translate( - 'xpack.securitySolution.assistant.conversationMigrationStatus.title', - { - defaultMessage: 'Local storage conversations persisted successfuly.', - } -); - -export const createConversations = async ( - notifications: NotificationsStart, - http: HttpSetup, - storage: Storage -) => { - // migrate conversations with messages from the local storage - // won't happen next time - const conversations = storage.get(`${APP_ID}.${LOCAL_STORAGE_KEY}`); - - if (conversations && Object.keys(conversations).length > 0) { - const conversationsToCreate = Object.values(conversations).filter( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (c: any) => c.messages && c.messages.length > 0 - ); - - const transformMessage = (m: Message) => { - const timestamp = parse(m.timestamp ?? '')?.toISOString(); - return { - ...m, - timestamp: timestamp == null ? new Date().toISOString() : timestamp, - }; - }; - const connectors = await loadConnectors({ http }); - - // post bulk create - const bulkResult = await bulkUpdateConversations( - http, - { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - create: conversationsToCreate.reduce((res: Record, c: any) => { - // ensure actionTypeId is added to apiConfig from legacy conversation data - if (c.apiConfig && !c.apiConfig.actionTypeId) { - const selectedConnector = (connectors ?? []).find( - (connector) => connector.id === c.apiConfig.connectorId - ); - if (selectedConnector) { - c.apiConfig = { - ...c.apiConfig, - actionTypeId: selectedConnector.actionTypeId, - }; - } else { - c.apiConfig = undefined; - } - } - res[c.id] = { - ...c, - messages: (c.messages ?? []).map(transformMessage), - title: c.id, - replacements: c.replacements, - }; - return res; - }, {}), - }, - notifications.toasts - ); - if (bulkResult && bulkResult.success) { - storage.remove(`${APP_ID}.${LOCAL_STORAGE_KEY}`); - notifications.toasts?.addSuccess({ - iconType: 'check', - title: LOCAL_CONVERSATIONS_MIGRATION_STATUS_TOAST_TITLE, - }); - return true; - } - return false; - } -}; +import { CommentActionsPortal } from './comment_actions/comment_actions_portal'; +import { AugmentMessageCodeBlocksPortal } from './use_augment_message_code_blocks/augment_message_code_blocks_portal'; +import { useElasticAssistantSharedStateSignalIndex } from './use_elastic_assistant_shared_state_signal_index/use_elastic_assistant_shared_state_signal_index'; +import { useMigrateConversationsFromLocalStorage } from './migrate_conversations_from_local_storage/use_migrate_conversation_from_local_storage'; export const createBasePrompts = async (notifications: NotificationsStart, http: HttpSetup) => { const promptsToCreate = [...BASE_SECURITY_QUICK_PROMPTS]; @@ -134,57 +46,16 @@ export const createBasePrompts = async (notifications: NotificationsStart, http: * This component configures the Elastic AI Assistant context provider for the Security Solution app. */ export const AssistantProvider: FC> = ({ children }) => { - const { - application: { navigateToApp, getUrlForApp, currentAppId$ }, - http, - notifications, - storage, - triggersActionsUi: { actionTypeRegistry }, - docLinks, - userProfile, - chrome, - productDocBase, - } = useKibana().services; + const { http, notifications, elasticAssistantSharedState } = useKibana().services; - let inferenceEnabled = false; - try { - actionTypeRegistry.get('.inference'); - inferenceEnabled = true; - } catch (e) { - // swallow error - // inferenceEnabled will be false - } - - const basePath = useBasePath(); + const assistantContextValue = useObservable( + elasticAssistantSharedState.assistantContextValue.getAssistantContextValue$() + ); const assistantAvailability = useAssistantAvailability(); - const assistantTelemetry = useAssistantTelemetry(); - const currentAppId = useObservable(currentAppId$, ''); const hasEnterpriseLicence = licenseService.isEnterprise(); - useEffect(() => { - const migrateConversationsFromLocalStorage = once(async () => { - if ( - hasEnterpriseLicence && - assistantAvailability.isAssistantEnabled && - assistantAvailability.hasAssistantPrivilege - ) { - const conversationsExist = await getUserConversationsExist({ - http, - }); - if (!conversationsExist) { - await createConversations(notifications, http, storage); - } - } - }); - migrateConversationsFromLocalStorage(); - }, [ - assistantAvailability.hasAssistantPrivilege, - assistantAvailability.isAssistantEnabled, - hasEnterpriseLicence, - http, - notifications, - storage, - ]); + useMigrateConversationsFromLocalStorage(); + useElasticAssistantSharedStateSignalIndex(); useEffect(() => { const createSecurityPrompts = once(async () => { @@ -215,32 +86,14 @@ export const AssistantProvider: FC> = ({ children }) notifications, ]); - const { signalIndexName } = useSignalIndex(); - const alertsIndexPattern = signalIndexName ?? undefined; - const toasts = useAppToasts() as unknown as IToasts; // useAppToasts is the current, non-deprecated method of getting the toasts service in the Security Solution, but it doesn't return the IToasts interface (defined by core) + if (!assistantContextValue) { + return null; + } return ( - + + + {children} ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/send_to_timeline/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/send_to_timeline/index.tsx index 43cf7ef99781..0968bc4a4fe2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/send_to_timeline/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/send_to_timeline/index.tsx @@ -10,7 +10,7 @@ import React, { useCallback } from 'react'; import { EuiButton, EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; import type { Filter } from '@kbn/es-query'; import { useDispatch, useSelector } from 'react-redux'; - +import { css } from '@emotion/react'; import { useAssistantContext } from '@kbn/elastic-assistant'; import { extractTimelineCapabilities } from '../../common/utils/timeline_capabilities'; import { sourcererSelectors } from '../../common/store'; @@ -271,6 +271,9 @@ export const SendToTimelineButton: FC <>{children} diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/use_assistant_telemetry/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/use_assistant_telemetry/index.tsx deleted file mode 100644 index e56ba3d4eb52..000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/use_assistant_telemetry/index.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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 { type AssistantTelemetry } from '@kbn/elastic-assistant'; -import { useCallback } from 'react'; -import { useKibana } from '../../common/lib/kibana'; -import type { - ReportAssistantInvokedParams, - ReportAssistantMessageSentParams, - ReportAssistantQuickPromptParams, - ReportAssistantSettingToggledParams, -} from '../../common/lib/telemetry'; -import { AssistantEventTypes } from '../../common/lib/telemetry'; -export const useAssistantTelemetry = (): AssistantTelemetry => { - const { - services: { telemetry }, - } = useKibana(); - - const reportTelemetry = useCallback( - async ({ - eventType, - params, - }: { - eventType: AssistantEventTypes; - params: - | ReportAssistantInvokedParams - | ReportAssistantMessageSentParams - | ReportAssistantQuickPromptParams; - }) => telemetry.reportEvent(eventType, params), - [telemetry] - ); - - return { - reportAssistantInvoked: (params: ReportAssistantInvokedParams) => - reportTelemetry({ eventType: AssistantEventTypes.AssistantInvoked, params }), - reportAssistantMessageSent: (params: ReportAssistantMessageSentParams) => - reportTelemetry({ eventType: AssistantEventTypes.AssistantMessageSent, params }), - reportAssistantQuickPrompt: (params: ReportAssistantQuickPromptParams) => - reportTelemetry({ eventType: AssistantEventTypes.AssistantQuickPrompt, params }), - reportAssistantSettingToggled: (params: ReportAssistantSettingToggledParams) => - telemetry.reportEvent(AssistantEventTypes.AssistantSettingToggled, params), - }; -}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/use_augment_message_code_blocks/augment_message_code_block_button.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/use_augment_message_code_blocks/augment_message_code_block_button.tsx new file mode 100644 index 000000000000..f2bf033f4f78 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/use_augment_message_code_blocks/augment_message_code_block_button.tsx @@ -0,0 +1,56 @@ +/* + * 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 { EuiIcon } from '@elastic/eui'; +import type { CodeBlockDetails, Conversation } from '@kbn/elastic-assistant'; +import { DETECTION_RULES_CREATE_FORM_CONVERSATION_ID } from '../../detection_engine/rule_creation_ui/components/ai_assistant/translations'; +import { sendToTimelineEligibleQueryTypes } from '../helpers'; +import { SendToTimelineButton } from '../send_to_timeline'; +import { UpdateQueryInFormButton } from '../update_query_in_form'; + +interface Props { + currentConversation: Conversation; + codeBlockDetails: CodeBlockDetails; +} + +export const AugmentMessageCodeBlockButton = ({ currentConversation, codeBlockDetails }: Props) => { + const sendToTimeline = sendToTimelineEligibleQueryTypes.includes(codeBlockDetails.type) && ( + + + + ); + + const updateQueryInForm = DETECTION_RULES_CREATE_FORM_CONVERSATION_ID === + currentConversation.title && ; + + return ( + <> + {sendToTimeline} + {updateQueryInForm} + + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/use_augment_message_code_blocks/augment_message_code_blocks_portal.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/use_augment_message_code_blocks/augment_message_code_blocks_portal.tsx new file mode 100644 index 000000000000..3f0649808cd1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/use_augment_message_code_blocks/augment_message_code_blocks_portal.tsx @@ -0,0 +1,127 @@ +/* + * 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, { useEffect, useState } from 'react'; +import type { HtmlPortalNode } from 'react-reverse-portal'; +import { InPortal, OutPortal, createHtmlPortalNode } from 'react-reverse-portal'; +import ReactDOM from 'react-dom'; +import type { Conversation } from '@kbn/elastic-assistant'; +import { analyzeMarkdown } from '@kbn/elastic-assistant'; +import { replaceAnonymizedValuesWithOriginalValues } from '@kbn/elastic-assistant-common'; +import { AugmentMessageCodeBlockButton } from './augment_message_code_block_button'; +import { useKibana } from '../../common/lib/kibana'; + +interface PortalInfo { + portalId: string; + node: HtmlPortalNode; + target: HTMLElement; + inPortal: React.ReactElement; +} + +export const AugmentMessageCodeBlocksPortal = () => { + const [portals, setPortals] = useState>({}); + const { elasticAssistantSharedState } = useKibana().services; + + const mountMessageCodeBlocks = ({ + currentConversation, + showAnonymizedValues, + }: { + currentConversation: Conversation; + showAnonymizedValues: boolean; + }) => { + const codeBlockDetails = currentConversation.messages.map(({ content }) => + analyzeMarkdown( + showAnonymizedValues + ? content ?? '' + : replaceAnonymizedValuesWithOriginalValues({ + messageContent: content ?? '', + replacements: currentConversation.replacements, + }) + ) + ); + + const mountingFunctions = codeBlockDetails.flatMap((codeBlocks, messageIndex) => + codeBlocks.map((codeBlock, codeBlockIndex) => { + const mount = () => { + const controlContainer = document.querySelectorAll( + `.message-${messageIndex} .euiCodeBlock__controls` + )[codeBlockIndex] as HTMLElement; + + if (!controlContainer) { + return () => {}; + } + + const portalId = `code-block-portal-${currentConversation.id}-${messageIndex}-${codeBlockIndex}`; + const portalNode = createHtmlPortalNode(); + + const inPortal = ( + + + + ); + + setPortals((prev) => ({ + ...prev, + [portalId]: { + portalId, + node: portalNode, + target: controlContainer, + inPortal, + }, + })); + + return () => { + portalNode.unmount(); + setPortals((prev) => { + const next = { ...prev }; + delete next[portalId]; + return next; + }); + }; + }; + + return mount; + }) + ); + + const mountAll = () => { + const unmounters = mountingFunctions.map((fn) => fn()); + return () => unmounters.forEach((unmount) => unmount()); + }; + + return mountAll(); + }; + + useEffect(() => { + const cleanup = + elasticAssistantSharedState.augmentMessageCodeBlocks.registerAugmentMessageCodeBlocks({ + mount: mountMessageCodeBlocks, + }); + + return () => { + cleanup(); + setPortals({}); + }; + }, [elasticAssistantSharedState.augmentMessageCodeBlocks]); + + return ( + <> + {/* InPortals that render the actual UI */} + {Object.values(portals).map(({ portalId, inPortal }) => ( + {inPortal} + ))} + + {/* OutPortals rendered into target elements via createPortal */} + {Object.values(portals).map(({ portalId, node, target }) => + target ? ReactDOM.createPortal(, target) : null + )} + + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/use_elastic_assistant_shared_state_signal_index/use_elastic_assistant_shared_state_signal_index.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/use_elastic_assistant_shared_state_signal_index/use_elastic_assistant_shared_state_signal_index.tsx new file mode 100644 index 000000000000..b0f0f12d66bc --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/use_elastic_assistant_shared_state_signal_index/use_elastic_assistant_shared_state_signal_index.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect } from 'react'; +import { useKibana } from '../../common/lib/kibana'; +import { useSignalIndex } from '../../detections/containers/detection_engine/alerts/use_signal_index'; + +export const useElasticAssistantSharedStateSignalIndex = () => { + const { elasticAssistantSharedState } = useKibana().services; + const { signalIndexName } = useSignalIndex(); + + useEffect(() => { + if (!signalIndexName) { + return elasticAssistantSharedState.signalIndex.setSignalIndex(undefined); + } + return elasticAssistantSharedState.signalIndex.setSignalIndex(signalIndexName); + }, [signalIndexName, elasticAssistantSharedState]); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/cases/components/provider/provider.tsx b/x-pack/solutions/security/plugins/security_solution/public/cases/components/provider/provider.tsx new file mode 100644 index 000000000000..b21fc94df891 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cases/components/provider/provider.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useKibana } from '../../../common/lib/kibana'; +import { APP_ID } from '../../../../common/constants'; + +interface Props { + children: React.ReactNode; +} +export const CaseProvider: React.FC = ({ children }) => { + const { cases } = useKibana().services; + const CasesContext = cases.ui.getCasesContext(); + const userCasesPermissions = cases.helpers.canUseCases([APP_ID]); + + return ( + + {children} + + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts index 7630ff119cfb..bc915c9d3001 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { assistantTelemetryEvents } from './ai_assistant'; import { alertsTelemetryEvents } from './alerts_grouping'; import { appTelemetryEvents } from './app'; import { dataQualityTelemetryEvents } from './data_quality'; @@ -19,7 +18,6 @@ import { previewRuleTelemetryEvents } from './preview_rule'; import { siemMigrationsTelemetryEvents } from './siem_migrations'; export const telemetryEvents = [ - ...assistantTelemetryEvents, ...alertsTelemetryEvents, ...previewRuleTelemetryEvents, ...entityTelemetryEvents, diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/types.ts b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/types.ts index 0ae352d93af7..641a76396a42 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/types.ts @@ -18,7 +18,6 @@ import type { EntityAnalyticsTelemetryEventsMap, EntityEventTypes, } from './events/entity_analytics/types'; -import type { AssistantEventTypes, AssistantTelemetryEventsMap } from './events/ai_assistant/types'; import type { DocumentDetailsTelemetryEventsMap, DocumentEventTypes, @@ -48,7 +47,6 @@ import type { } from './events/siem_migrations/types'; export * from './events/app/types'; -export * from './events/ai_assistant/types'; export * from './events/alerts_grouping/types'; export * from './events/data_quality/types'; export * from './events/onboarding/types'; @@ -64,9 +62,7 @@ export interface TelemetryServiceSetupParams { } // Combine all event type data -export type TelemetryEventTypeData = T extends AssistantEventTypes - ? AssistantTelemetryEventsMap[T] - : T extends AlertsEventTypes +export type TelemetryEventTypeData = T extends AlertsEventTypes ? AlertsGroupingTelemetryEventsMap[T] : T extends PreviewRuleEventTypes ? PreviewRuleTelemetryEventsMap[T] @@ -93,7 +89,6 @@ export type TelemetryEventTypeData = T extends As : never; export type TelemetryEventTypes = - | AssistantEventTypes | AlertsEventTypes | PreviewRuleEventTypes | EntityEventTypes diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx index 29965d1bc61f..3b72222a2475 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx @@ -9,10 +9,11 @@ import { httpServiceMock } from '@kbn/core-http-browser-mocks'; import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; import React from 'react'; import type { AssistantAvailability } from '@kbn/elastic-assistant'; -import { AssistantProvider, AssistantSpaceIdProvider } from '@kbn/elastic-assistant'; +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 { useAssistantContextValue } from '@kbn/elastic-assistant/impl/assistant_context'; import { docLinksServiceMock } from '@kbn/core/public/mocks'; interface Props { @@ -42,32 +43,33 @@ export const MockAssistantProviderComponent: React.FC = ({ }; const chrome = chromeServiceMock.createStartContract(); chrome.getChromeStyle$.mockReturnValue(of('classic')); + const docLinks = docLinksServiceMock.createStartContract(); const mockUserProfileService = { getCurrent: jest.fn(() => Promise.resolve({ avatar: 'avatar' })), } as unknown as UserProfileService; - return ( - [])} - basePath={'https://localhost:5601/kbn'} - docLinks={docLinksServiceMock.createStartContract()} - getComments={jest.fn(() => [])} - getUrlForApp={jest.fn()} - http={mockHttp} - navigateToApp={mockNavigateToApp} - currentAppId={'test'} - productDocBase={{ - installation: { getStatus: jest.fn(), install: jest.fn(), uninstall: jest.fn() }, - }} - userProfileService={mockUserProfileService} - chrome={chrome} - > - {children} - - ); + const assistantContextValue = useAssistantContextValue({ + actionTypeRegistry, + assistantAvailability: assistantAvailability ?? defaultAssistantAvailability, + augmentMessageCodeBlocks: { + mount: jest.fn().mockReturnValue(() => {}), + }, + basePath: 'https://localhost:5601/kbn', + docLinks, + getComments: jest.fn(() => []), + http: mockHttp, + navigateToApp: mockNavigateToApp, + currentAppId: 'test', + productDocBase: { + installation: { getStatus: jest.fn(), install: jest.fn(), uninstall: jest.fn() }, + }, + userProfileService: mockUserProfileService, + chrome, + getUrlForApp: jest.fn(), + }); + + return {children}; }; MockAssistantProviderComponent.displayName = 'MockAssistantProviderComponent'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/message_text.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/message_text.tsx index 6e70d3eb28bf..6020be013452 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/message_text.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/message_text.tsx @@ -17,8 +17,8 @@ import { } from '@elastic/eui'; import { css } from '@emotion/react'; import React, { useMemo } from 'react'; -import { CustomCodeBlock } from '../../../assistant/get_comments/custom_codeblock/custom_code_block'; -import { customCodeBlockLanguagePlugin } from '../../../assistant/get_comments/custom_codeblock/custom_codeblock_markdown_plugin'; +import { CustomCodeBlock } from '@kbn/elastic-assistant/impl/get_comments/custom_codeblock/custom_code_block'; +import { customCodeBlockLanguagePlugin } from '@kbn/elastic-assistant/impl/get_comments/custom_codeblock/custom_codeblock_markdown_plugin'; export const MESSAGE_TEXT_TEST_ID = 'ai-for-soc-alert-flyout-message-text'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx b/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx index 273bfdb9e0a3..d66dfc772594 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx @@ -65,6 +65,7 @@ import { PluginServices } from './plugin_services'; import { getExternalReferenceAttachmentEndpointRegular } from './cases/attachments/external_reference'; import { isSecuritySolutionAccessible } from './helpers_access'; import { generateAttachmentType } from './threat_intelligence/modules/cases/utils/attachments'; +import { PROMPT_CONTEXTS } from './assistant/content/prompt_contexts'; export class Plugin implements IPlugin { private config: SecuritySolutionUiConfigType; @@ -136,7 +137,21 @@ export class Plugin implements IPlugin { + unmountApp(); + unmountPromptContext(); + }; }, }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/types.ts b/x-pack/solutions/security/plugins/security_solution/public/types.ts index 81a655ac445d..758379460671 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/types.ts @@ -62,6 +62,7 @@ import type { ServerlessPluginStart } from '@kbn/serverless/public'; import type { DiscoverSharedPublicStart } from '@kbn/discover-shared-plugin/public'; import type { AutomaticImportPluginStart } from '@kbn/automatic-import-plugin/public'; import type { ProductFeatureKeys } from '@kbn/security-solution-features'; +import type { ElasticAssistantSharedStatePublicPluginStart } from '@kbn/elastic-assistant-shared-state-plugin/public'; import type { ResolverPluginSetup } from './resolver/types'; import type { Inspect } from '../common/search_strategy'; import type { Detections } from './detections'; @@ -153,6 +154,7 @@ export interface StartPlugins { automaticImport?: AutomaticImportPluginStart; serverless?: ServerlessPluginStart; productDocBase: ProductDocBasePluginStart; + elasticAssistantSharedState: ElasticAssistantSharedStatePublicPluginStart; } export interface StartPluginsDependencies extends StartPlugins { diff --git a/x-pack/solutions/security/plugins/security_solution/tsconfig.json b/x-pack/solutions/security/plugins/security_solution/tsconfig.json index f41da2539d91..dc964c3f3275 100644 --- a/x-pack/solutions/security/plugins/security_solution/tsconfig.json +++ b/x-pack/solutions/security/plugins/security_solution/tsconfig.json @@ -15,8 +15,7 @@ "scripts/**/*.json", "public/**/*.json", "../../../../../typings/**/*", - "emotion.d.ts" - ], + "emotion.d.ts" ], "exclude": [ "target/**/*", "**/cypress/**", @@ -254,6 +253,8 @@ "@kbn/triggers-actions-ui-types", "@kbn/unified-histogram", "@kbn/react-kibana-context-theme", - "@kbn/spaces-utils", + "@kbn/elastic-assistant-shared-state", + "@kbn/elastic-assistant-shared-state-plugin", + "@kbn/spaces-utils" ] } diff --git a/yarn.lock b/yarn.lock index 79c413b11f31..005d7ef6197f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5459,6 +5459,14 @@ version "0.0.0" uid "" +"@kbn/elastic-assistant-shared-state-plugin@link:x-pack/solutions/security/plugins/elastic_assistant_shared_state": + version "0.0.0" + uid "" + +"@kbn/elastic-assistant-shared-state@link:x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state": + version "0.0.0" + uid "" + "@kbn/elastic-assistant@link:x-pack/platform/packages/shared/kbn-elastic-assistant": version "0.0.0" uid ""