[FEAT] Edit message button ()

* 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>
This commit is contained in:
Sean Hatfield 2024-06-06 12:56:11 -07:00 committed by GitHub
parent 98cef508a6
commit 26c220503c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 513 additions and 63 deletions
frontend
package.json
src
components
ChatBubble
DefaultChat
UserIcon
WorkspaceChat
ChatContainer
ChatHistory
HistoricalMessage
Actions
index.jsx
PromptReply
index.jsx
index.jsx
index.jsx
models
utils/chat
server

View file

@ -63,4 +63,4 @@
"tailwindcss": "^3.3.1", "tailwindcss": "^3.3.1",
"vite": "^4.3.0" "vite": "^4.3.0"
} }
} }

View file

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import Jazzicon from "../UserIcon"; import UserIcon from "../UserIcon";
import { userFromStorage } from "@/utils/request"; import { userFromStorage } from "@/utils/request";
import { AI_BACKGROUND_COLOR, USER_BACKGROUND_COLOR } from "@/utils/constants"; 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={`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={`py-8 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col`}>
<div className="flex gap-x-5"> <div className="flex gap-x-5">
<Jazzicon <UserIcon
size={36}
user={{ uid: isUser ? userFromStorage()?.username : "system" }} user={{ uid: isUser ? userFromStorage()?.username : "system" }}
role={type} role={type}
/> />

View file

@ -13,7 +13,7 @@ import { isMobile } from "react-device-detect";
import { SidebarMobileHeader } from "../Sidebar"; import { SidebarMobileHeader } from "../Sidebar";
import ChatBubble from "../ChatBubble"; import ChatBubble from "../ChatBubble";
import System from "@/models/system"; import System from "@/models/system";
import Jazzicon from "../UserIcon"; import UserIcon from "../UserIcon";
import { userFromStorage } from "@/utils/request"; import { userFromStorage } from "@/utils/request";
import { AI_BACKGROUND_COLOR, USER_BACKGROUND_COLOR } from "@/utils/constants"; import { AI_BACKGROUND_COLOR, USER_BACKGROUND_COLOR } from "@/utils/constants";
import useUser from "@/hooks/useUser"; 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`} 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"> <div className="flex gap-x-5">
<Jazzicon size={36} user={{ uid: "system" }} role={"assistant"} /> <UserIcon user={{ uid: "system" }} role={"assistant"} />
<span <span
className={`whitespace-pre-line text-white font-normal text-sm md:text-sm flex flex-col gap-y-1 mt-2`} 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`} 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"> <div className="flex gap-x-5">
<Jazzicon size={36} user={{ uid: "system" }} role={"assistant"} /> <UserIcon user={{ uid: "system" }} role={"assistant"} />
<span <span
className={`whitespace-pre-line text-white font-normal text-sm md:text-sm flex flex-col gap-y-1 mt-2`} 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`} 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"> <div className="flex gap-x-5">
<Jazzicon size={36} user={{ uid: "system" }} role={"assistant"} /> <UserIcon user={{ uid: "system" }} role={"assistant"} />
<div> <div>
<span <span
className={`whitespace-pre-line text-white font-normal text-sm md:text-sm flex flex-col gap-y-1 mt-2`} 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`} className={`py-6 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col`}
> >
<div className="flex gap-x-5"> <div className="flex gap-x-5">
<Jazzicon <UserIcon
size={36}
user={{ uid: userFromStorage()?.username }} user={{ uid: userFromStorage()?.username }}
role={"user"} 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`} className={`py-6 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col`}
> >
<div className="flex gap-x-5"> <div className="flex gap-x-5">
<Jazzicon size={36} user={{ uid: "system" }} role={"assistant"} /> <UserIcon user={{ uid: "system" }} role={"assistant"} />
<div> <div>
<span <span
className={`whitespace-pre-line text-white font-normal text-sm md:text-sm flex flex-col gap-y-1 mt-2`} 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`} className={`py-6 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col`}
> >
<div className="flex gap-x-5"> <div className="flex gap-x-5">
<Jazzicon <UserIcon
size={36}
user={{ uid: userFromStorage()?.username }} user={{ uid: userFromStorage()?.username }}
role={"user"} 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`} className={`py-6 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col`}
> >
<div className="flex gap-x-5"> <div className="flex gap-x-5">
<Jazzicon size={36} user={{ uid: "system" }} role={"assistant"} /> <UserIcon user={{ uid: "system" }} role={"assistant"} />
<span <span
className={`whitespace-pre-line text-white font-normal text-sm md:text-sm flex flex-col gap-y-1 mt-2`} 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`} className={`py-6 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col`}
> >
<div className="flex gap-x-5"> <div className="flex gap-x-5">
<Jazzicon <UserIcon
size={36}
user={{ uid: userFromStorage()?.username }} user={{ uid: userFromStorage()?.username }}
role={"user"} 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`} className={`py-6 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col`}
> >
<div className="flex gap-x-5"> <div className="flex gap-x-5">
<Jazzicon size={36} user={{ uid: "system" }} role={"assistant"} /> <UserIcon user={{ uid: "system" }} role={"assistant"} />
<div> <div>
<span <span
className={`whitespace-pre-line text-white font-normal text-sm md:text-sm flex flex-col gap-y-1 mt-2`} className={`whitespace-pre-line text-white font-normal text-sm md:text-sm flex flex-col gap-y-1 mt-2`}

View file

@ -2,7 +2,7 @@ import React, { useRef, useEffect } from "react";
import JAZZ from "@metamask/jazzicon"; import JAZZ from "@metamask/jazzicon";
import usePfp from "../../hooks/usePfp"; 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 { pfp } = usePfp();
const divRef = useRef(null); const divRef = useRef(null);
const seed = user?.uid const seed = user?.uid

Binary file not shown.

After

(image error) Size: 1.5 KiB

View file

@ -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>
);
}

View file

@ -2,14 +2,15 @@ import React, { memo, useState } from "react";
import useCopyText from "@/hooks/useCopyText"; import useCopyText from "@/hooks/useCopyText";
import { import {
Check, Check,
ClipboardText,
ThumbsUp, ThumbsUp,
ThumbsDown, ThumbsDown,
ArrowsClockwise, ArrowsClockwise,
Copy,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { Tooltip } from "react-tooltip"; 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";
const Actions = ({ const Actions = ({
message, message,
@ -18,9 +19,10 @@ const Actions = ({
slug, slug,
isLastMessage, isLastMessage,
regenerateMessage, regenerateMessage,
isEditing,
role,
}) => { }) => {
const [selectedFeedback, setSelectedFeedback] = useState(feedbackScore); const [selectedFeedback, setSelectedFeedback] = useState(feedbackScore);
const handleFeedback = async (newFeedback) => { const handleFeedback = async (newFeedback) => {
const updatedFeedback = const updatedFeedback =
selectedFeedback === newFeedback ? null : newFeedback; selectedFeedback === newFeedback ? null : newFeedback;
@ -32,14 +34,15 @@ const Actions = ({
<div className="flex w-full justify-between items-center"> <div className="flex w-full justify-between items-center">
<div className="flex justify-start items-center gap-x-4"> <div className="flex justify-start items-center gap-x-4">
<CopyMessage message={message} /> <CopyMessage message={message} />
{isLastMessage && ( <EditMessageAction chatId={chatId} role={role} isEditing={isEditing} />
{isLastMessage && !isEditing && (
<RegenerateMessage <RegenerateMessage
regenerateMessage={regenerateMessage} regenerateMessage={regenerateMessage}
slug={slug} slug={slug}
chatId={chatId} chatId={chatId}
/> />
)} )}
{chatId && ( {chatId && role !== "user" && !isEditing && (
<> <>
<FeedbackButton <FeedbackButton
isSelected={selectedFeedback === true} isSelected={selectedFeedback === true}
@ -111,7 +114,7 @@ function CopyMessage({ message }) {
{copied ? ( {copied ? (
<Check size={18} className="mb-1" /> <Check size={18} className="mb-1" />
) : ( ) : (
<ClipboardText size={18} className="mb-1" /> <Copy size={18} className="mb-1" />
)} )}
</button> </button>
<Tooltip <Tooltip

View file

@ -1,6 +1,6 @@
import React, { memo } from "react"; import React, { memo } from "react";
import { Warning } from "@phosphor-icons/react"; import { Warning } from "@phosphor-icons/react";
import Jazzicon from "../../../../UserIcon"; import UserIcon from "../../../../UserIcon";
import Actions from "./Actions"; import Actions from "./Actions";
import renderMarkdown from "@/utils/chat/markdown"; import renderMarkdown from "@/utils/chat/markdown";
import { userFromStorage } from "@/utils/request"; import { userFromStorage } from "@/utils/request";
@ -8,6 +8,7 @@ import Citations from "../Citation";
import { AI_BACKGROUND_COLOR, USER_BACKGROUND_COLOR } from "@/utils/constants"; 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";
const DOMPurify = createDOMPurify(window); const DOMPurify = createDOMPurify(window);
const HistoricalMessage = ({ const HistoricalMessage = ({
@ -21,20 +22,28 @@ const HistoricalMessage = ({
chatId = null, chatId = null,
isLastMessage = false, isLastMessage = false,
regenerateMessage, regenerateMessage,
saveEditedMessage,
}) => { }) => {
return ( const { isEditing } = useEditMessage({ chatId, role });
<div const adjustTextArea = (event) => {
key={uuid} const element = event.target;
className={`flex justify-center items-end w-full ${ element.style.height = "auto";
role === "user" ? USER_BACKGROUND_COLOR : AI_BACKGROUND_COLOR element.style.height = element.scrollHeight + "px";
}`} };
>
<div className={`py-8 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col`}> if (!!error) {
<div className="flex gap-x-5"> return (
<ProfileImage role={role} workspace={workspace} /> <div
{error ? ( 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"> <div className="p-2 rounded-lg bg-red-50 text-red-500">
<span className={`inline-block `}> <span className="inline-block">
<Warning className="h-4 w-4 mb-1 inline-block" /> Could not <Warning className="h-4 w-4 mb-1 inline-block" /> Could not
respond to message. respond to message.
</span> </span>
@ -42,6 +51,30 @@ const HistoricalMessage = ({
{error} {error}
</p> </p>
</div> </div>
</div>
</div>
</div>
);
}
return (
<div
key={uuid}
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} />
{isEditing ? (
<EditMessageForm
role={role}
chatId={chatId}
message={message}
adjustTextArea={adjustTextArea}
saveChanges={saveEditedMessage}
/>
) : ( ) : (
<span <span
className={`flex flex-col gap-y-1`} className={`flex flex-col gap-y-1`}
@ -51,19 +84,19 @@ const HistoricalMessage = ({
/> />
)} )}
</div> </div>
{role === "assistant" && !error && ( <div className="flex gap-x-5">
<div className="flex gap-x-5"> <div className="relative w-[35px] h-[35px] rounded-full flex-shrink-0 overflow-hidden" />
<div className="relative w-[35px] h-[35px] rounded-full flex-shrink-0 overflow-hidden" /> <Actions
<Actions message={message}
message={message} feedbackScore={feedbackScore}
feedbackScore={feedbackScore} chatId={chatId}
chatId={chatId} slug={workspace?.slug}
slug={workspace?.slug} isLastMessage={isLastMessage}
isLastMessage={isLastMessage} regenerateMessage={regenerateMessage}
regenerateMessage={regenerateMessage} isEditing={isEditing}
/> role={role}
</div> />
)} </div>
{role === "assistant" && <Citations sources={sources} />} {role === "assistant" && <Citations sources={sources} />}
</div> </div>
</div> </div>
@ -84,8 +117,7 @@ function ProfileImage({ role, workspace }) {
} }
return ( return (
<Jazzicon <UserIcon
size={36}
user={{ user={{
uid: role === "user" ? userFromStorage()?.username : workspace.slug, uid: role === "user" ? userFromStorage()?.username : workspace.slug,
}} }}

View file

@ -1,6 +1,6 @@
import { memo } from "react"; import { memo } from "react";
import { Warning } from "@phosphor-icons/react"; import { Warning } from "@phosphor-icons/react";
import Jazzicon from "../../../../UserIcon"; import UserIcon from "../../../../UserIcon";
import renderMarkdown from "@/utils/chat/markdown"; import renderMarkdown from "@/utils/chat/markdown";
import Citations from "../Citation"; 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); export default memo(PromptReply);

View file

@ -7,14 +7,18 @@ import { ArrowDown } from "@phosphor-icons/react";
import debounce from "lodash.debounce"; import debounce from "lodash.debounce";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import Chartable from "./Chartable"; import Chartable from "./Chartable";
import Workspace from "@/models/workspace";
import { useParams } from "react-router-dom";
export default function ChatHistory({ export default function ChatHistory({
history = [], history = [],
workspace, workspace,
sendCommand, sendCommand,
updateHistory,
regenerateAssistantMessage, regenerateAssistantMessage,
}) { }) {
const { user } = useUser(); const { user } = useUser();
const { threadSlug = null } = useParams();
const { showing, showModal, hideModal } = useManageWorkspaceModal(); const { showing, showModal, hideModal } = useManageWorkspaceModal();
const [isAtBottom, setIsAtBottom] = useState(true); const [isAtBottom, setIsAtBottom] = useState(true);
const chatHistoryRef = useRef(null); const chatHistoryRef = useRef(null);
@ -87,6 +91,46 @@ export default function ChatHistory({
sendCommand(`${heading} ${message}`, true); 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) { if (history.length === 0) {
return ( return (
<div className="flex flex-col h-full md:mt-0 pb-44 md:pb-40 w-full justify-end items-center"> <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} error={props.error}
regenerateMessage={regenerateAssistantMessage} regenerateMessage={regenerateAssistantMessage}
isLastMessage={isLastBotReply} isLastMessage={isLastBotReply}
saveEditedMessage={saveEditedMessage}
/> />
); );
})} })}

View file

@ -240,6 +240,7 @@ export default function ChatContainer({ workspace, knownHistory = [] }) {
history={chatHistory} history={chatHistory}
workspace={workspace} workspace={workspace}
sendCommand={sendCommand} sendCommand={sendCommand}
updateHistory={setChatHistory}
regenerateAssistantMessage={regenerateAssistantMessage} regenerateAssistantMessage={regenerateAssistantMessage}
/> />
<PromptInput <PromptInput

View file

@ -22,6 +22,7 @@ export default function WorkspaceChat({ loading, workspace }) {
const chatHistory = threadSlug const chatHistory = threadSlug
? await Workspace.threads.chatHistory(workspace.slug, threadSlug) ? await Workspace.threads.chatHistory(workspace.slug, threadSlug)
: await Workspace.chatHistory(workspace.slug); : await Workspace.chatHistory(workspace.slug);
setHistory(chatHistory); setHistory(chatHistory);
setLoadingHistory(false); setLoadingHistory(false);
} }

View file

@ -90,6 +90,26 @@ const Workspace = {
return false; 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) { streamChat: async function ({ slug }, message, handleChat) {
const ctrl = new AbortController(); const ctrl = new AbortController();
@ -287,8 +307,6 @@ const Workspace = {
return null; return null;
}); });
}, },
threads: WorkspaceThread,
uploadPfp: async function (formData, slug) { uploadPfp: async function (formData, slug) {
return await fetch(`${API_BASE}/workspace/${slug}/upload-pfp`, { return await fetch(`${API_BASE}/workspace/${slug}/upload-pfp`, {
method: "POST", method: "POST",
@ -336,6 +354,37 @@ const Workspace = {
return { success: false, error: e.message }; 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; export default Workspace;

View file

@ -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; export default WorkspaceThread;

View file

@ -108,13 +108,10 @@ export default function handleChat(
} else if (type === "finalizeResponseStream") { } else if (type === "finalizeResponseStream") {
const chatIdx = _chatHistory.findIndex((chat) => chat.uuid === uuid); const chatIdx = _chatHistory.findIndex((chat) => chat.uuid === uuid);
if (chatIdx !== -1) { if (chatIdx !== -1) {
const existingHistory = { ..._chatHistory[chatIdx] }; _chatHistory[chatIdx - 1] = { ..._chatHistory[chatIdx - 1], chatId }; // update prompt with chatID
const updatedHistory = { _chatHistory[chatIdx] = { ..._chatHistory[chatIdx], chatId }; // update response with chatID
...existingHistory,
chatId, // finalize response stream only has some specific keys for data. we are explicitly listing them here.
};
_chatHistory[chatIdx] = updatedHistory;
} }
setChatHistory([..._chatHistory]); setChatHistory([..._chatHistory]);
setLoadingResponse(false); setLoadingResponse(false);
} else if (type === "stopGeneration") { } else if (type === "stopGeneration") {

View file

@ -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 { validatedRequest } = require("../utils/middleware/validatedRequest");
const { Telemetry } = require("../models/telemetry"); const { Telemetry } = require("../models/telemetry");
const { 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 }; module.exports = { workspaceThreadEndpoints };

View file

@ -380,7 +380,6 @@ function workspaceEndpoints(app) {
const history = multiUserMode(response) const history = multiUserMode(response)
? await WorkspaceChats.forWorkspaceByUser(workspace.id, user.id) ? await WorkspaceChats.forWorkspaceByUser(workspace.id, user.id)
: await WorkspaceChats.forWorkspace(workspace.id); : await WorkspaceChats.forWorkspace(workspace.id);
response.status(200).json({ history: convertToChatHistory(history) }); response.status(200).json({ history: convertToChatHistory(history) });
} catch (e) { } catch (e) {
console.log(e.message, 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( app.post(
"/workspace/:slug/chat-feedback/:chatId", "/workspace/:slug/chat-feedback/:chatId",
[validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug],

View file

@ -220,6 +220,24 @@ const WorkspaceChats = {
console.error(error.message); 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 }; module.exports = { WorkspaceChats };

View file

@ -174,6 +174,7 @@ function convertToChatHistory(history = []) {
role: "user", role: "user",
content: prompt, content: prompt,
sentAt: moment(createdAt).unix(), sentAt: moment(createdAt).unix(),
chatId: id,
}, },
{ {
type: data?.type || "chart", type: data?.type || "chart",