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 18 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
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)]
teresaqhoang marked this conversation as resolved.
Show resolved Hide resolved
[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}'.");
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(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));
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));
}

// 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;
}
teresaqhoang marked this conversation as resolved.
Show resolved Hide resolved

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 @@ -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
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
Loading