diff --git a/webapi/Controllers/DocumentController.cs b/webapi/Controllers/DocumentController.cs index 7a4b6b481..e4461c7cd 100644 --- a/webapi/Controllers/DocumentController.cs +++ b/webapi/Controllers/DocumentController.cs @@ -126,7 +126,7 @@ public Task DocumentImportAsync( /// Service API for removing a document. /// Documents imported through this route will be considered as global documents. /// - [Route("documents")] + [Route("documents/{documentId}")] [HttpDelete] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -238,9 +238,12 @@ private async Task DocumentRemoveAsync( return this.BadRequest("Document removal not enabled."); } + // First remove the document embeddings. await memoryClient.RemoveDocumentAsync(this._promptOptions.DocumentMemoryName, documentId.ToString(), cancellationToken); + // $$$ REMOVE CITED MESSAGES ??? + // Then remove the memory source. This ensures that delete may be re-attempted on exception. await this._sourceRepository.DeleteAsync(documentId.ToString(), chatId.ToString()); return this.Ok(); diff --git a/webapi/Extensions/ServiceExtensions.cs b/webapi/Extensions/ServiceExtensions.cs index 39f9f4a86..efbd124cd 100644 --- a/webapi/Extensions/ServiceExtensions.cs +++ b/webapi/Extensions/ServiceExtensions.cs @@ -273,6 +273,7 @@ public static IServiceCollection AddChatCopilotAuthorization(this IServiceCollec AuthPolicyName.RequireChatParticipant, builder => builder.RequireAuthenticatedUser().AddRequirements(new ChatParticipantRequirement())); + // $$$ TBD - AUTH //options.AddPolicy( // AuthPolicyName.RequireChatAdmin, // builder => builder.RequireAuthenticatedUser()); //.AddRequirements(new ChatParticipantRequirement())) $$$ diff --git a/webapp/src/components/chat/tabs/DocumentsTab.tsx b/webapp/src/components/chat/tabs/DocumentsTab.tsx index d515eaef1..9322f56b7 100644 --- a/webapp/src/components/chat/tabs/DocumentsTab.tsx +++ b/webapp/src/components/chat/tabs/DocumentsTab.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ // $$$ // Copyright (c) Microsoft. All rights reserved. import { @@ -8,7 +9,6 @@ import { MenuList, MenuPopover, MenuTrigger, - ProgressBar, Radio, RadioGroup, Spinner, @@ -40,6 +40,7 @@ import { import * as React from 'react'; import { useRef } from 'react'; import { Constants } from '../../../Constants'; +import { DeleteDocumentDialog } from './dialogs/DeleteDocumentDialog'; import { useChat, useFile } from '../../../libs/hooks'; import { ChatMemorySource } from '../../../libs/models/ChatMemorySource'; import { useAppSelector } from '../../../redux/app/hooks'; @@ -106,16 +107,16 @@ export const DocumentsTab: React.FC = () => { 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, - size: 0, - } as ChatMemorySource; - }) + return { + id: `in-progress-${index}`, + chatId: selectedId, + sourceType: 'N/A', + name: document, + sharedBy: 'N/A', + createdOn: 0, + size: 0, + } as ChatMemorySource; + }) : []; setResources(importingResources); @@ -127,114 +128,128 @@ export const DocumentsTab: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [importingDocuments, selectedId]); - const { columns, rows } = useTable(resources); + const handleDelete = async (chatId: string, documentId: string) => { + try { + await fileHandler.deleteDocument(chatId, documentId); + // Update the state immediately after deleting the file + setResources((prevResources) => prevResources.filter((resource) => resource.id !== documentId)); + } catch (error) { + console.error('Failed to delete the file:', error); + } + }; + + const { columns, rows } = useTable(resources, handleDelete); + return ( - -
- {/* Hidden input for file upload. Only accept .txt and .pdf files for now. */} - { - void fileHandler.handleImport(selectedId, localDocumentFileRef, false); - }} - /> - { - void fileHandler.handleImport(selectedId, globalDocumentFileRef, true); - }} - /> - - - - - - - - - localDocumentFileRef.current?.click()} - icon={} - disabled={ - conversations[selectedId].disabled || - (importingDocuments && importingDocuments.length > 0) - } - > - New local chat document - - globalDocumentFileRef.current?.click()} - icon={} - disabled={ - conversations[selectedId].disabled || - (importingDocuments && importingDocuments.length > 0) - } - > - New global document - - - - - {importingDocuments && importingDocuments.length > 0 && } - {/* Hardcode vector database as we don't support switching vector store dynamically now. */} -
- - - {serviceInfo.memoryStore.types.map((storeType) => { - return ( - - ); - })} - + <> + +
+ {/* Hidden input for file upload. Only accept .txt and .pdf files for now. */} + { + void fileHandler.handleImport(selectedId, localDocumentFileRef, false); + }} + /> + { + void fileHandler.handleImport(selectedId, globalDocumentFileRef, true); + }} + /> + + + + + + + + + localDocumentFileRef.current?.click()} + icon={} + disabled={ + conversations[selectedId].disabled || + (importingDocuments && importingDocuments.length > 0) + } + > + New local chat document + + globalDocumentFileRef.current?.click()} + icon={} + disabled={ + conversations[selectedId].disabled || + (importingDocuments && importingDocuments.length > 0) + } + > + New global document + + + + + {importingDocuments && importingDocuments.length > 0 && } + {/* Hardcode vector database as we don't support switching vector store dynamically now. */} +
+ + + {serviceInfo.memoryStore.types.map((storeType) => { + return ( + + ); + })} + +
-
- - - {columns.map((column) => column.renderHeaderCell())} - - - {rows.map((item) => ( - {columns.map((column) => column.renderCell(item))} - ))} - -
- - ); + + + {columns.map((column) => column.renderHeaderCell())} + + + {rows.map((item) => ( + {columns.map((column) => column.renderCell(item))} + ))} + +
+ + ); }; -function useTable(resources: ChatMemorySource[]) { +function useTable(resources: ChatMemorySource[], handleDelete: (chatId: string, documentId: string) => Promise) { + const { serviceInfo } = useAppSelector((state: RootState) => state.app); + const headerSortProps = (columnId: TableColumnId): TableHeaderCellProps => ({ onClick: (e: React.MouseEvent) => { toggleColumnSort(e, columnId); @@ -318,29 +333,22 @@ function useTable(resources: ChatMemorySource[]) { }, }), createTableColumn({ - columnId: 'progress', + columnId: 'delete', renderHeaderCell: () => ( - - Progress + + {(serviceInfo.isDeleteDocumentEnabled ? "Delete" : "")} ), - renderCell: (item) => ( - - + renderCell: (item) => + + + {( + serviceInfo.isDeleteDocumentEnabled ? + : + <> + )} + - ), - compare: (a, b) => { - const aAccess = getAccessString(a.chatId); - const bAccess = getAccessString(b.chatId); - const comparison = aAccess.localeCompare(bAccess); - return getSortDirection('progress') === 'ascending' ? comparison : comparison * -1; - }, }), ]; @@ -380,7 +388,7 @@ function useTable(resources: ChatMemorySource[]) { }); } - return { columns, rows: items }; + return { columns, rows: items, handleDelete }; } function getAccessString(chatId: string) { diff --git a/webapp/src/components/chat/tabs/dialogs/DeleteDocumentDialog.tsx b/webapp/src/components/chat/tabs/dialogs/DeleteDocumentDialog.tsx new file mode 100644 index 000000000..c93d010b7 --- /dev/null +++ b/webapp/src/components/chat/tabs/dialogs/DeleteDocumentDialog.tsx @@ -0,0 +1,91 @@ +import { Button } from '@fluentui/react-button'; +import { Tooltip, makeStyles } from '@fluentui/react-components'; +import { + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + DialogTrigger, +} from '@fluentui/react-dialog'; +import { Delete16 } from '../../../shared/BundledIcons'; +import { DocumentImportService } from '../../../../libs/services/DocumentImportService'; +import { AuthHelper } from '../../../../libs/auth/AuthHelper'; +import { useMsal } from '@azure/msal-react'; +import { AlertType } from '../../../../libs/models/AlertType'; +import { useAppDispatch } from '../../../../redux/app/hooks'; +import { addAlert } from '../../../../redux/features/app/appSlice'; + +const useClasses = makeStyles({ + root: { + width: '450px', + }, + actions: { + paddingTop: '10%', + }, +}); + +interface IDeleteDocumentProps { + chatId: string; + documentId: string; + documentName: string; +} + +export const DeleteDocumentDialog: React.FC = ({ chatId, documentId, documentName }) => { + const classes = useClasses(); + const dispatch = useAppDispatch(); + const { instance, inProgress } = useMsal(); + const documentImportService = new DocumentImportService(); + + const onDeleteDocument = async () => { + console.log(chatId); + console.log(documentId); + await documentImportService.deleteDocumentAsync( + chatId, + documentId, + await AuthHelper.getSKaaSAccessToken(instance, inProgress)) + .catch((e: any) => { + const errorDetails = (e as Error).message.includes('Failed to delete resources for chat id') + ? "Some or all resources associated with this chat couldn't be deleted. Please try again." + : `Details: ${(e as Error).message}`; + dispatch( + addAlert({ + message: `Unable to delete document {${documentName}}. ${errorDetails}`, + type: AlertType.Error + }), + ); + }); +; + }; + + return ( + + + + + + + + + + + + + ); +}; diff --git a/webapp/src/libs/hooks/useFile.ts b/webapp/src/libs/hooks/useFile.ts index 9c4dfbb8b..4bdcefa9c 100644 --- a/webapp/src/libs/hooks/useFile.ts +++ b/webapp/src/libs/hooks/useFile.ts @@ -86,6 +86,17 @@ export const useFile = () => { } }; + async function deleteDocument(chatId: string, documentId: string): Promise { + try { + + // Call the deleteDocumentAsync method from the DocumentDeleteService + await documentImportService.deleteDocumentAsync(chatId, documentId, await AuthHelper.getSKaaSAccessToken(instance, inProgress)); + + } catch (error) { + console.error('Failed to delete the file:', error); + } + } + const getContentSafetyStatus = async () => { try { const result = await documentImportService.getContentSafetyStatusAsync( @@ -105,6 +116,7 @@ export const useFile = () => { return { loadFile, downloadFile, + deleteDocument, handleImport, getContentSafetyStatus, }; diff --git a/webapp/src/libs/models/ServiceInfo.ts b/webapp/src/libs/models/ServiceInfo.ts index d74927742..c91e5932f 100644 --- a/webapp/src/libs/models/ServiceInfo.ts +++ b/webapp/src/libs/models/ServiceInfo.ts @@ -15,4 +15,5 @@ export interface ServiceInfo { availablePlugins: HostedPlugin[]; version: string; isContentSafetyEnabled: boolean; + isDeleteDocumentEnabled: boolean; } diff --git a/webapp/src/libs/services/DocumentImportService.ts b/webapp/src/libs/services/DocumentImportService.ts index 6f8620928..bf78dda16 100644 --- a/webapp/src/libs/services/DocumentImportService.ts +++ b/webapp/src/libs/services/DocumentImportService.ts @@ -20,7 +20,7 @@ export class DocumentImportService extends BaseService { return await this.getResponseAsync( { - commandPath: uploadToGlobal ? `documents` : `chats/${chatId}/documents`, + commandPath: uploadToGlobal ? 'documents' : `chats/${chatId}/documents`, method: 'POST', body: formData, }, @@ -28,6 +28,20 @@ export class DocumentImportService extends BaseService { ); }; + public deleteDocumentAsync = async ( + chatId: string, + documentId: string, + accessToken: string + ) => { + return await this.getResponseAsync( + { + commandPath: chatId ? `documents/${documentId}` : `chats/${chatId}/documents/${documentId}`, + method: 'DELETE' + }, + accessToken, + ); + }; + public getContentSafetyStatusAsync = async (accessToken: string): Promise => { const serviceInfo = await this.getResponseAsync( { diff --git a/webapp/src/redux/features/app/AppState.ts b/webapp/src/redux/features/app/AppState.ts index 027bcad15..e702610c5 100644 --- a/webapp/src/redux/features/app/AppState.ts +++ b/webapp/src/redux/features/app/AppState.ts @@ -149,6 +149,7 @@ export const initialState: AppState = { availablePlugins: [], version: '', isContentSafetyEnabled: false, + isDeleteDocumentEnabled: false, }, isMaintenance: false, };