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
This commit is contained in:
Timothy Carambat 2024-07-30 10:26:16 -07:00 committed by GitHub
parent 5e73dce506
commit d877d2b7ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 525 additions and 49 deletions

View File

@ -39,11 +39,13 @@
"openrouter",
"pagerender",
"Qdrant",
"royalblue",
"searxng",
"Serper",
"Serply",
"textgenwebui",
"togetherai",
"Unembed",
"vectordbs",
"Weaviate",
"Zilliz"

View File

@ -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">

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: {

View File

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