+ Your workspace is using {numberWithCommas(tokenCount)} of{" "}
+ {numberWithCommas(maxTokens)} available tokens. We recommend keeping
+ usage below {(Workspace.maxContextWindowLimit * 100).toFixed(0)}% to
+ ensure the best chat experience. Adding {fileCount} more{" "}
+ {pluralize("file", fileCount)} would exceed this limit.{" "}
+
+ Learn more about context windows →
+
+
+
+ Choose how you would like to proceed with these uploads.
+
+
+
+
+
+
+
+ {canEmbed && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx
index 27148ef044a..776291df450 100644
--- a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx
@@ -4,7 +4,9 @@ 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";
+import showToast from "@/utils/toast";
+import FileUploadWarningModal from "./FileUploadWarningModal";
+import pluralize from "pluralize";
export const DndUploaderContext = createContext();
export const REMOVE_ATTACHMENT_EVENT = "ATTACHMENT_REMOVE";
@@ -12,6 +14,8 @@ export const CLEAR_ATTACHMENTS_EVENT = "ATTACHMENT_CLEAR";
export const PASTE_ATTACHMENT_EVENT = "ATTACHMENT_PASTED";
export const ATTACHMENTS_PROCESSING_EVENT = "ATTACHMENTS_PROCESSING";
export const ATTACHMENTS_PROCESSED_EVENT = "ATTACHMENTS_PROCESSED";
+export const PARSED_FILE_ATTACHMENT_REMOVED_EVENT =
+ "PARSED_FILE_ATTACHMENT_REMOVED";
/**
* File Attachment for automatic upload on the chat container page.
@@ -19,30 +23,58 @@ export const ATTACHMENTS_PROCESSED_EVENT = "ATTACHMENTS_PROCESSED";
* @property {string} uid - unique file id.
* @property {File} file - native File object
* @property {string|null} contentString - base64 encoded string of file
- * @property {('in_progress'|'failed'|'success')} status - the automatic upload status.
+ * @property {('in_progress'|'failed'|'embedded'|'added_context')} status - the automatic upload status.
* @property {string|null} error - Error message
* @property {{id:string, location:string}|null} document - uploaded document details
* @property {('attachment'|'upload')} type - The type of upload. Attachments are chat-specific, uploads go to the workspace.
*/
-export function DnDFileUploaderProvider({ workspace, children }) {
+/**
+ * @typedef {Object} ParsedFile
+ * @property {number} id - The id of the parsed file.
+ * @property {string} filename - The name of the parsed file.
+ * @property {number} workspaceId - The id of the workspace the parsed file belongs to.
+ * @property {string|null} userId - The id of the user the parsed file belongs to.
+ * @property {string|null} threadId - The id of the thread the parsed file belongs to.
+ * @property {string} metadata - The metadata of the parsed file.
+ * @property {number} tokenCountEstimate - The estimated token count of the parsed file.
+ */
+
+export function DnDFileUploaderProvider({
+ workspace,
+ threadSlug = null,
+ children,
+}) {
const [files, setFiles] = useState([]);
const [ready, setReady] = useState(false);
const [dragging, setDragging] = useState(false);
- const { user } = useUser();
+ const [showWarningModal, setShowWarningModal] = useState(false);
+ const [isEmbedding, setIsEmbedding] = useState(false);
+ const [embedProgress, setEmbedProgress] = useState(0);
+ const [pendingFiles, setPendingFiles] = useState([]);
+ const [tokenCount, setTokenCount] = useState(0);
+ const [maxTokens, setMaxTokens] = useState(Number.POSITIVE_INFINITY);
useEffect(() => {
System.checkDocumentProcessorOnline().then((status) => setReady(status));
- }, [user]);
+ }, []);
useEffect(() => {
window.addEventListener(REMOVE_ATTACHMENT_EVENT, handleRemove);
window.addEventListener(CLEAR_ATTACHMENTS_EVENT, resetAttachments);
window.addEventListener(PASTE_ATTACHMENT_EVENT, handlePastedAttachment);
+ window.addEventListener(
+ PARSED_FILE_ATTACHMENT_REMOVED_EVENT,
+ handleRemoveParsedFile
+ );
return () => {
window.removeEventListener(REMOVE_ATTACHMENT_EVENT, handleRemove);
window.removeEventListener(CLEAR_ATTACHMENTS_EVENT, resetAttachments);
+ window.removeEventListener(
+ PARSED_FILE_ATTACHMENT_REMOVED_EVENT,
+ handleRemoveParsedFile
+ );
window.removeEventListener(
PASTE_ATTACHMENT_EVENT,
handlePastedAttachment
@@ -50,6 +82,18 @@ export function DnDFileUploaderProvider({ workspace, children }) {
};
}, []);
+ /**
+ * Handles the removal of a parsed file attachment from the uploader queue.
+ * Only uses the document id to remove the file from the queue
+ * @param {CustomEvent<{document: ParsedFile}>} event
+ */
+ async function handleRemoveParsedFile(event) {
+ const { document } = event.detail;
+ setFiles((prev) =>
+ prev.filter((prevFile) => prevFile.document.id !== document.id)
+ );
+ }
+
/**
* Remove file from uploader queue.
* @param {CustomEvent<{uid: string}>} event
@@ -112,8 +156,6 @@ export function DnDFileUploaderProvider({ workspace, children }) {
type: "attachment",
});
} else {
- // If the user is a default user, we do not want to allow them to upload files.
- if (!!user && user.role === "default") continue;
newAccepted.push({
uid: v4(),
file,
@@ -149,8 +191,6 @@ export function DnDFileUploaderProvider({ workspace, children }) {
type: "attachment",
});
} else {
- // If the user is a default user, we do not want to allow them to upload files.
- if (!!user && user.role === "default") continue;
newAccepted.push({
uid: v4(),
file,
@@ -170,36 +210,87 @@ export function DnDFileUploaderProvider({ workspace, children }) {
* Embeds attachments that are eligible for embedding - basically files that are not images.
* @param {Attachment[]} newAttachments
*/
- function embedEligibleAttachments(newAttachments = []) {
+ async function embedEligibleAttachments(newAttachments = []) {
window.dispatchEvent(new CustomEvent(ATTACHMENTS_PROCESSING_EVENT));
const promises = [];
+ const { currentContextTokenCount, contextWindow: newContextWindow } =
+ await Workspace.getParsedFiles(workspace.slug, threadSlug);
+ const newMaxTokens = Math.floor(
+ newContextWindow * Workspace.maxContextWindowLimit
+ );
+ setMaxTokens(newMaxTokens);
+
+ let totalTokenCount = currentContextTokenCount;
+ let batchPendingFiles = [];
+
for (const attachment of newAttachments) {
// Images/attachments are chat specific.
if (attachment.type === "attachment") continue;
const formData = new FormData();
formData.append("file", attachment.file, attachment.file.name);
+ formData.append("threadSlug", threadSlug || null);
promises.push(
- Workspace.uploadAndEmbedFile(workspace.slug, formData).then(
- ({ response, data }) => {
+ Workspace.parseFile(workspace.slug, formData).then(
+ async ({ response, data }) => {
+ if (!response.ok) {
+ const updates = {
+ status: "failed",
+ error: data?.error ?? null,
+ };
+ setFiles((prev) =>
+ prev.map(
+ (
+ /** @type {Attachment} */
+ prevFile
+ ) =>
+ prevFile.uid !== attachment.uid
+ ? prevFile
+ : { ...prevFile, ...updates }
+ )
+ );
+ return;
+ }
+ // Will always be one file in the array
+ /** @type {ParsedFile} */
+ const file = data.files[0];
+
+ // Add token count for this file
+ // and add it to the batch pending files
+ totalTokenCount += file.tokenCountEstimate;
+ batchPendingFiles.push({
+ attachment,
+ parsedFileId: file.id,
+ tokenCount: file.tokenCountEstimate,
+ });
+
+ if (totalTokenCount > newMaxTokens) {
+ setTokenCount(totalTokenCount);
+ setPendingFiles(batchPendingFiles);
+ setShowWarningModal(true);
+ return;
+ }
+
+ // File is within limits, keep in parsed files
+ const result = { success: true, document: file };
const updates = {
- status: response.ok ? "success" : "failed",
- error: data?.error ?? null,
- document: data?.document,
+ status: result.success ? "added_context" : "failed",
+ error: result.error ?? null,
+ document: result.document,
};
- setFiles((prev) => {
- return prev.map(
+ setFiles((prev) =>
+ prev.map(
(
/** @type {Attachment} */
prevFile
- ) => {
- if (prevFile.uid !== attachment.uid) return prevFile;
- return { ...prevFile, ...updates };
- }
- );
- });
+ ) =>
+ prevFile.uid !== attachment.uid
+ ? prevFile
+ : { ...prevFile, ...updates }
+ )
+ );
}
)
);
@@ -211,10 +302,117 @@ export function DnDFileUploaderProvider({ workspace, children }) {
);
}
+ // Handle modal actions
+ const handleCloseModal = async () => {
+ if (!pendingFiles.length) return;
+
+ // Delete all files from this batch
+ await Workspace.deleteParsedFiles(
+ workspace.slug,
+ pendingFiles.map((file) => file.parsedFileId)
+ );
+
+ // Remove all files from this batch from the UI
+ setFiles((prev) =>
+ prev.filter(
+ (prevFile) =>
+ !pendingFiles.some((file) => file.attachment.uid === prevFile.uid)
+ )
+ );
+ setShowWarningModal(false);
+ setPendingFiles([]);
+ setTokenCount(0);
+ window.dispatchEvent(new CustomEvent(ATTACHMENTS_PROCESSED_EVENT));
+ };
+
+ const handleContinueAnyway = async () => {
+ if (!pendingFiles.length) return;
+ const results = pendingFiles.map((file) => ({
+ success: true,
+ document: { id: file.parsedFileId },
+ }));
+
+ const fileUpdates = pendingFiles.map((file, i) => ({
+ uid: file.attachment.uid,
+ updates: {
+ status: results[i].success ? "success" : "failed",
+ error: results[i].error ?? null,
+ document: results[i].document,
+ },
+ }));
+
+ setFiles((prev) =>
+ prev.map((prevFile) => {
+ const update = fileUpdates.find((f) => f.uid === prevFile.uid);
+ return update ? { ...prevFile, ...update.updates } : prevFile;
+ })
+ );
+ setShowWarningModal(false);
+ setPendingFiles([]);
+ setTokenCount(0);
+ };
+
+ const handleEmbed = async () => {
+ if (!pendingFiles.length) return;
+ setIsEmbedding(true);
+ setEmbedProgress(0);
+
+ // Embed all pending files
+ let completed = 0;
+ const results = await Promise.all(
+ pendingFiles.map((file) =>
+ Workspace.embedParsedFile(workspace.slug, file.parsedFileId).then(
+ (result) => {
+ completed++;
+ setEmbedProgress(completed);
+ return result;
+ }
+ )
+ )
+ );
+
+ // Update status for all files
+ const fileUpdates = pendingFiles.map((file, i) => ({
+ uid: file.attachment.uid,
+ updates: {
+ status: results[i].response.ok ? "embedded" : "failed",
+ error: results[i].data?.error ?? null,
+ document: results[i].data?.document,
+ },
+ }));
+
+ setFiles((prev) =>
+ prev.map((prevFile) => {
+ const update = fileUpdates.find((f) => f.uid === prevFile.uid);
+ return update ? { ...prevFile, ...update.updates } : prevFile;
+ })
+ );
+ setShowWarningModal(false);
+ setPendingFiles([]);
+ setTokenCount(0);
+ setIsEmbedding(false);
+ window.dispatchEvent(new CustomEvent(ATTACHMENTS_PROCESSED_EVENT));
+ showToast(
+ `${pendingFiles.length} ${pluralize("file", pendingFiles.length)} embedded successfully`,
+ "success"
+ );
+ };
+
return (
+
{children}
);
@@ -231,8 +429,6 @@ export default function DnDFileUploaderWrapper({ children }) {
onDragEnter: () => setDragging(true),
onDragLeave: () => setDragging(false),
});
- const { user } = useUser();
- const canUploadAll = !user || user?.role !== "default";
return (
-
-
- Add {canUploadAll ? "anything" : "an image"}
-
+
+
Add anything
- {canUploadAll ? (
- <>
- Drop your file here to embed it into your
- workspace auto-magically.
- >
- ) : (
- <>
- Drop your image here to chat with it
- auto-magically.
- >
- )}
+ Drop a file or image here to attach it to your
+ workspace auto-magically.
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx
new file mode 100644
index 00000000000..b9a8392733e
--- /dev/null
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx
@@ -0,0 +1,194 @@
+import { useState } from "react";
+import { X, CircleNotch, Warning } from "@phosphor-icons/react";
+import Workspace from "@/models/workspace";
+import { useParams } from "react-router-dom";
+import { nFormatter } from "@/utils/numbers";
+import showToast from "@/utils/toast";
+import pluralize from "pluralize";
+import { PARSED_FILE_ATTACHMENT_REMOVED_EVENT } from "../../../DnDWrapper";
+import useUser from "@/hooks/useUser";
+
+export default function ParsedFilesMenu({
+ onEmbeddingChange,
+ tooltipRef,
+ files,
+ setFiles,
+ currentTokens,
+ setCurrentTokens,
+ contextWindow,
+ isLoading,
+}) {
+ const { user } = useUser();
+ const canEmbed = !user || user.role !== "default";
+ const initialContextWindowLimitExceeded =
+ currentTokens >= contextWindow * Workspace.maxContextWindowLimit;
+ const { slug, threadSlug = null } = useParams();
+ const [isEmbedding, setIsEmbedding] = useState(false);
+ const [embedProgress, setEmbedProgress] = useState(1);
+ const [contextWindowLimitExceeded, setContextWindowLimitExceeded] = useState(
+ initialContextWindowLimitExceeded
+ );
+
+ async function handleRemove(e, file) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (!file?.id) return;
+
+ const success = await Workspace.deleteParsedFiles(slug, [file.id]);
+ if (!success) return;
+
+ // Update the local files list and current tokens
+ setFiles((prev) => prev.filter((f) => f.id !== file.id));
+
+ // Dispatch an event to the DnDFileUploaderWrapper to update the files list in attachment manager if it exists
+ window.dispatchEvent(
+ new CustomEvent(PARSED_FILE_ATTACHMENT_REMOVED_EVENT, {
+ detail: { document: file },
+ })
+ );
+ const { currentContextTokenCount } = await Workspace.getParsedFiles(
+ slug,
+ threadSlug
+ );
+ const newContextWindowLimitExceeded =
+ currentContextTokenCount >=
+ contextWindow * Workspace.maxContextWindowLimit;
+ setCurrentTokens(currentContextTokenCount);
+ setContextWindowLimitExceeded(newContextWindowLimitExceeded);
+ }
+
+ /**
+ * Handles the embedding of the files when the user exceeds the context window limit
+ * and opts to embed the files into the workspace instead.
+ * @returns {Promise}
+ */
+ async function handleEmbed() {
+ if (!files.length) return;
+ setIsEmbedding(true);
+ onEmbeddingChange?.(true);
+ setEmbedProgress(1);
+ try {
+ let completed = 0;
+ await Promise.all(
+ files.map((file) =>
+ Workspace.embedParsedFile(slug, file.id).then(() => {
+ completed++;
+ setEmbedProgress(completed + 1);
+ })
+ )
+ );
+ setFiles([]);
+ const { currentContextTokenCount } = await Workspace.getParsedFiles(
+ slug,
+ threadSlug
+ );
+ setCurrentTokens(currentContextTokenCount);
+ setContextWindowLimitExceeded(
+ currentContextTokenCount >=
+ contextWindow * Workspace.maxContextWindowLimit
+ );
+ showToast(
+ `${files.length} ${pluralize("file", files.length)} embedded successfully`,
+ "success"
+ );
+ tooltipRef?.current?.close();
+ } catch (error) {
+ console.error("Failed to embed files:", error);
+ showToast("Failed to embed files", "error");
+ }
+ setIsEmbedding(false);
+ onEmbeddingChange?.(false);
+ setEmbedProgress(1);
+ }
+
+ return (
+
+ Your context window is getting full. Some files may be truncated
+ or excluded from chat responses. We recommend embedding these
+ files directly into your workspace for better results.
+