[Security Solution] Security AI Assistant persistent storage. (#173487)

## Summary
This PR including both new APIs and client side changes to use data
stream for Security Solution AI Assistant conversations persistence
storage.
Issue https://github.com/elastic/security-team/issues/7810

## Extended description

#### elastic-assistant plugin
All API changes are introduced in elastic-assistant plugin server
- `RequestContextFactory ` - this class helps to provide the needed
context for each API request for routes handler context.
- `AIAssistantService` - This service instance is created on the plugin
setup and included to the request context factory. It is responsible for
the needed conversations storage resources initialization and
installation. It uses DataStreamAdapter from
`packages/kbn-data-stream-adapter`. Conversations fieldMap definition
[here](https://github.com/elastic/kibana/pull/173487/files#diff-c4fdbd4023c6ebc0c0bb04a32314ce8ea614f2d0916afac5e366a71122687d54)
- `AIAssistantConversationsDataClient` - data client which has a set of
methods to interact with conversation storage on behalf of the current
user and space.
- `ConversationDataWriter` - is a helper class which implements a bulk
method to interact with esClient
- Added new routes using versioned router and o[penAPI code
generator](https://github.com/elastic/kibana/blob/main/packages/kbn-openapi-generator/README.md)
schemas:
`createConversationRoute`
`readConversationRoute`
`updateConversationRoute`
`deleteConversationRoute`
`appendMessagesRoute`
`findUserConversationsRoute`
`bulkActionsRoute`

- Migrated existing `knowledge_base`, `evaluate` and
`post_actions_connector_execute` routes to versioned routing and openAPI
code generator schemas.

#### kbn-elastic-assistant package

- removed local storage persistency logic for assistantConversations.
- added API requests definition to communicate to server side.
- 

#### kbn-elastic-assistant-common package
- Changed `transformsRowData` function to use async add replacements
API.
- Exposed routing URLs with constants file to be available for server
and client.

#### security_solution plugin

- Added `migrateConversationsFromLocalStorage` for existing
conversations in the local storage. This migration happening only for
the first time when user doesn't have any conversations persisted in the
current space. After mirgation complete, the old local storage key
`securitySolution.assistantConversation` will be removed.
- Passing security related `baseConversation` as a property to
`ElasticAssistantProvider`
- Changed `useAssistantTelemetry` to fetch information about the
conversation from the conversations API
- Modified `useConversationStore` to fetch the data from the
conversations API
`/api/elastic_assistant/conversations/current_user/_find` and merge with
security predefined `baseConversations` if they are not used(persisted)
yet.
- Extracted `AssistantTab` to a separate lazy loaded file to avoid
unnecessary rendering/requests till this tab will be shown in Timeline.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Steph Milovic <stephanie.milovic@elastic.co>
This commit is contained in:
Yuliia Naumenko 2024-03-13 17:34:55 -07:00 committed by GitHub
parent 1bc8c16574
commit 0631172a68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
232 changed files with 17152 additions and 3084 deletions

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 const ELASTIC_AI_ASSISTANT_API_CURRENT_VERSION = '2023-10-31';
export const ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION = '1';
export const ELASTIC_AI_ASSISTANT_URL = '/api/elastic_assistant';
export const ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL = `${ELASTIC_AI_ASSISTANT_URL}/current_user/conversations`;
export const ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BY_ID = `${ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL}/{id}`;
export const ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BY_ID_MESSAGES = `${ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BY_ID}/messages`;
export const ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BULK_ACTION = `${ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL}/_bulk_action`;
export const ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_FIND = `${ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL}/_find`;
export const ELASTIC_AI_ASSISTANT_PROMPTS_URL = `${ELASTIC_AI_ASSISTANT_URL}/prompts`;
export const ELASTIC_AI_ASSISTANT_PROMPTS_URL_BULK_ACTION = `${ELASTIC_AI_ASSISTANT_PROMPTS_URL}/_bulk_action`;
export const ELASTIC_AI_ASSISTANT_PROMPTS_URL_FIND = `${ELASTIC_AI_ASSISTANT_PROMPTS_URL}/_find`;
export const ELASTIC_AI_ASSISTANT_ANONYMIZATION_FIELDS_URL = `${ELASTIC_AI_ASSISTANT_URL}/anonymization_fields`;
export const ELASTIC_AI_ASSISTANT_ANONYMIZATION_FIELDS_URL_BULK_ACTION = `${ELASTIC_AI_ASSISTANT_ANONYMIZATION_FIELDS_URL}/_bulk_action`;
export const ELASTIC_AI_ASSISTANT_ANONYMIZATION_FIELDS_URL_FIND = `${ELASTIC_AI_ASSISTANT_ANONYMIZATION_FIELDS_URL}/_find`;

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { Replacement } from '../../schemas';
export const getIsDataAnonymizable = (rawData: string | Record<string, string[]>): boolean =>
typeof rawData !== 'string';
@ -21,3 +23,31 @@ export const isAnonymized = ({
allowReplacementSet: Set<string>;
field: string;
}): boolean => allowReplacementSet.has(field);
export const replaceAnonymizedValuesWithOriginalValues = ({
messageContent,
replacements,
}: {
messageContent: string;
replacements: Replacement[];
}): string =>
replacements != null
? replacements.reduce((acc, replacement) => {
const value = replacement.value;
return replacement.uuid && value ? acc.replaceAll(replacement.uuid, value) : acc;
}, messageContent)
: messageContent;
export const replaceOriginalValuesWithUuidValues = ({
messageContent,
replacements,
}: {
messageContent: string;
replacements: Replacement[];
}): string =>
replacements != null
? replacements.reduce((acc, replacement) => {
const value = replacement.value;
return replacement.uuid && value ? acc.replaceAll(value, replacement.uuid) : acc;
}, messageContent)
: messageContent;

View file

@ -20,7 +20,7 @@ describe('transformRawData', () => {
const result = transformRawData({
allow: inputRawData.allow,
allowReplacement: inputRawData.allowReplacement,
currentReplacements: {},
currentReplacements: [],
getAnonymizedValue: mockGetAnonymizedValue,
onNewReplacements: () => {},
rawData: inputRawData.rawData,
@ -42,13 +42,13 @@ describe('transformRawData', () => {
transformRawData({
allow: inputRawData.allow,
allowReplacement: inputRawData.allowReplacement,
currentReplacements: {},
currentReplacements: [],
getAnonymizedValue: mockGetAnonymizedValue,
onNewReplacements,
rawData: inputRawData.rawData,
});
expect(onNewReplacements).toHaveBeenCalledWith({ '1eulav': 'value1' });
expect(onNewReplacements).toHaveBeenCalledWith([{ uuid: '1eulav', value: 'value1' }]);
});
it('returns the expected mix of anonymized and non-anonymized data as a CSV string', () => {
@ -62,7 +62,7 @@ describe('transformRawData', () => {
const result = transformRawData({
allow: inputRawData.allow,
allowReplacement: inputRawData.allowReplacement,
currentReplacements: {},
currentReplacements: [],
getAnonymizedValue: mockGetAnonymizedValue,
onNewReplacements: () => {},
rawData: inputRawData.rawData,
@ -86,7 +86,7 @@ describe('transformRawData', () => {
const result = transformRawData({
allow: inputRawData.allow,
allowReplacement: inputRawData.allowReplacement,
currentReplacements: {},
currentReplacements: [],
getAnonymizedValue: mockGetAnonymizedValue,
onNewReplacements: () => {},
rawData: inputRawData.rawData,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { Replacement } from '../../schemas';
import { getAnonymizedData } from '../get_anonymized_data';
import { getAnonymizedValues } from '../get_anonymized_values';
import { getCsvFromData } from '../get_csv_from_data';
@ -19,7 +20,7 @@ export const transformRawData = ({
}: {
allow: string[];
allowReplacement: string[];
currentReplacements: Record<string, string> | undefined;
currentReplacements: Replacement[] | undefined;
getAnonymizedValue: ({
currentReplacements,
rawValue,
@ -27,7 +28,7 @@ export const transformRawData = ({
currentReplacements: Record<string, string> | undefined;
rawValue: string;
}) => string;
onNewReplacements?: (replacements: Record<string, string>) => void;
onNewReplacements?: (replacements: Replacement[]) => void;
rawData: string | Record<string, unknown[]>;
}): string => {
if (typeof rawData === 'string') {
@ -37,14 +38,22 @@ export const transformRawData = ({
const anonymizedData = getAnonymizedData({
allow,
allowReplacement,
currentReplacements,
currentReplacements: currentReplacements?.reduce((acc: Record<string, string>, r) => {
acc[r.uuid] = r.value;
return acc;
}, {}),
rawData,
getAnonymizedValue,
getAnonymizedValues,
});
if (onNewReplacements != null) {
onNewReplacements(anonymizedData.replacements);
onNewReplacements(
Object.keys(anonymizedData.replacements).map((key) => ({
uuid: key,
value: anonymizedData.replacements[key],
}))
);
}
return getCsvFromData(anonymizedData.anonymizedData);

View file

@ -0,0 +1,68 @@
/*
* 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 { z } from 'zod';
/*
* NOTICE: Do not edit this file manually.
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
*
* info:
* title: Execute Connector API endpoint
* version: 1
*/
import { UUID, Replacement } from '../conversations/common_attributes.gen';
export type ExecuteConnectorRequestParams = z.infer<typeof ExecuteConnectorRequestParams>;
export const ExecuteConnectorRequestParams = z.object({
/**
* The connector's `id` value.
*/
connectorId: z.string(),
});
export type ExecuteConnectorRequestParamsInput = z.input<typeof ExecuteConnectorRequestParams>;
export type ExecuteConnectorRequestBody = z.infer<typeof ExecuteConnectorRequestBody>;
export const ExecuteConnectorRequestBody = z.object({
conversationId: UUID.optional(),
message: z.string().optional(),
model: z.string().optional(),
subAction: z.enum(['invokeAI', 'invokeStream']),
alertsIndexPattern: z.string().optional(),
allow: z.array(z.string()).optional(),
allowReplacement: z.array(z.string()).optional(),
isEnabledKnowledgeBase: z.boolean().optional(),
isEnabledRAGAlerts: z.boolean().optional(),
replacements: z.array(Replacement),
size: z.number().optional(),
llmType: z.enum(['bedrock', 'openai']),
});
export type ExecuteConnectorRequestBodyInput = z.input<typeof ExecuteConnectorRequestBody>;
export type ExecuteConnectorResponse = z.infer<typeof ExecuteConnectorResponse>;
export const ExecuteConnectorResponse = z.object({
data: z.string().optional(),
connector_id: z.string().optional(),
replacements: z.array(Replacement).optional(),
status: z.string().optional(),
/**
* Trace Data
*/
trace_data: z
.object({
/**
* Could be any string, not necessarily a UUID
*/
transactionId: z.string().optional(),
/**
* Could be any string, not necessarily a UUID
*/
traceId: z.string().optional(),
})
.optional(),
});

View file

@ -0,0 +1,110 @@
openapi: 3.0.0
info:
title: Execute Connector API endpoint
version: '1'
paths:
/internal/elastic_assistant/actions/connector/{connectorId}/_execute:
post:
operationId: ExecuteConnector
x-codegen-enabled: true
description: Execute Elastic Assistant connector by id
summary: Execute Elastic Assistant connector
tags:
- Connector API
parameters:
- name: connectorId
in: path
required: true
description: The connector's `id` value.
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- params
- llmType
- replacements
- subAction
properties:
conversationId:
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/UUID'
message:
type: string
model:
type: string
subAction:
type: string
enum:
- invokeAI
- invokeStream
alertsIndexPattern:
type: string
allow:
type: array
items:
type: string
allowReplacement:
type: array
items:
type: string
isEnabledKnowledgeBase:
type: boolean
isEnabledRAGAlerts:
type: boolean
replacements:
type: array
items:
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/Replacement'
size:
type: number
llmType:
type: string
enum:
- bedrock
- openai
responses:
'200':
description: Successful response
content:
application/json:
schema:
type: object
properties:
data:
type: string
connector_id:
type: string
replacements:
type: array
items:
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/Replacement'
status:
type: string
trace_data:
type: object
description: Trace Data
properties:
transactionId:
type: string
description: Could be any string, not necessarily a UUID
traceId:
type: string
description: Could be any string, not necessarily a UUID
'400':
description: Generic Error
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
error:
type: string
message:
type: string

View file

@ -0,0 +1,127 @@
/*
* 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 { z } from 'zod';
/*
* NOTICE: Do not edit this file manually.
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
*
* info:
* title: Bulk Actions API endpoint
* version: 2023-10-31
*/
import { UUID, NonEmptyString, User } from '../conversations/common_attributes.gen';
export type BulkActionSkipReason = z.infer<typeof BulkActionSkipReason>;
export const BulkActionSkipReason = z.literal('ANONYMIZATION_FIELD_NOT_MODIFIED');
export type BulkActionSkipResult = z.infer<typeof BulkActionSkipResult>;
export const BulkActionSkipResult = z.object({
id: z.string(),
name: z.string().optional(),
skip_reason: BulkActionSkipReason,
});
export type AnonymizationFieldDetailsInError = z.infer<typeof AnonymizationFieldDetailsInError>;
export const AnonymizationFieldDetailsInError = z.object({
id: z.string(),
name: z.string().optional(),
});
export type NormalizedAnonymizationFieldError = z.infer<typeof NormalizedAnonymizationFieldError>;
export const NormalizedAnonymizationFieldError = z.object({
message: z.string(),
status_code: z.number().int(),
err_code: z.string().optional(),
anonymization_fields: z.array(AnonymizationFieldDetailsInError),
});
export type AnonymizationFieldResponse = z.infer<typeof AnonymizationFieldResponse>;
export const AnonymizationFieldResponse = z.object({
id: UUID,
timestamp: NonEmptyString.optional(),
field: z.string(),
defaultAllow: z.boolean().optional(),
defaultAllowReplacement: z.boolean().optional(),
updatedAt: z.string().optional(),
updatedBy: z.string().optional(),
createdAt: z.string().optional(),
createdBy: z.string().optional(),
users: z.array(User).optional(),
/**
* Kibana space
*/
namespace: z.string().optional(),
});
export type BulkCrudActionResults = z.infer<typeof BulkCrudActionResults>;
export const BulkCrudActionResults = z.object({
updated: z.array(AnonymizationFieldResponse),
created: z.array(AnonymizationFieldResponse),
deleted: z.array(z.string()),
skipped: z.array(BulkActionSkipResult),
});
export type BulkCrudActionSummary = z.infer<typeof BulkCrudActionSummary>;
export const BulkCrudActionSummary = z.object({
failed: z.number().int(),
skipped: z.number().int(),
succeeded: z.number().int(),
total: z.number().int(),
});
export type BulkCrudActionResponse = z.infer<typeof BulkCrudActionResponse>;
export const BulkCrudActionResponse = z.object({
success: z.boolean().optional(),
status_code: z.number().int().optional(),
message: z.string().optional(),
anonymization_fields_count: z.number().int().optional(),
attributes: z.object({
results: BulkCrudActionResults,
summary: BulkCrudActionSummary,
errors: z.array(NormalizedAnonymizationFieldError).optional(),
}),
});
export type BulkActionBase = z.infer<typeof BulkActionBase>;
export const BulkActionBase = z.object({
/**
* Query to filter anonymization fields
*/
query: z.string().optional(),
/**
* Array of anonymization fields IDs
*/
ids: z.array(z.string()).min(1).optional(),
});
export type AnonymizationFieldCreateProps = z.infer<typeof AnonymizationFieldCreateProps>;
export const AnonymizationFieldCreateProps = z.object({
field: z.string(),
defaultAllow: z.boolean().optional(),
defaultAllowReplacement: z.boolean().optional(),
});
export type AnonymizationFieldUpdateProps = z.infer<typeof AnonymizationFieldUpdateProps>;
export const AnonymizationFieldUpdateProps = z.object({
id: z.string(),
defaultAllow: z.boolean().optional(),
defaultAllowReplacement: z.boolean().optional(),
});
export type PerformBulkActionRequestBody = z.infer<typeof PerformBulkActionRequestBody>;
export const PerformBulkActionRequestBody = z.object({
delete: BulkActionBase.optional(),
create: z.array(AnonymizationFieldCreateProps).optional(),
update: z.array(AnonymizationFieldUpdateProps).optional(),
});
export type PerformBulkActionRequestBodyInput = z.input<typeof PerformBulkActionRequestBody>;
export type PerformBulkActionResponse = z.infer<typeof PerformBulkActionResponse>;
export const PerformBulkActionResponse = BulkCrudActionResponse;

View file

@ -0,0 +1,239 @@
openapi: 3.0.0
info:
title: Bulk Actions API endpoint
version: '2023-10-31'
paths:
/api/elastic_assistant/anonymization_fields/_bulk_action:
post:
operationId: PerformBulkAction
x-codegen-enabled: true
summary: Applies a bulk action to multiple anonymization fields
description: The bulk action is applied to all anonymization fields that match the filter or to the list of anonymization fields by their IDs.
tags:
- Bulk API
requestBody:
content:
application/json:
schema:
type: object
properties:
delete:
$ref: '#/components/schemas/BulkActionBase'
create:
type: array
items:
$ref: '#/components/schemas/AnonymizationFieldCreateProps'
update:
type: array
items:
$ref: '#/components/schemas/AnonymizationFieldUpdateProps'
responses:
200:
description: Indicates a successful call.
content:
application/json:
schema:
$ref: '#/components/schemas/BulkCrudActionResponse'
400:
description: Generic Error
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
error:
type: string
message:
type: string
components:
schemas:
BulkActionSkipReason:
type: string
enum:
- ANONYMIZATION_FIELD_NOT_MODIFIED
BulkActionSkipResult:
type: object
properties:
id:
type: string
name:
type: string
skip_reason:
$ref: '#/components/schemas/BulkActionSkipReason'
required:
- id
- skip_reason
AnonymizationFieldDetailsInError:
type: object
properties:
id:
type: string
name:
type: string
required:
- id
NormalizedAnonymizationFieldError:
type: object
properties:
message:
type: string
status_code:
type: integer
err_code:
type: string
anonymization_fields:
type: array
items:
$ref: '#/components/schemas/AnonymizationFieldDetailsInError'
required:
- message
- status_code
- anonymization_fields
AnonymizationFieldResponse:
type: object
required:
- id
- field
properties:
id:
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/UUID'
'timestamp':
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/NonEmptyString'
field:
type: string
defaultAllow:
type: boolean
defaultAllowReplacement:
type: boolean
updatedAt:
type: string
updatedBy:
type: string
createdAt:
type: string
createdBy:
type: string
users:
type: array
items:
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/User'
namespace:
type: string
description: Kibana space
BulkCrudActionResults:
type: object
properties:
updated:
type: array
items:
$ref: '#/components/schemas/AnonymizationFieldResponse'
created:
type: array
items:
$ref: '#/components/schemas/AnonymizationFieldResponse'
deleted:
type: array
items:
type: string
skipped:
type: array
items:
$ref: '#/components/schemas/BulkActionSkipResult'
required:
- updated
- created
- deleted
- skipped
BulkCrudActionSummary:
type: object
properties:
failed:
type: integer
skipped:
type: integer
succeeded:
type: integer
total:
type: integer
required:
- failed
- skipped
- succeeded
- total
BulkCrudActionResponse:
type: object
properties:
success:
type: boolean
status_code:
type: integer
message:
type: string
anonymization_fields_count:
type: integer
attributes:
type: object
properties:
results:
$ref: '#/components/schemas/BulkCrudActionResults'
summary:
$ref: '#/components/schemas/BulkCrudActionSummary'
errors:
type: array
items:
$ref: '#/components/schemas/NormalizedAnonymizationFieldError'
required:
- results
- summary
required:
- attributes
BulkActionBase:
x-inline: true
type: object
properties:
query:
type: string
description: Query to filter anonymization fields
ids:
type: array
description: Array of anonymization fields IDs
minItems: 1
items:
type: string
AnonymizationFieldCreateProps:
type: object
required:
- field
properties:
field:
type: string
defaultAllow:
type: boolean
defaultAllowReplacement:
type: boolean
AnonymizationFieldUpdateProps:
type: object
required:
- id
properties:
id:
type: string
defaultAllow:
type: boolean
defaultAllowReplacement:
type: boolean

View file

@ -0,0 +1,73 @@
/*
* 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 { z } from 'zod';
import { ArrayFromString } from '@kbn/zod-helpers';
/*
* NOTICE: Do not edit this file manually.
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
*
* info:
* title: Find AnonymizationFields API endpoint
* version: 2023-10-31
*/
import { AnonymizationFieldResponse } from './bulk_crud_anonymization_fields_route.gen';
export type FindAnonymizationFieldsSortField = z.infer<typeof FindAnonymizationFieldsSortField>;
export const FindAnonymizationFieldsSortField = z.enum([
'created_at',
'is_default',
'title',
'updated_at',
]);
export type FindAnonymizationFieldsSortFieldEnum = typeof FindAnonymizationFieldsSortField.enum;
export const FindAnonymizationFieldsSortFieldEnum = FindAnonymizationFieldsSortField.enum;
export type SortOrder = z.infer<typeof SortOrder>;
export const SortOrder = z.enum(['asc', 'desc']);
export type SortOrderEnum = typeof SortOrder.enum;
export const SortOrderEnum = SortOrder.enum;
export type FindAnonymizationFieldsRequestQuery = z.infer<
typeof FindAnonymizationFieldsRequestQuery
>;
export const FindAnonymizationFieldsRequestQuery = z.object({
fields: ArrayFromString(z.string()).optional(),
/**
* Search query
*/
filter: z.string().optional(),
/**
* Field to sort by
*/
sort_field: FindAnonymizationFieldsSortField.optional(),
/**
* Sort order
*/
sort_order: SortOrder.optional(),
/**
* Page number
*/
page: z.coerce.number().int().min(1).optional().default(1),
/**
* AnonymizationFields per page
*/
per_page: z.coerce.number().int().min(0).optional().default(20),
});
export type FindAnonymizationFieldsRequestQueryInput = z.input<
typeof FindAnonymizationFieldsRequestQuery
>;
export type FindAnonymizationFieldsResponse = z.infer<typeof FindAnonymizationFieldsResponse>;
export const FindAnonymizationFieldsResponse = z.object({
page: z.number().int(),
perPage: z.number().int(),
total: z.number().int(),
data: z.array(AnonymizationFieldResponse),
});

View file

@ -0,0 +1,108 @@
openapi: 3.0.0
info:
title: Find AnonymizationFields API endpoint
version: '2023-10-31'
paths:
/api/elastic_assistant/anonymization_fields/_find:
get:
operationId: FindAnonymizationFields
x-codegen-enabled: true
description: Finds anonymization fields that match the given query.
summary: Finds anonymization fields that match the given query.
tags:
- AnonymizationFields API
parameters:
- name: 'fields'
in: query
required: false
schema:
type: array
items:
type: string
- name: 'filter'
in: query
description: Search query
required: false
schema:
type: string
- name: 'sort_field'
in: query
description: Field to sort by
required: false
schema:
$ref: '#/components/schemas/FindAnonymizationFieldsSortField'
- name: 'sort_order'
in: query
description: Sort order
required: false
schema:
$ref: '#/components/schemas/SortOrder'
- name: 'page'
in: query
description: Page number
required: false
schema:
type: integer
minimum: 1
default: 1
- name: 'per_page'
in: query
description: AnonymizationFields per page
required: false
schema:
type: integer
minimum: 0
default: 20
responses:
200:
description: Successful response
content:
application/json:
schema:
type: object
properties:
page:
type: integer
perPage:
type: integer
total:
type: integer
data:
type: array
items:
$ref: './bulk_crud_anonymization_fields_route.schema.yaml#/components/schemas/AnonymizationFieldResponse'
required:
- page
- perPage
- total
- data
400:
description: Generic Error
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
error:
type: string
message:
type: string
components:
schemas:
FindAnonymizationFieldsSortField:
type: string
enum:
- 'created_at'
- 'is_default'
- 'title'
- 'updated_at'
SortOrder:
type: string
enum:
- 'asc'
- 'desc'

View file

@ -0,0 +1,99 @@
/*
* 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 { z } from 'zod';
/*
* NOTICE: Do not edit this file manually.
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
*
* info:
* title: Bulk Actions API endpoint
* version: 2023-10-31
*/
import {
ConversationCreateProps,
ConversationUpdateProps,
ConversationResponse,
} from './common_attributes.gen';
export type BulkActionSkipReason = z.infer<typeof BulkActionSkipReason>;
export const BulkActionSkipReason = z.literal('CONVERSATION_NOT_MODIFIED');
export type BulkActionSkipResult = z.infer<typeof BulkActionSkipResult>;
export const BulkActionSkipResult = z.object({
id: z.string(),
name: z.string().optional(),
skip_reason: BulkActionSkipReason,
});
export type ConversationDetailsInError = z.infer<typeof ConversationDetailsInError>;
export const ConversationDetailsInError = z.object({
id: z.string(),
name: z.string().optional(),
});
export type NormalizedConversationError = z.infer<typeof NormalizedConversationError>;
export const NormalizedConversationError = z.object({
message: z.string(),
status_code: z.number().int(),
err_code: z.string().optional(),
conversations: z.array(ConversationDetailsInError),
});
export type BulkCrudActionResults = z.infer<typeof BulkCrudActionResults>;
export const BulkCrudActionResults = z.object({
updated: z.array(ConversationResponse),
created: z.array(ConversationResponse),
deleted: z.array(z.string()),
skipped: z.array(BulkActionSkipResult),
});
export type BulkCrudActionSummary = z.infer<typeof BulkCrudActionSummary>;
export const BulkCrudActionSummary = z.object({
failed: z.number().int(),
skipped: z.number().int(),
succeeded: z.number().int(),
total: z.number().int(),
});
export type BulkCrudActionResponse = z.infer<typeof BulkCrudActionResponse>;
export const BulkCrudActionResponse = z.object({
success: z.boolean().optional(),
status_code: z.number().int().optional(),
message: z.string().optional(),
conversations_count: z.number().int().optional(),
attributes: z.object({
results: BulkCrudActionResults,
summary: BulkCrudActionSummary,
errors: z.array(NormalizedConversationError).optional(),
}),
});
export type BulkActionBase = z.infer<typeof BulkActionBase>;
export const BulkActionBase = z.object({
/**
* Query to filter conversations
*/
query: z.string().optional(),
/**
* Array of conversation IDs
*/
ids: z.array(z.string()).min(1).optional(),
});
export type PerformBulkActionRequestBody = z.infer<typeof PerformBulkActionRequestBody>;
export const PerformBulkActionRequestBody = z.object({
delete: BulkActionBase.optional(),
create: z.array(ConversationCreateProps).optional(),
update: z.array(ConversationUpdateProps).optional(),
});
export type PerformBulkActionRequestBodyInput = z.input<typeof PerformBulkActionRequestBody>;
export type PerformBulkActionResponse = z.infer<typeof PerformBulkActionResponse>;
export const PerformBulkActionResponse = BulkCrudActionResponse;

View file

@ -0,0 +1,183 @@
openapi: 3.0.0
info:
title: Bulk Actions API endpoint
version: '2023-10-31'
paths:
/api/elastic_assistant/conversations/_bulk_action:
post:
operationId: PerformBulkAction
x-codegen-enabled: true
summary: Applies a bulk action to multiple conversations
description: The bulk action is applied to all conversations that match the filter or to the list of conversations by their IDs.
tags:
- Bulk API
requestBody:
content:
application/json:
schema:
type: object
properties:
delete:
$ref: '#/components/schemas/BulkActionBase'
create:
type: array
items:
$ref: './common_attributes.schema.yaml#/components/schemas/ConversationCreateProps'
update:
type: array
items:
$ref: './common_attributes.schema.yaml#/components/schemas/ConversationUpdateProps'
responses:
200:
description: Indicates a successful call.
content:
application/json:
schema:
$ref: '#/components/schemas/BulkCrudActionResponse'
400:
description: Generic Error
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
error:
type: string
message:
type: string
components:
schemas:
BulkActionSkipReason:
type: string
enum:
- CONVERSATION_NOT_MODIFIED
BulkActionSkipResult:
type: object
properties:
id:
type: string
name:
type: string
skip_reason:
$ref: '#/components/schemas/BulkActionSkipReason'
required:
- id
- skip_reason
ConversationDetailsInError:
type: object
properties:
id:
type: string
name:
type: string
required:
- id
NormalizedConversationError:
type: object
properties:
message:
type: string
status_code:
type: integer
err_code:
type: string
conversations:
type: array
items:
$ref: '#/components/schemas/ConversationDetailsInError'
required:
- message
- status_code
- conversations
BulkCrudActionResults:
type: object
properties:
updated:
type: array
items:
$ref: './common_attributes.schema.yaml#/components/schemas/ConversationResponse'
created:
type: array
items:
$ref: './common_attributes.schema.yaml#/components/schemas/ConversationResponse'
deleted:
type: array
items:
type: string
skipped:
type: array
items:
$ref: '#/components/schemas/BulkActionSkipResult'
required:
- updated
- created
- deleted
- skipped
BulkCrudActionSummary:
type: object
properties:
failed:
type: integer
skipped:
type: integer
succeeded:
type: integer
total:
type: integer
required:
- failed
- skipped
- succeeded
- total
BulkCrudActionResponse:
type: object
properties:
success:
type: boolean
status_code:
type: integer
message:
type: string
conversations_count:
type: integer
attributes:
type: object
properties:
results:
$ref: '#/components/schemas/BulkCrudActionResults'
summary:
$ref: '#/components/schemas/BulkCrudActionSummary'
errors:
type: array
items:
$ref: '#/components/schemas/NormalizedConversationError'
required:
- results
- summary
required:
- attributes
BulkActionBase:
x-inline: true
type: object
properties:
query:
type: string
description: Query to filter conversations
ids:
type: array
description: Array of conversation IDs
minItems: 1
items:
type: string

View file

@ -0,0 +1,304 @@
/*
* 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 { z } from 'zod';
/*
* NOTICE: Do not edit this file manually.
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
*
* info:
* title: Common Conversation Attributes
* version: not applicable
*/
/**
* A string that is not empty and does not contain only whitespace
*/
export type NonEmptyString = z.infer<typeof NonEmptyString>;
export const NonEmptyString = z
.string()
.min(1)
.regex(/^(?! *$).+$/);
/**
* A universally unique identifier
*/
export type UUID = z.infer<typeof UUID>;
export const UUID = z.string().uuid();
/**
* Could be any string, not necessarily a UUID
*/
export type User = z.infer<typeof User>;
export const User = z.object({
/**
* User id.
*/
id: z.string().optional(),
/**
* User name.
*/
name: z.string().optional(),
});
/**
* trace Data
*/
export type TraceData = z.infer<typeof TraceData>;
export const TraceData = z.object({
/**
* Could be any string, not necessarily a UUID
*/
transactionId: z.string().optional(),
/**
* Could be any string, not necessarily a UUID
*/
traceId: z.string().optional(),
});
/**
* Replacements object used to anonymize/deanomymize messsages
*/
export type Replacement = z.infer<typeof Replacement>;
export const Replacement = z.object({
/**
* Actual value was anonymized.
*/
value: z.string(),
uuid: UUID,
});
export type Reader = z.infer<typeof Reader>;
export const Reader = z.object({}).catchall(z.unknown());
/**
* Provider
*/
export type Provider = z.infer<typeof Provider>;
export const Provider = z.enum(['OpenAI', 'Azure OpenAI']);
export type ProviderEnum = typeof Provider.enum;
export const ProviderEnum = Provider.enum;
/**
* Message role.
*/
export type MessageRole = z.infer<typeof MessageRole>;
export const MessageRole = z.enum(['system', 'user', 'assistant']);
export type MessageRoleEnum = typeof MessageRole.enum;
export const MessageRoleEnum = MessageRole.enum;
/**
* The conversation category.
*/
export type ConversationCategory = z.infer<typeof ConversationCategory>;
export const ConversationCategory = z.enum(['assistant', 'insights']);
export type ConversationCategoryEnum = typeof ConversationCategory.enum;
export const ConversationCategoryEnum = ConversationCategory.enum;
/**
* The conversation confidence.
*/
export type ConversationConfidence = z.infer<typeof ConversationConfidence>;
export const ConversationConfidence = z.enum(['low', 'medium', 'high']);
export type ConversationConfidenceEnum = typeof ConversationConfidence.enum;
export const ConversationConfidenceEnum = ConversationConfidence.enum;
/**
* AI assistant conversation message.
*/
export type Message = z.infer<typeof Message>;
export const Message = z.object({
/**
* Message content.
*/
content: z.string(),
/**
* Message content.
*/
reader: Reader.optional(),
/**
* Message role.
*/
role: MessageRole,
/**
* The timestamp message was sent or received.
*/
timestamp: NonEmptyString,
/**
* Is error message.
*/
isError: z.boolean().optional(),
/**
* trace Data
*/
traceData: TraceData.optional(),
});
export type ApiConfig = z.infer<typeof ApiConfig>;
export const ApiConfig = z.object({
/**
* connector Id
*/
connectorId: z.string(),
/**
* connector Type Title
*/
connectorTypeTitle: z.string(),
/**
* defaultSystemPromptId
*/
defaultSystemPromptId: z.string().optional(),
/**
* Provider
*/
provider: Provider.optional(),
/**
* model
*/
model: z.string().optional(),
});
export type ConversationSummary = z.infer<typeof ConversationSummary>;
export const ConversationSummary = z.object({
/**
* Summary text of the conversation over time.
*/
content: z.string().optional(),
/**
* The timestamp summary was updated.
*/
timestamp: NonEmptyString.optional(),
/**
* Define if summary is marked as publicly available.
*/
public: z.boolean().optional(),
/**
* How confident you are about this being a correct and useful learning.
*/
confidence: ConversationConfidence.optional(),
});
export type ErrorSchema = z.infer<typeof ErrorSchema>;
export const ErrorSchema = z
.object({
id: UUID.optional(),
error: z.object({
status_code: z.number().int().min(400),
message: z.string(),
}),
})
.strict();
export type ConversationResponse = z.infer<typeof ConversationResponse>;
export const ConversationResponse = z.object({
id: z.union([UUID, NonEmptyString]),
/**
* The conversation title.
*/
title: z.string(),
/**
* The conversation category.
*/
category: ConversationCategory,
summary: ConversationSummary.optional(),
timestamp: NonEmptyString.optional(),
/**
* The last time conversation was updated.
*/
updatedAt: z.string().optional(),
/**
* The last time conversation was updated.
*/
createdAt: z.string(),
replacements: z.array(Replacement).optional(),
users: z.array(User),
/**
* The conversation messages.
*/
messages: z.array(Message).optional(),
/**
* LLM API configuration.
*/
apiConfig: ApiConfig.optional(),
/**
* Is default conversation.
*/
isDefault: z.boolean().optional(),
/**
* excludeFromLastConversationStorage.
*/
excludeFromLastConversationStorage: z.boolean().optional(),
/**
* Kibana space
*/
namespace: z.string(),
});
export type ConversationUpdateProps = z.infer<typeof ConversationUpdateProps>;
export const ConversationUpdateProps = z.object({
id: z.union([UUID, NonEmptyString]),
/**
* The conversation title.
*/
title: z.string().optional(),
/**
* The conversation category.
*/
category: ConversationCategory.optional(),
/**
* The conversation messages.
*/
messages: z.array(Message).optional(),
/**
* LLM API configuration.
*/
apiConfig: ApiConfig.optional(),
summary: ConversationSummary.optional(),
/**
* excludeFromLastConversationStorage.
*/
excludeFromLastConversationStorage: z.boolean().optional(),
replacements: z.array(Replacement).optional(),
});
export type ConversationCreateProps = z.infer<typeof ConversationCreateProps>;
export const ConversationCreateProps = z.object({
/**
* The conversation title.
*/
title: z.string(),
/**
* The conversation category.
*/
category: ConversationCategory.optional(),
/**
* The conversation messages.
*/
messages: z.array(Message).optional(),
/**
* LLM API configuration.
*/
apiConfig: ApiConfig.optional(),
/**
* Is default conversation.
*/
isDefault: z.boolean().optional(),
/**
* excludeFromLastConversationStorage.
*/
excludeFromLastConversationStorage: z.boolean().optional(),
replacements: z.array(Replacement).optional(),
});
export type ConversationMessageCreateProps = z.infer<typeof ConversationMessageCreateProps>;
export const ConversationMessageCreateProps = z.object({
/**
* The conversation messages.
*/
messages: z.array(Message),
});

View file

@ -0,0 +1,303 @@
openapi: 3.0.0
info:
title: Common Conversation Attributes
version: 'not applicable'
paths: {}
components:
x-codegen-enabled: true
schemas:
NonEmptyString:
type: string
pattern: ^(?! *$).+$
minLength: 1
description: A string that is not empty and does not contain only whitespace
UUID:
type: string
format: uuid
description: A universally unique identifier
User:
type: object
description: Could be any string, not necessarily a UUID
properties:
id:
type: string
description: User id.
name:
type: string
description: User name.
TraceData:
type: object
description: trace Data
properties:
transactionId:
type: string
description: Could be any string, not necessarily a UUID
traceId:
type: string
description: Could be any string, not necessarily a UUID
Replacement:
type: object
required:
- 'value'
- 'uuid'
description: Replacements object used to anonymize/deanomymize messsages
properties:
value:
type: string
description: Actual value was anonymized.
uuid:
$ref: '#/components/schemas/UUID'
Reader:
type: object
additionalProperties: true
Provider:
type: string
description: Provider
enum:
- OpenAI
- Azure OpenAI
MessageRole:
type: string
description: Message role.
enum:
- system
- user
- assistant
ConversationCategory:
type: string
description: The conversation category.
enum:
- assistant
- insights
ConversationConfidence:
type: string
description: The conversation confidence.
enum:
- low
- medium
- high
Message:
type: object
description: AI assistant conversation message.
required:
- 'timestamp'
- 'content'
- 'role'
properties:
content:
type: string
description: Message content.
reader:
$ref: '#/components/schemas/Reader'
description: Message content.
role:
$ref: '#/components/schemas/MessageRole'
description: Message role.
timestamp:
$ref: '#/components/schemas/NonEmptyString'
description: The timestamp message was sent or received.
isError:
type: boolean
description: Is error message.
traceData:
$ref: '#/components/schemas/TraceData'
description: trace Data
ApiConfig:
type: object
required:
- connectorId
- connectorTypeTitle
properties:
connectorId:
type: string
description: connector Id
connectorTypeTitle:
type: string
description: connector Type Title
defaultSystemPromptId:
type: string
description: defaultSystemPromptId
provider:
$ref: '#/components/schemas/Provider'
description: Provider
model:
type: string
description: model
ConversationSummary:
type: object
properties:
content:
type: string
description: Summary text of the conversation over time.
timestamp:
$ref: '#/components/schemas/NonEmptyString'
description: The timestamp summary was updated.
public:
type: boolean
description: Define if summary is marked as publicly available.
confidence:
$ref: '#/components/schemas/ConversationConfidence'
description: How confident you are about this being a correct and useful learning.
ErrorSchema:
type: object
required:
- error
additionalProperties: false
properties:
id:
$ref: '#/components/schemas/UUID'
error:
type: object
required:
- status_code
- message
properties:
status_code:
type: integer
minimum: 400
message:
type: string
ConversationResponse:
type: object
required:
- id
- title
- createdAt
- users
- namespace
- category
properties:
id:
oneOf:
- $ref: '#/components/schemas/UUID'
- $ref: '#/components/schemas/NonEmptyString'
title:
type: string
description: The conversation title.
category:
$ref: '#/components/schemas/ConversationCategory'
description: The conversation category.
summary:
$ref: '#/components/schemas/ConversationSummary'
'timestamp':
$ref: '#/components/schemas/NonEmptyString'
updatedAt:
description: The last time conversation was updated.
type: string
createdAt:
description: The last time conversation was updated.
type: string
replacements:
type: array
items:
$ref: '#/components/schemas/Replacement'
users:
type: array
items:
$ref: '#/components/schemas/User'
messages:
type: array
items:
$ref: '#/components/schemas/Message'
description: The conversation messages.
apiConfig:
$ref: '#/components/schemas/ApiConfig'
description: LLM API configuration.
isDefault:
description: Is default conversation.
type: boolean
excludeFromLastConversationStorage:
description: excludeFromLastConversationStorage.
type: boolean
namespace:
type: string
description: Kibana space
ConversationUpdateProps:
type: object
required:
- id
properties:
id:
oneOf:
- $ref: '#/components/schemas/UUID'
- $ref: '#/components/schemas/NonEmptyString'
title:
type: string
description: The conversation title.
category:
$ref: '#/components/schemas/ConversationCategory'
description: The conversation category.
messages:
type: array
items:
$ref: '#/components/schemas/Message'
description: The conversation messages.
apiConfig:
$ref: '#/components/schemas/ApiConfig'
description: LLM API configuration.
summary:
$ref: '#/components/schemas/ConversationSummary'
excludeFromLastConversationStorage:
description: excludeFromLastConversationStorage.
type: boolean
replacements:
type: array
items:
$ref: '#/components/schemas/Replacement'
ConversationCreateProps:
type: object
required:
- title
properties:
title:
type: string
description: The conversation title.
category:
$ref: '#/components/schemas/ConversationCategory'
description: The conversation category.
messages:
type: array
items:
$ref: '#/components/schemas/Message'
description: The conversation messages.
apiConfig:
$ref: '#/components/schemas/ApiConfig'
description: LLM API configuration.
isDefault:
description: Is default conversation.
type: boolean
excludeFromLastConversationStorage:
description: excludeFromLastConversationStorage.
type: boolean
replacements:
type: array
items:
$ref: '#/components/schemas/Replacement'
ConversationMessageCreateProps:
type: object
required:
- messages
properties:
messages:
type: array
items:
$ref: '#/components/schemas/Message'
description: The conversation messages.

View file

@ -0,0 +1,96 @@
/*
* 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 { z } from 'zod';
/*
* NOTICE: Do not edit this file manually.
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
*
* info:
* title: Create Conversation API endpoint
* version: 2023-10-31
*/
import {
ConversationCreateProps,
ConversationResponse,
UUID,
ConversationUpdateProps,
ConversationMessageCreateProps,
} from './common_attributes.gen';
export type AppendConversationMessageRequestParams = z.infer<
typeof AppendConversationMessageRequestParams
>;
export const AppendConversationMessageRequestParams = z.object({
/**
* The conversation's `id` value.
*/
id: UUID,
});
export type AppendConversationMessageRequestParamsInput = z.input<
typeof AppendConversationMessageRequestParams
>;
export type AppendConversationMessageRequestBody = z.infer<
typeof AppendConversationMessageRequestBody
>;
export const AppendConversationMessageRequestBody = ConversationMessageCreateProps;
export type AppendConversationMessageRequestBodyInput = z.input<
typeof AppendConversationMessageRequestBody
>;
export type AppendConversationMessageResponse = z.infer<typeof AppendConversationMessageResponse>;
export const AppendConversationMessageResponse = ConversationResponse;
export type CreateConversationRequestBody = z.infer<typeof CreateConversationRequestBody>;
export const CreateConversationRequestBody = ConversationCreateProps;
export type CreateConversationRequestBodyInput = z.input<typeof CreateConversationRequestBody>;
export type CreateConversationResponse = z.infer<typeof CreateConversationResponse>;
export const CreateConversationResponse = ConversationResponse;
export type DeleteConversationRequestParams = z.infer<typeof DeleteConversationRequestParams>;
export const DeleteConversationRequestParams = z.object({
/**
* The conversation's `id` value.
*/
id: UUID,
});
export type DeleteConversationRequestParamsInput = z.input<typeof DeleteConversationRequestParams>;
export type DeleteConversationResponse = z.infer<typeof DeleteConversationResponse>;
export const DeleteConversationResponse = ConversationResponse;
export type ReadConversationRequestParams = z.infer<typeof ReadConversationRequestParams>;
export const ReadConversationRequestParams = z.object({
/**
* The conversation's `id` value.
*/
id: UUID,
});
export type ReadConversationRequestParamsInput = z.input<typeof ReadConversationRequestParams>;
export type ReadConversationResponse = z.infer<typeof ReadConversationResponse>;
export const ReadConversationResponse = ConversationResponse;
export type UpdateConversationRequestParams = z.infer<typeof UpdateConversationRequestParams>;
export const UpdateConversationRequestParams = z.object({
/**
* The conversation's `id` value.
*/
id: UUID,
});
export type UpdateConversationRequestParamsInput = z.input<typeof UpdateConversationRequestParams>;
export type UpdateConversationRequestBody = z.infer<typeof UpdateConversationRequestBody>;
export const UpdateConversationRequestBody = ConversationUpdateProps;
export type UpdateConversationRequestBodyInput = z.input<typeof UpdateConversationRequestBody>;
export type UpdateConversationResponse = z.infer<typeof UpdateConversationResponse>;
export const UpdateConversationResponse = ConversationResponse;

View file

@ -0,0 +1,191 @@
openapi: 3.0.0
info:
title: Create Conversation API endpoint
version: '2023-10-31'
paths:
/api/elastic_assistant/conversations:
post:
operationId: CreateConversation
x-codegen-enabled: true
description: Create a conversation
summary: Create a conversation
tags:
- Conversation API
requestBody:
required: true
content:
application/json:
schema:
$ref: './common_attributes.schema.yaml#/components/schemas/ConversationCreateProps'
responses:
200:
description: Indicates a successful call.
content:
application/json:
schema:
$ref: './common_attributes.schema.yaml#/components/schemas/ConversationResponse'
400:
description: Generic Error
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
error:
type: string
message:
type: string
/api/elastic_assistant/conversations/{id}:
get:
operationId: ReadConversation
x-codegen-enabled: true
description: Read a single conversation
summary: Read a single conversation
tags:
- Conversations API
parameters:
- name: id
in: path
required: true
description: The conversation's `id` value.
schema:
$ref: './common_attributes.schema.yaml#/components/schemas/UUID'
responses:
200:
description: Indicates a successful call.
content:
application/json:
schema:
$ref: './common_attributes.schema.yaml#/components/schemas/ConversationResponse'
400:
description: Generic Error
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
error:
type: string
message:
type: string
put:
operationId: UpdateConversation
x-codegen-enabled: true
description: Update a single conversation
summary: Update a conversation
tags:
- Conversation API
parameters:
- name: id
in: path
required: true
description: The conversation's `id` value.
schema:
$ref: './common_attributes.schema.yaml#/components/schemas/UUID'
requestBody:
required: true
content:
application/json:
schema:
$ref: './common_attributes.schema.yaml#/components/schemas/ConversationUpdateProps'
responses:
200:
description: Indicates a successful call.
content:
application/json:
schema:
$ref: './common_attributes.schema.yaml#/components/schemas/ConversationResponse'
400:
description: Generic Error
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
error:
type: string
message:
type: string
delete:
operationId: DeleteConversation
x-codegen-enabled: true
description: Deletes a single conversation using the `id` field.
summary: Deletes a single conversation using the `id` field.
tags:
- Conversation API
parameters:
- name: id
in: path
required: true
description: The conversation's `id` value.
schema:
$ref: './common_attributes.schema.yaml#/components/schemas/UUID'
responses:
200:
description: Indicates a successful call.
content:
application/json:
schema:
$ref: './common_attributes.schema.yaml#/components/schemas/ConversationResponse'
400:
description: Generic Error
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
error:
type: string
message:
type: string
/api/elastic_assistant/conversations/{id}/messages:
post:
operationId: AppendConversationMessage
x-codegen-enabled: true
description: Append a message to the conversation
summary: Append a message to the conversation
tags:
- Conversation API
parameters:
- name: id
in: path
required: true
description: The conversation's `id` value.
schema:
$ref: './common_attributes.schema.yaml#/components/schemas/UUID'
requestBody:
required: true
content:
application/json:
schema:
$ref: './common_attributes.schema.yaml#/components/schemas/ConversationMessageCreateProps'
responses:
200:
description: Indicates a successful call.
content:
application/json:
schema:
$ref: './common_attributes.schema.yaml#/components/schemas/ConversationResponse'
400:
description: Generic Error
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
error:
type: string
message:
type: string

View file

@ -0,0 +1,108 @@
/*
* 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 { z } from 'zod';
import { ArrayFromString } from '@kbn/zod-helpers';
/*
* NOTICE: Do not edit this file manually.
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
*
* info:
* title: Find Conversations API endpoint
* version: 2023-10-31
*/
import { ConversationResponse } from './common_attributes.gen';
export type FindConversationsSortField = z.infer<typeof FindConversationsSortField>;
export const FindConversationsSortField = z.enum([
'created_at',
'is_default',
'title',
'updated_at',
]);
export type FindConversationsSortFieldEnum = typeof FindConversationsSortField.enum;
export const FindConversationsSortFieldEnum = FindConversationsSortField.enum;
export type SortOrder = z.infer<typeof SortOrder>;
export const SortOrder = z.enum(['asc', 'desc']);
export type SortOrderEnum = typeof SortOrder.enum;
export const SortOrderEnum = SortOrder.enum;
export type FindConversationsRequestQuery = z.infer<typeof FindConversationsRequestQuery>;
export const FindConversationsRequestQuery = z.object({
fields: ArrayFromString(z.string()).optional(),
/**
* Search query
*/
filter: z.string().optional(),
/**
* Field to sort by
*/
sort_field: FindConversationsSortField.optional(),
/**
* Sort order
*/
sort_order: SortOrder.optional(),
/**
* Page number
*/
page: z.coerce.number().int().min(1).optional().default(1),
/**
* Conversations per page
*/
per_page: z.coerce.number().int().min(0).optional().default(20),
});
export type FindConversationsRequestQueryInput = z.input<typeof FindConversationsRequestQuery>;
export type FindConversationsResponse = z.infer<typeof FindConversationsResponse>;
export const FindConversationsResponse = z.object({
page: z.number().int(),
perPage: z.number().int(),
total: z.number().int(),
data: z.array(ConversationResponse),
});
export type FindCurrentUserConversationsRequestQuery = z.infer<
typeof FindCurrentUserConversationsRequestQuery
>;
export const FindCurrentUserConversationsRequestQuery = z.object({
fields: ArrayFromString(z.string()).optional(),
/**
* Search query
*/
filter: z.string().optional(),
/**
* Field to sort by
*/
sort_field: FindConversationsSortField.optional(),
/**
* Sort order
*/
sort_order: SortOrder.optional(),
/**
* Page number
*/
page: z.coerce.number().int().min(1).optional().default(1),
/**
* Conversations per page
*/
per_page: z.coerce.number().int().min(0).optional().default(20),
});
export type FindCurrentUserConversationsRequestQueryInput = z.input<
typeof FindCurrentUserConversationsRequestQuery
>;
export type FindCurrentUserConversationsResponse = z.infer<
typeof FindCurrentUserConversationsResponse
>;
export const FindCurrentUserConversationsResponse = z.object({
page: z.number().int(),
perPage: z.number().int(),
total: z.number().int(),
data: z.array(ConversationResponse),
});

View file

@ -0,0 +1,196 @@
openapi: 3.0.0
info:
title: Find Conversations API endpoint
version: '2023-10-31'
paths:
/api/elastic_assistant/conversations/_find:
get:
operationId: FindConversations
x-codegen-enabled: true
description: Finds conversations that match the given query.
summary: Finds conversations that match the given query.
tags:
- Conversations API
parameters:
- name: 'fields'
in: query
required: false
schema:
type: array
items:
type: string
- name: 'filter'
in: query
description: Search query
required: false
schema:
type: string
- name: 'sort_field'
in: query
description: Field to sort by
required: false
schema:
$ref: '#/components/schemas/FindConversationsSortField'
- name: 'sort_order'
in: query
description: Sort order
required: false
schema:
$ref: '#/components/schemas/SortOrder'
- name: 'page'
in: query
description: Page number
required: false
schema:
type: integer
minimum: 1
default: 1
- name: 'per_page'
in: query
description: Conversations per page
required: false
schema:
type: integer
minimum: 0
default: 20
responses:
200:
description: Successful response
content:
application/json:
schema:
type: object
properties:
page:
type: integer
perPage:
type: integer
total:
type: integer
data:
type: array
items:
$ref: './common_attributes.schema.yaml#/components/schemas/ConversationResponse'
required:
- page
- perPage
- total
- data
400:
description: Generic Error
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
error:
type: string
message:
type: string
/api/elastic_assistant/conversations/current_user/_find:
get:
operationId: FindCurrentUserConversations
x-codegen-enabled: true
description: Finds current user conversations that match the given query.
summary: Finds current user conversations that match the given query.
tags:
- Conversations API
parameters:
- name: 'fields'
in: query
required: false
schema:
type: array
items:
type: string
- name: 'filter'
in: query
description: Search query
required: false
schema:
type: string
- name: 'sort_field'
in: query
description: Field to sort by
required: false
schema:
$ref: '#/components/schemas/FindConversationsSortField'
- name: 'sort_order'
in: query
description: Sort order
required: false
schema:
$ref: '#/components/schemas/SortOrder'
- name: 'page'
in: query
description: Page number
required: false
schema:
type: integer
minimum: 1
default: 1
- name: 'per_page'
in: query
description: Conversations per page
required: false
schema:
type: integer
minimum: 0
default: 20
responses:
200:
description: Successful response
content:
application/json:
schema:
type: object
properties:
page:
type: integer
perPage:
type: integer
total:
type: integer
data:
type: array
items:
$ref: './common_attributes.schema.yaml#/components/schemas/ConversationResponse'
required:
- page
- perPage
- total
- data
400:
description: Generic Error
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
error:
type: string
message:
type: string
components:
schemas:
FindConversationsSortField:
type: string
enum:
- 'created_at'
- 'is_default'
- 'title'
- 'updated_at'
SortOrder:
type: string
enum:
- 'asc'
- 'desc'

View file

@ -24,3 +24,15 @@ export * from './evaluation/get_evaluate_route.gen';
// Capabilities Schemas
export * from './capabilities/get_capabilities_route.gen';
// Conversations Schemas
export * from './conversations/bulk_crud_conversations_route.gen';
export * from './conversations/common_attributes.gen';
export * from './conversations/crud_conversation_route.gen';
export * from './conversations/find_conversations_route.gen';
// Actions Connector Schemas
export * from './actions_connector/post_actions_connector_execute_route.gen';
// KB Schemas
export * from './knowledge_base/crud_kb_route.gen';

View file

@ -0,0 +1,72 @@
/*
* 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 { z } from 'zod';
/*
* NOTICE: Do not edit this file manually.
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
*
* info:
* title: KnowledgeBase API endpoints
* version: 2023-10-31
*/
/**
* AI assistant KnowledgeBase.
*/
export type KnowledgeBaseResponse = z.infer<typeof KnowledgeBaseResponse>;
export const KnowledgeBaseResponse = z.object({
/**
* Identify the success of the method execution.
*/
success: z.boolean().optional(),
});
export type CreateKnowledgeBaseRequestParams = z.infer<typeof CreateKnowledgeBaseRequestParams>;
export const CreateKnowledgeBaseRequestParams = z.object({
/**
* The KnowledgeBase `resource` value.
*/
resource: z.string().optional(),
});
export type CreateKnowledgeBaseRequestParamsInput = z.input<
typeof CreateKnowledgeBaseRequestParams
>;
export type CreateKnowledgeBaseResponse = z.infer<typeof CreateKnowledgeBaseResponse>;
export const CreateKnowledgeBaseResponse = KnowledgeBaseResponse;
export type DeleteKnowledgeBaseRequestParams = z.infer<typeof DeleteKnowledgeBaseRequestParams>;
export const DeleteKnowledgeBaseRequestParams = z.object({
/**
* The KnowledgeBase `resource` value.
*/
resource: z.string().optional(),
});
export type DeleteKnowledgeBaseRequestParamsInput = z.input<
typeof DeleteKnowledgeBaseRequestParams
>;
export type DeleteKnowledgeBaseResponse = z.infer<typeof DeleteKnowledgeBaseResponse>;
export const DeleteKnowledgeBaseResponse = KnowledgeBaseResponse;
export type ReadKnowledgeBaseRequestParams = z.infer<typeof ReadKnowledgeBaseRequestParams>;
export const ReadKnowledgeBaseRequestParams = z.object({
/**
* The KnowledgeBase `resource` value.
*/
resource: z.string().optional(),
});
export type ReadKnowledgeBaseRequestParamsInput = z.input<typeof ReadKnowledgeBaseRequestParams>;
export type ReadKnowledgeBaseResponse = z.infer<typeof ReadKnowledgeBaseResponse>;
export const ReadKnowledgeBaseResponse = z.object({
elser_exists: z.boolean().optional(),
index_exists: z.boolean().optional(),
pipeline_exists: z.boolean().optional(),
});

View file

@ -0,0 +1,122 @@
openapi: 3.0.0
info:
title: KnowledgeBase API endpoints
version: '2023-10-31'
paths:
/internal/elastic_assistant/knowledge_base/{resource}:
post:
operationId: CreateKnowledgeBase
x-codegen-enabled: true
summary: Create a KnowledgeBase
description: Create a KnowledgeBase
tags:
- KnowledgeBase API
parameters:
- name: resource
in: path
description: The KnowledgeBase `resource` value.
schema:
type: string
responses:
200:
description: Indicates a successful call.
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgeBaseResponse'
400:
description: Generic Error
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
error:
type: string
message:
type: string
get:
operationId: ReadKnowledgeBase
x-codegen-enabled: true
description: Read a single KB
summary: Read a KnowledgeBase
tags:
- KnowledgeBase API
parameters:
- name: resource
in: path
description: The KnowledgeBase `resource` value.
schema:
type: string
responses:
200:
description: Indicates a successful call.
content:
application/json:
schema:
type: object
properties:
elser_exists:
type: boolean
index_exists:
type: boolean
pipeline_exists:
type: boolean
400:
description: Generic Error
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
error:
type: string
message:
type: string
delete:
operationId: DeleteKnowledgeBase
x-codegen-enabled: true
description: Deletes KnowledgeBase with the `resource` field.
summary: Deletes a KnowledgeBase
tags:
- KnowledgeBase API
parameters:
- name: resource
in: path
description: The KnowledgeBase `resource` value.
schema:
type: string
responses:
200:
description: Indicates a successful call.
content:
application/json:
schema:
$ref: '#/components/schemas/KnowledgeBaseResponse'
400:
description: Generic Error
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
error:
type: string
message:
type: string
components:
schemas:
KnowledgeBaseResponse:
type: object
description: AI assistant KnowledgeBase.
properties:
success:
type: boolean
description: Identify the success of the method execution.

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 { z } from 'zod';
/*
* NOTICE: Do not edit this file manually.
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
*
* info:
* title: Bulk Actions API endpoint
* version: 2023-10-31
*/
import { UUID, NonEmptyString, User } from '../conversations/common_attributes.gen';
export type BulkActionSkipReason = z.infer<typeof BulkActionSkipReason>;
export const BulkActionSkipReason = z.literal('PROMPT_FIELD_NOT_MODIFIED');
export type BulkActionSkipResult = z.infer<typeof BulkActionSkipResult>;
export const BulkActionSkipResult = z.object({
id: z.string(),
name: z.string().optional(),
skip_reason: BulkActionSkipReason,
});
export type PromptDetailsInError = z.infer<typeof PromptDetailsInError>;
export const PromptDetailsInError = z.object({
id: z.string(),
name: z.string().optional(),
});
export type NormalizedPromptError = z.infer<typeof NormalizedPromptError>;
export const NormalizedPromptError = z.object({
message: z.string(),
status_code: z.number().int(),
err_code: z.string().optional(),
prompts: z.array(PromptDetailsInError),
});
export type PromptResponse = z.infer<typeof PromptResponse>;
export const PromptResponse = z.object({
id: UUID,
timestamp: NonEmptyString.optional(),
name: z.string(),
promptType: z.string(),
content: z.string(),
isNewConversationDefault: z.boolean().optional(),
isDefault: z.boolean().optional(),
isShared: z.boolean().optional(),
updatedAt: z.string().optional(),
updatedBy: z.string().optional(),
createdAt: z.string().optional(),
createdBy: z.string().optional(),
users: z.array(User).optional(),
/**
* Kibana space
*/
namespace: z.string().optional(),
});
export type BulkCrudActionResults = z.infer<typeof BulkCrudActionResults>;
export const BulkCrudActionResults = z.object({
updated: z.array(PromptResponse),
created: z.array(PromptResponse),
deleted: z.array(z.string()),
skipped: z.array(BulkActionSkipResult),
});
export type BulkCrudActionSummary = z.infer<typeof BulkCrudActionSummary>;
export const BulkCrudActionSummary = z.object({
failed: z.number().int(),
skipped: z.number().int(),
succeeded: z.number().int(),
total: z.number().int(),
});
export type BulkCrudActionResponse = z.infer<typeof BulkCrudActionResponse>;
export const BulkCrudActionResponse = z.object({
success: z.boolean().optional(),
status_code: z.number().int().optional(),
message: z.string().optional(),
prompts_count: z.number().int().optional(),
attributes: z.object({
results: BulkCrudActionResults,
summary: BulkCrudActionSummary,
errors: z.array(NormalizedPromptError).optional(),
}),
});
export type BulkActionBase = z.infer<typeof BulkActionBase>;
export const BulkActionBase = z.object({
/**
* Query to filter promps
*/
query: z.string().optional(),
/**
* Array of prompts IDs
*/
ids: z.array(z.string()).min(1).optional(),
});
export type PromptCreateProps = z.infer<typeof PromptCreateProps>;
export const PromptCreateProps = z.object({
name: z.string(),
promptType: z.string(),
content: z.string(),
isNewConversationDefault: z.boolean().optional(),
isDefault: z.boolean().optional(),
isShared: z.boolean().optional(),
});
export type PromptUpdateProps = z.infer<typeof PromptUpdateProps>;
export const PromptUpdateProps = z.object({
id: z.string(),
content: z.string().optional(),
isNewConversationDefault: z.boolean().optional(),
isDefault: z.boolean().optional(),
isShared: z.boolean().optional(),
});
export type PerformBulkActionRequestBody = z.infer<typeof PerformBulkActionRequestBody>;
export const PerformBulkActionRequestBody = z.object({
delete: BulkActionBase.optional(),
create: z.array(PromptCreateProps).optional(),
update: z.array(PromptUpdateProps).optional(),
});
export type PerformBulkActionRequestBodyInput = z.input<typeof PerformBulkActionRequestBody>;
export type PerformBulkActionResponse = z.infer<typeof PerformBulkActionResponse>;
export const PerformBulkActionResponse = BulkCrudActionResponse;

View file

@ -0,0 +1,259 @@
openapi: 3.0.0
info:
title: Bulk Actions API endpoint
version: '2023-10-31'
paths:
/api/elastic_assistant/prompts/_bulk_action:
post:
operationId: PerformBulkAction
x-codegen-enabled: true
summary: Applies a bulk action to multiple prompts
description: The bulk action is applied to all prompts that match the filter or to the list of prompts by their IDs.
tags:
- Bulk API
requestBody:
content:
application/json:
schema:
type: object
properties:
delete:
$ref: '#/components/schemas/BulkActionBase'
create:
type: array
items:
$ref: '#/components/schemas/PromptCreateProps'
update:
type: array
items:
$ref: '#/components/schemas/PromptUpdateProps'
responses:
200:
description: Indicates a successful call.
content:
application/json:
schema:
$ref: '#/components/schemas/BulkCrudActionResponse'
400:
description: Generic Error
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
error:
type: string
message:
type: string
components:
schemas:
BulkActionSkipReason:
type: string
enum:
- PROMPT_FIELD_NOT_MODIFIED
BulkActionSkipResult:
type: object
properties:
id:
type: string
name:
type: string
skip_reason:
$ref: '#/components/schemas/BulkActionSkipReason'
required:
- id
- skip_reason
PromptDetailsInError:
type: object
properties:
id:
type: string
name:
type: string
required:
- id
NormalizedPromptError:
type: object
properties:
message:
type: string
status_code:
type: integer
err_code:
type: string
prompts:
type: array
items:
$ref: '#/components/schemas/PromptDetailsInError'
required:
- message
- status_code
- prompts
PromptResponse:
type: object
required:
- id
- name
- promptType
- content
properties:
id:
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/UUID'
'timestamp':
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/NonEmptyString'
name:
type: string
promptType:
type: string
content:
type: string
isNewConversationDefault:
type: boolean
isDefault:
type: boolean
isShared:
type: boolean
updatedAt:
type: string
updatedBy:
type: string
createdAt:
type: string
createdBy:
type: string
users:
type: array
items:
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/User'
namespace:
type: string
description: Kibana space
BulkCrudActionResults:
type: object
properties:
updated:
type: array
items:
$ref: '#/components/schemas/PromptResponse'
created:
type: array
items:
$ref: '#/components/schemas/PromptResponse'
deleted:
type: array
items:
type: string
skipped:
type: array
items:
$ref: '#/components/schemas/BulkActionSkipResult'
required:
- updated
- created
- deleted
- skipped
BulkCrudActionSummary:
type: object
properties:
failed:
type: integer
skipped:
type: integer
succeeded:
type: integer
total:
type: integer
required:
- failed
- skipped
- succeeded
- total
BulkCrudActionResponse:
type: object
properties:
success:
type: boolean
status_code:
type: integer
message:
type: string
prompts_count:
type: integer
attributes:
type: object
properties:
results:
$ref: '#/components/schemas/BulkCrudActionResults'
summary:
$ref: '#/components/schemas/BulkCrudActionSummary'
errors:
type: array
items:
$ref: '#/components/schemas/NormalizedPromptError'
required:
- results
- summary
required:
- attributes
BulkActionBase:
x-inline: true
type: object
properties:
query:
type: string
description: Query to filter promps
ids:
type: array
description: Array of prompts IDs
minItems: 1
items:
type: string
PromptCreateProps:
type: object
required:
- name
- content
- promptType
properties:
name:
type: string
promptType:
type: string
content:
type: string
isNewConversationDefault:
type: boolean
isDefault:
type: boolean
isShared:
type: boolean
PromptUpdateProps:
type: object
required:
- id
properties:
id:
type: string
content:
type: string
isNewConversationDefault:
type: boolean
isDefault:
type: boolean
isShared:
type: boolean

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 { z } from 'zod';
import { ArrayFromString } from '@kbn/zod-helpers';
/*
* NOTICE: Do not edit this file manually.
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
*
* info:
* title: Find Prompts API endpoint
* version: 2023-10-31
*/
import { PromptResponse } from './bulk_crud_prompts_route.gen';
export type FindPromptsSortField = z.infer<typeof FindPromptsSortField>;
export const FindPromptsSortField = z.enum(['created_at', 'is_default', 'name', 'updated_at']);
export type FindPromptsSortFieldEnum = typeof FindPromptsSortField.enum;
export const FindPromptsSortFieldEnum = FindPromptsSortField.enum;
export type SortOrder = z.infer<typeof SortOrder>;
export const SortOrder = z.enum(['asc', 'desc']);
export type SortOrderEnum = typeof SortOrder.enum;
export const SortOrderEnum = SortOrder.enum;
export type FindPromptsRequestQuery = z.infer<typeof FindPromptsRequestQuery>;
export const FindPromptsRequestQuery = z.object({
fields: ArrayFromString(z.string()).optional(),
/**
* Search query
*/
filter: z.string().optional(),
/**
* Field to sort by
*/
sort_field: FindPromptsSortField.optional(),
/**
* Sort order
*/
sort_order: SortOrder.optional(),
/**
* Page number
*/
page: z.coerce.number().int().min(1).optional().default(1),
/**
* Prompts per page
*/
per_page: z.coerce.number().int().min(0).optional().default(20),
});
export type FindPromptsRequestQueryInput = z.input<typeof FindPromptsRequestQuery>;
export type FindPromptsResponse = z.infer<typeof FindPromptsResponse>;
export const FindPromptsResponse = z.object({
page: z.number().int(),
perPage: z.number().int(),
total: z.number().int(),
data: z.array(PromptResponse),
});

View file

@ -0,0 +1,108 @@
openapi: 3.0.0
info:
title: Find Prompts API endpoint
version: '2023-10-31'
paths:
/api/elastic_assistant/prompts/_find:
get:
operationId: FindPrompts
x-codegen-enabled: true
description: Finds prompts that match the given query.
summary: Finds prompts that match the given query.
tags:
- Prompts API
parameters:
- name: 'fields'
in: query
required: false
schema:
type: array
items:
type: string
- name: 'filter'
in: query
description: Search query
required: false
schema:
type: string
- name: 'sort_field'
in: query
description: Field to sort by
required: false
schema:
$ref: '#/components/schemas/FindPromptsSortField'
- name: 'sort_order'
in: query
description: Sort order
required: false
schema:
$ref: '#/components/schemas/SortOrder'
- name: 'page'
in: query
description: Page number
required: false
schema:
type: integer
minimum: 1
default: 1
- name: 'per_page'
in: query
description: Prompts per page
required: false
schema:
type: integer
minimum: 0
default: 20
responses:
200:
description: Successful response
content:
application/json:
schema:
type: object
properties:
page:
type: integer
perPage:
type: integer
total:
type: integer
data:
type: array
items:
$ref: './bulk_crud_prompts_route.schema.yaml#/components/schemas/PromptResponse'
required:
- page
- perPage
- total
- data
400:
description: Generic Error
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
error:
type: string
message:
type: string
components:
schemas:
FindPromptsSortField:
type: string
enum:
- 'created_at'
- 'is_default'
- 'name'
- 'updated_at'
SortOrder:
type: string
enum:
- 'asc'
- 'desc'

View file

@ -21,3 +21,9 @@ export {
} from './impl/data_anonymization/helpers';
export { transformRawData } from './impl/data_anonymization/transform_raw_data';
export {
replaceAnonymizedValuesWithOriginalValues,
replaceOriginalValuesWithUuidValues,
} from './impl/data_anonymization/helpers';
export * from './constants';

View file

@ -16,5 +16,8 @@
"target/**/*"
],
"kbn_references": [
"@kbn/zod-helpers",
"@kbn/securitysolution-io-ts-utils",
"@kbn/core",
]
}

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { act, renderHook } from '@testing-library/react-hooks';
import {
DeleteConversationParams,
GetConversationByIdParams,
deleteConversation,
getConversationById,
} from './conversations';
import { HttpSetupMock } from '@kbn/core-http-browser-mocks';
import { coreMock } from '@kbn/core/public/mocks';
let http: HttpSetupMock = coreMock.createSetup().http;
const toasts = {
addError: jest.fn(),
};
describe('conversations api', () => {
beforeEach(() => {
jest.clearAllMocks();
http = coreMock.createSetup().http;
});
it('should call api to delete conversation', async () => {
await act(async () => {
const deleteProps = { http, toasts, id: 'test' } as unknown as DeleteConversationParams;
const { waitForNextUpdate } = renderHook(() => deleteConversation(deleteProps));
await waitForNextUpdate();
expect(deleteProps.http.fetch).toHaveBeenCalledWith(
'/api/elastic_assistant/current_user/conversations/test',
{
method: 'DELETE',
signal: undefined,
version: '2023-10-31',
}
);
expect(toasts.addError).not.toHaveBeenCalled();
});
});
it('should display error toast when delete api throws error', async () => {
http.fetch.mockRejectedValue(new Error('this is an error'));
const deleteProps = { http, toasts, id: 'test' } as unknown as DeleteConversationParams;
await expect(deleteConversation(deleteProps)).rejects.toThrowError('this is an error');
expect(toasts.addError).toHaveBeenCalled();
});
it('should call api to get conversation', async () => {
await act(async () => {
const getProps = { http, toasts, id: 'test' } as unknown as GetConversationByIdParams;
const { waitForNextUpdate } = renderHook(() => getConversationById(getProps));
await waitForNextUpdate();
expect(getProps.http.fetch).toHaveBeenCalledWith(
'/api/elastic_assistant/current_user/conversations/test',
{
method: 'GET',
signal: undefined,
version: '2023-10-31',
}
);
expect(toasts.addError).not.toHaveBeenCalled();
});
});
it('should display error toast when get api throws error', async () => {
http.fetch.mockRejectedValue(new Error('this is an error'));
const getProps = { http, toasts, id: 'test' } as unknown as GetConversationByIdParams;
await expect(getConversationById(getProps)).rejects.toThrowError('this is an error');
expect(toasts.addError).toHaveBeenCalled();
});
});

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 { HttpSetup, IToasts } from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
import {
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL,
ELASTIC_AI_ASSISTANT_API_CURRENT_VERSION,
ApiConfig,
Replacement,
} from '@kbn/elastic-assistant-common';
import { Conversation, Message } from '../../../assistant_context/types';
export interface GetConversationByIdParams {
http: HttpSetup;
id: string;
toasts?: IToasts;
signal?: AbortSignal | undefined;
}
/**
* API call for getting conversation by id.
*
* @param {Object} options - The options object.
* @param {HttpSetup} options.http - HttpSetup
* @param {string} options.id - Conversation id.
* @param {IToasts} [options.toasts] - IToasts
* @param {AbortSignal} [options.signal] - AbortSignal
*
* @returns {Promise<Conversation>}
*/
export const getConversationById = async ({
http,
id,
signal,
toasts,
}: GetConversationByIdParams): Promise<Conversation | undefined> => {
try {
const response = await http.fetch(`${ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL}/${id}`, {
method: 'GET',
version: ELASTIC_AI_ASSISTANT_API_CURRENT_VERSION,
signal,
});
return response as Conversation;
} catch (error) {
toasts?.addError(error.body && error.body.message ? new Error(error.body.message) : error, {
title: i18n.translate('xpack.elasticAssistant.conversations.getConversationError', {
defaultMessage: 'Error fetching conversation by id {id}',
values: { id },
}),
});
throw error;
}
};
export interface PostConversationParams {
http: HttpSetup;
conversation: Conversation;
toasts?: IToasts;
signal?: AbortSignal | undefined;
}
/**
* API call for setting up the Conversation.
*
* @param {Object} options - The options object.
* @param {HttpSetup} options.http - HttpSetup
* @param {Conversation} [options.conversation] - Conversation to be added
* @param {AbortSignal} [options.signal] - AbortSignal
* @param {IToasts} [options.toasts] - IToasts
*
* @returns {Promise<PostConversationResponse>}
*/
export const createConversation = async ({
http,
conversation,
signal,
toasts,
}: PostConversationParams): Promise<Conversation> => {
try {
const response = await http.post(ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL, {
body: JSON.stringify(conversation),
version: ELASTIC_AI_ASSISTANT_API_CURRENT_VERSION,
signal,
});
return response as Conversation;
} catch (error) {
toasts?.addError(error.body && error.body.message ? new Error(error.body.message) : error, {
title: i18n.translate('xpack.elasticAssistant.conversations.createConversationError', {
defaultMessage: 'Error creating conversation with title {title}',
values: { title: conversation.title },
}),
});
throw error;
}
};
export interface DeleteConversationParams {
http: HttpSetup;
id: string;
toasts?: IToasts;
signal?: AbortSignal | undefined;
}
/**
* API call for deleting the Conversation. Provide a id to delete that specific resource.
*
* @param {Object} options - The options object.
* @param {HttpSetup} options.http - HttpSetup
* @param {string} [options.title] - Conversation title to be deleted
* @param {AbortSignal} [options.signal] - AbortSignal
* @param {IToasts} [options.toasts] - IToasts
*
* @returns {Promise<boolean>}
*/
export const deleteConversation = async ({
http,
id,
signal,
toasts,
}: DeleteConversationParams): Promise<boolean> => {
try {
const response = await http.fetch(`${ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL}/${id}`, {
method: 'DELETE',
version: ELASTIC_AI_ASSISTANT_API_CURRENT_VERSION,
signal,
});
return response as boolean;
} catch (error) {
toasts?.addError(error.body && error.body.message ? new Error(error.body.message) : error, {
title: i18n.translate('xpack.elasticAssistant.conversations.deleteConversationError', {
defaultMessage: 'Error deleting conversation by id {id}',
values: { id },
}),
});
throw error;
}
};
export interface PutConversationMessageParams {
http: HttpSetup;
toasts?: IToasts;
conversationId: string;
title?: string;
messages?: Message[];
apiConfig?: ApiConfig;
replacements?: Replacement[];
excludeFromLastConversationStorage?: boolean;
signal?: AbortSignal | undefined;
}
/**
* API call for updating conversation.
*
* @param {PutConversationMessageParams} options - The options object.
* @param {HttpSetup} options.http - HttpSetup
* @param {string} [options.title] - Conversation title
* @param {boolean} [options.excludeFromLastConversationStorage] - Conversation excludeFromLastConversationStorage
* @param {ApiConfig} [options.apiConfig] - Conversation apiConfig
* @param {Message[]} [options.messages] - Conversation messages
* @param {IToasts} [options.toasts] - IToasts
* @param {AbortSignal} [options.signal] - AbortSignal
*
* @returns {Promise<Conversation>}
*/
export const updateConversation = async ({
http,
toasts,
title,
conversationId,
messages,
apiConfig,
replacements,
excludeFromLastConversationStorage,
signal,
}: PutConversationMessageParams): Promise<Conversation> => {
try {
const response = await http.fetch(
`${ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL}/${conversationId}`,
{
method: 'PUT',
body: JSON.stringify({
id: conversationId,
title,
messages,
replacements,
apiConfig,
excludeFromLastConversationStorage,
}),
headers: {
'Content-Type': 'application/json',
},
version: ELASTIC_AI_ASSISTANT_API_CURRENT_VERSION,
signal,
}
);
return response as Conversation;
} catch (error) {
toasts?.addError(error.body && error.body.message ? new Error(error.body.message) : error, {
title: i18n.translate('xpack.elasticAssistant.conversations.updateConversationError', {
defaultMessage: 'Error updating conversation by id {conversationId}',
values: { conversationId },
}),
});
throw error;
}
};

View file

@ -5,9 +5,6 @@
* 2.0.
*/
import * as t from 'io-ts';
/** Validates the URL path of a POST request to the `/knowledge_base/{resource}` endpoint */
export const PostKnowledgeBasePathParams = t.type({
resource: t.union([t.string, t.undefined]),
});
export * from './conversations';
export * from './use_bulk_actions_conversations';
export * from './use_fetch_current_user_conversations';

View file

@ -0,0 +1,164 @@
/*
* 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 { bulkChangeConversations } from './use_bulk_actions_conversations';
import {
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BULK_ACTION,
ELASTIC_AI_ASSISTANT_API_CURRENT_VERSION,
} from '@kbn/elastic-assistant-common';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { IToasts } from '@kbn/core-notifications-browser';
const conversation1 = {
id: 'conversation1',
title: 'Conversation 1',
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
category: 'default',
messages: [
{
id: 'message1',
role: 'user' as const,
content: 'Hello',
timestamp: '2024-02-14T19:58:30.299Z',
},
{
id: 'message2',
role: 'user' as const,
content: 'How are you?',
timestamp: '2024-02-14T19:58:30.299Z',
},
],
};
const conversation2 = {
...conversation1,
id: 'conversation2',
title: 'Conversation 2',
};
const toasts = {
addError: jest.fn(),
};
describe('bulkChangeConversations', () => {
let httpMock: ReturnType<typeof httpServiceMock.createSetupContract>;
beforeEach(() => {
httpMock = httpServiceMock.createSetupContract();
jest.clearAllMocks();
});
it('should send a POST request with the correct parameters and receive a successful response', async () => {
const conversationsActions = {
create: {},
update: {},
delete: { ids: [] },
};
await bulkChangeConversations(httpMock, conversationsActions);
expect(httpMock.fetch).toHaveBeenCalledWith(
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BULK_ACTION,
{
method: 'POST',
version: ELASTIC_AI_ASSISTANT_API_CURRENT_VERSION,
body: JSON.stringify({
update: [],
create: [],
delete: { ids: [] },
}),
}
);
});
it('should transform the conversations dictionary to an array of conversations to create', async () => {
const conversationsActions = {
create: {
conversation1,
conversation2,
},
update: {},
delete: { ids: [] },
};
await bulkChangeConversations(httpMock, conversationsActions);
expect(httpMock.fetch).toHaveBeenCalledWith(
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BULK_ACTION,
{
method: 'POST',
version: ELASTIC_AI_ASSISTANT_API_CURRENT_VERSION,
body: JSON.stringify({
update: [],
create: [conversation1, conversation2],
delete: { ids: [] },
}),
}
);
});
it('should transform the conversations dictionary to an array of conversations to update', async () => {
const conversationsActions = {
update: {
conversation1,
conversation2,
},
delete: { ids: [] },
};
await bulkChangeConversations(httpMock, conversationsActions);
expect(httpMock.fetch).toHaveBeenCalledWith(
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BULK_ACTION,
{
method: 'POST',
version: ELASTIC_AI_ASSISTANT_API_CURRENT_VERSION,
body: JSON.stringify({
update: [conversation1, conversation2],
delete: { ids: [] },
}),
}
);
});
it('should throw an error with the correct message when receiving an unsuccessful response', async () => {
httpMock.fetch.mockResolvedValue({
success: false,
attributes: {
errors: [
{
statusCode: 400,
message: 'Error updating conversations',
conversations: [{ id: conversation1.id, name: conversation1.title }],
},
],
},
});
const conversationsActions = {
create: {},
update: {},
delete: { ids: [] },
};
await bulkChangeConversations(httpMock, conversationsActions, toasts as unknown as IToasts);
expect(toasts.addError.mock.calls[0][0]).toEqual(
new Error('Error message: Error updating conversations for conversation Conversation 1')
);
});
it('should handle cases where result.attributes.errors is undefined', async () => {
httpMock.fetch.mockResolvedValue({
success: false,
attributes: {},
});
const conversationsActions = {
create: {},
update: {},
delete: { ids: [] },
};
await bulkChangeConversations(httpMock, conversationsActions, toasts as unknown as IToasts);
expect(toasts.addError.mock.calls[0][0]).toEqual(new Error(''));
});
});

View file

@ -0,0 +1,146 @@
/*
* 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';
import { HttpSetup, IToasts } from '@kbn/core/public';
import {
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BULK_ACTION,
ELASTIC_AI_ASSISTANT_API_CURRENT_VERSION,
ApiConfig,
} from '@kbn/elastic-assistant-common';
import { Conversation, Message } from '../../../assistant_context/types';
export interface BulkActionSummary {
failed: number;
skipped: number;
succeeded: number;
total: number;
}
export interface BulkActionResult {
updated: Conversation[];
created: Conversation[];
deleted: Conversation[];
skipped: Conversation[];
}
export interface BulkActionAggregatedError {
message: string;
status_code: number;
err_code?: string;
conversations: Array<{ id: string; name?: string }>;
}
export interface BulkActionAttributes {
summary: BulkActionSummary;
results: BulkActionResult;
errors?: BulkActionAggregatedError[];
}
export interface BulkActionResponse {
success?: boolean;
conversations_count?: number;
message?: string;
statusCode?: number;
attributes: BulkActionAttributes;
}
export interface ConversationUpdateParams {
id?: string;
title?: string;
messages?: Message[];
apiConfig?: ApiConfig;
}
export interface ConversationsBulkActions {
update?: Record<string, ConversationUpdateParams>;
create?: Record<string, Conversation>;
delete?: {
ids: string[];
};
}
const transformCreateActions = (
createActions: Record<string, Conversation>,
conversationIdsToDelete?: string[]
) =>
Object.keys(createActions).reduce((conversationsToCreate: Conversation[], conversationId) => {
if (createActions && !conversationIdsToDelete?.includes(conversationId)) {
conversationsToCreate.push(createActions[conversationId]);
}
return conversationsToCreate;
}, []);
const transformUpdateActions = (
updateActions: Record<string, ConversationUpdateParams>,
conversationIdsToDelete?: string[]
) =>
Object.keys(updateActions).reduce(
(conversationsToUpdate: ConversationUpdateParams[], conversationId) => {
if (updateActions && !conversationIdsToDelete?.includes(conversationId)) {
conversationsToUpdate.push({
id: conversationId,
...updateActions[conversationId],
});
}
return conversationsToUpdate;
},
[]
);
export const bulkChangeConversations = async (
http: HttpSetup,
conversationsActions: ConversationsBulkActions,
toasts?: IToasts
) => {
// transform conversations disctionary to array of Conversations to create
// filter marked as deleted
const conversationsToCreate = conversationsActions.create
? transformCreateActions(conversationsActions.create, conversationsActions.delete?.ids)
: undefined;
// transform conversations disctionary to array of Conversations to update
// filter marked as deleted
const conversationsToUpdate = conversationsActions.update
? transformUpdateActions(conversationsActions.update, conversationsActions.delete?.ids)
: undefined;
try {
const result = await http.fetch<BulkActionResponse>(
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BULK_ACTION,
{
method: 'POST',
version: ELASTIC_AI_ASSISTANT_API_CURRENT_VERSION,
body: JSON.stringify({
update: conversationsToUpdate,
create: conversationsToCreate,
delete: conversationsActions.delete,
}),
}
);
if (!result.success) {
const serverError = result.attributes.errors
?.map(
(e) =>
`${e.status_code ? `Error code: ${e.status_code}. ` : ''}Error message: ${
e.message
} for conversation ${e.conversations.map((c) => c.name).join(',')}`
)
.join(',\n');
throw new Error(serverError);
}
return result;
} catch (error) {
toasts?.addError(error.body && error.body.message ? new Error(error.body.message) : error, {
title: i18n.translate('xpack.elasticAssistant.conversations.bulkActionsConversationsError', {
defaultMessage: 'Error updating conversations {error}',
values: { error },
}),
});
}
};

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 { act, renderHook } from '@testing-library/react-hooks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import React from 'react';
import {
UseFetchCurrentUserConversationsParams,
useFetchCurrentUserConversations,
} from './use_fetch_current_user_conversations';
const statusResponse = { assistantModelEvaluation: true, assistantStreamingEnabled: false };
const http = {
fetch: jest.fn().mockResolvedValue(statusResponse),
};
const onFetch = jest.fn();
const defaultProps = { http, onFetch } as unknown as UseFetchCurrentUserConversationsParams;
const createWrapper = () => {
const queryClient = new QueryClient();
// eslint-disable-next-line react/display-name
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('useFetchCurrentUserConversations', () => {
it(`should make http request to fetch conversations`, async () => {
renderHook(() => useFetchCurrentUserConversations(defaultProps), {
wrapper: createWrapper(),
});
await act(async () => {
const { waitForNextUpdate } = renderHook(() =>
useFetchCurrentUserConversations(defaultProps)
);
await waitForNextUpdate();
expect(defaultProps.http.fetch).toHaveBeenCalledWith(
'/api/elastic_assistant/current_user/conversations/_find',
{
method: 'GET',
query: {
page: 1,
perPage: 100,
},
version: '2023-10-31',
signal: undefined,
}
);
expect(onFetch).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,68 @@
/*
* 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 { HttpSetup } from '@kbn/core/public';
import { useQuery } from '@tanstack/react-query';
import {
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_FIND,
ELASTIC_AI_ASSISTANT_API_CURRENT_VERSION,
} from '@kbn/elastic-assistant-common';
import { Conversation } from '../../../assistant_context/types';
export interface FetchConversationsResponse {
page: number;
perPage: number;
total: number;
data: Conversation[];
}
export interface UseFetchCurrentUserConversationsParams {
http: HttpSetup;
onFetch: (result: FetchConversationsResponse) => Record<string, Conversation>;
signal?: AbortSignal | undefined;
}
/**
* API call for fetching assistant conversations for the current user
*
* @param {Object} options - The options object.
* @param {HttpSetup} options.http - HttpSetup
* @param {Function} [options.onFetch] - transformation function for conversations fetch result
* @param {AbortSignal} [options.signal] - AbortSignal
*
* @returns {useQuery} hook for getting the status of the conversations
*/
export const useFetchCurrentUserConversations = ({
http,
onFetch,
signal,
}: UseFetchCurrentUserConversationsParams) => {
const query = {
page: 1,
perPage: 100,
};
const cachingKeys = [
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_FIND,
query.page,
query.perPage,
ELASTIC_AI_ASSISTANT_API_CURRENT_VERSION,
];
return useQuery([cachingKeys, query], async () => {
const res = await http.fetch<FetchConversationsResponse>(
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_FIND,
{
method: 'GET',
version: ELASTIC_AI_ASSISTANT_API_CURRENT_VERSION,
query,
signal,
}
);
return onFetch(res);
});
};

View file

@ -14,9 +14,9 @@ import {
FetchConnectorExecuteAction,
getKnowledgeBaseStatus,
postKnowledgeBase,
} from './api';
import type { Conversation, Message } from '../assistant_context/types';
import { API_ERROR } from './translations';
} from '.';
import type { Conversation } from '../../assistant_context/types';
import { API_ERROR } from '../translations';
jest.mock('@kbn/core-http-browser');
@ -26,21 +26,20 @@ const mockHttp = {
const apiConfig: Conversation['apiConfig'] = {
connectorId: 'foo',
connectorTypeTitle: 'OpenAI',
model: 'gpt-4',
provider: OpenAiProviderType.OpenAi,
};
const messages: Message[] = [
{ content: 'This is a test', role: 'user', timestamp: new Date().toLocaleString() },
];
const fetchConnectorArgs: FetchConnectorExecuteAction = {
isEnabledRAGAlerts: false,
apiConfig,
isEnabledKnowledgeBase: true,
assistantStreamingEnabled: true,
http: mockHttp,
messages,
onNewReplacements: jest.fn(),
message: 'This is a test',
conversationId: 'test',
replacements: [],
};
describe('API tests', () => {
beforeEach(() => {
@ -54,10 +53,11 @@ describe('API tests', () => {
expect(mockHttp.fetch).toHaveBeenCalledWith(
'/internal/elastic_assistant/actions/connector/foo/_execute',
{
body: '{"params":{"subActionParams":{"model":"gpt-4","messages":[{"role":"user","content":"This is a test"}],"n":1,"stop":null,"temperature":0.2},"subAction":"invokeAI"},"isEnabledKnowledgeBase":true,"isEnabledRAGAlerts":false}',
body: '{"model":"gpt-4","message":"This is a test","subAction":"invokeAI","conversationId":"test","replacements":[],"isEnabledKnowledgeBase":true,"isEnabledRAGAlerts":false,"llmType":"openai"}',
headers: { 'Content-Type': 'application/json' },
method: 'POST',
signal: undefined,
version: '1',
}
);
});
@ -73,11 +73,12 @@ describe('API tests', () => {
expect(mockHttp.fetch).toHaveBeenCalledWith(
'/internal/elastic_assistant/actions/connector/foo/_execute',
{
body: '{"params":{"subActionParams":{"model":"gpt-4","messages":[{"role":"user","content":"This is a test"}],"n":1,"stop":null,"temperature":0.2},"subAction":"invokeStream"},"isEnabledKnowledgeBase":false,"isEnabledRAGAlerts":false}',
body: '{"model":"gpt-4","message":"This is a test","subAction":"invokeStream","conversationId":"test","replacements":[],"isEnabledKnowledgeBase":false,"isEnabledRAGAlerts":false,"llmType":"openai"}',
method: 'POST',
asResponse: true,
rawResponse: true,
signal: undefined,
version: '1',
}
);
});
@ -89,7 +90,7 @@ describe('API tests', () => {
alertsIndexPattern: '.alerts-security.alerts-default',
allow: ['a', 'b', 'c'],
allowReplacement: ['b', 'c'],
replacements: { auuid: 'real.hostname' },
replacements: [{ uuid: 'auuid', value: 'real.hostname' }],
size: 30,
};
@ -98,12 +99,13 @@ describe('API tests', () => {
expect(mockHttp.fetch).toHaveBeenCalledWith(
'/internal/elastic_assistant/actions/connector/foo/_execute',
{
body: '{"params":{"subActionParams":{"model":"gpt-4","messages":[{"role":"user","content":"This is a test"}],"n":1,"stop":null,"temperature":0.2},"subAction":"invokeAI"},"isEnabledKnowledgeBase":true,"isEnabledRAGAlerts":true,"alertsIndexPattern":".alerts-security.alerts-default","allow":["a","b","c"],"allowReplacement":["b","c"],"replacements":{"auuid":"real.hostname"},"size":30}',
body: '{"model":"gpt-4","message":"This is a test","subAction":"invokeAI","conversationId":"test","replacements":[{"uuid":"auuid","value":"real.hostname"}],"isEnabledKnowledgeBase":true,"isEnabledRAGAlerts":true,"llmType":"openai","alertsIndexPattern":".alerts-security.alerts-default","allow":["a","b","c"],"allowReplacement":["b","c"],"size":30}',
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
signal: undefined,
version: '1',
}
);
});
@ -120,12 +122,13 @@ describe('API tests', () => {
expect(mockHttp.fetch).toHaveBeenCalledWith(
'/internal/elastic_assistant/actions/connector/foo/_execute',
{
body: '{"params":{"subActionParams":{"model":"gpt-4","messages":[{"role":"user","content":"This is a test"}],"n":1,"stop":null,"temperature":0.2},"subAction":"invokeAI"},"isEnabledKnowledgeBase":false,"isEnabledRAGAlerts":false}',
body: '{"model":"gpt-4","message":"This is a test","subAction":"invokeAI","conversationId":"test","replacements":[],"isEnabledKnowledgeBase":false,"isEnabledRAGAlerts":false,"llmType":"openai"}',
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
signal: undefined,
version: '1',
}
);
});
@ -142,12 +145,13 @@ describe('API tests', () => {
expect(mockHttp.fetch).toHaveBeenCalledWith(
'/internal/elastic_assistant/actions/connector/foo/_execute',
{
body: '{"params":{"subActionParams":{"model":"gpt-4","messages":[{"role":"user","content":"This is a test"}],"n":1,"stop":null,"temperature":0.2},"subAction":"invokeAI"},"isEnabledKnowledgeBase":false,"isEnabledRAGAlerts":true}',
body: '{"model":"gpt-4","message":"This is a test","subAction":"invokeAI","conversationId":"test","replacements":[],"isEnabledKnowledgeBase":false,"isEnabledRAGAlerts":true,"llmType":"openai"}',
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
signal: undefined,
version: '1',
}
);
});
@ -224,23 +228,6 @@ describe('API tests', () => {
expect(result).toEqual({ response: API_ERROR, isStream: false, isError: true });
});
it('returns the value of the action_input property when isEnabledKnowledgeBase is true, and `content` has properly prefixed and suffixed JSON with the action_input property', async () => {
const response = '```json\n{"action_input": "value from action_input"}\n```';
(mockHttp.fetch as jest.Mock).mockResolvedValue({
status: 'ok',
data: response,
});
const result = await fetchConnectorExecuteAction(fetchConnectorArgs);
expect(result).toEqual({
response: 'value from action_input',
isStream: false,
isError: false,
});
});
it('returns the original content when isEnabledKnowledgeBase is true, and `content` has properly formatted JSON WITHOUT the action_input property', async () => {
const response = '```json\n{"some_key": "some value"}\n```';
@ -281,6 +268,7 @@ describe('API tests', () => {
{
method: 'GET',
signal: undefined,
version: '1',
}
);
});
@ -305,6 +293,7 @@ describe('API tests', () => {
{
method: 'POST',
signal: undefined,
version: '1',
}
);
});
@ -327,6 +316,7 @@ describe('API tests', () => {
{
method: 'DELETE',
signal: undefined,
version: '1',
}
);
});

View file

@ -5,30 +5,25 @@
* 2.0.
*/
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common';
import { HttpSetup, IHttpFetchError } from '@kbn/core-http-browser';
import type { Conversation, Message } from '../assistant_context/types';
import { API_ERROR } from './translations';
import { MODEL_GPT_3_5_TURBO } from '../connectorland/models/model_selector/model_selector';
import {
getFormattedMessageContent,
getOptionalRequestParams,
hasParsableResponse,
} from './helpers';
import { HttpSetup } from '@kbn/core/public';
import { IHttpFetchError } from '@kbn/core-http-browser';
import { ApiConfig, Replacement } from '@kbn/elastic-assistant-common';
import { API_ERROR } from '../translations';
import { getOptionalRequestParams, llmTypeDictionary } from '../helpers';
export * from './conversations';
export interface FetchConnectorExecuteAction {
conversationId: string;
isEnabledRAGAlerts: boolean;
alertsIndexPattern?: string;
allow?: string[];
allowReplacement?: string[];
isEnabledKnowledgeBase: boolean;
assistantStreamingEnabled: boolean;
apiConfig: Conversation['apiConfig'];
apiConfig: ApiConfig;
http: HttpSetup;
messages: Message[];
onNewReplacements: (newReplacements: Record<string, string>) => void;
replacements?: Record<string, string>;
message?: string;
replacements: Replacement[];
signal?: AbortSignal | undefined;
size?: number;
}
@ -44,6 +39,7 @@ export interface FetchConnectorExecuteResponse {
}
export const fetchConnectorExecuteAction = async ({
conversationId,
isEnabledRAGAlerts,
alertsIndexPattern,
allow,
@ -51,32 +47,13 @@ export const fetchConnectorExecuteAction = async ({
isEnabledKnowledgeBase,
assistantStreamingEnabled,
http,
messages,
onNewReplacements,
message,
replacements,
apiConfig,
signal,
size,
}: FetchConnectorExecuteAction): Promise<FetchConnectorExecuteResponse> => {
const outboundMessages = messages.map((msg) => ({
role: msg.role,
content: msg.content,
}));
const body =
apiConfig?.provider === OpenAiProviderType.OpenAi
? {
model: apiConfig.model ?? MODEL_GPT_3_5_TURBO,
messages: outboundMessages,
n: 1,
stop: null,
temperature: 0.2,
}
: {
// Azure OpenAI and Bedrock invokeAI both expect this body format
messages: outboundMessages,
};
const llmType = llmTypeDictionary[apiConfig.connectorTypeTitle];
// TODO: Remove in part 3 of streaming work for security solution
// tracked here: https://github.com/elastic/security-team/issues/7363
// In part 3 I will make enhancements to langchain to introduce streaming
@ -87,29 +64,21 @@ export const fetchConnectorExecuteAction = async ({
alertsIndexPattern,
allow,
allowReplacement,
replacements,
size,
});
const requestBody = isStream
? {
params: {
subActionParams: body,
subAction: 'invokeStream',
},
isEnabledKnowledgeBase,
isEnabledRAGAlerts,
...optionalRequestParams,
}
: {
params: {
subActionParams: body,
subAction: 'invokeAI',
},
isEnabledKnowledgeBase,
isEnabledRAGAlerts,
...optionalRequestParams,
};
const requestBody = {
// only used for openai, azure and bedrock ignore field
model: apiConfig?.model,
message,
subAction: isStream ? 'invokeStream' : 'invokeAI',
conversationId,
replacements,
isEnabledKnowledgeBase,
isEnabledRAGAlerts,
llmType,
...optionalRequestParams,
};
try {
if (isStream) {
@ -121,6 +90,7 @@ export const fetchConnectorExecuteAction = async ({
signal,
asResponse: isStream,
rawResponse: isStream,
version: '1',
}
);
@ -147,7 +117,7 @@ export const fetchConnectorExecuteAction = async ({
connector_id: string;
status: string;
data: string;
replacements?: Record<string, string>;
replacements?: Replacement[];
service_message?: string;
trace_data?: {
transaction_id: string;
@ -158,6 +128,7 @@ export const fetchConnectorExecuteAction = async ({
body: JSON.stringify(requestBody),
headers: { 'Content-Type': 'application/json' },
signal,
version: '1',
});
if (response.status !== 'ok' || !response.data) {
@ -184,15 +155,8 @@ export const fetchConnectorExecuteAction = async ({
}
: undefined;
onNewReplacements(response.replacements ?? {});
return {
response: hasParsableResponse({
isEnabledRAGAlerts,
isEnabledKnowledgeBase,
})
? getFormattedMessageContent(response.data)
: response.data,
response: response.data,
isError: false,
isStream: false,
traceData,
@ -251,6 +215,7 @@ export const getKnowledgeBaseStatus = async ({
const response = await http.fetch(path, {
method: 'GET',
signal,
version: '1',
});
return response as GetKnowledgeBaseStatusResponse;
@ -289,6 +254,7 @@ export const postKnowledgeBase = async ({
const response = await http.fetch(path, {
method: 'POST',
signal,
version: '1',
});
return response as PostKnowledgeBaseResponse;
@ -327,6 +293,7 @@ export const deleteKnowledgeBase = async ({
const response = await http.fetch(path, {
method: 'DELETE',
signal,
version: '1',
});
return response as DeleteKnowledgeBaseResponse;

View file

@ -6,13 +6,21 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { act, fireEvent, render } from '@testing-library/react';
import { AssistantHeader } from '.';
import { TestProviders } from '../../mock/test_providers/test_providers';
import { alertConvo, emptyWelcomeConvo } from '../../mock/conversation';
import { alertConvo, emptyWelcomeConvo, welcomeConvo } from '../../mock/conversation';
import { useLoadConnectors } from '../../connectorland/use_load_connectors';
import { mockConnectors } from '../../mock/connectors';
const onConversationSelected = jest.fn();
const setCurrentConversation = jest.fn();
const mockConversations = {
[alertConvo.title]: alertConvo,
[welcomeConvo.title]: welcomeConvo,
};
const testProps = {
currentConversation: emptyWelcomeConvo,
currentConversation: welcomeConvo,
title: 'Test Title',
docLinks: {
ELASTIC_WEBSITE_URL: 'https://www.elastic.co/',
@ -20,15 +28,45 @@ const testProps = {
},
isDisabled: false,
isSettingsModalVisible: false,
onConversationSelected: jest.fn(),
onConversationSelected,
onToggleShowAnonymizedValues: jest.fn(),
selectedConversationId: emptyWelcomeConvo.id,
setIsSettingsModalVisible: jest.fn(),
setSelectedConversationId: jest.fn(),
setCurrentConversation,
onConversationDeleted: jest.fn(),
showAnonymizedValues: false,
conversations: mockConversations,
refetchConversationsState: jest.fn(),
};
jest.mock('../../connectorland/use_load_connectors', () => ({
useLoadConnectors: jest.fn(() => {
return {
data: [],
error: null,
isSuccess: true,
};
}),
}));
(useLoadConnectors as jest.Mock).mockReturnValue({
data: mockConnectors,
error: null,
isSuccess: true,
});
const mockSetApiConfig = alertConvo;
jest.mock('../use_conversation', () => ({
useConversation: jest.fn(() => {
return {
setApiConfig: jest.fn().mockReturnValue(mockSetApiConfig),
};
}),
}));
describe('AssistantHeader', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('showAnonymizedValues is not checked when currentConversation.replacements is null', () => {
const { getByText, getByTestId } = render(<AssistantHeader {...testProps} />, {
wrapper: TestProviders,
@ -41,7 +79,7 @@ describe('AssistantHeader', () => {
const { getByText, getByTestId } = render(
<AssistantHeader
{...testProps}
currentConversation={{ ...emptyWelcomeConvo, replacements: {} }}
currentConversation={{ ...emptyWelcomeConvo, replacements: [] }}
/>,
{
wrapper: TestProviders,
@ -53,11 +91,7 @@ describe('AssistantHeader', () => {
it('showAnonymizedValues is not checked when currentConversation.replacements has values and showAnonymizedValues is false', () => {
const { getByTestId } = render(
<AssistantHeader
{...testProps}
currentConversation={alertConvo}
selectedConversationId={alertConvo.id}
/>,
<AssistantHeader {...testProps} currentConversation={alertConvo} />,
{
wrapper: TestProviders,
}
@ -67,16 +101,28 @@ describe('AssistantHeader', () => {
it('showAnonymizedValues is checked when currentConversation.replacements has values and showAnonymizedValues is true', () => {
const { getByTestId } = render(
<AssistantHeader
{...testProps}
currentConversation={alertConvo}
selectedConversationId={alertConvo.id}
showAnonymizedValues
/>,
<AssistantHeader {...testProps} currentConversation={alertConvo} showAnonymizedValues />,
{
wrapper: TestProviders,
}
);
expect(getByTestId('showAnonymizedValues')).toHaveAttribute('aria-checked', 'true');
});
it('Conversation is updated when connector change occurs', async () => {
const { getByTestId } = render(<AssistantHeader {...testProps} />, {
wrapper: TestProviders,
});
fireEvent.click(getByTestId('connectorSelectorPlaceholderButton'));
fireEvent.click(getByTestId('connector-selector'));
await act(async () => {
fireEvent.click(getByTestId('connectorId'));
});
expect(setCurrentConversation).toHaveBeenCalledWith(alertConvo);
expect(onConversationSelected).toHaveBeenCalledWith({
cId: alertConvo.id,
cTitle: alertConvo.title,
});
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
@ -17,7 +17,7 @@ import {
} from '@elastic/eui';
import { css } from '@emotion/react';
import { DocLinksStart } from '@kbn/core-doc-links-browser';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants';
import { AIConnector } from '../../connectorland/connector_selector';
import { Conversation } from '../../..';
import { AssistantTitle } from '../assistant_title';
import { ConversationSelector } from '../conversations/conversation_selector';
@ -26,19 +26,20 @@ import * as i18n from '../translations';
interface OwnProps {
currentConversation: Conversation;
defaultConnectorId?: string;
defaultProvider?: OpenAiProviderType;
defaultConnector?: AIConnector;
docLinks: Omit<DocLinksStart, 'links'>;
isDisabled: boolean;
isSettingsModalVisible: boolean;
onConversationSelected: (cId: string) => void;
onConversationSelected: ({ cId, cTitle }: { cId: string; cTitle: string }) => void;
onConversationDeleted: (conversationId: string) => void;
onToggleShowAnonymizedValues: (e: EuiSwitchEvent) => void;
selectedConversationId: string;
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedConversationId: React.Dispatch<React.SetStateAction<string>>;
setCurrentConversation: React.Dispatch<React.SetStateAction<Conversation>>;
shouldDisableKeyboardShortcut?: () => boolean;
showAnonymizedValues: boolean;
title: string | JSX.Element;
conversations: Record<string, Conversation>;
refetchConversationsState: () => Promise<void>;
}
type Props = OwnProps;
@ -49,19 +50,20 @@ type Props = OwnProps;
*/
export const AssistantHeader: React.FC<Props> = ({
currentConversation,
defaultConnectorId,
defaultProvider,
defaultConnector,
docLinks,
isDisabled,
isSettingsModalVisible,
onConversationSelected,
onConversationDeleted,
onToggleShowAnonymizedValues,
selectedConversationId,
setIsSettingsModalVisible,
setSelectedConversationId,
shouldDisableKeyboardShortcut,
showAnonymizedValues,
title,
setCurrentConversation,
conversations,
refetchConversationsState,
}) => {
const showAnonymizedValuesChecked = useMemo(
() =>
@ -70,6 +72,16 @@ export const AssistantHeader: React.FC<Props> = ({
showAnonymizedValues,
[currentConversation.replacements, showAnonymizedValues]
);
const onConversationChange = useCallback(
(updatedConversation) => {
setCurrentConversation(updatedConversation);
onConversationSelected({
cId: updatedConversation.id,
cTitle: updatedConversation.title,
});
},
[onConversationSelected, setCurrentConversation]
);
return (
<>
<EuiFlexGroup
@ -84,6 +96,7 @@ export const AssistantHeader: React.FC<Props> = ({
isDisabled={isDisabled}
docLinks={docLinks}
selectedConversation={currentConversation}
onChange={onConversationChange}
title={title}
/>
</EuiFlexItem>
@ -95,12 +108,13 @@ export const AssistantHeader: React.FC<Props> = ({
`}
>
<ConversationSelector
defaultConnectorId={defaultConnectorId}
defaultProvider={defaultProvider}
selectedConversationId={selectedConversationId}
defaultConnector={defaultConnector}
selectedConversationTitle={currentConversation.title}
onConversationSelected={onConversationSelected}
shouldDisableKeyboardShortcut={shouldDisableKeyboardShortcut}
isDisabled={isDisabled}
conversations={conversations}
onConversationDeleted={onConversationDeleted}
/>
<>
@ -125,13 +139,14 @@ export const AssistantHeader: React.FC<Props> = ({
<EuiFlexItem grow={false}>
<AssistantSettingsButton
defaultConnectorId={defaultConnectorId}
defaultProvider={defaultProvider}
defaultConnector={defaultConnector}
isDisabled={isDisabled}
isSettingsModalVisible={isSettingsModalVisible}
selectedConversation={currentConversation}
setIsSettingsModalVisible={setIsSettingsModalVisible}
setSelectedConversationId={setSelectedConversationId}
onConversationSelected={onConversationSelected}
conversations={conversations}
refetchConversationsState={refetchConversationsState}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -29,11 +29,12 @@ const StyledEuiModal = styled(EuiModal)`
*/
export const AssistantOverlay = React.memo(() => {
const [isModalVisible, setIsModalVisible] = useState(false);
const [conversationId, setConversationId] = useState<string | undefined>(
const [conversationTitle, setConversationTitle] = useState<string | undefined>(
WELCOME_CONVERSATION_TITLE
);
const [promptContextId, setPromptContextId] = useState<string | undefined>();
const { assistantTelemetry, setShowAssistantOverlay, getConversationId } = useAssistantContext();
const { assistantTelemetry, setShowAssistantOverlay, getLastConversationTitle } =
useAssistantContext();
// Bind `showAssistantOverlay` in SecurityAssistantContext to this modal instance
const showOverlay = useCallback(
@ -41,20 +42,20 @@ export const AssistantOverlay = React.memo(() => {
({
showOverlay: so,
promptContextId: pid,
conversationId: cid,
conversationTitle: cTitle,
}: ShowAssistantOverlayProps) => {
const newConversationId = getConversationId(cid);
const newConversationTitle = getLastConversationTitle(cTitle);
if (so)
assistantTelemetry?.reportAssistantInvoked({
conversationId: newConversationId,
conversationId: newConversationTitle,
invokedBy: 'click',
});
setIsModalVisible(so);
setPromptContextId(pid);
setConversationId(newConversationId);
setConversationTitle(newConversationTitle);
},
[assistantTelemetry, getConversationId]
[assistantTelemetry, getLastConversationTitle]
);
useEffect(() => {
setShowAssistantOverlay(showOverlay);
@ -64,15 +65,15 @@ export const AssistantOverlay = React.memo(() => {
const handleShortcutPress = useCallback(() => {
// Try to restore the last conversation on shortcut pressed
if (!isModalVisible) {
setConversationId(getConversationId());
setConversationTitle(getLastConversationTitle());
assistantTelemetry?.reportAssistantInvoked({
invokedBy: 'shortcut',
conversationId: getConversationId(),
conversationId: getLastConversationTitle(),
});
}
setIsModalVisible(!isModalVisible);
}, [assistantTelemetry, isModalVisible, getConversationId]);
}, [isModalVisible, getLastConversationTitle, assistantTelemetry]);
// Register keyboard listener to show the modal when cmd + ; is pressed
const onKeyDown = useCallback(
@ -90,8 +91,8 @@ export const AssistantOverlay = React.memo(() => {
const cleanupAndCloseModal = useCallback(() => {
setIsModalVisible(false);
setPromptContextId(undefined);
setConversationId(conversationId);
}, [conversationId]);
setConversationTitle(conversationTitle);
}, [conversationTitle]);
const handleCloseModal = useCallback(() => {
cleanupAndCloseModal();
@ -101,7 +102,7 @@ export const AssistantOverlay = React.memo(() => {
<>
{isModalVisible && (
<StyledEuiModal onClose={handleCloseModal} data-test-subj="ai-assistant-modal">
<Assistant conversationId={conversationId} promptContextId={promptContextId} />
<Assistant conversationTitle={conversationTitle} promptContextId={promptContextId} />
</StyledEuiModal>
)}
</>

View file

@ -14,6 +14,7 @@ const testProps = {
title: 'Test Title',
docLinks: { ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', DOC_LINK_VERSION: '7.15' },
selectedConversation: undefined,
onChange: jest.fn(),
};
describe('AssistantTitle', () => {

View file

@ -32,7 +32,8 @@ export const AssistantTitle: React.FC<{
title: string | JSX.Element;
docLinks: Omit<DocLinksStart, 'links'>;
selectedConversation: Conversation | undefined;
}> = ({ isDisabled = false, title, docLinks, selectedConversation }) => {
onChange: (updatedConversation: Conversation) => void;
}> = ({ isDisabled = false, title, docLinks, selectedConversation, onChange }) => {
const selectedConnectorId = selectedConversation?.apiConfig?.connectorId;
const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks;
@ -112,6 +113,7 @@ export const AssistantTitle: React.FC<{
isDisabled={isDisabled || selectedConversation === undefined}
selectedConnectorId={selectedConnectorId}
selectedConversation={selectedConversation}
onConnectorSelected={onChange}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -6,7 +6,7 @@
*/
import { HttpSetup } from '@kbn/core-http-browser';
import { useSendMessages } from '../use_send_messages';
import { useSendMessage } from '../use_send_message';
import { useConversation } from '../use_conversation';
import { emptyWelcomeConvo, welcomeConvo } from '../../mock/conversation';
import { defaultSystemPrompt, mockSystemPrompt } from '../../mock/system_prompt';
@ -14,24 +14,26 @@ import { useChatSend, UseChatSendProps } from './use_chat_send';
import { renderHook } from '@testing-library/react-hooks';
import { waitFor } from '@testing-library/react';
import { TestProviders } from '../../mock/test_providers/test_providers';
import { useAssistantContext } from '../../..';
jest.mock('../use_send_messages');
jest.mock('../use_send_message');
jest.mock('../use_conversation');
jest.mock('../../..');
const setEditingSystemPromptId = jest.fn();
const setPromptTextPreview = jest.fn();
const setSelectedPromptContexts = jest.fn();
const setUserPrompt = jest.fn();
const sendMessages = jest.fn();
const appendMessage = jest.fn();
const sendMessage = jest.fn();
const removeLastMessage = jest.fn();
const appendReplacements = jest.fn();
const clearConversation = jest.fn();
const refresh = jest.fn();
const setCurrentConversation = jest.fn();
export const testProps: UseChatSendProps = {
selectedPromptContexts: {},
allSystemPrompts: [defaultSystemPrompt, mockSystemPrompt],
currentConversation: emptyWelcomeConvo,
currentConversation: { ...emptyWelcomeConvo, id: 'an-id' },
http: {
basePath: {
basePath: '/mfg',
@ -45,23 +47,30 @@ export const testProps: UseChatSendProps = {
setPromptTextPreview,
setSelectedPromptContexts,
setUserPrompt,
refresh,
setCurrentConversation,
};
const robotMessage = { response: 'Response message from the robot', isError: false };
const reportAssistantMessageSent = jest.fn();
describe('use chat send', () => {
beforeEach(() => {
jest.clearAllMocks();
(useSendMessages as jest.Mock).mockReturnValue({
(useSendMessage as jest.Mock).mockReturnValue({
isLoading: false,
sendMessages: sendMessages.mockReturnValue(robotMessage),
sendMessage: sendMessage.mockReturnValue(robotMessage),
});
(useConversation as jest.Mock).mockReturnValue({
appendMessage,
appendReplacements,
removeLastMessage,
clearConversation,
});
(useAssistantContext as jest.Mock).mockReturnValue({
assistantTelemetry: {
reportAssistantMessageSent,
},
knowledgeBase: { isEnabledKnowledgeBase: false, isEnabledRAGAlerts: false },
});
});
it('handleOnChatCleared clears the conversation', () => {
it('handleOnChatCleared clears the conversation', async () => {
const { result } = renderHook(() => useChatSend(testProps), {
wrapper: TestProviders,
});
@ -70,7 +79,10 @@ describe('use chat send', () => {
expect(setPromptTextPreview).toHaveBeenCalledWith('');
expect(setUserPrompt).toHaveBeenCalledWith('');
expect(setSelectedPromptContexts).toHaveBeenCalledWith({});
expect(clearConversation).toHaveBeenCalledWith(testProps.currentConversation.id);
await waitFor(() => {
expect(clearConversation).toHaveBeenCalledWith(testProps.currentConversation.id);
expect(refresh).toHaveBeenCalled();
});
expect(setEditingSystemPromptId).toHaveBeenCalledWith(defaultSystemPrompt.id);
});
it('handlePromptChange updates prompt successfully', () => {
@ -90,21 +102,18 @@ describe('use chat send', () => {
expect(setUserPrompt).toHaveBeenCalledWith('');
await waitFor(() => {
expect(sendMessages).toHaveBeenCalled();
const appendMessageSend = appendMessage.mock.calls[0][0];
const appendMessageResponse = appendMessage.mock.calls[1][0];
expect(appendMessageSend.message.content).toEqual(
expect(sendMessage).toHaveBeenCalled();
const appendMessageSend = sendMessage.mock.calls[0][0].message;
expect(appendMessageSend).toEqual(
`You are a helpful, expert assistant who answers questions about Elastic Security. Do not answer questions unrelated to Elastic Security.\nIf you answer a question related to KQL or EQL, it should be immediately usable within an Elastic Security timeline; please always format the output correctly with back ticks. Any answer provided for Query DSL should also be usable in a security timeline. This means you should only ever include the "filter" portion of the query.\nUse the following context to answer questions:\n\n\n\n${promptText}`
);
expect(appendMessageSend.message.role).toEqual('user');
expect(appendMessageResponse.message.content).toEqual(robotMessage.response);
expect(appendMessageResponse.message.role).toEqual('assistant');
});
});
it('handleButtonSendMessage sends message with only provided prompt text and context already exists in convo history', async () => {
const promptText = 'prompt text';
const { result } = renderHook(
() => useChatSend({ ...testProps, currentConversation: welcomeConvo }),
() =>
useChatSend({ ...testProps, currentConversation: { ...welcomeConvo, id: 'welcome-id' } }),
{
wrapper: TestProviders,
}
@ -114,24 +123,50 @@ describe('use chat send', () => {
expect(setUserPrompt).toHaveBeenCalledWith('');
await waitFor(() => {
expect(sendMessages).toHaveBeenCalled();
expect(appendMessage.mock.calls[0][0].message.content).toEqual(`\n\n${promptText}`);
expect(sendMessage).toHaveBeenCalled();
const messages = setCurrentConversation.mock.calls[0][0].messages;
expect(messages[messages.length - 1].content).toEqual(`\n\n${promptText}`);
});
});
it('handleRegenerateResponse removes the last message of the conversation, resends the convo to GenAI, and appends the message received', async () => {
const { result } = renderHook(
() => useChatSend({ ...testProps, currentConversation: welcomeConvo }),
() =>
useChatSend({ ...testProps, currentConversation: { ...welcomeConvo, id: 'welcome-id' } }),
{
wrapper: TestProviders,
}
);
result.current.handleRegenerateResponse();
expect(removeLastMessage).toHaveBeenCalledWith('Welcome');
expect(removeLastMessage).toHaveBeenCalledWith('welcome-id');
await waitFor(() => {
expect(sendMessages).toHaveBeenCalled();
expect(appendMessage.mock.calls[0][0].message.content).toEqual(robotMessage.response);
expect(sendMessage).toHaveBeenCalled();
const messages = setCurrentConversation.mock.calls[1][0].messages;
expect(messages[messages.length - 1].content).toEqual(robotMessage.response);
});
});
it('sends telemetry events for both user and assistant', async () => {
const promptText = 'prompt text';
const { result } = renderHook(() => useChatSend(testProps), {
wrapper: TestProviders,
});
result.current.handleButtonSendMessage(promptText);
expect(setUserPrompt).toHaveBeenCalledWith('');
await waitFor(() => {
expect(reportAssistantMessageSent).toHaveBeenNthCalledWith(1, {
conversationId: testProps.currentConversation.title,
role: 'user',
isEnabledKnowledgeBase: false,
isEnabledRAGAlerts: false,
});
expect(reportAssistantMessageSent).toHaveBeenNthCalledWith(2, {
conversationId: testProps.currentConversation.title,
role: 'assistant',
isEnabledKnowledgeBase: false,
isEnabledRAGAlerts: false,
});
});
});
});

View file

@ -7,11 +7,12 @@
import React, { useCallback } from 'react';
import { HttpSetup } from '@kbn/core-http-browser';
import { i18n } from '@kbn/i18n';
import { SelectedPromptContext } from '../prompt_context/types';
import { useSendMessages } from '../use_send_messages';
import { useSendMessage } from '../use_send_message';
import { useConversation } from '../use_conversation';
import { getCombinedMessage } from '../prompt/helpers';
import { Conversation, Message, Prompt } from '../../..';
import { Conversation, Message, Prompt, useAssistantContext } from '../../..';
import { getMessageFromRawResponse } from '../helpers';
import { getDefaultSystemPrompt } from '../use_conversation/helpers';
@ -27,6 +28,8 @@ export interface UseChatSendProps {
React.SetStateAction<Record<string, SelectedPromptContext>>
>;
setUserPrompt: React.Dispatch<React.SetStateAction<string | null>>;
refresh: () => Promise<Conversation | undefined>;
setCurrentConversation: React.Dispatch<React.SetStateAction<Conversation>>;
}
export interface UseChatSend {
@ -53,10 +56,17 @@ export const useChatSend = ({
setPromptTextPreview,
setSelectedPromptContexts,
setUserPrompt,
refresh,
setCurrentConversation,
}: UseChatSendProps): UseChatSend => {
const { isLoading, sendMessages } = useSendMessages();
const { appendMessage, appendReplacements, clearConversation, removeLastMessage } =
useConversation();
const {
assistantTelemetry,
knowledgeBase: { isEnabledKnowledgeBase, isEnabledRAGAlerts },
toasts,
} = useAssistantContext();
const { isLoading, sendMessage } = useSendMessage();
const { clearConversation, removeLastMessage } = useConversation();
const handlePromptChange = (prompt: string) => {
setPromptTextPreview(prompt);
@ -66,88 +76,121 @@ export const useChatSend = ({
// Handles sending latest user prompt to API
const handleSendMessage = useCallback(
async (promptText: string) => {
const onNewReplacements = (newReplacements: Record<string, string>) =>
appendReplacements({
conversationId: currentConversation.id,
replacements: newReplacements,
});
if (!currentConversation.apiConfig) {
toasts?.addError(
new Error('The conversation needs a connector configured in order to send a message.'),
{
title: i18n.translate('xpack.elasticAssistant.knowledgeBase.setupError', {
defaultMessage: 'Error setting up Knowledge Base',
}),
}
);
return;
}
const systemPrompt = allSystemPrompts.find((prompt) => prompt.id === editingSystemPromptId);
const message = await getCombinedMessage({
const userMessage = getCombinedMessage({
isNewChat: currentConversation.messages.length === 0,
currentReplacements: currentConversation.replacements,
onNewReplacements,
promptText,
selectedPromptContexts,
selectedSystemPrompt: systemPrompt,
});
const updatedMessages = appendMessage({
conversationId: currentConversation.id,
message,
const replacements = userMessage.replacements ?? currentConversation.replacements;
const updatedMessages = [...currentConversation.messages, userMessage].map((m) => ({
...m,
content: m.content ?? '',
}));
setCurrentConversation({
...currentConversation,
replacements,
messages: updatedMessages,
});
// Reset prompt context selection and preview before sending:
setSelectedPromptContexts({});
setPromptTextPreview('');
const rawResponse = await sendMessages({
const rawResponse = await sendMessage({
apiConfig: currentConversation.apiConfig,
http,
messages: updatedMessages,
onNewReplacements,
replacements: currentConversation.replacements ?? {},
message: userMessage.content ?? '',
conversationId: currentConversation.id,
replacements,
});
assistantTelemetry?.reportAssistantMessageSent({
conversationId: currentConversation.title,
role: userMessage.role,
isEnabledKnowledgeBase,
isEnabledRAGAlerts,
});
const responseMessage: Message = getMessageFromRawResponse(rawResponse);
appendMessage({ conversationId: currentConversation.id, message: responseMessage });
setCurrentConversation({
...currentConversation,
replacements,
messages: [...updatedMessages, responseMessage],
});
assistantTelemetry?.reportAssistantMessageSent({
conversationId: currentConversation.title,
role: responseMessage.role,
isEnabledKnowledgeBase,
isEnabledRAGAlerts,
});
},
[
allSystemPrompts,
appendMessage,
appendReplacements,
currentConversation.apiConfig,
currentConversation.id,
currentConversation.messages.length,
currentConversation.replacements,
assistantTelemetry,
currentConversation,
editingSystemPromptId,
http,
isEnabledKnowledgeBase,
isEnabledRAGAlerts,
selectedPromptContexts,
sendMessages,
sendMessage,
setCurrentConversation,
setPromptTextPreview,
setSelectedPromptContexts,
toasts,
]
);
const handleRegenerateResponse = useCallback(async () => {
const onNewReplacements = (newReplacements: Record<string, string>) =>
appendReplacements({
conversationId: currentConversation.id,
replacements: newReplacements,
});
if (!currentConversation.apiConfig) {
toasts?.addError(
new Error('The conversation needs a connector configured in order to send a message.'),
{
title: i18n.translate('xpack.elasticAssistant.knowledgeBase.setupError', {
defaultMessage: 'Error setting up Knowledge Base',
}),
}
);
return;
}
// remove last message from the local state immediately
setCurrentConversation({
...currentConversation,
messages: currentConversation.messages.slice(0, -1),
});
const updatedMessages = (await removeLastMessage(currentConversation.id)) ?? [];
const updatedMessages = removeLastMessage(currentConversation.id);
const rawResponse = await sendMessages({
const rawResponse = await sendMessage({
apiConfig: currentConversation.apiConfig,
http,
messages: updatedMessages,
onNewReplacements,
replacements: currentConversation.replacements ?? {},
// do not send any new messages, the previous conversation is already stored
conversationId: currentConversation.id,
replacements: [],
});
const responseMessage: Message = getMessageFromRawResponse(rawResponse);
appendMessage({ conversationId: currentConversation.id, message: responseMessage });
}, [
appendMessage,
appendReplacements,
currentConversation.apiConfig,
currentConversation.id,
currentConversation.replacements,
http,
removeLastMessage,
sendMessages,
]);
setCurrentConversation({
...currentConversation,
messages: [...updatedMessages, responseMessage],
});
}, [currentConversation, http, removeLastMessage, sendMessage, setCurrentConversation, toasts]);
const handleButtonSendMessage = useCallback(
(message: string) => {
@ -157,7 +200,7 @@ export const useChatSend = ({
[handleSendMessage, setUserPrompt]
);
const handleOnChatCleared = useCallback(() => {
const handleOnChatCleared = useCallback(async () => {
const defaultSystemPromptId = getDefaultSystemPrompt({
allSystemPrompts,
conversation: currentConversation,
@ -166,12 +209,15 @@ export const useChatSend = ({
setPromptTextPreview('');
setUserPrompt('');
setSelectedPromptContexts({});
clearConversation(currentConversation.id);
await clearConversation(currentConversation.id);
await refresh();
setEditingSystemPromptId(defaultSystemPromptId);
}, [
allSystemPrompts,
clearConversation,
currentConversation,
refresh,
setEditingSystemPromptId,
setPromptTextPreview,
setSelectedPromptContexts,

View file

@ -25,17 +25,31 @@ const mockConversation = {
setConversation,
};
const mockConversations = {
[alertConvo.title]: alertConvo,
[welcomeConvo.title]: welcomeConvo,
};
const mockConversationsWithCustom = {
[alertConvo.title]: alertConvo,
[welcomeConvo.title]: welcomeConvo,
[customConvo.title]: customConvo,
};
jest.mock('../../use_conversation', () => ({
useConversation: () => mockConversation,
}));
const onConversationSelected = jest.fn();
const onConversationDeleted = jest.fn();
const defaultProps = {
isDisabled: false,
onConversationSelected,
selectedConversationId: 'Welcome',
selectedConversationTitle: 'Welcome',
defaultConnectorId: '123',
defaultProvider: OpenAiProviderType.OpenAi,
conversations: mockConversations,
onConversationDeleted,
};
describe('Conversation selector', () => {
beforeAll(() => {
@ -46,47 +60,29 @@ describe('Conversation selector', () => {
});
it('renders with correct selected conversation', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
<TestProviders>
<ConversationSelector {...defaultProps} />
</TestProviders>
);
expect(getByTestId('conversation-selector')).toBeInTheDocument();
expect(getByTestId('comboBoxSearchInput')).toHaveValue(welcomeConvo.id);
expect(getByTestId('comboBoxSearchInput')).toHaveValue(welcomeConvo.title);
});
it('On change, selects new item', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
<TestProviders>
<ConversationSelector {...defaultProps} />
</TestProviders>
);
fireEvent.click(getByTestId('comboBoxSearchInput'));
fireEvent.click(getByTestId(`convo-option-${alertConvo.id}`));
expect(onConversationSelected).toHaveBeenCalledWith(alertConvo.id);
fireEvent.click(getByTestId(`convo-option-${alertConvo.title}`));
expect(onConversationSelected).toHaveBeenCalledWith({
cId: '',
cTitle: alertConvo.title,
});
});
it('On clear input, clears selected options', () => {
const { getByPlaceholderText, queryByPlaceholderText, getByTestId, queryByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
<TestProviders>
<ConversationSelector {...defaultProps} />
</TestProviders>
);
@ -99,15 +95,8 @@ describe('Conversation selector', () => {
it('We can add a custom option', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
<ConversationSelector {...defaultProps} />
<TestProviders>
<ConversationSelector {...defaultProps} conversations={mockConversationsWithCustom} />
</TestProviders>
);
const customOption = 'Custom option';
@ -117,102 +106,75 @@ describe('Conversation selector', () => {
code: 'Enter',
charCode: 13,
});
expect(setConversation).toHaveBeenCalledWith({
conversation: {
id: customOption,
messages: [],
apiConfig: {
connectorId: '123',
defaultSystemPromptId: undefined,
provider: 'OpenAI',
},
},
expect(onConversationSelected).toHaveBeenCalledWith({
cId: '',
cTitle: customOption,
});
});
it('Only custom options can be deleted', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[customConvo.id]: customConvo,
}),
}}
>
<ConversationSelector {...defaultProps} />
<TestProviders>
<ConversationSelector
{...{ ...defaultProps, conversations: mockConversationsWithCustom }}
/>
</TestProviders>
);
fireEvent.click(getByTestId('comboBoxSearchInput'));
expect(
within(getByTestId(`convo-option-${customConvo.id}`)).getByTestId('delete-option')
within(getByTestId(`convo-option-${customConvo.title}`)).getByTestId('delete-option')
).toBeInTheDocument();
expect(
within(getByTestId(`convo-option-${alertConvo.id}`)).queryByTestId('delete-option')
within(getByTestId(`convo-option-${alertConvo.title}`)).queryByTestId('delete-option')
).not.toBeInTheDocument();
});
it('Custom options can be deleted', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[customConvo.id]: customConvo,
}),
}}
>
<ConversationSelector {...defaultProps} />
<TestProviders>
<ConversationSelector
{...{ ...defaultProps, conversations: mockConversationsWithCustom }}
/>
</TestProviders>
);
fireEvent.click(getByTestId('comboBoxSearchInput'));
fireEvent.click(
within(getByTestId(`convo-option-${customConvo.id}`)).getByTestId('delete-option')
within(getByTestId(`convo-option-${customConvo.title}`)).getByTestId('delete-option')
);
jest.runAllTimers();
expect(onConversationSelected).not.toHaveBeenCalled();
expect(deleteConversation).toHaveBeenCalledWith(customConvo.id);
expect(onConversationDeleted).toHaveBeenCalledWith(customConvo.title);
});
it('Previous conversation is set to active when selected conversation is deleted', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[customConvo.id]: customConvo,
}),
}}
>
<ConversationSelector {...defaultProps} selectedConversationId={customConvo.id} />
<TestProviders>
<ConversationSelector
{...{ ...defaultProps, conversations: mockConversationsWithCustom }}
selectedConversationTitle={customConvo.title}
/>
</TestProviders>
);
fireEvent.click(getByTestId('comboBoxSearchInput'));
fireEvent.click(
within(getByTestId(`convo-option-${customConvo.id}`)).getByTestId('delete-option')
within(getByTestId(`convo-option-${customConvo.title}`)).getByTestId('delete-option')
);
expect(onConversationSelected).toHaveBeenCalledWith(welcomeConvo.id);
expect(onConversationSelected).toHaveBeenCalledWith({
cId: '',
cTitle: welcomeConvo.title,
});
});
it('Left arrow selects first conversation', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[customConvo.id]: customConvo,
}),
}}
>
<ConversationSelector {...defaultProps} />
<TestProviders>
<ConversationSelector
{...{ ...defaultProps, conversations: mockConversationsWithCustom }}
/>
</TestProviders>
);
@ -222,21 +184,16 @@ describe('Conversation selector', () => {
code: 'ArrowLeft',
charCode: 27,
});
expect(onConversationSelected).toHaveBeenCalledWith(alertConvo.id);
expect(onConversationSelected).toHaveBeenCalledWith({
cId: '',
cTitle: alertConvo.title,
});
});
it('Right arrow selects last conversation', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[customConvo.id]: customConvo,
}),
}}
>
<ConversationSelector {...defaultProps} />
<TestProviders>
<ConversationSelector {...defaultProps} conversations={mockConversationsWithCustom} />
</TestProviders>
);
@ -246,21 +203,16 @@ describe('Conversation selector', () => {
code: 'ArrowRight',
charCode: 26,
});
expect(onConversationSelected).toHaveBeenCalledWith(customConvo.id);
expect(onConversationSelected).toHaveBeenCalledWith({
cId: '',
cTitle: customConvo.title,
});
});
it('Right arrow does nothing when ctrlKey is false', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[customConvo.id]: customConvo,
}),
}}
>
<ConversationSelector {...defaultProps} />
<TestProviders>
<ConversationSelector {...defaultProps} conversations={mockConversationsWithCustom} />
</TestProviders>
);
@ -275,14 +227,13 @@ describe('Conversation selector', () => {
it('Right arrow does nothing when conversation lenth is 1', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
<ConversationSelector {...defaultProps} />
<TestProviders>
<ConversationSelector
{...defaultProps}
conversations={{
[welcomeConvo.title]: welcomeConvo,
}}
/>
</TestProviders>
);

View file

@ -19,7 +19,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import useEvent from 'react-use/lib/useEvent';
import { css } from '@emotion/react';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants';
import { AIConnector } from '../../../connectorland/connector_selector';
import { Conversation } from '../../../..';
import { useAssistantContext } from '../../../assistant_context';
import * as i18n from './translations';
@ -30,58 +30,68 @@ import { SystemPromptSelectorOption } from '../../prompt_editor/system_prompt/sy
const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0;
interface Props {
defaultConnectorId?: string;
defaultProvider?: OpenAiProviderType;
selectedConversationId: string | undefined;
onConversationSelected: (conversationId: string) => void;
defaultConnector?: AIConnector;
selectedConversationTitle: string | undefined;
onConversationSelected: ({ cId, cTitle }: { cId: string; cTitle: string }) => void;
onConversationDeleted: (conversationId: string) => void;
shouldDisableKeyboardShortcut?: () => boolean;
isDisabled?: boolean;
conversations: Record<string, Conversation>;
}
const getPreviousConversationId = (conversationIds: string[], selectedConversationId: string) => {
return conversationIds.indexOf(selectedConversationId) === 0
? conversationIds[conversationIds.length - 1]
: conversationIds[conversationIds.indexOf(selectedConversationId) - 1];
const getPreviousConversationTitle = (
conversationTitles: string[],
selectedConversationTitle: string
) => {
return conversationTitles.indexOf(selectedConversationTitle) === 0
? conversationTitles[conversationTitles.length - 1]
: conversationTitles[conversationTitles.indexOf(selectedConversationTitle) - 1];
};
const getNextConversationId = (conversationIds: string[], selectedConversationId: string) => {
return conversationIds.indexOf(selectedConversationId) + 1 >= conversationIds.length
? conversationIds[0]
: conversationIds[conversationIds.indexOf(selectedConversationId) + 1];
const getNextConversationTitle = (
conversationTitles: string[],
selectedConversationTitle: string
) => {
return conversationTitles.indexOf(selectedConversationTitle) + 1 >= conversationTitles.length
? conversationTitles[0]
: conversationTitles[conversationTitles.indexOf(selectedConversationTitle) + 1];
};
const getConvoId = (cId: string, cTitle: string): string => (cId === cTitle ? '' : cId);
export type ConversationSelectorOption = EuiComboBoxOptionOption<{
isDefault: boolean;
}>;
export const ConversationSelector: React.FC<Props> = React.memo(
({
selectedConversationId = DEFAULT_CONVERSATION_TITLE,
defaultConnectorId,
defaultProvider,
selectedConversationTitle = DEFAULT_CONVERSATION_TITLE,
defaultConnector,
onConversationSelected,
onConversationDeleted,
shouldDisableKeyboardShortcut = () => false,
isDisabled = false,
conversations,
}) => {
const { allSystemPrompts, conversations } = useAssistantContext();
const { allSystemPrompts } = useAssistantContext();
const { deleteConversation, setConversation } = useConversation();
const conversationIds = useMemo(() => Object.keys(conversations), [conversations]);
const { createConversation } = useConversation();
const conversationTitles = useMemo(() => Object.keys(conversations), [conversations]);
const conversationOptions = useMemo<ConversationSelectorOption[]>(() => {
return Object.values(conversations).map((conversation) => ({
value: { isDefault: conversation.isDefault ?? false },
label: conversation.id,
id: conversation.id !== '' ? conversation.id : conversation.title,
label: conversation.title,
}));
}, [conversations]);
const [selectedOptions, setSelectedOptions] = useState<ConversationSelectorOption[]>(() => {
return conversationOptions.filter((c) => c.label === selectedConversationId) ?? [];
return conversationOptions.filter((c) => c.label === selectedConversationTitle) ?? [];
});
// Callback for when user types to create a new system prompt
const onCreateOption = useCallback(
(searchValue, flattenedOptions = []) => {
async (searchValue, flattenedOptions = []) => {
if (!searchValue || !searchValue.trim().toLowerCase()) {
return;
}
@ -96,66 +106,96 @@ export const ConversationSelector: React.FC<Props> = React.memo(
option.label.trim().toLowerCase() === normalizedSearchValue
) !== -1;
let createdConversation;
if (!optionExists) {
const newConversation: Conversation = {
id: searchValue,
id: '',
title: searchValue,
category: 'assistant',
messages: [],
apiConfig: {
connectorId: defaultConnectorId,
provider: defaultProvider,
defaultSystemPromptId: defaultSystemPrompt?.id,
},
replacements: [],
...(defaultConnector
? {
apiConfig: {
connectorId: defaultConnector.id,
connectorTypeTitle: defaultConnector.connectorTypeTitle,
provider: defaultConnector.apiProvider,
defaultSystemPromptId: defaultSystemPrompt?.id,
},
}
: {}),
};
setConversation({ conversation: newConversation });
createdConversation = await createConversation(newConversation);
}
onConversationSelected(searchValue);
onConversationSelected(
createdConversation
? { cId: '', cTitle: createdConversation.title }
: { cId: '', cTitle: DEFAULT_CONVERSATION_TITLE }
);
},
[
allSystemPrompts,
defaultConnectorId,
defaultProvider,
setConversation,
onConversationSelected,
]
[allSystemPrompts, onConversationSelected, defaultConnector, createConversation]
);
// Callback for when user deletes a conversation
const onDelete = useCallback(
(cId: string) => {
if (selectedConversationId === cId) {
onConversationSelected(getPreviousConversationId(conversationIds, cId));
(deletedTitle: string) => {
onConversationDeleted(deletedTitle);
if (selectedConversationTitle === deletedTitle) {
const prevConversationTitle = getPreviousConversationTitle(
conversationTitles,
selectedConversationTitle
);
onConversationSelected({
cId: getConvoId(conversations[prevConversationTitle].id, prevConversationTitle),
cTitle: prevConversationTitle,
});
}
setTimeout(() => {
deleteConversation(cId);
}, 0);
},
[conversationIds, deleteConversation, selectedConversationId, onConversationSelected]
[
selectedConversationTitle,
onConversationDeleted,
onConversationSelected,
conversationTitles,
conversations,
]
);
const onChange = useCallback(
(newOptions: ConversationSelectorOption[]) => {
if (newOptions.length === 0) {
async (newOptions: ConversationSelectorOption[]) => {
if (newOptions.length === 0 || !newOptions?.[0].id) {
setSelectedOptions([]);
} else if (conversationOptions.findIndex((o) => o.label === newOptions?.[0].label) !== -1) {
onConversationSelected(newOptions?.[0].label);
} else if (conversationOptions.findIndex((o) => o.id === newOptions?.[0].id) !== -1) {
const { id, label } = newOptions?.[0];
await onConversationSelected({ cId: getConvoId(id, label), cTitle: label });
}
},
[conversationOptions, onConversationSelected]
);
const onLeftArrowClick = useCallback(() => {
const prevId = getPreviousConversationId(conversationIds, selectedConversationId);
onConversationSelected(prevId);
}, [conversationIds, selectedConversationId, onConversationSelected]);
const prevTitle = getPreviousConversationTitle(conversationTitles, selectedConversationTitle);
onConversationSelected({
cId: getConvoId(conversations[prevTitle].id, prevTitle),
cTitle: prevTitle,
});
}, [conversationTitles, selectedConversationTitle, onConversationSelected, conversations]);
const onRightArrowClick = useCallback(() => {
const nextId = getNextConversationId(conversationIds, selectedConversationId);
onConversationSelected(nextId);
}, [conversationIds, selectedConversationId, onConversationSelected]);
const nextTitle = getNextConversationTitle(conversationTitles, selectedConversationTitle);
onConversationSelected({
cId: getConvoId(conversations[nextTitle].id, nextTitle),
cTitle: nextTitle,
});
}, [conversationTitles, selectedConversationTitle, onConversationSelected, conversations]);
// Register keyboard listener for quick conversation switching
const onKeyDown = useCallback(
(event: KeyboardEvent) => {
if (isDisabled || conversationIds.length <= 1) {
if (isDisabled || conversationTitles.length <= 1) {
return;
}
@ -177,7 +217,7 @@ export const ConversationSelector: React.FC<Props> = React.memo(
}
},
[
conversationIds.length,
conversationTitles.length,
isDisabled,
onLeftArrowClick,
onRightArrowClick,
@ -187,8 +227,8 @@ export const ConversationSelector: React.FC<Props> = React.memo(
useEvent('keydown', onKeyDown);
useEffect(() => {
setSelectedOptions(conversationOptions.filter((c) => c.label === selectedConversationId));
}, [conversationOptions, selectedConversationId]);
setSelectedOptions(conversationOptions.filter((c) => c.label === selectedConversationTitle));
}, [conversationOptions, selectedConversationTitle]);
const renderOption: (
option: ConversationSelectorOption,
@ -196,6 +236,7 @@ export const ConversationSelector: React.FC<Props> = React.memo(
OPTION_CONTENT_CLASSNAME: string
) => React.ReactNode = (option, searchValue, contentClassName) => {
const { label, value } = option;
return (
<EuiFlexGroup
alignItems="center"
@ -230,7 +271,7 @@ export const ConversationSelector: React.FC<Props> = React.memo(
color="danger"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onDelete(label);
onDelete(label ?? '');
}}
data-test-subj="delete-option"
css={css`
@ -264,7 +305,7 @@ export const ConversationSelector: React.FC<Props> = React.memo(
options={conversationOptions}
selectedOptions={selectedOptions}
onChange={onChange}
onCreateOption={onCreateOption}
onCreateOption={onCreateOption as unknown as () => void}
renderOption={renderOption}
compressed={true}
isDisabled={isDisabled}
@ -274,7 +315,7 @@ export const ConversationSelector: React.FC<Props> = React.memo(
iconType="arrowLeft"
aria-label={i18n.PREVIOUS_CONVERSATION_TITLE}
onClick={onLeftArrowClick}
disabled={isDisabled || conversationIds.length <= 1}
disabled={isDisabled || conversationTitles.length <= 1}
/>
</EuiToolTip>
}
@ -284,7 +325,7 @@ export const ConversationSelector: React.FC<Props> = React.memo(
iconType="arrowRight"
aria-label={i18n.NEXT_CONVERSATION_TITLE}
onClick={onRightArrowClick}
disabled={isDisabled || conversationIds.length <= 1}
disabled={isDisabled || conversationTitles.length <= 1}
/>
</EuiToolTip>
}

View file

@ -13,13 +13,13 @@ import { alertConvo, customConvo, welcomeConvo } from '../../../mock/conversatio
const onConversationSelectionChange = jest.fn();
const onConversationDeleted = jest.fn();
const mockConversations = {
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[customConvo.id]: customConvo,
[alertConvo.title]: alertConvo,
[welcomeConvo.title]: welcomeConvo,
[customConvo.title]: customConvo,
};
const testProps = {
conversations: mockConversations,
selectedConversationId: welcomeConvo.id,
selectedConversationTitle: welcomeConvo.title,
onConversationDeleted,
onConversationSelectionChange,
};
@ -30,9 +30,9 @@ describe('ConversationSelectorSettings', () => {
});
it('Selects an existing conversation', () => {
const { getByTestId } = render(<ConversationSelectorSettings {...testProps} />);
expect(getByTestId('comboBoxSearchInput')).toHaveValue(welcomeConvo.id);
expect(getByTestId('comboBoxSearchInput')).toHaveValue(welcomeConvo.title);
fireEvent.click(getByTestId('comboBoxToggleListButton'));
fireEvent.click(getByTestId(alertConvo.id));
fireEvent.click(getByTestId(alertConvo.title));
expect(onConversationSelectionChange).toHaveBeenCalledWith(alertConvo);
});
it('Only custom option can be deleted', () => {
@ -40,11 +40,11 @@ describe('ConversationSelectorSettings', () => {
fireEvent.click(getByTestId('comboBoxToggleListButton'));
// there is only one delete conversation because there is only one custom convo
fireEvent.click(getByTestId('delete-conversation'));
expect(onConversationDeleted).toHaveBeenCalledWith(customConvo.id);
expect(onConversationDeleted).toHaveBeenCalledWith(customConvo.title);
});
it('Selects existing conversation from the search input', () => {
const { getByTestId } = render(<ConversationSelectorSettings {...testProps} />);
fireEvent.change(getByTestId('comboBoxSearchInput'), { target: { value: alertConvo.id } });
fireEvent.change(getByTestId('comboBoxSearchInput'), { target: { value: alertConvo.title } });
fireEvent.keyDown(getByTestId('comboBoxSearchInput'), {
key: 'Enter',
code: 'Enter',

View file

@ -19,27 +19,34 @@ import React, { useCallback, useMemo, useState } from 'react';
import { css } from '@emotion/react';
import { Conversation } from '../../../..';
import { UseAssistantContext } from '../../../assistant_context';
import * as i18n from './translations';
import * as i18n from '../conversation_selector/translations';
import { SystemPromptSelectorOption } from '../../prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector';
interface Props {
conversations: UseAssistantContext['conversations'];
onConversationDeleted: (conversationId: string) => void;
conversations: Record<string, Conversation>;
onConversationDeleted: (conversationTitle: string) => void;
onConversationSelectionChange: (conversation?: Conversation | string) => void;
selectedConversationId?: string;
selectedConversationTitle: string;
shouldDisableKeyboardShortcut?: () => boolean;
isDisabled?: boolean;
}
const getPreviousConversationId = (conversationIds: string[], selectedConversationId = '') => {
return conversationIds.indexOf(selectedConversationId) === 0
? conversationIds[conversationIds.length - 1]
: conversationIds[conversationIds.indexOf(selectedConversationId) - 1];
const getPreviousConversationTitle = (
conversationTitles: string[],
selectedConversationTitle: string
) => {
return conversationTitles.indexOf(selectedConversationTitle) === 0
? conversationTitles[conversationTitles.length - 1]
: conversationTitles[conversationTitles.indexOf(selectedConversationTitle) - 1];
};
const getNextConversationId = (conversationIds: string[], selectedConversationId = '') => {
return conversationIds.indexOf(selectedConversationId) + 1 >= conversationIds.length
? conversationIds[0]
: conversationIds[conversationIds.indexOf(selectedConversationId) + 1];
const getNextConversationTitle = (
conversationTitles: string[],
selectedConversationTitle: string
) => {
return conversationTitles.indexOf(selectedConversationTitle) + 1 >= conversationTitles.length
? conversationTitles[0]
: conversationTitles[conversationTitles.indexOf(selectedConversationTitle) + 1];
};
export type ConversationSelectorSettingsOption = EuiComboBoxOptionOption<{
@ -57,25 +64,28 @@ export const ConversationSelectorSettings: React.FC<Props> = React.memo(
conversations,
onConversationDeleted,
onConversationSelectionChange,
selectedConversationId,
selectedConversationTitle,
isDisabled,
shouldDisableKeyboardShortcut = () => false,
}) => {
const conversationIds = useMemo(() => Object.keys(conversations), [conversations]);
const conversationTitles = useMemo(() => Object.keys(conversations), [conversations]);
const [conversationOptions, setConversationOptions] = useState<
ConversationSelectorSettingsOption[]
>(() => {
return Object.values(conversations).map((conversation) => ({
value: { isDefault: conversation.isDefault ?? false },
label: conversation.id,
'data-test-subj': conversation.id,
label: conversation.title,
id: conversation.id,
'data-test-subj': conversation.title,
}));
});
const selectedOptions = useMemo<ConversationSelectorSettingsOption[]>(() => {
return selectedConversationId
? conversationOptions.filter((c) => c.label === selectedConversationId) ?? []
return selectedConversationTitle
? conversationOptions.filter((c) => c.label === selectedConversationTitle) ?? []
: [];
}, [conversationOptions, selectedConversationId]);
}, [conversationOptions, selectedConversationTitle]);
const handleSelectionChange = useCallback(
(conversationSelectorSettingsOption: ConversationSelectorSettingsOption[]) => {
@ -83,8 +93,10 @@ export const ConversationSelectorSettings: React.FC<Props> = React.memo(
conversationSelectorSettingsOption.length === 0
? undefined
: Object.values(conversations).find(
(conversation) => conversation.id === conversationSelectorSettingsOption[0]?.label
(conversation) =>
conversation.title === conversationSelectorSettingsOption[0]?.label
) ?? conversationSelectorSettingsOption[0]?.label;
onConversationSelectionChange(newConversation);
},
[onConversationSelectionChange, conversations]
@ -107,6 +119,7 @@ export const ConversationSelectorSettings: React.FC<Props> = React.memo(
const newOption = {
value: searchValue,
label: searchValue,
id: '',
};
if (!optionExists) {
@ -142,21 +155,21 @@ export const ConversationSelectorSettings: React.FC<Props> = React.memo(
);
const onLeftArrowClick = useCallback(() => {
const prevId = getPreviousConversationId(conversationIds, selectedConversationId);
const previousOption = conversationOptions.filter((c) => c.label === prevId);
const prevTitle = getPreviousConversationTitle(conversationTitles, selectedConversationTitle);
const previousOption = conversationOptions.filter((c) => c.label === prevTitle);
handleSelectionChange(previousOption);
}, [conversationIds, conversationOptions, handleSelectionChange, selectedConversationId]);
}, [conversationTitles, selectedConversationTitle, conversationOptions, handleSelectionChange]);
const onRightArrowClick = useCallback(() => {
const nextId = getNextConversationId(conversationIds, selectedConversationId);
const nextOption = conversationOptions.filter((c) => c.label === nextId);
const nextTitle = getNextConversationTitle(conversationTitles, selectedConversationTitle);
const nextOption = conversationOptions.filter((c) => c.label === nextTitle);
handleSelectionChange(nextOption);
}, [conversationIds, conversationOptions, handleSelectionChange, selectedConversationId]);
}, [conversationTitles, selectedConversationTitle, conversationOptions, handleSelectionChange]);
const renderOption: (
option: ConversationSelectorSettingsOption,
searchValue: string,
OPTION_CONTENT_CLASSNAME: string
) => React.ReactNode = (option, searchValue, contentClassName) => {
) => React.ReactNode = (option, searchValue) => {
const { label, value } = option;
return (
<EuiFlexGroup
@ -217,6 +230,7 @@ export const ConversationSelectorSettings: React.FC<Props> = React.memo(
`}
>
<EuiComboBox
data-test-subj="conversation-selector"
aria-label={i18n.CONVERSATION_SELECTOR_ARIA_LABEL}
customOptionText={`${i18n.CONVERSATION_SELECTOR_CUSTOM_OPTION_TEXT} {searchValue}`}
placeholder={i18n.CONVERSATION_SELECTOR_PLACE_HOLDER}
@ -227,13 +241,14 @@ export const ConversationSelectorSettings: React.FC<Props> = React.memo(
onCreateOption={onCreateOption}
renderOption={renderOption}
compressed={true}
isDisabled={isDisabled}
prepend={
<EuiButtonIcon
iconType="arrowLeft"
data-test-subj="arrowLeft"
aria-label={i18n.PREVIOUS_CONVERSATION_TITLE}
onClick={onLeftArrowClick}
disabled={conversationIds.length <= 1}
disabled={isDisabled || conversationTitles.length <= 1}
/>
}
append={
@ -242,7 +257,7 @@ export const ConversationSelectorSettings: React.FC<Props> = React.memo(
data-test-subj="arrowRight"
aria-label={i18n.NEXT_CONVERSATION_TITLE}
onClick={onRightArrowClick}
disabled={conversationIds.length <= 1}
disabled={isDisabled || conversationTitles.length <= 1}
/>
}
/>

View file

@ -1,57 +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 { i18n } from '@kbn/i18n';
export const SELECTED_CONVERSATION_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelectorSettings.defaultConversationTitle',
{
defaultMessage: 'Conversations',
}
);
export const CONVERSATION_SELECTOR_ARIA_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelectorSettings.ariaLabel',
{
defaultMessage: 'Conversation selector',
}
);
export const CONVERSATION_SELECTOR_PLACE_HOLDER = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelectorSettings.placeholderTitle',
{
defaultMessage: 'Select or type to create new...',
}
);
export const CONVERSATION_SELECTOR_CUSTOM_OPTION_TEXT = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelectorSettings.CustomOptionTextTitle',
{
defaultMessage: 'Create new conversation:',
}
);
export const PREVIOUS_CONVERSATION_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelectorSettings.previousConversationTitle',
{
defaultMessage: 'Previous conversation',
}
);
export const NEXT_CONVERSATION_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelectorSettings.nextConversationTitle',
{
defaultMessage: 'Next conversation',
}
);
export const DELETE_CONVERSATION = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSelectorSettings.deleteConversationTitle',
{
defaultMessage: 'Delete conversation',
}
);

View file

@ -15,15 +15,14 @@ import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/c
import { mockConnectors } from '../../../mock/connectors';
const mockConvos = {
[welcomeConvo.id]: welcomeConvo,
[alertConvo.id]: alertConvo,
[customConvo.id]: customConvo,
[welcomeConvo.title]: { ...welcomeConvo, id: '1234' },
[alertConvo.title]: { ...alertConvo, id: '12345' },
[customConvo.title]: { ...customConvo, id: '123' },
};
const onSelectedConversationChange = jest.fn();
const setUpdatedConversationSettings = jest.fn().mockImplementation((fn) => {
return fn(mockConvos);
});
const setConversationSettings = jest.fn();
const setConversationsSettingsBulkActions = jest.fn();
const testProps = {
allSystemPrompts: mockSystemPrompts,
@ -32,8 +31,10 @@ const testProps = {
defaultProvider: OpenAiProviderType.OpenAi,
http: { basePath: { get: jest.fn() } },
onSelectedConversationChange,
selectedConversation: welcomeConvo,
setUpdatedConversationSettings,
selectedConversation: mockConvos[welcomeConvo.title],
setConversationSettings,
conversationsSettingsBulkActions: {},
setConversationsSettingsBulkActions,
} as unknown as ConversationSettingsProps;
jest.mock('../../../connectorland/use_load_connectors', () => ({
@ -44,7 +45,7 @@ jest.mock('../../../connectorland/use_load_connectors', () => ({
}),
}));
const mockConvo = alertConvo;
const mockConvo = mockConvos[alertConvo.title];
jest.mock('../conversation_selector_settings', () => ({
// @ts-ignore
ConversationSelectorSettings: ({ onConversationDeleted, onConversationSelectionChange }) => (
@ -59,6 +60,16 @@ jest.mock('../conversation_selector_settings', () => ({
data-test-subj="change-convo"
onClick={() => onConversationSelectionChange(mockConvo)}
/>
<button
type="button"
data-test-subj="change-new-convo"
onClick={() => onConversationSelectionChange({ ...mockConvo, id: '' })}
/>
<button
type="button"
data-test-subj="bad-id-convo"
onClick={() => onConversationSelectionChange({ ...mockConvo, id: 'not-the-right-id' })}
/>
<button
type="button"
data-test-subj="change-convo-custom"
@ -115,10 +126,10 @@ describe('ConversationSettings', () => {
</TestProviders>
);
fireEvent.click(getByTestId('change-sp'));
expect(setUpdatedConversationSettings).toHaveReturnedWith({
expect(setConversationSettings).toHaveBeenLastCalledWith({
...mockConvos,
[welcomeConvo.id]: {
...welcomeConvo,
[welcomeConvo.title]: {
...mockConvos[welcomeConvo.title],
apiConfig: {
...welcomeConvo.apiConfig,
defaultSystemPromptId: 'mock-superhero-system-prompt-1',
@ -127,6 +138,29 @@ describe('ConversationSettings', () => {
});
});
it('Selecting a system prompt updates the defaultSystemPromptId for the selected conversation when the id does not match the title', () => {
const { getByTestId } = render(
<TestProviders>
<ConversationSettings
{...testProps}
selectedConversation={{ ...mockConvo, id: 'not-the-right-id' }}
/>
</TestProviders>
);
fireEvent.click(getByTestId('change-sp'));
expect(setConversationSettings).toHaveBeenLastCalledWith({
...mockConvos,
[mockConvo.title]: {
...mockConvo,
id: 'not-the-right-id',
apiConfig: {
...mockConvo.apiConfig,
defaultSystemPromptId: 'mock-superhero-system-prompt-1',
},
},
});
});
it('Selecting an existing conversation updates the selected convo and does not update convo settings', () => {
const { getByTestId } = render(
<TestProviders>
@ -135,8 +169,24 @@ describe('ConversationSettings', () => {
);
fireEvent.click(getByTestId('change-convo'));
expect(setUpdatedConversationSettings).toHaveReturnedWith(mockConvos);
expect(onSelectedConversationChange).toHaveBeenCalledWith(alertConvo);
expect(setConversationSettings).toHaveBeenCalled();
expect(onSelectedConversationChange).toHaveBeenCalledWith(mockConvo);
});
it('Selecting a new conversation updates the selected convo and does not update convo settings', () => {
const { getByTestId } = render(
<TestProviders>
<ConversationSettings {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('change-new-convo'));
expect(onSelectedConversationChange).toHaveBeenCalledWith({ ...mockConvo, id: '' });
expect(setConversationsSettingsBulkActions).toHaveBeenCalledWith({
create: {
[mockConvo.title]: { ...mockConvo, id: '' },
},
});
});
it('Selecting an existing conversation updates the selected convo and is added to the convo settings', () => {
const { getByTestId } = render(
@ -146,17 +196,15 @@ describe('ConversationSettings', () => {
);
fireEvent.click(getByTestId('change-convo-custom'));
const newConvo = {
apiConfig: {
connectorId: '123',
defaultSystemPromptId: 'default-system-prompt',
provider: 'OpenAI',
},
id: 'Cool new conversation',
category: 'assistant',
id: '',
title: 'Cool new conversation',
messages: [],
replacements: [],
};
expect(setUpdatedConversationSettings).toHaveReturnedWith({
expect(setConversationSettings).toHaveBeenLastCalledWith({
...mockConvos,
[newConvo.id]: newConvo,
[newConvo.title]: newConvo,
});
expect(onSelectedConversationChange).toHaveBeenCalledWith(newConvo);
});
@ -167,8 +215,8 @@ describe('ConversationSettings', () => {
</TestProviders>
);
fireEvent.click(getByTestId('delete-convo'));
const { [customConvo.id]: _, ...rest } = mockConvos;
expect(setUpdatedConversationSettings).toHaveReturnedWith(rest);
const { [customConvo.title]: _, ...rest } = mockConvos;
expect(setConversationSettings).toHaveBeenLastCalledWith(rest);
});
it('Selecting a new connector updates the conversation', () => {
const { getByTestId } = render(
@ -177,12 +225,28 @@ describe('ConversationSettings', () => {
</TestProviders>
);
fireEvent.click(getByTestId('change-connector'));
expect(setUpdatedConversationSettings).toHaveReturnedWith({
expect(setConversationSettings).toHaveBeenLastCalledWith({
...mockConvos,
[welcomeConvo.id]: {
...welcomeConvo,
[welcomeConvo.title]: {
...mockConvos[welcomeConvo.title],
apiConfig: {
connectorId: mockConnector.id,
connectorTypeTitle: 'OpenAI',
model: undefined,
provider: undefined,
},
},
});
expect(setConversationsSettingsBulkActions).toHaveBeenLastCalledWith({
update: {
[welcomeConvo.title]: {
...mockConvos[welcomeConvo.title],
apiConfig: {
connectorId: mockConnector.id,
connectorTypeTitle: 'OpenAI',
model: undefined,
provider: undefined,
},
},
},
});
@ -194,15 +258,84 @@ describe('ConversationSettings', () => {
</TestProviders>
);
fireEvent.click(getByTestId('change-model'));
expect(setUpdatedConversationSettings).toHaveReturnedWith({
expect(setConversationSettings).toHaveBeenLastCalledWith({
...mockConvos,
[welcomeConvo.id]: {
...welcomeConvo,
[welcomeConvo.title]: {
...mockConvos[welcomeConvo.title],
apiConfig: {
...welcomeConvo.apiConfig,
model: 'MODEL_GPT_4',
},
},
});
expect(setConversationsSettingsBulkActions).toHaveBeenLastCalledWith({
update: {
[welcomeConvo.title]: {
...mockConvos[welcomeConvo.title],
apiConfig: {
...welcomeConvo.apiConfig,
model: 'MODEL_GPT_4',
},
},
},
});
});
it.skip('Selecting a new connector model updates the conversation when the id does not match the title', () => {
const { getByTestId } = render(
<TestProviders>
<ConversationSettings {...testProps} selectedConversation={{ ...mockConvo, id: '' }} />
</TestProviders>
);
fireEvent.click(getByTestId('change-model'));
expect(setConversationSettings).toHaveBeenLastCalledWith({
...mockConvos,
'not-the-right-id': {
...mockConvo,
id: '',
apiConfig: {
...mockConvo.apiConfig,
model: 'MODEL_GPT_4',
},
},
});
});
it('Selecting a connector with a new id updates the conversation settings', () => {
const { getByTestId } = render(
<TestProviders>
<ConversationSettings {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('bad-id-convo'));
expect(setConversationSettings).toHaveBeenCalled();
expect(onSelectedConversationChange).toHaveBeenCalledWith({
...mockConvo,
id: 'not-the-right-id',
});
expect(setConversationsSettingsBulkActions).not.toHaveBeenCalled();
});
it('Selecting a new connector updates the conversation when the id does not match the title', () => {
const { getByTestId } = render(
<TestProviders>
<ConversationSettings
{...testProps}
selectedConversation={{ ...mockConvo, id: 'not-the-right-id' }}
/>
</TestProviders>
);
fireEvent.click(getByTestId('change-connector'));
expect(setConversationSettings).toHaveBeenLastCalledWith({
...mockConvos,
[mockConvo.title]: {
...mockConvo,
id: 'not-the-right-id',
apiConfig: {
connectorId: mockConnector.id,
connectorTypeTitle: 'OpenAI',
model: undefined,
provider: undefined,
},
},
});
});
});

View file

@ -12,29 +12,32 @@ import { HttpSetup } from '@kbn/core-http-browser';
import { FormattedMessage } from '@kbn/i18n-react';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common';
import { noop } from 'lodash/fp';
import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public';
import { Conversation, Prompt } from '../../../..';
import * as i18n from './translations';
import * as i18nModel from '../../../connectorland/models/model_selector/translations';
import { ConnectorSelector } from '../../../connectorland/connector_selector';
import { AIConnector, ConnectorSelector } from '../../../connectorland/connector_selector';
import { SelectSystemPrompt } from '../../prompt_editor/system_prompt/select_system_prompt';
import { ModelSelector } from '../../../connectorland/models/model_selector/model_selector';
import { UseAssistantContext } from '../../../assistant_context';
import { ConversationSelectorSettings } from '../conversation_selector_settings';
import { getDefaultSystemPrompt } from '../../use_conversation/helpers';
import { useLoadConnectors } from '../../../connectorland/use_load_connectors';
import { getGenAiConfig } from '../../../connectorland/helpers';
import { ConversationsBulkActions } from '../../api';
export interface ConversationSettingsProps {
actionTypeRegistry: ActionTypeRegistryContract;
allSystemPrompts: Prompt[];
conversationSettings: UseAssistantContext['conversations'];
defaultConnectorId?: string;
defaultProvider?: OpenAiProviderType;
conversationSettings: Record<string, Conversation>;
conversationsSettingsBulkActions: ConversationsBulkActions;
defaultConnector?: AIConnector;
http: HttpSetup;
onSelectedConversationChange: (conversation?: Conversation) => void;
selectedConversation: Conversation | undefined;
setUpdatedConversationSettings: React.Dispatch<
React.SetStateAction<UseAssistantContext['conversations']>
selectedConversation?: Conversation;
setConversationSettings: React.Dispatch<React.SetStateAction<Record<string, Conversation>>>;
setConversationsSettingsBulkActions: React.Dispatch<
React.SetStateAction<ConversationsBulkActions>
>;
isDisabled?: boolean;
}
@ -44,15 +47,17 @@ export interface ConversationSettingsProps {
*/
export const ConversationSettings: React.FC<ConversationSettingsProps> = React.memo(
({
actionTypeRegistry,
allSystemPrompts,
defaultConnectorId,
defaultProvider,
defaultConnector,
selectedConversation,
onSelectedConversationChange,
conversationSettings,
http,
setUpdatedConversationSettings,
isDisabled = false,
setConversationSettings,
conversationsSettingsBulkActions,
setConversationsSettingsBulkActions,
}) => {
const defaultSystemPrompt = useMemo(() => {
return getDefaultSystemPrompt({ allSystemPrompts, conversation: undefined });
@ -62,30 +67,54 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
return getDefaultSystemPrompt({ allSystemPrompts, conversation: selectedConversation });
}, [allSystemPrompts, selectedConversation]);
const { data: connectors, isSuccess: areConnectorsFetched } = useLoadConnectors({ http });
const { data: connectors, isSuccess: areConnectorsFetched } = useLoadConnectors({
actionTypeRegistry,
http,
});
// Conversation callbacks
// When top level conversation selection changes
const onConversationSelectionChange = useCallback(
(c?: Conversation | string) => {
const isNew = typeof c === 'string';
const newSelectedConversation: Conversation | undefined = isNew
? {
id: c ?? '',
id: '',
title: c ?? '',
category: 'assistant',
messages: [],
apiConfig: {
connectorId: defaultConnectorId,
provider: defaultProvider,
defaultSystemPromptId: defaultSystemPrompt?.id,
},
replacements: [],
...(defaultConnector
? {
apiConfig: {
connectorId: defaultConnector.id,
connectorTypeTitle: defaultConnector.connectorTypeTitle,
provider: defaultConnector.apiProvider,
defaultSystemPromptId: defaultSystemPrompt?.id,
},
}
: {}),
}
: c;
if (newSelectedConversation != null) {
setUpdatedConversationSettings((prev) => {
if (newSelectedConversation && (isNew || newSelectedConversation.id === '')) {
setConversationSettings({
...conversationSettings,
[isNew ? c : newSelectedConversation.title]: newSelectedConversation,
});
setConversationsSettingsBulkActions({
...conversationsSettingsBulkActions,
create: {
...(conversationsSettingsBulkActions.create ?? {}),
[newSelectedConversation.title]: newSelectedConversation,
},
});
} else if (newSelectedConversation != null) {
setConversationSettings((prev) => {
return {
...prev,
[newSelectedConversation.id]: newSelectedConversation,
[newSelectedConversation.title]: newSelectedConversation,
};
});
}
@ -93,102 +122,227 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
onSelectedConversationChange(newSelectedConversation);
},
[
defaultConnectorId,
defaultProvider,
conversationSettings,
conversationsSettingsBulkActions,
defaultConnector,
defaultSystemPrompt?.id,
onSelectedConversationChange,
setUpdatedConversationSettings,
setConversationSettings,
setConversationsSettingsBulkActions,
]
);
const onConversationDeleted = useCallback(
(conversationId: string) => {
setUpdatedConversationSettings((prev) => {
const { [conversationId]: prevConversation, ...updatedConversations } = prev;
if (prevConversation != null) {
return updatedConversations;
}
return prev;
(conversationTitle: string) => {
const conversationId = conversationSettings[conversationTitle].id;
const updatedConversationSettings = { ...conversationSettings };
delete updatedConversationSettings[conversationTitle];
setConversationSettings(updatedConversationSettings);
setConversationsSettingsBulkActions({
...conversationsSettingsBulkActions,
delete: {
ids: [...(conversationsSettingsBulkActions.delete?.ids ?? []), conversationId],
},
});
},
[setUpdatedConversationSettings]
[
conversationSettings,
conversationsSettingsBulkActions,
setConversationSettings,
setConversationsSettingsBulkActions,
]
);
const handleOnSystemPromptSelectionChange = useCallback(
(systemPromptId?: string | undefined) => {
if (selectedConversation != null) {
setUpdatedConversationSettings((prev) => ({
...prev,
[selectedConversation.id]: {
...selectedConversation,
apiConfig: {
...selectedConversation.apiConfig,
defaultSystemPromptId: systemPromptId,
},
if (selectedConversation != null && selectedConversation.apiConfig) {
const updatedConversation = {
...selectedConversation,
apiConfig: {
...selectedConversation.apiConfig,
defaultSystemPromptId: systemPromptId,
},
}));
};
setConversationSettings({
...conversationSettings,
[updatedConversation.title]: updatedConversation,
});
if (selectedConversation.id !== '') {
setConversationsSettingsBulkActions({
...conversationsSettingsBulkActions,
update: {
...(conversationsSettingsBulkActions.update ?? {}),
[updatedConversation.title]: {
...updatedConversation,
...(conversationsSettingsBulkActions.update
? conversationsSettingsBulkActions.update[updatedConversation.title] ?? {}
: {}),
apiConfig: {
...updatedConversation.apiConfig,
...((conversationsSettingsBulkActions.update
? conversationsSettingsBulkActions.update[updatedConversation.title] ?? {}
: {}
).apiConfig ?? {}),
defaultSystemPromptId: systemPromptId,
},
},
},
});
} else {
setConversationsSettingsBulkActions({
...conversationsSettingsBulkActions,
create: {
...(conversationsSettingsBulkActions.create ?? {}),
[updatedConversation.title]: updatedConversation,
},
});
}
}
},
[selectedConversation, setUpdatedConversationSettings]
[
conversationSettings,
conversationsSettingsBulkActions,
selectedConversation,
setConversationSettings,
setConversationsSettingsBulkActions,
]
);
const selectedConnector = useMemo(() => {
const selectedConnectorId = selectedConversation?.apiConfig.connectorId;
const selectedConnectorId = selectedConversation?.apiConfig?.connectorId;
if (areConnectorsFetched) {
return connectors?.find((c) => c.id === selectedConnectorId);
}
return undefined;
}, [areConnectorsFetched, connectors, selectedConversation?.apiConfig.connectorId]);
}, [areConnectorsFetched, connectors, selectedConversation?.apiConfig?.connectorId]);
const selectedProvider = useMemo(
() => selectedConversation?.apiConfig.provider,
[selectedConversation?.apiConfig.provider]
() => selectedConversation?.apiConfig?.provider,
[selectedConversation?.apiConfig?.provider]
);
const handleOnConnectorSelectionChange = useCallback(
(connector) => {
if (selectedConversation != null) {
const config = getGenAiConfig(connector);
setUpdatedConversationSettings((prev) => ({
...prev,
[selectedConversation.id]: {
...selectedConversation,
apiConfig: {
...selectedConversation.apiConfig,
connectorId: connector?.id,
provider: config?.apiProvider,
model: config?.defaultModel,
},
const updatedConversation = {
...selectedConversation,
apiConfig: {
...selectedConversation.apiConfig,
connectorId: connector.id,
connectorTypeTitle: connector.connectorTypeTitle,
provider: config?.apiProvider,
model: config?.defaultModel,
},
}));
};
setConversationSettings({
...conversationSettings,
[selectedConversation.title]: updatedConversation,
});
if (selectedConversation.id !== '') {
setConversationsSettingsBulkActions({
...conversationsSettingsBulkActions,
update: {
...(conversationsSettingsBulkActions.update ?? {}),
[updatedConversation.title]: {
...updatedConversation,
...(conversationsSettingsBulkActions.update
? conversationsSettingsBulkActions.update[updatedConversation.title] ?? {}
: {}),
apiConfig: {
...updatedConversation.apiConfig,
...((conversationsSettingsBulkActions.update
? conversationsSettingsBulkActions.update[updatedConversation.title] ?? {}
: {}
).apiConfig ?? {}),
connectorId: connector?.id,
connectorTypeTitle: connector?.connectorTypeTitle,
provider: config?.apiProvider,
model: config?.defaultModel,
},
},
},
});
} else {
setConversationsSettingsBulkActions({
...conversationsSettingsBulkActions,
create: {
...(conversationsSettingsBulkActions.create ?? {}),
[updatedConversation.title]: updatedConversation,
},
});
}
}
},
[selectedConversation, setUpdatedConversationSettings]
[
conversationSettings,
conversationsSettingsBulkActions,
selectedConversation,
setConversationSettings,
setConversationsSettingsBulkActions,
]
);
const selectedModel = useMemo(() => {
const connectorModel = getGenAiConfig(selectedConnector)?.defaultModel;
// Prefer conversation configuration over connector default
return selectedConversation?.apiConfig.model ?? connectorModel;
}, [selectedConnector, selectedConversation?.apiConfig.model]);
return selectedConversation?.apiConfig?.model ?? connectorModel;
}, [selectedConnector, selectedConversation?.apiConfig?.model]);
const handleOnModelSelectionChange = useCallback(
(model?: string) => {
if (selectedConversation != null) {
setUpdatedConversationSettings((prev) => ({
...prev,
[selectedConversation.id]: {
...selectedConversation,
apiConfig: {
...selectedConversation.apiConfig,
model,
},
if (selectedConversation != null && selectedConversation.apiConfig) {
const updatedConversation = {
...selectedConversation,
apiConfig: {
...selectedConversation.apiConfig,
model,
},
}));
};
setConversationSettings({
...conversationSettings,
[updatedConversation.title]: updatedConversation,
});
if (selectedConversation.id !== '') {
setConversationsSettingsBulkActions({
...conversationsSettingsBulkActions,
update: {
...(conversationsSettingsBulkActions.update ?? {}),
[updatedConversation.title]: {
...updatedConversation,
...(conversationsSettingsBulkActions.update
? conversationsSettingsBulkActions.update[updatedConversation.title] ?? {}
: {}),
apiConfig: {
...updatedConversation.apiConfig,
...((conversationsSettingsBulkActions.update
? conversationsSettingsBulkActions.update[updatedConversation.title] ?? {}
: {}
).apiConfig ?? {}),
model,
},
},
},
});
} else {
setConversationsSettingsBulkActions({
...conversationsSettingsBulkActions,
create: {
...(conversationsSettingsBulkActions.create ?? {}),
[updatedConversation.title]: updatedConversation,
},
});
}
}
},
[selectedConversation, setUpdatedConversationSettings]
[
conversationSettings,
conversationsSettingsBulkActions,
selectedConversation,
setConversationSettings,
setConversationsSettingsBulkActions,
]
);
return (
@ -201,7 +355,7 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
<EuiHorizontalRule margin={'s'} />
<ConversationSelectorSettings
selectedConversationId={selectedConversation?.id}
selectedConversationTitle={selectedConversation?.title ?? ''}
conversations={conversationSettings}
onConversationDeleted={onConversationDeleted}
onConversationSelectionChange={onConversationSelectionChange}

View file

@ -8,22 +8,24 @@
import {
getBlockBotConversation,
getDefaultConnector,
getFormattedMessageContent,
getOptionalRequestParams,
hasParsableResponse,
mergeBaseWithPersistedConversations,
} from './helpers';
import { enterpriseMessaging } from './use_conversation/sample_conversations';
import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public';
import { AIConnector } from '../connectorland/connector_selector';
describe('getBlockBotConversation', () => {
describe('helpers', () => {
describe('isAssistantEnabled = false', () => {
const isAssistantEnabled = false;
it('When no conversation history, return only enterprise messaging', () => {
const conversation = {
id: 'conversation_id',
category: 'assistant',
theme: {},
messages: [],
apiConfig: {},
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
title: 'conversation_id',
};
const result = getBlockBotConversation(conversation, isAssistantEnabled);
expect(result.messages).toEqual(enterpriseMessaging);
@ -33,7 +35,6 @@ describe('getBlockBotConversation', () => {
it('When conversation history and the last message is not enterprise messaging, appends enterprise messaging to conversation', () => {
const conversation = {
id: 'conversation_id',
theme: {},
messages: [
{
role: 'user' as const,
@ -45,7 +46,10 @@ describe('getBlockBotConversation', () => {
},
},
],
apiConfig: {},
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
category: 'assistant',
title: 'conversation_id',
};
const result = getBlockBotConversation(conversation, isAssistantEnabled);
expect(result.messages.length).toEqual(2);
@ -54,9 +58,11 @@ describe('getBlockBotConversation', () => {
it('returns the conversation without changes when the last message is enterprise messaging', () => {
const conversation = {
id: 'conversation_id',
theme: {},
title: 'conversation_id',
messages: enterpriseMessaging,
apiConfig: {},
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
category: 'assistant',
};
const result = getBlockBotConversation(conversation, isAssistantEnabled);
expect(result.messages.length).toEqual(1);
@ -66,7 +72,8 @@ describe('getBlockBotConversation', () => {
it('returns the conversation with new enterprise message when conversation has enterprise messaging, but not as the last message', () => {
const conversation = {
id: 'conversation_id',
theme: {},
title: 'conversation_id',
category: 'assistant',
messages: [
...enterpriseMessaging,
{
@ -79,7 +86,8 @@ describe('getBlockBotConversation', () => {
},
},
],
apiConfig: {},
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
};
const result = getBlockBotConversation(conversation, isAssistantEnabled);
expect(result.messages.length).toEqual(3);
@ -91,9 +99,11 @@ describe('getBlockBotConversation', () => {
it('when no conversation history, returns the welcome conversation', () => {
const conversation = {
id: 'conversation_id',
theme: {},
title: 'conversation_id',
category: 'assistant',
messages: [],
apiConfig: {},
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
};
const result = getBlockBotConversation(conversation, isAssistantEnabled);
expect(result.messages.length).toEqual(3);
@ -101,7 +111,8 @@ describe('getBlockBotConversation', () => {
it('returns a conversation history with the welcome conversation appended', () => {
const conversation = {
id: 'conversation_id',
theme: {},
title: 'conversation_id',
category: 'assistant',
messages: [
{
role: 'user' as const,
@ -113,7 +124,8 @@ describe('getBlockBotConversation', () => {
},
},
],
apiConfig: {},
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
};
const result = getBlockBotConversation(conversation, isAssistantEnabled);
expect(result.messages.length).toEqual(4);
@ -129,17 +141,17 @@ describe('getBlockBotConversation', () => {
});
it('should return undefined if connectors array is empty', () => {
const connectors: Array<ActionConnector<Record<string, unknown>, Record<string, unknown>>> =
[];
const connectors: AIConnector[] = [];
const result = getDefaultConnector(connectors);
expect(result).toBeUndefined();
});
it('should return the connector id if there is only one connector', () => {
const connectors: Array<ActionConnector<Record<string, unknown>, Record<string, unknown>>> = [
const connectors: AIConnector[] = [
{
actionTypeId: '.gen-ai',
connectorTypeTitle: 'OpenAI',
isPreconfigured: false,
isDeprecated: false,
referencedByCount: 0,
@ -160,9 +172,10 @@ describe('getBlockBotConversation', () => {
});
it('should return undefined if there are multiple connectors', () => {
const connectors: Array<ActionConnector<Record<string, unknown>, Record<string, unknown>>> = [
const connectors: AIConnector[] = [
{
actionTypeId: '.gen-ai',
connectorTypeTitle: 'OpenAI',
isPreconfigured: false,
isDeprecated: false,
referencedByCount: 0,
@ -178,6 +191,7 @@ describe('getBlockBotConversation', () => {
},
{
actionTypeId: '.gen-ai',
connectorTypeTitle: 'OpenAI',
isPreconfigured: false,
isDeprecated: false,
referencedByCount: 0,
@ -197,43 +211,6 @@ describe('getBlockBotConversation', () => {
});
});
describe('getFormattedMessageContent', () => {
it('returns the value of the action_input property when `content` has properly prefixed and suffixed JSON with the action_input property', () => {
const content = '```json\n{"action_input": "value from action_input"}\n```';
expect(getFormattedMessageContent(content)).toBe('value from action_input');
});
it('returns the original content when `content` has properly formatted JSON WITHOUT the action_input property', () => {
const content = '```json\n{"some_key": "some value"}\n```';
expect(getFormattedMessageContent(content)).toBe(content);
});
it('returns the original content when `content` has improperly formatted JSON', () => {
const content = '```json\n{"action_input": "value from action_input",}\n```'; // <-- the trailing comma makes it invalid
expect(getFormattedMessageContent(content)).toBe(content);
});
it('returns the original content when `content` is missing the prefix', () => {
const content = '{"action_input": "value from action_input"}\n```'; // <-- missing prefix
expect(getFormattedMessageContent(content)).toBe(content);
});
it('returns the original content when `content` is missing the suffix', () => {
const content = '```json\n{"action_input": "value from action_input"}'; // <-- missing suffix
expect(getFormattedMessageContent(content)).toBe(content);
});
it('returns the original content when `content` does NOT contain a JSON string', () => {
const content = 'plain text content';
expect(getFormattedMessageContent(content)).toBe(content);
});
});
describe('getOptionalRequestParams', () => {
it('should return an empty object when alerts is false', () => {
const params = {
@ -241,7 +218,6 @@ describe('getBlockBotConversation', () => {
alertsIndexPattern: 'indexPattern',
allow: ['a', 'b', 'c'],
allowReplacement: ['b', 'c'],
replacements: { key: 'value' },
size: 10,
};
@ -256,7 +232,6 @@ describe('getBlockBotConversation', () => {
alertsIndexPattern: 'indexPattern',
allow: ['a', 'b', 'c'],
allowReplacement: ['b', 'c'],
replacements: { key: 'value' },
size: 10,
};
@ -266,7 +241,6 @@ describe('getBlockBotConversation', () => {
alertsIndexPattern: 'indexPattern',
allow: ['a', 'b', 'c'],
allowReplacement: ['b', 'c'],
replacements: { key: 'value' },
size: 10,
});
});
@ -285,41 +259,161 @@ describe('getBlockBotConversation', () => {
});
});
describe('hasParsableResponse', () => {
it('returns true when just isEnabledKnowledgeBase is true', () => {
const result = hasParsableResponse({
isEnabledRAGAlerts: false,
isEnabledKnowledgeBase: true,
});
describe('mergeBaseWithPersistedConversations', () => {
const messages = [
{ content: 'Message 1', role: 'user' as const, timestamp: '2024-02-14T22:29:43.862Z' },
{ content: 'Message 2', role: 'user' as const, timestamp: '2024-02-14T22:29:43.862Z' },
];
const defaultProps = {
messages,
category: 'assistant',
theme: {},
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
};
const baseConversations = {
conversation1: {
...defaultProps,
title: 'Conversation 1',
id: 'conversation_1',
},
conversation2: {
...defaultProps,
title: 'Conversation 2',
id: 'conversation_2',
},
};
const conversationsData = {
page: 1,
perPage: 10,
total: 2,
data: Object.values(baseConversations).map((c) => c),
};
expect(result).toBe(true);
it('should merge base conversations with user conversations when both are non-empty', () => {
const moreData = {
...conversationsData,
data: [
{
...defaultProps,
title: 'Conversation 3',
id: 'conversation_3',
},
{
...defaultProps,
title: 'Conversation 4',
id: 'conversation_4',
},
],
};
const result = mergeBaseWithPersistedConversations(baseConversations, moreData);
expect(result).toEqual({
conversation1: {
title: 'Conversation 1',
id: 'conversation_1',
...defaultProps,
},
conversation2: {
title: 'Conversation 2',
id: 'conversation_2',
...defaultProps,
},
'Conversation 3': {
title: 'Conversation 3',
id: 'conversation_3',
...defaultProps,
},
'Conversation 4': {
title: 'Conversation 4',
id: 'conversation_4',
...defaultProps,
},
});
});
it('returns true when just isEnabledRAGAlerts is true', () => {
const result = hasParsableResponse({
isEnabledRAGAlerts: true,
isEnabledKnowledgeBase: false,
it('should return base conversations when user conversations are empty', () => {
const result = mergeBaseWithPersistedConversations(baseConversations, {
...conversationsData,
total: 0,
data: [],
});
expect(result).toBe(true);
expect(result).toEqual(baseConversations);
});
it('returns true when both isEnabledKnowledgeBase and isEnabledRAGAlerts are true', () => {
const result = hasParsableResponse({
isEnabledRAGAlerts: true,
isEnabledKnowledgeBase: true,
});
it('should return user conversations when base conversations are empty', () => {
const result = mergeBaseWithPersistedConversations({}, conversationsData);
expect(result).toBe(true);
expect(result).toEqual({
'Conversation 1': {
...defaultProps,
title: 'Conversation 1',
id: 'conversation_1',
},
'Conversation 2': {
...defaultProps,
title: 'Conversation 2',
id: 'conversation_2',
},
});
});
it('returns false when both isEnabledKnowledgeBase and isEnabledRAGAlerts are false', () => {
const result = hasParsableResponse({
isEnabledRAGAlerts: false,
isEnabledKnowledgeBase: false,
});
it('should handle and merge conversations with duplicate titles', () => {
const result = mergeBaseWithPersistedConversations(
{
'Conversation 1': {
title: 'Conversation 1',
id: 'conversation1',
...defaultProps,
},
},
{
page: 1,
perPage: 10,
total: 1,
data: [
{
title: 'Conversation 1',
id: 'conversation1',
...defaultProps,
messages: [
{
content: 'Message 3',
role: 'user' as const,
timestamp: '2024-02-14T22:29:43.862Z',
},
{
content: 'Message 4',
role: 'user' as const,
timestamp: '2024-02-14T22:29:43.862Z',
},
],
},
],
}
);
expect(result).toBe(false);
expect(result).toEqual({
'Conversation 1': {
title: 'Conversation 1',
id: 'conversation1',
...defaultProps,
messages: [
{
content: 'Message 3',
role: 'user',
timestamp: '2024-02-14T22:29:43.862Z',
},
{
content: 'Message 4',
role: 'user',
timestamp: '2024-02-14T22:29:43.862Z',
},
],
},
});
});
});
});

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public';
import { FetchConnectorExecuteResponse } from './api';
import { merge } from 'lodash/fp';
import { AIConnector } from '../connectorland/connector_selector';
import { FetchConnectorExecuteResponse, FetchConversationsResponse } from './api';
import { Conversation } from '../..';
import type { Message } from '../assistant_context/types';
import { enterpriseMessaging, WELCOME_CONVERSATION } from './use_conversation/sample_conversations';
@ -34,6 +35,20 @@ export const getMessageFromRawResponse = (rawResponse: FetchConnectorExecuteResp
}
};
export const mergeBaseWithPersistedConversations = (
baseConversations: Record<string, Conversation>,
conversationsData: FetchConversationsResponse
): Record<string, Conversation> => {
const userConversations = (conversationsData?.data ?? []).reduce<Record<string, Conversation>>(
(transformed, conversation) => {
transformed[conversation.title] = conversation;
return transformed;
},
{}
);
return merge(baseConversations, userConversations);
};
export const getBlockBotConversation = (
conversation: Conversation,
isAssistantEnabled: boolean
@ -63,36 +78,13 @@ export const getBlockBotConversation = (
* @param connectors
*/
export const getDefaultConnector = (
connectors: Array<ActionConnector<Record<string, unknown>, Record<string, unknown>>> | undefined
): ActionConnector<Record<string, unknown>, Record<string, unknown>> | undefined =>
connectors?.length === 1 ? connectors[0] : undefined;
/**
* When `content` is a JSON string, prefixed with "```json\n"
* and suffixed with "\n```", this function will attempt to parse it and return
* the `action_input` property if it exists.
*/
export const getFormattedMessageContent = (content: string): string => {
const formattedContentMatch = content.match(/```json\n([\s\S]+)\n```/);
if (formattedContentMatch) {
try {
const parsedContent = JSON.parse(formattedContentMatch[1]);
return parsedContent.action_input ?? content;
} catch {
// we don't want to throw an error here, so we'll fall back to the original content
}
}
return content;
};
connectors: AIConnector[] | undefined
): AIConnector | undefined => (connectors?.length === 1 ? connectors[0] : undefined);
interface OptionalRequestParams {
alertsIndexPattern?: string;
allow?: string[];
allowReplacement?: string[];
replacements?: Record<string, string>;
size?: number;
}
@ -101,20 +93,17 @@ export const getOptionalRequestParams = ({
alertsIndexPattern,
allow,
allowReplacement,
replacements,
size,
}: {
isEnabledRAGAlerts: boolean;
alertsIndexPattern?: string;
allow?: string[];
allowReplacement?: string[];
replacements?: Record<string, string>;
size?: number;
}): OptionalRequestParams => {
const optionalAlertsIndexPattern = alertsIndexPattern ? { alertsIndexPattern } : undefined;
const optionalAllow = allow ? { allow } : undefined;
const optionalAllowReplacement = allowReplacement ? { allowReplacement } : undefined;
const optionalReplacements = replacements ? { replacements } : undefined;
const optionalSize = size ? { size } : undefined;
// the settings toggle must be enabled:
@ -126,15 +115,12 @@ export const getOptionalRequestParams = ({
...optionalAlertsIndexPattern,
...optionalAllow,
...optionalAllowReplacement,
...optionalReplacements,
...optionalSize,
};
};
export const hasParsableResponse = ({
isEnabledRAGAlerts,
isEnabledKnowledgeBase,
}: {
isEnabledRAGAlerts: boolean;
isEnabledKnowledgeBase: boolean;
}): boolean => isEnabledKnowledgeBase || isEnabledRAGAlerts;
export const llmTypeDictionary: Record<string, string> = {
'Amazon Bedrock': 'bedrock',
'Azure OpenAI': 'openai',
OpenAI: 'openai',
};

View file

@ -7,11 +7,9 @@
import React from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { Assistant } from '.';
import { Conversation } from '../assistant_context/types';
import type { IHttpFetchError } from '@kbn/core/public';
import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public';
import { useLoadConnectors } from '../connectorland/use_load_connectors';
import { useConnectorSetup } from '../connectorland/connector_setup';
@ -23,6 +21,11 @@ import { useLocalStorage } from 'react-use';
import { PromptEditor } from './prompt_editor';
import { QuickPrompts } from './quick_prompts/quick_prompts';
import { mockAssistantAvailability, TestProviders } from '../mock/test_providers/test_providers';
import { useFetchCurrentUserConversations } from './api';
import { Conversation } from '../assistant_context/types';
import * as all from './chat_send/use_chat_send';
import { useConversation } from './use_conversation';
import { AIConnector } from '../connectorland/connector_selector';
jest.mock('../connectorland/use_load_connectors');
jest.mock('../connectorland/connector_setup');
@ -30,31 +33,45 @@ jest.mock('react-use');
jest.mock('./prompt_editor', () => ({ PromptEditor: jest.fn() }));
jest.mock('./quick_prompts/quick_prompts', () => ({ QuickPrompts: jest.fn() }));
jest.mock('./api/conversations/use_fetch_current_user_conversations');
const MOCK_CONVERSATION_TITLE = 'electric sheep';
const getInitialConversations = (): Record<string, Conversation> => ({
[WELCOME_CONVERSATION_TITLE]: {
id: WELCOME_CONVERSATION_TITLE,
messages: [],
apiConfig: {},
},
[MOCK_CONVERSATION_TITLE]: {
id: MOCK_CONVERSATION_TITLE,
messages: [],
apiConfig: {},
},
});
jest.mock('./use_conversation');
const renderAssistant = (extraProps = {}, providerProps = {}) =>
render(
<TestProviders getInitialConversations={getInitialConversations} {...providerProps}>
<TestProviders>
<Assistant {...extraProps} />
</TestProviders>
);
const mockData = {
Welcome: {
id: 'Welcome Id',
title: 'Welcome',
category: 'assistant',
messages: [],
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
},
'electric sheep': {
id: 'electric sheep id',
category: 'assistant',
title: 'electric sheep',
messages: [],
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
},
};
const mockDeleteConvo = jest.fn();
const mockUseConversation = {
getConversation: jest.fn(),
getDefaultConversation: jest.fn().mockReturnValue(mockData.Welcome),
deleteConversation: mockDeleteConvo,
};
describe('Assistant', () => {
beforeAll(() => {
(useConversation as jest.Mock).mockReturnValue(mockUseConversation);
jest.mocked(useConnectorSetup).mockReturnValue({
comments: [],
prompt: <></>,
@ -62,6 +79,32 @@ describe('Assistant', () => {
jest.mocked(PromptEditor).mockReturnValue(null);
jest.mocked(QuickPrompts).mockReturnValue(null);
const connectors: unknown[] = [
{
id: 'hi',
name: 'OpenAI connector',
actionTypeId: '.gen-ai',
},
];
jest.mocked(useLoadConnectors).mockReturnValue({
isSuccess: true,
data: connectors,
} as unknown as UseQueryResult<AIConnector[], IHttpFetchError>);
jest.mocked(useFetchCurrentUserConversations).mockReturnValue({
data: mockData,
isLoading: false,
refetch: jest.fn().mockResolvedValue({
isLoading: false,
data: {
...mockData,
Welcome: {
...mockData.Welcome,
apiConfig: { newProp: true },
},
},
}),
} as unknown as UseQueryResult<Record<string, Conversation>, unknown>);
});
let persistToLocalStorage: jest.Mock;
@ -77,15 +120,85 @@ describe('Assistant', () => {
>);
});
describe('persistent storage', () => {
it('should refetchConversationsState after settings save button click', async () => {
const chatSendSpy = jest.spyOn(all, 'useChatSend');
const setConversationTitle = jest.fn();
renderAssistant({ setConversationTitle });
fireEvent.click(screen.getByTestId('settings'));
await act(async () => {
fireEvent.click(screen.getByTestId('save-button'));
});
expect(chatSendSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
currentConversation: {
apiConfig: { newProp: true },
category: 'assistant',
id: 'Welcome Id',
messages: [],
title: 'Welcome',
replacements: [],
},
})
);
});
it('should refetchConversationsState after settings save button click, but do not update convos when refetch returns bad results', async () => {
const { Welcome, ...rest } = mockData;
jest.mocked(useFetchCurrentUserConversations).mockReturnValue({
data: mockData,
isLoading: false,
refetch: jest.fn().mockResolvedValue({
isLoading: false,
data: rest,
}),
} as unknown as UseQueryResult<Record<string, Conversation>, unknown>);
const chatSendSpy = jest.spyOn(all, 'useChatSend');
const setConversationTitle = jest.fn();
renderAssistant({ setConversationTitle });
fireEvent.click(screen.getByTestId('settings'));
await act(async () => {
fireEvent.click(screen.getByTestId('save-button'));
});
expect(chatSendSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
currentConversation: {
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
category: 'assistant',
id: 'Welcome Id',
messages: [],
title: 'Welcome',
},
})
);
});
it('should delete conversation when delete button is clicked', async () => {
renderAssistant();
await act(async () => {
fireEvent.click(
within(screen.getByTestId('conversation-selector')).getByTestId(
'comboBoxToggleListButton'
)
);
});
const deleteButton = screen.getAllByTestId('delete-option')[0];
await act(async () => {
fireEvent.click(deleteButton);
});
expect(mockDeleteConvo).toHaveBeenCalledWith('Welcome Id');
});
});
describe('when selected conversation changes and some connectors are loaded', () => {
it('should persist the conversation id to local storage', async () => {
const connectors: unknown[] = [{}];
jest.mocked(useLoadConnectors).mockReturnValue({
isSuccess: true,
data: connectors,
} as unknown as UseQueryResult<ActionConnector[], IHttpFetchError>);
it('should persist the conversation title to local storage', async () => {
renderAssistant();
expect(persistToLocalStorage).toHaveBeenCalled();
@ -103,31 +216,19 @@ describe('Assistant', () => {
});
it('should not persist the conversation id to local storage when excludeFromLastConversationStorage flag is indicated', async () => {
const connectors: unknown[] = [{}];
jest.mocked(useFetchCurrentUserConversations).mockReturnValue({
data: {
...mockData,
'electric sheep': {
...mockData['electric sheep'],
excludeFromLastConversationStorage: true,
},
},
isLoading: false,
refetch: jest.fn(),
} as unknown as UseQueryResult<Record<string, Conversation>, unknown>);
jest.mocked(useLoadConnectors).mockReturnValue({
isSuccess: true,
data: connectors,
} as unknown as UseQueryResult<ActionConnector[], IHttpFetchError>);
const { getByLabelText } = renderAssistant(
{},
{
getInitialConversations: () => ({
[WELCOME_CONVERSATION_TITLE]: {
id: WELCOME_CONVERSATION_TITLE,
messages: [],
apiConfig: {},
},
[MOCK_CONVERSATION_TITLE]: {
id: MOCK_CONVERSATION_TITLE,
messages: [],
apiConfig: {},
excludeFromLastConversationStorage: true,
},
}),
}
);
const { getByLabelText } = renderAssistant();
expect(persistToLocalStorage).toHaveBeenCalled();
@ -142,33 +243,81 @@ describe('Assistant', () => {
});
expect(persistToLocalStorage).toHaveBeenLastCalledWith(WELCOME_CONVERSATION_TITLE);
});
it('should call the setConversationId callback if it is defined and the conversation id changes', async () => {
const connectors: unknown[] = [{}];
const setConversationId = jest.fn();
jest.mocked(useLoadConnectors).mockReturnValue({
isSuccess: true,
data: connectors,
} as unknown as UseQueryResult<ActionConnector[], IHttpFetchError>);
it('should call the setConversationTitle callback if it is defined and the conversation id changes', async () => {
const setConversationTitle = jest.fn();
renderAssistant({ setConversationId });
renderAssistant({ setConversationTitle });
await act(async () => {
fireEvent.click(screen.getByLabelText('Previous conversation'));
});
expect(setConversationId).toHaveBeenLastCalledWith('electric sheep');
expect(setConversationTitle).toHaveBeenLastCalledWith('electric sheep');
});
it('should fetch current conversation when id has value', async () => {
const chatSendSpy = jest.spyOn(all, 'useChatSend');
(useConversation as jest.Mock).mockReturnValue({
...mockUseConversation,
getConversation: jest
.fn()
.mockResolvedValue({ ...mockData['electric sheep'], title: 'updated title' }),
});
renderAssistant();
const previousConversationButton = screen.getByLabelText('Previous conversation');
await act(async () => {
fireEvent.click(previousConversationButton);
});
expect(chatSendSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
currentConversation: {
...mockData['electric sheep'],
title: 'updated title',
},
})
);
expect(persistToLocalStorage).toHaveBeenLastCalledWith('updated title');
});
it('should refetch all conversations when id is empty', async () => {
const chatSendSpy = jest.spyOn(all, 'useChatSend');
jest.mocked(useFetchCurrentUserConversations).mockReturnValue({
data: {
...mockData,
'electric sheep': { ...mockData['electric sheep'], id: '' },
},
isLoading: false,
refetch: jest.fn().mockResolvedValue({
isLoading: false,
data: {
...mockData,
'electric sheep': {
...mockData['electric sheep'],
apiConfig: { newProp: true },
},
},
}),
} as unknown as UseQueryResult<Record<string, Conversation>, unknown>);
renderAssistant();
const previousConversationButton = screen.getByLabelText('Previous conversation');
await act(async () => {
fireEvent.click(previousConversationButton);
});
expect(chatSendSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
currentConversation: {
...mockData['electric sheep'],
apiConfig: { newProp: true },
},
})
);
});
});
describe('when no connectors are loaded', () => {
it('should set welcome conversation id in local storage', async () => {
const emptyConnectors: unknown[] = [];
jest.mocked(useLoadConnectors).mockReturnValue({
isSuccess: true,
data: emptyConnectors,
} as unknown as UseQueryResult<ActionConnector[], IHttpFetchError>);
renderAssistant();
expect(persistToLocalStorage).toHaveBeenCalled();

View file

@ -29,14 +29,16 @@ import {
import { createPortal } from 'react-dom';
import { css } from '@emotion/react';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants';
import { ActionConnectorProps } from '@kbn/triggers-actions-ui-plugin/public/types';
import { useChatSend } from './chat_send/use_chat_send';
import { ChatSend } from './chat_send';
import { BlockBotCallToAction } from './block_bot/cta';
import { AssistantHeader } from './assistant_header';
import { WELCOME_CONVERSATION_TITLE } from './use_conversation/translations';
import { getDefaultConnector, getBlockBotConversation } from './helpers';
import {
getDefaultConnector,
getBlockBotConversation,
mergeBaseWithPersistedConversations,
} from './helpers';
import { useAssistantContext } from '../assistant_context';
import { ContextPills } from './context_pills';
@ -49,14 +51,20 @@ import { QuickPrompts } from './quick_prompts/quick_prompts';
import { useLoadConnectors } from '../connectorland/use_load_connectors';
import { useConnectorSetup } from '../connectorland/connector_setup';
import { ConnectorMissingCallout } from '../connectorland/connector_missing_callout';
import {
FetchConversationsResponse,
useFetchCurrentUserConversations,
} from './api/conversations/use_fetch_current_user_conversations';
import { Conversation } from '../assistant_context/types';
import { clearPresentationData } from '../connectorland/connector_setup/helpers';
export interface Props {
conversationId?: string;
conversationTitle?: string;
embeddedLayout?: boolean;
promptContextId?: string;
shouldRefocusPrompt?: boolean;
showTitle?: boolean;
setConversationId?: Dispatch<SetStateAction<string>>;
setConversationTitle?: Dispatch<SetStateAction<string>>;
}
/**
@ -64,76 +72,126 @@ export interface Props {
* quick prompts for common actions, settings, and prompt context providers.
*/
const AssistantComponent: React.FC<Props> = ({
conversationId,
conversationTitle,
embeddedLayout = false,
promptContextId = '',
shouldRefocusPrompt = false,
showTitle = true,
setConversationId,
setConversationTitle,
}) => {
const {
actionTypeRegistry,
assistantTelemetry,
augmentMessageCodeBlocks,
assistantAvailability: { isAssistantEnabled },
conversations,
defaultAllow,
defaultAllowReplacement,
docLinks,
getComments,
http,
promptContexts,
setLastConversationId,
getConversationId,
setLastConversationTitle,
getLastConversationTitle,
title,
allSystemPrompts,
baseConversations,
} = useAssistantContext();
const { getDefaultConversation, getConversation, deleteConversation } = useConversation();
const [selectedPromptContexts, setSelectedPromptContexts] = useState<
Record<string, SelectedPromptContext>
>({});
const [conversations, setConversations] = useState<Record<string, Conversation>>({});
const selectedPromptContextsCount = useMemo(
() => Object.keys(selectedPromptContexts).length,
[selectedPromptContexts]
);
const { amendMessage, createConversation } = useConversation();
const onFetchedConversations = useCallback(
(conversationsData: FetchConversationsResponse): Record<string, Conversation> =>
mergeBaseWithPersistedConversations(baseConversations, conversationsData),
[baseConversations]
);
const {
data: conversationsData,
isLoading,
isError,
refetch,
} = useFetchCurrentUserConversations({ http, onFetch: onFetchedConversations });
useEffect(() => {
if (!isLoading && !isError) {
setConversations(conversationsData ?? {});
}
}, [conversationsData, isError, isLoading]);
const refetchResults = useCallback(async () => {
const updatedConv = await refetch();
if (!updatedConv.isLoading) {
setConversations(updatedConv.data ?? {});
return updatedConv.data;
}
}, [refetch]);
// Connector details
const { data: connectors, isSuccess: areConnectorsFetched } = useLoadConnectors({ http });
const defaultConnectorId = useMemo(() => getDefaultConnector(connectors)?.id, [connectors]);
const defaultProvider = useMemo(
() =>
(
getDefaultConnector(connectors) as ActionConnectorProps<
{ apiProvider: OpenAiProviderType },
unknown
>
)?.config?.apiProvider,
[connectors]
);
const { data: connectors, isSuccess: areConnectorsFetched } = useLoadConnectors({
actionTypeRegistry,
http,
});
const defaultConnector = useMemo(() => getDefaultConnector(connectors), [connectors]);
const [selectedConversationId, setSelectedConversationId] = useState<string>(
isAssistantEnabled ? getConversationId(conversationId) : WELCOME_CONVERSATION_TITLE
const [selectedConversationTitle, setSelectedConversationTitle] = useState<string>(
isAssistantEnabled ? getLastConversationTitle(conversationTitle) : WELCOME_CONVERSATION_TITLE
);
useEffect(() => {
if (setConversationId) {
setConversationId(selectedConversationId);
if (setConversationTitle) {
setConversationTitle(selectedConversationTitle);
}
}, [selectedConversationId, setConversationId]);
}, [selectedConversationTitle, setConversationTitle]);
const currentConversation = useMemo(
() =>
conversations[selectedConversationId] ??
createConversation({ conversationId: selectedConversationId }),
[conversations, createConversation, selectedConversationId]
const [currentConversation, setCurrentConversation] = useState<Conversation>(
getDefaultConversation({ cTitle: selectedConversationTitle })
);
const refetchCurrentConversation = useCallback(
async (cId?: string) => {
if (cId === '' || !conversations[selectedConversationTitle]) {
return;
}
const updatedConversation = await getConversation(
cId ?? conversations[selectedConversationTitle].id
);
if (updatedConversation) {
setCurrentConversation(updatedConversation);
}
return updatedConversation;
},
[conversations, getConversation, selectedConversationTitle]
);
useEffect(() => {
if (!isLoading && Object.keys(conversations).length > 0) {
const conversation =
conversations[selectedConversationTitle ?? getLastConversationTitle(conversationTitle)];
if (conversation) {
setCurrentConversation(conversation);
}
}
}, [
conversationTitle,
conversations,
getLastConversationTitle,
isLoading,
selectedConversationTitle,
]);
// Welcome setup state
const isWelcomeSetup = useMemo(() => {
// if any conversation has a connector id, we're not in welcome set up
return Object.keys(conversations).some(
(conversation) => conversations[conversation].apiConfig.connectorId != null
(conversation) => conversations[conversation].apiConfig?.connectorId != null
)
? false
: (connectors?.length ?? 0) === 0;
@ -154,20 +212,20 @@ const AssistantComponent: React.FC<Props> = ({
// Clear it if there is no connectors
useEffect(() => {
if (areConnectorsFetched && !connectors?.length) {
return setLastConversationId(WELCOME_CONVERSATION_TITLE);
return setLastConversationTitle(WELCOME_CONVERSATION_TITLE);
}
if (!currentConversation.excludeFromLastConversationStorage) {
setLastConversationId(currentConversation.id);
setLastConversationTitle(currentConversation.title);
}
}, [areConnectorsFetched, connectors?.length, currentConversation, setLastConversationId]);
const { comments: connectorComments, prompt: connectorPrompt } = useConnectorSetup({
conversation: blockBotConversation,
});
const currentTitle: string | JSX.Element =
isWelcomeSetup && blockBotConversation.theme?.title ? blockBotConversation.theme?.title : title;
}, [
areConnectorsFetched,
connectors?.length,
conversationsData,
currentConversation,
isLoading,
setLastConversationTitle,
]);
const [promptTextPreview, setPromptTextPreview] = useState<string>('');
const [autoPopulatedOnce, setAutoPopulatedOnce] = useState<boolean>(false);
@ -182,9 +240,9 @@ const AssistantComponent: React.FC<Props> = ({
useLayoutEffect(() => {
// need in order for code block controls to be added to the DOM
setTimeout(() => {
setMessageCodeBlocks(augmentMessageCodeBlocks(currentConversation));
setMessageCodeBlocks(augmentMessageCodeBlocks(currentConversation, showAnonymizedValues));
}, 0);
}, [augmentMessageCodeBlocks, currentConversation]);
}, [augmentMessageCodeBlocks, currentConversation, showAnonymizedValues]);
const isSendingDisabled = useMemo(() => {
return isDisabled || showMissingConnectorCallout;
@ -235,13 +293,48 @@ const AssistantComponent: React.FC<Props> = ({
);
const handleOnConversationSelected = useCallback(
(cId: string) => {
setSelectedConversationId(cId);
setEditingSystemPromptId(
getDefaultSystemPrompt({ allSystemPrompts, conversation: conversations[cId] })?.id
);
async ({ cId, cTitle }: { cId: string; cTitle: string }) => {
if (cId === '') {
const updatedConv = await refetchResults();
if (updatedConv) {
setCurrentConversation(updatedConv[cTitle]);
setSelectedConversationTitle(cTitle);
setEditingSystemPromptId(
getDefaultSystemPrompt({ allSystemPrompts, conversation: updatedConv[cTitle] })?.id
);
}
} else {
setSelectedConversationTitle(cTitle);
const refetchedConversation = await refetchCurrentConversation(cId);
setEditingSystemPromptId(
getDefaultSystemPrompt({ allSystemPrompts, conversation: refetchedConversation })?.id
);
}
},
[allSystemPrompts, conversations]
[allSystemPrompts, refetchCurrentConversation, refetchResults]
);
const { comments: connectorComments, prompt: connectorPrompt } = useConnectorSetup({
conversation: blockBotConversation,
onConversationUpdate: handleOnConversationSelected,
onSetupComplete: () => {
setConversations({
...conversations,
[currentConversation.title]: clearPresentationData(currentConversation),
});
},
});
const handleOnConversationDeleted = useCallback(
async (cTitle: string) => {
setTimeout(() => {
deleteConversation(conversations[cTitle].id);
}, 0);
const deletedConv = { ...conversations };
delete deletedConv[cTitle];
setConversations(deletedConv);
},
[conversations, deleteConversation]
);
const handleOnSystemPromptSelectionChange = useCallback((systemPromptId?: string) => {
@ -264,8 +357,8 @@ const AssistantComponent: React.FC<Props> = ({
);
useEffect(() => {
// Adding `conversationId !== selectedConversationId` to prevent auto-run still executing after changing selected conversation
if (currentConversation.messages.length || conversationId !== selectedConversationId) {
// Adding `conversationTitle !== selectedConversationTitle` to prevent auto-run still executing after changing selected conversation
if (currentConversation.messages.length || conversationTitle !== selectedConversationTitle) {
return;
}
@ -302,8 +395,8 @@ const AssistantComponent: React.FC<Props> = ({
currentConversation.messages,
promptContexts,
promptContextId,
conversationId,
selectedConversationId,
conversationTitle,
selectedConversationTitle,
selectedPromptContexts,
autoPopulatedOnce,
defaultAllow,
@ -356,6 +449,8 @@ const AssistantComponent: React.FC<Props> = ({
setEditingSystemPromptId,
selectedPromptContexts,
setSelectedPromptContexts,
setCurrentConversation,
refresh: refetchCurrentConversation,
});
const chatbotComments = useMemo(
@ -365,7 +460,7 @@ const AssistantComponent: React.FC<Props> = ({
comments={getComments({
currentConversation,
showAnonymizedValues,
amendMessage,
refetchCurrentConversation,
regenerateMessage: handleRegenerateResponse,
isFetchingResponse: isLoadingChatSend,
})}
@ -395,7 +490,7 @@ const AssistantComponent: React.FC<Props> = ({
</>
),
[
amendMessage,
refetchCurrentConversation,
currentConversation,
editingSystemPromptId,
getComments,
@ -429,13 +524,20 @@ const AssistantComponent: React.FC<Props> = ({
const trackPrompt = useCallback(
(promptTitle: string) => {
assistantTelemetry?.reportAssistantQuickPrompt({
conversationId: selectedConversationId,
conversationId: currentConversation.title,
promptTitle,
});
},
[assistantTelemetry, selectedConversationId]
[assistantTelemetry, currentConversation.title]
);
const refetchConversationsState = useCallback(async () => {
const refetchedConversations = await refetchResults();
if (refetchedConversations && refetchedConversations[currentConversation.title]) {
setCurrentConversation(refetchedConversations[currentConversation.title]);
}
}, [currentConversation.title, refetchResults]);
return getWrapper(
<>
<EuiModalHeader
@ -447,18 +549,19 @@ const AssistantComponent: React.FC<Props> = ({
{showTitle && (
<AssistantHeader
currentConversation={currentConversation}
defaultConnectorId={defaultConnectorId}
defaultProvider={defaultProvider}
setCurrentConversation={setCurrentConversation}
defaultConnector={defaultConnector}
docLinks={docLinks}
isDisabled={isDisabled}
isSettingsModalVisible={isSettingsModalVisible}
onConversationSelected={handleOnConversationSelected}
onToggleShowAnonymizedValues={onToggleShowAnonymizedValues}
selectedConversationId={selectedConversationId}
setIsSettingsModalVisible={setIsSettingsModalVisible}
setSelectedConversationId={setSelectedConversationId}
showAnonymizedValues={showAnonymizedValues}
title={currentTitle}
title={title}
conversations={conversations}
onConversationDeleted={handleOnConversationDeleted}
refetchConversationsState={refetchConversationsState}
/>
)}

View file

@ -64,9 +64,8 @@ describe('helpers', () => {
describe('getCombinedMessage', () => {
it('returns correct content for a new chat with a system prompt', async () => {
const message: Message = await getCombinedMessage({
currentReplacements: {},
currentReplacements: [],
isNewChat: true,
onNewReplacements: jest.fn(),
promptText: 'User prompt text',
selectedPromptContexts: {
[mockSelectedAlertPromptContext.promptContextId]: mockSelectedAlertPromptContext,
@ -87,9 +86,8 @@ User prompt text`);
it('returns correct content for a new chat WITHOUT a system prompt', async () => {
const message: Message = await getCombinedMessage({
currentReplacements: {},
currentReplacements: [],
isNewChat: true,
onNewReplacements: jest.fn(),
promptText: 'User prompt text',
selectedPromptContexts: {
[mockSelectedAlertPromptContext.promptContextId]: mockSelectedAlertPromptContext,
@ -109,9 +107,8 @@ User prompt text`);
it('returns the correct content for an existing chat', async () => {
const message: Message = await getCombinedMessage({
currentReplacements: {},
currentReplacements: [],
isNewChat: false,
onNewReplacements: jest.fn(),
promptText: 'User prompt text',
selectedPromptContexts: {
[mockSelectedAlertPromptContext.promptContextId]: mockSelectedAlertPromptContext,
@ -129,9 +126,8 @@ User prompt text`);
it('returns the expected role', async () => {
const message: Message = await getCombinedMessage({
currentReplacements: {},
currentReplacements: [],
isNewChat: true,
onNewReplacements: jest.fn(),
promptText: 'User prompt text',
selectedPromptContexts: {
[mockSelectedAlertPromptContext.promptContextId]: mockSelectedAlertPromptContext,
@ -144,9 +140,8 @@ User prompt text`);
it('returns a valid timestamp', async () => {
const message: Message = await getCombinedMessage({
currentReplacements: {},
currentReplacements: [],
isNewChat: true,
onNewReplacements: jest.fn(),
promptText: 'User prompt text',
selectedPromptContexts: {},
selectedSystemPrompt: mockSystemPrompt,
@ -156,8 +151,6 @@ User prompt text`);
});
describe('when there is data to anonymize', () => {
const onNewReplacements = jest.fn();
const mockPromptContextWithDataToAnonymize: SelectedPromptContext = {
allow: ['field1', 'field2'],
allowReplacement: ['field1', 'field2'],
@ -169,11 +162,10 @@ User prompt text`);
};
it('invokes `onNewReplacements` with the expected replacements', async () => {
await getCombinedMessage({
currentReplacements: {},
const message = await getCombinedMessage({
currentReplacements: [],
getAnonymizedValue: mockGetAnonymizedValue,
isNewChat: true,
onNewReplacements,
promptText: 'User prompt text',
selectedPromptContexts: {
[mockPromptContextWithDataToAnonymize.promptContextId]:
@ -182,22 +174,21 @@ User prompt text`);
selectedSystemPrompt: mockSystemPrompt,
});
expect(onNewReplacements).toBeCalledWith({
elzoof: 'foozle',
oof: 'foo',
rab: 'bar',
zab: 'baz',
});
expect(message.replacements).toEqual([
{ uuid: 'oof', value: 'foo' },
{ uuid: 'rab', value: 'bar' },
{ uuid: 'zab', value: 'baz' },
{ uuid: 'elzoof', value: 'foozle' },
]);
});
it('returns the expected content when `isNewChat` is false', async () => {
const isNewChat = false; // <-- not a new chat
const message: Message = await getCombinedMessage({
currentReplacements: {},
currentReplacements: [],
getAnonymizedValue: mockGetAnonymizedValue,
isNewChat,
onNewReplacements: jest.fn(),
promptText: 'User prompt text',
selectedPromptContexts: {},
selectedSystemPrompt: mockSystemPrompt,

View file

@ -5,13 +5,12 @@
* 2.0.
*/
import { transformRawData } from '@kbn/elastic-assistant-common';
import type { Message } from '../../assistant_context/types';
import { SYSTEM_PROMPT_CONTEXT_NON_I18N } from '../../content/prompts/system/translations';
import { Replacement, transformRawData } from '@kbn/elastic-assistant-common';
import { getAnonymizedValue as defaultGetAnonymizedValue } from '../get_anonymized_value';
import type { Message } from '../../assistant_context/types';
import type { SelectedPromptContext } from '../prompt_context/types';
import type { Prompt } from '../types';
import { SYSTEM_PROMPT_CONTEXT_NON_I18N } from '../../content/prompts/system/translations';
export const getSystemMessages = ({
isNewChat,
@ -33,16 +32,15 @@ export const getSystemMessages = ({
return [message];
};
export async function getCombinedMessage({
export function getCombinedMessage({
currentReplacements,
getAnonymizedValue = defaultGetAnonymizedValue,
isNewChat,
onNewReplacements,
promptText,
selectedPromptContexts,
selectedSystemPrompt,
}: {
currentReplacements: Record<string, string> | undefined;
currentReplacements: Replacement[] | undefined;
getAnonymizedValue?: ({
currentReplacements,
rawValue,
@ -51,15 +49,19 @@ export async function getCombinedMessage({
rawValue: string;
}) => string;
isNewChat: boolean;
onNewReplacements: (newReplacements: Record<string, string>) => void;
promptText: string;
selectedPromptContexts: Record<string, SelectedPromptContext>;
selectedSystemPrompt: Prompt | undefined;
}): Promise<Message> {
}): Message {
const replacements: Replacement[] = currentReplacements ?? [];
const onNewReplacements = (newReplacements: Replacement[]) => {
replacements.push(...newReplacements);
};
const promptContextsContent = Object.keys(selectedPromptContexts)
.sort()
.map((id) => {
const promptContext = transformRawData({
const promptContextData = transformRawData({
allow: selectedPromptContexts[id].allow,
allowReplacement: selectedPromptContexts[id].allowReplacement,
currentReplacements,
@ -68,16 +70,15 @@ export async function getCombinedMessage({
rawData: selectedPromptContexts[id].rawData,
});
return `${SYSTEM_PROMPT_CONTEXT_NON_I18N(promptContext)}`;
return `${SYSTEM_PROMPT_CONTEXT_NON_I18N(promptContextData)}`;
});
return {
content: `${
isNewChat ? `${selectedSystemPrompt?.content ?? ''}\n\n` : ''
}${promptContextsContent}
${promptText}`,
}${promptContextsContent}\n\n${promptText}`,
role: 'user', // we are combining the system and user messages into one message
timestamp: new Date().toLocaleString(),
replacements,
};
}

View file

@ -22,6 +22,8 @@ import { WELCOME_CONVERSATION } from '../../use_conversation/sample_conversation
const BASE_CONVERSATION: Conversation = {
...WELCOME_CONVERSATION,
apiConfig: {
connectorId: '123',
connectorTypeTitle: 'OpenAI',
defaultSystemPromptId: mockSystemPrompt.id,
},
};
@ -372,14 +374,19 @@ describe('SystemPrompt', () => {
it('should save new prompt correctly when prompt is removed from a conversation and linked to another conversation in a single transaction', async () => {
const secondMockConversation: Conversation = {
id: 'second',
category: 'assistant',
apiConfig: {
connectorId: '123',
connectorTypeTitle: 'OpenAI',
defaultSystemPromptId: undefined,
},
title: 'second',
messages: [],
replacements: [],
};
const localMockConversations: Record<string, Conversation> = {
[DEFAULT_CONVERSATION_TITLE]: BASE_CONVERSATION,
[secondMockConversation.id]: secondMockConversation,
[secondMockConversation.title]: secondMockConversation,
};
const localMockUseAssistantContext = {
@ -451,9 +458,11 @@ describe('SystemPrompt', () => {
defaultSystemPromptId: undefined,
}),
}),
[secondMockConversation.id]: {
[secondMockConversation.title]: {
...secondMockConversation,
apiConfig: {
connectorId: '123',
connectorTypeTitle: 'OpenAI',
defaultSystemPromptId: mockSystemPrompt.id,
},
},

View file

@ -36,12 +36,12 @@ const SystemPromptComponent: React.FC<Props> = ({
if (editingSystemPromptId !== undefined) {
return (
allSystemPrompts?.find((p) => p.id === editingSystemPromptId) ??
allSystemPrompts?.find((p) => p.id === conversation?.apiConfig.defaultSystemPromptId)
allSystemPrompts?.find((p) => p.id === conversation?.apiConfig?.defaultSystemPromptId)
);
} else {
return undefined;
}
}, [allSystemPrompts, conversation?.apiConfig.defaultSystemPromptId, editingSystemPromptId]);
}, [allSystemPrompts, conversation?.apiConfig?.defaultSystemPromptId, editingSystemPromptId]);
const [isEditing, setIsEditing] = React.useState<boolean>(false);

View file

@ -30,7 +30,7 @@ import { TEST_IDS } from '../../../constants';
export interface Props {
allSystemPrompts: Prompt[];
compressed?: boolean;
conversation: Conversation | undefined;
conversation?: Conversation;
selectedPrompt: Prompt | undefined;
clearSelectedSystemPrompt?: () => void;
isClearable?: boolean;
@ -71,9 +71,9 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
// Write the selected system prompt to the conversation config
const setSelectedSystemPrompt = useCallback(
(prompt: Prompt | undefined) => {
if (conversation) {
if (conversation && conversation.apiConfig) {
setApiConfig({
conversationId: conversation.id,
conversation,
apiConfig: {
...conversation.apiConfig,
defaultSystemPromptId: prompt?.id,
@ -174,7 +174,7 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
onBlur={handleOnBlur}
options={[...options, addNewSystemPrompt]}
placeholder={i18n.SELECT_A_SYSTEM_PROMPT}
valueOfSelected={selectedPrompt?.id ?? allSystemPrompts[0].id}
valueOfSelected={selectedPrompt?.id ?? allSystemPrompts[0]?.id}
/>
</EuiFormRow>
)}

View file

@ -23,16 +23,16 @@ describe('ConversationMultiSelector', () => {
});
it('Selects an existing quick prompt', () => {
const { getByTestId } = render(<ConversationMultiSelector {...testProps} />);
expect(getByTestId('euiComboBoxPill')).toHaveTextContent(welcomeConvo.id);
expect(getByTestId('euiComboBoxPill')).toHaveTextContent(welcomeConvo.title);
fireEvent.click(getByTestId('comboBoxToggleListButton'));
fireEvent.click(getByTestId(`conversationMultiSelectorOption-${alertConvo.id}`));
fireEvent.click(getByTestId(`conversationMultiSelectorOption-${alertConvo.title}`));
expect(onConversationSelectionChange).toHaveBeenCalledWith([alertConvo, welcomeConvo]);
});
it('Selects existing conversation from the search input', () => {
const { getByTestId } = render(<ConversationMultiSelector {...testProps} />);
fireEvent.change(getByTestId('comboBoxSearchInput'), {
target: { value: alertConvo.id },
target: { value: alertConvo.title },
});
fireEvent.keyDown(getByTestId('comboBoxSearchInput'), {
key: 'Enter',

View file

@ -33,15 +33,15 @@ export const ConversationMultiSelector: React.FC<Props> = React.memo(
const options = useMemo<EuiComboBoxOptionOption[]>(
() =>
conversations.map((conversation) => ({
label: conversation.id,
'data-test-subj': TEST_IDS.CONVERSATIONS_MULTISELECTOR_OPTION(conversation.id),
label: conversation.title ?? '',
'data-test-subj': TEST_IDS.CONVERSATIONS_MULTISELECTOR_OPTION(conversation.title),
})),
[conversations]
);
const selectedOptions = useMemo<EuiComboBoxOptionOption[]>(() => {
return selectedConversations != null
? selectedConversations.map((conversation) => ({
label: conversation.id,
label: conversation.title,
}))
: [];
}, [selectedConversations]);
@ -49,7 +49,7 @@ export const ConversationMultiSelector: React.FC<Props> = React.memo(
const handleSelectionChange = useCallback(
(conversationMultiSelectorOption: EuiComboBoxOptionOption[]) => {
const newConversationSelection = conversations.filter((conversation) =>
conversationMultiSelectorOption.some((cmso) => conversation.id === cmso.label)
conversationMultiSelectorOption.some((cmso) => conversation.title === cmso.label)
);
onConversationSelectionChange(newConversationSelection);
},

View file

@ -17,22 +17,24 @@ const onSelectedSystemPromptChange = jest.fn();
const setUpdatedSystemPromptSettings = jest.fn().mockImplementation((fn) => {
return fn(mockSystemPrompts);
});
const setUpdatedConversationSettings = jest.fn().mockImplementation((fn) => {
const setConversationSettings = jest.fn().mockImplementation((fn) => {
return fn({
[welcomeConvo.id]: welcomeConvo,
[alertConvo.id]: alertConvo,
[welcomeConvo.title]: welcomeConvo,
[alertConvo.title]: alertConvo,
});
});
const testProps = {
conversationSettings: {
[welcomeConvo.id]: welcomeConvo,
[welcomeConvo.title]: welcomeConvo,
},
onSelectedSystemPromptChange,
selectedSystemPrompt: mockSystemPrompts[0],
setUpdatedSystemPromptSettings,
setUpdatedConversationSettings,
setConversationSettings,
systemPromptSettings: mockSystemPrompts,
conversationsSettingsBulkActions: {},
setConversationsSettingsBulkActions: jest.fn(),
};
jest.mock('./system_prompt_selector/system_prompt_selector', () => ({
@ -126,15 +128,15 @@ describe('SystemPromptSettings', () => {
);
fireEvent.click(getByTestId('change-multi'));
expect(setUpdatedConversationSettings).toHaveReturnedWith({
[welcomeConvo.id]: {
expect(setConversationSettings).toHaveReturnedWith({
[welcomeConvo.title]: {
...welcomeConvo,
apiConfig: {
...welcomeConvo.apiConfig,
defaultSystemPromptId: 'mock-system-prompt-1',
},
},
[alertConvo.id]: {
[alertConvo.title]: {
...alertConvo,
apiConfig: {
...alertConvo.apiConfig,

View file

@ -22,22 +22,27 @@ import {
import { keyBy } from 'lodash/fp';
import { css } from '@emotion/react';
import { ApiConfig } from '@kbn/elastic-assistant-common';
import { AIConnector } from '../../../../connectorland/connector_selector';
import { Conversation, Prompt } from '../../../../..';
import * as i18n from './translations';
import { UseAssistantContext } from '../../../../assistant_context';
import { ConversationMultiSelector } from './conversation_multi_selector/conversation_multi_selector';
import { SystemPromptSelector } from './system_prompt_selector/system_prompt_selector';
import { TEST_IDS } from '../../../constants';
import { ConversationsBulkActions } from '../../../api';
interface Props {
conversationSettings: UseAssistantContext['conversations'];
conversationSettings: Record<string, Conversation>;
conversationsSettingsBulkActions: ConversationsBulkActions;
onSelectedSystemPromptChange: (systemPrompt?: Prompt) => void;
selectedSystemPrompt: Prompt | undefined;
setUpdatedSystemPromptSettings: React.Dispatch<React.SetStateAction<Prompt[]>>;
setUpdatedConversationSettings: React.Dispatch<
React.SetStateAction<UseAssistantContext['conversations']>
>;
setConversationSettings: React.Dispatch<React.SetStateAction<Record<string, Conversation>>>;
systemPromptSettings: Prompt[];
setConversationsSettingsBulkActions: React.Dispatch<
React.SetStateAction<ConversationsBulkActions>
>;
defaultConnector?: AIConnector;
}
/**
@ -49,8 +54,11 @@ export const SystemPromptSettings: React.FC<Props> = React.memo(
onSelectedSystemPromptChange,
selectedSystemPrompt,
setUpdatedSystemPromptSettings,
setUpdatedConversationSettings,
setConversationSettings,
systemPromptSettings,
conversationsSettingsBulkActions,
setConversationsSettingsBulkActions,
defaultConnector,
}) => {
// Prompt
const promptContent = useMemo(
@ -92,19 +100,31 @@ export const SystemPromptSettings: React.FC<Props> = React.memo(
return selectedSystemPrompt != null
? Object.values(conversationSettings).filter(
(conversation) =>
conversation.apiConfig.defaultSystemPromptId === selectedSystemPrompt.id
conversation.apiConfig?.defaultSystemPromptId === selectedSystemPrompt.id
)
: [];
}, [conversationSettings, selectedSystemPrompt]);
const handleConversationSelectionChange = useCallback(
(currentPromptConversations: Conversation[]) => {
const currentPromptConversationIds = currentPromptConversations.map((convo) => convo.id);
const currentPromptConversationTitles = currentPromptConversations.map(
(convo) => convo.title
);
const getDefaultSystemPromptId = (convo: Conversation) =>
currentPromptConversationTitles.includes(convo.title)
? selectedSystemPrompt?.id
: convo.apiConfig && convo.apiConfig.defaultSystemPromptId === selectedSystemPrompt?.id
? // remove the default System Prompt if it is assigned to a conversation
// but that conversation is not in the currentPromptConversationList
// This means conversation was removed in the current transaction
undefined
: // leave it as it is .. if that conversation was neither added nor removed.
convo.apiConfig?.defaultSystemPromptId;
if (selectedSystemPrompt != null) {
setUpdatedConversationSettings((prev) =>
setConversationSettings((prev) =>
keyBy(
'id',
'title',
/*
* updatedConversationWithPrompts calculates the present of prompt for
* each conversation. Based on the values of selected conversation, it goes
@ -113,24 +133,90 @@ export const SystemPromptSettings: React.FC<Props> = React.memo(
* */
Object.values(prev).map((convo) => ({
...convo,
apiConfig: {
...convo.apiConfig,
defaultSystemPromptId: currentPromptConversationIds.includes(convo.id)
? selectedSystemPrompt?.id
: convo.apiConfig.defaultSystemPromptId === selectedSystemPrompt?.id
? // remove the default System Prompt if it is assigned to a conversation
// but that conversation is not in the currentPromptConversationList
// This means conversation was removed in the current transaction
undefined
: // leave it as it is .. if that conversation was neither added nor removed.
convo.apiConfig.defaultSystemPromptId,
},
...(convo.apiConfig
? {
apiConfig: {
...convo.apiConfig,
defaultSystemPromptId: getDefaultSystemPromptId(convo),
},
}
: {
apiConfig: {
defaultSystemPromptId: getDefaultSystemPromptId(convo),
connectorId: defaultConnector?.id ?? '',
connectorTypeTitle: defaultConnector?.connectorTypeTitle ?? '',
},
}),
}))
)
);
let updatedConversationsSettingsBulkActions = { ...conversationsSettingsBulkActions };
Object.values(conversationSettings).forEach((convo) => {
const getApiConfig = (): ApiConfig | {} => {
if (convo.apiConfig) {
return {
apiConfig: {
...convo.apiConfig,
defaultSystemPromptId: getDefaultSystemPromptId(convo),
},
};
}
return {};
};
const createOperation =
convo.id === ''
? {
create: {
...(updatedConversationsSettingsBulkActions.create ?? {}),
[convo.id]: {
...convo,
...(convo.apiConfig
? {
apiConfig: {
...convo.apiConfig,
defaultSystemPromptId: getDefaultSystemPromptId(convo),
},
}
: {}),
},
},
}
: {};
const updateOperation =
convo.id !== ''
? {
update: {
...(updatedConversationsSettingsBulkActions.update ?? {}),
[convo.id]: {
...(updatedConversationsSettingsBulkActions.update
? updatedConversationsSettingsBulkActions.update[convo.id] ?? {}
: {}),
...getApiConfig(),
},
},
}
: {};
updatedConversationsSettingsBulkActions = {
...updatedConversationsSettingsBulkActions,
...createOperation,
...updateOperation,
};
});
setConversationsSettingsBulkActions(updatedConversationsSettingsBulkActions);
}
},
[selectedSystemPrompt, setUpdatedConversationSettings]
[
conversationSettings,
conversationsSettingsBulkActions,
defaultConnector?.connectorTypeTitle,
defaultConnector?.id,
selectedSystemPrompt,
setConversationSettings,
setConversationsSettingsBulkActions,
]
);
// Whether this system prompt should be the default for new conversations

View file

@ -7,7 +7,7 @@
import { alertConvo, customConvo, welcomeConvo } from '../../mock/conversation';
import { useAssistantContext } from '../../assistant_context';
import { fireEvent, render } from '@testing-library/react';
import { fireEvent, render, act } from '@testing-library/react';
import {
AssistantSettings,
ANONYMIZATION_TAB,
@ -22,8 +22,8 @@ import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/c
import { MOCK_QUICK_PROMPTS } from '../../mock/quick_prompt';
const mockConversations = {
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[alertConvo.title]: alertConvo,
[welcomeConvo.title]: welcomeConvo,
};
const saveSettings = jest.fn();
@ -41,8 +41,8 @@ const mockContext = {
selectedSettingsTab: 'CONVERSATIONS_TAB',
};
const onClose = jest.fn();
const onSave = jest.fn();
const setSelectedConversationId = jest.fn();
const onSave = jest.fn().mockResolvedValue(() => {});
const onConversationSelected = jest.fn();
const testProps = {
defaultConnectorId: '123',
@ -50,7 +50,8 @@ const testProps = {
selectedConversation: welcomeConvo,
onClose,
onSave,
setSelectedConversationId,
onConversationSelected,
conversations: {},
};
jest.mock('../../assistant_context');
@ -79,20 +80,25 @@ describe('AssistantSettings', () => {
(useAssistantContext as jest.Mock).mockImplementation(() => mockContext);
});
it('saves changes', () => {
it('saves changes', async () => {
const { getByTestId } = render(<AssistantSettings {...testProps} />);
fireEvent.click(getByTestId('save-button'));
await act(async () => {
fireEvent.click(getByTestId('save-button'));
});
expect(onSave).toHaveBeenCalled();
expect(saveSettings).toHaveBeenCalled();
});
it('saves changes and updates selected conversation when selected conversation has been deleted', () => {
it('saves changes and updates selected conversation when selected conversation has been deleted', async () => {
const { getByTestId } = render(
<AssistantSettings {...testProps} selectedConversation={customConvo} />
);
fireEvent.click(getByTestId('save-button'));
await act(async () => {
fireEvent.click(getByTestId('save-button'));
});
expect(onSave).toHaveBeenCalled();
expect(setSelectedConversationId).toHaveBeenCalled();
expect(onConversationSelected).toHaveBeenCalled();
expect(saveSettings).toHaveBeenCalled();
});

View file

@ -23,7 +23,7 @@ import {
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { css } from '@emotion/react';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants';
import { AIConnector } from '../../connectorland/connector_selector';
import { Conversation, Prompt, QuickPrompt } from '../../..';
import * as i18n from './translations';
import { useAssistantContext } from '../../assistant_context';
@ -58,14 +58,14 @@ export type SettingsTabs =
| typeof KNOWLEDGE_BASE_TAB
| typeof EVALUATION_TAB;
interface Props {
defaultConnectorId?: string;
defaultProvider?: OpenAiProviderType;
defaultConnector?: AIConnector;
onClose: (
event?: React.KeyboardEvent<HTMLDivElement> | React.MouseEvent<HTMLButtonElement>
) => void;
onSave: () => void;
onSave: (success: boolean) => Promise<void>;
selectedConversation: Conversation;
setSelectedConversationId: React.Dispatch<React.SetStateAction<string>>;
onConversationSelected: ({ cId, cTitle }: { cId: string; cTitle: string }) => void;
conversations: Record<string, Conversation>;
}
/**
@ -74,37 +74,44 @@ interface Props {
*/
export const AssistantSettings: React.FC<Props> = React.memo(
({
defaultConnectorId,
defaultProvider,
defaultConnector,
onClose,
onSave,
selectedConversation: defaultSelectedConversation,
setSelectedConversationId,
onConversationSelected,
conversations,
}) => {
const { modelEvaluatorEnabled, http, selectedSettingsTab, setSelectedSettingsTab } =
useAssistantContext();
const {
actionTypeRegistry,
modelEvaluatorEnabled,
http,
selectedSettingsTab,
setSelectedSettingsTab,
} = useAssistantContext();
const {
conversationSettings,
setConversationSettings,
defaultAllow,
defaultAllowReplacement,
knowledgeBase,
quickPromptSettings,
systemPromptSettings,
setUpdatedConversationSettings,
setUpdatedDefaultAllow,
setUpdatedDefaultAllowReplacement,
setUpdatedKnowledgeBaseSettings,
setUpdatedQuickPromptSettings,
setUpdatedSystemPromptSettings,
saveSettings,
} = useSettingsUpdater();
conversationsSettingsBulkActions,
setConversationsSettingsBulkActions,
} = useSettingsUpdater(conversations);
// Local state for saving previously selected items so tab switching is friendlier
// Conversation Selection State
const [selectedConversation, setSelectedConversation] = useState<Conversation | undefined>(
() => {
return conversationSettings[defaultSelectedConversation.id];
return conversationSettings[defaultSelectedConversation.title];
}
);
const onHandleSelectedConversationChange = useCallback((conversation?: Conversation) => {
@ -112,7 +119,7 @@ export const AssistantSettings: React.FC<Props> = React.memo(
}, []);
useEffect(() => {
if (selectedConversation != null) {
setSelectedConversation(conversationSettings[selectedConversation.id]);
setSelectedConversation(conversationSettings[selectedConversation.title]);
}
}, [conversationSettings, selectedConversation]);
@ -140,22 +147,26 @@ export const AssistantSettings: React.FC<Props> = React.memo(
}
}, [selectedSystemPrompt, systemPromptSettings]);
const handleSave = useCallback(() => {
const handleSave = useCallback(async () => {
// If the selected conversation is deleted, we need to select a new conversation to prevent a crash creating a conversation that already exists
const isSelectedConversationDeleted =
conversationSettings[defaultSelectedConversation.id] == null;
const newSelectedConversationId: string | undefined = Object.keys(conversationSettings)[0];
if (isSelectedConversationDeleted && newSelectedConversationId != null) {
setSelectedConversationId(conversationSettings[newSelectedConversationId].id);
conversationSettings[defaultSelectedConversation.title] == null;
const newSelectedConversation: Conversation | undefined =
Object.values(conversationSettings)[0];
if (isSelectedConversationDeleted && newSelectedConversation != null) {
onConversationSelected({
cId: newSelectedConversation.id,
cTitle: newSelectedConversation.title,
});
}
saveSettings();
onSave();
const saveResult = await saveSettings();
await onSave(saveResult);
}, [
conversationSettings,
defaultSelectedConversation.id,
defaultSelectedConversation.title,
onConversationSelected,
onSave,
saveSettings,
setSelectedConversationId,
]);
return (
@ -279,10 +290,12 @@ export const AssistantSettings: React.FC<Props> = React.memo(
>
{selectedSettingsTab === CONVERSATIONS_TAB && (
<ConversationSettings
defaultConnectorId={defaultConnectorId}
defaultProvider={defaultProvider}
actionTypeRegistry={actionTypeRegistry}
defaultConnector={defaultConnector}
conversationSettings={conversationSettings}
setUpdatedConversationSettings={setUpdatedConversationSettings}
setConversationsSettingsBulkActions={setConversationsSettingsBulkActions}
conversationsSettingsBulkActions={conversationsSettingsBulkActions}
setConversationSettings={setConversationSettings}
allSystemPrompts={systemPromptSettings}
selectedConversation={selectedConversation}
isDisabled={selectedConversation == null}
@ -301,10 +314,13 @@ export const AssistantSettings: React.FC<Props> = React.memo(
{selectedSettingsTab === SYSTEM_PROMPTS_TAB && (
<SystemPromptSettings
conversationSettings={conversationSettings}
defaultConnector={defaultConnector}
systemPromptSettings={systemPromptSettings}
onSelectedSystemPromptChange={onHandleSelectedSystemPromptChange}
selectedSystemPrompt={selectedSystemPrompt}
setUpdatedConversationSettings={setUpdatedConversationSettings}
setConversationSettings={setConversationSettings}
setConversationsSettingsBulkActions={setConversationsSettingsBulkActions}
conversationsSettingsBulkActions={conversationsSettingsBulkActions}
setUpdatedSystemPromptSettings={setUpdatedSystemPromptSettings}
/>
)}

View file

@ -14,14 +14,17 @@ import { welcomeConvo } from '../../mock/conversation';
import { CONVERSATIONS_TAB } from './assistant_settings';
const setIsSettingsModalVisible = jest.fn();
const setSelectedConversationId = jest.fn();
const onConversationSelected = jest.fn();
const testProps = {
defaultConnectorId: '123',
defaultProvider: OpenAiProviderType.OpenAi,
isSettingsModalVisible: false,
selectedConversation: welcomeConvo,
setIsSettingsModalVisible,
setSelectedConversationId,
onConversationSelected,
conversations: {},
refetchConversationsState: jest.fn(),
};
const setSelectedSettingsTab = jest.fn();
const mockUseAssistantContext = {

View file

@ -7,21 +7,22 @@
import React, { useCallback } from 'react';
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants';
import { AIConnector } from '../../connectorland/connector_selector';
import { Conversation } from '../../..';
import { AssistantSettings, CONVERSATIONS_TAB } from './assistant_settings';
import * as i18n from './translations';
import { useAssistantContext } from '../../assistant_context';
interface Props {
defaultConnectorId?: string;
defaultProvider?: OpenAiProviderType;
defaultConnector?: AIConnector;
isSettingsModalVisible: boolean;
selectedConversation: Conversation;
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedConversationId: React.Dispatch<React.SetStateAction<string>>;
onConversationSelected: ({ cId, cTitle }: { cId: string; cTitle: string }) => void;
isDisabled?: boolean;
conversations: Record<string, Conversation>;
refetchConversationsState: () => Promise<void>;
}
/**
@ -29,13 +30,14 @@ interface Props {
*/
export const AssistantSettingsButton: React.FC<Props> = React.memo(
({
defaultConnectorId,
defaultProvider,
defaultConnector,
isDisabled = false,
isSettingsModalVisible,
setIsSettingsModalVisible,
selectedConversation,
setSelectedConversationId,
onConversationSelected,
conversations,
refetchConversationsState,
}) => {
const { toasts, setSelectedSettingsTab } = useAssistantContext();
@ -48,13 +50,19 @@ export const AssistantSettingsButton: React.FC<Props> = React.memo(
cleanupAndCloseModal();
}, [cleanupAndCloseModal]);
const handleSave = useCallback(() => {
cleanupAndCloseModal();
toasts?.addSuccess({
iconType: 'check',
title: i18n.SETTINGS_UPDATED_TOAST_TITLE,
});
}, [cleanupAndCloseModal, toasts]);
const handleSave = useCallback(
async (success: boolean) => {
cleanupAndCloseModal();
await refetchConversationsState();
if (success) {
toasts?.addSuccess({
iconType: 'check',
title: i18n.SETTINGS_UPDATED_TOAST_TITLE,
});
}
},
[cleanupAndCloseModal, refetchConversationsState, toasts]
);
const handleShowConversationSettings = useCallback(() => {
setSelectedSettingsTab(CONVERSATIONS_TAB);
@ -76,12 +84,12 @@ export const AssistantSettingsButton: React.FC<Props> = React.memo(
{isSettingsModalVisible && (
<AssistantSettings
defaultConnectorId={defaultConnectorId}
defaultProvider={defaultProvider}
defaultConnector={defaultConnector}
selectedConversation={selectedConversation}
setSelectedConversationId={setSelectedConversationId}
onConversationSelected={onConversationSelected}
onClose={handleCloseModal}
onSave={handleSave}
conversations={conversations}
/>
)}
</>

View file

@ -53,7 +53,7 @@ interface Props {
*/
export const EvaluationSettings: React.FC<Props> = React.memo(({ onEvaluationSettingsChange }) => {
const { actionTypeRegistry, basePath, http } = useAssistantContext();
const { data: connectors } = useLoadConnectors({ http });
const { data: connectors } = useLoadConnectors({ actionTypeRegistry, http });
const {
data: evalResponse,
mutate: performEvaluation,

View file

@ -15,12 +15,17 @@ import {
mockSuperheroSystemPrompt,
mockSystemPrompt,
} from '../../../mock/system_prompt';
import { HttpSetup } from '@kbn/core/public';
const mockConversations = {
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[alertConvo.title]: alertConvo,
[welcomeConvo.title]: welcomeConvo,
};
const mockHttp = {
fetch: jest.fn(),
} as unknown as HttpSetup;
const mockSystemPrompts: Prompt[] = [mockSystemPrompt];
const mockQuickPrompts: Prompt[] = [defaultSystemPrompt];
@ -29,14 +34,12 @@ const initialDefaultAllowReplacement = ['replacement1'];
const setAllQuickPromptsMock = jest.fn();
const setAllSystemPromptsMock = jest.fn();
const setConversationsMock = jest.fn();
const setDefaultAllowMock = jest.fn();
const setDefaultAllowReplacementMock = jest.fn();
const setKnowledgeBaseMock = jest.fn();
const reportAssistantSettingToggled = jest.fn();
const mockValues = {
assistantTelemetry: { reportAssistantSettingToggled },
conversations: mockConversations,
allSystemPrompts: mockSystemPrompts,
allQuickPrompts: mockQuickPrompts,
defaultAllow: initialDefaultAllow,
@ -46,16 +49,17 @@ const mockValues = {
isEnabledKnowledgeBase: true,
latestAlerts: DEFAULT_LATEST_ALERTS,
},
baseConversations: {},
setAllQuickPrompts: setAllQuickPromptsMock,
setConversations: setConversationsMock,
setAllSystemPrompts: setAllSystemPromptsMock,
setDefaultAllow: setDefaultAllowMock,
setDefaultAllowReplacement: setDefaultAllowReplacementMock,
setKnowledgeBase: setKnowledgeBaseMock,
http: mockHttp,
};
const updatedValues = {
conversations: { [customConvo.id]: customConvo },
conversations: { [customConvo.title]: customConvo },
allSystemPrompts: [mockSuperheroSystemPrompt],
allQuickPrompts: [{ title: 'Prompt 2', prompt: 'Prompt 2', color: 'red' }],
defaultAllow: ['allow2'],
@ -81,10 +85,11 @@ describe('useSettingsUpdater', () => {
});
it('should set all state variables to their initial values when resetSettings is called', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useSettingsUpdater());
const { result, waitForNextUpdate } = renderHook(() => useSettingsUpdater(mockConversations));
await waitForNextUpdate();
const {
setUpdatedConversationSettings,
setConversationSettings,
setConversationsSettingsBulkActions,
setUpdatedQuickPromptSettings,
setUpdatedSystemPromptSettings,
setUpdatedDefaultAllow,
@ -93,7 +98,8 @@ describe('useSettingsUpdater', () => {
resetSettings,
} = result.current;
setUpdatedConversationSettings(updatedValues.conversations);
setConversationSettings(updatedValues.conversations);
setConversationsSettingsBulkActions({});
setUpdatedQuickPromptSettings(updatedValues.allQuickPrompts);
setUpdatedSystemPromptSettings(updatedValues.allSystemPrompts);
setUpdatedDefaultAllow(updatedValues.defaultAllow);
@ -109,7 +115,7 @@ describe('useSettingsUpdater', () => {
resetSettings();
expect(result.current.conversationSettings).toEqual(mockValues.conversations);
expect(result.current.conversationSettings).toEqual(mockConversations);
expect(result.current.quickPromptSettings).toEqual(mockValues.allQuickPrompts);
expect(result.current.systemPromptSettings).toEqual(mockValues.allSystemPrompts);
expect(result.current.defaultAllow).toEqual(mockValues.defaultAllow);
@ -120,10 +126,11 @@ describe('useSettingsUpdater', () => {
it('should update all state variables to their updated values when saveSettings is called', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useSettingsUpdater());
const { result, waitForNextUpdate } = renderHook(() => useSettingsUpdater(mockConversations));
await waitForNextUpdate();
const {
setUpdatedConversationSettings,
setConversationSettings,
setConversationsSettingsBulkActions,
setUpdatedQuickPromptSettings,
setUpdatedSystemPromptSettings,
setUpdatedDefaultAllow,
@ -131,18 +138,26 @@ describe('useSettingsUpdater', () => {
setUpdatedKnowledgeBaseSettings,
} = result.current;
setUpdatedConversationSettings(updatedValues.conversations);
setConversationSettings(updatedValues.conversations);
setConversationsSettingsBulkActions({ delete: { ids: ['1'] } });
setUpdatedQuickPromptSettings(updatedValues.allQuickPrompts);
setUpdatedSystemPromptSettings(updatedValues.allSystemPrompts);
setUpdatedDefaultAllow(updatedValues.defaultAllow);
setUpdatedDefaultAllowReplacement(updatedValues.defaultAllowReplacement);
setUpdatedKnowledgeBaseSettings(updatedValues.knowledgeBase);
result.current.saveSettings();
await result.current.saveSettings();
expect(mockHttp.fetch).toHaveBeenCalledWith(
'/api/elastic_assistant/current_user/conversations/_bulk_action',
{
method: 'POST',
version: '2023-10-31',
body: '{"delete":{"ids":["1"]}}',
}
);
expect(setAllQuickPromptsMock).toHaveBeenCalledWith(updatedValues.allQuickPrompts);
expect(setAllSystemPromptsMock).toHaveBeenCalledWith(updatedValues.allSystemPrompts);
expect(setConversationsMock).toHaveBeenCalledWith(updatedValues.conversations);
expect(setDefaultAllowMock).toHaveBeenCalledWith(updatedValues.defaultAllow);
expect(setDefaultAllowReplacementMock).toHaveBeenCalledWith(
updatedValues.defaultAllowReplacement
@ -152,13 +167,13 @@ describe('useSettingsUpdater', () => {
});
it('should track which toggles have been updated when saveSettings is called', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useSettingsUpdater());
const { result, waitForNextUpdate } = renderHook(() => useSettingsUpdater(mockConversations));
await waitForNextUpdate();
const { setUpdatedKnowledgeBaseSettings } = result.current;
setUpdatedKnowledgeBaseSettings(updatedValues.knowledgeBase);
result.current.saveSettings();
await result.current.saveSettings();
expect(reportAssistantSettingToggled).toHaveBeenCalledWith({
isEnabledKnowledgeBase: false,
isEnabledRAGAlerts: false,
@ -167,7 +182,7 @@ describe('useSettingsUpdater', () => {
});
it('should track only toggles that updated', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useSettingsUpdater());
const { result, waitForNextUpdate } = renderHook(() => useSettingsUpdater(mockConversations));
await waitForNextUpdate();
const { setUpdatedKnowledgeBaseSettings } = result.current;
@ -175,7 +190,7 @@ describe('useSettingsUpdater', () => {
...updatedValues.knowledgeBase,
isEnabledKnowledgeBase: true,
});
result.current.saveSettings();
await result.current.saveSettings();
expect(reportAssistantSettingToggled).toHaveBeenCalledWith({
isEnabledRAGAlerts: false,
});
@ -183,12 +198,12 @@ describe('useSettingsUpdater', () => {
});
it('if no toggles update, do not track anything', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useSettingsUpdater());
const { result, waitForNextUpdate } = renderHook(() => useSettingsUpdater(mockConversations));
await waitForNextUpdate();
const { setUpdatedKnowledgeBaseSettings } = result.current;
setUpdatedKnowledgeBaseSettings(mockValues.knowledgeBase);
result.current.saveSettings();
await result.current.saveSettings();
expect(reportAssistantSettingToggled).not.toHaveBeenCalledWith();
});
});

View file

@ -6,12 +6,17 @@
*/
import React, { useCallback, useState } from 'react';
import { Prompt, QuickPrompt } from '../../../..';
import { UseAssistantContext, useAssistantContext } from '../../../assistant_context';
import { Conversation, Prompt, QuickPrompt } from '../../../..';
import { useAssistantContext } from '../../../assistant_context';
import type { KnowledgeBaseConfig } from '../../types';
import {
ConversationsBulkActions,
bulkChangeConversations,
} from '../../api/conversations/use_bulk_actions_conversations';
interface UseSettingsUpdater {
conversationSettings: UseAssistantContext['conversations'];
conversationSettings: Record<string, Conversation>;
conversationsSettingsBulkActions: ConversationsBulkActions;
defaultAllow: string[];
defaultAllowReplacement: string[];
knowledgeBase: KnowledgeBaseConfig;
@ -20,39 +25,44 @@ interface UseSettingsUpdater {
systemPromptSettings: Prompt[];
setUpdatedDefaultAllow: React.Dispatch<React.SetStateAction<string[]>>;
setUpdatedDefaultAllowReplacement: React.Dispatch<React.SetStateAction<string[]>>;
setUpdatedConversationSettings: React.Dispatch<
React.SetStateAction<UseAssistantContext['conversations']>
setConversationSettings: React.Dispatch<React.SetStateAction<Record<string, Conversation>>>;
setConversationsSettingsBulkActions: React.Dispatch<
React.SetStateAction<ConversationsBulkActions>
>;
setUpdatedKnowledgeBaseSettings: React.Dispatch<React.SetStateAction<KnowledgeBaseConfig>>;
setUpdatedQuickPromptSettings: React.Dispatch<React.SetStateAction<QuickPrompt[]>>;
setUpdatedSystemPromptSettings: React.Dispatch<React.SetStateAction<Prompt[]>>;
saveSettings: () => void;
saveSettings: () => Promise<boolean>;
}
export const useSettingsUpdater = (): UseSettingsUpdater => {
export const useSettingsUpdater = (
conversations: Record<string, Conversation>
): UseSettingsUpdater => {
// Initial state from assistant context
const {
allQuickPrompts,
allSystemPrompts,
assistantTelemetry,
conversations,
defaultAllow,
defaultAllowReplacement,
knowledgeBase,
setAllQuickPrompts,
setAllSystemPrompts,
setConversations,
setDefaultAllow,
setDefaultAllowReplacement,
setKnowledgeBase,
http,
toasts,
} = useAssistantContext();
/**
* Pending updating state
*/
// Conversations
const [updatedConversationSettings, setUpdatedConversationSettings] =
useState<UseAssistantContext['conversations']>(conversations);
const [conversationSettings, setConversationSettings] =
useState<Record<string, Conversation>>(conversations);
const [conversationsSettingsBulkActions, setConversationsSettingsBulkActions] =
useState<ConversationsBulkActions>({});
// Quick Prompts
const [updatedQuickPromptSettings, setUpdatedQuickPromptSettings] =
useState<QuickPrompt[]>(allQuickPrompts);
@ -71,7 +81,8 @@ export const useSettingsUpdater = (): UseSettingsUpdater => {
* Reset all pending settings
*/
const resetSettings = useCallback((): void => {
setUpdatedConversationSettings(conversations);
setConversationSettings(conversations);
setConversationsSettingsBulkActions({});
setUpdatedQuickPromptSettings(allQuickPrompts);
setUpdatedKnowledgeBaseSettings(knowledgeBase);
setUpdatedSystemPromptSettings(allSystemPrompts);
@ -89,10 +100,18 @@ export const useSettingsUpdater = (): UseSettingsUpdater => {
/**
* Save all pending settings
*/
const saveSettings = useCallback((): void => {
const saveSettings = useCallback(async (): Promise<boolean> => {
setAllQuickPrompts(updatedQuickPromptSettings);
setAllSystemPrompts(updatedSystemPromptSettings);
setConversations(updatedConversationSettings);
const hasBulkConversations =
conversationsSettingsBulkActions.create ||
conversationsSettingsBulkActions.update ||
conversationsSettingsBulkActions.delete;
const bulkResult = hasBulkConversations
? await bulkChangeConversations(http, conversationsSettingsBulkActions, toasts)
: undefined;
const didUpdateKnowledgeBase =
knowledgeBase.isEnabledKnowledgeBase !== updatedKnowledgeBaseSettings.isEnabledKnowledgeBase;
const didUpdateRAGAlerts =
@ -110,26 +129,30 @@ export const useSettingsUpdater = (): UseSettingsUpdater => {
setKnowledgeBase(updatedKnowledgeBaseSettings);
setDefaultAllow(updatedDefaultAllow);
setDefaultAllowReplacement(updatedDefaultAllowReplacement);
return bulkResult?.success ?? true;
}, [
assistantTelemetry,
knowledgeBase.isEnabledRAGAlerts,
knowledgeBase.isEnabledKnowledgeBase,
setAllQuickPrompts,
setAllSystemPrompts,
setConversations,
setDefaultAllow,
setDefaultAllowReplacement,
setKnowledgeBase,
updatedConversationSettings,
updatedDefaultAllow,
updatedDefaultAllowReplacement,
updatedKnowledgeBaseSettings,
updatedQuickPromptSettings,
setAllSystemPrompts,
updatedSystemPromptSettings,
http,
conversationsSettingsBulkActions,
toasts,
knowledgeBase.isEnabledKnowledgeBase,
knowledgeBase.isEnabledRAGAlerts,
updatedKnowledgeBaseSettings,
setKnowledgeBase,
setDefaultAllow,
updatedDefaultAllow,
setDefaultAllowReplacement,
updatedDefaultAllowReplacement,
assistantTelemetry,
]);
return {
conversationSettings: updatedConversationSettings,
conversationSettings,
conversationsSettingsBulkActions,
defaultAllow: updatedDefaultAllow,
defaultAllowReplacement: updatedDefaultAllowReplacement,
knowledgeBase: updatedKnowledgeBaseSettings,
@ -139,9 +162,10 @@ export const useSettingsUpdater = (): UseSettingsUpdater => {
saveSettings,
setUpdatedDefaultAllow,
setUpdatedDefaultAllowReplacement,
setUpdatedConversationSettings,
setUpdatedKnowledgeBaseSettings,
setUpdatedQuickPromptSettings,
setUpdatedSystemPromptSettings,
setConversationSettings,
setConversationsSettingsBulkActions,
};
};

View file

@ -96,7 +96,7 @@ describe('useAssistantOverlay', () => {
expect(mockUseAssistantContext.showAssistantOverlay).toHaveBeenCalledWith({
showOverlay: true,
promptContextId: 'id',
conversationId: 'conversation-id',
conversationTitle: 'conversation-id',
});
});
});

View file

@ -20,7 +20,7 @@ interface UseAssistantOverlay {
* `useAssistantOverlay` is a hook that registers context with the assistant overlay, and
* returns an optional `showAssistantOverlay` function to display the assistant overlay.
* As an alterative to using the `showAssistantOverlay` returned from this hook, you may
* use the `NewChatById` component and pass it the `promptContextId` returned by this hook.
* use the `NewChatByTitle` component and pass it the `promptContextId` returned by this hook.
*
* USE THIS WHEN: You want to register context in one part of the tree, and then show
* a _New chat_ button in another part of the tree without passing around the data, or when
@ -38,7 +38,7 @@ export const useAssistantOverlay = (
/**
* optionally automatically add this context to a specific conversation when the assistant is displayed
*/
conversationId: string | null,
conversationTitle: string | null,
/**
* The assistant will display this **short**, static description
@ -98,11 +98,11 @@ export const useAssistantOverlay = (
assistantContextShowOverlay({
showOverlay,
promptContextId,
conversationId: conversationId ?? undefined,
conversationTitle: conversationTitle ?? undefined,
});
}
},
[assistantContextShowOverlay, conversationId, promptContextId]
[assistantContextShowOverlay, conversationTitle, promptContextId]
);
useEffect(() => {

View file

@ -92,10 +92,15 @@ describe('useConversation helpers', () => {
);
const conversation: Conversation = {
apiConfig: {
connectorId: '123',
connectorTypeTitle: 'OpenAI',
defaultSystemPromptId: '3',
},
category: 'assistant',
id: '1',
messages: [],
replacements: [],
title: '1',
};
test('should return the conversation system prompt if it exists', () => {
@ -106,9 +111,12 @@ describe('useConversation helpers', () => {
test('should return the default (starred) isNewConversationDefault system prompt if conversation system prompt does not exist', () => {
const conversationWithoutSystemPrompt: Conversation = {
apiConfig: {},
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
category: 'assistant',
id: '1',
messages: [],
title: '1',
};
const result = getDefaultSystemPrompt({
allSystemPrompts,
@ -120,9 +128,12 @@ describe('useConversation helpers', () => {
test('should return the default (starred) isNewConversationDefault system prompt if conversation system prompt does not exist within all system prompts', () => {
const conversationWithoutSystemPrompt: Conversation = {
apiConfig: {},
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
category: 'assistant',
id: '4', // this id does not exist within allSystemPrompts
messages: [],
title: '4',
};
const result = getDefaultSystemPrompt({
allSystemPrompts,
@ -134,9 +145,12 @@ describe('useConversation helpers', () => {
test('should return the first prompt if both conversation system prompt and default new system prompt do not exist', () => {
const conversationWithoutSystemPrompt: Conversation = {
apiConfig: {},
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
category: 'assistant',
id: '1',
messages: [],
title: '1',
};
const result = getDefaultSystemPrompt({
allSystemPrompts: allSystemPromptsNoDefault,
@ -148,9 +162,12 @@ describe('useConversation helpers', () => {
test('should return undefined if conversation system prompt does not exist and there are no system prompts', () => {
const conversationWithoutSystemPrompt: Conversation = {
apiConfig: {},
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
category: 'assistant',
id: '1',
messages: [],
title: '1',
};
const result = getDefaultSystemPrompt({
allSystemPrompts: [],
@ -162,9 +179,12 @@ describe('useConversation helpers', () => {
test('should return undefined if conversation system prompt does not exist within all system prompts', () => {
const conversationWithoutSystemPrompt: Conversation = {
apiConfig: {},
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
category: 'assistant',
id: '4', // this id does not exist within allSystemPrompts
messages: [],
title: '1',
};
const result = getDefaultSystemPrompt({
allSystemPrompts: allSystemPromptsNoDefault,

View file

@ -8,9 +8,17 @@
import { useConversation } from '.';
import { act, renderHook } from '@testing-library/react-hooks';
import { TestProviders } from '../../mock/test_providers/test_providers';
import { alertConvo, welcomeConvo } from '../../mock/conversation';
import React from 'react';
import { ConversationRole } from '../../assistant_context/types';
import { httpServiceMock } from '@kbn/core/public/mocks';
import { WELCOME_CONVERSATION } from './sample_conversations';
import {
deleteConversation,
getConversationById as _getConversationById,
createConversation as _createConversationApi,
} from '../api/conversations';
jest.mock('../api/conversations');
const message = {
content: 'You are a robot',
role: 'user' as ConversationRole,
@ -24,106 +32,48 @@ const anotherMessage = {
const mockConvo = {
id: 'new-convo',
title: 'new-convo',
messages: [message, anotherMessage],
apiConfig: { defaultSystemPromptId: 'default-system-prompt' },
theme: {
title: 'Elastic AI Assistant',
titleIcon: 'logoSecurity',
assistant: { name: 'Assistant', icon: 'logoSecurity' },
system: { icon: 'logoElastic' },
user: {},
apiConfig: {
connectorId: '123',
connectorTypeTitle: 'OpenAI',
defaultSystemPromptId: 'default-system-prompt',
},
};
const getConversationById = _getConversationById as jest.Mock;
const createConversation = _createConversationApi as jest.Mock;
describe('useConversation', () => {
let httpMock: ReturnType<typeof httpServiceMock.createSetupContract>;
beforeEach(() => {
httpMock = httpServiceMock.createSetupContract();
jest.clearAllMocks();
});
it('should append a message to an existing conversation when called with valid conversationId and message', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useConversation(), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
{children}
</TestProviders>
),
});
await waitForNextUpdate();
const appendResult = result.current.appendMessage({
conversationId: welcomeConvo.id,
message,
});
expect(appendResult).toHaveLength(3);
expect(appendResult[2]).toEqual(message);
});
});
it('should report telemetry when a message has been sent', async () => {
await act(async () => {
const reportAssistantMessageSent = jest.fn();
const { result, waitForNextUpdate } = renderHook(() => useConversation(), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
assistantTelemetry: {
reportAssistantInvoked: () => {},
reportAssistantQuickPrompt: () => {},
reportAssistantSettingToggled: () => {},
reportAssistantMessageSent,
},
}}
>
{children}
</TestProviders>
),
});
await waitForNextUpdate();
result.current.appendMessage({
conversationId: welcomeConvo.id,
message,
});
expect(reportAssistantMessageSent).toHaveBeenCalledWith({
conversationId: 'Welcome',
isEnabledKnowledgeBase: false,
isEnabledRAGAlerts: false,
role: 'user',
});
});
});
it('should create a new conversation when called with valid conversationId and message', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useConversation(), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
{children}
</TestProviders>
<TestProviders providerContext={{ http: httpMock }}>{children}</TestProviders>
),
});
await waitForNextUpdate();
createConversation.mockResolvedValue(mockConvo);
const createResult = result.current.createConversation({
conversationId: mockConvo.id,
const createResult = await result.current.createConversation({
id: mockConvo.id,
messages: mockConvo.messages,
replacements: [],
apiConfig: {
connectorId: '123',
connectorTypeTitle: 'OpenAI',
defaultSystemPromptId: 'default-system-prompt',
},
title: mockConvo.title,
category: 'assistant',
});
expect(createResult).toEqual(mockConvo);
@ -134,243 +84,55 @@ describe('useConversation', () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useConversation(), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[mockConvo.id]: mockConvo,
}),
}}
>
{children}
</TestProviders>
<TestProviders providerContext={{ http: httpMock }}>{children}</TestProviders>
),
});
await waitForNextUpdate();
const deleteResult = result.current.deleteConversation('new-convo');
await result.current.deleteConversation('new-convo');
expect(deleteResult).toEqual(mockConvo);
expect(deleteConversation).toHaveBeenCalledWith({
http: httpMock,
id: 'new-convo',
});
});
});
it('should update the apiConfig for an existing conversation when called with a valid conversationId and apiConfig', async () => {
await act(async () => {
const setConversations = jest.fn();
const { result, waitForNextUpdate } = renderHook(() => useConversation(), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[welcomeConvo.id]: welcomeConvo,
}),
setConversations,
}}
>
{children}
</TestProviders>
<TestProviders providerContext={{ http: httpMock }}>{children}</TestProviders>
),
});
await waitForNextUpdate();
result.current.setApiConfig({
conversationId: welcomeConvo.id,
await result.current.setApiConfig({
conversation: WELCOME_CONVERSATION,
apiConfig: mockConvo.apiConfig,
});
expect(setConversations).toHaveBeenCalledWith({
[welcomeConvo.id]: { ...welcomeConvo, apiConfig: mockConvo.apiConfig },
});
});
});
it('overwrites a conversation', async () => {
await act(async () => {
const setConversations = jest.fn();
const { result, waitForNextUpdate } = renderHook(() => useConversation(), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
setConversations,
}}
>
{children}
</TestProviders>
),
});
await waitForNextUpdate();
result.current.setConversation({
conversation: {
...mockConvo,
id: welcomeConvo.id,
},
});
expect(setConversations).toHaveBeenCalledWith({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: { ...mockConvo, id: welcomeConvo.id },
});
});
});
it('clears a conversation', async () => {
await act(async () => {
const setConversations = jest.fn();
const { result, waitForNextUpdate } = renderHook(() => useConversation(), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
setConversations,
}}
>
{children}
</TestProviders>
),
});
await waitForNextUpdate();
result.current.clearConversation(welcomeConvo.id);
expect(setConversations).toHaveBeenCalledWith({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: {
...welcomeConvo,
apiConfig: {
...welcomeConvo.apiConfig,
defaultSystemPromptId: 'default-system-prompt',
},
messages: [],
replacements: undefined,
},
});
});
});
it('appends replacements', async () => {
await act(async () => {
const setConversations = jest.fn();
const { result, waitForNextUpdate } = renderHook(() => useConversation(), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
setConversations,
}}
>
{children}
</TestProviders>
),
});
await waitForNextUpdate();
result.current.appendReplacements({
conversationId: welcomeConvo.id,
replacements: {
'1.0.0.721': '127.0.0.1',
'1.0.0.01': '10.0.0.1',
'tsoh-tset': 'test-host',
},
});
expect(setConversations).toHaveBeenCalledWith({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: {
...welcomeConvo,
replacements: {
'1.0.0.721': '127.0.0.1',
'1.0.0.01': '10.0.0.1',
'tsoh-tset': 'test-host',
},
},
expect(createConversation).toHaveBeenCalledWith({
http: httpMock,
conversation: { ...WELCOME_CONVERSATION, apiConfig: mockConvo.apiConfig, id: '' },
});
});
});
it('should remove the last message from a conversation when called with valid conversationId', async () => {
await act(async () => {
const setConversations = jest.fn();
const { result, waitForNextUpdate } = renderHook(() => useConversation(), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[mockConvo.id]: mockConvo,
}),
setConversations,
}}
>
{children}
</TestProviders>
<TestProviders providerContext={{ http: httpMock }}>{children}</TestProviders>
),
});
await waitForNextUpdate();
const removeResult = result.current.removeLastMessage('new-convo');
getConversationById.mockResolvedValue(mockConvo);
const removeResult = await result.current.removeLastMessage('new-convo');
expect(removeResult).toEqual([message]);
expect(setConversations).toHaveBeenCalledWith({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[mockConvo.id]: { ...mockConvo, messages: [message] },
});
});
});
it('amendMessage updates the last message of conversation[] for a given conversationId with provided content', async () => {
await act(async () => {
const setConversations = jest.fn();
const { result, waitForNextUpdate } = renderHook(() => useConversation(), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[mockConvo.id]: mockConvo,
}),
setConversations,
}}
>
{children}
</TestProviders>
),
});
await waitForNextUpdate();
result.current.amendMessage({
conversationId: 'new-convo',
content: 'hello world',
});
expect(setConversations).toHaveBeenCalledWith({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[mockConvo.id]: {
...mockConvo,
messages: [
message,
{
...anotherMessage,
content: 'hello world',
},
],
},
});
});
});
});

View file

@ -7,343 +7,181 @@
import { useCallback } from 'react';
import { ApiConfig } from '@kbn/elastic-assistant-common';
import { useAssistantContext } from '../../assistant_context';
import { Conversation, Message } from '../../assistant_context/types';
import * as i18n from './translations';
import { ELASTIC_AI_ASSISTANT, ELASTIC_AI_ASSISTANT_TITLE } from './translations';
import { getDefaultSystemPrompt } from './helpers';
import {
createConversation as createConversationApi,
deleteConversation as deleteConversationApi,
getConversationById,
updateConversation,
} from '../api/conversations';
import { WELCOME_CONVERSATION } from './sample_conversations';
export const DEFAULT_CONVERSATION_STATE: Conversation = {
id: i18n.DEFAULT_CONVERSATION_TITLE,
messages: [],
apiConfig: {},
theme: {
title: ELASTIC_AI_ASSISTANT_TITLE,
titleIcon: 'logoSecurity',
assistant: {
name: ELASTIC_AI_ASSISTANT,
icon: 'logoSecurity',
},
system: {
icon: 'logoElastic',
},
user: {},
},
replacements: [],
category: 'assistant',
title: i18n.DEFAULT_CONVERSATION_TITLE,
};
interface AppendMessageProps {
conversationId: string;
message: Message;
}
interface AmendMessageProps {
conversationId: string;
content: string;
}
interface AppendReplacementsProps {
conversationId: string;
replacements: Record<string, string>;
}
interface CreateConversationProps {
conversationId: string;
cTitle: string;
messages?: Message[];
}
interface SetApiConfigProps {
conversationId: string;
apiConfig: Conversation['apiConfig'];
}
interface SetConversationProps {
conversation: Conversation;
apiConfig: ApiConfig;
}
interface UseConversation {
appendMessage: ({ conversationId, message }: AppendMessageProps) => Message[];
amendMessage: ({ conversationId, content }: AmendMessageProps) => void;
appendReplacements: ({
conversationId,
replacements,
}: AppendReplacementsProps) => Record<string, string>;
clearConversation: (conversationId: string) => void;
createConversation: ({ conversationId, messages }: CreateConversationProps) => Conversation;
clearConversation: (conversationId: string) => Promise<void>;
getDefaultConversation: ({ cTitle, messages }: CreateConversationProps) => Conversation;
deleteConversation: (conversationId: string) => void;
removeLastMessage: (conversationId: string) => Message[];
setApiConfig: ({ conversationId, apiConfig }: SetApiConfigProps) => void;
setConversation: ({ conversation }: SetConversationProps) => void;
removeLastMessage: (conversationId: string) => Promise<Message[] | undefined>;
setApiConfig: ({
conversation,
apiConfig,
}: SetApiConfigProps) => Promise<Conversation | undefined>;
createConversation: (conversation: Conversation) => Promise<Conversation | undefined>;
getConversation: (conversationId: string) => Promise<Conversation | undefined>;
}
export const useConversation = (): UseConversation => {
const {
allSystemPrompts,
assistantTelemetry,
knowledgeBase: { isEnabledKnowledgeBase, isEnabledRAGAlerts },
setConversations,
} = useAssistantContext();
const { allSystemPrompts, http, toasts } = useAssistantContext();
const getConversation = useCallback(
async (conversationId: string) => {
return getConversationById({ http, id: conversationId, toasts });
},
[http, toasts]
);
/**
* Removes the last message of conversation[] for a given conversationId
*/
const removeLastMessage = useCallback(
(conversationId: string) => {
async (conversationId: string) => {
let messages: Message[] = [];
setConversations((prev: Record<string, Conversation>) => {
const prevConversation: Conversation | undefined = prev[conversationId];
if (prevConversation != null) {
messages = prevConversation.messages.slice(0, prevConversation.messages.length - 1);
const newConversation = {
...prevConversation,
messages,
};
return {
...prev,
[conversationId]: newConversation,
};
} else {
return prev;
}
});
const prevConversation = await getConversationById({ http, id: conversationId, toasts });
if (prevConversation != null) {
messages = prevConversation.messages.slice(0, prevConversation.messages.length - 1);
await updateConversation({
http,
conversationId,
messages,
toasts,
});
}
return messages;
},
[setConversations]
);
/**
* Updates the last message of conversation[] for a given conversationId with provided content
*/
const amendMessage = useCallback(
({ conversationId, content }: AmendMessageProps) => {
setConversations((prev: Record<string, Conversation>) => {
const prevConversation: Conversation | undefined = prev[conversationId];
if (prevConversation != null) {
const { messages, ...rest } = prevConversation;
const message = messages[messages.length - 1];
const updatedMessages = message
? [...messages.slice(0, -1), { ...message, content }]
: [...messages];
const newConversation = {
...rest,
messages: updatedMessages,
};
return {
...prev,
[conversationId]: newConversation,
};
} else {
return prev;
}
});
},
[setConversations]
);
/**
* Append a message to the conversation[] for a given conversationId
*/
const appendMessage = useCallback(
({ conversationId, message }: AppendMessageProps): Message[] => {
assistantTelemetry?.reportAssistantMessageSent({
conversationId,
role: message.role,
isEnabledKnowledgeBase,
isEnabledRAGAlerts,
});
let messages: Message[] = [];
setConversations((prev: Record<string, Conversation>) => {
const prevConversation: Conversation | undefined = prev[conversationId];
if (prevConversation != null) {
messages = [...prevConversation.messages, message];
const newConversation = {
...prevConversation,
messages,
};
return {
...prev,
[conversationId]: newConversation,
};
} else {
return prev;
}
});
return messages;
},
[isEnabledKnowledgeBase, isEnabledRAGAlerts, assistantTelemetry, setConversations]
);
const appendReplacements = useCallback(
({ conversationId, replacements }: AppendReplacementsProps): Record<string, string> => {
let allReplacements = replacements;
setConversations((prev: Record<string, Conversation>) => {
const prevConversation: Conversation | undefined = prev[conversationId];
if (prevConversation != null) {
allReplacements = {
...prevConversation.replacements,
...replacements,
};
const newConversation = {
...prevConversation,
replacements: allReplacements,
};
return {
...prev,
[conversationId]: newConversation,
};
} else {
return prev;
}
});
return allReplacements;
},
[setConversations]
[http, toasts]
);
const clearConversation = useCallback(
(conversationId: string) => {
setConversations((prev: Record<string, Conversation>) => {
const prevConversation: Conversation | undefined = prev[conversationId];
async (conversationId: string) => {
const conversation = await getConversationById({ http, id: conversationId, toasts });
if (conversation && conversation.apiConfig) {
const defaultSystemPromptId = getDefaultSystemPrompt({
allSystemPrompts,
conversation: prevConversation,
conversation,
})?.id;
if (prevConversation != null) {
const newConversation: Conversation = {
...prevConversation,
apiConfig: {
...prevConversation.apiConfig,
defaultSystemPromptId,
},
messages: [],
replacements: undefined,
};
return {
...prev,
[conversationId]: newConversation,
};
} else {
return prev;
}
});
await updateConversation({
http,
toasts,
conversationId,
apiConfig: { ...conversation.apiConfig, defaultSystemPromptId },
messages: [],
replacements: [],
});
}
},
[allSystemPrompts, setConversations]
[allSystemPrompts, http, toasts]
);
/**
* Create a new conversation with the given conversationId, and optionally add messages
*/
const createConversation = useCallback(
({ conversationId, messages }: CreateConversationProps): Conversation => {
const defaultSystemPromptId = getDefaultSystemPrompt({
allSystemPrompts,
conversation: undefined,
})?.id;
const newConversation: Conversation = {
...DEFAULT_CONVERSATION_STATE,
apiConfig: {
...DEFAULT_CONVERSATION_STATE.apiConfig,
defaultSystemPromptId,
},
id: conversationId,
messages: messages != null ? messages : [],
};
setConversations((prev: Record<string, Conversation>) => {
const prevConversation: Conversation | undefined = prev[conversationId];
if (prevConversation != null) {
throw new Error('Conversation already exists!');
} else {
return {
...prev,
[conversationId]: {
...newConversation,
},
};
}
});
const getDefaultConversation = useCallback(
({ cTitle, messages }: CreateConversationProps): Conversation => {
const newConversation: Conversation =
cTitle === i18n.WELCOME_CONVERSATION_TITLE
? WELCOME_CONVERSATION
: {
...DEFAULT_CONVERSATION_STATE,
id: '',
title: cTitle,
messages: messages != null ? messages : [],
};
return newConversation;
},
[allSystemPrompts, setConversations]
[]
);
/**
* Create a new conversation with the given conversation
*/
const createConversation = useCallback(
async (conversation: Conversation): Promise<Conversation | undefined> => {
return createConversationApi({ http, conversation, toasts });
},
[http, toasts]
);
/**
* Delete the conversation with the given conversationId
*/
const deleteConversation = useCallback(
(conversationId: string): Conversation | undefined => {
let deletedConversation: Conversation | undefined;
setConversations((prev: Record<string, Conversation>) => {
const { [conversationId]: prevConversation, ...updatedConversations } = prev;
deletedConversation = prevConversation;
if (prevConversation != null) {
return updatedConversations;
}
return prev;
});
return deletedConversation;
async (conversationId: string): Promise<void> => {
await deleteConversationApi({ http, id: conversationId, toasts });
},
[setConversations]
[http, toasts]
);
/**
* Update the apiConfig for a given conversationId
* Create/Update the apiConfig for a given conversationId
*/
const setApiConfig = useCallback(
({ conversationId, apiConfig }: SetApiConfigProps): void => {
setConversations((prev: Record<string, Conversation>) => {
const prevConversation: Conversation | undefined = prev[conversationId];
if (prevConversation != null) {
const updatedConversation = {
...prevConversation,
async ({ conversation, apiConfig }: SetApiConfigProps) => {
if (conversation.id === '') {
return createConversationApi({
http,
conversation: {
apiConfig,
};
return {
...prev,
[conversationId]: updatedConversation,
};
} else {
return prev;
}
});
category: 'assistant',
title: conversation.title,
replacements: conversation.replacements,
excludeFromLastConversationStorage: conversation.excludeFromLastConversationStorage,
isDefault: conversation.isDefault,
id: '',
messages: conversation.messages ?? [],
},
toasts,
});
} else {
return updateConversation({
http,
conversationId: conversation.id,
apiConfig,
toasts,
});
}
},
[setConversations]
);
/**
* Set/overwrite an existing conversation (behaves as createConversation if not already existing)
*/
const setConversation = useCallback(
({ conversation }: SetConversationProps): void => {
setConversations((prev: Record<string, Conversation>) => {
return {
...prev,
[conversation.id]: conversation,
};
});
},
[setConversations]
[http, toasts]
);
return {
amendMessage,
appendMessage,
appendReplacements,
clearConversation,
createConversation,
getDefaultConversation,
deleteConversation,
removeLastMessage,
setApiConfig,
setConversation,
createConversation,
getConversation,
};
};

View file

@ -7,26 +7,12 @@
import { Conversation, Message } from '../../assistant_context/types';
import * as i18n from '../../content/prompts/welcome/translations';
import {
ELASTIC_AI_ASSISTANT,
ELASTIC_AI_ASSISTANT_TITLE,
WELCOME_CONVERSATION_TITLE,
} from './translations';
import { WELCOME_CONVERSATION_TITLE } from './translations';
export const WELCOME_CONVERSATION: Conversation = {
id: WELCOME_CONVERSATION_TITLE,
theme: {
title: ELASTIC_AI_ASSISTANT_TITLE,
titleIcon: 'logoSecurity',
assistant: {
name: ELASTIC_AI_ASSISTANT,
icon: 'logoSecurity',
},
system: {
icon: 'logoElastic',
},
user: {},
},
id: '',
title: WELCOME_CONVERSATION_TITLE,
category: 'assistant',
messages: [
{
role: 'assistant',
@ -56,7 +42,7 @@ export const WELCOME_CONVERSATION: Conversation = {
},
},
],
apiConfig: {},
replacements: [],
};
export const enterpriseMessaging: Message[] = [

View file

@ -7,31 +7,30 @@
import { HttpSetup } from '@kbn/core-http-browser';
import { useCallback, useState } from 'react';
import { ApiConfig, Replacement } from '@kbn/elastic-assistant-common';
import { useAssistantContext } from '../../assistant_context';
import { Conversation, Message } from '../../assistant_context/types';
import { fetchConnectorExecuteAction, FetchConnectorExecuteResponse } from '../api';
interface SendMessagesProps {
interface SendMessageProps {
allow?: string[];
allowReplacement?: string[];
apiConfig: Conversation['apiConfig'];
apiConfig: ApiConfig;
http: HttpSetup;
messages: Message[];
onNewReplacements: (newReplacements: Record<string, string>) => void;
replacements?: Record<string, string>;
message?: string;
conversationId: string;
replacements: Replacement[];
}
interface UseSendMessages {
interface UseSendMessage {
isLoading: boolean;
sendMessages: ({
sendMessage: ({
apiConfig,
http,
messages,
}: SendMessagesProps) => Promise<FetchConnectorExecuteResponse>;
message,
}: SendMessageProps) => Promise<FetchConnectorExecuteResponse>;
}
export const useSendMessages = (): UseSendMessages => {
export const useSendMessage = (): UseSendMessage => {
const {
alertsIndexPattern,
assistantStreamingEnabled,
@ -41,12 +40,13 @@ export const useSendMessages = (): UseSendMessages => {
} = useAssistantContext();
const [isLoading, setIsLoading] = useState(false);
const sendMessages = useCallback(
async ({ apiConfig, http, messages, onNewReplacements, replacements }: SendMessagesProps) => {
const sendMessage = useCallback(
async ({ apiConfig, http, message, conversationId, replacements }: SendMessageProps) => {
setIsLoading(true);
try {
return await fetchConnectorExecuteAction({
conversationId,
isEnabledRAGAlerts: knowledgeBase.isEnabledRAGAlerts, // settings toggle
alertsIndexPattern,
allow: defaultAllow,
@ -55,10 +55,9 @@ export const useSendMessages = (): UseSendMessages => {
isEnabledKnowledgeBase: knowledgeBase.isEnabledKnowledgeBase,
assistantStreamingEnabled,
http,
message,
replacements,
messages,
size: knowledgeBase.latestAlerts,
onNewReplacements,
});
} finally {
setIsLoading(false);
@ -75,5 +74,5 @@ export const useSendMessages = (): UseSendMessages => {
]
);
return { isLoading, sendMessages };
return { isLoading, sendMessage };
};

View file

@ -10,7 +10,7 @@ import { KnowledgeBaseConfig } from '../assistant/types';
export const DEFAULT_ASSISTANT_NAMESPACE = 'elasticAssistantDefault';
export const QUICK_PROMPT_LOCAL_STORAGE_KEY = 'quickPrompts';
export const SYSTEM_PROMPT_LOCAL_STORAGE_KEY = 'systemPrompts';
export const LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY = 'lastConversationId';
export const LAST_CONVERSATION_TITLE_LOCAL_STORAGE_KEY = 'lastConversationTitle';
export const KNOWLEDGE_BASE_LOCAL_STORAGE_KEY = 'knowledgeBase';
/** The default `n` latest alerts, ordered by risk score, sent as context to the assistant */

View file

@ -35,22 +35,22 @@ describe('AssistantContext', () => {
expect(result.current.http.fetch).toBeCalledWith(path);
});
test('getConversationId defaults to provided id', async () => {
test('getLastConversationTitle defaults to provided id', async () => {
const { result } = renderHook(useAssistantContext, { wrapper: TestProviders });
const id = result.current.getConversationId('123');
const id = result.current.getLastConversationTitle('123');
expect(id).toEqual('123');
});
test('getConversationId uses local storage id when no id is provided ', async () => {
test('getLastConversationTitle uses local storage id when no id is provided ', async () => {
const { result } = renderHook(useAssistantContext, { wrapper: TestProviders });
const id = result.current.getConversationId();
const id = result.current.getLastConversationTitle();
expect(id).toEqual('456');
});
test('getConversationId defaults to Welcome when no local storage id and no id is provided ', async () => {
test('getLastConversationTitle defaults to Welcome when no local storage id and no id is provided ', async () => {
(useLocalStorage as jest.Mock).mockReturnValue([undefined, jest.fn()]);
const { result } = renderHook(useAssistantContext, { wrapper: TestProviders });
const id = result.current.getConversationId();
const id = result.current.getLastConversationTitle();
expect(id).toEqual('Welcome');
});
});

View file

@ -8,13 +8,12 @@
import { EuiCommentProps } from '@elastic/eui';
import type { HttpSetup } from '@kbn/core-http-browser';
import { omit, uniq } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import type { IToasts } from '@kbn/core-notifications-browser';
import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public';
import { useLocalStorage } from 'react-use';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import { defaultAssistantFeatures } from '@kbn/elastic-assistant-common';
import { WELCOME_CONVERSATION_TITLE } from '../assistant/use_conversation/translations';
import { updatePromptContexts } from './helpers';
import type {
PromptContext,
@ -32,31 +31,35 @@ import {
DEFAULT_ASSISTANT_NAMESPACE,
DEFAULT_KNOWLEDGE_BASE_SETTINGS,
KNOWLEDGE_BASE_LOCAL_STORAGE_KEY,
LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY,
LAST_CONVERSATION_TITLE_LOCAL_STORAGE_KEY,
QUICK_PROMPT_LOCAL_STORAGE_KEY,
SYSTEM_PROMPT_LOCAL_STORAGE_KEY,
} from './constants';
import { CONVERSATIONS_TAB, SettingsTabs } from '../assistant/settings/assistant_settings';
import { AssistantAvailability, AssistantTelemetry } from './types';
import { useCapabilities } from '../assistant/api/capabilities/use_capabilities';
import { WELCOME_CONVERSATION_TITLE } from '../assistant/use_conversation/translations';
export interface ShowAssistantOverlayProps {
showOverlay: boolean;
promptContextId?: string;
conversationId?: string;
conversationTitle?: string;
}
type ShowAssistantOverlay = ({
showOverlay,
promptContextId,
conversationId,
conversationTitle,
}: ShowAssistantOverlayProps) => void;
export interface AssistantProviderProps {
actionTypeRegistry: ActionTypeRegistryContract;
alertsIndexPattern?: string;
assistantAvailability: AssistantAvailability;
assistantTelemetry?: AssistantTelemetry;
augmentMessageCodeBlocks: (currentConversation: Conversation) => CodeBlockDetails[][];
augmentMessageCodeBlocks: (
currentConversation: Conversation,
showAnonymizedValues: boolean
) => CodeBlockDetails[][];
baseAllow: string[];
baseAllowReplacement: string[];
defaultAllow: string[];
@ -68,28 +71,21 @@ export interface AssistantProviderProps {
docLinks: Omit<DocLinksStart, 'links'>;
children: React.ReactNode;
getComments: ({
amendMessage,
currentConversation,
isFetchingResponse,
refetchCurrentConversation,
regenerateMessage,
showAnonymizedValues,
}: {
amendMessage: ({
conversationId,
content,
}: {
conversationId: string;
content: string;
}) => void;
currentConversation: Conversation;
isFetchingResponse: boolean;
refetchCurrentConversation: () => void;
regenerateMessage: (conversationId: string) => void;
showAnonymizedValues: boolean;
}) => EuiCommentProps[];
http: HttpSetup;
getInitialConversations: () => Record<string, Conversation>;
baseConversations: Record<string, Conversation>;
nameSpace?: string;
setConversations: React.Dispatch<React.SetStateAction<Record<string, Conversation>>>;
setDefaultAllow: React.Dispatch<React.SetStateAction<string[]>>;
setDefaultAllowReplacement: React.Dispatch<React.SetStateAction<string[]>>;
title?: string;
@ -102,7 +98,10 @@ export interface UseAssistantContext {
assistantAvailability: AssistantAvailability;
assistantStreamingEnabled: boolean;
assistantTelemetry?: AssistantTelemetry;
augmentMessageCodeBlocks: (currentConversation: Conversation) => CodeBlockDetails[][];
augmentMessageCodeBlocks: (
currentConversation: Conversation,
showAnonymizedValues: boolean
) => CodeBlockDetails[][];
allQuickPrompts: QuickPrompt[];
allSystemPrompts: Prompt[];
baseAllow: string[];
@ -114,29 +113,22 @@ export interface UseAssistantContext {
basePromptContexts: PromptContextTemplate[];
baseQuickPrompts: QuickPrompt[];
baseSystemPrompts: Prompt[];
conversationIds: string[];
conversations: Record<string, Conversation>;
baseConversations: Record<string, Conversation>;
getComments: ({
currentConversation,
showAnonymizedValues,
amendMessage,
refetchCurrentConversation,
isFetchingResponse,
}: {
currentConversation: Conversation;
isFetchingResponse: boolean;
amendMessage: ({
conversationId,
content,
}: {
conversationId: string;
content: string;
}) => void;
refetchCurrentConversation: () => void;
regenerateMessage: () => void;
showAnonymizedValues: boolean;
}) => EuiCommentProps[];
http: HttpSetup;
knowledgeBase: KnowledgeBaseConfig;
getConversationId: (id?: string) => string;
getLastConversationTitle: (conversationTitle?: string) => string;
promptContexts: Record<string, PromptContext>;
modelEvaluatorEnabled: boolean;
nameSpace: string;
@ -144,11 +136,10 @@ export interface UseAssistantContext {
selectedSettingsTab: SettingsTabs;
setAllQuickPrompts: React.Dispatch<React.SetStateAction<QuickPrompt[] | undefined>>;
setAllSystemPrompts: React.Dispatch<React.SetStateAction<Prompt[] | undefined>>;
setConversations: React.Dispatch<React.SetStateAction<Record<string, Conversation>>>;
setDefaultAllow: React.Dispatch<React.SetStateAction<string[]>>;
setDefaultAllowReplacement: React.Dispatch<React.SetStateAction<string[]>>;
setKnowledgeBase: React.Dispatch<React.SetStateAction<KnowledgeBaseConfig | undefined>>;
setLastConversationId: React.Dispatch<React.SetStateAction<string | undefined>>;
setLastConversationTitle: React.Dispatch<React.SetStateAction<string | undefined>>;
setSelectedSettingsTab: React.Dispatch<React.SetStateAction<SettingsTabs>>;
setShowAssistantOverlay: (showAssistantOverlay: ShowAssistantOverlay) => void;
showAssistantOverlay: ShowAssistantOverlay;
@ -177,9 +168,8 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
children,
getComments,
http,
getInitialConversations,
baseConversations,
nameSpace = DEFAULT_ASSISTANT_NAMESPACE,
setConversations,
setDefaultAllow,
setDefaultAllowReplacement,
title = DEFAULT_ASSISTANT_TITLE,
@ -201,8 +191,8 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
baseSystemPrompts
);
const [localStorageLastConversationId, setLocalStorageLastConversationId] =
useLocalStorage<string>(`${nameSpace}.${LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY}`);
const [localStorageLastConversationTitle, setLocalStorageLastConversationTitle] =
useLocalStorage<string>(`${nameSpace}.${LAST_CONVERSATION_TITLE_LOCAL_STORAGE_KEY}`);
/**
* Local storage for knowledge base configuration, prefixed by assistant nameSpace
@ -257,43 +247,13 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
*/
const [selectedSettingsTab, setSelectedSettingsTab] = useState<SettingsTabs>(CONVERSATIONS_TAB);
const [conversations, setConversationsInternal] = useState(getInitialConversations());
const conversationIds = useMemo(() => Object.keys(conversations).sort(), [conversations]);
// TODO: This is a fix for conversations not loading out of localstorage. Also re-introduces our cascading render issue (as it loops back in localstorage)
useEffect(() => {
setConversationsInternal(getInitialConversations());
}, [getInitialConversations]);
const onConversationsUpdated = useCallback<
React.Dispatch<React.SetStateAction<Record<string, Conversation>>>
>(
(
newConversations:
| Record<string, Conversation>
| ((prev: Record<string, Conversation>) => Record<string, Conversation>)
) => {
if (typeof newConversations === 'function') {
const updater = newConversations;
setConversationsInternal((prevValue) => {
const newValue = updater(prevValue);
setConversations(newValue);
return newValue;
});
} else {
setConversations(newConversations);
setConversationsInternal(newConversations);
}
},
[setConversations]
);
const getConversationId = useCallback(
const getLastConversationTitle = useCallback(
// if a conversationId has been provided, use that
// if not, check local storage
// last resort, go to welcome conversation
(id?: string) => id ?? localStorageLastConversationId ?? WELCOME_CONVERSATION_TITLE,
[localStorageLastConversationId]
(conversationTitle?: string) =>
conversationTitle ?? localStorageLastConversationTitle ?? WELCOME_CONVERSATION_TITLE,
[localStorageLastConversationTitle]
);
// Fetch assistant capabilities
@ -317,8 +277,6 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
basePromptContexts,
baseQuickPrompts,
baseSystemPrompts,
conversationIds,
conversations,
defaultAllow: uniq(defaultAllow),
defaultAllowReplacement: uniq(defaultAllowReplacement),
docLinks,
@ -332,7 +290,6 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
selectedSettingsTab,
setAllQuickPrompts: setLocalStorageQuickPrompts,
setAllSystemPrompts: setLocalStorageSystemPrompts,
setConversations: onConversationsUpdated,
setDefaultAllow,
setDefaultAllowReplacement,
setKnowledgeBase: setLocalStorageKnowledgeBase,
@ -342,8 +299,9 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
title,
toasts,
unRegisterPromptContext,
getConversationId,
setLastConversationId: setLocalStorageLastConversationId,
getLastConversationTitle,
setLastConversationTitle: setLocalStorageLastConversationTitle,
baseConversations,
}),
[
actionTypeRegistry,
@ -352,39 +310,37 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
assistantStreamingEnabled,
assistantTelemetry,
augmentMessageCodeBlocks,
localStorageQuickPrompts,
localStorageSystemPrompts,
baseAllow,
baseAllowReplacement,
basePath,
basePromptContexts,
baseQuickPrompts,
baseSystemPrompts,
conversationIds,
conversations,
defaultAllow,
defaultAllowReplacement,
docLinks,
getComments,
http,
localStorageKnowledgeBase,
getConversationId,
localStorageQuickPrompts,
localStorageSystemPrompts,
modelEvaluatorEnabled,
nameSpace,
onConversationsUpdated,
promptContexts,
nameSpace,
registerPromptContext,
selectedSettingsTab,
setLocalStorageQuickPrompts,
setLocalStorageSystemPrompts,
setDefaultAllow,
setDefaultAllowReplacement,
setLocalStorageKnowledgeBase,
setLocalStorageLastConversationId,
setLocalStorageQuickPrompts,
setLocalStorageSystemPrompts,
showAssistantOverlay,
title,
toasts,
unRegisterPromptContext,
getLastConversationTitle,
setLocalStorageLastConversationTitle,
baseConversations,
]
);

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants';
import { ApiConfig, Replacement } from '@kbn/elastic-assistant-common';
export type ConversationRole = 'system' | 'user' | 'assistant';
@ -13,10 +13,11 @@ export interface MessagePresentation {
delay?: number;
stream?: boolean;
}
export interface Message {
role: ConversationRole;
reader?: ReadableStreamDefaultReader<Uint8Array>;
replacements?: Record<string, string>;
replacements?: Replacement[];
content?: string;
timestamp: string;
isError?: boolean;
@ -43,24 +44,25 @@ export interface ConversationTheme {
icon?: string;
};
}
/**
* Complete state to reconstruct a conversation instance.
* Includes all messages, connector configured, and relevant UI state.
*
*/
export interface Conversation {
apiConfig: {
connectorId?: string;
connectorTypeTitle?: string;
defaultSystemPromptId?: string;
provider?: OpenAiProviderType;
model?: string;
'@timestamp'?: string;
apiConfig?: ApiConfig;
user?: {
id?: string;
name?: string;
};
category: string;
id: string;
title: string;
messages: Message[];
replacements?: Record<string, string>;
theme?: ConversationTheme;
updatedAt?: Date;
createdAt?: Date;
replacements: Replacement[];
isDefault?: boolean;
excludeFromLastConversationStorage?: boolean;
}

View file

@ -10,6 +10,7 @@ import React, { useCallback, useMemo, useState } from 'react';
import { ActionConnector, ActionType } from '@kbn/triggers-actions-ui-plugin/public';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants';
import { useLoadConnectors } from '../use_load_connectors';
import * as i18n from '../translations';
import { useLoadActionTypes } from '../use_load_action_types';
@ -29,7 +30,10 @@ interface Props {
}
export type AIConnector = ActionConnector & {
// ex: Bedrock, OpenAI
connectorTypeTitle: string;
// related to OpenAI connectors, ex: Azure OpenAI, OpenAI
apiProvider?: OpenAiProviderType;
};
export const ConnectorSelector: React.FC<Props> = React.memo(
@ -49,22 +53,11 @@ export const ConnectorSelector: React.FC<Props> = React.memo(
const [selectedActionType, setSelectedActionType] = useState<ActionType | null>(null);
const {
data: connectorsWithoutActionContext,
data: aiConnectors,
isLoading: isLoadingConnectors,
isFetching: isFetchingConnectors,
refetch: refetchConnectors,
} = useLoadConnectors({ http });
const aiConnectors: AIConnector[] = useMemo(
() =>
connectorsWithoutActionContext
? connectorsWithoutActionContext.map((c) => ({
...c,
connectorTypeTitle: getActionTypeTitle(actionTypeRegistry.get(c.actionTypeId)),
}))
: [],
[actionTypeRegistry, connectorsWithoutActionContext]
);
} = useLoadConnectors({ actionTypeRegistry, http });
const isLoading = isLoadingConnectors || isFetchingConnectors;
const localIsDisabled = isDisabled || !assistantAvailability.hasConnectorsReadPrivilege;
@ -96,7 +89,7 @@ export const ConnectorSelector: React.FC<Props> = React.memo(
const connectorOptions = useMemo(
() =>
aiConnectors.map((connector) => {
(aiConnectors ?? []).map((connector) => {
const connectorTypeTitle =
getGenAiConfig(connector)?.apiProvider ?? connector.connectorTypeTitle;
const connectorDetails = connector.isPreconfigured
@ -146,7 +139,7 @@ export const ConnectorSelector: React.FC<Props> = React.memo(
return;
}
const connector = aiConnectors.find((c) => c.id === connectorId);
const connector = (aiConnectors ?? []).find((c) => c.id === connectorId);
if (connector) {
onConnectorSelectionChange(connector);
}

View file

@ -65,6 +65,7 @@ describe('ConnectorSelectorInline', () => {
isDisabled={false}
selectedConnectorId={undefined}
selectedConversation={undefined}
onConnectorSelected={jest.fn()}
/>
</TestProviders>
);
@ -74,8 +75,11 @@ describe('ConnectorSelectorInline', () => {
it('renders empty view if selectedConnectorId is NOT in list of connectors', () => {
const conversation: Conversation = {
id: 'conversation_id',
category: 'assistant',
messages: [],
apiConfig: {},
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
title: 'conversation_id',
};
const { getByText } = render(
<TestProviders>
@ -83,6 +87,7 @@ describe('ConnectorSelectorInline', () => {
isDisabled={false}
selectedConnectorId={'missing-connector-id'}
selectedConversation={conversation}
onConnectorSelected={jest.fn()}
/>
</TestProviders>
);
@ -91,8 +96,11 @@ describe('ConnectorSelectorInline', () => {
it('Clicking add connector button opens the connector selector', () => {
const conversation: Conversation = {
id: 'conversation_id',
category: 'assistant',
messages: [],
apiConfig: {},
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
title: 'conversation_id',
};
const { getByTestId, queryByTestId } = render(
<TestProviders>
@ -100,6 +108,7 @@ describe('ConnectorSelectorInline', () => {
isDisabled={false}
selectedConnectorId={'missing-connector-id'}
selectedConversation={conversation}
onConnectorSelected={jest.fn()}
/>
</TestProviders>
);
@ -111,8 +120,11 @@ describe('ConnectorSelectorInline', () => {
const connectorTwo = mockConnectors[1];
const conversation: Conversation = {
id: 'conversation_id',
category: 'assistant',
messages: [],
apiConfig: {},
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
title: 'conversation_id',
};
const { getByTestId, queryByTestId } = render(
<TestProviders>
@ -120,6 +132,7 @@ describe('ConnectorSelectorInline', () => {
isDisabled={false}
selectedConnectorId={'missing-connector-id'}
selectedConversation={conversation}
onConnectorSelected={jest.fn()}
/>
</TestProviders>
);
@ -134,14 +147,24 @@ describe('ConnectorSelectorInline', () => {
model: undefined,
provider: 'OpenAI',
},
conversationId: 'conversation_id',
conversation: {
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
category: 'assistant',
id: 'conversation_id',
messages: [],
title: 'conversation_id',
},
});
});
it('On connector change to add new connector, onchange event does nothing', () => {
const conversation: Conversation = {
id: 'conversation_id',
category: 'assistant',
messages: [],
apiConfig: {},
apiConfig: { connectorId: '123', connectorTypeTitle: 'OpenAI' },
replacements: [],
title: 'conversation_id',
};
const { getByTestId } = render(
<TestProviders>
@ -149,6 +172,7 @@ describe('ConnectorSelectorInline', () => {
isDisabled={false}
selectedConnectorId={'missing-connector-id'}
selectedConversation={conversation}
onConnectorSelected={jest.fn()}
/>
</TestProviders>
);

View file

@ -6,7 +6,7 @@
*/
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useState } from 'react';
import { css } from '@emotion/css';
import { AIConnector, ConnectorSelector } from '../connector_selector';
@ -15,7 +15,7 @@ import { useLoadConnectors } from '../use_load_connectors';
import * as i18n from '../translations';
import { useAssistantContext } from '../../assistant_context';
import { useConversation } from '../../assistant/use_conversation';
import { getActionTypeTitle, getGenAiConfig } from '../helpers';
import { getGenAiConfig } from '../helpers';
export const ADD_NEW_CONNECTOR = 'ADD_NEW_CONNECTOR';
@ -23,6 +23,7 @@ interface Props {
isDisabled?: boolean;
selectedConnectorId?: string;
selectedConversation?: Conversation;
onConnectorSelected: (conversation: Conversation) => void;
}
const inputContainerClassName = css`
@ -65,26 +66,18 @@ const placeholderButtonClassName = css`
* A compact wrapper of the ConnectorSelector component used in the Settings modal.
*/
export const ConnectorSelectorInline: React.FC<Props> = React.memo(
({ isDisabled = false, selectedConnectorId, selectedConversation }) => {
({ isDisabled = false, selectedConnectorId, selectedConversation, onConnectorSelected }) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const { actionTypeRegistry, assistantAvailability, http } = useAssistantContext();
const { setApiConfig } = useConversation();
const { data: connectorsWithoutActionContext } = useLoadConnectors({ http });
const aiConnectors: AIConnector[] = useMemo(
() =>
connectorsWithoutActionContext
? connectorsWithoutActionContext.map((c) => ({
...c,
connectorTypeTitle: getActionTypeTitle(actionTypeRegistry.get(c.actionTypeId)),
}))
: [],
[actionTypeRegistry, connectorsWithoutActionContext]
);
const { data: aiConnectors } = useLoadConnectors({
actionTypeRegistry,
http,
});
const selectedConnectorName =
aiConnectors.find((c) => c.id === selectedConnectorId)?.name ??
(aiConnectors ?? []).find((c) => c.id === selectedConnectorId)?.name ??
i18n.INLINE_CONNECTOR_PLACEHOLDER;
const localIsDisabled = isDisabled || !assistantAvailability.hasConnectorsReadPrivilege;
@ -93,7 +86,7 @@ export const ConnectorSelectorInline: React.FC<Props> = React.memo(
}, [isOpen]);
const onChange = useCallback(
(connector: AIConnector) => {
async (connector: AIConnector) => {
const connectorId = connector.id;
if (connectorId === ADD_NEW_CONNECTOR) {
return;
@ -105,8 +98,8 @@ export const ConnectorSelectorInline: React.FC<Props> = React.memo(
setIsOpen(false);
if (selectedConversation != null) {
setApiConfig({
conversationId: selectedConversation.id,
const conversation = await setApiConfig({
conversation: selectedConversation,
apiConfig: {
...selectedConversation.apiConfig,
connectorId,
@ -116,9 +109,13 @@ export const ConnectorSelectorInline: React.FC<Props> = React.memo(
model: model ?? config?.defaultModel,
},
});
if (conversation) {
onConnectorSelected(conversation);
}
}
},
[selectedConversation, setApiConfig]
[selectedConversation, setApiConfig, onConnectorSelected]
);
return (

View file

@ -9,14 +9,17 @@ import React from 'react';
import { useConnectorSetup } from '.';
import { act, renderHook } from '@testing-library/react-hooks';
import { fireEvent, render } from '@testing-library/react';
import { alertConvo, welcomeConvo } from '../../mock/conversation';
import { welcomeConvo } from '../../mock/conversation';
import { TestProviders } from '../../mock/test_providers/test_providers';
import { EuiCommentList } from '@elastic/eui';
const onSetupComplete = jest.fn();
const onConversationUpdate = jest.fn();
const defaultProps = {
conversation: welcomeConvo,
onSetupComplete,
onConversationUpdate,
};
const newConnector = { actionTypeId: '.gen-ai', name: 'cool name' };
jest.mock('../add_connector_modal', () => ({
@ -32,8 +35,7 @@ jest.mock('../add_connector_modal', () => ({
),
}));
const setConversation = jest.fn();
const setApiConfig = jest.fn();
const setApiConfig = jest.fn().mockResolvedValue(welcomeConvo);
const mockConversation = {
appendMessage: jest.fn(),
appendReplacements: jest.fn(),
@ -41,7 +43,6 @@ const mockConversation = {
createConversation: jest.fn(),
deleteConversation: jest.fn(),
setApiConfig,
setConversation,
};
jest.mock('../../assistant/use_conversation', () => ({
@ -56,18 +57,7 @@ describe('useConnectorSetup', () => {
it('should render comments and prompts', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useConnectorSetup(defaultProps), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
{children}
</TestProviders>
),
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
await waitForNextUpdate();
expect(
@ -78,7 +68,7 @@ describe('useConnectorSetup', () => {
timestamp: 'at: 7/17/2023, 1:00:36 PM',
},
{
username: 'Elastic AI Assistant',
username: 'Assistant',
timestamp: 'at: 7/17/2023, 1:00:40 PM',
},
]);
@ -89,18 +79,7 @@ describe('useConnectorSetup', () => {
it('should set api config for each conversation when new connector is saved', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useConnectorSetup(defaultProps), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
{children}
</TestProviders>
),
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
await waitForNextUpdate();
const { getByTestId, queryByTestId, rerender } = render(result.current.prompt, {
@ -112,7 +91,7 @@ describe('useConnectorSetup', () => {
rerender(result.current.prompt);
fireEvent.click(getByTestId('modal-mock'));
expect(setApiConfig).toHaveBeenCalledTimes(2);
expect(setApiConfig).toHaveBeenCalledTimes(1);
});
});
@ -147,21 +126,10 @@ describe('useConnectorSetup', () => {
expect(queryByTestId('connectorButton')).not.toBeInTheDocument();
});
});
it('should call onSetupComplete and setConversation when onHandleMessageStreamingComplete', async () => {
it('should call onSetupComplete and setConversations when onHandleMessageStreamingComplete', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useConnectorSetup(defaultProps), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
{children}
</TestProviders>
),
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
await waitForNextUpdate();
render(<EuiCommentList comments={result.current.comments} />, {
@ -170,7 +138,6 @@ describe('useConnectorSetup', () => {
expect(clearTimeout).toHaveBeenCalled();
expect(onSetupComplete).toHaveBeenCalled();
expect(setConversation).toHaveBeenCalled();
});
});
});

View file

@ -20,7 +20,7 @@ import { useLoadActionTypes } from '../use_load_action_types';
import { StreamingText } from '../../assistant/streaming_text';
import { ConnectorButton } from '../connector_button';
import { useConversation } from '../../assistant/use_conversation';
import { clearPresentationData, conversationHasNoPresentationData } from './helpers';
import { conversationHasNoPresentationData } from './helpers';
import * as i18n from '../translations';
import { useAssistantContext } from '../../assistant_context';
import { useLoadConnectors } from '../use_load_connectors';
@ -38,24 +38,27 @@ const SkipEuiText = styled(EuiText)`
export interface ConnectorSetupProps {
conversation?: Conversation;
onSetupComplete?: () => void;
onConversationUpdate: ({ cId, cTitle }: { cId: string; cTitle: string }) => Promise<void>;
}
export const useConnectorSetup = ({
conversation = WELCOME_CONVERSATION,
onSetupComplete,
onConversationUpdate,
}: ConnectorSetupProps): {
comments: EuiCommentProps[];
prompt: React.ReactElement;
} => {
const { appendMessage, setApiConfig, setConversation } = useConversation();
const { setApiConfig } = useConversation();
const bottomRef = useRef<HTMLDivElement | null>(null);
// Access all conversations so we can add connector to all on initial setup
const { actionTypeRegistry, conversations, http } = useAssistantContext();
const { actionTypeRegistry, http } = useAssistantContext();
const {
data: connectors,
isSuccess: areConnectorsFetched,
refetch: refetchConnectors,
} = useLoadConnectors({ http });
} = useLoadConnectors({ actionTypeRegistry, http });
const isConnectorConfigured = areConnectorsFetched && !!connectors?.length;
const [isConnectorModalVisible, setIsConnectorModalVisible] = useState<boolean>(false);
@ -67,15 +70,6 @@ export const useConnectorSetup = ({
const [selectedActionType, setSelectedActionType] = useState<ActionType | null>(null);
// User constants
const userName = useMemo(
() => conversation.theme?.user?.name ?? i18n.CONNECTOR_SETUP_USER_YOU,
[conversation.theme?.user?.name]
);
const assistantName = useMemo(
() => conversation.theme?.assistant?.name ?? i18n.CONNECTOR_SETUP_USER_ASSISTANT,
[conversation.theme?.assistant?.name]
);
const lastConversationMessageIndex = useMemo(
() => conversation.messages.length - 1,
[conversation.messages.length]
@ -108,8 +102,7 @@ export const useConnectorSetup = ({
setShowAddConnectorButton(true);
bottomRef.current?.scrollIntoView({ block: 'end' });
onSetupComplete?.();
setConversation({ conversation: clearPresentationData(conversation) });
}, [conversation, onSetupComplete, setConversation]);
}, [onSetupComplete]);
// Show button to add connector after last message has finished streaming
const handleSkipSetup = useCallback(() => {
@ -160,7 +153,7 @@ export const useConnectorSetup = ({
const isUser = message.role === 'user';
const commentProps: EuiCommentProps = {
username: isUser ? userName : assistantName,
username: isUser ? i18n.CONNECTOR_SETUP_USER_YOU : i18n.CONNECTOR_SETUP_USER_ASSISTANT,
children: commentBody(message, index, conversation.messages.length),
timelineAvatar: (
<EuiAvatar
@ -174,46 +167,35 @@ export const useConnectorSetup = ({
};
return commentProps;
}),
[assistantName, commentBody, conversation.messages, currentMessageIndex, userName]
[commentBody, conversation.messages, currentMessageIndex]
);
const onSaveConnector = useCallback(
(connector: ActionConnector) => {
async (connector: ActionConnector) => {
const config = getGenAiConfig(connector);
// add action type title to new connector
const connectorTypeTitle = getActionTypeTitle(actionTypeRegistry.get(connector.actionTypeId));
Object.values(conversations).forEach((c) => {
setApiConfig({
conversationId: c.id,
apiConfig: {
...c.apiConfig,
connectorId: connector.id,
connectorTypeTitle,
provider: config?.apiProvider,
model: config?.defaultModel,
},
});
});
// persist only the active conversation
refetchConnectors?.();
setIsConnectorModalVisible(false);
appendMessage({
conversationId: conversation.id,
message: {
role: 'assistant',
content: i18n.CONNECTOR_SETUP_COMPLETE,
timestamp: new Date().toLocaleString(),
const updatedConversation = await setApiConfig({
conversation,
apiConfig: {
...conversation.apiConfig,
connectorId: connector.id,
connectorTypeTitle,
provider: config?.apiProvider,
model: config?.defaultModel,
},
});
if (updatedConversation) {
onConversationUpdate({ cId: updatedConversation.id, cTitle: updatedConversation.title });
refetchConnectors?.();
setIsConnectorModalVisible(false);
}
},
[
actionTypeRegistry,
appendMessage,
conversation.id,
conversations,
refetchConnectors,
setApiConfig,
]
[actionTypeRegistry, conversation, onConversationUpdate, refetchConnectors, setApiConfig]
);
return {

View file

@ -41,7 +41,7 @@ export const PRECONFIGURED_CONNECTOR = i18n.translate(
export const CONNECTOR_SELECTOR_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.connectors.connectorSelector.ariaLabel',
{
defaultMessage: 'Conversation Selector',
defaultMessage: 'Connector Selector',
}
);

View file

@ -82,7 +82,10 @@ describe('useLoadConnectors', () => {
const { result, waitForNextUpdate } = renderHook(() => useLoadConnectors(defaultProps));
await waitForNextUpdate();
await expect(result.current).resolves.toStrictEqual(loadConnectorsResult);
await expect(result.current).resolves.toStrictEqual(
// @ts-ignore ts does not like config, but we define it in the mock data
loadConnectorsResult.map((c) => ({ ...c, apiProvider: c.config.apiProvider }))
);
});
});
it('should display error toast when api throws error', async () => {

View file

@ -10,9 +10,13 @@ import { useQuery } from '@tanstack/react-query';
import type { ServerError } from '@kbn/cases-plugin/public/types';
import { loadAllActions as loadConnectors } from '@kbn/triggers-actions-ui-plugin/public/common/constants';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public';
import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public';
import { HttpSetup } from '@kbn/core-http-browser';
import { IToasts } from '@kbn/core-notifications-browser';
import { useMemo } from 'react';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants';
import { AIConnector } from '../connector_selector';
import { getActionTypeTitle } from '../helpers';
import * as i18n from '../translations';
/**
@ -22,21 +26,62 @@ import * as i18n from '../translations';
const QUERY_KEY = ['elastic-assistant, load-connectors'];
export interface Props {
actionTypeRegistry: ActionTypeRegistryContract;
http: HttpSetup;
toasts?: IToasts;
}
const actionTypeKey = {
bedrock: '.bedrock',
openai: '.gen-ai',
};
export const useLoadConnectors = ({
actionTypeRegistry,
http,
toasts,
}: Props): UseQueryResult<ActionConnector[], IHttpFetchError> => {
}: Props): UseQueryResult<AIConnector[], IHttpFetchError> => {
const connectorDetails = useMemo(
() =>
actionTypeRegistry
? {
[actionTypeKey.bedrock]: getActionTypeTitle(
actionTypeRegistry.get(actionTypeKey.bedrock)
),
[actionTypeKey.openai]: getActionTypeTitle(
actionTypeRegistry.get(actionTypeKey.openai)
),
}
: {
[actionTypeKey.bedrock]: 'Amazon Bedrock',
[actionTypeKey.openai]: 'OpenAI',
},
[actionTypeRegistry]
);
return useQuery(
QUERY_KEY,
async () => {
const queryResult = await loadConnectors({ http });
return queryResult.filter(
(connector) =>
!connector.isMissingSecrets && ['.bedrock', '.gen-ai'].includes(connector.actionTypeId)
return queryResult.reduce(
(acc: AIConnector[], connector) => [
...acc,
...(!connector.isMissingSecrets &&
[actionTypeKey.bedrock, actionTypeKey.openai].includes(connector.actionTypeId)
? [
{
...connector,
connectorTypeTitle: connectorDetails[connector.actionTypeId],
apiProvider:
!connector.isPreconfigured &&
!connector.isSystemAction &&
connector?.config?.apiProvider
? (connector?.config?.apiProvider as OpenAiProviderType)
: undefined,
},
]
: []),
],
[]
);
},
{

View file

@ -58,6 +58,8 @@ describe('useDeleteKnowledgeBase', () => {
'/internal/elastic_assistant/knowledge_base/',
{
method: 'DELETE',
signal: undefined,
version: '1',
}
);
expect(toasts.addError).not.toHaveBeenCalled();
@ -80,6 +82,8 @@ describe('useDeleteKnowledgeBase', () => {
'/internal/elastic_assistant/knowledge_base/something',
{
method: 'DELETE',
signal: undefined,
version: '1',
}
);
});

View file

@ -57,6 +57,8 @@ describe('useKnowledgeBaseStatus', () => {
'/internal/elastic_assistant/knowledge_base/',
{
method: 'GET',
signal: undefined,
version: '1',
}
);
expect(toasts.addError).not.toHaveBeenCalled();
@ -73,6 +75,8 @@ describe('useKnowledgeBaseStatus', () => {
'/internal/elastic_assistant/knowledge_base/something',
{
method: 'GET',
signal: undefined,
version: '1',
}
);
});

View file

@ -57,6 +57,7 @@ describe('useSetupKnowledgeBase', () => {
'/internal/elastic_assistant/knowledge_base/',
{
method: 'POST',
version: '1',
}
);
expect(toasts.addError).not.toHaveBeenCalled();
@ -79,6 +80,7 @@ describe('useSetupKnowledgeBase', () => {
'/internal/elastic_assistant/knowledge_base/something',
{
method: 'POST',
version: '1',
}
);
});

View file

@ -6,7 +6,7 @@
*/
import { ActionType } from '@kbn/actions-plugin/common';
import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public';
import { AIConnector } from '../connectorland/connector_selector';
export const mockActionTypes = [
{
@ -31,9 +31,10 @@ export const mockActionTypes = [
} as ActionType,
];
export const mockConnectors: ActionConnector[] = [
export const mockConnectors: AIConnector[] = [
{
id: 'connectorId',
connectorTypeTitle: 'OpenAI',
name: 'Captain Connector',
isMissingSecrets: false,
actionTypeId: '.gen-ai',
@ -47,6 +48,7 @@ export const mockConnectors: ActionConnector[] = [
},
{
id: 'c29c28a0-20fe-11ee-9306-a1f4d42ec542',
connectorTypeTitle: 'OpenAI',
name: 'Professor Connector',
isMissingSecrets: false,
actionTypeId: '.gen-ai',

View file

@ -9,7 +9,9 @@ import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/c
import { Conversation } from '../..';
export const alertConvo: Conversation = {
id: 'Alert summary',
id: '',
title: 'Alert summary',
category: 'assistant',
isDefault: true,
messages: [
{
@ -21,33 +23,26 @@ export const alertConvo: Conversation = {
],
apiConfig: {
connectorId: 'c29c28a0-20fe-11ee-9306-a1f4d42ec542',
connectorTypeTitle: 'OpenAI',
provider: OpenAiProviderType.OpenAi,
},
replacements: {
'94277492-11f8-493b-9c52-c1c9ecd330d2': '192.168.0.4',
'67bf8338-261a-4de6-b43e-d30b59e884a7': '192.168.0.1',
'0b2e352b-35fc-47bd-a8d4-43019ed38a25': 'Stephs-MacBook-Pro.local',
},
replacements: [
{ uuid: '94277492-11f8-493b-9c52-c1c9ecd330d2', value: '192.168.0.4' },
{ uuid: '67bf8338-261a-4de6-b43e-d30b59e884a7', value: '192.168.0.1' },
{ uuid: '0b2e352b-35fc-47bd-a8d4-43019ed38a25', value: 'Stephs-MacBook-Pro.local' },
],
};
export const emptyWelcomeConvo: Conversation = {
id: 'Welcome',
id: '',
title: 'Welcome',
category: 'assistant',
isDefault: true,
theme: {
title: 'Elastic AI Assistant',
titleIcon: 'logoSecurity',
assistant: {
name: 'Elastic AI Assistant',
icon: 'logoSecurity',
},
system: {
icon: 'logoElastic',
},
user: {},
},
messages: [],
replacements: [],
apiConfig: {
connectorId: 'c29c28a0-20fe-11ee-9306-a1f4d42ec542',
connectorTypeTitle: 'OpenAI',
provider: OpenAiProviderType.OpenAi,
},
};
@ -71,11 +66,15 @@ export const welcomeConvo: Conversation = {
};
export const customConvo: Conversation = {
id: 'Custom option',
id: '',
category: 'assistant',
title: 'Custom option',
isDefault: false,
messages: [],
replacements: [],
apiConfig: {
connectorId: 'c29c28a0-20fe-11ee-9306-a1f4d42ec542',
connectorTypeTitle: 'OpenAI',
provider: OpenAiProviderType.OpenAi,
},
};

View file

@ -15,20 +15,17 @@ import { ThemeProvider } from 'styled-components';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AssistantProvider, AssistantProviderProps } from '../../assistant_context';
import { AssistantAvailability, Conversation } from '../../assistant_context/types';
import { AssistantAvailability } from '../../assistant_context/types';
interface Props {
assistantAvailability?: AssistantAvailability;
children: React.ReactNode;
getInitialConversations?: () => Record<string, Conversation>;
providerContext?: Partial<AssistantProviderProps>;
}
window.scrollTo = jest.fn();
window.HTMLElement.prototype.scrollIntoView = jest.fn();
const mockGetInitialConversations = () => ({});
export const mockAssistantAvailability: AssistantAvailability = {
hasAssistantPrivilege: false,
hasConnectorsAllPrivilege: true,
@ -40,7 +37,6 @@ export const mockAssistantAvailability: AssistantAvailability = {
export const TestProvidersComponent: React.FC<Props> = ({
assistantAvailability = mockAssistantAvailability,
children,
getInitialConversations = mockGetInitialConversations,
providerContext,
}) => {
const actionTypeRegistry = actionTypeRegistryMock.create();
@ -83,11 +79,10 @@ export const TestProvidersComponent: React.FC<Props> = ({
DOC_LINK_VERSION: 'current',
}}
getComments={mockGetComments}
getInitialConversations={getInitialConversations}
setConversations={jest.fn()}
setDefaultAllow={jest.fn()}
setDefaultAllowReplacement={jest.fn()}
http={mockHttp}
baseConversations={{}}
{...providerContext}
>
{children}

View file

@ -9,7 +9,7 @@ import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NewChatById } from '.';
import { NewChatByTitle } from '.';
const mockUseAssistantContext = {
showAssistantOverlay: jest.fn(),
@ -18,71 +18,73 @@ jest.mock('../assistant_context', () => ({
useAssistantContext: () => mockUseAssistantContext,
}));
describe('NewChatById', () => {
describe('NewChatByTitle', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('renders the default New Chat button with a discuss icon', () => {
render(<NewChatById />);
render(<NewChatByTitle />);
const newChatButton = screen.getByTestId('newChatById');
const newChatButton = screen.getByTestId('newChatByTitle');
expect(newChatButton.querySelector('[data-euiicon-type="discuss"]')).toBeInTheDocument();
});
it('renders the default "New Chat" text when children are NOT provided', () => {
render(<NewChatById />);
render(<NewChatByTitle />);
const newChatButton = screen.getByTestId('newChatById');
const newChatButton = screen.getByTestId('newChatByTitle');
expect(newChatButton.textContent).toContain('Chat');
});
it('renders custom children', async () => {
render(<NewChatById>{'🪄✨'}</NewChatById>);
render(<NewChatByTitle>{'🪄✨'}</NewChatByTitle>);
const newChatButton = screen.getByTestId('newChatById');
const newChatButton = screen.getByTestId('newChatByTitle');
expect(newChatButton.textContent).toContain('🪄✨');
});
it('renders custom icons', async () => {
render(<NewChatById iconType="help" />);
render(<NewChatByTitle iconType="help" />);
const newChatButton = screen.getByTestId('newChatById');
const newChatButton = screen.getByTestId('newChatByTitle');
expect(newChatButton.querySelector('[data-euiicon-type="help"]')).toBeInTheDocument();
});
it('does NOT render an icon when iconType is null', () => {
render(<NewChatById iconType={null} />);
render(<NewChatByTitle iconType={null} />);
const newChatButton = screen.getByTestId('newChatById');
const newChatButton = screen.getByTestId('newChatByTitle');
expect(newChatButton.querySelector('.euiButtonContent__icon')).not.toBeInTheDocument();
});
it('renders button icon when iconOnly is true', async () => {
render(<NewChatById iconOnly />);
render(<NewChatByTitle iconOnly />);
const newChatButton = screen.getByTestId('newChatById');
const newChatButton = screen.getByTestId('newChatByTitle');
expect(newChatButton.querySelector('[data-euiicon-type="discuss"]')).toBeInTheDocument();
expect(newChatButton.textContent).not.toContain('Chat');
});
it('calls showAssistantOverlay on click', () => {
const conversationId = 'test-conversation-id';
const conversationTitle = 'test-conversation-id';
const promptContextId = 'test-prompt-context-id';
render(<NewChatById conversationId={conversationId} promptContextId={promptContextId} />);
const newChatButton = screen.getByTestId('newChatById');
render(
<NewChatByTitle conversationTitle={conversationTitle} promptContextId={promptContextId} />
);
const newChatButton = screen.getByTestId('newChatByTitle');
userEvent.click(newChatButton);
expect(mockUseAssistantContext.showAssistantOverlay).toHaveBeenCalledWith({
conversationId,
conversationTitle,
promptContextId,
showOverlay: true,
});

View file

@ -15,7 +15,7 @@ import * as i18n from './translations';
export interface Props {
children?: React.ReactNode;
/** Optionally automatically add this context to a conversation when the assistant is shown */
conversationId?: string;
conversationTitle?: string;
/** Defaults to `discuss`. If null, the button will not have an icon */
iconType?: string | null;
/** Optionally specify a well known ID, or default to a UUID */
@ -24,9 +24,9 @@ export interface Props {
iconOnly?: boolean;
}
const NewChatByIdComponent: React.FC<Props> = ({
const NewChatByTitleComponent: React.FC<Props> = ({
children = i18n.NEW_CHAT,
conversationId,
conversationTitle,
iconType,
promptContextId,
iconOnly = false,
@ -34,15 +34,13 @@ const NewChatByIdComponent: React.FC<Props> = ({
const { showAssistantOverlay } = useAssistantContext();
// proxy show / hide calls to assistant context, using our internal prompt context id:
const showOverlay = useCallback(
() =>
showAssistantOverlay({
conversationId,
promptContextId,
showOverlay: true,
}),
[conversationId, promptContextId, showAssistantOverlay]
);
const showOverlay = useCallback(() => {
showAssistantOverlay({
conversationTitle,
promptContextId,
showOverlay: true,
});
}, [conversationTitle, promptContextId, showAssistantOverlay]);
const icon = useMemo(() => {
if (iconType === null) {
@ -57,7 +55,7 @@ const NewChatByIdComponent: React.FC<Props> = ({
iconOnly ? (
<EuiToolTip content={i18n.NEW_CHAT}>
<EuiButtonIcon
data-test-subj="newChatById"
data-test-subj="newChatByTitle"
iconType={icon ?? 'discuss'}
onClick={showOverlay}
color={'text'}
@ -66,7 +64,7 @@ const NewChatByIdComponent: React.FC<Props> = ({
</EuiToolTip>
) : (
<EuiButtonEmpty
data-test-subj="newChatById"
data-test-subj="newChatByTitle"
iconType={icon}
onClick={showOverlay}
aria-label={i18n.NEW_CHAT}
@ -78,10 +76,10 @@ const NewChatByIdComponent: React.FC<Props> = ({
);
};
NewChatByIdComponent.displayName = 'NewChatByIdComponent';
NewChatByTitleComponent.displayName = 'NewChatByTitleComponent';
/**
* `NewChatByID` displays a _New chat_ icon button by providing only the `promptContextId`
* `NewChatByTitle` displays a _New chat_ icon button by providing only the `promptContextId`
* of a context that was (already) registered by the `useAssistantOverlay` hook. You may
* optionally style the button icon, or override the default _New chat_ text with custom
* content, like {'🪄✨'}
@ -92,4 +90,4 @@ NewChatByIdComponent.displayName = 'NewChatByIdComponent';
* registered where the data is available, and then the _New chat_ button can be displayed
* in another part of the tree.
*/
export const NewChatById = React.memo(NewChatByIdComponent);
export const NewChatByTitle = React.memo(NewChatByTitleComponent);

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