From d877d2b7ad30c0232f5ca15b0fffc58b06f4f23a Mon Sep 17 00:00:00 2001
From: Timothy Carambat <rambat1010@gmail.com>
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 6b47d3871..3fcc79cd5 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 53cbeb64f..647d104f3 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 (
       <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 items-center md:items-start md:max-w-[600px] w-full px-4">
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
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

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 000000000..d7e4edb62
--- /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 (
+    <div
+      className={`relative flex flex-col h-full w-full md:mt-0 mt-[40px] p-[1px]`}
+      {...getRootProps()}
+    >
+      <div
+        hidden={!dragging}
+        className="absolute top-0 w-full h-full bg-dark-text/90 rounded-2xl border-[4px] border-white z-[9999]"
+      >
+        <div className="w-full h-full flex justify-center items-center rounded-xl">
+          <div className="flex flex-col gap-y-[14px] justify-center items-center">
+            <img src={DndIcon} width={69} height={69} />
+            <p className="text-white text-[24px] font-semibold">Add anything</p>
+            <p className="text-white text-[16px] text-center">
+              Drop your file here to embed it into your <br />
+              workspace auto-magically.
+            </p>
+          </div>
+        </div>
+      </div>
+      <input {...getInputProps()} />
+      {children(files, setFiles)}
+    </div>
+  );
+}
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 000000000..b0032b95a
--- /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 (
+    <div className="flex flex-wrap my-2">
+      {attachments.map((attachment) => (
+        <AttachmentItem key={attachment.uid} attachment={attachment} />
+      ))}
+    </div>
+  );
+}
+
+/**
+ * @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 (
+      <div
+        className={`h-14 px-2 py-2 flex items-center gap-x-4 rounded-lg bg-zinc-800 border border-white/20 w-[200px]`}
+      >
+        <div
+          className={`${iconBgColor} rounded-lg flex items-center justify-center flex-shrink-0 p-1`}
+        >
+          <CircleNotch size={30} className="text-white animate-spin" />
+        </div>
+        <div className="flex flex-col w-[130px]">
+          <p className="text-white text-xs font-medium truncate">{file.name}</p>
+          <p className="text-white/60 text-xs font-medium">
+            {humanFileSize(file.size)}
+          </p>
+        </div>
+      </div>
+    );
+  }
+
+  if (status === "failed") {
+    return (
+      <>
+        <div
+          data-tooltip-id={`attachment-uid-${uid}-error`}
+          data-tooltip-content={error}
+          className={`relative h-14 px-2 py-2 flex items-center gap-x-4 rounded-lg bg-[#4E140B] border border-transparent w-[200px] group`}
+        >
+          <div className="invisible group-hover:visible absolute -top-[5px] -right-[5px] w-fit h-fit z-[10]">
+            <button
+              onClick={removeFileFromQueue}
+              type="button"
+              className="bg-zinc-700 hover:bg-red-400 rounded-full p-1 flex items-center justify-center hover:border-transparent border border-white/40"
+            >
+              <X
+                size={10}
+                className="flex-shrink-0 text-zinc-200 group-hover:text-white"
+              />
+            </button>
+          </div>
+          <div
+            className={`bg-danger rounded-lg flex items-center justify-center flex-shrink-0 p-1`}
+          >
+            <WarningOctagon size={30} className="text-white" />
+          </div>
+          <div className="flex flex-col w-[130px]">
+            <p className="text-white text-xs font-medium truncate">
+              {file.name}
+            </p>
+            <p className="text-red-100 text-xs truncate">
+              {error ?? "this file failed to upload"}. It will not be available
+              in the workspace.
+            </p>
+          </div>
+        </div>
+        <Tooltip
+          id={`attachment-uid-${uid}-error`}
+          place="top"
+          delayShow={300}
+          className="allm-tooltip !allm-text-xs"
+        />
+      </>
+    );
+  }
+
+  return (
+    <>
+      <div
+        data-tooltip-id={`attachment-uid-${uid}-success`}
+        data-tooltip-content={`${file.name} was uploaded and embedded into this workspace. It will be available for RAG chat now.`}
+        className={`relative h-14 px-2 py-2 flex items-center gap-x-4 rounded-lg bg-zinc-800 border border-white/20 w-[200px] group`}
+      >
+        <div className="invisible group-hover:visible absolute -top-[5px] -right-[5px] w-fit h-fit z-[10]">
+          <button
+            onClick={removeFileFromQueue}
+            type="button"
+            className="bg-zinc-700 hover:bg-red-400 rounded-full p-1 flex items-center justify-center hover:border-transparent border border-white/40"
+          >
+            <X
+              size={10}
+              className="flex-shrink-0 text-zinc-200 group-hover:text-white"
+            />
+          </button>
+        </div>
+        <div
+          className={`${iconBgColor} rounded-lg flex items-center justify-center flex-shrink-0 p-1`}
+        >
+          <Icon size={30} className="text-white" />
+        </div>
+        <div className="flex flex-col w-[130px]">
+          <p className="text-white text-xs font-medium truncate">{file.name}</p>
+          <p className="text-white/80 text-xs font-medium">File embedded!</p>
+        </div>
+      </div>
+      <Tooltip
+        id={`attachment-uid-${uid}-success`}
+        place="top"
+        delayShow={300}
+        className="allm-tooltip !allm-text-xs"
+      />
+    </>
+  );
+}
+
+/**
+ * @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 fc46fbe9c..253f158f5 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({
       />
       <form
         onSubmit={handleSubmit}
-        className="flex flex-col gap-y-1 rounded-t-lg md:w-3/4 w-full mx-auto max-w-xl"
+        className="flex flex-col gap-y-1 rounded-t-lg md:w-3/4 w-full mx-auto max-w-xl items-center"
       >
         <div className="flex items-center rounded-lg md:mb-4">
-          <div className="w-[600px] bg-main-gradient shadow-2xl border border-white/50 rounded-2xl flex flex-col px-4 overflow-hidden">
+          <div className="w-[635px] bg-main-gradient shadow-2xl border border-white/50 rounded-2xl flex flex-col px-4 overflow-hidden">
+            <AttachmentManager attachments={attachments} />
             <div className="flex items-center w-full border-b-2 border-gray-500/50">
               <textarea
                 ref={textareaRef}
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx
index cce249ec6..2d6be099b 100644
--- a/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx
@@ -1,5 +1,6 @@
 import { useState, useEffect } from "react";
 import ChatHistory from "./ChatHistory";
+import DnDFileUploadWrapper, { CLEAR_ATTACHMENTS_EVENT } from "./DnDWrapper";
 import PromptInput, { PROMPT_INPUT_EVENT } from "./PromptInput";
 import Workspace from "@/models/workspace";
 import handleChat, { ABORT_STREAM_EVENT } from "@/utils/chat";
@@ -121,37 +122,22 @@ export default function ChatContainer({ workspace, knownHistory = [] }) {
         return;
       }
 
-      // TODO: Simplify this
       if (!promptMessage || !promptMessage?.userMessage) return false;
-      if (!!threadSlug) {
-        await Workspace.threads.streamChat(
-          { workspaceSlug: workspace.slug, threadSlug },
-          promptMessage.userMessage,
-          (chatResult) =>
-            handleChat(
-              chatResult,
-              setLoadingResponse,
-              setChatHistory,
-              remHistory,
-              _chatHistory,
-              setSocketId
-            )
-        );
-      } else {
-        await Workspace.streamChat(
-          workspace,
-          promptMessage.userMessage,
-          (chatResult) =>
-            handleChat(
-              chatResult,
-              setLoadingResponse,
-              setChatHistory,
-              remHistory,
-              _chatHistory,
-              setSocketId
-            )
-        );
-      }
+      window.dispatchEvent(new CustomEvent(CLEAR_ATTACHMENTS_EVENT));
+      await Workspace.multiplexStream({
+        workspaceSlug: workspace.slug,
+        threadSlug,
+        prompt: promptMessage.userMessage,
+        chatHandler: (chatResult) =>
+          handleChat(
+            chatResult,
+            setLoadingResponse,
+            setChatHistory,
+            remHistory,
+            _chatHistory,
+            setSocketId
+          ),
+      });
       return;
     }
     loadingResponse === true && fetchReply();
@@ -205,6 +191,7 @@ export default function ChatContainer({ workspace, knownHistory = [] }) {
         });
         setWebsocket(socket);
         window.dispatchEvent(new CustomEvent(AGENT_SESSION_START));
+        window.dispatchEvent(new CustomEvent(CLEAR_ATTACHMENTS_EVENT));
       } catch (e) {
         setChatHistory((prev) => [
           ...prev.filter((msg) => !!msg.content),
@@ -234,22 +221,28 @@ export default function ChatContainer({ workspace, knownHistory = [] }) {
       className="transition-all duration-500 relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-main-gradient w-full h-full overflow-y-scroll border-2 border-outline"
     >
       {isMobile && <SidebarMobileHeader />}
-      <div className="flex flex-col h-full w-full md:mt-0 mt-[40px]">
-        <ChatHistory
-          history={chatHistory}
-          workspace={workspace}
-          sendCommand={sendCommand}
-          updateHistory={setChatHistory}
-          regenerateAssistantMessage={regenerateAssistantMessage}
-        />
-        <PromptInput
-          submit={handleSubmit}
-          onChange={handleMessageChange}
-          inputDisabled={loadingResponse}
-          buttonDisabled={loadingResponse}
-          sendCommand={sendCommand}
-        />
-      </div>
+      <DnDFileUploadWrapper workspace={workspace}>
+        {(files) => (
+          <>
+            <ChatHistory
+              history={chatHistory}
+              workspace={workspace}
+              sendCommand={sendCommand}
+              updateHistory={setChatHistory}
+              regenerateAssistantMessage={regenerateAssistantMessage}
+              hasAttachments={files.length > 0}
+            />
+            <PromptInput
+              submit={handleSubmit}
+              onChange={handleMessageChange}
+              inputDisabled={loadingResponse}
+              buttonDisabled={loadingResponse}
+              sendCommand={sendCommand}
+              attachments={files}
+            />
+          </>
+        )}
+      </DnDFileUploadWrapper>
     </div>
   );
 }
diff --git a/frontend/src/models/workspace.js b/frontend/src/models/workspace.js
index 369ae986c..c45502580 100644
--- a/frontend/src/models/workspace.js
+++ b/frontend/src/models/workspace.js
@@ -110,6 +110,20 @@ const Workspace = {
       );
     return this._updateChatResponse(slug, chatId, newText);
   },
+  multiplexStream: async function ({
+    workspaceSlug,
+    threadSlug = null,
+    prompt,
+    chatHandler,
+  }) {
+    if (!!threadSlug)
+      return this.threads.streamChat(
+        { workspaceSlug, threadSlug },
+        prompt,
+        chatHandler
+      );
+    return this.streamChat({ slug: workspaceSlug }, prompt, chatHandler);
+  },
   streamChat: async function ({ slug }, message, handleChat) {
     const ctrl = new AbortController();
 
@@ -411,6 +425,43 @@ const Workspace = {
         return null;
       });
   },
+  /**
+   * Uploads and embeds a single file in a single call into a workspace
+   * @param {string} slug - workspace slug
+   * @param {FormData} formData
+   * @returns {Promise<{response: {ok: boolean}, data: {success: boolean, error: string|null, document: {id: string, location:string}|null}}>}
+   */
+  uploadAndEmbedFile: async function (slug, formData) {
+    const response = await fetch(
+      `${API_BASE}/workspace/${slug}/upload-and-embed`,
+      {
+        method: "POST",
+        body: formData,
+        headers: baseHeaders(),
+      }
+    );
+
+    const data = await response.json();
+    return { response, data };
+  },
+
+  /**
+   * Deletes and un-embeds a single file in a single call from a workspace
+   * @param {string} slug - workspace slug
+   * @param {string} documentLocation - location of file eg: custom-documents/my-file-uuid.json
+   * @returns {Promise<boolean>}
+   */
+  deleteAndUnembedFile: async function (slug, documentLocation) {
+    const response = await fetch(
+      `${API_BASE}/workspace/${slug}/remove-and-unembed`,
+      {
+        method: "DELETE",
+        body: JSON.stringify({ documentLocation }),
+        headers: baseHeaders(),
+      }
+    );
+    return response.ok;
+  },
   threads: WorkspaceThread,
 };
 
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index e6b5baa86..892b4a488 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -36,6 +36,11 @@ export default {
         "dark-text": "#222628",
         description: "#D2D5DB",
         "x-button": "#9CA3AF",
+        royalblue: '#3538CD',
+        magenta: '#C11574',
+        danger: '#F04438',
+        warn: '#854708',
+        success: '#027A48',
         darker: "#F4F4F4"
       },
       backgroundImage: {
diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js
index aa3ef19b0..4f523aaaf 100644
--- a/server/endpoints/workspaces.js
+++ b/server/endpoints/workspaces.js
@@ -33,6 +33,7 @@ const {
 const { getTTSProvider } = require("../utils/TextToSpeech");
 const { WorkspaceThread } = require("../models/workspaceThread");
 const truncate = require("truncate");
+const { purgeDocument } = require("../utils/files/purgeDocument");
 
 function workspaceEndpoints(app) {
   if (!app) return;
@@ -863,6 +864,114 @@ function workspaceEndpoints(app) {
       }
     }
   );
+
+  /** Handles the uploading and embedding in one-call by uploading via drag-and-drop in chat container. */
+  app.post(
+    "/workspace/:slug/upload-and-embed",
+    [
+      validatedRequest,
+      flexUserRoleValid([ROLES.admin, ROLES.manager]),
+      handleFileUpload,
+    ],
+    async function (request, response) {
+      try {
+        const { slug = null } = request.params;
+        const user = await userFromSession(request, response);
+        const currWorkspace = multiUserMode(response)
+          ? await Workspace.getWithUser(user, { slug })
+          : await Workspace.get({ slug });
+
+        if (!currWorkspace) {
+          response.sendStatus(400).end();
+          return;
+        }
+
+        const Collector = new CollectorApi();
+        const { originalname } = request.file;
+        const processingOnline = await Collector.online();
+
+        if (!processingOnline) {
+          response
+            .status(500)
+            .json({
+              success: false,
+              error: `Document processing API is not online. Document ${originalname} will not be processed automatically.`,
+            })
+            .end();
+          return;
+        }
+
+        const { success, reason, documents } =
+          await Collector.processDocument(originalname);
+        if (!success || documents?.length === 0) {
+          response.status(500).json({ success: false, error: reason }).end();
+          return;
+        }
+
+        Collector.log(
+          `Document ${originalname} uploaded processed and successfully. It is now available in documents.`
+        );
+        await Telemetry.sendTelemetry("document_uploaded");
+        await EventLogs.logEvent(
+          "document_uploaded",
+          {
+            documentName: originalname,
+          },
+          response.locals?.user?.id
+        );
+
+        const document = documents[0];
+        const { failedToEmbed = [], errors = [] } = await Document.addDocuments(
+          currWorkspace,
+          [document.location],
+          response.locals?.user?.id
+        );
+
+        if (failedToEmbed.length > 0)
+          return response
+            .status(200)
+            .json({ success: false, error: errors?.[0], document: null });
+
+        response.status(200).json({
+          success: true,
+          error: null,
+          document: { id: document.id, location: document.location },
+        });
+      } catch (e) {
+        console.error(e.message, e);
+        response.sendStatus(500).end();
+      }
+    }
+  );
+
+  app.delete(
+    "/workspace/:slug/remove-and-unembed",
+    [
+      validatedRequest,
+      flexUserRoleValid([ROLES.admin, ROLES.manager]),
+      handleFileUpload,
+    ],
+    async function (request, response) {
+      try {
+        const { slug = null } = request.params;
+        const body = reqBody(request);
+        const user = await userFromSession(request, response);
+        const currWorkspace = multiUserMode(response)
+          ? await Workspace.getWithUser(user, { slug })
+          : await Workspace.get({ slug });
+
+        if (!currWorkspace || !body.documentLocation)
+          return response.sendStatus(400).end();
+
+        // Will delete the document from the entire system + wil unembed it.
+        await purgeDocument(body.documentLocation);
+        response.status(200).end();
+      } catch (e) {
+        console.error(e.message, e);
+        response.sendStatus(500).end();
+      }
+    }
+  );
 }
 
 module.exports = { workspaceEndpoints };