[Security Solution] [AI assistant] Make the Security AI assistant global (#223936)

## Summary

Fixes https://github.com/elastic/security-team/issues/8934

Summarize your PR. If it involves visual changes include a screenshot or
gif.

This PR enables the Security AI assistant to be used globally - i.e. if
you are outside of the security solution (e.g. Discover), the Security
Assistant can still be opened.

Changes:
- A public module has been added to the elastic-assistant plugin
(previously it was a server-side-only plugin).
- The vast majority of the assistant (flyout and nav bar) has been moved
into the new elastic-assistantpublic plugin.
- Comment actions & message augmentations remain within the
security-solution.
- A new public plugin was created called elastic-assistant-shared state.
This plugin is used to share state between the elastic-assistant public
plugin and other plugins (e.g. security-solution).
- For example, the security solution registers comment actions in the
elastic-assistant-shared-state plugin. The elastic-assistant public
plugin then reads the comment actions from
elastic-assistant-shared-state and renders them in the assistant flyout.


![image](https://github.com/user-attachments/assets/3322e434-f2f4-42c7-ac8a-63070a1cb9ca)

### Considerations:
- Currently, the Security AI assistant is being displayed everywhere
except the observability solution (see implementation
[here](https://github.com/elastic/kibana/pull/223936/files#diff-5dd1ea91c2d5d242203cc58ee59ec283116e5e739ed82bae4e2cd78af322150c)).
This is only for testing while the PR is in review. We plan to add a
setting to the stack management that allows the user to configure where
they would like the assistant to be shown. This will be changed before
the PR is merged.

## How to test

Feel free to use the cloud and serverless deployments created by the CI
pipeline for testing. Credentials can be found on Buildkite.

### Verify that the Security AI assistant works as expected within the
security solution
Expected there to be no changes in how the security AI assistant works
within the Security Solution. Please do some exploratory testing to make
sure nothing has changed.

Start the branch locally and go to http://localhost:5601/app/security/

Things to test:
- Does the assistant open?
- Can I send an alert to the assistant from the alerts page?
- Does the assistant display code blocks correctly?
- Does the assistant display ESQL correctly (can I view the ESQL in the
timeline)?
- Do assistant messages have the correct comment actions? Do the comment
actions work?
- Are conversations displayed correctly?
- Do citations work?
- Does the assistant work in serverless? Does the assistant work as
expected in AI4SOC?
- Do quick prompts work?
- Can you select a system prompt for a new convo?
- Can you send alerts to the Security AI assistant?
- AI assistant in a space that has Security disabled.
- Does attack discovery work?

AI assistant open in Discover app:
<img width="1841" alt="image"
src="https://github.com/user-attachments/assets/0a13a100-d192-4fa4-b395-0951452e14c2"
/>

AI assistant in Security solution:
<img width="1841" alt="image"
src="https://github.com/user-attachments/assets/7ed38f37-79de-41a7-a80f-8b96147bfdf6"
/>


### Verify the Security AI assistant works in Discover (or anywhere
outside of the Security solution)?
Head over to http://localhost:5601/app/discover. Note that some
functionality is removed when using the AI assistant outside of
security:
- Only the "copy" comment action appears on messages.
- Code block augmentations (i.e. the button that opens ESQL inside of
the timeline) don't appear.

Things to test:
- Does the security AI assistant button appear in the nav bar?
- Can you open the security AI assistant?
- Are you able to send messages?
- Are conversations appearing as expected?
- Can you close the assistant?
- Do citations work?
- Can you switch to a different solution while the assistant is open?

Security AI assistant open in AI4SOC Discover:
<img width="1841" alt="image"
src="https://github.com/user-attachments/assets/36537b9b-e945-459e-ac13-43e9444e92b7"
/>


### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [X] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [X]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [X] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [X] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [X] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [X] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [X] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

### Identify risks

Does this PR introduce any risks? For example, consider risks like hard
to test bugs, performance regression, potential of data loss.

Describe the risk, its severity, and mitigation for each identified
risk. Invite stakeholders and evaluate how to proceed before merging.

- [ ] [See some risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx)
- [ ] ...

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Kenneth Kreindler 2025-06-24 19:11:43 +01:00 committed by GitHub
parent 081872cf5c
commit e3976c9c0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
152 changed files with 4585 additions and 963 deletions

2
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -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. |

View file

@ -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",

View file

@ -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

View file

@ -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"],

View file

@ -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';

View file

@ -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:
'<rootDir>/target/kibana-coverage/jest/x-pack/packages/kbn-elastic-assistant-shared-state',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/**/*.{ts,tsx}',
'!<rootDir>/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/{__test__,__snapshots__,__examples__,*mock*,tests,test_helpers,integration_tests,types}/**/*',
'!<rootDir>/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/*mock*.{ts,tsx}',
'!<rootDir>/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/*.test.{ts,tsx}',
'!<rootDir>/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/*.d.ts',
'!<rootDir>/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state/src/*.config.ts',
],
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/x-pack/platform/packages/shared/kbn-elastic-assistant-shared-state'],
};

View file

@ -0,0 +1,9 @@
{
"type": "shared-browser",
"id": "@kbn/elastic-assistant-shared-state",
"owner": [
"@elastic/security-generative-ai"
],
"group": "platform",
"visibility": "shared"
}

View file

@ -0,0 +1,7 @@
{
"name": "@kbn/elastic-assistant-shared-state",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0",
"sideEffects": false
}

View file

@ -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';

View file

@ -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<UseAssistantContext | undefined> = [];
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,
]);
});
});

View file

@ -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<void>(1);
public start() {
const assistantContextValue$ = new BehaviorSubject<UseAssistantContext | undefined>(undefined);
return {
setAssistantContextValue: (assistantContextValue: UseAssistantContext) => {
assistantContextValue$.next(assistantContextValue);
return () => {
assistantContextValue$.next(undefined);
};
},
getAssistantContextValue$: () => assistantContextValue$.pipe(takeUntil(this.stop$)),
};
}
public stop() {
this.stop$.next();
}
}

View file

@ -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);
});
});

View file

@ -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<void>(1);
public start() {
const augmentMessageCodeBlocks$ = new BehaviorSubject<AugmentMessageCodeBlocks>(defaultValue);
return {
registerAugmentMessageCodeBlocks: (augmentMessageCodeBlocks: AugmentMessageCodeBlocks) => {
augmentMessageCodeBlocks$.next(augmentMessageCodeBlocks);
return () => {
augmentMessageCodeBlocks$.next(defaultValue);
};
},
getAugmentMessageCodeBlocks$: () => augmentMessageCodeBlocks$.pipe(takeUntil(this.stop$)),
};
}
public stop() {
this.stop$.next();
}
}

View file

@ -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);
});
});

View file

@ -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<void>(1);
public start() {
const actions$ = new BehaviorSubject<ReadonlySet<CommentServiceActions>>(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();
}
}

View file

@ -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<Record<string, PromptContextTemplate>> = [];
getPromptContext$().subscribe((value) => {
values.push(value);
});
const mockPromptContext1: Record<string, PromptContextTemplate> = {
test1: {
category: 'Test Context 1',
description: 'Test description 1',
tooltip: 'Test tooltip 1',
},
};
const mockPromptContext2: Record<string, PromptContextTemplate> = {
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);
});
});

View file

@ -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<void>(1);
public start() {
const promptContext$ = new BehaviorSubject<Record<string, PromptContextTemplate>>({});
return {
setPromptContext: (promptContext: Record<string, PromptContextTemplate>) => {
promptContext$.next(promptContext);
return () => {
promptContext$.next({});
};
},
getPromptContext$: () => promptContext$.pipe(takeUntil(this.stop$)),
};
}
public stop() {
this.stop$.next();
}
}

View file

@ -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<string | undefined> = [];
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<string | undefined> = [];
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);
});
});

View file

@ -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<void>(1);
public start() {
const signalIndex$ = new BehaviorSubject<string | undefined>(undefined);
return {
setSignalIndex: (signalIndex: string | undefined) => {
signalIndex$.next(signalIndex);
return () => {
signalIndex$.next(undefined);
};
},
getSignalIndex$: () => signalIndex$.pipe(takeUntil(this.stop$)),
};
}
public stop() {
this.stop$.next();
}
}

View file

@ -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",
]
}

View file

@ -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<Props> = ({
const [autoPopulatedOnce, setAutoPopulatedOnce] = useState<boolean>(false);
const [messageCodeBlocks, setMessageCodeBlocks] = useState<CodeBlockDetails[][]>();
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<Props> = ({
setUserPrompt,
]);
const createCodeBlockPortals = useCallback(
() =>
messageCodeBlocks?.map((codeBlocks: CodeBlockDetails[], i: number) => {
return (
<span key={`${i}`}>
{codeBlocks.map((codeBlock: CodeBlockDetails, j: number) => {
const getElement = codeBlock.getControlContainer;
const element = getElement?.();
return (
<span key={`${i}+${j}`}>
{element ? createPortal(codeBlock.button, element) : <></>}
</span>
);
})}
</span>
);
}),
[messageCodeBlocks]
);
const comments = useMemo(
() => (
<>
@ -513,9 +495,6 @@ const AssistantComponent: React.FC<Props> = ({
setIsSettingsModalVisible={setIsSettingsModalVisible}
setPaginationObserver={setPaginationObserver}
/>
{/* Create portals for each EuiCodeBlock to add the `Investigate in Timeline` action */}
{createCodeBlockPortals()}
</EuiFlyoutHeader>
<EuiFlyoutBody
css={css`

View file

@ -6,24 +6,17 @@
*/
import React from 'react';
import { render, renderHook } from '@testing-library/react';
import { render } from '@testing-library/react';
import { AssistantNavLink } from './assistant_nav_link';
import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks';
import { ChromeNavControl } from '@kbn/core/public';
import { createHtmlPortalNode, OutPortal } from 'react-reverse-portal';
import { of } from 'rxjs';
import { useAssistantContext } from '.';
const MockNavigationBar = OutPortal;
const mockShowAssistantOverlay = jest.fn();
const mockNavControls = chromeServiceMock.createStartContract().navControls;
const mockGetChromeStyle = jest.fn();
const mockAssistantContext = {
chrome: {
getChromeStyle$: mockGetChromeStyle,
navControls: mockNavControls,
},
showAssistantOverlay: mockShowAssistantOverlay,
assistantAvailability: {
@ -47,25 +40,11 @@ describe('AssistantNavLink', () => {
});
});
it('should register link in nav bar', () => {
render(<AssistantNavLink />);
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(
<>
<MockNavigationBar node={portalNode.current} />
<AssistantNavLink />
</>
);
@ -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(
<>
<MockNavigationBar node={portalNode.current} />
<AssistantNavLink />
</>
);
@ -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(
<>
<MockNavigationBar node={portalNode.current} />
<AssistantNavLink />
</>
);
@ -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(
<>
<MockNavigationBar node={portalNode.current} />
<AssistantNavLink />
</>
);
@ -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(
<>
<MockNavigationBar node={portalNode.current} />
<AssistantNavLink />
</>
);

View file

@ -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<ChromeStyle | undefined>(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(<OutPortal node={portalNode} />, 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 (
<InPortal node={portalNode}>
<EuiToolTip content={TOOLTIP_CONTENT}>
<EuiButtonBasicOrEmpty
onClick={showOverlay}
color="primary"
size="s"
data-test-subj="assistantNavLink"
>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<AssistantIcon size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>{LINK_LABEL}</EuiFlexItem>
</EuiFlexGroup>
</EuiButtonBasicOrEmpty>
</EuiToolTip>
</InPortal>
<EuiToolTip content={TOOLTIP_CONTENT}>
<EuiButtonBasicOrEmpty
onClick={showOverlay}
color="primary"
size="s"
data-test-subj="assistantNavLink"
>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<AssistantIcon size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>{LINK_LABEL}</EuiFlexItem>
</EuiFlexGroup>
</EuiButtonBasicOrEmpty>
</EuiToolTip>
);
};

View file

@ -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<AssistantFeatures>;
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<UseAssistantContext | undefined>(undefined);
export const AssistantProvider: React.FC<AssistantProviderProps> = ({
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<TraceOptions>(
`${nameSpace}.${TRACE_OPTIONS_SESSION_STORAGE_KEY}`,
@ -380,20 +395,12 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
]
);
return (
<AssistantContext.Provider value={value}>
<AssistantNavLink />
{children}
</AssistantContext.Provider>
);
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<typeof useAssistantContextValue>;
}> = ({ children, value }) => {
return <AssistantContext.Provider value={value}>{children}</AssistantContext.Provider>;
};

View file

@ -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<Props> = React.memo(
actionTypeSelectorInline = false,
}) => (
<>
<ActionTypeSelectorModal
actionTypes={actionTypes}
actionTypeRegistry={actionTypeRegistry}
onClose={onClose}
onSelect={onSelectActionType}
actionTypeSelectorInline={actionTypeSelectorInline}
/>
<Suspense fallback={null}>
<ActionTypeSelectorModal
actionTypes={actionTypes}
actionTypeRegistry={actionTypeRegistry}
onClose={onClose}
onSelect={onSelectActionType}
actionTypeSelectorInline={actionTypeSelectorInline}
/>
</Suspense>
{selectedActionType && (
<ConnectorAddModal
actionType={selectedActionType}

View file

@ -17,7 +17,11 @@ import { UserProfileService } from '@kbn/core/public';
import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks';
import { of } from 'rxjs';
import { docLinksServiceMock } from '@kbn/core/public/mocks';
import { AssistantProvider, AssistantProviderProps } from '../../assistant_context';
import {
AssistantProvider,
AssistantProviderProps,
useAssistantContextValue,
} from '../../assistant_context';
import { AssistantAvailability } from '../../assistant_context/types';
import { AssistantSpaceIdProvider } from '../../assistant/use_space_aware_context';
@ -72,31 +76,36 @@ export const TestProvidersComponent: React.FC<Props> = ({
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 (
<I18nProvider>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<AssistantProvider
actionTypeRegistry={actionTypeRegistry}
assistantAvailability={assistantAvailability}
augmentMessageCodeBlocks={jest.fn().mockReturnValue([])}
basePath={'https://localhost:5601/kbn'}
docLinks={docLinksServiceMock.createStartContract()}
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={chrome}
>
<TestAssistantProviders assistantProviderProps={assistantProviderProps}>
<AssistantSpaceIdProvider spaceId="default">{children}</AssistantSpaceIdProvider>
</AssistantProvider>
</TestAssistantProviders>
</QueryClientProvider>
</ThemeProvider>
</I18nProvider>
@ -106,3 +115,14 @@ export const TestProvidersComponent: React.FC<Props> = ({
TestProvidersComponent.displayName = 'TestProvidersComponent';
export const TestProviders = React.memo(TestProvidersComponent);
const TestAssistantProviders = ({
assistantProviderProps,
children,
}: {
assistantProviderProps: AssistantProviderProps;
children: React.ReactNode;
}) => {
const assistantContextValue = useAssistantContextValue(assistantProviderProps);
return <AssistantProvider value={assistantContextValue}>{children}</AssistantProvider>;
};

View file

@ -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;
}

View file

@ -43,6 +43,6 @@
"@kbn/shared-ux-router",
"@kbn/datemath",
"@kbn/alerts-ui-shared",
"@kbn/deeplinks-security"
"@kbn/deeplinks-security",
]
}

View file

@ -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 <bold>{keyboardShortcut}</bold>",
"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 sest produite lors de lenvoi 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 sest produite lors de lenvoi 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 dattaque.",
@ -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 sest produite lors de lenvoi 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 quexpert en opérations de sécurité et en réponses aux incidents, décomposer lalerte jointe et résumer ce quelle peut impliquer pour mon organisation.",
"xpack.securitySolution.assistant.quickPrompts.alertSummarizationTitle": "Synthèse de lalerte",
"xpack.securitySolution.assistant.quickPrompts.AutomationPrompt": "Quelle intégration dElastic 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",

View file

@ -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": "既存のケースに追加",

View file

@ -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": "添加到现有案例",

View file

@ -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<TestExternalProvidersProps> = ({
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 (
<KibanaRenderContextProvider {...coreMock.createStart()}>
<I18nProvider>
<EuiThemeProvider>
<QueryClientProvider client={queryClient}>
<AssistantProvider
actionTypeRegistry={actionTypeRegistry}
assistantAvailability={mockAssistantAvailability}
augmentMessageCodeBlocks={jest.fn()}
basePath={'https://localhost:5601/kbn'}
docLinks={docLinksServiceMock.createStartContract()}
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={chrome}
>
<TestAssistantProvider assistantProviderProps={assistantProviderProps}>
{children}
</AssistantProvider>
</TestAssistantProvider>
</QueryClientProvider>
</EuiThemeProvider>
</I18nProvider>
@ -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 <AssistantProvider value={assistantContextValue}>{children}</AssistantProvider>;
};
export interface TestDataQualityProvidersProps {
children: React.ReactNode;
dataQualityContextProps?: Partial<DataQualityProviderProps>;

View file

@ -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

View file

@ -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;

View file

@ -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"
]
}
}
}

View file

@ -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();

View file

@ -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<ElasticAssistantPublicPlugin['setup']>;
export type ElasticAssistantPublicPluginStart = ReturnType<ElasticAssistantPublicPlugin['start']>;
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(
<I18nProvider>
<KibanaContextProvider
services={{
appName: 'securitySolution',
...services,
}}
>
<KibanaThemeProvider {...coreStart}>
<ReactQueryClientProvider>
<AssistantSpaceIdProvider>
<AssistantProvider>
<Suspense fallback={null}>
<AssistantNavLink />
<AssistantOverlay />
</Suspense>
</AssistantProvider>
</AssistantSpaceIdProvider>
</ReactQueryClientProvider>
</KibanaThemeProvider>
</KibanaContextProvider>
</I18nProvider>,
targetDomElement
);
return () => ReactDOM.unmountComponentAtNode(targetDomElement);
}
public stop() {
// Cleanup when plugin is stopped
}
}

View file

@ -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;

View file

@ -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];

View file

@ -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';

View file

@ -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() });

View file

@ -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',
}
);
});
});
});

View file

@ -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: <T extends TelemetryEventTypes>(
eventType: T,
eventData: TelemetryEventTypeData<T>
) => 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<TelemetryEventTypeData<TelemetryEventTypes>>(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 };
}
}

View file

@ -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 TelemetryEventTypes> = T extends AssistantEventTypes
? AssistantTelemetryEventsMap[T]
: never;
export type TelemetryEventTypes = AssistantEventTypes;

View file

@ -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(
<BaseCommentActions message={message}>
<EuiFlexItem grow={false} data-test-subj="placeholder_actions">
<div>{'Other actions'}</div>
</EuiFlexItem>
</BaseCommentActions>
);
expect(EuiCopy).toHaveBeenCalledWith(
expect.objectContaining({
textToCopy: expected,
}),
expect.anything()
);
expect(screen.getByTestId('copy-to-clipboard-action')).toBeInTheDocument();
expect(screen.getByTestId('placeholder_actions')).toBeInTheDocument();
});
});

View file

@ -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<Props> = ({ message, children }) => {
const content = message.content ?? '';
return (
<EuiFlexGroup alignItems="center" gutterSize="none">
{children}
<EuiFlexItem grow={false} data-test-subj="copy-to-clipboard-action">
<EuiToolTip position="top" content={COPY_TO_CLIPBOARD}>
<EuiCopy textToCopy={getSelfContainedContent(content)}>
{(copy) => (
<EuiButtonIcon
aria-label={COPY_TO_CLIPBOARD}
color="primary"
iconType="copyClipboard"
onClick={copy}
/>
)}
</EuiCopy>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const BaseCommentActions = React.memo(BaseCommentActionsComponent);

View file

@ -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(<EuiFlexItem data-test-subj="placeholder_actions_1">{'Hello'}</EuiFlexItem>);
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(<EuiFlexItem data-test-subj="placeholder_actions_2">{'Bye'}</EuiFlexItem>);
return () => {
root.unmount();
target.removeChild(div);
};
},
});
render(<CommentActionsMounter message={message} getActions$={start.getActions$()} />);
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(<EuiFlexItem data-test-subj="placeholder_actions_1">{'Hello'}</EuiFlexItem>);
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(<EuiFlexItem data-test-subj="placeholder_actions_2">{'Bye'}</EuiFlexItem>);
return () => {
root.unmount();
target.removeChild(div);
};
},
});
render(<CommentActionsMounter message={message} getActions$={start.getActions$()} />);
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();
});
});

View file

@ -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<CommentServiceActions[]>;
}
export const CommentActionsMounter = ({ message, getActions$ }: Props) => {
const actions = useObservable(getActions$, []);
const actionMountPointRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const mountPoint = actionMountPointRef.current;
const unmountActions = mountPoint
? actions.map((action) => action.mount({ message })(mountPoint))
: [];
return () => {
unmountActions.forEach((unmount) => unmount());
};
}, [actions, message]);
return (
<BaseCommentActions message={message}>
<EuiFlexGroup alignItems="center" gutterSize="none" ref={actionMountPointRef} />
</BaseCommentActions>
);
};

View file

@ -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: {

View file

@ -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<EsqlContentReference>;

View file

@ -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();

View file

@ -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 {

View file

@ -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');
});
});

View file

@ -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}`}`;

View file

@ -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)}`;

View file

@ -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,
},

View file

@ -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;

View file

@ -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<SecurityAlertContentReference>;
}

View file

@ -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,
},

View file

@ -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<SecurityAlertsPageContentReference>;

View file

@ -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',
}

View file

@ -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()}`
);

View file

@ -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: (
<SpinnerWrapper>
<EuiLoadingSpinner size="xl" />
</SpinnerWrapper>
),
timestamp: '...',
children: (
<StreamComment
abortStream={abortStream}
content=""
refetchCurrentConversation={refetchCurrentConversation}
regenerateMessage={regenerateMessageOfConversation}
setIsStreaming={setIsStreaming}
contentReferencesVisible={contentReferencesVisible}
transformMessage={() => ({ 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 (
<EuiAvatar
name="user"
size="l"
color={currentUserAvatar?.color ?? 'subdued'}
{...(currentUserAvatar?.imageUrl
? { imageUrl: currentUserAvatar.imageUrl as string }
: { initials: currentUserAvatar?.initials })}
/>
);
}
return <EuiAvatar name="user" size="l" color="subdued" iconType="userAvatar" />;
};
return [
...(systemPromptContent && currentConversation.messages.length
? [
{
username: i18n.SYSTEM,
timelineAvatar: <AssistantAvatar name="machine" size="l" color="subdued" />,
timestamp:
currentConversation.messages[0].timestamp.length === 0
? new Date().toLocaleString()
: new Date(currentConversation.messages[0].timestamp).toLocaleString(),
children: (
<StreamComment
abortStream={abortStream}
content={systemPromptContent}
refetchCurrentConversation={refetchCurrentConversation}
regenerateMessage={regenerateMessageOfConversation}
setIsStreaming={setIsStreaming}
contentReferences={null}
contentReferencesVisible={contentReferencesVisible}
transformMessage={() => ({ 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 ? (
<UserAvatar />
) : (
<AssistantAvatar name="machine" size="l" color="subdued" />
),
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: (
<StreamComment
abortStream={abortStream}
contentReferences={null}
contentReferencesVisible={contentReferencesVisible}
index={index}
isControlsEnabled={isControlsEnabled}
isError={message.isError}
reader={message.reader}
refetchCurrentConversation={refetchCurrentConversation}
regenerateMessage={regenerateMessageOfConversation}
setIsStreaming={setIsStreaming}
transformMessage={transformMessage}
messageRole={message.role}
/>
),
};
}
// transform message here so we can send correct message to CommentActions
const transformedMessage = transformMessage(message.content ?? '');
return {
...messageProps,
actions: <args.CommentActions message={transformedMessage} />,
children: (
<StreamComment
abortStream={abortStream}
content={transformedMessage.content}
contentReferences={message.metadata?.contentReferences}
contentReferencesVisible={contentReferencesVisible}
index={index}
isControlsEnabled={isControlsEnabled}
isError={message.isError}
// reader is used to determine if streaming controls are shown
reader={transformedMessage.reader}
regenerateMessage={regenerateMessageOfConversation}
refetchCurrentConversation={refetchCurrentConversation}
setIsStreaming={setIsStreaming}
transformMessage={transformMessage}
messageRole={message.role}
/>
),
};
}),
...extraLoadingComment,
];
};

View file

@ -18,7 +18,7 @@ export function RegenerateResponseButton(props: Partial<EuiButtonEmptyProps>) {
iconType="sparkles"
{...props}
>
{i18n.translate('xpack.securitySolution.aiAssistant.regenerateResponseButtonLabel', {
{i18n.translate('xpack.elasticAssistantPlugin.aiAssistant.regenerateResponseButtonLabel', {
defaultMessage: 'Regenerate',
})}
</EuiButtonEmpty>

View file

@ -19,7 +19,7 @@ export function StopGeneratingButton(props: Partial<EuiButtonEmptyProps>) {
size="s"
{...props}
>
{i18n.translate('xpack.securitySolution.aiAssistant.stopGeneratingButtonLabel', {
{i18n.translate('xpack.elasticAssistantPlugin.aiAssistant.stopGeneratingButtonLabel', {
defaultMessage: 'Stop generating',
})}
</EuiButtonEmpty>

View file

@ -17,7 +17,7 @@ export function FailedToLoadResponse() {
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s" color="danger">
{i18n.translate('xpack.securitySolution.aiAssistant.failedLoadingResponseText', {
{i18n.translate('xpack.elasticAssistantPlugin.aiAssistant.failedLoadingResponseText', {
defaultMessage: 'Failed to load response',
})}
</EuiText>

View file

@ -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<Array<AsApiContract<Connector>>, unknown>);
});
it('renders content correctly', () => {
render(<StreamComment {...testProps} />);

View file

@ -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';

View file

@ -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.',
});

View file

@ -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(
<AssistantProvider>
<div data-test-subj="assistant-provider-test">{'Assistant Provider Test'}</div>
</AssistantProvider>,
{
wrapper: ({ children }) => (
<ElasticAssistantTestProviders
services={{
application: mockApplication,
triggersActionsUi: mockTriggersActionsUi,
http: mockHttp,
notifications,
elasticAssistantSharedState,
}}
>
{children}
</ElasticAssistantTestProviders>
),
}
);
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),
})
);
});
});

View file

@ -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 (
<CommentActionsMounter
message={args.message}
getActions$={elasticAssistantSharedState.comments.getActions$()}
/>
);
},
[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 (
<ElasticAssistantProvider value={assistantContextValue}>{children}</ElasticAssistantProvider>
);
}

View file

@ -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<typeof useSpaceId>;
jest.mock('@kbn/elastic-assistant', () => ({
AssistantSpaceIdProvider: jest.fn(({ children }) => (
<div data-test-subj="elastic-assistant-provider">{children}</div>
)),
}));
describe('AssistantSpaceIdProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should not render children when spaceId is undefined', () => {
mockUseSpaceId.mockReturnValue(undefined);
const { container, queryByText, queryByTestId } = render(
<AssistantSpaceIdProvider>
<div data-test-subj="child-component">{'Child Component'}</div>
</AssistantSpaceIdProvider>
);
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(
<AssistantSpaceIdProvider>
<div data-test-subj="child-component">{'Child Component'}</div>
</AssistantSpaceIdProvider>
);
expect(getByTestId('elastic-assistant-provider')).toBeInTheDocument();
expect(getByText('Child Component')).toBeInTheDocument();
});
});

View file

@ -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 (
<ElasticAssistantSpaceIdProvider spaceId={spaceId}>{children}</ElasticAssistantSpaceIdProvider>
);
};

View file

@ -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<typeof QueryClient>[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<ReactQueryClientProviderProps>(
({ queryClient, children }) => {
const client = useMemo(() => {
return queryClient || elasticAssistantQueryClient;
}, [queryClient]);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
);
ReactQueryClientProvider.displayName = 'ReactQueryClientProvider';

View file

@ -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<StartServices>();
export { KibanaContextProvider, useTypedKibana as useKibana };

View file

@ -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<typeof useLicense>;
const mockUseKibana = useKibana as jest.MockedFunction<typeof useKibana>;
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<typeof useKibana>);
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<typeof useKibana>);
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<typeof useKibana>);
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<typeof useKibana>);
const { result } = renderHook(() => useAssistantAvailability());
expect(result.current).toEqual({
hasSearchAILakeConfigurations: false,
hasAssistantPrivilege: false,
hasConnectorsAllPrivilege: false,
hasConnectorsReadPrivilege: false,
isAssistantEnabled: true,
hasUpdateAIAssistantAnonymization: false,
hasManageGlobalKnowledgeBase: false,
});
});
});

View file

@ -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,
};
};

View file

@ -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();

View file

@ -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;
};

View file

@ -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<string, PublicAppInfo>,
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,
};
}

Some files were not shown because too many files have changed in this diff Show more