[FEAT] Ability to delete messages from conversation history (#1812)

* implement delete message feature

* add new delete chat endpoint that just hides messages from ui and fix perms

* refactor: add delete-event hooks for delete so we dont need to pass so much
refactor: implmentation of delete endpoint PUT

* update animation
linting
remove element on animiation to prevent popin

* linting and cleanup imports

---------

Co-authored-by: timothycarambat <rambat1010@gmail.com>
This commit is contained in:
Sean Hatfield 2024-07-03 18:28:08 -07:00 committed by GitHub
parent 8658b1e7c7
commit ab56bae1bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 136 additions and 2 deletions

View File

@ -0,0 +1,67 @@
import { useState, useEffect } from "react";
import { Trash } from "@phosphor-icons/react";
import { Tooltip } from "react-tooltip";
import Workspace from "@/models/workspace";
const DELETE_EVENT = "delete-message";
export function useWatchDeleteMessage({ chatId = null, role = "user" }) {
const [isDeleted, setIsDeleted] = useState(false);
const [completeDelete, setCompleteDelete] = useState(false);
useEffect(() => {
function listenForEvent() {
if (!chatId) return;
window.addEventListener(DELETE_EVENT, onDeleteEvent);
}
listenForEvent();
return () => {
window.removeEventListener(DELETE_EVENT, onDeleteEvent);
};
}, [chatId]);
function onEndAnimation() {
if (!isDeleted) return;
setCompleteDelete(true);
}
async function onDeleteEvent(e) {
if (e.detail.chatId === chatId) {
setIsDeleted(true);
// Do this to prevent double-emission of the PUT/DELETE api call
// because then there will be a race condition and it will make an error log for nothing
// as one call will complete and the other will fail.
if (role === "assistant") await Workspace.deleteChat(chatId);
return false;
}
}
return { isDeleted, completeDelete, onEndAnimation };
}
export function DeleteMessage({ chatId, isEditing, role }) {
if (!chatId || isEditing || role === "user") return null;
function emitDeleteEvent() {
window.dispatchEvent(new CustomEvent(DELETE_EVENT, { detail: { chatId } }));
}
return (
<div className="mt-3 relative">
<button
onClick={emitDeleteEvent}
data-tooltip-id={`delete-message-${chatId}`}
data-tooltip-content="Delete message"
className="border-none text-zinc-300"
aria-label="Delete"
>
<Trash size={18} className="mb-1" />
</button>
<Tooltip
id={`delete-message-${chatId}`}
place="bottom"
delayShow={300}
className="tooltip !text-xs"
/>
</div>
);
}

View File

@ -12,6 +12,7 @@ import { Tooltip } from "react-tooltip";
import Workspace from "@/models/workspace"; import Workspace from "@/models/workspace";
import TTSMessage from "./TTSButton"; import TTSMessage from "./TTSButton";
import { EditMessageAction } from "./EditMessage"; import { EditMessageAction } from "./EditMessage";
import { DeleteMessage } from "./DeleteMessage";
const Actions = ({ const Actions = ({
message, message,
@ -50,6 +51,7 @@ const Actions = ({
chatId={chatId} chatId={chatId}
/> />
)} )}
<DeleteMessage chatId={chatId} role={role} isEditing={isEditing} />
{chatId && role !== "user" && !isEditing && ( {chatId && role !== "user" && !isEditing && (
<> <>
<FeedbackButton <FeedbackButton

View File

@ -9,6 +9,7 @@ import { AI_BACKGROUND_COLOR, USER_BACKGROUND_COLOR } from "@/utils/constants";
import { v4 } from "uuid"; import { v4 } from "uuid";
import createDOMPurify from "dompurify"; import createDOMPurify from "dompurify";
import { EditMessageForm, useEditMessage } from "./Actions/EditMessage"; import { EditMessageForm, useEditMessage } from "./Actions/EditMessage";
import { useWatchDeleteMessage } from "./Actions/DeleteMessage";
const DOMPurify = createDOMPurify(window); const DOMPurify = createDOMPurify(window);
const HistoricalMessage = ({ const HistoricalMessage = ({
@ -26,6 +27,10 @@ const HistoricalMessage = ({
forkThread, forkThread,
}) => { }) => {
const { isEditing } = useEditMessage({ chatId, role }); const { isEditing } = useEditMessage({ chatId, role });
const { isDeleted, completeDelete, onEndAnimation } = useWatchDeleteMessage({
chatId,
role,
});
const adjustTextArea = (event) => { const adjustTextArea = (event) => {
const element = event.target; const element = event.target;
element.style.height = "auto"; element.style.height = "auto";
@ -58,10 +63,14 @@ const HistoricalMessage = ({
); );
} }
if (completeDelete) return null;
return ( return (
<div <div
key={uuid} key={uuid}
className={`flex justify-center items-end w-full group ${ onAnimationEnd={onEndAnimation}
className={`${
isDeleted ? "animate-remove" : ""
} flex justify-center items-end w-full group ${
role === "user" ? USER_BACKGROUND_COLOR : AI_BACKGROUND_COLOR role === "user" ? USER_BACKGROUND_COLOR : AI_BACKGROUND_COLOR
}`} }`}
> >

View File

@ -746,3 +746,23 @@ does not extend the close button beyond the viewport. */
.search-input::-webkit-search-cancel-button { .search-input::-webkit-search-cancel-button {
filter: grayscale(100%) invert(1) brightness(100) opacity(0.5); filter: grayscale(100%) invert(1) brightness(100) opacity(0.5);
} }
.animate-remove {
animation: fadeAndShrink 800ms forwards;
}
@keyframes fadeAndShrink {
50% {
opacity: 25%;
}
75% {
opacity: 10%;
}
100% {
height: 0px;
opacity: 0%;
display: none;
}
}

View File

@ -384,6 +384,17 @@ const Workspace = {
return false; return false;
}); });
}, },
deleteChat: async (chatId) => {
return await fetch(`${API_BASE}/workspace/workspace-chats/${chatId}`, {
method: "PUT",
headers: baseHeaders(),
})
.then((res) => res.json())
.catch((e) => {
console.error(e);
return { success: false, error: e.message };
});
},
forkThread: async function (slug = "", threadSlug = null, chatId = null) { forkThread: async function (slug = "", threadSlug = null, chatId = null) {
return await fetch(`${API_BASE}/workspace/${slug}/thread/fork`, { return await fetch(`${API_BASE}/workspace/${slug}/thread/fork`, {
method: "POST", method: "POST",

View File

@ -833,11 +833,36 @@ function workspaceEndpoints(app) {
); );
response.status(200).json({ newThreadSlug: newThread.slug }); response.status(200).json({ newThreadSlug: newThread.slug });
} catch (e) { } catch (e) {
console.log(e.message, e); console.error(e.message, e);
response.status(500).json({ message: "Internal server error" }); response.status(500).json({ message: "Internal server error" });
} }
} }
); );
app.put(
"/workspace/workspace-chats/:id",
[validatedRequest, flexUserRoleValid([ROLES.all])],
async (request, response) => {
try {
const { id } = request.params;
const user = await userFromSession(request, response);
const validChat = await WorkspaceChats.get({
id: Number(id),
user_id: user?.id ?? null,
});
if (!validChat)
return response
.status(404)
.json({ success: false, error: "Chat not found." });
await WorkspaceChats._update(validChat.id, { include: false });
response.json({ success: true, error: null });
} catch (e) {
console.error(e.message, e);
response.status(500).json({ success: false, error: "Server error" });
}
}
);
} }
module.exports = { workspaceEndpoints }; module.exports = { workspaceEndpoints };