diff --git a/webapi/Controllers/ChatHistoryController.cs b/webapi/Controllers/ChatHistoryController.cs index fb84848f7..58f1078c7 100644 --- a/webapi/Controllers/ChatHistoryController.cs +++ b/webapi/Controllers/ChatHistoryController.cs @@ -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; @@ -19,6 +21,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Memory; namespace CopilotChat.WebApi.Controllers; @@ -31,6 +34,7 @@ namespace CopilotChat.WebApi.Controllers; public class ChatHistoryController : ControllerBase { private readonly ILogger _logger; + private readonly IMemoryStore _memoryStore; private readonly ChatSessionRepository _sessionRepository; private readonly ChatMessageRepository _messageRepository; private readonly ChatParticipantRepository _participantRepository; @@ -38,11 +42,13 @@ public class ChatHistoryController : ControllerBase private readonly PromptsOptions _promptOptions; private readonly IAuthInfo _authInfo; private const string ChatEditedClientCall = "ChatEdited"; + private const string ChatDeletedClientCall = "ChatDeleted"; /// /// Initializes a new instance of the class. /// /// The logger. + /// Memory store. /// The chat session repository. /// The chat message repository. /// The chat participant repository. @@ -51,6 +57,7 @@ public class ChatHistoryController : ControllerBase /// The auth info for the current request. public ChatHistoryController( ILogger logger, + IMemoryStore memoryStore, ChatSessionRepository sessionRepository, ChatMessageRepository messageRepository, ChatParticipantRepository participantRepository, @@ -59,6 +66,7 @@ public ChatHistoryController( IAuthInfo authInfo) { this._logger = logger; + this._memoryStore = memoryStore; this._sessionRepository = sessionRepository; this._messageRepository = messageRepository; this._participantRepository = participantRepository; @@ -260,4 +268,111 @@ public async Task>> GetSourcesAsync( return this.NotFound($"No chat session found for chat id '{chatId}'."); } + + /// + /// Delete a chat session. + /// + /// The chat id. + [HttpDelete] + [Route("chatSession/{chatId:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Authorize(Policy = AuthPolicyName.RequireChatParticipant)] + public async Task DeleteChatSessionAsync( + [FromServices] IHubContext 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(); + } + + /// + /// Deletes all associated resources (messages, memories, participants) associated with a chat session. + /// + /// The chat id. + private async Task DeleteChatResourcesAsync(string chatId, CancellationToken cancellationToken) + { + var cleanupTasks = new List(); + + // 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()) + .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); + } + } } diff --git a/webapi/Extensions/ServiceExtensions.cs b/webapi/Extensions/ServiceExtensions.cs index aba3f1b7f..e63f33441 100644 --- a/webapi/Extensions/ServiceExtensions.cs +++ b/webapi/Extensions/ServiceExtensions.cs @@ -108,7 +108,7 @@ internal static IServiceCollection AddCorsPolicy(this IServiceCollection service policy => { policy.WithOrigins(allowedOrigins) - .WithMethods("GET", "POST") + .WithMethods("GET", "POST", "DELETE") .AllowAnyHeader(); }); }); diff --git a/webapp/src/Constants.ts b/webapp/src/Constants.ts index d741c1220..05f7efc5b 100644 --- a/webapp/src/Constants.ts +++ b/webapp/src/Constants.ts @@ -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, }; diff --git a/webapp/src/assets/strings.ts b/webapp/src/assets/strings.ts new file mode 100644 index 000000000..03e0c8dee --- /dev/null +++ b/webapp/src/assets/strings.ts @@ -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.', +}; diff --git a/webapp/src/components/chat/ChatInput.tsx b/webapp/src/components/chat/ChatInput.tsx index 757ff18a4..2d277b19d 100644 --- a/webapp/src/components/chat/ChatInput.tsx +++ b/webapp/src/components/chat/ChatInput.tsx @@ -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'; @@ -115,7 +116,7 @@ export const ChatInput: React.FC = ({ isDraggingOver, onDragLeav React.useEffect(() => { const chatState = conversations[selectedId]; - setValue(chatState.input); + setValue(chatState.disabled ? COPY.CHAT_DELETED_MESSAGE() : chatState.input); }, [conversations, selectedId]); const handleSpeech = () => { @@ -170,6 +171,7 @@ export const ChatInput: React.FC = ({ isDraggingOver, onDragLeav ref={textAreaRef} id="chat-input" resize="vertical" + disabled={conversations[selectedId].disabled} textarea={{ className: isDraggingOver ? mergeClasses(classes.dragAndDrop, classes.textarea) @@ -225,7 +227,9 @@ export const ChatInput: React.FC = ({ isDraggingOver, onDragLeav }} /> - diff --git a/webapp/src/components/chat/persona/MemoryBiasSlider.tsx b/webapp/src/components/chat/persona/MemoryBiasSlider.tsx index 1488916e2..04e6c2ad7 100644 --- a/webapp/src/components/chat/persona/MemoryBiasSlider.tsx +++ b/webapp/src/components/chat/persona/MemoryBiasSlider.tsx @@ -90,6 +90,7 @@ export const MemoryBiasSlider: React.FC = () => { onChange={(_, data) => { sliderValueChange(data.value); }} + disabled={conversations[selectedId].disabled} /> diff --git a/webapp/src/components/chat/persona/PromptEditor.tsx b/webapp/src/components/chat/persona/PromptEditor.tsx index 0d77a66d2..41b367a71 100644 --- a/webapp/src/components/chat/persona/PromptEditor.tsx +++ b/webapp/src/components/chat/persona/PromptEditor.tsx @@ -12,7 +12,7 @@ import { } from '@fluentui/react-components'; import React from 'react'; import { AlertType } from '../../../libs/models/AlertType'; -import { useAppDispatch } from '../../../redux/app/hooks'; +import { useAppDispatch, useAppSelector } from '../../../redux/app/hooks'; import { addAlert } from '../../../redux/features/app/appSlice'; import { Info16 } from '../../shared/BundledIcons'; @@ -60,6 +60,7 @@ export const PromptEditor: React.FC = ({ const classes = useClasses(); const dispatch = useAppDispatch(); const [value, setValue] = React.useState(prompt); + const { conversations } = useAppSelector((state) => state.conversations); React.useEffect(() => { // Taking a dependency on the chatId because the value state needs @@ -106,7 +107,10 @@ export const PromptEditor: React.FC = ({ /> {isEditable && (
-
diff --git a/webapp/src/components/chat/prompt-dialog/stepwise-planner/StepwiseThoughtProcessView.tsx b/webapp/src/components/chat/prompt-dialog/stepwise-planner/StepwiseThoughtProcessView.tsx index 6196736ee..7f8ad18a1 100644 --- a/webapp/src/components/chat/prompt-dialog/stepwise-planner/StepwiseThoughtProcessView.tsx +++ b/webapp/src/components/chat/prompt-dialog/stepwise-planner/StepwiseThoughtProcessView.tsx @@ -9,7 +9,7 @@ import { tokens, } from '@fluentui/react-components'; import { useState } from 'react'; -import { Constants } from '../../../../Constants'; +import { COPY } from '../../../../assets/strings'; import { DependencyDetails } from '../../../../libs/models/BotResponsePrompt'; import { StepwiseStep } from '../../../../libs/models/StepwiseStep'; import { StepwiseThoughtProcess } from '../../../../libs/models/StepwiseThoughtProcess'; @@ -36,7 +36,7 @@ export const StepwiseThoughtProcessView: React.FC 0; if (resultNotFound) { diff --git a/webapp/src/components/chat/tabs/DocumentsTab.tsx b/webapp/src/components/chat/tabs/DocumentsTab.tsx index b33ebbda2..b36c9e04b 100644 --- a/webapp/src/components/chat/tabs/DocumentsTab.tsx +++ b/webapp/src/components/chat/tabs/DocumentsTab.tsx @@ -94,25 +94,26 @@ export const DocumentsTab: React.FC = () => { const documentFileRef = useRef(null); React.useEffect(() => { - const importingResources = importingDocuments - ? importingDocuments.map((document, index) => { - return { - id: `in-progress-${index}`, - chatId: selectedId, - sourceType: 'N/A', - name: document, - sharedBy: 'N/A', - createdOn: 0, - tokens: 0, - } as ChatMemorySource; - }) - : []; - setResources(importingResources); - - void chat.getChatMemorySources(selectedId).then((sources) => { - setResources([...importingResources, ...sources]); - }); + if (!conversations[selectedId].disabled) { + const importingResources = importingDocuments + ? importingDocuments.map((document, index) => { + return { + id: `in-progress-${index}`, + chatId: selectedId, + sourceType: 'N/A', + name: document, + sharedBy: 'N/A', + createdOn: 0, + tokens: 0, + } as ChatMemorySource; + }) + : []; + setResources(importingResources); + void chat.getChatMemorySources(selectedId).then((sources) => { + setResources([...importingResources, ...sources]); + }); + } // We don't want to have chat as one of the dependencies as it will cause infinite loop. // eslint-disable-next-line react-hooks/exhaustive-deps }, [importingDocuments, selectedId]); @@ -140,7 +141,9 @@ export const DocumentsTab: React.FC = () => {