[Observability AI Assistant] Lens function (#163872)

Co-authored-by: Coen Warmer <coen.warmer@gmail.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Clint Andrew Hall <clint@clintandrewhall.com>
Co-authored-by: Carlos Crespo <carloshenrique.leonelcrespo@elastic.co>
Co-authored-by: Alejandro Fernández Haro <alejandro.haro@elastic.co>
This commit is contained in:
Dario Gieselaar 2023-08-17 09:45:51 +02:00 committed by GitHub
parent 26f147e9a4
commit 3128328def
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 2294 additions and 533 deletions

View file

@ -5,9 +5,10 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import type { RegisterFunctionDefinition } from '@kbn/observability-ai-assistant-plugin/common/types';
import { CorrelationsEventType } from '../../../common/assistant/constants';
import { callApmApi } from '../../services/rest/create_call_apm_api';
import { CorrelationsEventType } from '../../common/assistant/constants';
import { callApmApi } from '../services/rest/create_call_apm_api';
export function registerGetApmCorrelationsFunction({
registerFunction,
@ -28,11 +29,16 @@ export function registerGetApmCorrelationsFunction({
ALWAYS put a field value in double quotes. Best: event.outcome:\"failure\".
Wrong: event.outcome:'failure'. This is very important! ONLY use this function
if you have something to compare it to.`,
descriptionForUser: `Get field values that are more prominent in the foreground set than the
descriptionForUser: i18n.translate(
'xpack.apm.observabilityAiAssistant.functions.registerGetApmCorrelationsFunction.descriptionForUser',
{
defaultMessage: `Get field values that are more prominent in the foreground set than the
background set. This can be useful in determining what attributes (like
error.message, service.node.name or transaction.name) are contributing to for
instance a higher latency. Another option is a time-based comparison, where you
compare before and after a change point.`,
}
),
parameters: {
type: 'object',
properties: {

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import type { RegisterFunctionDefinition } from '@kbn/observability-ai-assistant-plugin/common/types';
import { callApmApi } from '../../services/rest/create_call_apm_api';
import { callApmApi } from '../services/rest/create_call_apm_api';
export function registerGetApmDownstreamDependenciesFunction({
registerFunction,
@ -21,10 +22,15 @@ export function registerGetApmDownstreamDependenciesFunction({
service. This allows you to map the dowstream dependency name to a service, by
returning both span.destination.service.resource and service.name. Use this to
drilldown further if needed.`,
descriptionForUser: `Get the downstream dependencies (services or uninstrumented backends) for a
descriptionForUser: i18n.translate(
'xpack.apm.observabilityAiAssistant.functions.registerGetApmDownstreamDependencies.descriptionForUser',
{
defaultMessage: `Get the downstream dependencies (services or uninstrumented backends) for a
service. This allows you to map the dowstream dependency name to a service, by
returning both span.destination.service.resource and service.name. Use this to
drilldown further if needed.`,
}
),
parameters: {
type: 'object',
properties: {

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import type { RegisterFunctionDefinition } from '@kbn/observability-ai-assistant-plugin/common/types';
import { callApmApi } from '../../services/rest/create_call_apm_api';
import { callApmApi } from '../services/rest/create_call_apm_api';
export function registerGetApmErrorDocumentFunction({
registerFunction,
@ -20,8 +21,13 @@ export function registerGetApmErrorDocumentFunction({
description: `Get a sample error document based on its grouping name. This also includes the
stacktrace of the error, which might give you a hint as to what the cause is.
ONLY use this for error events.`,
descriptionForUser: `Get a sample error document based on its grouping name. This also includes the
descriptionForUser: i18n.translate(
'xpack.apm.observabilityAiAssistant.functions.registerGetApmErrorDocument.descriptionForUser',
{
defaultMessage: `Get a sample error document based on its grouping name. This also includes the
stacktrace of the error, which might give you a hint as to what the cause is.`,
}
),
parameters: {
type: 'object',
properties: {

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import type { RegisterFunctionDefinition } from '@kbn/observability-ai-assistant-plugin/common/types';
import { callApmApi } from '../../services/rest/create_call_apm_api';
import { callApmApi } from '../services/rest/create_call_apm_api';
export function registerGetApmServiceSummaryFunction({
registerFunction,
@ -18,13 +19,18 @@ export function registerGetApmServiceSummaryFunction({
name: 'get_apm_service_summary',
contexts: ['apm'],
description: `Gets a summary of a single service, including: the language, service version,
deployments, and the infrastructure that it is running in, for instance on how
deployments, the environments, and the infrastructure that it is running in, for instance on how
many pods, and a list of its downstream dependencies. It also returns active
alerts and anomalies.`,
descriptionForUser: `Gets a summary of a single service, including: the language, service version,
deployments, and the infrastructure that it is running in, for instance on how
descriptionForUser: i18n.translate(
'xpack.apm.observabilityAiAssistant.functions.registerGetApmServiceSummary.descriptionForUser',
{
defaultMessage: `Gets a summary of a single service, including: the language, service version,
deployments, the environments, and the infrastructure that it is running in, for instance on how
many pods, and a list of its downstream dependencies. It also returns active
alerts and anomalies.`,
}
),
parameters: {
type: 'object',
properties: {

View file

@ -8,28 +8,29 @@ import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import type { RegisterFunctionDefinition } from '@kbn/observability-ai-assistant-plugin/common/types';
import { groupBy } from 'lodash';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { FETCH_STATUS } from '../../hooks/use_fetcher';
import { callApmApi } from '../../services/rest/create_call_apm_api';
import { getTimeZone } from '../shared/charts/helper/timezone';
import { TimeseriesChart } from '../shared/charts/timeseries_chart';
import { ChartPointerEventContextProvider } from '../../context/chart_pointer_event/chart_pointer_event_context';
import { ApmThemeProvider } from '../routing/app_root';
import { Coordinate, TimeSeries } from '../../../typings/timeseries';
import { FETCH_STATUS } from '../hooks/use_fetcher';
import { callApmApi } from '../services/rest/create_call_apm_api';
import { getTimeZone } from '../components/shared/charts/helper/timezone';
import { TimeseriesChart } from '../components/shared/charts/timeseries_chart';
import { ChartPointerEventContextProvider } from '../context/chart_pointer_event/chart_pointer_event_context';
import { ApmThemeProvider } from '../components/routing/app_root';
import { Coordinate, TimeSeries } from '../../typings/timeseries';
import {
ChartType,
getTimeSeriesColor,
} from '../shared/charts/helper/get_timeseries_color';
import { LatencyAggregationType } from '../../../common/latency_aggregation_types';
} from '../components/shared/charts/helper/get_timeseries_color';
import { LatencyAggregationType } from '../../common/latency_aggregation_types';
import {
asPercent,
asTransactionRate,
getDurationFormatter,
} from '../../../common/utils/formatters';
} from '../../common/utils/formatters';
import {
getMaxY,
getResponseTimeTickFormatter,
} from '../shared/charts/transaction_charts/helper';
} from '../components/shared/charts/transaction_charts/helper';
export function registerGetApmTimeseriesFunction({
registerFunction,
@ -40,7 +41,12 @@ export function registerGetApmTimeseriesFunction({
{
contexts: ['apm'],
name: 'get_apm_timeseries',
descriptionForUser: `Display different APM metrics, like throughput, failure rate, or latency, for any service or all services, or any or all of its dependencies, both as a timeseries and as a single statistic. Additionally, the function will return any changes, such as spikes, step and trend changes, or dips. You can also use it to compare data by requesting two different time ranges, or for instance two different service versions`,
descriptionForUser: i18n.translate(
'xpack.apm.observabilityAiAssistant.functions.registerGetApmTimeseries.descriptionForUser',
{
defaultMessage: `Display different APM metrics, like throughput, failure rate, or latency, for any service or all services, or any or all of its dependencies, both as a timeseries and as a single statistic. Additionally, the function will return any changes, such as spikes, step and trend changes, or dips. You can also use it to compare data by requesting two different time ranges, or for instance two different service versions`,
}
),
description: `Display different APM metrics, like throughput, failure rate, or latency, for any service or all services, or any or all of its dependencies, both as a timeseries and as a single statistic. Additionally, the function will return any changes, such as spikes, step and trend changes, or dips. You can also use it to compare data by requesting two different time ranges, or for instance two different service versions. In KQL, escaping happens with double quotes, not single quotes. Some characters that need escaping are: ':()\\\/\". Always put a field value in double quotes. Best: service.name:\"opbeans-go\". Wrong: service.name:opbeans-go. This is very important!`,
parameters: {
type: 'object',
@ -135,7 +141,7 @@ export function registerGetApmTimeseriesFunction({
'service.environment': {
type: 'string',
description:
"The environment that the service is running in. If you don't know this, use ENVIRONMENT_ALL.",
'The environment that the service is running in.',
},
filter: {
type: 'string',

View file

@ -5,32 +5,45 @@
* 2.0.
*/
import { CoreStart } from '@kbn/core/public';
import {
import type { CoreStart } from '@kbn/core/public';
import type {
RegisterContextDefinition,
RegisterFunctionDefinition,
} from '@kbn/observability-ai-assistant-plugin/common/types';
import { ApmPluginStartDeps } from '../../plugin';
import { createCallApmApi } from '../../services/rest/create_call_apm_api';
import type { ApmPluginStartDeps } from '../plugin';
import {
createCallApmApi,
callApmApi,
} from '../services/rest/create_call_apm_api';
import { registerGetApmCorrelationsFunction } from './get_apm_correlations';
import { registerGetApmDownstreamDependenciesFunction } from './get_apm_downstream_dependencies';
import { registerGetApmErrorDocumentFunction } from './get_apm_error_document';
import { registerGetApmServiceSummaryFunction } from './get_apm_service_summary';
import { registerGetApmTimeseriesFunction } from './get_apm_timeseries';
export function registerAssistantFunctions({
export async function registerAssistantFunctions({
pluginsStart,
coreStart,
registerContext,
registerFunction,
signal,
}: {
pluginsStart: ApmPluginStartDeps;
coreStart: CoreStart;
registerFunction: RegisterFunctionDefinition;
registerContext: RegisterContextDefinition;
registerFunction: RegisterFunctionDefinition;
signal: AbortSignal;
}) {
createCallApmApi(coreStart);
const response = await callApmApi('GET /internal/apm/has_data', {
signal,
});
if (!response.hasData) {
return;
}
registerGetApmTimeseriesFunction({
registerFunction,
});

View file

@ -415,14 +415,15 @@ export class ApmPlugin implements Plugin<ApmPluginSetup, ApmPluginStart> {
const { fleet } = plugins;
plugins.observabilityAIAssistant.register(
async ({ signal, registerFunction, registerContext }) => {
const mod = await import('./components/assistant_functions');
async ({ signal, registerContext, registerFunction }) => {
const mod = await import('./assistant_functions');
mod.registerAssistantFunctions({
coreStart: core,
pluginsStart: plugins,
registerFunction,
registerContext,
registerFunction,
signal,
});
}
);

View file

@ -35,6 +35,7 @@ import { getMlJobsWithAPMGroup } from '../../../lib/anomaly_detection/get_ml_job
import { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client';
import { ApmAlertsClient } from '../../../lib/helpers/get_apm_alerts_client';
import { MlClient } from '../../../lib/helpers/get_ml_client';
import { getEnvironments } from '../../environments/get_environments';
import { getServiceAnnotations } from '../../services/annotations';
import { getServiceMetadataDetails } from '../../services/get_service_metadata_details';
@ -212,6 +213,7 @@ async function getAnomalies({
export interface ServiceSummary {
'service.name': string;
'service.environment': string[];
'agent.name'?: string;
'service.version'?: string[];
'language.name'?: string;
@ -256,54 +258,66 @@ export async function getApmServiceSummary({
const environment = args['service.environment'] || ENVIRONMENT_ALL.value;
const transactionType = args['transaction.type'];
const [metadataDetails, anomalies, annotations, alerts] = await Promise.all([
getServiceMetadataDetails({
apmEventClient,
start,
end,
serviceName,
}),
getAnomalies({
serviceName,
start,
end,
environment,
mlClient,
logger,
transactionType,
}),
getServiceAnnotations({
apmEventClient,
start,
end,
searchAggregatedTransactions: true,
client: esClient,
annotationsClient,
environment,
logger,
serviceName,
}),
apmAlertsClient.search({
size: 100,
track_total_hits: false,
body: {
query: {
bool: {
filter: [
...termQuery(ALERT_RULE_PRODUCER, 'apm'),
...termQuery(ALERT_STATUS, ALERT_STATUS_ACTIVE),
...rangeQuery(start, end),
...termQuery(SERVICE_NAME, serviceName),
...environmentQuery(environment),
],
const [environments, metadataDetails, anomalies, annotations, alerts] =
await Promise.all([
environment === ENVIRONMENT_ALL.value
? getEnvironments({
apmEventClient,
start,
end,
size: 10,
serviceName,
searchAggregatedTransactions: true,
})
: Promise.resolve([environment]),
getServiceMetadataDetails({
apmEventClient,
start,
end,
serviceName,
}),
getAnomalies({
serviceName,
start,
end,
environment,
mlClient,
logger,
transactionType,
}),
getServiceAnnotations({
apmEventClient,
start,
end,
searchAggregatedTransactions: true,
client: esClient,
annotationsClient,
environment,
logger,
serviceName,
}),
apmAlertsClient.search({
size: 100,
track_total_hits: false,
body: {
query: {
bool: {
filter: [
...termQuery(ALERT_RULE_PRODUCER, 'apm'),
...termQuery(ALERT_STATUS, ALERT_STATUS_ACTIVE),
...rangeQuery(start, end),
...termQuery(SERVICE_NAME, serviceName),
...environmentQuery(environment),
],
},
},
},
},
}),
]);
}),
]);
return {
'service.name': serviceName,
'service.environment': environments,
'agent.name': metadataDetails.service?.agent.name,
'service.version': metadataDetails.service?.versions,
'language.name': metadataDetails.service?.agent.name,

View file

@ -66,7 +66,7 @@ export interface KnowledgeBaseEntry {
public: boolean;
}
type CompatibleJSONSchema = Exclude<JSONSchema, boolean>;
export type CompatibleJSONSchema = Exclude<JSONSchema, boolean>;
export interface ContextDefinition {
name: string;

View file

@ -7,8 +7,8 @@
"server": true,
"browser": true,
"configPath": ["xpack", "observabilityAIAssistant"],
"requiredPlugins": ["triggersActionsUi", "actions", "security", "features", "observabilityShared"],
"requiredBundles": ["kibanaReact", "kibanaUtils"],
"requiredPlugins": ["triggersActionsUi", "actions", "security", "features", "observabilityShared", "taskManager", "lens", "dataViews"],
"requiredBundles": ["kibanaReact", "kibanaUtils", "fieldFormats"],
"optionalPlugins": [],
"extraPublicDirs": []
}

View file

@ -10,6 +10,7 @@ import React, { useState } from 'react';
import { ObservabilityAIAssistantChatServiceProvider } from '../../context/observability_ai_assistant_chat_service_provider';
import { useAbortableAsync } from '../../hooks/use_abortable_async';
import { useConversation } from '../../hooks/use_conversation';
import { useGenAIConnectors } from '../../hooks/use_genai_connectors';
import { useObservabilityAIAssistant } from '../../hooks/use_observability_ai_assistant';
import { EMPTY_CONVERSATION_TITLE } from '../../i18n';
import { AssistantAvatar } from '../assistant_avatar';
@ -17,6 +18,7 @@ import { ChatFlyout } from '../chat/chat_flyout';
export function ObservabilityAIAssistantActionMenuItem() {
const service = useObservabilityAIAssistant();
const connectors = useGenAIConnectors();
const [isOpen, setIsOpen] = useState(false);
@ -34,6 +36,8 @@ export function ObservabilityAIAssistantActionMenuItem() {
const { conversation, displayedMessages, setDisplayedMessages, save } = useConversation({
conversationId,
connectorId: connectors.selectedConnector,
chatService: chatService.value,
});
if (!service.isEnabled()) {

View file

@ -15,7 +15,8 @@ import {
} from '@elastic/eui';
import { css } from '@emotion/css';
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
import React from 'react';
import React, { useEffect, useRef } from 'react';
import { last } from 'lodash';
import type { Message } from '../../../common/types';
import type { UseGenAIConnectorsResult } from '../../hooks/use_genai_connectors';
import type { UseKnowledgeBaseResult } from '../../hooks/use_knowledge_base';
@ -25,11 +26,10 @@ import { MissingCredentialsCallout } from '../missing_credentials_callout';
import { ChatHeader } from './chat_header';
import { ChatPromptEditor } from './chat_prompt_editor';
import { ChatTimeline } from './chat_timeline';
import { KnowledgeBaseCallout } from './knowledge_base_callout';
const containerClassName = css`
max-height: 100%;
max-width: 100%;
max-width: 800px;
`;
const timelineClassName = css`
@ -42,22 +42,27 @@ const loadingSpinnerContainerClassName = css`
export function ChatBody({
title,
loading,
messages,
connectors,
knowledgeBase,
currentUser,
connectorsManagementHref,
currentUser,
onChatUpdate,
onChatComplete,
onSaveTitle,
}: {
title: string;
loading: boolean;
messages: Message[];
connectors: UseGenAIConnectorsResult;
knowledgeBase: UseKnowledgeBaseResult;
currentUser?: Pick<AuthenticatedUser, 'full_name' | 'username'>;
connectorsManagementHref: string;
conversationId?: string;
currentUser?: Pick<AuthenticatedUser, 'full_name' | 'username'>;
onChatUpdate: (messages: Message[]) => void;
onChatComplete: (messages: Message[]) => void;
onSaveTitle: (title: string) => void;
}) {
const chatService = useObservabilityAIAssistantChatService();
@ -70,8 +75,57 @@ export function ChatBody({
onChatComplete,
});
const timelineContainerRef = useRef<HTMLDivElement | null>(null);
let footer: React.ReactNode;
const isLoading = Boolean(
connectors.loading || knowledgeBase.status.loading || last(timeline.items)?.loading
);
useEffect(() => {
const parent = timelineContainerRef.current?.parentElement;
if (!parent) {
return;
}
let rafId: number | undefined;
const isAtBottom = () => parent.scrollTop >= parent.scrollHeight - parent.offsetHeight;
const stick = () => {
if (!isAtBottom()) {
parent.scrollTop = parent.scrollHeight - parent.offsetHeight;
}
rafId = requestAnimationFrame(stick);
};
const unstick = () => {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = undefined;
}
};
const onScroll = (event: Event) => {
if (isAtBottom()) {
stick();
} else {
unstick();
}
};
parent.addEventListener('scroll', onScroll);
stick();
return () => {
unstick();
parent.removeEventListener('scroll', onScroll);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timelineContainerRef.current]);
if (connectors.loading || knowledgeBase.status.loading) {
footer = (
<EuiFlexItem className={loadingSpinnerContainerClassName}>
@ -89,15 +143,17 @@ export function ChatBody({
footer = (
<>
<EuiFlexItem grow className={timelineClassName}>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="m">
<ChatTimeline
items={timeline.items}
onEdit={timeline.onEdit}
onFeedback={timeline.onFeedback}
onRegenerate={timeline.onRegenerate}
onStopGenerating={timeline.onStopGenerating}
/>
</EuiPanel>
<div ref={timelineContainerRef}>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="m">
<ChatTimeline
items={timeline.items}
onEdit={timeline.onEdit}
onFeedback={timeline.onFeedback}
onRegenerate={timeline.onRegenerate}
onStopGenerating={timeline.onStopGenerating}
/>
</EuiPanel>
</div>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHorizontalRule margin="none" />
@ -105,10 +161,11 @@ export function ChatBody({
<EuiFlexItem grow={false}>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="m">
<ChatPromptEditor
loading={false}
loading={isLoading}
disabled={!connectors.selectedConnector}
onSubmit={timeline.onSubmit}
/>
<EuiSpacer size="s" />
</EuiPanel>
</EuiFlexItem>
</>
@ -118,12 +175,13 @@ export function ChatBody({
return (
<EuiFlexGroup direction="column" gutterSize="none" className={containerClassName}>
<EuiFlexItem grow={false}>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="m">
<ChatHeader title={title} connectors={connectors} />
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<KnowledgeBaseCallout knowledgeBase={knowledgeBase} />
<ChatHeader
title={title}
connectors={connectors}
knowledgeBase={knowledgeBase}
loading={loading}
onSaveTitle={onSaveTitle}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHorizontalRule margin="none" />

View file

@ -9,10 +9,13 @@ import { css } from '@emotion/css';
import { i18n } from '@kbn/i18n';
import React from 'react';
import type { Message } from '../../../common/types';
import { useAbortableAsync } from '../../hooks/use_abortable_async';
import { useConversation } from '../../hooks/use_conversation';
import { useCurrentUser } from '../../hooks/use_current_user';
import { useGenAIConnectors } from '../../hooks/use_genai_connectors';
import { useKibana } from '../../hooks/use_kibana';
import { useKnowledgeBase } from '../../hooks/use_knowledge_base';
import { useObservabilityAIAssistant } from '../../hooks/use_observability_ai_assistant';
import { useObservabilityAIAssistantRouter } from '../../hooks/use_observability_ai_assistant_router';
import { getConnectorsManagementHref } from '../../utils/get_connectors_management_href';
import { ChatBody } from './chat_body';
@ -42,20 +45,34 @@ export function ChatFlyout({
onChatUpdate?: (messages: Message[]) => void;
onChatComplete?: (messages: Message[]) => void;
}) {
const connectors = useGenAIConnectors();
const currentUser = useCurrentUser();
const { euiTheme } = useEuiTheme();
const {
services: { http },
} = useKibana();
const { euiTheme } = useEuiTheme();
const currentUser = useCurrentUser();
const connectors = useGenAIConnectors();
const service = useObservabilityAIAssistant();
const chatService = useAbortableAsync(
({ signal }) => {
return service.start({ signal });
},
[service]
);
const router = useObservabilityAIAssistantRouter();
const knowledgeBase = useKnowledgeBase();
const { saveTitle } = useConversation({
conversationId,
chatService: chatService.value,
connectorId: connectors.selectedConnector,
});
return isOpen ? (
<EuiFlyout onClose={onClose}>
<EuiFlexGroup
@ -92,6 +109,7 @@ export function ChatFlyout({
</EuiFlexItem>
<EuiFlexItem grow className={bodyClassName}>
<ChatBody
loading={false}
connectors={connectors}
title={title}
messages={messages}
@ -108,6 +126,9 @@ export function ChatFlyout({
onChatComplete(nextMessages);
}
}}
onSaveTitle={(newTitle) => {
saveTitle(newTitle);
}}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -29,6 +29,18 @@ export const ChatHeaderLoaded: ComponentStoryObj<typeof Component> = {
] as FindActionResult[],
selectConnector: () => {},
},
knowledgeBase: {
status: {
loading: false,
value: {
ready: true,
},
refresh: () => {},
},
isInstalling: false,
installError: undefined,
install: async () => {},
},
},
render: (props) => {
return (

View file

@ -4,20 +4,42 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiTitle, useEuiTheme } from '@elastic/eui';
import React, { useRef } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiInlineEditTitle,
EuiLoadingSpinner,
EuiPanel,
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/css';
import { UseGenAIConnectorsResult } from '../../hooks/use_genai_connectors';
import { AssistantAvatar } from '../assistant_avatar';
import { ConnectorSelectorBase } from '../connector_selector/connector_selector_base';
import { EMPTY_CONVERSATION_TITLE } from '../../i18n';
import { KnowledgeBaseCallout } from './knowledge_base_callout';
import { TechnicalPreviewBadge } from '../technical_preview_badge';
import { useUnmountAndRemountWhenPropChanges } from '../../hooks/use_unmount_and_remount_when_prop_changes';
import type { UseGenAIConnectorsResult } from '../../hooks/use_genai_connectors';
import type { UseKnowledgeBaseResult } from '../../hooks/use_knowledge_base';
const minWidthClassName = css`
min-width: 0;
`;
export function ChatHeader({
title,
loading,
knowledgeBase,
connectors,
onSaveTitle,
}: {
title: string;
loading: boolean;
knowledgeBase: UseKnowledgeBaseResult;
connectors: UseGenAIConnectorsResult;
onSaveTitle?: (title: string) => void;
}) {
const hasTitle = !!title;
@ -25,28 +47,62 @@ export function ChatHeader({
const theme = useEuiTheme();
// Component only works uncontrolled at the moment, so need to unmount and remount on prop change.
// https://github.com/elastic/eui/issues/7084
const shouldRender = useUnmountAndRemountWhenPropChanges(displayedTitle);
const inputRef = useRef<HTMLInputElement>(null);
return (
<EuiFlexGroup alignItems="center" gutterSize="l">
<EuiFlexItem grow={false}>
<AssistantAvatar size="l" />
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="none" justifyContent="center">
<EuiFlexItem grow={false}>
<EuiTitle
size="m"
className={css`
color: ${hasTitle ? theme.euiTheme.colors.text : theme.euiTheme.colors.subduedText};
`}
>
<h2>{displayedTitle}</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ConnectorSelectorBase {...connectors} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiPanel paddingSize="m" hasBorder={false} hasShadow={false} borderRadius="none">
<EuiFlexGroup alignItems="flexStart" gutterSize="m" responsive={false}>
<EuiFlexItem grow={false}>
{loading ? <EuiLoadingSpinner size="xl" /> : <AssistantAvatar size="m" />}
</EuiFlexItem>
<EuiFlexItem grow className={minWidthClassName}>
<EuiFlexGroup direction="column" gutterSize="none" className={minWidthClassName}>
<EuiFlexItem grow={false} className={minWidthClassName}>
<EuiFlexGroup
direction="row"
gutterSize="m"
className={minWidthClassName}
alignItems="center"
>
<EuiFlexItem grow className={minWidthClassName}>
{shouldRender ? (
<EuiInlineEditTitle
heading="h2"
size="s"
defaultValue={displayedTitle}
className={css`
color: ${hasTitle
? theme.euiTheme.colors.text
: theme.euiTheme.colors.subduedText};
`}
inputAriaLabel={i18n.translate(
'xpack.observabilityAiAssistant.chatHeader.editConversationInput',
{ defaultMessage: 'Edit conversation' }
)}
editModeProps={{ inputProps: { inputRef } }}
isReadOnly={!Boolean(onSaveTitle)}
onSave={onSaveTitle}
/>
) : null}
</EuiFlexItem>
<EuiFlexItem grow={false} className={minWidthClassName}>
<TechnicalPreviewBadge />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<KnowledgeBaseCallout knowledgeBase={knowledgeBase} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ConnectorSelectorBase {...connectors} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}

View file

@ -23,6 +23,7 @@ import { ChatTimelineItem } from './chat_timeline';
import { getRoleTranslation } from '../../utils/get_role_translation';
import type { Feedback } from '../feedback_buttons';
import { Message } from '../../../common';
import { FailedToLoadResponse } from '../message_panel/failed_to_load_response';
export interface ChatItemProps extends ChatTimelineItem {
onEditSubmit: (message: Message) => Promise<void>;
@ -126,7 +127,7 @@ export function ChatItem({
};
let contentElement: React.ReactNode =
content || error ? (
content || loading || error ? (
<ChatItemContentInlinePromptEditor
content={content}
editing={editing}
@ -179,8 +180,8 @@ export function ChatItem({
>
<EuiPanel hasShadow={false} paddingSize="s">
{element ? <EuiErrorBoundary>{element}</EuiErrorBoundary> : null}
{contentElement}
{error ? <FailedToLoadResponse /> : null}
</EuiPanel>
<ChatItemControls

View file

@ -91,6 +91,9 @@ export function ChatPromptEditor({
};
const handleSubmit = useCallback(async () => {
if (loading) {
return;
}
const currentPrompt = prompt;
const currentPayload = functionPayload;
@ -120,12 +123,11 @@ export function ChatPromptEditor({
'@timestamp': new Date().toISOString(),
message: { role: MessageRole.User, content: currentPrompt },
});
setPrompt('');
}
} catch (_) {
setPrompt(currentPrompt);
}
}, [functionPayload, onSubmit, prompt, selectedFunctionName]);
}, [functionPayload, loading, onSubmit, prompt, selectedFunctionName]);
useEffect(() => {
const keyboardListener = (event: KeyboardEvent) => {
@ -166,6 +168,7 @@ export function ChatPromptEditor({
<FunctionListPopover
selectedFunctionName={selectedFunctionName}
onSelectFunction={handleSelectFunction}
disabled={loading}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
@ -174,6 +177,7 @@ export function ChatPromptEditor({
iconType="cross"
iconSide="right"
size="xs"
disabled={loading}
onClick={handleClearSelection}
>
{i18n.translate('xpack.observabilityAiAssistant.prompt.emptySelection', {
@ -237,7 +241,7 @@ export function ChatPromptEditor({
fullWidth
inputRef={textAreaRef}
placeholder={i18n.translate('xpack.observabilityAiAssistant.prompt.placeholder', {
defaultMessage: 'Press $ for function recommendations',
defaultMessage: 'Send a message to the Assistant',
})}
resize="vertical"
rows={1}

View file

@ -78,7 +78,10 @@ const defaultProps: ComponentProps<typeof Component> = {
trigger: MessageRole.Assistant,
},
actions: {
canEdit: true,
canEdit: false,
canCopy: true,
canGiveFeedback: true,
canRegenerate: true,
},
}),
buildFunctionChatItem({
@ -86,6 +89,9 @@ const defaultProps: ComponentProps<typeof Component> = {
error: new Error(),
actions: {
canRegenerate: false,
canEdit: true,
canGiveFeedback: false,
canCopy: true,
},
}),
buildAssistantChatItem({
@ -98,6 +104,9 @@ const defaultProps: ComponentProps<typeof Component> = {
},
actions: {
canEdit: true,
canCopy: true,
canGiveFeedback: true,
canRegenerate: true,
},
}),
buildFunctionChatItem({

View file

@ -5,14 +5,14 @@
* 2.0.
*/
import React, { ReactNode } from 'react';
import { css } from '@emotion/react';
import { compact } from 'lodash';
import { EuiCommentList } from '@elastic/eui';
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
import { compact } from 'lodash';
import React, { ReactNode } from 'react';
import { type Message } from '../../../common';
import type { Feedback } from '../feedback_buttons';
import { ChatItem } from './chat_item';
import type { Feedback } from '../feedback_buttons';
import type { Message } from '../../../common';
export interface ChatTimelineItem
extends Pick<Message['message'], 'role' | 'content' | 'function_call'> {
@ -50,7 +50,11 @@ export function ChatTimeline({
onStopGenerating,
}: ChatTimelineProps) {
return (
<EuiCommentList>
<EuiCommentList
css={css`
padding-bottom: 32px;
`}
>
{compact(
items.map((item, index) =>
!item.display.hide ? (

View file

@ -13,6 +13,7 @@ import {
EuiListGroupItem,
EuiLoadingSpinner,
EuiPanel,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { css } from '@emotion/css';
@ -28,6 +29,18 @@ const titleClassName = css`
text-transform: uppercase;
`;
const panelClassName = css`
max-height: 100%;
`;
const overflowScrollClassName = css`
overflow-y: auto;
`;
const newChatButtonWrapperClassName = css`
padding-bottom: 5px;
`;
export function ConversationList({
selected,
onClickNewChat,
@ -45,14 +58,15 @@ export function ConversationList({
onClickDeleteConversation: (id: string) => void;
}) {
return (
<EuiPanel paddingSize="s" hasShadow={false}>
<EuiPanel paddingSize="s" hasShadow={false} className={panelClassName}>
<EuiFlexGroup direction="column" gutterSize="none" className={containerClassName}>
<EuiFlexItem grow>
<EuiFlexItem grow className={overflowScrollClassName}>
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiSpacer size="s" />
<EuiText className={titleClassName} size="s">
<strong>
{i18n.translate('xpack.observabilityAiAssistant.conversationList.title', {
@ -101,6 +115,7 @@ export function ConversationList({
isActive={conversation.id === selected}
isDisabled={loading}
href={conversation.href}
wrapText
extraAction={
conversation.id
? {
@ -135,7 +150,7 @@ export function ConversationList({
<EuiFlexItem grow={false}>
<EuiPanel paddingSize="s" hasBorder={false} hasShadow={false}>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow>
<EuiFlexItem grow className={newChatButtonWrapperClassName}>
<NewChatButton onClick={onClickNewChat} />
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -24,6 +24,7 @@ const Template: ComponentStory<typeof Component> = (props: FunctionListPopover)
const defaultProps: FunctionListPopover = {
onSelectFunction: () => {},
disabled: false,
};
export const ConversationList = Template.bind({});

View file

@ -5,27 +5,43 @@
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import {
EuiButtonEmpty,
EuiContextMenu,
EuiContextMenuPanel,
EuiHighlight,
EuiPopover,
EuiSelectable,
EuiSelectableOption,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import type { EuiSelectableOptionCheckedType } from '@elastic/eui/src/components/selectable/selectable_option';
import { i18n } from '@kbn/i18n';
import { FunctionDefinition } from '../../../common/types';
import type { FunctionDefinition } from '../../../common/types';
import { useObservabilityAIAssistantChatService } from '../../hooks/use_observability_ai_assistant_chat_service';
interface FunctionListOption {
label: string;
searchableLabel: string;
}
export function FunctionListPopover({
selectedFunctionName,
onSelectFunction,
disabled,
}: {
selectedFunctionName?: string;
onSelectFunction: (func: string) => void;
disabled: boolean;
}) {
const chatService = useObservabilityAIAssistantChatService();
const { getFunctions } = useObservabilityAIAssistantChatService();
const functions = getFunctions();
const filterRef = useRef<HTMLInputElement | null>(null);
const [functionOptions, setFunctionOptions] = useState<
Array<EuiSelectableOption<FunctionListOption>>
>(mapFunctions({ functions, selectedFunctionName }));
const [isFunctionListOpen, setIsFunctionListOpen] = useState(false);
@ -33,9 +49,9 @@ export function FunctionListPopover({
setIsFunctionListOpen(!isFunctionListOpen);
};
const handleSelectFunction = (func: FunctionDefinition) => {
const handleSelectFunction = (func: EuiSelectableOption<FunctionListOption>) => {
setIsFunctionListOpen(false);
onSelectFunction(func.options.name);
onSelectFunction(func.label);
};
useEffect(() => {
@ -52,6 +68,42 @@ export function FunctionListPopover({
};
}, []);
useEffect(() => {
if (isFunctionListOpen && filterRef.current) {
filterRef.current.focus();
}
}, [isFunctionListOpen]);
useEffect(() => {
const options = mapFunctions({ functions, selectedFunctionName });
if (options.length !== functionOptions.length) {
setFunctionOptions(options);
}
}, [functionOptions.length, functions, selectedFunctionName]);
const renderFunctionOption = (
option: EuiSelectableOption<FunctionListOption>,
searchValue: string
) => {
return (
<>
<EuiText size="s">
<p>
<strong>
<EuiHighlight search={searchValue}>{option.label}</EuiHighlight>{' '}
</strong>
</p>
</EuiText>
<EuiSpacer size="xs" />
<EuiText size="s">
<p style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>
<EuiHighlight search={searchValue}>{option.searchableLabel || ''}</EuiHighlight>
</p>
</EuiText>
</>
);
};
return (
<EuiPopover
anchorPosition="downLeft"
@ -61,10 +113,11 @@ export function FunctionListPopover({
iconSide="right"
size="xs"
onClick={handleClickFunctionList}
disabled={disabled}
>
{selectedFunctionName
? selectedFunctionName
: i18n.translate('xpack.observabilityAiAssistant.prompt.callFunction', {
: i18n.translate('xpack.observabilityAiAssistant.prompt.functionList.callFunction', {
defaultMessage: 'Call function',
})}
</EuiButtonEmpty>
@ -74,33 +127,59 @@ export function FunctionListPopover({
panelPaddingSize="none"
isOpen={isFunctionListOpen}
>
<EuiContextMenuPanel size="s">
<EuiContextMenu
initialPanelId={0}
panels={[
{
id: 0,
width: 500,
items: chatService.getFunctions().map((func) => ({
name: (
<>
<EuiText size="s">
<p>
<strong>{func.options.name}</strong>
</p>
</EuiText>
<EuiSpacer size="xs" />
<EuiText size="s">
<p>{func.options.descriptionForUser}</p>
</EuiText>
</>
),
onClick: () => handleSelectFunction(func),
})),
},
]}
/>
</EuiContextMenuPanel>
<EuiSelectable
aria-label={i18n.translate(
'xpack.observabilityAiAssistant.prompt.functionList.functionList',
{
defaultMessage: 'Function list',
}
)}
listProps={{
isVirtualized: false,
showIcons: false,
}}
options={functionOptions}
renderOption={renderFunctionOption}
searchable
searchProps={{
'data-test-subj': 'searchFiltersList',
inputRef: (node) => (filterRef.current = node),
placeholder: i18n.translate('xpack.observabilityAiAssistant.prompt.functionList.filter', {
defaultMessage: 'Filter',
}),
}}
singleSelection
onChange={(options) => {
const selectedFunction = options.filter((fn) => fn.checked !== 'off');
if (selectedFunction && selectedFunction.length === 1) {
handleSelectFunction({ ...selectedFunction[0], checked: 'on' });
}
}}
>
{(list, search) => (
<div style={{ overflow: 'hidden' }}>
{search}
<div style={{ width: 500, height: 350, overflowY: 'scroll' }}>{list}</div>
</div>
)}
</EuiSelectable>
</EuiPopover>
);
}
function mapFunctions({
functions,
selectedFunctionName,
}: {
functions: FunctionDefinition[];
selectedFunctionName: string | undefined;
}) {
return functions.map((func) => ({
label: func.options.name,
searchableLabel: func.options.descriptionForUser,
checked:
func.options.name === selectedFunctionName
? ('on' as EuiSelectableOptionCheckedType)
: ('off' as EuiSelectableOptionCheckedType),
}));
}

View file

@ -9,9 +9,11 @@ import React from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiLink,
EuiLoadingSpinner,
EuiPanel,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -50,6 +52,7 @@ export function KnowledgeBaseCallout({ knowledgeBase }: { knowledgeBase: UseKnow
color = 'plain';
content = (
<EuiText size="xs" color="subdued">
<EuiIcon type="iInCircle" />{' '}
{i18n.translate('xpack.observabilityAiAssistant.poweredByModel', {
defaultMessage: 'Powered by {model}',
values: {
@ -91,6 +94,7 @@ export function KnowledgeBaseCallout({ knowledgeBase }: { knowledgeBase: UseKnow
}}
>
<EuiText size="xs">
<EuiIcon type="iInCircle" />{' '}
{i18n.translate('xpack.observabilityAiAssistant.setupKb', {
defaultMessage: 'Improve your experience by setting up the knowledge base.',
})}
@ -100,8 +104,18 @@ export function KnowledgeBaseCallout({ knowledgeBase }: { knowledgeBase: UseKnow
}
return (
<EuiPanel hasBorder={false} hasShadow={false} borderRadius="none" color={color} paddingSize="s">
{content}
</EuiPanel>
<>
{knowledgeBase.status.value?.ready ? null : <EuiSpacer size="s" />}
<EuiPanel
hasBorder={false}
hasShadow={false}
borderRadius="none"
color={color}
paddingSize={knowledgeBase.status.value?.ready ? 'none' : 's'}
css={{ width: 'max-content' }}
>
{content}
</EuiPanel>
</>
);
}

View file

@ -84,7 +84,7 @@ export function ConnectorSelectorBase(props: ConnectorSelectorBaseProps) {
gutterSize="xs"
responsive={false}
>
<EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSuperSelect
compressed
valueOfSelected={props.selectedConnector}

View file

@ -21,6 +21,7 @@ import {
import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
import { AssistantAvatar } from '../assistant_avatar';
import { TechnicalPreviewBadge } from '../technical_preview_badge';
export interface InsightBaseProps {
title: string;
@ -116,6 +117,9 @@ export function InsightBase({
</EuiPopover>
</EuiFlexItem>
) : null}
<EuiFlexItem>
<TechnicalPreviewBadge />
</EuiFlexItem>
</EuiFlexGroup>
) : null
}

View file

@ -0,0 +1,27 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
export function FailedToLoadResponse() {
return (
<EuiFlexGroup direction="row" gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon type="alert" color="danger" size="s" />
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s" color="danger">
{i18n.translate('xpack.observabilityAiAssistant.failedLoadingResponseText', {
defaultMessage: 'Failed to load response',
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -4,16 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiIcon,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { EuiHorizontalRule, EuiSpacer } from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FailedToLoadResponse } from './failed_to_load_response';
interface Props {
error?: Error;
@ -28,18 +21,7 @@ export function MessagePanel(props: Props) {
{props.error ? (
<>
{props.body ? <EuiSpacer size="xs" /> : null}
<EuiFlexGroup direction="row" gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon type="alert" color="danger" size="s" />
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s" color="danger">
{i18n.translate('xpack.observabilityAiAssistant.messagePanel.failedLoadingText', {
defaultMessage: 'Failed to load response',
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<FailedToLoadResponse />
</>
) : null}
{props.controls ? (

View file

@ -0,0 +1,27 @@
/*
* 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 { EuiBetaBadge } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export function TechnicalPreviewBadge() {
return (
<EuiBetaBadge
label="E"
iconType="beaker"
color="hollow"
title={i18n.translate('xpack.observabilityAiAssistant.experimentalTitle', {
defaultMessage: 'Technical preview',
})}
tooltipContent={i18n.translate('xpack.observabilityAiAssistant.experimentalTooltip', {
defaultMessage:
'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.',
})}
/>
);
}

View file

@ -20,7 +20,8 @@ export function registerElasticsearchFunction({
{
name: 'elasticsearch',
contexts: ['core'],
description: 'Call Elasticsearch APIs on behalf of the user',
description:
'Call Elasticsearch APIs on behalf of the user. Make sure the request body is valid for the API that you are using.',
descriptionForUser: 'Call Elasticsearch APIs on behalf of the user',
parameters: {
type: 'object',

View file

@ -10,6 +10,7 @@ import type { RegisterContextDefinition, RegisterFunctionDefinition } from '../.
import type { ObservabilityAIAssistantPluginStartDependencies } from '../types';
import type { ObservabilityAIAssistantService } from '../types';
import { registerElasticsearchFunction } from './elasticsearch';
import { registerLensFunction } from './lens';
import { registerRecallFunction } from './recall';
import { registerSummarisationFunction } from './summarise';
@ -33,7 +34,7 @@ export async function registerFunctions({
.then((response) => {
const isReady = response.ready;
let description = `You have the ability to call Elasticsearch APIs or create visualisations using Lens.`;
let description = `You have the ability to call Elasticsearch APIs with the "elasticsearch" function or create visualisations using Lens with the "lens" function in the context of this chat.`;
if (isReady) {
description += `You can use the "summarise" functions to store new information you have learned in a knowledge database. Once you have established that you did not know the answer to a question, and the user gave you this information, it's important that you create a summarisation of what you have learned and store it in the knowledge database. When you create this summarisation, make sure you craft it in a way that can be recalled with a semantic search later.
@ -55,11 +56,17 @@ export async function registerFunctions({
WRONG:
User: "What is NASA"
Assistant answers question without querying the knowledge`;
Assistant answers question without querying the knowledge.
BEFORE you use a function, always query the knowledge database for more information about that function. This is important.
Avoid making too many assumptions about user's data. If clarification is needed, query the knowledge base for previous learnings. If you don't find anything, ask the user for clarification, and when successful, store this into the knowledge base.
`;
registerSummarisationFunction({ service, registerFunction });
registerRecallFunction({ service, registerFunction });
registerLensFunction({ service, pluginsStart, registerFunction });
} else {
description += `You do not have a working memory. Don't try to recall information via other functions. If the user expects you to remember the previous conversations, tell them they can set up the knowledge base. A banner is available at the top of the conversation to set this up.`;
description += `You do not have a working memory. Don't try to recall information via the "recall" function. If the user expects you to remember the previous conversations, tell them they can set up the knowledge base. A banner is available at the top of the conversation to set this up.`;
}
registerElasticsearchFunction({ service, registerFunction });

View file

@ -0,0 +1,216 @@
/*
* 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 { EuiLoadingSpinner } from '@elastic/eui';
import { LensAttributesBuilder, XYChart, XYDataLayer } from '@kbn/lens-embeddable-utils';
import React from 'react';
import useAsync from 'react-use/lib/useAsync';
import { FIELD_FORMAT_IDS } from '@kbn/field-formats-plugin/common';
import type { RegisterFunctionDefinition } from '../../common/types';
import { useKibana } from '../hooks/use_kibana';
import type {
ObservabilityAIAssistantPluginStartDependencies,
ObservabilityAIAssistantService,
} from '../types';
export enum SeriesType {
Bar = 'bar',
Line = 'line',
Area = 'area',
BarStacked = 'bar_stacked',
AreaStacked = 'area_stacked',
BarHorizontal = 'bar_horizontal',
BarPercentageStacked = 'bar_percentage_stacked',
AreaPercentageStacked = 'area_percentage_stacked',
BarHorizontalPercentageStacked = 'bar_horizontal_percentage_stacked',
}
function Lens({
indexPattern,
xyDataLayer,
start,
end,
}: {
indexPattern: string;
xyDataLayer: XYDataLayer;
start: string;
end: string;
}) {
const {
services: {
plugins: {
start: { lens, dataViews },
},
},
} = useKibana();
const formulaAsync = useAsync(() => {
return lens.stateHelperApi();
}, [lens]);
const dataViewAsync = useAsync(() => {
return dataViews.create({
id: indexPattern,
title: indexPattern,
timeFieldName: '@timestamp',
});
}, [indexPattern]);
if (!formulaAsync.value || !dataViewAsync.value) {
return <EuiLoadingSpinner />;
}
const attributes = new LensAttributesBuilder({
visualization: new XYChart({
layers: [xyDataLayer],
formulaAPI: formulaAsync.value.formula,
dataView: dataViewAsync.value,
}),
}).build();
return (
<lens.EmbeddableComponent
id={indexPattern}
attributes={attributes}
timeRange={{
from: start,
to: end,
mode: 'relative',
}}
style={{
height: 240,
}}
/>
);
}
export function registerLensFunction({
service,
registerFunction,
pluginsStart,
}: {
service: ObservabilityAIAssistantService;
registerFunction: RegisterFunctionDefinition;
pluginsStart: ObservabilityAIAssistantPluginStartDependencies;
}) {
registerFunction(
{
name: 'lens',
contexts: ['core'],
description:
'Use this function to create custom visualisations, using Lens, that can be saved to dashboards. When using this function, make sure to use the recall function to get more information about how to use it, with how you want to use it.',
descriptionForUser:
'Use this function to create custom visualisations, using Lens, that can be saved to dashboards.',
parameters: {
type: 'object',
properties: {
layers: {
type: 'array',
items: {
type: 'object',
properties: {
label: {
type: 'string',
},
value: {
type: 'string',
description:
'The formula for calculating the value, e.g. sum(my_field_name). Query the knowledge base to get more information about the syntax and available formulas.',
},
filter: {
type: 'string',
description: 'A KQL query that will be used as a filter for the series',
},
format: {
type: 'object',
properties: {
id: {
type: 'string',
description:
'How to format the value. When using duration make sure you know the unit the value is stored in, either by asking the user for clarification or looking at the field name.',
enum: [
FIELD_FORMAT_IDS.BYTES,
FIELD_FORMAT_IDS.CURRENCY,
FIELD_FORMAT_IDS.DURATION,
FIELD_FORMAT_IDS.NUMBER,
FIELD_FORMAT_IDS.PERCENT,
FIELD_FORMAT_IDS.STRING,
],
},
},
required: ['id'],
},
},
required: ['label', 'value', 'format'],
},
},
breakdown: {
type: 'object',
properties: {
field: {
type: 'string',
},
},
required: ['field'],
},
indexPattern: {
type: 'string',
},
seriesType: {
type: 'string',
enum: [
SeriesType.Area,
SeriesType.AreaPercentageStacked,
SeriesType.AreaStacked,
SeriesType.Bar,
SeriesType.BarHorizontal,
SeriesType.BarHorizontalPercentageStacked,
SeriesType.BarPercentageStacked,
SeriesType.BarStacked,
SeriesType.Line,
],
},
start: {
type: 'string',
description: 'The start of the time range, in Elasticsearch datemath',
},
end: {
type: 'string',
description: 'The end of the time range, in Elasticsearch datemath',
},
},
required: ['layers', 'indexPattern', 'start', 'end'],
} as const,
},
async () => {
return {
content: {},
};
},
({ arguments: { layers, indexPattern, breakdown, seriesType, start, end } }) => {
const xyDataLayer = new XYDataLayer({
data: layers.map((layer) => ({
type: 'formula',
value: layer.value,
label: layer.label,
format: layer.format,
filter: {
language: 'kql',
query: layer.filter ?? '',
},
})),
options: {
seriesType,
breakdown: breakdown
? { type: 'top_values', params: { size: 10 }, field: breakdown.field }
: undefined,
},
});
return <Lens indexPattern={indexPattern} xyDataLayer={xyDataLayer} start={start} end={end} />;
}
);
}

View file

@ -19,8 +19,11 @@ export type AbortableAsyncState<T> = (T extends Promise<infer TReturn>
export function useAbortableAsync<T>(
fn: ({}: { signal: AbortSignal }) => T,
deps: any[]
deps: any[],
options?: { clearValueOnNext?: boolean }
): AbortableAsyncState<T> {
const clearValueOnNext = options?.clearValueOnNext;
const controllerRef = useRef(new AbortController());
const [refreshId, setRefreshId] = useState(0);
@ -35,6 +38,10 @@ export function useAbortableAsync<T>(
const controller = new AbortController();
controllerRef.current = controller;
if (clearValueOnNext) {
setValue(undefined);
}
try {
const response = fn({ signal: controller.signal });
if (isPromise(response)) {
@ -61,7 +68,7 @@ export function useAbortableAsync<T>(
controller.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps.concat(refreshId));
}, deps.concat(refreshId, clearValueOnNext));
return useMemo<AbortableAsyncState<T>>(() => {
return {

View file

@ -7,7 +7,7 @@
import { i18n } from '@kbn/i18n';
import { merge, omit } from 'lodash';
import { Dispatch, SetStateAction, useState } from 'react';
import type { Conversation, Message } from '../../common';
import { type Conversation, type Message } from '../../common';
import type { ConversationCreateRequest } from '../../common/types';
import { ObservabilityAIAssistantChatService } from '../types';
import { useAbortableAsync, type AbortableAsyncState } from './use_abortable_async';
@ -18,14 +18,20 @@ import { createNewConversation } from './use_timeline';
export function useConversation({
conversationId,
chatService,
connectorId,
}: {
conversationId?: string;
chatService?: ObservabilityAIAssistantChatService;
connectorId: string | undefined;
}): {
conversation: AbortableAsyncState<ConversationCreateRequest | Conversation | undefined>;
displayedMessages: Message[];
setDisplayedMessages: Dispatch<SetStateAction<Message[]>>;
save: (messages: Message[]) => Promise<Conversation>;
save: (messages: Message[], handleRefreshConversations?: () => void) => Promise<Conversation>;
saveTitle: (
title: string,
handleRefreshConversations?: () => void
) => Promise<Conversation | void>;
} {
const service = useObservabilityAIAssistant();
@ -67,11 +73,12 @@ export function useConversation({
conversation,
displayedMessages,
setDisplayedMessages,
save: (messages: Message[]) => {
save: (messages: Message[], handleRefreshConversations?: () => void) => {
const conversationObject = conversation.value!;
return conversationId
? service
.callApi(`POST /internal/observability_ai_assistant/conversation/{conversationId}`, {
.callApi(`PUT /internal/observability_ai_assistant/conversation/{conversationId}`, {
signal: null,
params: {
path: {
@ -100,7 +107,7 @@ export function useConversation({
throw err;
})
: service
.callApi(`PUT /internal/observability_ai_assistant/conversation`, {
.callApi(`POST /internal/observability_ai_assistant/conversation`, {
signal: null,
params: {
body: {
@ -108,6 +115,30 @@ export function useConversation({
},
},
})
.then((nextConversation) => {
if (connectorId) {
service
.callApi(
`PUT /internal/observability_ai_assistant/conversation/{conversationId}/auto_title`,
{
signal: null,
params: {
path: {
conversationId: nextConversation.conversation.id,
},
body: {
connectorId,
},
},
}
)
.then(() => {
handleRefreshConversations?.();
return conversation.refresh();
});
}
return nextConversation;
})
.catch((err) => {
notifications.toasts.addError(err, {
title: i18n.translate('xpack.observabilityAiAssistant.errorCreatingConversation', {
@ -117,5 +148,25 @@ export function useConversation({
throw err;
});
},
saveTitle: (title: string, handleRefreshConversations?: () => void) => {
if (conversationId) {
return service
.callApi('PUT /internal/observability_ai_assistant/conversation/{conversationId}/title', {
signal: null,
params: {
path: {
conversationId,
},
body: {
title,
},
},
})
.then(() => {
handleRefreshConversations?.();
});
}
return Promise.resolve();
},
};
}

View file

@ -4,7 +4,6 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { useMemo, useState } from 'react';
import { AbortableAsyncState, useAbortableAsync } from './use_abortable_async';
@ -52,6 +51,17 @@ export function useKnowledgeBase(): UseKnowledgeBaseResult {
})
.then(() => {
status.refresh();
toasts.addSuccess({
title: i18n.translate('xpack.observabilityAiAssistant.knowledgeBaseReadyTitle', {
defaultMessage: 'Knowledge base is ready',
}),
text: i18n.translate(
'xpack.observabilityAiAssistant.knowledgeBaseReadyContentReload',
{
defaultMessage: 'A page reload is needed to be able to use it.',
}
),
});
})
.catch((error) => {
setInstallError(error);

View file

@ -21,6 +21,18 @@ type HookProps = Parameters<typeof useTimeline>[0];
const WAIT_OPTIONS = { timeout: 1500 };
jest.mock('./use_kibana', () => ({
useKibana: () => ({
services: {
notifications: {
toasts: {
addError: jest.fn(),
},
},
},
}),
}));
describe('useTimeline', () => {
let hookResult: RenderHookResult<HookProps, UseTimelineResult, Renderer<HookProps>>;
@ -120,7 +132,7 @@ describe('useTimeline', () => {
canCopy: true,
canEdit: false,
canRegenerate: true,
canGiveFeedback: true,
canGiveFeedback: false,
},
role: MessageRole.Assistant,
content: 'Goodbye',
@ -242,7 +254,7 @@ describe('useTimeline', () => {
loading: false,
actions: {
canRegenerate: true,
canGiveFeedback: true,
canGiveFeedback: false,
},
});
});
@ -364,10 +376,13 @@ describe('useTimeline', () => {
canCopy: true,
canEdit: false,
canRegenerate: true,
canGiveFeedback: true,
canGiveFeedback: false,
},
content: 'Regenerated',
currentUser: undefined,
function_call: undefined,
id: expect.any(String),
element: undefined,
loading: false,
title: '',
role: MessageRole.Assistant,

View file

@ -10,6 +10,7 @@ import type { AuthenticatedUser } from '@kbn/security-plugin/common';
import { last } from 'lodash';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { Subscription } from 'rxjs';
import { i18n } from '@kbn/i18n';
import {
ContextDefinition,
MessageRole,
@ -23,6 +24,7 @@ import { getAssistantSetupMessage } from '../service/get_assistant_setup_message
import type { ObservabilityAIAssistantChatService, PendingMessage } from '../types';
import { getTimelineItemsfromConversation } from '../utils/get_timeline_items_from_conversation';
import type { UseGenAIConnectorsResult } from './use_genai_connectors';
import { useKibana } from './use_kibana';
export function createNewConversation({
contexts,
@ -66,6 +68,10 @@ export function useTimeline({
const hasConnector = !!connectorId;
const {
services: { notifications },
} = useKibana();
const conversationItems = useMemo(() => {
const items = getTimelineItemsfromConversation({
messages,
@ -116,6 +122,13 @@ export function useTimeline({
},
error: reject,
complete: () => {
if (pendingMessageLocal?.error) {
notifications.toasts.addError(pendingMessageLocal?.error, {
title: i18n.translate('xpack.observabilityAiAssistant.failedToLoadResponse', {
defaultMessage: 'Failed to load response from the AI Assistant',
}),
});
}
resolve(pendingMessageLocal!);
},
});
@ -191,7 +204,7 @@ export function useTimeline({
const items = useMemo(() => {
if (pendingMessage) {
return conversationItems.concat({
const nextItems = conversationItems.concat({
id: '',
actions: {
canCopy: true,
@ -211,6 +224,8 @@ export function useTimeline({
role: pendingMessage.message.role,
title: '',
});
return nextItems;
}
return conversationItems;

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect, useRef, useState } from 'react';
export function useUnmountAndRemountWhenPropChanges(currentProp: string) {
const prevPropRef = useRef(currentProp);
const [shouldRender, setShouldRender] = useState(true);
useEffect(() => {
if (prevPropRef.current !== currentProp) {
setShouldRender(false);
}
}, [prevPropRef, currentProp]);
useEffect(() => {
if (!shouldRender) {
setShouldRender(true);
}
}, [shouldRender]);
useEffect(() => {
prevPropRef.current = currentProp;
}, [currentProp]);
return shouldRender;
}

View file

@ -17,7 +17,6 @@ import { i18n } from '@kbn/i18n';
import type { Logger } from '@kbn/logging';
import React from 'react';
import ReactDOM from 'react-dom';
import { registerFunctions } from './functions';
import { createService } from './service/create_service';
import type {
ConfigSchema,
@ -101,8 +100,10 @@ export class ObservabilityAIAssistantPlugin
enabled: coreStart.application.capabilities.observabilityAIAssistant.show === true,
}));
service.register(({ signal, registerContext, registerFunction }) => {
return registerFunctions({
service.register(async ({ signal, registerContext, registerFunction }) => {
const mod = await import('./functions');
return mod.registerFunctions({
service,
signal,
pluginsStart,

View file

@ -4,10 +4,11 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo, useState } from 'react';
import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer } from '@elastic/eui';
import { css } from '@emotion/css';
import { i18n } from '@kbn/i18n';
import React, { useMemo, useState } from 'react';
import { euiThemeVars } from '@kbn/ui-theme';
import { ChatBody } from '../../components/chat/chat_body';
import { ConversationList } from '../../components/chat/conversation_list';
import { ObservabilityAIAssistantChatServiceProvider } from '../../context/observability_ai_assistant_chat_service_provider';
@ -21,8 +22,8 @@ import { useKnowledgeBase } from '../../hooks/use_knowledge_base';
import { useObservabilityAIAssistant } from '../../hooks/use_observability_ai_assistant';
import { useObservabilityAIAssistantParams } from '../../hooks/use_observability_ai_assistant_params';
import { useObservabilityAIAssistantRouter } from '../../hooks/use_observability_ai_assistant_router';
import { EMPTY_CONVERSATION_TITLE } from '../../i18n';
import { getConnectorsManagementHref } from '../../utils/get_connectors_management_href';
import { EMPTY_CONVERSATION_TITLE } from '../../i18n';
const containerClassName = css`
max-width: 100%;
@ -32,15 +33,21 @@ const chatBodyContainerClassNameWithError = css`
align-self: center;
`;
const conversationListContainerName = css`
min-width: 250px;
width: 250px;
border-right: solid 1px ${euiThemeVars.euiColorLightShade};
`;
export function ConversationView() {
const connectors = useGenAIConnectors();
const knowledgeBase = useKnowledgeBase();
const currentUser = useCurrentUser();
const service = useObservabilityAIAssistant();
const connectors = useGenAIConnectors();
const knowledgeBase = useKnowledgeBase();
const observabilityAIAssistantRouter = useObservabilityAIAssistantRouter();
const { path } = useObservabilityAIAssistantParams('/conversations/*');
@ -72,10 +79,12 @@ export function ConversationView() {
const conversationId = 'conversationId' in path ? path.conversationId : undefined;
const { conversation, displayedMessages, setDisplayedMessages, save } = useConversation({
conversationId,
chatService: chatService.value,
});
const { conversation, displayedMessages, setDisplayedMessages, save, saveTitle } =
useConversation({
conversationId,
chatService: chatService.value,
connectorId: connectors.selectedConnector,
});
const conversations = useAbortableAsync(
({ signal }) => {
@ -111,11 +120,15 @@ export function ConversationView() {
);
}
function handleRefreshConversations() {
conversations.refresh();
}
return (
<>
{confirmDeleteElement}
<EuiFlexGroup direction="row" className={containerClassName}>
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="row" className={containerClassName} gutterSize="none">
<EuiFlexItem grow={false} className={conversationListContainerName}>
<ConversationList
selected={conversationId ?? ''}
loading={conversations.loading || isUpdatingList}
@ -186,7 +199,7 @@ export function ConversationView() {
});
}}
/>
<EuiSpacer size="m" />
<EuiSpacer size="s" />
</EuiFlexItem>
<EuiFlexItem
grow
@ -210,7 +223,7 @@ export function ConversationView() {
})}
</EuiCallOut>
) : null}
{chatService.loading || conversation.loading ? (
{!chatService.value ? (
<EuiFlexGroup direction="column" alignItems="center" gutterSize="l">
<EuiFlexItem grow={false}>
<EuiSpacer size="xl" />
@ -218,32 +231,36 @@ export function ConversationView() {
</EuiFlexItem>
</EuiFlexGroup>
) : null}
{!conversation.error && conversation.value && chatService.value ? (
{conversation.value && chatService.value && !conversation.error ? (
<ObservabilityAIAssistantChatServiceProvider value={chatService.value}>
<ChatBody
loading={conversation.loading}
currentUser={currentUser}
connectors={connectors}
knowledgeBase={knowledgeBase}
title={conversation.value.conversation.title}
connectorsManagementHref={getConnectorsManagementHref(http)}
conversationId={conversationId}
knowledgeBase={knowledgeBase}
messages={displayedMessages}
title={conversation.value.conversation.title}
onChatUpdate={(messages) => {
setDisplayedMessages(messages);
}}
onChatComplete={(messages) => {
save(messages)
save(messages, handleRefreshConversations)
.then((nextConversation) => {
conversations.refresh();
if (!conversationId) {
if (!conversationId && nextConversation?.conversation?.id) {
navigateToConversation(nextConversation.conversation.id);
}
})
.catch(() => {});
.catch((e) => {});
}}
onChatUpdate={(messages) => {
setDisplayedMessages(messages);
onSaveTitle={(title) => {
saveTitle(title, handleRefreshConversations);
}}
/>
</ObservabilityAIAssistantChatServiceProvider>
) : null}
<EuiSpacer size="m" />
</EuiFlexItem>
</EuiFlexGroup>
</>

View file

@ -191,5 +191,32 @@ describe('createChatService', () => {
aborted: true,
});
});
it('propagates an error if finish_reason == length', async () => {
respondWithChunks({
chunks: [
'data: {"id":"chatcmpl-7mna2SFmEAqdCTX5arxueoErLjmt1","object":"chat.completion.chunk","created":1691864686,"model":"gpt-4-0613","choices":[{"index":0,"delta":{"content":"roaming"},"finish_reason":null}]}\n',
'data: {"id":"chatcmpl-7mna2SFmEAqdCTX5arxueoErLjmt1","object":"chat.completion.chunk","created":1691864686,"model":"gpt-4-0613","choices":[{"index":0,"delta":{"content":" deer"},"finish_reason":null}]}\n',
'data: {"id":"chatcmpl-7mna2SFmEAqdCTX5arxueoErLjmt1","object":"chat.completion.chunk","created":1691864686,"model":"gpt-4-0613","choices":[{"index":0,"delta":{},"finish_reason":"length"}]}\n',
],
});
const response$ = chat();
const value = await lastValueFrom(response$);
expect(value).toEqual({
aborted: false,
error: expect.any(Error),
message: {
role: 'assistant',
content: 'roaming deer',
function_call: {
name: '',
arguments: '',
trigger: 'assistant',
},
},
});
});
});
});

View file

@ -18,6 +18,7 @@ import {
shareReplay,
finalize,
delay,
tap,
} from 'rxjs';
import { HttpResponse } from '@kbn/core/public';
import { AbortError } from '@kbn/kibana-utils-plugin/common';
@ -38,6 +39,12 @@ import type {
} from '../types';
import { readableStreamReaderIntoObservable } from '../utils/readable_stream_reader_into_observable';
class TokenLimitReachedError extends Error {
constructor() {
super(`Token limit reached`);
}
}
export async function createChatService({
signal: setupAbortSignal,
registrations,
@ -166,6 +173,11 @@ export async function createChatService({
rxJsFilter((line) => !!line && line !== '[DONE]'),
map((line) => JSON.parse(line) as CreateChatCompletionResponseChunk),
rxJsFilter((line) => line.object === 'chat.completion.chunk'),
tap((choice) => {
if (choice.choices[0].finish_reason === 'length') {
throw new TokenLimitReachedError();
}
}),
scan(
(acc, { choices }) => {
acc.message.content += choices[0].delta.content ?? '';

View file

@ -16,7 +16,7 @@ export function getAssistantSetupMessage({ contexts }: { contexts: ContextDefini
role: MessageRole.System as const,
content: [
dedent(
`You are a helpful assistant for Elastic Observability. Your goal is to help the Elastic Observability users to quickly assess what is happening in their observed systems. You can help them visualise and analyze data, investigate their systems, perform root cause analysis or identify optimisation opportunities`
`You are a helpful assistant for Elastic Observability. Your goal is to help the Elastic Observability users to quickly assess what is happening in their observed systems. You can help them visualise and analyze data, investigate their systems, perform root cause analysis or identify optimisation opportunities.`
),
]
.concat(contexts.map((context) => context.description))

View file

@ -24,6 +24,11 @@ import type {
CreateChatCompletionResponseChoicesInner,
} from 'openai';
import type { Observable } from 'rxjs';
import type { LensPublicSetup, LensPublicStart } from '@kbn/lens-plugin/public';
import type {
DataViewsPublicPluginSetup,
DataViewsPublicPluginStart,
} from '@kbn/data-views-plugin/public';
import type {
ContextDefinition,
FunctionDefinition,
@ -89,12 +94,16 @@ export interface ObservabilityAIAssistantPluginSetupDependencies {
security: SecurityPluginSetup;
features: FeaturesPluginSetup;
observabilityShared: ObservabilitySharedPluginSetup;
lens: LensPublicSetup;
dataViews: DataViewsPublicPluginSetup;
}
export interface ObservabilityAIAssistantPluginStartDependencies {
security: SecurityPluginStart;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
observabilityShared: ObservabilitySharedPluginStart;
features: FeaturesPluginStart;
lens: LensPublicStart;
dataViews: DataViewsPublicPluginStart;
}
export interface ConfigSchema {}

View file

@ -30,7 +30,10 @@ function convertMessageToMarkdownCodeBlock(message: Message['message']) {
args,
};
} else {
const content = message.content ? JSON.parse(message.content) : undefined;
const content =
message.role !== MessageRole.Assistant && message.content
? JSON.parse(message.content)
: message.content;
const data = message.data ? JSON.parse(message.data) : undefined;
value = omitBy(
{
@ -113,8 +116,14 @@ export function getTimelineItemsfromConversation({
// User executed a function:
if (message.message.name && prevFunctionCall) {
const parsedContent = JSON.parse(message.message.content ?? 'null');
const isError = !!(parsedContent && 'error' in parsedContent);
let parsedContent;
try {
parsedContent = JSON.parse(message.message.content ?? 'null');
} catch (error) {
parsedContent = message.message.content;
}
const isError = typeof parsedContent === 'object' && 'error' in parsedContent;
title = !isError ? (
<FormattedMessage
@ -177,7 +186,7 @@ export function getTimelineItemsfromConversation({
case MessageRole.Assistant:
actions.canRegenerate = hasConnector;
actions.canCopy = true;
actions.canGiveFeedback = true;
actions.canGiveFeedback = false;
display.hide = false;
// is a function suggestion by the assistant

View file

@ -45,12 +45,6 @@ export class ObservabilityAIAssistantPlugin
constructor(context: PluginInitializerContext<ObservabilityAIAssistantConfig>) {
this.logger = context.logger.get();
}
public start(
core: CoreStart,
plugins: ObservabilityAIAssistantPluginStartDependencies
): ObservabilityAIAssistantPluginStart {
return {};
}
public setup(
core: CoreSetup<
ObservabilityAIAssistantPluginStartDependencies,
@ -91,21 +85,12 @@ export class ObservabilityAIAssistantPlugin
ui: ['show'],
},
read: {
app: [OBSERVABILITY_AI_ASSISTANT_FEATURE_ID, 'kibana'],
api: [OBSERVABILITY_AI_ASSISTANT_FEATURE_ID, 'ai_assistant'],
catalogue: [OBSERVABILITY_AI_ASSISTANT_FEATURE_ID],
disabled: true,
savedObject: {
all: [],
read: [
ACTION_SAVED_OBJECT_TYPE,
ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE,
CONNECTOR_TOKEN_SAVED_OBJECT_TYPE,
],
read: [],
},
management: {
insightsAndAlerting: ['triggersActionsConnectors'],
},
ui: ['show'],
ui: [],
},
},
});
@ -126,8 +111,11 @@ export class ObservabilityAIAssistantPlugin
const service = new ObservabilityAIAssistantService({
logger: this.logger.get('service'),
core,
taskManager: plugins.taskManager,
});
// addLensDocsToKb(service);
registerServerRoutes({
core,
logger: this.logger,
@ -139,4 +127,11 @@ export class ObservabilityAIAssistantPlugin
return {};
}
public start(
core: CoreStart,
plugins: ObservabilityAIAssistantPluginStartDependencies
): ObservabilityAIAssistantPluginStart {
return {};
}
}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import * as t from 'io-ts';
import type { IncomingMessage } from 'http';
import { IncomingMessage } from 'http';
import { notImplemented } from '@hapi/boom';
import { createObservabilityAIAssistantServerRoute } from '../create_observability_ai_assistant_server_route';
import { messageRt } from '../runtime_types';

View file

@ -58,7 +58,7 @@ const findConversationsRoute = createObservabilityAIAssistantServerRoute({
});
const createConversationRoute = createObservabilityAIAssistantServerRoute({
endpoint: 'PUT /internal/observability_ai_assistant/conversation',
endpoint: 'POST /internal/observability_ai_assistant/conversation',
params: t.type({
body: t.type({
conversation: conversationCreateRt,
@ -81,7 +81,7 @@ const createConversationRoute = createObservabilityAIAssistantServerRoute({
});
const updateConversationRoute = createObservabilityAIAssistantServerRoute({
endpoint: 'POST /internal/observability_ai_assistant/conversation/{conversationId}',
endpoint: 'PUT /internal/observability_ai_assistant/conversation/{conversationId}',
params: t.type({
path: t.type({
conversationId: t.string,
@ -108,6 +108,68 @@ const updateConversationRoute = createObservabilityAIAssistantServerRoute({
},
});
const updateConversationTitleBasedOnMessages = createObservabilityAIAssistantServerRoute({
endpoint: 'PUT /internal/observability_ai_assistant/conversation/{conversationId}/auto_title',
params: t.type({
path: t.type({
conversationId: t.string,
}),
body: t.type({
connectorId: t.string,
}),
}),
options: {
tags: ['access:ai_assistant'],
},
handler: async (resources): Promise<Conversation> => {
const { service, request, params } = resources;
const client = await service.getClient({ request });
if (!client) {
throw notImplemented();
}
const conversation = await client.autoTitle({
conversationId: params.path.conversationId,
connectorId: params.body.connectorId,
});
return Promise.resolve(conversation);
},
});
const updateConversationTitle = createObservabilityAIAssistantServerRoute({
endpoint: 'PUT /internal/observability_ai_assistant/conversation/{conversationId}/title',
params: t.type({
path: t.type({
conversationId: t.string,
}),
body: t.type({
title: t.string,
}),
}),
options: {
tags: ['access:ai_assistant'],
},
handler: async (resources): Promise<Conversation> => {
const { service, request, params } = resources;
const client = await service.getClient({ request });
if (!client) {
throw notImplemented();
}
const conversation = await client.setTitle({
conversationId: params.path.conversationId,
title: params.body.title,
});
return Promise.resolve(conversation);
},
});
const deleteConversationRoute = createObservabilityAIAssistantServerRoute({
endpoint: 'DELETE /internal/observability_ai_assistant/conversation/{conversationId}',
params: t.type({
@ -136,5 +198,7 @@ export const conversationRoutes = {
...findConversationsRoute,
...createConversationRoute,
...updateConversationRoute,
...updateConversationTitleBasedOnMessages,
...updateConversationTitle,
...deleteConversationRoute,
};

View file

@ -140,7 +140,7 @@ const setupKnowledgeBaseRoute = createObservabilityAIAssistantServerRoute({
idleSocket: 20 * 60 * 1000, // 20 minutes
},
},
handler: async (resources): Promise<void> => {
handler: async (resources): Promise<{}> => {
const client = await resources.service.getClient({ request: resources.request });
if (!client) {
@ -148,6 +148,8 @@ const setupKnowledgeBaseRoute = createObservabilityAIAssistantServerRoute({
}
await client.setupKnowledgeBase();
return {};
},
});

View file

@ -11,13 +11,13 @@ import type {
ObservabilityAIAssistantPluginSetupDependencies,
ObservabilityAIAssistantPluginStartDependencies,
} from '../types';
import type { IObservabilityAIAssistantService } from '../service/types';
import type { ObservabilityAIAssistantService } from '../service';
export interface ObservabilityAIAssistantRouteHandlerResources {
request: KibanaRequest;
context: RequestHandlerContext;
logger: Logger;
service: IObservabilityAIAssistantService;
service: ObservabilityAIAssistantService;
plugins: {
[key in keyof ObservabilityAIAssistantPluginSetupDependencies]: {
setup: Required<ObservabilityAIAssistantPluginSetupDependencies>[key];

View file

@ -4,9 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { errors } from '@elastic/elasticsearch';
import type { QueryDslTextExpansionQuery, SearchHit } from '@elastic/elasticsearch/lib/api/types';
import { internal, notFound, serverUnavailable } from '@hapi/boom';
import type { SearchHit } from '@elastic/elasticsearch/lib/api/types';
import { internal, notFound } from '@hapi/boom';
import type { ActionsClient } from '@kbn/actions-plugin/server/actions_client';
import type { ElasticsearchClient } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
@ -17,30 +16,23 @@ import type {
ChatCompletionFunctions,
ChatCompletionRequestMessage,
CreateChatCompletionRequest,
CreateChatCompletionResponse,
} from 'openai';
import pRetry from 'p-retry';
import { v4 } from 'uuid';
import {
type KnowledgeBaseEntry,
type CompatibleJSONSchema,
MessageRole,
type Conversation,
type ConversationCreateRequest,
type ConversationUpdateRequest,
type FunctionDefinition,
type KnowledgeBaseEntry,
type Message,
} from '../../../common/types';
import type {
IObservabilityAIAssistantClient,
ObservabilityAIAssistantResourceNames,
} from '../types';
import type { KnowledgeBaseService } from '../kb_service';
import type { ObservabilityAIAssistantResourceNames } from '../types';
import { getAccessQuery } from '../util/get_access_query';
const ELSER_MODEL_ID = '.elser_model_1';
function throwKnowledgeBaseNotReady(body: any) {
throw serverUnavailable(`Knowledge base is not ready yet`, body);
}
export class ObservabilityAIAssistantClient implements IObservabilityAIAssistantClient {
export class ObservabilityAIAssistantClient {
constructor(
private readonly dependencies: {
actionsClient: PublicMethodsOf<ActionsClient>;
@ -52,42 +44,10 @@ export class ObservabilityAIAssistantClient implements IObservabilityAIAssistant
id?: string;
name: string;
};
knowledgeBaseService: KnowledgeBaseService;
}
) {}
private getAccessQuery() {
return [
{
bool: {
filter: [
{
bool: {
should: [
{
term: {
'user.name': this.dependencies.user.name,
},
},
{
term: {
public: true,
},
},
],
minimum_should_match: 1,
},
},
{
term: {
namespace: this.dependencies.namespace,
},
},
],
},
},
];
}
private getConversationWithMetaFields = async (
conversationId: string
): Promise<SearchHit<Conversation> | undefined> => {
@ -95,7 +55,13 @@ export class ObservabilityAIAssistantClient implements IObservabilityAIAssistant
index: this.dependencies.resources.aliases.conversations,
query: {
bool: {
filter: [...this.getAccessQuery(), { term: { 'conversation.id': conversationId } }],
filter: [
...getAccessQuery({
user: this.dependencies.user,
namespace: this.dependencies.namespace,
}),
{ term: { 'conversation.id': conversationId } },
],
},
},
size: 1,
@ -138,15 +104,17 @@ export class ObservabilityAIAssistantClient implements IObservabilityAIAssistant
});
};
chat = async ({
chat = async <TStream extends boolean | undefined = true>({
messages,
connectorId,
functions,
stream = true,
}: {
messages: Message[];
connectorId: string;
functions: Array<Pick<FunctionDefinition['options'], 'name' | 'description' | 'parameters'>>;
}): Promise<IncomingMessage> => {
functions?: Array<{ name: string; description: string; parameters: CompatibleJSONSchema }>;
stream?: TStream;
}): Promise<TStream extends false ? CreateChatCompletionResponse : IncomingMessage> => {
const messagesForOpenAI: ChatCompletionRequestMessage[] = compact(
messages
.filter((message) => message.message.content || message.message.function_call?.name)
@ -165,7 +133,7 @@ export class ObservabilityAIAssistantClient implements IObservabilityAIAssistant
})
);
const functionsForOpenAI: ChatCompletionFunctions[] = functions;
const functionsForOpenAI: ChatCompletionFunctions[] | undefined = functions;
const request: Omit<CreateChatCompletionRequest, 'model'> & { model?: string } = {
messages: messagesForOpenAI,
@ -177,10 +145,10 @@ export class ObservabilityAIAssistantClient implements IObservabilityAIAssistant
const executeResult = await this.dependencies.actionsClient.execute({
actionId: connectorId,
params: {
subAction: 'stream',
subAction: stream ? 'stream' : 'run',
subActionParams: {
body: JSON.stringify(request),
stream: true,
...(stream ? { stream: true } : {}),
},
},
});
@ -189,7 +157,7 @@ export class ObservabilityAIAssistantClient implements IObservabilityAIAssistant
throw internal(`${executeResult?.message} - ${executeResult?.serviceMessage}`);
}
return executeResult.data as IncomingMessage;
return executeResult.data as any;
};
find = async (options?: { query?: string }): Promise<{ conversations: Conversation[] }> => {
@ -198,7 +166,12 @@ export class ObservabilityAIAssistantClient implements IObservabilityAIAssistant
allow_no_indices: true,
query: {
bool: {
filter: [...this.getAccessQuery()],
filter: [
...getAccessQuery({
user: this.dependencies.user,
namespace: this.dependencies.namespace,
}),
],
},
},
sort: {
@ -235,6 +208,88 @@ export class ObservabilityAIAssistantClient implements IObservabilityAIAssistant
return updatedConversation;
};
autoTitle = async ({
conversationId,
connectorId,
}: {
conversationId: string;
connectorId: string;
}) => {
const document = await this.getConversationWithMetaFields(conversationId);
if (!document) {
throw notFound();
}
const conversation = await this.get(conversationId);
if (!conversation) {
throw notFound();
}
const response = await this.chat({
messages: [
{
'@timestamp': new Date().toISOString(),
message: {
role: MessageRole.Assistant,
content: conversation.messages.slice(1).reduce((acc, curr) => {
return `${acc} ${curr.message.role}: ${curr.message.content}`;
}, 'You are a helpful assistant for Elastic Observability. Assume the following message is the start of a conversation between you and a user; give this conversation a title based on this content: '),
},
},
],
connectorId,
stream: false,
});
if ('object' in response && response.object === 'chat.completion') {
const title =
response.choices[0].message?.content?.slice(1, -1) ||
`Conversation on ${conversation['@timestamp']}`;
const updatedConversation: Conversation = merge(
{},
conversation,
{ conversation: { title } },
this.getConversationUpdateValues(new Date().toISOString())
);
await this.setTitle({ conversationId, title });
return updatedConversation;
}
return conversation;
};
setTitle = async ({ conversationId, title }: { conversationId: string; title: string }) => {
const document = await this.getConversationWithMetaFields(conversationId);
if (!document) {
throw notFound();
}
const conversation = await this.get(conversationId);
if (!conversation) {
throw notFound();
}
const updatedConversation: Conversation = merge(
{},
conversation,
{ conversation: { title } },
this.getConversationUpdateValues(new Date().toISOString())
);
await this.dependencies.esClient.update({
id: document._id,
index: document._index,
doc: { conversation: { title } },
refresh: 'wait_for',
});
return updatedConversation;
};
create = async (conversation: ConversationCreateRequest): Promise<Conversation> => {
const now = new Date().toISOString();
@ -258,157 +313,30 @@ export class ObservabilityAIAssistantClient implements IObservabilityAIAssistant
};
recall = async (query: string): Promise<{ entries: KnowledgeBaseEntry[] }> => {
try {
const response = await this.dependencies.esClient.search<KnowledgeBaseEntry>({
index: this.dependencies.resources.aliases.kb,
query: {
bool: {
should: [
{
text_expansion: {
'ml.tokens': {
model_text: query,
model_id: '.elser_model_1',
},
} as unknown as QueryDslTextExpansionQuery,
},
],
filter: [...this.getAccessQuery()],
},
},
_source: {
excludes: ['ml.tokens'],
},
});
return { entries: response.hits.hits.map((hit) => hit._source!) };
} catch (error) {
if (
(error instanceof errors.ResponseError &&
error.body.error.type === 'resource_not_found_exception') ||
error.body.error.type === 'status_exception'
) {
throwKnowledgeBaseNotReady(error.body);
}
throw error;
}
return this.dependencies.knowledgeBaseService.recall({
namespace: this.dependencies.namespace,
user: this.dependencies.user,
query,
});
};
summarise = async ({
entry: { id, ...document },
entry,
}: {
entry: Omit<KnowledgeBaseEntry, '@timestamp'>;
}): Promise<void> => {
try {
await this.dependencies.esClient.index({
index: this.dependencies.resources.aliases.kb,
id,
document: {
'@timestamp': new Date().toISOString(),
...document,
user: this.dependencies.user,
namespace: this.dependencies.namespace,
},
pipeline: this.dependencies.resources.pipelines.kb,
});
} catch (error) {
if (error instanceof errors.ResponseError && error.body.error.type === 'status_exception') {
throwKnowledgeBaseNotReady(error.body);
}
throw error;
}
return this.dependencies.knowledgeBaseService.summarise({
namespace: this.dependencies.namespace,
user: this.dependencies.user,
entry,
});
};
getKnowledgeBaseStatus = async () => {
try {
const modelStats = await this.dependencies.esClient.ml.getTrainedModelsStats({
model_id: ELSER_MODEL_ID,
});
const elserModelStats = modelStats.trained_model_stats[0];
const deploymentState = elserModelStats.deployment_stats?.state;
const allocationState = elserModelStats.deployment_stats?.allocation_status.state;
return {
ready: deploymentState === 'started' && allocationState === 'fully_allocated',
deployment_state: deploymentState,
allocation_state: allocationState,
};
} catch (error) {
return {
error: error instanceof errors.ResponseError ? error.body.error : String(error),
ready: false,
};
}
getKnowledgeBaseStatus = () => {
return this.dependencies.knowledgeBaseService.status();
};
setupKnowledgeBase = async () => {
// if this fails, it's fine to propagate the error to the user
const installModel = async () => {
this.dependencies.logger.info('Installing ELSER model');
await this.dependencies.esClient.ml.putTrainedModel(
{
model_id: ELSER_MODEL_ID,
input: {
field_names: ['text_field'],
},
// @ts-expect-error
wait_for_completion: true,
},
{ requestTimeout: '20m' }
);
this.dependencies.logger.info('Finished installing ELSER model');
};
try {
const getResponse = await this.dependencies.esClient.ml.getTrainedModels({
model_id: ELSER_MODEL_ID,
include: 'definition_status',
});
if (!getResponse.trained_model_configs[0]?.fully_defined) {
this.dependencies.logger.info('Model is not fully defined');
await installModel();
}
} catch (error) {
if (
error instanceof errors.ResponseError &&
error.body.error.type === 'resource_not_found_exception'
) {
await installModel();
} else {
throw error;
}
}
try {
await this.dependencies.esClient.ml.startTrainedModelDeployment({
model_id: ELSER_MODEL_ID,
wait_for: 'fully_allocated',
});
} catch (error) {
if (error instanceof errors.ResponseError && error.body.error.type === 'status_exception') {
await pRetry(
async () => {
const response = await this.dependencies.esClient.ml.getTrainedModelsStats({
model_id: ELSER_MODEL_ID,
});
if (
response.trained_model_stats[0]?.deployment_stats?.allocation_status.state ===
'fully_allocated'
) {
return Promise.resolve();
}
this.dependencies.logger.debug('Model is not allocated yet');
return Promise.reject(new Error('Not Ready'));
},
{ factor: 1, minTimeout: 10000, maxRetryTime: 20 * 60 * 1000 }
);
} else {
throw error;
}
}
setupKnowledgeBase = () => {
return this.dependencies.knowledgeBaseService.setup();
};
}

View file

@ -11,23 +11,28 @@ import { createConcreteWriteIndex } from '@kbn/alerting-plugin/server';
import type { CoreSetup, CoreStart, KibanaRequest, Logger } from '@kbn/core/server';
import type { SecurityPluginStart } from '@kbn/security-plugin/server';
import { getSpaceIdFromPath } from '@kbn/spaces-plugin/common';
import type { TaskManagerSetupContract } from '@kbn/task-manager-plugin/server';
import { once } from 'lodash';
import { KnowledgeBaseEntry } from '../../common/types';
import type { ObservabilityAIAssistantPluginStartDependencies } from '../types';
import { ObservabilityAIAssistantClient } from './client';
import { conversationComponentTemplate } from './conversation_component_template';
import { kbComponentTemplate } from './kb_component_template';
import type {
IObservabilityAIAssistantClient,
IObservabilityAIAssistantService,
ObservabilityAIAssistantResourceNames,
} from './types';
import { KnowledgeBaseService } from './kb_service';
import type { ObservabilityAIAssistantResourceNames } from './types';
function getResourceName(resource: string) {
return `.kibana-observability-ai-assistant-${resource}`;
}
export class ObservabilityAIAssistantService implements IObservabilityAIAssistantService {
private readonly core: CoreSetup;
export const INDEX_QUEUED_DOCUMENTS_TASK_ID = 'observabilityAIAssistant:indexQueuedDocumentsTask';
export const INDEX_QUEUED_DOCUMENTS_TASK_TYPE = INDEX_QUEUED_DOCUMENTS_TASK_ID + 'Type';
export class ObservabilityAIAssistantService {
private readonly core: CoreSetup<ObservabilityAIAssistantPluginStartDependencies>;
private readonly logger: Logger;
private kbService?: KnowledgeBaseService;
private readonly resourceNames: ObservabilityAIAssistantResourceNames = {
componentTemplate: {
@ -54,14 +59,41 @@ export class ObservabilityAIAssistantService implements IObservabilityAIAssistan
},
};
constructor({ logger, core }: { logger: Logger; core: CoreSetup }) {
constructor({
logger,
core,
taskManager,
}: {
logger: Logger;
core: CoreSetup<ObservabilityAIAssistantPluginStartDependencies>;
taskManager: TaskManagerSetupContract;
}) {
this.core = core;
this.logger = logger;
taskManager.registerTaskDefinitions({
[INDEX_QUEUED_DOCUMENTS_TASK_TYPE]: {
title: 'Index queued KB articles',
description:
'Indexes previously registered entries into the knowledge base when it is ready',
timeout: '30m',
maxAttempts: 2,
createTaskRunner: (context) => {
return {
run: async () => {
if (this.kbService) {
// await this.kbService.processQueue();
}
},
};
},
},
});
}
init = once(async () => {
try {
const [coreStart] = await this.core.getStartServices();
const [coreStart, pluginsStart] = await this.core.getStartServices();
const esClient = coreStart.elasticsearch.client.asInternalUser;
@ -173,6 +205,13 @@ export class ObservabilityAIAssistantService implements IObservabilityAIAssistan
},
});
this.kbService = new KnowledgeBaseService({
logger: this.logger.get('kb'),
esClient,
resources: this.resourceNames,
taskManagerStart: pluginsStart.taskManager,
});
this.logger.info('Successfully set up index assets');
} catch (error) {
this.logger.error(`Failed to initialize service: ${error.message}`);
@ -185,7 +224,7 @@ export class ObservabilityAIAssistantService implements IObservabilityAIAssistan
request,
}: {
request: KibanaRequest;
}): Promise<IObservabilityAIAssistantClient> {
}): Promise<ObservabilityAIAssistantClient> {
const [_, [coreStart, plugins]] = await Promise.all([
this.init(),
this.core.getStartServices() as Promise<
@ -213,6 +252,24 @@ export class ObservabilityAIAssistantService implements IObservabilityAIAssistan
id: user.profile_uid,
name: user.username,
},
knowledgeBaseService: this.kbService!,
});
}
async addToKnowledgeBase(
entries: Array<
Omit<KnowledgeBaseEntry, 'is_correction' | 'public' | 'confidence' | '@timestamp'>
>
): Promise<void> {
await this.init();
this.kbService!.store(
entries.map((entry) => ({
...entry,
'@timestamp': new Date().toISOString(),
public: true,
confidence: 'high',
is_correction: false,
}))
);
}
}

View file

@ -0,0 +1,294 @@
/*
* 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 { errors } from '@elastic/elasticsearch';
import type { QueryDslTextExpansionQuery } from '@elastic/elasticsearch/lib/api/types';
import { serverUnavailable } from '@hapi/boom';
import type { ElasticsearchClient } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import { TaskManagerStartContract } from '@kbn/task-manager-plugin/server';
import pLimit from 'p-limit';
import pRetry from 'p-retry';
import { INDEX_QUEUED_DOCUMENTS_TASK_ID, INDEX_QUEUED_DOCUMENTS_TASK_TYPE } from '..';
import type { KnowledgeBaseEntry } from '../../../common/types';
import type { ObservabilityAIAssistantResourceNames } from '../types';
import { getAccessQuery } from '../util/get_access_query';
interface Dependencies {
esClient: ElasticsearchClient;
resources: ObservabilityAIAssistantResourceNames;
logger: Logger;
taskManagerStart: TaskManagerStartContract;
}
const ELSER_MODEL_ID = '.elser_model_1';
function throwKnowledgeBaseNotReady(body: any) {
throw serverUnavailable(`Knowledge base is not ready yet`, body);
}
export class KnowledgeBaseService {
private hasSetup: boolean = false;
private entryQueue: KnowledgeBaseEntry[] = [];
constructor(private readonly dependencies: Dependencies) {
this.ensureTaskScheduled();
}
private ensureTaskScheduled() {
this.dependencies.taskManagerStart
.ensureScheduled({
taskType: INDEX_QUEUED_DOCUMENTS_TASK_TYPE,
id: INDEX_QUEUED_DOCUMENTS_TASK_ID,
state: {},
params: {},
schedule: {
interval: '1h',
},
})
.then(() => {
this.dependencies.logger.debug('Scheduled document queue task');
return this.dependencies.taskManagerStart.runSoon(INDEX_QUEUED_DOCUMENTS_TASK_ID);
})
.then(() => {
this.dependencies.logger.debug('Document queue task ran');
})
.catch((err) => {
this.dependencies.logger.error(`Failed to schedule document queue task`);
this.dependencies.logger.error(err);
});
}
async processQueue() {
if (!this.entryQueue.length) {
return;
}
if (!(await this.status()).ready) {
this.dependencies.logger.debug(`Bailing on document queue task: KB is not ready yet`);
return;
}
this.dependencies.logger.debug(`Processing document queue`);
this.hasSetup = true;
this.dependencies.logger.info(`Indexing ${this.entryQueue.length} queued entries into KB`);
const limiter = pLimit(5);
const entries = this.entryQueue.concat();
await Promise.all(
entries.map((entry) =>
limiter(() => {
this.entryQueue.splice(entries.indexOf(entry), 1);
return this.summarise({ entry });
})
)
);
this.dependencies.logger.info('Indexed all queued entries into KB');
}
async store(entries: KnowledgeBaseEntry[]) {
if (!entries.length) {
return;
}
if (!this.hasSetup) {
this.entryQueue.push(...entries);
return;
}
const limiter = pLimit(5);
const limitedFunctions = entries.map((entry) => limiter(() => this.summarise({ entry })));
Promise.all(limitedFunctions).catch((err) => {
this.dependencies.logger.error(`Failed to index all knowledge base entries`);
this.dependencies.logger.error(err);
});
}
recall = async ({
user,
query,
namespace,
}: {
query: string;
user: { name: string };
namespace: string;
}): Promise<{ entries: KnowledgeBaseEntry[] }> => {
try {
const response = await this.dependencies.esClient.search<KnowledgeBaseEntry>({
index: this.dependencies.resources.aliases.kb,
query: {
bool: {
should: [
{
text_expansion: {
'ml.tokens': {
model_text: query,
model_id: '.elser_model_1',
},
} as unknown as QueryDslTextExpansionQuery,
},
],
filter: [
...getAccessQuery({
user,
namespace,
}),
],
},
},
size: 3,
_source: {
includes: ['text', 'id'],
},
});
return { entries: response.hits.hits.map((hit) => ({ ...hit._source!, score: hit._score })) };
} catch (error) {
if (
(error instanceof errors.ResponseError &&
error.body.error.type === 'resource_not_found_exception') ||
error.body.error.type === 'status_exception'
) {
throwKnowledgeBaseNotReady(error.body);
}
throw error;
}
};
summarise = async ({
entry: { id, ...document },
user,
namespace,
}: {
entry: Omit<KnowledgeBaseEntry, '@timestamp'>;
user?: { name: string; id?: string };
namespace?: string;
}): Promise<void> => {
try {
await this.dependencies.esClient.index({
index: this.dependencies.resources.aliases.kb,
id,
document: {
'@timestamp': new Date().toISOString(),
...document,
user,
namespace,
},
pipeline: this.dependencies.resources.pipelines.kb,
});
} catch (error) {
if (error instanceof errors.ResponseError && error.body.error.type === 'status_exception') {
throwKnowledgeBaseNotReady(error.body);
}
throw error;
}
};
status = async () => {
try {
const modelStats = await this.dependencies.esClient.ml.getTrainedModelsStats({
model_id: ELSER_MODEL_ID,
});
const elserModelStats = modelStats.trained_model_stats[0];
const deploymentState = elserModelStats.deployment_stats?.state;
const allocationState = elserModelStats.deployment_stats?.allocation_status.state;
return {
ready: deploymentState === 'started' && allocationState === 'fully_allocated',
deployment_state: deploymentState,
allocation_state: allocationState,
};
} catch (error) {
return {
error: error instanceof errors.ResponseError ? error.body.error : String(error),
ready: false,
};
}
};
setup = async () => {
// if this fails, it's fine to propagate the error to the user
const installModel = async () => {
this.dependencies.logger.info('Installing ELSER model');
await this.dependencies.esClient.ml.putTrainedModel(
{
model_id: ELSER_MODEL_ID,
input: {
field_names: ['text_field'],
},
// @ts-expect-error
wait_for_completion: true,
},
{ requestTimeout: '20m' }
);
this.dependencies.logger.info('Finished installing ELSER model');
};
try {
const getResponse = await this.dependencies.esClient.ml.getTrainedModels({
model_id: ELSER_MODEL_ID,
include: 'definition_status',
});
if (!getResponse.trained_model_configs[0]?.fully_defined) {
this.dependencies.logger.info('Model is not fully defined');
await installModel();
}
} catch (error) {
if (
error instanceof errors.ResponseError &&
error.body.error.type === 'resource_not_found_exception'
) {
await installModel();
} else {
throw error;
}
}
try {
await this.dependencies.esClient.ml.startTrainedModelDeployment({
model_id: ELSER_MODEL_ID,
wait_for: 'fully_allocated',
});
} catch (error) {
if (
!(error instanceof errors.ResponseError && error.body.error.type === 'status_exception')
) {
throw error;
}
}
await pRetry(
async () => {
const response = await this.dependencies.esClient.ml.getTrainedModelsStats({
model_id: ELSER_MODEL_ID,
});
if (
response.trained_model_stats[0]?.deployment_stats?.allocation_status.state ===
'fully_allocated'
) {
return Promise.resolve();
}
this.dependencies.logger.debug('Model is not allocated yet');
return Promise.reject(new Error('Not Ready'));
},
{ factor: 1, minTimeout: 10000, maxRetryTime: 20 * 60 * 1000 }
);
this.dependencies.logger.info('Model is ready');
this.ensureTaskScheduled();
};
}

View file

@ -0,0 +1,583 @@
/*
* 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 dedent from 'dedent';
import type { ObservabilityAIAssistantService } from '../..';
export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
service.addToKnowledgeBase([
{
id: 'lens_formulas_how_it_works',
text: dedent(`## How it works
Lens formulas let you do math using a combination of Elasticsearch aggregations and
math functions. There are three main types of functions:
* Elasticsearch metrics, like \`sum(bytes)\`
* Time series functions use Elasticsearch metrics as input, like \`cumulative_sum()\`
* Math functions like \`round()\`
An example formula that uses all of these:
\`\`\`
round(100 * moving_average(
average(cpu.load.pct),
window=10,
kql='datacenter.name: east*'
))
\`\`\`
Elasticsearch functions take a field name, which can be in quotes. \`sum(bytes)\` is the same
as \`sum('bytes')\`.
Some functions take named arguments, like \`moving_average(count(), window=5)\`.
Elasticsearch metrics can be filtered using KQL or Lucene syntax. To add a filter, use the named
parameter \`kql='field: value'\` or \`lucene=''\`. Always use single quotes when writing KQL or Lucene
queries. If your search has a single quote in it, use a backslash to escape, like: \`kql='Women's'\'
Math functions can take positional arguments, like pow(count(), 3) is the same as count() * count() * count()
Use the symbols +, -, /, and * to perform basic math.`),
},
{
id: 'lens_common_formulas',
text: dedent(`## Common formulas
The most common formulas are dividing two values to produce a percent. To display accurately, set
"value format" to "percent".
### Filter ratio:
Use \`kql=''\` to filter one set of documents and compare it to other documents within the same grouping.
For example, to see how the error rate changes over time:
\`\`\`
count(kql='response.status_code > 400') / count()
\`\`\`
### Week over week:
Use \`shift='1w'\` to get the value of each grouping from
the previous week. Time shift should not be used with the *Top values* function.
\`\`\`
percentile(system.network.in.bytes, percentile=99) /
percentile(system.network.in.bytes, percentile=99, shift='1w')
\`\`\`
### Percent of total
Formulas can calculate \`overall_sum\` for all the groupings,
which lets you convert each grouping into a percent of total:
\`\`\`
sum(products.base_price) / overall_sum(sum(products.base_price))
\`\`\`
### Recent change
Use \`reducedTimeRange='30m'\` to add an additional filter on the
time range of a metric aligned with the end of the global time range.
This can be used to calculate how much a value changed recently.
\`\`\`
max(system.network.in.bytes, reducedTimeRange="30m")
- min(system.network.in.bytes, reducedTimeRange="30m")
\`\`\`
`),
},
{
id: 'lens_formulas_elasticsearch_functions',
text: dedent(`## Elasticsearch functions
These functions will be executed on the raw documents for each row of the
resulting table, aggregating all documents matching the break down
dimensions into a single value.
#### average(field: string)
Returns the average of a field. This function only works for number fields.
Example: Get the average of price: \`average(price)\`
Example: Get the average of price for orders from the UK: \`average(price,
kql='location:UK')\`
#### count([field: string])
The total number of documents. When you provide a field, the total number of
field values is counted. When you use the Count function for fields that have
multiple values in a single document, all values are counted.
To calculate the total number of documents, use \`count().\`
To calculate the number of products in all orders, use \`count(products.id)\`.
To calculate the number of documents that match a specific filter, use
\`count(kql='price > 500')\`.
#### last_value(field: string)
Returns the value of a field from the last document, ordered by the default
time field of the data view.
This function is usefull the retrieve the latest state of an entity.
Example: Get the current status of server A: \`last_value(server.status,
kql='server.name="A"')\`
#### max(field: string)
Returns the max of a field. This function only works for number fields.
Example: Get the max of price: \`max(price)\`
Example: Get the max of price for orders from the UK: \`max(price,
kql='location:UK')\`
#### median(field: string)
Returns the median of a field. This function only works for number fields.
Example: Get the median of price: \`median(price)\`
Example: Get the median of price for orders from the UK: \`median(price,
kql='location:UK')\`
#### min(field: string)
Returns the min of a field. This function only works for number fields.
Example: Get the min of price: \`min(price)\`
Example: Get the min of price for orders from the UK: \`min(price,
kql='location:UK')\`
#### percentile(field: string, [percentile]: number)
Returns the specified percentile of the values of a field. This is the value n
percent of the values occuring in documents are smaller.
Example: Get the number of bytes larger than 95 % of values:
\`percentile(bytes, percentile=95)\`
#### percentile_rank(field: string, [value]: number)
Returns the percentage of values which are below a certain value. For example,
if a value is greater than or equal to 95% of the observed values it is said to
be at the 95th percentile rank
Example: Get the percentage of values which are below of 100:
\`percentile_rank(bytes, value=100)\`
#### standard_deviation(field: string)
Returns the amount of variation or dispersion of the field. The function works
only for number fields.
Example: To get the standard deviation of price, use
\`standard_deviation(price).\`
Example: To get the variance of price for orders from the UK, use
\`square(standard_deviation(price, kql='location:UK'))\`.
#### sum(field: string)
Returns the sum of a field. This function only works for number fields.
Example: Get the sum of price: sum(price)
Example: Get the sum of price for orders from the UK: \`sum(price,
kql='location:UK')\`
#### unique_count(field: string)
Calculates the number of unique values of a specified field. Works for number,
string, date and boolean values.
Example: Calculate the number of different products:
\`unique_count(product.name)\`
Example: Calculate the number of different products from the "clothes" group:
\`unique_count(product.name, kql='product.group=clothes')\`
`),
},
{
id: 'lens_formulas_column_functions',
text: dedent(`## Column calculations
These functions are executed for each row, but are provided with the whole
column as context. This is also known as a window function.
#### counter_rate(metric: number)
Calculates the rate of an ever increasing counter. This function will only
yield helpful results on counter metric fields which contain a measurement of
some kind monotonically growing over time. If the value does get smaller, it
will interpret this as a counter reset. To get most precise results,
counter_rate should be calculated on the max of a field.
This calculation will be done separately for separate series defined by filters
or top values dimensions. It uses the current interval when used in Formula.
Example: Visualize the rate of bytes received over time by a memcached server:
counter_rate(max(memcached.stats.read.bytes))
cumulative_sum(metric: number)
Calculates the cumulative sum of a metric over time, adding all previous values
of a series to each value. To use this function, you need to configure a date
histogram dimension as well.
This calculation will be done separately for separate series defined by filters
or top values dimensions.
Example: Visualize the received bytes accumulated over time:
cumulative_sum(sum(bytes))
differences(metric: number)
Calculates the difference to the last value of a metric over time. To use this
function, you need to configure a date histogram dimension as well. Differences
requires the data to be sequential. If your data is empty when using
differences, try increasing the date histogram interval.
This calculation will be done separately for separate series defined by filters
or top values dimensions.
Example: Visualize the change in bytes received over time:
differences(sum(bytes))
moving_average(metric: number, [window]: number)
Calculates the moving average of a metric over time, averaging the last n-th
values to calculate the current value. To use this function, you need to
configure a date histogram dimension as well. The default window value is 5.
This calculation will be done separately for separate series defined by filters
or top values dimensions.
Takes a named parameter window which specifies how many last values to include
in the average calculation for the current value.
Example: Smooth a line of measurements: moving_average(sum(bytes), window=5)
normalize_by_unit(metric: number, unit: s|m|h|d|w|M|y)
This advanced function is useful for normalizing counts and sums to a specific
time interval. It allows for integration with metrics that are stored already
normalized to a specific time interval.
This function can only be used if there's a date histogram function used in the
current chart.
Example: A ratio comparing an already normalized metric to another metric that
needs to be normalized.
normalize_by_unit(counter_rate(max(system.diskio.write.bytes)), unit='s') /
last_value(apache.status.bytes_per_second)
overall_average(metric: number)
Calculates the average of a metric for all data points of a series in the
current chart. A series is defined by a dimension using a date histogram or
interval function. Other dimensions breaking down the data like top values or
filter are treated as separate series.
If no date histograms or interval functions are used in the current chart,
overall_average is calculating the average over all dimensions no matter the
used function
Example: Divergence from the mean: sum(bytes) - overall_average(sum(bytes))
overall_max(metric: number)
Calculates the maximum of a metric for all data points of a series in the
current chart. A series is defined by a dimension using a date histogram or
interval function. Other dimensions breaking down the data like top values or
filter are treated as separate series.
If no date histograms or interval functions are used in the current chart,
overall_max is calculating the maximum over all dimensions no matter the used
function
Example: Percentage of range (sum(bytes) - overall_min(sum(bytes))) /
(overall_max(sum(bytes)) - overall_min(sum(bytes)))
overall_min(metric: number)
Calculates the minimum of a metric for all data points of a series in the
current chart. A series is defined by a dimension using a date histogram or
interval function. Other dimensions breaking down the data like top values or
filter are treated as separate series.
If no date histograms or interval functions are used in the current chart,
overall_min is calculating the minimum over all dimensions no matter the used
function
Example: Percentage of range (sum(bytes) - overall_min(sum(bytes)) /
(overall_max(sum(bytes)) - overall_min(sum(bytes)))
overall_sum(metric: number)
Calculates the sum of a metric of all data points of a series in the current
chart. A series is defined by a dimension using a date histogram or interval
function. Other dimensions breaking down the data like top values or filter are
treated as separate series.
If no date histograms or interval functions are used in the current chart,
overall_sum is calculating the sum over all dimensions no matter the used
function.
Example: Percentage of total sum(bytes) / overall_sum(sum(bytes))`),
},
{
id: 'lens_formulas_math_functions',
text: dedent(`Math
These functions will be executed for reach row of the resulting table using single values from the same row calculated using other functions.
abs([value]: number)
Calculates absolute value. A negative value is multiplied by -1, a positive value stays the same.
Example: Calculate average distance to sea level abs(average(altitude))
add([left]: number, [right]: number)
Adds up two numbers.
Also works with + symbol.
Example: Calculate the sum of two fields
sum(price) + sum(tax)
Example: Offset count by a static value
add(count(), 5)
cbrt([value]: number)
Cube root of value.
Example: Calculate side length from volume
cbrt(last_value(volume))
ceil([value]: number)
Ceiling of value, rounds up.
Example: Round up price to the next dollar
ceil(sum(price))
clamp([value]: number, [min]: number, [max]: number)
Limits the value from a minimum to maximum.
Example: Make sure to catch outliers
clamp(
average(bytes),
percentile(bytes, percentile=5),
percentile(bytes, percentile=95)
)
cube([value]: number)
Calculates the cube of a number.
Example: Calculate volume from side length
cube(last_value(length))
defaults([value]: number, [default]: number)
Returns a default numeric value when value is null.
Example: Return -1 when a field has no data
defaults(average(bytes), -1)
divide([left]: number, [right]: number)
Divides the first number by the second number.
Also works with / symbol
Example: Calculate profit margin
sum(profit) / sum(revenue)
Example: divide(sum(bytes), 2)
exp([value]: number)
Raises e to the nth power.
Example: Calculate the natural exponential function
exp(last_value(duration))
fix([value]: number)
For positive values, takes the floor. For negative values, takes the ceiling.
Example: Rounding towards zero
fix(sum(profit))
floor([value]: number)
Round down to nearest integer value
Example: Round down a price
floor(sum(price))
log([value]: number, [base]?: number)
Logarithm with optional base. The natural base e is used as default.
Example: Calculate number of bits required to store values
log(sum(bytes))
log(sum(bytes), 2)
mod([value]: number, [base]: number)
Remainder after dividing the function by a number
Example: Calculate last three digits of a value
mod(sum(price), 1000)
multiply([left]: number, [right]: number)
Multiplies two numbers.
Also works with * symbol.
Example: Calculate price after current tax rate
sum(bytes) * last_value(tax_rate)
Example: Calculate price after constant tax rate
multiply(sum(price), 1.2)
pick_max([left]: number, [right]: number)
Finds the maximum value between two numbers.
Example: Find the maximum between two fields averages
pick_max(average(bytes), average(memory))
pick_min([left]: number, [right]: number)
Finds the minimum value between two numbers.
Example: Find the minimum between two fields averages
pick_min(average(bytes), average(memory))
pow([value]: number, [base]: number)
Raises the value to a certain power. The second argument is required
Example: Calculate volume based on side length
pow(last_value(length), 3)
round([value]: number, [decimals]?: number)
Rounds to a specific number of decimal places, default of 0
Examples: Round to the cent
round(sum(bytes))
round(sum(bytes), 2)
sqrt([value]: number)
Square root of a positive value only
Example: Calculate side length based on area
sqrt(last_value(area))
square([value]: number)
Raise the value to the 2nd power
Example: Calculate area based on side length
square(last_value(length))
subtract([left]: number, [right]: number)
Subtracts the first number from the second number.
Also works with - symbol.
Example: Calculate the range of a field
subtract(max(bytes), min(bytes))
Comparison
These functions are used to perform value comparison.
eq([left]: number, [right]: number)
Performs an equality comparison between two values.
To be used as condition for ifelse comparison function.
Also works with == symbol.
Example: Returns true if the average of bytes is exactly the same amount of average memory
average(bytes) == average(memory)
Example: eq(sum(bytes), 1000000)
gt([left]: number, [right]: number)
Performs a greater than comparison between two values.
To be used as condition for ifelse comparison function.
Also works with > symbol.
Example: Returns true if the average of bytes is greater than the average amount of memory
average(bytes) > average(memory)
Example: gt(average(bytes), 1000)
gte([left]: number, [right]: number)
Performs a greater than comparison between two values.
To be used as condition for ifelse comparison function.
Also works with >= symbol.
Example: Returns true if the average of bytes is greater than or equal to the average amount of memory
average(bytes) >= average(memory)
Example: gte(average(bytes), 1000)
ifelse([condition]: boolean, [left]: number, [right]: number)
Returns a value depending on whether the element of condition is true or false.
Example: Average revenue per customer but in some cases customer id is not provided which counts as additional customer
sum(total)/(unique_count(customer_id) + ifelse( count() > count(kql='customer_id:*'), 1, 0))
lt([left]: number, [right]: number)
Performs a lower than comparison between two values.
To be used as condition for ifelse comparison function.
Also works with < symbol.
Example: Returns true if the average of bytes is lower than the average amount of memory
average(bytes) <= average(memory)
Example: lt(average(bytes), 1000)
lte([left]: number, [right]: number)
Performs a lower than or equal comparison between two values.
To be used as condition for ifelse comparison function.
Also works with <= symbol.
Example: Returns true if the average of bytes is lower than or equal to the average amount of memory
average(bytes) <= average(memory)
Example: lte(average(bytes), 1000)`),
},
{
id: 'lens_formulas_kibana_context',
text: dedent(`Kibana context
These functions are used to retrieve Kibana context variables, which are the
date histogram \`interval\`, the current \`now\` and the selected \`time_range\`
and help you to compute date math operations.
interval()
The specified minimum interval for the date histogram, in milliseconds (ms).
now()
The current now moment used in Kibana expressed in milliseconds (ms).
time_range()
The specified time range, in milliseconds (ms).`),
},
]);
}

View file

@ -5,45 +5,6 @@
* 2.0.
*/
import { IncomingMessage } from 'http';
import { KibanaRequest } from '@kbn/core/server';
import type {
Conversation,
ConversationCreateRequest,
ConversationUpdateRequest,
FunctionDefinition,
KnowledgeBaseEntry,
Message,
} from '../../common/types';
export interface IObservabilityAIAssistantClient {
chat: (options: {
messages: Message[];
connectorId: string;
functions: Array<Pick<FunctionDefinition['options'], 'name' | 'description' | 'parameters'>>;
}) => Promise<IncomingMessage>;
get: (conversationId: string) => Promise<Conversation>;
find: (options?: { query?: string }) => Promise<{ conversations: Conversation[] }>;
create: (conversation: ConversationCreateRequest) => Promise<Conversation>;
update: (conversation: ConversationUpdateRequest) => Promise<Conversation>;
delete: (conversationId: string) => Promise<void>;
recall: (query: string) => Promise<{ entries: KnowledgeBaseEntry[] }>;
summarise: (options: { entry: Omit<KnowledgeBaseEntry, '@timestamp'> }) => Promise<void>;
getKnowledgeBaseStatus: () => Promise<{
ready: boolean;
error?: any;
deployment_state?: string;
allocation_state?: string;
}>;
setupKnowledgeBase: () => Promise<void>;
}
export interface IObservabilityAIAssistantService {
getClient: (options: {
request: KibanaRequest;
}) => Promise<IObservabilityAIAssistantClient | undefined>;
}
export interface ObservabilityAIAssistantResourceNames {
componentTemplate: {
conversations: string;

View file

@ -0,0 +1,59 @@
/*
* 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 function getAccessQuery({
user,
namespace,
}: {
user: { name: string; id?: string };
namespace?: string;
}) {
return [
{
bool: {
filter: [
{
bool: {
should: [
{
term: {
'user.name': user.name,
},
},
{
term: {
public: true,
},
},
],
minimum_should_match: 1,
},
},
{
bool: {
should: [
{
term: {
namespace,
},
},
{
bool: {
must_not: {
exists: {
field: 'namespace',
},
},
},
},
],
},
},
],
},
},
];
}

View file

@ -13,6 +13,10 @@ import type {
PluginStartContract as ActionsPluginStart,
} from '@kbn/actions-plugin/server';
import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server';
import type {
TaskManagerSetupContract,
TaskManagerStartContract,
} from '@kbn/task-manager-plugin/server';
/* eslint-disable @typescript-eslint/no-empty-interface*/
export interface ObservabilityAIAssistantPluginStart {}
@ -21,9 +25,11 @@ export interface ObservabilityAIAssistantPluginSetupDependencies {
actions: ActionsPluginSetup;
security: SecurityPluginSetup;
features: FeaturesPluginSetup;
taskManager: TaskManagerSetupContract;
}
export interface ObservabilityAIAssistantPluginStartDependencies {
actions: ActionsPluginStart;
security: SecurityPluginStart;
features: FeaturesPluginStart;
taskManager: TaskManagerStartContract;
}

View file

@ -37,7 +37,12 @@
"@kbn/alerting-plugin",
"@kbn/features-plugin",
"@kbn/react-kibana-context-theme",
"@kbn/i18n-react"
"@kbn/lens-embeddable-utils",
"@kbn/i18n-react",
"@kbn/field-formats-plugin",
"@kbn/lens-plugin",
"@kbn/data-views-plugin",
"@kbn/task-manager-plugin"
],
"exclude": ["target/**/*"]
}

View file

@ -59,7 +59,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns a 404 for updating conversations', async () => {
await observabilityAIAssistantAPIClient
.writeUser({
endpoint: 'POST /internal/observability_ai_assistant/conversation/{conversationId}',
endpoint: 'PUT /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: {
conversationId: 'non-existing-conversation-id',
@ -88,12 +88,12 @@ export default function ApiTest({ getService }: FtrProviderContext) {
describe('when creating a conversation with the write user', () => {
let createResponse: Awaited<
SupertestReturnType<'PUT /internal/observability_ai_assistant/conversation'>
SupertestReturnType<'POST /internal/observability_ai_assistant/conversation'>
>;
before(async () => {
createResponse = await observabilityAIAssistantAPIClient
.writeUser({
endpoint: 'PUT /internal/observability_ai_assistant/conversation',
endpoint: 'POST /internal/observability_ai_assistant/conversation',
params: {
body: {
conversation: conversationCreate,
@ -149,7 +149,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns a 404 for updating a non-existing conversation', async () => {
await observabilityAIAssistantAPIClient
.writeUser({
endpoint: 'POST /internal/observability_ai_assistant/conversation/{conversationId}',
endpoint: 'PUT /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: {
conversationId: 'non-existing-conversation-id',
@ -205,13 +205,13 @@ export default function ApiTest({ getService }: FtrProviderContext) {
describe('after updating', () => {
let updateResponse: Awaited<
SupertestReturnType<'POST /internal/observability_ai_assistant/conversation/{conversationId}'>
SupertestReturnType<'PUT /internal/observability_ai_assistant/conversation/{conversationId}'>
>;
before(async () => {
updateResponse = await observabilityAIAssistantAPIClient
.writeUser({
endpoint: 'POST /internal/observability_ai_assistant/conversation/{conversationId}',
endpoint: 'PUT /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: {
conversationId: createResponse.body.conversation.id,

View file

@ -130,6 +130,7 @@ export default function ({ getService }: FtrProviderContext) {
'fleet:unenroll_action:retry',
'fleet:update_agent_tags:retry',
'fleet:upgrade_action:retry',
'observabilityAIAssistant:indexQueuedDocumentsTaskType',
'osquery:telemetry-configs',
'osquery:telemetry-packs',
'osquery:telemetry-saved-queries',

View file

@ -101,6 +101,7 @@ export default function catalogueTests({ getService }: FtrProviderContext) {
'enterpriseSearchVectorSearch',
'elasticsearch',
'appSearch',
'observabilityAIAssistant',
'workplaceSearch',
'searchExperiences',
'spaces',

View file

@ -75,6 +75,7 @@ export default function navLinksTests({ getService }: FtrProviderContext) {
'enterpriseSearchApplications',
'enterpriseSearchEsre',
'enterpriseSearchVectorSearch',
'observabilityAIAssistant',
'appSearch',
'workplaceSearch',
'guidedOnboardingFeature',