[Observability AI Assistant]: Chat & function calling (#162906)

Implements chat & function calling for the Observability AI Assistant.
The APM related changes are due to a correction in the `toBooleanRt`
type.

Code in `x-pack/observability_ai_assistant` has been reviewed via
feature branches.

---------

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>
This commit is contained in:
Dario Gieselaar 2023-08-08 13:48:05 +02:00 committed by GitHub
parent 304cb256cf
commit e47152d8fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
105 changed files with 5518 additions and 414 deletions

View file

@ -878,6 +878,7 @@
"js-search": "^1.4.3",
"js-sha256": "^0.9.0",
"js-yaml": "^3.14.1",
"json-schema-to-ts": "^2.9.1",
"json-stable-stringify": "^1.0.1",
"json-stringify-pretty-compact": "1.2.0",
"json-stringify-safe": "5.0.1",

View file

@ -24,7 +24,8 @@ type ParsableType =
| t.InterfaceType<any>
| MergeType<any, any>
| t.DictionaryType<any, any>
| t.ArrayType<any, any>;
| t.ArrayType<any, any>
| t.AnyType;
const tags = [
'DictionaryType',
@ -35,6 +36,7 @@ const tags = [
'ExactType',
'UnionType',
'ArrayType',
'AnyType',
];
function isParsableType(type: t.Mixed): type is ParsableType {
@ -47,6 +49,9 @@ function getHandlingTypes(type: t.Mixed, key: string, value: object): t.Mixed[]
}
switch (type._tag) {
case 'AnyType':
return [type];
case 'ArrayType':
return [type.type];

View file

@ -8,7 +8,7 @@
import * as t from 'io-ts';
export const toBooleanRt = new t.Type<boolean, unknown, unknown>(
export const toBooleanRt = new t.Type<boolean, boolean, unknown>(
'ToBoolean',
t.boolean.is,
(input) => {

View file

@ -119,4 +119,35 @@ describe('decodeRequestParams', () => {
expect(decode()).toEqual({});
});
it('allows excess keys in an any type', () => {
const decode = () => {
return decodeRequestParams(
{
params: {},
query: {},
body: {
body: {
query: 'foo',
},
},
},
t.type({
body: t.type({
body: t.any,
}),
})
);
};
expect(decode).not.toThrow();
expect(decode()).toEqual({
body: {
body: {
query: 'foo',
},
},
});
});
});

View file

@ -156,6 +156,7 @@ export const applicationUsageSchema = {
monitoring: commonSchema,
'observability-overview': commonSchema,
observabilityOnboarding: commonSchema,
observabilityAIAssistant: commonSchema,
'exploratory-view': commonSchema,
osquery: commonSchema,
profiling: commonSchema,

View file

@ -4849,6 +4849,137 @@
}
}
},
"observabilityAIAssistant": {
"properties": {
"appId": {
"type": "keyword",
"_meta": {
"description": "The application being tracked"
}
},
"viewId": {
"type": "keyword",
"_meta": {
"description": "Always `main`"
}
},
"clicks_total": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application since we started counting them"
}
},
"clicks_7_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 7 days"
}
},
"clicks_30_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 30 days"
}
},
"clicks_90_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 90 days"
}
},
"minutes_on_screen_total": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen since we started counting them."
}
},
"minutes_on_screen_7_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 7 days"
}
},
"minutes_on_screen_30_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 30 days"
}
},
"minutes_on_screen_90_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 90 days"
}
},
"views": {
"type": "array",
"items": {
"properties": {
"appId": {
"type": "keyword",
"_meta": {
"description": "The application being tracked"
}
},
"viewId": {
"type": "keyword",
"_meta": {
"description": "The application view being tracked"
}
},
"clicks_total": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application sub view since we started counting them"
}
},
"clicks_7_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 7 days"
}
},
"clicks_30_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 30 days"
}
},
"clicks_90_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 90 days"
}
},
"minutes_on_screen_total": {
"type": "float",
"_meta": {
"description": "Minutes the application sub view is active and on-screen since we started counting them."
}
},
"minutes_on_screen_7_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 7 days"
}
},
"minutes_on_screen_30_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 30 days"
}
},
"minutes_on_screen_90_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 90 days"
}
}
}
}
}
}
},
"exploratory-view": {
"properties": {
"appId": {

View file

@ -54,27 +54,27 @@ export function ErrorSampleContextualInsight({
role: MessageRole.User,
content: `I'm an SRE. I am looking at an exception and trying to understand what it means.
Your task is to describe what the error means and what it could be caused by.
The error occurred on a service called ${serviceName}, which is a ${runtimeName} service written in ${languageName}. The
runtime version is ${runtimeVersion}.
The request it occurred for is called ${transactionName}.
${
logStacktrace
? `The log stacktrace:
${logStacktrace}`
: ''
}
${
exceptionStacktrace
? `The exception stacktrace:
${exceptionStacktrace}`
: ''
}
`,
Your task is to describe what the error means and what it could be caused by.
The error occurred on a service called ${serviceName}, which is a ${runtimeName} service written in ${languageName}. The
runtime version is ${runtimeVersion}.
The request it occurred for is called ${transactionName}.
${
logStacktrace
? `The log stacktrace:
${logStacktrace}`
: ''
}
${
exceptionStacktrace
? `The exception stacktrace:
${exceptionStacktrace}`
: ''
}
`,
},
},
];

View file

@ -171,7 +171,7 @@ export function TransactionsTable({
start,
end,
transactionType,
useDurationSummary: shouldUseDurationSummary,
useDurationSummary: !!shouldUseDurationSummary,
latencyAggregationType:
latencyAggregationType as LatencyAggregationType,
documentType: preferred.source.documentType,
@ -256,7 +256,7 @@ export function TransactionsTable({
transactionType,
documentType: preferred.source.documentType,
rollupInterval: preferred.source.rollupInterval,
useDurationSummary: shouldUseDurationSummary,
useDurationSummary: !!shouldUseDurationSummary,
latencyAggregationType:
latencyAggregationType as LatencyAggregationType,
transactionNames: JSON.stringify(

View file

@ -78,7 +78,7 @@ export function useTransactionLatencyChartsFetcher({
start,
end,
transactionType,
useDurationSummary: shouldUseDurationSummary,
useDurationSummary: !!shouldUseDurationSummary,
transactionName: transactionName || undefined,
latencyAggregationType,
offset:

View file

@ -5,14 +5,16 @@
* 2.0.
*/
import { Serializable } from '@kbn/utility-types';
import type { Serializable } from '@kbn/utility-types';
import type { FromSchema } from 'json-schema-to-ts';
import type { JSONSchema } from 'json-schema-to-ts';
import React from 'react';
export enum MessageRole {
System = 'system',
Assistant = 'assistant',
User = 'user',
Function = 'function',
Event = 'event',
Elastic = 'elastic',
}
@ -21,13 +23,14 @@ export interface Message {
message: {
content?: string;
name?: string;
event?: string;
role: MessageRole;
function_call?: {
name: string;
args?: Serializable;
arguments?: string;
trigger: MessageRole.Assistant | MessageRole.User | MessageRole.Elastic;
};
data?: Serializable;
data?: string;
};
}
@ -46,6 +49,7 @@ export interface Conversation {
labels: Record<string, string>;
numeric_labels: Record<string, number>;
namespace: string;
public: boolean;
}
export type ConversationRequestBase = Omit<Conversation, 'user' | 'conversation' | 'namespace'> & {
@ -54,3 +58,60 @@ export type ConversationRequestBase = Omit<Conversation, 'user' | 'conversation'
export type ConversationCreateRequest = ConversationRequestBase;
export type ConversationUpdateRequest = ConversationRequestBase & { conversation: { id: string } };
export interface KnowledgeBaseEntry {
'@timestamp': string;
id: string;
text: string;
confidence: 'low' | 'medium' | 'high';
is_correction: boolean;
public: boolean;
}
type CompatibleJSONSchema = Exclude<JSONSchema, boolean>;
export interface ContextDefinition {
name: string;
description: string;
}
interface FunctionResponse {
content?: Serializable;
data?: Serializable;
}
interface FunctionOptions<TParameters extends CompatibleJSONSchema = CompatibleJSONSchema> {
name: string;
description: string;
parameters: TParameters;
contexts: string[];
}
type RespondFunction<
TParameters extends CompatibleJSONSchema,
TResponse extends FunctionResponse
> = (options: { arguments: FromSchema<TParameters> }, signal: AbortSignal) => Promise<TResponse>;
type RenderFunction<TResponse extends FunctionResponse> = (options: {
response: TResponse;
}) => React.ReactNode;
export interface FunctionDefinition {
options: FunctionOptions;
respond: (options: { arguments: any }, signal: AbortSignal) => Promise<FunctionResponse>;
render?: RenderFunction<any>;
}
export type RegisterContextDefinition = (options: ContextDefinition) => void;
export type RegisterFunctionDefinition = <
TParameters extends CompatibleJSONSchema,
TResponse extends FunctionResponse
>(
options: FunctionOptions<TParameters>,
respond: RespondFunction<TParameters, TResponse>,
render?: RenderFunction<TResponse>
) => void;
export type ContextRegistry = Map<string, ContextDefinition>;
export type FunctionRegistry = Map<string, FunctionDefinition>;

View file

@ -6,19 +6,9 @@
"id": "observabilityAIAssistant",
"server": true,
"browser": true,
"configPath": [
"xpack",
"observabilityAIAssistant"
],
"requiredPlugins": [
"triggersActionsUi",
"actions",
"security",
"features"
],
"requiredBundles": [
"kibanaReact"
],
"configPath": ["xpack", "observabilityAIAssistant"],
"requiredPlugins": ["triggersActionsUi", "actions", "security", "features", "observabilityShared"],
"requiredBundles": ["kibanaReact", "kibanaUtils"],
"optionalPlugins": [],
"extraPublicDirs": []
}

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.
*/
import { EuiErrorBoundary } from '@elastic/eui';
import type { CoreStart, CoreTheme } from '@kbn/core/public';
import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config';
import type { History } from 'history';
import React from 'react';
import type { Observable } from 'rxjs';
import { ObservabilityAIAssistantProvider } from './context/observability_ai_assistant_provider';
import { observabilityAIAssistantRouter } from './routes/config';
import type {
ObservabilityAIAssistantPluginStartDependencies,
ObservabilityAIAssistantService,
} from './types';
export function Application({
theme$,
history,
coreStart,
pluginsStart,
service,
}: {
theme$: Observable<CoreTheme>;
history: History;
coreStart: CoreStart;
pluginsStart: ObservabilityAIAssistantPluginStartDependencies;
service: ObservabilityAIAssistantService;
}) {
return (
<EuiErrorBoundary>
<KibanaThemeProvider theme$={theme$}>
<KibanaContextProvider
services={{
...coreStart,
plugins: {
start: pluginsStart,
},
}}
>
<RedirectAppLinks coreStart={coreStart}>
<coreStart.i18n.Context>
<ObservabilityAIAssistantProvider value={service}>
<RouterProvider history={history} router={observabilityAIAssistantRouter as any}>
<RouteRenderer />
</RouterProvider>
</ObservabilityAIAssistantProvider>
</coreStart.i18n.Context>
</RedirectAppLinks>
</KibanaContextProvider>
</KibanaThemeProvider>
</EuiErrorBoundary>
);
}

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 from 'react';
import React, { ReactNode } from 'react';
export interface AssistantAvatarProps {
size: keyof typeof sizeMap;
size?: keyof typeof sizeMap;
children?: ReactNode;
}
export const sizeMap = {
@ -18,7 +19,7 @@ export const sizeMap = {
xs: 16,
};
export function AssistantAvatar({ size }: AssistantAvatarProps) {
export function AssistantAvatar({ size = 's' }: AssistantAvatarProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View file

@ -0,0 +1,25 @@
/*
* 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 { EuiButtonEmpty } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export function HideExpandConversationListButton(
props: React.ComponentProps<typeof EuiButtonEmpty> & { isExpanded: boolean }
) {
return (
<EuiButtonEmpty iconType={props.isExpanded ? 'menuLeft' : 'menuRight'} size="xs" {...props}>
{props.isExpanded
? i18n.translate('xpack.observabilityAiAssistant.hideExpandConversationButton.hide', {
defaultMessage: 'Hide chats',
})
: i18n.translate('xpack.observabilityAiAssistant.hideExpandConversationButton.show', {
defaultMessage: 'Show chats',
})}
</EuiButtonEmpty>
);
}

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ComponentMeta, ComponentStoryObj } from '@storybook/react';
import { NewChatButton as Component } from './new_chat_button';
const meta: ComponentMeta<typeof Component> = {
component: Component,
title: 'app/Atoms/NewChatButton',
};
export default meta;
export const NewChatButton: ComponentStoryObj<typeof Component> = {
args: {},
};

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export function NewChatButton(props: React.ComponentProps<typeof EuiButton>) {
return (
<EuiButton {...props} fill iconType="discuss">
{i18n.translate('xpack.observabilityAiAssistant.newChatButton', {
defaultMessage: 'New chat',
})}
</EuiButton>
);
}

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ComponentMeta, ComponentStoryObj } from '@storybook/react';
import { RegenerateResponseButton as Component } from './regenerate_response_button';
const meta: ComponentMeta<typeof Component> = {
component: Component,
title: 'app/Atoms/RegenerateResponseButton',
};
export default meta;
export const RegenerateResponseButton: ComponentStoryObj<typeof Component> = {
args: {},
};

View file

@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n';
export function RegenerateResponseButton(props: Partial<EuiButtonEmptyProps>) {
return (
<EuiButtonEmpty {...props} iconType="sparkles" size="s">
<EuiButtonEmpty size="s" {...props} iconType="sparkles">
{i18n.translate('xpack.observabilityAiAssistant.regenerateResponseButtonLabel', {
defaultMessage: 'Regenerate',
})}

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ComponentMeta, ComponentStoryObj } from '@storybook/react';
import { StartChatButton as Component } from './start_chat_button';
const meta: ComponentMeta<typeof Component> = {
component: Component,
title: 'app/Atoms/StartChatButton',
};
export default meta;
export const StartChatButton: ComponentStoryObj<typeof Component> = {
args: {},
};

View file

@ -5,10 +5,10 @@
* 2.0.
*/
import React from 'react';
import { EuiButton, EuiButtonProps } from '@elastic/eui';
import { EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export function StartChatButton(props: Partial<EuiButtonProps>) {
export function StartChatButton(props: React.ComponentProps<typeof EuiButton>) {
return (
<EuiButton {...props} fill iconType="discuss" size="s">
{i18n.translate('xpack.observabilityAiAssistant.insight.response.startChat', {

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ComponentMeta, ComponentStoryObj } from '@storybook/react';
import { StopGeneratingButton as Component } from './stop_generating_button';
const meta: ComponentMeta<typeof Component> = {
component: Component,
title: 'app/Atoms/StopGeneratingButton',
};
export default meta;
export const StopGeneratingButton: ComponentStoryObj<typeof Component> = {
args: {},
};

View file

@ -0,0 +1,20 @@
/*
* 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 { EuiButtonEmpty, EuiButtonEmptyProps } from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
export function StopGeneratingButton(props: Partial<EuiButtonEmptyProps>) {
return (
<EuiButtonEmpty size="s" {...props} iconType="stop" color="text">
{i18n.translate('xpack.observabilityAiAssistant.stopGeneratingButtonLabel', {
defaultMessage: 'Stop generating',
})}
</EuiButtonEmpty>
);
}

View file

@ -0,0 +1,64 @@
/*
* 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 { ComponentStory } from '@storybook/react';
import React from 'react';
import { Observable } from 'rxjs';
import { getSystemMessage } from '../../service/get_system_message';
import { ObservabilityAIAssistantService } from '../../types';
import { ChatBody as Component } from './chat_body';
export default {
component: Component,
title: 'app/Organisms/ChatBody',
};
type ChatBodyProps = React.ComponentProps<typeof Component>;
const Template: ComponentStory<typeof Component> = (props: ChatBodyProps) => {
return (
<div style={{ minHeight: 800, display: 'flex' }}>
<Component {...props} />
</div>
);
};
const defaultProps: ChatBodyProps = {
title: 'My Conversation',
messages: [getSystemMessage()],
connectors: {
connectors: [
{
id: 'foo',
referencedByCount: 1,
actionTypeId: 'foo',
name: 'GPT-v8-ultra',
isPreconfigured: true,
isDeprecated: false,
isSystemAction: false,
},
],
loading: false,
error: undefined,
selectedConnector: 'foo',
selectConnector: () => {},
},
connectorsManagementHref: '',
currentUser: {
username: 'elastic',
},
service: {
chat: () => {
return new Observable();
},
} as unknown as ObservabilityAIAssistantService,
onChatUpdate: () => {},
onChatComplete: () => {},
};
export const ChatBody = Template.bind({});
ChatBody.args = defaultProps;

View file

@ -0,0 +1,149 @@
/*
* 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,
EuiHorizontalRule,
EuiLoadingSpinner,
EuiPanel,
EuiSpacer,
useEuiTheme,
} from '@elastic/eui';
import { css } from '@emotion/css';
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
import React from 'react';
import type { Message } from '../../../common/types';
import type { UseGenAIConnectorsResult } from '../../hooks/use_genai_connectors';
import { useTimeline } from '../../hooks/use_timeline';
import { ObservabilityAIAssistantService } from '../../types';
import { HideExpandConversationListButton } from '../buttons/hide_expand_conversation_list_button';
import { MissingCredentialsCallout } from '../missing_credentials_callout';
import { ChatHeader } from './chat_header';
import { ChatPromptEditor } from './chat_prompt_editor';
import { ChatTimeline } from './chat_timeline';
const containerClassName = css`
max-height: 100%;
`;
const timelineClassName = css`
overflow-y: auto;
`;
const loadingSpinnerContainerClassName = css`
align-self: center;
`;
export function ChatBody({
title,
messages,
connectors,
currentUser,
service,
connectorsManagementHref,
isConversationListExpanded,
onToggleExpandConversationList,
onChatUpdate,
onChatComplete,
}: {
title: string;
messages: Message[];
connectors: UseGenAIConnectorsResult;
currentUser?: Pick<AuthenticatedUser, 'full_name' | 'username'>;
service: ObservabilityAIAssistantService;
connectorsManagementHref: string;
isConversationListExpanded?: boolean;
onToggleExpandConversationList?: () => void;
onChatUpdate: (messages: Message[]) => void;
onChatComplete: (messages: Message[]) => void;
}) {
const { euiTheme } = useEuiTheme();
const timeline = useTimeline({
messages,
connectors,
currentUser,
service,
onChatUpdate,
onChatComplete,
});
let footer: React.ReactNode;
if (connectors.loading || connectors.connectors?.length === 0) {
footer = (
<>
<EuiSpacer size="l" />
{connectors.connectors?.length === 0 ? (
<MissingCredentialsCallout connectorsManagementHref={connectorsManagementHref} />
) : (
<EuiFlexItem className={loadingSpinnerContainerClassName}>
<EuiLoadingSpinner />
</EuiFlexItem>
)}
</>
);
} else {
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>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHorizontalRule margin="none" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="m">
<ChatPromptEditor
loading={false}
disabled={!connectors.selectedConnector}
onSubmit={timeline.onSubmit}
/>
</EuiPanel>
</EuiFlexItem>
</>
);
}
return (
<EuiFlexGroup direction="column" gutterSize="none" className={containerClassName}>
<EuiFlexItem grow={false}>
{onToggleExpandConversationList ? (
<EuiPanel
hasShadow={false}
hasBorder={false}
borderRadius="none"
css={{ borderBottom: `solid 1px ${euiTheme.border.color}` }}
>
<HideExpandConversationListButton
isExpanded={Boolean(isConversationListExpanded)}
onClick={onToggleExpandConversationList}
/>
</EuiPanel>
) : null}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="m">
<ChatHeader title={title} connectors={connectors} />
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHorizontalRule margin="none" />
</EuiFlexItem>
{footer}
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,38 @@
/*
* 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 { ComponentStory } from '@storybook/react';
import React from 'react';
import { getSystemMessage } from '../../service/get_system_message';
import { KibanaReactStorybookDecorator } from '../../utils/storybook_decorator';
import { ChatFlyout as Component } from './chat_flyout';
export default {
component: Component,
title: 'app/Organisms/ChatFlyout',
decorators: [KibanaReactStorybookDecorator],
};
type ChatFlyoutProps = React.ComponentProps<typeof Component>;
const Template: ComponentStory<typeof Component> = (props: ChatFlyoutProps) => {
return (
<div style={{ display: 'flex', minHeight: 800 }}>
<Component {...props} />
</div>
);
};
const defaultProps: ChatFlyoutProps = {
isOpen: true,
title: 'How is this working',
messages: [getSystemMessage()],
onClose: () => {},
};
export const ChatFlyout = Template.bind({});
ChatFlyout.args = defaultProps;

View file

@ -0,0 +1,62 @@
/*
* 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, EuiFlyout } from '@elastic/eui';
import React, { useState } from 'react';
import type { Message } from '../../../common/types';
import { useCurrentUser } from '../../hooks/use_current_user';
import { useGenAIConnectors } from '../../hooks/use_genai_connectors';
import { useKibana } from '../../hooks/use_kibana';
import { useObservabilityAIAssistant } from '../../hooks/use_observability_ai_assistant';
import { getConnectorsManagementHref } from '../../utils/get_connectors_management_href';
import { ChatBody } from './chat_body';
export function ChatFlyout({
title,
messages,
isOpen,
onClose,
}: {
title: string;
messages: Message[];
isOpen: boolean;
onClose: () => void;
}) {
const connectors = useGenAIConnectors();
const currentUser = useCurrentUser();
const {
services: { http },
} = useKibana();
const [isConversationListExpanded, setIsConversationListExpanded] = useState(false);
const service = useObservabilityAIAssistant();
return isOpen ? (
<EuiFlyout onClose={onClose}>
<EuiFlexGroup responsive={false} gutterSize="none">
<EuiFlexItem>
<ChatBody
service={service}
connectors={connectors}
title={title}
messages={messages}
currentUser={currentUser}
connectorsManagementHref={getConnectorsManagementHref(http)}
isConversationListExpanded={isConversationListExpanded}
onToggleExpandConversationList={() =>
setIsConversationListExpanded(!isConversationListExpanded)
}
onChatComplete={() => {}}
onChatUpdate={() => {}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyout>
) : null;
}

View file

@ -0,0 +1,40 @@
/*
* 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 { ComponentMeta, ComponentStoryObj } from '@storybook/react';
import { FindActionResult } from '@kbn/actions-plugin/server';
import { EuiPanel } from '@elastic/eui';
import { ChatHeader as Component } from './chat_header';
const meta: ComponentMeta<typeof Component> = {
component: Component,
title: 'app/Molecules/ChatHeader',
};
export default meta;
export const ChatHeaderLoaded: ComponentStoryObj<typeof Component> = {
args: {
title: 'My conversation',
connectors: {
loading: false,
selectedConnector: 'gpt-4',
connectors: [
{ id: 'gpt-4', name: 'OpenAI GPT-4' },
{ id: 'gpt-3.5-turbo', name: 'OpenAI GPT-3.5 Turbo' },
] as FindActionResult[],
selectConnector: () => {},
},
},
render: (props) => {
return (
<EuiPanel hasBorder hasShadow={false}>
<Component {...props} />
</EuiPanel>
);
},
};

View file

@ -0,0 +1,52 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiTitle, useEuiTheme } from '@elastic/eui';
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';
export function ChatHeader({
title,
connectors,
}: {
title: string;
connectors: UseGenAIConnectorsResult;
}) {
const hasTitle = !!title;
const displayedTitle = title || EMPTY_CONVERSATION_TITLE;
const theme = useEuiTheme();
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>
);
}

View file

@ -0,0 +1,195 @@
/*
* 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 {
EuiButtonIcon,
EuiComment,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
} from '@elastic/eui';
import { css } from '@emotion/css';
import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
import { MessageRole } from '../../../common/types';
import { Feedback, FeedbackButtons } from '../feedback_buttons';
import { MessagePanel } from '../message_panel/message_panel';
import { MessageText } from '../message_panel/message_text';
import { RegenerateResponseButton } from '../buttons/regenerate_response_button';
import { StopGeneratingButton } from '../buttons/stop_generating_button';
import { ChatItemAvatar } from './chat_item_avatar';
import { ChatItemTitle } from './chat_item_title';
import { ChatTimelineItem } from './chat_timeline';
export interface ChatItemAction {
id: string;
label: string;
icon?: string;
handler: () => void;
}
export interface ChatItemProps extends ChatTimelineItem {
onEditSubmit: (content: string) => void;
onFeedbackClick: (feedback: Feedback) => void;
onRegenerateClick: () => void;
onStopGeneratingClick: () => void;
}
const euiCommentClassName = css`
.euiCommentEvent__headerEvent {
flex-grow: 1;
}
> div:last-child {
overflow: hidden;
}
`;
export function ChatItem({
title,
content,
canEdit,
canGiveFeedback,
canRegenerate,
role,
loading,
error,
currentUser,
onEditSubmit,
onRegenerateClick,
onStopGeneratingClick,
onFeedbackClick,
}: ChatItemProps) {
const [isActionsPopoverOpen, setIsActionsPopover] = useState(false);
const handleClickActions = () => {
setIsActionsPopover(!isActionsPopoverOpen);
};
const [_, setEditing] = useState(false);
const actions: ChatItemAction[] = canEdit
? [
{
id: 'edit',
label: i18n.translate('xpack.observabilityAiAssistant.chatTimeline.actions.editMessage', {
defaultMessage: 'Edit message',
}),
handler: () => {
setEditing(false);
setIsActionsPopover(false);
},
},
]
: [];
let controls: React.ReactNode;
const displayFeedback = !error && canGiveFeedback;
const displayRegenerate = !loading && canRegenerate;
if (loading) {
controls = <StopGeneratingButton onClick={onStopGeneratingClick} />;
} else if (displayFeedback || displayRegenerate) {
controls = (
<EuiFlexGroup justifyContent="flexEnd">
{displayFeedback ? (
<EuiFlexItem grow={true}>
<FeedbackButtons onClickFeedback={onFeedbackClick} />
</EuiFlexItem>
) : null}
{displayRegenerate ? (
<EuiFlexItem grow={false} style={{ alignSelf: 'flex-end' }}>
<RegenerateResponseButton onClick={onRegenerateClick} />
</EuiFlexItem>
) : null}
</EuiFlexGroup>
);
}
return (
<EuiComment
event={
<ChatItemTitle
actionsTrigger={
actions.length ? (
<EuiPopover
anchorPosition="downLeft"
button={
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.observabilityAiAssistant.chatTimeline.actions',
{
defaultMessage: 'Actions',
}
)}
color="text"
display="empty"
iconType="boxesHorizontal"
size="s"
onClick={handleClickActions}
/>
}
panelPaddingSize="s"
closePopover={handleClickActions}
isOpen={isActionsPopoverOpen}
>
<EuiContextMenuPanel
size="s"
items={actions.map(({ id, icon, label, handler }) => (
<EuiContextMenuItem key={id} icon={icon} onClick={handler}>
{label}
</EuiContextMenuItem>
))}
/>
</EuiPopover>
) : null
}
title={title}
/>
}
className={euiCommentClassName}
timelineAvatar={
<ChatItemAvatar loading={loading && !content} currentUser={currentUser} role={role} />
}
username={getRoleTranslation(role)}
>
{content || error || controls ? (
<MessagePanel
body={
content || loading ? <MessageText content={content || ''} loading={loading} /> : null
}
error={error}
controls={controls}
/>
) : null}
</EuiComment>
);
}
const getRoleTranslation = (role: MessageRole) => {
if (role === MessageRole.User) {
return i18n.translate('xpack.observabilityAiAssistant.chatTimeline.messages.user.label', {
defaultMessage: 'You',
});
}
if (role === MessageRole.System) {
return i18n.translate('xpack.observabilityAiAssistant.chatTimeline.messages.system.label', {
defaultMessage: 'System',
});
}
return i18n.translate(
'xpack.observabilityAiAssistant.chatTimeline.messages.elasticAssistant.label',
{
defaultMessage: 'Elastic Assistant',
}
);
};

View file

@ -0,0 +1,43 @@
/*
* 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 { UserAvatar } from '@kbn/user-profile-components';
import { EuiAvatar, EuiLoadingSpinner } from '@elastic/eui';
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
import { AssistantAvatar } from '../assistant_avatar';
import { MessageRole } from '../../../common/types';
interface ChatAvatarProps {
currentUser?: Pick<AuthenticatedUser, 'full_name' | 'username'> | undefined;
role: MessageRole;
loading: boolean;
}
export function ChatItemAvatar({ currentUser, role, loading }: ChatAvatarProps) {
const isLoading = loading || !currentUser;
if (isLoading) {
return <EuiLoadingSpinner size="xl" />;
}
switch (role) {
case MessageRole.User:
return <UserAvatar user={currentUser} size="m" data-test-subj="userMenuAvatar" />;
case MessageRole.Assistant:
case MessageRole.Elastic:
case MessageRole.Function:
return <EuiAvatar name="Elastic Assistant" iconType={AssistantAvatar} color="subdued" />;
case MessageRole.System:
return <EuiAvatar name="system" iconType="dot" color="subdued" />;
default:
return 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 { euiThemeVars } from '@kbn/ui-theme';
import React, { ReactNode } from 'react';
interface ChatItemTitleProps {
actionsTrigger?: ReactNode;
title: string;
}
export function ChatItemTitle({ actionsTrigger, title }: ChatItemTitleProps) {
return (
<>
{title}
{actionsTrigger ? (
<div css={{ position: 'absolute', top: 2, right: euiThemeVars.euiSizeS }}>
{actionsTrigger}
</div>
) : null}
</>
);
}

View file

@ -0,0 +1,38 @@
/*
* 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 { ComponentStory } from '@storybook/react';
import { ChatPromptEditor as Component, ChatPromptEditorProps } from './chat_prompt_editor';
import { KibanaReactStorybookDecorator } from '../../utils/storybook_decorator';
/*
JSON Schema validation in the ChatPromptEditor compponent does not work
when rendering the component from within Storybook.
*/
export default {
component: Component,
title: 'app/Molecules/ChatPromptEditor',
argTypes: {},
parameters: {
backgrounds: {
default: 'white',
values: [{ name: 'white', value: '#fff' }],
},
},
decorators: [KibanaReactStorybookDecorator],
};
const Template: ComponentStory<typeof Component> = (props: ChatPromptEditorProps) => {
return <Component {...props} />;
};
const defaultProps = {};
export const ChatPromptEditor = Template.bind({});
ChatPromptEditor.args = defaultProps;

View file

@ -0,0 +1,219 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
EuiButtonIcon,
EuiButtonEmpty,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiPanel,
keys,
} from '@elastic/eui';
import { CodeEditor } from '@kbn/kibana-react-plugin/public';
import { i18n } from '@kbn/i18n';
import { useObservabilityAIAssistant } from '../../hooks/use_observability_ai_assistant';
import { useJsonEditorModel } from '../../hooks/use_json_editor_model';
import { type Message, MessageRole } from '../../../common';
import type { FunctionDefinition } from '../../../common/types';
import { FunctionListPopover } from './function_list_popover';
export interface ChatPromptEditorProps {
disabled: boolean;
loading: boolean;
onSubmit: (message: Message) => Promise<void>;
}
export function ChatPromptEditor({ onSubmit, disabled, loading }: ChatPromptEditorProps) {
const { getFunctions } = useObservabilityAIAssistant();
const functions = getFunctions();
const [prompt, setPrompt] = useState('');
const [functionPayload, setFunctionPayload] = useState<string | undefined>('');
const [selectedFunction, setSelectedFunction] = useState<FunctionDefinition | undefined>();
const { model, initialJsonString } = useJsonEditorModel(selectedFunction);
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
setFunctionPayload(initialJsonString);
}, [initialJsonString, selectedFunction]);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setPrompt(event.currentTarget.value);
};
const handleChangeFunctionPayload = (params: string) => {
setFunctionPayload(params);
};
const handleClearSelection = () => {
setSelectedFunction(undefined);
setFunctionPayload('');
};
const handleSubmit = useCallback(async () => {
const currentPrompt = prompt;
const currentPayload = functionPayload;
setPrompt('');
setFunctionPayload(undefined);
try {
if (selectedFunction) {
await onSubmit({
'@timestamp': new Date().toISOString(),
message: {
role: MessageRole.Function,
function_call: {
name: selectedFunction.options.name,
trigger: MessageRole.User,
arguments: currentPayload,
},
},
});
} else {
await onSubmit({
'@timestamp': new Date().toISOString(),
message: { role: MessageRole.User, content: currentPrompt },
});
setPrompt('');
}
} catch (_) {
setPrompt(currentPrompt);
}
}, [functionPayload, onSubmit, prompt, selectedFunction]);
useEffect(() => {
const keyboardListener = (event: KeyboardEvent) => {
if (event.key === keys.ENTER) {
handleSubmit();
}
};
window.addEventListener('keyup', keyboardListener);
return () => {
window.removeEventListener('keyup', keyboardListener);
};
}, [handleSubmit]);
useEffect(() => {
if (ref.current) {
ref.current.focus();
}
});
return (
<EuiFlexGroup gutterSize="s" responsive={false}>
<EuiFlexItem grow>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<EuiFlexGroup responsive={false}>
<EuiFlexItem grow>
<FunctionListPopover
functions={functions}
selectedFunction={selectedFunction}
onSelectFunction={setSelectedFunction}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{selectedFunction ? (
<EuiButtonEmpty
iconType="cross"
iconSide="right"
size="xs"
onClick={handleClearSelection}
>
{i18n.translate('xpack.observabilityAiAssistant.prompt.emptySelection', {
defaultMessage: 'Empty selection',
})}
</EuiButtonEmpty>
) : null}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
{selectedFunction ? (
<EuiPanel borderRadius="none" color="subdued" hasShadow={false} paddingSize="xs">
<CodeEditor
aria-label="payloadEditor"
fullWidth
height="120px"
languageId="json"
value={functionPayload || ''}
onChange={handleChangeFunctionPayload}
isCopyable
languageConfiguration={{
autoClosingPairs: [
{
open: '{',
close: '}',
},
],
}}
options={{
accessibilitySupport: 'off',
acceptSuggestionOnEnter: 'on',
automaticLayout: true,
autoClosingQuotes: 'always',
autoIndent: 'full',
contextmenu: true,
fontSize: 12,
formatOnPaste: true,
formatOnType: true,
inlineHints: { enabled: true },
lineNumbers: 'on',
minimap: { enabled: false },
model,
overviewRulerBorder: false,
quickSuggestions: true,
scrollbar: { alwaysConsumeMouseWheel: false },
scrollBeyondLastLine: false,
suggestOnTriggerCharacters: true,
tabSize: 2,
wordWrap: 'on',
wrappingIndent: 'indent',
}}
transparentBackground
/>
</EuiPanel>
) : (
<EuiFieldText
fullWidth
value={prompt}
placeholder={i18n.translate('xpack.observabilityAiAssistant.prompt.placeholder', {
defaultMessage: 'Press $ for function recommendations',
})}
inputRef={ref}
onChange={handleChange}
onSubmit={handleSubmit}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSpacer size="xl" />
<EuiButtonIcon
aria-label="Submit"
isLoading={loading}
disabled={selectedFunction ? false : !prompt || loading || disabled}
display={
selectedFunction ? (functionPayload ? 'fill' : 'base') : prompt ? 'fill' : 'base'
}
iconType="kqlFunction"
size="m"
onClick={handleSubmit}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,110 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButton, EuiSpacer } from '@elastic/eui';
import { ComponentStory } from '@storybook/react';
import React, { ComponentProps, useState } from 'react';
import { MessageRole } from '../../../common';
import {
buildAssistantChatItem,
buildChatInitItem,
buildFunctionChatItem,
buildUserChatItem,
} from '../../utils/builders';
import { ChatTimeline as Component, ChatTimelineProps } from './chat_timeline';
export default {
component: Component,
title: 'app/Organisms/ChatTimeline',
parameters: {
backgrounds: {
default: 'white',
values: [{ name: 'white', value: '#fff' }],
},
},
argTypes: {},
};
const Template: ComponentStory<typeof Component> = (props: ChatTimelineProps) => {
const [count, setCount] = useState(props.items.length - 1);
return (
<>
<Component {...props} items={props.items.filter((_, index) => index <= count)} />
<EuiSpacer />
<EuiButton
onClick={() => setCount(count >= 0 && count < props.items.length - 1 ? count + 1 : 0)}
>
Add message
</EuiButton>
</>
);
};
const defaultProps: ComponentProps<typeof Component> = {
items: [
buildChatInitItem(),
buildUserChatItem(),
buildAssistantChatItem(),
buildUserChatItem({ content: 'How does it work?' }),
buildAssistantChatItem({
content: `The way functions work depends on whether we are talking about mathematical functions or programming functions. Let's explore both:
Mathematical Functions:
In mathematics, a function maps input values to corresponding output values based on a specific rule or expression. The general process of how a mathematical function works can be summarized as follows:
Step 1: Input - You provide an input value to the function, denoted as 'x' in the notation f(x). This value represents the independent variable.
Step 2: Processing - The function takes the input value and applies a specific rule or algorithm to it. This rule is defined by the function itself and varies depending on the function's expression.
Step 3: Output - After processing the input, the function produces an output value, denoted as 'f(x)' or 'y'. This output represents the dependent variable and is the result of applying the function's rule to the input.
Step 4: Uniqueness - A well-defined mathematical function ensures that each input value corresponds to exactly one output value. In other words, the function should yield the same output for the same input whenever it is called.`,
}),
buildUserChatItem({
content: 'Can you execute a function?',
}),
buildAssistantChatItem({
content: 'Sure, I can do that.',
title: 'suggested a function',
function_call: {
name: 'a_function',
arguments: '{ "foo": "bar" }',
trigger: MessageRole.Assistant,
},
canEdit: true,
}),
buildFunctionChatItem({
content: '{ "message": "The arguments are wrong" }',
error: new Error(),
canRegenerate: false,
}),
buildAssistantChatItem({
content: '',
title: 'suggested a function',
function_call: {
name: 'a_function',
arguments: '{ "bar": "foo" }',
trigger: MessageRole.Assistant,
},
canEdit: true,
}),
buildFunctionChatItem({
content: '',
title: 'are executing a function',
loading: true,
}),
],
onEdit: () => {},
onFeedback: () => {},
onRegenerate: () => {},
onStopGenerating: () => {},
};
export const ChatTimeline = Template.bind({});
ChatTimeline.args = defaultProps;

View file

@ -0,0 +1,63 @@
/*
* 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 { EuiCommentList } from '@elastic/eui';
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
import React from 'react';
import type { Message } from '../../../common';
import type { Feedback } from '../feedback_buttons';
import { ChatItem } from './chat_item';
export interface ChatTimelineItem
extends Pick<Message['message'], 'role' | 'content' | 'function_call'> {
id: string;
title: string;
loading: boolean;
error?: any;
canEdit: boolean;
canRegenerate: boolean;
canGiveFeedback: boolean;
currentUser?: Pick<AuthenticatedUser, 'username' | 'full_name'>;
}
export interface ChatTimelineProps {
items: ChatTimelineItem[];
onEdit: (item: ChatTimelineItem, content: string) => void;
onFeedback: (item: ChatTimelineItem, feedback: Feedback) => void;
onRegenerate: (item: ChatTimelineItem) => void;
onStopGenerating: () => void;
}
export function ChatTimeline({
items = [],
onEdit,
onFeedback,
onRegenerate,
onStopGenerating,
}: ChatTimelineProps) {
return (
<EuiCommentList>
{items.map((item, index) => (
<ChatItem
// use index, not id to prevent unmounting of component when message is persisted
key={index}
{...item}
onFeedbackClick={(feedback) => {
onFeedback(item, feedback);
}}
onRegenerateClick={() => {
onRegenerate(item);
}}
onEditSubmit={(content) => {
onEdit(item, content);
}}
onStopGeneratingClick={onStopGenerating}
/>
))}
</EuiCommentList>
);
}

View file

@ -0,0 +1,76 @@
/*
* 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 { ComponentMeta, ComponentStoryObj } from '@storybook/react';
import React from 'react';
import { KibanaReactStorybookDecorator } from '../../utils/storybook_decorator';
import { ConversationList as Component } from './conversation_list';
type ConversationListProps = React.ComponentProps<typeof Component>;
const meta: ComponentMeta<typeof Component> = {
component: Component,
title: 'app/Organisms/ConversationList',
decorators: [KibanaReactStorybookDecorator],
};
export default meta;
const Wrapper = (props: ConversationListProps) => {
return (
<div style={{ minHeight: 800, maxWidth: 240, display: 'flex' }}>
<Component {...props} />
</div>
);
};
export const ChatHeaderLoading: ComponentStoryObj<typeof Component> = {
args: {
loading: true,
},
render: Wrapper,
};
export const ChatHeaderError: ComponentStoryObj<typeof Component> = {
args: {
error: new Error(),
},
render: Wrapper,
};
export const ChatHeaderLoaded: ComponentStoryObj<typeof Component> = {
args: {
loading: false,
selected: '',
conversations: [
{
id: '',
label: 'New conversation',
},
{
id: 'first',
label: 'My first conversation',
href: '/my-first-conversation',
},
{
id: 'second',
label: 'My second conversation',
href: '/my-second-conversation',
},
],
},
render: Wrapper,
};
export const ChatHeaderEmpty: ComponentStoryObj<typeof Component> = {
args: {
loading: false,
selected: '',
conversations: [],
},
render: Wrapper,
};

View file

@ -0,0 +1,147 @@
/*
* 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,
EuiListGroup,
EuiListGroupItem,
EuiLoadingSpinner,
EuiPanel,
EuiText,
} from '@elastic/eui';
import { css } from '@emotion/css';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { NewChatButton } from '../buttons/new_chat_button';
const containerClassName = css`
height: 100%;
`;
const titleClassName = css`
text-transform: uppercase;
`;
export function ConversationList({
selected,
onClickNewChat,
loading,
error,
conversations,
onClickDeleteConversation,
}: {
selected: string;
onClickConversation: (conversationId: string) => void;
onClickNewChat: () => void;
loading: boolean;
error?: any;
conversations?: Array<{ id: string; label: string; href?: string }>;
onClickDeleteConversation: (id: string) => void;
}) {
return (
<EuiPanel paddingSize="s" hasShadow={false}>
<EuiFlexGroup direction="column" gutterSize="none" className={containerClassName}>
<EuiFlexItem grow>
<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}>
<EuiText className={titleClassName} size="s">
<strong>
{i18n.translate('xpack.observabilityAiAssistant.conversationList.title', {
defaultMessage: 'Previously',
})}
</strong>
</EuiText>
</EuiFlexItem>
{loading ? (
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="s" />
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
{error ? (
<EuiFlexItem grow={false}>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
<EuiFlexGroup direction="row" alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiIcon type="warning" color="danger" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" color="danger">
{i18n.translate(
'xpack.observabilityAiAssistant.conversationList.errorMessage',
{
defaultMessage: 'Failed to load',
}
)}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
) : null}
{conversations?.length ? (
<EuiFlexItem grow>
<EuiListGroup flush={false} gutterSize="none">
{conversations?.map((conversation) => (
<EuiListGroupItem
key={conversation.id}
label={conversation.label}
size="s"
isActive={conversation.id === selected}
isDisabled={loading}
href={conversation.href}
extraAction={
conversation.id
? {
iconType: 'trash',
onClick: () => {
onClickDeleteConversation(conversation.id);
},
}
: undefined
}
/>
))}
</EuiListGroup>
</EuiFlexItem>
) : null}
{!loading && !error && !conversations?.length ? (
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
<EuiText color="subdued" size="s">
{i18n.translate(
'xpack.observabilityAiAssistant.conversationList.noConversations',
{
defaultMessage: 'No conversations',
}
)}
</EuiText>
</EuiPanel>
) : null}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPanel paddingSize="s" hasBorder={false} hasShadow={false}>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow>
<NewChatButton onClick={onClickNewChat} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}

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 { ComponentStory } from '@storybook/react';
import React from 'react';
import { KibanaReactStorybookDecorator } from '../../utils/storybook_decorator';
import { FunctionListPopover as Component } from './function_list_popover';
export default {
component: Component,
title: 'app/Organisms/FunctionListPopover',
decorators: [KibanaReactStorybookDecorator],
};
type FunctionListPopover = React.ComponentProps<typeof Component>;
const Template: ComponentStory<typeof Component> = (props: FunctionListPopover) => {
return <Component {...props} />;
};
const defaultProps: FunctionListPopover = {
functions: [],
onSelectFunction: () => {},
};
export const ConversationList = Template.bind({});
ConversationList.args = defaultProps;

View file

@ -0,0 +1,92 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import {
EuiButtonEmpty,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiPopover,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FunctionDefinition } from '../../../common/types';
export function FunctionListPopover({
functions,
selectedFunction,
onSelectFunction,
}: {
functions: FunctionDefinition[];
selectedFunction?: FunctionDefinition;
onSelectFunction: (func: FunctionDefinition) => void;
}) {
const [isFunctionListOpen, setIsFunctionListOpen] = useState(false);
const handleClickFunctionList = () => {
setIsFunctionListOpen(!isFunctionListOpen);
};
const handleSelectFunction = (func: FunctionDefinition) => {
setIsFunctionListOpen(false);
onSelectFunction(func);
};
useEffect(() => {
const keyboardListener = (event: KeyboardEvent) => {
if (event.shiftKey && event.code === 'Digit4') {
setIsFunctionListOpen(true);
}
};
window.addEventListener('keyup', keyboardListener);
return () => {
window.removeEventListener('keyup', keyboardListener);
};
}, []);
return (
<EuiPopover
anchorPosition="downLeft"
button={
<EuiButtonEmpty
iconType="arrowRight"
iconSide="right"
size="xs"
onClick={handleClickFunctionList}
>
{selectedFunction
? selectedFunction.options.name
: i18n.translate('xpack.observabilityAiAssistant.prompt.callFunction', {
defaultMessage: 'Call function',
})}
</EuiButtonEmpty>
}
closePopover={handleClickFunctionList}
panelPaddingSize="none"
isOpen={isFunctionListOpen}
>
<EuiContextMenuPanel size="s">
{functions.map((func) => (
<EuiContextMenuItem key={func.options.name} onClick={() => handleSelectFunction(func)}>
<EuiText size="s">
<p>
<strong>{func.options.name}</strong>
</p>
</EuiText>
<EuiSpacer size="xs" />
<EuiText size="s">
<p>{func.options.description}</p>
</EuiText>
</EuiContextMenuItem>
))}
</EuiContextMenuPanel>
</EuiPopover>
);
}

View file

@ -4,9 +4,11 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { ComponentMeta, ComponentStoryObj } from '@storybook/react';
import { FindActionResult } from '@kbn/actions-plugin/server';
import { ComponentProps } from 'react';
import { EuiPanel } from '@elastic/eui';
import { ConnectorSelectorBase as Component } from './connector_selector_base';
const meta: ComponentMeta<typeof Component> = {
@ -16,6 +18,14 @@ const meta: ComponentMeta<typeof Component> = {
export default meta;
const render = (props: ComponentProps<typeof Component>) => {
return (
<EuiPanel hasBorder hasShadow={false}>
<Component {...props} />
</EuiPanel>
);
};
export const Loaded: ComponentStoryObj<typeof Component> = {
args: {
loading: false,
@ -25,12 +35,14 @@ export const Loaded: ComponentStoryObj<typeof Component> = {
{ id: 'gpt-3.5-turbo', name: 'OpenAI GPT-3.5 Turbo' },
] as FindActionResult[],
},
render,
};
export const Loading: ComponentStoryObj<typeof Component> = {
args: {
loading: true,
},
render,
};
export const Empty: ComponentStoryObj<typeof Component> = {
@ -38,6 +50,7 @@ export const Empty: ComponentStoryObj<typeof Component> = {
loading: false,
connectors: [],
},
render,
};
export const FailedToLoad: ComponentStoryObj<typeof Component> = {
@ -45,4 +58,5 @@ export const FailedToLoad: ComponentStoryObj<typeof Component> = {
loading: false,
error: new Error('Failed to load connectors'),
},
render,
};

View file

@ -26,6 +26,7 @@ const wrapperClassName = css`
border: none;
box-shadow: none;
background: none;
padding-left: 0;
}
`;

View file

@ -4,26 +4,123 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { Message } from '../../../common/types';
import { useChat } from '../../hooks/use_chat';
import { AbortError } from '@kbn/kibana-utils-plugin/common';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { Subscription } from 'rxjs';
import { MessageRole, type Message } from '../../../common/types';
import { useGenAIConnectors } from '../../hooks/use_genai_connectors';
import { useObservabilityAIAssistant } from '../../hooks/use_observability_ai_assistant';
import type { PendingMessage } from '../../types';
import { ChatFlyout } from '../chat/chat_flyout';
import { ConnectorSelectorBase } from '../connector_selector/connector_selector_base';
import { MessagePanel } from '../message_panel/message_panel';
import { MessageText } from '../message_panel/message_text';
import { RegenerateResponseButton } from '../buttons/regenerate_response_button';
import { StartChatButton } from '../buttons/start_chat_button';
import { StopGeneratingButton } from '../buttons/stop_generating_button';
import { InsightBase } from './insight_base';
import { InsightMissingCredentials } from './insight_missing_credentials';
import { MissingCredentialsCallout } from '../missing_credentials_callout';
import { getConnectorsManagementHref } from '../../utils/get_connectors_management_href';
function ChatContent({ messages, connectorId }: { messages: Message[]; connectorId: string }) {
const chat = useChat({ messages, connectorId });
function ChatContent({
title,
messages,
connectorId,
}: {
title: string;
messages: Message[];
connectorId: string;
}) {
const service = useObservabilityAIAssistant();
const [pendingMessage, setPendingMessage] = useState<PendingMessage | undefined>();
const [loading, setLoading] = useState(false);
const [subscription, setSubscription] = useState<Subscription | undefined>();
const reloadReply = useCallback(() => {
setLoading(true);
const nextSubscription = service.chat({ messages, connectorId }).subscribe({
next: (msg) => {
setPendingMessage(() => msg);
},
complete: () => {
setLoading(false);
},
});
setSubscription(nextSubscription);
}, [messages, connectorId, service]);
useEffect(() => {
reloadReply();
}, [reloadReply]);
const [isOpen, setIsOpen] = useState(false);
const displayedMessages = useMemo(() => {
return pendingMessage
? messages.concat({
'@timestamp': new Date().toISOString(),
message: {
...pendingMessage.message,
},
})
: messages;
}, [pendingMessage, messages]);
return (
<MessagePanel
body={<MessageText content={chat.content ?? ''} loading={chat.loading} />}
error={chat.error}
controls={null}
/>
<>
<MessagePanel
body={<MessageText content={pendingMessage?.message.content ?? ''} loading={loading} />}
error={pendingMessage?.error}
controls={
loading ? (
<StopGeneratingButton
onClick={() => {
subscription?.unsubscribe();
setLoading(false);
setPendingMessage((prev) => ({
message: {
role: MessageRole.Assistant,
...prev?.message,
},
aborted: true,
error: new AbortError(),
}));
}}
/>
) : (
<EuiFlexGroup direction="row">
<EuiFlexItem grow={false}>
<RegenerateResponseButton
onClick={() => {
reloadReply();
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<StartChatButton
onClick={() => {
setIsOpen(() => true);
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
)
}
/>
<ChatFlyout
title={title}
isOpen={isOpen}
onClose={() => {
setIsOpen(() => false);
}}
messages={displayedMessages}
/>
</>
);
}
@ -39,14 +136,12 @@ export function Insight({ messages, title }: { messages: Message[]; title: strin
let children: React.ReactNode = null;
if (hasOpened && connectors.selectedConnector) {
children = <ChatContent messages={messages} connectorId={connectors.selectedConnector} />;
children = (
<ChatContent title={title} messages={messages} connectorId={connectors.selectedConnector} />
);
} else if (!connectors.loading && !connectors.connectors?.length) {
children = (
<InsightMissingCredentials
connectorsManagementHref={http!.basePath.prepend(
`/app/management/insightsAndAlerting/triggersActionsConnectors/connectors`
)}
/>
<MissingCredentialsCallout connectorsManagementHref={getConnectorsManagementHref(http!)} />
);
}

View file

@ -16,8 +16,8 @@ import { ConnectorSelectorBase } from '../connector_selector/connector_selector_
import { MessagePanel } from '../message_panel/message_panel';
import { MessageText } from '../message_panel/message_text';
import { FeedbackButtons } from '../feedback_buttons';
import { RegenerateResponseButton } from '../regenerate_response_button';
import { StartChatButton } from '../start_chat_button';
import { RegenerateResponseButton } from '../buttons/regenerate_response_button';
import { StartChatButton } from '../buttons/start_chat_button';
export default {
component: Component,

View file

@ -122,7 +122,9 @@ export function InsightBase({
onToggle={onToggle}
>
<EuiSpacer size="m" />
{children}
<EuiPanel hasBorder={false} hasShadow={false} color="subdued">
{children}
</EuiPanel>
</EuiAccordion>
</EuiPanel>
);

View file

@ -4,6 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiPanel } from '@elastic/eui';
import { ComponentMeta, ComponentStoryObj } from '@storybook/react';
import React from 'react';
import { FeedbackButtons } from '../feedback_buttons';
@ -18,12 +19,25 @@ const meta: ComponentMeta<typeof Component> = {
export default meta;
export const ContentLoading: ComponentStoryObj<typeof Component> = {
render: (props, context) => {
return (
<EuiPanel>
<Component {...props} />
</EuiPanel>
);
},
args: {
body: (
<MessageText
content={`# This is a piece of text.
And an extra _paragraph_.
\`This is inline code\`
\`\`\`
This is a code block
\`\`\`
#### With a title

View file

@ -9,7 +9,6 @@ import {
EuiFlexItem,
EuiHorizontalRule,
EuiIcon,
EuiPanel,
EuiSpacer,
EuiText,
} from '@elastic/eui';
@ -24,7 +23,7 @@ interface Props {
export function MessagePanel(props: Props) {
return (
<EuiPanel color="subdued" hasShadow={false}>
<>
{props.body}
{props.error ? (
<>
@ -51,6 +50,6 @@ export function MessagePanel(props: Props) {
{props.controls}
</>
) : null}
</EuiPanel>
</>
);
}

View file

@ -4,33 +4,37 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { v4 } from 'uuid';
import React from 'react';
import type { Node } from 'unist';
import { css } from '@emotion/css';
import type { Parent, Text } from 'mdast';
import ReactMarkdown from 'react-markdown';
import { EuiText } from '@elastic/eui';
import { css } from '@emotion/css';
import { euiThemeVars } from '@kbn/ui-theme';
import classNames from 'classnames';
import type { Code, InlineCode, Parent, Text } from 'mdast';
import React from 'react';
import ReactMarkdown from 'react-markdown';
import type { Node } from 'unist';
import { v4 } from 'uuid';
interface Props {
content: string;
loading: boolean;
}
const ANIMATION_TIME = 1;
const cursorCss = css`
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
animation: blink 1s infinite;
animation: blink ${ANIMATION_TIME}s infinite;
width: 10px;
height: 16px;
vertical-align: middle;
@ -38,7 +42,7 @@ const cursorCss = css`
background: rgba(0, 0, 0, 0.25);
`;
const cursor = <span className={cursorCss} />;
const Cursor = () => <span key="cursor" className={classNames(cursorCss, 'cursor')} />;
const CURSOR = `{{${v4()}}`;
@ -51,11 +55,11 @@ const loadingCursorPlugin = () => {
});
}
if (node.type !== 'text') {
if (node.type !== 'text' && node.type !== 'inlineCode' && node.type !== 'code') {
return;
}
const textNode = node as Text;
const textNode = node as Text | InlineCode | Code;
const indexOfCursor = textNode.value.indexOf(CURSOR);
if (indexOfCursor === -1) {
@ -80,13 +84,22 @@ const loadingCursorPlugin = () => {
};
export function MessageText(props: Props) {
const containerClassName = css`
overflow-wrap: break-word;
code {
background: ${euiThemeVars.euiColorLightestShade};
padding: 0 8px;
}
`;
return (
<EuiText size="s">
<EuiText size="s" className={containerClassName}>
<ReactMarkdown
plugins={[loadingCursorPlugin]}
components={
{
cursor: () => cursor,
cursor: Cursor,
} as Record<string, any>
}
>

View file

@ -13,16 +13,16 @@ interface Props {
connectorsManagementHref: string;
}
export function InsightMissingCredentials(props: Props) {
export function MissingCredentialsCallout(props: Props) {
return (
<EuiCallOut
title={i18n.translate('xpack.observabilityAiAssistant.insight.missing.title', {
title={i18n.translate('xpack.observabilityAiAssistant.missingCredentialsCallout.title', {
defaultMessage: 'Missing credentials',
})}
color="primary"
iconType="iInCircle"
>
{i18n.translate('xpack.observabilityAiAssistant.insight.missing.description', {
{i18n.translate('xpack.observabilityAiAssistant.missingCredentialsCallout.description', {
defaultMessage:
'You havent authorised OpenAI in order to generate responses from the Elastic Assistant. Authorise the model in order to proceed.',
})}
@ -30,7 +30,7 @@ export function InsightMissingCredentials(props: Props) {
<EuiSpacer size="m" />
<EuiButton fill color="primary" href={props.connectorsManagementHref}>
{i18n.translate('xpack.observabilityAiAssistant.insight.missing.buttonLabel', {
{i18n.translate('xpack.observabilityAiAssistant.missingCredentialsCallout.buttonLabel', {
defaultMessage: 'Connect Assistant',
})}
</EuiButton>

View file

@ -0,0 +1,43 @@
/*
* 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 { css } from '@emotion/css';
import React from 'react';
import { useKibana } from '../hooks/use_kibana';
const pageSectionContentClassName = css`
width: 100%;
display: flex;
flex-grow: 1;
padding-top: 0;
padding-bottom: 0;
`;
export function ObservabilityAIAssistantPageTemplate({ children }: { children: React.ReactNode }) {
const {
services: {
plugins: {
start: { observabilityShared },
},
},
} = useKibana();
const PageTemplate = observabilityShared.navigation.PageTemplate;
return (
<PageTemplate
pageSectionProps={{
alignment: 'horizontalCenter',
restrictWidth: true,
contentProps: {
className: pageSectionContentClassName,
},
}}
>
{children}
</PageTemplate>
);
}

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Serializable } from '@kbn/utility-types';
import type { RegisterFunctionDefinition } from '../../common/types';
import type { ObservabilityAIAssistantService } from '../types';
export function registerElasticsearchFunction({
service,
registerFunction,
}: {
service: ObservabilityAIAssistantService;
registerFunction: RegisterFunctionDefinition;
}) {
registerFunction(
{
name: 'elasticsearch',
contexts: ['core'],
description: 'Call Elasticsearch APIs on behalf of the user',
parameters: {
type: 'object',
properties: {
method: {
type: 'string',
description: 'The HTTP method of the Elasticsearch endpoint',
enum: ['GET', 'PUT', 'POST', 'DELETE', 'PATCH'] as const,
},
path: {
type: 'string',
description: 'The path of the Elasticsearch endpoint, including query parameters',
},
},
required: ['method' as const, 'path' as const],
},
},
({ arguments: { method, path, body } }, signal) => {
return service
.callApi(`POST /internal/observability_ai_assistant/functions/elasticsearch`, {
signal,
params: {
body: {
method,
path,
body,
},
},
})
.then((response) => ({ content: response as Serializable }));
}
);
}

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { RegisterContextDefinition, RegisterFunctionDefinition } from '../../common/types';
import type { ObservabilityAIAssistantService } from '../types';
import { registerElasticsearchFunction } from './elasticsearch';
import { registerRecallFunction } from './recall';
import { registerSetupKbFunction } from './setup_kb';
import { registerSummarisationFunction } from './summarise';
export function registerFunctions({
registerFunction,
registerContext,
service,
}: {
registerFunction: RegisterFunctionDefinition;
registerContext: RegisterContextDefinition;
service: ObservabilityAIAssistantService;
}) {
registerContext({
name: 'core',
description:
'Core functions, like calling Elasticsearch APIs, storing embeddables for instructions or creating base visualisations.',
});
registerElasticsearchFunction({ service, registerFunction });
registerSummarisationFunction({ service, registerFunction });
registerRecallFunction({ service, registerFunction });
registerSetupKbFunction({ service, registerFunction });
}

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Serializable } from '@kbn/utility-types';
import type { RegisterFunctionDefinition } from '../../common/types';
import type { ObservabilityAIAssistantService } from '../types';
export function registerRecallFunction({
service,
registerFunction,
}: {
service: ObservabilityAIAssistantService;
registerFunction: RegisterFunctionDefinition;
}) {
registerFunction(
{
name: 'recall',
contexts: ['core'],
description:
'Use this function to recall earlier learnings. Anything you will summarise can be retrieved again later via this function.',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The query for the semantic search',
},
},
required: ['query' as const],
},
},
({ arguments: { query } }, signal) => {
return service
.callApi('POST /internal/observability_ai_assistant/functions/recall', {
params: {
body: {
query,
},
},
signal,
})
.then((response) => ({ content: response as unknown as Serializable }));
}
);
}

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Serializable } from '@kbn/utility-types';
import type { RegisterFunctionDefinition } from '../../common/types';
import type { ObservabilityAIAssistantService } from '../types';
export function registerSetupKbFunction({
service,
registerFunction,
}: {
service: ObservabilityAIAssistantService;
registerFunction: RegisterFunctionDefinition;
}) {
registerFunction(
{
name: 'setup_kb',
contexts: ['core'],
description:
'Use this function to set up the knowledge base. ONLY use this if you got an error from the recall or summarise function, or if the user has explicitly requested it. Note that it might take a while (e.g. ten minutes) until the knowledge base is available. Assume it will not be ready for the rest of the current conversation.',
parameters: {
type: 'object',
properties: {},
},
},
({}, signal) => {
return service
.callApi('POST /internal/observability_ai_assistant/functions/setup_kb', {
signal,
})
.then((response) => ({ content: response as unknown as Serializable }));
}
);
}

View file

@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { RegisterFunctionDefinition } from '../../common/types';
import type { ObservabilityAIAssistantService } from '../types';
export function registerSummarisationFunction({
service,
registerFunction,
}: {
service: ObservabilityAIAssistantService;
registerFunction: RegisterFunctionDefinition;
}) {
registerFunction(
{
name: 'summarise',
contexts: ['core'],
description:
'Use this function to summarise things learned from the conversation. You can score the learnings with a confidence metric, whether it is a correction on a previous learning. An embedding will be created that you can recall later with a semantic search. There is no need to ask the user for permission to store something you have learned, unless you do not feel confident.',
parameters: {
type: 'object',
properties: {
id: {
type: 'string',
description:
'An id for the document. This should be a short human-readable keyword field with only alphabetic characters and underscores, that allow you to update it later.',
},
text: {
type: 'string',
description:
'A human-readable summary of what you have learned, described in such a way that you can recall it later with semantic search.',
},
is_correction: {
type: 'boolean',
description: 'Whether this is a correction for a previous learning.',
},
confidence: {
type: 'string',
description: 'How confident you are about this being a correct and useful learning',
enum: ['low' as const, 'medium' as const, 'high' as const],
},
public: {
type: 'boolean',
description:
'Whether this information is specific to the user, or generally applicable to any user of the product',
},
},
required: [
'id' as const,
'text' as const,
'is_correction' as const,
'confidence' as const,
'public' as const,
],
},
},
(
{ arguments: { id, text, is_correction: isCorrection, confidence, public: isPublic } },
signal
) => {
return service
.callApi('POST /internal/observability_ai_assistant/functions/summarise', {
params: {
body: {
id,
text,
is_correction: isCorrection,
confidence,
public: isPublic,
},
},
signal,
})
.then(() => ({
content: {
message: `The document has been stored`,
},
}));
}
);
}

View file

@ -0,0 +1,63 @@
/*
* 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 { uniqueId } from 'lodash';
import { buildConversation } from '../../utils/builders';
export function useConversations() {
return [
buildConversation({
conversation: {
id: uniqueId(),
title: 'Investigation into Cart service degradation',
last_updated: '',
},
}),
buildConversation({
conversation: {
id: uniqueId(),
title: 'Why is database service responding with errors after I did rm -rf /postgres',
last_updated: '',
},
}),
buildConversation({
conversation: {
id: uniqueId(),
title: 'Why is database service responding with errors after I did rm -rf /postgres',
last_updated: '',
},
}),
buildConversation({
conversation: {
id: uniqueId(),
title: 'Why is database service responding with errors after I did rm -rf /postgres',
last_updated: '',
},
}),
buildConversation({
conversation: {
id: uniqueId(),
title: 'Why is database service responding with errors after I did rm -rf /postgres',
last_updated: '',
},
}),
buildConversation({
conversation: {
id: uniqueId(),
title: 'Why is database service responding with errors after I did rm -rf /postgres',
last_updated: '',
},
}),
buildConversation({
conversation: {
id: uniqueId(),
title: 'Why is database service responding with errors after I did rm -rf /postgres',
last_updated: '',
},
}),
];
}

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export function useCurrentUser() {
return {
username: 'john_doe',
email: 'john.doe@example.com',
full_name: 'John Doe',
roles: ['user', 'editor'],
enabled: true,
};
}

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { UseGenAIConnectorsResult } from '../use_genai_connectors';
export function useGenAIConnectors(): UseGenAIConnectorsResult {
return {
connectors: [],
loading: false,
error: undefined,
selectedConnector: 'foo',
selectConnector: (id: string) => {},
};
}

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export function useKibana() {
return {
services: {
uiSettings: {
get: (setting: string) => {
if (setting === 'dateFormat') {
return 'MMM D, YYYY HH:mm';
}
},
},
notifications: {
toasts: {
addSuccess: () => {},
addError: () => {},
},
},
},
};
}

View file

@ -0,0 +1,76 @@
/*
* 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 { isPromise } from '@kbn/std';
import { useEffect, useMemo, useRef, useState } from 'react';
interface State<T> {
error?: Error;
value?: T;
loading: boolean;
}
export type AbortableAsyncState<T> = (T extends Promise<infer TReturn>
? State<TReturn>
: State<T>) & { refresh: () => void };
export function useAbortableAsync<T>(
fn: ({}: { signal: AbortSignal }) => T,
deps: any[]
): AbortableAsyncState<T> {
const controllerRef = useRef(new AbortController());
const [refreshId, setRefreshId] = useState(0);
const [error, setError] = useState<Error>();
const [loading, setLoading] = useState(false);
const [value, setValue] = useState<T>();
useEffect(() => {
controllerRef.current.abort();
const controller = new AbortController();
controllerRef.current = controller;
try {
const response = fn({ signal: controller.signal });
if (isPromise(response)) {
setLoading(true);
response
.then(setValue)
.catch((err) => {
setValue(undefined);
setError(err);
})
.finally(() => setLoading(false));
} else {
setError(undefined);
setValue(response);
}
} catch (err) {
setValue(undefined);
setError(err);
} finally {
setLoading(false);
}
return () => {
controller.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps.concat(refreshId));
return useMemo<AbortableAsyncState<T>>(() => {
return {
error,
loading,
value,
refresh: () => {
setRefreshId((id) => id + 1);
},
} as unknown as AbortableAsyncState<T>;
}, [error, value, loading]);
}

View file

@ -1,111 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { clone } from 'lodash';
import { useEffect, useState } from 'react';
import { concatMap, delay, of } from 'rxjs';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { i18n } from '@kbn/i18n';
import type { Message } from '../../common/types';
import { useObservabilityAIAssistant } from './use_observability_ai_assistant';
interface MessageResponse {
content?: string;
function_call?: {
name?: string;
args?: string;
};
}
export function useChat({ messages, connectorId }: { messages: Message[]; connectorId: string }): {
content?: string;
function_call?: {
name?: string;
args?: string;
};
loading: boolean;
error?: Error;
} {
const assistant = useObservabilityAIAssistant();
const {
services: { notifications },
} = useKibana();
const [response, setResponse] = useState<MessageResponse | undefined>(undefined);
const [error, setError] = useState<Error | undefined>(undefined);
const [loading, setLoading] = useState(false);
useEffect(() => {
const controller = new AbortController();
setResponse(undefined);
setError(undefined);
setLoading(true);
const partialResponse = {
content: '',
function_call: {
name: '',
args: '',
},
};
assistant
.chat({ messages, connectorId, signal: controller.signal })
.then((response$) => {
return new Promise<void>((resolve, reject) => {
const subscription = response$
.pipe(concatMap((value) => of(value).pipe(delay(50))))
.subscribe({
next: (chunk) => {
partialResponse.content += chunk.choices[0].delta.content ?? '';
partialResponse.function_call.name +=
chunk.choices[0].delta.function_call?.name ?? '';
partialResponse.function_call.args +=
chunk.choices[0].delta.function_call?.args ?? '';
setResponse(clone(partialResponse));
},
error: (err) => {
reject(err);
},
complete: () => {
resolve();
},
});
controller.signal.addEventListener('abort', () => {
subscription.unsubscribe();
});
});
})
.catch((err) => {
notifications?.showErrorDialog({
title: i18n.translate('xpack.observabilityAiAssistant.failedToLoadChatTitle', {
defaultMessage: 'Failed to load chat',
}),
error: err,
});
setError(err);
})
.finally(() => {
setLoading(false);
});
return () => {
controller.abort();
};
}, [messages, connectorId, assistant, notifications]);
return {
...response,
error,
loading,
};
}

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiConfirmModal } from '@elastic/eui';
import { useState } from 'react';
export function useConfirmModal({
title,
children,
confirmButtonText,
}: {
title: React.ReactNode;
children: React.ReactNode;
confirmButtonText: React.ReactNode;
}) {
const [element, setElement] = useState<React.ReactNode | undefined>(undefined);
const confirm = () => {
return new Promise<boolean>((resolve) => {
setElement(
<EuiConfirmModal
title={title}
onConfirm={() => {
resolve(true);
setElement(undefined);
}}
onCancel={() => {
resolve(false);
setElement(undefined);
}}
confirmButtonText={confirmButtonText}
>
{children}
</EuiConfirmModal>
);
});
};
return {
element,
confirm,
};
}

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Conversation } from '../../common';
export function useConversations(): Conversation[] {
return [];
}

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { AuthenticatedUser } from '@kbn/security-plugin/common/model';
import { useEffect, useState } from 'react';
import { useObservabilityAIAssistant } from './use_observability_ai_assistant';
export function useCurrentUser() {
const service = useObservabilityAIAssistant();
const [user, setUser] = useState<AuthenticatedUser>();
useEffect(() => {
const getCurrentUser = async () => {
try {
const authenticatedUser = await service.getCurrentUser();
setUser(authenticatedUser);
} catch {
setUser(undefined);
}
};
getCurrentUser();
}, [service]);
return user;
}

View file

@ -43,6 +43,13 @@ export function useGenAIConnectors(): UseGenAIConnectorsResult {
})
.then((results) => {
setConnectors(results);
setSelectedConnector((connectorId) => {
if (connectorId && results.findIndex((result) => result.id === connectorId) === -1) {
return '';
}
return connectorId;
});
setError(undefined);
})
.catch((err) => {
@ -56,7 +63,7 @@ export function useGenAIConnectors(): UseGenAIConnectorsResult {
return () => {
controller.abort();
};
}, [assistant]);
}, [assistant, setSelectedConnector]);
return {
connectors,

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useMemo } from 'react';
import { monaco } from '@kbn/monaco';
import { FunctionDefinition } from '../../common/types';
const { editor, languages, Uri } = monaco;
const SCHEMA_URI = 'http://elastic.co/foo.json';
const modelUri = Uri.parse(SCHEMA_URI);
export const useJsonEditorModel = (functionDefinition?: FunctionDefinition) => {
return useMemo(() => {
if (!functionDefinition) {
return {};
}
const schema = { ...functionDefinition.options.parameters };
const initialJsonString = functionDefinition.options.parameters.properties
? Object.keys(functionDefinition.options.parameters.properties).reduce(
(acc, curr, index, arr) => {
const val = `${acc} "${curr}": "",\n`;
return index === arr.length - 1 ? `${val}}` : val;
},
'{\n'
)
: '';
languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
schemas: [
{
uri: SCHEMA_URI,
fileMatch: [String(modelUri)],
schema,
},
],
});
let model = editor.getModel(modelUri);
if (model === null) {
model = editor.createModel(initialJsonString, 'json', modelUri);
}
return { model, initialJsonString };
}, [functionDefinition]);
};

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CoreStart } from '@kbn/core/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { ObservabilityAIAssistantPluginStartDependencies } from '../types';
export type StartServices<TAdditionalServices> = CoreStart & {
plugins: { start: ObservabilityAIAssistantPluginStartDependencies };
} & TAdditionalServices & {};
const useTypedKibana = <AdditionalServices extends object = {}>() =>
useKibana<StartServices<AdditionalServices>>();
export { useTypedKibana as useKibana };

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { type PathsOf, type TypeOf, useParams } from '@kbn/typed-react-router-config';
import type { ObservabilityAIAssistantRoutes } from '../routes/config';
export function useObservabilityAIAssistantParams<
TPath extends PathsOf<ObservabilityAIAssistantRoutes>
>(path: TPath): TypeOf<ObservabilityAIAssistantRoutes, TPath> {
return useParams(path)! as TypeOf<ObservabilityAIAssistantRoutes, TPath>;
}

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { PathsOf, TypeAsArgs, TypeOf } from '@kbn/typed-react-router-config';
import { useMemo } from 'react';
import { useHistory } from 'react-router-dom';
import { ObservabilityAIAssistantRouter, ObservabilityAIAssistantRoutes } from '../routes/config';
import { observabilityAIAssistantRouter } from '../routes/config';
import { useKibana } from './use_kibana';
interface StatefulObservabilityAIAssistantRouter extends ObservabilityAIAssistantRouter {
push<T extends PathsOf<ObservabilityAIAssistantRoutes>>(
path: T,
...params: TypeAsArgs<TypeOf<ObservabilityAIAssistantRoutes, T>>
): void;
replace<T extends PathsOf<ObservabilityAIAssistantRoutes>>(
path: T,
...params: TypeAsArgs<TypeOf<ObservabilityAIAssistantRoutes, T>>
): void;
}
export function useObservabilityAIAssistantRouter(): StatefulObservabilityAIAssistantRouter {
const history = useHistory();
const {
services: { http },
} = useKibana();
const link = (...args: any[]) => {
// @ts-expect-error
return observabilityAIAssistantRouter.link(...args);
};
return useMemo<StatefulObservabilityAIAssistantRouter>(
() => ({
...observabilityAIAssistantRouter,
push: (...args) => {
const next = link(...args);
history.push(next);
},
replace: (path, ...args) => {
const next = link(path, ...args);
history.replace(next);
},
link: (path, ...args) => {
return http.basePath.prepend('/app/observabilityAIAssistant' + link(path, ...args));
},
}),
[http, history]
);
}

View file

@ -0,0 +1,401 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { FindActionResult } from '@kbn/actions-plugin/server';
import { AbortError } from '@kbn/kibana-utils-plugin/common';
import {
act,
renderHook,
type Renderer,
type RenderHookResult,
} from '@testing-library/react-hooks';
import { BehaviorSubject, Subject } from 'rxjs';
import { MessageRole } from '../../common';
import type { PendingMessage } from '../types';
import { useTimeline, UseTimelineResult } from './use_timeline';
type HookProps = Parameters<typeof useTimeline>[0];
const WAIT_OPTIONS = { timeout: 1500 };
describe('useTimeline', () => {
let hookResult: RenderHookResult<HookProps, UseTimelineResult, Renderer<HookProps>>;
describe('with an empty conversation', () => {
beforeAll(() => {
hookResult = renderHook((props) => useTimeline(props), {
initialProps: {
connectors: {
loading: false,
selectedConnector: 'OpenAI',
selectConnector: () => {},
connectors: [{ id: 'OpenAI' }] as FindActionResult[],
},
service: {},
messages: [],
onChatComplete: jest.fn(),
onChatUpdate: jest.fn(),
} as unknown as HookProps,
});
});
it('renders the correct timeline items', () => {
expect(hookResult.result.current.items.length).toEqual(1);
expect(hookResult.result.current.items[0]).toEqual({
canEdit: false,
canRegenerate: false,
canGiveFeedback: false,
role: MessageRole.User,
title: 'started a conversation',
loading: false,
id: expect.any(String),
});
});
});
describe('with an existing conversation', () => {
beforeAll(() => {
hookResult = renderHook((props) => useTimeline(props), {
initialProps: {
messages: [
{
'@timestamp': new Date().toISOString(),
message: {
role: MessageRole.User,
content: 'Hello',
},
},
{
'@timestamp': new Date().toISOString(),
message: {
role: MessageRole.Assistant,
content: 'Goodbye',
},
},
],
connectors: {
selectedConnector: 'foo',
},
service: {
chat: () => {},
},
} as unknown as HookProps,
});
});
it('renders the correct timeline items', () => {
expect(hookResult.result.current.items.length).toEqual(3);
expect(hookResult.result.current.items[1]).toEqual({
canEdit: true,
canRegenerate: false,
canGiveFeedback: false,
role: MessageRole.User,
content: 'Hello',
loading: false,
id: expect.any(String),
title: '',
});
expect(hookResult.result.current.items[2]).toEqual({
canEdit: false,
canRegenerate: true,
canGiveFeedback: true,
role: MessageRole.Assistant,
content: 'Goodbye',
loading: false,
id: expect.any(String),
title: '',
});
});
});
describe('when submitting a new prompt', () => {
let subject: Subject<PendingMessage>;
let props: Omit<HookProps, 'onChatUpdate' | 'onChatComplete' | 'service'> & {
onChatUpdate: jest.MockedFn<HookProps['onChatUpdate']>;
onChatComplete: jest.MockedFn<HookProps['onChatComplete']>;
service: Omit<HookProps['service'], 'executeFunction'> & {
executeFunction: jest.MockedFn<HookProps['service']['executeFunction']>;
};
};
beforeEach(() => {
props = {
messages: [],
connectors: {
selectedConnector: 'foo',
},
service: {
chat: jest.fn().mockImplementation(() => {
subject = new BehaviorSubject<PendingMessage>({
message: {
role: MessageRole.Assistant,
content: '',
},
});
return subject;
}),
executeFunction: jest.fn(),
},
onChatUpdate: jest.fn().mockImplementation((messages) => {
props = { ...props, messages };
hookResult.rerender(props as unknown as HookProps);
}),
onChatComplete: jest.fn(),
} as any;
hookResult = renderHook((nextProps) => useTimeline(nextProps), {
initialProps: props as unknown as HookProps,
});
});
describe("and it's loading", () => {
beforeEach(() => {
act(() => {
hookResult.result.current.onSubmit({
'@timestamp': new Date().toISOString(),
message: { role: MessageRole.User, content: 'Hello' },
});
});
});
it('adds two items of which the last one is loading', async () => {
expect(hookResult.result.current.items[0].role).toEqual(MessageRole.User);
expect(hookResult.result.current.items[1].role).toEqual(MessageRole.User);
expect(hookResult.result.current.items[2].role).toEqual(MessageRole.Assistant);
expect(hookResult.result.current.items[1]).toMatchObject({
role: MessageRole.User,
content: 'Hello',
loading: false,
});
expect(hookResult.result.current.items[2]).toMatchObject({
role: MessageRole.Assistant,
content: '',
loading: true,
canRegenerate: false,
canGiveFeedback: false,
});
expect(hookResult.result.current.items.length).toBe(3);
expect(hookResult.result.current.items[2]).toMatchObject({
role: MessageRole.Assistant,
content: '',
loading: true,
canRegenerate: false,
canGiveFeedback: false,
});
act(() => {
subject.next({ message: { role: MessageRole.Assistant, content: 'Goodbye' } });
});
expect(hookResult.result.current.items[2]).toMatchObject({
role: MessageRole.Assistant,
content: 'Goodbye',
loading: true,
canRegenerate: false,
canGiveFeedback: false,
});
act(() => {
subject.complete();
});
await hookResult.waitForNextUpdate(WAIT_OPTIONS);
expect(hookResult.result.current.items[2]).toMatchObject({
role: MessageRole.Assistant,
content: 'Goodbye',
loading: false,
canRegenerate: true,
canGiveFeedback: true,
});
});
describe('and it is being aborted', () => {
beforeEach(() => {
act(() => {
subject.next({ message: { role: MessageRole.Assistant, content: 'My partial' } });
subject.next({
message: {
role: MessageRole.Assistant,
content: 'My partial',
},
aborted: true,
error: new AbortError(),
});
subject.complete();
});
});
it('adds the partial response', async () => {
expect(hookResult.result.current.items.length).toBe(3);
expect(hookResult.result.current.items[2]).toEqual({
canEdit: false,
canRegenerate: true,
canGiveFeedback: false,
content: 'My partial',
id: expect.any(String),
loading: false,
title: '',
role: MessageRole.Assistant,
error: expect.any(AbortError),
});
});
describe('and it being regenerated', () => {
beforeEach(() => {
act(() => {
hookResult.result.current.onRegenerate(hookResult.result.current.items[2]);
subject.next({ message: { role: MessageRole.Assistant, content: '' } });
});
});
it('updates the last item in the array to be loading', () => {
expect(hookResult.result.current.items[2]).toEqual({
canEdit: false,
canRegenerate: false,
canGiveFeedback: false,
content: '',
id: expect.any(String),
loading: true,
title: '',
role: MessageRole.Assistant,
});
});
describe('and it is regenerated again', () => {
beforeEach(async () => {
act(() => {
hookResult.result.current.onStopGenerating();
});
act(() => {
hookResult.result.current.onRegenerate(hookResult.result.current.items[2]);
});
});
it('updates the last item to be not loading again', async () => {
expect(hookResult.result.current.items.length).toBe(3);
expect(hookResult.result.current.items[2]).toEqual({
canEdit: false,
canRegenerate: false,
canGiveFeedback: false,
content: '',
id: expect.any(String),
loading: true,
title: '',
role: MessageRole.Assistant,
});
act(() => {
subject.next({ message: { role: MessageRole.Assistant, content: 'Regenerated' } });
subject.complete();
});
await hookResult.waitForNextUpdate(WAIT_OPTIONS);
expect(hookResult.result.current.items.length).toBe(3);
expect(hookResult.result.current.items[2]).toEqual({
canEdit: false,
canRegenerate: true,
canGiveFeedback: true,
content: 'Regenerated',
id: expect.any(String),
loading: false,
title: '',
role: MessageRole.Assistant,
});
});
});
});
});
describe('and a function call is returned', () => {
it('the function call is executed and its response is sent as a user reply', async () => {
jest.clearAllMocks();
act(() => {
subject.next({
message: {
role: MessageRole.Assistant,
function_call: {
trigger: MessageRole.Assistant,
name: 'my_function',
arguments: '{}',
},
},
});
subject.complete();
});
props.service.executeFunction.mockResolvedValueOnce({
content: {
message: 'my-response',
},
});
await hookResult.waitForNextUpdate(WAIT_OPTIONS);
expect(props.onChatUpdate).toHaveBeenCalledTimes(2);
expect(
props.onChatUpdate.mock.calls[0][0].map(
(msg) => msg.message.content || msg.message.function_call?.name
)
).toEqual(['Hello', 'my_function']);
expect(
props.onChatUpdate.mock.calls[1][0].map(
(msg) => msg.message.content || msg.message.function_call?.name
)
).toEqual(['Hello', 'my_function', JSON.stringify({ message: 'my-response' })]);
expect(props.onChatComplete).not.toHaveBeenCalled();
expect(props.service.executeFunction).toHaveBeenCalledWith(
'my_function',
'{}',
expect.any(Object)
);
act(() => {
subject.next({
message: {
role: MessageRole.Assistant,
content: 'looks like my-function returned my-response',
},
});
subject.complete();
});
await hookResult.waitForNextUpdate(WAIT_OPTIONS);
expect(props.onChatComplete).toHaveBeenCalledTimes(1);
expect(
props.onChatComplete.mock.calls[0][0].map(
(msg) => msg.message.content || msg.message.function_call?.name
)
).toEqual([
'Hello',
'my_function',
JSON.stringify({ message: 'my-response' }),
'looks like my-function returned my-response',
]);
});
});
});
});
});

View file

@ -0,0 +1,215 @@
/*
* 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 { AbortError } from '@kbn/kibana-utils-plugin/common';
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { Subscription } from 'rxjs';
import { MessageRole, type ConversationCreateRequest, type Message } from '../../common/types';
import type { ChatPromptEditorProps } from '../components/chat/chat_prompt_editor';
import type { ChatTimelineProps } from '../components/chat/chat_timeline';
import { EMPTY_CONVERSATION_TITLE } from '../i18n';
import { getSystemMessage } from '../service/get_system_message';
import type { ObservabilityAIAssistantService, PendingMessage } from '../types';
import { getTimelineItemsfromConversation } from '../utils/get_timeline_items_from_conversation';
import type { UseGenAIConnectorsResult } from './use_genai_connectors';
export function createNewConversation(): ConversationCreateRequest {
return {
'@timestamp': new Date().toISOString(),
messages: [getSystemMessage()],
conversation: {
title: EMPTY_CONVERSATION_TITLE,
},
labels: {},
numeric_labels: {},
public: false,
};
}
export type UseTimelineResult = Pick<
ChatTimelineProps,
'onEdit' | 'onFeedback' | 'onRegenerate' | 'onStopGenerating' | 'items'
> &
Pick<ChatPromptEditorProps, 'onSubmit'>;
export function useTimeline({
messages,
connectors,
currentUser,
service,
onChatUpdate,
onChatComplete,
}: {
messages: Message[];
connectors: UseGenAIConnectorsResult;
currentUser?: Pick<AuthenticatedUser, 'full_name' | 'username'>;
service: ObservabilityAIAssistantService;
onChatUpdate: (messages: Message[]) => void;
onChatComplete: (messages: Message[]) => void;
}): UseTimelineResult {
const connectorId = connectors.selectedConnector;
const hasConnector = !!connectorId;
const conversationItems = useMemo(() => {
return getTimelineItemsfromConversation({
messages,
currentUser,
hasConnector,
});
}, [messages, currentUser, hasConnector]);
const [subscription, setSubscription] = useState<Subscription | undefined>();
const controllerRef = useRef(new AbortController());
const [pendingMessage, setPendingMessage] = useState<PendingMessage>();
function chat(nextMessages: Message[]): Promise<Message[]> {
const controller = new AbortController();
return new Promise<PendingMessage>((resolve, reject) => {
if (!connectorId) {
reject(new Error('Can not add a message without a connector'));
return;
}
onChatUpdate(nextMessages);
const response$ = service.chat({ messages: nextMessages, connectorId });
let pendingMessageLocal = pendingMessage;
const nextSubscription = response$.subscribe({
next: (nextPendingMessage) => {
pendingMessageLocal = nextPendingMessage;
setPendingMessage(() => nextPendingMessage);
},
error: reject,
complete: () => {
resolve(pendingMessageLocal!);
},
});
setSubscription(() => {
controllerRef.current = controller;
return nextSubscription;
});
}).then(async (reply) => {
if (reply.error) {
return nextMessages;
}
if (reply.aborted) {
return nextMessages;
}
setPendingMessage(undefined);
const messagesAfterChat = nextMessages.concat({
'@timestamp': new Date().toISOString(),
message: {
...reply.message,
},
});
onChatUpdate(messagesAfterChat);
if (reply?.message.function_call?.name) {
const name = reply.message.function_call.name;
try {
const message = await service.executeFunction(
name,
reply.message.function_call.arguments,
controller.signal
);
return await chat(
messagesAfterChat.concat({
'@timestamp': new Date().toISOString(),
message: {
role: MessageRole.User,
name,
content: JSON.stringify(message.content),
data: JSON.stringify(message.data),
},
})
);
} catch (error) {
return await chat(
messagesAfterChat.concat({
'@timestamp': new Date().toISOString(),
message: {
role: MessageRole.User,
name,
content: JSON.stringify({
message: error.toString(),
...error.body,
}),
},
})
);
}
}
return messagesAfterChat;
});
}
const items = useMemo(() => {
if (pendingMessage) {
return conversationItems.concat({
id: '',
canEdit: false,
canRegenerate: pendingMessage.aborted || !!pendingMessage.error,
canGiveFeedback: false,
title: '',
role: pendingMessage.message.role,
content: pendingMessage.message.content,
loading: !pendingMessage.aborted && !pendingMessage.error,
function_call: pendingMessage.message.function_call,
currentUser,
error: pendingMessage.error,
});
}
return conversationItems;
}, [conversationItems, pendingMessage, currentUser]);
useEffect(() => {
return () => {
subscription?.unsubscribe();
};
}, [subscription]);
return {
items,
onEdit: (item, content) => {},
onFeedback: (item, feedback) => {},
onRegenerate: (item) => {
const indexOf = items.indexOf(item);
chat(messages.slice(0, indexOf - 1)).then((nextMessages) => onChatComplete(nextMessages));
},
onStopGenerating: () => {
subscription?.unsubscribe();
setPendingMessage((prevPendingMessage) => ({
message: {
role: MessageRole.Assistant,
...prevPendingMessage?.message,
},
aborted: true,
error: new AbortError(),
}));
setSubscription(undefined);
},
onSubmit: async (message) => {
const nextMessages = await chat(messages.concat(message));
onChatComplete(nextMessages);
},
};
}

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const EMPTY_CONVERSATION_TITLE = i18n.translate(
'xpack.observabilityAiAssistant.emptyConversationTitle',
{ defaultMessage: 'New conversation' }
);

View file

@ -1,48 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import type { Logger } from '@kbn/logging';
import { createService } from './service/create_service';
import type {
ConfigSchema,
ObservabilityAIAssistantPluginSetup,
ObservabilityAIAssistantPluginSetupDependencies,
ObservabilityAIAssistantPluginStart,
ObservabilityAIAssistantPluginStartDependencies,
} from './types';
export class ObservabilityAIAssistantPlugin
implements
Plugin<
ObservabilityAIAssistantPluginSetup,
ObservabilityAIAssistantPluginStart,
ObservabilityAIAssistantPluginSetupDependencies,
ObservabilityAIAssistantPluginStartDependencies
>
{
logger: Logger;
constructor(context: PluginInitializerContext<ConfigSchema>) {
this.logger = context.logger.get();
}
setup(
core: CoreSetup,
pluginsSetup: ObservabilityAIAssistantPluginSetupDependencies
): ObservabilityAIAssistantPluginSetup {
return {};
}
start(
coreStart: CoreStart,
pluginsStart: ObservabilityAIAssistantPluginStartDependencies
): ObservabilityAIAssistantPluginStart {
return createService({
coreStart,
enabled: coreStart.application.capabilities.observabilityAIAssistant.show === true,
});
}
}

View file

@ -0,0 +1,135 @@
/*
* 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 {
AppNavLinkStatus,
DEFAULT_APP_CATEGORIES,
type AppMountParameters,
type CoreSetup,
type CoreStart,
type Plugin,
type PluginInitializerContext,
} from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
import type { Logger } from '@kbn/logging';
import React from 'react';
import ReactDOM from 'react-dom';
import type {
ContextRegistry,
FunctionRegistry,
RegisterContextDefinition,
RegisterFunctionDefinition,
} from '../common/types';
import { registerFunctions } from './functions';
import { createService } from './service/create_service';
import type {
ConfigSchema,
ObservabilityAIAssistantPluginSetup,
ObservabilityAIAssistantPluginSetupDependencies,
ObservabilityAIAssistantPluginStart,
ObservabilityAIAssistantPluginStartDependencies,
ObservabilityAIAssistantService,
} from './types';
export class ObservabilityAIAssistantPlugin
implements
Plugin<
ObservabilityAIAssistantPluginSetup,
ObservabilityAIAssistantPluginStart,
ObservabilityAIAssistantPluginSetupDependencies,
ObservabilityAIAssistantPluginStartDependencies
>
{
logger: Logger;
service?: ObservabilityAIAssistantService;
constructor(context: PluginInitializerContext<ConfigSchema>) {
this.logger = context.logger.get();
}
setup(
coreSetup: CoreSetup,
pluginsSetup: ObservabilityAIAssistantPluginSetupDependencies
): ObservabilityAIAssistantPluginSetup {
coreSetup.application.register({
id: 'observabilityAIAssistant',
title: i18n.translate('xpack.observabilityAiAssistant.appTitle', {
defaultMessage: 'Observability AI Assistant',
}),
euiIconType: 'logoObservability',
appRoute: '/app/observabilityAIAssistant',
category: DEFAULT_APP_CATEGORIES.observability,
navLinkStatus: AppNavLinkStatus.hidden,
deepLinks: [
{
id: 'conversations',
title: i18n.translate('xpack.observabilityAiAssistant.conversationsDeepLinkTitle', {
defaultMessage: 'Conversations',
}),
path: '/conversations',
},
],
mount: async (appMountParameters: AppMountParameters<unknown>) => {
// Load application bundle and Get start services
const [{ Application }, [coreStart, pluginsStart]] = await Promise.all([
import('./application'),
coreSetup.getStartServices(),
]);
ReactDOM.render(
<Application
{...appMountParameters}
service={this.service!}
coreStart={coreStart}
pluginsStart={pluginsStart as ObservabilityAIAssistantPluginStartDependencies}
/>,
appMountParameters.element
);
return () => {
ReactDOM.unmountComponentAtNode(appMountParameters.element);
};
},
});
return {};
}
start(
coreStart: CoreStart,
pluginsStart: ObservabilityAIAssistantPluginStartDependencies
): ObservabilityAIAssistantPluginStart {
const contextRegistry: ContextRegistry = new Map();
const functionRegistry: FunctionRegistry = new Map();
const service = (this.service = createService({
coreStart,
securityStart: pluginsStart.security,
contextRegistry,
functionRegistry,
enabled: coreStart.application.capabilities.observabilityAIAssistant.show === true,
}));
const registerContext: RegisterContextDefinition = (context) => {
contextRegistry.set(context.name, context);
};
const registerFunction: RegisterFunctionDefinition = (def, respond, render) => {
functionRegistry.set(def.name, { options: def, respond, render });
};
registerFunctions({
registerContext,
registerFunction,
service,
});
return {
...service,
registerContext,
registerFunction,
};
}
}

View file

@ -0,0 +1,49 @@
/*
* 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 { createRouter, Outlet } from '@kbn/typed-react-router-config';
import * as t from 'io-ts';
import React from 'react';
import { Redirect } from 'react-router-dom';
import { ObservabilityAIAssistantPageTemplate } from '../components/page_template';
import { ConversationView } from './conversations/conversation_view';
/**
* The array of route definitions to be used when the application
* creates the routes.
*/
const observabilityAIAssistantRoutes = {
'/': {
element: <Redirect to="/conversations/new" />,
},
'/conversations': {
element: (
<ObservabilityAIAssistantPageTemplate>
<Outlet />
</ObservabilityAIAssistantPageTemplate>
),
children: {
'/conversations/new': {
element: <ConversationView />,
},
'/conversations/{conversationId}': {
params: t.type({
path: t.type({
conversationId: t.string,
}),
}),
element: <ConversationView />,
},
},
},
};
export type ObservabilityAIAssistantRoutes = typeof observabilityAIAssistantRoutes;
export const observabilityAIAssistantRouter = createRouter(observabilityAIAssistantRoutes);
export type ObservabilityAIAssistantRouter = typeof observabilityAIAssistantRouter;

View file

@ -0,0 +1,312 @@
/*
* 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 { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer } from '@elastic/eui';
import { css } from '@emotion/css';
import { i18n } from '@kbn/i18n';
import { merge, omit } from 'lodash';
import React, { useMemo, useState } from 'react';
import type { ConversationCreateRequest, Message } from '../../../common/types';
import { ChatBody } from '../../components/chat/chat_body';
import { ConversationList } from '../../components/chat/conversation_list';
import { AbortableAsyncState, useAbortableAsync } from '../../hooks/use_abortable_async';
import { useConfirmModal } from '../../hooks/use_confirm_modal';
import { useCurrentUser } from '../../hooks/use_current_user';
import { useGenAIConnectors } from '../../hooks/use_genai_connectors';
import { useKibana } from '../../hooks/use_kibana';
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 { createNewConversation } from '../../hooks/use_timeline';
import { EMPTY_CONVERSATION_TITLE } from '../../i18n';
import { getConnectorsManagementHref } from '../../utils/get_connectors_management_href';
const containerClassName = css`
max-width: 100%;
`;
const chatBodyContainerClassNameWithError = css`
align-self: center;
`;
export function ConversationView() {
const connectors = useGenAIConnectors();
const currentUser = useCurrentUser();
const service = useObservabilityAIAssistant();
const observabilityAIAssistantRouter = useObservabilityAIAssistantRouter();
const { path } = useObservabilityAIAssistantParams('/conversations/*');
const {
services: { http, notifications },
} = useKibana();
const { element: confirmDeleteElement, confirm: confirmDeleteFunction } = useConfirmModal({
title: i18n.translate('xpack.observabilityAiAssistant.confirmDeleteConversationTitle', {
defaultMessage: 'Delete this conversation?',
}),
children: i18n.translate('xpack.observabilityAiAssistant.confirmDeleteConversationContent', {
defaultMessage: 'This action cannot be undone.',
}),
confirmButtonText: i18n.translate('xpack.observabilityAiAssistant.confirmDeleteButtonText', {
defaultMessage: 'Delete conversation',
}),
});
const [isUpdatingList, setIsUpdatingList] = useState(false);
const conversationId = 'conversationId' in path ? path.conversationId : undefined;
const conversation: AbortableAsyncState<ConversationCreateRequest | undefined> =
useAbortableAsync(
({ signal }) => {
if (!conversationId) {
const nextConversation = createNewConversation();
setDisplayedMessages(nextConversation.messages);
return nextConversation;
}
return service
.callApi('GET /internal/observability_ai_assistant/conversation/{conversationId}', {
signal,
params: { path: { conversationId } },
})
.then((nextConversation) => {
setDisplayedMessages(nextConversation.messages);
return nextConversation;
})
.catch((error) => {
setDisplayedMessages([]);
throw error;
});
},
[conversationId]
);
const conversations = useAbortableAsync(
({ signal }) => {
return service.callApi('POST /internal/observability_ai_assistant/conversations', {
signal,
});
},
[service]
);
const displayedConversations = useMemo(() => {
return [
...(!conversationId ? [{ id: '', label: EMPTY_CONVERSATION_TITLE }] : []),
...(conversations.value?.conversations ?? []).map((conv) => ({
id: conv.conversation.id,
label: conv.conversation.title,
href: observabilityAIAssistantRouter.link('/conversations/{conversationId}', {
path: {
conversationId: conv.conversation.id,
},
}),
})),
];
}, [conversations.value?.conversations, conversationId, observabilityAIAssistantRouter]);
const [displayedMessages, setDisplayedMessages] = useState<Message[]>([]);
function navigateToConversation(nextConversationId?: string) {
observabilityAIAssistantRouter.push(
nextConversationId ? '/conversations/{conversationId}' : '/conversations/new',
{
path: { conversationId: nextConversationId },
query: {},
}
);
}
return (
<>
{confirmDeleteElement}
<EuiFlexGroup direction="row" className={containerClassName}>
<EuiFlexItem grow={false}>
<ConversationList
selected={conversationId ?? ''}
loading={conversations.loading || isUpdatingList}
error={conversations.error}
conversations={displayedConversations}
onClickConversation={(nextConversationId) => {
observabilityAIAssistantRouter.push('/conversations/{conversationId}', {
path: {
conversationId: nextConversationId,
},
query: {},
});
}}
onClickNewChat={() => {
observabilityAIAssistantRouter.push('/conversations/new', {
path: {},
query: {},
});
}}
onClickDeleteConversation={(id) => {
confirmDeleteFunction()
.then(async (confirmed) => {
if (!confirmed) {
return;
}
setIsUpdatingList(true);
await service.callApi(
'DELETE /internal/observability_ai_assistant/conversation/{conversationId}',
{
params: {
path: {
conversationId: id,
},
},
signal: null,
}
);
const isCurrentConversation = id === conversationId;
const hasOtherConversations = conversations.value?.conversations.find(
(conv) => 'id' in conv.conversation && conv.conversation.id !== id
);
if (isCurrentConversation) {
navigateToConversation(
hasOtherConversations
? conversations.value!.conversations[0].conversation.id
: undefined
);
}
conversations.refresh();
})
.catch((error) => {
notifications.toasts.addError(error, {
title: i18n.translate(
'xpack.observabilityAiAssistant.failedToDeleteConversation',
{
defaultMessage: 'Could not delete conversation',
}
),
});
})
.finally(() => {
setIsUpdatingList(false);
});
}}
/>
</EuiFlexItem>
<EuiFlexItem
grow
className={conversation.error ? chatBodyContainerClassNameWithError : undefined}
>
{conversation.error ? (
<EuiCallOut
color="danger"
title={i18n.translate(
'xpack.observabilityAiAssistant.couldNotFindConversationTitle',
{
defaultMessage: 'Conversation not found',
}
)}
iconType="warning"
>
{i18n.translate('xpack.observabilityAiAssistant.couldNotFindConversationContent', {
defaultMessage:
'Could not find a conversation with id {conversationId}. Make sure the conversation exists and you have access to it.',
values: { conversationId },
})}
</EuiCallOut>
) : null}
{conversation.loading ? <EuiLoadingSpinner /> : null}
{!conversation.error && conversation.value ? (
<ChatBody
currentUser={currentUser}
connectors={connectors}
title={conversation.value.conversation.title}
connectorsManagementHref={getConnectorsManagementHref(http)}
service={service}
messages={displayedMessages}
onChatComplete={(messages) => {
const conversationObject = conversation.value!;
if (conversationId) {
service
.callApi(
`POST /internal/observability_ai_assistant/conversation/{conversationId}`,
{
signal: null,
params: {
path: {
conversationId,
},
body: {
conversation: merge(
{
'@timestamp': conversationObject['@timestamp'],
conversation: {
id: conversationId,
},
},
omit(
conversationObject,
'conversation.last_updated',
'namespace',
'user'
),
{ messages }
),
},
},
}
)
.then(() => {
conversations.refresh();
})
.catch((err) => {
notifications.toasts.addError(err, {
title: i18n.translate(
'xpack.observabilityAiAssistant.errorCreatingConversation',
{ defaultMessage: 'Could not create conversation' }
),
});
});
} else {
service
.callApi(`PUT /internal/observability_ai_assistant/conversation`, {
signal: null,
params: {
body: {
conversation: merge({}, conversationObject, { messages }),
},
},
})
.then((createdConversation) => {
navigateToConversation(createdConversation.conversation.id);
conversations.refresh();
})
.catch((err) => {
notifications.toasts.addError(err, {
title: i18n.translate(
'xpack.observabilityAiAssistant.errorCreatingConversation',
{ defaultMessage: 'Could not create conversation' }
),
});
});
}
}}
onChatUpdate={(messages) => {
setDisplayedMessages(messages);
}}
/>
) : null}
<EuiSpacer size="m" />
</EuiFlexItem>
</EuiFlexGroup>
</>
);
}

View file

@ -4,10 +4,13 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CoreStart } from '@kbn/core/public';
import type { CoreStart, HttpFetchOptions } from '@kbn/core/public';
import { ReadableStream } from 'stream/web';
import { ObservabilityAIAssistantService } from '../types';
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
import type { ObservabilityAIAssistantService } from '../types';
import { createService } from './create_service';
import { SecurityPluginStart } from '@kbn/security-plugin/public';
import { lastValueFrom } from 'rxjs';
describe('createService', () => {
describe('chat', () => {
@ -15,14 +18,14 @@ describe('createService', () => {
const httpPostSpy = jest.fn();
function respondWithChunks({ chunks, status = 200 }: { status?: number; chunks: string[][] }) {
function respondWithChunks({ chunks, status = 200 }: { status?: number; chunks: string[] }) {
const response = {
response: {
status,
body: new ReadableStream({
start(controller) {
chunks.forEach((chunk) => {
controller.enqueue(new TextEncoder().encode(chunk.join('\n')));
controller.enqueue(new TextEncoder().encode(chunk));
});
controller.close();
},
@ -33,10 +36,8 @@ describe('createService', () => {
httpPostSpy.mockResolvedValueOnce(response);
}
async function chat(signal: AbortSignal = new AbortController().signal) {
const response = await service.chat({ messages: [], connectorId: '', signal });
return response;
function chat() {
return service.chat({ messages: [], connectorId: '' });
}
beforeEach(() => {
@ -46,6 +47,13 @@ describe('createService', () => {
post: httpPostSpy,
},
} as unknown as CoreStart,
securityStart: {
authc: {
getCurrentUser: () => Promise.resolve({ username: 'elastic' } as AuthenticatedUser),
},
} as unknown as SecurityPluginStart,
contextRegistry: new Map(),
functionRegistry: new Map(),
enabled: true,
});
});
@ -55,57 +63,145 @@ describe('createService', () => {
});
it('correctly parses a stream of JSON lines', async () => {
const chunk1 = ['data: {}', 'data: {}'];
const chunk2 = ['data: {}', 'data: [DONE]'];
const chunk1 =
'data: {"object":"chat.completion.chunk","choices":[{"delta":{"content":"My"}}]}\ndata: {"object":"chat.completion.chunk","choices":[{"delta":{"content":" new"}}]}';
const chunk2 =
'\ndata: {"object":"chat.completion.chunk","choices":[{"delta":{"content":" message"}}]}\ndata: [DONE]';
respondWithChunks({ chunks: [chunk1, chunk2] });
const response$ = await chat();
const response$ = chat();
const results: any = [];
response$.subscribe({
const subscription = response$.subscribe({
next: (data) => results.push(data),
complete: () => {
expect(results).toHaveLength(3);
expect(results).toHaveLength(4);
},
});
const value = await lastValueFrom(response$);
subscription.unsubscribe();
expect(value).toEqual({
message: {
role: 'assistant',
content: 'My new message',
function_call: {
arguments: '',
name: '',
trigger: 'assistant',
},
},
});
});
it('correctly buffers partial lines', async () => {
const chunk1 = ['data: {}', 'data: {'];
const chunk2 = ['}', 'data: [DONE]'];
const chunk1 =
'data: {"object":"chat.completion.chunk","choices":[{"delta":{"content":"My"}}]}\ndata: {"object":"chat.completion.chunk","choices":[{"delta":{"content":" new"';
const chunk2 =
'}}]}\ndata: {"object":"chat.completion.chunk","choices":[{"delta":{"content":" message"}}]}\ndata: [DONE]';
respondWithChunks({ chunks: [chunk1, chunk2] });
const response$ = await chat();
const response$ = chat();
const results: any = [];
response$.subscribe({
next: (data) => results.push(data),
complete: () => {
expect(results).toHaveLength(2);
await new Promise<void>((resolve, reject) => {
response$.subscribe({
next: (data) => {
results.push(data);
},
error: reject,
complete: resolve,
});
});
const value = await lastValueFrom(response$);
expect(results).toHaveLength(4);
expect(value).toEqual({
message: {
role: 'assistant',
content: 'My new message',
function_call: {
arguments: '',
name: '',
trigger: 'assistant',
},
},
});
});
it('propagates invalid requests as an error', () => {
it('catches invalid requests and flags it as an error', async () => {
respondWithChunks({ status: 400, chunks: [] });
expect(() => chat()).rejects.toThrowErrorMatchingInlineSnapshot(`"Unexpected error"`);
const response$ = chat();
const value = await lastValueFrom(response$);
expect(value).toEqual({
aborted: false,
error: expect.any(Error),
message: {
role: 'assistant',
},
});
});
it('propagates JSON parsing errors', async () => {
const chunk1 = ['data: {}', 'data: invalid json'];
respondWithChunks({ chunks: ['data: {}', 'data: invalid json'] });
respondWithChunks({ chunks: [chunk1] });
const response$ = chat();
const response$ = await chat();
const value = await lastValueFrom(response$);
response$.subscribe({
error: (err) => {
expect(err).toBeInstanceOf(SyntaxError);
expect(value).toEqual({
aborted: false,
error: expect.any(Error),
message: {
role: 'assistant',
},
});
});
it('cancels a running http request when aborted', async () => {
httpPostSpy.mockImplementationOnce((endpoint: string, options: HttpFetchOptions) => {
options.signal?.addEventListener('abort', () => {
expect(options.signal?.aborted).toBeTruthy();
});
return Promise.resolve({
response: {
status: 200,
body: new ReadableStream({
start(controller) {},
}),
},
});
});
const response$ = chat();
await new Promise<void>((resolve, reject) => {
const subscription = response$.subscribe({});
setTimeout(() => {
subscription.unsubscribe();
resolve();
}, 100);
});
const value = await lastValueFrom(response$);
expect(value).toEqual({
message: {
role: 'assistant',
},
aborted: true,
});
});
});
});

View file

@ -6,65 +6,199 @@
*/
import type { CoreStart, HttpResponse } from '@kbn/core/public';
import { filter, map } from 'rxjs';
import { AbortError } from '@kbn/kibana-utils-plugin/common';
import { SecurityPluginStart } from '@kbn/security-plugin/public';
import { IncomingMessage } from 'http';
import { cloneDeep } from 'lodash';
import {
BehaviorSubject,
catchError,
concatMap,
delay,
filter as rxJsFilter,
finalize,
map,
of,
scan,
shareReplay,
} from 'rxjs';
import type { Message } from '../../common';
import { ContextRegistry, FunctionRegistry, MessageRole } from '../../common/types';
import { createCallObservabilityAIAssistantAPI } from '../api';
import { CreateChatCompletionResponseChunk, ObservabilityAIAssistantService } from '../types';
import type {
CreateChatCompletionResponseChunk,
ObservabilityAIAssistantService,
PendingMessage,
} from '../types';
import { readableStreamReaderIntoObservable } from '../utils/readable_stream_reader_into_observable';
export function createService({
coreStart,
securityStart,
functionRegistry,
contextRegistry,
enabled,
}: {
coreStart: CoreStart;
securityStart: SecurityPluginStart;
functionRegistry: FunctionRegistry;
contextRegistry: ContextRegistry;
enabled: boolean;
}): ObservabilityAIAssistantService {
const client = createCallObservabilityAIAssistantAPI(coreStart);
const getContexts: ObservabilityAIAssistantService['getContexts'] = () => {
return Array.from(contextRegistry.values());
};
const getFunctions: ObservabilityAIAssistantService['getFunctions'] = ({
contexts,
filter,
} = {}) => {
const allFunctions = Array.from(functionRegistry.values());
return contexts || filter
? allFunctions.filter((fn) => {
const matchesContext =
!contexts || fn.options.contexts.some((context) => contexts.includes(context));
const matchesFilter =
!filter || fn.options.name.includes(filter) || fn.options.description.includes(filter);
return matchesContext && matchesFilter;
})
: allFunctions;
};
return {
isEnabled: () => {
return enabled;
},
async chat({
connectorId,
messages,
signal,
}: {
connectorId: string;
messages: Message[];
signal: AbortSignal;
}) {
const response = (await client('POST /internal/observability_ai_assistant/chat', {
chat({ connectorId, messages }: { connectorId: string; messages: Message[] }) {
const subject = new BehaviorSubject<PendingMessage>({
message: {
role: MessageRole.Assistant,
},
});
const contexts = ['core'];
const functions = getFunctions({ contexts });
const controller = new AbortController();
client('POST /internal/observability_ai_assistant/chat', {
params: {
body: {
messages,
connectorId,
functions: functions.map((fn) => fn.options),
},
},
signal,
signal: controller.signal,
asResponse: true,
rawResponse: true,
})) as unknown as HttpResponse;
})
.then((_response) => {
const response = _response as unknown as HttpResponse<IncomingMessage>;
const status = response.response?.status;
const status = response.response?.status;
if (!status || status >= 400) {
throw new Error(response.response?.statusText || 'Unexpected error');
}
if (!status || status >= 400) {
throw new Error(response.response?.statusText || 'Unexpected error');
}
const reader = response.response.body?.getReader();
const reader = response.response.body?.getReader();
if (!reader) {
throw new Error('Could not get reader from response');
}
if (!reader) {
throw new Error('Could not get reader from response');
}
return readableStreamReaderIntoObservable(reader).pipe(
map((line) => line.substring(6)),
filter((line) => !!line && line !== '[DONE]'),
map((line) => JSON.parse(line) as CreateChatCompletionResponseChunk),
filter((line) => line.object === 'chat.completion.chunk')
const subscription = readableStreamReaderIntoObservable(reader)
.pipe(
map((line) => line.substring(6)),
rxJsFilter((line) => !!line && line !== '[DONE]'),
map((line) => JSON.parse(line) as CreateChatCompletionResponseChunk),
rxJsFilter((line) => line.object === 'chat.completion.chunk'),
scan(
(acc, { choices }) => {
acc.message.content += choices[0].delta.content ?? '';
acc.message.function_call.name += choices[0].delta.function_call?.name ?? '';
acc.message.function_call.arguments +=
choices[0].delta.function_call?.arguments ?? '';
return cloneDeep(acc);
},
{
message: {
content: '',
function_call: {
name: '',
arguments: '',
trigger: MessageRole.Assistant as const,
},
role: MessageRole.Assistant,
},
}
),
catchError((error) =>
of({
...subject.value,
error,
aborted: error instanceof AbortError || controller.signal.aborted,
})
)
)
.subscribe(subject);
controller.signal.addEventListener('abort', () => {
subscription.unsubscribe();
subject.next({
...subject.value,
aborted: true,
});
subject.complete();
});
})
.catch((err) => {
subject.next({
...subject.value,
aborted: false,
error: err,
});
subject.complete();
});
return subject.pipe(
concatMap((value) => of(value).pipe(delay(50))),
shareReplay(1),
finalize(() => {
controller.abort();
})
);
},
callApi: client,
getCurrentUser: () => securityStart.authc.getCurrentUser(),
getContexts,
getFunctions,
executeFunction: async (name, args, signal) => {
const fn = functionRegistry.get(name);
if (!fn) {
throw new Error(`Function ${name} not found`);
}
const parsedArguments = args ? JSON.parse(args) : {};
// validate
return await fn.respond({ arguments: parsedArguments }, signal);
},
renderFunction: (name, response) => {
const fn = functionRegistry.get(name);
if (!fn) {
throw new Error(`Function ${name} not found`);
}
return fn.render?.({ response });
},
};
}

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import dedent from 'dedent';
import { MessageRole } from '../../common';
export function getSystemMessage() {
return {
'@timestamp': new Date().toISOString(),
message: {
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 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.
Additionally, you can use the "recall" function to retrieve relevant information from the knowledge database. Using the "recall" function will allow you to create a much more tailored and improved user experience. Assume the user has told you before.
For every question that the user asks, you, as the assistant, must query the knowledge base, no matter how confident you are in your response.
When the user starts a conversation, you as the assistant, MUST ALWAYS use the "recall" function to make sure you have enough context from the knowledge database to understand their question. DO NOT assume your knowledge is sufficient to answer the question appropriately. Make sure you mention this to the user in your first reply to them.
You do have access to private data that the user has shared with you in previous conversations.
Given this ability to store and recall data, it is very important to not make assumptions about the user's data or preferences, but rather, first query the knowledge database, and if nothing relevant comes up, ask the user for clarification. This is very important! They could be storing their data in any field, and in any data stream or index.
RIGHT:
User: "What is NASA"
Assistant executes recall function
Assistant answers question with data from recall function response
WRONG:
User: "What is NASA"
Assistant answers question without querying the knowledge
You should autonomously execute these functions - do not wait on the user's permission, but be proactive.
Note that any visualisations will be displayed ABOVE your textual response, not below.
Feel free to use Markdown in your replies, especially for code and query statements.`),
},
};
}

View file

@ -4,18 +4,33 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { FeaturesPluginStart, FeaturesPluginSetup } from '@kbn/features-plugin/public';
import type {
ObservabilitySharedPluginSetup,
ObservabilitySharedPluginStart,
} from '@kbn/observability-shared-plugin/public';
import type {
AuthenticatedUser,
SecurityPluginSetup,
SecurityPluginStart,
} from '@kbn/security-plugin/public';
import type {
TriggersAndActionsUIPublicPluginSetup,
TriggersAndActionsUIPublicPluginStart,
} from '@kbn/triggers-actions-ui-plugin/public';
import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public';
import type { Serializable } from '@kbn/utility-types';
import type {
CreateChatCompletionResponse,
CreateChatCompletionResponseChoicesInner,
} from 'openai';
import type { Observable } from 'rxjs';
import type { FeaturesPluginSetup, FeaturesPluginStart } from '@kbn/features-plugin/public';
import type { Message } from '../common/types';
import type {
ContextDefinition,
FunctionDefinition,
Message,
RegisterContextDefinition,
RegisterFunctionDefinition,
} from '../common/types';
import type { ObservabilityAIAssistantAPIClient } from './api';
/* eslint-disable @typescript-eslint/no-empty-interface*/
@ -23,32 +38,51 @@ import type { ObservabilityAIAssistantAPIClient } from './api';
export type CreateChatCompletionResponseChunk = Omit<CreateChatCompletionResponse, 'choices'> & {
choices: Array<
Omit<CreateChatCompletionResponseChoicesInner, 'message'> & {
delta: { content?: string; function_call?: { name?: string; args?: string } };
delta: { content?: string; function_call?: { name?: string; arguments?: string } };
}
>;
};
export interface ObservabilityAIAssistantService {
isEnabled: () => boolean;
chat: (options: {
messages: Message[];
connectorId: string;
signal: AbortSignal;
}) => Promise<Observable<CreateChatCompletionResponseChunk>>;
callApi: ObservabilityAIAssistantAPIClient;
export interface PendingMessage {
message: Message['message'];
aborted?: boolean;
error?: any;
}
export interface ObservabilityAIAssistantPluginStart extends ObservabilityAIAssistantService {}
export interface ObservabilityAIAssistantService {
isEnabled: () => boolean;
chat: (options: { messages: Message[]; connectorId: string }) => Observable<PendingMessage>;
callApi: ObservabilityAIAssistantAPIClient;
getCurrentUser: () => Promise<AuthenticatedUser>;
getContexts: () => ContextDefinition[];
getFunctions: (options?: { contexts?: string[]; filter?: string }) => FunctionDefinition[];
executeFunction: (
name: string,
args: string | undefined,
signal: AbortSignal
) => Promise<{ content?: Serializable; data?: Serializable }>;
renderFunction: (
name: string,
response: { data?: Serializable; content?: Serializable }
) => React.ReactNode;
}
export interface ObservabilityAIAssistantPluginStart extends ObservabilityAIAssistantService {
registerContext: RegisterContextDefinition;
registerFunction: RegisterFunctionDefinition;
}
export interface ObservabilityAIAssistantPluginSetup {}
export interface ObservabilityAIAssistantPluginSetupDependencies {
triggersActionsUi: TriggersAndActionsUIPublicPluginSetup;
security: SecurityPluginSetup;
features: FeaturesPluginSetup;
observabilityShared: ObservabilitySharedPluginSetup;
}
export interface ObservabilityAIAssistantPluginStartDependencies {
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
security: SecurityPluginStart;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
observabilityShared: ObservabilitySharedPluginStart;
features: FeaturesPluginStart;
}

View file

@ -0,0 +1,144 @@
/*
* 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 { uniqueId } from 'lodash';
import { MessageRole, Conversation, FunctionDefinition } from '../../common/types';
import { ChatTimelineItem } from '../components/chat/chat_timeline';
import { getSystemMessage } from '../service/get_system_message';
type ChatItemBuildProps = Partial<ChatTimelineItem> & Pick<ChatTimelineItem, 'role'>;
export function buildChatItem(params: ChatItemBuildProps): ChatTimelineItem {
return {
id: uniqueId(),
title: '',
canEdit: false,
canGiveFeedback: false,
canRegenerate: params.role === MessageRole.User,
currentUser: {
username: 'elastic',
},
loading: false,
...params,
};
}
export function buildSystemChatItem(params?: Omit<ChatItemBuildProps, 'role'>) {
return buildChatItem({
role: MessageRole.System,
...params,
});
}
export function buildChatInitItem() {
return buildChatItem({
role: MessageRole.User,
title: 'started a conversation',
canRegenerate: false,
});
}
export function buildUserChatItem(params?: Omit<ChatItemBuildProps, 'role'>) {
return buildChatItem({
role: MessageRole.User,
content: "What's a function?",
canEdit: true,
...params,
});
}
export function buildAssistantChatItem(params?: Omit<ChatItemBuildProps, 'role'>) {
return buildChatItem({
role: MessageRole.Assistant,
content: `In computer programming and mathematics, a function is a fundamental concept that represents a relationship between input values and output values. It takes one or more input values (also known as arguments or parameters) and processes them to produce a result, which is the output of the function. The input values are passed to the function, and the function performs a specific set of operations or calculations on those inputs to produce the desired output.
A function is often defined with a name, which serves as an identifier to call and use the function in the code. It can be thought of as a reusable block of code that can be executed whenever needed, and it helps in organizing code and making it more modular and maintainable.`,
canRegenerate: true,
canGiveFeedback: true,
...params,
});
}
export function buildFunctionChatItem(params: Omit<ChatItemBuildProps, 'role'>) {
return buildChatItem({
role: MessageRole.User,
title: 'executed a function',
function_call: {
name: 'leftpad',
arguments: '{ foo: "bar" }',
trigger: MessageRole.Assistant,
},
...params,
});
}
export function buildTimelineItems() {
return {
items: [buildSystemChatItem(), buildUserChatItem(), buildAssistantChatItem()],
};
}
export function buildConversation(params?: Partial<Conversation>) {
return {
'@timestamp': '',
user: {
name: 'foo',
},
conversation: {
id: uniqueId(),
title: '',
last_updated: '',
},
messages: [getSystemMessage()],
labels: {},
numeric_labels: {},
namespace: '',
...params,
};
}
export function buildFunction(): FunctionDefinition {
return {
options: {
name: 'elasticsearch',
contexts: ['core'],
description: 'Call Elasticsearch APIs on behalf of the user',
parameters: {
type: 'object',
properties: {
method: {
type: 'string',
description: 'The HTTP method of the Elasticsearch endpoint',
enum: ['GET', 'PUT', 'POST', 'DELETE', 'PATCH'] as const,
},
path: {
type: 'string',
description: 'The path of the Elasticsearch endpoint, including query parameters',
},
},
required: ['method' as const, 'path' as const],
},
},
respond: async (options: { arguments: any }, signal: AbortSignal) => ({}),
};
}
export const buildFunctionElasticsearch = buildFunction;
export function buildFunctionServiceSummary(): FunctionDefinition {
return {
options: {
name: 'get_service_summary',
contexts: ['core'],
description:
'Gets a summary of a single service, including: the language, service version, deployments, infrastructure, alerting, etc. ',
parameters: {
type: 'object',
},
},
respond: async (options: { arguments: any }, signal: AbortSignal) => ({}),
};
}

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { HttpStart } from '@kbn/core/public';
export function getConnectorsManagementHref(http: HttpStart) {
return http!.basePath.prepend(
`/app/management/insightsAndAlerting/triggersActionsConnectors/connectors`
);
}

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { v4 } from 'uuid';
import { i18n } from '@kbn/i18n';
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
import dedent from 'dedent';
import { type Message, MessageRole } from '../../common';
import type { ChatTimelineItem } from '../components/chat/chat_timeline';
export function getTimelineItemsfromConversation({
currentUser,
messages,
hasConnector,
}: {
currentUser?: Pick<AuthenticatedUser, 'username' | 'full_name'>;
messages: Message[];
hasConnector: boolean;
}): ChatTimelineItem[] {
return [
{
id: v4(),
role: MessageRole.User,
title: i18n.translate('xpack.observabilityAiAssistant.conversationStartTitle', {
defaultMessage: 'started a conversation',
}),
canEdit: false,
canGiveFeedback: false,
canRegenerate: false,
loading: false,
currentUser,
},
...messages.map((message) => {
const hasFunction = !!message.message.function_call?.name;
const isSystemPrompt = message.message.role === MessageRole.System;
let title: string;
let content: string | undefined;
if (hasFunction) {
title = i18n.translate('xpack.observabilityAiAssistant.suggestedFunctionEvent', {
defaultMessage: 'suggested a function',
});
content = dedent(`I have requested your system performs the function _${
message.message.function_call?.name
}_ with the payload
\`\`\`
${JSON.stringify(JSON.parse(message.message.function_call?.arguments || ''), null, 4)}
\`\`\`
and return its results for me to look at.`);
} else if (isSystemPrompt) {
title = i18n.translate('xpack.observabilityAiAssistant.addedSystemPromptEvent', {
defaultMessage: 'added a prompt',
});
content = '';
} else {
title = '';
content = message.message.content;
}
const props = {
id: v4(),
role: message.message.role,
canEdit: hasConnector && (message.message.role === MessageRole.User || hasFunction),
canRegenerate: hasConnector && message.message.role === MessageRole.Assistant,
canGiveFeedback: message.message.role === MessageRole.Assistant,
loading: false,
title,
content,
currentUser,
};
return props;
}),
];
}

View file

@ -13,7 +13,7 @@ export function readableStreamReaderIntoObservable(
return new Observable<string>((subscriber) => {
let lineBuffer: string = '';
async function read() {
async function read(): Promise<void> {
const { done, value } = await readableStreamReader.read();
if (done) {
if (lineBuffer) {
@ -35,13 +35,13 @@ export function readableStreamReaderIntoObservable(
subscriber.next(line);
}
read();
return read();
}
read().catch((err) => subscriber.error(err));
return () => {
readableStreamReader.cancel();
readableStreamReader.cancel().catch(() => {});
};
}).pipe(share());
}

View file

@ -5,7 +5,39 @@
* 2.0.
*/
import React, { ComponentType } from 'react';
import { Observable } from 'rxjs';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import type { Serializable } from '@kbn/utility-types';
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
import { ObservabilityAIAssistantProvider } from '../context/observability_ai_assistant_provider';
import { ObservabilityAIAssistantAPIClient } from '../api';
import type { Message } from '../../common';
import type { ObservabilityAIAssistantService, PendingMessage } from '../types';
import { buildFunctionElasticsearch, buildFunctionServiceSummary } from './builders';
const service: ObservabilityAIAssistantService = {
isEnabled: () => true,
chat: (options: { messages: Message[]; connectorId: string }) => new Observable<PendingMessage>(),
callApi: {} as ObservabilityAIAssistantAPIClient,
getCurrentUser: async (): Promise<AuthenticatedUser> => ({
username: 'user',
roles: [],
enabled: true,
authentication_realm: { name: 'foo', type: '' },
lookup_realm: { name: 'foo', type: '' },
authentication_provider: { name: '', type: '' },
authentication_type: '',
elastic_cloud_user: false,
}),
getContexts: () => [],
getFunctions: () => [buildFunctionElasticsearch(), buildFunctionServiceSummary()],
executeFunction: async (
name: string,
args: string | undefined,
signal: AbortSignal
): Promise<{ content?: Serializable; data?: Serializable }> => ({}),
renderFunction: (name: string, response: {}) => <div>Hello! {name}</div>,
};
export function KibanaReactStorybookDecorator(Story: ComponentType) {
return (
@ -21,7 +53,9 @@ export function KibanaReactStorybookDecorator(Story: ComponentType) {
},
}}
>
<Story />
<ObservabilityAIAssistantProvider value={service}>
<Story />
</ObservabilityAIAssistantProvider>
</KibanaContextProvider>
);
}

View file

@ -5,11 +5,18 @@
* 2.0.
*/
import type { PluginInitializerContext } from '@kbn/core/server';
import type { PluginConfigDescriptor, PluginInitializerContext } from '@kbn/core/server';
import type { ObservabilityAIAssistantConfig } from './config';
import { ObservabilityAIAssistantPlugin } from './plugin';
export type { ObservabilityAIAssistantServerRouteRepository } from './routes/get_global_observability_ai_assistant_route_repository';
import { config as configSchema } from './config';
export const config: PluginConfigDescriptor<ObservabilityAIAssistantConfig> = {
exposeToBrowser: {},
schema: configSchema,
};
export const plugin = (ctx: PluginInitializerContext<ObservabilityAIAssistantConfig>) =>
new ObservabilityAIAssistantPlugin(ctx);

View file

@ -19,6 +19,14 @@ const chatRoute = createObservabilityAIAssistantServerRoute({
body: t.type({
messages: t.array(messageRt),
connectorId: t.string,
functions: t.array(
t.type({
name: t.string,
description: t.string,
parameters: t.any,
contexts: t.array(t.string),
})
),
}),
}),
handler: async (resources): Promise<IncomingMessage> => {
@ -30,9 +38,14 @@ const chatRoute = createObservabilityAIAssistantServerRoute({
throw notImplemented();
}
const {
body: { messages, connectorId, functions },
} = params;
return client.chat({
messages: params.body.messages,
connectorId: params.body.connectorId,
messages,
connectorId,
functions,
});
},
});

View file

@ -21,7 +21,7 @@ const getConversationRoute = createObservabilityAIAssistantServerRoute({
options: {
tags: ['access:ai_assistant'],
},
handler: async (resources): Promise<void> => {
handler: async (resources): Promise<Conversation> => {
const { service, request, params } = resources;
const client = await service.getClient({ request });

View file

@ -0,0 +1,133 @@
/*
* 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 * as t from 'io-ts';
import { nonEmptyStringRt, toBooleanRt } from '@kbn/io-ts-utils';
import { notImplemented } from '@hapi/boom';
import { createObservabilityAIAssistantServerRoute } from '../create_observability_ai_assistant_server_route';
import { KnowledgeBaseEntry } from '../../../common/types';
const functionElasticsearchRoute = createObservabilityAIAssistantServerRoute({
endpoint: 'POST /internal/observability_ai_assistant/functions/elasticsearch',
options: {
tags: ['access:ai_assistant'],
},
params: t.type({
body: t.intersection([
t.type({
method: t.union([
t.literal('GET'),
t.literal('POST'),
t.literal('PATCH'),
t.literal('PUT'),
t.literal('DELETE'),
]),
path: t.string,
}),
t.partial({
body: t.any,
}),
]),
}),
handler: async (resources): Promise<unknown> => {
const { method, path, body } = resources.params.body;
const response = await (
await resources.context.core
).elasticsearch.client.asCurrentUser.transport.request({
method,
path,
body,
});
return response;
},
});
const functionRecallRoute = createObservabilityAIAssistantServerRoute({
endpoint: 'POST /internal/observability_ai_assistant/functions/recall',
params: t.type({
body: t.type({
query: nonEmptyStringRt,
}),
}),
options: {
tags: ['access:ai_assistant'],
},
handler: async (resources): Promise<{ entries: KnowledgeBaseEntry[] }> => {
const client = await resources.service.getClient({ request: resources.request });
if (!client) {
throw notImplemented();
}
return client.recall(resources.params.body.query);
},
});
const functionSummariseRoute = createObservabilityAIAssistantServerRoute({
endpoint: 'POST /internal/observability_ai_assistant/functions/summarise',
params: t.type({
body: t.type({
id: t.string,
text: nonEmptyStringRt,
confidence: t.union([t.literal('low'), t.literal('medium'), t.literal('high')]),
is_correction: toBooleanRt,
public: toBooleanRt,
}),
}),
options: {
tags: ['access:ai_assistant'],
},
handler: async (resources): Promise<void> => {
const client = await resources.service.getClient({ request: resources.request });
if (!client) {
throw notImplemented();
}
const {
confidence,
id,
is_correction: isCorrection,
text,
public: isPublic,
} = resources.params.body;
return client.summarise({
entry: {
confidence,
id,
is_correction: isCorrection,
text,
public: isPublic,
},
});
},
});
const setupKnowledgeBaseRoute = createObservabilityAIAssistantServerRoute({
endpoint: 'POST /internal/observability_ai_assistant/functions/setup_kb',
options: {
tags: ['access:ai_assistant'],
},
handler: async (resources): Promise<void> => {
const client = await resources.service.getClient({ request: resources.request });
if (!client) {
throw notImplemented();
}
await client.setupKnowledgeBase();
},
});
export const functionRoutes = {
...functionElasticsearchRoute,
...functionRecallRoute,
...functionSummariseRoute,
...setupKnowledgeBaseRoute,
};

View file

@ -8,12 +8,14 @@
import { chatRoutes } from './chat/route';
import { connectorRoutes } from './connectors/route';
import { conversationRoutes } from './conversations/route';
import { functionRoutes } from './functions/route';
export function getGlobalObservabilityAIAssistantServerRouteRepository() {
return {
...chatRoutes,
...conversationRoutes,
...connectorRoutes,
...functionRoutes,
};
}

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import * as t from 'io-ts';
import { toBooleanRt } from '@kbn/io-ts-utils';
import {
Conversation,
ConversationCreateRequest,
@ -23,7 +24,6 @@ export const messageRt: t.Type<Message> = t.type({
role: t.union([
t.literal(MessageRole.System),
t.literal(MessageRole.Assistant),
t.literal(MessageRole.Event),
t.literal(MessageRole.Function),
t.literal(MessageRole.User),
t.literal(MessageRole.Elastic),
@ -32,6 +32,7 @@ export const messageRt: t.Type<Message> = t.type({
t.partial({
content: t.string,
name: t.string,
event: t.string,
function_call: t.intersection([
t.type({
name: t.string,
@ -42,7 +43,7 @@ export const messageRt: t.Type<Message> = t.type({
]),
}),
t.partial({
args: serializeableRt,
arguments: serializeableRt,
data: serializeableRt,
}),
]),
@ -58,6 +59,7 @@ export const baseConversationRt: t.Type<ConversationRequestBase> = t.type({
messages: t.array(messageRt),
labels: t.record(t.string, t.string),
numeric_labels: t.record(t.string, t.number),
public: toBooleanRt,
});
export const conversationCreateRt: t.Type<ConversationCreateRequest> = t.intersection([

View file

@ -4,29 +4,41 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { v4 } from 'uuid';
import type { ChatCompletionRequestMessage, CreateChatCompletionRequest } from 'openai';
import type { IncomingMessage } from 'http';
import { errors } from '@elastic/elasticsearch';
import type { QueryDslTextExpansionQuery, SearchHit } from '@elastic/elasticsearch/lib/api/types';
import { internal, notFound, serverUnavailable } 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';
import type { ActionsClient } from '@kbn/actions-plugin/server/actions_client';
import type { PublicMethodsOf } from '@kbn/utility-types';
import { internal, notFound } from '@hapi/boom';
import type { IncomingMessage } from 'http';
import { compact, isEmpty, merge, omit } from 'lodash';
import type { SearchHit } from '@elastic/elasticsearch/lib/api/types';
import type {
ChatCompletionFunctions,
ChatCompletionRequestMessage,
CreateChatCompletionRequest,
} from 'openai';
import { v4 } from 'uuid';
import {
KnowledgeBaseEntry,
MessageRole,
type Conversation,
type ConversationCreateRequest,
type ConversationUpdateRequest,
type FunctionDefinition,
type Message,
MessageRole,
} from '../../../common/types';
import type {
IObservabilityAIAssistantClient,
ObservabilityAIAssistantResourceNames,
} from '../types';
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 {
constructor(
private readonly dependencies: {
@ -48,8 +60,20 @@ export class ObservabilityAIAssistantClient implements IObservabilityAIAssistant
bool: {
filter: [
{
term: {
'user.name': this.dependencies.user.name,
bool: {
should: [
{
term: {
'user.name': this.dependencies.user.name,
},
},
{
term: {
public: true,
},
},
],
minimum_should_match: 1,
},
},
{
@ -90,8 +114,13 @@ export class ObservabilityAIAssistantClient implements IObservabilityAIAssistant
};
};
get = async (conversationId: string): Promise<Conversation | undefined> => {
return (await this.getConversationWithMetaFields(conversationId))?._source;
get = async (conversationId: string): Promise<Conversation> => {
const conversation = await this.getConversationWithMetaFields(conversationId);
if (!conversation) {
throw notFound();
}
return conversation._source!;
};
delete = async (conversationId: string): Promise<void> => {
@ -104,38 +133,46 @@ export class ObservabilityAIAssistantClient implements IObservabilityAIAssistant
await this.dependencies.esClient.delete({
id: conversation._id,
index: conversation._index,
refresh: 'wait_for',
});
};
chat = async ({
messages,
connectorId,
functions,
}: {
messages: Message[];
connectorId: string;
functions: Array<FunctionDefinition['options']>;
}): Promise<IncomingMessage> => {
const messagesForOpenAI: ChatCompletionRequestMessage[] = compact(
messages.map((message) => {
if (message.message.role === MessageRole.Event) {
return undefined;
}
const role =
message.message.role === MessageRole.Elastic ? MessageRole.User : message.message.role;
messages
.filter((message) => message.message.content || message.message.function_call?.name)
.map((message) => {
const role =
message.message.role === MessageRole.Elastic ? MessageRole.User : message.message.role;
return {
role,
content: message.message.content,
function_call: isEmpty(message.message.function_call?.name)
? undefined
: omit(message.message.function_call, 'trigger'),
name: message.message.name,
};
})
return {
role,
content: message.message.content,
function_call: isEmpty(message.message.function_call?.name)
? undefined
: omit(message.message.function_call, 'trigger'),
name: message.message.name,
};
})
);
const functionsForOpenAI: ChatCompletionFunctions[] = functions.map((fn) =>
omit(fn, 'contexts')
);
const request: Omit<CreateChatCompletionRequest, 'model'> & { model?: string } = {
messages: messagesForOpenAI,
stream: true,
functions: functionsForOpenAI,
temperature: 0,
};
const executeResult = await this.dependencies.actionsClient.execute({
@ -193,6 +230,7 @@ export class ObservabilityAIAssistantClient implements IObservabilityAIAssistant
id: document._id,
index: document._index,
doc: updatedConversation,
refresh: 'wait_for',
});
return updatedConversation;
@ -214,8 +252,110 @@ export class ObservabilityAIAssistantClient implements IObservabilityAIAssistant
await this.dependencies.esClient.index({
index: this.dependencies.resources.aliases.conversations,
document: createdConversation,
refresh: 'wait_for',
});
return createdConversation;
};
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;
}
};
summarise = async ({
entry: { id, ...document },
}: {
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;
}
};
setupKnowledgeBase = async () => {
// if this fails, it's fine to propagate the error to the user
await this.dependencies.esClient.ml.putTrainedModel({
model_id: ELSER_MODEL_ID,
input: {
field_names: ['text_field'],
},
});
try {
await this.dependencies.esClient.ml.startTrainedModelDeployment({
model_id: ELSER_MODEL_ID,
});
const modelStats = await this.dependencies.esClient.ml.getTrainedModelsStats({
model_id: ELSER_MODEL_ID,
});
const elserModelStats = modelStats.trained_model_stats[0];
if (elserModelStats?.deployment_stats?.state !== 'started') {
throwKnowledgeBaseNotReady({
message: `Deployment has not started`,
deployment_stats: elserModelStats.deployment_stats,
});
}
return;
} 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;
}
};
}

View file

@ -66,6 +66,7 @@ export const conversationComponentTemplate: ClusterComponentTemplate['component_
type: 'object',
properties: {
content: text,
event: text,
role: keyword,
data: {
type: 'object',
@ -75,7 +76,7 @@ export const conversationComponentTemplate: ClusterComponentTemplate['component_
type: 'object',
properties: {
name: keyword,
args: {
arguments: {
type: 'object',
enabled: false,
},
@ -86,6 +87,9 @@ export const conversationComponentTemplate: ClusterComponentTemplate['component_
},
},
},
public: {
type: 'boolean',
},
},
},
};

View file

@ -14,6 +14,7 @@ import { getSpaceIdFromPath } from '@kbn/spaces-plugin/common';
import { once } from 'lodash';
import { ObservabilityAIAssistantClient } from './client';
import { conversationComponentTemplate } from './conversation_component_template';
import { kbComponentTemplate } from './kb_component_template';
import type {
IObservabilityAIAssistantClient,
IObservabilityAIAssistantService,
@ -31,19 +32,26 @@ export class ObservabilityAIAssistantService implements IObservabilityAIAssistan
private readonly resourceNames: ObservabilityAIAssistantResourceNames = {
componentTemplate: {
conversations: getResourceName('component-template-conversations'),
kb: getResourceName('component-template-kb'),
},
aliases: {
conversations: getResourceName('conversations'),
kb: getResourceName('kb'),
},
indexPatterns: {
conversations: getResourceName('conversations*'),
kb: getResourceName('kb*'),
},
indexTemplate: {
conversations: getResourceName('index-template-conversations'),
kb: getResourceName('index-template-kb'),
},
ilmPolicy: {
conversations: getResourceName('ilm-policy-conversations'),
},
pipelines: {
kb: getResourceName('kb-ingest-pipeline'),
},
};
constructor({ logger, core }: { logger: Logger; core: CoreSetup }) {
@ -94,20 +102,78 @@ export class ObservabilityAIAssistantService implements IObservabilityAIAssistan
},
});
const aliasName = this.resourceNames.aliases.conversations;
const conversationAliasName = this.resourceNames.aliases.conversations;
await createConcreteWriteIndex({
esClient,
logger: this.logger,
totalFieldsLimit: 10000,
indexPatterns: {
alias: aliasName,
pattern: `${aliasName}*`,
basePattern: `${aliasName}*`,
name: `${aliasName}-000001`,
alias: conversationAliasName,
pattern: `${conversationAliasName}*`,
basePattern: `${conversationAliasName}*`,
name: `${conversationAliasName}-000001`,
template: this.resourceNames.indexTemplate.conversations,
},
});
await esClient.cluster.putComponentTemplate({
create: false,
name: this.resourceNames.componentTemplate.kb,
template: kbComponentTemplate,
});
await esClient.ingest.putPipeline({
id: this.resourceNames.pipelines.kb,
processors: [
{
inference: {
model_id: '.elser_model_1',
target_field: 'ml',
field_map: {
text: 'text_field',
},
inference_config: {
// @ts-expect-error
text_expansion: {
results_field: 'tokens',
},
},
},
},
],
});
await esClient.indices.putIndexTemplate({
name: this.resourceNames.indexTemplate.kb,
composed_of: [this.resourceNames.componentTemplate.kb],
create: false,
index_patterns: [this.resourceNames.indexPatterns.kb],
template: {
settings: {
number_of_shards: 1,
auto_expand_replicas: '0-1',
refresh_interval: '1s',
},
},
});
const kbAliasName = this.resourceNames.aliases.kb;
await createConcreteWriteIndex({
esClient,
logger: this.logger,
totalFieldsLimit: 10000,
indexPatterns: {
alias: kbAliasName,
pattern: `${kbAliasName}*`,
basePattern: `${kbAliasName}*`,
name: `${kbAliasName}-000001`,
template: this.resourceNames.indexTemplate.kb,
},
});
this.logger.info('Successfully set up index assets');
} catch (error) {
this.logger.error(`Failed to initialize service: ${error.message}`);
this.logger.debug(error);

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ClusterComponentTemplate } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
const keyword = {
type: 'keyword' as const,
ignore_above: 1024,
};
const text = {
type: 'text' as const,
};
const date = {
type: 'date' as const,
};
export const kbComponentTemplate: ClusterComponentTemplate['component_template']['template'] = {
mappings: {
dynamic: false,
properties: {
'@timestamp': date,
id: keyword,
user: {
properties: {
id: keyword,
name: keyword,
},
},
conversation: {
properties: {
id: keyword,
title: text,
last_updated: date,
},
},
namespace: keyword,
text,
'ml.tokens': {
type: 'rank_features',
},
confidence: keyword,
is_correction: {
type: 'boolean',
},
public: {
type: 'boolean',
},
},
},
};

View file

@ -7,20 +7,29 @@
import { IncomingMessage } from 'http';
import { KibanaRequest } from '@kbn/core/server';
import {
import type {
Conversation,
ConversationCreateRequest,
ConversationUpdateRequest,
FunctionDefinition,
KnowledgeBaseEntry,
Message,
} from '../../common/types';
export interface IObservabilityAIAssistantClient {
chat: (options: { messages: Message[]; connectorId: string }) => Promise<IncomingMessage>;
get: (conversationId: string) => void;
chat: (options: {
messages: Message[];
connectorId: string;
functions: Array<FunctionDefinition['options']>;
}) => 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>;
setupKnowledgeBase: () => Promise<void>;
}
export interface IObservabilityAIAssistantService {
@ -32,17 +41,24 @@ export interface IObservabilityAIAssistantService {
export interface ObservabilityAIAssistantResourceNames {
componentTemplate: {
conversations: string;
kb: string;
};
indexTemplate: {
conversations: string;
kb: string;
};
ilmPolicy: {
conversations: string;
};
aliases: {
conversations: string;
kb: string;
};
indexPatterns: {
conversations: string;
kb: string;
};
pipelines: {
kb: string;
};
}

View file

@ -1,7 +1,7 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"outDir": "target/types"
},
"include": [
"../../../typings/**/*",
@ -25,9 +25,17 @@
"@kbn/kibana-react-plugin",
"@kbn/shared-ux-utility",
"@kbn/alerting-plugin",
"@kbn/shared-ux-link-redirect-app",
"@kbn/typed-react-router-config",
"@kbn/ui-theme",
"@kbn/user-profile-components",
"@kbn/observability-shared-plugin",
"@kbn/kibana-utils-plugin",
"@kbn/monaco",
"@kbn/io-ts-utils",
"@kbn/std",
"@kbn/alerting-plugin",
"@kbn/features-plugin"
],
"exclude": [
"target/**/*",
]
"exclude": ["target/**/*"]
}

View file

@ -38,6 +38,7 @@ export interface CreateTest {
services: InheritedServices & {
observabilityAIAssistantAPIClient: (context: InheritedFtrProviderContext) => Promise<{
readUser: ObservabilityAIAssistantAPIClient;
writeUser: ObservabilityAIAssistantAPIClient;
}>;
observabilityAIAssistantFtrConfig: (
context: InheritedFtrProviderContext
@ -74,6 +75,9 @@ export function createTestConfig(
readUser: await getObservabilityAIAssistantAPIClient({
kibanaServer,
}),
writeUser: await getObservabilityAIAssistantAPIClient({
kibanaServer,
}),
};
},
},

View file

@ -5,25 +5,25 @@
* 2.0.
*/
import { format } from 'url';
import supertest from 'supertest';
import request from 'superagent';
import type {
ObservabilityAIAssistantAPIEndpoint,
ObservabilityAIAssistantAPIClientRequestParamsOf,
APIReturnType,
ObservabilityAIAssistantAPIClientRequestParamsOf,
ObservabilityAIAssistantAPIEndpoint,
} from '@kbn/observability-ai-assistant-plugin/public';
import { formatRequest } from '@kbn/server-route-repository';
import supertest from 'supertest';
import { format } from 'url';
import { Subtract } from 'utility-types';
export function createObservabilityAIAssistantApiClient(st: supertest.SuperTest<supertest.Test>) {
return async <TEndpoint extends ObservabilityAIAssistantAPIEndpoint>(
return <TEndpoint extends ObservabilityAIAssistantAPIEndpoint>(
options: {
type?: 'form-data';
endpoint: TEndpoint;
} & ObservabilityAIAssistantAPIClientRequestParamsOf<TEndpoint> & {
params?: { query?: { _inspect?: boolean } };
}
): Promise<SupertestReturnType<TEndpoint>> => {
): SupertestReturnType<TEndpoint> => {
const { endpoint, type } = options;
const params = 'params' in options ? (options.params as Record<string, any>) : {};
@ -37,7 +37,7 @@ export function createObservabilityAIAssistantApiClient(st: supertest.SuperTest<
headers['Elastic-Api-Version'] = version;
}
let res: request.Response;
let res: supertest.Test;
if (type === 'form-data') {
const fields: Array<[string, any]> = Object.entries(params.body);
const formDataRequest = st[method](url)
@ -48,19 +48,14 @@ export function createObservabilityAIAssistantApiClient(st: supertest.SuperTest<
formDataRequest.field(field[0], field[1]);
}
res = await formDataRequest;
res = formDataRequest;
} else if (params.body) {
res = await st[method](url).send(params.body).set(headers);
res = st[method](url).send(params.body).set(headers);
} else {
res = await st[method](url).set(headers);
res = st[method](url).set(headers);
}
// supertest doesn't throw on http errors
if (res?.status !== 200) {
throw new Error(`Request to ${endpoint} failed with status code ${res?.status ?? 0}`);
}
return res;
return res as unknown as SupertestReturnType<TEndpoint>;
};
}
@ -68,8 +63,47 @@ export type ObservabilityAIAssistantAPIClient = ReturnType<
typeof createObservabilityAIAssistantApiClient
>;
export interface SupertestReturnType<TEndpoint extends ObservabilityAIAssistantAPIEndpoint> {
text: string;
status: number;
body: APIReturnType<TEndpoint>;
type WithoutPromise<T extends Promise<any>> = Subtract<T, Promise<any>>;
// this is a little intense, but without it, method overrides are lost
// e.g., {
// end(one:string)
// end(one:string, two:string)
// }
// would lose the first signature. This keeps up to four signatures.
type OverloadedParameters<T> = T extends {
(...args: infer A1): any;
(...args: infer A2): any;
(...args: infer A3): any;
(...args: infer A4): any;
}
? A1 | A2 | A3 | A4
: T extends { (...args: infer A1): any; (...args: infer A2): any; (...args: infer A3): any }
? A1 | A2 | A3
: T extends { (...args: infer A1): any; (...args: infer A2): any }
? A1 | A2
: T extends (...args: infer A) => any
? A
: any;
type OverrideReturnType<T extends (...args: any[]) => any, TNextReturnType> = (
...args: OverloadedParameters<T>
) => WithoutPromise<ReturnType<T>> & TNextReturnType;
type OverwriteThisMethods<T extends Record<string, any>, TNextReturnType> = TNextReturnType & {
[key in keyof T]: T[key] extends (...args: infer TArgs) => infer TReturnType
? TReturnType extends Promise<any>
? OverrideReturnType<T[key], TNextReturnType>
: (...args: TArgs) => TReturnType
: T[key];
};
export type SupertestReturnType<TEndpoint extends ObservabilityAIAssistantAPIEndpoint> =
OverwriteThisMethods<
WithoutPromise<supertest.Test>,
Promise<{
text: string;
status: number;
body: APIReturnType<TEndpoint>;
}>
>;

Some files were not shown because too many files have changed in this diff Show more