From d877d2b7ad30c0232f5ca15b0fffc58b06f4f23a Mon Sep 17 00:00:00 2001 From: Timothy Carambat Date: Tue, 30 Jul 2024 10:26:16 -0700 Subject: [PATCH] Add drag-and-drop to chat window (#1995) * Add drag-and-drop to chat window * add uploader icon and remove empty space text when attachments are present * color theme * color update --- .vscode/settings.json | 2 + .../ChatContainer/ChatHistory/index.jsx | 3 +- .../ChatContainer/DnDWrapper/dnd-icon.png | Bin 0 -> 2692 bytes .../ChatContainer/DnDWrapper/index.jsx | 136 ++++++++++++++ .../PromptInput/Attachments/index.jsx | 176 ++++++++++++++++++ .../ChatContainer/PromptInput/index.jsx | 7 +- .../WorkspaceChat/ChatContainer/index.jsx | 85 ++++----- frontend/src/models/workspace.js | 51 +++++ frontend/tailwind.config.js | 5 + server/endpoints/workspaces.js | 109 +++++++++++ 10 files changed, 525 insertions(+), 49 deletions(-) create mode 100644 frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/dnd-icon.png create mode 100644 frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx create mode 100644 frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 6b47d38710..3fcc79cd5a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,11 +39,13 @@ "openrouter", "pagerender", "Qdrant", + "royalblue", "searxng", "Serper", "Serply", "textgenwebui", "togetherai", + "Unembed", "vectordbs", "Weaviate", "Zilliz" diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx index 53cbeb64f6..647d104f30 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx @@ -17,6 +17,7 @@ export default function ChatHistory({ sendCommand, updateHistory, regenerateAssistantMessage, + hasAttachments = false, }) { const { user } = useUser(); const { threadSlug = null } = useParams(); @@ -144,7 +145,7 @@ export default function ChatHistory({ ); }; - if (history.length === 0) { + if (history.length === 0 && !hasAttachments) { return (
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/dnd-icon.png b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/dnd-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9cb0cd7cccb4d98d89220d35c8a194c8321e1ec2 GIT binary patch literal 2692 zcmV-~3VZd5P)a6K~#7F-CS*K z6vr8U9o)Lu!ZC?y0(Z6#^AVrr2os1&TZNd!sH&6{4*!YLf=#7Lk)VO5as*Z)jEDf0 zs>NWWR+VZSt5JW{F=;AQ4JH_(R+zNH{ZXGIHO=AR5EW41PO<9Nh`i72%rZXqe7CcE zXFHEHx4XCN^WL-1`!O@`Tpr~RckbLN;Y}^KRdO4WjkGNg<91WFe{lOqZin*n@@`TN zkVjbtlTpu`r?}P2<`o0nPH}sSNs3XHkQHL2GInv>OqMbP5{U%;b2v)Tkr5h>j?mcH zm}v2Mg5rs|{rANSiYXXeLJN3X9j>B4AVA@&DtcmFUGl#g;&zlQl6+N?uf%AC?B<4j zLB{Xj?4#5DXDG^K=mfY02n#b&jgifQBoHPM*~{waJlVtQh*GW)WaYfHa|bnUiKw??+_p18 z17rweVx&s;k)@KHC?p-$)z#6F!v}Q}Yco}k5iv|kh&N}sh2TSu9`B&8?vpfI@Y2qm zwChJZ)FB*n#I5gfO$?KQC<9VT5K&&(+C*1)JKF&BdZVXXsVBy5gBx=0BNt#&nn;q; z`{pTncGH$wNeXa9J~V0CA#RuCL(K>oD@Kyih5cj4Pf&YD2hA0{+PasvH*Lc%avdmV zDBa&=M2uwm687gBDGHk30XeJFnN?a-l8&|y`*W2P;27U`Z=d)Yh^A-d_ezOr6(d73 zloIOXsPp>4Lp0X`hm5e!7+}?GNOfI0#UM`!A^JcqUk1FmW2fk!G3xoj|030ODHX$i z)XbYs0EY zVPZf^8$RH<%@Oh&7*5#O@HBmcJAYMGVIE=#aL;*xeLl`AMPCaK1if;!Y~cL6{VD<6 z&#GzD&)q|e^wtK+#zy^ReGQ;0|h5Ouo0pP%vxypE}`;$qM01 z^)VeMQ) z3smyc!-quQ#V@FIf6SNrb5>2VV(RhYms*g9LnVHa0^sxh$Wdiu!!zU+Nvdg9?7>Y znmmmdg5>k>o?+i5d>_myrYi;``t**+(1`E{pOPzB$~o@IA%om^PWP)r!Glf==6*cq z4Cul7?NibL|L+G)TgeMfpRvEUFegEbnjr*pUdC*p7E_zY)CxMOrje$(smYUSwC`OD zK9pkWt()^UvagM^x<81~>{QeIR8Q4I4$AX(8Vq=*qieivp)EkGh3mp5oW zA?H;!(P3K{wThTk;zV?WOz{LqqL~I%gPyY~q9gYAw}}z6hv<`-!jELj4|ur`EvdCl zDTEg-Oe9kvq$ZQ2h#kE6zA-7l@Q8Vmx^m?sYWJ2R2Bo6(=%Y1! zv-gt;qAqVJnVe*pkOH`QKst(x@23^ZSExf@iKoK+X{l(<~@DFzM1f{P1EbDEJzu5w_zUz>A*wp2N%IYgUVcWy_X}KX8lJ4;}J# zwlEXm|8*J^M-~(mu<2Unv-UB&3hgbt=){OQll4!2Qyk2jmoFsd7VtGKds-=1Ks5#? z-%Kjb!{Hjr&$p8Q>br^&JlMp@Qizx`Cjce|Z|xQ^sazEqwd~nzWJ2(x$|4{JncNMi z613z_q?oI0jC@!70J9LD?FuX?1sSr*tOiI?CIqa1@>^=ROYI~RgOwaNA*<56=`n|~ zrR=qf$Spt)%1He7IaYn~Hly4L4_PM#)=_O)NQ~9et=^wPjEqk0*l&zHXTJ}BQ(awS zL=I|f-~;>`Isn96Z9Q{s;EC+qWm_ z!w>&OpM7?NOyIB>A|t4!Jt)gJ9c6SPWzA!&=_k9zut$uND#ue*RJHMD-*EH;4!=k4 zIe+=`zvMMc#!VvZZb5M{xDnl}~z5g%U}vnvq>2jBO!&MDcaD763luyVRT zW>Svo=TZ@4m$CNz_ILEBKi`|`U%mPXjgEdqegcZ}vw{nuxV(I+WAH$LkwFH&Wohzx z$DvK>YqqNo&>gYD;>Am-wsx&i5||2pl7jwTP0cDtQc6o7)FZEBt#8UPUzyjmBd>uS zVw((PjKGp1jTb|F_TO=YNIf<6CU7yb7K~;@3gCGaEffQ@a!LYc z3YtFwa{IXk?eCX_dFj1#zu`cL7&b{>Vq*foe+?u=mU%U)_h4e^ zrJv_0=9MHw8JNjh9etVuWb5~1LS&W5+9sv|k}i*a%x#pDzZ*>4 z4H5G7u&+9;nCB`ioX=#G3qmkcSTwcIHbBwy literal 0 HcmV?d00001 diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx new file mode 100644 index 0000000000..d7e4edb628 --- /dev/null +++ b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx @@ -0,0 +1,136 @@ +import { useState, useEffect } from "react"; +import { v4 } from "uuid"; +import System from "@/models/system"; +import { useDropzone } from "react-dropzone"; +import DndIcon from "./dnd-icon.png"; +import Workspace from "@/models/workspace"; +import useUser from "@/hooks/useUser"; + +export const REMOVE_ATTACHMENT_EVENT = "ATTACHMENT_REMOVE"; +export const CLEAR_ATTACHMENTS_EVENT = "ATTACHMENT_CLEAR"; + +/** + * File Attachment for automatic upload on the chat container page. + * @typedef Attachment + * @property {string} uid - unique file id. + * @property {File} file - native File object + * @property {('in_progress'|'failed'|'success')} status - the automatic upload status. + * @property {string|null} error - Error message + * @property {{id:string, location:string}|null} document - uploaded document details + */ + +export default function DnDFileUploaderWrapper({ workspace, children }) { + /** @type {[Attachment[], Function]} */ + const [files, setFiles] = useState([]); + const [ready, setReady] = useState(false); + const [dragging, setDragging] = useState(false); + const { user } = useUser(); + + useEffect(() => { + if (!!user && user.role === "default") return false; + System.checkDocumentProcessorOnline().then((status) => setReady(status)); + }, [user]); + + useEffect(() => { + window.addEventListener(REMOVE_ATTACHMENT_EVENT, handleRemove); + window.addEventListener(CLEAR_ATTACHMENTS_EVENT, resetAttachments); + + return () => { + window.removeEventListener(REMOVE_ATTACHMENT_EVENT, handleRemove); + window.removeEventListener(CLEAR_ATTACHMENTS_EVENT, resetAttachments); + }; + }, []); + + /** + * Remove file from uploader queue. + * @param {CustomEvent<{uid: string}>} event + */ + async function handleRemove(event) { + /** @type {{uid: Attachment['uid'], document: Attachment['document']}} */ + const { uid, document } = event.detail; + setFiles((prev) => prev.filter((prevFile) => prevFile.uid !== uid)); + if (!document.location) return; + await Workspace.deleteAndUnembedFile(workspace.slug, document.location); + } + + /** + * Clear queue of attached files currently in prompt box + */ + function resetAttachments() { + setFiles([]); + } + + async function onDrop(acceptedFiles, _rejections) { + setDragging(false); + /** @type {Attachment[]} */ + const newAccepted = acceptedFiles.map((file) => { + return { + uid: v4(), + file, + status: "in_progress", + error: null, + }; + }); + setFiles((prev) => [...prev, ...newAccepted]); + + for (const attachment of newAccepted) { + const formData = new FormData(); + formData.append("file", attachment.file, attachment.file.name); + Workspace.uploadAndEmbedFile(workspace.slug, formData).then( + ({ response, data }) => { + const updates = { + status: response.ok ? "success" : "failed", + error: data?.error ?? null, + document: data?.document, + }; + + setFiles((prev) => { + return prev.map( + ( + /** @type {Attachment} */ + prevFile + ) => { + if (prevFile.uid !== attachment.uid) return prevFile; + return { ...prevFile, ...updates }; + } + ); + }); + } + ); + } + } + + const { getRootProps, getInputProps } = useDropzone({ + onDrop, + disabled: !ready, + noClick: true, + noKeyboard: true, + onDragEnter: () => setDragging(true), + onDragLeave: () => setDragging(false), + }); + + return ( +
+ + + {children(files, setFiles)} +
+ ); +} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx new file mode 100644 index 0000000000..b0032b95a1 --- /dev/null +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx @@ -0,0 +1,176 @@ +import { + CircleNotch, + FileCode, + FileCsv, + FileDoc, + FileHtml, + FilePdf, + WarningOctagon, + X, +} from "@phosphor-icons/react"; +import { humanFileSize } from "@/utils/numbers"; +import { FileText } from "@phosphor-icons/react/dist/ssr"; +import { REMOVE_ATTACHMENT_EVENT } from "../../DnDWrapper"; +import { Tooltip } from "react-tooltip"; + +/** + * @param {{attachments: import("../../DnDWrapper").Attachment[]}} + * @returns + */ +export default function AttachmentManager({ attachments }) { + if (attachments.length === 0) return null; + return ( +
+ {attachments.map((attachment) => ( + + ))} +
+ ); +} + +/** + * @param {{attachment: import("../../DnDWrapper").Attachment}} + */ +function AttachmentItem({ attachment }) { + const { uid, file, status, error, document } = attachment; + const { iconBgColor, Icon } = displayFromFile(file); + + function removeFileFromQueue() { + window.dispatchEvent( + new CustomEvent(REMOVE_ATTACHMENT_EVENT, { detail: { uid, document } }) + ); + } + + if (status === "in_progress") { + return ( +
+
+ +
+
+

{file.name}

+

+ {humanFileSize(file.size)} +

+
+
+ ); + } + + if (status === "failed") { + return ( + <> +
+
+ +
+
+ +
+
+

+ {file.name} +

+

+ {error ?? "this file failed to upload"}. It will not be available + in the workspace. +

+
+
+ + + ); + } + + return ( + <> +
+
+ +
+
+ +
+
+

{file.name}

+

File embedded!

+
+
+ + + ); +} + +/** + * @param {File} file + * @returns {{iconBgColor:string, Icon: React.Component}} + */ +function displayFromFile(file) { + const extension = file?.name?.split(".")?.pop()?.toLowerCase() ?? "txt"; + switch (extension) { + case "pdf": + return { iconBgColor: "bg-magenta", Icon: FilePdf }; + case "doc": + case "docx": + return { iconBgColor: "bg-royalblue", Icon: FileDoc }; + case "html": + return { iconBgColor: "bg-warn", Icon: FileHtml }; + case "csv": + case "xlsx": + return { iconBgColor: "bg-success", Icon: FileCsv }; + case "json": + case "sql": + case "js": + case "jsx": + case "cpp": + case "c": + case "c": + return { iconBgColor: "bg-warn", Icon: FileCode }; + default: + return { iconBgColor: "bg-royalblue", Icon: FileText }; + } +} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx index fc46fbe9c7..253f158f58 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx @@ -13,6 +13,7 @@ import AvailableAgentsButton, { import TextSizeButton from "./TextSizeMenu"; import SpeechToText from "./SpeechToText"; import { Tooltip } from "react-tooltip"; +import AttachmentManager from "./Attachments"; export const PROMPT_INPUT_EVENT = "set_prompt_input"; export default function PromptInput({ @@ -21,6 +22,7 @@ export default function PromptInput({ inputDisabled, buttonDisabled, sendCommand, + attachments = [], }) { const [promptInput, setPromptInput] = useState(""); const { showAgents, setShowAgents } = useAvailableAgents(); @@ -106,10 +108,11 @@ export default function PromptInput({ />
-
+
+