[AI Assistant] Add assistant to Serverless Search (#196832)

## Summary

This adds the AI assistant to Serverless Elasticsearch. It also disables
the knowledge base, and disables a few config values we don't want users
to be able to set in that context.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elena Shostak <165678770+elena-shostak@users.noreply.github.com>
This commit is contained in:
Sander Philipse 2024-10-25 12:03:04 +02:00 committed by GitHub
parent 27a98aa9c3
commit 3bc5e2db73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 642 additions and 352 deletions

View file

@ -10,7 +10,6 @@ xpack.observability.enabled: false
xpack.securitySolution.enabled: false xpack.securitySolution.enabled: false
xpack.serverless.observability.enabled: false xpack.serverless.observability.enabled: false
enterpriseSearch.enabled: false enterpriseSearch.enabled: false
xpack.observabilityAIAssistant.enabled: false
xpack.osquery.enabled: false xpack.osquery.enabled: false
# Enable fleet on search projects for agentless features # Enable fleet on search projects for agentless features
@ -120,4 +119,16 @@ xpack.searchInferenceEndpoints.ui.enabled: false
xpack.search.notebooks.catalog.url: https://elastic-enterprise-search.s3.us-east-2.amazonaws.com/serverless/catalog.json xpack.search.notebooks.catalog.url: https://elastic-enterprise-search.s3.us-east-2.amazonaws.com/serverless/catalog.json
# Semantic text UI # Semantic text UI
xpack.index_management.dev.enableSemanticText: true xpack.index_management.dev.enableSemanticText: true
# AI Assistant config
xpack.observabilityAIAssistant.enabled: true
xpack.searchAssistant.enabled: true
xpack.searchAssistant.ui.enabled: true
xpack.observabilityAIAssistant.scope: "search"
xpack.observabilityAIAssistant.enableKnowledgeBase: false
aiAssistantManagementSelection.preferredAIAssistantType: "observability"
xpack.observabilityAiAssistantManagement.logSourcesEnabled: false
xpack.observabilityAiAssistantManagement.spacesEnabled: false
xpack.observabilityAiAssistantManagement.visibilityEnabled: false

View file

@ -183,6 +183,7 @@ xpack.apm.featureFlags.storageExplorerAvailable: false
## Set the AI Assistant type ## Set the AI Assistant type
aiAssistantManagementSelection.preferredAIAssistantType: "observability" aiAssistantManagementSelection.preferredAIAssistantType: "observability"
xpack.observabilityAIAssistant.scope: "observability"
# Specify in telemetry the project type # Specify in telemetry the project type
telemetry.labels.serverless: observability telemetry.labels.serverless: observability

View file

@ -362,6 +362,9 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.observability_onboarding.ui.enabled (boolean?)', 'xpack.observability_onboarding.ui.enabled (boolean?)',
'xpack.observabilityLogsExplorer.navigation.showAppLink (boolean?|never)', 'xpack.observabilityLogsExplorer.navigation.showAppLink (boolean?|never)',
'xpack.observabilityAIAssistant.scope (observability?|search?)', 'xpack.observabilityAIAssistant.scope (observability?|search?)',
'xpack.observabilityAiAssistantManagement.logSourcesEnabled (boolean?)',
'xpack.observabilityAiAssistantManagement.spacesEnabled (boolean?)',
'xpack.observabilityAiAssistantManagement.visibilityEnabled (boolean?)',
'share.new_version.enabled (boolean?)', 'share.new_version.enabled (boolean?)',
'aiAssistantManagementSelection.preferredAIAssistantType (default?|never?|observability?)', 'aiAssistantManagementSelection.preferredAIAssistantType (default?|never?|observability?)',
/** /**

View file

@ -18,6 +18,7 @@ import {
import { ConnectorSelectorBase } from '@kbn/observability-ai-assistant-plugin/public'; import { ConnectorSelectorBase } from '@kbn/observability-ai-assistant-plugin/public';
import type { UseGenAIConnectorsResult } from '../hooks/use_genai_connectors'; import type { UseGenAIConnectorsResult } from '../hooks/use_genai_connectors';
import { useKibana } from '../hooks/use_kibana'; import { useKibana } from '../hooks/use_kibana';
import { useKnowledgeBase } from '../hooks';
export function ChatActionsMenu({ export function ChatActionsMenu({
connectors, connectors,
@ -31,6 +32,7 @@ export function ChatActionsMenu({
onCopyConversationClick: () => void; onCopyConversationClick: () => void;
}) { }) {
const { application, http } = useKibana().services; const { application, http } = useKibana().services;
const knowledgeBase = useKnowledgeBase();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const handleNavigateToConnectors = () => { const handleNavigateToConnectors = () => {
@ -91,6 +93,8 @@ export function ChatActionsMenu({
defaultMessage: 'Actions', defaultMessage: 'Actions',
}), }),
items: [ items: [
...(knowledgeBase?.status.value?.enabled
? [
{ {
name: i18n.translate('xpack.aiAssistant.chatHeader.actions.knowledgeBase', { name: i18n.translate('xpack.aiAssistant.chatHeader.actions.knowledgeBase', {
defaultMessage: 'Manage knowledge base', defaultMessage: 'Manage knowledge base',
@ -100,6 +104,8 @@ export function ChatActionsMenu({
handleNavigateToSettingsKnowledgeBase(); handleNavigateToSettingsKnowledgeBase();
}, },
}, },
]
: []),
{ {
name: i18n.translate('xpack.aiAssistant.chatHeader.actions.settings', { name: i18n.translate('xpack.aiAssistant.chatHeader.actions.settings', {
defaultMessage: 'AI Assistant Settings', defaultMessage: 'AI Assistant Settings',

View file

@ -37,6 +37,7 @@ const defaultProps: ComponentStoryObj<typeof Component> = {
loading: false, loading: false,
value: { value: {
ready: true, ready: true,
enabled: true,
}, },
refresh: () => {}, refresh: () => {},
}, },

View file

@ -123,7 +123,7 @@ export function ChatBody({
showLinkToConversationsApp: boolean; showLinkToConversationsApp: boolean;
onConversationUpdate: (conversation: { conversation: Conversation['conversation'] }) => void; onConversationUpdate: (conversation: { conversation: Conversation['conversation'] }) => void;
onToggleFlyoutPositionMode?: (flyoutPositionMode: FlyoutPositionMode) => void; onToggleFlyoutPositionMode?: (flyoutPositionMode: FlyoutPositionMode) => void;
navigateToConversation: (conversationId?: string) => void; navigateToConversation?: (conversationId?: string) => void;
}) { }) {
const license = useLicense(); const license = useLicense();
const hasCorrectLicense = license?.hasAtLeast('enterprise'); const hasCorrectLicense = license?.hasAtLeast('enterprise');

View file

@ -53,7 +53,7 @@ export function ChatFlyout({
initialFlyoutPositionMode?: FlyoutPositionMode; initialFlyoutPositionMode?: FlyoutPositionMode;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
navigateToConversation(conversationId?: string): void; navigateToConversation?: (conversationId?: string) => void;
}) { }) {
const { euiTheme } = useEuiTheme(); const { euiTheme } = useEuiTheme();
const breakpoint = useCurrentEuiBreakpoint(); const breakpoint = useCurrentEuiBreakpoint();
@ -272,10 +272,14 @@ export function ChatFlyout({
conversationList.conversations.refresh(); conversationList.conversations.refresh();
}} }}
onToggleFlyoutPositionMode={handleToggleFlyoutPositionMode} onToggleFlyoutPositionMode={handleToggleFlyoutPositionMode}
navigateToConversation={(newConversationId?: string) => { navigateToConversation={
navigateToConversation
? (newConversationId?: string) => {
if (onClose) onClose(); if (onClose) onClose();
navigateToConversation(newConversationId); navigateToConversation(newConversationId);
}} }
: undefined
}
/> />
</EuiFlexItem> </EuiFlexItem>

View file

@ -60,7 +60,7 @@ export function ChatHeader({
onCopyConversation: () => void; onCopyConversation: () => void;
onSaveTitle: (title: string) => void; onSaveTitle: (title: string) => void;
onToggleFlyoutPositionMode?: (newFlyoutPositionMode: FlyoutPositionMode) => void; onToggleFlyoutPositionMode?: (newFlyoutPositionMode: FlyoutPositionMode) => void;
navigateToConversation: (nextConversationId?: string) => void; navigateToConversation?: (nextConversationId?: string) => void;
}) { }) {
const theme = useEuiTheme(); const theme = useEuiTheme();
const breakpoint = useCurrentEuiBreakpoint(); const breakpoint = useCurrentEuiBreakpoint();
@ -164,7 +164,7 @@ export function ChatHeader({
} }
/> />
</EuiFlexItem> </EuiFlexItem>
{navigateToConversation ? (
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<EuiPopover <EuiPopover
anchorPosition="downLeft" anchorPosition="downLeft"
@ -189,6 +189,7 @@ export function ChatHeader({
} }
/> />
</EuiFlexItem> </EuiFlexItem>
) : null}
</> </>
) : null} ) : null}

View file

@ -58,6 +58,7 @@ const defaultProps: ComponentProps<typeof Component> = {
loading: false, loading: false,
value: { value: {
ready: true, ready: true,
enabled: true,
}, },
refresh: () => {}, refresh: () => {},
}, },

View file

@ -24,6 +24,7 @@ const defaultProps: ComponentStoryObj<typeof Component> = {
loading: false, loading: false,
value: { value: {
ready: false, ready: false,
enabled: true,
}, },
refresh: () => {}, refresh: () => {},
}, },
@ -43,12 +44,15 @@ export const Loading: ComponentStoryObj<typeof Component> = merge({}, defaultPro
}); });
export const NotInstalled: ComponentStoryObj<typeof Component> = merge({}, defaultProps, { export const NotInstalled: ComponentStoryObj<typeof Component> = merge({}, defaultProps, {
args: { knowledgeBase: { status: { loading: false, value: { ready: false } } } }, args: { knowledgeBase: { status: { loading: false, value: { ready: false, enabled: true } } } },
}); });
export const Installing: ComponentStoryObj<typeof Component> = merge({}, defaultProps, { export const Installing: ComponentStoryObj<typeof Component> = merge({}, defaultProps, {
args: { args: {
knowledgeBase: { status: { loading: false, value: { ready: false } }, isInstalling: true }, knowledgeBase: {
status: { loading: false, value: { ready: false, enabled: true } },
isInstalling: true,
},
}, },
}); });
@ -57,7 +61,7 @@ export const InstallError: ComponentStoryObj<typeof Component> = merge({}, defau
knowledgeBase: { knowledgeBase: {
status: { status: {
loading: false, loading: false,
value: { ready: false }, value: { ready: false, enabled: true },
}, },
isInstalling: false, isInstalling: false,
installError: new Error(), installError: new Error(),
@ -66,5 +70,5 @@ export const InstallError: ComponentStoryObj<typeof Component> = merge({}, defau
}); });
export const Installed: ComponentStoryObj<typeof Component> = merge({}, defaultProps, { export const Installed: ComponentStoryObj<typeof Component> = merge({}, defaultProps, {
args: { knowledgeBase: { status: { loading: false, value: { ready: true } } } }, args: { knowledgeBase: { status: { loading: false, value: { ready: true, enabled: true } } } },
}); });

View file

@ -85,8 +85,9 @@ export function WelcomeMessage({
connectors={connectors} connectors={connectors}
onSetupConnectorClick={handleConnectorClick} onSetupConnectorClick={handleConnectorClick}
/> />
{knowledgeBase.status.value?.enabled ? (
<WelcomeMessageKnowledgeBase connectors={connectors} knowledgeBase={knowledgeBase} /> <WelcomeMessageKnowledgeBase connectors={connectors} knowledgeBase={knowledgeBase} />
) : null}
</EuiFlexItem> </EuiFlexItem>
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>

View file

@ -25,7 +25,7 @@ const SECOND_SLOT_CONTAINER_WIDTH = 400;
interface ConversationViewProps { interface ConversationViewProps {
conversationId?: string; conversationId?: string;
navigateToConversation: (nextConversationId?: string) => void; navigateToConversation?: (nextConversationId?: string) => void;
getConversationHref?: (conversationId: string) => string; getConversationHref?: (conversationId: string) => string;
newConversationHref?: string; newConversationHref?: string;
scopes?: AssistantScope[]; scopes?: AssistantScope[];
@ -81,8 +81,10 @@ export const ConversationView: React.FC<ConversationViewProps> = ({
const handleConversationUpdate = (conversation: { conversation: { id: string } }) => { const handleConversationUpdate = (conversation: { conversation: { id: string } }) => {
if (!conversationId) { if (!conversationId) {
updateConversationIdInPlace(conversation.conversation.id); updateConversationIdInPlace(conversation.conversation.id);
if (navigateToConversation) {
navigateToConversation(conversation.conversation.id); navigateToConversation(conversation.conversation.id);
} }
}
handleRefreshConversations(); handleRefreshConversations();
}; };
@ -143,7 +145,7 @@ export const ConversationView: React.FC<ConversationViewProps> = ({
isLoading={conversationList.isLoading} isLoading={conversationList.isLoading}
onConversationDeleteClick={(deletedConversationId) => { onConversationDeleteClick={(deletedConversationId) => {
conversationList.deleteConversation(deletedConversationId).then(() => { conversationList.deleteConversation(deletedConversationId).then(() => {
if (deletedConversationId === conversationId) { if (deletedConversationId === conversationId && navigateToConversation) {
navigateToConversation(undefined); navigateToConversation(undefined);
} }
}); });

View file

@ -17,6 +17,7 @@ export function useKnowledgeBase(): UseKnowledgeBaseResult {
error: undefined, error: undefined,
value: { value: {
ready: true, ready: true,
enabled: true,
}, },
}, },
}; };

View file

@ -20,6 +20,7 @@ import { useAIAssistantAppService } from './use_ai_assistant_app_service';
export interface UseKnowledgeBaseResult { export interface UseKnowledgeBaseResult {
status: AbortableAsyncState<{ status: AbortableAsyncState<{
ready: boolean; ready: boolean;
enabled: boolean;
error?: any; error?: any;
deployment_state?: MlDeploymentState; deployment_state?: MlDeploymentState;
allocation_state?: MlDeploymentAllocationState; allocation_state?: MlDeploymentAllocationState;

View file

@ -11,6 +11,7 @@ export const config = schema.object({
enabled: schema.boolean({ defaultValue: true }), enabled: schema.boolean({ defaultValue: true }),
modelId: schema.maybe(schema.string()), modelId: schema.maybe(schema.string()),
scope: schema.maybe(schema.oneOf([schema.literal('observability'), schema.literal('search')])), scope: schema.maybe(schema.oneOf([schema.literal('observability'), schema.literal('search')])),
enableKnowledgeBase: schema.boolean({ defaultValue: true }),
}); });
export type ObservabilityAIAssistantConfig = TypeOf<typeof config>; export type ObservabilityAIAssistantConfig = TypeOf<typeof config>;

View file

@ -159,11 +159,14 @@ export class ObservabilityAIAssistantPlugin
core, core,
taskManager: plugins.taskManager, taskManager: plugins.taskManager,
getModelId, getModelId,
enableKnowledgeBase: this.config.enableKnowledgeBase,
})); }));
service.register(registerFunctions); service.register(registerFunctions);
if (this.config.enableKnowledgeBase) {
addLensDocsToKb({ service, logger: this.logger.get('kb').get('lens') }); addLensDocsToKb({ service, logger: this.logger.get('kb').get('lens') });
}
registerServerRoutes({ registerServerRoutes({
core, core,

View file

@ -28,6 +28,7 @@ const getKnowledgeBaseStatus = createObservabilityAIAssistantServerRoute({
handler: async ( handler: async (
resources resources
): Promise<{ ): Promise<{
enabled: boolean;
ready: boolean; ready: boolean;
error?: any; error?: any;
deployment_state?: MlDeploymentState; deployment_state?: MlDeploymentState;

View file

@ -707,14 +707,16 @@ export class ObservabilityAIAssistantClient {
queries: Array<{ text: string; boost?: number }>; queries: Array<{ text: string; boost?: number }>;
categories?: string[]; categories?: string[];
}): Promise<{ entries: RecalledEntry[] }> => { }): Promise<{ entries: RecalledEntry[] }> => {
return this.dependencies.knowledgeBaseService.recall({ return (
this.dependencies.knowledgeBaseService?.recall({
namespace: this.dependencies.namespace, namespace: this.dependencies.namespace,
user: this.dependencies.user, user: this.dependencies.user,
queries, queries,
categories, categories,
esClient: this.dependencies.esClient, esClient: this.dependencies.esClient,
uiSettingsClient: this.dependencies.uiSettingsClient, uiSettingsClient: this.dependencies.uiSettingsClient,
}); }) || { entries: [] }
);
}; };
getKnowledgeBaseStatus = () => { getKnowledgeBaseStatus = () => {

View file

@ -70,6 +70,7 @@ export class ObservabilityAIAssistantService {
private readonly logger: Logger; private readonly logger: Logger;
private readonly getModelId: () => Promise<string>; private readonly getModelId: () => Promise<string>;
private kbService?: KnowledgeBaseService; private kbService?: KnowledgeBaseService;
private enableKnowledgeBase: boolean;
private readonly registrations: RegistrationCallback[] = []; private readonly registrations: RegistrationCallback[] = [];
@ -78,18 +79,21 @@ export class ObservabilityAIAssistantService {
core, core,
taskManager, taskManager,
getModelId, getModelId,
enableKnowledgeBase,
}: { }: {
logger: Logger; logger: Logger;
core: CoreSetup<ObservabilityAIAssistantPluginStartDependencies>; core: CoreSetup<ObservabilityAIAssistantPluginStartDependencies>;
taskManager: TaskManagerSetupContract; taskManager: TaskManagerSetupContract;
getModelId: () => Promise<string>; getModelId: () => Promise<string>;
enableKnowledgeBase: boolean;
}) { }) {
this.core = core; this.core = core;
this.logger = logger; this.logger = logger;
this.getModelId = getModelId; this.getModelId = getModelId;
this.enableKnowledgeBase = enableKnowledgeBase;
this.allowInit(); this.allowInit();
if (enableKnowledgeBase) {
taskManager.registerTaskDefinitions({ taskManager.registerTaskDefinitions({
[INDEX_QUEUED_DOCUMENTS_TASK_TYPE]: { [INDEX_QUEUED_DOCUMENTS_TASK_TYPE]: {
title: 'Index queued KB articles', title: 'Index queued KB articles',
@ -109,6 +113,7 @@ export class ObservabilityAIAssistantService {
}, },
}); });
} }
}
getKnowledgeBaseStatus() { getKnowledgeBaseStatus() {
return this.init().then(() => { return this.init().then(() => {
@ -237,6 +242,7 @@ export class ObservabilityAIAssistantService {
esClient, esClient,
taskManagerStart: pluginsStart.taskManager, taskManagerStart: pluginsStart.taskManager,
getModelId: this.getModelId, getModelId: this.getModelId,
enabled: this.enableKnowledgeBase,
}); });
this.logger.info('Successfully set up index assets'); this.logger.info('Successfully set up index assets');
@ -331,6 +337,7 @@ export class ObservabilityAIAssistantService {
} }
addToKnowledgeBaseQueue(entries: KnowledgeBaseEntryRequest[]): void { addToKnowledgeBaseQueue(entries: KnowledgeBaseEntryRequest[]): void {
if (this.enableKnowledgeBase) {
this.init() this.init()
.then(() => { .then(() => {
this.kbService!.queue( this.kbService!.queue(
@ -370,8 +377,10 @@ export class ObservabilityAIAssistantService {
this.logger.error(error); this.logger.error(error);
}); });
} }
}
addCategoryToKnowledgeBase(categoryId: string, entries: KnowledgeBaseEntryRequest[]) { addCategoryToKnowledgeBase(categoryId: string, entries: KnowledgeBaseEntryRequest[]) {
if (this.enableKnowledgeBase) {
this.addToKnowledgeBaseQueue( this.addToKnowledgeBaseQueue(
entries.map((entry) => { entries.map((entry) => {
return { return {
@ -384,6 +393,7 @@ export class ObservabilityAIAssistantService {
}) })
); );
} }
}
register(cb: RegistrationCallback) { register(cb: RegistrationCallback) {
this.registrations.push(cb); this.registrations.push(cb);

View file

@ -34,6 +34,7 @@ interface Dependencies {
logger: Logger; logger: Logger;
taskManagerStart: TaskManagerStartContract; taskManagerStart: TaskManagerStartContract;
getModelId: () => Promise<string>; getModelId: () => Promise<string>;
enabled: boolean;
} }
export interface RecalledEntry { export interface RecalledEntry {
@ -92,6 +93,9 @@ export class KnowledgeBaseService {
} }
setup = async () => { setup = async () => {
if (!this.dependencies.enabled) {
return;
}
const elserModelId = await this.dependencies.getModelId(); const elserModelId = await this.dependencies.getModelId();
const retryOptions = { factor: 1, minTimeout: 10000, retries: 12 }; const retryOptions = { factor: 1, minTimeout: 10000, retries: 12 };
@ -113,9 +117,9 @@ export class KnowledgeBaseService {
} catch (error) { } catch (error) {
if (isModelMissingOrUnavailableError(error)) { if (isModelMissingOrUnavailableError(error)) {
return false; return false;
} else {
throw error;
} }
throw error;
} }
}; };
@ -202,6 +206,9 @@ export class KnowledgeBaseService {
}; };
private ensureTaskScheduled() { private ensureTaskScheduled() {
if (!this.dependencies.enabled) {
return;
}
this.dependencies.taskManagerStart this.dependencies.taskManagerStart
.ensureScheduled({ .ensureScheduled({
taskType: INDEX_QUEUED_DOCUMENTS_TASK_TYPE, taskType: INDEX_QUEUED_DOCUMENTS_TASK_TYPE,
@ -251,7 +258,7 @@ export class KnowledgeBaseService {
} }
async processQueue() { async processQueue() {
if (!this._queue.length) { if (!this._queue.length || !this.dependencies.enabled) {
return; return;
} }
@ -305,6 +312,9 @@ export class KnowledgeBaseService {
} }
status = async () => { status = async () => {
if (!this.dependencies.enabled) {
return { ready: false, enabled: false };
}
const elserModelId = await this.dependencies.getModelId(); const elserModelId = await this.dependencies.getModelId();
try { try {
@ -320,11 +330,13 @@ export class KnowledgeBaseService {
deployment_state: deploymentState, deployment_state: deploymentState,
allocation_state: allocationState, allocation_state: allocationState,
model_name: elserModelId, model_name: elserModelId,
enabled: true,
}; };
} catch (error) { } catch (error) {
return { return {
error: error instanceof errors.ResponseError ? error.body.error : String(error), error: error instanceof errors.ResponseError ? error.body.error : String(error),
ready: false, ready: false,
enabled: true,
model_name: elserModelId, model_name: elserModelId,
}; };
} }
@ -402,6 +414,9 @@ export class KnowledgeBaseService {
}): Promise<{ }): Promise<{
entries: RecalledEntry[]; entries: RecalledEntry[];
}> => { }> => {
if (!this.dependencies.enabled) {
return { entries: [] };
}
this.dependencies.logger.debug( this.dependencies.logger.debug(
() => `Recalling entries from KB for queries: "${JSON.stringify(queries)}"` () => `Recalling entries from KB for queries: "${JSON.stringify(queries)}"`
); );
@ -474,6 +489,9 @@ export class KnowledgeBaseService {
namespace: string, namespace: string,
user?: { name: string } user?: { name: string }
): Promise<Array<Instruction & { public?: boolean }>> => { ): Promise<Array<Instruction & { public?: boolean }>> => {
if (!this.dependencies.enabled) {
return [];
}
try { try {
const response = await this.dependencies.esClient.asInternalUser.search<KnowledgeBaseEntry>({ const response = await this.dependencies.esClient.asInternalUser.search<KnowledgeBaseEntry>({
index: resourceNames.aliases.kb, index: resourceNames.aliases.kb,
@ -514,6 +532,9 @@ export class KnowledgeBaseService {
sortBy?: string; sortBy?: string;
sortDirection?: 'asc' | 'desc'; sortDirection?: 'asc' | 'desc';
}): Promise<{ entries: KnowledgeBaseEntry[] }> => { }): Promise<{ entries: KnowledgeBaseEntry[] }> => {
if (!this.dependencies.enabled) {
return { entries: [] };
}
try { try {
const response = await this.dependencies.esClient.asInternalUser.search<KnowledgeBaseEntry>({ const response = await this.dependencies.esClient.asInternalUser.search<KnowledgeBaseEntry>({
index: resourceNames.aliases.kb, index: resourceNames.aliases.kb,
@ -578,6 +599,9 @@ export class KnowledgeBaseService {
user?: { name: string; id?: string }; user?: { name: string; id?: string };
namespace?: string; namespace?: string;
}) => { }) => {
if (!this.dependencies.enabled) {
return null;
}
const res = await this.dependencies.esClient.asInternalUser.search< const res = await this.dependencies.esClient.asInternalUser.search<
Pick<KnowledgeBaseEntry, 'doc_id'> Pick<KnowledgeBaseEntry, 'doc_id'>
>({ >({
@ -607,6 +631,9 @@ export class KnowledgeBaseService {
user?: { name: string; id?: string }; user?: { name: string; id?: string };
namespace?: string; namespace?: string;
}): Promise<void> => { }): Promise<void> => {
if (!this.dependencies.enabled) {
return;
}
// for now we want to limit the number of user instructions to 1 per user // for now we want to limit the number of user instructions to 1 per user
if (document.type === KnowledgeBaseType.UserInstruction) { if (document.type === KnowledgeBaseType.UserInstruction) {
const existingId = await this.getExistingUserInstructionId({ const existingId = await this.getExistingUserInstructionId({
@ -647,6 +674,9 @@ export class KnowledgeBaseService {
}: { }: {
operations: KnowledgeBaseEntryOperation[]; operations: KnowledgeBaseEntryOperation[];
}): Promise<void> => { }): Promise<void> => {
if (!this.dependencies.enabled) {
return;
}
this.dependencies.logger.info(`Starting import of ${operations.length} entries`); this.dependencies.logger.info(`Starting import of ${operations.length} entries`);
const limiter = pLimit(5); const limiter = pLimit(5);

View file

@ -164,7 +164,7 @@ export function NavControl() {
onClose={() => { onClose={() => {
setIsOpen(false); setIsOpen(false);
}} }}
navigateToConversation={(conversationId: string) => { navigateToConversation={(conversationId?: string) => {
application.navigateToUrl( application.navigateToUrl(
http.basePath.prepend( http.basePath.prepend(
`/app/observabilityAIAssistant/conversations/${conversationId || ''}` `/app/observabilityAIAssistant/conversations/${conversationId || ''}`

View file

@ -6,9 +6,24 @@
"id": "observabilityAiAssistantManagement", "id": "observabilityAiAssistantManagement",
"server": true, "server": true,
"browser": true, "browser": true,
"configPath": ["xpack", "observabilityAiAssistantManagement"], "configPath": [
"requiredPlugins": ["management", "observabilityAIAssistant", "observabilityShared"], "xpack",
"optionalPlugins": ["actions", "home", "serverless", "enterpriseSearch"], "observabilityAiAssistantManagement"
"requiredBundles": ["kibanaReact", "logsDataAccess"] ],
"requiredPlugins": [
"actions",
"management",
"observabilityAIAssistant",
"observabilityShared"
],
"optionalPlugins": [
"home",
"serverless",
"enterpriseSearch"
],
"requiredBundles": [
"kibanaReact",
"logsDataAccess",
]
} }
} }

View file

@ -15,7 +15,11 @@ import { i18n } from '@kbn/i18n';
import { CoreSetup } from '@kbn/core/public'; import { CoreSetup } from '@kbn/core/public';
import { wrapWithTheme } from '@kbn/kibana-react-plugin/public'; import { wrapWithTheme } from '@kbn/kibana-react-plugin/public';
import { ManagementAppMountParams } from '@kbn/management-plugin/public'; import { ManagementAppMountParams } from '@kbn/management-plugin/public';
import { StartDependencies, AiAssistantManagementObservabilityPluginStart } from './plugin'; import {
StartDependencies,
AiAssistantManagementObservabilityPluginStart,
ConfigSchema,
} from './plugin';
import { aIAssistantManagementObservabilityRouter } from './routes/config'; import { aIAssistantManagementObservabilityRouter } from './routes/config';
import { RedirectToHomeIfUnauthorized } from './routes/components/redirect_to_home_if_unauthorized'; import { RedirectToHomeIfUnauthorized } from './routes/components/redirect_to_home_if_unauthorized';
import { AppContextProvider } from './context/app_context'; import { AppContextProvider } from './context/app_context';
@ -23,9 +27,10 @@ import { AppContextProvider } from './context/app_context';
interface MountParams { interface MountParams {
core: CoreSetup<StartDependencies, AiAssistantManagementObservabilityPluginStart>; core: CoreSetup<StartDependencies, AiAssistantManagementObservabilityPluginStart>;
mountParams: ManagementAppMountParams; mountParams: ManagementAppMountParams;
config: ConfigSchema;
} }
export const mountManagementSection = async ({ core, mountParams }: MountParams) => { export const mountManagementSection = async ({ core, mountParams, config }: MountParams) => {
const [coreStart, startDeps] = await core.getStartServices(); const [coreStart, startDeps] = await core.getStartServices();
if (!startDeps.observabilityAIAssistant) return () => {}; if (!startDeps.observabilityAIAssistant) return () => {};
@ -46,7 +51,7 @@ export const mountManagementSection = async ({ core, mountParams }: MountParams)
<RedirectToHomeIfUnauthorized coreStart={coreStart}> <RedirectToHomeIfUnauthorized coreStart={coreStart}>
<I18nProvider> <I18nProvider>
<KibanaContextProvider services={{ ...coreStart, ...startDeps }}> <KibanaContextProvider services={{ ...coreStart, ...startDeps }}>
<AppContextProvider value={{ setBreadcrumbs }}> <AppContextProvider value={{ setBreadcrumbs, config }}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<RouterProvider <RouterProvider
history={history} history={history}

View file

@ -7,9 +7,11 @@
import React, { createContext } from 'react'; import React, { createContext } from 'react';
import type { ChromeBreadcrumb } from '@kbn/core-chrome-browser'; import type { ChromeBreadcrumb } from '@kbn/core-chrome-browser';
import { ConfigSchema } from '../plugin';
export interface AppContextValue { export interface AppContextValue {
setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void;
config: ConfigSchema;
} }
export const AppContext = createContext<AppContextValue>(null as any); export const AppContext = createContext<AppContextValue>(null as any);

View file

@ -70,6 +70,11 @@ export const render = (
const appContextValue = mocks?.appContextValue ?? { const appContextValue = mocks?.appContextValue ?? {
setBreadcrumbs: () => {}, setBreadcrumbs: () => {},
config: {
logSourcesEnabled: true,
spacesEnabled: true,
visibilityEnabled: true,
},
}; };
return testLibRender( return testLibRender(

View file

@ -5,13 +5,26 @@
* 2.0. * 2.0.
*/ */
import { AiAssistantManagementObservabilityPlugin as AiAssistantManagementObservabilityPlugin } from './plugin'; import { PluginInitializer, PluginInitializerContext } from '@kbn/core-plugins-browser';
import {
AiAssistantManagementObservabilityPlugin,
AiAssistantManagementObservabilityPluginSetup,
AiAssistantManagementObservabilityPluginStart,
ConfigSchema,
SetupDependencies,
StartDependencies,
} from './plugin';
export type { export type {
AiAssistantManagementObservabilityPluginSetup, AiAssistantManagementObservabilityPluginSetup,
AiAssistantManagementObservabilityPluginStart, AiAssistantManagementObservabilityPluginStart,
} from './plugin'; } from './plugin';
export function plugin() { export const plugin: PluginInitializer<
return new AiAssistantManagementObservabilityPlugin(); AiAssistantManagementObservabilityPluginSetup,
} AiAssistantManagementObservabilityPluginStart,
SetupDependencies,
StartDependencies
> = (pluginInitializerContext: PluginInitializerContext<ConfigSchema>) => {
return new AiAssistantManagementObservabilityPlugin(pluginInitializerContext);
};

View file

@ -6,7 +6,7 @@
*/ */
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { CoreSetup, Plugin } from '@kbn/core/public'; import { CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/public';
import { ManagementSetup } from '@kbn/management-plugin/public'; import { ManagementSetup } from '@kbn/management-plugin/public';
import { HomePublicPluginSetup } from '@kbn/home-plugin/public'; import { HomePublicPluginSetup } from '@kbn/home-plugin/public';
import { ServerlessPluginStart } from '@kbn/serverless/public'; import { ServerlessPluginStart } from '@kbn/serverless/public';
@ -35,6 +35,12 @@ export interface StartDependencies {
enterpriseSearch?: EnterpriseSearchPublicStart; enterpriseSearch?: EnterpriseSearchPublicStart;
} }
export interface ConfigSchema {
logSourcesEnabled: boolean;
spacesEnabled: boolean;
visibilityEnabled: boolean;
}
export class AiAssistantManagementObservabilityPlugin export class AiAssistantManagementObservabilityPlugin
implements implements
Plugin< Plugin<
@ -44,6 +50,12 @@ export class AiAssistantManagementObservabilityPlugin
StartDependencies StartDependencies
> >
{ {
private readonly config: ConfigSchema;
constructor(context: PluginInitializerContext<ConfigSchema>) {
this.config = context.config.get();
}
public setup( public setup(
core: CoreSetup<StartDependencies, AiAssistantManagementObservabilityPluginStart>, core: CoreSetup<StartDependencies, AiAssistantManagementObservabilityPluginStart>,
{ home, management, observabilityAIAssistant }: SetupDependencies { home, management, observabilityAIAssistant }: SetupDependencies
@ -78,6 +90,7 @@ export class AiAssistantManagementObservabilityPlugin
return mountManagementSection({ return mountManagementSection({
core, core,
mountParams, mountParams,
config: this.config,
}); });
}, },
}); });

View file

@ -8,8 +8,24 @@
import React from 'react'; import React from 'react';
import { coreStartMock, render } from '../../helpers/test_helper'; import { coreStartMock, render } from '../../helpers/test_helper';
import { SettingsPage } from './settings_page'; import { SettingsPage } from './settings_page';
import { useKnowledgeBase } from '@kbn/ai-assistant';
jest.mock('@kbn/ai-assistant');
const useKnowledgeBaseMock = useKnowledgeBase as jest.Mock;
describe('Settings Page', () => { describe('Settings Page', () => {
const appContextValue = {
config: { spacesEnabled: true, visibilityEnabled: true, logSourcesEnabled: true },
setBreadcrumbs: () => {},
};
useKnowledgeBaseMock.mockReturnValue({
status: {
value: {
enabled: true,
},
},
});
it('should navigate to home when not authorized', () => { it('should navigate to home when not authorized', () => {
render(<SettingsPage />, { render(<SettingsPage />, {
coreStart: { coreStart: {
@ -21,13 +37,16 @@ describe('Settings Page', () => {
}, },
}, },
}, },
appContextValue,
}); });
expect(coreStartMock.application.navigateToApp).toBeCalledWith('home'); expect(coreStartMock.application.navigateToApp).toBeCalledWith('home');
}); });
it('should render settings and knowledge base tabs', () => { it('should render settings and knowledge base tabs', () => {
const { getByTestId } = render(<SettingsPage />); const { getByTestId } = render(<SettingsPage />, {
appContextValue,
});
expect(getByTestId('settingsPageTab-settings')).toBeInTheDocument(); expect(getByTestId('settingsPageTab-settings')).toBeInTheDocument();
expect(getByTestId('settingsPageTab-knowledge_base')).toBeInTheDocument(); expect(getByTestId('settingsPageTab-knowledge_base')).toBeInTheDocument();
@ -36,7 +55,7 @@ describe('Settings Page', () => {
it('should set breadcrumbs', () => { it('should set breadcrumbs', () => {
const setBreadcrumbs = jest.fn(); const setBreadcrumbs = jest.fn();
render(<SettingsPage />, { render(<SettingsPage />, {
appContextValue: { setBreadcrumbs }, appContextValue: { ...appContextValue, setBreadcrumbs },
}); });
expect(setBreadcrumbs).toHaveBeenCalledWith([ expect(setBreadcrumbs).toHaveBeenCalledWith([

View file

@ -8,6 +8,7 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { EuiSpacer, EuiTab, EuiTabs, EuiTitle } from '@elastic/eui'; import { EuiSpacer, EuiTab, EuiTabs, EuiTitle } from '@elastic/eui';
import { useKnowledgeBase } from '@kbn/ai-assistant';
import { useAppContext } from '../../hooks/use_app_context'; import { useAppContext } from '../../hooks/use_app_context';
import { SettingsTab } from './settings_tab/settings_tab'; import { SettingsTab } from './settings_tab/settings_tab';
import { KnowledgeBaseTab } from './knowledge_base_tab'; import { KnowledgeBaseTab } from './knowledge_base_tab';
@ -28,6 +29,7 @@ export function SettingsPage() {
} = useKibana(); } = useKibana();
const router = useObservabilityAIAssistantManagementRouter(); const router = useObservabilityAIAssistantManagementRouter();
const knowledgeBase = useKnowledgeBase();
const { const {
query: { tab }, query: { tab },
@ -85,6 +87,7 @@ export function SettingsPage() {
} }
), ),
content: <KnowledgeBaseTab />, content: <KnowledgeBaseTab />,
disabled: !knowledgeBase.status.value?.enabled,
}, },
{ {
id: 'search_connector', id: 'search_connector',

View file

@ -9,10 +9,14 @@ import React from 'react';
import { fireEvent } from '@testing-library/react'; import { fireEvent } from '@testing-library/react';
import { render } from '../../../helpers/test_helper'; import { render } from '../../../helpers/test_helper';
import { SettingsTab } from './settings_tab'; import { SettingsTab } from './settings_tab';
import { useAppContext } from '../../../hooks/use_app_context';
jest.mock('../../../hooks/use_app_context'); jest.mock('../../../hooks/use_app_context');
const useAppContextMock = useAppContext as jest.Mock;
describe('SettingsTab', () => { describe('SettingsTab', () => {
useAppContextMock.mockReturnValue({ config: { spacesEnabled: true, visibilityEnabled: true } });
it('should offer a way to configure Observability AI Assistant visibility in apps', () => { it('should offer a way to configure Observability AI Assistant visibility in apps', () => {
const navigateToAppMock = jest.fn(() => Promise.resolve()); const navigateToAppMock = jest.fn(() => Promise.resolve());
const { getByTestId } = render(<SettingsTab />, { const { getByTestId } = render(<SettingsTab />, {

View file

@ -8,6 +8,7 @@
import React from 'react'; import React from 'react';
import { EuiButton, EuiDescribedFormGroup, EuiFormRow, EuiPanel } from '@elastic/eui'; import { EuiButton, EuiDescribedFormGroup, EuiFormRow, EuiPanel } from '@elastic/eui';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { useAppContext } from '../../../hooks/use_app_context';
import { useKibana } from '../../../hooks/use_kibana'; import { useKibana } from '../../../hooks/use_kibana';
import { UISettings } from './ui_settings'; import { UISettings } from './ui_settings';
@ -15,6 +16,7 @@ export function SettingsTab() {
const { const {
application: { navigateToApp }, application: { navigateToApp },
} = useKibana().services; } = useKibana().services;
const { config } = useAppContext();
const handleNavigateToConnectors = () => { const handleNavigateToConnectors = () => {
navigateToApp('management', { navigateToApp('management', {
@ -30,6 +32,7 @@ export function SettingsTab() {
return ( return (
<EuiPanel hasBorder grow={false}> <EuiPanel hasBorder grow={false}>
{config.spacesEnabled && (
<EuiDescribedFormGroup <EuiDescribedFormGroup
fullWidth fullWidth
title={ title={
@ -68,6 +71,7 @@ export function SettingsTab() {
</EuiButton> </EuiButton>
</EuiFormRow> </EuiFormRow>
</EuiDescribedFormGroup> </EuiDescribedFormGroup>
)}
<EuiDescribedFormGroup <EuiDescribedFormGroup
fullWidth fullWidth

View file

@ -18,14 +18,10 @@ import { EuiSpacer } from '@elastic/eui';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { LogSourcesSettingSynchronisationInfo } from '@kbn/logs-data-access-plugin/public'; import { LogSourcesSettingSynchronisationInfo } from '@kbn/logs-data-access-plugin/public';
import { useKnowledgeBase } from '@kbn/ai-assistant';
import { useAppContext } from '../../../hooks/use_app_context';
import { useKibana } from '../../../hooks/use_kibana'; import { useKibana } from '../../../hooks/use_kibana';
const settingsKeys = [
aiAssistantSimulatedFunctionCalling,
aiAssistantSearchConnectorIndexPattern,
aiAssistantPreferredAIAssistantType,
];
export function UISettings() { export function UISettings() {
const { const {
docLinks, docLinks,
@ -33,6 +29,14 @@ export function UISettings() {
notifications, notifications,
application: { capabilities, getUrlForApp }, application: { capabilities, getUrlForApp },
} = useKibana().services; } = useKibana().services;
const knowledgeBase = useKnowledgeBase();
const { config } = useAppContext();
const settingsKeys = [
aiAssistantSimulatedFunctionCalling,
...(knowledgeBase.status.value?.enabled ? [aiAssistantSearchConnectorIndexPattern] : []),
...(config.visibilityEnabled ? [aiAssistantPreferredAIAssistantType] : []),
];
const { fields, handleFieldChange, unsavedChanges, saveAll, isSaving, cleanUnsavedChanges } = const { fields, handleFieldChange, unsavedChanges, saveAll, isSaving, cleanUnsavedChanges } =
useEditableSettings(settingsKeys); useEditableSettings(settingsKeys);
@ -84,12 +88,13 @@ export function UISettings() {
</FieldRowProvider> </FieldRowProvider>
); );
})} })}
{config.logSourcesEnabled && (
<LogSourcesSettingSynchronisationInfo <LogSourcesSettingSynchronisationInfo
isLoading={false} isLoading={false}
logSourcesValue={settings.client.get(aiAssistantLogsIndexPattern)} logSourcesValue={settings.client.get(aiAssistantLogsIndexPattern)}
getUrlForApp={getUrlForApp} getUrlForApp={getUrlForApp}
/> />
)}
{!isEmpty(unsavedChanges) && ( {!isEmpty(unsavedChanges) && (
<BottomBarActions <BottomBarActions

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema, type TypeOf } from '@kbn/config-schema';
import { PluginConfigDescriptor } from '@kbn/core-plugins-server';
const configSchema = schema.object({
visibilityEnabled: schema.boolean({ defaultValue: true }),
spacesEnabled: schema.boolean({ defaultValue: true }),
logSourcesEnabled: schema.boolean({ defaultValue: true }),
});
export type ObservabilityAIAssistantManagementConfig = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<ObservabilityAIAssistantManagementConfig> = {
schema: configSchema,
exposeToBrowser: { logSourcesEnabled: true, spacesEnabled: true, visibilityEnabled: true },
};

View file

@ -5,6 +5,8 @@
* 2.0. * 2.0.
*/ */
export { config } from './config';
export const plugin = async () => { export const plugin = async () => {
const { AiAssistantManagementPlugin } = await import('./plugin'); const { AiAssistantManagementPlugin } = await import('./plugin');
return new AiAssistantManagementPlugin(); return new AiAssistantManagementPlugin();

View file

@ -3,7 +3,11 @@
"compilerOptions": { "compilerOptions": {
"outDir": "target/types" "outDir": "target/types"
}, },
"include": ["common/**/*", "public/**/*", "server/**/*"], "include": [
"common/**/*",
"public/**/*",
"server/**/*"
],
"kbn_references": [ "kbn_references": [
"@kbn/core", "@kbn/core",
"@kbn/home-plugin", "@kbn/home-plugin",
@ -22,6 +26,11 @@
"@kbn/config-schema", "@kbn/config-schema",
"@kbn/core-ui-settings-common", "@kbn/core-ui-settings-common",
"@kbn/logs-data-access-plugin", "@kbn/logs-data-access-plugin",
"@kbn/core-plugins-browser",
"@kbn/ai-assistant",
"@kbn/core-plugins-server"
], ],
"exclude": ["target/**/*"] "exclude": [
"target/**/*"
]
} }

View file

@ -15,7 +15,6 @@
"actions", "actions",
"licensing", "licensing",
"observabilityAIAssistant", "observabilityAIAssistant",
"observabilityAIAssistantApp",
"triggersActionsUi", "triggersActionsUi",
"share" "share"
], ],

View file

@ -1,34 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import type { AppMountParameters, CoreStart } from '@kbn/core/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { I18nProvider } from '@kbn/i18n-react';
import type { SearchAssistantPluginStartDependencies } from './types';
import { SearchAssistantRouter } from './components/routes/router';
export const renderApp = (
core: CoreStart,
services: SearchAssistantPluginStartDependencies,
appMountParameters: AppMountParameters
) => {
ReactDOM.render(
<KibanaRenderContextProvider {...core}>
<KibanaContextProvider services={{ ...core, ...services }}>
<I18nProvider>
<SearchAssistantRouter history={appMountParameters.history} />
</I18nProvider>
</KibanaContextProvider>
</KibanaRenderContextProvider>,
appMountParameters.element
);
return () => ReactDOM.unmountComponentAtNode(appMountParameters.element);
};

View file

@ -1,15 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
export const App: React.FC = () => {
return (
<KibanaPageTemplate.Section alignment="top" restrictWidth={false} grow paddingSize="none">
<div />
</KibanaPageTemplate.Section>
);
};

View file

@ -0,0 +1,152 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useRef, useState } from 'react';
import { AssistantAvatar, useAbortableAsync } from '@kbn/observability-ai-assistant-plugin/public';
import { EuiButton, EuiLoadingSpinner, EuiToolTip, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { v4 } from 'uuid';
import useObservable from 'react-use/lib/useObservable';
import { i18n } from '@kbn/i18n';
import { useAIAssistantAppService, ChatFlyout } from '@kbn/ai-assistant';
import { useKibana } from '@kbn/ai-assistant/src/hooks/use_kibana';
import { AIAssistantPluginStartDependencies } from '@kbn/ai-assistant/src/types';
import { EuiErrorBoundary } from '@elastic/eui';
import type { CoreStart } from '@kbn/core/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
interface NavControlWithProviderDeps {
coreStart: CoreStart;
pluginsStart: AIAssistantPluginStartDependencies;
}
export const NavControlWithProvider = ({ coreStart, pluginsStart }: NavControlWithProviderDeps) => {
return (
<EuiErrorBoundary>
<KibanaThemeProvider theme={coreStart.theme}>
<KibanaContextProvider
services={{
...coreStart,
...pluginsStart,
}}
>
<RedirectAppLinks coreStart={coreStart}>
<coreStart.i18n.Context>
<NavControl />
</coreStart.i18n.Context>
</RedirectAppLinks>
</KibanaContextProvider>
</KibanaThemeProvider>
</EuiErrorBoundary>
);
};
export function NavControl() {
const service = useAIAssistantAppService();
const {
services: { notifications, observabilityAIAssistant },
} = useKibana();
const [hasBeenOpened, setHasBeenOpened] = useState(false);
const chatService = useAbortableAsync(
({ signal }) => {
return hasBeenOpened
? service.start({ signal }).catch((error) => {
notifications?.toasts.addError(error, {
title: i18n.translate('xpack.searchAssistant.navControl.initFailureErrorTitle', {
defaultMessage: 'Failed to initialize AI Assistant',
}),
});
setHasBeenOpened(false);
setIsOpen(false);
throw error;
})
: undefined;
},
[service, hasBeenOpened, notifications?.toasts]
);
const [isOpen, setIsOpen] = useState(false);
const keyRef = useRef(v4());
useEffect(() => {
const conversationSubscription = service.conversations.predefinedConversation$.subscribe(() => {
keyRef.current = v4();
setHasBeenOpened(true);
setIsOpen(true);
});
return () => {
conversationSubscription.unsubscribe();
};
}, [service.conversations.predefinedConversation$]);
const { messages, title } = useObservable(service.conversations.predefinedConversation$) ?? {
messages: [],
title: undefined,
};
const theme = useEuiTheme().euiTheme;
const buttonCss = css`
padding: 0px 8px;
svg path {
fill: ${theme.colors.darkestShade};
}
`;
return (
<>
<EuiToolTip content={buttonLabel}>
<EuiButton
aria-label={buttonLabel}
data-test-subj="AiAssistantAppNavControlButton"
css={buttonCss}
onClick={() => {
service.conversations.openNewConversation({
messages: [],
});
}}
color="primary"
size="s"
fullWidth={false}
minWidth={0}
>
{chatService.loading ? <EuiLoadingSpinner size="s" /> : <AssistantAvatar size="xs" />}
</EuiButton>
</EuiToolTip>
{chatService.value &&
Boolean(observabilityAIAssistant?.ObservabilityAIAssistantChatServiceContext) ? (
<observabilityAIAssistant.ObservabilityAIAssistantChatServiceContext.Provider
value={chatService.value}
>
<ChatFlyout
key={keyRef.current}
isOpen={isOpen}
initialMessages={messages}
initialTitle={title ?? ''}
onClose={() => {
setIsOpen(false);
}}
/>
</observabilityAIAssistant.ObservabilityAIAssistantChatServiceContext.Provider>
) : undefined}
</>
);
}
const buttonLabel = i18n.translate(
'xpack.searchAssistant.navControl.openTheAIAssistantPopoverLabel',
{ defaultMessage: 'Open the AI Assistant' }
);

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { dynamic } from '@kbn/shared-ux-utility';
import React from 'react';
import { CoreStart } from '@kbn/core-lifecycle-browser';
import { AIAssistantAppService } from '@kbn/ai-assistant';
import { AIAssistantPluginStartDependencies } from '@kbn/ai-assistant/src/types';
const LazyNavControlWithProvider = dynamic(() =>
import('.').then((m) => ({ default: m.NavControlWithProvider }))
);
interface NavControlInitiatorProps {
appService: AIAssistantAppService;
coreStart: CoreStart;
pluginsStart: AIAssistantPluginStartDependencies;
}
export const NavControlInitiator = ({ coreStart, pluginsStart }: NavControlInitiatorProps) => {
return <LazyNavControlWithProvider coreStart={coreStart} pluginsStart={pluginsStart} />;
};

View file

@ -1,36 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { ConversationView } from '@kbn/ai-assistant';
import { useParams } from 'react-router-dom';
import { useKibana } from '@kbn/kibana-react-plugin/public';
export function ConversationViewWithProps() {
const { conversationId } = useParams<{ conversationId?: string }>();
const {
services: { application, http },
} = useKibana();
function navigateToConversation(nextConversationId?: string) {
application?.navigateToUrl(
http?.basePath.prepend(`/app/searchAssistant/conversations/${nextConversationId || ''}`) || ''
);
}
return (
<ConversationView
conversationId={conversationId}
navigateToConversation={navigateToConversation}
newConversationHref={
http?.basePath.prepend(`/app/searchAssistant/conversations/new|| ''}`) || ''
}
getConversationHref={(id: string) =>
http?.basePath.prepend(`/app/searchAssistant/conversations/${id || ''}`) || ''
}
scopes={['search']}
/>
);
}

View file

@ -1,32 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { History } from 'history';
import { Route, Router, Routes } from '@kbn/shared-ux-router';
import { Redirect } from 'react-router-dom';
import { SearchAIAssistantPageTemplate } from '../page_template';
import { ConversationViewWithProps } from './conversations/conversation_view_with_props';
export const SearchAssistantRouter: React.FC<{ history: History }> = ({ history }) => {
return (
<SearchAIAssistantPageTemplate>
<Router history={history}>
<Routes>
<Redirect from="/" to="/conversations/new" exact />
<Redirect from="/conversations" to="/conversations/new" exact />
<Route path="/conversations/new" exact>
<ConversationViewWithProps />
</Route>
<Route path="/conversations/:conversationId">
<ConversationViewWithProps />
</Route>
</Routes>
</Router>
</SearchAIAssistantPageTemplate>
);
};

View file

@ -5,20 +5,16 @@
* 2.0. * 2.0.
*/ */
import { import { type CoreSetup, type Plugin, CoreStart, PluginInitializerContext } from '@kbn/core/public';
DEFAULT_APP_CATEGORIES, import { createAppService } from '@kbn/ai-assistant';
type CoreSetup, import ReactDOM from 'react-dom';
type Plugin, import React from 'react';
CoreStart,
AppMountParameters,
PluginInitializerContext,
} from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
import type { import type {
SearchAssistantPluginSetup, SearchAssistantPluginSetup,
SearchAssistantPluginStart, SearchAssistantPluginStart,
SearchAssistantPluginStartDependencies, SearchAssistantPluginStartDependencies,
} from './types'; } from './types';
import { NavControlInitiator } from './components/nav_control/lazy_nav_control';
export interface PublicConfigType { export interface PublicConfigType {
ui: { ui: {
@ -44,36 +40,43 @@ export class SearchAssistantPlugin
public setup( public setup(
core: CoreSetup<SearchAssistantPluginStartDependencies, SearchAssistantPluginStart> core: CoreSetup<SearchAssistantPluginStartDependencies, SearchAssistantPluginStart>
): SearchAssistantPluginSetup { ): SearchAssistantPluginSetup {
return {};
}
public start(
coreStart: CoreStart,
pluginsStart: SearchAssistantPluginStartDependencies
): SearchAssistantPluginStart {
if (!this.config.ui.enabled) { if (!this.config.ui.enabled) {
return {}; return {};
} }
const appService = createAppService({
core.application.register({ pluginsStart,
id: 'searchAssistant',
title: i18n.translate('xpack.searchAssistant.appTitle', {
defaultMessage: 'Search Assistant',
}),
euiIconType: 'logoEnterpriseSearch',
appRoute: '/app/searchAssistant',
category: DEFAULT_APP_CATEGORIES.search,
visibleIn: [],
deepLinks: [],
mount: async (appMountParameters: AppMountParameters<unknown>) => {
// Load application bundle and Get start services
const [{ renderApp }, [coreStart, pluginsStart]] = await Promise.all([
import('./application'),
core.getStartServices() as Promise<
[CoreStart, SearchAssistantPluginStartDependencies, unknown]
>,
]);
return renderApp(coreStart, pluginsStart, appMountParameters);
},
}); });
const isEnabled = appService.isEnabled();
if (!isEnabled) {
return {}; return {};
} }
public start(): SearchAssistantPluginStart { coreStart.chrome.navControls.registerRight({
mount: (element) => {
ReactDOM.render(
<NavControlInitiator
appService={appService}
coreStart={coreStart}
pluginsStart={pluginsStart}
/>,
element,
() => {}
);
return () => {};
},
// right before the user profile
order: 1001,
});
return {}; return {};
} }

View file

@ -7,6 +7,10 @@
import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import { ObservabilityAIAssistantPublicStart } from '@kbn/observability-ai-assistant-plugin/public'; import { ObservabilityAIAssistantPublicStart } from '@kbn/observability-ai-assistant-plugin/public';
import { LicensingPluginStart } from '@kbn/licensing-plugin/public';
import { MlPluginStart } from '@kbn/ml-plugin/public';
import { SharePluginStart } from '@kbn/share-plugin/public';
import { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public';
// eslint-disable-next-line @typescript-eslint/no-empty-interface // eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface SearchAssistantPluginSetup {} export interface SearchAssistantPluginSetup {}
@ -15,6 +19,10 @@ export interface SearchAssistantPluginSetup {}
export interface SearchAssistantPluginStart {} export interface SearchAssistantPluginStart {}
export interface SearchAssistantPluginStartDependencies { export interface SearchAssistantPluginStartDependencies {
licensing: LicensingPluginStart;
ml: MlPluginStart;
observabilityAIAssistant: ObservabilityAIAssistantPublicStart; observabilityAIAssistant: ObservabilityAIAssistantPublicStart;
share: SharePluginStart;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
usageCollection?: UsageCollectionStart; usageCollection?: UsageCollectionStart;
} }

View file

@ -13,17 +13,22 @@
], ],
"kbn_references": [ "kbn_references": [
"@kbn/core", "@kbn/core",
"@kbn/react-kibana-context-render",
"@kbn/kibana-react-plugin", "@kbn/kibana-react-plugin",
"@kbn/i18n-react",
"@kbn/shared-ux-page-kibana-template", "@kbn/shared-ux-page-kibana-template",
"@kbn/usage-collection-plugin", "@kbn/usage-collection-plugin",
"@kbn/observability-ai-assistant-plugin", "@kbn/observability-ai-assistant-plugin",
"@kbn/config-schema", "@kbn/config-schema",
"@kbn/ai-assistant", "@kbn/ai-assistant",
"@kbn/i18n", "@kbn/i18n",
"@kbn/shared-ux-router", "@kbn/serverless",
"@kbn/serverless" "@kbn/react-kibana-context-theme",
"@kbn/shared-ux-link-redirect-app",
"@kbn/shared-ux-utility",
"@kbn/core-lifecycle-browser",
"@kbn/licensing-plugin",
"@kbn/ml-plugin",
"@kbn/share-plugin",
"@kbn/triggers-actions-ui-plugin"
], ],
"exclude": [ "exclude": [
"target/**/*", "target/**/*",

View file

@ -13,12 +13,12 @@
"requiredPlugins": [ "requiredPlugins": [
"actions", "actions",
"features", "features",
"ml",
"share", "share",
], ],
"optionalPlugins": [ "optionalPlugins": [
"cloud", "cloud",
"console", "console",
"ml"
], ],
"requiredBundles": [ "requiredBundles": [
"kibanaReact" "kibanaReact"

View file

@ -49,6 +49,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
expect(res.body).to.eql({ expect(res.body).to.eql({
ready: false, ready: false,
model_name: TINY_ELSER.id, model_name: TINY_ELSER.id,
enabled: true,
}); });
}); });
}); });

View file

@ -69,6 +69,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
expect(res.body).to.eql({ expect(res.body).to.eql({
ready: false, ready: false,
model_name: TINY_ELSER.id, model_name: TINY_ELSER.id,
enabled: true,
}); });
}); });
}); });

View file

@ -64,5 +64,8 @@ export function SvlSearchHomePageProvider({ getService }: FtrProviderContext) {
keyName keyName
); );
}, },
async expectAIAssistantToExist() {
await testSubjects.existOrFail('AiAssistantAppNavControlButton');
},
}; };
} }

View file

@ -84,5 +84,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await pageObjects.svlSearchHomePage.createApiKeyInFlyout('ftr-test-key'); await pageObjects.svlSearchHomePage.createApiKeyInFlyout('ftr-test-key');
await pageObjects.svlSearchHomePage.closeConnectionDetailsFlyout(); await pageObjects.svlSearchHomePage.closeConnectionDetailsFlyout();
}); });
it('shows the AI assistant', async () => {
await pageObjects.svlSearchHomePage.expectAIAssistantToExist();
});
}); });
} }