Skip to content

Commit

Permalink
Add drag-and-drop to chat window (#1995)
Browse files Browse the repository at this point in the history
* Add drag-and-drop to chat window

* add uploader icon and remove empty space text when attachments are present

* color theme

* color update
  • Loading branch information
timothycarambat authored Jul 30, 2024
1 parent 5e73dce commit d877d2b
Show file tree
Hide file tree
Showing 10 changed files with 525 additions and 49 deletions.
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,13 @@
"openrouter",
"pagerender",
"Qdrant",
"royalblue",
"searxng",
"Serper",
"Serply",
"textgenwebui",
"togetherai",
"Unembed",
"vectordbs",
"Weaviate",
"Zilliz"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default function ChatHistory({
sendCommand,
updateHistory,
regenerateAssistantMessage,
hasAttachments = false,
}) {
const { user } = useUser();
const { threadSlug = null } = useParams();
Expand Down Expand Up @@ -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">
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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 };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -21,6 +22,7 @@ export default function PromptInput({
inputDisabled,
buttonDisabled,
sendCommand,
attachments = [],
}) {
const [promptInput, setPromptInput] = useState("");
const { showAgents, setShowAgents } = useAvailableAgents();
Expand Down Expand Up @@ -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}
Expand Down
Loading

0 comments on commit d877d2b

Please sign in to comment.