diff --git a/frontend/package.json b/frontend/package.json index 2b669731a3eb1c05f8a9fa2c48a0c749a759c9d0..8aa4dcfa55031358259e9be4c3289d13972e1bc4 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 8d3118838b7da20bcdcb4251a4eced3541e20ec2..c5a1f19076f4d9459404d82a10111145422717b8 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 43ae6e7a6e41fb73ccc576496f82d8e79097a495..ae52a0d2bc0738ccc10c8353472baa70e4d4d381 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 6cc9b57d042a4d9ccf43feac22093fb40e6649ec..7fc6b8df6d2f051dad406852db521b5579a291d8 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 Binary files /dev/null and b/frontend/src/components/UserIcon/workspace.png differ 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 0000000000000000000000000000000000000000..f9346b26ab349f2f6b1cd1c6458deabb20522c0b --- /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 41fd7067b8a86999edeb4754292edbe00f700007..85590e7f310ea7772bf248722a6a6bfbfcadfdf8 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 ae8f10b4a4c47f5b4897c9269546e01c4ba43bdb..1fdb61d45ce14e20b46c01fdc035c406ee7bce4a 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 07f8280a14a65064db2b8ccfb06b4da938b75f8c..73275e9dbd10cf0124e44ebddb5012a5cc42bd00 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 6e9f4e779b69919cc293ae2a4550d46ede96ee11..19b65453a412e25bd6038595108be5cb55522b5c 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 494ee57d9bf9b07280ec5586b895b4bead795c98..28d87e0dfa5e7988de0514967bf6a311a2b40f03 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 990ac7f5a0a74697860c0ef73401a14642f35669..dec4c541f9e230796059166f2f6da58d2feb386b 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 64732c0441e0c4d9bd6cafb0bf04fc50b576300f..cfbde704a1b4d44242553fc999251fb79847af89 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 039ee186832ff573fc3a5346f82975ab53aa90da..a73006c99ec3742bdcb214b3eb261a45b2d0a625 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 c5730dbe0e9f870a6c72fcb89a587ff88453689b..a57b11e2127de964d086c4c4be29694211bd78da 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 e2aead974d4d46b01b66e4eee4f5b9a5b55bb867..1c207e5230f340cf084b41d2805627a21442950d 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 2657eb976ec4a3012ad3978270a37e73e435cdd4..6d6f29bbd51147f0d7116da6d4c04ee15485e6bf 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 c81992caadb5921e3fdf380ce373f9cfcb6b3ea8..bda40064d5bb59ad307ee4c585a57f308b58b90a 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 d07eae308e9575a96449b79635d0b96269dce446..609b18190fe97277c994388802deca562471d06d 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",