Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Delete Chat #193

Merged
merged 19 commits into from
Aug 22, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 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 class ChatHistoryController : ControllerBase
IAuthInfo authInfo)
{
this._logger = logger;
this._memoryStore = memoryStore;
this._sessionRepository = sessionRepository;
this._messageRepository = messageRepository;
this._participantRepository = participantRepository;
Expand Down Expand Up @@ -260,4 +268,105 @@ public async Task<IActionResult> GetAllChatSessionsAsync(string userId)

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

/// <summary>
/// Delete a chat session.
/// </summary>
/// <param name="userId">Unique Id of user who initiated the request.</param>
teresaqhoang marked this conversation as resolved.
Show resolved Hide resolved
/// <param name="sessionId">The chat id.</param>
[HttpDelete]
[Route("chatSession/{sessionId:guid}")]
teresaqhoang marked this conversation as resolved.
Show resolved Hide resolved
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
teresaqhoang marked this conversation as resolved.
Show resolved Hide resolved
public async Task<IActionResult> DeleteChatSessionAsync([FromServices] IHubContext<MessageRelayHub> messageRelayHubContext, [FromBody] DeleteChatRequest requestBody, Guid sessionId, CancellationToken cancellationToken)
{
var chatId = sessionId.ToString();
var userId = requestBody.UserId;
gitri-ms marked this conversation as resolved.
Show resolved Hide resolved

if (userId == null)
{
return this.BadRequest("UserId cannot be null.");
}

ChatSession? chatToDelete = null;
try
{
// Make sure the chat session exists
chatToDelete = await this._sessionRepository.FindByIdAsync(chatId);
TaoChenOSU marked this conversation as resolved.
Show resolved Hide resolved
}
catch (KeyNotFoundException)
{
return this.NotFound($"No chat session found for chat id '{chatId}'.");
}

// Delete any resources associated with the chat session.
var deleteResourcesResult = await this.DeleteChatResourcesAsync(messageRelayHubContext, sessionId, cancellationToken) as StatusCodeResult;
if (deleteResourcesResult?.StatusCode != 204)
{
return this.StatusCode(500, $"Failed to delete resources for chat id '{chatId}'.");
glahaye marked this conversation as resolved.
Show resolved Hide resolved
}

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

return this.NoContent();
}

/// <summary>
/// Deletes all associated resources (messages, memories, participants) associated with a chat session.
/// </summary>
/// <param name="sessionId">The chat id.</param>
private async Task<IActionResult> DeleteChatResourcesAsync([FromServices] IHubContext<MessageRelayHub> messageRelayHubContext, Guid sessionId, CancellationToken cancellationToken)
{
var chatId = sessionId.ToString();
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));
glahaye marked this conversation as resolved.
Show resolved Hide resolved
}

// 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));
}

// Await all the tasks in parallel and handle the exceptions
await Task.WhenAll(cleanupTasks);
var failedTasks = false;

// Iterate over the tasks and check their status and exception
foreach (var task in cleanupTasks)
teresaqhoang marked this conversation as resolved.
Show resolved Hide resolved
{
if (task.IsFaulted && task.Exception != null)
{
failedTasks = true;
this._logger.LogInformation("Failed to delete an entity of chat {0}: {1}", chatId, task.Exception.Message);
}
teresaqhoang marked this conversation as resolved.
Show resolved Hide resolved
}

return failedTasks ? this.StatusCode(500) : this.NoContent();
}
}
2 changes: 1 addition & 1 deletion webapi/Extensions/ServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ internal static IServiceCollection AddCorsPolicy(this IServiceCollection service
policy =>
{
policy.WithOrigins(allowedOrigins)
.WithMethods("GET", "POST")
.WithMethods("GET", "POST", "DELETE")
.AllowAnyHeader();
});
});
Expand Down
17 changes: 17 additions & 0 deletions webapi/Models/Request/DeleteChatRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Text.Json.Serialization;

namespace CopilotChat.WebApi.Models.Request;

/// <summary>
/// Json body for deleting a chat session.
/// </summary>
public class DeleteChatRequest
{
/// <summary>
/// Id of the user who initiated chat deletion.
/// </summary>
[JsonPropertyName("userId")]
public string? UserId { get; set; }
}
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,
teresaqhoang marked this conversation as resolved.
Show resolved Hide resolved
};
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 deleted by another user. Please save any resources you need and refresh the page.`,
teresaqhoang marked this conversation as resolved.
Show resolved Hide resolved
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