[Security Assistant] Add the option to delete bulk conversations for the AI Settings page (#223136)

## Summary

This PR adds a new api for deleting all conversations without providing
conversation ids. It takes excluded ids to skip the conversations that
are not going to be deleted.

example: Deleting all conversations except the latest one.

<img width="2558" alt="Screenshot 2025-06-19 at 10 28 19"
src="https://github.com/user-attachments/assets/eef4c9ef-1415-47b4-ad84-957bfd7f6874"
/>

```
delete `/app/management/kibana/securityAiAssistantManagement`
{"excludedIds":["7X-Dh5cBzHjHjpq0iVDH"]}
```

To test: (Test env:
https://p.elstc.co/paste/UjwhcpLK#-Bi4EAfwWwrJNg0kCHz4mZVDy8k16HtNlf9FdJgUM7K)
1. Add some conversations from dev tools:


<details>
  <summary><i>mock conversations:</i></summary>

```
POST .kibana-elastic-ai-assistant-conversations-default/_bulk
{ "create": {}}
{
  "@timestamp": "2025-06-10T08:00:00Z",
  "title": "Example Conversation 3",
  "api_config": {
      "action_type_id": ".inference",
      "connector_id": "elastic-llm"
    },
  "messages": [
    {
      "@timestamp": "2025-06-10T08:20:00Z",
      "content": "Tell me a joke.",
      "is_error": false,
      "role": "user",
      "metadata": {
        "content_references": {}
      }
    },
    {
      "@timestamp": "2025-06-10T08:20:01Z",
      "content": "Why did the chicken cross the road? To get to the other side!",
      "is_error": false,
      "role": "assistant",
      "metadata": {
        "content_references": {}
      }
    }
  ],
  "summary": {
    "@timestamp": "2025-06-10T08:20:01Z",
    "confidence": "low",
    "content": "User asked for a joke, assistant provided one.",
    "public": true
  },
  "users": [
    {
      "name": "elastic",
      "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0"
    }
  ],
  "replacements": {
    "uuid": "repl-003",
    "value": "value-003"
  }
}
{ "create": {}}
{
  "@timestamp": "2025-06-10T08:00:00Z",
  "title": "Example Conversation 4",
  "api_config": {
      "action_type_id": ".inference",
      "connector_id": "elastic-llm"
    },
  "messages": [
    {
      "@timestamp": "2025-06-10T08:30:00Z",
      "content": "What is the capital of France?",
      "is_error": false,
      "role": "user",
      "metadata": {
        "content_references": {}
      }
    },
    {
      "@timestamp": "2025-06-10T08:30:01Z",
      "content": "The capital of France is Paris.",
      "is_error": false,
      "role": "assistant",
      "metadata": {
        "content_references": {}
      }
    }
  ],
  "summary": {
    "@timestamp": "2025-06-10T08:30:01Z",
    "confidence": "high",
    "content": "User asked about the capital of France, assistant answered correctly.",
    "public": true
  },
  "users": [
    {
      "name": "elastic",
      "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0"
    }
  ],
  "replacements": {
    "uuid": "repl-004",
    "value": "value-004"
  }
}
{ "create": {}}
{
  "@timestamp": "2025-06-10T08:00:00Z",
  "title": "Example Conversation 5",
  "api_config": {
      "action_type_id": ".inference",
      "connector_id": "elastic-llm"
    },
  "messages": [
    {
      "@timestamp": "2025-06-10T08:40:00Z",
      "content": "How do I reset my password?",
      "is_error": false,
      "role": "user",
      "metadata": {
        "content_references": {}
      }
    },
    {
      "@timestamp": "2025-06-10T08:40:01Z",
      "content": "To reset your password, go to settings and click 'Reset Password'.",
      "is_error": false,
      "role": "assistant",
      "metadata": {
        "content_references": {}
      }
    }
  ],
  "summary": {
    "@timestamp": "2025-06-10T08:40:01Z",
    "confidence": "medium",
    "content": "User asked how to reset password, assistant provided instructions.",
    "public": true
  },
  "users": [
    {
      "name": "elastic",
      "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0"
    }
  ],
  "replacements": {
    "uuid": "repl-005",
    "value": "value-005"
  }
}
{ "create": {}}
{
  "@timestamp": "2025-06-10T08:00:00Z",
  "title": "Example Conversation 6",
  "api_config": {
      "action_type_id": ".inference",
      "connector_id": "elastic-llm"
    },
  "messages": [
    {
      "@timestamp": "2025-06-10T08:50:00Z",
      "content": "What is the meaning of life?",
      "is_error": false,
      "role": "user",
      "metadata": {
        "content_references": {}
      }
    },
    {
      "@timestamp": "2025-06-10T08:50:01Z",
      "content": "The meaning of life is subjective and varies for each individual.",
      "is_error": false,
      "role": "assistant",
      "metadata": {
        "content_references": {}
      }
    }
  ],
  "summary": {
    "@timestamp": "2025-06-10T08:50:01Z",
    "confidence": "low",
    "content": "User asked philosophical question, assistant provided general answer.",
    "public": true
  },
  "users": [
    {
      "name": "elastic",
      "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0"
    }
  ],
  "replacements": {
    "uuid": "repl-006",
    "value": "value-006"
  }
}
{ "create": {}}
{
  "@timestamp": "2025-06-10T08:00:00Z",
  "title": "Example Conversation 7",
  "api_config": {
      "action_type_id": ".inference",
      "connector_id": "elastic-llm"
    },
  "messages": [
    {
      "@timestamp": "2025-06-10T09:00:00Z",
      "content": "Can you help me with my homework?",
      "is_error": false,
      "role": "user",
      "metadata": {
        "content_references": {}
      }
    },
    {
      "@timestamp": "2025-06-10T09:00:01Z",
      "content": "Sure! What subject is your homework in?",
      "is_error": false,
      "role": "assistant",
      "metadata": {
        "content_references": {}
      }
    }
  ],
  "summary": {
    "@timestamp": "2025-06-10T09:00:01Z",
    "confidence": "high",
    "content": "User asked for homework help, assistant offered to assist.",
    "public": true
  },
  "users": [
    {
      "name": "elastic",
      "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0"
    }
  ],
  "replacements": {
    "uuid": "repl-007",
    "value": "value-007"
  }
}
{ "create": {}}
{
  "@timestamp": "2025-06-10T08:00:00Z",
  "title": "Example Conversation 8",
  "api_config": {
      "action_type_id": ".inference",
      "connector_id": "elastic-llm"
    },
  "messages": [
    {
      "@timestamp": "2025-06-10T09:10:00Z",
      "content": "What is the best programming language?",
      "is_error": false,
      "role": "user",
      "metadata": {
        "content_references": {}
      }
    },
    {
      "@timestamp": "2025-06-10T09:10:01Z",
      "content": "It depends on your needs, but Python is a great choice for beginners.",
      "is_error": false,
      "role": "assistant",
      "metadata": {
        "content_references": {}
      }
    }
  ],
  "summary": {
    "@timestamp": "2025-06-10T09:10:01Z",
    "confidence": "medium",
    "content": "User asked about programming languages, assistant provided recommendation.",
    "public": true
  },
  "users": [
    {
      "name": "elastic",
      "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0"
    }
  ],
  "replacements": {
    "uuid": "repl-008",
    "value": "value-008"
  }
}
{ "create": {}}
{
  "@timestamp": "2025-06-10T08:00:00Z",
  "title": "Example Conversation 9",
  "api_config": {
      "action_type_id": ".inference",
      "connector_id": "elastic-llm"
    },
  "messages": [
    {
      "@timestamp": "2025-06-10T09:20:00Z",
      "content": "How do I improve my writing skills?",
      "is_error": false,
      "role": "user",
      "metadata": {
        "content_references": {}
      }
    },
    {
      "@timestamp": "2025-06-10T09:20:01Z",
      "content": "Practice regularly, read widely, and seek feedback.",
      "is_error": false,
      "role": "assistant",
      "metadata": {
        "content_references": {}
      }
    }
  ],
  "summary": {
    "@timestamp": "2025-06-10T09:20:01Z",
    "confidence": "high",
    "content": "User asked about writing skills, assistant provided tips.",
    "public": true
  },
  "users": [
    {
      "name": "elastic",
      "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0"
    }
  ],
  "replacements": {
    "uuid": "repl-009",
    "value": "value-009"
  }
}
{ "create": {}}
{
  "@timestamp": "2025-06-10T08:00:00Z",
  "title": "Example Conversation 10",
  "api_config": {
      "action_type_id": ".inference",
      "connector_id": "elastic-llm"
    },
  "messages": [
    {
      "@timestamp": "2025-06-10T09:30:00Z",
      "content": "What is the best way to learn a new language?",
      "is_error": false,
      "role": "user",
      "metadata": {
        "content_references": {}
      }
    },
    {
      "@timestamp": "2025-06-10T09:30:01Z",
      "content": "Immersion, practice speaking, and using language learning apps are effective.",
      "is_error": false,
      "role": "assistant",
      "metadata": {
        "content_references": {}
      }
    }
  ],
  "summary": {
    "@timestamp": "2025-06-10T09:30:01Z",
    "confidence": "medium",
    "content": "User asked about language learning, assistant provided methods.",
    "public": true
  },
  "users": [
    {
      "name": "elastic",
      "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0"
    }
  ],
  "replacements": {
    "uuid": "repl-010",
    "value": "value-010"
  }
}
{ "create": {}}
{
  "@timestamp": "2025-06-10T08:00:00Z",
  "title": "Example Conversation 11",
  "api_config": {
      "action_type_id": ".inference",
      "connector_id": "elastic-llm"
    },
  "messages": [
    {
      "@timestamp": "2025-06-10T09:40:00Z",
      "content": "What are some tips for public speaking?",
      "is_error": false,
      "role": "user",
      "metadata": {
        "content_references": {}
      }
    },
    {
      "@timestamp": "2025-06-10T09:40:01Z",
      "content": "Practice, know your material, and engage with your audience.",
      "is_error": false,
      "role": "assistant",
      "metadata": {
        "content_references": {}
      }
    }
  ],
  "summary": {
    "@timestamp": "2025-06-10T09:40:01Z",
    "confidence": "high",
    "content": "User asked about public speaking, assistant provided tips.",
    "public": true
  },
  "users": [
    {
      "name": "elastic",
      "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0"
    }
  ],
  "replacements": {
    "uuid": "repl-011",
    "value": "value-011"
  }
}
{ "create": {}}
{
  "@timestamp": "2025-06-10T08:00:00Z",
  "title": "Example Conversation 12",
  "api_config": {
      "action_type_id": ".inference",
      "connector_id": "elastic-llm"
    },
  "messages": [
    {
      "@timestamp": "2025-06-10T09:50:00Z",
      "content": "How can I improve my time management skills?",
      "is_error": false,
      "role": "user",
      "metadata": {
        "content_references": {}
      }
    },
    {
      "@timestamp": "2025-06-10T09:50:01Z",
      "content": "Prioritize tasks, set deadlines, and use tools like calendars.",
      "is_error": false,
      "role": "assistant",
      "metadata": {
        "content_references": {}
      }
    }
  ],
  "summary": {
    "@timestamp": "2025-06-10T09:50:01Z",
    "confidence": "medium",
    "content": "User asked about time management, assistant provided strategies.",
    "public": true
  },
  "users": [
    {
      "name": "elastic",
      "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0"
    }
  ],
  "replacements": {
    "uuid": "repl-012",
    "value": "value-012"
  }
}
{ "create": {}}
{
  "@timestamp": "2025-06-10T08:00:00Z",
  "title": "Example Conversation 13",
  "api_config": {
      "action_type_id": ".inference",
      "connector_id": "elastic-llm"
    },
  "messages": [
    {
      "@timestamp": "2025-06-10T10:00:00Z",
      "content": "What are some effective study techniques?",
      "is_error": false,
      "role": "user",
      "metadata": {
        "content_references": {}
      }
    },
    {
      "@timestamp": "2025-06-10T10:00:01Z",
      "content": "Active recall, spaced repetition, and summarization are effective.",
      "is_error": false,
      "role": "assistant",
      "metadata": {
        "content_references": {}
      }
    }
  ],
  "summary": {
    "@timestamp": "2025-06-10T10:00:01Z",
    "confidence": "high",
    "content": "User asked about study techniques, assistant provided methods.",
    "public": true
  },
  "users": [
    {
      "name": "elastic",
      "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0"
    }
  ],
  "replacements": {
    "uuid": "repl-013",
    "value": "value-013"
  }
}
{ "create": {}}
{
  "@timestamp": "2025-06-10T08:00:00Z",
  "title": "Example Conversation 14",
  "api_config": {
      "action_type_id": ".inference",
      "connector_id": "elastic-llm"
    },
  "messages": [
    {
      "@timestamp": "2025-06-10T10:10:00Z",
      "content": "How can I enhance my critical thinking skills?",
      "is_error": false,
      "role": "user",
      "metadata": {
        "content_references": {}
      }
    },
    {
      "@timestamp": "2025-06-10T10:10:01Z",
      "content": "Engage in debates, analyze arguments, and reflect on your reasoning.",
      "is_error": false,
      "role": "assistant",
      "metadata": {
        "content_references": {}
      }
    }
  ],
  "summary": {
    "@timestamp": "2025-06-10T10:10:01Z",
    "confidence": "medium",
    "content": "User asked about critical thinking, assistant provided tips.",
    "public": true
  },
  "users": [
    {
      "name": "elastic",
      "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0"
    }
  ],
  "replacements": {
    "uuid": "repl-014",
    "value": "value-014"
  }
}
```

</details>

2. Visit `/app/management/kibana/securityAiAssistantManagement` and try
deleting conversations.

If it's a delete all request, the request should look like this:
```
delete `/app/management/kibana/securityAiAssistantManagement`
```


<img width="1281" alt="Screenshot 2025-06-16 at 12 59 01"
src="https://github.com/user-attachments/assets/9f9562f3-b5d8-4c3b-9418-8550ce24a6b0"
/>



### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Angela Chuang 2025-06-20 13:24:23 +01:00 committed by GitHub
parent c019b59b41
commit fde7604f47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1857 additions and 67 deletions

View file

@ -42235,6 +42235,63 @@ paths:
tags:
- Security AI Assistant API
/api/security_ai_assistant/current_user/conversations:
delete:
description: This endpoint allows users to permanently delete all conversations.
operationId: DeleteAllConversations
requestBody:
content:
application/json:
schema:
type: object
properties:
excludedIds:
description: Optional list of conversation IDs to delete.
example:
- abc123
- def456
items:
type: string
type: array
required: false
responses:
'200':
content:
application/json:
example:
success: true
schema:
type: object
properties:
failures:
items:
type: string
type: array
success:
example: true
type: boolean
totalDeleted:
example: 10
type: number
description: Indicates a successful call. The conversations were deleted successfully.
'400':
content:
application/json:
schema:
type: object
properties:
error:
example: Bad Request
type: string
message:
example: Invalid conversation ID
type: string
statusCode:
example: 400
type: number
description: Generic Error. This response indicates an issue with the request.
summary: Delete conversations
tags:
- Security AI Assistant API
post:
description: Create a new Security AI Assistant conversation. This endpoint allows the user to initiate a conversation with the Security AI Assistant by providing the required parameters.
operationId: CreateConversation

View file

@ -45205,6 +45205,63 @@ paths:
tags:
- Security AI Assistant API
/api/security_ai_assistant/current_user/conversations:
delete:
description: This endpoint allows users to permanently delete all conversations.
operationId: DeleteAllConversations
requestBody:
content:
application/json:
schema:
type: object
properties:
excludedIds:
description: Optional list of conversation IDs to delete.
example:
- abc123
- def456
items:
type: string
type: array
required: false
responses:
'200':
content:
application/json:
example:
success: true
schema:
type: object
properties:
failures:
items:
type: string
type: array
success:
example: true
type: boolean
totalDeleted:
example: 10
type: number
description: Indicates a successful call. The conversations were deleted successfully.
'400':
content:
application/json:
schema:
type: object
properties:
error:
example: Bad Request
type: string
message:
example: Invalid conversation ID
type: string
statusCode:
example: 400
type: number
description: Generic Error. This response indicates an issue with the request.
summary: Delete conversations
tags:
- Security AI Assistant API
post:
description: Create a new Security AI Assistant conversation. This endpoint allows the user to initiate a conversation with the Security AI Assistant by providing the required parameters.
operationId: CreateConversation

View file

@ -329,6 +329,66 @@ paths:
- Security AI Assistant API
- Chat Complete API
/api/security_ai_assistant/current_user/conversations:
delete:
description: This endpoint allows users to permanently delete all conversations.
operationId: DeleteAllConversations
requestBody:
content:
application/json:
schema:
type: object
properties:
excludedIds:
description: Optional list of conversation IDs to delete.
example:
- abc123
- def456
items:
type: string
type: array
required: false
responses:
'200':
content:
application/json:
example:
success: true
schema:
type: object
properties:
failures:
items:
type: string
type: array
success:
example: true
type: boolean
totalDeleted:
example: 10
type: number
description: >-
Indicates a successful call. The conversations were deleted
successfully.
'400':
content:
application/json:
schema:
type: object
properties:
error:
example: Bad Request
type: string
message:
example: Invalid conversation ID
type: string
statusCode:
example: 400
type: number
description: Generic Error. This response indicates an issue with the request.
summary: Delete conversations
tags:
- Security AI Assistant API
- Conversation API
post:
description: >-
Create a new Security AI Assistant conversation. This endpoint allows

View file

@ -329,6 +329,66 @@ paths:
- Security AI Assistant API
- Chat Complete API
/api/security_ai_assistant/current_user/conversations:
delete:
description: This endpoint allows users to permanently delete all conversations.
operationId: DeleteAllConversations
requestBody:
content:
application/json:
schema:
type: object
properties:
excludedIds:
description: Optional list of conversation IDs to delete.
example:
- abc123
- def456
items:
type: string
type: array
required: false
responses:
'200':
content:
application/json:
example:
success: true
schema:
type: object
properties:
failures:
items:
type: string
type: array
success:
example: true
type: boolean
totalDeleted:
example: 10
type: number
description: >-
Indicates a successful call. The conversations were deleted
successfully.
'400':
content:
application/json:
schema:
type: object
properties:
error:
example: Bad Request
type: string
message:
example: Invalid conversation ID
type: string
statusCode:
example: 400
type: number
description: Generic Error. This response indicates an issue with the request.
summary: Delete conversations
tags:
- Security AI Assistant API
- Conversation API
post:
description: >-
Create a new Security AI Assistant conversation. This endpoint allows

View file

@ -30,6 +30,24 @@ export type CreateConversationRequestBodyInput = z.input<typeof CreateConversati
export type CreateConversationResponse = z.infer<typeof CreateConversationResponse>;
export const CreateConversationResponse = ConversationResponse;
export type DeleteAllConversationsRequestBody = z.infer<typeof DeleteAllConversationsRequestBody>;
export const DeleteAllConversationsRequestBody = z.object({
/**
* Optional list of conversation IDs to delete.
*/
excludedIds: z.array(z.string()).optional(),
});
export type DeleteAllConversationsRequestBodyInput = z.input<
typeof DeleteAllConversationsRequestBody
>;
export type DeleteAllConversationsResponse = z.infer<typeof DeleteAllConversationsResponse>;
export const DeleteAllConversationsResponse = z.object({
success: z.boolean().optional(),
totalDeleted: z.number().optional(),
failures: z.array(z.string()).optional(),
});
export type DeleteConversationRequestParams = z.infer<typeof DeleteConversationRequestParams>;
export const DeleteConversationRequestParams = z.object({
/**

View file

@ -71,7 +71,63 @@ paths:
message:
type: string
example: "Missing required parameter: title"
delete:
x-codegen-enabled: true
x-labels: [ess, serverless]
operationId: DeleteAllConversations
description: This endpoint allows users to permanently delete all conversations.
summary: Delete conversations
tags:
- Conversation API
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
excludedIds:
type: array
items:
type: string
description: Optional list of conversation IDs to delete.
example: ["abc123", "def456"]
responses:
200:
description: Indicates a successful call. The conversations were deleted successfully.
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
example: true
totalDeleted:
type: number
example: 10
failures:
type: array
items:
type: string
example:
success: true
400:
description: Generic Error. This response indicates an issue with the request.
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
example: 400
error:
type: string
example: "Bad Request"
message:
type: string
example: "Invalid conversation ID"
/api/security_ai_assistant/current_user/conversations/{id}:
get:
x-codegen-enabled: true

View file

@ -0,0 +1,57 @@
/*
* 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 { httpServiceMock } from '@kbn/core-http-browser-mocks';
import {
API_VERSIONS,
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL,
} from '@kbn/elastic-assistant-common';
import { deleteAllConversations } from './delete_all_conversations';
import { IToasts } from '@kbn/core/public';
const mockAddError = jest.fn();
const toasts = {
addError: mockAddError,
} as unknown as IToasts;
describe('deleteAllConversations', () => {
let httpMock: ReturnType<typeof httpServiceMock.createSetupContract>;
beforeEach(() => {
httpMock = httpServiceMock.createSetupContract();
jest.clearAllMocks();
});
it('should send a POST request with the correct parameters and receive a successful response', async () => {
await deleteAllConversations({ http: httpMock, toasts });
expect(httpMock.fetch).toHaveBeenCalledWith(ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL, {
method: 'DELETE',
version: API_VERSIONS.public.v1,
body: JSON.stringify({
excludedIds: [],
}),
});
});
it('should handle cases where result.failure exists', async () => {
httpMock.fetch.mockResolvedValue({
success: false,
failures: [{ message: 'Error updating conversations for conversation Conversation 1' }],
});
await deleteAllConversations({ http: httpMock, toasts });
expect(mockAddError.mock.calls[0][0]).toEqual(new Error('Failed to delete all conversations'));
});
it('should handle error', async () => {
httpMock.fetch.mockRejectedValue(new Error('Error'));
await deleteAllConversations({ http: httpMock, toasts });
expect(mockAddError.mock.calls[0][0]).toEqual(new Error('Error'));
});
});

View file

@ -0,0 +1,57 @@
/*
* 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 { API_VERSIONS, DeleteAllConversationsResponse } from '@kbn/elastic-assistant-common';
import { ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL } from '@kbn/elastic-assistant-common/constants';
export const deleteAllConversations = async ({
http,
signal,
toasts,
excludedIds = [],
}: {
http: HttpSetup;
toasts?: IToasts;
signal?: AbortSignal | undefined;
excludedIds?: string[];
}) => {
try {
const result = await http.fetch<DeleteAllConversationsResponse>(
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL,
{
method: 'DELETE',
signal,
version: API_VERSIONS.public.v1,
body: JSON.stringify({ excludedIds }),
}
);
if (result?.failures) {
const error = new Error('Failed to delete all conversations');
toasts?.addError(error, {
title: i18n.translate('xpack.elasticAssistant.conversations.deleteAllError', {
defaultMessage: 'Failed to delete all conversations',
}),
toastMessage: result.failures.join(','),
});
}
return result;
} catch (error) {
toasts?.addError(error.body && error.body.message ? new Error(error.body.message) : error, {
title: i18n.translate('xpack.elasticAssistant.conversations.deleteAllConversationsError', {
defaultMessage: 'Error deleting conversations {error}',
values: {
error: error.message
? Array.isArray(error.message)
? error.message.join(',')
: error.message
: error,
},
}),
});
}
};

View file

@ -8,3 +8,4 @@
export * from './conversations';
export * from './bulk_update_actions_conversations';
export * from './use_fetch_current_user_conversations';
export * from './delete_all_conversations';

View file

@ -45,7 +45,7 @@ export const ElasticLlmCallout = ({ showEISCallout }: { showEISCallout: boolean
}, [showEISCallout, tourCompleted]);
if (!showCallOut) {
return;
return null;
}
return (

View file

@ -21,7 +21,7 @@ import { css } from '@emotion/react';
import { PromptTypeEnum } from '@kbn/elastic-assistant-common';
import { useConversationsUpdater } from '../../settings/use_settings_updater/use_conversations_updater';
import { Conversation } from '../../../assistant_context/types';
import { ConversationTableItem, useConversationsTable } from './use_conversations_table';
import { useConversationsTable } from './use_conversations_table';
import { ConversationStreamingSwitch } from '../conversation_settings/conversation_streaming_switch';
import { AIConnector } from '../../../connectorland/connector_selector';
import * as i18n from './translations';
@ -38,11 +38,16 @@ import {
useSessionPagination,
} from '../../common/components/assistant_settings_management/pagination/use_session_pagination';
import { AssistantSettingsBottomBar } from '../../settings/assistant_settings_bottom_bar';
import { Toolbar } from './tool_bar_component';
import { ConversationTableItem } from './types';
import { useConversationSelection } from './use_conversation_selection';
interface Props {
connectors: AIConnector[] | undefined;
defaultConnector?: AIConnector;
isDisabled?: boolean;
}
const ConversationSettingsManagementComponent: React.FC<Props> = ({
connectors,
defaultConnector,
@ -58,6 +63,26 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
const { data: allPrompts, refetch: refetchPrompts } = useFetchPrompts();
const [totalItemCount, setTotalItemCount] = useState(5);
const {
selectionState: {
isDeleteAll,
isExcludedMode,
deletedConversations,
totalSelectedConversations,
excludedIds,
},
selectionActions: {
handleUnselectAll,
handleSelectAll,
handlePageUnchecked,
handlePageChecked,
handleRowUnChecked,
handleRowChecked,
setDeletedConversations,
},
} = useConversationSelection();
const { onTableChange, pagination, sorting } = useSessionPagination<Conversation, false>({
nameSpace,
storageKey: CONVERSATION_TABLE_SESSION_STORAGE_KEY,
@ -66,6 +91,11 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
totalItemCount,
});
const deletedConversationsIds = useMemo(
() => deletedConversations.map((item) => item.id),
[deletedConversations]
);
const allSystemPrompts = useMemo(
() => allPrompts.data.filter((p) => p.promptType === PromptTypeEnum.system),
[allPrompts.data]
@ -97,6 +127,7 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
const {
assistantStreamingEnabled,
conversationsSettingsBulkActions,
onConversationsBulkDeleted,
onConversationDeleted,
resetConversationsSettings,
saveConversationsSettings,
@ -109,19 +140,32 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
const handleSave = useCallback(
async (param?: { callback?: () => void }) => {
const isSuccess = await saveConversationsSettings();
const { callback } = param ?? {};
const saveConversationsSettingsParams =
isDeleteAll || excludedIds.length > 0
? { isDeleteAll: true, excludedIds }
: { isDeleteAll: false };
const isSuccess = await saveConversationsSettings(saveConversationsSettingsParams);
if (isSuccess) {
toasts?.addSuccess({
iconType: 'check',
title: SETTINGS_UPDATED_TOAST_TITLE,
});
setHasPendingChanges(false);
param?.callback?.();
handleUnselectAll();
callback?.();
} else {
resetConversationsSettings();
}
},
[resetConversationsSettings, saveConversationsSettings, toasts]
[
excludedIds,
handleUnselectAll,
isDeleteAll,
resetConversationsSettings,
saveConversationsSettings,
toasts,
]
);
const setAssistantStreamingEnabled = useCallback(
@ -150,7 +194,6 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
openFlyout: openEditFlyout,
closeFlyout: closeEditFlyout,
} = useFlyoutModalVisibility();
const [deletedConversation, setDeletedConversation] = useState<ConversationTableItem | null>();
const {
isFlyoutOpen: deleteConfirmModalVisibility,
@ -168,15 +211,22 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
const onDeleteActionClicked = useCallback(
(rowItem: ConversationTableItem) => {
setDeletedConversation(rowItem);
setDeletedConversations([rowItem]);
onConversationDeleted(rowItem.id);
closeEditFlyout();
openConfirmModal();
},
[closeEditFlyout, onConversationDeleted, openConfirmModal]
[closeEditFlyout, onConversationDeleted, openConfirmModal, setDeletedConversations]
);
const onBulkDeleteActionClicked = useCallback(() => {
onConversationsBulkDeleted(deletedConversationsIds);
closeEditFlyout();
openConfirmModal();
}, [closeEditFlyout, deletedConversationsIds, onConversationsBulkDeleted, openConfirmModal]);
const onDeleteConfirmed = useCallback(() => {
if (Object.keys(conversationsSettingsBulkActions).length === 0) {
return;
@ -193,10 +243,10 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
]);
const onDeleteCancelled = useCallback(() => {
setDeletedConversation(null);
handleUnselectAll();
closeConfirmModal();
onCancelClick();
}, [closeConfirmModal, onCancelClick]);
}, [closeConfirmModal, handleUnselectAll, onCancelClick]);
const { getConversationsList, getColumns } = useConversationsTable();
@ -222,21 +272,48 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
const columns = useMemo(
() =>
getColumns({
isDeleteEnabled: () => true,
isEditEnabled: () => true,
conversationOptions,
deletedConversationsIds,
excludedIds,
handlePageChecked,
handlePageUnchecked,
handleRowChecked,
handleRowUnChecked,
isDeleteEnabled: () => !isDeleteAll && deletedConversations.length === 0,
isEditEnabled: () => !isDeleteAll && deletedConversations.length === 0,
isExcludedMode,
onDeleteActionClicked,
onEditActionClicked,
totalItemCount,
}),
[getColumns, onDeleteActionClicked, onEditActionClicked]
[
conversationOptions,
deletedConversations.length,
deletedConversationsIds,
excludedIds,
getColumns,
handlePageChecked,
handlePageUnchecked,
handleRowChecked,
handleRowUnChecked,
isDeleteAll,
isExcludedMode,
onDeleteActionClicked,
onEditActionClicked,
totalItemCount,
]
);
const confirmationTitle = useMemo(
() =>
deletedConversation?.title
? i18n.DELETE_CONVERSATION_CONFIRMATION_TITLE(deletedConversation?.title)
: i18n.DELETE_CONVERSATION_CONFIRMATION_DEFAULT_TITLE,
[deletedConversation?.title]
);
const confirmationTitle = useMemo(() => {
if (!deletedConversations) {
return;
}
return deletedConversations.length === 1
? deletedConversations[0]?.title
? i18n.DELETE_CONVERSATION_CONFIRMATION_TITLE(deletedConversations[0]?.title)
: i18n.DELETE_CONVERSATION_CONFIRMATION_DEFAULT_TITLE
: i18n.DELETE_MULTIPLE_CONVERSATIONS_CONFIRMATION_TITLE(totalSelectedConversations);
}, [deletedConversations, totalSelectedConversations]);
if (!conversationsLoaded) {
return null;
@ -260,12 +337,21 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
<EuiSpacer size="xs" />
<EuiText size="m">{i18n.CONVERSATIONS_LIST_DESCRIPTION}</EuiText>
<EuiSpacer size="s" />
<Toolbar
onConversationsBulkDeleted={onBulkDeleteActionClicked}
handleSelectAll={handleSelectAll}
handleUnselectAll={handleUnselectAll}
totalConversations={totalItemCount}
totalSelected={totalSelectedConversations}
isDeleteAll={isDeleteAll}
/>
<EuiBasicTable
items={conversationOptions}
columns={columns}
pagination={pagination}
sorting={sorting}
onChange={onTableChange}
itemId="id"
/>
</EuiPanel>
{editFlyoutVisible && (
@ -301,21 +387,21 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
)}
</Flyout>
)}
{deleteConfirmModalVisibility && deletedConversation?.title && (
<EuiConfirmModal
aria-labelledby={confirmationTitle}
title={confirmationTitle}
titleProps={{ id: deletedConversation?.id ?? undefined }}
onCancel={onDeleteCancelled}
onConfirm={onDeleteConfirmed}
cancelButtonText={CANCEL}
confirmButtonText={DELETE}
buttonColor="danger"
defaultFocusedButton="confirm"
>
<p />
</EuiConfirmModal>
)}
{deleteConfirmModalVisibility &&
(isDeleteAll || deletedConversations?.length > 0 || excludedIds?.length > 0) && (
<EuiConfirmModal
aria-labelledby={confirmationTitle}
title={confirmationTitle}
onCancel={onDeleteCancelled}
onConfirm={onDeleteConfirmed}
cancelButtonText={CANCEL}
confirmButtonText={DELETE}
buttonColor="danger"
defaultFocusedButton="confirm"
>
<p />
</EuiConfirmModal>
)}
<AssistantSettingsBottomBar
hasPendingChanges={hasPendingChanges}
onCancelClick={onCancelClick}

View file

@ -0,0 +1,245 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { InputCheckbox, PageSelectionCheckbox } from './table_selection_checkbox';
import { ConversationTableItem } from './types';
describe('PageSelectionCheckbox', () => {
it('should render null when conversationOptionsIds is empty', () => {
const { container } = render(
<PageSelectionCheckbox
conversationOptions={[]}
deletedConversationsIds={[]}
excludedIds={[]}
handlePageChecked={jest.fn()}
handlePageUnchecked={jest.fn()}
isExcludedMode={false}
totalItemCount={0}
/>
);
expect(container.firstChild).toBeNull();
});
it('page selection checkbox be checked isExcludedMode is true, and excludedIds does not includes conversationOptionsIds', () => {
const conversationOptions: ConversationTableItem[] = [
{ id: 'conversation1', title: 'Conversation 1' } as ConversationTableItem,
];
const deletedConversationsIds: string[] = [];
const excludedIds: string[] = ['conversation2'];
const handlePageChecked = jest.fn();
const handlePageUnchecked = jest.fn();
const isExcludedMode = true;
const totalItemCount = 2;
const { getByTestId } = render(
<PageSelectionCheckbox
conversationOptions={conversationOptions}
deletedConversationsIds={deletedConversationsIds}
excludedIds={excludedIds}
handlePageChecked={handlePageChecked}
handlePageUnchecked={handlePageUnchecked}
isExcludedMode={isExcludedMode}
totalItemCount={totalItemCount}
/>
);
const checkbox = getByTestId('conversationPageSelect');
expect(checkbox).toBeInTheDocument();
expect(checkbox).toBeChecked();
});
it('page selection checkbox should be unchecked when isExcludedMode is true, and not any conversationOptionsId are included in excludedIds', () => {
const conversationOptions: ConversationTableItem[] = [
{ id: 'conversation1', title: 'Conversation 1' } as ConversationTableItem,
];
const deletedConversationsIds: string[] = ['conversation2'];
const excludedIds: string[] = ['conversation1'];
const handlePageChecked = jest.fn();
const handlePageUnchecked = jest.fn();
const isExcludedMode = true;
const totalItemCount = 2;
const { getByTestId } = render(
<PageSelectionCheckbox
conversationOptions={conversationOptions}
deletedConversationsIds={deletedConversationsIds}
excludedIds={excludedIds}
handlePageChecked={handlePageChecked}
handlePageUnchecked={handlePageUnchecked}
isExcludedMode={isExcludedMode}
totalItemCount={totalItemCount}
/>
);
const checkbox = getByTestId('conversationPageSelect');
expect(checkbox).toBeInTheDocument();
expect(checkbox).not.toBeChecked();
});
it('page selection checkbox should be checked when isExcludedMode is false and every conversationOptionsIds is included in deletedConversationsIds', () => {
const conversationOptions: ConversationTableItem[] = [
{ id: 'conversation1', title: 'Conversation 1' } as ConversationTableItem,
{ id: 'conversation2', title: 'Conversation 2' } as ConversationTableItem,
];
const deletedConversationsIds: string[] = ['conversation1', 'conversation2'];
const excludedIds: string[] = [];
const handlePageChecked = jest.fn();
const handlePageUnchecked = jest.fn();
const isExcludedMode = false;
const totalItemCount = 2;
const { getByTestId } = render(
<PageSelectionCheckbox
conversationOptions={conversationOptions}
deletedConversationsIds={deletedConversationsIds}
excludedIds={excludedIds}
handlePageChecked={handlePageChecked}
handlePageUnchecked={handlePageUnchecked}
isExcludedMode={isExcludedMode}
totalItemCount={totalItemCount}
/>
);
const checkbox = getByTestId('conversationPageSelect');
expect(checkbox).toBeInTheDocument();
expect(checkbox).toBeChecked();
});
it('page selection checkbox should be unchecked when isExcludedMode is false and not all conversationOptionsIds are included in deletedConversationsIds', () => {
const conversationOptions: ConversationTableItem[] = [
{ id: 'conversation1', title: 'Conversation 1' } as ConversationTableItem,
{ id: 'conversation2', title: 'Conversation 2' } as ConversationTableItem,
];
const deletedConversationsIds: string[] = ['conversation1'];
const excludedIds: string[] = [];
const handlePageChecked = jest.fn();
const handlePageUnchecked = jest.fn();
const isExcludedMode = false;
const totalItemCount = 2;
const { getByTestId } = render(
<PageSelectionCheckbox
conversationOptions={conversationOptions}
deletedConversationsIds={deletedConversationsIds}
excludedIds={excludedIds}
handlePageChecked={handlePageChecked}
handlePageUnchecked={handlePageUnchecked}
isExcludedMode={isExcludedMode}
totalItemCount={totalItemCount}
/>
);
const checkbox = getByTestId('conversationPageSelect');
expect(checkbox).toBeInTheDocument();
expect(checkbox).not.toBeChecked();
});
});
describe('InputCheckbox', () => {
it('input checkbox should be checked when isExcludedMode is true, and conversationOptionsId is not included in excludedIds', () => {
const conversation: ConversationTableItem = {
id: 'conversation1',
title: 'Conversation 1',
} as ConversationTableItem;
const deletedConversationsIds: string[] = ['conversation2'];
const excludedIds: string[] = ['conversation2'];
const handleRowChecked = jest.fn();
const handleRowUnChecked = jest.fn();
const isExcludedMode = true;
const totalItemCount = 1;
const { getByTestId } = render(
<InputCheckbox
conversation={conversation}
deletedConversationsIds={deletedConversationsIds}
excludedIds={excludedIds}
isExcludedMode={isExcludedMode}
handleRowChecked={handleRowChecked}
handleRowUnChecked={handleRowUnChecked}
totalItemCount={totalItemCount}
/>
);
const checkbox = getByTestId(`conversationSelect-${conversation.id}`);
expect(checkbox).toBeInTheDocument();
expect(checkbox).toBeChecked();
});
it('input checkbox should be unchecked when isExcludedMode is true, and conversationOptionsId is included in excludedIds', () => {
const conversation: ConversationTableItem = {
id: 'conversation1',
title: 'Conversation 1',
} as ConversationTableItem;
const deletedConversationsIds: string[] = ['conversation2'];
const excludedIds: string[] = ['conversation1'];
const handleRowChecked = jest.fn();
const handleRowUnChecked = jest.fn();
const isExcludedMode = true;
const totalItemCount = 1;
const { getByTestId } = render(
<InputCheckbox
conversation={conversation}
deletedConversationsIds={deletedConversationsIds}
excludedIds={excludedIds}
isExcludedMode={isExcludedMode}
handleRowChecked={handleRowChecked}
handleRowUnChecked={handleRowUnChecked}
totalItemCount={totalItemCount}
/>
);
const checkbox = getByTestId(`conversationSelect-${conversation.id}`);
expect(checkbox).toBeInTheDocument();
expect(checkbox).not.toBeChecked();
});
it('input checkbox should be checked when isExcludedMode is false, and conversationOptionsId is included in deletedConversationsIds', () => {
const conversation: ConversationTableItem = {
id: 'conversation1',
title: 'Conversation 1',
} as ConversationTableItem;
const deletedConversationsIds: string[] = ['conversation1'];
const excludedIds: string[] = [];
const handleRowChecked = jest.fn();
const handleRowUnChecked = jest.fn();
const isExcludedMode = false;
const totalItemCount = 1;
const { getByTestId } = render(
<InputCheckbox
conversation={conversation}
deletedConversationsIds={deletedConversationsIds}
excludedIds={excludedIds}
isExcludedMode={isExcludedMode}
handleRowChecked={handleRowChecked}
handleRowUnChecked={handleRowUnChecked}
totalItemCount={totalItemCount}
/>
);
const checkbox = getByTestId(`conversationSelect-${conversation.id}`);
expect(checkbox).toBeInTheDocument();
expect(checkbox).toBeChecked();
});
it('input checkbox should be unchecked when isExcludedMode is false, and conversationOptionsId is not included in deletedConversationsIds', () => {
const conversation: ConversationTableItem = {
id: 'conversation1',
title: 'Conversation 1',
} as ConversationTableItem;
const deletedConversationsIds: string[] = [];
const excludedIds: string[] = [];
const handleRowChecked = jest.fn();
const handleRowUnChecked = jest.fn();
const isExcludedMode = false;
const totalItemCount = 1;
const { getByTestId } = render(
<InputCheckbox
conversation={conversation}
deletedConversationsIds={deletedConversationsIds}
excludedIds={excludedIds}
isExcludedMode={isExcludedMode}
handleRowChecked={handleRowChecked}
handleRowUnChecked={handleRowUnChecked}
totalItemCount={totalItemCount}
/>
);
const checkbox = getByTestId(`conversationSelect-${conversation.id}`);
expect(checkbox).toBeInTheDocument();
expect(checkbox).not.toBeChecked();
});
});

View file

@ -0,0 +1,119 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useMemo, useState } from 'react';
import { EuiCheckbox } from '@elastic/eui';
import {
ConversationTableItem,
HandlePageChecked,
HandlePageUnchecked,
HandleRowChecked,
HandleRowUnChecked,
} from './types';
export const PageSelectionCheckbox = ({
conversationOptions,
deletedConversationsIds,
excludedIds,
handlePageChecked,
handlePageUnchecked,
isExcludedMode,
totalItemCount,
}: {
conversationOptions: ConversationTableItem[];
deletedConversationsIds: string[];
excludedIds: string[];
handlePageChecked: HandlePageChecked;
handlePageUnchecked: HandlePageUnchecked;
isExcludedMode: boolean;
totalItemCount: number;
}) => {
const conversationOptionsIds = useMemo(
() => conversationOptions.map((item) => item.id),
[conversationOptions]
);
const [pageSelectionChecked, setPageSelectionChecked] = useState(
(!isExcludedMode &&
conversationOptionsIds.every((id) => deletedConversationsIds.includes(id))) ||
(isExcludedMode && !excludedIds.some((id) => conversationOptionsIds.includes(id)))
);
useEffect(() => {
setPageSelectionChecked(
(!isExcludedMode &&
conversationOptionsIds.every((id) => deletedConversationsIds.includes(id))) ||
(isExcludedMode && !excludedIds.some((id) => conversationOptionsIds.includes(id)))
);
}, [deletedConversationsIds, conversationOptionsIds, excludedIds, isExcludedMode]);
if (conversationOptionsIds.length === 0) {
return null;
}
return (
<EuiCheckbox
data-test-subj={`conversationPageSelect`}
id={`conversationPageSelect`}
checked={pageSelectionChecked}
onChange={(e) => {
if (e.target.checked) {
setPageSelectionChecked(true);
handlePageChecked({ conversationOptions, totalItemCount });
} else {
setPageSelectionChecked(false);
handlePageUnchecked({ conversationOptionsIds, totalItemCount });
}
}}
/>
);
};
export const InputCheckbox = ({
conversation,
deletedConversationsIds,
excludedIds,
isExcludedMode,
handleRowChecked,
handleRowUnChecked,
totalItemCount,
}: {
conversation: ConversationTableItem;
deletedConversationsIds: string[];
excludedIds: string[];
isExcludedMode: boolean;
handleRowChecked: HandleRowChecked;
handleRowUnChecked: HandleRowUnChecked;
totalItemCount: number;
}) => {
const [checked, setChecked] = useState(
(!isExcludedMode && deletedConversationsIds.includes(conversation.id)) ||
(isExcludedMode && !excludedIds.includes(conversation.id))
);
useEffect(() => {
setChecked(
(!isExcludedMode && deletedConversationsIds.includes(conversation.id)) ||
(isExcludedMode && !excludedIds.includes(conversation.id))
);
}, [deletedConversationsIds, conversation.id, excludedIds, isExcludedMode]);
return (
<EuiCheckbox
data-test-subj={`conversationSelect-${conversation.id}`}
id={`conversationSelect-${conversation.id}`}
checked={checked}
onChange={(e) => {
if (e.target.checked) {
setChecked(true);
handleRowChecked({ selectedItem: conversation, totalItemCount });
} else {
setChecked(false);
handleRowUnChecked({ selectedItem: conversation, totalItemCount });
}
}}
/>
);
};

View file

@ -0,0 +1,92 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import React, { useCallback } from 'react';
import * as i18n from './translations';
export interface Props {
onConversationsBulkDeleted: () => void;
handleSelectAll: (totalItemCount: number) => void;
handleUnselectAll: () => void;
totalConversations: number;
totalSelected: number;
isDeleteAll: boolean;
}
const ToolbarComponent: React.FC<Props> = ({
onConversationsBulkDeleted,
handleSelectAll,
handleUnselectAll,
totalConversations,
totalSelected,
isDeleteAll,
}) => {
const isAnySelected = totalSelected > 0 || isDeleteAll;
const onSelectAllClicked = useCallback(() => {
handleSelectAll(totalConversations);
}, [handleSelectAll, totalConversations]);
const onDeleteClicked = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
onConversationsBulkDeleted();
},
[onConversationsBulkDeleted]
);
if (totalConversations === 0) {
return null;
}
return (
<EuiFlexGroup alignItems="center" data-test-subj="toolbar" gutterSize="none">
<EuiFlexItem grow={false}>
{!isDeleteAll && (
<EuiButtonEmpty
data-test-subj="selectAllConversations"
iconType="pagesSelect"
onClick={onSelectAllClicked}
size="xs"
>
{i18n.SELECT_ALL_CONVERSATIONS(totalConversations)}
</EuiButtonEmpty>
)}
</EuiFlexItem>
{isAnySelected && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="unselectAllConversations"
onClick={handleUnselectAll}
size="xs"
>
{i18n.UNSELECT_ALL_CONVERSATIONS(totalConversations)}
</EuiButtonEmpty>
</EuiFlexItem>
)}
{isAnySelected && (
<EuiFlexItem grow={false}>
<EuiText color="subdued" data-test-subj="selectedFields" size="xs">
{i18n.SELECTED_CONVERSATIONS(isDeleteAll ? totalConversations : totalSelected)}
</EuiText>
</EuiFlexItem>
)}
{isAnySelected && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty size="xs" onClick={onDeleteClicked}>
{i18n.DELETE_SELECTED_CONVERSATIONS}
</EuiButtonEmpty>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};
ToolbarComponent.displayName = 'ToolbarComponent';
export const Toolbar = React.memo(ToolbarComponent);

View file

@ -75,3 +75,39 @@ export const DELETE_CONVERSATION_CONFIRMATION_TITLE = (conversationTitle: string
values: { conversationTitle },
defaultMessage: 'Delete "{conversationTitle}"?',
});
export const DELETE_MULTIPLE_CONVERSATIONS_CONFIRMATION_TITLE = (count: number) =>
i18n.translate(
'xpack.elasticAssistant.assistant.conversationSettings.deleteConfirmation.multipleTitle',
{
values: { count },
defaultMessage: 'Delete {count} conversations?',
}
);
export const SELECTED_CONVERSATIONS = (selected: number) =>
i18n.translate('xpack.elasticAssistant.assistant.conversationSettings.selectedConversations', {
values: { selected },
defaultMessage: 'Selected {selected} conversation{selected, plural, one {} other {s}}',
});
export const SELECT_ALL_CONVERSATIONS = (conversations: number) =>
i18n.translate('xpack.elasticAssistant.assistant.conversationSettings.selectAllConversations', {
values: { conversations },
defaultMessage:
'Select all {conversations} conversation{conversations, plural, one {} other {s}}',
});
export const UNSELECT_ALL_CONVERSATIONS = (conversations: number) =>
i18n.translate('xpack.elasticAssistant.assistant.conversationSettings.unselectAllConversations', {
values: { conversations },
defaultMessage:
'Unselect all {conversations} conversation{conversations, plural, one {} other {s}}',
});
export const DELETE_SELECTED_CONVERSATIONS = i18n.translate(
'xpack.elasticAssistant.assistant.conversationSettings.deleteSelectedConversations',
{
defaultMessage: 'Delete',
}
);

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Conversation } from '../../../assistant_context/types';
export type ConversationTableItem = Conversation & {
connectorTypeTitle?: string | null;
systemPromptTitle?: string | null;
};
export type HandlePageChecked = (params: {
conversationOptions: ConversationTableItem[];
totalItemCount: number;
}) => void;
export type HandlePageUnchecked = (params: {
conversationOptionsIds: string[];
totalItemCount: number;
}) => void;
export type HandleRowChecked = (params: {
selectedItem: ConversationTableItem;
totalItemCount: number;
}) => void;
export type HandleRowUnChecked = (params: {
selectedItem: ConversationTableItem;
totalItemCount: number;
}) => void;

View file

@ -0,0 +1,145 @@
/*
* 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';
import { useConversationSelection } from './use_conversation_selection';
import { ConversationTableItem } from './types';
describe('useConversationSelection', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => useConversationSelection());
expect(result.current.selectionState.isDeleteAll).toBe(false);
expect(result.current.selectionState.isExcludedMode).toBe(false);
expect(result.current.selectionState.deletedConversations).toEqual([]);
expect(result.current.selectionState.totalSelectedConversations).toBe(0);
expect(result.current.selectionState.excludedIds).toEqual([]);
});
it('should handle unselect all', () => {
const { result } = renderHook(() => useConversationSelection());
act(() => {
result.current.selectionActions.handleUnselectAll();
});
expect(result.current.selectionState.isDeleteAll).toBe(false);
expect(result.current.selectionState.isExcludedMode).toBe(false);
expect(result.current.selectionState.deletedConversations).toEqual([]);
expect(result.current.selectionState.totalSelectedConversations).toBe(0);
expect(result.current.selectionState.excludedIds).toEqual([]);
});
it('should handle select all', () => {
const totalItemCount = 5;
const { result } = renderHook(() => useConversationSelection());
act(() => {
result.current.selectionActions.handleSelectAll(totalItemCount);
});
expect(result.current.selectionState.isDeleteAll).toBe(true);
expect(result.current.selectionState.isExcludedMode).toBe(true);
expect(result.current.selectionState.totalSelectedConversations).toBe(totalItemCount);
});
it('should handle selecting all conversations on the current page', () => {
const { result } = renderHook(() => useConversationSelection());
const conversationOptions = [
{ id: '1', title: 'Conversation 1' },
{ id: '2', title: 'Conversation 2' },
] as ConversationTableItem[];
act(() => {
result.current.selectionActions.handleRowChecked({
selectedItem: conversationOptions[0],
totalItemCount: 2,
});
});
expect(result.current.selectionState.deletedConversations).toEqual([conversationOptions[0]]);
expect(result.current.selectionState.totalSelectedConversations).toBe(1);
expect(result.current.selectionState.isDeleteAll).toBe(false);
act(() => {
result.current.selectionActions.handlePageChecked({
conversationOptions,
totalItemCount: 2,
});
});
expect(result.current.selectionState.deletedConversations).toEqual(conversationOptions);
expect(result.current.selectionState.totalSelectedConversations).toBe(2);
expect(result.current.selectionState.isDeleteAll).toBe(true);
});
it('should handle page unselected', () => {
const { result } = renderHook(() => useConversationSelection());
const conversationOptions = [
{ id: '1', title: 'Conversation 1' },
{ id: '2', title: 'Conversation 2' },
] as ConversationTableItem[];
const conversationOptionsIds = conversationOptions.map((item) => item.id);
act(() => {
result.current.selectionActions.handlePageChecked({
conversationOptions,
totalItemCount: 2,
});
});
expect(result.current.selectionState.excludedIds).toEqual([]);
expect(result.current.selectionState.totalSelectedConversations).toBe(2);
expect(result.current.selectionState.deletedConversations).toEqual(conversationOptions);
expect(result.current.selectionState.isDeleteAll).toBe(true);
expect(result.current.selectionState.isExcludedMode).toBe(true);
act(() => {
result.current.selectionActions.handlePageUnchecked({
conversationOptionsIds,
totalItemCount: 2,
});
});
expect(result.current.selectionState.excludedIds).toEqual(['1', '2']);
expect(result.current.selectionState.totalSelectedConversations).toBe(0);
expect(result.current.selectionState.deletedConversations).toEqual([]);
expect(result.current.selectionState.isDeleteAll).toBe(false);
expect(result.current.selectionState.isExcludedMode).toBe(true);
});
it('should handle row checked', () => {
const { result } = renderHook(() => useConversationSelection());
const conversation = { id: '1', title: 'Conversation 1' } as ConversationTableItem;
act(() => {
result.current.selectionActions.handleRowChecked({
selectedItem: conversation,
totalItemCount: 1,
});
});
expect(result.current.selectionState.deletedConversations).toEqual([conversation]);
expect(result.current.selectionState.totalSelectedConversations).toBe(1);
expect(result.current.selectionState.isDeleteAll).toBe(true);
expect(result.current.selectionState.isExcludedMode).toBe(true);
});
it('should handle row unchecked', () => {
const { result } = renderHook(() => useConversationSelection());
const conversation = { id: '1', title: 'Conversation 1' } as ConversationTableItem;
act(() => {
result.current.selectionActions.handleRowChecked({
selectedItem: conversation,
totalItemCount: 1,
});
});
expect(result.current.selectionState.deletedConversations).toEqual([conversation]);
expect(result.current.selectionState.totalSelectedConversations).toBe(1);
expect(result.current.selectionState.isDeleteAll).toBe(true);
expect(result.current.selectionState.isExcludedMode).toBe(true);
act(() => {
result.current.selectionActions.handleRowUnChecked({
selectedItem: conversation,
totalItemCount: 1,
});
});
expect(result.current.selectionState.deletedConversations).toEqual([]);
expect(result.current.selectionState.totalSelectedConversations).toBe(0);
expect(result.current.selectionState.isDeleteAll).toBe(false);
expect(result.current.selectionState.isExcludedMode).toBe(true);
expect(result.current.selectionState.excludedIds).toEqual(['1']);
});
});

View file

@ -0,0 +1,164 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useCallback, useState } from 'react';
import { ConversationTableItem } from './types';
const EMPTY_CONVERSATIONS_ARRAY: ConversationTableItem[] = [];
const EMPTY_CONVERSATIONS_IDS_ARRAY: string[] = [];
export const useConversationSelection = () => {
const [isDeleteAll, setIsDeleteAll] = useState(false);
const [isExcludedMode, setIsExcludedMode] = useState(false);
const [deletedConversations, setDeletedConversations] = useState(EMPTY_CONVERSATIONS_ARRAY);
const [totalSelectedConversations, setTotalSelectedConversations] = useState(0);
const [excludedIds, setExcludedIds] = useState<string[]>(EMPTY_CONVERSATIONS_IDS_ARRAY);
const handleUnselectAll = useCallback(() => {
setIsDeleteAll(false);
setIsExcludedMode(false);
setDeletedConversations([]);
setTotalSelectedConversations(0);
setExcludedIds([]);
}, []);
const handleSelectAll = useCallback((totalItemCount: number) => {
setIsDeleteAll(true);
setIsExcludedMode(true);
setTotalSelectedConversations(totalItemCount);
setExcludedIds([]);
}, []);
const handlePageChecked = useCallback(
({
conversationOptions,
totalItemCount,
}: {
conversationOptions: ConversationTableItem[];
totalItemCount: number;
}) => {
const conversationOptionsIds = conversationOptions.map((item) => item.id);
const deletedConversationsIds = deletedConversations.map((item) => item.id);
if (isExcludedMode) {
const newExcludedIds = excludedIds.filter((item) => !conversationOptionsIds.includes(item));
setExcludedIds(newExcludedIds);
setTotalSelectedConversations(
(prev) => prev + conversationOptionsIds.filter((id) => excludedIds.includes(id)).length
);
} else {
const newDeletedConversations = conversationOptions.reduce(
(acc, curr) => {
if (!deletedConversationsIds.includes(curr.id)) {
acc.push(curr);
}
return acc;
},
[...deletedConversations]
);
setDeletedConversations(newDeletedConversations);
setTotalSelectedConversations(
(prev) =>
prev +
conversationOptionsIds.filter((id) => !deletedConversationsIds.includes(id)).length
);
if (newDeletedConversations.length === totalItemCount) {
setIsDeleteAll(true);
setIsExcludedMode(true);
}
}
},
[deletedConversations, excludedIds, isExcludedMode]
);
const handlePageUnchecked = useCallback(
({
conversationOptionsIds,
totalItemCount,
}: {
conversationOptionsIds: string[];
totalItemCount: number;
}) => {
if (isExcludedMode) {
setExcludedIds((prev) => [...prev, ...conversationOptionsIds]);
}
setDeletedConversations(
deletedConversations.filter((item) => !conversationOptionsIds.includes(item.id))
);
setTotalSelectedConversations(
(prev) => (prev || totalItemCount) - conversationOptionsIds.length
);
setIsDeleteAll(false);
},
[deletedConversations, isExcludedMode]
);
const handleRowChecked = useCallback(
({
selectedItem,
totalItemCount,
}: {
selectedItem: ConversationTableItem;
totalItemCount: number;
}) => {
if (isExcludedMode) {
const newExcludedIds = excludedIds.filter((item) => item !== selectedItem.id);
setExcludedIds(newExcludedIds);
} else {
const newDeletedConversations = [...deletedConversations, selectedItem];
setDeletedConversations(newDeletedConversations);
if (newDeletedConversations.length === totalItemCount) {
setIsDeleteAll(true);
setIsExcludedMode(true);
}
}
setTotalSelectedConversations((prev) => prev + 1);
},
[deletedConversations, excludedIds, isExcludedMode]
);
const handleRowUnChecked = useCallback(
({
selectedItem,
totalItemCount,
}: {
selectedItem: ConversationTableItem;
totalItemCount: number;
}) => {
if (isExcludedMode) {
setExcludedIds((prev) => [...prev, selectedItem.id]);
}
setDeletedConversations((prev) => prev.filter((item) => item.id !== selectedItem.id));
setIsDeleteAll(false);
setTotalSelectedConversations((prev) => (prev || totalItemCount) - 1);
},
[isExcludedMode]
);
return {
selectionState: {
isDeleteAll,
isExcludedMode,
deletedConversations,
totalSelectedConversations,
excludedIds,
},
selectionActions: {
handleUnselectAll,
handleSelectAll,
handlePageUnchecked,
handlePageChecked,
handleRowUnChecked,
handleRowChecked,
setDeletedConversations,
setExcludedIds,
setIsDeleteAll,
setIsExcludedMode,
setTotalSelectedConversations,
},
};
};

View file

@ -6,15 +6,12 @@
*/
import { renderHook } from '@testing-library/react';
import {
useConversationsTable,
GetConversationsListParams,
ConversationTableItem,
} from './use_conversations_table';
import { useConversationsTable, GetConversationsListParams } from './use_conversations_table';
import { alertConvo, welcomeConvo, customConvo } from '../../../mock/conversation';
import { mockActionTypes, mockConnectors } from '../../../mock/connectors';
import { mockSystemPrompts } from '../../../mock/system_prompt';
import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public';
import { ConversationTableItem } from './types';
const mockActionTypeRegistry: ActionTypeRegistryContract = {
has: jest
@ -35,19 +32,29 @@ describe('useConversationsTable', () => {
it('should return columns', () => {
const { result } = renderHook(() => useConversationsTable());
const columns = result.current.getColumns({
conversationOptions: [],
deletedConversationsIds: [],
excludedIds: [],
handlePageChecked: jest.fn(),
handlePageUnchecked: jest.fn(),
handleRowChecked: jest.fn(),
handleRowUnChecked: jest.fn(),
isDeleteEnabled: jest.fn(),
isEditEnabled: jest.fn(),
isExcludedMode: false,
onDeleteActionClicked: jest.fn(),
onEditActionClicked: jest.fn(),
totalItemCount: 0,
});
expect(columns).toHaveLength(5);
expect(columns).toHaveLength(6);
expect(columns[0].name).toBe('Title');
expect(columns[1].name).toBe('System prompt');
expect(columns[2].name).toBe('Connector');
expect(columns[3].name).toBe('Date updated');
expect(columns[4].name).toBe('Actions');
// column 0 is the checkbox column
expect(columns[1].name).toBe('Title');
expect(columns[2].name).toBe('System prompt');
expect(columns[3].name).toBe('Connector');
expect(columns[4].name).toBe('Date updated');
expect(columns[5].name).toBe('Actions');
});
it('should return a list of conversations', () => {

View file

@ -18,6 +18,14 @@ import { getConnectorTypeTitle } from '../../../connectorland/helpers';
import { getConversationApiConfig } from '../../use_conversation/helpers';
import * as i18n from './translations';
import { useInlineActions } from '../../common/components/assistant_settings_management/inline_actions';
import { InputCheckbox, PageSelectionCheckbox } from './table_selection_checkbox';
import {
ConversationTableItem,
HandlePageChecked,
HandlePageUnchecked,
HandleRowChecked,
HandleRowUnChecked,
} from './types';
const emptyConversations = {};
@ -29,26 +37,68 @@ export interface GetConversationsListParams {
defaultConnector?: AIConnector;
}
export type ConversationTableItem = Conversation & {
connectorTypeTitle?: string | null;
systemPromptTitle?: string | null;
};
interface GetColumnsParams {
conversationOptions: ConversationTableItem[];
deletedConversationsIds: string[];
excludedIds: string[];
handlePageChecked: HandlePageChecked;
handlePageUnchecked: HandlePageUnchecked;
handleRowChecked: HandleRowChecked;
handleRowUnChecked: HandleRowUnChecked;
isDeleteEnabled: (conversation: ConversationTableItem) => boolean;
isEditEnabled: (conversation: ConversationTableItem) => boolean;
isExcludedMode: boolean;
onDeleteActionClicked: (conversation: ConversationTableItem) => void;
onEditActionClicked: (conversation: ConversationTableItem) => void;
totalItemCount: number;
}
export const useConversationsTable = () => {
const getActions = useInlineActions<ConversationTableItem>();
const getColumns = useCallback(
({
conversationOptions,
deletedConversationsIds,
excludedIds,
handlePageChecked,
handlePageUnchecked,
handleRowChecked,
handleRowUnChecked,
isDeleteEnabled,
isEditEnabled,
isExcludedMode,
onDeleteActionClicked,
onEditActionClicked,
}: {
isDeleteEnabled: (conversation: ConversationTableItem) => boolean;
isEditEnabled: (conversation: ConversationTableItem) => boolean;
onDeleteActionClicked: (conversation: ConversationTableItem) => void;
onEditActionClicked: (conversation: ConversationTableItem) => void;
}): Array<EuiBasicTableColumn<ConversationTableItem>> => {
totalItemCount,
}: GetColumnsParams): Array<EuiBasicTableColumn<ConversationTableItem>> => {
return [
{
field: '',
name: (
<PageSelectionCheckbox
conversationOptions={conversationOptions}
deletedConversationsIds={deletedConversationsIds}
excludedIds={excludedIds}
isExcludedMode={isExcludedMode}
handlePageChecked={handlePageChecked}
handlePageUnchecked={handlePageUnchecked}
totalItemCount={totalItemCount}
/>
),
render: (conversation: ConversationTableItem) => (
<InputCheckbox
conversation={conversation}
deletedConversationsIds={deletedConversationsIds}
excludedIds={excludedIds}
isExcludedMode={isExcludedMode}
handleRowChecked={handleRowChecked}
handleRowUnChecked={handleRowUnChecked}
totalItemCount={totalItemCount}
/>
),
width: '70px',
sortable: false,
},
{
name: i18n.CONVERSATIONS_TABLE_COLUMN_TITLE,
render: (conversation: ConversationTableItem) => (

View file

@ -113,7 +113,7 @@ const SystemPromptSettingsManagementComponent = ({ connectors, defaultConnector
async (param?: { callback?: () => void }) => {
const { success, conversationUpdates } = await saveSystemPromptSettings();
if (success) {
await saveConversationsSettings(conversationUpdates);
await saveConversationsSettings({ bulkActions: conversationUpdates });
await refetchPrompts();
await refetchSystemPromptConversations();
toasts?.addSuccess({

View file

@ -153,7 +153,9 @@ describe('AssistantSettings', () => {
expect(onSave).toHaveBeenCalled();
expect(mockSystemUpdater.saveSystemPromptSettings).toHaveBeenCalled();
expect(mockConversationsUpdater.saveConversationsSettings).toHaveBeenCalledWith({
updates: [],
bulkActions: {
updates: [],
},
});
});

View file

@ -142,7 +142,7 @@ export const AssistantSettings: React.FC<Props> = React.memo(
const { success: systemPromptSuccess, conversationUpdates } =
await saveSystemPromptSettings();
if (systemPromptSuccess) {
saveResult = await saveConversationsSettings(conversationUpdates);
saveResult = await saveConversationsSettings({ bulkActions: conversationUpdates });
} else {
saveResult = false;
}

View file

@ -10,9 +10,13 @@ import { useConversationsUpdater } from './use_conversations_updater';
import { useAssistantContext } from '../../../assistant_context';
import { bulkUpdateConversations } from '../../api/conversations/bulk_update_actions_conversations';
import { Conversation } from '../../../..';
import { deleteAllConversations } from '../../api/conversations/delete_all_conversations';
jest.mock('../../../assistant_context');
jest.mock('../../api/conversations/bulk_update_actions_conversations');
jest.mock('../../api/conversations/delete_all_conversations', () => ({
deleteAllConversations: jest.fn(),
}));
const mockConversations: Record<string, Conversation> = {
'03a2ef3c-3aec-4f13-8f18-bb31b47b2df1': {
@ -200,4 +204,38 @@ describe('useConversationsUpdater', () => {
expect(result.current.conversationSettings).toEqual(mockConversations);
expect(result.current.conversationsSettingsBulkActions).toEqual({});
});
it('should call deleteAllConversations when isDeleteAll is true', async () => {
const { result } = renderHook(() => useConversationsUpdater(mockConversations, true));
act(() => {
result.current.saveConversationsSettings({
isDeleteAll: true,
excludedIds: [],
});
});
expect(deleteAllConversations as jest.Mock).toHaveBeenCalledWith({
excludedIds: [],
http: mockAssistantContext.http,
toasts: mockAssistantContext.toasts,
});
});
it('should call deleteAllConversations when excludedIds is not empty', async () => {
const { result } = renderHook(() => useConversationsUpdater(mockConversations, true));
act(() => {
result.current.saveConversationsSettings({
isDeleteAll: true,
excludedIds: ['1'],
});
});
expect(deleteAllConversations as jest.Mock).toHaveBeenCalledWith({
excludedIds: ['1'],
http: mockAssistantContext.http,
toasts: mockAssistantContext.toasts,
});
});
});

View file

@ -12,19 +12,28 @@ import {
ConversationsBulkActions,
bulkUpdateConversations,
} from '../../api/conversations/bulk_update_actions_conversations';
import { deleteAllConversations } from '../../api/conversations/delete_all_conversations';
export type SaveConversationsSettingsParams =
| {
isDeleteAll?: boolean;
bulkActions?: ConversationsBulkActions;
excludedIds?: string[];
}
| undefined;
interface UseConversationsUpdater {
assistantStreamingEnabled: boolean;
conversationSettings: Record<string, Conversation>;
conversationsSettingsBulkActions: ConversationsBulkActions;
onConversationDeleted: (cId: string) => void;
onConversationsBulkDeleted: (cIds: string[]) => void;
resetConversationsSettings: () => void;
setConversationSettings: React.Dispatch<React.SetStateAction<Record<string, Conversation>>>;
setConversationsSettingsBulkActions: React.Dispatch<
React.SetStateAction<ConversationsBulkActions>
>;
setUpdatedAssistantStreamingEnabled: React.Dispatch<React.SetStateAction<boolean>>;
saveConversationsSettings: (bulkActions?: ConversationsBulkActions) => Promise<boolean>;
saveConversationsSettings: (params?: SaveConversationsSettingsParams) => Promise<boolean>;
}
export const useConversationsUpdater = (
@ -55,6 +64,37 @@ export const useConversationsUpdater = (
setUpdatedAssistantStreamingEnabled(assistantStreamingEnabled);
}, [assistantStreamingEnabled, conversations]);
const onConversationsBulkDeleted = useCallback(
(cIds: string[]) => {
let updatedConversationSettings: Record<string, Conversation> = {};
const deletedConversations = new Set(conversationsSettingsBulkActions.delete?.ids ?? []);
Object.values(conversations).forEach((current) => {
const isConversationExist = cIds.includes(current.id);
if (isConversationExist) {
if (!deletedConversations.has(current.id)) {
deletedConversations.add(current.id);
} else {
updatedConversationSettings = { ...updatedConversationSettings, current };
}
}
});
setConversationSettings(updatedConversationSettings);
setConversationsSettingsBulkActions({
...conversationsSettingsBulkActions,
delete: {
ids: Array.from(deletedConversations),
},
});
},
[
conversations,
conversationsSettingsBulkActions,
setConversationSettings,
setConversationsSettingsBulkActions,
]
);
const onConversationDeleted = useCallback(
(cId: string) => {
const conversationId = Object.values(conversations).find((c) => c.id === cId)?.id;
@ -85,14 +125,18 @@ export const useConversationsUpdater = (
* Save all pending settings
*/
const saveConversationsSettings = useCallback(
async (bulkActions?: ConversationsBulkActions): Promise<boolean> => {
async (params?: SaveConversationsSettingsParams): Promise<boolean> => {
const { isDeleteAll, bulkActions, excludedIds = [] } = params ?? {};
// had trouble with conversationsSettingsBulkActions not updating fast enough
// from the setConversationsSettingsBulkActions in saveSystemPromptSettings
const bulkUpdates = bulkActions ?? conversationsSettingsBulkActions;
const hasBulkConversations = bulkUpdates.create || bulkUpdates.update || bulkUpdates.delete;
const bulkResult = hasBulkConversations
? await bulkUpdateConversations(http, bulkUpdates, toasts)
: undefined;
const bulkResult =
isDeleteAll || excludedIds?.length > 0
? await deleteAllConversations({ http, toasts, excludedIds })
: hasBulkConversations
? await bulkUpdateConversations(http, bulkUpdates, toasts)
: undefined;
const didUpdateAssistantStreamingEnabled =
assistantStreamingEnabled !== updatedAssistantStreamingEnabled;
@ -107,9 +151,9 @@ export const useConversationsUpdater = (
return bulkResult?.success ?? didUpdateAssistantStreamingEnabled ?? false;
},
[
conversationsSettingsBulkActions,
http,
toasts,
conversationsSettingsBulkActions,
assistantStreamingEnabled,
updatedAssistantStreamingEnabled,
setAssistantStreamingEnabled,
@ -129,6 +173,7 @@ export const useConversationsUpdater = (
conversationSettings,
conversationsSettingsBulkActions,
onConversationDeleted,
onConversationsBulkDeleted,
resetConversationsSettings,
saveConversationsSettings,
setUpdatedAssistantStreamingEnabled,

View file

@ -12,6 +12,7 @@ import {
ConversationCreateProps,
ConversationResponse,
ConversationUpdateProps,
DeleteAllConversationsRequestBody,
} from '@kbn/elastic-assistant-common';
import {
CreateMessageSchema,
@ -88,6 +89,10 @@ export const getCreateConversationSchemaMock = (
...rest,
});
export const getDeleteAllConversationsSchemaMock = (): DeleteAllConversationsRequestBody => ({
excludedIds: ['conversation-1'],
});
export const getUpdateConversationSchemaMock = (
conversationId = 'conversation-1'
): ConversationUpdateProps => ({

View file

@ -30,6 +30,7 @@ const createConversationsDataClientMock = () => {
appendConversationMessages: jest.fn(),
createConversation: jest.fn(),
deleteConversation: jest.fn(),
deleteAllConversations: jest.fn(),
getConversation: jest.fn(),
updateConversation: jest.fn(),
getReader: jest.fn(),

View file

@ -53,6 +53,7 @@ import {
import {
getAppendConversationMessagesSchemaMock,
getCreateConversationSchemaMock,
getDeleteAllConversationsSchemaMock,
getUpdateConversationSchemaMock,
} from './conversations_schema.mock';
import { getCreateKnowledgeBaseEntrySchemaMock } from './knowledge_base_entry_schema.mock';
@ -186,6 +187,13 @@ export const getDeleteConversationRequest = (id: string = '04128c15-0d1b-4716-a4
params: { id },
});
export const getDeleteAllConversationsRequest = () =>
requestMock.create({
method: 'delete',
path: ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL,
body: getDeleteAllConversationsSchemaMock(),
});
export const getCreateConversationRequest = () =>
requestMock.create({
method: 'post',

View file

@ -0,0 +1,54 @@
/*
* 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 { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
import { DeleteAllConversationsParams, deleteAllConversations } from './delete_all_conversations';
export const getDeleteAllConversationsOptionsMock = (): DeleteAllConversationsParams => ({
esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser,
conversationIndex: '.kibana-elastic-ai-assistant-conversations',
logger: loggingSystemMock.createLogger(),
excludedIds: ['test'],
});
describe('deleteAllConversations', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
test('Delete all conversations', async () => {
const mockResponse = { deleted: 1 };
const options = getDeleteAllConversationsOptionsMock();
options.esClient.deleteByQuery = jest.fn().mockResolvedValue(mockResponse);
const deletedConversations = await deleteAllConversations(options);
expect(deletedConversations).toEqual(mockResponse);
});
test('throw error if no conversation was deleted', async () => {
const mockResponse = { deleted: 0 };
const options = getDeleteAllConversationsOptionsMock();
options.esClient.deleteByQuery = jest.fn().mockResolvedValue(mockResponse);
await expect(deleteAllConversations(options)).rejects.toThrow(
'No conversations have been deleted.'
);
});
test('handles error from deleteByQuery', async () => {
const mockError = new Error('Test Error');
const options = getDeleteAllConversationsOptionsMock();
options.esClient.deleteByQuery = jest.fn().mockRejectedValue(mockError);
await expect(deleteAllConversations(options)).rejects.toThrow(mockError);
});
});

View file

@ -0,0 +1,51 @@
/*
* 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 { DeleteByQueryResponse } from '@elastic/elasticsearch/lib/api/types';
import { ElasticsearchClient, Logger } from '@kbn/core/server';
export interface DeleteAllConversationsParams {
esClient: ElasticsearchClient;
conversationIndex: string;
logger: Logger;
excludedIds?: string[];
}
export const deleteAllConversations = async ({
esClient,
conversationIndex,
logger,
excludedIds = [],
}: DeleteAllConversationsParams): Promise<DeleteByQueryResponse | undefined> => {
try {
const response = await esClient.deleteByQuery({
query: {
bool: {
must: {
match_all: {},
},
must_not: {
ids: {
values: excludedIds,
},
},
},
},
conflicts: 'proceed',
index: conversationIndex,
refresh: true,
});
if (!response.deleted && response.deleted === 0) {
logger.error('No conversations have been deleted.');
throw Error('No conversations have been deleted.');
}
return response;
} catch (err) {
logger.error(`Error deleting all conversations: ${err}`);
throw err;
}
};

View file

@ -12,12 +12,14 @@ import {
ConversationUpdateProps,
Message,
} from '@kbn/elastic-assistant-common';
import { DeleteByQueryResponse } from '@elastic/elasticsearch/lib/api/types';
import { createConversation } from './create_conversation';
import { updateConversation } from './update_conversation';
import { getConversation } from './get_conversation';
import { deleteConversation } from './delete_conversation';
import { appendConversationMessages } from './append_conversation_messages';
import { AIAssistantDataClient, AIAssistantDataClientParams } from '..';
import { deleteAllConversations } from './delete_all_conversations';
/**
* Params for when creating ConversationDataClient in Request Context Factory. Useful if needing to modify
@ -148,7 +150,7 @@ export class AIAssistantConversationsDataClient extends AIAssistantDataClient {
* @param options.id The id of the conversation to delete
* @returns The conversation deleted if found, otherwise null
*/
public deleteConversation = async (id: string) => {
public deleteConversation = async (id: string): Promise<number | undefined> => {
const esClient = await this.options.elasticsearchClientPromise;
return deleteConversation({
esClient,
@ -157,4 +159,21 @@ export class AIAssistantConversationsDataClient extends AIAssistantDataClient {
logger: this.options.logger,
});
};
/**
* Deletes all conversations in the index.
* @param options.excludedIds An array of ids to exclude from deletion.
* @returns The number of conversations deleted
*/
public deleteAllConversations = async (options?: {
excludedIds?: string[];
}): Promise<DeleteByQueryResponse | undefined> => {
const esClient = await this.options.elasticsearchClientPromise;
return deleteAllConversations({
esClient,
conversationIndex: this.indexTemplateAndPattern.alias,
logger: this.options.logger,
excludedIds: options?.excludedIds,
});
};
}

View file

@ -55,6 +55,7 @@ import { findAttackDiscoverySchedulesRoute } from './attack_discovery/schedules/
import { disableAttackDiscoverySchedulesRoute } from './attack_discovery/schedules/disable';
import { enableAttackDiscoverySchedulesRoute } from './attack_discovery/schedules/enable';
import type { ConfigSchema } from '../config_schema';
import { deleteAllConversationsRoute } from './user_conversations/delete_all_route';
export const registerRoutes = (
router: ElasticAssistantPluginRouter,
@ -74,6 +75,7 @@ export const registerRoutes = (
readConversationRoute(router);
updateConversationRoute(router);
deleteConversationRoute(router);
deleteAllConversationsRoute(router);
appendConversationMessageRoute(router);
// User Conversations bulk CRUD

View file

@ -0,0 +1,88 @@
/*
* 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 { requestContextMock } from '../../__mocks__/request_context';
import { serverMock } from '../../__mocks__/server';
import { getDeleteAllConversationsRequest } from '../../__mocks__/request';
import { authenticatedUser } from '../../__mocks__/user';
import { deleteAllConversationsRoute } from './delete_all_route';
describe('Delete all conversations route', () => {
let server: ReturnType<typeof serverMock.create>;
let { clients, context } = requestContextMock.createTools();
const mockUser1 = authenticatedUser;
beforeEach(() => {
server = serverMock.create();
({ clients, context } = requestContextMock.createTools());
clients.elasticAssistant.getAIAssistantConversationsDataClient.deleteAllConversations.mockResolvedValue(
{
total: 1,
}
);
context.elasticAssistant.getCurrentUser.mockResolvedValue(mockUser1);
deleteAllConversationsRoute(server.router);
});
describe('status codes with getAIAssistantConversationsDataClient', () => {
test('returns 200 when deleting all conversations', async () => {
const response = await server.inject(
getDeleteAllConversationsRequest(),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(200);
});
test('returns failure if exists', async () => {
clients.elasticAssistant.getAIAssistantConversationsDataClient.deleteAllConversations.mockResolvedValue(
{
total: 0,
failures: [
{
id: 'error-id',
index: 'test-index',
status: 400,
cause: {
reason: 'Test error',
type: 'Error',
},
},
],
}
);
const response = await server.inject(
getDeleteAllConversationsRequest(),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(200);
expect(response.body).toEqual({
success: false,
totalDeleted: 0,
failures: ['Test error'],
});
});
test('catches error if deletion throws error', async () => {
clients.elasticAssistant.getAIAssistantConversationsDataClient.deleteAllConversations.mockRejectedValue(
new Error('Test error')
);
const response = await server.inject(
getDeleteAllConversationsRequest(),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(500);
expect(response.body).toEqual({
message: 'Test error',
status_code: 500,
});
});
});
});

View file

@ -0,0 +1,77 @@
/*
* 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 { transformError } from '@kbn/securitysolution-es-utils';
import {
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL,
API_VERSIONS,
DeleteAllConversationsRequestBody,
} from '@kbn/elastic-assistant-common';
import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common';
import { ElasticAssistantPluginRouter } from '../../types';
import { buildResponse } from '../utils';
import { performChecks } from '../helpers';
export const deleteAllConversationsRoute = (router: ElasticAssistantPluginRouter) => {
router.versioned
.delete({
access: 'public',
path: ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL,
security: {
authz: {
requiredPrivileges: ['elasticAssistant'],
},
},
})
.addVersion(
{
version: API_VERSIONS.public.v1,
validate: {
request: {
body: buildRouteValidationWithZod(DeleteAllConversationsRequestBody),
},
},
},
async (context, request, response) => {
const assistantResponse = buildResponse(response);
try {
const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']);
const checkResponse = await performChecks({
context: ctx,
request,
response,
});
if (!checkResponse.isSuccess) {
return checkResponse.response;
}
const dataClient = await ctx.elasticAssistant.getAIAssistantConversationsDataClient();
const result = await dataClient?.deleteAllConversations({
excludedIds: request.body?.excludedIds,
});
const hasFailures = result?.failures && result.failures.length > 0;
return response.ok({
body: {
success: !hasFailures,
totalDeleted: result?.total,
failures: hasFailures
? result.failures?.map((failure) => failure.cause.reason)
: null,
},
});
} catch (err) {
const error = transformError(err);
return assistantResponse.error({
body: error.message,
statusCode: error.statusCode,
});
}
}
);
};