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