From 84c1f6e0ea8a217aa93c2701765cd54a08a89675 Mon Sep 17 00:00:00 2001 From: Timothy Carambat Date: Thu, 19 Sep 2024 14:44:49 -0500 Subject: [PATCH] Add ability to copy/paste images, files, and text from web, local, or otherwise (#2326) --- .../ChatContainer/DnDWrapper/index.jsx | 50 ++++++++++++++++++- .../PromptInput/Attachments/index.jsx | 20 +++++--- .../ChatContainer/PromptInput/index.jsx | 35 +++++++++++++ 3 files changed, 97 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx index 6d6f34535..b79a4535a 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx @@ -9,6 +9,7 @@ import useUser from "@/hooks/useUser"; export const DndUploaderContext = createContext(); export const REMOVE_ATTACHMENT_EVENT = "ATTACHMENT_REMOVE"; export const CLEAR_ATTACHMENTS_EVENT = "ATTACHMENT_CLEAR"; +export const PASTE_ATTACHMENT_EVENT = "ATTACHMENT_PASTED"; /** * File Attachment for automatic upload on the chat container page. @@ -36,10 +37,15 @@ export function DnDFileUploaderProvider({ workspace, children }) { useEffect(() => { window.addEventListener(REMOVE_ATTACHMENT_EVENT, handleRemove); window.addEventListener(CLEAR_ATTACHMENTS_EVENT, resetAttachments); + window.addEventListener(PASTE_ATTACHMENT_EVENT, handlePastedAttachment); return () => { window.removeEventListener(REMOVE_ATTACHMENT_EVENT, handleRemove); window.removeEventListener(CLEAR_ATTACHMENTS_EVENT, resetAttachments); + window.removeEventListener( + PASTE_ATTACHMENT_EVENT, + handlePastedAttachment + ); }; }, []); @@ -86,6 +92,39 @@ export function DnDFileUploaderProvider({ workspace, children }) { ); } + /** + * Handle pasted attachments. + * @param {CustomEvent<{files: File[]}>} event + */ + async function handlePastedAttachment(event) { + const { files = [] } = event.detail; + if (!files.length) return; + const newAccepted = []; + for (const file of files) { + if (file.type.startsWith("image/")) { + newAccepted.push({ + uid: v4(), + file, + contentString: await toBase64(file), + status: "success", + error: null, + type: "attachment", + }); + } else { + newAccepted.push({ + uid: v4(), + file, + contentString: null, + status: "in_progress", + error: null, + type: "upload", + }); + } + } + setFiles((prev) => [...prev, ...newAccepted]); + embedEligibleAttachments(newAccepted); + } + /** * Handle dropped files. * @param {Attachment[]} acceptedFiles @@ -119,8 +158,15 @@ export function DnDFileUploaderProvider({ workspace, children }) { } setFiles((prev) => [...prev, ...newAccepted]); + embedEligibleAttachments(newAccepted); + } - for (const attachment of newAccepted) { + /** + * Embeds attachments that are eligible for embedding - basically files that are not images. + * @param {Attachment[]} newAttachments + */ + function embedEligibleAttachments(newAttachments = []) { + for (const attachment of newAttachments) { // Images/attachments are chat specific. if (attachment.type === "attachment") continue; @@ -200,7 +246,7 @@ export default function DnDFileUploaderWrapper({ children }) { /** * Convert image types into Base64 strings for requests. * @param {File} file - * @returns {string} + * @returns {Promise} */ async function toBase64(file) { return new Promise((resolve, reject) => { diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx index 8cfc3c535..44677e8b7 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx @@ -33,7 +33,8 @@ export default function AttachmentManager({ attachments }) { * @param {{attachment: import("../../DnDWrapper").Attachment}} */ function AttachmentItem({ attachment }) { - const { uid, file, status, error, document, type } = attachment; + const { uid, file, status, error, document, type, contentString } = + attachment; const { iconBgColor, Icon } = displayFromFile(file); function removeFileFromQueue() { @@ -127,11 +128,18 @@ function AttachmentItem({ attachment }) { /> -
- -
+ {contentString ? ( + + ) : ( +
+ +
+ )}

{file.name} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx index 3647113b4..abd1fbebb 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx @@ -15,6 +15,7 @@ import SpeechToText from "./SpeechToText"; import { Tooltip } from "react-tooltip"; import AttachmentManager from "./Attachments"; import AttachItem from "./AttachItem"; +import { PASTE_ATTACHMENT_EVENT } from "../DnDWrapper"; export const PROMPT_INPUT_EVENT = "set_prompt_input"; export default function PromptInput({ @@ -91,6 +92,39 @@ export default function PromptInput({ element.style.height = `${element.scrollHeight}px`; }; + const handlePasteEvent = (e) => { + e.preventDefault(); + if (e.clipboardData.items.length === 0) return false; + + // paste any clipboard items that are images. + for (const item of e.clipboardData.items) { + if (item.type.startsWith("image/")) { + const file = item.getAsFile(); + window.dispatchEvent( + new CustomEvent(PASTE_ATTACHMENT_EVENT, { + detail: { files: [file] }, + }) + ); + continue; + } + + // handle files specifically that are not images as uploads + if (item.kind === "file") { + const file = item.getAsFile(); + window.dispatchEvent( + new CustomEvent(PASTE_ATTACHMENT_EVENT, { + detail: { files: [file] }, + }) + ); + continue; + } + } + + const pasteText = e.clipboardData.getData("text/plain"); + if (pasteText) setPromptInput(pasteText.trim()); + return; + }; + const watchForSlash = debounce(checkForSlash, 300); const watchForAt = debounce(checkForAt, 300); @@ -125,6 +159,7 @@ export default function PromptInput({ setPromptInput(e.target.value); }} onKeyDown={captureEnter} + onPaste={handlePasteEvent} required={true} disabled={inputDisabled} onFocus={() => setFocused(true)}