diff --git a/.vscode/settings.json b/.vscode/settings.json index 6b47d387..3fcc79cd 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 53cbeb64..647d104f 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 (
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 00000000..9cb0cd7c 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 00000000..d7e4edb6 --- /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 ( +
+ + + {children(files, setFiles)} +
+ ); +} 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 00000000..b0032b95 --- /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 ( +
+ {attachments.map((attachment) => ( + + ))} +
+ ); +} + +/** + * @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 ( +
+
+ +
+
+

{file.name}

+

+ {humanFileSize(file.size)} +

+
+
+ ); + } + + if (status === "failed") { + return ( + <> +
+
+ +
+
+ +
+
+

+ {file.name} +

+

+ {error ?? "this file failed to upload"}. It will not be available + in the workspace. +

+
+
+ + + ); + } + + return ( + <> +
+
+ +
+
+ +
+
+

{file.name}

+

File embedded!

+
+
+ + + ); +} + +/** + * @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 fc46fbe9..253f158f 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({ />
-
+
+