diff --git a/.eslintrc.js b/.eslintrc.js index 39133952bc3c3..109be71acddcb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -921,6 +921,7 @@ module.exports = { 'x-pack/plugins/profiling/**/*.tsx', 'x-pack/plugins/synthetics/**/*.tsx', 'x-pack/plugins/ux/**/*.tsx', + 'src/plugins/ai_assistant_management/**/*.tsx', ], rules: { '@kbn/telemetry/event_generating_elements_should_be_instrumented': 'error', @@ -938,6 +939,7 @@ module.exports = { 'x-pack/plugins/profiling/**/!(*.stories.tsx|*.test.tsx|*.storybook_decorator.tsx|*.mock.tsx)', 'x-pack/plugins/synthetics/**/!(*.stories.tsx|*.test.tsx|*.storybook_decorator.tsx|*.mock.tsx)', 'x-pack/plugins/ux/**/!(*.stories.tsx|*.test.tsx|*.storybook_decorator.tsx|*.mock.tsx)', + 'src/plugins/ai_assistant_management/**/!(*.stories.tsx|*.test.tsx|*.storybook_decorator.tsx|*.mock.tsx)', ], rules: { '@kbn/i18n/strings_should_be_translated_with_i18n': 'warn', diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index df7b20e7b3baa..cfcef5a6f740d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -11,6 +11,8 @@ x-pack/plugins/actions @elastic/response-ops x-pack/test/alerting_api_integration/common/plugins/actions_simulators @elastic/response-ops packages/kbn-actions-types @elastic/response-ops src/plugins/advanced_settings @elastic/appex-sharedux @elastic/platform-deployment-management +src/plugins/ai_assistant_management/observability @elastic/obs-knowledge-team +src/plugins/ai_assistant_management/selection @elastic/obs-knowledge-team x-pack/packages/ml/aiops_components @elastic/ml-ui x-pack/plugins/aiops @elastic/ml-ui x-pack/packages/ml/aiops_utils @elastic/ml-ui diff --git a/.i18nrc.json b/.i18nrc.json index a6caadbf0cf51..2869094e7e152 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -1,6 +1,8 @@ { "paths": { "advancedSettings": "src/plugins/advanced_settings", + "aiAssistantManagementSelection": "src/plugins/ai_assistant_management/selection", + "aiAssistantManagementObservability": "src/plugins/ai_assistant_management/observability", "alerts": "packages/kbn-alerts/src", "alertsUIShared": "packages/kbn-alerts-ui-shared/src", "alertingTypes": "packages/kbn-alerting-types", diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index cb233fc8c10ce..3e2b2ba853398 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -28,6 +28,14 @@ allowing users to configure their advanced settings, also known as uiSettings within the code. +|{kib-repo}blob/{branch}/src/plugins/ai_assistant_management/observability/README.md[aiAssistantManagementObservability] +|The aiAssistantManagementObservability plugin manages the Ai Assistant for Observability management section. + + +|{kib-repo}blob/{branch}/src/plugins/ai_assistant_management/selection/README.md[aiAssistantManagementSelection] +|The aiAssistantManagementSelection plugin manages the Ai Assistant management section. + + |{kib-repo}blob/{branch}/src/plugins/bfetch/README.md[bfetch] |bfetch allows to batch HTTP requests and streams responses back. diff --git a/package.json b/package.json index 090a80413334b..be000b3b1967e 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,8 @@ "@kbn/actions-simulators-plugin": "link:x-pack/test/alerting_api_integration/common/plugins/actions_simulators", "@kbn/actions-types": "link:packages/kbn-actions-types", "@kbn/advanced-settings-plugin": "link:src/plugins/advanced_settings", + "@kbn/ai-assistant-management-observability-plugin": "link:src/plugins/ai_assistant_management/observability", + "@kbn/ai-assistant-management-plugin": "link:src/plugins/ai_assistant_management/selection", "@kbn/aiops-components": "link:x-pack/packages/ml/aiops_components", "@kbn/aiops-plugin": "link:x-pack/plugins/aiops", "@kbn/aiops-utils": "link:x-pack/packages/ml/aiops_utils", diff --git a/packages/deeplinks/management/deep_links.ts b/packages/deeplinks/management/deep_links.ts index 0e9b16dd7dc8f..2b055f42e214e 100644 --- a/packages/deeplinks/management/deep_links.ts +++ b/packages/deeplinks/management/deep_links.ts @@ -27,6 +27,8 @@ export type IntegrationsDeepLinkId = IntegrationsAppId | FleetAppId | OsQueryApp // Management export type ManagementAppId = typeof MANAGEMENT_APP_ID; export type ManagementId = + | 'aiAssistantManagementSelection' + | 'aiAssistantManagementObservability' | 'api_keys' | 'cases' | 'cross_cluster_replication' diff --git a/packages/default-nav/management/default_navigation.ts b/packages/default-nav/management/default_navigation.ts index 062163625f565..d7839b226da46 100644 --- a/packages/default-nav/management/default_navigation.ts +++ b/packages/default-nav/management/default_navigation.ts @@ -119,6 +119,9 @@ export const defaultNavigation: ManagementNodeDefinition = { { link: 'management:dataViews', }, + { + link: 'management:aiAssistantManagementSelection', + }, { // Saved objects link: 'management:objects', diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 178de997e86b8..ef99652ee767f 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -1,6 +1,8 @@ pageLoadAssetSize: actions: 20000 advancedSettings: 27596 + aiAssistantManagementObservability: 19279 + aiAssistantManagementSelection: 19146 aiops: 10000 alerting: 106936 apm: 64385 diff --git a/packages/shared-ux/chrome/navigation/mocks/src/navlinks.ts b/packages/shared-ux/chrome/navigation/mocks/src/navlinks.ts index b0adfd22734fc..bcc718ec36566 100644 --- a/packages/shared-ux/chrome/navigation/mocks/src/navlinks.ts +++ b/packages/shared-ux/chrome/navigation/mocks/src/navlinks.ts @@ -29,6 +29,8 @@ const allNavLinks: AppDeepLinkId[] = [ 'fleet', 'integrations', 'management', + 'management:aiAssistantManagementSelection', + 'management:aiAssistantManagementObservability', 'management:api_keys', 'management:cases', 'management:cross_cluster_replication', diff --git a/src/plugins/ai_assistant_management/observability/README.md b/src/plugins/ai_assistant_management/observability/README.md new file mode 100644 index 0000000000000..dfe70a6400a4e --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/README.md @@ -0,0 +1,3 @@ +# `aiAssistantManagementObservability` plugin + +The `aiAssistantManagementObservability` plugin manages the `Ai Assistant for Observability` management section. diff --git a/src/plugins/ai_assistant_management/observability/jest.config.js b/src/plugins/ai_assistant_management/observability/jest.config.js new file mode 100644 index 0000000000000..8c274bc5e029b --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/jest.config.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/src/plugins/ai_assistant_management/observability'], + coverageDirectory: '/target/kibana-coverage/jest/src/plugins/ai_assistant_management', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/src/plugins/ai_assistant_management/observability/{common,public,server}/**/*.{ts,tsx}', + ], +}; diff --git a/src/plugins/ai_assistant_management/observability/kibana.jsonc b/src/plugins/ai_assistant_management/observability/kibana.jsonc new file mode 100644 index 0000000000000..6ddb104cbf16d --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/kibana.jsonc @@ -0,0 +1,13 @@ +{ + "type": "plugin", + "id": "@kbn/ai-assistant-management-observability-plugin", + "owner": "@elastic/obs-knowledge-team", + "plugin": { + "id": "aiAssistantManagementObservability", + "server": false, + "browser": true, + "requiredPlugins": ["management"], + "optionalPlugins": ["actions", "home", "observabilityAIAssistant", "serverless"], + "requiredBundles": ["kibanaReact"] + } +} diff --git a/src/plugins/ai_assistant_management/observability/public/app.tsx b/src/plugins/ai_assistant_management/observability/public/app.tsx new file mode 100644 index 0000000000000..667e104dd6466 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/app.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { I18nProvider } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { CoreSetup } from '@kbn/core/public'; +import { wrapWithTheme } from '@kbn/kibana-react-plugin/public'; +import { ManagementAppMountParams } from '@kbn/management-plugin/public'; +import { StartDependencies, AiAssistantManagementObservabilityPluginStart } from './plugin'; +import { aIAssistantManagementObservabilityRouter } from './routes/config'; +import { RedirectToHomeIfUnauthorized } from './routes/components/redirect_to_home_if_unauthorized'; +import { AppContextProvider } from './context/app_context'; + +interface MountParams { + core: CoreSetup; + mountParams: ManagementAppMountParams; +} + +export const mountManagementSection = async ({ core, mountParams }: MountParams) => { + const [coreStart, startDeps] = await core.getStartServices(); + + if (!startDeps.observabilityAIAssistant) return () => {}; + + const { element, history, setBreadcrumbs } = mountParams; + const { theme$ } = core.theme; + + coreStart.chrome.docTitle.change( + i18n.translate('aiAssistantManagementObservability.app.titleBar', { + defaultMessage: 'AI Assistant for Observability Settings', + }) + ); + + const queryClient = new QueryClient(); + + ReactDOM.render( + wrapWithTheme( + + + + + + + + + + + , + theme$ + ), + element + ); + + return () => { + coreStart.chrome.docTitle.reset(); + ReactDOM.unmountComponentAtNode(element); + }; +}; diff --git a/src/plugins/ai_assistant_management/observability/public/constants.ts b/src/plugins/ai_assistant_management/observability/public/constants.ts new file mode 100644 index 0000000000000..48d8fb9b2da59 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/constants.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const REACT_QUERY_KEYS = { + GET_GENAI_CONNECTORS: 'get_genai_connectors', + GET_KB_ENTRIES: 'get_kb_entries', + CREATE_KB_ENTRIES: 'create_kb_entry', + IMPORT_KB_ENTRIES: 'import_kb_entry', +}; diff --git a/src/plugins/ai_assistant_management/observability/public/context/app_context.tsx b/src/plugins/ai_assistant_management/observability/public/context/app_context.tsx new file mode 100644 index 0000000000000..832701e9aed94 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/context/app_context.tsx @@ -0,0 +1,34 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { createContext } from 'react'; +import type { ChromeBreadcrumb } from '@kbn/core-chrome-browser'; +import type { CoreStart, HttpSetup } from '@kbn/core/public'; +import type { ObservabilityAIAssistantPluginStart } from '@kbn/observability-ai-assistant-plugin/public'; +import type { StartDependencies } from '../plugin'; + +export interface ContextValue extends StartDependencies { + application: CoreStart['application']; + http: HttpSetup; + notifications: CoreStart['notifications']; + observabilityAIAssistant: ObservabilityAIAssistantPluginStart; + setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; + uiSettings: CoreStart['uiSettings']; +} + +export const AppContext = createContext(null as any); + +export const AppContextProvider = ({ + children, + value, +}: { + value: ContextValue; + children: React.ReactNode; +}) => { + return {children}; +}; diff --git a/src/plugins/ai_assistant_management/observability/public/helpers/categorize_entries.ts b/src/plugins/ai_assistant_management/observability/public/helpers/categorize_entries.ts new file mode 100644 index 0000000000000..e9937b1df1215 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/helpers/categorize_entries.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { KnowledgeBaseEntry } from '@kbn/observability-ai-assistant-plugin/common/types'; + +export interface KnowledgeBaseEntryCategory { + '@timestamp': string; + categoryName: string; + entries: KnowledgeBaseEntry[]; +} + +export function categorizeEntries({ entries }: { entries: KnowledgeBaseEntry[] }) { + return entries.reduce((acc, entry) => { + const categoryName = entry.labels.category ?? entry.id; + + const index = acc.findIndex((item) => item.categoryName === categoryName); + + if (index > -1) { + acc[index].entries.push(entry); + return acc; + } else { + return acc.concat({ categoryName, entries: [entry], '@timestamp': entry['@timestamp'] }); + } + }, [] as Array<{ categoryName: string; entries: KnowledgeBaseEntry[]; '@timestamp': string }>); +} diff --git a/src/plugins/ai_assistant_management/observability/public/helpers/test_helper.tsx b/src/plugins/ai_assistant_management/observability/public/helpers/test_helper.tsx new file mode 100644 index 0000000000000..2e1b8e0def9b4 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/helpers/test_helper.tsx @@ -0,0 +1,89 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { createMemoryHistory } from 'history'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render as testLibRender } from '@testing-library/react'; +import { coreMock } from '@kbn/core/public/mocks'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import translations from '@kbn/translations-plugin/translations/ja-JP.json'; + +import { mockObservabilityAIAssistantService } from '@kbn/observability-ai-assistant-plugin/public'; +import { RouterProvider } from '@kbn/typed-react-router-config'; +import { AppContextProvider } from '../context/app_context'; +import { RedirectToHomeIfUnauthorized } from '../routes/components/redirect_to_home_if_unauthorized'; +import { aIAssistantManagementObservabilityRouter } from '../routes/config'; + +export const coreStart = coreMock.createStart(); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + logger: { + // eslint-disable-next-line no-console + log: console.log, + // eslint-disable-next-line no-console + warn: console.warn, + error: () => {}, + }, +}); + +export const render = (component: React.ReactNode, params?: { show: boolean }) => { + const history = createMemoryHistory(); + + return testLibRender( + // @ts-ignore + + + ({ + loading: false, + selectConnector: () => {}, + reloadConnectors: () => {}, + }), + }, + uiSettings: coreStart.uiSettings, + setBreadcrumbs: () => {}, + }} + > + + + {component} + + + + + + ); +}; diff --git a/src/plugins/ai_assistant_management/observability/public/hooks/use_app_context.tsx b/src/plugins/ai_assistant_management/observability/public/hooks/use_app_context.tsx new file mode 100644 index 0000000000000..ddc215a648a09 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/hooks/use_app_context.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useContext } from 'react'; +import { AppContext, ContextValue } from '../context/app_context'; + +export const useAppContext = () => { + const ctx = useContext(AppContext); + if (!ctx) { + throw new Error('"useAppContext" can only be called inside of AppContext.Provider!'); + } + return ctx; +}; diff --git a/src/plugins/ai_assistant_management/observability/public/hooks/use_create_knowledge_base_entry.ts b/src/plugins/ai_assistant_management/observability/public/hooks/use_create_knowledge_base_entry.ts new file mode 100644 index 0000000000000..5d367a8e66023 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/hooks/use_create_knowledge_base_entry.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import type { KnowledgeBaseEntry } from '@kbn/observability-ai-assistant-plugin/common/types'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useAppContext } from './use_app_context'; +import { REACT_QUERY_KEYS } from '../constants'; + +type ServerError = IHttpFetchError; + +export function useCreateKnowledgeBaseEntry() { + const { + notifications: { toasts }, + observabilityAIAssistant, + } = useAppContext(); + const queryClient = useQueryClient(); + const observabilityAIAssistantApi = observabilityAIAssistant.service.callApi; + + return useMutation< + void, + ServerError, + { + entry: Omit< + KnowledgeBaseEntry, + '@timestamp' | 'confidence' | 'is_correction' | 'public' | 'labels' | 'role' + >; + } + >( + [REACT_QUERY_KEYS.CREATE_KB_ENTRIES], + ({ entry }) => { + if (!observabilityAIAssistantApi) { + return Promise.reject('Error with observabilityAIAssistantApi: API not found.'); + } + + return observabilityAIAssistantApi?.( + 'POST /internal/observability_ai_assistant/kb/entries/save', + { + signal: null, + params: { + body: { + ...entry, + role: 'user_entry', + }, + }, + } + ); + }, + { + onSuccess: (_data, { entry }) => { + toasts.addSuccess( + i18n.translate( + 'aiAssistantManagementObservability.kb.addManualEntry.successNotification', + { + defaultMessage: 'Successfully created {name}', + values: { name: entry.id }, + } + ) + ); + + queryClient.invalidateQueries({ + queryKey: [REACT_QUERY_KEYS.GET_KB_ENTRIES], + refetchType: 'all', + }); + }, + onError: (error, { entry }) => { + toasts.addError(new Error(error.body?.message ?? error.message), { + title: i18n.translate( + 'aiAssistantManagementObservability.kb.addManualEntry.errorNotification', + { + defaultMessage: 'Something went wrong while creating {name}', + values: { name: entry.id }, + } + ), + }); + }, + } + ); +} diff --git a/src/plugins/ai_assistant_management/observability/public/hooks/use_delete_knowledge_base_entry.ts b/src/plugins/ai_assistant_management/observability/public/hooks/use_delete_knowledge_base_entry.ts new file mode 100644 index 0000000000000..3c07a1c047cb0 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/hooks/use_delete_knowledge_base_entry.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useAppContext } from './use_app_context'; +import { REACT_QUERY_KEYS } from '../constants'; + +type ServerError = IHttpFetchError; + +export function useDeleteKnowledgeBaseEntry() { + const { + observabilityAIAssistant, + notifications: { toasts }, + } = useAppContext(); + const queryClient = useQueryClient(); + const observabilityAIAssistantApi = observabilityAIAssistant.service.callApi; + + return useMutation( + [REACT_QUERY_KEYS.CREATE_KB_ENTRIES], + ({ id: entryId }) => { + if (!observabilityAIAssistantApi) { + return Promise.reject('Error with observabilityAIAssistantApi: API not found.'); + } + + return observabilityAIAssistantApi?.( + 'DELETE /internal/observability_ai_assistant/kb/entries/{entryId}', + { + signal: null, + params: { + path: { + entryId, + }, + }, + } + ); + }, + { + onSuccess: (_data, { id }) => { + toasts.addSuccess( + i18n.translate( + 'aiAssistantManagementObservability.kb.deleteManualEntry.successNotification', + { + defaultMessage: 'Successfully deleted {id}', + values: { id }, + } + ) + ); + + queryClient.invalidateQueries({ + queryKey: [REACT_QUERY_KEYS.GET_KB_ENTRIES], + refetchType: 'all', + }); + }, + onError: (error, { id }) => { + toasts.addError(new Error(error.body?.message ?? error.message), { + title: i18n.translate( + 'aiAssistantManagementObservability.kb.deleteManualEntry.errorNotification', + { + defaultMessage: 'Something went wrong while deleting {name}', + values: { name: id }, + } + ), + }); + }, + } + ); +} diff --git a/src/plugins/ai_assistant_management/observability/public/hooks/use_get_knowledge_base_entries.ts b/src/plugins/ai_assistant_management/observability/public/hooks/use_get_knowledge_base_entries.ts new file mode 100644 index 0000000000000..ca0be9aa77944 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/hooks/use_get_knowledge_base_entries.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useQuery } from '@tanstack/react-query'; +import { REACT_QUERY_KEYS } from '../constants'; +import { useAppContext } from './use_app_context'; + +export function useGetKnowledgeBaseEntries({ + query, + sortBy, + sortDirection, +}: { + query: string; + sortBy: string; + sortDirection: 'asc' | 'desc'; +}) { + const { observabilityAIAssistant } = useAppContext(); + + const observabilityAIAssistantApi = observabilityAIAssistant.service.callApi; + + const { isLoading, isError, isSuccess, isRefetching, data, refetch } = useQuery({ + queryKey: [REACT_QUERY_KEYS.GET_KB_ENTRIES, query, sortBy, sortDirection], + queryFn: async ({ signal }) => { + if (!observabilityAIAssistantApi || !signal) { + return Promise.reject('Error with observabilityAIAssistantApi: API not found.'); + } + + return observabilityAIAssistantApi(`GET /internal/observability_ai_assistant/kb/entries`, { + signal, + params: { + query: { + query, + sortBy, + sortDirection, + }, + }, + }); + }, + keepPreviousData: true, + refetchOnWindowFocus: false, + }); + + return { + entries: data?.entries, + refetch, + isLoading, + isRefetching, + isSuccess, + isError, + }; +} diff --git a/src/plugins/ai_assistant_management/observability/public/hooks/use_import_knowledge_base_entries.ts b/src/plugins/ai_assistant_management/observability/public/hooks/use_import_knowledge_base_entries.ts new file mode 100644 index 0000000000000..399d234b651d6 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/hooks/use_import_knowledge_base_entries.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import type { KnowledgeBaseEntry } from '@kbn/observability-ai-assistant-plugin/common/types'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useAppContext } from './use_app_context'; +import { REACT_QUERY_KEYS } from '../constants'; + +type ServerError = IHttpFetchError; + +export function useImportKnowledgeBaseEntries() { + const { + observabilityAIAssistant, + notifications: { toasts }, + } = useAppContext(); + const queryClient = useQueryClient(); + const observabilityAIAssistantApi = observabilityAIAssistant.service.callApi; + + return useMutation< + void, + ServerError, + { + entries: Array< + Omit< + KnowledgeBaseEntry, + '@timestamp' | 'confidence' | 'is_correction' | 'public' | 'labels' + > + >; + } + >( + [REACT_QUERY_KEYS.IMPORT_KB_ENTRIES], + ({ entries }) => { + if (!observabilityAIAssistantApi) { + return Promise.reject('Error with observabilityAIAssistantApi: API not found.'); + } + + return observabilityAIAssistantApi?.( + 'POST /internal/observability_ai_assistant/kb/entries/import', + { + signal: null, + params: { + body: { + entries, + }, + }, + } + ); + }, + { + onSuccess: (_data, { entries }) => { + toasts.addSuccess( + i18n.translate( + 'aiAssistantManagementObservability.kb.importEntries.successNotification', + { + defaultMessage: 'Successfully imported {number} items', + values: { number: entries.length }, + } + ) + ); + + queryClient.invalidateQueries({ + queryKey: [REACT_QUERY_KEYS.GET_KB_ENTRIES], + refetchType: 'all', + }); + }, + onError: (error) => { + toasts.addError(new Error(error.body?.message ?? error.message), { + title: i18n.translate( + 'aiAssistantManagementObservability.kb.importEntries.errorNotification', + { + defaultMessage: 'Something went wrong while importing items', + } + ), + }); + }, + } + ); +} diff --git a/src/plugins/ai_assistant_management/observability/public/hooks/use_observability_management_params.ts b/src/plugins/ai_assistant_management/observability/public/hooks/use_observability_management_params.ts new file mode 100644 index 0000000000000..d76b05824e3c7 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/hooks/use_observability_management_params.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { type PathsOf, type TypeOf, useParams } from '@kbn/typed-react-router-config'; +import type { AIAssistantManagementObservabilityRoutes } from '../routes/config'; + +export function useObservabilityAIAssistantManagementRouterParams< + TPath extends PathsOf +>(path: TPath): TypeOf { + return useParams(path)! as TypeOf; +} diff --git a/src/plugins/ai_assistant_management/observability/public/hooks/use_observability_management_router.ts b/src/plugins/ai_assistant_management/observability/public/hooks/use_observability_management_router.ts new file mode 100644 index 0000000000000..2336c812262e8 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/hooks/use_observability_management_router.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PathsOf, TypeAsArgs, TypeOf } from '@kbn/typed-react-router-config'; +import { useMemo } from 'react'; +import { useHistory } from 'react-router-dom'; +import { useAppContext } from './use_app_context'; +import { + AIAssistantManagementObservabilityRouter, + AIAssistantManagementObservabilityRoutes, +} from '../routes/config'; +import { aIAssistantManagementObservabilityRouter } from '../routes/config'; + +interface StatefulObservabilityAIAssistantRouter extends AIAssistantManagementObservabilityRouter { + push>( + path: T, + ...params: TypeAsArgs> + ): void; + replace>( + path: T, + ...params: TypeAsArgs> + ): void; +} + +export function useObservabilityAIAssistantManagementRouter(): StatefulObservabilityAIAssistantRouter { + const history = useHistory(); + + const { http } = useAppContext(); + + const link = (...args: any[]) => { + // @ts-ignore + return aIAssistantManagementObservabilityRouter.link(...args); + }; + + return useMemo( + () => ({ + ...aIAssistantManagementObservabilityRouter, + push: (...args) => { + const next = link(...args); + + history.push(next); + }, + replace: (path, ...args) => { + const next = link(path, ...args); + history.replace(next); + }, + link: (path, ...args) => { + return http.basePath.prepend( + '/app/management/aiAssistantManagementObservability' + link(path, ...args) + ); + }, + }), + [http, history] + ); +} diff --git a/src/plugins/ai_assistant_management/observability/public/index.ts b/src/plugins/ai_assistant_management/observability/public/index.ts new file mode 100644 index 0000000000000..afbcf68bf4be3 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { AiAssistantManagementObservabilityPlugin as AiAssistantManagementObservabilityPlugin } from './plugin'; + +export type { + AiAssistantManagementObservabilityPluginSetup, + AiAssistantManagementObservabilityPluginStart, +} from './plugin'; + +export function plugin() { + return new AiAssistantManagementObservabilityPlugin(); +} diff --git a/src/plugins/ai_assistant_management/observability/public/plugin.ts b/src/plugins/ai_assistant_management/observability/public/plugin.ts new file mode 100644 index 0000000000000..7da6f9999b2f8 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/plugin.ts @@ -0,0 +1,90 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { CoreSetup, Plugin } from '@kbn/core/public'; +import { ManagementSetup } from '@kbn/management-plugin/public'; +import { HomePublicPluginSetup } from '@kbn/home-plugin/public'; +import { ServerlessPluginStart } from '@kbn/serverless/public'; +import type { + ObservabilityAIAssistantPluginSetup, + ObservabilityAIAssistantPluginStart, +} from '@kbn/observability-ai-assistant-plugin/public'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface AiAssistantManagementObservabilityPluginSetup {} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface AiAssistantManagementObservabilityPluginStart {} + +export interface SetupDependencies { + management: ManagementSetup; + home?: HomePublicPluginSetup; + observabilityAIAssistant?: ObservabilityAIAssistantPluginSetup; +} + +export interface StartDependencies { + observabilityAIAssistant?: ObservabilityAIAssistantPluginStart; + serverless?: ServerlessPluginStart; +} + +export class AiAssistantManagementObservabilityPlugin + implements + Plugin< + AiAssistantManagementObservabilityPluginSetup, + AiAssistantManagementObservabilityPluginStart, + SetupDependencies, + StartDependencies + > +{ + public setup( + core: CoreSetup, + { home, management, observabilityAIAssistant }: SetupDependencies + ): AiAssistantManagementObservabilityPluginSetup { + const title = i18n.translate('aiAssistantManagementObservability.app.title', { + defaultMessage: 'AI Assistant for Observability', + }); + + if (home) { + home.featureCatalogue.register({ + id: 'ai_assistant_observability', + title, + description: i18n.translate('aiAssistantManagementObservability.app.description', { + defaultMessage: 'Manage your AI Assistant for Observability.', + }), + icon: 'sparkles', + path: '/app/management/kibana/ai-assistant/observability', + showOnHomePage: false, + category: 'admin', + }); + } + + if (observabilityAIAssistant) { + management.sections.section.kibana.registerApp({ + id: 'aiAssistantManagementObservability', + title, + hideFromSidebar: true, + order: 1, + mount: async (mountParams) => { + const { mountManagementSection } = await import('./app'); + + return mountManagementSection({ + core, + mountParams, + }); + }, + }); + } + + return {}; + } + + public start() { + return {}; + } +} diff --git a/src/plugins/ai_assistant_management/observability/public/routes/components/knowledge_base_bulk_import_flyout.tsx b/src/plugins/ai_assistant_management/observability/public/routes/components/knowledge_base_bulk_import_flyout.tsx new file mode 100644 index 0000000000000..f860505e8a3f0 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/routes/components/knowledge_base_bulk_import_flyout.tsx @@ -0,0 +1,194 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButton, + EuiButtonEmpty, + EuiCode, + EuiCodeBlock, + EuiFilePicker, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiHorizontalRule, + EuiIcon, + EuiSpacer, + EuiText, + EuiTitle, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { KnowledgeBaseEntry } from '@kbn/observability-ai-assistant-plugin/common/types'; +import { useImportKnowledgeBaseEntries } from '../../hooks/use_import_knowledge_base_entries'; +import { useAppContext } from '../../hooks/use_app_context'; + +export function KnowledgeBaseBulkImportFlyout({ onClose }: { onClose: () => void }) { + const { + notifications: { toasts }, + } = useAppContext(); + + const { mutateAsync, isLoading } = useImportKnowledgeBaseEntries(); + + const filePickerId = useGeneratedHtmlId({ prefix: 'filePicker' }); + + const [files, setFiles] = useState([]); + + const onChange = (file: FileList | null) => { + setFiles(file && file.length > 0 ? Array.from(file) : []); + }; + + const handleSubmitNewEntryClick = async () => { + let entries: Array> = []; + const text = await files[0].text(); + + const elements = text.split('\n').filter(Boolean); + + try { + entries = elements.map((el) => JSON.parse(el)) as Array< + Omit + >; + } catch (_) { + toasts.addError( + new Error( + i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseBulkImportFlyout.errorParsingEntries.description', + { + defaultMessage: 'Error parsing JSON entries', + } + ) + ), + { + title: i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseBulkImportFlyout.errorParsingEntries.title', + { + defaultMessage: 'Something went wrong', + } + ), + } + ); + } + + mutateAsync({ entries }).then(onClose); + }; + + return ( + + + +

+ {i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseBulkImportFlyout.h2.bulkImportLabel', + { defaultMessage: 'Import files' } + )} +

+
+
+ + + + + + + + +

+ {i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseBulkImportFlyout.addFilesToEnrichTitleLabel', + { defaultMessage: 'Add files to enrich your Knowledge base' } + )} +

+
+
+
+ + + + + .ndjson, + }} + /> + + + + + + {i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseBulkImportFlyout.theObjectsShouldBeTextLabel', + { defaultMessage: 'The objects should be of the following format:' } + )} + + + + + + {`{ + "id": "a_unique_human_readable_id", + "text": "Contents of item", +} +`} + + + + + +
+ + + + + + {i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseBulkImportFlyout.cancelButtonEmptyLabel', + { defaultMessage: 'Cancel' } + )} + + + + + {i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseBulkImportFlyout.saveButtonLabel', + { defaultMessage: 'Save' } + )} + + + + +
+ ); +} diff --git a/src/plugins/ai_assistant_management/observability/public/routes/components/knowledge_base_category_flyout.tsx b/src/plugins/ai_assistant_management/observability/public/routes/components/knowledge_base_category_flyout.tsx new file mode 100644 index 0000000000000..63a92c4cd504f --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/routes/components/knowledge_base_category_flyout.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiBadge, + EuiBasicTable, + EuiBasicTableColumn, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { capitalize } from 'lodash'; +import type { KnowledgeBaseEntry } from '@kbn/observability-ai-assistant-plugin/common/types'; +import moment from 'moment'; +import { useDeleteKnowledgeBaseEntry } from '../../hooks/use_delete_knowledge_base_entry'; +import { KnowledgeBaseEntryCategory } from '../../helpers/categorize_entries'; +import { useAppContext } from '../../hooks/use_app_context'; + +const CATEGORY_MAP = { + lens: { + description: ( + <> + + {i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseCategoryFlyout.categoryMap.lensCategoryDescriptionLabel', + { + defaultMessage: + 'Lens is a Kibana feature which allows the Assistant to visualize data in response to user queries. These Knowledge base items are loaded into the Knowledge base by default.', + } + )} + + + ), + }, +}; + +export function KnowledgeBaseCategoryFlyout({ + category, + onClose, +}: { + category: KnowledgeBaseEntryCategory; + onClose: () => void; +}) { + const { uiSettings } = useAppContext(); + const dateFormat = uiSettings.get('dateFormat'); + + const { mutate: deleteEntry } = useDeleteKnowledgeBaseEntry(); + + const columns: Array> = [ + { + field: '@timestamp', + name: i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseCategoryFlyout.actions.dateCreated', + { + defaultMessage: 'Date created', + } + ), + sortable: true, + render: (timestamp: KnowledgeBaseEntry['@timestamp']) => ( + {moment(timestamp).format(dateFormat)} + ), + }, + { + field: 'id', + name: i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseCategoryFlyout.actions.name', + { + defaultMessage: 'Name', + } + ), + sortable: true, + width: '340px', + }, + { + name: 'Actions', + actions: [ + { + name: i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseCategoryFlyout.actions.delete', + { + defaultMessage: 'Delete', + } + ), + description: i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseCategoryFlyout.actions.deleteDescription', + { defaultMessage: 'Delete this entry' } + ), + type: 'icon', + icon: 'trash', + onClick: ({ id }) => { + deleteEntry({ id }); + }, + }, + ], + }, + ]; + + const hasDescription = + CATEGORY_MAP[category.categoryName as unknown as keyof typeof CATEGORY_MAP]?.description; + + return ( + + + +

{capitalize(category.categoryName)}

+
+
+ + {hasDescription ? ( + hasDescription + ) : ( + columns={columns} items={category.entries ?? []} /> + )} + +
+ ); +} diff --git a/src/plugins/ai_assistant_management/observability/public/routes/components/knowledge_base_edit_manual_entry_flyout.tsx b/src/plugins/ai_assistant_management/observability/public/routes/components/knowledge_base_edit_manual_entry_flyout.tsx new file mode 100644 index 0000000000000..80918e00a768d --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/routes/components/knowledge_base_edit_manual_entry_flyout.tsx @@ -0,0 +1,189 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiFormRow, + EuiMarkdownEditor, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import moment from 'moment'; +import type { KnowledgeBaseEntry } from '@kbn/observability-ai-assistant-plugin/common/types'; +import { useCreateKnowledgeBaseEntry } from '../../hooks/use_create_knowledge_base_entry'; +import { useDeleteKnowledgeBaseEntry } from '../../hooks/use_delete_knowledge_base_entry'; +import { useAppContext } from '../../hooks/use_app_context'; + +export function KnowledgeBaseEditManualEntryFlyout({ + entry, + onClose, +}: { + entry?: KnowledgeBaseEntry; + onClose: () => void; +}) { + const { uiSettings } = useAppContext(); + const dateFormat = uiSettings.get('dateFormat'); + + const { mutateAsync: createEntry, isLoading } = useCreateKnowledgeBaseEntry(); + const { mutateAsync: deleteEntry, isLoading: isDeleting } = useDeleteKnowledgeBaseEntry(); + + const [newEntryId, setNewEntryId] = useState(entry?.id ?? ''); + const [newEntryText, setNewEntryText] = useState(entry?.text ?? ''); + + const handleSubmitNewEntryClick = async () => { + createEntry({ + entry: { + id: newEntryId, + doc_id: newEntryId, + text: newEntryText, + }, + }).then(onClose); + }; + + const handleDelete = async () => { + await deleteEntry({ id: entry!.id }); + onClose(); + }; + + return ( + + + +

+ {!entry + ? i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseNewEntryFlyout.h2.newEntryLabel', + { + defaultMessage: 'New entry', + } + ) + : i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseNewEntryFlyout.h2.editEntryLabel', + { + defaultMessage: 'Edit {id}', + values: { id: entry.id }, + } + )} +

+
+
+ + + {!entry ? ( + + setNewEntryId(e.target.value)} + /> + + ) : ( + + + + {i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseEditManualEntryFlyout.createdOnTextLabel', + { defaultMessage: 'Created on' } + )} + + {moment(entry['@timestamp']).format(dateFormat)} + + + + {i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseEditManualEntryFlyout.deleteEntryButtonLabel', + { defaultMessage: 'Delete entry' } + )} + + + + )} + + + + + setNewEntryText(text)} + /> + + + + + + + + {i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseNewManualEntryFlyout.cancelButtonEmptyLabel', + { defaultMessage: 'Cancel' } + )} + + + + + {i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseNewManualEntryFlyout.saveButtonLabel', + { defaultMessage: 'Save' } + )} + + + + +
+ ); +} diff --git a/src/plugins/ai_assistant_management/observability/public/routes/components/knowledge_base_tab.test.tsx b/src/plugins/ai_assistant_management/observability/public/routes/components/knowledge_base_tab.test.tsx new file mode 100644 index 0000000000000..a7abaca3210f6 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/routes/components/knowledge_base_tab.test.tsx @@ -0,0 +1,180 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { fireEvent } from '@testing-library/react'; +import { render } from '../../helpers/test_helper'; +import { useCreateKnowledgeBaseEntry } from '../../hooks/use_create_knowledge_base_entry'; +import { useDeleteKnowledgeBaseEntry } from '../../hooks/use_delete_knowledge_base_entry'; +import { useGetKnowledgeBaseEntries } from '../../hooks/use_get_knowledge_base_entries'; +import { useImportKnowledgeBaseEntries } from '../../hooks/use_import_knowledge_base_entries'; +import { KnowledgeBaseTab } from './knowledge_base_tab'; + +jest.mock('../../hooks/use_get_knowledge_base_entries'); +jest.mock('../../hooks/use_create_knowledge_base_entry'); +jest.mock('../../hooks/use_import_knowledge_base_entries'); +jest.mock('../../hooks/use_delete_knowledge_base_entry'); + +const useGetKnowledgeBaseEntriesMock = useGetKnowledgeBaseEntries as jest.Mock; +const useCreateKnowledgeBaseEntryMock = useCreateKnowledgeBaseEntry as jest.Mock; +const useImportKnowledgeBaseEntriesMock = useImportKnowledgeBaseEntries as jest.Mock; +const useDeleteKnowledgeBaseEntryMock = useDeleteKnowledgeBaseEntry as jest.Mock; + +const createMock = jest.fn(() => Promise.resolve()); +const importMock = jest.fn(() => Promise.resolve()); +const deleteMock = jest.fn(() => Promise.resolve()); + +describe('KnowledgeBaseTab', () => { + beforeEach(() => { + useGetKnowledgeBaseEntriesMock.mockReturnValue({ + loading: false, + entries: [], + }); + + useDeleteKnowledgeBaseEntryMock.mockReturnValue({ + mutateAsync: deleteMock, + isLoading: false, + }); + }); + + it('should render a table', () => { + const { getByTestId } = render(); + expect(getByTestId('knowledgeBaseTable')).toBeInTheDocument(); + }); + + describe('when creating a new item', () => { + beforeEach(() => { + useCreateKnowledgeBaseEntryMock.mockReturnValue({ + mutateAsync: createMock, + isLoading: false, + }); + }); + + it('should render a manual import flyout', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('knowledgeBaseNewEntryButton')); + + fireEvent.click(getByTestId('knowledgeBaseSingleEntryContextMenuItem')); + + expect(getByTestId('knowledgeBaseManualEntryFlyout')).toBeInTheDocument(); + }); + + it('should allow creating of an item', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('knowledgeBaseNewEntryButton')); + + fireEvent.click(getByTestId('knowledgeBaseSingleEntryContextMenuItem')); + + fireEvent.click(getByTestId('knowledgeBaseEditManualEntryFlyoutFieldText')); + + fireEvent.change(getByTestId('knowledgeBaseEditManualEntryFlyoutFieldText'), { + target: { value: 'foo' }, + }); + + getByTestId('knowledgeBaseEditManualEntryFlyoutSaveButton').click(); + + expect(createMock).toHaveBeenCalledWith({ entry: { id: 'foo', doc_id: 'foo', text: '' } }); + }); + }); + + describe('when importing a file', () => { + beforeEach(() => { + useImportKnowledgeBaseEntriesMock.mockReturnValue({ + mutateAsync: importMock, + isLoading: false, + }); + }); + + it('should render an import flyout', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('knowledgeBaseNewEntryButton')); + + fireEvent.click(getByTestId('knowledgeBaseBulkImportContextMenuItem')); + + expect(getByTestId('knowledgeBaseBulkImportFlyout')).toBeInTheDocument(); + }); + }); + + describe('when there are entries', () => { + beforeEach(() => { + useGetKnowledgeBaseEntriesMock.mockReturnValue({ + loading: false, + entries: [ + { + id: 'test', + doc_id: 'test', + text: 'test', + '@timestamp': 1638340456, + labels: {}, + role: 'user_entry', + }, + { + id: 'test2', + doc_id: 'test2', + text: 'test', + '@timestamp': 1638340456, + labels: { + category: 'lens', + }, + role: 'elastic', + }, + { + id: 'test3', + doc_id: 'test3', + text: 'test', + '@timestamp': 1638340456, + labels: { + category: 'lens', + }, + role: 'elastic', + }, + ], + }); + + useImportKnowledgeBaseEntriesMock.mockReturnValue({ + mutateAsync: importMock, + isLoading: false, + }); + + useDeleteKnowledgeBaseEntryMock.mockReturnValue({ + mutateAsync: deleteMock, + }); + }); + + describe('when selecting an item', () => { + it('should render an edit flyout when clicking on an entry', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('knowledgeBaseTable').querySelectorAll('tbody tr')[0]); + + expect(getByTestId('knowledgeBaseManualEntryFlyout')).toBeInTheDocument(); + }); + + it('should be able to delete an item', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('knowledgeBaseTable').querySelectorAll('tbody tr')[0]); + + fireEvent.click(getByTestId('knowledgeBaseEditManualEntryFlyoutDeleteEntryButton')); + + expect(deleteMock).toHaveBeenCalledWith({ id: 'test' }); + }); + + it('should render a category flyout when clicking on a categorized item', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('knowledgeBaseTable').querySelectorAll('tbody tr')[1]); + + expect(getByTestId('knowledgeBaseCategoryFlyout')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/plugins/ai_assistant_management/observability/public/routes/components/knowledge_base_tab.tsx b/src/plugins/ai_assistant_management/observability/public/routes/components/knowledge_base_tab.tsx new file mode 100644 index 0000000000000..2da5fa0607f94 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/routes/components/knowledge_base_tab.tsx @@ -0,0 +1,343 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + Criteria, + EuiBadge, + EuiBasicTable, + EuiBasicTableColumn, + EuiButton, + EuiButtonIcon, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPopover, + EuiScreenReaderOnly, +} from '@elastic/eui'; +import moment from 'moment'; +import type { KnowledgeBaseEntry } from '@kbn/observability-ai-assistant-plugin/common/types'; +import { useAppContext } from '../../hooks/use_app_context'; +import { useGetKnowledgeBaseEntries } from '../../hooks/use_get_knowledge_base_entries'; +import { categorizeEntries, KnowledgeBaseEntryCategory } from '../../helpers/categorize_entries'; +import { KnowledgeBaseEditManualEntryFlyout } from './knowledge_base_edit_manual_entry_flyout'; +import { KnowledgeBaseCategoryFlyout } from './knowledge_base_category_flyout'; +import { KnowledgeBaseBulkImportFlyout } from './knowledge_base_bulk_import_flyout'; + +export function KnowledgeBaseTab() { + const { uiSettings } = useAppContext(); + const dateFormat = uiSettings.get('dateFormat'); + + const columns: Array> = [ + { + align: 'right', + width: '40px', + isExpander: true, + name: ( + + + {i18n.translate('aiAssistantManagementObservability.span.expandRowLabel', { + defaultMessage: 'Expand row', + })} + + + ), + render: (category: KnowledgeBaseEntryCategory) => { + return ( + setSelectedCategory(category)} + aria-label={ + category.categoryName === selectedCategory?.categoryName ? 'Collapse' : 'Expand' + } + iconType={ + category.categoryName === selectedCategory?.categoryName ? 'minimize' : 'expand' + } + /> + ); + }, + }, + { + field: '', + name: '', + render: (category: KnowledgeBaseEntryCategory) => { + if (category.entries.length === 1 && category.entries[0].role === 'user_entry') { + return ; + } + if ( + category.entries.length === 1 && + category.entries[0].role === 'assistant_summarization' + ) { + return ; + } + + return ; + }, + width: '40px', + }, + { + field: 'categoryName', + name: i18n.translate('aiAssistantManagementObservability.kbTab.columns.name', { + defaultMessage: 'Name', + }), + sortable: true, + }, + { + name: i18n.translate('aiAssistantManagementObservability.kbTab.columns.numberOfEntries', { + defaultMessage: 'Number of entries', + }), + width: '140px', + render: (category: KnowledgeBaseEntryCategory) => { + if (category.entries.length > 1 && category.entries[0].role === 'elastic') { + return {category.entries.length}; + } + return null; + }, + }, + { + field: '@timestamp', + name: i18n.translate('aiAssistantManagementObservability.kbTab.columns.dateCreated', { + defaultMessage: 'Date created', + }), + width: '140px', + sortable: true, + render: (timestamp: KnowledgeBaseEntry['@timestamp']) => ( + {moment(timestamp).format(dateFormat)} + ), + }, + { + name: i18n.translate('aiAssistantManagementObservability.kbTab.columns.type', { + defaultMessage: 'Type', + }), + width: '140px', + render: (category: KnowledgeBaseEntryCategory) => { + if (category.entries.length === 1 && category.entries[0].role === 'user_entry') { + return ( + + {i18n.translate('aiAssistantManagementObservability.kbTab.columns.manualBadgeLabel', { + defaultMessage: 'Manual', + })} + + ); + } + + if ( + category.entries.length === 1 && + category.entries[0].role === 'assistant_summarization' + ) { + return ( + + {i18n.translate( + 'aiAssistantManagementObservability.kbTab.columns.assistantSummarization', + { + defaultMessage: 'Assistant', + } + )} + + ); + } + + return ( + + {i18n.translate('aiAssistantManagementObservability.columns.systemBadgeLabel', { + defaultMessage: 'System', + })} + + ); + }, + }, + ]; + + const [selectedCategory, setSelectedCategory] = useState< + KnowledgeBaseEntryCategory | undefined + >(); + + const [flyoutOpenType, setFlyoutOpenType] = useState< + 'singleEntry' | 'bulkImport' | 'category' | undefined + >(); + + const [newEntryPopoverOpen, setNewEntryPopoverOpen] = useState(false); + const [query, setQuery] = useState(''); + const [sortBy, setSortBy] = useState<'doc_id' | '@timestamp'>('doc_id'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + + const { + entries = [], + isLoading, + refetch, + } = useGetKnowledgeBaseEntries({ query, sortBy, sortDirection }); + const categories = categorizeEntries({ entries }); + + const handleChangeSort = ({ + sort, + }: Criteria) => { + if (sort) { + const { field, direction } = sort; + if (field === '@timestamp') { + setSortBy(field); + } + if (field === 'categoryName') { + setSortBy('doc_id'); + } + setSortDirection(direction); + } + }; + + const handleClickNewEntry = () => { + setNewEntryPopoverOpen(true); + }; + + const handleChangeQuery = (e: React.ChangeEvent | undefined) => { + setQuery(e?.currentTarget.value || ''); + }; + + return ( + <> + + + + + + + + refetch()} + > + {i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseTab.reloadButtonLabel', + { defaultMessage: 'Reload' } + )} + + + + setNewEntryPopoverOpen(false)} + button={ + + {i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseTab.newEntryButtonLabel', + { + defaultMessage: 'New entry', + } + )} + + } + > + { + setNewEntryPopoverOpen(false); + setFlyoutOpenType('singleEntry'); + }} + size="s" + > + {i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseTab.singleEntryContextMenuItemLabel', + { defaultMessage: 'Single entry' } + )} + , + { + setNewEntryPopoverOpen(false); + setFlyoutOpenType('bulkImport'); + }} + > + {i18n.translate( + 'aiAssistantManagementObservability.knowledgeBaseTab.bulkImportContextMenuItemLabel', + { defaultMessage: 'Bulk import' } + )} + , + ]} + /> + + + + + + + + data-test-subj="knowledgeBaseTable" + columns={columns} + items={categories} + loading={isLoading} + sorting={{ + sort: { + field: sortBy === 'doc_id' ? 'categoryName' : sortBy, + direction: sortDirection, + }, + }} + rowProps={(row) => ({ + onClick: () => setSelectedCategory(row), + })} + onChange={handleChangeSort} + /> + + + + {flyoutOpenType === 'singleEntry' ? ( + setFlyoutOpenType(undefined)} /> + ) : null} + + {flyoutOpenType === 'bulkImport' ? ( + setFlyoutOpenType(undefined)} /> + ) : null} + + {selectedCategory ? ( + selectedCategory.entries.length === 1 && + (selectedCategory.entries[0].role === 'user_entry' || + selectedCategory.entries[0].role === 'assistant_summarization') ? ( + setSelectedCategory(undefined)} + /> + ) : ( + setSelectedCategory(undefined)} + /> + ) + ) : null} + + ); +} diff --git a/src/plugins/ai_assistant_management/observability/public/routes/components/redirect_to_home_if_unauthorized.tsx b/src/plugins/ai_assistant_management/observability/public/routes/components/redirect_to_home_if_unauthorized.tsx new file mode 100644 index 0000000000000..57113836b0652 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/routes/components/redirect_to_home_if_unauthorized.tsx @@ -0,0 +1,31 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { ReactNode } from 'react'; +import type { CoreStart } from '@kbn/core/public'; +export function RedirectToHomeIfUnauthorized({ + coreStart, + children, +}: { + coreStart: CoreStart; + children: ReactNode; +}) { + const { + application: { capabilities, navigateToApp }, + } = coreStart; + + const allowed = + (capabilities?.management && capabilities?.observabilityAIAssistant?.show) ?? false; + + if (!allowed) { + navigateToApp('home'); + return null; + } + + return <>{children}; +} diff --git a/src/plugins/ai_assistant_management/observability/public/routes/components/settings_page.test.tsx b/src/plugins/ai_assistant_management/observability/public/routes/components/settings_page.test.tsx new file mode 100644 index 0000000000000..39918a564860b --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/routes/components/settings_page.test.tsx @@ -0,0 +1,58 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { useAppContext } from '../../hooks/use_app_context'; +import { coreStart, render } from '../../helpers/test_helper'; +import { SettingsPage } from './settings_page'; + +jest.mock('../../hooks/use_app_context'); + +const useAppContextMock = useAppContext as jest.Mock; + +const setBreadcrumbs = jest.fn(); +const navigateToApp = jest.fn(); + +describe('Settings Page', () => { + beforeEach(() => { + useAppContextMock.mockReturnValue({ + observabilityAIAssistant: { + useGenAIConnectors: () => ({ connectors: [] }), + }, + setBreadcrumbs, + application: { navigateToApp }, + }); + }); + + it('should navigate to home when not authorized', () => { + render(, { show: false }); + + expect(coreStart.application.navigateToApp).toBeCalledWith('home'); + }); + + it('should render settings and knowledge base tabs', () => { + const { getByTestId } = render(); + + expect(getByTestId('settingsPageTab-settings')).toBeInTheDocument(); + expect(getByTestId('settingsPageTab-knowledge_base')).toBeInTheDocument(); + }); + + it('should set breadcrumbs', () => { + render(); + + expect(setBreadcrumbs).toHaveBeenCalledWith([ + { + text: 'AI Assistants', + onClick: expect.any(Function), + }, + { + text: 'Observability', + }, + ]); + }); +}); diff --git a/src/plugins/ai_assistant_management/observability/public/routes/components/settings_page.tsx b/src/plugins/ai_assistant_management/observability/public/routes/components/settings_page.tsx new file mode 100644 index 0000000000000..4c7ad7f8aa738 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/routes/components/settings_page.tsx @@ -0,0 +1,123 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiSpacer, EuiTab, EuiTabs, EuiTitle } from '@elastic/eui'; +import { useAppContext } from '../../hooks/use_app_context'; +import { SettingsTab } from './settings_tab'; +import { KnowledgeBaseTab } from './knowledge_base_tab'; +import { useObservabilityAIAssistantManagementRouterParams } from '../../hooks/use_observability_management_params'; +import { useObservabilityAIAssistantManagementRouter } from '../../hooks/use_observability_management_router'; +import type { TabsRt } from '../config'; +export function SettingsPage() { + const { + application: { navigateToApp }, + serverless, + setBreadcrumbs, + } = useAppContext(); + + const router = useObservabilityAIAssistantManagementRouter(); + + const { + query: { tab }, + } = useObservabilityAIAssistantManagementRouterParams('/'); + + useEffect(() => { + if (serverless) { + serverless.setBreadcrumbs([ + { + text: i18n.translate( + 'aiAssistantManagementObservability.breadcrumb.serverless.observability', + { + defaultMessage: 'AI Assistant for Observability Settings', + } + ), + }, + ]); + } else { + setBreadcrumbs([ + { + text: i18n.translate('aiAssistantManagementObservability.breadcrumb.index', { + defaultMessage: 'AI Assistants', + }), + onClick: (e) => { + e.preventDefault(); + navigateToApp('management', { path: '/kibana/aiAssistantManagementSelection' }); + }, + }, + { + text: i18n.translate('aiAssistantManagementObservability.breadcrumb.observability', { + defaultMessage: 'Observability', + }), + }, + ]); + } + }, [navigateToApp, serverless, setBreadcrumbs]); + + const tabs: Array<{ id: TabsRt; name: string; content: JSX.Element }> = [ + { + id: 'settings', + name: i18n.translate('aiAssistantManagementObservability.settingsPage.settingsLabel', { + defaultMessage: 'Settings', + }), + content: , + }, + { + id: 'knowledge_base', + name: i18n.translate('aiAssistantManagementObservability.settingsPage.knowledgeBaseLabel', { + defaultMessage: 'Knowledge base', + }), + content: , + }, + ]; + + const [selectedTabId, setSelectedTabId] = useState( + tab ? tabs.find((t) => t.id === tab)?.id : tabs[0].id + ); + + const selectedTabContent = tabs.find((obj) => obj.id === selectedTabId)?.content; + + const onSelectedTabChanged = (id: TabsRt) => { + setSelectedTabId(id); + router.push('/', { path: '/', query: { tab: id } }); + }; + + return ( + <> + +

+ {i18n.translate('aiAssistantManagementObservability.settingsPage.h2.settingsLabel', { + defaultMessage: 'Settings', + })} +

+
+ + + + + {tabs.map((t, index) => ( + onSelectedTabChanged(t.id)} + isSelected={t.id === selectedTabId} + > + {t.name} + + ))} + + + + + {selectedTabContent} + + + + ); +} diff --git a/src/plugins/ai_assistant_management/observability/public/routes/components/settings_tab.test.tsx b/src/plugins/ai_assistant_management/observability/public/routes/components/settings_tab.test.tsx new file mode 100644 index 0000000000000..990abfa0078ec --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/routes/components/settings_tab.test.tsx @@ -0,0 +1,66 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { fireEvent } from '@testing-library/react'; +import { render } from '../../helpers/test_helper'; +import { useAppContext } from '../../hooks/use_app_context'; +import { SettingsTab } from './settings_tab'; + +jest.mock('../../hooks/use_app_context'); + +const useAppContextMock = useAppContext as jest.Mock; + +const navigateToAppMock = jest.fn(() => Promise.resolve()); +const selectConnectorMock = jest.fn(); + +describe('SettingsTab', () => { + beforeEach(() => { + useAppContextMock.mockReturnValue({ + application: { navigateToApp: navigateToAppMock }, + observabilityAIAssistant: { + useGenAIConnectors: () => ({ + connectors: [ + { name: 'openAi', id: 'openAi' }, + { name: 'azureOpenAi', id: 'azureOpenAi' }, + { name: 'bedrock', id: 'bedrock' }, + ], + selectConnector: selectConnectorMock, + }), + }, + }); + }); + + it('should offer a way to configure Observability AI Assistant visibility in apps', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('settingsTabGoToSpacesButton')); + + expect(navigateToAppMock).toBeCalledWith('management', { path: '/kibana/spaces' }); + }); + + it('should offer a way to configure Gen AI connectors', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('settingsTabGoToConnectorsButton')); + + expect(navigateToAppMock).toBeCalledWith('management', { + path: '/insightsAndAlerting/triggersActionsConnectors/connectors', + }); + }); + + it('should allow selection of a configured Observability AI Assistant connector', () => { + const { getByTestId } = render(); + + fireEvent.change(getByTestId('settingsTabGenAIConnectorSelect'), { + target: { value: 'bedrock' }, + }); + + expect(selectConnectorMock).toBeCalledWith('bedrock'); + }); +}); diff --git a/src/plugins/ai_assistant_management/observability/public/routes/components/settings_tab.tsx b/src/plugins/ai_assistant_management/observability/public/routes/components/settings_tab.tsx new file mode 100644 index 0000000000000..9385f1e42f899 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/routes/components/settings_tab.tsx @@ -0,0 +1,187 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { + EuiButton, + EuiDescribedFormGroup, + EuiForm, + EuiFormRow, + EuiPanel, + EuiSelect, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useAppContext } from '../../hooks/use_app_context'; + +export const SELECTED_CONNECTOR_LOCAL_STORAGE_KEY = + 'xpack.observabilityAiAssistant.lastUsedConnector'; + +export function SettingsTab() { + const { + application: { navigateToApp }, + observabilityAIAssistant, + } = useAppContext(); + + const { + connectors = [], + selectedConnector, + selectConnector, + } = observabilityAIAssistant.useGenAIConnectors(); + + const selectorOptions = connectors.map((connector) => ({ + text: connector.name, + value: connector.id, + })); + + const handleNavigateToConnectors = () => { + navigateToApp('management', { + path: '/insightsAndAlerting/triggersActionsConnectors/connectors', + }); + }; + + const handleNavigateToSpacesConfiguration = () => { + navigateToApp('management', { + path: '/kibana/spaces', + }); + }; + + return ( + <> + + + + {i18n.translate( + 'aiAssistantManagementObservability.settingsPage.showAIAssistantButtonLabel', + { + defaultMessage: + 'Show AI Assistant button and Contextual Insights in Observability apps', + } + )} + + } + description={ +

+ {i18n.translate( + 'aiAssistantManagementObservability.settingsPage.showAIAssistantDescriptionLabel', + { + defaultMessage: + 'Toggle the AI Assistant button and Contextual Insights on or off in Observability apps by checking or unchecking the AI Assistant feature in Spaces > > Features.', + } + )} +

+ } + > + +
+ + {i18n.translate( + 'aiAssistantManagementObservability.settingsPage.goToFeatureControlsButtonLabel', + { defaultMessage: 'Go to Spaces' } + )} + +
+
+
+
+
+ + + + + + + {i18n.translate( + 'aiAssistantManagementObservability.settingsPage.connectorSettingsLabel', + { + defaultMessage: 'Connector settings', + } + )} + + } + description={i18n.translate( + 'aiAssistantManagementObservability.settingsPage.euiDescribedFormGroup.inOrderToUseLabel', + { + defaultMessage: + 'In order to use the Observability AI Assistant you must set up a Generative AI connector.', + } + )} + > + +
+ + {i18n.translate( + 'aiAssistantManagementObservability.settingsPage.goToConnectorsButtonLabel', + { + defaultMessage: 'Manage connectors', + } + )} + +
+
+
+ + + {i18n.translate( + 'aiAssistantManagementObservability.settingsPage.h4.selectDefaultConnectorLabel', + { defaultMessage: 'Default connector' } + )} + + } + description={i18n.translate( + 'aiAssistantManagementObservability.settingsPage.connectYourElasticAITextLabel', + { + defaultMessage: + 'Select the Generative AI connector you want to use as the default for the Observability AI Assistant.', + } + )} + > + + { + selectConnector(e.target.value); + }} + aria-label={i18n.translate( + 'aiAssistantManagementObservability.settingsPage.euiSelect.generativeAIProviderLabel', + { defaultMessage: 'Generative AI provider' } + )} + /> + + +
+
+ + ); +} diff --git a/src/plugins/ai_assistant_management/observability/public/routes/config.tsx b/src/plugins/ai_assistant_management/observability/public/routes/config.tsx new file mode 100644 index 0000000000000..447d817c95e10 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/public/routes/config.tsx @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import * as t from 'io-ts'; +import { createRouter } from '@kbn/typed-react-router-config'; +import { SettingsPage } from './components/settings_page'; + +const Tabs = t.union([t.literal('settings'), t.literal('knowledge_base'), t.undefined]); +export type TabsRt = t.TypeOf; + +const aIAssistantManagementObservabilityRoutes = { + '/': { + element: , + params: t.type({ + query: t.partial({ + tab: Tabs, + }), + }), + }, +}; + +export type AIAssistantManagementObservabilityRoutes = + typeof aIAssistantManagementObservabilityRoutes; + +export const aIAssistantManagementObservabilityRouter = createRouter( + aIAssistantManagementObservabilityRoutes +); + +export type AIAssistantManagementObservabilityRouter = + typeof aIAssistantManagementObservabilityRouter; diff --git a/src/plugins/ai_assistant_management/observability/tsconfig.json b/src/plugins/ai_assistant_management/observability/tsconfig.json new file mode 100644 index 0000000000000..09822d6179850 --- /dev/null +++ b/src/plugins/ai_assistant_management/observability/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": ["public/**/*"], + "kbn_references": [ + "@kbn/core", + "@kbn/home-plugin", + "@kbn/kibana-react-plugin", + "@kbn/management-plugin", + "@kbn/i18n", + "@kbn/i18n-react", + "@kbn/typed-react-router-config", + "@kbn/core-chrome-browser", + "@kbn/observability-ai-assistant-plugin", + "@kbn/serverless", + "@kbn/translations-plugin" + ], + "exclude": ["target/**/*"] +} diff --git a/src/plugins/ai_assistant_management/selection/README.md b/src/plugins/ai_assistant_management/selection/README.md new file mode 100644 index 0000000000000..ad92dad37166b --- /dev/null +++ b/src/plugins/ai_assistant_management/selection/README.md @@ -0,0 +1,3 @@ +# `aiAssistantManagementSelection` plugin + +The `aiAssistantManagementSelection` plugin manages the `Ai Assistant` management section. diff --git a/src/plugins/ai_assistant_management/selection/jest.config.js b/src/plugins/ai_assistant_management/selection/jest.config.js new file mode 100644 index 0000000000000..e2dd57982e455 --- /dev/null +++ b/src/plugins/ai_assistant_management/selection/jest.config.js @@ -0,0 +1,19 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/src/plugins/ai_assistant_management/selection'], + coverageDirectory: + '/target/kibana-coverage/jest/src/plugins/ai_assistant_management/selection', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/src/plugins/ai_assistant_management/selection/{common,public,server}/**/*.{ts,tsx}', + ], +}; diff --git a/src/plugins/ai_assistant_management/selection/kibana.jsonc b/src/plugins/ai_assistant_management/selection/kibana.jsonc new file mode 100644 index 0000000000000..7db0b04b877d6 --- /dev/null +++ b/src/plugins/ai_assistant_management/selection/kibana.jsonc @@ -0,0 +1,13 @@ +{ + "type": "plugin", + "id": "@kbn/ai-assistant-management-plugin", + "owner": "@elastic/obs-knowledge-team", + "plugin": { + "id": "aiAssistantManagementSelection", + "server": false, + "browser": true, + "requiredPlugins": ["management"], + "optionalPlugins": ["home", "serverless"], + "requiredBundles": ["kibanaReact"] + } +} diff --git a/src/plugins/ai_assistant_management/selection/public/app_context.tsx b/src/plugins/ai_assistant_management/selection/public/app_context.tsx new file mode 100644 index 0000000000000..9f7998b36800d --- /dev/null +++ b/src/plugins/ai_assistant_management/selection/public/app_context.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { createContext, useContext } from 'react'; +import type { ChromeBreadcrumb } from '@kbn/core-chrome-browser'; +import type { CoreStart } from '@kbn/core/public'; +import type { StartDependencies } from './plugin'; + +interface ContextValue extends StartDependencies { + setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; + + capabilities: CoreStart['application']['capabilities']; + navigateToApp: CoreStart['application']['navigateToApp']; +} + +const AppContext = createContext(null as any); + +export const AppContextProvider = ({ + children, + value, +}: { + value: ContextValue; + children: React.ReactNode; +}) => { + return {children}; +}; + +export const useAppContext = () => { + const ctx = useContext(AppContext); + if (!ctx) { + throw new Error('"useAppContext" can only be called inside of AppContext.Provider!'); + } + return ctx; +}; diff --git a/src/plugins/ai_assistant_management/selection/public/index.ts b/src/plugins/ai_assistant_management/selection/public/index.ts new file mode 100644 index 0000000000000..54d13961ca017 --- /dev/null +++ b/src/plugins/ai_assistant_management/selection/public/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { AiAssistantManagementPlugin } from './plugin'; + +export type { + AiAssistantManagementSelectionPluginSetup, + AiAssistantManagementSelectionPluginStart, +} from './plugin'; + +export function plugin() { + return new AiAssistantManagementPlugin(); +} diff --git a/src/plugins/ai_assistant_management/selection/public/management_section/mount_section.tsx b/src/plugins/ai_assistant_management/selection/public/management_section/mount_section.tsx new file mode 100644 index 0000000000000..9a957d862bd71 --- /dev/null +++ b/src/plugins/ai_assistant_management/selection/public/management_section/mount_section.tsx @@ -0,0 +1,65 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config'; +import { I18nProvider } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { CoreSetup } from '@kbn/core/public'; +import { wrapWithTheme } from '@kbn/kibana-react-plugin/public'; +import { ManagementAppMountParams } from '@kbn/management-plugin/public'; +import { StartDependencies, AiAssistantManagementSelectionPluginStart } from '../plugin'; +import { aIAssistantManagementSelectionRouter } from '../routes/config'; +import { RedirectToHomeIfUnauthorized } from '../routes/components/redirect_to_home_if_unauthorized'; +import { AppContextProvider } from '../app_context'; + +interface MountParams { + core: CoreSetup; + mountParams: ManagementAppMountParams; +} + +export const mountManagementSection = async ({ core, mountParams }: MountParams) => { + const [coreStart, startDeps] = await core.getStartServices(); + const { element, history, setBreadcrumbs } = mountParams; + const { theme$ } = core.theme; + + coreStart.chrome.docTitle.change( + i18n.translate('aiAssistantManagementSelection.app.titleBar', { + defaultMessage: 'AI Assistants', + }) + ); + + ReactDOM.render( + wrapWithTheme( + + + + + + + + + , + theme$ + ), + element + ); + + return () => { + coreStart.chrome.docTitle.reset(); + ReactDOM.unmountComponentAtNode(element); + }; +}; diff --git a/src/plugins/ai_assistant_management/selection/public/plugin.ts b/src/plugins/ai_assistant_management/selection/public/plugin.ts new file mode 100644 index 0000000000000..4658c9ec8795d --- /dev/null +++ b/src/plugins/ai_assistant_management/selection/public/plugin.ts @@ -0,0 +1,83 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { CoreSetup, Plugin } from '@kbn/core/public'; +import { ManagementSetup } from '@kbn/management-plugin/public'; +import { HomePublicPluginSetup } from '@kbn/home-plugin/public'; +import { ServerlessPluginSetup } from '@kbn/serverless/public'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface AiAssistantManagementSelectionPluginSetup {} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface AiAssistantManagementSelectionPluginStart {} + +export interface SetupDependencies { + management: ManagementSetup; + home?: HomePublicPluginSetup; + serverless?: ServerlessPluginSetup; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface StartDependencies {} + +export class AiAssistantManagementPlugin + implements + Plugin< + AiAssistantManagementSelectionPluginSetup, + AiAssistantManagementSelectionPluginStart, + SetupDependencies, + StartDependencies + > +{ + public setup( + core: CoreSetup, + { home, management, serverless }: SetupDependencies + ): AiAssistantManagementSelectionPluginSetup { + if (serverless) return {}; + + if (home) { + home.featureCatalogue.register({ + id: 'ai_assistant', + title: i18n.translate('aiAssistantManagementSelection.app.title', { + defaultMessage: 'AI Assistants', + }), + description: i18n.translate('aiAssistantManagementSelection.app.description', { + defaultMessage: 'Manage your AI Assistants.', + }), + icon: 'sparkles', + path: '/app/management/kibana/ai-assistant', + showOnHomePage: false, + category: 'admin', + }); + } + + management.sections.section.kibana.registerApp({ + id: 'aiAssistantManagementSelection', + title: i18n.translate('aiAssistantManagementSelection.managementSectionLabel', { + defaultMessage: 'AI Assistants', + }), + order: 1, + mount: async (mountParams) => { + const { mountManagementSection } = await import('./management_section/mount_section'); + + return mountManagementSection({ + core, + mountParams, + }); + }, + }); + + return {}; + } + + public start() { + return {}; + } +} diff --git a/src/plugins/ai_assistant_management/selection/public/routes/components/ai_assistant_selection_page.tsx b/src/plugins/ai_assistant_management/selection/public/routes/components/ai_assistant_selection_page.tsx new file mode 100644 index 0000000000000..51fe5e2b3700c --- /dev/null +++ b/src/plugins/ai_assistant_management/selection/public/routes/components/ai_assistant_selection_page.tsx @@ -0,0 +1,119 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiCallOut, + EuiCard, + EuiFlexGrid, + EuiFlexItem, + EuiIcon, + EuiLink, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { useAppContext } from '../../app_context'; + +export function AiAssistantSelectionPage() { + const { capabilities, setBreadcrumbs, navigateToApp } = useAppContext(); + + const observabilityAIAssistantEnabled = capabilities.observabilityAIAssistant.show; + + useEffect(() => { + setBreadcrumbs([ + { + text: i18n.translate('aiAssistantManagementSelection.breadcrumb.index', { + defaultMessage: 'AI Assistant', + }), + }, + ]); + }, [setBreadcrumbs]); + + return ( + <> + +

+ {i18n.translate( + 'aiAssistantManagementSelection.aiAssistantSettingsPage.h2.aIAssistantLabel', + { + defaultMessage: 'AI Assistant', + } + )} +

+
+ + + + + {i18n.translate( + 'aiAssistantManagementSelection.aiAssistantSettingsPage.descriptionTextLabel', + { + defaultMessage: + 'AI Assistants use generative AI to help your team by explaining errors, suggesting remediation, and helping you request, analyze, and visualize your data.', + } + )} + + + + + + + + {!observabilityAIAssistantEnabled ? ( + <> + + Features.', + } + )} + size="s" + /> + + + ) : null} + + {i18n.translate( + 'aiAssistantManagementSelection.aiAssistantSettingsPage.obsAssistant.documentationLinkLabel', + { defaultMessage: 'Documentation' } + )} + + + } + display="plain" + hasBorder + icon={} + isDisabled={!observabilityAIAssistantEnabled} + layout="horizontal" + title={i18n.translate( + 'aiAssistantManagementSelection.aiAssistantSelectionPage.observabilityLabel', + { defaultMessage: 'Elastic AI Assistant for Observability' } + )} + titleSize="xs" + onClick={() => + navigateToApp('management', { path: 'kibana/aiAssistantManagementObservability' }) + } + /> + + + + ); +} diff --git a/src/plugins/ai_assistant_management/selection/public/routes/components/redirect_to_home_if_unauthorized.tsx b/src/plugins/ai_assistant_management/selection/public/routes/components/redirect_to_home_if_unauthorized.tsx new file mode 100644 index 0000000000000..25e80b701a8e7 --- /dev/null +++ b/src/plugins/ai_assistant_management/selection/public/routes/components/redirect_to_home_if_unauthorized.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { ReactNode } from 'react'; +import type { CoreStart } from '@kbn/core/public'; +export function RedirectToHomeIfUnauthorized({ + coreStart, + children, +}: { + coreStart: CoreStart; + children: ReactNode; +}) { + const { + application: { capabilities, navigateToApp }, + } = coreStart; + + const allowed = capabilities?.management ?? false; + + if (!allowed) { + navigateToApp('home'); + return null; + } + + return <>{children}; +} diff --git a/src/plugins/ai_assistant_management/selection/public/routes/config.tsx b/src/plugins/ai_assistant_management/selection/public/routes/config.tsx new file mode 100644 index 0000000000000..c459041516dec --- /dev/null +++ b/src/plugins/ai_assistant_management/selection/public/routes/config.tsx @@ -0,0 +1,29 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { createRouter } from '@kbn/typed-react-router-config'; +import { AiAssistantSelectionPage } from './components/ai_assistant_selection_page'; + +/** + * The array of route definitions to be used when the application + * creates the routes. + */ +const aIAssistantManagementSelectionRoutes = { + '/': { + element: , + }, +}; + +export type AIAssistantManagementSelectionRoutes = typeof aIAssistantManagementSelectionRoutes; + +export const aIAssistantManagementSelectionRouter = createRouter( + aIAssistantManagementSelectionRoutes +); + +export type AIAssistantManagementSelectionRouter = typeof aIAssistantManagementSelectionRouter; diff --git a/src/plugins/ai_assistant_management/selection/tsconfig.json b/src/plugins/ai_assistant_management/selection/tsconfig.json new file mode 100644 index 0000000000000..2c287b2ee77de --- /dev/null +++ b/src/plugins/ai_assistant_management/selection/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": ["common/**/*", "public/**/*", "server/**/*"], + "kbn_references": [ + "@kbn/core", + "@kbn/home-plugin", + "@kbn/kibana-react-plugin", + "@kbn/management-plugin", + "@kbn/i18n", + "@kbn/i18n-react", + "@kbn/core-chrome-browser", + "@kbn/typed-react-router-config", + "@kbn/serverless" + ], + "exclude": ["target/**/*"] +} diff --git a/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx b/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx index bcd693ac8f56e..1dfe5ef633d71 100644 --- a/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx +++ b/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx @@ -35,11 +35,13 @@ export const managementSidebarNav = ({ const apps = sortBy(section.getAppsEnabled(), 'order'); if (apps.length) { - acc.push({ - ...createNavItem(section, { - items: appsToNavItems(apps), - }), - }); + if (!section.hideFromSidebar) { + acc.push({ + ...createNavItem(section, { + items: appsToNavItems(apps.filter((app) => !app.hideFromSidebar)), + }), + }); + } } return acc; diff --git a/src/plugins/management/public/types.ts b/src/plugins/management/public/types.ts index 7a5f1775f9a65..9201beeb57696 100644 --- a/src/plugins/management/public/types.ts +++ b/src/plugins/management/public/types.ts @@ -82,6 +82,7 @@ export interface CreateManagementItemArgs { order?: number; euiIconType?: string; // takes precedence over `icon` property. icon?: string; // URL to image file; fallback if no `euiIconType` + hideFromSidebar?: boolean; capabilitiesId?: string; // overrides app id redirectFrom?: string; // redirects from an old app id to the current app id } diff --git a/src/plugins/management/public/utils/management_item.ts b/src/plugins/management/public/utils/management_item.ts index e8d13b78460f2..69de0600d8936 100644 --- a/src/plugins/management/public/utils/management_item.ts +++ b/src/plugins/management/public/utils/management_item.ts @@ -13,6 +13,7 @@ export class ManagementItem { public readonly title: string; public readonly tip?: string; public readonly order: number; + public readonly hideFromSidebar?: boolean; public readonly euiIconType?: string; public readonly icon?: string; public readonly capabilitiesId?: string; @@ -25,6 +26,7 @@ export class ManagementItem { title, tip, order = 100, + hideFromSidebar = false, euiIconType, icon, capabilitiesId, @@ -34,6 +36,7 @@ export class ManagementItem { this.title = title; this.tip = tip; this.order = order; + this.hideFromSidebar = hideFromSidebar; this.euiIconType = euiIconType; this.icon = icon; this.capabilitiesId = capabilitiesId; diff --git a/src/plugins/management/server/capabilities_provider.ts b/src/plugins/management/server/capabilities_provider.ts index 184fd96880049..43fc19f71223b 100644 --- a/src/plugins/management/server/capabilities_provider.ts +++ b/src/plugins/management/server/capabilities_provider.ts @@ -16,6 +16,8 @@ export const capabilitiesProvider = () => ({ settings: true, indexPatterns: true, objects: true, + aiAssistantManagementSelection: true, + aiAssistantManagementObservability: true, }, }, }); diff --git a/tsconfig.base.json b/tsconfig.base.json index a4d383942bc34..9a6a7e7a868e0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -16,6 +16,10 @@ "@kbn/actions-types/*": ["packages/kbn-actions-types/*"], "@kbn/advanced-settings-plugin": ["src/plugins/advanced_settings"], "@kbn/advanced-settings-plugin/*": ["src/plugins/advanced_settings/*"], + "@kbn/ai-assistant-management-observability-plugin": ["src/plugins/ai_assistant_management/observability"], + "@kbn/ai-assistant-management-observability-plugin/*": ["src/plugins/ai_assistant_management/observability/*"], + "@kbn/ai-assistant-management-plugin": ["src/plugins/ai_assistant_management/selection"], + "@kbn/ai-assistant-management-plugin/*": ["src/plugins/ai_assistant_management/selection/*"], "@kbn/aiops-components": ["x-pack/packages/ml/aiops_components"], "@kbn/aiops-components/*": ["x-pack/packages/ml/aiops_components/*"], "@kbn/aiops-plugin": ["x-pack/plugins/aiops"], diff --git a/x-pack/plugins/apm/public/components/routing/app_root/index.tsx b/x-pack/plugins/apm/public/components/routing/app_root/index.tsx index e528d1625681c..8021fe3c6c88e 100644 --- a/x-pack/plugins/apm/public/components/routing/app_root/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/app_root/index.tsx @@ -76,7 +76,7 @@ export function ApmAppRoot({ > diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 80dfa77ca40d0..51a5587452261 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -427,7 +427,7 @@ export class ApmPlugin implements Plugin { public start(core: CoreStart, plugins: ApmPluginStartDeps) { const { fleet } = plugins; - plugins.observabilityAIAssistant.register( + plugins.observabilityAIAssistant.service.register( async ({ signal, registerContext, registerFunction }) => { const mod = await import('./assistant_functions'); diff --git a/x-pack/plugins/exploratory_view/public/application/application.test.tsx b/x-pack/plugins/exploratory_view/public/application/application.test.tsx index 2f51f32050a22..23203930616e4 100644 --- a/x-pack/plugins/exploratory_view/public/application/application.test.tsx +++ b/x-pack/plugins/exploratory_view/public/application/application.test.tsx @@ -13,6 +13,7 @@ import { AppMountParameters, CoreStart } from '@kbn/core/public'; import { themeServiceMock } from '@kbn/core/public/mocks'; import { ExploratoryViewPublicPluginsStart } from '../plugin'; import { renderApp } from '.'; +import { mockObservabilityAIAssistantService } from '@kbn/observability-ai-assistant-plugin/public'; describe('renderApp', () => { const originalConsole = global.console; @@ -28,7 +29,6 @@ describe('renderApp', () => { it('renders', async () => { const plugins = { - usageCollection: { reportUiCounter: noop }, data: { query: { timefilter: { @@ -42,6 +42,8 @@ describe('renderApp', () => { }, }, }, + usageCollection: { reportUiCounter: noop }, + observabilityAIAssistant: { service: mockObservabilityAIAssistantService }, } as unknown as ExploratoryViewPublicPluginsStart; const core = { diff --git a/x-pack/plugins/exploratory_view/public/application/index.tsx b/x-pack/plugins/exploratory_view/public/application/index.tsx index a534048540b2d..c59aa090f75b7 100644 --- a/x-pack/plugins/exploratory_view/public/application/index.tsx +++ b/x-pack/plugins/exploratory_view/public/application/index.tsx @@ -68,7 +68,7 @@ export const renderApp = ({ const ApplicationUsageTrackingProvider = usageCollection?.components.ApplicationUsageTrackingProvider ?? React.Fragment; - const aiAssistantService = plugins.observabilityAIAssistant; + const aiAssistantService = plugins.observabilityAIAssistant.service; ReactDOM.render( diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_details_app_section/index.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_details_app_section/index.tsx index 5d6f6e6a7ce80..05b8c53f9ded9 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_details_app_section/index.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_details_app_section/index.tsx @@ -44,7 +44,10 @@ const AlertDetailsAppSection = ({ alert, setAlertSummaryFields, }: AlertDetailsAppSectionProps) => { - const { logsShared, observabilityAIAssistant } = useKibanaContextForPlugin().services; + const { + logsShared, + observabilityAIAssistant: { service: observabilityAIAssistantService }, + } = useKibanaContextForPlugin().services; const theme = useTheme(); const timeRange = getPaddedAlertTimeRange(alert.fields[ALERT_START]!, alert.fields[ALERT_END]); const alertEnd = alert.fields[ALERT_END] ? moment(alert.fields[ALERT_END]).valueOf() : undefined; @@ -242,7 +245,7 @@ const AlertDetailsAppSection = ({ }; return ( - + {getLogRatioChart()} {getLogCountChart()} diff --git a/x-pack/plugins/infra/public/apps/common_providers.tsx b/x-pack/plugins/infra/public/apps/common_providers.tsx index b0eccab420fd6..67c13bad48c96 100644 --- a/x-pack/plugins/infra/public/apps/common_providers.tsx +++ b/x-pack/plugins/infra/public/apps/common_providers.tsx @@ -32,7 +32,7 @@ export const CommonInfraProviders: React.FC<{ }> = ({ children, triggersActionsUI, - observabilityAIAssistant, + observabilityAIAssistant: { service: observabilityAIAssistantService }, setHeaderActionMenu, appName, storage, @@ -44,7 +44,7 @@ export const CommonInfraProviders: React.FC<{ - + {children} diff --git a/x-pack/plugins/logs_shared/public/components/log_ai_assistant/index.tsx b/x-pack/plugins/logs_shared/public/components/log_ai_assistant/index.tsx index 8cf9b2da45c06..0bfb9f516209c 100644 --- a/x-pack/plugins/logs_shared/public/components/log_ai_assistant/index.tsx +++ b/x-pack/plugins/logs_shared/public/components/log_ai_assistant/index.tsx @@ -20,9 +20,9 @@ export type LogAIAssistantComponent = ComponentType< >; export function createLogAIAssistant({ - observabilityAIAssistant: aiAssistant, + observabilityAIAssistant: aiAssistantService, }: LogAIAssistantFactoryDeps): LogAIAssistantComponent { - return ({ observabilityAIAssistant = aiAssistant, ...props }) => ( + return ({ observabilityAIAssistant = aiAssistantService, ...props }) => ( ); } diff --git a/x-pack/plugins/logs_shared/public/components/log_ai_assistant/log_ai_assistant.tsx b/x-pack/plugins/logs_shared/public/components/log_ai_assistant/log_ai_assistant.tsx index e2b61d2115838..8a8c755d70ff7 100644 --- a/x-pack/plugins/logs_shared/public/components/log_ai_assistant/log_ai_assistant.tsx +++ b/x-pack/plugins/logs_shared/public/components/log_ai_assistant/log_ai_assistant.tsx @@ -27,7 +27,7 @@ export interface LogAIAssistantProps { } export interface LogAIAssistantDeps extends LogAIAssistantProps { - observabilityAIAssistant: ObservabilityAIAssistantPluginStart; + observabilityAIAssistant: ObservabilityAIAssistantPluginStart['service']; } export const LogAIAssistant = withProviders(({ doc }: LogAIAssistantProps) => { @@ -102,11 +102,11 @@ export default LogAIAssistant; function withProviders(Component: React.FunctionComponent) { return function ComponentWithProviders({ - observabilityAIAssistant, + observabilityAIAssistant: observabilityAIAssistantService, ...props }: LogAIAssistantDeps) { return ( - + ); diff --git a/x-pack/plugins/logs_shared/public/components/logging/log_entry_flyout/log_entry_flyout.tsx b/x-pack/plugins/logs_shared/public/components/logging/log_entry_flyout/log_entry_flyout.tsx index 99b89f0c1c432..2c5913d282e1d 100644 --- a/x-pack/plugins/logs_shared/public/components/logging/log_entry_flyout/log_entry_flyout.tsx +++ b/x-pack/plugins/logs_shared/public/components/logging/log_entry_flyout/log_entry_flyout.tsx @@ -101,7 +101,9 @@ export const LogEntryFlyout = ({ logViewReference, }: LogEntryFlyoutProps) => { const { - services: { observabilityAIAssistant }, + services: { + observabilityAIAssistant: { service: observabilityAIAssistantService }, + }, } = useKibanaContextForPlugin(); const { @@ -184,7 +186,10 @@ export const LogEntryFlyout = ({ > - + diff --git a/x-pack/plugins/logs_shared/public/plugin.ts b/x-pack/plugins/logs_shared/public/plugin.ts index 092e95570db7f..372ca2c124bcc 100644 --- a/x-pack/plugins/logs_shared/public/plugin.ts +++ b/x-pack/plugins/logs_shared/public/plugin.ts @@ -33,7 +33,9 @@ export class LogsSharedPlugin implements LogsSharedClientPluginClass { search: data.search, }); - const LogAIAssistant = createLogAIAssistant({ observabilityAIAssistant }); + const LogAIAssistant = createLogAIAssistant({ + observabilityAIAssistant: observabilityAIAssistant.service, + }); return { logViews, diff --git a/x-pack/plugins/observability/public/application/application.test.tsx b/x-pack/plugins/observability/public/application/application.test.tsx index 38c7ad4c2ecf2..eb7da89d3441d 100644 --- a/x-pack/plugins/observability/public/application/application.test.tsx +++ b/x-pack/plugins/observability/public/application/application.test.tsx @@ -15,6 +15,7 @@ import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; import { ConfigSchema, ObservabilityPublicPluginsStart } from '../plugin'; import { createObservabilityRuleTypeRegistryMock } from '../rules/observability_rule_type_registry_mock'; import { renderApp } from '.'; +import { mockObservabilityAIAssistantService } from '@kbn/observability-ai-assistant-plugin/public'; describe('renderApp', () => { const originalConsole = global.console; @@ -31,7 +32,6 @@ describe('renderApp', () => { const mockSearchSessionClear = jest.fn(); const plugins = { - usageCollection: { reportUiCounter: noop }, data: { query: { timefilter: { @@ -50,6 +50,8 @@ describe('renderApp', () => { }, }, }, + usageCollection: { reportUiCounter: noop }, + observabilityAIAssistant: { service: mockObservabilityAIAssistantService }, } as unknown as ObservabilityPublicPluginsStart; const core = { diff --git a/x-pack/plugins/observability/public/application/index.tsx b/x-pack/plugins/observability/public/application/index.tsx index 4a99d3648af34..5eb3fa65849cd 100644 --- a/x-pack/plugins/observability/public/application/index.tsx +++ b/x-pack/plugins/observability/public/application/index.tsx @@ -101,7 +101,7 @@ export const renderApp = ({ isServerless, }} > - + ; + role: KnowledgeBaseEntryRole; } export type CompatibleJSONSchema = Exclude; diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_actions_menu.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_actions_menu.tsx index 457106d474806..a67e49b7e7a7b 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_actions_menu.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_actions_menu.tsx @@ -7,51 +7,42 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { - EuiButton, - EuiButtonIcon, - EuiContextMenu, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiLink, - EuiLoadingSpinner, - EuiPanel, - EuiPopover, - EuiSpacer, - EuiSwitch, - EuiText, -} from '@elastic/eui'; -import { ConnectorSelectorBase } from '../connector_selector/connector_selector_base'; +import { EuiButtonIcon, EuiContextMenu, EuiPanel, EuiPopover } from '@elastic/eui'; +import { useKibana } from '../../hooks/use_kibana'; +import { getSettingsHref } from '../../utils/get_settings_href'; +import { getSettingsKnowledgeBaseHref } from '../../utils/get_settings_kb_href'; import type { UseGenAIConnectorsResult } from '../../hooks/use_genai_connectors'; -import type { UseKnowledgeBaseResult } from '../../hooks/use_knowledge_base'; -import type { StartedFrom } from '../../utils/get_timeline_items_from_conversation'; +import { ConnectorSelectorBase } from '../connector_selector/connector_selector_base'; export function ChatActionsMenu({ connectors, - connectorsManagementHref, conversationId, disabled, - knowledgeBase, - modelsManagementHref, - startedFrom, onCopyConversationClick, }: { connectors: UseGenAIConnectorsResult; - connectorsManagementHref: string; conversationId?: string; disabled: boolean; - knowledgeBase: UseKnowledgeBaseResult; - modelsManagementHref: string; - startedFrom?: StartedFrom; onCopyConversationClick: () => void; }) { + const { + application: { navigateToUrl }, + http, + } = useKibana().services; const [isOpen, setIsOpen] = useState(false); const toggleActionsMenu = () => { setIsOpen(!isOpen); }; + const handleNavigateToSettings = () => { + navigateToUrl(getSettingsHref(http)); + }; + + const handleNavigateToSettingsKnowledgeBase = () => { + navigateToUrl(getSettingsKnowledgeBaseHref(http)); + }; + return ( } panelPaddingSize="none" @@ -93,28 +87,25 @@ export function ChatActionsMenu({ panel: 1, }, { - name: ( - - - {i18n.translate( - 'xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase', - { - defaultMessage: 'Knowledge base', - } - )} - - - {knowledgeBase.status.loading || knowledgeBase.isInstalling ? ( - - ) : knowledgeBase.status.value?.ready ? ( - - ) : ( - - )} - - + name: i18n.translate('xpack.observabilityAiAssistant.chatHeader.actions.settings', { + defaultMessage: 'AI Assistant Settings', + }), + onClick: () => { + toggleActionsMenu(); + handleNavigateToSettings(); + }, + }, + { + name: i18n.translate( + 'xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase', + { + defaultMessage: 'Manage knowledge base', + } ), - panel: 2, + onClick: () => { + toggleActionsMenu(); + handleNavigateToSettingsKnowledgeBase(); + }, }, { name: i18n.translate( @@ -140,107 +131,6 @@ export function ChatActionsMenu({ content: ( - - - {i18n.translate( - 'xpack.observabilityAiAssistant.chatHeader.actions.connectorManagement.button', - { - defaultMessage: 'Manage connectors', - } - )} - - - ), - }, - { - id: 2, - width: 256, - title: i18n.translate( - 'xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase.title', - { - defaultMessage: 'Knowledge base', - } - ), - content: ( - - - {i18n.translate( - 'xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase.description.paragraph', - { - defaultMessage: - 'Using a knowledge base is optional but improves the experience of using the Assistant significantly.', - } - )}{' '} - - {i18n.translate( - 'xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase.elser.learnMore', - { - defaultMessage: 'Learn more', - } - )} - - - - - {knowledgeBase.isInstalling || knowledgeBase.status.loading ? ( - - ) : ( - <> - { - if (e.target.checked) { - knowledgeBase.install(); - } - }} - /> - - - - - {i18n.translate( - 'xpack.observabilityAiAssistant.chatHeader.actions.connectorManagement', - { - defaultMessage: 'Go to Machine Learning', - } - )} - - - )} ), }, diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_body.stories.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_body.stories.tsx index 52c927795c416..6d6b11b0ab097 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_body.stories.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_body.stories.tsx @@ -59,6 +59,7 @@ const defaultProps: ComponentStoryObj = { error: undefined, selectedConnector: 'foo', selectConnector: () => {}, + reloadConnectors: () => {}, }, connectorsManagementHref: '', currentUser: { diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_body.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_body.tsx index d9164bb2de49d..cc805ded3341c 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_body.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_body.tsx @@ -62,7 +62,6 @@ export function ChatBody({ connectors, knowledgeBase, connectorsManagementHref, - modelsManagementHref, currentUser, startedFrom, onConversationUpdate, @@ -73,7 +72,6 @@ export function ChatBody({ connectors: UseGenAIConnectorsResult; knowledgeBase: UseKnowledgeBaseResult; connectorsManagementHref: string; - modelsManagementHref: string; currentUser?: Pick; startedFrom?: StartedFrom; onConversationUpdate: (conversation: Conversation) => void; @@ -333,7 +331,6 @@ export function ChatBody({ : undefined } connectorsManagementHref={connectorsManagementHref} - modelsManagementHref={modelsManagementHref} knowledgeBase={knowledgeBase} licenseInvalid={!hasCorrectLicense && !initialConversationId} loading={isLoading} diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_consolidated_items.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_consolidated_items.tsx index 18a98c09ff387..7668c5d0df7ad 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_consolidated_items.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_consolidated_items.tsx @@ -27,7 +27,7 @@ const noPanelStyle = css` } .euiLink { - padding: 0 8px; + padding: 0; } .euiLink:focus { @@ -37,10 +37,14 @@ const noPanelStyle = css` .euiLink:hover { text-decoration: underline; } -`; -const avatarStyle = css` - cursor: 'pointer'; + .euiAvatar { + cursor: pointer; + + :hover { + border: solid 2px #d3dae6; + } + } `; export function ChatConsolidatedItems({ @@ -71,7 +75,6 @@ export function ChatConsolidatedItems({ timelineAvatar={ { diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_header.stories.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_header.stories.tsx index 55948f37e5417..5bf9cd3577de4 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_header.stories.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_header.stories.tsx @@ -28,6 +28,7 @@ export const ChatHeaderLoaded: ComponentStoryObj = { { id: 'gpt-3.5-turbo', name: 'OpenAI GPT-3.5 Turbo' }, ] as FindActionResult[], selectConnector: () => {}, + reloadConnectors: () => {}, }, knowledgeBase: { status: { diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_header.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_header.tsx index 977b342bbe1d7..15cc963e1f023 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_header.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_header.tsx @@ -34,7 +34,6 @@ export function ChatHeader({ licenseInvalid, connectors, connectorsManagementHref, - modelsManagementHref, conversationId, knowledgeBase, startedFrom, @@ -46,7 +45,6 @@ export function ChatHeader({ licenseInvalid: boolean; connectors: UseGenAIConnectorsResult; connectorsManagementHref: string; - modelsManagementHref: string; conversationId?: string; knowledgeBase: UseKnowledgeBaseResult; startedFrom?: StartedFrom; @@ -103,12 +101,8 @@ export function ChatHeader({ diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item.tsx index 09cb52a1c30b9..e421537edc781 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item.tsx @@ -35,13 +35,10 @@ export interface ChatItemProps extends ChatTimelineItem { } const normalMessageClassName = css` - .euiCommentEvent__header { - padding: 4px 8px; - } - .euiCommentEvent__body { padding: 0; } + /* targets .*euiTimelineItemEvent-top, makes sure text properly wraps and doesn't overflow */ > :last-child { overflow-x: hidden; @@ -56,6 +53,10 @@ const noPanelMessageClassName = css` .euiCommentEvent__header { background: transparent; border-block-end: none; + + > .euiPanel { + background: none; + } } .euiCommentEvent__body { @@ -89,10 +90,6 @@ export function ChatItem({ const actions = [canCopy, collapsed, canCopy].filter(Boolean); const noBodyMessageClassName = css` - .euiCommentEvent__header { - padding: 4px 8px; - } - .euiCommentEvent__body { padding: 0; height: ${expanded ? 'fit-content' : '0px'}; diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/experimental_feature_banner.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/experimental_feature_banner.tsx index f3e656da5438e..4facf8fe746ba 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/experimental_feature_banner.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/experimental_feature_banner.tsx @@ -31,7 +31,16 @@ export function ExperimentalFeatureBanner() { Technical preview }} + values={{ + techPreview: ( + + {i18n.translate( + 'xpack.observabilityAiAssistant.experimentalFeatureBanner.strong.technicalPreviewLabel', + { defaultMessage: 'Technical preview' } + )} + + ), + }} /> diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/initial_setup_panel.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/initial_setup_panel.tsx index dc6b23a94c1bc..ade3a9e2ae4dc 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/initial_setup_panel.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/initial_setup_panel.tsx @@ -5,11 +5,10 @@ * 2.0. */ -import React from 'react'; +import React, { useState } from 'react'; import { EuiBetaBadge, EuiButton, - EuiCallOut, EuiCard, EuiFlexGroup, EuiFlexItem, @@ -19,16 +18,16 @@ import { EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; import { ConnectorSelectorBase } from '../connector_selector/connector_selector_base'; import type { UseGenAIConnectorsResult } from '../../hooks/use_genai_connectors'; import { ExperimentalFeatureBanner } from './experimental_feature_banner'; import { UseKnowledgeBaseResult } from '../../hooks/use_knowledge_base'; import { StartedFrom } from '../../utils/get_timeline_items_from_conversation'; +import { useKibana } from '../../hooks/use_kibana'; export function InitialSetupPanel({ connectors, - connectorsManagementHref, - knowledgeBase, startedFrom, }: { connectors: UseGenAIConnectorsResult; @@ -36,6 +35,31 @@ export function InitialSetupPanel({ knowledgeBase: UseKnowledgeBaseResult; startedFrom?: StartedFrom; }) { + const [connectorFlyoutOpen, setConnectorFlyoutOpen] = useState(false); + + const { + application: { navigateToApp, capabilities }, + triggersActionsUi: { getAddConnectorFlyout: ConnectorFlyout }, + } = useKibana().services; + + const handleConnectorClick = () => { + if (capabilities.management?.insightsAndAlerting?.triggersActions) { + setConnectorFlyoutOpen(true); + } else { + navigateToApp('management', { + path: '/insightsAndAlerting/triggersActionsConnectors/connectors', + }); + } + }; + + const onConnectorCreated = (createdConnector: ActionConnector) => { + setConnectorFlyoutOpen(false); + + if (createdConnector.actionTypeId === '.gen-ai') { + connectors.reloadConnectors(); + } + }; + return ( <> @@ -52,78 +76,6 @@ export function InitialSetupPanel({ - - } - title={i18n.translate( - 'xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.title', - { - defaultMessage: 'Knowledge Base', - } - )} - description={ - <> - - {i18n.translate( - 'xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.description.paragraph1', - { - defaultMessage: - 'We recommend you enable the knowledge base for a better experience. It will provide the assistant with the ability to learn from your interaction with it.', - } - )} - - - {i18n.translate( - 'xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.description.paragraph2', - { - defaultMessage: 'This step is optional, you can always do it later.', - } - )} - - - } - footer={ - knowledgeBase.status.value?.ready ? ( - - ) : ( - - {knowledgeBase.isInstalling || knowledgeBase.status.loading - ? i18n.translate( - 'xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.buttonLabel.installingKb', - { - defaultMessage: 'Installing knowledge base', - } - ) - : i18n.translate( - 'xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.buttonLabel.kbNotInstalledYet', - { - defaultMessage: 'Set up knowledge base', - } - )} - - ) - } - /> - - } @@ -177,9 +129,7 @@ export function InitialSetupPanel({ } )} - ) : ( - '' - ) + ) : undefined } footer={ !connectors.connectors?.length ? ( @@ -187,7 +137,7 @@ export function InitialSetupPanel({ data-test-subj="observabilityAiAssistantInitialSetupPanelSetUpGenerativeAiConnectorButton" fill color="primary" - href={connectorsManagementHref} + onClick={handleConnectorClick} > {i18n.translate( 'xpack.observabilityAiAssistant.initialSetupPanel.setupConnector.buttonLabel', @@ -213,6 +163,13 @@ export function InitialSetupPanel({ })} + + {connectorFlyoutOpen ? ( + setConnectorFlyoutOpen(false)} + onConnectorCreated={onConnectorCreated} + /> + ) : null} ); } diff --git a/x-pack/plugins/observability_ai_assistant/public/components/insight/insight_base.stories.tsx b/x-pack/plugins/observability_ai_assistant/public/components/insight/insight_base.stories.tsx index bd894159eb288..f34bb0ce3034d 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/insight/insight_base.stories.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/insight/insight_base.stories.tsx @@ -47,6 +47,7 @@ const defaultProps: InsightBaseProps = { selectedConnector="gpt-4" loading={false} selectConnector={() => {}} + reloadConnectors={() => {}} /> ), onToggle: () => {}, diff --git a/x-pack/plugins/observability_ai_assistant/public/functions/index.ts b/x-pack/plugins/observability_ai_assistant/public/functions/index.ts index 97c311cfac069..fe35f66593a19 100644 --- a/x-pack/plugins/observability_ai_assistant/public/functions/index.ts +++ b/x-pack/plugins/observability_ai_assistant/public/functions/index.ts @@ -35,7 +35,7 @@ export async function registerFunctions({ signal: AbortSignal; }) { return service - .callApi('GET /internal/observability_ai_assistant/functions/kb_status', { + .callApi('GET /internal/observability_ai_assistant/kb/status', { signal, }) .then((response) => { diff --git a/x-pack/plugins/observability_ai_assistant/public/hooks/__storybook_mocks__/use_genai_connectors.ts b/x-pack/plugins/observability_ai_assistant/public/hooks/__storybook_mocks__/use_genai_connectors.ts index 29290d9b6b898..9d158b7474c75 100644 --- a/x-pack/plugins/observability_ai_assistant/public/hooks/__storybook_mocks__/use_genai_connectors.ts +++ b/x-pack/plugins/observability_ai_assistant/public/hooks/__storybook_mocks__/use_genai_connectors.ts @@ -14,5 +14,6 @@ export function useGenAIConnectors(): UseGenAIConnectorsResult { error: undefined, selectedConnector: 'foo', selectConnector: (id: string) => {}, + reloadConnectors: () => {}, }; } diff --git a/x-pack/plugins/observability_ai_assistant/public/hooks/use_conversation.test.tsx b/x-pack/plugins/observability_ai_assistant/public/hooks/use_conversation.test.tsx index 884aaff592a4e..f55cd3250bd5f 100644 --- a/x-pack/plugins/observability_ai_assistant/public/hooks/use_conversation.test.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/hooks/use_conversation.test.tsx @@ -38,6 +38,7 @@ const mockService: MockedService = { getLicenseManagementLocator: jest.fn(), isEnabled: jest.fn(), start: jest.fn(), + register: jest.fn(), }; const mockChatService = createMockChatService(); diff --git a/x-pack/plugins/observability_ai_assistant/public/hooks/use_genai_connectors.ts b/x-pack/plugins/observability_ai_assistant/public/hooks/use_genai_connectors.ts index 69b657e54661c..5af156b7b04c6 100644 --- a/x-pack/plugins/observability_ai_assistant/public/hooks/use_genai_connectors.ts +++ b/x-pack/plugins/observability_ai_assistant/public/hooks/use_genai_connectors.ts @@ -5,9 +5,10 @@ * 2.0. */ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import type { FindActionResult } from '@kbn/actions-plugin/server'; import useLocalStorage from 'react-use/lib/useLocalStorage'; +import type { ObservabilityAIAssistantService } from '../types'; import { useObservabilityAIAssistant } from './use_observability_ai_assistant'; export interface UseGenAIConnectorsResult { @@ -16,11 +17,18 @@ export interface UseGenAIConnectorsResult { loading: boolean; error?: Error; selectConnector: (id: string) => void; + reloadConnectors: () => void; } export function useGenAIConnectors(): UseGenAIConnectorsResult { const assistant = useObservabilityAIAssistant(); + return useGenAIConnectorsWithoutContext(assistant); +} + +export function useGenAIConnectorsWithoutContext( + assistant: ObservabilityAIAssistantService +): UseGenAIConnectorsResult { const [connectors, setConnectors] = useState(undefined); const [selectedConnector, setSelectedConnector] = useLocalStorage( @@ -32,11 +40,10 @@ export function useGenAIConnectors(): UseGenAIConnectorsResult { const [error, setError] = useState(undefined); - useEffect(() => { + const controller = useMemo(() => new AbortController(), []); + const fetchConnectors = useCallback(async () => { setLoading(true); - const controller = new AbortController(); - assistant .callApi('GET /internal/observability_ai_assistant/connectors', { signal: controller.signal, @@ -59,11 +66,15 @@ export function useGenAIConnectors(): UseGenAIConnectorsResult { .finally(() => { setLoading(false); }); + }, [assistant, controller.signal, setSelectedConnector]); + + useEffect(() => { + fetchConnectors(); return () => { controller.abort(); }; - }, [assistant, setSelectedConnector]); + }, [assistant, controller, fetchConnectors, setSelectedConnector]); return { connectors, @@ -73,5 +84,8 @@ export function useGenAIConnectors(): UseGenAIConnectorsResult { selectConnector: (id: string) => { setSelectedConnector(id); }, + reloadConnectors: () => { + fetchConnectors(); + }, }; } diff --git a/x-pack/plugins/observability_ai_assistant/public/hooks/use_kibana.ts b/x-pack/plugins/observability_ai_assistant/public/hooks/use_kibana.ts index 03cdd5f5ac826..0362415d7a232 100644 --- a/x-pack/plugins/observability_ai_assistant/public/hooks/use_kibana.ts +++ b/x-pack/plugins/observability_ai_assistant/public/hooks/use_kibana.ts @@ -5,13 +5,15 @@ * 2.0. */ -import { CoreStart } from '@kbn/core/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { ObservabilityAIAssistantPluginStartDependencies } from '../types'; +import type { CoreStart } from '@kbn/core/public'; +import type { ObservabilityAIAssistantPluginStartDependencies } from '../types'; + +export type StartServices = CoreStart & + ObservabilityAIAssistantPluginStartDependencies & { + plugins: { start: ObservabilityAIAssistantPluginStartDependencies }; + } & TAdditionalServices & {}; -export type StartServices = CoreStart & { - plugins: { start: ObservabilityAIAssistantPluginStartDependencies }; -} & TAdditionalServices & {}; const useTypedKibana = () => useKibana>(); diff --git a/x-pack/plugins/observability_ai_assistant/public/hooks/use_knowledge_base.tsx b/x-pack/plugins/observability_ai_assistant/public/hooks/use_knowledge_base.tsx index 24c83d3fa8eb4..84cc7fd2a9932 100644 --- a/x-pack/plugins/observability_ai_assistant/public/hooks/use_knowledge_base.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/hooks/use_knowledge_base.tsx @@ -29,7 +29,7 @@ export function useKnowledgeBase(): UseKnowledgeBaseResult { const service = useObservabilityAIAssistant(); const status = useAbortableAsync(({ signal }) => { - return service.callApi('GET /internal/observability_ai_assistant/functions/kb_status', { + return service.callApi('GET /internal/observability_ai_assistant/kb/status', { signal, }); }, []); @@ -45,7 +45,7 @@ export function useKnowledgeBase(): UseKnowledgeBaseResult { const install = (): Promise => { setIsInstalling(true); return service - .callApi('POST /internal/observability_ai_assistant/functions/setup_kb', { + .callApi('POST /internal/observability_ai_assistant/kb/setup', { signal: null, }) .then(() => { diff --git a/x-pack/plugins/observability_ai_assistant/public/index.ts b/x-pack/plugins/observability_ai_assistant/public/index.ts index 18039652eaa66..fb77855d8898f 100644 --- a/x-pack/plugins/observability_ai_assistant/public/index.ts +++ b/x-pack/plugins/observability_ai_assistant/public/index.ts @@ -14,7 +14,9 @@ import type { ObservabilityAIAssistantPluginSetupDependencies, ObservabilityAIAssistantPluginStartDependencies, ConfigSchema, + ObservabilityAIAssistantService, } from './types'; +export { mockService as mockObservabilityAIAssistantService } from './utils/storybook_decorator'; export const ContextualInsight = withSuspense( lazy(() => import('./components/insight/insight').then((m) => ({ default: m.Insight }))) @@ -30,15 +32,19 @@ export const ObservabilityAIAssistantActionMenuItem = withSuspense( export { ObservabilityAIAssistantProvider } from './context/observability_ai_assistant_provider'; -export type { ObservabilityAIAssistantPluginSetup, ObservabilityAIAssistantPluginStart }; +export type { + ObservabilityAIAssistantPluginSetup, + ObservabilityAIAssistantPluginStart, + ObservabilityAIAssistantService, +}; export { useObservabilityAIAssistant, useObservabilityAIAssistantOptional, } from './hooks/use_observability_ai_assistant'; -export type { Conversation, Message } from '../common'; -export { MessageRole } from '../common'; +export type { Conversation, Message, KnowledgeBaseEntry } from '../common'; +export { MessageRole, KnowledgeBaseEntryRole } from '../common'; export type { ObservabilityAIAssistantAPIClientRequestParamsOf, diff --git a/x-pack/plugins/observability_ai_assistant/public/plugin.tsx b/x-pack/plugins/observability_ai_assistant/public/plugin.tsx index ba59575d12426..e71562f6de26c 100644 --- a/x-pack/plugins/observability_ai_assistant/public/plugin.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/plugin.tsx @@ -18,6 +18,7 @@ import { import { i18n } from '@kbn/i18n'; import type { Logger } from '@kbn/logging'; import { createService } from './service/create_service'; +import { useGenAIConnectorsWithoutContext } from './hooks/use_genai_connectors'; import type { ConfigSchema, ObservabilityAIAssistantPluginSetup, @@ -119,6 +120,6 @@ export class ObservabilityAIAssistantPlugin }); }); - return service; + return { service, useGenAIConnectors: () => useGenAIConnectorsWithoutContext(service) }; } } diff --git a/x-pack/plugins/observability_ai_assistant/public/routes/conversations/conversation_view.tsx b/x-pack/plugins/observability_ai_assistant/public/routes/conversations/conversation_view.tsx index ca8bf82178ff6..b360b136272da 100644 --- a/x-pack/plugins/observability_ai_assistant/public/routes/conversations/conversation_view.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/routes/conversations/conversation_view.tsx @@ -26,7 +26,6 @@ import { useObservabilityAIAssistantParams } from '../../hooks/use_observability import { useObservabilityAIAssistantRouter } from '../../hooks/use_observability_ai_assistant_router'; import { EMPTY_CONVERSATION_TITLE } from '../../i18n'; import { getConnectorsManagementHref } from '../../utils/get_connectors_management_href'; -import { getModelsManagementHref } from '../../utils/get_models_management_href'; const containerClassName = css` max-width: 100%; @@ -229,7 +228,6 @@ export function ConversationView() { currentUser={currentUser} connectors={connectors} connectorsManagementHref={getConnectorsManagementHref(http)} - modelsManagementHref={getModelsManagementHref(http)} initialConversationId={conversationId} knowledgeBase={knowledgeBase} startedFrom="conversationView" diff --git a/x-pack/plugins/observability_ai_assistant/public/service/create_chat_service.ts b/x-pack/plugins/observability_ai_assistant/public/service/create_chat_service.ts index 2d0931e4dfa54..4dbd61c55ecea 100644 --- a/x-pack/plugins/observability_ai_assistant/public/service/create_chat_service.ts +++ b/x-pack/plugins/observability_ai_assistant/public/service/create_chat_service.ts @@ -36,7 +36,7 @@ import { } from '../../common/types'; import { ObservabilityAIAssistantAPIClient } from '../api'; import type { - ChatRegistrationFunction, + AssistantRegistrationFunction, CreateChatCompletionResponseChunk, ObservabilityAIAssistantChatService, PendingMessage, @@ -65,7 +65,7 @@ export async function createChatService({ }: { analytics: AnalyticsServiceStart; signal: AbortSignal; - registrations: ChatRegistrationFunction[]; + registrations: AssistantRegistrationFunction[]; client: ObservabilityAIAssistantAPIClient; }): Promise { const contextRegistry: ContextRegistry = new Map(); diff --git a/x-pack/plugins/observability_ai_assistant/public/service/create_service.ts b/x-pack/plugins/observability_ai_assistant/public/service/create_service.ts index 1a5462e1fbe25..c83f9fb8178e1 100644 --- a/x-pack/plugins/observability_ai_assistant/public/service/create_service.ts +++ b/x-pack/plugins/observability_ai_assistant/public/service/create_service.ts @@ -10,7 +10,7 @@ import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { SecurityPluginStart } from '@kbn/security-plugin/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; import { createCallObservabilityAIAssistantAPI } from '../api'; -import type { ChatRegistrationFunction, ObservabilityAIAssistantService } from '../types'; +import type { AssistantRegistrationFunction, ObservabilityAIAssistantService } from '../types'; export function createService({ analytics, @@ -26,10 +26,10 @@ export function createService({ licenseStart: LicensingPluginStart; securityStart: SecurityPluginStart; shareStart: SharePluginStart; -}): ObservabilityAIAssistantService & { register: (fn: ChatRegistrationFunction) => void } { +}): ObservabilityAIAssistantService { const client = createCallObservabilityAIAssistantAPI(coreStart); - const registrations: ChatRegistrationFunction[] = []; + const registrations: AssistantRegistrationFunction[] = []; return { isEnabled: () => { diff --git a/x-pack/plugins/observability_ai_assistant/public/types.ts b/x-pack/plugins/observability_ai_assistant/public/types.ts index 41293ea2d355f..274dc953e679e 100644 --- a/x-pack/plugins/observability_ai_assistant/public/types.ts +++ b/x-pack/plugins/observability_ai_assistant/public/types.ts @@ -42,6 +42,7 @@ import type { } from '../common/types'; import type { ObservabilityAIAssistantAPIClient } from './api'; import type { PendingMessage } from '../common/types'; +import { UseGenAIConnectorsResult } from './hooks/use_genai_connectors'; /* eslint-disable @typescript-eslint/no-empty-interface*/ @@ -78,7 +79,7 @@ export interface ObservabilityAIAssistantChatService { ) => React.ReactNode; } -export type ChatRegistrationFunction = ({}: { +export type AssistantRegistrationFunction = ({}: { signal: AbortSignal; registerFunction: RegisterFunctionDefinition; registerContext: RegisterContextDefinition; @@ -91,10 +92,12 @@ export interface ObservabilityAIAssistantService { getLicense: () => Observable; getLicenseManagementLocator: () => SharePluginStart; start: ({}: { signal: AbortSignal }) => Promise; + register: (fn: AssistantRegistrationFunction) => void; } -export interface ObservabilityAIAssistantPluginStart extends ObservabilityAIAssistantService { - register: (fn: ChatRegistrationFunction) => void; +export interface ObservabilityAIAssistantPluginStart { + service: ObservabilityAIAssistantService; + useGenAIConnectors: () => UseGenAIConnectorsResult; } export interface ObservabilityAIAssistantPluginSetup {} diff --git a/x-pack/plugins/observability_ai_assistant/public/utils/get_settings_href.ts b/x-pack/plugins/observability_ai_assistant/public/utils/get_settings_href.ts new file mode 100644 index 0000000000000..e6a974205857f --- /dev/null +++ b/x-pack/plugins/observability_ai_assistant/public/utils/get_settings_href.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HttpStart } from '@kbn/core/public'; + +export function getSettingsHref(http: HttpStart) { + return http!.basePath.prepend(`/app/management/kibana/aiAssistantManagementObservability`); +} diff --git a/x-pack/plugins/observability_ai_assistant/public/utils/get_settings_kb_href.ts b/x-pack/plugins/observability_ai_assistant/public/utils/get_settings_kb_href.ts new file mode 100644 index 0000000000000..6285c2923b4e7 --- /dev/null +++ b/x-pack/plugins/observability_ai_assistant/public/utils/get_settings_kb_href.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HttpStart } from '@kbn/core/public'; + +export function getSettingsKnowledgeBaseHref(http: HttpStart) { + return http!.basePath.prepend( + `/app/management/kibana/aiAssistantManagementObservability?tab=knowledge_base` + ); +} diff --git a/x-pack/plugins/observability_ai_assistant/public/utils/storybook_decorator.tsx b/x-pack/plugins/observability_ai_assistant/public/utils/storybook_decorator.tsx index c497f1f544294..6311c64308073 100644 --- a/x-pack/plugins/observability_ai_assistant/public/utils/storybook_decorator.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/utils/storybook_decorator.tsx @@ -44,7 +44,7 @@ const chatService: ObservabilityAIAssistantChatService = { hasRenderFunction: () => true, }; -const service: ObservabilityAIAssistantService = { +export const mockService: ObservabilityAIAssistantService = { isEnabled: () => true, start: async () => { return chatService; @@ -66,6 +66,7 @@ const service: ObservabilityAIAssistantService = { url: {}, navigate: () => {}, } as unknown as SharePluginStart), + register: () => {}, }; export function KibanaReactStorybookDecorator(Story: ComponentType) { @@ -82,7 +83,7 @@ export function KibanaReactStorybookDecorator(Story: ComponentType) { }, }} > - + diff --git a/x-pack/plugins/observability_ai_assistant/server/index.ts b/x-pack/plugins/observability_ai_assistant/server/index.ts index 8660446357e34..0869b0bb43519 100644 --- a/x-pack/plugins/observability_ai_assistant/server/index.ts +++ b/x-pack/plugins/observability_ai_assistant/server/index.ts @@ -11,6 +11,7 @@ import type { ObservabilityAIAssistantConfig } from './config'; export type { ObservabilityAIAssistantServerRouteRepository } from './routes/get_global_observability_ai_assistant_route_repository'; import { config as configSchema } from './config'; +import { ObservabilityAIAssistantService } from './service'; export const config: PluginConfigDescriptor = { deprecations: ({ unusedFromRoot }) => [ @@ -37,6 +38,20 @@ export const config: PluginConfigDescriptor = { schema: configSchema, }; +export interface ObservabilityAIAssistantPluginSetup { + /** + * Returns a Observability AI Assistant service instance + */ + service: ObservabilityAIAssistantService; +} + +export interface ObservabilityAIAssistantPluginStart { + /** + * Returns a Observability AI Assistant service instance + */ + service: ObservabilityAIAssistantService; +} + export const plugin = async (ctx: PluginInitializerContext) => { const { ObservabilityAIAssistantPlugin } = await import('./plugin'); return new ObservabilityAIAssistantPlugin(ctx); diff --git a/x-pack/plugins/observability_ai_assistant/server/plugin.ts b/x-pack/plugins/observability_ai_assistant/server/plugin.ts index d6a256ddf6022..c793e28f40a86 100644 --- a/x-pack/plugins/observability_ai_assistant/server/plugin.ts +++ b/x-pack/plugins/observability_ai_assistant/server/plugin.ts @@ -7,7 +7,6 @@ import { CoreSetup, - CoreStart, DEFAULT_APP_CATEGORIES, Logger, Plugin, @@ -31,7 +30,7 @@ import { ObservabilityAIAssistantPluginSetupDependencies, ObservabilityAIAssistantPluginStartDependencies, } from './types'; -import { addLensDocsToKb } from './service/kb_service/kb_docs/lens'; +import { addLensDocsToKb } from './service/knowledge_base_service/kb_docs/lens'; export class ObservabilityAIAssistantPlugin implements @@ -43,6 +42,8 @@ export class ObservabilityAIAssistantPlugin > { logger: Logger; + service: ObservabilityAIAssistantService | undefined; + constructor(context: PluginInitializerContext) { this.logger = context.logger.get(); } @@ -103,30 +104,29 @@ export class ObservabilityAIAssistantPlugin }; }) as ObservabilityAIAssistantRouteHandlerResources['plugins']; - const service = new ObservabilityAIAssistantService({ + this.service = new ObservabilityAIAssistantService({ logger: this.logger.get('service'), core, taskManager: plugins.taskManager, }); - addLensDocsToKb({ service, logger: this.logger.get('kb').get('lens') }); + addLensDocsToKb({ service: this.service, logger: this.logger.get('kb').get('lens') }); registerServerRoutes({ core, logger: this.logger, dependencies: { plugins: routeHandlerPlugins, - service, + service: this.service, }, }); - return {}; + return { + service: this.service, + }; } - public start( - core: CoreStart, - plugins: ObservabilityAIAssistantPluginStartDependencies - ): ObservabilityAIAssistantPluginStart { + public start(): ObservabilityAIAssistantPluginStart { return {}; } } diff --git a/x-pack/plugins/observability_ai_assistant/server/routes/functions/route.ts b/x-pack/plugins/observability_ai_assistant/server/routes/functions/route.ts index 087e8c079b5ef..4567addfeb2d5 100644 --- a/x-pack/plugins/observability_ai_assistant/server/routes/functions/route.ts +++ b/x-pack/plugins/observability_ai_assistant/server/routes/functions/route.ts @@ -15,8 +15,9 @@ import { ALERT_STATUS, ALERT_STATUS_ACTIVE, } from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names'; +import { KnowledgeBaseEntryRole } from '../../../common/types'; import { createObservabilityAIAssistantServerRoute } from '../create_observability_ai_assistant_server_route'; -import type { RecalledEntry } from '../../service/kb_service'; +import type { RecalledEntry } from '../../service/knowledge_base_service'; const functionElasticsearchRoute = createObservabilityAIAssistantServerRoute({ endpoint: 'POST /internal/observability_ai_assistant/functions/elasticsearch', @@ -219,63 +220,21 @@ const functionSummariseRoute = createObservabilityAIAssistantServerRoute({ labels, } = resources.params.body; - return client.summarize({ + return client.createKnowledgeBaseEntry({ entry: { confidence, id, + doc_id: id, is_correction: isCorrection, text, public: isPublic, labels, + role: KnowledgeBaseEntryRole.AssistantSummarization, }, }); }, }); -const getKnowledgeBaseStatus = createObservabilityAIAssistantServerRoute({ - endpoint: 'GET /internal/observability_ai_assistant/functions/kb_status', - options: { - tags: ['access:ai_assistant'], - }, - handler: async ( - resources - ): Promise<{ - ready: boolean; - error?: any; - deployment_state?: string; - allocation_state?: string; - }> => { - const client = await resources.service.getClient({ request: resources.request }); - - if (!client) { - throw notImplemented(); - } - - return await client.getKnowledgeBaseStatus(); - }, -}); - -const setupKnowledgeBaseRoute = createObservabilityAIAssistantServerRoute({ - endpoint: 'POST /internal/observability_ai_assistant/functions/setup_kb', - options: { - tags: ['access:ai_assistant'], - timeout: { - idleSocket: 20 * 60 * 1000, // 20 minutes - }, - }, - handler: async (resources): Promise<{}> => { - const client = await resources.service.getClient({ request: resources.request }); - - if (!client) { - throw notImplemented(); - } - - await client.setupKnowledgeBase(); - - return {}; - }, -}); - const functionGetDatasetInfoRoute = createObservabilityAIAssistantServerRoute({ endpoint: 'POST /internal/observability_ai_assistant/functions/get_dataset_info', params: t.type({ @@ -352,8 +311,6 @@ export const functionRoutes = { ...functionElasticsearchRoute, ...functionRecallRoute, ...functionSummariseRoute, - ...setupKnowledgeBaseRoute, - ...getKnowledgeBaseStatus, ...functionAlertsRoute, ...functionGetDatasetInfoRoute, }; diff --git a/x-pack/plugins/observability_ai_assistant/server/routes/get_global_observability_ai_assistant_route_repository.ts b/x-pack/plugins/observability_ai_assistant/server/routes/get_global_observability_ai_assistant_route_repository.ts index a01033137600b..846a05797f975 100644 --- a/x-pack/plugins/observability_ai_assistant/server/routes/get_global_observability_ai_assistant_route_repository.ts +++ b/x-pack/plugins/observability_ai_assistant/server/routes/get_global_observability_ai_assistant_route_repository.ts @@ -9,6 +9,7 @@ import { chatRoutes } from './chat/route'; import { connectorRoutes } from './connectors/route'; import { conversationRoutes } from './conversations/route'; import { functionRoutes } from './functions/route'; +import { knowledgeBaseRoutes } from './knowledge_base/route'; export function getGlobalObservabilityAIAssistantServerRouteRepository() { return { @@ -16,6 +17,7 @@ export function getGlobalObservabilityAIAssistantServerRouteRepository() { ...conversationRoutes, ...connectorRoutes, ...functionRoutes, + ...knowledgeBaseRoutes, }; } diff --git a/x-pack/plugins/observability_ai_assistant/server/routes/knowledge_base/route.ts b/x-pack/plugins/observability_ai_assistant/server/routes/knowledge_base/route.ts new file mode 100644 index 0000000000000..40a47a5a46cfa --- /dev/null +++ b/x-pack/plugins/observability_ai_assistant/server/routes/knowledge_base/route.ts @@ -0,0 +1,200 @@ +/* + * 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 { notImplemented } from '@hapi/boom'; +import { nonEmptyStringRt, toBooleanRt } from '@kbn/io-ts-utils'; +import * as t from 'io-ts'; +import { createObservabilityAIAssistantServerRoute } from '../create_observability_ai_assistant_server_route'; +import { KnowledgeBaseEntry, KnowledgeBaseEntryRole } from '../../../common/types'; + +const getKnowledgeBaseStatus = createObservabilityAIAssistantServerRoute({ + endpoint: 'GET /internal/observability_ai_assistant/kb/status', + options: { + tags: ['access:ai_assistant'], + }, + handler: async ( + resources + ): Promise<{ + ready: boolean; + error?: any; + deployment_state?: string; + allocation_state?: string; + }> => { + const client = await resources.service.getClient({ request: resources.request }); + + if (!client) { + throw notImplemented(); + } + + return await client.getKnowledgeBaseStatus(); + }, +}); + +const setupKnowledgeBase = createObservabilityAIAssistantServerRoute({ + endpoint: 'POST /internal/observability_ai_assistant/kb/setup', + options: { + tags: ['access:ai_assistant'], + timeout: { + idleSocket: 20 * 60 * 1000, // 20 minutes + }, + }, + handler: async (resources): Promise<{}> => { + const client = await resources.service.getClient({ request: resources.request }); + + if (!client) { + throw notImplemented(); + } + + await client.setupKnowledgeBase(); + + return {}; + }, +}); + +const getKnowledgeBaseEntries = createObservabilityAIAssistantServerRoute({ + endpoint: 'GET /internal/observability_ai_assistant/kb/entries', + options: { + tags: ['access:ai_assistant'], + }, + params: t.type({ + query: t.type({ + query: t.string, + sortBy: t.string, + sortDirection: t.union([t.literal('asc'), t.literal('desc')]), + }), + }), + handler: async ( + resources + ): Promise<{ + entries: KnowledgeBaseEntry[]; + }> => { + const client = await resources.service.getClient({ request: resources.request }); + + if (!client) { + throw notImplemented(); + } + + const { query, sortBy, sortDirection } = resources.params.query; + + return await client.getKnowledgeBaseEntries({ query, sortBy, sortDirection }); + }, +}); + +const saveKnowledgeBaseEntry = createObservabilityAIAssistantServerRoute({ + endpoint: 'POST /internal/observability_ai_assistant/kb/entries/save', + params: t.type({ + body: t.intersection([ + t.type({ + id: t.string, + text: nonEmptyStringRt, + }), + t.partial({ + confidence: t.union([t.literal('low'), t.literal('medium'), t.literal('high')]), + is_correction: toBooleanRt, + public: toBooleanRt, + labels: t.record(t.string, t.string), + role: t.union([ + t.literal('assistant_summarization'), + t.literal('user_entry'), + t.literal('elastic'), + ]), + }), + ]), + }), + options: { + tags: ['access:ai_assistant'], + }, + handler: async (resources): Promise => { + const client = await resources.service.getClient({ request: resources.request }); + + if (!client) { + throw notImplemented(); + } + + const { id, text } = resources.params.body; + + return client.createKnowledgeBaseEntry({ + entry: { + id, + text, + doc_id: id, + confidence: resources.params.body.confidence ?? 'high', + is_correction: resources.params.body.is_correction ?? false, + public: resources.params.body.public ?? true, + labels: resources.params.body.labels ?? {}, + role: + (resources.params.body.role as KnowledgeBaseEntryRole) ?? + KnowledgeBaseEntryRole.UserEntry, + }, + }); + }, +}); + +const deleteKnowledgeBaseEntry = createObservabilityAIAssistantServerRoute({ + endpoint: 'DELETE /internal/observability_ai_assistant/kb/entries/{entryId}', + params: t.type({ + path: t.type({ + entryId: t.string, + }), + }), + options: { + tags: ['access:ai_assistant'], + }, + handler: async (resources): Promise => { + const client = await resources.service.getClient({ request: resources.request }); + + if (!client) { + throw notImplemented(); + } + + return client.deleteKnowledgeBaseEntry(resources.params.path.entryId); + }, +}); + +const importKnowledgeBaseEntries = createObservabilityAIAssistantServerRoute({ + endpoint: 'POST /internal/observability_ai_assistant/kb/entries/import', + params: t.type({ + body: t.type({ + entries: t.array( + t.type({ + id: t.string, + text: nonEmptyStringRt, + }) + ), + }), + }), + options: { + tags: ['access:ai_assistant'], + }, + handler: async (resources): Promise => { + const client = await resources.service.getClient({ request: resources.request }); + + if (!client) { + throw notImplemented(); + } + + const entries = resources.params.body.entries.map((entry) => ({ + doc_id: entry.id, + confidence: 'high' as KnowledgeBaseEntry['confidence'], + is_correction: false, + public: true, + labels: {}, + role: KnowledgeBaseEntryRole.UserEntry, + ...entry, + })); + + return await client.importKnowledgeBaseEntries({ entries }); + }, +}); + +export const knowledgeBaseRoutes = { + ...setupKnowledgeBase, + ...getKnowledgeBaseStatus, + ...getKnowledgeBaseEntries, + ...importKnowledgeBaseEntries, + ...saveKnowledgeBaseEntry, + ...deleteKnowledgeBaseEntry, +}; diff --git a/x-pack/plugins/observability_ai_assistant/server/service/client/index.ts b/x-pack/plugins/observability_ai_assistant/server/service/client/index.ts index 2332f63a54c78..e31f213eeff02 100644 --- a/x-pack/plugins/observability_ai_assistant/server/service/client/index.ts +++ b/x-pack/plugins/observability_ai_assistant/server/service/client/index.ts @@ -28,7 +28,11 @@ import { type KnowledgeBaseEntry, type Message, } from '../../../common/types'; -import type { KnowledgeBaseService, RecalledEntry } from '../kb_service'; +import { + KnowledgeBaseEntryOperationType, + KnowledgeBaseService, + RecalledEntry, +} from '../knowledge_base_service'; import type { ObservabilityAIAssistantResourceNames } from '../types'; import { getAccessQuery } from '../util/get_access_query'; @@ -377,23 +381,52 @@ export class ObservabilityAIAssistantClient { }); }; - summarize = async ({ + getKnowledgeBaseStatus = () => { + return this.dependencies.knowledgeBaseService.status(); + }; + + setupKnowledgeBase = () => { + return this.dependencies.knowledgeBaseService.setup(); + }; + + createKnowledgeBaseEntry = async ({ entry, }: { entry: Omit; }): Promise => { - return this.dependencies.knowledgeBaseService.summarize({ + return this.dependencies.knowledgeBaseService.addEntry({ namespace: this.dependencies.namespace, user: this.dependencies.user, entry, }); }; - getKnowledgeBaseStatus = () => { - return this.dependencies.knowledgeBaseService.status(); + importKnowledgeBaseEntries = async ({ + entries, + }: { + entries: Array>; + }): Promise => { + const operations = entries.map((entry) => ({ + type: KnowledgeBaseEntryOperationType.Index, + document: { ...entry, '@timestamp': new Date().toISOString() }, + })); + + await this.dependencies.knowledgeBaseService.addEntries({ operations }); }; - setupKnowledgeBase = () => { - return this.dependencies.knowledgeBaseService.setup(); + getKnowledgeBaseEntries = async ({ + query, + sortBy, + sortDirection, + }: { + query: string; + sortBy: string; + sortDirection: 'asc' | 'desc'; + }) => { + return this.dependencies.knowledgeBaseService.getEntries({ query, sortBy, sortDirection }); + }; + + deleteKnowledgeBaseEntry = async (id: string) => { + return this.dependencies.knowledgeBaseService.deleteEntry({ id }); }; } diff --git a/x-pack/plugins/observability_ai_assistant/server/service/index.ts b/x-pack/plugins/observability_ai_assistant/server/service/index.ts index ab37c59563713..00cb233364381 100644 --- a/x-pack/plugins/observability_ai_assistant/server/service/index.ts +++ b/x-pack/plugins/observability_ai_assistant/server/service/index.ts @@ -13,11 +13,12 @@ import type { SecurityPluginStart } from '@kbn/security-plugin/server'; import { getSpaceIdFromPath } from '@kbn/spaces-plugin/common'; import type { TaskManagerSetupContract } from '@kbn/task-manager-plugin/server'; import { once } from 'lodash'; +import { KnowledgeBaseEntryRole } from '../../common/types'; import type { ObservabilityAIAssistantPluginStartDependencies } from '../types'; import { ObservabilityAIAssistantClient } from './client'; import { conversationComponentTemplate } from './conversation_component_template'; import { kbComponentTemplate } from './kb_component_template'; -import { KnowledgeBaseEntryOperationType, KnowledgeBaseService } from './kb_service'; +import { KnowledgeBaseEntryOperationType, KnowledgeBaseService } from './knowledge_base_service'; import type { ObservabilityAIAssistantResourceNames } from './types'; import { splitKbText } from './util/split_kb_text'; @@ -262,13 +263,14 @@ export class ObservabilityAIAssistantService { const entryWithSystemProperties = { ...entry, '@timestamp': new Date().toISOString(), + doc_id: entry.id, public: true, confidence: 'high' as const, is_correction: false, labels: { ...entry.labels, - document_id: entry.id, }, + role: KnowledgeBaseEntryRole.Elastic, }; const operations = diff --git a/x-pack/plugins/observability_ai_assistant/server/service/kb_component_template.ts b/x-pack/plugins/observability_ai_assistant/server/service/kb_component_template.ts index 8d0e5ff423b2c..c28821c3d8517 100644 --- a/x-pack/plugins/observability_ai_assistant/server/service/kb_component_template.ts +++ b/x-pack/plugins/observability_ai_assistant/server/service/kb_component_template.ts @@ -31,6 +31,7 @@ export const kbComponentTemplate: ClusterComponentTemplate['component_template'] properties: { '@timestamp': date, id: keyword, + doc_id: { type: 'text', fielddata: true }, user: { properties: { id: keyword, diff --git a/x-pack/plugins/observability_ai_assistant/server/service/kb_service/index.ts b/x-pack/plugins/observability_ai_assistant/server/service/knowledge_base_service/index.ts similarity index 80% rename from x-pack/plugins/observability_ai_assistant/server/service/kb_service/index.ts rename to x-pack/plugins/observability_ai_assistant/server/service/knowledge_base_service/index.ts index e5a92b3467768..f319bbbf88610 100644 --- a/x-pack/plugins/observability_ai_assistant/server/service/kb_service/index.ts +++ b/x-pack/plugins/observability_ai_assistant/server/service/knowledge_base_service/index.ts @@ -18,7 +18,7 @@ import { INDEX_QUEUED_DOCUMENTS_TASK_ID, INDEX_QUEUED_DOCUMENTS_TASK_TYPE, } from '..'; -import type { KnowledgeBaseEntry } from '../../../common/types'; +import { KnowledgeBaseEntry, KnowledgeBaseEntryRole } from '../../../common/types'; import type { ObservabilityAIAssistantResourceNames } from '../types'; import { getAccessQuery } from '../util/get_access_query'; import { getCategoryQuery } from '../util/get_category_query'; @@ -57,7 +57,7 @@ export enum KnowledgeBaseEntryOperationType { interface KnowledgeBaseDeleteOperation { type: KnowledgeBaseEntryOperationType.Delete; - id?: string; + doc_id?: string; labels?: Record; } @@ -79,6 +79,91 @@ export class KnowledgeBaseService { this.ensureTaskScheduled(); } + setup = async () => { + const retryOptions = { factor: 1, minTimeout: 10000, retries: 12 }; + + const installModel = async () => { + this.dependencies.logger.info('Installing ELSER model'); + await this.dependencies.esClient.ml.putTrainedModel( + { + model_id: ELSER_MODEL_ID, + input: { + field_names: ['text_field'], + }, + // @ts-expect-error + wait_for_completion: true, + }, + { requestTimeout: '20m' } + ); + this.dependencies.logger.info('Finished installing ELSER model'); + }; + + const getIsModelInstalled = async () => { + const getResponse = await this.dependencies.esClient.ml.getTrainedModels({ + model_id: ELSER_MODEL_ID, + include: 'definition_status', + }); + + this.dependencies.logger.debug( + 'Model definition status:\n' + JSON.stringify(getResponse.trained_model_configs[0]) + ); + + return Boolean(getResponse.trained_model_configs[0]?.fully_defined); + }; + + await pRetry(async () => { + let isModelInstalled: boolean = false; + try { + isModelInstalled = await getIsModelInstalled(); + } catch (error) { + if (isAlreadyExistsError(error)) { + await installModel(); + isModelInstalled = await getIsModelInstalled(); + } + } + + if (!isModelInstalled) { + throwKnowledgeBaseNotReady({ + message: 'Model is not fully defined', + }); + } + }, retryOptions); + + try { + await this.dependencies.esClient.ml.startTrainedModelDeployment({ + model_id: ELSER_MODEL_ID, + wait_for: 'fully_allocated', + }); + } catch (error) { + this.dependencies.logger.debug('Error starting model deployment'); + this.dependencies.logger.debug(error); + if (!isAlreadyExistsError(error)) { + throw error; + } + } + + await pRetry(async () => { + const response = await this.dependencies.esClient.ml.getTrainedModelsStats({ + model_id: ELSER_MODEL_ID, + }); + + if ( + response.trained_model_stats[0]?.deployment_stats?.allocation_status.state === + 'fully_allocated' + ) { + return Promise.resolve(); + } + + this.dependencies.logger.debug('Model is not allocated yet'); + this.dependencies.logger.debug(JSON.stringify(response)); + + throw gatewayTimeout(); + }, retryOptions); + + this.dependencies.logger.info('Model is ready'); + this.ensureTaskScheduled(); + }; + private ensureTaskScheduled() { this.dependencies.taskManagerStart .ensureScheduled({ @@ -110,7 +195,7 @@ export class KnowledgeBaseService { query: { bool: { filter: [ - ...(operation.id ? [{ term: { _id: operation.id } }] : []), + ...(operation.doc_id ? [{ term: { _id: operation.doc_id } }] : []), ...(operation.labels ? map(operation.labels, (value, key) => { return { term: { [key]: value } }; @@ -123,7 +208,7 @@ export class KnowledgeBaseService { return; } - await this.summarize({ + await this.addEntry({ entry: operation.document, }); } @@ -143,6 +228,7 @@ export class KnowledgeBaseService { this.hasSetup = true; this.dependencies.logger.info(`Processing ${this._queue.length} queue operations`); + const limiter = pLimit(5); const operations = this._queue.concat(); @@ -181,6 +267,27 @@ export class KnowledgeBaseService { }); } + status = async () => { + try { + const modelStats = await this.dependencies.esClient.ml.getTrainedModelsStats({ + model_id: ELSER_MODEL_ID, + }); + const elserModelStats = modelStats.trained_model_stats[0]; + const deploymentState = elserModelStats.deployment_stats?.state; + const allocationState = elserModelStats.deployment_stats?.allocation_status.state; + return { + ready: deploymentState === 'started' && allocationState === 'fully_allocated', + deployment_state: deploymentState, + allocation_state: allocationState, + }; + } catch (error) { + return { + error: error instanceof errors.ResponseError ? error.body.error : String(error), + ready: false, + }; + } + }; + recall = async ({ user, queries, @@ -218,7 +325,7 @@ export class KnowledgeBaseService { const response = await this.dependencies.esClient.search< Pick >({ - index: this.dependencies.resources.aliases.kb, + index: [this.dependencies.resources.aliases.kb], query, size: 5, _source: { @@ -241,7 +348,68 @@ export class KnowledgeBaseService { } }; - summarize = async ({ + getEntries = async ({ + query, + sortBy, + sortDirection, + }: { + query?: string; + sortBy?: string; + sortDirection?: 'asc' | 'desc'; + }): Promise<{ entries: KnowledgeBaseEntry[] }> => { + try { + const response = await this.dependencies.esClient.search({ + index: this.dependencies.resources.aliases.kb, + ...(query + ? { + query: { + wildcard: { + doc_id: { + value: `${query}*`, + }, + }, + }, + } + : {}), + sort: [ + { + [String(sortBy)]: { + order: sortDirection, + }, + }, + ], + size: 500, + _source: { + includes: [ + 'doc_id', + 'text', + 'is_correction', + 'labels', + 'confidence', + 'public', + '@timestamp', + 'role', + ], + }, + }); + + return { + entries: response.hits.hits.map((hit) => ({ + ...hit._source!, + role: hit._source!.role ?? KnowledgeBaseEntryRole.UserEntry, + score: hit._score, + id: hit._id, + })), + }; + } catch (error) { + if (isAlreadyExistsError(error)) { + throwKnowledgeBaseNotReady(error.body); + } + throw error; + } + }; + + addEntry = async ({ entry: { id, ...document }, user, namespace, @@ -271,109 +439,40 @@ export class KnowledgeBaseService { } }; - status = async () => { - try { - const modelStats = await this.dependencies.esClient.ml.getTrainedModelsStats({ - model_id: ELSER_MODEL_ID, - }); - const elserModelStats = modelStats.trained_model_stats[0]; - const deploymentState = elserModelStats.deployment_stats?.state; - const allocationState = elserModelStats.deployment_stats?.allocation_status.state; - return { - ready: deploymentState === 'started' && allocationState === 'fully_allocated', - deployment_state: deploymentState, - allocation_state: allocationState, - }; - } catch (error) { - return { - error: error instanceof errors.ResponseError ? error.body.error : String(error), - ready: false, - }; - } - }; - - setup = async () => { - const retryOptions = { factor: 1, minTimeout: 10000, retries: 12 }; - - const installModel = async () => { - this.dependencies.logger.info('Installing ELSER model'); - await this.dependencies.esClient.ml.putTrainedModel( - { - model_id: ELSER_MODEL_ID, - input: { - field_names: ['text_field'], - }, - // @ts-expect-error - wait_for_completion: true, - }, - { requestTimeout: '20m' } - ); - this.dependencies.logger.info('Finished installing ELSER model'); - }; - - const getIsModelInstalled = async () => { - const getResponse = await this.dependencies.esClient.ml.getTrainedModels({ - model_id: ELSER_MODEL_ID, - include: 'definition_status', - }); - - this.dependencies.logger.debug( - 'Model definition status:\n' + JSON.stringify(getResponse.trained_model_configs[0]) - ); + addEntries = async ({ + operations, + }: { + operations: KnowledgeBaseEntryOperation[]; + }): Promise => { + this.dependencies.logger.info(`Starting import of ${operations.length} entries`); - return Boolean(getResponse.trained_model_configs[0]?.fully_defined); - }; + const limiter = pLimit(5); - await pRetry(async () => { - let isModelInstalled: boolean = false; - try { - isModelInstalled = await getIsModelInstalled(); - } catch (error) { - if (isAlreadyExistsError(error)) { - await installModel(); - isModelInstalled = await getIsModelInstalled(); - } - } + await Promise.all( + operations.map((operation) => + limiter(async () => { + await this.processOperation(operation); + }) + ) + ); - if (!isModelInstalled) { - throwKnowledgeBaseNotReady({ - message: 'Model is not fully defined', - }); - } - }, retryOptions); + this.dependencies.logger.info(`Completed import of ${operations.length} entries`); + }; + deleteEntry = async ({ id }: { id: string }): Promise => { try { - await this.dependencies.esClient.ml.startTrainedModelDeployment({ - model_id: ELSER_MODEL_ID, - wait_for: 'fully_allocated', + await this.dependencies.esClient.delete({ + index: this.dependencies.resources.aliases.kb, + id, + refresh: 'wait_for', }); + + return Promise.resolve(); } catch (error) { - this.dependencies.logger.debug('Error starting model deployment'); - this.dependencies.logger.debug(error); - if (!isAlreadyExistsError(error)) { - throw error; + if (isAlreadyExistsError(error)) { + throwKnowledgeBaseNotReady(error.body); } + throw error; } - - await pRetry(async () => { - const response = await this.dependencies.esClient.ml.getTrainedModelsStats({ - model_id: ELSER_MODEL_ID, - }); - - if ( - response.trained_model_stats[0]?.deployment_stats?.allocation_status.state === - 'fully_allocated' - ) { - return Promise.resolve(); - } - - this.dependencies.logger.debug('Model is not allocated yet'); - this.dependencies.logger.debug(JSON.stringify(response)); - - throw gatewayTimeout(); - }, retryOptions); - - this.dependencies.logger.info('Model is ready'); - this.ensureTaskScheduled(); }; } diff --git a/x-pack/plugins/observability_ai_assistant/server/service/kb_service/kb_docs/lens.ts b/x-pack/plugins/observability_ai_assistant/server/service/knowledge_base_service/kb_docs/lens.ts similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/service/kb_service/kb_docs/lens.ts rename to x-pack/plugins/observability_ai_assistant/server/service/knowledge_base_service/kb_docs/lens.ts diff --git a/x-pack/plugins/observability_ai_assistant/server/service/util/split_kb_text.ts b/x-pack/plugins/observability_ai_assistant/server/service/util/split_kb_text.ts index e2b5b7c2c5784..9a2f047b60f9b 100644 --- a/x-pack/plugins/observability_ai_assistant/server/service/util/split_kb_text.ts +++ b/x-pack/plugins/observability_ai_assistant/server/service/util/split_kb_text.ts @@ -7,7 +7,10 @@ import { merge } from 'lodash'; import type { KnowledgeBaseEntry } from '../../../common/types'; -import { type KnowledgeBaseEntryOperation, KnowledgeBaseEntryOperationType } from '../kb_service'; +import { + type KnowledgeBaseEntryOperation, + KnowledgeBaseEntryOperationType, +} from '../knowledge_base_service'; export function splitKbText({ id, @@ -17,17 +20,15 @@ export function splitKbText({ return [ { type: KnowledgeBaseEntryOperationType.Delete, - labels: { - document_id: id, - }, + doc_id: id, + labels: {}, }, ...texts.map((text, index) => ({ type: KnowledgeBaseEntryOperationType.Index, document: merge({}, rest, { id: [id, index].join('_'), - labels: { - document_id: id, - }, + doc_id: id, + labels: {}, text, }), })), diff --git a/x-pack/plugins/profiling/public/app.tsx b/x-pack/plugins/profiling/public/app.tsx index 8ffc064359e3d..2d5dd828c18b0 100644 --- a/x-pack/plugins/profiling/public/app.tsx +++ b/x-pack/plugins/profiling/public/app.tsx @@ -84,7 +84,7 @@ function App({ - + diff --git a/x-pack/plugins/profiling/public/embeddables/profiling_embeddable_provider.tsx b/x-pack/plugins/profiling/public/embeddables/profiling_embeddable_provider.tsx index b8defbdfa4acb..15e4af7bedb94 100644 --- a/x-pack/plugins/profiling/public/embeddables/profiling_embeddable_provider.tsx +++ b/x-pack/plugins/profiling/public/embeddables/profiling_embeddable_provider.tsx @@ -53,7 +53,9 @@ export function ProfilingEmbeddableProvider({ deps, children }: Props) { - + {children} diff --git a/x-pack/plugins/serverless_observability/public/plugin.ts b/x-pack/plugins/serverless_observability/public/plugin.ts index bbc2ac7e0435b..a24ebf3855429 100644 --- a/x-pack/plugins/serverless_observability/public/plugin.ts +++ b/x-pack/plugins/serverless_observability/public/plugin.ts @@ -6,7 +6,9 @@ */ import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; import { appIds } from '@kbn/management-cards-navigation'; +import { appCategories } from '@kbn/management-cards-navigation/src/types'; import { getObservabilitySideNavComponent } from './components/side_navigation'; import { createObservabilityDashboardRegistration } from './logs_signal/overview_registration'; import { @@ -49,6 +51,21 @@ export class ServerlessObservabilityPlugin management.setupCardsNavigation({ enabled: true, hideLinksTo: [appIds.RULES], + extendCardNavDefinitions: { + aiAssistantManagementObservability: { + category: appCategories.OTHER, + title: i18n.translate('xpack.serverlessObservability.aiAssistantManagementTitle', { + defaultMessage: 'AI assistant for Observability settings', + }), + description: i18n.translate( + 'xpack.serverlessObservability.aiAssistantManagementDescription', + { + defaultMessage: 'Manage your AI assistant for Observability settings.', + } + ), + icon: 'sparkles', + }, + }, }); return {}; } diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/synthetics_app.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/synthetics_app.tsx index 5a6492f552fc9..2ce861c65be79 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/synthetics_app.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/synthetics_app.tsx @@ -15,6 +15,7 @@ import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; import { InspectorContextProvider } from '@kbn/observability-shared-plugin/public'; import { ObservabilityAIAssistantProvider } from '@kbn/observability-ai-assistant-plugin/public'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { SyntheticsDataViewContextProvider } from './contexts/synthetics_data_view_context'; import { SyntheticsAppProps } from './contexts'; @@ -68,6 +69,8 @@ const Application = (props: SyntheticsAppProps) => { store.dispatch(setBasePath(basePath)); + const queryClient = new QueryClient(); + return ( @@ -81,52 +84,56 @@ const Application = (props: SyntheticsAppProps) => { }} > - - - - - - - - - -
- - - - - - - -
-
-
-
-
-
-
-
-
-
+ + + + + + + + + + +
+ + + + + + + +
+
+
+
+
+
+
+
+
+
+
diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index eb29d6aec21dd..3d9f3621d0759 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -29653,15 +29653,8 @@ "xpack.observabilityAiAssistant.askAssistantButton.popoverTitle": "Assistant d'Elastic", "xpack.observabilityAiAssistant.assistantSetup.title": "Bienvenue sur l'assistant d'intelligence artificielle d'Elastic", "xpack.observabilityAiAssistant.chatHeader.actions.connector": "Connecteur", - "xpack.observabilityAiAssistant.chatHeader.actions.connectorManagement": "Accéder au Machine Learning", - "xpack.observabilityAiAssistant.chatHeader.actions.connectorManagement.button": "Gérer les connecteurs", "xpack.observabilityAiAssistant.chatHeader.actions.copyConversation": "Copier la conversation", "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase": "Base de connaissances", - "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase.description.paragraph": "L'utilisation d'une base de connaissances est facultative, mais améliore largement l'expérience d'utilisation de l'assistant.", - "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase.elser.learnMore": "En savoir plus", - "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase.switchLabel.enable": "La base de connaissances est installée", - "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase.switchLabel.installing": "Configuration de la base de connaissances", - "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase.title": "Base de connaissances", "xpack.observabilityAiAssistant.chatHeader.actions.title": "Actions", "xpack.observabilityAiAssistant.chatHeader.editConversationInput": "Modifier la conversation", "xpack.observabilityAiAssistant.chatTimeline.actions.copyMessage": "Copier le message", @@ -29710,12 +29703,6 @@ "xpack.observabilityAiAssistant.incorrectLicense.subscriptionPlansButton": "Plans d'abonnement", "xpack.observabilityAiAssistant.incorrectLicense.title": "Mettez votre licence à niveau", "xpack.observabilityAiAssistant.initialSetupPanel.disclaimer": "Le fournisseur d'intelligence artificielle configuré peut collecter des données télémétriques lors de l'utilisation de l'assistant d'intelligence artificielle d'Elastic. Contactez votre fournisseur d'intelligence artificielle pour obtenir des informations sur le mode de collecte des données.", - "xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.buttonLabel.alreadyInstalled": "La base de connaissances est installée", - "xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.buttonLabel.installingKb": "Installation de la base de connaissances", - "xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.buttonLabel.kbNotInstalledYet": "Configurer la base de connaissances", - "xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.description.paragraph1": "Nous vous recommandons d'activer la base de connaissances pour bénéficier d'une meilleure expérience. L'assistant peut ainsi apprendre à partir des interactions que vous engagez avec lui.", - "xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.description.paragraph2": "Cette étape est facultative, vous pouvez y revenir plus tard.", - "xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.title": "Base de connaissances", "xpack.observabilityAiAssistant.initialSetupPanel.setupConnector.buttonLabel": "Configurez un connecteur OpenAI", "xpack.observabilityAiAssistant.initialSetupPanel.setupConnector.description": "Veuillez sélectionner un fournisseur.", "xpack.observabilityAiAssistant.initialSetupPanel.setupConnector.description1": "Configurez un connecteur OpenAI avec votre fournisseur d'intelligence artificielle.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index bf155e5d63898..9bb2ba113aa0d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -29653,15 +29653,8 @@ "xpack.observabilityAiAssistant.askAssistantButton.popoverTitle": "Elastic Assistant", "xpack.observabilityAiAssistant.assistantSetup.title": "Elastic AI Assistantへようこそ", "xpack.observabilityAiAssistant.chatHeader.actions.connector": "コネクター", - "xpack.observabilityAiAssistant.chatHeader.actions.connectorManagement": "機械学習に移動", - "xpack.observabilityAiAssistant.chatHeader.actions.connectorManagement.button": "コネクターを管理", "xpack.observabilityAiAssistant.chatHeader.actions.copyConversation": "会話をコピー", "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase": "ナレッジベース", - "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase.description.paragraph": "ナレッジベースの使用は任意ですが、アシスタントの使用エクスペリエンスが大幅に向上します。", - "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase.elser.learnMore": "詳細", - "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase.switchLabel.enable": "ナレッジベースがインストールされました", - "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase.switchLabel.installing": "ナレッジベースをセットアップ中", - "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase.title": "ナレッジベース", "xpack.observabilityAiAssistant.chatHeader.actions.title": "アクション", "xpack.observabilityAiAssistant.chatHeader.editConversationInput": "会話を編集", "xpack.observabilityAiAssistant.chatTimeline.actions.copyMessage": "メッセージをコピー", @@ -29710,12 +29703,6 @@ "xpack.observabilityAiAssistant.incorrectLicense.subscriptionPlansButton": "サブスクリプションオプション", "xpack.observabilityAiAssistant.incorrectLicense.title": "ライセンスをアップグレード", "xpack.observabilityAiAssistant.initialSetupPanel.disclaimer": "構成されたAIプロバイダーは、Elastic AI Assistantの使用時にテレメトリを収集することがあります。データの収集方法については、AIプロバイダーにお問い合わせください。", - "xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.buttonLabel.alreadyInstalled": "ナレッジベースがインストールされました", - "xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.buttonLabel.installingKb": "ナレッジベースをインストール中", - "xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.buttonLabel.kbNotInstalledYet": "ナレッジベースをセットアップ", - "xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.description.paragraph1": "エクスペリエンスを強化するために、ナレッジベースを有効にすることをお勧めします。ナレッジベースによって、アシスタントは、アシスタントとあなたの対話から学習する能力を得ることができます。", - "xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.description.paragraph2": "このステップは任意で、後でいつでも行うことができます。", - "xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.title": "ナレッジベース", "xpack.observabilityAiAssistant.initialSetupPanel.setupConnector.buttonLabel": "OpenAIコネクターをセットアップ", "xpack.observabilityAiAssistant.initialSetupPanel.setupConnector.description": "プロバイダーを選択してください。", "xpack.observabilityAiAssistant.initialSetupPanel.setupConnector.description1": "AIプロバイダーとOpenAIコネクターを設定します。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 882f1042fa930..bdf1e68b72200 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -29650,15 +29650,8 @@ "xpack.observabilityAiAssistant.askAssistantButton.popoverTitle": "Elastic 助手", "xpack.observabilityAiAssistant.assistantSetup.title": "欢迎使用 Elastic AI 助手", "xpack.observabilityAiAssistant.chatHeader.actions.connector": "连接器", - "xpack.observabilityAiAssistant.chatHeader.actions.connectorManagement": "前往 Machine Learning", - "xpack.observabilityAiAssistant.chatHeader.actions.connectorManagement.button": "管理连接器", "xpack.observabilityAiAssistant.chatHeader.actions.copyConversation": "复制对话", "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase": "知识库", - "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase.description.paragraph": "使用知识库为可选操作,但可以显著改进使用助手的体验。", - "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase.elser.learnMore": "了解详情", - "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase.switchLabel.enable": "已安装知识库", - "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase.switchLabel.installing": "正在设置知识库", - "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase.title": "知识库", "xpack.observabilityAiAssistant.chatHeader.actions.title": "操作", "xpack.observabilityAiAssistant.chatHeader.editConversationInput": "编辑对话", "xpack.observabilityAiAssistant.chatTimeline.actions.copyMessage": "复制消息", @@ -29707,12 +29700,6 @@ "xpack.observabilityAiAssistant.incorrectLicense.subscriptionPlansButton": "订阅计划", "xpack.observabilityAiAssistant.incorrectLicense.title": "升级您的许可证", "xpack.observabilityAiAssistant.initialSetupPanel.disclaimer": "配置的 AI 提供商可能会在使用 Elastic AI 助手时收集遥测数据。请联系您的 AI 提供商了解有关如何收集数据的信息。", - "xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.buttonLabel.alreadyInstalled": "已安装知识库", - "xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.buttonLabel.installingKb": "正在安装知识库", - "xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.buttonLabel.kbNotInstalledYet": "设置知识库", - "xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.description.paragraph1": "建议您启用知识库以获得更好的体验。这将提供帮助,提高您通过与其进行交互来学习的能力。", - "xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.description.paragraph2": "此步骤可选,您始终可以在稍后执行该步骤。", - "xpack.observabilityAiAssistant.initialSetupPanel.knowledgeBase.title": "知识库", "xpack.observabilityAiAssistant.initialSetupPanel.setupConnector.buttonLabel": "设置 OpenAI 连接器", "xpack.observabilityAiAssistant.initialSetupPanel.setupConnector.description": "请选择提供商。", "xpack.observabilityAiAssistant.initialSetupPanel.setupConnector.description1": "通过 AI 提供商设置 OpenAI 连接器。", diff --git a/x-pack/plugins/uptime/public/legacy_uptime/app/uptime_app.tsx b/x-pack/plugins/uptime/public/legacy_uptime/app/uptime_app.tsx index 574e9ee985812..a87180eb0a932 100644 --- a/x-pack/plugins/uptime/public/legacy_uptime/app/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/legacy_uptime/app/uptime_app.tsx @@ -127,7 +127,9 @@ const Application = (props: UptimeAppProps) => { cases: startPlugins.cases, }} > - + diff --git a/x-pack/plugins/ux/public/application/application.test.tsx b/x-pack/plugins/ux/public/application/application.test.tsx index 9a7eed50b4ab3..33a2ef66b9c70 100644 --- a/x-pack/plugins/ux/public/application/application.test.tsx +++ b/x-pack/plugins/ux/public/application/application.test.tsx @@ -67,6 +67,9 @@ const mockCorePlugins = { ), }, }, + observabilityAIAssistant: { + service: {}, + }, data: { query: { timefilter: { diff --git a/x-pack/plugins/ux/public/application/ux_app.tsx b/x-pack/plugins/ux/public/application/ux_app.tsx index b7ca736c6b5af..338b5af32d0e8 100644 --- a/x-pack/plugins/ux/public/application/ux_app.tsx +++ b/x-pack/plugins/ux/public/application/ux_app.tsx @@ -152,7 +152,9 @@ export function UXAppRoot({ lens, }} > - + { + const requestHandler = ( + request: http.IncomingMessage, + response: http.ServerResponse & { req: http.IncomingMessage } + ) => {}; + + let server: Server; + + before(async () => { + const port = await getPort({ port: getPort.makeRange(9000, 9100) }); + + server = http + .createServer((request, response) => { + requestHandler(request, response); + }) + .listen(port); + }); + + after(() => { + server.close(); + }); + + it('should be possible to set up the knowledge base', async () => { + return supertest + .get(`${KNOWLEDGE_BASE_API_URL}/setup`) + .set('kbn-xsrf', 'foo') + .expect(200) + .then((response) => { + expect(response.body).to.eql({ entries: [] }); + }); + }); + + describe('when creating a single entry', () => { + it('returns a 200 when using the right payload', async () => { + const knowledgeBaseEntry = { + id: 'my-doc-id-1', + text: 'My content', + }; + + await supertest + .post(`${KNOWLEDGE_BASE_API_URL}/entries/save`) + .set('kbn-xsrf', 'foo') + .send(knowledgeBaseEntry) + .expect(200); + + return supertest + .get(`${KNOWLEDGE_BASE_API_URL}/entries?query=&sortBy=doc_id&sortDirection=asc`) + .set('kbn-xsrf', 'foo') + .expect(200) + .then((response) => { + expect(response.body).to.eql({ entries: [knowledgeBaseEntry] }); + }); + }); + + it('returns a 500 when using the wrong payload', async () => { + const knowledgeBaseEntry = { + foo: 'my-doc-id-1', + }; + + await supertest + .post(`${KNOWLEDGE_BASE_API_URL}/entries/save`) + .set('kbn-xsrf', 'foo') + .send(knowledgeBaseEntry) + .expect(500); + }); + }); + + describe('when importing multiple entries', () => { + it('returns a 200 when using the right payload', async () => { + const knowledgeBaseEntries = [ + { + id: 'my-doc-id-2', + text: 'My content 2', + }, + { + id: 'my-doc-id-3', + text: 'My content 3', + }, + ]; + + await supertest + .post(`${KNOWLEDGE_BASE_API_URL}/entries/import`) + .set('kbn-xsrf', 'foo') + .send(knowledgeBaseEntries) + .expect(200); + + return supertest + .get(`${KNOWLEDGE_BASE_API_URL}/entries?query=&sortBy=doc_id&sortDirection=asc`) + .set('kbn-xsrf', 'foo') + .expect(200) + .then((response) => { + expect(response.body).to.eql({ entries: knowledgeBaseEntries }); + }); + }); + + it('returns a 500 when using the wrong payload', async () => { + const knowledgeBaseEntry = { + foo: 'my-doc-id-1', + }; + + await supertest + .post(`${KNOWLEDGE_BASE_API_URL}/entries/import`) + .set('kbn-xsrf', 'foo') + .send(knowledgeBaseEntry) + .expect(500); + }); + }); + + describe('when deleting an entry', () => { + it('returns a 200 when the item is found and the item is deleted', async () => { + await supertest + .delete(`${KNOWLEDGE_BASE_API_URL}/entries/delete/my-doc-id-2`) + .set('kbn-xsrf', 'foo') + .expect(200); + + return supertest + .get(`${KNOWLEDGE_BASE_API_URL}/entries?query=&sortBy=doc_id&sortDirection=asc`) + .set('kbn-xsrf', 'foo') + .expect(200) + .then((response) => { + expect(response.body).to.eql({ + entries: [ + { + id: 'my-doc-id-1', + text: 'My content 1', + confidence: 'high', + }, + { + id: 'my-doc-id-3', + text: 'My content 3', + }, + ], + }); + }); + }); + + it('returns a 500 when the item is not found ', async () => { + return await supertest + .delete(`${KNOWLEDGE_BASE_API_URL}/entries/delete/my-doc-id-2`) + .set('kbn-xsrf', 'foo') + .expect(500); + }); + }); + + describe('when retrieving entries', () => { + it('returns a 200 when calling get entries with the right parameters', async () => { + return supertest + .get(`${KNOWLEDGE_BASE_API_URL}/entries?query=&sortBy=doc_id&sortDirection=asc`) + .set('kbn-xsrf', 'foo') + .expect(200) + .then((response) => { + expect(response.body).to.eql({ entries: [] }); + }); + }); + + it('allows sorting', async () => { + return supertest + .get(`${KNOWLEDGE_BASE_API_URL}/entries?query=&sortBy=doc_id&sortDirection=desc`) + .set('kbn-xsrf', 'foo') + .expect(200) + .then((response) => { + expect(response.body).to.eql({ + entries: [ + { + id: 'my-doc-id-3', + text: 'My content 3', + }, + { + id: 'my-doc-id-1', + text: 'My content 1', + }, + ], + }); + }); + }); + + it('allows searching', async () => { + return supertest + .get(`${KNOWLEDGE_BASE_API_URL}/entries?query=my-doc-3&sortBy=doc_id&sortDirection=asc`) + .set('kbn-xsrf', 'foo') + .expect(200) + .then((response) => { + expect(response.body).to.eql({ + entries: [ + { + id: 'my-doc-id-3', + text: 'My content 3', + }, + ], + }); + }); + }); + + it('returns a 500 when calling get entries with the wrong parameters', async () => { + return supertest + .get(`${KNOWLEDGE_BASE_API_URL}/entries`) + .set('kbn-xsrf', 'foo') + .expect(500); + }); + }); + }); +} diff --git a/yarn.lock b/yarn.lock index 72ac0376b5ce1..d2efd008f3670 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2967,6 +2967,14 @@ version "0.0.0" uid "" +"@kbn/ai-assistant-management-observability-plugin@link:src/plugins/ai_assistant_management/observability": + version "0.0.0" + uid "" + +"@kbn/ai-assistant-management-plugin@link:src/plugins/ai_assistant_management/selection": + version "0.0.0" + uid "" + "@kbn/aiops-components@link:x-pack/packages/ml/aiops_components": version "0.0.0" uid ""