diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 61f2b6a5c3476..498243ac8931a 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -25,7 +25,7 @@ pageLoadAssetSize: contentConnectors: 33014 contentManagement: 8350 controls: 12234 - core: 566392 + core: 565105 cps: 5918 crossClusterReplication: 12662 customIntegrations: 11715 @@ -75,7 +75,7 @@ pageLoadAssetSize: filesManagement: 5208 fileUpload: 22957 fleet: 187942 - genAiSettings: 5507 + genAiSettings: 5663 globalSearch: 6890 globalSearchBar: 26986 globalSearchProviders: 4646 @@ -130,7 +130,7 @@ pageLoadAssetSize: painlessLab: 6299 presentationPanel: 11418 presentationUtil: 9000 - productDocBase: 3083 + productDocBase: 5025 productIntercept: 9860 profiling: 20716 reindexService: 3469 diff --git a/x-pack/platform/plugins/private/gen_ai_settings/kibana.jsonc b/x-pack/platform/plugins/private/gen_ai_settings/kibana.jsonc index b5d83ee278adb..16aded90fdea8 100644 --- a/x-pack/platform/plugins/private/gen_ai_settings/kibana.jsonc +++ b/x-pack/platform/plugins/private/gen_ai_settings/kibana.jsonc @@ -9,8 +9,8 @@ "browser": true, "server": true, "configPath": ["xpack", "genAiSettings"], - "requiredPlugins": ["management", "actions", "inference", "licensing"], + "requiredPlugins": ["management", "actions", "inference", "licensing", "productDocBase"], "optionalPlugins": ["spaces"], - "requiredBundles": ["kibanaReact"] + "requiredBundles": ["kibanaReact", "productDocBase"] } } diff --git a/x-pack/platform/plugins/private/gen_ai_settings/moon.yml b/x-pack/platform/plugins/private/gen_ai_settings/moon.yml index 527a5805e5363..7fe14db457a81 100644 --- a/x-pack/platform/plugins/private/gen_ai_settings/moon.yml +++ b/x-pack/platform/plugins/private/gen_ai_settings/moon.yml @@ -43,6 +43,7 @@ dependsOn: - '@kbn/licensing-plugin' - '@kbn/management-settings-components-field-row' - '@kbn/react-query' + - '@kbn/product-doc-base-plugin' - '@kbn/ai-assistant-common' - '@kbn/ai-agent-confirmation-modal' tags: diff --git a/x-pack/platform/plugins/private/gen_ai_settings/public/components/documentation/documentation_section.test.tsx b/x-pack/platform/plugins/private/gen_ai_settings/public/components/documentation/documentation_section.test.tsx new file mode 100644 index 0000000000000..a7f9d0915d342 --- /dev/null +++ b/x-pack/platform/plugins/private/gen_ai_settings/public/components/documentation/documentation_section.test.tsx @@ -0,0 +1,268 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { coreMock } from '@kbn/core/public/mocks'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { QueryClient, QueryClientProvider } from '@kbn/react-query'; +import { I18nProvider } from '@kbn/i18n-react'; +import type { ProductDocBasePluginStart } from '@kbn/product-doc-base-plugin/public'; +import { DocumentationSection } from './documentation_section'; + +describe('DocumentationSection', () => { + const coreStart = coreMock.createStart(); + + const mockProductDocBase: ProductDocBasePluginStart = { + installation: { + getStatus: jest.fn().mockResolvedValue({ + inferenceId: '.elser-2-elasticsearch', + overall: 'uninstalled', + perProducts: {}, + }), + install: jest.fn().mockResolvedValue({ installed: true }), + uninstall: jest.fn().mockResolvedValue({ success: true }), + }, + }; + + const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + const renderComponent = ( + productDocBase: ProductDocBasePluginStart = mockProductDocBase, + hasManagePrivilege: boolean = true + ) => { + const queryClient = createQueryClient(); + // Set capabilities directly on the mock before rendering + (coreStart.application.capabilities as Record>).agentBuilder = { + show: true, + manageAgents: hasManagePrivilege, + }; + return render( + + + + + + + + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('should render the documentation section', async () => { + renderComponent(mockProductDocBase); + + await waitFor(() => { + expect(screen.getByTestId('documentationSection')).toBeInTheDocument(); + expect(screen.getByTestId('documentationTitle')).toBeInTheDocument(); + expect(screen.getByTestId('documentationTable')).toBeInTheDocument(); + }); + }); + + it('should render all documentation items', async () => { + renderComponent(mockProductDocBase); + + await waitFor(() => { + expect(screen.getByText('Elastic documents')).toBeInTheDocument(); + // Security labs is commented out for now - will be enabled later + expect(screen.queryByText('Security labs')).not.toBeInTheDocument(); + }); + }); + }); + + describe('status display', () => { + it('should show "Not installed" status when uninstalled', async () => { + mockProductDocBase.installation.getStatus = jest.fn().mockResolvedValue({ + inferenceId: '.elser-2-elasticsearch', + overall: 'uninstalled', + perProducts: {}, + }); + + renderComponent(mockProductDocBase); + + await waitFor(() => { + const notInstalledBadges = screen.getAllByText('Not installed'); + expect(notInstalledBadges.length).toBeGreaterThan(0); + }); + }); + + it('should show "Installed" status when installed', async () => { + mockProductDocBase.installation.getStatus = jest.fn().mockResolvedValue({ + inferenceId: '.elser-2-elasticsearch', + overall: 'installed', + perProducts: {}, + }); + + renderComponent(mockProductDocBase); + + await waitFor(() => { + expect(screen.getByText('Installed')).toBeInTheDocument(); + }); + }); + }); + + describe('actions', () => { + it('should show install action for uninstalled items', async () => { + mockProductDocBase.installation.getStatus = jest.fn().mockResolvedValue({ + inferenceId: '.elser-2-elasticsearch', + overall: 'uninstalled', + perProducts: {}, + }); + + renderComponent(mockProductDocBase); + + await waitFor(() => { + expect(screen.getByTestId('documentation-install-elastic_documents')).toBeInTheDocument(); + }); + }); + + it('should show uninstall action for installed items', async () => { + mockProductDocBase.installation.getStatus = jest.fn().mockResolvedValue({ + inferenceId: '.elser-2-elasticsearch', + overall: 'installed', + perProducts: {}, + }); + + renderComponent(mockProductDocBase); + + await waitFor(() => { + expect(screen.getByTestId('documentation-uninstall-elastic_documents')).toBeInTheDocument(); + }); + }); + + it('should call install when install action is clicked', async () => { + mockProductDocBase.installation.getStatus = jest.fn().mockResolvedValue({ + inferenceId: '.elser-2-elasticsearch', + overall: 'uninstalled', + perProducts: {}, + }); + + renderComponent(mockProductDocBase, true); + + await waitFor(() => { + expect(screen.getByTestId('documentation-install-elastic_documents')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('documentation-install-elastic_documents')); + + await waitFor(() => { + expect(mockProductDocBase.installation.install).toHaveBeenCalledWith({ + inferenceId: '.elser-2-elasticsearch', + }); + }); + }); + + it('should call uninstall when uninstall action is clicked', async () => { + mockProductDocBase.installation.getStatus = jest.fn().mockResolvedValue({ + inferenceId: '.elser-2-elasticsearch', + overall: 'installed', + perProducts: {}, + }); + + renderComponent(mockProductDocBase, true); + + await waitFor(() => { + expect(screen.getByTestId('documentation-uninstall-elastic_documents')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('documentation-uninstall-elastic_documents')); + + await waitFor(() => { + expect(mockProductDocBase.installation.uninstall).toHaveBeenCalledWith({ + inferenceId: '.elser-2-elasticsearch', + }); + }); + }); + }); + + describe('RBAC - insufficient privileges', () => { + it('should disable install button when user lacks manage privilege', async () => { + mockProductDocBase.installation.getStatus = jest.fn().mockResolvedValue({ + inferenceId: '.elser-2-elasticsearch', + overall: 'uninstalled', + perProducts: {}, + }); + + renderComponent(mockProductDocBase, false); + + await waitFor(() => { + const installButton = screen.getByTestId('documentation-install-elastic_documents'); + expect(installButton).toBeInTheDocument(); + expect(installButton).toBeDisabled(); + }); + }); + + it('should disable uninstall button when user lacks manage privilege', async () => { + mockProductDocBase.installation.getStatus = jest.fn().mockResolvedValue({ + inferenceId: '.elser-2-elasticsearch', + overall: 'installed', + perProducts: {}, + }); + + renderComponent(mockProductDocBase, false); + + await waitFor(() => { + const uninstallButton = screen.getByTestId('documentation-uninstall-elastic_documents'); + expect(uninstallButton).toBeInTheDocument(); + expect(uninstallButton).toBeDisabled(); + }); + }); + + it('should not call install when install button is clicked without privilege', async () => { + mockProductDocBase.installation.getStatus = jest.fn().mockResolvedValue({ + inferenceId: '.elser-2-elasticsearch', + overall: 'uninstalled', + perProducts: {}, + }); + + renderComponent(mockProductDocBase, false); + + await waitFor(() => { + expect(screen.getByTestId('documentation-install-elastic_documents')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('documentation-install-elastic_documents')); + + // Wait a bit to ensure no call was made + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockProductDocBase.installation.install).not.toHaveBeenCalled(); + }); + + it('should not call uninstall when uninstall button is clicked without privilege', async () => { + mockProductDocBase.installation.getStatus = jest.fn().mockResolvedValue({ + inferenceId: '.elser-2-elasticsearch', + overall: 'installed', + perProducts: {}, + }); + + renderComponent(mockProductDocBase, false); + + await waitFor(() => { + expect(screen.getByTestId('documentation-uninstall-elastic_documents')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('documentation-uninstall-elastic_documents')); + + // Wait a bit to ensure no call was made + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockProductDocBase.installation.uninstall).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/platform/plugins/private/gen_ai_settings/public/components/documentation/documentation_section.tsx b/x-pack/platform/plugins/private/gen_ai_settings/public/components/documentation/documentation_section.tsx new file mode 100644 index 0000000000000..3339822880665 --- /dev/null +++ b/x-pack/platform/plugins/private/gen_ai_settings/public/components/documentation/documentation_section.tsx @@ -0,0 +1,342 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo } from 'react'; +import { + EuiBasicTable, + EuiBadge, + EuiBetaBadge, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSplitPanel, + EuiSpacer, + EuiText, + EuiTitle, + EuiToolTip, + type EuiBasicTableColumn, +} from '@elastic/eui'; +import { defaultInferenceEndpoints } from '@kbn/inference-common'; +import { + useProductDocStatus, + useInstallProductDoc, + useUninstallProductDoc, + type ProductDocBasePluginStart, +} from '@kbn/product-doc-base-plugin/public'; +import { useKibana } from '../../hooks/use_kibana'; +import type { DocumentationItem, DocumentationStatus } from './types'; +import { ELASTIC_DOCS_ID } from './types'; +import * as i18n from './translations'; + +interface DocumentationSectionProps { + productDocBase: ProductDocBasePluginStart; +} + +export const DocumentationSection: React.FC = ({ productDocBase }) => { + const { services } = useKibana(); + const { notifications, application } = services; + + // Check if user has Agent Builder 'All' privileges (manageAgents capability) + const hasManagePrivilege = application.capabilities.agentBuilder?.manageAgents === true; + + const { status, isLoading, refetch } = useProductDocStatus(productDocBase); + + const { mutate: installDoc, isLoading: isInstalling } = useInstallProductDoc(productDocBase, { + onSuccess: () => { + notifications.toasts.addSuccess({ title: i18n.INSTALL_SUCCESS }); + }, + onError: (error) => { + notifications.toasts.addError(new Error(error.body?.message ?? error.message), { + title: i18n.INSTALL_ERROR, + }); + }, + }); + + const { mutate: uninstallDoc, isLoading: isUninstalling } = useUninstallProductDoc( + productDocBase, + { + onSuccess: () => { + notifications.toasts.addSuccess({ title: i18n.UNINSTALL_SUCCESS }); + }, + onError: (error) => { + notifications.toasts.addError(new Error(error.body?.message ?? error.message), { + title: i18n.UNINSTALL_ERROR, + }); + }, + } + ); + + const handleInstall = useCallback( + (itemId: string) => { + if (itemId === ELASTIC_DOCS_ID) { + installDoc(defaultInferenceEndpoints.ELSER); + } + // Security labs is stubbed - no action + }, + [installDoc] + ); + + const handleUninstall = useCallback( + (itemId: string) => { + if (itemId === ELASTIC_DOCS_ID) { + uninstallDoc(defaultInferenceEndpoints.ELSER); + } + // Security labs is stubbed - no action + }, + [uninstallDoc] + ); + + const handleRetry = useCallback( + (itemId: string) => { + handleInstall(itemId); + }, + [handleInstall] + ); + + const documentationItems: DocumentationItem[] = useMemo(() => { + const elasticDocsStatus: DocumentationStatus = status?.overall ?? 'uninstalled'; + + return [ + { + id: ELASTIC_DOCS_ID, + name: i18n.ELASTIC_DOCS_NAME, + status: elasticDocsStatus, + isTechPreview: false, + isStubbed: false, + icon: 'logoElastic', + }, + // TODO: Enable Security Labs after https://github.com/elastic/kibana/issues/244946 is worked + // { + // id: SECURITY_LABS_ID, + // name: i18n.SECURITY_LABS_NAME, + // status: 'uninstalled', + // isTechPreview: false, + // isStubbed: true, + // icon: 'logoSecurity', + // }, + ]; + }, [status]); + + const getStatusBadge = useCallback((itemStatus: DocumentationStatus) => { + // Status badge only shows binary state: Installed or Not installed + // Action states (installing/uninstalling) are shown in the action button + switch (itemStatus) { + case 'installed': + return {i18n.STATUS_INSTALLED}; + case 'uninstalling': + return {i18n.STATUS_INSTALLED}; + case 'error': + return {i18n.STATUS_ERROR}; + case 'not_available': + return {i18n.STATUS_NOT_AVAILABLE}; + case 'installing': + case 'uninstalled': + default: + return {i18n.STATUS_NOT_INSTALLED}; + } + }, []); + + const getActionButton = useCallback( + (item: DocumentationItem) => { + // Use server status OR local mutation state to determine if action is in progress + const isItemInstalling = + item.status === 'installing' || (isInstalling && item.id === ELASTIC_DOCS_ID); + const isItemUninstalling = + item.status === 'uninstalling' || (isUninstalling && item.id === ELASTIC_DOCS_ID); + + // Helper to wrap button with tooltip when user lacks privileges + const wrapWithPrivilegeTooltip = (button: React.ReactElement) => { + if (!hasManagePrivilege) { + return ( + + {button} + + ); + } + return button; + }; + + // Stubbed items show disabled install button + if (item.isStubbed && item.status !== 'not_available') { + return wrapWithPrivilegeTooltip( + + {i18n.ACTION_INSTALL} + + ); + } + + // Not available - show retry + if (item.status === 'not_available') { + return wrapWithPrivilegeTooltip( + refetch() : undefined} + isDisabled={!hasManagePrivilege} + data-test-subj={`documentation-retry-${item.id}`} + > + {i18n.ACTION_RETRY} + + ); + } + + // Error state - show retry + if (item.status === 'error') { + return wrapWithPrivilegeTooltip( + handleRetry(item.id) : undefined} + isDisabled={!hasManagePrivilege} + data-test-subj={`documentation-retry-${item.id}`} + > + {i18n.ACTION_RETRY} + + ); + } + + // Uninstalling - show loading state + if (isItemUninstalling) { + return ( + + {i18n.STATUS_UNINSTALLING} + + ); + } + + // Installed - show uninstall button + if (item.status === 'installed') { + return wrapWithPrivilegeTooltip( + handleUninstall(item.id) : undefined} + isDisabled={!hasManagePrivilege} + data-test-subj={`documentation-uninstall-${item.id}`} + > + {i18n.ACTION_UNINSTALL} + + ); + } + + // Installing - show loading state + if (isItemInstalling) { + return ( + + {i18n.STATUS_INSTALLING} + + ); + } + + // Default - show install button + return wrapWithPrivilegeTooltip( + handleInstall(item.id) : undefined} + isDisabled={!hasManagePrivilege} + data-test-subj={`documentation-install-${item.id}`} + > + {i18n.ACTION_INSTALL} + + ); + }, + [ + handleInstall, + handleUninstall, + handleRetry, + isInstalling, + isUninstalling, + refetch, + hasManagePrivilege, + ] + ); + + const columns: Array> = useMemo( + () => [ + { + field: 'name', + name: i18n.COLUMN_NAME, + sortable: false, + render: (name: string, item: DocumentationItem) => ( + + + + + + {name} + + {item.isTechPreview && ( + + + + )} + + ), + }, + { + field: 'status', + name: i18n.COLUMN_STATUS, + sortable: false, + width: '300px', + render: (itemStatus: DocumentationStatus) => getStatusBadge(itemStatus), + }, + { + field: 'actions', + name: i18n.COLUMN_ACTIONS, + sortable: false, + width: '150px', + align: 'right', + render: (_: unknown, item: DocumentationItem) => getActionButton(item), + }, + ], + [getStatusBadge, getActionButton] + ); + + return ( + + + +

{i18n.DOCUMENTATION_TITLE}

+
+
+ + + {i18n.DOCUMENTATION_DESCRIPTION} + + + + {i18n.SHOWING} 1-{documentationItems.length} {i18n.OF}{' '} + {documentationItems.length} {i18n.DOCUMENTATION_TITLE} + + + + +
+ ); +}; diff --git a/x-pack/platform/plugins/private/gen_ai_settings/public/components/documentation/index.ts b/x-pack/platform/plugins/private/gen_ai_settings/public/components/documentation/index.ts new file mode 100644 index 0000000000000..70341ac2af1a6 --- /dev/null +++ b/x-pack/platform/plugins/private/gen_ai_settings/public/components/documentation/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { DocumentationSection } from './documentation_section'; diff --git a/x-pack/platform/plugins/private/gen_ai_settings/public/components/documentation/translations.ts b/x-pack/platform/plugins/private/gen_ai_settings/public/components/documentation/translations.ts new file mode 100644 index 0000000000000..bda23d2e982d9 --- /dev/null +++ b/x-pack/platform/plugins/private/gen_ai_settings/public/components/documentation/translations.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const DOCUMENTATION_TITLE = i18n.translate('genAiSettings.documentation.title', { + defaultMessage: 'Documentation', +}); + +export const DOCUMENTATION_DESCRIPTION = i18n.translate('genAiSettings.documentation.description', { + defaultMessage: + 'Help improve Agent Builder responses to your prompts by installing product documentation. All entries are global to the cluster.', +}); + +export const ELASTIC_DOCS_NAME = i18n.translate('genAiSettings.documentation.elasticDocs.name', { + defaultMessage: 'Elastic documents', +}); + +export const SECURITY_LABS_NAME = i18n.translate('genAiSettings.documentation.securityLabs.name', { + defaultMessage: 'Security labs', +}); + +export const COLUMN_NAME = i18n.translate('genAiSettings.documentation.column.name', { + defaultMessage: 'Name', +}); + +export const COLUMN_STATUS = i18n.translate('genAiSettings.documentation.column.status', { + defaultMessage: 'Status', +}); + +export const COLUMN_ACTIONS = i18n.translate('genAiSettings.documentation.column.actions', { + defaultMessage: 'Actions', +}); + +export const STATUS_INSTALLED = i18n.translate('genAiSettings.documentation.status.installed', { + defaultMessage: 'Installed', +}); + +export const STATUS_NOT_INSTALLED = i18n.translate( + 'genAiSettings.documentation.status.notInstalled', + { + defaultMessage: 'Not installed', + } +); + +export const STATUS_INSTALLING = i18n.translate('genAiSettings.documentation.status.installing', { + defaultMessage: 'Installing...', +}); + +export const STATUS_UNINSTALLING = i18n.translate( + 'genAiSettings.documentation.status.uninstalling', + { + defaultMessage: 'Uninstalling...', + } +); + +export const STATUS_ERROR = i18n.translate('genAiSettings.documentation.status.error', { + defaultMessage: 'Error', +}); + +export const STATUS_NOT_AVAILABLE = i18n.translate( + 'genAiSettings.documentation.status.notAvailable', + { + defaultMessage: 'Not available', + } +); + +export const ACTION_INSTALL = i18n.translate('genAiSettings.documentation.action.install', { + defaultMessage: 'Install', +}); + +export const ACTION_UNINSTALL = i18n.translate('genAiSettings.documentation.action.uninstall', { + defaultMessage: 'Uninstall', +}); + +export const ACTION_RETRY = i18n.translate('genAiSettings.documentation.action.retry', { + defaultMessage: 'Retry', +}); + +export const INSTALL_SUCCESS = i18n.translate('genAiSettings.documentation.install.success', { + defaultMessage: 'Documentation installed successfully', +}); + +export const INSTALL_ERROR = i18n.translate('genAiSettings.documentation.install.error', { + defaultMessage: 'Failed to install documentation', +}); + +export const UNINSTALL_SUCCESS = i18n.translate('genAiSettings.documentation.uninstall.success', { + defaultMessage: 'Documentation uninstalled successfully', +}); + +export const UNINSTALL_ERROR = i18n.translate('genAiSettings.documentation.uninstall.error', { + defaultMessage: 'Failed to uninstall documentation', +}); + +export const TECH_PREVIEW = i18n.translate('genAiSettings.documentation.techPreview', { + defaultMessage: 'TECH PREVIEW', +}); + +export const SHOWING = i18n.translate('genAiSettings.documentation.showing', { + defaultMessage: 'Showing', +}); + +export const OF = i18n.translate('genAiSettings.documentation.of', { + defaultMessage: 'of', +}); + +export const INSUFFICIENT_PRIVILEGES = i18n.translate( + 'genAiSettings.documentation.insufficientPrivileges', + { + defaultMessage: "Agent Builder 'All' privileges are required to manage documentation", + } +); diff --git a/x-pack/platform/plugins/private/gen_ai_settings/public/components/documentation/types.ts b/x-pack/platform/plugins/private/gen_ai_settings/public/components/documentation/types.ts new file mode 100644 index 0000000000000..70419038915b5 --- /dev/null +++ b/x-pack/platform/plugins/private/gen_ai_settings/public/components/documentation/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { InstallationStatus } from '@kbn/product-doc-base-plugin/common/install_status'; + +export type DocumentationStatus = InstallationStatus | 'not_available'; + +export interface DocumentationItem { + id: string; + name: string; + description?: string; + status: DocumentationStatus; + isTechPreview?: boolean; + isStubbed?: boolean; + icon?: string; +} + +export const ELASTIC_DOCS_ID = 'elastic_documents'; +export const SECURITY_LABS_ID = 'security_labs'; diff --git a/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.test.tsx b/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.test.tsx index 3de501a44ce36..19c01a04e5ae6 100644 --- a/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.test.tsx +++ b/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.test.tsx @@ -24,6 +24,17 @@ import { jest.mock('../contexts/enabled_features_context'); const mockUseEnabledFeatures = useEnabledFeatures as jest.MockedFunction; +// Mock productDocBase +const mockProductDocBase = { + installation: { + getStatus: jest.fn().mockResolvedValue({ + overall: 'uninstalled', + }), + install: jest.fn().mockResolvedValue({}), + uninstall: jest.fn().mockResolvedValue({}), + }, +}; + describe('GenAiSettingsApp', () => { const coreStart = coreMock.createStart(); const setBreadcrumbs = jest.fn(); @@ -87,9 +98,13 @@ describe('GenAiSettingsApp', () => { }); const renderComponent = (props = {}) => { + const services = { + ...coreStart, + productDocBase: mockProductDocBase, + }; return renderWithI18n( - + @@ -282,4 +297,37 @@ describe('GenAiSettingsApp', () => { }); }); }); + + describe('Documentation Section conditional rendering', () => { + it('hides Documentation section when chat experience is Classic', async () => { + mockUseEnabledFeatures.mockReturnValue(createFeatureFlagsMock()); + + coreStart.settings.client.getAll.mockReturnValue(createSettingsMock() as any); + + renderComponent(); + + // Documentation section should not be visible in Classic mode + expect(screen.queryByTestId('documentationSection')).not.toBeInTheDocument(); + }); + + it('shows Documentation section when chat experience is Agent', async () => { + mockUseEnabledFeatures.mockReturnValue(createFeatureFlagsMock()); + + coreStart.settings.client.getAll.mockReturnValue( + createSettingsMock({ + [AI_CHAT_EXPERIENCE_TYPE]: { + value: AIChatExperience.Classic, + userValue: AIChatExperience.Agent, // userValue maps to savedValue + type: 'select', + options: [AIChatExperience.Classic, AIChatExperience.Agent], + }, + }) as any + ); + + renderComponent(); + + // Documentation section should be visible in Agent mode + expect(await screen.findByTestId('documentationSection')).toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.tsx b/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.tsx index 24a47df97e6bf..2848732ed8dbe 100644 --- a/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.tsx +++ b/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.tsx @@ -9,7 +9,7 @@ import React, { useEffect, useCallback, useMemo } from 'react'; import { EuiPageSection, EuiSpacer, - EuiPanel, + EuiSplitPanel, EuiDescribedFormGroup, EuiFormRow, EuiFlexGroup, @@ -25,6 +25,8 @@ import type { ManagementAppMountParams } from '@kbn/management-plugin/public'; import { getSpaceIdFromPath } from '@kbn/spaces-utils'; import { isEmpty } from 'lodash'; +import { AI_CHAT_EXPERIENCE_TYPE } from '@kbn/management-settings-ids'; +import { AIChatExperience } from '@kbn/ai-assistant-common'; import { useEnabledFeatures } from '../contexts/enabled_features_context'; import { useKibana } from '../hooks/use_kibana'; import { GoToSpacesButton } from './go_to_spaces_button'; @@ -35,6 +37,7 @@ import { DefaultAIConnector } from './default_ai_connector/default_ai_connector' import { BottomBarActions } from './bottom_bar_actions/bottom_bar_actions'; import { AIAssistantVisibility } from './ai_assistant_visibility/ai_assistant_visibility'; import { ChatExperience } from './chat_experience/chat_experience'; +import { DocumentationSection } from './documentation'; interface GenAiSettingsAppProps { setBreadcrumbs: ManagementAppMountParams['setBreadcrumbs']; @@ -42,7 +45,7 @@ interface GenAiSettingsAppProps { export const GenAiSettingsApp: React.FC = ({ setBreadcrumbs }) => { const { services } = useKibana(); - const { application, http, docLinks } = services; + const { application, http, docLinks, productDocBase } = services; const { showSpacesIntegration, isPermissionsBased, @@ -51,7 +54,15 @@ export const GenAiSettingsApp: React.FC = ({ setBreadcrum showChatExperienceSetting, } = useEnabledFeatures(); const { euiTheme } = useEuiTheme(); - const { unsavedChanges, isSaving, cleanUnsavedChanges, saveAll } = useSettingsContext(); + const { fields, unsavedChanges, isSaving, cleanUnsavedChanges, saveAll } = useSettingsContext(); + + // Determine current chat experience (including unsaved changes) + const chatExperienceField = fields[AI_CHAT_EXPERIENCE_TYPE]; + const currentChatExperience = + unsavedChanges[AI_CHAT_EXPERIENCE_TYPE]?.unsavedValue ?? + chatExperienceField?.savedValue ?? + AIChatExperience.Classic; + const isAgentExperience = currentChatExperience === AIChatExperience.Agent; const hasConnectorsAllPrivilege = application.capabilities.actions?.show === true && @@ -100,9 +111,9 @@ export const GenAiSettingsApp: React.FC = ({ setBreadcrum

= ({ setBreadcrum paddingTop: euiTheme.size.l, }} > - - - - - - - -

- -

- - - - } - description={connectorDescription} - > - - - - - - - - - - {showSpacesIntegration && canManageSpaces && } - - {showSpacesIntegration && canManageSpaces && ( + + + +

+ +

+
+
+ - - - } - description={ -

- {isPermissionsBased ? ( - - - - ), - spaces: ( - - - - ), - rolesLink: ( - - - - ), - }} - /> - ) : ( - - - - ), - }} - /> - )} -

+ + + + + + +

+ +

+
+
+
} + description={connectorDescription} > - + + + + +
- )} - {showChatExperienceSetting && ( - - - - )} - {showAiAssistantsVisibilitySetting && ( - - - - )} - + + {showSpacesIntegration && canManageSpaces && } + + {showSpacesIntegration && canManageSpaces && ( + + + + } + description={ +

+ {isPermissionsBased ? ( + + + + ), + spaces: ( + + + + ), + rolesLink: ( + + + + ), + }} + /> + ) : ( + + + + ), + }} + /> + )} +

+ } + > + + + +
+ )} + {showChatExperienceSetting && ( + + + + )} + {showAiAssistantsVisibilitySetting && ( + + + + )} +
+
+ + {isAgentExperience && ( + <> + + + + + )} {!isEmpty(unsavedChanges) && ( diff --git a/x-pack/platform/plugins/private/gen_ai_settings/public/plugin.ts b/x-pack/platform/plugins/private/gen_ai_settings/public/plugin.ts index 5f686208e4400..9df47b796f6de 100644 --- a/x-pack/platform/plugins/private/gen_ai_settings/public/plugin.ts +++ b/x-pack/platform/plugins/private/gen_ai_settings/public/plugin.ts @@ -11,12 +11,14 @@ import { type CoreSetup, type CoreStart, type PluginInitializerContext } from '@ import type { ManagementSetup } from '@kbn/management-plugin/public'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; +import type { ProductDocBasePluginStart } from '@kbn/product-doc-base-plugin/public'; import { firstValueFrom } from 'rxjs'; import type { GenAiSettingsConfigType } from '../common/config'; export interface GenAiSettingsStartDeps { spaces?: SpacesPluginStart; licensing: LicensingPluginStart; + productDocBase: ProductDocBasePluginStart; } export interface GenAiSettingsSetupDeps { diff --git a/x-pack/platform/plugins/private/gen_ai_settings/tsconfig.json b/x-pack/platform/plugins/private/gen_ai_settings/tsconfig.json index d0f62f6638484..35cc0894f3660 100644 --- a/x-pack/platform/plugins/private/gen_ai_settings/tsconfig.json +++ b/x-pack/platform/plugins/private/gen_ai_settings/tsconfig.json @@ -30,8 +30,9 @@ "@kbn/licensing-plugin", "@kbn/management-settings-components-field-row", "@kbn/react-query", + "@kbn/product-doc-base-plugin", "@kbn/ai-assistant-common", - "@kbn/ai-agent-confirmation-modal", + "@kbn/ai-agent-confirmation-modal" ], "exclude": ["target/**/*"] } diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/tools/product_documentation.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/tools/product_documentation.ts index 56172b33ddd3f..b5d2773c0706d 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/tools/product_documentation.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/tools/product_documentation.ts @@ -31,6 +31,9 @@ const productDocumentationSchema = z.object({ // TODO make this configurable, we need a platform level setting for the embedding model const inferenceId = defaultInferenceEndpoints.ELSER; +// Path to GenAI Settings within the management app +const GENAI_SETTINGS_APP_PATH = '/app/management/ai/genAiSettings'; + export const productDocumentationTool = ( coreSetup: CoreSetup ): BuiltinToolDefinition => { @@ -40,6 +43,21 @@ export const productDocumentationTool = ( return plugins.llmTasks; }; + // Check if product documentation is installed + const isProductDocAvailable = async ( + llmTasks: NonNullable>> + ) => { + try { + return ( + (await llmTasks.retrieveDocumentationAvailable({ + inferenceId, + })) ?? false + ); + } catch { + return false; + } + }; + const baseTool: BuiltinToolDefinition = { id: platformCoreTools.productDocumentation, type: ToolType.builtin, @@ -58,6 +76,25 @@ export const productDocumentationTool = ( }; } + // Check if product documentation is installed + const isAvailable = await isProductDocAvailable(llmTasks); + if (!isAvailable) { + // Build the full settings URL using the request's base path (includes space prefix) + const basePath = coreSetup.http.basePath.get(request); + const settingsUrl = `${basePath}${GENAI_SETTINGS_APP_PATH}`; + + return { + results: [ + createErrorResult({ + message: `Product documentation is not installed. To use this tool, please install Elastic documentation from the GenAI Settings page: ${settingsUrl}. Do not perform any other tool calls, and provide the user with a link to install the documentation.`, + metadata: { + settingsUrl, + }, + }), + ], + }; + } + try { // Get the default model to extract the connector const model = await modelProvider.getDefaultModel(); @@ -118,28 +155,11 @@ export const productDocumentationTool = ( } }, tags: [], + // Tool is always available - handler will check if docs are installed and provide guidance availability: { cacheMode: 'space', handler: async () => { - try { - const [, plugins] = await coreSetup.getStartServices(); - const llmTasks = plugins.llmTasks; - - if (!llmTasks) { - return { status: 'unavailable' }; - } - - const isAvailable = - (await llmTasks.retrieveDocumentationAvailable({ - inferenceId, - })) ?? false; - - return { - status: isAvailable ? 'available' : 'unavailable', - }; - } catch (error) { - return { status: 'unavailable' }; - } + return { status: 'available' }; }, }, }; diff --git a/x-pack/platform/plugins/shared/ai_infra/product_doc_base/moon.yml b/x-pack/platform/plugins/shared/ai_infra/product_doc_base/moon.yml index 27f7ab884380a..f5f9d3ec6d08f 100644 --- a/x-pack/platform/plugins/shared/ai_infra/product_doc_base/moon.yml +++ b/x-pack/platform/plugins/shared/ai_infra/product_doc_base/moon.yml @@ -33,6 +33,7 @@ dependsOn: - '@kbn/ml-is-populated-object' - '@kbn/i18n' - '@kbn/licensing-types' + - '@kbn/react-query' tags: - plugin - prod diff --git a/x-pack/platform/plugins/shared/ai_infra/product_doc_base/public/hooks/constants.ts b/x-pack/platform/plugins/shared/ai_infra/product_doc_base/public/hooks/constants.ts new file mode 100644 index 0000000000000..eac06929785fc --- /dev/null +++ b/x-pack/platform/plugins/shared/ai_infra/product_doc_base/public/hooks/constants.ts @@ -0,0 +1,15 @@ +/* + * 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. + */ + +/** + * React Query keys for product documentation queries and mutations + */ +export const REACT_QUERY_KEYS = { + GET_PRODUCT_DOC_STATUS: 'productDocBase.status', + INSTALL_PRODUCT_DOC: 'productDocBase.install', + UNINSTALL_PRODUCT_DOC: 'productDocBase.uninstall', +} as const; diff --git a/x-pack/platform/plugins/shared/ai_infra/product_doc_base/public/hooks/index.ts b/x-pack/platform/plugins/shared/ai_infra/product_doc_base/public/hooks/index.ts new file mode 100644 index 0000000000000..cb379501fffc3 --- /dev/null +++ b/x-pack/platform/plugins/shared/ai_infra/product_doc_base/public/hooks/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { useProductDocStatus, type UseProductDocStatusOptions } from './use_product_doc_status'; +export { useInstallProductDoc, type UseInstallProductDocOptions } from './use_install_product_doc'; +export { + useUninstallProductDoc, + type UseUninstallProductDocOptions, +} from './use_uninstall_product_doc'; +export { REACT_QUERY_KEYS } from './constants'; diff --git a/x-pack/platform/plugins/shared/ai_infra/product_doc_base/public/hooks/use_install_product_doc.ts b/x-pack/platform/plugins/shared/ai_infra/product_doc_base/public/hooks/use_install_product_doc.ts new file mode 100644 index 0000000000000..961c0ccd65fc3 --- /dev/null +++ b/x-pack/platform/plugins/shared/ai_infra/product_doc_base/public/hooks/use_install_product_doc.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMutation, useQueryClient } from '@kbn/react-query'; +import { defaultInferenceEndpoints } from '@kbn/inference-common'; +import type { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public'; +import type { PerformInstallResponse } from '../../common/http_api/installation'; +import type { ProductDocBasePluginStart } from '../types'; +import { REACT_QUERY_KEYS } from './constants'; + +type ServerError = IHttpFetchError; + +export interface UseInstallProductDocOptions { + /** Callback fired on successful installation */ + onSuccess?: () => void; + /** Callback fired on installation error */ + onError?: (error: ServerError) => void; +} + +/** + * Hook to install product documentation. + * Automatically invalidates the status query on success. + */ +export function useInstallProductDoc( + productDocBase: ProductDocBasePluginStart, + options: UseInstallProductDocOptions = {} +) { + const { onSuccess, onError } = options; + const queryClient = useQueryClient(); + + return useMutation( + [REACT_QUERY_KEYS.INSTALL_PRODUCT_DOC], + async (inferenceId?: string) => { + return productDocBase.installation.install({ + inferenceId: inferenceId || defaultInferenceEndpoints.ELSER, + }); + }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [REACT_QUERY_KEYS.GET_PRODUCT_DOC_STATUS], + refetchType: 'all', + }); + onSuccess?.(); + }, + onError, + } + ); +} diff --git a/x-pack/platform/plugins/shared/ai_infra/product_doc_base/public/hooks/use_product_doc_status.ts b/x-pack/platform/plugins/shared/ai_infra/product_doc_base/public/hooks/use_product_doc_status.ts new file mode 100644 index 0000000000000..38f74ebe44bc2 --- /dev/null +++ b/x-pack/platform/plugins/shared/ai_infra/product_doc_base/public/hooks/use_product_doc_status.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery } from '@kbn/react-query'; +import { defaultInferenceEndpoints } from '@kbn/inference-common'; +import type { InstallationStatusResponse } from '../../common/http_api/installation'; +import type { ProductDocBasePluginStart } from '../types'; +import { REACT_QUERY_KEYS } from './constants'; + +const POLLING_INTERVAL_MS = 5000; // Poll every 5 seconds during installation/uninstallation + +export interface UseProductDocStatusOptions { + /** The inference ID to check status for. Defaults to ELSER. */ + inferenceId?: string; +} + +/** + * Hook to fetch the installation status of product documentation. + * Automatically polls when installation or uninstallation is in progress. + */ +export function useProductDocStatus( + productDocBase: ProductDocBasePluginStart, + options: UseProductDocStatusOptions = {} +) { + const { inferenceId = defaultInferenceEndpoints.ELSER } = options; + + const { isLoading, isError, isSuccess, isRefetching, data, refetch } = useQuery({ + queryKey: [REACT_QUERY_KEYS.GET_PRODUCT_DOC_STATUS, inferenceId], + queryFn: async (): Promise => { + return productDocBase.installation.getStatus({ inferenceId }); + }, + keepPreviousData: false, + refetchOnWindowFocus: false, + // Poll when installation or uninstallation is in progress + refetchInterval: (queryData) => { + const status = queryData?.overall; + if (status === 'installing' || status === 'uninstalling') { + return POLLING_INTERVAL_MS; + } + return false; + }, + }); + + return { + status: data, + refetch, + isLoading, + isRefetching, + isSuccess, + isError, + }; +} diff --git a/x-pack/platform/plugins/shared/ai_infra/product_doc_base/public/hooks/use_uninstall_product_doc.ts b/x-pack/platform/plugins/shared/ai_infra/product_doc_base/public/hooks/use_uninstall_product_doc.ts new file mode 100644 index 0000000000000..240f4eb4a0988 --- /dev/null +++ b/x-pack/platform/plugins/shared/ai_infra/product_doc_base/public/hooks/use_uninstall_product_doc.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMutation, useQueryClient } from '@kbn/react-query'; +import { defaultInferenceEndpoints } from '@kbn/inference-common'; +import type { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public'; +import type { UninstallResponse } from '../../common/http_api/installation'; +import type { ProductDocBasePluginStart } from '../types'; +import { REACT_QUERY_KEYS } from './constants'; + +type ServerError = IHttpFetchError; + +export interface UseUninstallProductDocOptions { + /** Callback fired on successful uninstallation */ + onSuccess?: () => void; + /** Callback fired on uninstallation error */ + onError?: (error: ServerError) => void; +} + +/** + * Hook to uninstall product documentation. + * Automatically invalidates the status query on success. + */ +export function useUninstallProductDoc( + productDocBase: ProductDocBasePluginStart, + options: UseUninstallProductDocOptions = {} +) { + const { onSuccess, onError } = options; + const queryClient = useQueryClient(); + + return useMutation( + [REACT_QUERY_KEYS.UNINSTALL_PRODUCT_DOC], + async (inferenceId?: string) => { + return productDocBase.installation.uninstall({ + inferenceId: inferenceId || defaultInferenceEndpoints.ELSER, + }); + }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [REACT_QUERY_KEYS.GET_PRODUCT_DOC_STATUS], + refetchType: 'all', + }); + onSuccess?.(); + }, + onError, + } + ); +} diff --git a/x-pack/platform/plugins/shared/ai_infra/product_doc_base/public/index.ts b/x-pack/platform/plugins/shared/ai_infra/product_doc_base/public/index.ts index b5ccbf029a73e..54d42917ed175 100644 --- a/x-pack/platform/plugins/shared/ai_infra/product_doc_base/public/index.ts +++ b/x-pack/platform/plugins/shared/ai_infra/product_doc_base/public/index.ts @@ -17,6 +17,16 @@ import type { export type { ProductDocBasePluginSetup, ProductDocBasePluginStart }; +export { + useProductDocStatus, + useInstallProductDoc, + useUninstallProductDoc, + REACT_QUERY_KEYS, + type UseProductDocStatusOptions, + type UseInstallProductDocOptions, + type UseUninstallProductDocOptions, +} from './hooks'; + export const plugin: PluginInitializer< ProductDocBasePluginSetup, ProductDocBasePluginStart, diff --git a/x-pack/platform/plugins/shared/ai_infra/product_doc_base/tsconfig.json b/x-pack/platform/plugins/shared/ai_infra/product_doc_base/tsconfig.json index d94053834f833..c5784c115dfda 100644 --- a/x-pack/platform/plugins/shared/ai_infra/product_doc_base/tsconfig.json +++ b/x-pack/platform/plugins/shared/ai_infra/product_doc_base/tsconfig.json @@ -29,6 +29,7 @@ "@kbn/core-security-server", "@kbn/ml-is-populated-object", "@kbn/i18n", - "@kbn/licensing-types" + "@kbn/licensing-types", + "@kbn/react-query" ] } diff --git a/x-pack/platform/plugins/shared/onechat/server/features.ts b/x-pack/platform/plugins/shared/onechat/server/features.ts index ec09a35f06d48..e6e5df4a3a601 100644 --- a/x-pack/platform/plugins/shared/onechat/server/features.ts +++ b/x-pack/platform/plugins/shared/onechat/server/features.ts @@ -6,6 +6,7 @@ */ import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server'; +import { ApiPrivileges } from '@kbn/core-security-server'; import type { FeaturesPluginSetup } from '@kbn/features-plugin/server'; import { ONECHAT_APP_ID, @@ -27,7 +28,11 @@ export const registerFeatures = ({ features }: { features: FeaturesPluginSetup } privileges: { all: { app: ['kibana', ONECHAT_APP_ID], - api: [apiPrivileges.readOnechat, apiPrivileges.manageOnechat], + api: [ + apiPrivileges.readOnechat, + apiPrivileges.manageOnechat, + ApiPrivileges.manage('llm_product_doc'), + ], catalogue: [ONECHAT_FEATURE_ID], savedObject: { all: [],