diff --git a/x-pack/packages/kbn-elastic-assistant-common/constants.ts b/x-pack/packages/kbn-elastic-assistant-common/constants.ts new file mode 100755 index 0000000000000..dba89d807961b --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/constants.ts @@ -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`; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/helpers/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/helpers/index.ts index 410ef5fc8cc48..3aa74fe80ff0a 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/helpers/index.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/helpers/index.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { Replacement } from '../../schemas'; + export const getIsDataAnonymizable = (rawData: string | Record): boolean => typeof rawData !== 'string'; @@ -21,3 +23,31 @@ export const isAnonymized = ({ allowReplacementSet: Set; 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; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/transform_raw_data/index.test.tsx b/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/transform_raw_data/index.test.tsx index fb8b1c5b42f6b..7c3cf5c02b02a 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/transform_raw_data/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/transform_raw_data/index.test.tsx @@ -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, diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/transform_raw_data/index.tsx b/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/transform_raw_data/index.tsx index f1fe5e9331344..7665225927b94 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/transform_raw_data/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/transform_raw_data/index.tsx @@ -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 | undefined; + currentReplacements: Replacement[] | undefined; getAnonymizedValue: ({ currentReplacements, rawValue, @@ -27,7 +28,7 @@ export const transformRawData = ({ currentReplacements: Record | undefined; rawValue: string; }) => string; - onNewReplacements?: (replacements: Record) => void; + onNewReplacements?: (replacements: Replacement[]) => void; rawData: string | Record; }): string => { if (typeof rawData === 'string') { @@ -37,14 +38,22 @@ export const transformRawData = ({ const anonymizedData = getAnonymizedData({ allow, allowReplacement, - currentReplacements, + currentReplacements: currentReplacements?.reduce((acc: Record, 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); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/actions_connector/post_actions_connector_execute_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/actions_connector/post_actions_connector_execute_route.gen.ts new file mode 100644 index 0000000000000..d8af61a2e2d9c --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/actions_connector/post_actions_connector_execute_route.gen.ts @@ -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; +export const ExecuteConnectorRequestParams = z.object({ + /** + * The connector's `id` value. + */ + connectorId: z.string(), +}); +export type ExecuteConnectorRequestParamsInput = z.input; + +export type ExecuteConnectorRequestBody = z.infer; +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; + +export type ExecuteConnectorResponse = z.infer; +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(), +}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/actions_connector/post_actions_connector_execute_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/actions_connector/post_actions_connector_execute_route.schema.yaml new file mode 100644 index 0000000000000..936d28ce62589 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/actions_connector/post_actions_connector_execute_route.schema.yaml @@ -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 + diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen.ts new file mode 100644 index 0000000000000..f9f7900f62f24 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen.ts @@ -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; +export const BulkActionSkipReason = z.literal('ANONYMIZATION_FIELD_NOT_MODIFIED'); + +export type BulkActionSkipResult = z.infer; +export const BulkActionSkipResult = z.object({ + id: z.string(), + name: z.string().optional(), + skip_reason: BulkActionSkipReason, +}); + +export type AnonymizationFieldDetailsInError = z.infer; +export const AnonymizationFieldDetailsInError = z.object({ + id: z.string(), + name: z.string().optional(), +}); + +export type NormalizedAnonymizationFieldError = z.infer; +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; +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; +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; +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; +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; +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; +export const AnonymizationFieldCreateProps = z.object({ + field: z.string(), + defaultAllow: z.boolean().optional(), + defaultAllowReplacement: z.boolean().optional(), +}); + +export type AnonymizationFieldUpdateProps = z.infer; +export const AnonymizationFieldUpdateProps = z.object({ + id: z.string(), + defaultAllow: z.boolean().optional(), + defaultAllowReplacement: z.boolean().optional(), +}); + +export type PerformBulkActionRequestBody = z.infer; +export const PerformBulkActionRequestBody = z.object({ + delete: BulkActionBase.optional(), + create: z.array(AnonymizationFieldCreateProps).optional(), + update: z.array(AnonymizationFieldUpdateProps).optional(), +}); +export type PerformBulkActionRequestBodyInput = z.input; + +export type PerformBulkActionResponse = z.infer; +export const PerformBulkActionResponse = BulkCrudActionResponse; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.schema.yaml new file mode 100644 index 0000000000000..5df94fb538ace --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.schema.yaml @@ -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 + \ No newline at end of file diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/anonymization_fields/find_anonymization_fields_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/anonymization_fields/find_anonymization_fields_route.gen.ts new file mode 100644 index 0000000000000..58a1395c54a03 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/anonymization_fields/find_anonymization_fields_route.gen.ts @@ -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; +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; +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; +export const FindAnonymizationFieldsResponse = z.object({ + page: z.number().int(), + perPage: z.number().int(), + total: z.number().int(), + data: z.array(AnonymizationFieldResponse), +}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/anonymization_fields/find_anonymization_fields_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/anonymization_fields/find_anonymization_fields_route.schema.yaml new file mode 100644 index 0000000000000..3782fb2e4876a --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/anonymization_fields/find_anonymization_fields_route.schema.yaml @@ -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' diff --git a/x-pack/plugins/elastic_assistant/server/schemas/common.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/common.ts similarity index 100% rename from x-pack/plugins/elastic_assistant/server/schemas/common.ts rename to x-pack/packages/kbn-elastic-assistant-common/impl/schemas/common.ts diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/bulk_crud_conversations_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/bulk_crud_conversations_route.gen.ts new file mode 100644 index 0000000000000..bb401150bbee0 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/bulk_crud_conversations_route.gen.ts @@ -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; +export const BulkActionSkipReason = z.literal('CONVERSATION_NOT_MODIFIED'); + +export type BulkActionSkipResult = z.infer; +export const BulkActionSkipResult = z.object({ + id: z.string(), + name: z.string().optional(), + skip_reason: BulkActionSkipReason, +}); + +export type ConversationDetailsInError = z.infer; +export const ConversationDetailsInError = z.object({ + id: z.string(), + name: z.string().optional(), +}); + +export type NormalizedConversationError = z.infer; +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; +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; +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; +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; +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; +export const PerformBulkActionRequestBody = z.object({ + delete: BulkActionBase.optional(), + create: z.array(ConversationCreateProps).optional(), + update: z.array(ConversationUpdateProps).optional(), +}); +export type PerformBulkActionRequestBodyInput = z.input; + +export type PerformBulkActionResponse = z.infer; +export const PerformBulkActionResponse = BulkCrudActionResponse; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/bulk_crud_conversations_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/bulk_crud_conversations_route.schema.yaml new file mode 100644 index 0000000000000..790f4e5e85d5e --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/bulk_crud_conversations_route.schema.yaml @@ -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 + \ No newline at end of file diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.gen.ts new file mode 100644 index 0000000000000..b95d0f8918705 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.gen.ts @@ -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; +export const NonEmptyString = z + .string() + .min(1) + .regex(/^(?! *$).+$/); + +/** + * A universally unique identifier + */ +export type UUID = z.infer; +export const UUID = z.string().uuid(); + +/** + * Could be any string, not necessarily a UUID + */ +export type User = z.infer; +export const User = z.object({ + /** + * User id. + */ + id: z.string().optional(), + /** + * User name. + */ + name: z.string().optional(), +}); + +/** + * trace Data + */ +export type TraceData = z.infer; +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; +export const Replacement = z.object({ + /** + * Actual value was anonymized. + */ + value: z.string(), + uuid: UUID, +}); + +export type Reader = z.infer; +export const Reader = z.object({}).catchall(z.unknown()); + +/** + * Provider + */ +export type Provider = z.infer; +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; +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; +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; +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; +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; +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; +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; +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; +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; +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; +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; +export const ConversationMessageCreateProps = z.object({ + /** + * The conversation messages. + */ + messages: z.array(Message), +}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.schema.yaml new file mode 100644 index 0000000000000..1359543f2e588 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.schema.yaml @@ -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. + diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/crud_conversation_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/crud_conversation_route.gen.ts new file mode 100644 index 0000000000000..ed5f1b8f057c6 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/crud_conversation_route.gen.ts @@ -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; +export const AppendConversationMessageResponse = ConversationResponse; + +export type CreateConversationRequestBody = z.infer; +export const CreateConversationRequestBody = ConversationCreateProps; +export type CreateConversationRequestBodyInput = z.input; + +export type CreateConversationResponse = z.infer; +export const CreateConversationResponse = ConversationResponse; + +export type DeleteConversationRequestParams = z.infer; +export const DeleteConversationRequestParams = z.object({ + /** + * The conversation's `id` value. + */ + id: UUID, +}); +export type DeleteConversationRequestParamsInput = z.input; + +export type DeleteConversationResponse = z.infer; +export const DeleteConversationResponse = ConversationResponse; + +export type ReadConversationRequestParams = z.infer; +export const ReadConversationRequestParams = z.object({ + /** + * The conversation's `id` value. + */ + id: UUID, +}); +export type ReadConversationRequestParamsInput = z.input; + +export type ReadConversationResponse = z.infer; +export const ReadConversationResponse = ConversationResponse; + +export type UpdateConversationRequestParams = z.infer; +export const UpdateConversationRequestParams = z.object({ + /** + * The conversation's `id` value. + */ + id: UUID, +}); +export type UpdateConversationRequestParamsInput = z.input; + +export type UpdateConversationRequestBody = z.infer; +export const UpdateConversationRequestBody = ConversationUpdateProps; +export type UpdateConversationRequestBodyInput = z.input; + +export type UpdateConversationResponse = z.infer; +export const UpdateConversationResponse = ConversationResponse; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/crud_conversation_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/crud_conversation_route.schema.yaml new file mode 100644 index 0000000000000..a7f08659e76e3 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/crud_conversation_route.schema.yaml @@ -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 \ No newline at end of file diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/find_conversations_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/find_conversations_route.gen.ts new file mode 100644 index 0000000000000..16743f77b3efd --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/find_conversations_route.gen.ts @@ -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; +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; +export const SortOrder = z.enum(['asc', 'desc']); +export type SortOrderEnum = typeof SortOrder.enum; +export const SortOrderEnum = SortOrder.enum; + +export type FindConversationsRequestQuery = z.infer; +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; + +export type FindConversationsResponse = z.infer; +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), +}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/find_conversations_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/find_conversations_route.schema.yaml new file mode 100644 index 0000000000000..b44cebd1d3ec2 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/find_conversations_route.schema.yaml @@ -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' diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts index 4257cb9bae149..2a77b795de95f 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts @@ -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'; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.gen.ts new file mode 100644 index 0000000000000..634cd8cb6e78b --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.gen.ts @@ -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; +export const KnowledgeBaseResponse = z.object({ + /** + * Identify the success of the method execution. + */ + success: z.boolean().optional(), +}); + +export type CreateKnowledgeBaseRequestParams = z.infer; +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; +export const CreateKnowledgeBaseResponse = KnowledgeBaseResponse; + +export type DeleteKnowledgeBaseRequestParams = z.infer; +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; +export const DeleteKnowledgeBaseResponse = KnowledgeBaseResponse; + +export type ReadKnowledgeBaseRequestParams = z.infer; +export const ReadKnowledgeBaseRequestParams = z.object({ + /** + * The KnowledgeBase `resource` value. + */ + resource: z.string().optional(), +}); +export type ReadKnowledgeBaseRequestParamsInput = z.input; + +export type ReadKnowledgeBaseResponse = z.infer; +export const ReadKnowledgeBaseResponse = z.object({ + elser_exists: z.boolean().optional(), + index_exists: z.boolean().optional(), + pipeline_exists: z.boolean().optional(), +}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.schema.yaml new file mode 100644 index 0000000000000..650a7e141ce39 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.schema.yaml @@ -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. diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen.ts new file mode 100644 index 0000000000000..ce54c6a41fecc --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen.ts @@ -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; +export const BulkActionSkipReason = z.literal('PROMPT_FIELD_NOT_MODIFIED'); + +export type BulkActionSkipResult = z.infer; +export const BulkActionSkipResult = z.object({ + id: z.string(), + name: z.string().optional(), + skip_reason: BulkActionSkipReason, +}); + +export type PromptDetailsInError = z.infer; +export const PromptDetailsInError = z.object({ + id: z.string(), + name: z.string().optional(), +}); + +export type NormalizedPromptError = z.infer; +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; +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; +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; +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; +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; +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; +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; +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; +export const PerformBulkActionRequestBody = z.object({ + delete: BulkActionBase.optional(), + create: z.array(PromptCreateProps).optional(), + update: z.array(PromptUpdateProps).optional(), +}); +export type PerformBulkActionRequestBodyInput = z.input; + +export type PerformBulkActionResponse = z.infer; +export const PerformBulkActionResponse = BulkCrudActionResponse; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.schema.yaml new file mode 100644 index 0000000000000..2f6a419d9bf2c --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.schema.yaml @@ -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 + \ No newline at end of file diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/find_prompts_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/find_prompts_route.gen.ts new file mode 100644 index 0000000000000..7400b11f25c7a --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/find_prompts_route.gen.ts @@ -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; +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; +export const SortOrder = z.enum(['asc', 'desc']); +export type SortOrderEnum = typeof SortOrder.enum; +export const SortOrderEnum = SortOrder.enum; + +export type FindPromptsRequestQuery = z.infer; +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; + +export type FindPromptsResponse = z.infer; +export const FindPromptsResponse = z.object({ + page: z.number().int(), + perPage: z.number().int(), + total: z.number().int(), + data: z.array(PromptResponse), +}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/find_prompts_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/find_prompts_route.schema.yaml new file mode 100644 index 0000000000000..b5d3b25ca2018 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/find_prompts_route.schema.yaml @@ -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' diff --git a/x-pack/packages/kbn-elastic-assistant-common/index.ts b/x-pack/packages/kbn-elastic-assistant-common/index.ts index e285be395c71c..db5648151ce41 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/index.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/index.ts @@ -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'; diff --git a/x-pack/packages/kbn-elastic-assistant-common/tsconfig.json b/x-pack/packages/kbn-elastic-assistant-common/tsconfig.json index 94b099694eaf4..d4d082d9c13e5 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/tsconfig.json +++ b/x-pack/packages/kbn-elastic-assistant-common/tsconfig.json @@ -16,5 +16,8 @@ "target/**/*" ], "kbn_references": [ + "@kbn/zod-helpers", + "@kbn/securitysolution-io-ts-utils", + "@kbn/core", ] } diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/conversations.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/conversations.test.tsx new file mode 100644 index 0000000000000..0edb5e0f4a158 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/conversations.test.tsx @@ -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(); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/conversations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/conversations.ts new file mode 100644 index 0000000000000..54883e6aa8aef --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/conversations.ts @@ -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} + */ +export const getConversationById = async ({ + http, + id, + signal, + toasts, +}: GetConversationByIdParams): Promise => { + 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} + */ +export const createConversation = async ({ + http, + conversation, + signal, + toasts, +}: PostConversationParams): Promise => { + 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} + */ +export const deleteConversation = async ({ + http, + id, + signal, + toasts, +}: DeleteConversationParams): Promise => { + 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} + */ +export const updateConversation = async ({ + http, + toasts, + title, + conversationId, + messages, + apiConfig, + replacements, + excludeFromLastConversationStorage, + signal, +}: PutConversationMessageParams): Promise => { + 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; + } +}; diff --git a/x-pack/plugins/elastic_assistant/server/schemas/knowledge_base/post_knowledge_base.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/index.tsx similarity index 53% rename from x-pack/plugins/elastic_assistant/server/schemas/knowledge_base/post_knowledge_base.ts rename to x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/index.tsx index 60d7f1064cefd..606574b12233b 100644 --- a/x-pack/plugins/elastic_assistant/server/schemas/knowledge_base/post_knowledge_base.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/index.tsx @@ -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'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/use_bulk_actions_conversations.test.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/use_bulk_actions_conversations.test.ts new file mode 100644 index 0000000000000..011788adf083f --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/use_bulk_actions_conversations.test.ts @@ -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; + + 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('')); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/use_bulk_actions_conversations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/use_bulk_actions_conversations.ts new file mode 100644 index 0000000000000..a0ae2475233ac --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/use_bulk_actions_conversations.ts @@ -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; + create?: Record; + delete?: { + ids: string[]; + }; +} + +const transformCreateActions = ( + createActions: Record, + conversationIdsToDelete?: string[] +) => + Object.keys(createActions).reduce((conversationsToCreate: Conversation[], conversationId) => { + if (createActions && !conversationIdsToDelete?.includes(conversationId)) { + conversationsToCreate.push(createActions[conversationId]); + } + return conversationsToCreate; + }, []); + +const transformUpdateActions = ( + updateActions: Record, + 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( + 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 }, + }), + }); + } +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/use_fetch_current_user_conversations.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/use_fetch_current_user_conversations.test.tsx new file mode 100644 index 0000000000000..b890fe57247fe --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/use_fetch_current_user_conversations.test.tsx @@ -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 }) => ( + {children} + ); +}; + +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(); + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/use_fetch_current_user_conversations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/use_fetch_current_user_conversations.ts new file mode 100644 index 0000000000000..5a1478e6725b3 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/conversations/use_fetch_current_user_conversations.ts @@ -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; + 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( + ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_FIND, + { + method: 'GET', + version: ELASTIC_AI_ASSISTANT_API_CURRENT_VERSION, + query, + signal, + } + ); + return onFetch(res); + }); +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/index.test.tsx similarity index 80% rename from x-pack/packages/kbn-elastic-assistant/impl/assistant/api.test.tsx rename to x-pack/packages/kbn-elastic-assistant/impl/assistant/api/index.test.tsx index 26a37e12c4e53..585dc12e8e7c7 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/index.test.tsx @@ -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', } ); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/index.tsx similarity index 80% rename from x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx rename to x-pack/packages/kbn-elastic-assistant/impl/assistant/api/index.tsx index c18193c7fa0a6..bfd567d2f99ca 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/index.tsx @@ -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) => void; - replacements?: Record; + 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 => { - 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; + 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; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.test.tsx index a4c0e2fe20719..036320378f84f 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.test.tsx @@ -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(, { wrapper: TestProviders, @@ -41,7 +79,7 @@ describe('AssistantHeader', () => { const { getByText, getByTestId } = render( , { 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( - , + , { wrapper: TestProviders, } @@ -67,16 +101,28 @@ describe('AssistantHeader', () => { it('showAnonymizedValues is checked when currentConversation.replacements has values and showAnonymizedValues is true', () => { const { getByTestId } = render( - , + , { wrapper: TestProviders, } ); expect(getByTestId('showAnonymizedValues')).toHaveAttribute('aria-checked', 'true'); }); + + it('Conversation is updated when connector change occurs', async () => { + const { getByTestId } = render(, { + 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, + }); + }); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx index e4c4454859d34..8dc9aa56990c7 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx @@ -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; 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>; - setSelectedConversationId: React.Dispatch>; + setCurrentConversation: React.Dispatch>; shouldDisableKeyboardShortcut?: () => boolean; showAnonymizedValues: boolean; title: string | JSX.Element; + conversations: Record; + refetchConversationsState: () => Promise; } type Props = OwnProps; @@ -49,19 +50,20 @@ type Props = OwnProps; */ export const AssistantHeader: React.FC = ({ 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 = ({ showAnonymizedValues, [currentConversation.replacements, showAnonymizedValues] ); + const onConversationChange = useCallback( + (updatedConversation) => { + setCurrentConversation(updatedConversation); + onConversationSelected({ + cId: updatedConversation.id, + cTitle: updatedConversation.title, + }); + }, + [onConversationSelected, setCurrentConversation] + ); return ( <> = ({ isDisabled={isDisabled} docLinks={docLinks} selectedConversation={currentConversation} + onChange={onConversationChange} title={title} /> @@ -95,12 +108,13 @@ export const AssistantHeader: React.FC = ({ `} > <> @@ -125,13 +139,14 @@ export const AssistantHeader: React.FC = ({ diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx index e866cad765456..5f02af5d3175d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx @@ -29,11 +29,12 @@ const StyledEuiModal = styled(EuiModal)` */ export const AssistantOverlay = React.memo(() => { const [isModalVisible, setIsModalVisible] = useState(false); - const [conversationId, setConversationId] = useState( + const [conversationTitle, setConversationTitle] = useState( WELCOME_CONVERSATION_TITLE ); const [promptContextId, setPromptContextId] = useState(); - 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 && ( - + )} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.test.tsx index bdc9f71e42fc5..fa5f4347d5069 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.test.tsx @@ -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', () => { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.tsx index b3dcd0ae08429..d4dee2453fd0e 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.tsx @@ -32,7 +32,8 @@ export const AssistantTitle: React.FC<{ title: string | JSX.Element; docLinks: Omit; 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} /> diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.test.tsx index da03b876813bc..75672e8d7375d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.test.tsx @@ -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(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(sendMessages).toHaveBeenCalled(); - expect(appendMessage.mock.calls[0][0].message.content).toEqual(robotMessage.response); + 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, + }); }); }); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.tsx index 2c35ae9c495e4..e3bea5754ca7b 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.tsx @@ -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> >; setUserPrompt: React.Dispatch>; + refresh: () => Promise; + setCurrentConversation: React.Dispatch>; } 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) => - 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) => - appendReplacements({ - conversationId: currentConversation.id, - replacements: newReplacements, - }); - - const updatedMessages = removeLastMessage(currentConversation.id); + 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 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, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.test.tsx index 4ab45c10b021a..9399153a0583c 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.test.tsx @@ -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( - ({ - [alertConvo.id]: alertConvo, - [welcomeConvo.id]: welcomeConvo, - }), - }} - > + ); 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( - ({ - [alertConvo.id]: alertConvo, - [welcomeConvo.id]: welcomeConvo, - }), - }} - > + ); 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( - ({ - [alertConvo.id]: alertConvo, - [welcomeConvo.id]: welcomeConvo, - }), - }} - > + ); @@ -99,15 +95,8 @@ describe('Conversation selector', () => { it('We can add a custom option', () => { const { getByTestId } = render( - ({ - [alertConvo.id]: alertConvo, - [welcomeConvo.id]: welcomeConvo, - }), - }} - > - + + ); 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( - ({ - [alertConvo.id]: alertConvo, - [welcomeConvo.id]: welcomeConvo, - [customConvo.id]: customConvo, - }), - }} - > - + + ); 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( - ({ - [alertConvo.id]: alertConvo, - [welcomeConvo.id]: welcomeConvo, - [customConvo.id]: customConvo, - }), - }} - > - + + ); 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( - ({ - [alertConvo.id]: alertConvo, - [welcomeConvo.id]: welcomeConvo, - [customConvo.id]: customConvo, - }), - }} - > - + + ); 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( - ({ - [alertConvo.id]: alertConvo, - [welcomeConvo.id]: welcomeConvo, - [customConvo.id]: customConvo, - }), - }} - > - + + ); @@ -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( - ({ - [alertConvo.id]: alertConvo, - [welcomeConvo.id]: welcomeConvo, - [customConvo.id]: customConvo, - }), - }} - > - + + ); @@ -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( - ({ - [alertConvo.id]: alertConvo, - [welcomeConvo.id]: welcomeConvo, - [customConvo.id]: customConvo, - }), - }} - > - + + ); @@ -275,14 +227,13 @@ describe('Conversation selector', () => { it('Right arrow does nothing when conversation lenth is 1', () => { const { getByTestId } = render( - ({ - [welcomeConvo.id]: welcomeConvo, - }), - }} - > - + + ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.tsx index a671ad73acabe..7a7316c3e6678 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.tsx @@ -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; } -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 = 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 { deleteConversation, setConversation } = useConversation(); + const { allSystemPrompts } = useAssistantContext(); - const conversationIds = useMemo(() => Object.keys(conversations), [conversations]); + const { createConversation } = useConversation(); + const conversationTitles = useMemo(() => Object.keys(conversations), [conversations]); const conversationOptions = useMemo(() => { 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(() => { - 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 = 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 = React.memo( } }, [ - conversationIds.length, + conversationTitles.length, isDisabled, onLeftArrowClick, onRightArrowClick, @@ -187,8 +227,8 @@ export const ConversationSelector: React.FC = 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 = React.memo( OPTION_CONTENT_CLASSNAME: string ) => React.ReactNode = (option, searchValue, contentClassName) => { const { label, value } = option; + return ( = 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 = 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 = React.memo( iconType="arrowLeft" aria-label={i18n.PREVIOUS_CONVERSATION_TITLE} onClick={onLeftArrowClick} - disabled={isDisabled || conversationIds.length <= 1} + disabled={isDisabled || conversationTitles.length <= 1} /> } @@ -284,7 +325,7 @@ export const ConversationSelector: React.FC = React.memo( iconType="arrowRight" aria-label={i18n.NEXT_CONVERSATION_TITLE} onClick={onRightArrowClick} - disabled={isDisabled || conversationIds.length <= 1} + disabled={isDisabled || conversationTitles.length <= 1} /> } diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector_settings/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector_settings/index.test.tsx index 150784d9db4cf..e2c630bbedd14 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector_settings/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector_settings/index.test.tsx @@ -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(); - 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(); - fireEvent.change(getByTestId('comboBoxSearchInput'), { target: { value: alertConvo.id } }); + fireEvent.change(getByTestId('comboBoxSearchInput'), { target: { value: alertConvo.title } }); fireEvent.keyDown(getByTestId('comboBoxSearchInput'), { key: 'Enter', code: 'Enter', diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector_settings/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector_settings/index.tsx index 7e79740214a60..1060f1be75155 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector_settings/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector_settings/index.tsx @@ -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; + 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 = 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(() => { - 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 = 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 = React.memo( const newOption = { value: searchValue, label: searchValue, + id: '', }; if (!optionExists) { @@ -142,21 +155,21 @@ export const ConversationSelectorSettings: React.FC = 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 ( = React.memo( `} > = React.memo( onCreateOption={onCreateOption} renderOption={renderOption} compressed={true} + isDisabled={isDisabled} prepend={ } append={ @@ -242,7 +257,7 @@ export const ConversationSelectorSettings: React.FC = React.memo( data-test-subj="arrowRight" aria-label={i18n.NEXT_CONVERSATION_TITLE} onClick={onRightArrowClick} - disabled={conversationIds.length <= 1} + disabled={isDisabled || conversationTitles.length <= 1} /> } /> diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector_settings/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector_settings/translations.ts deleted file mode 100644 index 7ba634ded5f12..0000000000000 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector_settings/translations.ts +++ /dev/null @@ -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', - } -); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings.test.tsx index 25e2f757bea5b..fdfea79f4b073 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings.test.tsx @@ -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)} /> +