Skip to content

Commit

Permalink
Delete Chat (#193)
Browse files Browse the repository at this point in the history
### Motivation and Context

<!-- Thank you for your contribution to the copilot-chat repo!
Please help reviewers and future users, providing the following
information:
  1. Why is this change required?
  2. What problem does it solve?
  3. What scenario does it contribute to?
  4. If it fixes an open issue, please link to the issue here.
-->
This PR enables chat deletion. For V1, any user is able to delete any
chat they're a part of. This will delete the chat for all participants,
including Chat Copilot, as well as all related resources (memories,
messages, etc.).

### Description

<!-- Describe your changes, the overall approach, the underlying design.
These notes will help understanding how your code works. Thanks! -->

When a chat is deleted,
- It will be immediately removed for the user who initiated the
deletion.
<img width="1234" alt="Screenshot 2023-08-16 at 5 54 46 PM"
src="https://github.com/microsoft/chat-copilot/assets/125500434/7fb8a2e7-c70f-44e2-9e35-91bbe9e3f810">

- If the chat was a multi-user chat, the chat will be disabled in
current session. This allows other participants to save any resources
before the chat is wiped out. On refresh, chat is removed from list.
<img width="1680" alt="Screenshot 2023-08-16 at 5 54 35 PM"
src="https://github.com/microsoft/chat-copilot/assets/125500434/5ac2c7d3-f11a-4575-8d8a-5fe2bdb5aba7">
<img width="429" alt="Screenshot 2023-08-16 at 5 54 58 PM"
src="https://github.com/microsoft/chat-copilot/assets/125500434/f1bedd1a-7801-406a-8263-2965f67e5a39">

- If the chat was the last chat in the user's current session, a new
chat will automatically be created to populate the chat list.

### Contribution Checklist

<!-- Before submitting this PR, please make sure: -->

- [x] The code builds clean without any errors or warnings
- [x] The PR follows the [Contribution
Guidelines](https://github.com/microsoft/copilot-chat/blob/main/CONTRIBUTING.md)
and the [pre-submission formatting
script](https://github.com/microsoft/copilot-chat/blob/main/CONTRIBUTING.md#development-scripts)
raises no violations
~~- [ ] All unit tests pass, and I have added new tests where possible~~
- [x] I didn't break anyone 😄
  • Loading branch information
teresaqhoang authored Aug 22, 2023
1 parent aa2d078 commit c0012fa
Show file tree
Hide file tree
Showing 24 changed files with 405 additions and 130 deletions.
115 changes: 115 additions & 0 deletions webapi/Controllers/ChatHistoryController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CopilotChat.WebApi.Auth;
using CopilotChat.WebApi.Extensions;
using CopilotChat.WebApi.Hubs;
using CopilotChat.WebApi.Models.Request;
using CopilotChat.WebApi.Models.Response;
Expand All @@ -19,6 +21,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Memory;

namespace CopilotChat.WebApi.Controllers;

Expand All @@ -31,18 +34,21 @@ namespace CopilotChat.WebApi.Controllers;
public class ChatHistoryController : ControllerBase
{
private readonly ILogger<ChatHistoryController> _logger;
private readonly IMemoryStore _memoryStore;
private readonly ChatSessionRepository _sessionRepository;
private readonly ChatMessageRepository _messageRepository;
private readonly ChatParticipantRepository _participantRepository;
private readonly ChatMemorySourceRepository _sourceRepository;
private readonly PromptsOptions _promptOptions;
private readonly IAuthInfo _authInfo;
private const string ChatEditedClientCall = "ChatEdited";
private const string ChatDeletedClientCall = "ChatDeleted";

/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryController"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="memoryStore">Memory store.</param>
/// <param name="sessionRepository">The chat session repository.</param>
/// <param name="messageRepository">The chat message repository.</param>
/// <param name="participantRepository">The chat participant repository.</param>
Expand All @@ -51,6 +57,7 @@ public class ChatHistoryController : ControllerBase
/// <param name="authInfo">The auth info for the current request.</param>
public ChatHistoryController(
ILogger<ChatHistoryController> logger,
IMemoryStore memoryStore,
ChatSessionRepository sessionRepository,
ChatMessageRepository messageRepository,
ChatParticipantRepository participantRepository,
Expand All @@ -59,6 +66,7 @@ public ChatHistoryController(
IAuthInfo authInfo)
{
this._logger = logger;
this._memoryStore = memoryStore;
this._sessionRepository = sessionRepository;
this._messageRepository = messageRepository;
this._participantRepository = participantRepository;
Expand Down Expand Up @@ -260,4 +268,111 @@ public async Task<ActionResult<IEnumerable<MemorySource>>> GetSourcesAsync(

return this.NotFound($"No chat session found for chat id '{chatId}'.");
}

/// <summary>
/// Delete a chat session.
/// </summary>
/// <param name="chatId">The chat id.</param>
[HttpDelete]
[Route("chatSession/{chatId:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[Authorize(Policy = AuthPolicyName.RequireChatParticipant)]
public async Task<IActionResult> DeleteChatSessionAsync(
[FromServices] IHubContext<MessageRelayHub> messageRelayHubContext,
Guid chatId,
CancellationToken cancellationToken)
{
var chatIdString = chatId.ToString();
ChatSession? chatToDelete = null;
try
{
// Make sure the chat session exists
chatToDelete = await this._sessionRepository.FindByIdAsync(chatIdString);
}
catch (KeyNotFoundException)
{
return this.NotFound($"No chat session found for chat id '{chatId}'.");
}

// Delete any resources associated with the chat session.
try
{
await this.DeleteChatResourcesAsync(chatIdString, cancellationToken);
}
catch (AggregateException)
{
return this.StatusCode(500, $"Failed to delete resources for chat id '{chatId}'.");
}

// Delete chat session and broadcast update to all participants.
await this._sessionRepository.DeleteAsync(chatToDelete);
await messageRelayHubContext.Clients.Group(chatIdString).SendAsync(ChatDeletedClientCall, chatIdString, this._authInfo.UserId, cancellationToken: cancellationToken);

return this.NoContent();
}

/// <summary>
/// Deletes all associated resources (messages, memories, participants) associated with a chat session.
/// </summary>
/// <param name="chatId">The chat id.</param>
private async Task DeleteChatResourcesAsync(string chatId, CancellationToken cancellationToken)
{
var cleanupTasks = new List<Task>();

// Create and store the tasks for deleting all users tied to the chat.
var participants = await this._participantRepository.FindByChatIdAsync(chatId);
foreach (var participant in participants)
{
cleanupTasks.Add(this._participantRepository.DeleteAsync(participant));
}

// Create and store the tasks for deleting chat messages.
var messages = await this._messageRepository.FindByChatIdAsync(chatId);
foreach (var message in messages)
{
cleanupTasks.Add(this._messageRepository.DeleteAsync(message));
}

// Create and store the tasks for deleting memory sources.
var sources = await this._sourceRepository.FindByChatIdAsync(chatId, false);
foreach (var source in sources)
{
cleanupTasks.Add(this._sourceRepository.DeleteAsync(source));
}

// Create and store the tasks for deleting semantic memories.
// TODO: [Issue #47] Filtering memory collections by name might be fragile.
var memoryCollections = (await this._memoryStore.GetCollectionsAsync(cancellationToken).ToListAsync<string>())
.Where(collection => collection.StartsWith(chatId, StringComparison.OrdinalIgnoreCase));
foreach (var collection in memoryCollections)
{
cleanupTasks.Add(this._memoryStore.DeleteCollectionAsync(collection, cancellationToken));
}

// Create a task that represents the completion of all cleanupTasks
Task aggregationTask = Task.WhenAll(cleanupTasks);
try
{
// Await the completion of all tasks in parallel
await aggregationTask;
}
catch (Exception ex)
{
// Handle any exceptions that occurred during the tasks
if (aggregationTask?.Exception?.InnerExceptions != null && aggregationTask.Exception.InnerExceptions.Count != 0)
{
foreach (var innerEx in aggregationTask.Exception.InnerExceptions)
{
this._logger.LogInformation("Failed to delete an entity of chat {0}: {1}", chatId, innerEx.Message);
}

throw aggregationTask.Exception;
}

throw new AggregateException($"Resource deletion failed for chat {chatId}.", ex);
}
}
}
2 changes: 1 addition & 1 deletion webapi/Extensions/ServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ internal static IServiceCollection AddCorsPolicy(this IServiceCollection service
policy =>
{
policy.WithOrigins(allowedOrigins)
.WithMethods("GET", "POST")
.WithMethods("GET", "POST", "DELETE")
.AllowAnyHeader();
});
});
Expand Down
1 change: 0 additions & 1 deletion webapp/src/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,4 @@ export const Constants = {
MANIFEST_PATH: '/.well-known/ai-plugin.json',
},
KEYSTROKE_DEBOUNCE_TIME_MS: 250,
STEPWISE_RESULT_NOT_FOUND_REGEX: /(Result not found, review _stepsTaken to see what happened\.)\s+(\[{.*}])/g,
};
8 changes: 8 additions & 0 deletions webapp/src/assets/strings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const COPY = {
STEPWISE_RESULT_NOT_FOUND_REGEX: /(Result not found, review _stepsTaken to see what happened\.)\s+(\[{.*}])/g,
CHAT_DELETED_MESSAGE: (chatName?: string) =>
`Chat ${
chatName ? `{${chatName}} ` : ''
}has been removed by another user. You can still access the latest chat history for now. All chat content will be cleared once you refresh or exit the application.`,
REFRESH_APP_ADVISORY: 'Please refresh the page to ensure you have the latest data.',
};
11 changes: 8 additions & 3 deletions webapp/src/components/chat/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import debug from 'debug';
import * as speechSdk from 'microsoft-cognitiveservices-speech-sdk';
import React, { useRef, useState } from 'react';
import { Constants } from '../../Constants';
import { COPY } from '../../assets/strings';
import { AuthHelper } from '../../libs/auth/AuthHelper';
import { useFile } from '../../libs/hooks';
import { GetResponseOptions } from '../../libs/hooks/useChat';
Expand Down Expand Up @@ -115,7 +116,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ isDraggingOver, onDragLeav

React.useEffect(() => {
const chatState = conversations[selectedId];
setValue(chatState.input);
setValue(chatState.disabled ? COPY.CHAT_DELETED_MESSAGE() : chatState.input);
}, [conversations, selectedId]);

const handleSpeech = () => {
Expand Down Expand Up @@ -170,6 +171,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ isDraggingOver, onDragLeav
ref={textAreaRef}
id="chat-input"
resize="vertical"
disabled={conversations[selectedId].disabled}
textarea={{
className: isDraggingOver
? mergeClasses(classes.dragAndDrop, classes.textarea)
Expand Down Expand Up @@ -225,7 +227,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ isDraggingOver, onDragLeav
}}
/>
<Button
disabled={importingDocuments && importingDocuments.length > 0}
disabled={
conversations[selectedId].disabled || (importingDocuments && importingDocuments.length > 0)
}
appearance="transparent"
icon={<AttachRegular />}
onClick={() => documentFileRef.current?.click()}
Expand All @@ -238,7 +242,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ isDraggingOver, onDragLeav
{recognizer && (
<Button
appearance="transparent"
disabled={isListening}
disabled={conversations[selectedId].disabled || isListening}
icon={<MicRegular />}
onClick={handleSpeech}
/>
Expand All @@ -251,6 +255,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ isDraggingOver, onDragLeav
onClick={() => {
handleSubmit(value);
}}
disabled={conversations[selectedId].disabled}
/>
</div>
</div>
Expand Down
5 changes: 1 addition & 4 deletions webapp/src/components/chat/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,11 @@ const useClasses = makeStyles({
export const ChatWindow: React.FC = () => {
const classes = useClasses();
const { features } = useAppSelector((state: RootState) => state.app);

const showShareBotMenu = features[FeatureKeys.BotAsDocs].enabled || features[FeatureKeys.MultiUserChat].enabled;

const { conversations, selectedId } = useAppSelector((state: RootState) => state.conversations);
const showShareBotMenu = features[FeatureKeys.BotAsDocs].enabled || features[FeatureKeys.MultiUserChat].enabled;
const chatName = conversations[selectedId].title;

const [isEditing, setIsEditing] = useState<boolean>(false);

const [selectedTab, setSelectedTab] = React.useState<TabValue>('chat');
const onTabSelect: SelectTabEventHandler = (_event, data) => {
setSelectedTab(data.value);
Expand Down
1 change: 0 additions & 1 deletion webapp/src/components/chat/chat-list/ChatListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,6 @@ export const ChatListItem: FC<IChatListItemProps> = ({
{showActions && (
<ListItemActions
chatId={id}
chatName={header}
onEditTitleClick={() => {
setEditingTitle(true);
}}
Expand Down
17 changes: 3 additions & 14 deletions webapp/src/components/chat/chat-list/ChatListSection.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { makeStyles, shorthands, Text, tokens } from '@fluentui/react-components';
import { AuthorRoles, ChatMessageType } from '../../../libs/models/ChatMessage';
import { getFriendlyChatName } from '../../../libs/hooks/useChat';
import { ChatMessageType } from '../../../libs/models/ChatMessage';
import { isPlan } from '../../../libs/utils/PlanUtils';
import { useAppSelector } from '../../../redux/app/hooks';
import { RootState } from '../../../redux/app/store';
Expand Down Expand Up @@ -46,24 +47,12 @@ export const ChatListSection: React.FC<IChatListSectionProps> = ({ header, conve
const messages = convo.messages;
const lastMessage = messages[convo.messages.length - 1];
const isSelected = id === selectedId;

/* Regex to match the Copilot timestamp format that is used as the default chat name.
The format is: 'Copilot @ MM/DD/YYYY, hh:mm:ss AM/PM'. */
const autoGeneratedTitleRegex =
/Copilot @ [0-9]{1,2}\/[0-9]{1,2}\/[0-9]{1,4}, [0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2} [A,P]M/;
const firstUserMessage = messages.find(
(message) => message.authorRole !== AuthorRoles.Bot && message.type === ChatMessageType.Message,
);
const title = autoGeneratedTitleRegex.test(convo.title)
? firstUserMessage?.content ?? 'New Chat'
: convo.title;

return (
<ChatListItem
id={id}
key={id}
isSelected={isSelected}
header={title}
header={getFriendlyChatName(convo)}
timestamp={convo.lastUpdatedTimestamp ?? lastMessage.timestamp}
preview={
messages.length > 0
Expand Down
Loading

0 comments on commit c0012fa

Please sign in to comment.