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 000000000..9cb0cd7cc Binary files /dev/null and b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/dnd-icon.png differ 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 };