diff --git a/x-pack/packages/kbn-elastic-assistant-common/env/http-client.env.json b/x-pack/packages/kbn-elastic-assistant-common/env/http-client.env.json index 3f48e01e099fa..2c93670ea1f85 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/env/http-client.env.json +++ b/x-pack/packages/kbn-elastic-assistant-common/env/http-client.env.json @@ -4,11 +4,22 @@ "port": "5601", "basePath": "", "elasticApiVersion": "1", + "elasticsearch": { + "host": "localhost", + "port": "9200" + }, "auth": { - "username": "elastic", - "password": "changeme" + "admin": { + "username": "elastic", + "password": "changeme" + }, + "assistant_all": { + "username": "assistant_all", + "password": "changeme" + } }, "appContext": { + "management": "%7B%22type%22%3A%22application%22%2C%22name%22%3A%22management%22%2C%22url%22%3A%22%2Fkbn%2Fapp%2Fmanagement%22%2C%22page%22%3A%22%22%7D", "security": "%7B%22type%22%3A%22application%22%2C%22name%22%3A%22securitySolutionUI%22%2C%22url%22%3A%22%2Fkbn%2Fapp%2Fsecurity%22%7D" } } diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.gen.ts index f18bfb57fa359..ca00075b30e36 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.gen.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.gen.ts @@ -27,6 +27,15 @@ export const KnowledgeBaseResponse = z.object({ success: z.boolean().optional(), }); +export type CreateKnowledgeBaseRequestQuery = z.infer; +export const CreateKnowledgeBaseRequestQuery = z.object({ + /** + * Optional ELSER modelId to use when setting up the Knowledge Base + */ + modelId: z.string().optional(), +}); +export type CreateKnowledgeBaseRequestQueryInput = z.input; + export type CreateKnowledgeBaseRequestParams = z.infer; export const CreateKnowledgeBaseRequestParams = z.object({ /** diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.schema.yaml index 4342d334aad1a..8b4e6bfaca5ec 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.schema.yaml +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.schema.yaml @@ -18,6 +18,12 @@ paths: description: The KnowledgeBase `resource` value. schema: type: string + - name: modelId + in: query + description: Optional ELSER modelId to use when setting up the Knowledge Base + required: false + schema: + type: string responses: 200: description: Indicates a successful call. diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/bulk_crud_knowledge_base_entries_route.http b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/bulk_crud_knowledge_base_entries_route.http index d8ad084a792a1..873a7299820f0 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/bulk_crud_knowledge_base_entries_route.http +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/bulk_crud_knowledge_base_entries_route.http @@ -3,7 +3,7 @@ POST http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_b kbn-xsrf: "true" Content-Type: application/json Elastic-Api-Version: {{elasticApiVersion}} -Authorization: Basic {{auth.username}} {{auth.password}} +Authorization: Basic {{auth.admin.username}} {{auth.admin.password}} X-Kbn-Context: {{appContext.security}} { diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/crud_knowledge_base_entries_route.http b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/crud_knowledge_base_entries_route.http index 88ad6555da50f..6bd8dc7ba288b 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/crud_knowledge_base_entries_route.http +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/crud_knowledge_base_entries_route.http @@ -1,45 +1,151 @@ -### Create Document Entry +### Create Document Entry [Admin] [Private] POST http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_base/entries kbn-xsrf: "true" Content-Type: application/json Elastic-Api-Version: {{elasticApiVersion}} -Authorization: Basic {{auth.username}} {{auth.password}} +Authorization: Basic {{auth.admin.username}} {{auth.admin.password}} X-Kbn-Context: {{appContext.security}} { "type": "document", - "name": "Favorites", + "name": "Document Entry [Admin] [Private]", "kbResource": "user", "source": "api", "required": true, "text": "My favorite food is Dan Bing" } -### Create Index Entry +### Create Document Entry [Admin] [Global] POST http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_base/entries kbn-xsrf: "true" Content-Type: application/json Elastic-Api-Version: {{elasticApiVersion}} -Authorization: Basic {{auth.username}} {{auth.password}} +Authorization: Basic {{auth.admin.username}} {{auth.admin.password}} +X-Kbn-Context: {{appContext.security}} + +{ + "type": "document", + "name": "Document Entry [Admin] [Global]", + "kbResource": "user", + "source": "api", + "required": true, + "text": "My favorite food is pizza", + "users": [] +} + +### Create Document Entry [Assistant All] [Private] +POST http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_base/entries +kbn-xsrf: "true" +Content-Type: application/json +Elastic-Api-Version: {{elasticApiVersion}} +Authorization: Basic {{auth.assistant_all.username}} {{auth.assistant_all.password}} +X-Kbn-Context: {{appContext.security}} + +{ + "type": "document", + "name": "Document Entry [Assistant All] [Private]", + "kbResource": "user", + "source": "api", + "required": true, + "text": "My favorite food is popcorn" +} + +### Create Document Entry [Assistant All] [Global] +POST http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_base/entries +kbn-xsrf: "true" +Content-Type: application/json +Elastic-Api-Version: {{elasticApiVersion}} +Authorization: Basic {{auth.assistant_all.username}} {{auth.assistant_all.password}} +X-Kbn-Context: {{appContext.security}} + +{ + "type": "document", + "name": "Document Entry [Assistant All] [Global]", + "kbResource": "user", + "source": "api", + "required": true, + "text": "My favorite food is peaches", + "users": [] +} + +### Create Index Entry [Admin] [Private] +POST http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_base/entries +kbn-xsrf: "true" +Content-Type: application/json +Elastic-Api-Version: {{elasticApiVersion}} +Authorization: Basic {{auth.admin.username}} {{auth.admin.password}} X-Kbn-Context: {{appContext.security}} { "type": "index", - "name": "SpongBotSlackConnector", + "name": "Slackbot-test Index Entry [Admin] [Private]", "namespace": "default", - "index": "spongbot-slack", + "index": "slackbot-test", + "field": "semantic_text", + "description": "Use this index to search for the user's Slack messages.", + "queryDescription": + "The free text search that the user wants to perform over this dataset. So if asking \"what are my slack messages from last week about failed tests\", the query would be \"A test has failed! failing test failed test\"", + "outputFields": ["author", "text", "timestamp"] +} + +### Create Index Entry [Admin] [Global] +POST http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_base/entries +kbn-xsrf: "true" +Content-Type: application/json +Elastic-Api-Version: {{elasticApiVersion}} +Authorization: Basic {{auth.admin.username}} {{auth.admin.password}} +X-Kbn-Context: {{appContext.security}} + +{ + "type": "index", + "name": "Slackbot-test Index Entry [Admin] [Global]", + "namespace": "default", + "index": "slackbot-test", "field": "semantic_text", "description": "Use this index to search for the user's Slack messages.", "queryDescription": "The free text search that the user wants to perform over this dataset. So if asking \"what are my slack messages from last week about failed tests\", the query would be \"A test has failed! failing test failed test\"", - "inputSchema": [ - { - "fieldName": "author", - "fieldType": "string", - "description": "The author of the message. So if asking for recent messages from Stan, you would provide 'Stan' as the author." - } - ], - "outputFields": ["author", "text", "timestamp"] + "outputFields": ["author", "text", "timestamp"], + "users": [] +} + +### Create Index Entry [Assistant All] [Private] +POST http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_base/entries +kbn-xsrf: "true" +Content-Type: application/json +Elastic-Api-Version: {{elasticApiVersion}} +Authorization: Basic {{auth.assistant_all.username}} {{auth.assistant_all.password}} +X-Kbn-Context: {{appContext.security}} + +{ + "type": "index", + "name": "Slackbot-test Index Entry [Assistant All] [Private]", + "namespace": "default", + "index": "slackbot-test", + "field": "semantic_text", + "description": "Use this index to search for the user's Slack messages.", + "queryDescription": "The free text search that the user wants to perform over this dataset. So if asking \"what are my slack messages from last week about failed tests\", the query would be \"A test has failed! failing test failed test\"", + "outputFields": ["author", "text", "timestamp" ] +} + +### Create Index Entry [Assistant All] [Global] +POST http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_base/entries +kbn-xsrf: "true" +Content-Type: application/json +Elastic-Api-Version: {{elasticApiVersion}} +Authorization: Basic {{auth.assistant_all.username}} {{auth.assistant_all.password}} +X-Kbn-Context: {{appContext.security}} + +{ + "type": "index", + "name": "Slackbot-test Index Entry [Assistant All] [Global]", + "namespace": "default", + "index": "slackbot-test", + "field": "semantic_text", + "description": "Use this index to search for the user's Slack messages.", + "queryDescription": "The free text search that the user wants to perform over this dataset. So if asking \"what are my slack messages from last week about failed tests\", the query would be \"A test has failed! failing test failed test\"", + "outputFields": ["author", "text", "timestamp" ], + "users": [] } diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/find_knowledge_base_entries_route.http b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/find_knowledge_base_entries_route.http index 3d90053767dfe..81733c62838a4 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/find_knowledge_base_entries_route.http +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/find_knowledge_base_entries_route.http @@ -1,6 +1,6 @@ ### Find all knowledge base entries GET http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_base/entries/_find Elastic-Api-Version: {{elasticApiVersion}} -Authorization: Basic {{auth.username}} {{auth.password}} +Authorization: Basic {{auth.admin.username}} {{auth.admin.password}} X-Kbn-Context: {{appContext.security}} diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/mocks.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/mocks.ts index a4828c2f24d91..24a43bd3182df 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/mocks.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/mocks.ts @@ -16,12 +16,4 @@ export const indexEntryMock: IndexEntryCreateFields = { description: "Use this index to search for the user's Slack messages.", queryDescription: 'The free text search that the user wants to perform over this dataset. So if asking "what are my slack messages from last week about failed tests", the query would be "A test has failed! failing test failed test".', - inputSchema: [ - { - fieldName: 'author', - fieldType: 'string', - description: - "The author of the message. So if asking for recent messages from Stan, you would provide 'Stan' as the author.", - }, - ], }; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/utils/spaces_roles_users_data.http b/x-pack/packages/kbn-elastic-assistant-common/impl/utils/spaces_roles_users_data.http new file mode 100644 index 0000000000000..16cdf270565a4 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/utils/spaces_roles_users_data.http @@ -0,0 +1,182 @@ +### Create Space-X +POST http://{{host}}:{{port}}{{basePath}}/api/spaces/space +kbn-xsrf: "true" +Content-Type: application/json +Elastic-Api-Version: {{elasticApiVersion}} +Authorization: Basic {{auth.admin.username}} {{auth.admin.password}} + +{ + "name": "Space-X", + "id": "space-x", + "initials": "🚀", + "color": "#9170B8", + "disabledFeatures": [], + "imageUrl": "" +} + +### Create Space-Y +POST http://{{host}}:{{port}}{{basePath}}/api/spaces/space +kbn-xsrf: "true" +Content-Type: application/json +Elastic-Api-Version: {{elasticApiVersion}} +Authorization: Basic {{auth.admin.username}} {{auth.admin.password}} + +{ + "name": "Space-Y", + "id": "space-y", + "initials": "🛰", + "color": "#DA8B45", + "disabledFeatures": [], + "imageUrl": "" +} + +### Create Assistant All Role - All Spaces, All Features +PUT http://{{host}}:{{port}}{{basePath}}/api/security/role/assistant_all +kbn-xsrf: "true" +Content-Type: application/json +Elastic-Api-Version: {{elasticApiVersion}} +Authorization: Basic {{auth.admin.username}} {{auth.admin.password}} + +{ + "description": "Grants access to all Security Assistant features in all spaces", + "elasticsearch": { + "cluster": [ + "all" + ], + "indices": [ + { + "names": [ + "*" + ], + "privileges": [ + "all" + ], + "field_security": { + "grant": [ + "*" + ], + "except": [] + } + } + ], + "run_as": [] + }, + "kibana": [ + { + "spaces": [ + "*" + ], + "base": [], + "feature": { + "siem": [ + "all" + ], + "securitySolutionCases": [ + "all" + ], + "securitySolutionAssistant": [ + "all" + ], + "securitySolutionAttackDiscovery": [ + "all" + ], + "aiAssistantManagementSelection": [ + "all" + ], + "searchInferenceEndpoints": [ + "all" + ], + "dev_tools": [ + "all" + ], + "actions": [ + "all" + ], + "indexPatterns": [ + "all" + ] + } + } + ] +} + +### Create Assistant All User - All Spaces, All Features +POST http://{{host}}:{{port}}{{basePath}}/internal/security/users/assistant_all +kbn-xsrf: "true" +Content-Type: application/json +Elastic-Api-Version: {{elasticApiVersion}} +Authorization: Basic {{auth.admin.username}} {{auth.admin.password}} + +{ + "password": "{{auth.assistant_all.password}}", + "username": "{{auth.assistant_all.username}}", + "full_name": "Assistant All", + "email": "", + "roles": [ + "assistant_all" + ] +} + +### Create Inference Endpoint +PUT http://{{elasticsearch.host}}:{{elasticsearch.port}}/_inference/sparse_embedding/elser_model_2 +Content-Type: application/json +Authorization: Basic {{auth.admin.username}} {{auth.admin.password}} + +{ + "service": "elser", + "service_settings": { + "num_allocations": 1, + "num_threads": 1 + } +} + +### Create Slackbot Mappings +PUT http://{{elasticsearch.host}}:{{elasticsearch.port}}/slackbot-test +Content-Type: application/json +Authorization: Basic {{auth.admin.username}} {{auth.admin.password}} + +{ + "settings": { + "number_of_shards": 1 + }, + "mappings": { + "dynamic": "true", + "properties": { + "semantic_text": { + "type": "semantic_text", + "inference_id": "elser_model_2", + "model_settings": { + "task_type": "sparse_embedding" + } + }, + "text": { + "type": "text", + "copy_to": [ + "semantic_text" + ] + } + } + } +} + +### Create Slackbot Document +POST http://{{elasticsearch.host}}:{{elasticsearch.port}}/slackbot-test/_doc +Content-Type: application/json +Authorization: Basic {{auth.admin.username}} {{auth.admin.password}} + +{ + "subtype": null, + "author": "spong", + "edited_ts": null, + "thread_ts": "1727113718.664029", + "channel": "dev-details", + "text": "The Dude: That rug really tied the room together.", + "id": "C0A6H3AA1BL-1727115800.120029", + "type": "message", + "reply_count": null, + "ts": "1727115800.120029", + "latest_reply": null +} + + + diff --git a/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings.tsx index be6fea6649bbb..e73bfa15e66be 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings.tsx @@ -5,19 +5,12 @@ * 2.0. */ -import { - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiRange, - EuiSpacer, - EuiText, - useGeneratedHtmlId, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFormRow, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; import { css } from '@emotion/react'; import React from 'react'; import { KnowledgeBaseConfig } from '../../assistant/types'; +import { AlertsRange } from '../../knowledge_base/alerts_range'; import * as i18n from '../../knowledge_base/translations'; export const MIN_LATEST_ALERTS = 10; @@ -32,8 +25,6 @@ interface Props { } const AlertsSettingsComponent = ({ knowledgeBase, setUpdatedKnowledgeBaseSettings }: Props) => { - const inputRangeSliderId = useGeneratedHtmlId({ prefix: 'inputRangeSlider' }); - return ( <> - - setUpdatedKnowledgeBaseSettings({ - ...knowledgeBase, - latestAlerts: Number(e.currentTarget.value), - }) - } - showTicks - step={TICK_INTERVAL} - value={knowledgeBase.latestAlerts} + diff --git a/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings_management.tsx b/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings_management.tsx new file mode 100644 index 0000000000000..d103c1a8c03c2 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings_management.tsx @@ -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 { EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import React from 'react'; +import { KnowledgeBaseConfig } from '../../assistant/types'; +import { AlertsRange } from '../../knowledge_base/alerts_range'; +import * as i18n from '../../knowledge_base/translations'; + +interface Props { + knowledgeBase: KnowledgeBaseConfig; + setUpdatedKnowledgeBaseSettings: React.Dispatch>; +} + +export const AlertsSettingsManagement: React.FC = React.memo( + ({ knowledgeBase, setUpdatedKnowledgeBaseSettings }) => { + return ( + + +

{i18n.ALERTS_LABEL}

+
+ + + + {i18n.LATEST_AND_RISKIEST_OPEN_ALERTS(knowledgeBase.latestAlerts)} + {i18n.YOUR_ANONYMIZATION_SETTINGS} + + + + +
+ ); + } +); + +AlertsSettingsManagement.displayName = 'AlertsSettingsManagement'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_create_knowledge_base_entry.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_create_knowledge_base_entry.tsx index eaf9a32fde81a..f4edcb0d8d442 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_create_knowledge_base_entry.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_create_knowledge_base_entry.tsx @@ -78,6 +78,13 @@ export const useCreateKnowledgeBaseEntry = ({ onSettled: () => { invalidateKnowledgeBaseEntries(); }, + onSuccess: () => { + toasts?.addSuccess({ + title: i18n.translate('xpack.elasticAssistant.knowledgeBase.entries.createSuccessTitle', { + defaultMessage: 'Knowledge Base Entry created', + }), + }); + }, } ); }; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_knowledge_base_entries.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_knowledge_base_entries.ts index 43d70af7dd255..b41119779b21d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_knowledge_base_entries.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_knowledge_base_entries.ts @@ -5,8 +5,9 @@ * 2.0. */ -import { HttpSetup } from '@kbn/core/public'; +import { HttpSetup, type IHttpFetchError, type ResponseErrorBody } from '@kbn/core/public'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import type { IToasts } from '@kbn/core-notifications-browser'; import { API_VERSIONS, ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_FIND, @@ -15,11 +16,14 @@ import { } from '@kbn/elastic-assistant-common'; import { useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; export interface UseKnowledgeBaseEntriesParams { http: HttpSetup; - query: FindKnowledgeBaseEntriesRequestQuery; + query?: FindKnowledgeBaseEntriesRequestQuery; signal?: AbortSignal | undefined; + toasts?: IToasts; + enabled?: boolean; // For disabling if FF is off } const defaultQuery: FindKnowledgeBaseEntriesRequestQuery = { @@ -50,6 +54,8 @@ export const useKnowledgeBaseEntries = ({ http, query = defaultQuery, signal, + toasts, + enabled = false, }: UseKnowledgeBaseEntriesParams) => useQuery( KNOWLEDGE_BASE_ENTRY_QUERY_KEY, @@ -64,8 +70,18 @@ export const useKnowledgeBaseEntries = ({ } ), { + enabled, keepPreviousData: true, initialData: { page: 1, perPage: 100, total: 0, data: [] }, + onError: (error: IHttpFetchError) => { + if (error.name !== 'AbortError') { + toasts?.addError(error, { + title: i18n.translate('xpack.elasticAssistant.knowledgeBase.fetchError', { + defaultMessage: 'Error fetching Knowledge Base entries', + }), + }); + } + }, } ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_update_knowledge_base_entries.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_update_knowledge_base_entries.tsx new file mode 100644 index 0000000000000..e717c6786cd25 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_update_knowledge_base_entries.tsx @@ -0,0 +1,94 @@ +/* + * 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 { useMutation } from '@tanstack/react-query'; +import type { HttpSetup, IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser'; +import type { IToasts } from '@kbn/core-notifications-browser'; +import { i18n } from '@kbn/i18n'; + +import { + API_VERSIONS, + ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_BULK_ACTION, + KnowledgeBaseEntryBulkCrudActionResponse, + PerformKnowledgeBaseEntryBulkActionRequestBody, +} from '@kbn/elastic-assistant-common'; +import { useInvalidateKnowledgeBaseEntries } from './use_knowledge_base_entries'; + +const BULK_UPDATE_KNOWLEDGE_BASE_ENTRY_MUTATION_KEY = [ + ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_BULK_ACTION, + API_VERSIONS.internal.v1, + 'UPDATE', +]; + +export interface UseUpdateKnowledgeBaseEntriesParams { + http: HttpSetup; + signal?: AbortSignal; + toasts?: IToasts; +} + +/** + * Hook for updating Knowledge Base Entries by id or query. + * + * @param {Object} options - The options object + * @param {HttpSetup} options.http - HttpSetup + * @param {AbortSignal} [options.signal] - AbortSignal + * @param {IToasts} [options.toasts] - IToasts + * + * @returns mutation hook for updating Knowledge Base Entries + * + */ +export const useUpdateKnowledgeBaseEntries = ({ + http, + signal, + toasts, +}: UseUpdateKnowledgeBaseEntriesParams) => { + const invalidateKnowledgeBaseEntries = useInvalidateKnowledgeBaseEntries(); + + return useMutation( + BULK_UPDATE_KNOWLEDGE_BASE_ENTRY_MUTATION_KEY, + (updatedEntries: PerformKnowledgeBaseEntryBulkActionRequestBody['update']) => { + const body: PerformKnowledgeBaseEntryBulkActionRequestBody = { + update: updatedEntries, + }; + return http.post( + ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_BULK_ACTION, + { + body: JSON.stringify(body), + version: API_VERSIONS.internal.v1, + signal, + } + ); + }, + { + onError: (error: IHttpFetchError) => { + if (error.name !== 'AbortError') { + toasts?.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { + title: i18n.translate( + 'xpack.elasticAssistant.knowledgeBase.entries.updateErrorTitle', + { + defaultMessage: 'Error updating Knowledge Base Entries', + } + ), + } + ); + } + }, + onSettled: () => { + invalidateKnowledgeBaseEntries(); + }, + onSuccess: () => { + toasts?.addSuccess({ + title: i18n.translate('xpack.elasticAssistant.knowledgeBase.entries.updateSuccessTitle', { + defaultMessage: 'Knowledge Base Entries updated successfully', + }), + }); + }, + } + ); +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_knowledge_base_status.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_knowledge_base_status.tsx index ba6317329d350..6749b260fbfb9 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_knowledge_base_status.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_knowledge_base_status.tsx @@ -78,3 +78,20 @@ export const useInvalidateKnowledgeBaseStatus = () => { }); }, [queryClient]); }; + +/** + * Helper for determining if Knowledge Base setup is complete. + * + * Note: Consider moving to API + * + * @param kbStatus ReadKnowledgeBaseResponse + */ +export const isKnowledgeBaseSetup = (kbStatus: ReadKnowledgeBaseResponse | undefined): boolean => { + return ( + (kbStatus?.elser_exists && + kbStatus?.esql_exists && + kbStatus?.index_exists && + kbStatus?.pipeline_exists) ?? + false + ); +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_setup_knowledge_base.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_setup_knowledge_base.test.tsx index a252700ba744f..c08f6f93617fc 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_setup_knowledge_base.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_setup_knowledge_base.test.tsx @@ -20,6 +20,7 @@ jest.mock('./api', () => { }; }); jest.mock('./use_knowledge_base_status'); +jest.mock('./entries/use_knowledge_base_entries'); jest.mock('@tanstack/react-query', () => ({ useMutation: jest.fn().mockImplementation(async (queryKey, fn, opts) => { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_setup_knowledge_base.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_setup_knowledge_base.tsx index c27c97976e989..88a0e49ba724b 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_setup_knowledge_base.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_setup_knowledge_base.tsx @@ -11,6 +11,7 @@ import type { IToasts } from '@kbn/core-notifications-browser'; import { i18n } from '@kbn/i18n'; import { postKnowledgeBase } from './api'; import { useInvalidateKnowledgeBaseStatus } from './use_knowledge_base_status'; +import { useInvalidateKnowledgeBaseEntries } from './entries/use_knowledge_base_entries'; const SETUP_KNOWLEDGE_BASE_MUTATION_KEY = ['elastic-assistant', 'post-knowledge-base']; @@ -31,6 +32,7 @@ export interface UseSetupKnowledgeBaseParams { */ export const useSetupKnowledgeBase = ({ http, toasts }: UseSetupKnowledgeBaseParams) => { const invalidateKnowledgeBaseStatus = useInvalidateKnowledgeBaseStatus(); + const invalidateKnowledgeBaseEntries = useInvalidateKnowledgeBaseEntries(); return useMutation( SETUP_KNOWLEDGE_BASE_MUTATION_KEY, @@ -53,6 +55,7 @@ export const useSetupKnowledgeBase = ({ http, toasts }: UseSetupKnowledgeBasePar }, onSettled: () => { invalidateKnowledgeBaseStatus(); + invalidateKnowledgeBaseEntries(); }, } ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/badges/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/badges/index.tsx index 3a93da1e6f72a..0a44664bd5d34 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/badges/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/badges/index.tsx @@ -8,16 +8,19 @@ import { EuiBadge } from '@elastic/eui'; import React from 'react'; -export const BadgesColumn: React.FC<{ items: string[] | null | undefined; prefix: string }> = - React.memo(({ items, prefix }) => - items && items.length > 0 ? ( -
- {items.map((c, idx) => ( - - {c} - - ))} -
- ) : null - ); +export const BadgesColumn: React.FC<{ + items: string[] | null | undefined; + prefix: string; + color?: string; +}> = React.memo(({ items, prefix, color = 'hollow' }) => + items && items.length > 0 ? ( +
+ {items.map((c, idx) => ( + + {c} + + ))} +
+ ) : null +); BadgesColumn.displayName = 'BadgesColumn'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/flyout/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/flyout/index.tsx index f36591e5dbb0e..ac0109f31b9b7 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/flyout/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/flyout/index.tsx @@ -22,7 +22,7 @@ import * as i18n from './translations'; interface Props { children: React.ReactNode; - title: string; + title?: string; flyoutVisible: boolean; onClose: () => void; onSaveCancelled: () => void; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/inline_actions/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/inline_actions/index.tsx index 6e955dd554d39..f89ad5912a60a 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/inline_actions/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/inline_actions/index.tsx @@ -10,51 +10,60 @@ import { useCallback } from 'react'; import * as i18n from './translations'; interface Props { - disabled?: boolean; + isEditEnabled?: (rowItem: T) => boolean; + isDeleteEnabled?: (rowItem: T) => boolean; onDelete?: (rowItem: T) => void; onEdit?: (rowItem: T) => void; } export const useInlineActions = () => { - const getInlineActions = useCallback(({ disabled = false, onDelete, onEdit }: Props) => { - const handleEdit = (rowItem: T) => { - onEdit?.(rowItem); - }; + const getInlineActions = useCallback( + ({ + isEditEnabled = () => false, + isDeleteEnabled = () => false, + onDelete, + onEdit, + }: Props) => { + const handleEdit = (rowItem: T) => { + onEdit?.(rowItem); + }; - const handleDelete = (rowItem: T) => { - onDelete?.(rowItem); - }; + const handleDelete = (rowItem: T) => { + onDelete?.(rowItem); + }; - const actions: EuiTableActionsColumnType = { - name: i18n.ACTIONS_BUTTON, - actions: [ - { - name: i18n.EDIT_BUTTON, - description: i18n.EDIT_BUTTON, - icon: 'pencil', - type: 'icon', - onClick: (rowItem: T) => { - handleEdit(rowItem); + const actions: EuiTableActionsColumnType = { + name: i18n.ACTIONS_BUTTON, + actions: [ + { + name: i18n.EDIT_BUTTON, + description: i18n.EDIT_BUTTON, + icon: 'pencil', + type: 'icon', + onClick: (rowItem: T) => { + handleEdit(rowItem); + }, + enabled: isEditEnabled, + available: () => onEdit != null, }, - enabled: () => !disabled, - available: () => onEdit != null, - }, - { - name: i18n.DELETE_BUTTON, - description: i18n.DELETE_BUTTON, - icon: 'trash', - type: 'icon', - onClick: (rowItem: T) => { - handleDelete(rowItem); + { + name: i18n.DELETE_BUTTON, + description: i18n.DELETE_BUTTON, + icon: 'trash', + type: 'icon', + onClick: (rowItem: T) => { + handleDelete(rowItem); + }, + enabled: isDeleteEnabled, + available: () => onDelete != null, + color: 'danger', }, - enabled: ({ isDefault }: { isDefault?: boolean }) => !isDefault && !disabled, - available: () => onDelete != null, - color: 'danger', - }, - ], - }; - return actions; - }, []); + ], + }; + return actions; + }, + [] + ); return getInlineActions; }; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/index.tsx index f8818f5faab25..368b7d3d2c8b6 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/index.tsx @@ -260,6 +260,8 @@ const ConversationSettingsManagementComponent: React.FC = ({ const columns = useMemo( () => getColumns({ + isDeleteEnabled: (rowItem: ConversationTableItem) => rowItem.isDefault !== true, + isEditEnabled: () => true, onDeleteActionClicked, onEditActionClicked, }), diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/use_conversations_table.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/use_conversations_table.test.tsx index 1c293cf061c29..797bde3466223 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/use_conversations_table.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/use_conversations_table.test.tsx @@ -34,6 +34,8 @@ describe('useConversationsTable', () => { it('should return columns', () => { const { result } = renderHook(() => useConversationsTable()); const columns = result.current.getColumns({ + isDeleteEnabled: jest.fn(), + isEditEnabled: jest.fn(), onDeleteActionClicked: jest.fn(), onEditActionClicked: jest.fn(), }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/use_conversations_table.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/use_conversations_table.tsx index e9c1cead27d66..001bdfd559003 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/use_conversations_table.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/use_conversations_table.tsx @@ -38,9 +38,13 @@ export const useConversationsTable = () => { const getActions = useInlineActions(); const getColumns = useCallback( ({ + isDeleteEnabled, + isEditEnabled, onDeleteActionClicked, onEditActionClicked, }: { + isDeleteEnabled: (conversation: ConversationTableItem) => boolean; + isEditEnabled: (conversation: ConversationTableItem) => boolean; onDeleteActionClicked: (conversation: ConversationTableItem) => void; onEditActionClicked: (conversation: ConversationTableItem) => void; }): Array> => { @@ -91,6 +95,8 @@ export const useConversationsTable = () => { width: '120px', align: 'center', ...getActions({ + isDeleteEnabled, + isEditEnabled, onDelete: onDeleteActionClicked, onEdit: onEditActionClicked, }), diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx index 43f637b1769f3..c52d94138b839 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx @@ -90,9 +90,12 @@ const AssistantComponent: React.FC = ({ getLastConversationId, http, promptContexts, + setCurrentUserAvatar, setLastConversationId, } = useAssistantContext(); + setCurrentUserAvatar(currentUserAvatar); + const [selectedPromptContexts, setSelectedPromptContexts] = useState< Record >({}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/index.tsx index 69ec81547912e..ac4488a50b79b 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/index.tsx @@ -204,7 +204,13 @@ const SystemPromptSettingsManagementComponent = ({ connectors, defaultConnector const columns = useMemo( () => - getColumns({ isActionsDisabled: isTableLoading, onEditActionClicked, onDeleteActionClicked }), + getColumns({ + isActionsDisabled: isTableLoading, + onEditActionClicked, + onDeleteActionClicked, + isDeleteEnabled: (prompt: PromptResponse) => prompt.isDefault !== true, + isEditEnabled: () => true, + }), [getColumns, isTableLoading, onEditActionClicked, onDeleteActionClicked] ); const systemPromptListItems = useMemo( diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/use_system_prompt_table.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/use_system_prompt_table.test.tsx index e677001e2d38b..220e150ec8647 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/use_system_prompt_table.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/use_system_prompt_table.test.tsx @@ -66,6 +66,8 @@ describe('useSystemPromptTable', () => { const onDeleteActionClicked = jest.fn(); const columns = result.current.getColumns({ isActionsDisabled: false, + isDeleteEnabled: jest.fn(), + isEditEnabled: jest.fn(), onEditActionClicked, onDeleteActionClicked, }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/use_system_prompt_table.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/use_system_prompt_table.tsx index d2b63e9abdbd5..26eb003d86bf5 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/use_system_prompt_table.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/use_system_prompt_table.tsx @@ -25,10 +25,14 @@ export const useSystemPromptTable = () => { const getColumns = useCallback( ({ isActionsDisabled, + isDeleteEnabled, + isEditEnabled, onEditActionClicked, onDeleteActionClicked, }: { isActionsDisabled: boolean; + isDeleteEnabled: (conversation: SystemPromptTableItem) => boolean; + isEditEnabled: (conversation: SystemPromptTableItem) => boolean; onEditActionClicked: (prompt: SystemPromptTableItem) => void; onDeleteActionClicked: (prompt: SystemPromptTableItem) => void; }): Array> => [ @@ -79,6 +83,8 @@ export const useSystemPromptTable = () => { align: 'center', width: '120px', ...getActions({ + isDeleteEnabled, + isEditEnabled, onDelete: onDeleteActionClicked, onEdit: onEditActionClicked, }), diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/index.tsx index 0f7b6df8d1893..16389b331175d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/index.tsx @@ -148,6 +148,8 @@ const QuickPromptSettingsManagementComponent = () => { basePromptContexts, onEditActionClicked, onDeleteActionClicked, + isDeleteEnabled: (prompt: PromptResponse) => prompt.isDefault !== true, + isEditEnabled: () => true, }); const { onTableChange, pagination, sorting } = useSessionPagination({ diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/use_quick_prompt_table.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/use_quick_prompt_table.test.tsx index 6732b93a5b5d9..ae91a8bb971c6 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/use_quick_prompt_table.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/use_quick_prompt_table.test.tsx @@ -12,6 +12,7 @@ import { MOCK_QUICK_PROMPTS } from '../../../mock/quick_prompt'; import { mockPromptContexts } from '../../../mock/prompt_context'; import { PromptResponse } from '@kbn/elastic-assistant-common'; +const mockIsEditEnabled = jest.fn(); const mockOnEditActionClicked = jest.fn(); const mockOnDeleteActionClicked = jest.fn(); @@ -20,6 +21,8 @@ describe('useQuickPromptTable', () => { const props = { isActionsDisabled: false, basePromptContexts: mockPromptContexts, + isDeleteEnabled: (prompt: PromptResponse) => prompt.isDefault !== true, + isEditEnabled: mockIsEditEnabled, onEditActionClicked: mockOnEditActionClicked, onDeleteActionClicked: mockOnDeleteActionClicked, }; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/use_quick_prompt_table.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/use_quick_prompt_table.tsx index 0f2c6a88bc3d7..e9de769e3f735 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/use_quick_prompt_table.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/use_quick_prompt_table.tsx @@ -19,11 +19,15 @@ export const useQuickPromptTable = () => { const getColumns = useCallback( ({ isActionsDisabled, + isDeleteEnabled, + isEditEnabled, basePromptContexts, onEditActionClicked, onDeleteActionClicked, }: { isActionsDisabled: boolean; + isDeleteEnabled: (prompt: PromptResponse) => boolean; + isEditEnabled: (prompt: PromptResponse) => boolean; basePromptContexts: PromptContextTemplate[]; onEditActionClicked: (prompt: PromptResponse, color?: string) => void; onDeleteActionClicked: (prompt: PromptResponse) => void; @@ -74,6 +78,8 @@ export const useQuickPromptTable = () => { align: 'center', width: '120px', ...getActions({ + isDeleteEnabled, + isEditEnabled, onDelete: onDeleteActionClicked, onEdit: onEditActionClicked, }), diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx index 4217a3d9dc2b8..75516eaf907b2 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx @@ -96,6 +96,7 @@ export interface UseAssistantContext { docLinks: Omit; basePath: string; baseConversations: Record; + currentUserAvatar?: UserAvatar; getComments: GetAssistantMessages; http: HttpSetup; knowledgeBase: KnowledgeBaseConfig; @@ -106,6 +107,7 @@ export interface UseAssistantContext { registerPromptContext: RegisterPromptContext; selectedSettingsTab: SettingsTabs | null; setAssistantStreamingEnabled: React.Dispatch>; + setCurrentUserAvatar: React.Dispatch>; setKnowledgeBase: React.Dispatch>; setLastConversationId: React.Dispatch>; setSelectedSettingsTab: React.Dispatch>; @@ -218,6 +220,11 @@ export const AssistantProvider: React.FC = ({ */ const [showAssistantOverlay, setShowAssistantOverlay] = useState(() => {}); + /** + * Current User Avatar + */ + const [currentUserAvatar, setCurrentUserAvatar] = useState(); + /** * Settings State */ @@ -250,6 +257,7 @@ export const AssistantProvider: React.FC = ({ augmentMessageCodeBlocks, basePath, basePromptContexts, + currentUserAvatar, docLinks, getComments, http, @@ -263,6 +271,7 @@ export const AssistantProvider: React.FC = ({ assistantStreamingEnabled: localStorageStreaming ?? true, setAssistantStreamingEnabled: setLocalStorageStreaming, setKnowledgeBase: setLocalStorageKnowledgeBase, + setCurrentUserAvatar, setSelectedSettingsTab, setShowAssistantOverlay, setTraceOptions: setSessionStorageTraceOptions, @@ -286,6 +295,7 @@ export const AssistantProvider: React.FC = ({ augmentMessageCodeBlocks, basePath, basePromptContexts, + currentUserAvatar, docLinks, getComments, http, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx new file mode 100644 index 0000000000000..152f0a91a7d04 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiRange, useGeneratedHtmlId } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React from 'react'; +import { + MAX_LATEST_ALERTS, + MIN_LATEST_ALERTS, + TICK_INTERVAL, +} from '../alerts/settings/alerts_settings'; +import { KnowledgeBaseConfig } from '../assistant/types'; +import { ALERTS_RANGE } from './translations'; + +interface Props { + knowledgeBase: KnowledgeBaseConfig; + setUpdatedKnowledgeBaseSettings: React.Dispatch>; + compressed?: boolean; +} + +const MAX_ALERTS_RANGE_WIDTH = 649; // px + +export const AlertsRange: React.FC = React.memo( + ({ knowledgeBase, setUpdatedKnowledgeBaseSettings, compressed = true }) => { + const inputRangeSliderId = useGeneratedHtmlId({ prefix: 'inputRangeSlider' }); + + return ( + + setUpdatedKnowledgeBaseSettings({ + ...knowledgeBase, + latestAlerts: Number(e.currentTarget.value), + }) + } + showTicks + step={TICK_INTERVAL} + value={knowledgeBase.latestAlerts} + css={css` + max-inline-size: ${MAX_ALERTS_RANGE_WIDTH}px; + & .euiRangeTrack { + margin-inline-start: 0; + margin-inline-end: 0; + } + `} + /> + ); + } +); + +AlertsRange.displayName = 'AlertsRange'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/const.ts b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/const.ts new file mode 100644 index 0000000000000..3cfd0cf3b4205 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/const.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export const ESQL_RESOURCE = 'esql'; +export const KNOWLEDGE_BASE_INDEX_PATTERN_OLD = '.kibana-elastic-ai-assistant-kb'; +export const KNOWLEDGE_BASE_INDEX_PATTERN = '.kibana-elastic-ai-assistant-knowledge-base-(SPACE)'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management.tsx deleted file mode 100644 index 0f0a90b05b0de..0000000000000 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management.tsx +++ /dev/null @@ -1,272 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useCallback, useMemo, useState } from 'react'; -import { - EuiFormRow, - EuiText, - EuiHorizontalRule, - EuiSpacer, - EuiLink, - EuiFlexGroup, - EuiFlexItem, - EuiHealth, - EuiButtonEmpty, - EuiPanel, - EuiToolTip, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { css } from '@emotion/react'; - -import { AlertsSettings } from '../alerts/settings/alerts_settings'; -import { useAssistantContext } from '../assistant_context'; -import * as i18n from './translations'; -import { useKnowledgeBaseStatus } from '../assistant/api/knowledge_base/use_knowledge_base_status'; -import { useSetupKnowledgeBase } from '../assistant/api/knowledge_base/use_setup_knowledge_base'; -import { - useSettingsUpdater, - DEFAULT_CONVERSATIONS, - DEFAULT_PROMPTS, -} from '../assistant/settings/use_settings_updater/use_settings_updater'; -import { AssistantSettingsBottomBar } from '../assistant/settings/assistant_settings_bottom_bar'; -import { SETTINGS_UPDATED_TOAST_TITLE } from '../assistant/settings/translations'; -import { SETUP_KNOWLEDGE_BASE_BUTTON_TOOLTIP } from './translations'; - -const ESQL_RESOURCE = 'esql'; -const KNOWLEDGE_BASE_INDEX_PATTERN = '.kibana-elastic-ai-assistant-knowledge-base-(SPACE)'; - -/** - * Knowledge Base Settings -- set up the Knowledge Base and configure RAG on alerts - */ -export const KnowledgeBaseSettingsManagement: React.FC = React.memo(() => { - const { http, toasts } = useAssistantContext(); - const [hasPendingChanges, setHasPendingChanges] = useState(false); - - const { knowledgeBase, setUpdatedKnowledgeBaseSettings, resetSettings, saveSettings } = - useSettingsUpdater( - DEFAULT_CONVERSATIONS, // Knowledge Base settings do not require conversations - DEFAULT_PROMPTS, // Knowledge Base settings do not require prompts - false, // Knowledge Base settings do not require prompts - false // Knowledge Base settings do not require conversations - ); - - const handleSave = useCallback( - async (param?: { callback?: () => void }) => { - await saveSettings(); - toasts?.addSuccess({ - iconType: 'check', - title: SETTINGS_UPDATED_TOAST_TITLE, - }); - setHasPendingChanges(false); - param?.callback?.(); - }, - [saveSettings, toasts] - ); - - const handleUpdateKnowledgeBaseSettings = useCallback( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (updatedKnowledgebase: any) => { - setHasPendingChanges(true); - setUpdatedKnowledgeBaseSettings(updatedKnowledgebase); - }, - [setUpdatedKnowledgeBaseSettings] - ); - - const onCancelClick = useCallback(() => { - resetSettings(); - setHasPendingChanges(false); - }, [resetSettings]); - - const onSaveButtonClicked = useCallback(() => { - handleSave(); - }, [handleSave]); - - const { - data: kbStatus, - isLoading, - isFetching, - } = useKnowledgeBaseStatus({ http, resource: ESQL_RESOURCE }); - const { mutate: setupKB, isLoading: isSettingUpKB } = useSetupKnowledgeBase({ http, toasts }); - - // Resource enabled state - const isElserEnabled = kbStatus?.elser_exists ?? false; - const isESQLEnabled = kbStatus?.esql_exists ?? false; - const isKnowledgeBaseSetup = - (isElserEnabled && isESQLEnabled && kbStatus?.index_exists && kbStatus?.pipeline_exists) ?? - false; - const isSetupInProgress = kbStatus?.is_setup_in_progress ?? false; - const isSetupAvailable = kbStatus?.is_setup_available ?? false; - - // Resource availability state - const isLoadingKb = isLoading || isFetching || isSettingUpKB || isSetupInProgress; - - // Calculated health state for EuiHealth component - const elserHealth = isElserEnabled ? 'success' : 'subdued'; - const knowledgeBaseHealth = isKnowledgeBaseSetup ? 'success' : 'subdued'; - const esqlHealth = isESQLEnabled ? 'success' : 'subdued'; - - ////////////////////////////////////////////////////////////////////////////////////////// - // Main `Knowledge Base` setup button - const onSetupKnowledgeBaseButtonClick = useCallback(() => { - setupKB(ESQL_RESOURCE); - }, [setupKB]); - - const toolTipContent = !isSetupAvailable ? SETUP_KNOWLEDGE_BASE_BUTTON_TOOLTIP : undefined; - - const setupKnowledgeBaseButton = useMemo(() => { - return isKnowledgeBaseSetup ? ( - <> - ) : ( - - - {i18n.SETUP_KNOWLEDGE_BASE_BUTTON} - - - ); - }, [ - isKnowledgeBaseSetup, - isLoadingKb, - isSetupAvailable, - onSetupKnowledgeBaseButtonClick, - toolTipContent, - ]); - - ////////////////////////////////////////////////////////////////////////////////////////// - // Knowledge Base Resource - const knowledgeBaseDescription = useMemo(() => { - return isKnowledgeBaseSetup ? ( - - {i18n.KNOWLEDGE_BASE_DESCRIPTION_INSTALLED(KNOWLEDGE_BASE_INDEX_PATTERN)} - - ) : ( - {i18n.KNOWLEDGE_BASE_DESCRIPTION} - ); - }, [isKnowledgeBaseSetup]); - - ////////////////////////////////////////////////////////////////////////////////////////// - // ESQL Resource - - const esqlDescription = useMemo(() => { - return isESQLEnabled ? ( - {i18n.ESQL_DESCRIPTION_INSTALLED} - ) : ( - {i18n.ESQL_DESCRIPTION} - ); - }, [isESQLEnabled]); - - return ( - - - - {i18n.KNOWLEDGE_BASE_DOCUMENTATION} - - ), - }} - /> - - - - - {setupKnowledgeBaseButton} - - - - - -
- {i18n.KNOWLEDGE_BASE_ELSER_LABEL} - - - -
-
- -
- {i18n.KNOWLEDGE_BASE_LABEL} - - {knowledgeBaseDescription} - -
-
- - - {i18n.ESQL_LABEL} - - {esqlDescription} - - - -
- - - - - - -
- ); -}); - -KnowledgeBaseSettingsManagement.displayName = 'KnowledgeBaseSettingsManagement'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/add_entry_button.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/add_entry_button.tsx new file mode 100644 index 0000000000000..5b3ec4562d086 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/add_entry_button.tsx @@ -0,0 +1,85 @@ +/* + * 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 { + EuiButton, + EuiIcon, + EuiPopover, + EuiContextMenuItem, + EuiContextMenuPanel, +} from '@elastic/eui'; +import React, { useCallback, useState } from 'react'; +import * as i18n from './translations'; + +interface Props { + isDocumentAvailable?: boolean; + isIndexAvailable?: boolean; + onDocumentClicked?: () => void; + onIndexClicked?: () => void; +} + +export const AddEntryButton: React.FC = React.memo( + ({ + isDocumentAvailable = true, + isIndexAvailable = true, + onDocumentClicked, + onIndexClicked, + }: Props) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + const handleIndexClicked = useCallback(() => { + closePopover(); + onIndexClicked?.(); + }, [closePopover, onIndexClicked]); + + const handleDocumentClicked = useCallback(() => { + closePopover(); + onDocumentClicked?.(); + }, [closePopover, onDocumentClicked]); + + const onButtonClick = useCallback(() => setIsPopoverOpen((prevState) => !prevState), []); + + const items = [ + + {i18n.INDEX} + , + + {i18n.DOCUMENT} + , + ]; + return onIndexClicked || onDocumentClicked ? ( + + + {i18n.NEW} + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + anchorPosition="downLeft" + > + + + ) : null; + } +); + +AddEntryButton.displayName = 'AddEntryButton'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/document_entry_editor.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/document_entry_editor.tsx new file mode 100644 index 0000000000000..850c8b29d063a --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/document_entry_editor.tsx @@ -0,0 +1,136 @@ +/* + * 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 { + EuiCheckbox, + EuiFieldText, + EuiForm, + EuiFormRow, + EuiMarkdownEditor, + EuiSuperSelect, + EuiIcon, + EuiText, +} from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { DocumentEntry } from '@kbn/elastic-assistant-common'; +import * as i18n from './translations'; + +interface Props { + entry?: DocumentEntry; + setEntry: React.Dispatch>>; +} + +export const DocumentEntryEditor: React.FC = React.memo(({ entry, setEntry }) => { + // Name + const setName = useCallback( + (e) => setEntry((prevEntry) => ({ ...prevEntry, name: e.target.value })), + [setEntry] + ); + + // Sharing + const setSharingOptions = useCallback( + (value) => + setEntry((prevEntry) => ({ + ...prevEntry, + users: value === i18n.SHARING_GLOBAL_OPTION_LABEL ? [] : undefined, + })), + [setEntry] + ); + // TODO: KB-RBAC Disable global option if no RBAC + const sharingOptions = [ + { + value: i18n.SHARING_PRIVATE_OPTION_LABEL, + inputDisplay: ( + + + {i18n.SHARING_PRIVATE_OPTION_LABEL} + + ), + }, + { + value: i18n.SHARING_GLOBAL_OPTION_LABEL, + inputDisplay: ( + + + {i18n.SHARING_GLOBAL_OPTION_LABEL} + + ), + }, + ]; + const selectedSharingOption = + entry?.users?.length === 0 ? sharingOptions[1].value : sharingOptions[0].value; + + // Text / markdown + const setMarkdownValue = useCallback( + (value: string) => { + setEntry((prevEntry) => ({ ...prevEntry, text: value })); + }, + [setEntry] + ); + + // Required checkbox + const onRequiredKnowledgeChanged = useCallback( + (e: React.ChangeEvent) => { + setEntry((prevEntry) => ({ ...prevEntry, required: e.target.checked })); + }, + [setEntry] + ); + + return ( + + + + + + + + + + + + + + + ); +}); + +DocumentEntryEditor.displayName = 'DocumentEntryEditor'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/helpers.ts b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/helpers.ts new file mode 100644 index 0000000000000..3d522ab975f3c --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/helpers.ts @@ -0,0 +1,36 @@ +/* + * 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 { + DocumentEntryType, + KnowledgeBaseEntryCreateProps, + KnowledgeBaseEntryResponse, +} from '@kbn/elastic-assistant-common'; +import { z } from '@kbn/zod'; + +export const isEsqlSystemEntry = ( + entry: KnowledgeBaseEntryResponse +): entry is KnowledgeBaseEntryResponse & { + type: DocumentEntryType; + kbResource: 'esql'; +} => { + return entry.type === DocumentEntryType.value && entry.kbResource === 'esql'; +}; + +export const isKnowledgeBaseEntryCreateProps = ( + entry: unknown +): entry is z.infer => { + const result = KnowledgeBaseEntryCreateProps.safeParse(entry); + return result.success; +}; + +export const isKnowledgeBaseEntryResponse = ( + entry: unknown +): entry is z.infer => { + const result = KnowledgeBaseEntryResponse.safeParse(entry); + return result.success; +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.tsx new file mode 100644 index 0000000000000..d970b76e98bd1 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.tsx @@ -0,0 +1,300 @@ +/* + * 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 { + EuiInMemoryTable, + EuiLink, + EuiPanel, + EuiSearchBarProps, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + DocumentEntry, + DocumentEntryType, + IndexEntry, + IndexEntryType, + KnowledgeBaseEntryCreateProps, + KnowledgeBaseEntryResponse, +} from '@kbn/elastic-assistant-common'; +import { AlertsSettingsManagement } from '../../alerts/settings/alerts_settings_management'; +import { useKnowledgeBaseEntries } from '../../assistant/api/knowledge_base/entries/use_knowledge_base_entries'; +import { useAssistantContext } from '../../assistant_context'; +import { useKnowledgeBaseTable } from './use_knowledge_base_table'; +import { AssistantSettingsBottomBar } from '../../assistant/settings/assistant_settings_bottom_bar'; +import { + useSettingsUpdater, + DEFAULT_CONVERSATIONS, + DEFAULT_PROMPTS, +} from '../../assistant/settings/use_settings_updater/use_settings_updater'; +import { AddEntryButton } from './add_entry_button'; +import * as i18n from './translations'; +import { Flyout } from '../../assistant/common/components/assistant_settings_management/flyout'; +import { useFlyoutModalVisibility } from '../../assistant/common/components/assistant_settings_management/flyout/use_flyout_modal_visibility'; +import { IndexEntryEditor } from './index_entry_editor'; +import { DocumentEntryEditor } from './document_entry_editor'; +import { KnowledgeBaseSettings } from '../knowledge_base_settings'; +import { SetupKnowledgeBaseButton } from '../setup_knowledge_base_button'; +import { useDeleteKnowledgeBaseEntries } from '../../assistant/api/knowledge_base/entries/use_delete_knowledge_base_entries'; +import { + isEsqlSystemEntry, + isKnowledgeBaseEntryCreateProps, + isKnowledgeBaseEntryResponse, +} from './helpers'; +import { useCreateKnowledgeBaseEntry } from '../../assistant/api/knowledge_base/entries/use_create_knowledge_base_entry'; +import { useUpdateKnowledgeBaseEntries } from '../../assistant/api/knowledge_base/entries/use_update_knowledge_base_entries'; +import { SETTINGS_UPDATED_TOAST_TITLE } from '../../assistant/settings/translations'; + +export const KnowledgeBaseSettingsManagement: React.FC = React.memo(() => { + const { + assistantFeatures: { assistantKnowledgeBaseByDefault: enableKnowledgeBaseByDefault }, + http, + toasts, + } = useAssistantContext(); + const [hasPendingChanges, setHasPendingChanges] = useState(false); + + // Only needed for legacy settings management + const { knowledgeBase, setUpdatedKnowledgeBaseSettings, resetSettings, saveSettings } = + useSettingsUpdater( + DEFAULT_CONVERSATIONS, // Knowledge Base settings do not require conversations + DEFAULT_PROMPTS, // Knowledge Base settings do not require prompts + false, // Knowledge Base settings do not require conversations + false // Knowledge Base settings do not require prompts + ); + + const handleUpdateKnowledgeBaseSettings = useCallback( + (updatedKnowledgeBase) => { + setHasPendingChanges(true); + setUpdatedKnowledgeBaseSettings(updatedKnowledgeBase); + }, + [setUpdatedKnowledgeBaseSettings] + ); + + const handleSave = useCallback( + async (param?: { callback?: () => void }) => { + await saveSettings(); + toasts?.addSuccess({ + iconType: 'check', + title: SETTINGS_UPDATED_TOAST_TITLE, + }); + setHasPendingChanges(false); + param?.callback?.(); + }, + [saveSettings, toasts] + ); + + const onCancelClick = useCallback(() => { + resetSettings(); + setHasPendingChanges(false); + }, [resetSettings]); + + const onSaveButtonClicked = useCallback(() => { + handleSave(); + }, [handleSave]); + + const { isFlyoutOpen: isFlyoutVisible, openFlyout, closeFlyout } = useFlyoutModalVisibility(); + + const [selectedEntry, setSelectedEntry] = + useState>(); + + // CRUD API accessors + const { mutate: createEntry, isLoading: isCreatingEntry } = useCreateKnowledgeBaseEntry({ + http, + toasts, + }); + const { mutate: updateEntries, isLoading: isUpdatingEntries } = useUpdateKnowledgeBaseEntries({ + http, + toasts, + }); + const { mutate: deleteEntry, isLoading: isDeletingEntries } = useDeleteKnowledgeBaseEntries({ + http, + toasts, + }); + const isModifyingEntry = isCreatingEntry || isUpdatingEntries || isDeletingEntries; + + // Flyout Save/Cancel Actions + const onSaveConfirmed = useCallback(() => { + if (isKnowledgeBaseEntryCreateProps(selectedEntry)) { + createEntry(selectedEntry); + closeFlyout(); + } else if (isKnowledgeBaseEntryResponse(selectedEntry)) { + updateEntries([selectedEntry]); + closeFlyout(); + } + }, [closeFlyout, selectedEntry, createEntry, updateEntries]); + + const onSaveCancelled = useCallback(() => { + setSelectedEntry(undefined); + closeFlyout(); + }, [closeFlyout]); + + const { data: entries } = useKnowledgeBaseEntries({ + http, + toasts, + enabled: enableKnowledgeBaseByDefault, + }); + const { getColumns } = useKnowledgeBaseTable(); + const columns = useMemo( + () => + getColumns({ + onEntryNameClicked: ({ id }: KnowledgeBaseEntryResponse) => { + const entry = entries.data.find((e) => e.id === id); + setSelectedEntry(entry); + openFlyout(); + }, + onSpaceNameClicked: ({ namespace }: KnowledgeBaseEntryResponse) => { + openFlyout(); + }, + isDeleteEnabled: (entry: KnowledgeBaseEntryResponse) => { + return !isEsqlSystemEntry(entry); + }, + onDeleteActionClicked: ({ id }: KnowledgeBaseEntryResponse) => { + deleteEntry({ ids: [id] }); + }, + isEditEnabled: (entry: KnowledgeBaseEntryResponse) => { + return !isEsqlSystemEntry(entry); + }, + onEditActionClicked: ({ id }: KnowledgeBaseEntryResponse) => { + const entry = entries.data.find((e) => e.id === id); + setSelectedEntry(entry); + openFlyout(); + }, + }), + [deleteEntry, entries.data, getColumns, openFlyout] + ); + + const onDocumentClicked = useCallback(() => { + setSelectedEntry({ type: DocumentEntryType.value, kbResource: 'user', source: 'user' }); + openFlyout(); + }, [openFlyout]); + + const onIndexClicked = useCallback(() => { + setSelectedEntry({ type: IndexEntryType.value }); + openFlyout(); + }, [openFlyout]); + + const search: EuiSearchBarProps = useMemo( + () => ({ + toolsRight: ( + + ), + box: { + incremental: true, + placeholder: i18n.SEARCH_PLACEHOLDER, + }, + filters: [], + }), + [onDocumentClicked, onIndexClicked] + ); + + const flyoutTitle = useMemo(() => { + // @ts-expect-error TS doesn't understand that selectedEntry is a partial + if (selectedEntry?.id != null) { + return selectedEntry.type === DocumentEntryType.value + ? i18n.EDIT_DOCUMENT_FLYOUT_TITLE + : i18n.EDIT_INDEX_FLYOUT_TITLE; + } + return selectedEntry?.type === DocumentEntryType.value + ? i18n.NEW_DOCUMENT_FLYOUT_TITLE + : i18n.NEW_INDEX_FLYOUT_TITLE; + }, [selectedEntry]); + + if (!enableKnowledgeBaseByDefault) { + return ( + <> + + + + ); + } + + const sorting = { + sort: { + field: 'name', + direction: 'desc' as const, + }, + }; + + return ( + <> + + + + {i18n.KNOWLEDGE_BASE_DOCUMENTATION} + + ), + }} + /> + + + + + + + + + + <> + {selectedEntry?.type === DocumentEntryType.value ? ( + >> + } + /> + ) : ( + >> + } + /> + )} + + + + ); +}); + +KnowledgeBaseSettingsManagement.displayName = 'KnowledgeBaseSettingsManagement'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.tsx new file mode 100644 index 0000000000000..97ae01ad2edd4 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.tsx @@ -0,0 +1,188 @@ +/* + * 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 { + EuiComboBox, + EuiFieldText, + EuiForm, + EuiFormRow, + EuiComboBoxOptionOption, + EuiText, + EuiIcon, + EuiSuperSelect, +} from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { IndexEntry } from '@kbn/elastic-assistant-common'; +import * as i18n from './translations'; + +interface Props { + entry?: IndexEntry; + setEntry: React.Dispatch>>; +} + +export const IndexEntryEditor: React.FC = React.memo(({ entry, setEntry }) => { + // Name + const setName = useCallback( + (e) => setEntry((prevEntry) => ({ ...prevEntry, name: e.target.value })), + [setEntry] + ); + + // Sharing + const setSharingOptions = useCallback( + (value) => + setEntry((prevEntry) => ({ + ...prevEntry, + users: value === i18n.SHARING_GLOBAL_OPTION_LABEL ? [] : undefined, + })), + [setEntry] + ); + // TODO: KB-RBAC Disable global option if no RBAC + const sharingOptions = [ + { + value: i18n.SHARING_PRIVATE_OPTION_LABEL, + inputDisplay: ( + + + {i18n.SHARING_PRIVATE_OPTION_LABEL} + + ), + }, + { + value: i18n.SHARING_GLOBAL_OPTION_LABEL, + inputDisplay: ( + + + {i18n.SHARING_GLOBAL_OPTION_LABEL} + + ), + }, + ]; + const selectedSharingOption = + entry?.users?.length === 0 ? sharingOptions[1].value : sharingOptions[0].value; + + // Index + const setIndex = useCallback( + (e: Array>) => + setEntry((prevEntry) => ({ ...prevEntry, index: e[0].value })), + [setEntry] + ); + + const onCreateOption = (searchValue: string) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + + const newOption: EuiComboBoxOptionOption = { + label: searchValue, + value: searchValue, + }; + + setIndex([newOption]); + }; + + // Field + const setField = useCallback( + (e) => setEntry((prevEntry) => ({ ...prevEntry, field: e.target.value })), + [setEntry] + ); + + // Description + const setDescription = useCallback( + (e) => setEntry((prevEntry) => ({ ...prevEntry, description: e.target.value })), + [setEntry] + ); + + // Query Description + const setQueryDescription = useCallback( + (e) => setEntry((prevEntry) => ({ ...prevEntry, queryDescription: e.target.value })), + [setEntry] + ); + + return ( + + + + + + + + + + + + + + + + + + + + + ); +}); +IndexEntryEditor.displayName = 'IndexEntryEditor'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/translations.ts new file mode 100644 index 0000000000000..ed4a3676975b8 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/translations.ts @@ -0,0 +1,291 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const NEW = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.newLabel', + { + defaultMessage: 'New', + } +); + +export const INDEX = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.indexLabel', + { + defaultMessage: 'Index', + } +); + +export const DOCUMENT = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.documentLabel', + { + defaultMessage: 'Document', + } +); + +export const COLUMN_NAME = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.columnNameLabel', + { + defaultMessage: 'Name', + } +); + +export const COLUMN_SHARING = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.columnSharingLabel', + { + defaultMessage: 'Sharing', + } +); + +export const COLUMN_AUTHOR = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.columnAuthorLabel', + { + defaultMessage: 'Author', + } +); + +export const COLUMN_ENTRIES = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.columnEntriesLabel', + { + defaultMessage: 'Entries', + } +); + +export const COLUMN_SPACE = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.columnSpaceLabel', + { + defaultMessage: 'Space', + } +); + +export const COLUMN_CREATED = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.columnCreatedLabel', + { + defaultMessage: 'Created', + } +); + +export const COLUMN_ACTIONS = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.columnActionsLabel', + { + defaultMessage: 'Actions', + } +); + +export const SEARCH_PLACEHOLDER = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.searchPlaceholder', + { + defaultMessage: 'Search for an entry', + } +); + +export const DEFAULT_FLYOUT_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.defaultFlyoutTitle', + { + defaultMessage: 'Knowledge Base', + } +); + +export const NEW_INDEX_FLYOUT_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.newIndexEntryFlyoutTitle', + { + defaultMessage: 'New index entry', + } +); + +export const EDIT_INDEX_FLYOUT_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.editIndexEntryFlyoutTitle', + { + defaultMessage: 'Edit index entry', + } +); + +export const NEW_DOCUMENT_FLYOUT_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.newDocumentEntryFlyoutTitle', + { + defaultMessage: 'New document entry', + } +); + +export const EDIT_DOCUMENT_FLYOUT_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.editDocumentEntryFlyoutTitle', + { + defaultMessage: 'Edit document entry', + } +); + +export const MANUAL = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.manualButtonLabel', + { + defaultMessage: 'Manual', + } +); + +export const CREATE_INDEX_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.createIndexTitle', + { + defaultMessage: 'New Index entry', + } +); + +export const NEW_ENTRY_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.newEntryTitle', + { + defaultMessage: 'New entry', + } +); + +export const DELETE_ENTRY_DEFAULT_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.deleteEntryDefaultTitle', + { + defaultMessage: 'Delete item', + } +); + +export const ENTRY_NAME_INPUT_LABEL = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryNameInputLabel', + { + defaultMessage: 'Name', + } +); + +export const ENTRY_SHARING_INPUT_LABEL = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entrySharingInputLabel', + { + defaultMessage: 'Sharing', + } +); + +export const ENTRY_NAME_INPUT_PLACEHOLDER = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryNameInputPlaceholder', + { + defaultMessage: 'Name your Knowledge Base entry', + } +); + +export const ENTRY_SPACE_INPUT_LABEL = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entrySpaceInputLabel', + { + defaultMessage: 'Space', + } +); + +export const ENTRY_SPACE_INPUT_PLACEHOLDER = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entrySpaceInputPlaceholder', + { + defaultMessage: 'Select', + } +); + +export const SHARING_PRIVATE_OPTION_LABEL = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.sharingPrivateOptionLabel', + { + defaultMessage: 'Private to you', + } +); + +export const SHARING_GLOBAL_OPTION_LABEL = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.sharingGlobalOptionLabel', + { + defaultMessage: 'Global to everyone in the Space', + } +); + +export const SHARING_HELP_TEXT = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.sharingHelpText', + { + defaultMessage: 'Set to global if you’d like other users in your Org to have access.', + } +); + +export const DELETE_ENTRY_CONFIRMATION_TITLE = (title: string) => + i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.deleteEntryTitle', + { + values: { title }, + defaultMessage: 'Delete "{title}"?', + } + ); + +export const ENTRY_MARKDOWN_INPUT_TEXT = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryMarkdownInputText', + { + defaultMessage: 'Markdown text', + } +); + +export const ENTRY_REQUIRED_KNOWLEDGE_HELP_TEXT = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryRequiredKnowledgeHelpText', + { + defaultMessage: + 'Check to indicate a Knowledge Base entry that’s included in every conversation', + } +); + +export const ENTRY_REQUIRED_KNOWLEDGE_CHECKBOX_LABEL = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryRequiredKnowledgeCheckboxLabel', + { + defaultMessage: 'Required knowledge', + } +); + +export const ENTRY_INDEX_NAME_INPUT_LABEL = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryIndexNameInputLabel', + { + defaultMessage: 'Index', + } +); + +export const ENTRY_FIELD_INPUT_LABEL = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryFieldInputLabel', + { + defaultMessage: 'Field', + } +); + +export const ENTRY_DESCRIPTION_INPUT_LABEL = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryDescriptionInputLabel', + { + defaultMessage: 'Description', + } +); + +export const ENTRY_QUERY_DESCRIPTION_INPUT_LABEL = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryQueryDescriptionInputLabel', + { + defaultMessage: 'Query Description', + } +); + +export const ENTRY_INPUT_PLACEHOLDER = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryInputPlaceholder', + { + defaultMessage: 'Input', + } +); + +export const KNOWLEDGE_BASE_DOCUMENTATION = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.knowledgeBaseDocumentation', + { + defaultMessage: 'Learn more', + } +); + +export const GLOBAL = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.knowledgeBaseGlobal', + { + defaultMessage: 'Global', + } +); + +export const PRIVATE = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.knowledgeBasePrivate', + { + defaultMessage: 'Private', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/use_knowledge_base_table.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/use_knowledge_base_table.tsx new file mode 100644 index 0000000000000..db46d55f4e5d6 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/use_knowledge_base_table.tsx @@ -0,0 +1,159 @@ +/* + * 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 { EuiAvatar, EuiBadge, EuiBasicTableColumn, EuiIcon, EuiLink, EuiText } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useCallback } from 'react'; +import { FormattedDate } from '@kbn/i18n-react'; +import { + DocumentEntryType, + IndexEntryType, + KnowledgeBaseEntryResponse, +} from '@kbn/elastic-assistant-common'; +import { useAssistantContext } from '../../..'; +import * as i18n from './translations'; +import { BadgesColumn } from '../../assistant/common/components/assistant_settings_management/badges'; +import { useInlineActions } from '../../assistant/common/components/assistant_settings_management/inline_actions'; +import { isEsqlSystemEntry } from './helpers'; + +export const useKnowledgeBaseTable = () => { + const { currentUserAvatar } = useAssistantContext(); + const getActions = useInlineActions(); + + const getIconForEntry = (entry: KnowledgeBaseEntryResponse): string => { + if (entry.type === DocumentEntryType.value) { + if (entry.kbResource === 'user') { + return 'userAvatar'; + } + if (entry.kbResource === 'esql') { + return 'logoElastic'; + } + return 'visText'; + } else if (entry.type === IndexEntryType.value) { + return 'index'; + } + return 'questionInCircle'; + }; + + const getColumns = useCallback( + ({ + isDeleteEnabled, + isEditEnabled, + onEntryNameClicked, + onDeleteActionClicked, + onEditActionClicked, + }): Array> => { + return [ + { + name: '', + render: (entry: KnowledgeBaseEntryResponse) => , + width: '24px', + }, + { + name: i18n.COLUMN_NAME, + render: ({ id, name }: KnowledgeBaseEntryResponse) => ( + onEntryNameClicked({ id })}>{name} + ), + sortable: ({ name }: KnowledgeBaseEntryResponse) => name, + width: '30%', + }, + { + name: i18n.COLUMN_SHARING, + sortable: ({ users }: KnowledgeBaseEntryResponse) => users.length, + render: ({ id, users }: KnowledgeBaseEntryResponse) => { + const sharingItem = users.length > 0 ? i18n.PRIVATE : i18n.GLOBAL; + const color = users.length > 0 ? 'hollow' : 'primary'; + return ; + }, + width: '100px', + }, + { + name: i18n.COLUMN_AUTHOR, + sortable: ({ users }: KnowledgeBaseEntryResponse) => users[0]?.name, + render: (entry: KnowledgeBaseEntryResponse) => { + // TODO: Look up user from `createdBy` id if privileges allow + const userName = entry.users?.[0]?.name ?? 'Unknown'; + const badgeItem = isEsqlSystemEntry(entry) ? 'Elastic' : userName; + const userImage = isEsqlSystemEntry(entry) ? ( + + ) : currentUserAvatar?.imageUrl != null ? ( + + ) : ( + + ); + return ( + <> + {userImage} + {badgeItem} + + ); + }, + }, + { + name: i18n.COLUMN_ENTRIES, + render: (entry: KnowledgeBaseEntryResponse) => { + return isEsqlSystemEntry(entry) + ? entry.text + : entry.type === DocumentEntryType.value + ? '1' + : '-'; + }, + }, + { + name: i18n.COLUMN_CREATED, + render: ({ createdAt }: { createdAt: string }) => ( + <> + {createdAt ? ( + + + + ) : null} + + ), + sortable: ({ createdAt }: KnowledgeBaseEntryResponse) => createdAt, + }, + { + ...getActions({ + isDeleteEnabled, + isEditEnabled, + onDelete: onDeleteActionClicked, + onEdit: onEditActionClicked, + }), + }, + ]; + }, + [currentUserAvatar, getActions] + ); + return { getColumns }; +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/setup_knowledge_base_button.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/setup_knowledge_base_button.tsx index 008597c1e8243..094d3164c531d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/setup_knowledge_base_button.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/setup_knowledge_base_button.tsx @@ -6,7 +6,7 @@ */ import React, { useCallback } from 'react'; -import { EuiButton, EuiToolTip } from '@elastic/eui'; +import { EuiButton, EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useAssistantContext } from '../..'; @@ -15,11 +15,15 @@ import { useKnowledgeBaseStatus } from '../assistant/api/knowledge_base/use_know export const ESQL_RESOURCE = 'esql'; +interface Props { + display?: 'mini'; +} + /** * Self-contained component that renders a button to set up the knowledge base. * */ -export const SetupKnowledgeBaseButton: React.FC = React.memo(() => { +export const SetupKnowledgeBaseButton: React.FC = React.memo(({ display }: Props) => { const { http, toasts } = useAssistantContext(); const { data: kbStatus } = useKnowledgeBaseStatus({ http, resource: ESQL_RESOURCE }); @@ -48,19 +52,35 @@ export const SetupKnowledgeBaseButton: React.FC = React.memo(() => { return ( - - {i18n.translate('xpack.elasticAssistant.knowledgeBase.installKnowledgeBaseButton', { - defaultMessage: 'Setup Knowledge Base', - })} - + {display === 'mini' ? ( + + {i18n.translate('xpack.elasticAssistant.knowledgeBase.installKnowledgeBaseButton', { + defaultMessage: 'Setup Knowledge Base', + })} + + ) : ( + + {i18n.translate('xpack.elasticAssistant.knowledgeBase.installKnowledgeBaseButton', { + defaultMessage: 'Setup Knowledge Base', + })} + + )} ); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/translations.ts index 23ea88ceaf2df..3666f94af3edb 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/translations.ts @@ -13,6 +13,14 @@ export const ALERTS_LABEL = i18n.translate( defaultMessage: 'Alerts', } ); + +export const SEND_ALERTS_LABEL = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettings.sendAlertsLabel', + { + defaultMessage: 'Send Alerts', + } +); + export const LATEST_AND_RISKIEST_OPEN_ALERTS = (alertsCount: number) => i18n.translate( 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettings.latestAndRiskiestOpenAlertsLabel', diff --git a/x-pack/packages/kbn-elastic-assistant/tsconfig.json b/x-pack/packages/kbn-elastic-assistant/tsconfig.json index f7cf4cd015920..ed2631b597bd6 100644 --- a/x-pack/packages/kbn-elastic-assistant/tsconfig.json +++ b/x-pack/packages/kbn-elastic-assistant/tsconfig.json @@ -29,5 +29,6 @@ "@kbn/ui-theme", "@kbn/core-doc-links-browser", "@kbn/core", + "@kbn/zod", ] } diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts index 6ae7ec9e4469b..b52e7db536a3d 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts @@ -21,7 +21,10 @@ import { } from './data_clients.mock'; import { AIAssistantConversationsDataClient } from '../ai_assistant_data_clients/conversations'; import { AIAssistantDataClient } from '../ai_assistant_data_clients'; -import { AIAssistantKnowledgeBaseDataClient } from '../ai_assistant_data_clients/knowledge_base'; +import { + AIAssistantKnowledgeBaseDataClient, + GetAIAssistantKnowledgeBaseDataClientParams, +} from '../ai_assistant_data_clients/knowledge_base'; import { defaultAssistantFeatures } from '@kbn/elastic-assistant-common'; import { AttackDiscoveryDataClient } from '../ai_assistant_data_clients/attack_discovery'; @@ -124,10 +127,12 @@ const createElasticAssistantRequestContextMock = ( () => clients.elasticAssistant.getAIAssistantKnowledgeBaseDataClient ) as unknown as jest.MockInstance< Promise, - [boolean | undefined], + [params: GetAIAssistantKnowledgeBaseDataClientParams], unknown > & - ((v2KnowledgeBaseEnabled?: boolean) => Promise), + (( + params: GetAIAssistantKnowledgeBaseDataClientParams + ) => Promise), getCurrentUser: jest.fn(), getServerBasePath: jest.fn(), getSpaceId: jest.fn(), diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.ts index 304a5a54a737f..7dac58ddecc9b 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.ts @@ -9,8 +9,10 @@ import { v4 as uuidv4 } from 'uuid'; import { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server'; import { + DocumentEntryCreateFields, KnowledgeBaseEntryCreateProps, KnowledgeBaseEntryResponse, + Metadata, } from '@kbn/elastic-assistant-common'; import { getKnowledgeBaseEntry } from './get_knowledge_base_entry'; import { CreateKnowledgeBaseEntrySchema } from './types'; @@ -21,7 +23,9 @@ export interface CreateKnowledgeBaseEntryParams { logger: Logger; spaceId: string; user: AuthenticatedUser; - knowledgeBaseEntry: KnowledgeBaseEntryCreateProps; + knowledgeBaseEntry: KnowledgeBaseEntryCreateProps | LegacyKnowledgeBaseEntryCreateProps; + global?: boolean; + isV2?: boolean; } export const createKnowledgeBaseEntry = async ({ @@ -31,9 +35,25 @@ export const createKnowledgeBaseEntry = async ({ user, knowledgeBaseEntry, logger, + global = false, + isV2 = false, }: CreateKnowledgeBaseEntryParams): Promise => { const createdAt = new Date().toISOString(); - const body = transformToCreateSchema(createdAt, spaceId, user, knowledgeBaseEntry); + const body = isV2 + ? transformToCreateSchema({ + createdAt, + spaceId, + user, + entry: knowledgeBaseEntry as unknown as KnowledgeBaseEntryCreateProps, + global, + }) + : transformToLegacyCreateSchema({ + createdAt, + spaceId, + user, + entry: knowledgeBaseEntry as unknown as TransformToLegacyCreateSchemaProps['entry'], + global, + }); try { const response = await esClient.create({ body, @@ -57,25 +77,38 @@ export const createKnowledgeBaseEntry = async ({ } }; -export const transformToCreateSchema = ( - createdAt: string, - spaceId: string, - user: AuthenticatedUser, - entry: KnowledgeBaseEntryCreateProps -): CreateKnowledgeBaseEntrySchema => { +interface TransformToCreateSchemaProps { + createdAt: string; + spaceId: string; + user: AuthenticatedUser; + entry: KnowledgeBaseEntryCreateProps; + global?: boolean; +} + +export const transformToCreateSchema = ({ + createdAt, + spaceId, + user, + entry, + global = false, +}: TransformToCreateSchemaProps): CreateKnowledgeBaseEntrySchema => { const base = { '@timestamp': createdAt, created_at: createdAt, created_by: user.profile_uid ?? 'unknown', updated_at: createdAt, updated_by: user.profile_uid ?? 'unknown', + name: entry.name, namespace: spaceId, - users: [ - { - id: user.profile_uid, - name: user.username, - }, - ], + type: entry.type, + users: global + ? [] + : [ + { + id: user.profile_uid, + name: user.username, + }, + ], }; if (entry.type === 'index') { @@ -93,5 +126,54 @@ export const transformToCreateSchema = ( output_fields: outputFields ?? undefined, }; } - return { ...base, ...entry, vector: undefined }; + return { + ...base, + kb_resource: entry.kbResource, + required: entry.required ?? false, + source: entry.source, + text: entry.text, + vector: undefined, + }; +}; + +export type LegacyKnowledgeBaseEntryCreateProps = Omit< + DocumentEntryCreateFields, + 'kbResource' | 'source' +> & { + metadata: Metadata; +}; + +interface TransformToLegacyCreateSchemaProps { + createdAt: string; + spaceId: string; + user: AuthenticatedUser; + entry: LegacyKnowledgeBaseEntryCreateProps; + global?: boolean; +} + +export const transformToLegacyCreateSchema = ({ + createdAt, + spaceId, + user, + entry, + global = false, +}: TransformToLegacyCreateSchemaProps): CreateKnowledgeBaseEntrySchema => { + return { + '@timestamp': createdAt, + created_at: createdAt, + created_by: user.profile_uid ?? 'unknown', + updated_at: createdAt, + updated_by: user.profile_uid ?? 'unknown', + namespace: spaceId, + users: global + ? [] + : [ + { + id: user.profile_uid, + name: user.username, + }, + ], + ...entry, + vector: undefined, + }; }; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/get_knowledge_base_entry.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/get_knowledge_base_entry.ts index 7820abfbcfe25..3195ff4577ddd 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/get_knowledge_base_entry.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/get_knowledge_base_entry.ts @@ -25,24 +25,47 @@ export const getKnowledgeBaseEntry = async ({ id, user, }: GetKnowledgeBaseEntryParams): Promise => { - const filterByUser = [ - { - nested: { - path: 'users', - query: { - bool: { - must: [ - { - match: user.profile_uid - ? { 'users.id': user.profile_uid } - : { 'users.name': user.username }, - }, - ], + const userFilter = { + should: [ + { + nested: { + path: 'users', + query: { + bool: { + minimum_should_match: 1, + should: [ + { + match: user.profile_uid + ? { 'users.id': user.profile_uid } + : { 'users.name': user.username }, + }, + ], + }, }, }, }, - }, - ]; + { + bool: { + must_not: [ + { + nested: { + path: 'users', + query: { + bool: { + filter: { + exists: { + field: 'users', + }, + }, + }, + }, + }, + }, + ], + }, + }, + ], + }; try { const response = await esClient.search({ query: { @@ -59,8 +82,9 @@ export const getKnowledgeBaseEntry = async ({ ], }, }, - ...filterByUser, ], + ...userFilter, + minimum_should_match: 1, }, }, _source: true, diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/helpers.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/helpers.ts index f0d87b8b14cff..8ff8de6cfb408 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/helpers.ts @@ -71,24 +71,47 @@ export const getKBVectorSearchQuery = ({ ] : []; - const userFilter = [ - { - nested: { - path: 'users', - query: { - bool: { - must: [ - { - match: user.profile_uid - ? { 'users.id': user.profile_uid } - : { 'users.name': user.username }, - }, - ], + const userFilter = { + should: [ + { + nested: { + path: 'users', + query: { + bool: { + minimum_should_match: 1, + should: [ + { + match: user.profile_uid + ? { 'users.id': user.profile_uid } + : { 'users.name': user.username }, + }, + ], + }, }, }, }, - }, - ]; + { + bool: { + must_not: [ + { + nested: { + path: 'users', + query: { + bool: { + filter: { + exists: { + field: 'users', + }, + }, + }, + }, + }, + }, + ], + }, + }, + ], + }; return { bool: { @@ -103,9 +126,10 @@ export const getKBVectorSearchQuery = ({ }, ...requiredFilter, ...resourceFilter, - ...userFilter, ], + ...userFilter, filter, + minimum_should_match: 1, }, }; }; @@ -137,7 +161,7 @@ export const getStructuredToolForIndexEntry = ({ }, {}); return new DynamicStructuredTool({ - name: indexEntry.name.replaceAll(' ', ''), // Tool names cannot contain spaces, further sanitization possibly needed + name: indexEntry.name.replace(/[^a-zA-Z0-9-]/g, ''), // // Tool names expects a string that matches the pattern '^[a-zA-Z0-9-]+$' description: indexEntry.description, schema: z.object({ query: z.string().describe(indexEntry.queryDescription), diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts index 38852dc8a91fc..932f90491a1c0 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts @@ -25,10 +25,14 @@ import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWith import { StructuredTool } from '@langchain/core/tools'; import { ElasticsearchClient } from '@kbn/core/server'; import { AIAssistantDataClient, AIAssistantDataClientParams } from '..'; -import { ElasticsearchStore } from '../../lib/langchain/elasticsearch_store/elasticsearch_store'; import { loadESQL } from '../../lib/langchain/content_loaders/esql_loader'; import { AssistantToolParams, GetElser } from '../../types'; -import { createKnowledgeBaseEntry, transformToCreateSchema } from './create_knowledge_base_entry'; +import { + createKnowledgeBaseEntry, + LegacyKnowledgeBaseEntryCreateProps, + transformToCreateSchema, + transformToLegacyCreateSchema, +} from './create_knowledge_base_entry'; import { EsDocumentEntry, EsIndexEntry, EsKnowledgeBaseEntrySchema } from './types'; import { transformESSearchToKnowledgeBaseEntry } from './transforms'; import { ESQL_DOCS_LOADED_QUERY } from '../../routes/knowledge_base/constants'; @@ -39,6 +43,15 @@ import { } from './helpers'; import { getKBUserFilter } from '../../routes/knowledge_base/entries/utils'; +/** + * Params for when creating KbDataClient in Request Context Factory. Useful if needing to modify + * configuration after initial plugin start + */ +export interface GetAIAssistantKnowledgeBaseDataClientParams { + modelIdOverride?: string; + v2KnowledgeBaseEnabled?: boolean; +} + interface KnowledgeBaseDataClientParams extends AIAssistantDataClientParams { ml: MlPluginSetup; getElserId: GetElser; @@ -182,17 +195,17 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { * See ml-team issue for providing 'dry run' flag to perform these checks: https://github.com/elastic/ml-team/issues/1208 * * @param options - * @param options.esStore ElasticsearchStore for loading ES|QL docs via LangChain loaders * @param options.soClient SavedObjectsClientContract for installing ELSER so that ML SO's are in sync + * @param options.installEsqlDocs Whether to install ESQL documents as part of setup (e.g. not needed in test env) * * @returns Promise */ public setupKnowledgeBase = async ({ - esStore, soClient, + installEsqlDocs = true, }: { - esStore: ElasticsearchStore; soClient: SavedObjectsClientContract; + installEsqlDocs?: boolean; }): Promise => { if (this.options.getIsKBSetupInProgress()) { this.options.logger.debug('Knowledge Base setup already in progress'); @@ -235,12 +248,14 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { } this.options.logger.debug(`Checking if Knowledge Base docs have been loaded...`); - const kbDocsLoaded = (await esStore.similaritySearch(ESQL_DOCS_LOADED_QUERY)).length > 0; - if (!kbDocsLoaded) { - this.options.logger.debug(`Loading KB docs...`); - await loadESQL(esStore, this.options.logger); - } else { - this.options.logger.debug(`Knowledge Base docs already loaded!`); + if (installEsqlDocs) { + const kbDocsLoaded = await this.isESQLDocsLoaded(); + if (!kbDocsLoaded) { + this.options.logger.debug(`Loading KB docs...`); + await loadESQL(this, this.options.logger); + } else { + this.options.logger.debug(`Knowledge Base docs already loaded!`); + } } } catch (e) { this.options.setIsKBSetupInProgress(false); @@ -254,15 +269,19 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { * Adds LangChain Documents to the knowledge base * * @param {Array>} documents - LangChain Documents to add to the knowledge base + * @param global whether these entries should be added globally, i.e. empty users[] */ public addKnowledgeBaseDocuments = async ({ documents, + global = false, }: { documents: Array>; + global?: boolean; }): Promise => { const writer = await this.getWriter(); const changedAt = new Date().toISOString(); const authenticatedUser = this.options.currentUser; + // TODO: KB-RBAC check for when `global:true` if (authenticatedUser == null) { throw new Error( 'Authenticated user not found! Ensure kbDataClient was initialized from a request.' @@ -270,27 +289,40 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { } const { errors, docs_created: docsCreated } = await writer.bulk({ documentsToCreate: documents.map((doc) => { - // v1 schema has metadata nested in a `metadata` object, and kbResource vs kb_resource - const body = this.options.v2KnowledgeBaseEnabled - ? { - kb_resource: doc.metadata.kbResource ?? 'unknown', + // v1 schema has metadata nested in a `metadata` object + if (this.options.v2KnowledgeBaseEnabled) { + return transformToCreateSchema({ + createdAt: changedAt, + spaceId: this.spaceId, + user: authenticatedUser, + entry: { + type: DocumentEntryType.value, + name: 'unknown', + text: doc.pageContent, + kbResource: doc.metadata.kbResource ?? 'unknown', required: doc.metadata.required ?? false, source: doc.metadata.source ?? 'unknown', - } - : { + }, + global, + }); + } else { + return transformToLegacyCreateSchema({ + createdAt: changedAt, + spaceId: this.spaceId, + user: authenticatedUser, + entry: { + type: DocumentEntryType.value, + name: 'unknown', + text: doc.pageContent, metadata: { kbResource: doc.metadata.kbResource ?? 'unknown', required: doc.metadata.required ?? false, source: doc.metadata.source ?? 'unknown', }, - }; - // @ts-ignore Transform only explicitly supports v2 schema, but technically still supports v1 - return transformToCreateSchema(changedAt, this.spaceId, authenticatedUser, { - type: DocumentEntryType.value, - name: 'unknown', - text: doc.pageContent, - ...body, - }); + }, + global, + }); + } }), authenticatedUser, }); @@ -308,6 +340,18 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { return created?.data ? transformESSearchToKnowledgeBaseEntry(created?.data) : []; }; + /** + * Returns if ES|QL KB docs have been loaded + */ + public isESQLDocsLoaded = async (): Promise => { + const esqlDocs = await this.getKnowledgeBaseDocumentEntries({ + query: ESQL_DOCS_LOADED_QUERY, + // kbResource, // Note: `8.15` installs have kbResource as `unknown`, so don't filter yet + required: true, + }); + return esqlDocs.length > 0; + }; + /** * Performs similarity search to retrieve LangChain Documents from the knowledge base */ @@ -386,13 +430,17 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { * Creates a new Knowledge Base Entry. * * @param knowledgeBaseEntry + * @param global */ public createKnowledgeBaseEntry = async ({ knowledgeBaseEntry, + global = false, }: { - knowledgeBaseEntry: KnowledgeBaseEntryCreateProps; + knowledgeBaseEntry: KnowledgeBaseEntryCreateProps | LegacyKnowledgeBaseEntryCreateProps; + global?: boolean; }): Promise => { const authenticatedUser = this.options.currentUser; + // TODO: KB-RBAC check for when `global:true` if (authenticatedUser == null) { throw new Error( 'Authenticated user not found! Ensure kbDataClient was initialized from a request.' @@ -410,6 +458,8 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { spaceId: this.spaceId, user: authenticatedUser, knowledgeBaseEntry, + global, + isV2: this.options.v2KnowledgeBaseEnabled, }); }; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts index ebdc5accec463..942f94c203873 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts @@ -26,8 +26,14 @@ import { conversationsFieldMap } from '../ai_assistant_data_clients/conversation import { assistantPromptsFieldMap } from '../ai_assistant_data_clients/prompts/field_maps_configuration'; import { assistantAnonymizationFieldsFieldMap } from '../ai_assistant_data_clients/anonymization_fields/field_maps_configuration'; import { AIAssistantDataClient } from '../ai_assistant_data_clients'; -import { knowledgeBaseFieldMap } from '../ai_assistant_data_clients/knowledge_base/field_maps_configuration'; -import { AIAssistantKnowledgeBaseDataClient } from '../ai_assistant_data_clients/knowledge_base'; +import { + knowledgeBaseFieldMap, + knowledgeBaseFieldMapV2, +} from '../ai_assistant_data_clients/knowledge_base/field_maps_configuration'; +import { + AIAssistantKnowledgeBaseDataClient, + GetAIAssistantKnowledgeBaseDataClientParams, +} from '../ai_assistant_data_clients/knowledge_base'; import { AttackDiscoveryDataClient } from '../ai_assistant_data_clients/attack_discovery'; import { createGetElserId, createPipeline, pipelineExists } from './helpers'; @@ -90,7 +96,7 @@ export class AIAssistantService { this.knowledgeBaseDataStream = this.createDataStream({ resource: 'knowledgeBase', kibanaVersion: options.kibanaVersion, - fieldMap: knowledgeBaseFieldMap, // TODO: use v2 if FF is enabled + fieldMap: knowledgeBaseFieldMap, // TODO: use V2 if FF is enabled }); this.promptsDataStream = this.createDataStream({ resource: 'prompts', @@ -173,17 +179,28 @@ export class AIAssistantService { pluginStop$: this.options.pluginStop$, }); + // If v2 is enabled, re-install data stream resources for new mappings + if (this.v2KnowledgeBaseEnabled) { + this.options.logger.debug(`Using V2 Knowledge Base Mappings`); + this.knowledgeBaseDataStream = this.createDataStream({ + resource: 'knowledgeBase', + kibanaVersion: this.options.kibanaVersion, + fieldMap: knowledgeBaseFieldMapV2, + }); + } + await this.knowledgeBaseDataStream.install({ esClient, logger: this.options.logger, pluginStop$: this.options.pluginStop$, }); - // TODO: Pipeline creation is temporary as we'll be moving to semantic_text field once available in ES + // Note: Pipeline creation can be removed in favor of semantic_text const pipelineCreated = await pipelineExists({ esClient, id: this.resourceNames.pipelines.knowledgeBase, }); + // TODO: When FF is removed, ensure pipeline is re-created for those upgrading if (!pipelineCreated || this.v2KnowledgeBaseEnabled) { this.options.logger.debug( `Installing ingest pipeline - ${this.resourceNames.pipelines.knowledgeBase}` @@ -329,12 +346,24 @@ export class AIAssistantService { } public async createAIAssistantKnowledgeBaseDataClient( - opts: CreateAIAssistantClientParams & { v2KnowledgeBaseEnabled: boolean } + opts: CreateAIAssistantClientParams & GetAIAssistantKnowledgeBaseDataClientParams ): Promise { + // If modelIdOverride is set, swap getElserId(), and ensure the pipeline is re-created with the correct model + if (opts.modelIdOverride != null) { + const modelIdOverride = opts.modelIdOverride; + this.getElserId = async () => modelIdOverride; + } + // Note: Due to plugin lifecycle and feature flag registration timing, we need to pass in the feature flag here // Remove this param and initialization when the `assistantKnowledgeBaseByDefault` feature flag is removed if (opts.v2KnowledgeBaseEnabled) { this.v2KnowledgeBaseEnabled = true; + } + + // If either v2 KB or a modelIdOverride is provided, we need to reinitialize all persistence resources to make sure + // they're using the correct model/mappings. Technically all existing KB data is stale since it was created + // with a different model/mappings, but modelIdOverride is only intended for testing purposes at this time + if (opts.v2KnowledgeBaseEnabled || opts.modelIdOverride != null) { await this.initializeResources(); } @@ -356,7 +385,7 @@ export class AIAssistantService { ml: this.options.ml, setIsKBSetupInProgress: this.setIsKBSetupInProgress.bind(this), spaceId: opts.spaceId, - v2KnowledgeBaseEnabled: opts.v2KnowledgeBaseEnabled, + v2KnowledgeBaseEnabled: opts.v2KnowledgeBaseEnabled ?? false, }); } diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/content_loaders/esql_loader.test.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/content_loaders/esql_loader.test.ts index 56ae404e4a2db..718f80b16972f 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/content_loaders/esql_loader.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/content_loaders/esql_loader.test.ts @@ -8,7 +8,6 @@ import { Logger } from '@kbn/core/server'; import { addRequiredKbResourceMetadata } from './add_required_kb_resource_metadata'; -import { ElasticsearchStore } from '../elasticsearch_store/elasticsearch_store'; import { loadESQL } from './esql_loader'; import { mockEsqlDocsFromDirectoryLoader, @@ -16,6 +15,7 @@ import { mockExampleQueryDocsFromDirectoryLoader, } from '../../../__mocks__/docs_from_directory_loader'; import { ESQL_RESOURCE } from '../../../routes/knowledge_base/constants'; +import { AIAssistantKnowledgeBaseDataClient } from '../../../ai_assistant_data_clients/knowledge_base'; let mockLoad = jest.fn(); @@ -29,9 +29,9 @@ jest.mock('langchain/document_loaders/fs/text', () => ({ TextLoader: jest.fn().mockImplementation(() => ({})), })); -const esStore = { - addDocuments: jest.fn().mockResolvedValue(['1', '2', '3', '4', '5']), -} as unknown as ElasticsearchStore; +const kbDataClient = { + addKnowledgeBaseDocuments: jest.fn().mockResolvedValue(['1', '2', '3', '4', '5']), +} as unknown as AIAssistantKnowledgeBaseDataClient; const logger = { info: jest.fn(), @@ -51,26 +51,29 @@ describe('loadESQL', () => { describe('loadESQL', () => { beforeEach(async () => { - await loadESQL(esStore, logger); + await loadESQL(kbDataClient, logger); }); it('loads ES|QL docs, language files, and example queries into the Knowledge Base', async () => { - expect(esStore.addDocuments).toHaveBeenCalledWith([ - ...addRequiredKbResourceMetadata({ - docs: mockEsqlDocsFromDirectoryLoader, - kbResource: ESQL_RESOURCE, - required: false, - }), - ...addRequiredKbResourceMetadata({ - docs: mockEsqlLanguageDocsFromDirectoryLoader, - kbResource: ESQL_RESOURCE, - required: false, - }), - ...addRequiredKbResourceMetadata({ - docs: mockExampleQueryDocsFromDirectoryLoader, - kbResource: ESQL_RESOURCE, - }), - ]); + expect(kbDataClient.addKnowledgeBaseDocuments).toHaveBeenCalledWith({ + documents: [ + ...addRequiredKbResourceMetadata({ + docs: mockEsqlDocsFromDirectoryLoader, + kbResource: ESQL_RESOURCE, + required: false, + }), + ...addRequiredKbResourceMetadata({ + docs: mockEsqlLanguageDocsFromDirectoryLoader, + kbResource: ESQL_RESOURCE, + required: false, + }), + ...addRequiredKbResourceMetadata({ + docs: mockExampleQueryDocsFromDirectoryLoader, + kbResource: ESQL_RESOURCE, + }), + ], + global: true, + }); }); it('logs the expected (distinct) counts for each category of documents', async () => { @@ -91,26 +94,28 @@ describe('loadESQL', () => { }); it('returns true if documents were loaded', async () => { - (esStore.addDocuments as jest.Mock).mockResolvedValueOnce(['this is a response']); + (kbDataClient.addKnowledgeBaseDocuments as jest.Mock).mockResolvedValueOnce([ + 'this is a response', + ]); - const result = await loadESQL(esStore, logger); + const result = await loadESQL(kbDataClient, logger); expect(result).toBe(true); }); it('returns false if documents were NOT loaded', async () => { - (esStore.addDocuments as jest.Mock).mockResolvedValueOnce([]); + (kbDataClient.addKnowledgeBaseDocuments as jest.Mock).mockResolvedValueOnce([]); - const result = await loadESQL(esStore, logger); + const result = await loadESQL(kbDataClient, logger); expect(result).toBe(false); }); it('logs the expected error if loading fails', async () => { const error = new Error('Failed to load documents'); - (esStore.addDocuments as jest.Mock).mockRejectedValueOnce(error); + (kbDataClient.addKnowledgeBaseDocuments as jest.Mock).mockRejectedValueOnce(error); - await loadESQL(esStore, logger); + await loadESQL(kbDataClient, logger); expect(logger.error).toHaveBeenCalledWith( 'Failed to load ES|QL docs, language docs, and example queries into the Knowledge Base\nError: Failed to load documents' diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/content_loaders/esql_loader.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/content_loaders/esql_loader.ts index 72882f771c5a1..4668671674bc3 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/content_loaders/esql_loader.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/content_loaders/esql_loader.ts @@ -12,17 +12,17 @@ import { resolve } from 'path'; import { Document } from 'langchain/document'; import { Metadata } from '@kbn/elastic-assistant-common'; -import { ElasticsearchStore } from '../elasticsearch_store/elasticsearch_store'; import { addRequiredKbResourceMetadata } from './add_required_kb_resource_metadata'; import { ESQL_RESOURCE } from '../../../routes/knowledge_base/constants'; +import { AIAssistantKnowledgeBaseDataClient } from '../../../ai_assistant_data_clients/knowledge_base'; /** * Loads the ESQL docs and language files into the Knowledge Base. - * - * *Item of Interest* - * Knob #1: Types of documents loaded, metadata included, and document chunking strategies + text-splitting */ -export const loadESQL = async (esStore: ElasticsearchStore, logger: Logger): Promise => { +export const loadESQL = async ( + kbDataClient: AIAssistantKnowledgeBaseDataClient, + logger: Logger +): Promise => { try { const docsLoader = new DirectoryLoader( resolve(__dirname, '../../../knowledge_base/esql/documentation'), @@ -76,11 +76,10 @@ export const loadESQL = async (esStore: ElasticsearchStore, logger: Logger): Pro `Loading ${docsWithMetadata.length} ES|QL docs, ${languageDocsWithMetadata.length} language docs, and ${requiredExampleQueries.length} example queries into the Knowledge Base` ); - const response = await esStore.addDocuments([ - ...docsWithMetadata, - ...languageDocsWithMetadata, - ...requiredExampleQueries, - ]); + const response = await kbDataClient.addKnowledgeBaseDocuments({ + documents: [...docsWithMetadata, ...languageDocsWithMetadata, ...requiredExampleQueries], + global: true, + }); logger.info( `Loaded ${ diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/elasticsearch_store/elasticsearch_store.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/elasticsearch_store/elasticsearch_store.ts index 5398abcb8a78e..24cec841a443f 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/elasticsearch_store/elasticsearch_store.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/elasticsearch_store/elasticsearch_store.ts @@ -157,6 +157,7 @@ export class ElasticsearchStore extends VectorStore { try { const response = await this.kbDataClient.addKnowledgeBaseDocuments({ documents, + global: true, }); return response.map((doc) => doc.id); } catch (e) { diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/index.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/index.ts index 8cc676cd851a7..9b421d2d93ebc 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/index.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/index.ts @@ -95,8 +95,6 @@ export const callAssistantGraph: AgentExecutor = async ({ const latestMessage = langChainMessages.slice(-1); // the last message - const modelExists = await esStore.isModelInstalled(); - // Create a chain that uses the ELSER backed ElasticsearchStore, override k=10 for esql query generation for now const chain = RetrievalQAChain.fromLLM(createLlmInstance(), esStore.asRetriever(10)); @@ -114,7 +112,7 @@ export const callAssistantGraph: AgentExecutor = async ({ isEnabledKnowledgeBase, kbDataClient: dataClients?.kbDataClient, logger, - modelExists, + modelExists: isEnabledKnowledgeBase, onNewReplacements, replacements, request, diff --git a/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts index 27ea4eea46d45..090dfa2acf5f0 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts @@ -158,9 +158,9 @@ export const postEvaluateRoute = ( const conversationsDataClient = (await assistantContext.getAIAssistantConversationsDataClient()) ?? undefined; const kbDataClient = - (await assistantContext.getAIAssistantKnowledgeBaseDataClient( - v2KnowledgeBaseEnabled - )) ?? undefined; + (await assistantContext.getAIAssistantKnowledgeBaseDataClient({ + v2KnowledgeBaseEnabled, + })) ?? undefined; const dataClients: AssistantDataClients = { anonymizationFieldsDataClient, conversationsDataClient, @@ -221,8 +221,6 @@ export const postEvaluateRoute = ( ? transformESSearchToAnonymizationFields(anonymizationFieldsRes.data) : undefined; - const modelExists = await esStore.isModelInstalled(); - // Create a chain that uses the ELSER backed ElasticsearchStore, override k=10 for esql query generation for now const chain = RetrievalQAChain.fromLLM(llm, esStore.asRetriever(10)); @@ -257,7 +255,7 @@ export const postEvaluateRoute = ( kbDataClient: dataClients?.kbDataClient, llm, logger, - modelExists, + modelExists: isEnabledKnowledgeBase, request: skeletonRequest, alertsIndexPattern, // onNewReplacements, diff --git a/x-pack/plugins/elastic_assistant/server/routes/helpers.ts b/x-pack/plugins/elastic_assistant/server/routes/helpers.ts index 1e8acf4bee885..860c6882a6b27 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/helpers.ts @@ -389,8 +389,9 @@ export const langChainExecute = async ({ // Create an ElasticsearchStore for KB interactions const kbDataClient = - (await assistantContext.getAIAssistantKnowledgeBaseDataClient(v2KnowledgeBaseEnabled)) ?? - undefined; + (await assistantContext.getAIAssistantKnowledgeBaseDataClient({ + v2KnowledgeBaseEnabled, + })) ?? undefined; const bedrockChatEnabled = assistantContext.getRegisteredFeatures(pluginName).assistantBedrockChat; const esStore = new ElasticsearchStore( diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/delete_knowledge_base.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/delete_knowledge_base.ts index 427bca2da3dcc..3e387e8a8a4d2 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/delete_knowledge_base.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/delete_knowledge_base.ts @@ -19,9 +19,6 @@ import { import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; import { buildResponse } from '../../lib/build_response'; import { ElasticAssistantRequestHandlerContext } from '../../types'; -import { ElasticsearchStore } from '../../lib/langchain/elasticsearch_store/elasticsearch_store'; -import { ESQL_RESOURCE } from './constants'; -import { getKbResource } from './get_kb_resource'; import { isV2KnowledgeBaseEnabled } from '../helpers'; /** @@ -53,44 +50,22 @@ export const deleteKnowledgeBaseRoute = ( const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); const assistantContext = ctx.elasticAssistant; const logger = ctx.elasticAssistant.logger; - const telemetry = assistantContext.telemetry; // FF Check for V2 KB const v2KnowledgeBaseEnabled = isV2KnowledgeBaseEnabled({ context: ctx, request }); try { - const kbResource = getKbResource(request); - const esClient = (await context.core).elasticsearch.client.asInternalUser; - const knowledgeBaseDataClient = - await assistantContext.getAIAssistantKnowledgeBaseDataClient(v2KnowledgeBaseEnabled); + await assistantContext.getAIAssistantKnowledgeBaseDataClient({ + v2KnowledgeBaseEnabled, + }); if (!knowledgeBaseDataClient) { return response.custom({ body: { success: false }, statusCode: 500 }); } - const esStore = new ElasticsearchStore( - esClient, - knowledgeBaseDataClient.indexTemplateAndPattern.alias, - logger, - telemetry, - 'elserId', // Not needed for delete ops - kbResource, - knowledgeBaseDataClient - ); - - if (kbResource === ESQL_RESOURCE) { - // For now, tearing down the Knowledge Base is fine, but will want to support removing specific assets based - // on resource name or document query - // Implement deleteDocuments(query: string) in ElasticsearchStore - // const success = await esStore.deleteDocuments(); - // return const body: DeleteKnowledgeBaseResponse = { success }; - } - - // Delete index and pipeline - const indexDeleted = await esStore.deleteIndex(); - const pipelineDeleted = await esStore.deletePipeline(); + // TODO: This delete API is likely not needed and can be replaced by the new `entries` API const body: DeleteKnowledgeBaseResponse = { - success: indexDeleted && pipelineDeleted, + success: false, }; return response.ok({ body }); diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.ts index af6bf559376ad..96045b17e6171 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.ts @@ -166,9 +166,9 @@ export const bulkActionKnowledgeBaseEntriesRoute = (router: ElasticAssistantPlug // subscribing to completed$, because it handles both cases when request was completed and aborted. // when route is finished by timeout, aborted$ is not getting fired request.events.completed$.subscribe(() => abortController.abort()); - const kbDataClient = await ctx.elasticAssistant.getAIAssistantKnowledgeBaseDataClient( - true - ); + const kbDataClient = await ctx.elasticAssistant.getAIAssistantKnowledgeBaseDataClient({ + v2KnowledgeBaseEnabled: true, + }); const spaceId = ctx.elasticAssistant.getSpaceId(); // Authenticated user null check completed in `performChecks()` above const authenticatedUser = ctx.elasticAssistant.getCurrentUser() as AuthenticatedUser; @@ -201,8 +201,13 @@ export const bulkActionKnowledgeBaseEntriesRoute = (router: ElasticAssistantPlug docs_deleted: docsDeleted, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion } = await writer!.bulk({ - documentsToCreate: body.create?.map((c) => - transformToCreateSchema(changedAt, spaceId, authenticatedUser, c) + documentsToCreate: body.create?.map((entry) => + transformToCreateSchema({ + createdAt: changedAt, + spaceId, + user: authenticatedUser, + entry, + }) ), documentsToDelete: body.delete?.ids, documentsToUpdate: [], // TODO: Support bulk update diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.ts index cf7c31d980ac8..3dbb5a9cf930e 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.ts @@ -59,13 +59,15 @@ export const createKnowledgeBaseEntryRoute = (router: ElasticAssistantPluginRout } // Check mappings and upgrade if necessary -- this route only supports v2 KB, so always `true` - const kbDataClient = await ctx.elasticAssistant.getAIAssistantKnowledgeBaseDataClient( - true - ); + const kbDataClient = await ctx.elasticAssistant.getAIAssistantKnowledgeBaseDataClient({ + v2KnowledgeBaseEnabled: true, + }); logger.debug(() => `Creating KB Entry:\n${JSON.stringify(request.body)}`); const createResponse = await kbDataClient?.createKnowledgeBaseEntry({ knowledgeBaseEntry: request.body, + // TODO: KB-RBAC check, required when users != null as entry will either be created globally if empty, or for specific users (only admin API feature) + global: request.body.users != null && request.body.users.length === 0, }); if (createResponse == null) { diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/find_route.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/find_route.ts index 25d4bbb195d68..b28fabedb5cef 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/find_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/find_route.ts @@ -10,6 +10,8 @@ import { transformError } from '@kbn/securitysolution-es-utils'; import { API_VERSIONS, + DocumentEntry, + DocumentEntryType, ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_FIND, FindKnowledgeBaseEntriesRequestQuery, FindKnowledgeBaseEntriesResponse, @@ -22,6 +24,7 @@ import { performChecks } from '../../helpers'; import { transformESSearchToKnowledgeBaseEntry } from '../../../ai_assistant_data_clients/knowledge_base/transforms'; import { EsKnowledgeBaseEntrySchema } from '../../../ai_assistant_data_clients/knowledge_base/types'; import { getKBUserFilter } from './utils'; +import { ESQL_RESOURCE } from '../constants'; export const findKnowledgeBaseEntriesRoute = (router: ElasticAssistantPluginRouter) => { router.versioned @@ -64,29 +67,62 @@ export const findKnowledgeBaseEntriesRoute = (router: ElasticAssistantPluginRout return checkResponse; } - const kbDataClient = await ctx.elasticAssistant.getAIAssistantKnowledgeBaseDataClient( - true - ); + const kbDataClient = await ctx.elasticAssistant.getAIAssistantKnowledgeBaseDataClient({ + v2KnowledgeBaseEnabled: true, + }); const currentUser = ctx.elasticAssistant.getCurrentUser(); const userFilter = getKBUserFilter(currentUser); + const systemFilter = ` AND NOT kb_resource:"${ESQL_RESOURCE}"`; const additionalFilter = query.filter ? ` AND ${query.filter}` : ''; + // TODO: Either plumb through new `findDocuments` that takes query DSL so you can do agg + pagination to collapse + // TODO: system entries, use scoped esClient from request, or query them separate and mess with pagination...latter for now. const result = await kbDataClient?.findDocuments({ perPage: query.per_page, page: query.page, sortField: query.sort_field, sortOrder: query.sort_order, - filter: `${userFilter}${additionalFilter}`, + filter: `${userFilter}${systemFilter}${additionalFilter}`, fields: query.fields, }); + const systemResult = await kbDataClient?.findDocuments({ + perPage: 1000, + page: 1, + filter: `kb_resource:"${ESQL_RESOURCE}"`, + }); + + // Group system entries + const systemEntry = systemResult?.data.hits.hits?.[0]?._source; + const systemEntryCount = systemResult?.data.hits.hits?.length ?? 1; + const systemEntries: DocumentEntry[] = + systemEntry == null + ? [] + : [ + { + id: 'someID', + createdAt: systemEntry.created_at, + createdBy: systemEntry.created_by, + updatedAt: systemEntry.updated_at, + updatedBy: systemEntry.updated_by, + users: [], + name: 'ES|QL documents', + namespace: systemEntry.namespace, + type: DocumentEntryType.value, + kbResource: ESQL_RESOURCE, + source: '', + required: true, + text: `${systemEntryCount}`, + }, + ]; + if (result) { return response.ok({ body: { perPage: result.perPage, page: result.page, total: result.total, - data: transformESSearchToKnowledgeBaseEntry(result.data), + data: [...transformESSearchToKnowledgeBaseEntry(result.data), ...systemEntries], }, }); } diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/utils.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/utils.ts index 6ca137d64e7a3..3a548cd812539 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/utils.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/utils.ts @@ -8,5 +8,19 @@ import { AuthenticatedUser } from '@kbn/core-security-common'; export const getKBUserFilter = (user: AuthenticatedUser | null) => { - return user?.profile_uid ? `users.id: "${user?.profile_uid}" or NOT users: *` : 'NOT users: *'; + // Only return the current users entries and all other global entries (where user[] is empty) + const globalFilter = 'NOT users: {name:* OR id:* }'; + + const nameFilter = user?.username ? `users: {name: ${user?.username}}` : ''; + const idFilter = user?.profile_uid ? `users: {id: ${user?.profile_uid}}` : ''; + const userFilter = + user?.username && user?.profile_uid + ? ` OR (${nameFilter} OR ${idFilter})` + : user?.username + ? ` OR ${nameFilter}` + : user?.profile_uid + ? ` OR ${idFilter}` + : ''; + + return `(${globalFilter}${userFilter})`; }; diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.test.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.test.ts index d63e1a6d3d57d..7f1d1d0149f56 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.test.ts @@ -9,17 +9,13 @@ import { getKnowledgeBaseStatusRoute } from './get_knowledge_base_status'; import { serverMock } from '../../__mocks__/server'; import { requestContextMock } from '../../__mocks__/request_context'; import { getGetKnowledgeBaseStatusRequest } from '../../__mocks__/request'; -import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { AuthenticatedUser } from '@kbn/core-security-common'; describe('Get Knowledge Base Status Route', () => { let server: ReturnType; - // eslint-disable-next-line prefer-const - let { clients, context } = requestContextMock.createTools(); - clients.core.elasticsearch.client = elasticsearchServiceMock.createScopedClusterClient(); + let { context } = requestContextMock.createTools(); - const mockGetElser = jest.fn().mockResolvedValue('.elser_model_2'); const mockUser = { username: 'my_username', authentication_realm: { @@ -39,9 +35,10 @@ describe('Get Knowledge Base Status Route', () => { }, isModelInstalled: jest.fn().mockResolvedValue(true), isSetupAvailable: jest.fn().mockResolvedValue(true), + isModelDeployed: jest.fn().mockResolvedValue(true), }); - getKnowledgeBaseStatusRoute(server.router, mockGetElser); + getKnowledgeBaseStatusRoute(server.router); }); describe('Status codes', () => { @@ -52,16 +49,5 @@ describe('Get Knowledge Base Status Route', () => { ); expect(response.status).toEqual(200); }); - - test('returns 500 if error is thrown in checking kb status', async () => { - context.core.elasticsearch.client.asInternalUser.indices.exists.mockRejectedValue( - new Error('Test error') - ); - const response = await server.inject( - getGetKnowledgeBaseStatusRequest('esql'), - requestContextMock.convertContext(context) - ); - expect(response.status).toEqual(500); - }); }); }); diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.ts index 404e489d79606..a6853cdcf27b0 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.ts @@ -17,21 +17,16 @@ import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/ import { KibanaRequest } from '@kbn/core/server'; import { getKbResource } from './get_kb_resource'; import { buildResponse } from '../../lib/build_response'; -import { ElasticAssistantPluginRouter, GetElser } from '../../types'; -import { ElasticsearchStore } from '../../lib/langchain/elasticsearch_store/elasticsearch_store'; -import { ESQL_DOCS_LOADED_QUERY, ESQL_RESOURCE } from './constants'; +import { ElasticAssistantPluginRouter } from '../../types'; +import { ESQL_RESOURCE } from './constants'; import { isV2KnowledgeBaseEnabled } from '../helpers'; /** * Get the status of the Knowledge Base index, pipeline, and resources (collection of documents) * * @param router IRouter for registering routes - * @param getElser Function to get the default Elser ID */ -export const getKnowledgeBaseStatusRoute = ( - router: ElasticAssistantPluginRouter, - getElser: GetElser -) => { +export const getKnowledgeBaseStatusRoute = (router: ElasticAssistantPluginRouter) => { router.versioned .get({ access: 'internal', @@ -54,39 +49,26 @@ export const getKnowledgeBaseStatusRoute = ( const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); const assistantContext = ctx.elasticAssistant; const logger = ctx.elasticAssistant.logger; - const telemetry = assistantContext.telemetry; try { // Use asInternalUser - const esClient = (await context.core).elasticsearch.client.asInternalUser; - const elserId = await getElser(); const kbResource = getKbResource(request); // FF Check for V2 KB const v2KnowledgeBaseEnabled = isV2KnowledgeBaseEnabled({ context: ctx, request }); - const kbDataClient = await assistantContext.getAIAssistantKnowledgeBaseDataClient( - v2KnowledgeBaseEnabled - ); + const kbDataClient = await assistantContext.getAIAssistantKnowledgeBaseDataClient({ + v2KnowledgeBaseEnabled, + }); if (!kbDataClient) { return response.custom({ body: { success: false }, statusCode: 500 }); } - // Use old status checks by overriding esStore to use kbDataClient - const esStore = new ElasticsearchStore( - esClient, - kbDataClient.indexTemplateAndPattern.alias, - logger, - telemetry, - elserId, - kbResource, - kbDataClient - ); - - const indexExists = await esStore.indexExists(); - const pipelineExists = await esStore.pipelineExists(); - const modelExists = await esStore.isModelInstalled(elserId); + const indexExists = true; // Installed at startup, always true + const pipelineExists = true; // Installed at startup, always true + const modelExists = await kbDataClient.isModelInstalled(); const setupAvailable = await kbDataClient.isSetupAvailable(); + const isModelDeployed = await kbDataClient.isModelDeployed(); const body: ReadKnowledgeBaseResponse = { elser_exists: modelExists, @@ -96,14 +78,8 @@ export const getKnowledgeBaseStatusRoute = ( pipeline_exists: pipelineExists, }; - if (indexExists && kbResource === ESQL_RESOURCE) { - const esqlExists = - ( - await kbDataClient.getKnowledgeBaseDocumentEntries({ - query: ESQL_DOCS_LOADED_QUERY, - required: true, - }) - ).length > 0; + if (indexExists && isModelDeployed && kbResource === ESQL_RESOURCE) { + const esqlExists = await kbDataClient.isESQLDocsLoaded(); return response.ok({ body: { ...body, esql_exists: esqlExists } }); } diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/post_knowledge_base.test.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/post_knowledge_base.test.ts index 7e56561e67915..69301ac7035a4 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/post_knowledge_base.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/post_knowledge_base.test.ts @@ -19,7 +19,6 @@ describe('Post Knowledge Base Route', () => { clients.core.elasticsearch.client = elasticsearchServiceMock.createScopedClusterClient(); - const mockGetElser = jest.fn().mockResolvedValue('.elser_model_2'); const mockUser = { username: 'my_username', authentication_realm: { @@ -40,7 +39,7 @@ describe('Post Knowledge Base Route', () => { isModelInstalled: jest.fn().mockResolvedValue(true), }); - postKnowledgeBaseRoute(server.router, mockGetElser); + postKnowledgeBaseRoute(server.router); }); describe('Status codes', () => { diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/post_knowledge_base.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/post_knowledge_base.ts index 36ea1e867eb7d..b72674c6ae900 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/post_knowledge_base.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/post_knowledge_base.ts @@ -10,14 +10,14 @@ import { CreateKnowledgeBaseRequestParams, CreateKnowledgeBaseResponse, ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_URL, + CreateKnowledgeBaseRequestQuery, } from '@kbn/elastic-assistant-common'; import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; -import { IKibanaResponse, KibanaRequest } from '@kbn/core/server'; +import { IKibanaResponse } from '@kbn/core/server'; import { buildResponse } from '../../lib/build_response'; -import { ElasticAssistantPluginRouter, GetElser } from '../../types'; -import { ElasticsearchStore } from '../../lib/langchain/elasticsearch_store/elasticsearch_store'; -import { getKbResource } from './get_kb_resource'; +import { ElasticAssistantPluginRouter } from '../../types'; import { isV2KnowledgeBaseEnabled } from '../helpers'; +import { ESQL_RESOURCE } from './constants'; // Since we're awaiting on ELSER setup, this could take a bit (especially if ML needs to autoscale) // Consider just returning if attempt was successful, and switch to client polling @@ -26,12 +26,8 @@ const ROUTE_HANDLER_TIMEOUT = 10 * 60 * 1000; // 10 * 60 seconds = 10 minutes /** * Load Knowledge Base index, pipeline, and resources (collection of documents) * @param router - * @param getElser */ -export const postKnowledgeBaseRoute = ( - router: ElasticAssistantPluginRouter, - getElser: GetElser -) => { +export const postKnowledgeBaseRoute = (router: ElasticAssistantPluginRouter) => { router.versioned .post({ access: 'internal', @@ -49,46 +45,36 @@ export const postKnowledgeBaseRoute = ( validate: { request: { params: buildRouteValidationWithZod(CreateKnowledgeBaseRequestParams), + query: buildRouteValidationWithZod(CreateKnowledgeBaseRequestQuery), }, }, }, - async ( - context, - request: KibanaRequest, - response - ): Promise> => { + async (context, request, response): Promise> => { const resp = buildResponse(response); const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); const assistantContext = ctx.elasticAssistant; - const logger = ctx.elasticAssistant.logger; - const telemetry = assistantContext.telemetry; - const elserId = await getElser(); const core = ctx.core; - const esClient = core.elasticsearch.client.asInternalUser; const soClient = core.savedObjects.getClient(); + const kbResource = request.params.resource; // FF Check for V2 KB const v2KnowledgeBaseEnabled = isV2KnowledgeBaseEnabled({ context: ctx, request }); + // Only allow modelId override if FF is enabled as this will re-write the ingest pipeline and break any previous KB entries + // This is only really needed for API integration tests + const modelIdOverride = v2KnowledgeBaseEnabled ? request.query.modelId : undefined; try { const knowledgeBaseDataClient = - await assistantContext.getAIAssistantKnowledgeBaseDataClient(v2KnowledgeBaseEnabled); + await assistantContext.getAIAssistantKnowledgeBaseDataClient({ + modelIdOverride, + v2KnowledgeBaseEnabled, + }); if (!knowledgeBaseDataClient) { return response.custom({ body: { success: false }, statusCode: 500 }); } - // Continue to use esStore for loading esql docs until `semantic_text` is available and we can test the new chunking strategy - const esStore = new ElasticsearchStore( - esClient, - knowledgeBaseDataClient.indexTemplateAndPattern.alias, - logger, - telemetry, - elserId, - getKbResource(request), - knowledgeBaseDataClient - ); - - await knowledgeBaseDataClient.setupKnowledgeBase({ esStore, soClient }); + const installEsqlDocs = kbResource === ESQL_RESOURCE; + await knowledgeBaseDataClient.setupKnowledgeBase({ soClient, installEsqlDocs }); return response.ok({ body: { success: true } }); } catch (error) { diff --git a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts index 97ff073ecd5cc..736d60ff666b0 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts @@ -157,9 +157,9 @@ export const postActionsConnectorExecuteRoute = ( const v2KnowledgeBaseEnabled = assistantContext.getRegisteredFeatures(pluginName).assistantKnowledgeBaseByDefault; const kbDataClient = - (await assistantContext.getAIAssistantKnowledgeBaseDataClient( - v2KnowledgeBaseEnabled - )) ?? undefined; + (await assistantContext.getAIAssistantKnowledgeBaseDataClient({ + v2KnowledgeBaseEnabled, + })) ?? undefined; const isEnabledKnowledgeBase = await getIsKnowledgeBaseEnabled(kbDataClient); telemetry.reportEvent(INVOKE_ASSISTANT_ERROR_EVENT.eventType, { diff --git a/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts b/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts index bab389d514b7e..56eb9760e442a 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts @@ -18,7 +18,6 @@ import { updateConversationRoute } from './user_conversations/update_route'; import { findUserConversationsRoute } from './user_conversations/find_route'; import { bulkActionConversationsRoute } from './user_conversations/bulk_actions_route'; import { appendConversationMessageRoute } from './user_conversations/append_conversation_messages_route'; -import { deleteKnowledgeBaseRoute } from './knowledge_base/delete_knowledge_base'; import { getKnowledgeBaseStatusRoute } from './knowledge_base/get_knowledge_base_status'; import { postKnowledgeBaseRoute } from './knowledge_base/post_knowledge_base'; import { getEvaluateRoute } from './evaluate/get_evaluate'; @@ -61,9 +60,8 @@ export const registerRoutes = ( findUserConversationsRoute(router); // Knowledge Base Setup - deleteKnowledgeBaseRoute(router); - getKnowledgeBaseStatusRoute(router, getElserId); - postKnowledgeBaseRoute(router, getElserId); + getKnowledgeBaseStatusRoute(router); + postKnowledgeBaseRoute(router); // Knowledge Base Entries findKnowledgeBaseEntriesRoute(router); diff --git a/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts b/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts index e861fa6ffe279..3a5b8f220eff4 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts @@ -84,16 +84,21 @@ export class RequestContextFactory implements IRequestContextFactory { telemetry: core.analytics, // Note: Due to plugin lifecycle and feature flag registration timing, we need to pass in the feature flag here - // Remove `initializeKnowledgeBase` once 'assistantKnowledgeBaseByDefault' feature flag is removed - getAIAssistantKnowledgeBaseDataClient: memoize((v2KnowledgeBaseEnabled = false) => { - const currentUser = getCurrentUser(); - return this.assistantService.createAIAssistantKnowledgeBaseDataClient({ - spaceId: getSpaceId(), - logger: this.logger, - currentUser, - v2KnowledgeBaseEnabled, - }); - }), + // Remove `v2KnowledgeBaseEnabled` once 'assistantKnowledgeBaseByDefault' feature flag is removed + // Additionally, modelIdOverride is used here to enable setting up the KB using a different ELSER model, which + // is necessary for testing purposes (`pt_tiny_elser`). + getAIAssistantKnowledgeBaseDataClient: memoize( + ({ modelIdOverride, v2KnowledgeBaseEnabled = false }) => { + const currentUser = getCurrentUser(); + return this.assistantService.createAIAssistantKnowledgeBaseDataClient({ + spaceId: getSpaceId(), + logger: this.logger, + currentUser, + modelIdOverride, + v2KnowledgeBaseEnabled, + }); + } + ), getAttackDiscoveryDataClient: memoize(() => { const currentUser = getCurrentUser(); diff --git a/x-pack/plugins/elastic_assistant/server/types.ts b/x-pack/plugins/elastic_assistant/server/types.ts index ca0010ae1e6b8..e685c1d4e9358 100755 --- a/x-pack/plugins/elastic_assistant/server/types.ts +++ b/x-pack/plugins/elastic_assistant/server/types.ts @@ -46,6 +46,7 @@ import { } from '@kbn/langchain/server'; import type { InferenceServerStart } from '@kbn/inference-plugin/server'; +import type { GetAIAssistantKnowledgeBaseDataClientParams } from './ai_assistant_data_clients/knowledge_base'; import { AttackDiscoveryDataClient } from './ai_assistant_data_clients/attack_discovery'; import { AIAssistantConversationsDataClient } from './ai_assistant_data_clients/conversations'; import type { GetRegisteredFeatures, GetRegisteredTools } from './services/app_context'; @@ -126,7 +127,7 @@ export interface ElasticAssistantApiRequestHandlerContext { getCurrentUser: () => AuthenticatedUser | null; getAIAssistantConversationsDataClient: () => Promise; getAIAssistantKnowledgeBaseDataClient: ( - v2KnowledgeBaseEnabled?: boolean + params: GetAIAssistantKnowledgeBaseDataClientParams ) => Promise; getAttackDiscoveryDataClient: () => Promise; getAIAssistantPromptsDataClient: () => Promise; diff --git a/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.test.tsx b/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.test.tsx index 0662ca042a522..1c988d14e845f 100644 --- a/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.test.tsx +++ b/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.test.tsx @@ -16,6 +16,7 @@ import { } from '@kbn/elastic-assistant'; import { useKibana } from '../../common/lib/kibana'; import { useConversation } from '@kbn/elastic-assistant/impl/assistant/use_conversation'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; // Mock the necessary hooks and components jest.mock('@kbn/elastic-assistant', () => ({ @@ -40,9 +41,11 @@ const useKibanaMock = useKibana as jest.Mock; const useConversationMock = useConversation as jest.Mock; describe('ManagementSettings', () => { + const queryClient = new QueryClient(); const baseConversations = { base: 'conversation' }; const http = {}; const getDefaultConversation = jest.fn(); + const setCurrentUserAvatar = jest.fn(); const navigateToApp = jest.fn(); const mockConversations = { [WELCOME_CONVERSATION_TITLE]: { title: WELCOME_CONVERSATION_TITLE }, @@ -59,6 +62,7 @@ describe('ManagementSettings', () => { baseConversations, http, assistantAvailability: { isAssistantEnabled }, + setCurrentUserAvatar, }); useFetchCurrentUserConversationsMock.mockReturnValue({ @@ -73,6 +77,11 @@ describe('ManagementSettings', () => { securitySolutionAssistant: { 'ai-assistant': false }, }, }, + security: { + userProfiles: { + getCurrent: jest.fn().mockResolvedValue({ data: { color: 'blue', initials: 'P' } }), + }, + }, }, }); @@ -80,7 +89,11 @@ describe('ManagementSettings', () => { getDefaultConversation, }); - return render(); + return render( + + + + ); }; beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.tsx b/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.tsx index 6a7478eca0df0..90e39398474ec 100644 --- a/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.tsx +++ b/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.tsx @@ -16,6 +16,8 @@ import { } from '@kbn/elastic-assistant'; import { useConversation } from '@kbn/elastic-assistant/impl/assistant/use_conversation'; import type { FetchConversationsResponse } from '@kbn/elastic-assistant/impl/assistant/api'; +import { useQuery } from '@tanstack/react-query'; +import type { UserAvatar } from '@kbn/elastic-assistant/impl/assistant_context'; import { useKibana } from '../../common/lib/kibana'; const defaultSelectedConversationId = WELCOME_CONVERSATION_TITLE; @@ -25,6 +27,7 @@ export const ManagementSettings = React.memo(() => { baseConversations, http, assistantAvailability: { isAssistantEnabled }, + setCurrentUserAvatar, } = useAssistantContext(); const { @@ -34,8 +37,23 @@ export const ManagementSettings = React.memo(() => { securitySolutionAssistant: { 'ai-assistant': securityAIAssistantEnabled }, }, }, + security, } = useKibana().services; + const { data: currentUserAvatar } = useQuery({ + queryKey: ['currentUserAvatar'], + queryFn: () => + security?.userProfiles.getCurrent<{ avatar: UserAvatar }>({ + dataPath: 'avatar', + }), + select: (data) => { + return data.data.avatar; + }, + keepPreviousData: true, + refetchOnWindowFocus: false, + }); + setCurrentUserAvatar(currentUserAvatar); + const onFetchedConversations = useCallback( (conversationsData: FetchConversationsResponse): Record => mergeBaseWithPersistedConversations(baseConversations, conversationsData), diff --git a/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_write_tool.ts b/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_write_tool.ts index 90f0ad0b96e6e..9b46c625e115b 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_write_tool.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_write_tool.ts @@ -11,6 +11,7 @@ import type { AssistantTool, AssistantToolParams } from '@kbn/elastic-assistant- import type { AIAssistantKnowledgeBaseDataClient } from '@kbn/elastic-assistant-plugin/server/ai_assistant_data_clients/knowledge_base'; import { DocumentEntryType } from '@kbn/elastic-assistant-common'; import type { KnowledgeBaseEntryCreateProps } from '@kbn/elastic-assistant-common'; +import type { LegacyKnowledgeBaseEntryCreateProps } from '@kbn/elastic-assistant-plugin/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry'; import { APP_UI_ID } from '../../../../common'; export interface KnowledgeBaseWriteToolParams extends AssistantToolParams { @@ -56,21 +57,24 @@ export const KNOWLEDGE_BASE_WRITE_TOOL: AssistantTool = { () => `KnowledgeBaseWriteToolParams:input\n ${JSON.stringify(input, null, 2)}` ); - // Backwards compatibility with v1 schema -- createKnowledgeBaseEntry() technically supports both for now - const knowledgeBaseEntry: KnowledgeBaseEntryCreateProps = - kbDataClient.isV2KnowledgeBaseEnabled - ? { - name: input.name, - kbResource: 'user', - source: 'conversation', - required: input.required, - text: input.query, - type: DocumentEntryType.value, - } - : ({ - metadata: { kbResource: 'user', source: 'conversation', required: input.required }, - text: input.query, - } as unknown as KnowledgeBaseEntryCreateProps); + // Backwards compatibility with v1 schema since this feature is technically supported in `8.15` + const knowledgeBaseEntry: + | KnowledgeBaseEntryCreateProps + | LegacyKnowledgeBaseEntryCreateProps = kbDataClient.isV2KnowledgeBaseEnabled + ? { + name: input.name, + kbResource: 'user', + source: 'conversation', + required: input.required, + text: input.query, + type: DocumentEntryType.value, + } + : { + type: DocumentEntryType.value, + name: 'unknown', + metadata: { kbResource: 'user', source: 'conversation', required: input.required }, + text: input.query, + }; logger.debug(() => `knowledgeBaseEntry\n ${JSON.stringify(knowledgeBaseEntry, null, 2)}`); const resp = await kbDataClient.createKnowledgeBaseEntry({ knowledgeBaseEntry }); diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 17f31718070b3..0f442564d4487 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -554,14 +554,13 @@ export class Plugin implements ISecuritySolutionPlugin { APP_UI_ID, getAssistantTools(config.experimentalFeatures.assistantNaturalLanguageESQLTool) ); - plugins.elasticAssistant.registerFeatures(APP_UI_ID, { + const features = { assistantBedrockChat: config.experimentalFeatures.assistantBedrockChat, assistantKnowledgeBaseByDefault: config.experimentalFeatures.assistantKnowledgeBaseByDefault, assistantModelEvaluation: config.experimentalFeatures.assistantModelEvaluation, - }); - plugins.elasticAssistant.registerFeatures('management', { - assistantModelEvaluation: config.experimentalFeatures.assistantModelEvaluation, - }); + }; + plugins.elasticAssistant.registerFeatures(APP_UI_ID, features); + plugins.elasticAssistant.registerFeatures('management', features); if (this.lists && plugins.taskManager && plugins.fleet) { // Exceptions, Artifacts and Manifests start diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 9a3b233e170e8..62cda8f224799 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -15044,7 +15044,6 @@ "xpack.elasticAssistant.assistant.settings.flyout.cancelButtonTitle": "Annuler", "xpack.elasticAssistant.assistant.settings.flyout.saveButtonTitle": "Enregistrer", "xpack.elasticAssistant.assistant.settings.knowledgeBasedSetting.knowledgeBaseDescription": "Propulsée par ELSER, la base de connaissances permet à l'Assistant d'IA de rappeler des documents et d'autres contextes pertinents dans votre conversation. Pour plus d'informations sur l'accès utilisateur, consultez notre {documentation}.", - "xpack.elasticAssistant.assistant.settings.knowledgeBasedSettingManagements.knowledgeBaseDescription": "Propulsée par ELSER, la base de connaissances permet à l'Assistant d'IA de rappeler des documents et d'autres contextes pertinents dans votre conversation. Pour plus d'informations sur l'accès utilisateur, consultez notre {documentation}.", "xpack.elasticAssistant.assistant.settings.knowledgeBasedSettings.knowledgeBaseDescription": "Pour commencer, configurez ELSER dans {machineLearning}. {seeDocs}", "xpack.elasticAssistant.assistant.settings.knowledgeBaseSettings.alertsLabel": "Alertes", "xpack.elasticAssistant.assistant.settings.knowledgeBaseSettings.alertsRangeSliderLabel": "Plage d'alertes", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 0cbfa9bab0e11..4ba631dc3e527 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -14790,7 +14790,6 @@ "xpack.elasticAssistant.assistant.settings.flyout.cancelButtonTitle": "キャンセル", "xpack.elasticAssistant.assistant.settings.flyout.saveButtonTitle": "保存", "xpack.elasticAssistant.assistant.settings.knowledgeBasedSetting.knowledgeBaseDescription": "ELSERを活用したナレッジベースは、AI Assistantによって、会話の中でドキュメントやその他の関連するコンテキストを呼び出すことができます。ユーザーアクセスの詳細については、{documentation}を参照してください。", - "xpack.elasticAssistant.assistant.settings.knowledgeBasedSettingManagements.knowledgeBaseDescription": "ELSERを活用したナレッジベースは、AI Assistantによって、会話の中でドキュメントやその他の関連するコンテキストを呼び出すことができます。ユーザーアクセスの詳細については、{documentation}を参照してください。", "xpack.elasticAssistant.assistant.settings.knowledgeBasedSettings.knowledgeBaseDescription": "{machineLearning}内でELSERを構成して開始します。{seeDocs}", "xpack.elasticAssistant.assistant.settings.knowledgeBaseSettings.alertsLabel": "アラート", "xpack.elasticAssistant.assistant.settings.knowledgeBaseSettings.alertsRangeSliderLabel": "アラート範囲", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 65672e7eaa46d..c6cd6564a70d5 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -14818,7 +14818,6 @@ "xpack.elasticAssistant.assistant.settings.flyout.cancelButtonTitle": "取消", "xpack.elasticAssistant.assistant.settings.flyout.saveButtonTitle": "保存", "xpack.elasticAssistant.assistant.settings.knowledgeBasedSetting.knowledgeBaseDescription": "使用由 ELSER 提供支持的知识库,AI 助手可以在对话中重复调用文档和其他相关上下文。有关用户访问权限的更多信息,请参阅我们的 {documentation}。", - "xpack.elasticAssistant.assistant.settings.knowledgeBasedSettingManagements.knowledgeBaseDescription": "使用由 ELSER 提供支持的知识库,AI 助手可以在对话中重复调用文档和其他相关上下文。有关用户访问权限的更多信息,请参阅我们的 {documentation}。", "xpack.elasticAssistant.assistant.settings.knowledgeBasedSettings.knowledgeBaseDescription": "在 {machineLearning} 中配置 ELSER 以开始。{seeDocs}", "xpack.elasticAssistant.assistant.settings.knowledgeBaseSettings.alertsLabel": "告警", "xpack.elasticAssistant.assistant.settings.knowledgeBaseSettings.alertsRangeSliderLabel": "告警范围", diff --git a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/basic.ts b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/basic.ts deleted file mode 100644 index e31e36cf5f4bf..0000000000000 --- a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/basic.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import expect from 'expect'; -import { - DocumentEntryCreateFields, - DocumentEntryType, - IndexEntryCreateFields, - IndexEntryType, -} from '@kbn/elastic-assistant-common'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; -import { createEntry } from '../utils/create_entry'; - -const documentEntry: DocumentEntryCreateFields = { - name: 'Sample Document Entry', - type: DocumentEntryType.value, - required: false, - source: 'api', - kbResource: 'user', - namespace: 'default', - text: 'This is a sample document entry', - users: [], -}; - -const indexEntry: IndexEntryCreateFields = { - name: 'Sample Index Entry', - type: IndexEntryType.value, - namespace: 'default', - index: 'sample-index', - field: 'sample-field', - description: 'This is a sample index entry', - users: [], - queryDescription: 'Use sample-field to search in sample-index', -}; - -export default ({ getService }: FtrProviderContext) => { - const supertest = getService('supertest'); - const log = getService('log'); - - // TODO: Fill out tests - describe.skip('@ess @serverless Basic Security AI Assistant Knowledge Base Entries', () => { - describe('Create Entries', () => { - it('should create a new document entry', async () => { - const entry = await createEntry(supertest, log, documentEntry); - - expect(entry).toEqual(documentEntry); - }); - - it('should create a new index entry', async () => { - const entry = await createEntry(supertest, log, indexEntry); - - expect(entry).toEqual(indexEntry); - }); - }); - }); -}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/configs/ess.config.ts index 8b26b7b465248..55860215ebfcc 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/configs/ess.config.ts @@ -16,11 +16,38 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), kbnTestServer: { ...functionalConfig.get('kbnTestServer'), - serverArgs: [...functionalConfig.get('kbnTestServer.serverArgs')], + serverArgs: [ + ...functionalConfig + .get('kbnTestServer.serverArgs') + // ssl: false as ML vocab API is broken with SSL enabled + .filter( + (a: string) => + !( + a.startsWith('--elasticsearch.hosts=') || + a.startsWith('--elasticsearch.ssl.certificateAuthorities=') + ) + ), + '--elasticsearch.hosts=http://localhost:9220', + `--xpack.securitySolution.enableExperimental=${JSON.stringify([ + 'assistantKnowledgeBaseByDefault', + ])}`, + ], }, testFiles: [require.resolve('..')], junit: { reportName: 'GenAI - Knowledge Base Entries Tests - ESS Env - Trial License', }, + // ssl: false as ML vocab API is broken with SSL enabled + servers: { + ...functionalConfig.get('servers'), + elasticsearch: { + ...functionalConfig.get('servers.elasticsearch'), + protocol: 'http', + }, + }, + esTestCluster: { + ...functionalConfig.get('esTestCluster'), + ssl: false, + }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/configs/serverless.config.ts index 129f7243059ca..0c09bbaeceaee 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/configs/serverless.config.ts @@ -8,7 +8,16 @@ import { createTestConfig } from '../../../../../../config/serverless/config.base'; export default createTestConfig({ - kbnTestServerArgs: [], + kbnTestServerArgs: [ + `--xpack.securitySolution.enableExperimental=${JSON.stringify([ + 'assistantKnowledgeBaseByDefault', + ])}`, + `--xpack.securitySolutionServerless.productTypes=${JSON.stringify([ + { product_line: 'security', product_tier: 'complete' }, + { product_line: 'endpoint', product_tier: 'complete' }, + { product_line: 'cloud', product_tier: 'complete' }, + ])}`, + ], testFiles: [require.resolve('..')], junit: { reportName: 'GenAI - Knowledge Base Entries Tests - Serverless Env - Complete Tier', diff --git a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/entries.ts b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/entries.ts new file mode 100644 index 0000000000000..7cd44a21ce236 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/entries.ts @@ -0,0 +1,192 @@ +/* + * 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 expect from 'expect'; +import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { createEntry, createEntryForUser } from '../utils/create_entry'; +import { findEntries } from '../utils/find_entry'; +import { + clearKnowledgeBase, + deleteTinyElser, + installTinyElser, + setupKnowledgeBase, +} from '../utils/helpers'; +import { removeServerGeneratedProperties } from '../utils/remove_server_generated_properties'; +import { MachineLearningProvider } from '../../../../../../functional/services/ml'; +import { documentEntry, indexEntry, globalDocumentEntry } from './mocks/entries'; +import { secOnlySpacesAll } from '../utils/auth/users'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const log = getService('log'); + const es = getService('es'); + const ml = getService('ml') as ReturnType; + + describe('@ess Basic Security AI Assistant Knowledge Base Entries', () => { + before(async () => { + await installTinyElser(ml); + await setupKnowledgeBase(supertest, log); + }); + + after(async () => { + await deleteTinyElser(ml); + }); + + afterEach(async () => { + await clearKnowledgeBase(es); + }); + + describe('Create Entries', () => { + // TODO: KB-RBAC: Added stubbed admin tests for when RBAC is enabled. Hopefully this helps :] + // NOTE: Will need to update each section with the expected user, can use `createEntryForUser()` helper + describe('Admin User', () => { + it('should create a new document entry for the current user', async () => { + const entry = await createEntry({ supertest, log, entry: documentEntry }); + + const expectedDocumentEntry = { + ...documentEntry, + users: [{ name: 'elastic' }], + }; + + expect(removeServerGeneratedProperties(entry)).toEqual(expectedDocumentEntry); + }); + + it('should create a new index entry for the current user', async () => { + const entry = await createEntry({ supertest, log, entry: indexEntry }); + + const expectedIndexEntry = { + ...indexEntry, + inputSchema: [], + outputFields: [], + users: [{ name: 'elastic' }], + }; + + expect(removeServerGeneratedProperties(entry)).toEqual(expectedIndexEntry); + }); + + it('should create a new global entry for all users', async () => { + const entry = await createEntry({ supertest, log, entry: globalDocumentEntry }); + + expect(removeServerGeneratedProperties(entry)).toEqual(globalDocumentEntry); + }); + + it('should create a new global entry for all users in another space', async () => { + const entry = await createEntry({ + supertest, + log, + entry: globalDocumentEntry, + space: 'space-x', + }); + + const expectedDocumentEntry = { + ...globalDocumentEntry, + namespace: 'space-x', + }; + + expect(removeServerGeneratedProperties(entry)).toEqual(expectedDocumentEntry); + }); + }); + + describe('Non-Admin User', () => { + it('should create a new document entry', async () => { + const entry = await createEntry({ supertest, log, entry: documentEntry }); + + const expectedDocumentEntry = { + ...documentEntry, + users: [{ name: 'elastic' }], + }; + + expect(removeServerGeneratedProperties(entry)).toEqual(expectedDocumentEntry); + }); + + it('should create a new index entry', async () => { + const entry = await createEntry({ supertest, log, entry: indexEntry }); + + const expectedIndexEntry = { + ...indexEntry, + inputSchema: [], + outputFields: [], + users: [{ name: 'elastic' }], + }; + + expect(removeServerGeneratedProperties(entry)).toEqual(expectedIndexEntry); + }); + + it('should not be able to create an entry for another user', async () => { + const entry = await createEntry({ + supertest, + log, + entry: { + ...documentEntry, + users: [{ name: 'george' }], + }, + }); + + const expectedDocumentEntry = { + ...documentEntry, + users: [{ name: 'elastic' }], + }; + + expect(removeServerGeneratedProperties(entry)).toEqual(expectedDocumentEntry); + }); + + // TODO: KB-RBAC: Action not currently limited without RBAC + it.skip('should not be able to create a global entry', async () => { + const entry = await createEntry({ supertest, log, entry: globalDocumentEntry }); + + const expectedDocumentEntry = { + ...globalDocumentEntry, + users: [{ name: 'elastic' }], + }; + + expect(removeServerGeneratedProperties(entry)).toEqual(expectedDocumentEntry); + }); + }); + }); + + describe('Find Entries', () => { + it('should see other users global entries', async () => { + const users = [secOnlySpacesAll]; + + await Promise.all( + users.map((user) => + createEntryForUser({ + supertestWithoutAuth, + log, + entry: globalDocumentEntry, + user, + }) + ) + ); + + const entries = await findEntries({ supertest, log }); + + expect(entries.total).toEqual(1); + }); + + it('should not see other users private entries', async () => { + const users = [secOnlySpacesAll]; + + await Promise.all( + users.map((user) => + createEntryForUser({ + supertestWithoutAuth, + log, + entry: documentEntry, + user, + }) + ) + ); + + const entries = await findEntries({ supertest, log }); + + expect(entries.total).toEqual(0); + }); + }); + }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/index.ts b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/index.ts index a8f259ee4c8e2..21469b8e67606 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/index.ts @@ -6,9 +6,18 @@ */ import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { createSpacesAndUsers, deleteSpacesAndUsers } from '../utils/auth'; -export default function ({ loadTestFile }: FtrProviderContext) { +export default function ({ loadTestFile, getService }: FtrProviderContext) { describe('GenAI - Knowledge Base Entries APIs', function () { - loadTestFile(require.resolve('./basic')); + before(async () => { + await createSpacesAndUsers(getService); + }); + + after(async () => { + await deleteSpacesAndUsers(getService); + }); + + loadTestFile(require.resolve('./entries')); }); } diff --git a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/mocks/entries.ts b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/mocks/entries.ts new file mode 100644 index 0000000000000..27db88ec7150e --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/mocks/entries.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + DocumentEntryCreateFields, + DocumentEntryType, + IndexEntryCreateFields, + IndexEntryType, +} from '@kbn/elastic-assistant-common'; + +export const documentEntry: DocumentEntryCreateFields = { + name: 'Sample Document Entry', + type: DocumentEntryType.value, + required: false, + source: 'api', + kbResource: 'user', + namespace: 'default', + text: 'This is a sample document entry', + users: undefined, +}; + +export const globalDocumentEntry: DocumentEntryCreateFields = { + ...documentEntry, + name: 'Sample Global Document Entry', + users: [], +}; + +export const indexEntry: IndexEntryCreateFields = { + name: 'Sample Index Entry', + type: IndexEntryType.value, + namespace: 'default', + index: 'sample-index', + field: 'sample-field', + description: 'This is a sample index entry', + queryDescription: 'Use sample-field to search in sample-index', + users: undefined, +}; + +export const globalIndexEntry: IndexEntryCreateFields = { + ...indexEntry, + name: 'Sample Global Index Entry', + users: [], +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/auth/index.ts b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/auth/index.ts new file mode 100644 index 0000000000000..575c1a2aed9de --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/auth/index.ts @@ -0,0 +1,105 @@ +/* + * 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 { FtrProviderContext } from '../../../../../../ftr_provider_context'; +import { Role, User, UserInfo } from './types'; +import { allUsers } from './users'; +import { allRoles } from './roles'; +import { spaces } from './spaces'; + +export const getUserInfo = (user: User): UserInfo => ({ + username: user.username, + full_name: user.username.replace('_', ' '), + email: `${user.username}@elastic.co`, +}); + +export const createSpaces = async (getService: FtrProviderContext['getService']) => { + const spacesService = getService('spaces'); + for (const space of spaces) { + await spacesService.create(space); + } +}; + +/** + * Creates the users and roles for use in the tests. Defaults to specific users and roles used by the security_and_spaces + * scenarios but can be passed specific ones as well. + */ +export const createUsersAndRoles = async ( + getService: FtrProviderContext['getService'], + usersToCreate: User[] = allUsers, + rolesToCreate: Role[] = allRoles +) => { + const security = getService('security'); + + const createRole = async ({ name, privileges }: Role) => { + return security.role.create(name, privileges); + }; + + const createUser = async (user: User) => { + const userInfo = getUserInfo(user); + + return security.user.create(user.username, { + password: user.password, + roles: user.roles, + full_name: userInfo.full_name, + email: userInfo.email, + }); + }; + + for (const role of rolesToCreate) { + await createRole(role); + } + + for (const user of usersToCreate) { + await createUser(user); + } +}; + +export const deleteSpaces = async (getService: FtrProviderContext['getService']) => { + const spacesService = getService('spaces'); + for (const space of spaces) { + try { + await spacesService.delete(space.id); + } catch (error) { + // ignore errors because if a migration is run it will delete the .kibana index which remove the spaces and users + } + } +}; + +export const deleteUsersAndRoles = async ( + getService: FtrProviderContext['getService'], + usersToDelete: User[] = allUsers, + rolesToDelete: Role[] = allRoles +) => { + const security = getService('security'); + + for (const user of usersToDelete) { + try { + await security.user.delete(user.username); + } catch (error) { + // ignore errors because if a migration is run it will delete the .kibana index which remove the spaces and users + } + } + + for (const role of rolesToDelete) { + try { + await security.role.delete(role.name); + } catch (error) { + // ignore errors because if a migration is run it will delete the .kibana index which remove the spaces and users + } + } +}; + +export const createSpacesAndUsers = async (getService: FtrProviderContext['getService']) => { + await createSpaces(getService); + await createUsersAndRoles(getService); +}; + +export const deleteSpacesAndUsers = async (getService: FtrProviderContext['getService']) => { + await deleteSpaces(getService); + await deleteUsersAndRoles(getService); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/auth/roles.ts b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/auth/roles.ts new file mode 100644 index 0000000000000..d83a2791d3409 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/auth/roles.ts @@ -0,0 +1,199 @@ +/* + * 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 { Role } from './types'; + +export const noKibanaPrivileges: Role = { + name: 'no_kibana_privileges', + privileges: { + elasticsearch: { + indices: [], + }, + }, +}; + +export const globalRead: Role = { + name: 'global_read', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + base: ['read'], + spaces: ['*'], + }, + ], + }, +}; + +export const securitySolutionOnlyAll: Role = { + name: 'sec_only_all_spaces_space1', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['all'], + securitySolutionAssistant: ['all'], + securitySolutionAttackDiscovery: ['all'], + aiAssistantManagementSelection: ['all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const securitySolutionOnlyAllSpace2: Role = { + name: 'sec_only_all_spaces_space2', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['all'], + securitySolutionAssistant: ['all'], + securitySolutionAttackDiscovery: ['all'], + aiAssistantManagementSelection: ['all'], + }, + spaces: ['space2'], + }, + ], + }, +}; + +export const securitySolutionOnlyRead: Role = { + name: 'sec_only_read_spaces_space1', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['read'], + securitySolutionAssistant: ['all'], + securitySolutionAttackDiscovery: ['all'], + aiAssistantManagementSelection: ['all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const securitySolutionOnlyReadSpace2: Role = { + name: 'sec_only_read_spaces_space2', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['read'], + securitySolutionAssistant: ['all'], + securitySolutionAttackDiscovery: ['all'], + aiAssistantManagementSelection: ['all'], + }, + spaces: ['space2'], + }, + ], + }, +}; + +/** + * These roles have access to all spaces. + */ +export const securitySolutionOnlyAllSpacesAll: Role = { + name: 'sec_only_all_spaces_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['all'], + securitySolutionAssistant: ['all'], + securitySolutionAttackDiscovery: ['all'], + aiAssistantManagementSelection: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const securitySolutionOnlyAllSpacesAllWithReadESIndices: Role = { + name: 'sec_only_all_spaces_all_with_read_es_indices', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + siem: ['all'], + securitySolutionAssistant: ['all'], + securitySolutionAttackDiscovery: ['all'], + aiAssistantManagementSelection: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const securitySolutionOnlyReadSpacesAll: Role = { + name: 'sec_only_read_spaces_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['read'], + securitySolutionAssistant: ['all'], + securitySolutionAttackDiscovery: ['all'], + aiAssistantManagementSelection: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const roles = [ + noKibanaPrivileges, + globalRead, + securitySolutionOnlyAll, + securitySolutionOnlyRead, +]; + +export const allRoles = [ + noKibanaPrivileges, + globalRead, + securitySolutionOnlyAll, + securitySolutionOnlyRead, + securitySolutionOnlyAllSpacesAll, + securitySolutionOnlyAllSpacesAllWithReadESIndices, + securitySolutionOnlyReadSpacesAll, + securitySolutionOnlyAllSpace2, + securitySolutionOnlyReadSpace2, +]; diff --git a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/auth/spaces.ts b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/auth/spaces.ts new file mode 100644 index 0000000000000..a4b6037c6d9de --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/auth/spaces.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Space } from './types'; + +const space1: Space = { + id: 'space1', + name: 'Space 1', + disabledFeatures: [], +}; + +const space2: Space = { + id: 'space2', + name: 'Space 2', + disabledFeatures: [], +}; + +const other: Space = { + id: 'other', + name: 'Other Space', + disabledFeatures: [], +}; + +export const spaces: Space[] = [space1, space2, other]; + +export const getSpaceUrlPrefix = (spaceId?: string) => { + return spaceId && spaceId !== 'default' ? `/s/${spaceId}` : ``; +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/auth/types.ts b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/auth/types.ts new file mode 100644 index 0000000000000..3bf3629441f93 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/auth/types.ts @@ -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. + */ + +export interface Space { + id: string; + namespace?: string; + name: string; + disabledFeatures: string[]; +} + +export interface User { + username: string; + password: string; + description?: string; + roles: string[]; +} + +export interface UserInfo { + username: string; + full_name: string; + email: string; +} + +interface FeaturesPrivileges { + [featureId: string]: string[]; +} + +interface ElasticsearchIndices { + names: string[]; + privileges: string[]; +} + +export interface ElasticSearchPrivilege { + cluster?: string[]; + indices?: ElasticsearchIndices[]; +} + +export interface KibanaPrivilege { + spaces: string[]; + base?: string[]; + feature?: FeaturesPrivileges; +} + +export interface Role { + name: string; + privileges: { + elasticsearch?: ElasticSearchPrivilege; + kibana?: KibanaPrivilege[]; + }; +} diff --git a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/auth/users.ts b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/auth/users.ts new file mode 100644 index 0000000000000..6e0d790072df1 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/auth/users.ts @@ -0,0 +1,100 @@ +/* + * 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 { + securitySolutionOnlyAll, + securitySolutionOnlyRead, + globalRead as globalReadRole, + noKibanaPrivileges as noKibanaPrivilegesRole, + securitySolutionOnlyAllSpacesAll, + securitySolutionOnlyReadSpacesAll, + + // trial license roles + securitySolutionOnlyAllSpace2, + securitySolutionOnlyReadSpace2, + securitySolutionOnlyAllSpacesAllWithReadESIndices, +} from './roles'; +import { User } from './types'; + +export const superUser: User = { + username: 'superuser', + password: 'superuser', + roles: ['superuser'], +}; + +export const secOnly: User = { + username: 'sec_only_all_spaces_space1', + password: 'sec_only_all_spaces_space1', + roles: [securitySolutionOnlyAll.name], +}; + +export const secOnlySpace2: User = { + username: 'sec_only_all_spaces_space2', + password: 'sec_only_all_spaces_space2', + roles: [securitySolutionOnlyAllSpace2.name], +}; + +export const secOnlyRead: User = { + username: 'sec_only_read_spaces_space1', + password: 'sec_only_read_spaces_space1', + roles: [securitySolutionOnlyRead.name], +}; + +export const secOnlyReadSpace2: User = { + username: 'sec_only_read_spaces_space2', + password: 'sec_only_read_spaces_space2', + roles: [securitySolutionOnlyReadSpace2.name], +}; + +export const globalRead: User = { + username: 'global_read', + password: 'global_read', + roles: [globalReadRole.name], +}; + +export const noKibanaPrivileges: User = { + username: 'no_kibana_privileges', + password: 'no_kibana_privileges', + roles: [noKibanaPrivilegesRole.name], +}; + +export const users = [superUser, secOnly, secOnlyRead, globalRead, noKibanaPrivileges]; + +/** + * These users will have access to all spaces. + */ + +export const secOnlySpacesAll: User = { + username: 'sec_only_all_spaces_all', + password: 'sec_only_all_spaces_all', + roles: [securitySolutionOnlyAllSpacesAll.name], +}; + +export const secOnlyReadSpacesAll: User = { + username: 'sec_only_read_spaces_all', + password: 'sec_only_read_spaces_all', + roles: [securitySolutionOnlyReadSpacesAll.name], +}; + +export const secOnlySpacesAllEsReadAll: User = { + username: 'sec_only_all_spaces_all_with_read_es_indices', + password: 'sec_only_all_spaces_all_with_read_es_indices', + roles: [securitySolutionOnlyAllSpacesAllWithReadESIndices.name], +}; + +export const allUsers = [ + superUser, + secOnly, + secOnlyRead, + globalRead, + noKibanaPrivileges, + secOnlySpacesAll, + secOnlySpacesAllEsReadAll, + secOnlyReadSpacesAll, + secOnlySpace2, + secOnlyReadSpace2, +]; diff --git a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/create_entry.ts b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/create_entry.ts index 86210d53c45e2..f69c42dcbd9bd 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/create_entry.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/create_entry.ts @@ -8,12 +8,13 @@ import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; import type { ToolingLog } from '@kbn/tooling-log'; import type SuperTest from 'supertest'; - import { ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL, KnowledgeBaseEntryCreateProps, KnowledgeBaseEntryResponse, } from '@kbn/elastic-assistant-common'; +import type { User } from './auth/types'; + import { routeWithNamespace } from '../../../../../../common/utils/security_solution'; /** @@ -21,15 +22,20 @@ import { routeWithNamespace } from '../../../../../../common/utils/security_solu * @param supertest The supertest deps * @param log The tooling logger * @param entry The entry to create - * @param namespace The Kibana Space to create the entry in (optional) + * @param space The Kibana Space to create the entry in (optional) */ -export const createEntry = async ( - supertest: SuperTest.Agent, - log: ToolingLog, - entry: KnowledgeBaseEntryCreateProps, - namespace?: string -): Promise => { - const route = routeWithNamespace(ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL, namespace); +export const createEntry = async ({ + supertest, + log, + entry, + space, +}: { + supertest: SuperTest.Agent; + log: ToolingLog; + entry: KnowledgeBaseEntryCreateProps; + space?: string; +}): Promise => { + const route = routeWithNamespace(ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL, space); const response = await supertest .post(route) .set('kbn-xsrf', 'true') @@ -45,3 +51,42 @@ export const createEntry = async ( return response.body; } }; + +/** + * Creates a Knowledge Base Entry for a given User + * @param supertest The supertest deps + * @param log The tooling logger + * @param entry The entry to create + * @param user The user to create the entry on behalf of + * @param space The Kibana Space to create the entry in (optional) + */ +export const createEntryForUser = async ({ + supertestWithoutAuth, + log, + entry, + user, + space, +}: { + supertestWithoutAuth: SuperTest.Agent; + log: ToolingLog; + entry: KnowledgeBaseEntryCreateProps; + user: User; + space?: string; +}): Promise => { + const route = routeWithNamespace(ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL, space); + const response = await supertestWithoutAuth + .post(route) + .auth(user.username, user.password) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .send(entry); + if (response.status !== 200) { + throw new Error( + `Unexpected non 200 ok when attempting to create entry: ${JSON.stringify( + response.status + )},${JSON.stringify(response, null, 4)}` + ); + } else { + return response.body; + } +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/find_entry.ts b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/find_entry.ts new file mode 100644 index 0000000000000..23dbfafe8d56b --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/find_entry.ts @@ -0,0 +1,92 @@ +/* + * 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 { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; +import type { ToolingLog } from '@kbn/tooling-log'; +import type SuperTest from 'supertest'; +import { + FindKnowledgeBaseEntriesResponse, + ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_FIND, + FindKnowledgeBaseEntriesRequestQuery, +} from '@kbn/elastic-assistant-common'; +import type { User } from './auth/types'; + +import { routeWithNamespace } from '../../../../../../common/utils/security_solution'; + +/** + * Finds Knowledge Base Entries + * @param supertest The supertest deps + * @param log The tooling logger + * @param params Params for find API (optional) + * @param space The Kibana Space to find entries in (optional) + */ +export const findEntries = async ({ + supertest, + log, + params, + space, +}: { + supertest: SuperTest.Agent; + log: ToolingLog; + params?: FindKnowledgeBaseEntriesRequestQuery; + space?: string; +}): Promise => { + const route = routeWithNamespace(ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_FIND, space); + const response = await supertest + .get(route) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .send(); + if (response.status !== 200) { + throw new Error( + `Unexpected non 200 ok when attempting to find entries: ${JSON.stringify( + response.status + )},${JSON.stringify(response, null, 4)}` + ); + } else { + return response.body; + } +}; + +/** + * Finds Knowledge Base Entries on behalf of a given User + * @param supertest The supertest deps + * @param log The tooling logger + * @param user The user to perform search on behalf of + * @param params Params for find API (optional) + * @param space The Kibana Space to find entries in (optional) + */ +export const findEntriesForUser = async ({ + supertestWithoutAuth, + log, + user, + params, + space, +}: { + supertestWithoutAuth: SuperTest.Agent; + log: ToolingLog; + user: User; + params?: FindKnowledgeBaseEntriesRequestQuery; + space?: string; +}): Promise => { + const route = routeWithNamespace(ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_FIND, space); + const response = await supertestWithoutAuth + .get(route) + .auth(user.username, user.password) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .send(); + if (response.status !== 200) { + throw new Error( + `Unexpected non 200 ok when attempting to find entries: ${JSON.stringify( + response.status + )},${JSON.stringify(response, null, 4)}` + ); + } else { + return response.body; + } +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/helpers.ts b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/helpers.ts new file mode 100644 index 0000000000000..62174da6bce4c --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/helpers.ts @@ -0,0 +1,96 @@ +/* + * 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 { Client } from '@elastic/elasticsearch'; +import { + CreateKnowledgeBaseResponse, + ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_URL, +} from '@kbn/elastic-assistant-common'; + +import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; +import type { ToolingLog } from '@kbn/tooling-log'; +import type SuperTest from 'supertest'; +import { MachineLearningProvider } from '../../../../../../functional/services/ml'; +import { SUPPORTED_TRAINED_MODELS } from '../../../../../../functional/services/ml/api'; + +import { routeWithNamespace } from '../../../../../../common/utils/security_solution'; + +export const TINY_ELSER = { + ...SUPPORTED_TRAINED_MODELS.TINY_ELSER, + id: SUPPORTED_TRAINED_MODELS.TINY_ELSER.name, +}; + +/** + * Installs `pt_tiny_elser` model for testing Kb features + * @param ml + */ +export const installTinyElser = async (ml: ReturnType) => { + const config = { + ...ml.api.getTrainedModelConfig(TINY_ELSER.name), + input: { + field_names: ['text_field'], + }, + }; + await ml.api.assureMlStatsIndexExists(); + await ml.api.importTrainedModel(TINY_ELSER.name, TINY_ELSER.id, config); +}; + +/** + * Deletes `pt_tiny_elser` model for testing Kb features + * @param ml + */ +export const deleteTinyElser = async (ml: ReturnType) => { + await ml.api.stopTrainedModelDeploymentES(TINY_ELSER.id, true); + await ml.api.deleteTrainedModelES(TINY_ELSER.id); + await ml.api.cleanMlIndices(); + await ml.testResources.cleanMLSavedObjects(); +}; + +/** + * Setup Knowledge Base + * @param supertest The supertest deps + * @param log The tooling logger + * @param resource + * @param namespace The Kibana Space where the KB should be set up + */ +export const setupKnowledgeBase = async ( + supertest: SuperTest.Agent, + log: ToolingLog, + resource?: string, + namespace?: string +): Promise => { + const path = ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_URL.replace('{resource?}', resource || ''); + const route = routeWithNamespace(`${path}?modelId=pt_tiny_elser`, namespace); + const response = await supertest + .post(route) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .send(); + if (response.status !== 200) { + throw new Error( + `Unexpected non 200 ok when attempting to setup Knowledge Base: ${JSON.stringify( + response.status + )},${JSON.stringify(response, null, 4)}` + ); + } else { + return response.body; + } +}; + +/** + * Clear Knowledge Base + * @param es + * @param space + */ +export const clearKnowledgeBase = async (es: Client, space = 'default') => { + return es.deleteByQuery({ + index: `.kibana-elastic-ai-assistant-knowledge-base-${space}`, + conflicts: 'proceed', + query: { match_all: {} }, + refresh: true, + }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/remove_server_generated_properties.ts b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/remove_server_generated_properties.ts new file mode 100644 index 0000000000000..1dd9e66b98198 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/remove_server_generated_properties.ts @@ -0,0 +1,36 @@ +/* + * 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 { omit, pickBy } from 'lodash'; +import { KnowledgeBaseEntryCreateProps } from '@kbn/elastic-assistant-common'; + +const serverGeneratedProperties = [ + 'id', + 'createdAt', + 'createdBy', + 'updatedAt', + 'updatedBy', + 'vector', +] as const; + +type ServerGeneratedProperties = (typeof serverGeneratedProperties)[number]; +export type EntryWithoutServerGeneratedProperties = Omit< + KnowledgeBaseEntryCreateProps, + ServerGeneratedProperties +>; + +/** + * This will remove server generated properties such as date times, etc... + * @param entry KnowledgeBaseEntryCreateProps to pass in to remove typical server generated properties + */ +export const removeServerGeneratedProperties = ( + entry: KnowledgeBaseEntryCreateProps +): EntryWithoutServerGeneratedProperties => { + const removedProperties = omit(entry, serverGeneratedProperties); + + // We're only removing undefined values, so this cast correctly narrows the type + return pickBy(removedProperties, (value) => value !== undefined) as KnowledgeBaseEntryCreateProps; +};