Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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)]
Comment thread
teresaqhoang marked this conversation as 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}'.");
Comment thread
glahaye marked this conversation as 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));
Comment thread
glahaye marked this conversation as 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;
}
Comment thread
teresaqhoang marked this conversation as 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,
Comment thread
teresaqhoang marked this conversation as 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 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