From 26c220503cbf22ec2b55fa588e1f795914beb89a Mon Sep 17 00:00:00 2001 From: Sean Hatfield <seanhatfield5@gmail.com> Date: Thu, 6 Jun 2024 12:56:11 -0700 Subject: [PATCH] [FEAT] Edit message button (#1392) * WIP edit message feature * WIP edit message * WIP editing messages feature * Fix PFPs TODO: Fix default user profile image Add User and Assistant workspace response * unset PFP changes for later PR --------- Co-authored-by: timothycarambat <rambat1010@gmail.com> --- frontend/package.json | 2 +- frontend/src/components/ChatBubble/index.jsx | 5 +- frontend/src/components/DefaultChat/index.jsx | 23 ++-- frontend/src/components/UserIcon/index.jsx | 2 +- .../src/components/UserIcon/workspace.png | Bin 0 -> 1486 bytes .../Actions/EditMessage/index.jsx | 126 ++++++++++++++++++ .../HistoricalMessage/Actions/index.jsx | 13 +- .../ChatHistory/HistoricalMessage/index.jsx | 86 ++++++++---- .../ChatHistory/PromptReply/index.jsx | 4 +- .../ChatContainer/ChatHistory/index.jsx | 45 +++++++ .../WorkspaceChat/ChatContainer/index.jsx | 1 + .../src/components/WorkspaceChat/index.jsx | 1 + frontend/src/models/workspace.js | 53 +++++++- frontend/src/models/workspaceThread.js | 45 +++++++ frontend/src/utils/chat/index.js | 9 +- server/endpoints/workspaceThreads.js | 78 ++++++++++- server/endpoints/workspaces.js | 62 ++++++++- server/models/workspaceChats.js | 18 +++ server/utils/helpers/chat/responses.js | 1 + 19 files changed, 512 insertions(+), 62 deletions(-) create mode 100644 frontend/src/components/UserIcon/workspace.png create mode 100644 frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/EditMessage/index.jsx diff --git a/frontend/package.json b/frontend/package.json index 2b669731a..8aa4dcfa5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -63,4 +63,4 @@ "tailwindcss": "^3.3.1", "vite": "^4.3.0" } -} +} \ No newline at end of file diff --git a/frontend/src/components/ChatBubble/index.jsx b/frontend/src/components/ChatBubble/index.jsx index 8d3118838..c5a1f1907 100644 --- a/frontend/src/components/ChatBubble/index.jsx +++ b/frontend/src/components/ChatBubble/index.jsx @@ -1,5 +1,5 @@ import React from "react"; -import Jazzicon from "../UserIcon"; +import UserIcon from "../UserIcon"; import { userFromStorage } from "@/utils/request"; import { AI_BACKGROUND_COLOR, USER_BACKGROUND_COLOR } from "@/utils/constants"; @@ -11,8 +11,7 @@ export default function ChatBubble({ message, type, popMsg }) { <div className={`flex justify-center items-end w-full ${backgroundColor}`}> <div className={`py-8 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col`}> <div className="flex gap-x-5"> - <Jazzicon - size={36} + <UserIcon user={{ uid: isUser ? userFromStorage()?.username : "system" }} role={type} /> diff --git a/frontend/src/components/DefaultChat/index.jsx b/frontend/src/components/DefaultChat/index.jsx index 43ae6e7a6..ae52a0d2b 100644 --- a/frontend/src/components/DefaultChat/index.jsx +++ b/frontend/src/components/DefaultChat/index.jsx @@ -13,7 +13,7 @@ import { isMobile } from "react-device-detect"; import { SidebarMobileHeader } from "../Sidebar"; import ChatBubble from "../ChatBubble"; import System from "@/models/system"; -import Jazzicon from "../UserIcon"; +import UserIcon from "../UserIcon"; import { userFromStorage } from "@/utils/request"; import { AI_BACKGROUND_COLOR, USER_BACKGROUND_COLOR } from "@/utils/constants"; import useUser from "@/hooks/useUser"; @@ -46,7 +46,7 @@ export default function DefaultChatContainer() { className={`pt-10 pb-6 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col`} > <div className="flex gap-x-5"> - <Jazzicon size={36} user={{ uid: "system" }} role={"assistant"} /> + <UserIcon user={{ uid: "system" }} role={"assistant"} /> <span className={`whitespace-pre-line text-white font-normal text-sm md:text-sm flex flex-col gap-y-1 mt-2`} @@ -70,7 +70,7 @@ export default function DefaultChatContainer() { className={`pb-4 pt-2 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col`} > <div className="flex gap-x-5"> - <Jazzicon size={36} user={{ uid: "system" }} role={"assistant"} /> + <UserIcon user={{ uid: "system" }} role={"assistant"} /> <span className={`whitespace-pre-line text-white font-normal text-sm md:text-sm flex flex-col gap-y-1 mt-2`} @@ -93,7 +93,7 @@ export default function DefaultChatContainer() { className={`pt-2 pb-6 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col`} > <div className="flex gap-x-5"> - <Jazzicon size={36} user={{ uid: "system" }} role={"assistant"} /> + <UserIcon user={{ uid: "system" }} role={"assistant"} /> <div> <span className={`whitespace-pre-line text-white font-normal text-sm md:text-sm flex flex-col gap-y-1 mt-2`} @@ -127,8 +127,7 @@ export default function DefaultChatContainer() { className={`py-6 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col`} > <div className="flex gap-x-5"> - <Jazzicon - size={36} + <UserIcon user={{ uid: userFromStorage()?.username }} role={"user"} /> @@ -151,7 +150,7 @@ export default function DefaultChatContainer() { className={`py-6 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col`} > <div className="flex gap-x-5"> - <Jazzicon size={36} user={{ uid: "system" }} role={"assistant"} /> + <UserIcon user={{ uid: "system" }} role={"assistant"} /> <div> <span className={`whitespace-pre-line text-white font-normal text-sm md:text-sm flex flex-col gap-y-1 mt-2`} @@ -188,8 +187,7 @@ export default function DefaultChatContainer() { className={`py-6 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col`} > <div className="flex gap-x-5"> - <Jazzicon - size={36} + <UserIcon user={{ uid: userFromStorage()?.username }} role={"user"} /> @@ -213,7 +211,7 @@ export default function DefaultChatContainer() { className={`py-6 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col`} > <div className="flex gap-x-5"> - <Jazzicon size={36} user={{ uid: "system" }} role={"assistant"} /> + <UserIcon user={{ uid: "system" }} role={"assistant"} /> <span className={`whitespace-pre-line text-white font-normal text-sm md:text-sm flex flex-col gap-y-1 mt-2`} @@ -251,8 +249,7 @@ export default function DefaultChatContainer() { className={`py-6 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col`} > <div className="flex gap-x-5"> - <Jazzicon - size={36} + <UserIcon user={{ uid: userFromStorage()?.username }} role={"user"} /> @@ -275,7 +272,7 @@ export default function DefaultChatContainer() { className={`py-6 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col`} > <div className="flex gap-x-5"> - <Jazzicon size={36} user={{ uid: "system" }} role={"assistant"} /> + <UserIcon user={{ uid: "system" }} role={"assistant"} /> <div> <span className={`whitespace-pre-line text-white font-normal text-sm md:text-sm flex flex-col gap-y-1 mt-2`} diff --git a/frontend/src/components/UserIcon/index.jsx b/frontend/src/components/UserIcon/index.jsx index 6cc9b57d0..7fc6b8df6 100644 --- a/frontend/src/components/UserIcon/index.jsx +++ b/frontend/src/components/UserIcon/index.jsx @@ -2,7 +2,7 @@ import React, { useRef, useEffect } from "react"; import JAZZ from "@metamask/jazzicon"; import usePfp from "../../hooks/usePfp"; -export default function Jazzicon({ size = 10, user, role }) { +export default function UserIcon({ size = 36, user, role }) { const { pfp } = usePfp(); const divRef = useRef(null); const seed = user?.uid diff --git a/frontend/src/components/UserIcon/workspace.png b/frontend/src/components/UserIcon/workspace.png new file mode 100644 index 0000000000000000000000000000000000000000..537d583c5827cb6112b6d16e91bb28ef46c7ec8f GIT binary patch literal 1486 zcmV;<1u^=GP)<h;3K|Lk000e1NJLTq001Qb001Qj1^@s6#hxGo00009a7bBm000XU z000XU0RWnu7ytkO0drDELIAGL9O(c600d`2O+f$vv5yP<VFdsH1!GA>K~#7F?N)tE zl~ou&@8#YLmwUN_@*#LZmsoj`Vv#eGH3*rTTXS<mDqWK{TP`bWk+y7-rLJ0Ou0JAe z*_yh=ooQ&vN66=Zh+3{u0U`zjT;zVkz3+KHPS5k+mtuIiLaYDov;Fp-kLNkh?|II1 z&Up!mNJJtMk%%l<xPPh3$p0uBa%Upi)YQ~i_{IUufJKO;q$Cr3^^7r&+#VeEbpWHP zA3rV7Hd(D!{XBe!CWOYly}dhJE?2eN<7s!f-5u^A+U>V)*-A@Gt)LfMUS9sU1C?Y< zZEbD7+wHl6b~Ax?Cr+GrNDV5AEKoEyo9(^;rOeOs3IaT~r@^y7hK3qIZzX0Di2)(B z)15qdGS}z#yFp)8bOXG)M$m3#)cynL-35pyAxbD*O=+*x)^AzAew__FEltf$g21~t zO_Y?Fl$f+;O?DoRf8W`8yr>}meQ4SN^4|UXGY;(A->B2+EgrYa(b3-4uGQ-)2N|@a zXRKJ6x$1tuf6|_@Y}sRiAp8b4UNYYr&CSi#Rh~cp__))}5HPeIumO+*c(|##^@82$ zVt?AlebCPW;C94czd2YlZBwvuW0_W~EyCphYtNoL_YRKV93B~ie!kgkUK}bha~4}y zR~HcknWKV8s371FE_4HGp%1>lm%sJqyW`{IKYe@on+oVlfSAsouN{mr#1^6crHdEO zT&=5X6GgEb=6cX}N%B%B&QXCPktmPH6VkVqgb^VS;BXO46GY4#jYdBZ@k2LIUsKcl zK=zt#JkO(pN!!IsTbCpz??wH_hWe_~oll*H@d3aD)Elj+sE}lilawGuiNSgV2y+V} z1=~j`lrA645(?93G%`^R7=!gW9}o&aU-!nLBi5|!?Dvq6%i$Q@x&6sgNXTNbObiVT z@jyzc8A*^B6@e5Lkr0cEiz8mIcmAYs`y}9lk)dEzPN!3e3<#kU+%j(W?%ioyw-wt& zSvLFJo{86AEjy~$>-#CCV@OCnk!V9iNhHBcDS^&;$ii(OqreIyQ_sfi6T&4VB*d0H zz3XF{5eqnDue`A5aBFj87tD>q9mi*7WpQc%1g;Gd2v80)FNhMBL&z+l%vtF9`T0bm zl2YU$B11dF=eDm7>Z1)sJQYY?P4(H1wpJuGcIHCW=IuLoSt~0m4aLR9ijR?z5h4)S zKFAa=DBCxe>$yom6dWZ|Fx>!%jDsVr0kUWled?^O#2mj^DeTskrmB4}?)d^74Z?V# zC4I%|2lMjI<rNg%1H|yEz#Y->0YQWM!asEyQo^1-nNqc;7}-)%k{xX{MkSb+Osy*@ zNVF_pejMgFm&-9&_S~~r*!}RdIo)2D7q*ClOZMJpo`31>r5PE=Qqt1g{>e!l)YnQu zEp%KIf@Gntg>W~z?zQqGMH>nWFV9eCL7QNCO-=e79~?e(puM@V3A%%snVEcFUthwp zPd+PKwc7gf?XH4$unPlEY~Hv7n!f-%WLCX~dl0#nhVQ%^9xY5a7)@C+RI?<@oFq%| zWdv@6sZ$?)c(k#ux&;XVd=7_0f@?th+V*8n&f0Zx=J<phiOEco&`u^I*b0<RU9GPE z^xBV|osuLuz)o05rf?2_J<=Z8v?)iJq$b8Or3T@2GyCY6<9g4p1JE6YljiE~?xr}0 z4>SA)n1Eo)-K$rxN=!~ok!3k*+HP!Q*xBFPd&6Wh4f}jPXDCwS4y2+~VkriLAw4E0 zW;wJG=|~o^2;WFDBRKWf1<A*P<7CvGVV40&owieTLplyeO06NqiDuR~ilnwfz4DqF owlhUm^<@%~h(shJ5&7@rFSk<l5#I9B$^ZZW07*qoM6N<$g1wrz`Tzg` literal 0 HcmV?d00001 diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/EditMessage/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/EditMessage/index.jsx new file mode 100644 index 000000000..f9346b26a --- /dev/null +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/EditMessage/index.jsx @@ -0,0 +1,126 @@ +import { AI_BACKGROUND_COLOR, USER_BACKGROUND_COLOR } from "@/utils/constants"; +import { Pencil } from "@phosphor-icons/react"; +import { useState, useEffect, useRef } from "react"; +import { Tooltip } from "react-tooltip"; +const EDIT_EVENT = "toggle-message-edit"; + +export function useEditMessage({ chatId, role }) { + const [isEditing, setIsEditing] = useState(false); + + function onEditEvent(e) { + if (e.detail.chatId !== chatId || e.detail.role !== role) { + setIsEditing(false); + return false; + } + setIsEditing((prev) => !prev); + } + + useEffect(() => { + function listenForEdits() { + if (!chatId || !role) return; + window.addEventListener(EDIT_EVENT, onEditEvent); + } + listenForEdits(); + return () => { + window.removeEventListener(EDIT_EVENT, onEditEvent); + }; + }, [chatId, role]); + + return { isEditing, setIsEditing }; +} + +export function EditMessageAction({ chatId = null, role, isEditing }) { + function handleEditClick() { + window.dispatchEvent( + new CustomEvent(EDIT_EVENT, { detail: { chatId, role } }) + ); + } + + if (!chatId || isEditing) return null; + return ( + <div + className={`mt-3 relative ${ + role === "user" && !isEditing ? "opacity-0" : "" + } group-hover:opacity-100 transition-all duration-300`} + > + <button + onClick={handleEditClick} + data-tooltip-id="edit-input-text" + data-tooltip-content={`Edit ${ + role === "user" ? "Prompt" : "Response" + } `} + className="border-none text-zinc-300" + aria-label={`Edit ${role === "user" ? "Prompt" : "Response"}`} + > + <Pencil size={18} className="mb-1" /> + </button> + <Tooltip + id="edit-input-text" + place="bottom" + delayShow={300} + className="tooltip !text-xs" + /> + </div> + ); +} + +export function EditMessageForm({ + role, + chatId, + message, + adjustTextArea, + saveChanges, +}) { + const formRef = useRef(null); + function handleSaveMessage(e) { + e.preventDefault(); + const form = new FormData(e.target); + const editedMessage = form.get("editedMessage"); + saveChanges({ editedMessage, chatId, role }); + window.dispatchEvent( + new CustomEvent(EDIT_EVENT, { detail: { chatId, role } }) + ); + } + + function cancelEdits() { + window.dispatchEvent( + new CustomEvent(EDIT_EVENT, { detail: { chatId, role } }) + ); + return false; + } + + useEffect(() => { + if (!formRef || !formRef.current) return; + formRef.current.focus(); + adjustTextArea({ target: formRef.current }); + }, [formRef]); + + return ( + <form onSubmit={handleSaveMessage} className="flex flex-col w-full"> + <textarea + ref={formRef} + name="editedMessage" + className={`w-full rounded ${ + role === "user" ? USER_BACKGROUND_COLOR : AI_BACKGROUND_COLOR + } border border-white/20 active:outline-none focus:outline-none focus:ring-0 pr-16 pl-1.5 pt-1.5 resize-y`} + defaultValue={message} + onChange={adjustTextArea} + /> + <div className="mt-3 flex justify-center"> + <button + type="submit" + className="px-2 py-1 bg-gray-200 text-gray-700 font-medium rounded-md mr-2 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" + > + Save & Submit + </button> + <button + type="button" + className="px-2 py-1 bg-historical-msg-system text-white font-medium rounded-md hover:bg-historical-msg-user/90 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2" + onClick={cancelEdits} + > + Cancel + </button> + </div> + </form> + ); +} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/index.jsx index 41fd7067b..85590e7f3 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/index.jsx @@ -2,14 +2,15 @@ import React, { memo, useState } from "react"; import useCopyText from "@/hooks/useCopyText"; import { Check, - ClipboardText, ThumbsUp, ThumbsDown, ArrowsClockwise, + Copy, } from "@phosphor-icons/react"; import { Tooltip } from "react-tooltip"; import Workspace from "@/models/workspace"; import TTSMessage from "./TTSButton"; +import { EditMessageAction } from "./EditMessage"; const Actions = ({ message, @@ -18,9 +19,10 @@ const Actions = ({ slug, isLastMessage, regenerateMessage, + isEditing, + role, }) => { const [selectedFeedback, setSelectedFeedback] = useState(feedbackScore); - const handleFeedback = async (newFeedback) => { const updatedFeedback = selectedFeedback === newFeedback ? null : newFeedback; @@ -32,14 +34,15 @@ const Actions = ({ <div className="flex w-full justify-between items-center"> <div className="flex justify-start items-center gap-x-4"> <CopyMessage message={message} /> - {isLastMessage && ( + <EditMessageAction chatId={chatId} role={role} isEditing={isEditing} /> + {isLastMessage && !isEditing && ( <RegenerateMessage regenerateMessage={regenerateMessage} slug={slug} chatId={chatId} /> )} - {chatId && ( + {chatId && role !== "user" && !isEditing && ( <> <FeedbackButton isSelected={selectedFeedback === true} @@ -111,7 +114,7 @@ function CopyMessage({ message }) { {copied ? ( <Check size={18} className="mb-1" /> ) : ( - <ClipboardText size={18} className="mb-1" /> + <Copy size={18} className="mb-1" /> )} </button> <Tooltip diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx index ae8f10b4a..1fdb61d45 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx @@ -1,6 +1,6 @@ import React, { memo } from "react"; import { Warning } from "@phosphor-icons/react"; -import Jazzicon from "../../../../UserIcon"; +import UserIcon from "../../../../UserIcon"; import Actions from "./Actions"; import renderMarkdown from "@/utils/chat/markdown"; import { userFromStorage } from "@/utils/request"; @@ -8,6 +8,7 @@ import Citations from "../Citation"; import { AI_BACKGROUND_COLOR, USER_BACKGROUND_COLOR } from "@/utils/constants"; import { v4 } from "uuid"; import createDOMPurify from "dompurify"; +import { EditMessageForm, useEditMessage } from "./Actions/EditMessage"; const DOMPurify = createDOMPurify(window); const HistoricalMessage = ({ @@ -21,27 +22,59 @@ const HistoricalMessage = ({ chatId = null, isLastMessage = false, regenerateMessage, + saveEditedMessage, }) => { + const { isEditing } = useEditMessage({ chatId, role }); + const adjustTextArea = (event) => { + const element = event.target; + element.style.height = "auto"; + element.style.height = element.scrollHeight + "px"; + }; + + if (!!error) { + return ( + <div + key={uuid} + className={`flex justify-center items-end w-full ${ + role === "user" ? USER_BACKGROUND_COLOR : AI_BACKGROUND_COLOR + }`} + > + <div className="py-8 px-4 w-full flex gap-x-5 md:max-w-[800px] flex-col"> + <div className="flex gap-x-5"> + <ProfileImage role={role} workspace={workspace} /> + <div className="p-2 rounded-lg bg-red-50 text-red-500"> + <span className="inline-block"> + <Warning className="h-4 w-4 mb-1 inline-block" /> Could not + respond to message. + </span> + <p className="text-xs font-mono mt-2 border-l-2 border-red-300 pl-2 bg-red-200 p-2 rounded-sm"> + {error} + </p> + </div> + </div> + </div> + </div> + ); + } + return ( <div key={uuid} - className={`flex justify-center items-end w-full ${ + className={`flex justify-center items-end w-full group ${ role === "user" ? USER_BACKGROUND_COLOR : AI_BACKGROUND_COLOR }`} > <div className={`py-8 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col`}> <div className="flex gap-x-5"> <ProfileImage role={role} workspace={workspace} /> - {error ? ( - <div className="p-2 rounded-lg bg-red-50 text-red-500"> - <span className={`inline-block `}> - <Warning className="h-4 w-4 mb-1 inline-block" /> Could not - respond to message. - </span> - <p className="text-xs font-mono mt-2 border-l-2 border-red-300 pl-2 bg-red-200 p-2 rounded-sm"> - {error} - </p> - </div> + {isEditing ? ( + <EditMessageForm + role={role} + chatId={chatId} + message={message} + adjustTextArea={adjustTextArea} + saveChanges={saveEditedMessage} + /> ) : ( <span className={`flex flex-col gap-y-1`} @@ -51,19 +84,19 @@ const HistoricalMessage = ({ /> )} </div> - {role === "assistant" && !error && ( - <div className="flex gap-x-5"> - <div className="relative w-[35px] h-[35px] rounded-full flex-shrink-0 overflow-hidden" /> - <Actions - message={message} - feedbackScore={feedbackScore} - chatId={chatId} - slug={workspace?.slug} - isLastMessage={isLastMessage} - regenerateMessage={regenerateMessage} - /> - </div> - )} + <div className="flex gap-x-5"> + <div className="relative w-[35px] h-[35px] rounded-full flex-shrink-0 overflow-hidden" /> + <Actions + message={message} + feedbackScore={feedbackScore} + chatId={chatId} + slug={workspace?.slug} + isLastMessage={isLastMessage} + regenerateMessage={regenerateMessage} + isEditing={isEditing} + role={role} + /> + </div> {role === "assistant" && <Citations sources={sources} />} </div> </div> @@ -84,8 +117,7 @@ function ProfileImage({ role, workspace }) { } return ( - <Jazzicon - size={36} + <UserIcon user={{ uid: role === "user" ? userFromStorage()?.username : workspace.slug, }} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/PromptReply/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/PromptReply/index.jsx index 07f8280a1..73275e9db 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/PromptReply/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/PromptReply/index.jsx @@ -1,6 +1,6 @@ import { memo } from "react"; import { Warning } from "@phosphor-icons/react"; -import Jazzicon from "../../../../UserIcon"; +import UserIcon from "../../../../UserIcon"; import renderMarkdown from "@/utils/chat/markdown"; import Citations from "../Citation"; @@ -84,7 +84,7 @@ export function WorkspaceProfileImage({ workspace }) { ); } - return <Jazzicon size={36} user={{ uid: workspace.slug }} role="assistant" />; + return <UserIcon user={{ uid: workspace.slug }} role="assistant" />; } export default memo(PromptReply); diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx index 6e9f4e779..19b65453a 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx @@ -7,14 +7,18 @@ import { ArrowDown } from "@phosphor-icons/react"; import debounce from "lodash.debounce"; import useUser from "@/hooks/useUser"; import Chartable from "./Chartable"; +import Workspace from "@/models/workspace"; +import { useParams } from "react-router-dom"; export default function ChatHistory({ history = [], workspace, sendCommand, + updateHistory, regenerateAssistantMessage, }) { const { user } = useUser(); + const { threadSlug = null } = useParams(); const { showing, showModal, hideModal } = useManageWorkspaceModal(); const [isAtBottom, setIsAtBottom] = useState(true); const chatHistoryRef = useRef(null); @@ -87,6 +91,46 @@ export default function ChatHistory({ sendCommand(`${heading} ${message}`, true); }; + const saveEditedMessage = async ({ editedMessage, chatId, role }) => { + if (!editedMessage) return; // Don't save empty edits. + + // if the edit was a user message, we will auto-regenerate the response and delete all + // messages post modified message + if (role === "user") { + // remove all messages after the edited message + // technically there are two chatIds per-message pair, this will split the first. + const updatedHistory = history.slice( + 0, + history.findIndex((msg) => msg.chatId === chatId) + 1 + ); + + // update last message in history to edited message + updatedHistory[updatedHistory.length - 1].content = editedMessage; + // remove all edited messages after the edited message in backend + await Workspace.deleteEditedChats(workspace.slug, threadSlug, chatId); + sendCommand(editedMessage, true, updatedHistory); + return; + } + + // If role is an assistant we simply want to update the comment and save on the backend as an edit. + if (role === "assistant") { + const updatedHistory = [...history]; + const targetIdx = history.findIndex( + (msg) => msg.chatId === chatId && msg.role === role + ); + if (targetIdx < 0) return; + updatedHistory[targetIdx].content = editedMessage; + updateHistory(updatedHistory); + await Workspace.updateChatResponse( + workspace.slug, + threadSlug, + chatId, + editedMessage + ); + return; + } + }; + if (history.length === 0) { return ( <div className="flex flex-col h-full md:mt-0 pb-44 md:pb-40 w-full justify-end items-center"> @@ -172,6 +216,7 @@ export default function ChatHistory({ error={props.error} regenerateMessage={regenerateAssistantMessage} isLastMessage={isLastBotReply} + saveEditedMessage={saveEditedMessage} /> ); })} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx index 494ee57d9..28d87e0df 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx @@ -240,6 +240,7 @@ export default function ChatContainer({ workspace, knownHistory = [] }) { history={chatHistory} workspace={workspace} sendCommand={sendCommand} + updateHistory={setChatHistory} regenerateAssistantMessage={regenerateAssistantMessage} /> <PromptInput diff --git a/frontend/src/components/WorkspaceChat/index.jsx b/frontend/src/components/WorkspaceChat/index.jsx index 990ac7f5a..dec4c541f 100644 --- a/frontend/src/components/WorkspaceChat/index.jsx +++ b/frontend/src/components/WorkspaceChat/index.jsx @@ -22,6 +22,7 @@ export default function WorkspaceChat({ loading, workspace }) { const chatHistory = threadSlug ? await Workspace.threads.chatHistory(workspace.slug, threadSlug) : await Workspace.chatHistory(workspace.slug); + setHistory(chatHistory); setLoadingHistory(false); } diff --git a/frontend/src/models/workspace.js b/frontend/src/models/workspace.js index 64732c044..cfbde704a 100644 --- a/frontend/src/models/workspace.js +++ b/frontend/src/models/workspace.js @@ -90,6 +90,26 @@ const Workspace = { return false; }); }, + deleteEditedChats: async function (slug = "", threadSlug = "", startingId) { + if (!!threadSlug) + return this.threads._deleteEditedChats(slug, threadSlug, startingId); + return this._deleteEditedChats(slug, startingId); + }, + updateChatResponse: async function ( + slug = "", + threadSlug = "", + chatId, + newText + ) { + if (!!threadSlug) + return this.threads._updateChatResponse( + slug, + threadSlug, + chatId, + newText + ); + return this._updateChatResponse(slug, chatId, newText); + }, streamChat: async function ({ slug }, message, handleChat) { const ctrl = new AbortController(); @@ -287,8 +307,6 @@ const Workspace = { return null; }); }, - threads: WorkspaceThread, - uploadPfp: async function (formData, slug) { return await fetch(`${API_BASE}/workspace/${slug}/upload-pfp`, { method: "POST", @@ -336,6 +354,37 @@ const Workspace = { return { success: false, error: e.message }; }); }, + _updateChatResponse: async function (slug = "", chatId, newText) { + return await fetch(`${API_BASE}/workspace/${slug}/update-chat`, { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify({ chatId, newText }), + }) + .then((res) => { + if (res.ok) return true; + throw new Error("Failed to update chat."); + }) + .catch((e) => { + console.log(e); + return false; + }); + }, + _deleteEditedChats: async function (slug = "", startingId) { + return await fetch(`${API_BASE}/workspace/${slug}/delete-edited-chats`, { + method: "DELETE", + headers: baseHeaders(), + body: JSON.stringify({ startingId }), + }) + .then((res) => { + if (res.ok) return true; + throw new Error("Failed to delete chats."); + }) + .catch((e) => { + console.log(e); + return false; + }); + }, + threads: WorkspaceThread, }; export default Workspace; diff --git a/frontend/src/models/workspaceThread.js b/frontend/src/models/workspaceThread.js index 039ee1868..a73006c99 100644 --- a/frontend/src/models/workspaceThread.js +++ b/frontend/src/models/workspaceThread.js @@ -163,6 +163,51 @@ const WorkspaceThread = { } ); }, + _deleteEditedChats: async function ( + workspaceSlug = "", + threadSlug = "", + startingId + ) { + return await fetch( + `${API_BASE}/workspace/${workspaceSlug}/thread/${threadSlug}/delete-edited-chats`, + { + method: "DELETE", + headers: baseHeaders(), + body: JSON.stringify({ startingId }), + } + ) + .then((res) => { + if (res.ok) return true; + throw new Error("Failed to delete chats."); + }) + .catch((e) => { + console.log(e); + return false; + }); + }, + _updateChatResponse: async function ( + workspaceSlug = "", + threadSlug = "", + chatId, + newText + ) { + return await fetch( + `${API_BASE}/workspace/${workspaceSlug}/thread/${threadSlug}/update-chat`, + { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify({ chatId, newText }), + } + ) + .then((res) => { + if (res.ok) return true; + throw new Error("Failed to update chat."); + }) + .catch((e) => { + console.log(e); + return false; + }); + }, }; export default WorkspaceThread; diff --git a/frontend/src/utils/chat/index.js b/frontend/src/utils/chat/index.js index c5730dbe0..a57b11e21 100644 --- a/frontend/src/utils/chat/index.js +++ b/frontend/src/utils/chat/index.js @@ -108,13 +108,10 @@ export default function handleChat( } else if (type === "finalizeResponseStream") { const chatIdx = _chatHistory.findIndex((chat) => chat.uuid === uuid); if (chatIdx !== -1) { - const existingHistory = { ..._chatHistory[chatIdx] }; - const updatedHistory = { - ...existingHistory, - chatId, // finalize response stream only has some specific keys for data. we are explicitly listing them here. - }; - _chatHistory[chatIdx] = updatedHistory; + _chatHistory[chatIdx - 1] = { ..._chatHistory[chatIdx - 1], chatId }; // update prompt with chatID + _chatHistory[chatIdx] = { ..._chatHistory[chatIdx], chatId }; // update response with chatID } + setChatHistory([..._chatHistory]); setLoadingResponse(false); } else if (type === "stopGeneration") { diff --git a/server/endpoints/workspaceThreads.js b/server/endpoints/workspaceThreads.js index e2aead974..1c207e523 100644 --- a/server/endpoints/workspaceThreads.js +++ b/server/endpoints/workspaceThreads.js @@ -1,4 +1,9 @@ -const { multiUserMode, userFromSession, reqBody } = require("../utils/http"); +const { + multiUserMode, + userFromSession, + reqBody, + safeJsonParse, +} = require("../utils/http"); const { validatedRequest } = require("../utils/middleware/validatedRequest"); const { Telemetry } = require("../models/telemetry"); const { @@ -168,6 +173,77 @@ function workspaceThreadEndpoints(app) { } } ); + + app.delete( + "/workspace/:slug/thread/:threadSlug/delete-edited-chats", + [ + validatedRequest, + flexUserRoleValid([ROLES.all]), + validWorkspaceAndThreadSlug, + ], + async (request, response) => { + try { + const { startingId } = reqBody(request); + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + const thread = response.locals.thread; + + await WorkspaceChats.delete({ + workspaceId: Number(workspace.id), + thread_id: Number(thread.id), + user_id: user?.id, + id: { gte: Number(startingId) }, + }); + + response.sendStatus(200).end(); + } catch (e) { + console.log(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/workspace/:slug/thread/:threadSlug/update-chat", + [ + validatedRequest, + flexUserRoleValid([ROLES.all]), + validWorkspaceAndThreadSlug, + ], + async (request, response) => { + try { + const { chatId, newText = null } = reqBody(request); + if (!newText || !String(newText).trim()) + throw new Error("Cannot save empty response"); + + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + const thread = response.locals.thread; + const existingChat = await WorkspaceChats.get({ + workspaceId: workspace.id, + thread_id: thread.id, + user_id: user?.id, + id: Number(chatId), + }); + if (!existingChat) throw new Error("Invalid chat."); + + const chatResponse = safeJsonParse(existingChat.response, null); + if (!chatResponse) throw new Error("Failed to parse chat response"); + + await WorkspaceChats._update(existingChat.id, { + response: JSON.stringify({ + ...chatResponse, + text: String(newText), + }), + }); + + response.sendStatus(200).end(); + } catch (e) { + console.log(e.message, e); + response.sendStatus(500).end(); + } + } + ); } module.exports = { workspaceThreadEndpoints }; diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js index 2657eb976..6d6f29bbd 100644 --- a/server/endpoints/workspaces.js +++ b/server/endpoints/workspaces.js @@ -380,7 +380,6 @@ function workspaceEndpoints(app) { const history = multiUserMode(response) ? await WorkspaceChats.forWorkspaceByUser(workspace.id, user.id) : await WorkspaceChats.forWorkspace(workspace.id); - response.status(200).json({ history: convertToChatHistory(history) }); } catch (e) { console.log(e.message, e); @@ -420,6 +419,67 @@ function workspaceEndpoints(app) { } ); + app.delete( + "/workspace/:slug/delete-edited-chats", + [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], + async (request, response) => { + try { + const { startingId } = reqBody(request); + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + + await WorkspaceChats.delete({ + workspaceId: workspace.id, + thread_id: null, + user_id: user?.id, + id: { gte: Number(startingId) }, + }); + + response.sendStatus(200).end(); + } catch (e) { + console.log(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/workspace/:slug/update-chat", + [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], + async (request, response) => { + try { + const { chatId, newText = null } = reqBody(request); + if (!newText || !String(newText).trim()) + throw new Error("Cannot save empty response"); + + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + const existingChat = await WorkspaceChats.get({ + workspaceId: workspace.id, + thread_id: null, + user_id: user?.id, + id: Number(chatId), + }); + if (!existingChat) throw new Error("Invalid chat."); + + const chatResponse = safeJsonParse(existingChat.response, null); + if (!chatResponse) throw new Error("Failed to parse chat response"); + + await WorkspaceChats._update(existingChat.id, { + response: JSON.stringify({ + ...chatResponse, + text: String(newText), + }), + }); + + response.sendStatus(200).end(); + } catch (e) { + console.log(e.message, e); + response.sendStatus(500).end(); + } + } + ); + app.post( "/workspace/:slug/chat-feedback/:chatId", [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], diff --git a/server/models/workspaceChats.js b/server/models/workspaceChats.js index c81992caa..bda40064d 100644 --- a/server/models/workspaceChats.js +++ b/server/models/workspaceChats.js @@ -220,6 +220,24 @@ const WorkspaceChats = { console.error(error.message); } }, + + // Explicit update of settings + key validations. + // Only use this method when directly setting a key value + // that takes no user input for the keys being modified. + _update: async function (id = null, data = {}) { + if (!id) throw new Error("No workspace chat id provided for update"); + + try { + await prisma.workspace_chats.update({ + where: { id }, + data, + }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, }; module.exports = { WorkspaceChats }; diff --git a/server/utils/helpers/chat/responses.js b/server/utils/helpers/chat/responses.js index d07eae308..609b18190 100644 --- a/server/utils/helpers/chat/responses.js +++ b/server/utils/helpers/chat/responses.js @@ -174,6 +174,7 @@ function convertToChatHistory(history = []) { role: "user", content: prompt, sentAt: moment(createdAt).unix(), + chatId: id, }, { type: data?.type || "chart", -- GitLab