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 3 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
75 changes: 75 additions & 0 deletions webapi/Controllers/ChatHistoryController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public class ChatHistoryController : ControllerBase
private readonly ChatMemorySourceRepository _sourceRepository;
private readonly PromptsOptions _promptOptions;
private const string ChatEditedClientCall = "ChatEdited";
private const string ChatDeletedClientCall = "ChatDeleted";

/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryController"/> class.
Expand Down Expand Up @@ -240,4 +241,78 @@ 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="requestParameters">Object that contains the parameters to delete the chat.</param>
[HttpPost]
[Route("chatSession/delete")]
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 requestParameters)
glahaye marked this conversation as resolved.
Show resolved Hide resolved
{
string? chatId = requestParameters.ChatId;
string? userId = requestParameters.UserId;

if (chatId == null || userId == null)
{
return this.BadRequest("Chat session parameters 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 message and broadcast update to all participants.
await this._sessionRepository.DeleteAsync(chatToDelete);
await messageRelayHubContext.Clients.Group(chatId).SendAsync(ChatDeletedClientCall, chatId, userId);
teresaqhoang marked this conversation as resolved.
Show resolved Hide resolved

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

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

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

// Await all the tasks in parallel and handle the exceptions
var cleanupTasks = participantsTasks.Concat(messageTasks).Concat(sourceTasks);
await Task.WhenAll(cleanupTasks);

// 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)
{
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 this.NoContent();
}
}
23 changes: 23 additions & 0 deletions webapi/Models/Request/DeleteChatRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// 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; }

/// <summary>
/// Id of the chat to delete.
/// </summary>
[JsonPropertyName("chatId")]
public string? ChatId { get; set; }
teresaqhoang marked this conversation as resolved.
Show resolved Hide resolved
}
4 changes: 4 additions & 0 deletions webapp/src/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,8 @@ export const Constants = {
},
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
CHAT_DELETED_MESSAGE: (chatName?: string) =>
`Chat ${
chatName ? `{${chatName}} ` : ''
}has been deleted by another user. Please save any resources you need and refresh the page.`,
};
8 changes: 5 additions & 3 deletions webapp/src/components/chat/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ isDraggingOver, onDragLeav

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

const handleSpeech = () => {
Expand Down Expand Up @@ -188,6 +188,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 @@ -243,7 +244,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ isDraggingOver, onDragLeav
}}
/>
<Button
disabled={documentImporting}
disabled={conversations[selectedId].disabled || documentImporting}
appearance="transparent"
icon={<AttachRegular />}
onClick={() => documentFileRef.current?.click()}
Expand All @@ -256,7 +257,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 @@ -269,6 +270,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
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
87 changes: 51 additions & 36 deletions webapp/src/components/chat/chat-list/ListItemActions.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Button } from '@fluentui/react-button';
import { makeStyles } from '@fluentui/react-components';
import { ErrorCircleRegular } from '@fluentui/react-icons';
import { Tooltip } from '@fluentui/react-tooltip';
import React, { useCallback, useState } from 'react';
import { Constants } from '../../../Constants';
import { useChat, useFile } from '../../../libs/hooks';
import { useAppSelector } from '../../../redux/app/hooks';
import { RootState } from '../../../redux/app/store';
Expand Down Expand Up @@ -29,6 +31,7 @@ interface IListItemActionsProps {
export const ListItemActions: React.FC<IListItemActionsProps> = ({ chatId, chatName, onEditTitleClick }) => {
const classes = useClasses();
const { features } = useAppSelector((state: RootState) => state.app);
const { conversations } = useAppSelector((state: RootState) => state.conversations);

const chat = useChat();
const { downloadFile } = useFile();
Expand All @@ -47,42 +50,54 @@ export const ListItemActions: React.FC<IListItemActionsProps> = ({ chatId, chatN

return (
<div className={classes.root}>
<Tooltip content={'Edit chat name'} relationship="label">
<Button
icon={<Edit />}
appearance="transparent"
aria-label="Edit chat name"
onClick={onEditTitleClick}
/>
</Tooltip>
<Tooltip content={'Download chat session'} relationship="label">
<Button
disabled={!features[FeatureKeys.BotAsDocs].enabled}
icon={<ArrowDownload16 />}
appearance="transparent"
aria-label="Download chat session"
onClick={onDownloadBotClick}
/>
</Tooltip>
<Tooltip content={'Share live chat code'} relationship="label">
<Button
disabled={!features[FeatureKeys.MultiUserChat].enabled}
icon={<Share20 />}
appearance="transparent"
aria-label="Share live chat code"
onClick={() => {
setIsGettingInvitationId(true);
}}
/>
</Tooltip>
{features[FeatureKeys.DeleteChats].enabled && <DeleteChatDialog chatId={chatId} chatName={chatName} />}
{isGettingInvitationId && (
<InvitationCreateDialog
onCancel={() => {
setIsGettingInvitationId(false);
}}
chatId={chatId}
/>
{conversations[chatId].disabled ? (
<Tooltip content={Constants.CHAT_DELETED_MESSAGE()} relationship="label">
<Button
icon={<ErrorCircleRegular />}
appearance="transparent"
aria-label="Alert: Chat has been deleted by another user."
/>
</Tooltip>
) : (
<>
<Tooltip content={'Edit chat name'} relationship="label">
<Button
icon={<Edit />}
appearance="transparent"
aria-label="Edit chat name"
onClick={onEditTitleClick}
/>
</Tooltip>
<Tooltip content={'Download chat session'} relationship="label">
<Button
disabled={!features[FeatureKeys.BotAsDocs].enabled}
icon={<ArrowDownload16 />}
appearance="transparent"
aria-label="Download chat session"
onClick={onDownloadBotClick}
/>
</Tooltip>
<Tooltip content={'Share live chat code'} relationship="label">
<Button
disabled={!features[FeatureKeys.MultiUserChat].enabled}
icon={<Share20 />}
appearance="transparent"
aria-label="Share live chat code"
onClick={() => {
setIsGettingInvitationId(true);
}}
/>
</Tooltip>
<DeleteChatDialog chatId={chatId} chatName={chatName} />
{isGettingInvitationId && (
<InvitationCreateDialog
onCancel={() => {
setIsGettingInvitationId(false);
}}
chatId={chatId}
/>
)}
</>
)}
</div>
);
Expand Down
23 changes: 12 additions & 11 deletions webapp/src/components/chat/chat-list/dialogs/DeleteChatDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
DialogTitle,
DialogTrigger,
} from '@fluentui/react-dialog';
import { useChat } from '../../../../libs/hooks';
import { Delete16 } from '../../../shared/BundledIcons';

const useClasses = makeStyles({
Expand All @@ -25,8 +26,13 @@ interface IEditChatNameProps {
chatName: string;
}

export const DeleteChatDialog: React.FC<IEditChatNameProps> = ({ chatName }) => {
export const DeleteChatDialog: React.FC<IEditChatNameProps> = ({ chatId, chatName }) => {
const classes = useClasses();
const chat = useChat();

const onDeleteChat = () => {
void chat.deleteChat(chatId);
};

return (
<Dialog modalType="alert">
Expand All @@ -37,22 +43,17 @@ export const DeleteChatDialog: React.FC<IEditChatNameProps> = ({ chatName }) =>
</DialogTrigger>
<DialogSurface className={classes.root}>
<DialogBody>
<DialogTitle>Are you sure you want to delete chat {chatName}?</DialogTitle>
<DialogContent
// TODO: [sk Issue #1642] Check with Matthew on proper copy here
>
This will permanently delete the chat for you but not for Chat Copilot. You need to delete
anything that you have shared (files, tasks, etc.) separately.
<DialogTitle>Are you sure you want to delete chat: {chatName}?</DialogTitle>
<DialogContent>
This action will permanently delete the chat, and any associated resources and memories, for all
participants, including Chat Copilot.
</DialogContent>
<DialogActions className={classes.actions}>
<DialogTrigger action="close" disableButtonEnhancement>
<Button appearance="secondary">Cancel</Button>
</DialogTrigger>
<DialogTrigger action="close" disableButtonEnhancement>
<Button
appearance="primary"
// onClick={ TODO: [sk Issue #1642] Handle delete chat }
>
<Button appearance="primary" onClick={onDeleteChat}>
Delete
</Button>
</DialogTrigger>
Expand Down
1 change: 1 addition & 0 deletions webapp/src/components/chat/persona/MemoryBiasSlider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export const MemoryBiasSlider: React.FC = () => {
onChange={(_, data) => {
sliderValueChange(data.value);
}}
disabled={conversations[selectedId].disabled}
/>
<Label>Long Term</Label>
</div>
Expand Down
Loading