From b069caf8df28e98437386759bf15b76bbe5aeb40 Mon Sep 17 00:00:00 2001 From: shatfield4 Date: Thu, 31 Jul 2025 17:17:12 -0700 Subject: [PATCH 01/19] prompt user to embed if exceeds prompt window + handle embed + handle cancel --- .../ChatContainer/DnDWrapper/index.jsx | 84 ++++++++++++++--- frontend/src/models/workspace.js | 24 +++++ server/endpoints/workspaces.js | 92 +++++++++++++++++++ server/models/workspaceParsedFiles.js | 77 ++++++++++++++++ 4 files changed, 263 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx index 27148ef044a..1f848d03e3e 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx @@ -5,6 +5,7 @@ import { useDropzone } from "react-dropzone"; import DndIcon from "./dnd-icon.png"; import Workspace from "@/models/workspace"; import useUser from "@/hooks/useUser"; +import { useParams } from "react-router-dom"; export const DndUploaderContext = createContext(); export const REMOVE_ATTACHMENT_EVENT = "ATTACHMENT_REMOVE"; @@ -26,6 +27,7 @@ export const ATTACHMENTS_PROCESSED_EVENT = "ATTACHMENTS_PROCESSED"; */ export function DnDFileUploaderProvider({ workspace, children }) { + const { threadSlug = null } = useParams(); const [files, setFiles] = useState([]); const [ready, setReady] = useState(false); const [dragging, setDragging] = useState(false); @@ -170,7 +172,7 @@ 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 = []; @@ -180,26 +182,80 @@ export function DnDFileUploaderProvider({ workspace, children }) { const formData = new FormData(); formData.append("file", attachment.file, attachment.file.name); + formData.append("threadSlug", threadSlug || ""); 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; + } + + // Check if any file exceeds context window + const tokenCount = data.files.reduce( + (sum, file) => + sum + JSON.parse(file.metadata).token_count_estimate, + 0 + ); + const contextWindow = 8000; // TODO: Get from workspace or system settings + const maxTokens = Math.floor(contextWindow * 0.8); + + if (tokenCount > maxTokens) { + // Show alert dialog + const choice = window.confirm( + `This document exceeds 80% of your context window (${tokenCount} > ${maxTokens} tokens). Would you like to embed it anyway?` + ); + + if (!choice) { + // Delete parsed file and remove from UI + await Workspace.deleteParsedFile( + workspace.slug, + data.files[0].id + ); + setFiles((prev) => + prev.filter((prevFile) => prevFile.uid !== attachment.uid) + ); + return; + } + } + + // User chose to continue or file is within limits + // We already have the file parsed, just need to move and embed it + const embedResult = await Workspace.embedParsedFile( + workspace.slug, + data.files[0].id + ); const updates = { - status: response.ok ? "success" : "failed", - error: data?.error ?? null, - document: data?.document, + status: embedResult.response.ok ? "success" : "failed", + error: embedResult.data?.error ?? null, + document: embedResult.data?.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 } + ) + ); } ) ); diff --git a/frontend/src/models/workspace.js b/frontend/src/models/workspace.js index 5db3fff866f..5bdbbe552d0 100644 --- a/frontend/src/models/workspace.js +++ b/frontend/src/models/workspace.js @@ -464,6 +464,30 @@ const Workspace = { return { response, data }; }, + deleteParsedFile: async function (slug, fileId) { + const response = await fetch( + `${API_BASE}/workspace/${slug}/delete-parsed-file/${fileId}`, + { + method: "DELETE", + headers: baseHeaders(), + } + ); + return response.ok; + }, + + embedParsedFile: async function (slug, fileId) { + const response = await fetch( + `${API_BASE}/workspace/${slug}/embed-parsed-file/${fileId}`, + { + method: "POST", + 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 diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js index b2b70d9fbe5..920d1891cda 100644 --- a/server/endpoints/workspaces.js +++ b/server/endpoints/workspaces.js @@ -32,6 +32,7 @@ const { } = require("../utils/files/pfp"); const { getTTSProvider } = require("../utils/TextToSpeech"); const { WorkspaceThread } = require("../models/workspaceThread"); + const truncate = require("truncate"); const { purgeDocument } = require("../utils/files/purgeDocument"); const { getModelTag } = require("./utils"); @@ -112,6 +113,85 @@ function workspaceEndpoints(app) { } ); + app.delete( + "/workspace/:slug/delete-parsed-file/:fileId", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async function (request, response) { + try { + const { slug = null, fileId = null } = request.params; + const user = await userFromSession(request, response); + const workspace = multiUserMode(response) + ? await Workspace.getWithUser(user, { slug }) + : await Workspace.get({ slug }); + + if (!workspace || !fileId) { + response.sendStatus(400).end(); + return; + } + + const success = await WorkspaceParsedFiles.delete({ + id: parseInt(fileId), + }); + response.status(success ? 200 : 500).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/workspace/:slug/embed-parsed-file/:fileId", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async function (request, response) { + try { + const { slug = null, fileId = null } = request.params; + const user = await userFromSession(request, response); + const workspace = multiUserMode(response) + ? await Workspace.getWithUser(user, { slug }) + : await Workspace.get({ slug }); + + if (!workspace || !fileId) { + response.sendStatus(400).end(); + return; + } + + const { success, error, document } = + await WorkspaceParsedFiles.moveToDocumentsAndEmbed( + fileId, + workspace.id + ); + + if (!success) { + response.status(500).json({ + success: false, + error: error || "Failed to embed file", + }); + return; + } + + await Telemetry.sendTelemetry("document_embedded"); + await EventLogs.logEvent( + "document_embedded", + { + documentName: document?.name || "unknown", + workspaceId: workspace.id, + }, + user?.id + ); + + response.status(200).json({ + success: true, + error: null, + document, + }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + app.post( "/workspace/:slug/parse", [ @@ -154,6 +234,17 @@ function workspaceEndpoints(app) { return; } + // Get thread ID if we have a slug + const { threadSlug } = reqBody(request); + let threadId = null; + if (threadSlug) { + const thread = await WorkspaceThread.get({ + slug: threadSlug, + workspace_id: workspace.id, + }); + threadId = thread?.id || null; + } + const files = await Promise.all( documents.map(async (doc) => { const metadata = { ...doc }; @@ -165,6 +256,7 @@ function workspaceEndpoints(app) { filename, workspaceId: workspace.id, userId: user?.id || null, + threadId, metadata: JSON.stringify(metadata), }); diff --git a/server/models/workspaceParsedFiles.js b/server/models/workspaceParsedFiles.js index caef4bafb84..a8eb0034f74 100644 --- a/server/models/workspaceParsedFiles.js +++ b/server/models/workspaceParsedFiles.js @@ -1,4 +1,9 @@ const prisma = require("../utils/prisma"); +const { EventLogs } = require("./eventLogs"); +const { Document } = require("./documents"); +const { Workspace } = require("./workspace"); +const fs = require("fs"); +const path = require("path"); const WorkspaceParsedFiles = { create: async function ({ @@ -19,6 +24,15 @@ const WorkspaceParsedFiles = { }, }); + await EventLogs.logEvent( + "workspace_file_parsed", + { + filename, + workspaceId, + }, + userId + ); + return { file, error: null }; } catch (error) { console.error("FAILED TO CREATE PARSED FILE RECORD.", error.message); @@ -62,6 +76,69 @@ const WorkspaceParsedFiles = { return false; } }, + + moveToDocumentsAndEmbed: async function (fileId, workspaceId) { + try { + const parsedFile = await this.get({ id: parseInt(fileId) }); + if (!parsedFile) throw new Error("File not found"); + + // Parse the metadata to get the actual file location + const metadata = JSON.parse(parsedFile.metadata || "{}"); + const location = metadata.location; + if (!location) throw new Error("No file location in metadata"); + + // Read the file from direct-uploads using the location from metadata + const directUploadsPath = + process.env.NODE_ENV === "development" + ? path.resolve(__dirname, "../storage/direct-uploads") + : path.resolve(process.env.STORAGE_DIR, "direct-uploads"); + const sourceFile = path.join(directUploadsPath, location.split("/")[1]); + if (!fs.existsSync(sourceFile)) throw new Error("Source file not found"); + + // Move to documents/custom-documents + const documentsPath = + process.env.NODE_ENV === "development" + ? path.resolve(__dirname, "../storage/documents") + : path.resolve(process.env.STORAGE_DIR, "documents"); + const customDocsPath = path.join(documentsPath, "custom-documents"); + if (!fs.existsSync(customDocsPath)) + fs.mkdirSync(customDocsPath, { recursive: true }); + + // Copy the file to custom-documents + const targetPath = path.join(customDocsPath, location.split("/")[1]); + fs.copyFileSync(sourceFile, targetPath); + fs.unlinkSync(sourceFile); + + // Embed file from custom-documents + const workspace = await Workspace.get({ id: parseInt(workspaceId) }); + if (!workspace) throw new Error("Workspace not found"); + const { + failedToEmbed = [], + errors = [], + embedded = [], + } = await Document.addDocuments( + workspace, + [`custom-documents/${location.split("/")[1]}`], + parsedFile.userId + ); + + if (failedToEmbed.length > 0) { + throw new Error(errors[0] || "Failed to embed document"); + } + + await this.delete({ id: parseInt(fileId) }); + + const document = await Document.get({ + workspaceId: parseInt(workspaceId), + docpath: embedded[0], + }); + + return { success: true, error: null, document }; + } catch (error) { + console.error("Failed to move and embed file:", error); + return { success: false, error: error.message, document: null }; + } + }, }; module.exports = { WorkspaceParsedFiles }; From a5c5ceb580943e638a037c87a8038f185062417d Mon Sep 17 00:00:00 2001 From: shatfield4 Date: Fri, 1 Aug 2025 15:46:30 -0700 Subject: [PATCH 02/19] add tokenCountEstimate to workspace_parsed_files + optimizations --- .../ChatContainer/DnDWrapper/index.jsx | 10 ++++++---- .../WorkspaceChat/ChatContainer/index.jsx | 2 +- .../src/components/WorkspaceChat/index.jsx | 2 +- server/endpoints/workspaces.js | 8 ++++++++ server/models/workspaceParsedFiles.js | 4 +++- .../migration.sql | 1 + server/prisma/schema.prisma | 19 ++++++++++--------- 7 files changed, 30 insertions(+), 16 deletions(-) rename server/prisma/migrations/{20250730235629_init => 20250801213234_init}/migration.sql (95%) diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx index 1f848d03e3e..bee8ff7aa80 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx @@ -5,7 +5,6 @@ import { useDropzone } from "react-dropzone"; import DndIcon from "./dnd-icon.png"; import Workspace from "@/models/workspace"; import useUser from "@/hooks/useUser"; -import { useParams } from "react-router-dom"; export const DndUploaderContext = createContext(); export const REMOVE_ATTACHMENT_EVENT = "ATTACHMENT_REMOVE"; @@ -26,8 +25,11 @@ export const ATTACHMENTS_PROCESSED_EVENT = "ATTACHMENTS_PROCESSED"; * @property {('attachment'|'upload')} type - The type of upload. Attachments are chat-specific, uploads go to the workspace. */ -export function DnDFileUploaderProvider({ workspace, children }) { - const { threadSlug = null } = useParams(); +export function DnDFileUploaderProvider({ + workspace, + children, + threadSlug = null, +}) { const [files, setFiles] = useState([]); const [ready, setReady] = useState(false); const [dragging, setDragging] = useState(false); @@ -182,7 +184,7 @@ export function DnDFileUploaderProvider({ workspace, children }) { const formData = new FormData(); formData.append("file", attachment.file, attachment.file.name); - formData.append("threadSlug", threadSlug || ""); + formData.append("threadSlug", threadSlug || null); promises.push( Workspace.parseFile(workspace.slug, formData).then( async ({ response, data }) => { diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx index 6e961a95717..7feb753db59 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx @@ -304,7 +304,7 @@ 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-theme-bg-secondary w-full h-full overflow-y-scroll no-scroll z-[2]" > {isMobile && } - + - + diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js index 920d1891cda..20015cd5e7e 100644 --- a/server/endpoints/workspaces.js +++ b/server/endpoints/workspaces.js @@ -188,6 +188,12 @@ function workspaceEndpoints(app) { } catch (e) { console.error(e.message, e); response.sendStatus(500).end(); + } finally { + if (request.params.fileId) { + await WorkspaceParsedFiles.delete({ + id: parseInt(request.params.fileId), + }); + } } } ); @@ -241,6 +247,7 @@ function workspaceEndpoints(app) { const thread = await WorkspaceThread.get({ slug: threadSlug, workspace_id: workspace.id, + user_id: user?.id || null, }); threadId = thread?.id || null; } @@ -258,6 +265,7 @@ function workspaceEndpoints(app) { userId: user?.id || null, threadId, metadata: JSON.stringify(metadata), + tokenCountEstimate: doc.token_count_estimate || 0, }); if (dbError) throw new Error(dbError); diff --git a/server/models/workspaceParsedFiles.js b/server/models/workspaceParsedFiles.js index a8eb0034f74..c48983d0db5 100644 --- a/server/models/workspaceParsedFiles.js +++ b/server/models/workspaceParsedFiles.js @@ -12,6 +12,7 @@ const WorkspaceParsedFiles = { userId = null, threadId = null, metadata = null, + tokenCountEstimate = 0, }) { try { const file = await prisma.workspace_parsed_files.create({ @@ -21,11 +22,12 @@ const WorkspaceParsedFiles = { userId: userId ? parseInt(userId) : null, threadId: threadId ? parseInt(threadId) : null, metadata, + tokenCountEstimate, }, }); await EventLogs.logEvent( - "workspace_file_parsed", + "workspace_file_uploaded", { filename, workspaceId, diff --git a/server/prisma/migrations/20250730235629_init/migration.sql b/server/prisma/migrations/20250801213234_init/migration.sql similarity index 95% rename from server/prisma/migrations/20250730235629_init/migration.sql rename to server/prisma/migrations/20250801213234_init/migration.sql index 2622825985a..5af540b078e 100644 --- a/server/prisma/migrations/20250730235629_init/migration.sql +++ b/server/prisma/migrations/20250801213234_init/migration.sql @@ -6,6 +6,7 @@ CREATE TABLE "workspace_parsed_files" ( "userId" INTEGER, "threadId" INTEGER, "metadata" TEXT, + "tokenCountEstimate" INTEGER DEFAULT 0, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "workspace_parsed_files_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "workspace_parsed_files_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 18e90f14e83..28f734c7ed6 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -375,15 +375,16 @@ model desktop_mobile_devices { } model workspace_parsed_files { - id Int @id @default(autoincrement()) - filename String @unique - workspaceId Int - userId Int? - threadId Int? - metadata String? - createdAt DateTime @default(now()) - workspace workspaces @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - user users? @relation(fields: [userId], references: [id], onDelete: SetNull) + id Int @id @default(autoincrement()) + filename String @unique + workspaceId Int + userId Int? + threadId Int? + metadata String? + tokenCountEstimate Int? @default(0) + createdAt DateTime @default(now()) + workspace workspaces @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + user users? @relation(fields: [userId], references: [id], onDelete: SetNull) @@index([workspaceId]) @@index([userId]) From 46abdd9989dcdd9d03a92c5bc5e96effd378e4e0 Mon Sep 17 00:00:00 2001 From: shatfield4 Date: Fri, 1 Aug 2025 16:01:51 -0700 Subject: [PATCH 03/19] use util for path locations + use safeJsonParse --- server/models/workspaceParsedFiles.js | 22 +++++++++------------- server/utils/files/index.js | 5 +++++ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/server/models/workspaceParsedFiles.js b/server/models/workspaceParsedFiles.js index c48983d0db5..52121c7bba4 100644 --- a/server/models/workspaceParsedFiles.js +++ b/server/models/workspaceParsedFiles.js @@ -2,6 +2,8 @@ const prisma = require("../utils/prisma"); const { EventLogs } = require("./eventLogs"); const { Document } = require("./documents"); const { Workspace } = require("./workspace"); +const { documentsPath, directUploadsPath } = require("../utils/files"); +const { safeJsonParse } = require("../utils/http"); const fs = require("fs"); const path = require("path"); @@ -84,24 +86,16 @@ const WorkspaceParsedFiles = { const parsedFile = await this.get({ id: parseInt(fileId) }); if (!parsedFile) throw new Error("File not found"); - // Parse the metadata to get the actual file location - const metadata = JSON.parse(parsedFile.metadata || "{}"); + // Get file location from metadata + const metadata = safeJsonParse(parsedFile.metadata, {}); const location = metadata.location; if (!location) throw new Error("No file location in metadata"); - // Read the file from direct-uploads using the location from metadata - const directUploadsPath = - process.env.NODE_ENV === "development" - ? path.resolve(__dirname, "../storage/direct-uploads") - : path.resolve(process.env.STORAGE_DIR, "direct-uploads"); + // Get file from metadata location const sourceFile = path.join(directUploadsPath, location.split("/")[1]); if (!fs.existsSync(sourceFile)) throw new Error("Source file not found"); - // Move to documents/custom-documents - const documentsPath = - process.env.NODE_ENV === "development" - ? path.resolve(__dirname, "../storage/documents") - : path.resolve(process.env.STORAGE_DIR, "documents"); + // Move to custom-documents const customDocsPath = path.join(documentsPath, "custom-documents"); if (!fs.existsSync(customDocsPath)) fs.mkdirSync(customDocsPath, { recursive: true }); @@ -111,7 +105,7 @@ const WorkspaceParsedFiles = { fs.copyFileSync(sourceFile, targetPath); fs.unlinkSync(sourceFile); - // Embed file from custom-documents + // Embed file const workspace = await Workspace.get({ id: parseInt(workspaceId) }); if (!workspace) throw new Error("Workspace not found"); const { @@ -139,6 +133,8 @@ const WorkspaceParsedFiles = { } catch (error) { console.error("Failed to move and embed file:", error); return { success: false, error: error.message, document: null }; + } finally { + await this.delete({ id: parseInt(fileId) }); } }, }; diff --git a/server/utils/files/index.js b/server/utils/files/index.js index 47c358d40c3..73c442b63b4 100644 --- a/server/utils/files/index.js +++ b/server/utils/files/index.js @@ -7,6 +7,10 @@ const documentsPath = process.env.NODE_ENV === "development" ? path.resolve(__dirname, `../../storage/documents`) : path.resolve(process.env.STORAGE_DIR, `documents`); +const directUploadsPath = + process.env.NODE_ENV === "development" + ? path.resolve(__dirname, `../../storage/direct-uploads`) + : path.resolve(process.env.STORAGE_DIR, `direct-uploads`); const vectorCachePath = process.env.NODE_ENV === "development" ? path.resolve(__dirname, `../../storage/vector-cache`) @@ -468,6 +472,7 @@ module.exports = { normalizePath, isWithin, documentsPath, + directUploadsPath, hasVectorCachedFiles, purgeEntireVectorCache, getDocumentsByFolder, From 3a8a72fad9eb7690ae2db6a488d327c5914af5a5 Mon Sep 17 00:00:00 2001 From: shatfield4 Date: Fri, 1 Aug 2025 17:27:27 -0700 Subject: [PATCH 04/19] add modal for user decision on overflow of context window --- .../DnDWrapper/FileUploadWarningModal.jsx | 66 +++++++++ .../ChatContainer/DnDWrapper/index.jsx | 127 +++++++++++++++--- 2 files changed, 171 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/FileUploadWarningModal.jsx diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/FileUploadWarningModal.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/FileUploadWarningModal.jsx new file mode 100644 index 00000000000..9d30fac564c --- /dev/null +++ b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/FileUploadWarningModal.jsx @@ -0,0 +1,66 @@ +import { X } from "@phosphor-icons/react"; +import ModalWrapper from "@/components/ModalWrapper"; + +export default function FileUploadWarningModal({ + show, + onClose, + onContinue, + onEmbed, + tokenCount, + maxTokens, + fileCount = 1, +}) { + if (!show) return null; + + return ( + +
+
+
+

+ {fileCount === 1 ? 'File' : 'Files'} exceed{fileCount === 1 ? 's' : ''} context window +

+ +
+
+ +
+

+ This document exceeds 80% of your context window ({tokenCount} > {maxTokens} tokens). + Choose how you would like to proceed: +

+
+ +
+ + + +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx index bee8ff7aa80..37607e48371 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx @@ -5,6 +5,7 @@ import { useDropzone } from "react-dropzone"; import DndIcon from "./dnd-icon.png"; import Workspace from "@/models/workspace"; import useUser from "@/hooks/useUser"; +import FileUploadWarningModal from "./FileUploadWarningModal"; export const DndUploaderContext = createContext(); export const REMOVE_ATTACHMENT_EVENT = "ATTACHMENT_REMOVE"; @@ -33,8 +34,14 @@ export function DnDFileUploaderProvider({ const [files, setFiles] = useState([]); const [ready, setReady] = useState(false); const [dragging, setDragging] = useState(false); + const [showWarningModal, setShowWarningModal] = useState(false); + const [pendingFiles, setPendingFiles] = useState([]); + const [tokenCount, setTokenCount] = useState(0); const { user } = useUser(); + const contextWindow = 8000; // TODO: Get from workspace or system settings + const maxTokens = Math.floor(contextWindow * 0.8); + useEffect(() => { System.checkDocumentProcessorOnline().then((status) => setReady(status)); }, [user]); @@ -208,35 +215,25 @@ export function DnDFileUploaderProvider({ } // Check if any file exceeds context window - const tokenCount = data.files.reduce( + const newTokenCount = data.files.reduce( (sum, file) => sum + JSON.parse(file.metadata).token_count_estimate, 0 ); - const contextWindow = 8000; // TODO: Get from workspace or system settings - const maxTokens = Math.floor(contextWindow * 0.8); - if (tokenCount > maxTokens) { - // Show alert dialog - const choice = window.confirm( - `This document exceeds 80% of your context window (${tokenCount} > ${maxTokens} tokens). Would you like to embed it anyway?` - ); - if (!choice) { - // Delete parsed file and remove from UI - await Workspace.deleteParsedFile( - workspace.slug, - data.files[0].id - ); - setFiles((prev) => - prev.filter((prevFile) => prevFile.uid !== attachment.uid) - ); - return; - } + if (newTokenCount > maxTokens) { + setTokenCount((prev) => prev + newTokenCount); + setPendingFiles((prev) => [...prev, { + attachment, + parsedFileId: data.files[0].id, + }]); + setShowWarningModal(true); + return; } - // User chose to continue or file is within limits - // We already have the file parsed, just need to move and embed it + // File is within limits, proceed with embedding + // TODO: to be replaced with using same logic as pinning documents const embedResult = await Workspace.embedParsedFile( workspace.slug, data.files[0].id @@ -269,10 +266,96 @@ export function DnDFileUploaderProvider({ ); } + // Handle modal actions + const handleCloseModal = async () => { + if (!pendingFiles.length) return; + // Delete all parsed files and remove them from UI + await Promise.all( + pendingFiles.map(file => Workspace.deleteParsedFile(workspace.slug, file.parsedFileId)) + ); + setFiles((prev) => prev.filter(prevFile => + !pendingFiles.some(file => file.attachment.uid === prevFile.uid) + )); + setShowWarningModal(false); + setPendingFiles([]); + setTokenCount(0); + }; + + const handleContinueAnyway = async () => { + // TODO: to be replaced with using same logic as pinning documents + if (!pendingFiles.length) return; + // Embed all pending files + const results = await Promise.all( + pendingFiles.map(file => + Workspace.embedParsedFile(workspace.slug, file.parsedFileId) + ) + ); + + // Update status for all files + const fileUpdates = pendingFiles.map((file, i) => ({ + uid: file.attachment.uid, + updates: { + status: results[i].response.ok ? "success" : "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); + }; + + + const handleEmbed = async () => { + if (!pendingFiles.length) return; + // Embed all pending files + const results = await Promise.all( + pendingFiles.map(file => + Workspace.embedParsedFile(workspace.slug, file.parsedFileId) + ) + ); + + // Update status for all files + const fileUpdates = pendingFiles.map((file, i) => ({ + uid: file.attachment.uid, + updates: { + status: results[i].response.ok ? "success" : "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); + }; + return ( + {children} ); @@ -303,7 +386,7 @@ export default function DnDFileUploaderWrapper({ children }) { >
- + Drag and drop icon

Add {canUploadAll ? "anything" : "an image"}

From 38706395b7ed650d0438fc32fb776f7826533c48 Mon Sep 17 00:00:00 2001 From: shatfield4 Date: Fri, 1 Aug 2025 17:28:09 -0700 Subject: [PATCH 05/19] lint --- .../DnDWrapper/FileUploadWarningModal.jsx | 11 ++--- .../ChatContainer/DnDWrapper/index.jsx | 45 ++++++++++++------- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/FileUploadWarningModal.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/FileUploadWarningModal.jsx index 9d30fac564c..7340f50a87a 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/FileUploadWarningModal.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/FileUploadWarningModal.jsx @@ -18,7 +18,8 @@ export default function FileUploadWarningModal({

- {fileCount === 1 ? 'File' : 'Files'} exceed{fileCount === 1 ? 's' : ''} context window + {fileCount === 1 ? "File" : "Files"} exceed + {fileCount === 1 ? "s" : ""} context window

@@ -57,10 +58,10 @@ export default function FileUploadWarningModal({ type="button" className="transition-all duration-300 bg-white text-black hover:opacity-60 px-4 py-2 rounded-lg text-sm" > - Embed {fileCount === 1 ? 'File' : 'Files'} + Embed {fileCount === 1 ? "File" : "Files"}
); -} \ No newline at end of file +} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx index 37607e48371..66f6c0b8378 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx @@ -221,13 +221,15 @@ export function DnDFileUploaderProvider({ 0 ); - if (newTokenCount > maxTokens) { setTokenCount((prev) => prev + newTokenCount); - setPendingFiles((prev) => [...prev, { - attachment, - parsedFileId: data.files[0].id, - }]); + setPendingFiles((prev) => [ + ...prev, + { + attachment, + parsedFileId: data.files[0].id, + }, + ]); setShowWarningModal(true); return; } @@ -271,11 +273,16 @@ export function DnDFileUploaderProvider({ if (!pendingFiles.length) return; // Delete all parsed files and remove them from UI await Promise.all( - pendingFiles.map(file => Workspace.deleteParsedFile(workspace.slug, file.parsedFileId)) + pendingFiles.map((file) => + Workspace.deleteParsedFile(workspace.slug, file.parsedFileId) + ) + ); + setFiles((prev) => + prev.filter( + (prevFile) => + !pendingFiles.some((file) => file.attachment.uid === prevFile.uid) + ) ); - setFiles((prev) => prev.filter(prevFile => - !pendingFiles.some(file => file.attachment.uid === prevFile.uid) - )); setShowWarningModal(false); setPendingFiles([]); setTokenCount(0); @@ -286,7 +293,7 @@ export function DnDFileUploaderProvider({ if (!pendingFiles.length) return; // Embed all pending files const results = await Promise.all( - pendingFiles.map(file => + pendingFiles.map((file) => Workspace.embedParsedFile(workspace.slug, file.parsedFileId) ) ); @@ -298,12 +305,12 @@ export function DnDFileUploaderProvider({ status: results[i].response.ok ? "success" : "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); + const update = fileUpdates.find((f) => f.uid === prevFile.uid); return update ? { ...prevFile, ...update.updates } : prevFile; }) ); @@ -312,12 +319,11 @@ export function DnDFileUploaderProvider({ setTokenCount(0); }; - const handleEmbed = async () => { if (!pendingFiles.length) return; // Embed all pending files const results = await Promise.all( - pendingFiles.map(file => + pendingFiles.map((file) => Workspace.embedParsedFile(workspace.slug, file.parsedFileId) ) ); @@ -329,12 +335,12 @@ export function DnDFileUploaderProvider({ status: results[i].response.ok ? "success" : "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); + const update = fileUpdates.find((f) => f.uid === prevFile.uid); return update ? { ...prevFile, ...update.updates } : prevFile; }) ); @@ -386,7 +392,12 @@ export default function DnDFileUploaderWrapper({ children }) { >
- Drag and drop icon + Drag and drop icon

Add {canUploadAll ? "anything" : "an image"}

From dd03431f2d71d73e74cd8011d82dc684f4d25f5f Mon Sep 17 00:00:00 2001 From: shatfield4 Date: Mon, 4 Aug 2025 18:14:51 -0700 Subject: [PATCH 06/19] dynamic fetching of provider/model combo + inject parsed documents --- .../ChatContainer/DnDWrapper/index.jsx | 45 ++++++++----------- server/models/workspace.js | 28 +++++++++++- server/models/workspaceParsedFiles.js | 34 ++++++++++++++ server/utils/chats/apiChatHandler.js | 33 ++++++++++++++ server/utils/chats/stream.js | 17 +++++++ 5 files changed, 130 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx index 66f6c0b8378..f96adc3cded 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx @@ -39,7 +39,7 @@ export function DnDFileUploaderProvider({ const [tokenCount, setTokenCount] = useState(0); const { user } = useUser(); - const contextWindow = 8000; // TODO: Get from workspace or system settings + const contextWindow = workspace?.contextWindow || 8000; const maxTokens = Math.floor(contextWindow * 0.8); useEffect(() => { @@ -214,12 +214,10 @@ export function DnDFileUploaderProvider({ return; } - // Check if any file exceeds context window - const newTokenCount = data.files.reduce( - (sum, file) => - sum + JSON.parse(file.metadata).token_count_estimate, - 0 - ); + // Check if files exceed context window + const newTokenCount = data.files.reduce((sum, file) => { + return sum + (file.tokenCountEstimate || 0); + }, 0); if (newTokenCount > maxTokens) { setTokenCount((prev) => prev + newTokenCount); @@ -234,16 +232,12 @@ export function DnDFileUploaderProvider({ return; } - // File is within limits, proceed with embedding - // TODO: to be replaced with using same logic as pinning documents - const embedResult = await Workspace.embedParsedFile( - workspace.slug, - data.files[0].id - ); + // File is within limits, keep in parsed files + const result = { success: true, document: data.files[0] }; const updates = { - status: embedResult.response.ok ? "success" : "failed", - error: embedResult.data?.error ?? null, - document: embedResult.data?.document, + status: result.success ? "success" : "failed", + error: result.error ?? null, + document: result.document, }; setFiles((prev) => @@ -289,22 +283,21 @@ export function DnDFileUploaderProvider({ }; const handleContinueAnyway = async () => { - // TODO: to be replaced with using same logic as pinning documents if (!pendingFiles.length) return; - // Embed all pending files - const results = await Promise.all( - pendingFiles.map((file) => - Workspace.embedParsedFile(workspace.slug, file.parsedFileId) - ) - ); + // Use direct upload with pinning for all pending files + // Files are already in WorkspaceParsedFiles, just return success + const results = pendingFiles.map((file) => ({ + success: true, + document: { id: file.parsedFileId }, + })); // Update status for all files const fileUpdates = pendingFiles.map((file, i) => ({ uid: file.attachment.uid, updates: { - status: results[i].response.ok ? "success" : "failed", - error: results[i].data?.error ?? null, - document: results[i].data?.document, + status: results[i].success ? "success" : "failed", + error: results[i].error ?? null, + document: results[i].document, }, })); diff --git a/server/models/workspace.js b/server/models/workspace.js index c195b176143..01463b41822 100644 --- a/server/models/workspace.js +++ b/server/models/workspace.js @@ -283,6 +283,7 @@ const Workspace = { return { ...workspace, documents: await Document.forWorkspace(workspace.id), + contextWindow: this._getContextWindow(workspace), }; } catch (error) { console.error(error.message); @@ -290,6 +291,26 @@ const Workspace = { } }, + /** + * Get the context window size for a workspace based on its provider and model settings. + * If the workspace has no provider/model set, falls back to system defaults. + * @param {Workspace} workspace - The workspace to get context window for + * @returns {number} The context window size in tokens (defaults to 8000 if no provider/model found) + * @private + */ + _getContextWindow: function (workspace) { + const { + getLLMProviderClass, + getBaseLLMProviderModel, + } = require("../utils/helpers"); + const provider = + workspace.chatProvider || process.env.LLM_PROVIDER || "openai"; + const LLMProvider = getLLMProviderClass({ provider }); + const model = + workspace.chatModel || getBaseLLMProviderModel({ provider }) || "gpt-4"; + return LLMProvider?.promptWindowLimit?.(model) || 8000; + }, + get: async function (clause = {}) { try { const workspace = await prisma.workspaces.findFirst({ @@ -299,7 +320,12 @@ const Workspace = { }, }); - return workspace || null; + if (!workspace) return null; + + return { + ...workspace, + contextWindow: this._getContextWindow(workspace), + }; } catch (error) { console.error(error.message); return null; diff --git a/server/models/workspaceParsedFiles.js b/server/models/workspaceParsedFiles.js index 52121c7bba4..45daaf79b3b 100644 --- a/server/models/workspaceParsedFiles.js +++ b/server/models/workspaceParsedFiles.js @@ -137,6 +137,40 @@ const WorkspaceParsedFiles = { await this.delete({ id: parseInt(fileId) }); } }, + + getContextFiles: async function (workspaceId, threadId = null) { + try { + const files = await this.where({ + workspaceId: parseInt(workspaceId), + threadId: threadId ? parseInt(threadId) : null, + }); + + const results = []; + for (const file of files) { + const metadata = safeJsonParse(file.metadata, {}); + const location = metadata.location; + if (!location) continue; + + const sourceFile = path.join(directUploadsPath, location.split("/")[1]); + if (!fs.existsSync(sourceFile)) continue; + + const content = fs.readFileSync(sourceFile, "utf-8"); + const data = safeJsonParse(content, null); + if (!data?.pageContent) continue; + + results.push({ + pageContent: data.pageContent, + token_count_estimate: file.tokenCountEstimate, + ...metadata, + }); + } + + return results; + } catch (error) { + console.error("Failed to get context files:", error); + return []; + } + }, }; module.exports = { WorkspaceParsedFiles }; diff --git a/server/utils/chats/apiChatHandler.js b/server/utils/chats/apiChatHandler.js index 43a87d60c14..09569311c65 100644 --- a/server/utils/chats/apiChatHandler.js +++ b/server/utils/chats/apiChatHandler.js @@ -1,6 +1,7 @@ const { v4: uuidv4 } = require("uuid"); const { DocumentManager } = require("../DocumentManager"); const { WorkspaceChats } = require("../../models/workspaceChats"); +const { WorkspaceParsedFiles } = require("../../models/workspaceParsedFiles"); const { getVectorDbClass, getLLMProvider } = require("../helpers"); const { writeResponseChunk } = require("../helpers/chat/responses"); const { @@ -208,6 +209,22 @@ async function chatSync({ }); }); + // Inject any parsed files for this workspace/thread/user + const parsedFiles = await WorkspaceParsedFiles.getContextFiles( + workspace.id, + thread?.id || null, + user?.id || null + ); + parsedFiles.forEach((doc) => { + const { pageContent, ...metadata } = doc; + contextTexts.push(doc.pageContent); + sources.push({ + text: + pageContent.slice(0, 1_000) + "...continued on in source document...", + ...metadata, + }); + }); + const vectorSearchResults = embeddingsCount !== 0 ? await VectorDb.performSimilaritySearch({ @@ -544,6 +561,22 @@ async function streamChat({ }); }); + // Inject any parsed files for this workspace/thread/user + const parsedFiles = await WorkspaceParsedFiles.getContextFiles( + workspace.id, + thread?.id || null, + user?.id || null + ); + parsedFiles.forEach((doc) => { + const { pageContent, ...metadata } = doc; + contextTexts.push(doc.pageContent); + sources.push({ + text: + pageContent.slice(0, 1_000) + "...continued on in source document...", + ...metadata, + }); + }); + const vectorSearchResults = embeddingsCount !== 0 ? await VectorDb.performSimilaritySearch({ diff --git a/server/utils/chats/stream.js b/server/utils/chats/stream.js index 0ecf86c8141..597b95b825f 100644 --- a/server/utils/chats/stream.js +++ b/server/utils/chats/stream.js @@ -1,6 +1,7 @@ const { v4: uuidv4 } = require("uuid"); const { DocumentManager } = require("../DocumentManager"); const { WorkspaceChats } = require("../../models/workspaceChats"); +const { WorkspaceParsedFiles } = require("../../models/workspaceParsedFiles"); const { getVectorDbClass, getLLMProvider } = require("../helpers"); const { writeResponseChunk } = require("../helpers/chat/responses"); const { grepAgents } = require("./agents"); @@ -130,6 +131,22 @@ async function streamChatWithWorkspace( }); }); + // Inject any parsed files for this workspace/thread/user + const parsedFiles = await WorkspaceParsedFiles.getContextFiles( + workspace.id, + thread?.id || null, + user?.id || null + ); + parsedFiles.forEach((doc) => { + const { pageContent, ...metadata } = doc; + contextTexts.push(doc.pageContent); + sources.push({ + text: + pageContent.slice(0, 1_000) + "...continued on in source document...", + ...metadata, + }); + }); + const vectorSearchResults = embeddingsCount !== 0 ? await VectorDb.performSimilaritySearch({ From 0323aa47bcaa941bba3013c4ac044df343f8cd1d Mon Sep 17 00:00:00 2001 From: shatfield4 Date: Mon, 4 Aug 2025 18:17:21 -0700 Subject: [PATCH 07/19] remove unneeded comments --- .../WorkspaceChat/ChatContainer/DnDWrapper/index.jsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx index f96adc3cded..c9d3711a41b 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx @@ -284,14 +284,11 @@ export function DnDFileUploaderProvider({ const handleContinueAnyway = async () => { if (!pendingFiles.length) return; - // Use direct upload with pinning for all pending files - // Files are already in WorkspaceParsedFiles, just return success const results = pendingFiles.map((file) => ({ success: true, document: { id: file.parsedFileId }, })); - // Update status for all files const fileUpdates = pendingFiles.map((file, i) => ({ uid: file.attachment.uid, updates: { From b3f990a44da29a33158cf07b5e483e243370a378 Mon Sep 17 00:00:00 2001 From: shatfield4 Date: Tue, 5 Aug 2025 17:33:34 -0700 Subject: [PATCH 08/19] popup ui for attaching/removing files + warning to embed + wip fetching states on update --- .../ChatContainer/DnDWrapper/index.jsx | 4 +- .../AttachItem/ParsedFilesMenu/index.jsx | 134 ++++++++++++++++++ .../PromptInput/AttachItem/index.jsx | 32 ++++- .../ChatContainer/PromptInput/index.jsx | 3 +- .../WorkspaceChat/ChatContainer/index.jsx | 1 + frontend/src/models/workspace.js | 11 ++ server/endpoints/workspaces.js | 49 +++++-- server/models/workspace.js | 31 +++- server/models/workspaceParsedFiles.js | 25 ++++ server/utils/chats/apiChatHandler.js | 32 ----- 10 files changed, 270 insertions(+), 52 deletions(-) create mode 100644 frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx index c9d3711a41b..20bc8e885a0 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx @@ -39,7 +39,9 @@ export function DnDFileUploaderProvider({ const [tokenCount, setTokenCount] = useState(0); const { user } = useUser(); - const contextWindow = workspace?.contextWindow || 8000; + // const contextWindow = workspace?.contextWindow || Number.POSITIVE_INFINITY; + const contextWindow = 8000; + console.log("workspace", workspace); const maxTokens = Math.floor(contextWindow * 0.8); useEffect(() => { 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..11db72bf3f7 --- /dev/null +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx @@ -0,0 +1,134 @@ +import { useEffect, 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"; + +export default function ParsedFilesMenu({ workspace, onEmbeddingChange }) { + const { threadSlug = null } = useParams(); + const [files, setFiles] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isEmbedding, setIsEmbedding] = useState(false); + const [embedProgress, setEmbedProgress] = useState(1); + + const contextWindow = workspace?.contextWindow || Infinity; + const currentTokens = workspace?.currentContextTokenCount || 0; + const isOverflowing = currentTokens >= contextWindow * 0.8; + + useEffect(() => { + async function fetchFiles() { + if (!workspace?.slug) return; + const parsedFiles = await Workspace.getParsedFiles( + workspace.slug, + threadSlug + ); + setFiles(parsedFiles); + setIsLoading(false); + } + fetchFiles(); + }, [workspace, threadSlug]); + + async function handleRemove(file) { + if (!file?.id) return; + await Workspace.deleteParsedFile(workspace.slug, file.id); + setFiles((prev) => prev.filter((f) => f.id !== file.id)); + } + + 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(workspace.slug, file.id).then(() => { + completed++; + setEmbedProgress(completed + 1); + }) + ) + ); + setFiles([]); + } catch (error) { + console.error("Failed to embed files:", error); + } + setIsEmbedding(false); + onEmbeddingChange?.(false); + setEmbedProgress(1); + } + + return ( +
+
+
+ Current Context +
+
+ {nFormatter(currentTokens)} / {nFormatter(contextWindow)} tokens +
+
+ {isOverflowing && ( +
+
+ +
+ 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. +
+
+ +
+ )} +
+ {files.map((file, i) => ( +
+
+ {file.title} +
+ +
+ ))} + {isLoading && ( +
+ + Loading... +
+ )} + {!isLoading && files.length === 0 && ( +
+ No files found +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/index.jsx index 81a402d2c82..0c00e526b51 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/index.jsx @@ -2,22 +2,28 @@ import useUser from "@/hooks/useUser"; import { PaperclipHorizontal } from "@phosphor-icons/react"; import { Tooltip } from "react-tooltip"; import { useTranslation } from "react-i18next"; +import { useRef, useState } from "react"; +import { useTheme } from "@/hooks/useTheme"; +import ParsedFilesMenu from "./ParsedFilesMenu"; /** * This is a simple proxy component that clicks on the DnD file uploader for the user. * @returns */ -export default function AttachItem() { +export default function AttachItem({ workspace }) { const { t } = useTranslation(); const { user } = useUser(); + const tooltipRef = useRef(null); + const { theme } = useTheme(); + const [isEmbedding, setIsEmbedding] = useState(false); + if (!!user && user.role === "default") return null; return ( <> + delayHide={isEmbedding ? null : 800} + arrowColor={ + theme === "light" + ? "var(--theme-modal-border)" + : "var(--theme-bg-primary)" + } + className="z-99 !w-[400px] !bg-theme-bg-primary !px-[5px] !rounded-lg !pointer-events-auto light:border-2 light:border-theme-modal-border" + > + + ); } diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx index 2a716aad8c9..68ee6d5b2a3 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx @@ -35,6 +35,7 @@ export default function PromptInput({ isStreaming, sendCommand, attachments = [], + workspace, }) { const { t } = useTranslation(); const { isDisabled } = useIsDisabled(); @@ -318,7 +319,7 @@ export default function PromptInput({
- + diff --git a/frontend/src/models/workspace.js b/frontend/src/models/workspace.js index 5bdbbe552d0..c83f56a0f9e 100644 --- a/frontend/src/models/workspace.js +++ b/frontend/src/models/workspace.js @@ -260,6 +260,17 @@ const Workspace = { const data = await response.json(); return { response, data }; }, + + getParsedFiles: async function (slug, threadSlug = null) { + const response = await fetch(`${API_BASE}/workspace/${slug}/parsed-files`, { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify({ threadSlug }), + }); + + const data = await response.json(); + return data.files || []; + }, uploadLink: async function (slug, link) { const response = await fetch(`${API_BASE}/workspace/${slug}/upload-link`, { method: "POST", diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js index 20015cd5e7e..2d5c06eaf84 100644 --- a/server/endpoints/workspaces.js +++ b/server/endpoints/workspaces.js @@ -44,6 +44,41 @@ function workspaceEndpoints(app) { const responseCache = new Map(); + app.post( + "/workspace/:slug/parsed-files", + [validatedRequest], + async (request, response) => { + try { + const { slug = null } = request.params; + const { threadSlug = null } = reqBody(request); + const user = await userFromSession(request, response); + + const workspace = multiUserMode(response) + ? await Workspace.getWithUser(user, { slug }) + : await Workspace.get({ slug }); + + if (!workspace) { + response.sendStatus(400).end(); + return; + } + + const thread = threadSlug + ? await WorkspaceThread.get({ slug: threadSlug }) + : null; + + const files = await WorkspaceParsedFiles.getContextMetadata( + workspace.id, + thread?.id || null + ); + + response.status(200).json({ files }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + app.post( "/workspace/new", [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], @@ -968,13 +1003,11 @@ function workspaceEndpoints(app) { // Get threadId we are branching from if that request body is sent // and is a valid thread slug. - const threadId = !!threadSlug - ? ( - await WorkspaceThread.get({ - slug: String(threadSlug), - workspace_id: workspace.id, - }) - )?.id ?? null + const threadId = threadSlug + ? await WorkspaceThread.get({ + slug: String(threadSlug), + workspace_id: workspace.id, + }).then((thread) => thread?.id || null) : null; const chatsToFork = await WorkspaceChats.where( { @@ -1009,7 +1042,7 @@ function workspaceEndpoints(app) { }); await WorkspaceChats.bulkCreate(chatsData); await WorkspaceThread.update(newThread, { - name: !!lastMessageText + name: lastMessageText ? truncate(lastMessageText, 22) : "Forked Thread", }); diff --git a/server/models/workspace.js b/server/models/workspace.js index 01463b41822..0ddd1fbb77c 100644 --- a/server/models/workspace.js +++ b/server/models/workspace.js @@ -284,6 +284,9 @@ const Workspace = { ...workspace, documents: await Document.forWorkspace(workspace.id), contextWindow: this._getContextWindow(workspace), + currentContextTokenCount: await this._getCurrentContextTokenCount( + workspace.id + ), }; } catch (error) { console.error(error.message); @@ -291,6 +294,22 @@ const Workspace = { } }, + /** + * Get the total token count of all parsed files in a workspace/thread + * @param {number} workspaceId - The ID of the workspace + * @param {number|null} threadId - Optional thread ID to filter by + * @returns {Promise} Total token count of all files + * @private + */ + async _getCurrentContextTokenCount(workspaceId, threadId = null) { + const { WorkspaceParsedFiles } = require("./workspaceParsedFiles"); + const files = await WorkspaceParsedFiles.where({ + workspaceId: Number(workspaceId), + threadId: threadId ? Number(threadId) : null, + }); + return files.reduce((sum, file) => sum + (file.tokenCountEstimate || 0), 0); + }, + /** * Get the context window size for a workspace based on its provider and model settings. * If the workspace has no provider/model set, falls back to system defaults. @@ -303,12 +322,13 @@ const Workspace = { getLLMProviderClass, getBaseLLMProviderModel, } = require("../utils/helpers"); - const provider = - workspace.chatProvider || process.env.LLM_PROVIDER || "openai"; + const provider = workspace.chatProvider || process.env.LLM_PROVIDER || null; const LLMProvider = getLLMProviderClass({ provider }); const model = - workspace.chatModel || getBaseLLMProviderModel({ provider }) || "gpt-4"; - return LLMProvider?.promptWindowLimit?.(model) || 8000; + workspace.chatModel || getBaseLLMProviderModel({ provider }) || null; + + if (!provider || !model) return Number.POSITIVE_INFINITY; + return LLMProvider?.promptWindowLimit?.(model) || Number.POSITIVE_INFINITY; }, get: async function (clause = {}) { @@ -325,6 +345,9 @@ const Workspace = { return { ...workspace, contextWindow: this._getContextWindow(workspace), + currentContextTokenCount: await this._getCurrentContextTokenCount( + workspace.id + ), }; } catch (error) { console.error(error.message); diff --git a/server/models/workspaceParsedFiles.js b/server/models/workspaceParsedFiles.js index 45daaf79b3b..b7739cb5ce1 100644 --- a/server/models/workspaceParsedFiles.js +++ b/server/models/workspaceParsedFiles.js @@ -138,6 +138,31 @@ const WorkspaceParsedFiles = { } }, + getContextMetadata: async function (workspaceId, threadId = null) { + try { + const files = await this.where({ + workspaceId: parseInt(workspaceId), + threadId: threadId ? parseInt(threadId) : null, + }); + + const results = []; + for (const file of files) { + const metadata = safeJsonParse(file.metadata, {}); + results.push({ + id: file.id, + title: metadata.title || metadata.location, + location: metadata.location, + token_count_estimate: file.tokenCountEstimate, + }); + } + + return results; + } catch (error) { + console.error("Failed to get context metadata:", error); + return []; + } + }, + getContextFiles: async function (workspaceId, threadId = null) { try { const files = await this.where({ diff --git a/server/utils/chats/apiChatHandler.js b/server/utils/chats/apiChatHandler.js index 09569311c65..122674c7140 100644 --- a/server/utils/chats/apiChatHandler.js +++ b/server/utils/chats/apiChatHandler.js @@ -209,22 +209,6 @@ async function chatSync({ }); }); - // Inject any parsed files for this workspace/thread/user - const parsedFiles = await WorkspaceParsedFiles.getContextFiles( - workspace.id, - thread?.id || null, - user?.id || null - ); - parsedFiles.forEach((doc) => { - const { pageContent, ...metadata } = doc; - contextTexts.push(doc.pageContent); - sources.push({ - text: - pageContent.slice(0, 1_000) + "...continued on in source document...", - ...metadata, - }); - }); - const vectorSearchResults = embeddingsCount !== 0 ? await VectorDb.performSimilaritySearch({ @@ -561,22 +545,6 @@ async function streamChat({ }); }); - // Inject any parsed files for this workspace/thread/user - const parsedFiles = await WorkspaceParsedFiles.getContextFiles( - workspace.id, - thread?.id || null, - user?.id || null - ); - parsedFiles.forEach((doc) => { - const { pageContent, ...metadata } = doc; - contextTexts.push(doc.pageContent); - sources.push({ - text: - pageContent.slice(0, 1_000) + "...continued on in source document...", - ...metadata, - }); - }); - const vectorSearchResults = embeddingsCount !== 0 ? await VectorDb.performSimilaritySearch({ From 163f11664a569eebb94cac8bdb520091a9f59d68 Mon Sep 17 00:00:00 2001 From: shatfield4 Date: Wed, 6 Aug 2025 13:09:32 -0700 Subject: [PATCH 09/19] remove prop drilling, fetch files/limits directly in attach files popup --- .../AttachItem/ParsedFilesMenu/index.jsx | 14 +++++----- .../PromptInput/AttachItem/index.jsx | 7 ++--- .../ChatContainer/PromptInput/index.jsx | 3 +-- .../WorkspaceChat/ChatContainer/index.jsx | 1 - server/endpoints/workspaces.js | 27 +++++++++++-------- server/models/workspaceParsedFiles.js | 19 ++++++++++--- 6 files changed, 42 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx index 11db72bf3f7..a90d5b19a1b 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx @@ -10,19 +10,19 @@ export default function ParsedFilesMenu({ workspace, onEmbeddingChange }) { const [isLoading, setIsLoading] = useState(true); const [isEmbedding, setIsEmbedding] = useState(false); const [embedProgress, setEmbedProgress] = useState(1); + const [contextWindow, setContextWindow] = useState(Infinity); + const [currentTokens, setCurrentTokens] = useState(0); - const contextWindow = workspace?.contextWindow || Infinity; - const currentTokens = workspace?.currentContextTokenCount || 0; const isOverflowing = currentTokens >= contextWindow * 0.8; useEffect(() => { async function fetchFiles() { if (!workspace?.slug) return; - const parsedFiles = await Workspace.getParsedFiles( - workspace.slug, - threadSlug - ); - setFiles(parsedFiles); + const { files, contextWindow, currentContextTokenCount } = + await Workspace.getParsedFiles(workspace.slug, threadSlug); + setFiles(files); + setContextWindow(contextWindow); + setCurrentTokens(currentContextTokenCount); setIsLoading(false); } fetchFiles(); diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/index.jsx index 0c00e526b51..5befc498cad 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/index.jsx @@ -10,7 +10,7 @@ import ParsedFilesMenu from "./ParsedFilesMenu"; * This is a simple proxy component that clicks on the DnD file uploader for the user. * @returns */ -export default function AttachItem({ workspace }) { +export default function AttachItem() { const { t } = useTranslation(); const { user } = useUser(); const tooltipRef = useRef(null); @@ -53,10 +53,7 @@ export default function AttachItem({ workspace }) { } className="z-99 !w-[400px] !bg-theme-bg-primary !px-[5px] !rounded-lg !pointer-events-auto light:border-2 light:border-theme-modal-border" > - + ); diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx index 68ee6d5b2a3..2a716aad8c9 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx @@ -35,7 +35,6 @@ export default function PromptInput({ isStreaming, sendCommand, attachments = [], - workspace, }) { const { t } = useTranslation(); const { isDisabled } = useIsDisabled(); @@ -319,7 +318,7 @@ export default function PromptInput({
- + diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js index 2d5c06eaf84..7ca0cc67da5 100644 --- a/server/endpoints/workspaces.js +++ b/server/endpoints/workspaces.js @@ -66,12 +66,15 @@ function workspaceEndpoints(app) { ? await WorkspaceThread.get({ slug: threadSlug }) : null; - const files = await WorkspaceParsedFiles.getContextMetadata( - workspace.id, - thread?.id || null - ); + const { files, contextWindow, currentContextTokenCount } = + await WorkspaceParsedFiles.getContextMetadataAndLimits( + workspace.id, + thread?.id || null + ); - response.status(200).json({ files }); + response + .status(200) + .json({ files, contextWindow, currentContextTokenCount }); } catch (e) { console.error(e.message, e); response.sendStatus(500).end(); @@ -1003,11 +1006,13 @@ function workspaceEndpoints(app) { // Get threadId we are branching from if that request body is sent // and is a valid thread slug. - const threadId = threadSlug - ? await WorkspaceThread.get({ - slug: String(threadSlug), - workspace_id: workspace.id, - }).then((thread) => thread?.id || null) + const threadId = !!threadSlug + ? ( + await WorkspaceThread.get({ + slug: String(threadSlug), + workspace_id: workspace.id, + }) + )?.id ?? null : null; const chatsToFork = await WorkspaceChats.where( { @@ -1042,7 +1047,7 @@ function workspaceEndpoints(app) { }); await WorkspaceChats.bulkCreate(chatsData); await WorkspaceThread.update(newThread, { - name: lastMessageText + name: !!lastMessageText ? truncate(lastMessageText, 22) : "Forked Thread", }); diff --git a/server/models/workspaceParsedFiles.js b/server/models/workspaceParsedFiles.js index b7739cb5ce1..888489c9e74 100644 --- a/server/models/workspaceParsedFiles.js +++ b/server/models/workspaceParsedFiles.js @@ -138,16 +138,21 @@ const WorkspaceParsedFiles = { } }, - getContextMetadata: async function (workspaceId, threadId = null) { + getContextMetadataAndLimits: async function (workspaceId, threadId = null) { try { const files = await this.where({ workspaceId: parseInt(workspaceId), threadId: threadId ? parseInt(threadId) : null, }); + const workspace = await Workspace.get({ id: parseInt(workspaceId) }); + if (!workspace) throw new Error("Workspace not found"); + const results = []; + let totalTokens = 0; for (const file of files) { const metadata = safeJsonParse(file.metadata, {}); + totalTokens += file.tokenCountEstimate || 0; results.push({ id: file.id, title: metadata.title || metadata.location, @@ -156,10 +161,18 @@ const WorkspaceParsedFiles = { }); } - return results; + return { + files: results, + contextWindow: workspace.contextWindow, + currentContextTokenCount: totalTokens, + }; } catch (error) { console.error("Failed to get context metadata:", error); - return []; + return { + files: [], + contextWindow: Infinity, + currentContextTokenCount: 0, + }; } }, From 7917134b308371233cb1098167ec958a3e2e06e6 Mon Sep 17 00:00:00 2001 From: shatfield4 Date: Wed, 6 Aug 2025 18:54:35 -0700 Subject: [PATCH 10/19] rework ux of FE + BE optimizations --- .../index.jsx} | 12 +- .../ChatContainer/DnDWrapper/index.jsx | 32 +++--- .../AttachItem/ParsedFilesMenu/index.jsx | 103 +++++++++++------- .../PromptInput/AttachItem/index.jsx | 57 ++++++++-- .../PromptInput/Attachments/index.jsx | 6 +- frontend/src/models/workspace.js | 8 +- server/endpoints/workspaces.js | 9 +- 7 files changed, 150 insertions(+), 77 deletions(-) rename frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/{FileUploadWarningModal.jsx => FileUploadWarningModal/index.jsx} (83%) diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/FileUploadWarningModal.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/FileUploadWarningModal/index.jsx similarity index 83% rename from frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/FileUploadWarningModal.jsx rename to frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/FileUploadWarningModal/index.jsx index 7340f50a87a..02023474791 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/FileUploadWarningModal.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/FileUploadWarningModal/index.jsx @@ -1,5 +1,6 @@ import { X } from "@phosphor-icons/react"; import ModalWrapper from "@/components/ModalWrapper"; +import pluralize from "pluralize"; export default function FileUploadWarningModal({ show, @@ -18,8 +19,7 @@ export default function FileUploadWarningModal({

- {fileCount === 1 ? "File" : "Files"} exceed - {fileCount === 1 ? "s" : ""} context window + Context Window Warning

@@ -58,7 +60,7 @@ export default function FileUploadWarningModal({ type="button" className="transition-all duration-300 bg-white text-black hover:opacity-60 px-4 py-2 rounded-lg text-sm" > - Embed {fileCount === 1 ? "File" : "Files"} + Embed {pluralize("File", fileCount)}
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx index 20bc8e885a0..f8b71361df5 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx @@ -20,7 +20,7 @@ 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. @@ -39,9 +39,7 @@ export function DnDFileUploaderProvider({ const [tokenCount, setTokenCount] = useState(0); const { user } = useUser(); - // const contextWindow = workspace?.contextWindow || Number.POSITIVE_INFINITY; - const contextWindow = 8000; - console.log("workspace", workspace); + const contextWindow = workspace?.contextWindow || Number.POSITIVE_INFINITY; const maxTokens = Math.floor(contextWindow * 0.8); useEffect(() => { @@ -187,6 +185,13 @@ export function DnDFileUploaderProvider({ window.dispatchEvent(new CustomEvent(ATTACHMENTS_PROCESSING_EVENT)); const promises = []; + // Get current token count first + const { currentContextTokenCount } = await Workspace.getParsedFiles( + workspace.slug, + threadSlug + ); + let totalTokenCount = currentContextTokenCount; + for (const attachment of newAttachments) { // Images/attachments are chat specific. if (attachment.type === "attachment") continue; @@ -220,9 +225,10 @@ export function DnDFileUploaderProvider({ const newTokenCount = data.files.reduce((sum, file) => { return sum + (file.tokenCountEstimate || 0); }, 0); + totalTokenCount += newTokenCount; - if (newTokenCount > maxTokens) { - setTokenCount((prev) => prev + newTokenCount); + if (totalTokenCount > maxTokens) { + setTokenCount(totalTokenCount); setPendingFiles((prev) => [ ...prev, { @@ -237,7 +243,7 @@ export function DnDFileUploaderProvider({ // File is within limits, keep in parsed files const result = { success: true, document: data.files[0] }; const updates = { - status: result.success ? "success" : "failed", + status: result.success ? "added_context" : "failed", error: result.error ?? null, document: result.document, }; @@ -267,11 +273,9 @@ export function DnDFileUploaderProvider({ // Handle modal actions const handleCloseModal = async () => { if (!pendingFiles.length) return; - // Delete all parsed files and remove them from UI - await Promise.all( - pendingFiles.map((file) => - Workspace.deleteParsedFile(workspace.slug, file.parsedFileId) - ) + await Workspace.deleteParsedFiles( + workspace.slug, + pendingFiles.map((file) => file.parsedFileId) ); setFiles((prev) => prev.filter( @@ -282,6 +286,7 @@ export function DnDFileUploaderProvider({ setShowWarningModal(false); setPendingFiles([]); setTokenCount(0); + window.dispatchEvent(new CustomEvent(ATTACHMENTS_PROCESSED_EVENT)); }; const handleContinueAnyway = async () => { @@ -324,7 +329,7 @@ export function DnDFileUploaderProvider({ const fileUpdates = pendingFiles.map((file, i) => ({ uid: file.attachment.uid, updates: { - status: results[i].response.ok ? "success" : "failed", + status: results[i].response.ok ? "embedded" : "failed", error: results[i].data?.error ?? null, document: results[i].data?.document, }, @@ -339,6 +344,7 @@ export function DnDFileUploaderProvider({ setShowWarningModal(false); setPendingFiles([]); setTokenCount(0); + window.dispatchEvent(new CustomEvent(ATTACHMENTS_PROCESSED_EVENT)); }; return ( diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx index a90d5b19a1b..8e7e25d3e3b 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx @@ -1,37 +1,44 @@ -import { useEffect, useState } from "react"; +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"; + +export default function ParsedFilesMenu({ + onEmbeddingChange, + tooltipRef, + files, + setFiles, + currentTokens, + setCurrentTokens, + contextWindow, + isLoading, +}) { + const { slug, threadSlug = null } = useParams(); -export default function ParsedFilesMenu({ workspace, onEmbeddingChange }) { - const { threadSlug = null } = useParams(); - const [files, setFiles] = useState([]); - const [isLoading, setIsLoading] = useState(true); const [isEmbedding, setIsEmbedding] = useState(false); const [embedProgress, setEmbedProgress] = useState(1); - const [contextWindow, setContextWindow] = useState(Infinity); - const [currentTokens, setCurrentTokens] = useState(0); const isOverflowing = currentTokens >= contextWindow * 0.8; - useEffect(() => { - async function fetchFiles() { - if (!workspace?.slug) return; - const { files, contextWindow, currentContextTokenCount } = - await Workspace.getParsedFiles(workspace.slug, threadSlug); - setFiles(files); - setContextWindow(contextWindow); + async function handleRemove(e, file) { + e.preventDefault(); + e.stopPropagation(); + if (!file?.id) return; + const success = await Workspace.deleteParsedFiles(slug, [file.id]); + if (success) { + setFiles((prev) => prev.filter((f) => f.id !== file.id)); + showToast("File removed from context window", "success", { + clear: true, + }); + const { currentContextTokenCount } = await Workspace.getParsedFiles( + slug, + threadSlug + ); setCurrentTokens(currentContextTokenCount); - setIsLoading(false); } - fetchFiles(); - }, [workspace, threadSlug]); - - async function handleRemove(file) { - if (!file?.id) return; - await Workspace.deleteParsedFile(workspace.slug, file.id); - setFiles((prev) => prev.filter((f) => f.id !== file.id)); } async function handleEmbed() { @@ -43,15 +50,26 @@ export default function ParsedFilesMenu({ workspace, onEmbeddingChange }) { let completed = 0; await Promise.all( files.map((file) => - Workspace.embedParsedFile(workspace.slug, file.id).then(() => { + Workspace.embedParsedFile(slug, file.id).then(() => { completed++; setEmbedProgress(completed + 1); }) ) ); setFiles([]); + const { currentContextTokenCount } = await Workspace.getParsedFiles( + slug, + threadSlug + ); + setCurrentTokens(currentContextTokenCount); + 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); @@ -62,7 +80,7 @@ export default function ParsedFilesMenu({ workspace, onEmbeddingChange }) {
- Current Context + Current Context ({files.length} files)
{nFormatter(currentTokens)} / {nFormatter(contextWindow)} tokens @@ -98,25 +116,26 @@ export default function ParsedFilesMenu({ workspace, onEmbeddingChange }) {
)}
- {files.map((file, i) => ( -
-
- {file.title} -
- -
- ))} +
+ {file.title} +
+ +
+ ))} {isLoading && (
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/index.jsx index 5befc498cad..6363d5fe36a 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/index.jsx @@ -2,7 +2,10 @@ import useUser from "@/hooks/useUser"; import { PaperclipHorizontal } from "@phosphor-icons/react"; import { Tooltip } from "react-tooltip"; import { useTranslation } from "react-i18next"; -import { useRef, useState } from "react"; +import { useRef, useState, useEffect } from "react"; +import { useParams } from "react-router-dom"; +import Workspace from "@/models/workspace"; +import { ATTACHMENTS_PROCESSED_EVENT } from "../../DnDWrapper"; import { useTheme } from "@/hooks/useTheme"; import ParsedFilesMenu from "./ParsedFilesMenu"; @@ -13,9 +16,32 @@ import ParsedFilesMenu from "./ParsedFilesMenu"; export default function AttachItem() { const { t } = useTranslation(); const { user } = useUser(); - const tooltipRef = useRef(null); const { theme } = useTheme(); + const { slug, threadSlug = null } = useParams(); + const tooltipRef = useRef(null); const [isEmbedding, setIsEmbedding] = useState(false); + const [files, setFiles] = useState([]); + const [currentTokens, setCurrentTokens] = useState(0); + const [contextWindow, setContextWindow] = useState(Infinity); + const [isLoading, setIsLoading] = useState(true); + + const fetchFiles = async () => { + if (!slug) return; + setIsLoading(true); + const { files, contextWindow, currentContextTokenCount } = + await Workspace.getParsedFiles(slug, threadSlug); + setFiles(files); + setContextWindow(contextWindow); + setCurrentTokens(currentContextTokenCount); + setIsLoading(false); + }; + + useEffect(() => { + fetchFiles(); + window.addEventListener(ATTACHMENTS_PROCESSED_EVENT, fetchFiles); + return () => + window.removeEventListener(ATTACHMENTS_PROCESSED_EVENT, fetchFiles); + }, [slug, threadSlug]); if (!!user && user.role === "default") return null; @@ -31,12 +57,20 @@ export default function AttachItem() { document?.getElementById("dnd-chat-file-uploader")?.click(); return; }} + onPointerEnter={fetchFiles} className={`border-none relative flex justify-center items-center opacity-60 hover:opacity-100 light:opacity-100 light:hover:opacity-60 cursor-pointer`} > - +
+ + {files.length > 0 && ( +
+ {files.length} +
+ )} +
- + ); diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx index a961bc8d20b..ed6e007a3f5 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx @@ -160,7 +160,9 @@ function AttachmentItem({ attachment }) { <>
@@ -186,7 +188,7 @@ function AttachmentItem({ attachment }) { {file.name}

- File embedded! + {status === "embedded" ? "File embedded!" : "Added as context!"}

diff --git a/frontend/src/models/workspace.js b/frontend/src/models/workspace.js index c83f56a0f9e..f65bc119ee9 100644 --- a/frontend/src/models/workspace.js +++ b/frontend/src/models/workspace.js @@ -267,9 +267,8 @@ const Workspace = { headers: baseHeaders(), body: JSON.stringify({ threadSlug }), }); - const data = await response.json(); - return data.files || []; + return data; }, uploadLink: async function (slug, link) { const response = await fetch(`${API_BASE}/workspace/${slug}/upload-link`, { @@ -475,12 +474,13 @@ const Workspace = { return { response, data }; }, - deleteParsedFile: async function (slug, fileId) { + deleteParsedFiles: async function (slug, fileIds = []) { const response = await fetch( - `${API_BASE}/workspace/${slug}/delete-parsed-file/${fileId}`, + `${API_BASE}/workspace/${slug}/delete-parsed-files`, { method: "DELETE", headers: baseHeaders(), + body: JSON.stringify({ fileIds }), } ); return response.ok; diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js index 7ca0cc67da5..d788e1f50df 100644 --- a/server/endpoints/workspaces.js +++ b/server/endpoints/workspaces.js @@ -152,23 +152,24 @@ function workspaceEndpoints(app) { ); app.delete( - "/workspace/:slug/delete-parsed-file/:fileId", + "/workspace/:slug/delete-parsed-files", [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], async function (request, response) { try { - const { slug = null, fileId = null } = request.params; + const { slug = null } = request.params; + const { fileIds = [] } = reqBody(request); const user = await userFromSession(request, response); const workspace = multiUserMode(response) ? await Workspace.getWithUser(user, { slug }) : await Workspace.get({ slug }); - if (!workspace || !fileId) { + if (!workspace || !fileIds.length) { response.sendStatus(400).end(); return; } const success = await WorkspaceParsedFiles.delete({ - id: parseInt(fileId), + id: { in: fileIds.map((id) => parseInt(id)) }, }); response.status(success ? 200 : 500).end(); } catch (e) { From 346e6eadb6e0afb455c0f2eaf69189c3e65160b8 Mon Sep 17 00:00:00 2001 From: shatfield4 Date: Thu, 7 Aug 2025 15:30:20 -0700 Subject: [PATCH 11/19] fix ux of FE + BE optimizations --- .../FileUploadWarningModal/index.jsx | 31 +++++++--- .../ChatContainer/DnDWrapper/index.jsx | 60 +++++++++++++------ .../PromptInput/Attachments/index.jsx | 8 ++- server/endpoints/workspaces.js | 7 ++- server/models/workspaceParsedFiles.js | 23 ++++--- server/utils/chats/stream.js | 6 +- 6 files changed, 91 insertions(+), 44 deletions(-) diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/FileUploadWarningModal/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/FileUploadWarningModal/index.jsx index 02023474791..96f56a35a25 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/FileUploadWarningModal/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/FileUploadWarningModal/index.jsx @@ -1,4 +1,4 @@ -import { X } from "@phosphor-icons/react"; +import { CircleNotch } from "@phosphor-icons/react"; import ModalWrapper from "@/components/ModalWrapper"; import pluralize from "pluralize"; @@ -10,9 +10,30 @@ export default function FileUploadWarningModal({ tokenCount, maxTokens, fileCount = 1, + isEmbedding = false, + embedProgress = 0, }) { if (!show) return null; + if (isEmbedding) { + return ( + +
+
+

+ Embedding {embedProgress + 1} of {fileCount}{" "} + {pluralize("file", fileCount)} +

+ +

+ Please wait while we embed your files... +

+
+
+
+ ); + } + return (
@@ -21,13 +42,6 @@ export default function FileUploadWarningModal({

Context Window Warning

-
@@ -57,6 +71,7 @@ export default function FileUploadWarningModal({ @@ -73,7 +75,7 @@ export default function FileUploadWarningModal({ onClick={onEmbed} disabled={isEmbedding} type="button" - className="transition-all duration-300 bg-white text-black hover:opacity-60 px-4 py-2 rounded-lg text-sm" + className="border-none transition-all duration-300 bg-white text-black hover:opacity-60 px-4 py-2 rounded-lg text-sm" > Embed {pluralize("File", fileCount)} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx index 65d9cf3e5ed..d9bfd5a8c71 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx @@ -15,6 +15,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. @@ -28,6 +30,17 @@ export const ATTACHMENTS_PROCESSED_EVENT = "ATTACHMENTS_PROCESSED"; * @property {('attachment'|'upload')} type - The type of upload. Attachments are chat-specific, uploads go to the workspace. */ +/** + * @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, children, @@ -52,10 +65,18 @@ export function DnDFileUploaderProvider({ 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 @@ -63,6 +84,18 @@ export function DnDFileUploaderProvider({ }; }, []); + /** + * 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 @@ -189,7 +222,9 @@ export function DnDFileUploaderProvider({ const { currentContextTokenCount, contextWindow: newContextWindow } = await Workspace.getParsedFiles(workspace.slug, threadSlug); - const newMaxTokens = Math.floor(newContextWindow * 0.8); + const newMaxTokens = Math.floor( + newContextWindow * Workspace.maxContextWindowLimit + ); setMaxTokens(newMaxTokens); let totalTokenCount = currentContextTokenCount; @@ -223,17 +258,17 @@ export function DnDFileUploaderProvider({ ); return; } + // Will always be one file in the array + /** @type {ParsedFile} */ + const file = data.files[0]; // Add token count for this file - const newTokenCount = data.files.reduce((sum, file) => { - return sum + (file.tokenCountEstimate || 0); - }, 0); - - totalTokenCount += newTokenCount; + // and add it to the batch pending files + totalTokenCount += file.tokenCountEstimate; batchPendingFiles.push({ attachment, - parsedFileId: data.files[0].id, - tokenCount: newTokenCount, + parsedFileId: file.id, + tokenCount: file.tokenCountEstimate, }); if (totalTokenCount > newMaxTokens) { @@ -244,7 +279,7 @@ export function DnDFileUploaderProvider({ } // File is within limits, keep in parsed files - const result = { success: true, document: data.files[0] }; + const result = { success: true, document: file }; const updates = { status: result.success ? "added_context" : "failed", error: result.error ?? null, diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx index 8e7e25d3e3b..31df15c1d91 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx @@ -5,6 +5,7 @@ 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"; export default function ParsedFilesMenu({ onEmbeddingChange, @@ -17,30 +18,40 @@ export default function ParsedFilesMenu({ isLoading, }) { const { slug, threadSlug = null } = useParams(); - const [isEmbedding, setIsEmbedding] = useState(false); const [embedProgress, setEmbedProgress] = useState(1); - - const isOverflowing = currentTokens >= contextWindow * 0.8; + const contextWindowLimitExceeded = + currentTokens >= contextWindow * Workspace.maxContextWindowLimit; async function handleRemove(e, file) { e.preventDefault(); e.stopPropagation(); if (!file?.id) return; + const success = await Workspace.deleteParsedFiles(slug, [file.id]); - if (success) { - setFiles((prev) => prev.filter((f) => f.id !== file.id)); - showToast("File removed from context window", "success", { - clear: true, - }); - const { currentContextTokenCount } = await Workspace.getParsedFiles( - slug, - threadSlug - ); - setCurrentTokens(currentContextTokenCount); - } + 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 + ); + setCurrentTokens(currentContextTokenCount); } + /** + * 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); @@ -86,7 +97,7 @@ export default function ParsedFilesMenu({ {nFormatter(currentTokens)} / {nFormatter(contextWindow)} tokens
- {isOverflowing && ( + {contextWindowLimitExceeded && (
{ + const fetchFiles = () => { if (!slug) return; setIsLoading(true); - const { files, contextWindow, currentContextTokenCount } = - await Workspace.getParsedFiles(slug, threadSlug); - setFiles(files); - setContextWindow(contextWindow); - setCurrentTokens(currentContextTokenCount); - setIsLoading(false); + Workspace.getParsedFiles(slug, threadSlug) + .then(({ files, contextWindow, currentContextTokenCount }) => { + setFiles(files); + setShowTooltip(files.length > 0); + setContextWindow(contextWindow); + setCurrentTokens(currentContextTokenCount); + }) + .finally(() => { + setIsLoading(false); + }); }; + /** + * Handles the removal of an attachment from the parsed files + * and triggers a re-fetch of the parsed files. + * This function handles when the user clicks the X on an Attachment via the AttachmentManager + * so we need to sync the state in the ParsedFilesMenu picker here. + */ + async function handleRemoveAttachment(e) { + const { document } = e.detail; + await Workspace.deleteParsedFiles(slug, [document.id]); + fetchFiles(); + } + + /** + * Handles the click event for the attach item button. + * @param {MouseEvent} e - The click event. + * @returns {void} + */ + function handleClick(e) { + e?.target?.blur(); + document?.getElementById("dnd-chat-file-uploader")?.click(); + return; + } + useEffect(() => { fetchFiles(); window.addEventListener(ATTACHMENTS_PROCESSED_EVENT, fetchFiles); - return () => + window.addEventListener(REMOVE_ATTACHMENT_EVENT, handleRemoveAttachment); + return () => { window.removeEventListener(ATTACHMENTS_PROCESSED_EVENT, fetchFiles); + window.removeEventListener( + REMOVE_ATTACHMENT_EVENT, + handleRemoveAttachment + ); + }; }, [slug, threadSlug]); if (!!user && user.role === "default") return null; - return ( <> - - - + {showTooltip && ( + + + + )} ); } diff --git a/frontend/src/models/workspace.js b/frontend/src/models/workspace.js index f65bc119ee9..20fef9512f3 100644 --- a/frontend/src/models/workspace.js +++ b/frontend/src/models/workspace.js @@ -7,6 +7,8 @@ import { ABORT_STREAM_EVENT } from "@/utils/chat"; const Workspace = { workspaceOrderStorageKey: "anythingllm-workspace-order", + /** The maximum percentage of the context window that can be used for attachments */ + maxContextWindowLimit: 0.8, new: async function (data = {}) { const { workspace, message } = await fetch(`${API_BASE}/workspace/new`, { diff --git a/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MSSQL.js b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MSSQL.js index d11314f6f26..720b304fb31 100644 --- a/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MSSQL.js +++ b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MSSQL.js @@ -96,7 +96,6 @@ class MSSQLConnector { getTablesSql() { return `SELECT name FROM sysobjects WHERE xtype='U';`; } - getTableSchemaSql(table_name) { return `SELECT COLUMN_NAME,COLUMN_DEFAULT,IS_NULLABLE,DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='${table_name}'`; } diff --git a/server/utils/chats/apiChatHandler.js b/server/utils/chats/apiChatHandler.js index 122674c7140..43a87d60c14 100644 --- a/server/utils/chats/apiChatHandler.js +++ b/server/utils/chats/apiChatHandler.js @@ -1,7 +1,6 @@ const { v4: uuidv4 } = require("uuid"); const { DocumentManager } = require("../DocumentManager"); const { WorkspaceChats } = require("../../models/workspaceChats"); -const { WorkspaceParsedFiles } = require("../../models/workspaceParsedFiles"); const { getVectorDbClass, getLLMProvider } = require("../helpers"); const { writeResponseChunk } = require("../helpers/chat/responses"); const { From 954cc38216f32b4d33c270486342fafdeabd39de Mon Sep 17 00:00:00 2001 From: timothycarambat Date: Thu, 7 Aug 2025 18:15:03 -0700 Subject: [PATCH 13/19] move parse support to another endpoint file simplify calls and loading of records --- server/endpoints/workspaces.js | 229 +--------------------- server/endpoints/workspacesParsedFiles.js | 203 +++++++++++++++++++ server/models/workspace.js | 4 +- server/models/workspaceParsedFiles.js | 30 +-- 4 files changed, 224 insertions(+), 242 deletions(-) create mode 100644 server/endpoints/workspacesParsedFiles.js diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js index 4e0d3d9bd00..af4eb9983b3 100644 --- a/server/endpoints/workspaces.js +++ b/server/endpoints/workspaces.js @@ -37,52 +37,12 @@ const truncate = require("truncate"); const { purgeDocument } = require("../utils/files/purgeDocument"); const { getModelTag } = require("./utils"); const { searchWorkspaceAndThreads } = require("../utils/helpers/search"); -const { WorkspaceParsedFiles } = require("../models/workspaceParsedFiles"); +const { workspaceParsedFilesEndpoints } = require("./workspacesParsedFiles"); function workspaceEndpoints(app) { if (!app) return; - const responseCache = new Map(); - app.post( - "/workspace/:slug/parsed-files", - [validatedRequest, flexUserRoleValid([ROLES.all])], - async (request, response) => { - try { - const { slug = null } = request.params; - const { threadSlug = null } = reqBody(request); - const user = await userFromSession(request, response); - - const workspace = multiUserMode(response) - ? await Workspace.getWithUser(user, { slug }) - : await Workspace.get({ slug }); - - if (!workspace) { - response.sendStatus(400).end(); - return; - } - - const thread = threadSlug - ? await WorkspaceThread.get({ slug: threadSlug }) - : null; - - const { files, contextWindow, currentContextTokenCount } = - await WorkspaceParsedFiles.getContextMetadataAndLimits( - workspace, - thread || null, - multiUserMode(response) ? user : null - ); - - response - .status(200) - .json({ files, contextWindow, currentContextTokenCount }); - } catch (e) { - console.error(e.message, e); - response.sendStatus(500).end(); - } - } - ); - app.post( "/workspace/new", [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], @@ -152,190 +112,6 @@ function workspaceEndpoints(app) { } ); - app.delete( - "/workspace/:slug/delete-parsed-files", - [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], - async function (request, response) { - try { - const { slug = null } = request.params; - const { fileIds = [] } = reqBody(request); - const user = await userFromSession(request, response); - const workspace = multiUserMode(response) - ? await Workspace.getWithUser(user, { slug }) - : await Workspace.get({ slug }); - - if (!workspace || !fileIds.length) { - response.sendStatus(400).end(); - return; - } - - const success = await WorkspaceParsedFiles.delete({ - id: { in: fileIds.map((id) => parseInt(id)) }, - }); - response.status(success ? 200 : 500).end(); - } catch (e) { - console.error(e.message, e); - response.sendStatus(500).end(); - } - } - ); - - app.post( - "/workspace/:slug/embed-parsed-file/:fileId", - [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], - async function (request, response) { - try { - const { slug = null, fileId = null } = request.params; - const user = await userFromSession(request, response); - const workspace = multiUserMode(response) - ? await Workspace.getWithUser(user, { slug }) - : await Workspace.get({ slug }); - - if (!workspace || !fileId) { - response.sendStatus(400).end(); - return; - } - - const { success, error, document } = - await WorkspaceParsedFiles.moveToDocumentsAndEmbed( - fileId, - workspace.id - ); - - if (!success) { - response.status(500).json({ - success: false, - error: error || "Failed to embed file", - }); - return; - } - - await Telemetry.sendTelemetry("document_embedded"); - await EventLogs.logEvent( - "document_embedded", - { - documentName: document?.name || "unknown", - workspaceId: workspace.id, - }, - user?.id - ); - - response.status(200).json({ - success: true, - error: null, - document, - }); - } catch (e) { - console.error(e.message, e); - response.sendStatus(500).end(); - } finally { - if (request.params.fileId) { - await WorkspaceParsedFiles.delete({ - id: parseInt(request.params.fileId), - }); - } - } - } - ); - - app.post( - "/workspace/:slug/parse", - [ - validatedRequest, - flexUserRoleValid([ROLES.admin, ROLES.manager]), - handleFileUpload, - ], - async function (request, response) { - try { - const { slug = null } = request.params; - const user = await userFromSession(request, response); - const workspace = multiUserMode(response) - ? await Workspace.getWithUser(user, { slug }) - : await Workspace.get({ slug }); - - if (!workspace) { - 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 parsed.`, - }); - return; - } - - const { success, reason, documents } = - await Collector.parseDocument(originalname); - if (!success || !documents?.[0]) { - response.status(500).json({ - success: false, - error: reason || "No document returned from collector", - }); - return; - } - - // Get thread ID if we have a slug - const { threadSlug } = reqBody(request); - let threadId = null; - if (threadSlug) { - const thread = await WorkspaceThread.get({ - slug: threadSlug, - workspace_id: workspace.id, - user_id: user?.id || null, - }); - threadId = thread?.id || null; - } - - const files = await Promise.all( - documents.map(async (doc) => { - const metadata = { ...doc }; - // Strip out pageContent - delete metadata.pageContent; - const filename = `${originalname}-${doc.id}.json`; - - const { file, error: dbError } = await WorkspaceParsedFiles.create({ - filename, - workspaceId: workspace.id, - userId: user?.id || null, - threadId, - metadata: JSON.stringify(metadata), - tokenCountEstimate: doc.token_count_estimate || 0, - }); - - if (dbError) throw new Error(dbError); - return file; - }) - ); - - Collector.log(`Document ${originalname} parsed successfully.`); - await Telemetry.sendTelemetry("document_parsed"); - await EventLogs.logEvent( - "document_parsed", - { - documentName: originalname, - workspaceId: workspace.id, - }, - user?.id - ); - - response.status(200).json({ - success: true, - error: null, - files, - }); - } catch (e) { - console.error(e.message, e); - response.sendStatus(500).end(); - } - } - ); - app.post( "/workspace/:slug/upload", [ @@ -1285,6 +1061,9 @@ function workspaceEndpoints(app) { } } ); + + // Parsed Files in separate endpoint just to keep the workspace endpoints clean + workspaceParsedFilesEndpoints(app); } module.exports = { workspaceEndpoints }; diff --git a/server/endpoints/workspacesParsedFiles.js b/server/endpoints/workspacesParsedFiles.js new file mode 100644 index 00000000000..228cc1cca8f --- /dev/null +++ b/server/endpoints/workspacesParsedFiles.js @@ -0,0 +1,203 @@ +const { reqBody, multiUserMode, userFromSession } = require("../utils/http"); +const { handleFileUpload } = require("../utils/files/multer"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { Telemetry } = require("../models/telemetry"); +const { + flexUserRoleValid, + ROLES, +} = require("../utils/middleware/multiUserProtected"); +const { EventLogs } = require("../models/eventLogs"); +const { validWorkspaceSlug } = require("../utils/middleware/validWorkspace"); +const { CollectorApi } = require("../utils/collectorApi"); +const { WorkspaceThread } = require("../models/workspaceThread"); + +const { WorkspaceParsedFiles } = require("../models/workspaceParsedFiles"); + +function workspaceParsedFilesEndpoints(app) { + if (!app) return; + + app.post( + "/workspace/:slug/parsed-files", + [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], + async (request, response) => { + try { + const { threadSlug = null } = reqBody(request); + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + const thread = threadSlug + ? await WorkspaceThread.get({ slug: threadSlug }) + : null; + const { files, contextWindow, currentContextTokenCount } = + await WorkspaceParsedFiles.getContextMetadataAndLimits( + workspace, + thread || null, + multiUserMode(response) ? user : null + ); + + return response + .status(200) + .json({ files, contextWindow, currentContextTokenCount }); + } catch (e) { + console.error(e.message, e); + return response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/workspace/:slug/delete-parsed-files", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + validWorkspaceSlug, + ], + async function (request, response) { + try { + const { fileIds = [] } = reqBody(request); + if (!fileIds.length) return response.sendStatus(400).end(); + const success = await WorkspaceParsedFiles.delete({ + id: { in: fileIds.map((id) => parseInt(id)) }, + }); + return response.status(success ? 200 : 500).end(); + } catch (e) { + console.error(e.message, e); + return response.sendStatus(500).end(); + } + } + ); + + app.post( + "/workspace/:slug/embed-parsed-file/:fileId", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + validWorkspaceSlug, + ], + async function (request, response) { + const { fileId = null } = request.params; + try { + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + + if (!fileId) return response.sendStatus(400).end(); + const { success, error, document } = + await WorkspaceParsedFiles.moveToDocumentsAndEmbed(fileId, workspace); + + if (!success) { + return response.status(500).json({ + success: false, + error: error || "Failed to embed file", + }); + } + + await Telemetry.sendTelemetry("document_embedded"); + await EventLogs.logEvent( + "document_embedded", + { + documentName: document?.name || "unknown", + workspaceId: workspace.id, + }, + user?.id + ); + + return response.status(200).json({ + success: true, + error: null, + document, + }); + } catch (e) { + console.error(e.message, e); + return response.sendStatus(500).end(); + } finally { + if (!fileId) return; + await WorkspaceParsedFiles.delete({ id: parseInt(fileId) }); + } + } + ); + + app.post( + "/workspace/:slug/parse", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + handleFileUpload, + validWorkspaceSlug, + ], + async function (request, response) { + try { + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + const Collector = new CollectorApi(); + const { originalname } = request.file; + const processingOnline = await Collector.online(); + + if (!processingOnline) { + return response.status(500).json({ + success: false, + error: `Document processing API is not online. Document ${originalname} will not be parsed.`, + }); + } + + const { success, reason, documents } = + await Collector.parseDocument(originalname); + if (!success || !documents?.[0]) { + return response.status(500).json({ + success: false, + error: reason || "No document returned from collector", + }); + } + + // Get thread ID if we have a slug + const { threadSlug = null } = reqBody(request); + const thread = threadSlug + ? await WorkspaceThread.get({ + slug: String(threadSlug), + workspace_id: workspace.id, + user_id: user?.id || null, + }) + : null; + const files = await Promise.all( + documents.map(async (doc) => { + const metadata = { ...doc }; + // Strip out pageContent + delete metadata.pageContent; + const filename = `${originalname}-${doc.id}.json`; + const { file, error: dbError } = await WorkspaceParsedFiles.create({ + filename, + workspaceId: workspace.id, + userId: user?.id || null, + threadId: thread?.id || null, + metadata: JSON.stringify(metadata), + tokenCountEstimate: doc.token_count_estimate || 0, + }); + + if (dbError) throw new Error(dbError); + return file; + }) + ); + + Collector.log(`Document ${originalname} parsed successfully.`); + await EventLogs.logEvent( + "document_uploaded_to_chat", + { + documentName: originalname, + workspace: workspace.slug, + thread: thread?.name || null, + }, + user?.id + ); + + return response.status(200).json({ + success: true, + error: null, + files, + }); + } catch (e) { + console.error(e.message, e); + return response.sendStatus(500).end(); + } + } + ); +} + +module.exports = { workspaceParsedFilesEndpoints }; diff --git a/server/models/workspace.js b/server/models/workspace.js index 0ddd1fbb77c..d17ffac6ba0 100644 --- a/server/models/workspace.js +++ b/server/models/workspace.js @@ -303,11 +303,10 @@ const Workspace = { */ async _getCurrentContextTokenCount(workspaceId, threadId = null) { const { WorkspaceParsedFiles } = require("./workspaceParsedFiles"); - const files = await WorkspaceParsedFiles.where({ + return await WorkspaceParsedFiles.totalTokenCount({ workspaceId: Number(workspaceId), threadId: threadId ? Number(threadId) : null, }); - return files.reduce((sum, file) => sum + (file.tokenCountEstimate || 0), 0); }, /** @@ -341,7 +340,6 @@ const Workspace = { }); if (!workspace) return null; - return { ...workspace, contextWindow: this._getContextWindow(workspace), diff --git a/server/models/workspaceParsedFiles.js b/server/models/workspaceParsedFiles.js index 9497c7e5106..ee71a2c9949 100644 --- a/server/models/workspaceParsedFiles.js +++ b/server/models/workspaceParsedFiles.js @@ -81,7 +81,15 @@ const WorkspaceParsedFiles = { } }, - moveToDocumentsAndEmbed: async function (fileId, workspaceId) { + totalTokenCount: async function (clause = {}) { + const { _sum } = await prisma.workspace_parsed_files.aggregate({ + where: clause, + _sum: { tokenCountEstimate: true }, + }); + return _sum.tokenCountEstimate || 0; + }, + + moveToDocumentsAndEmbed: async function (fileId, workspace) { try { const parsedFile = await this.get({ id: parseInt(fileId) }); if (!parsedFile) throw new Error("File not found"); @@ -105,9 +113,6 @@ const WorkspaceParsedFiles = { fs.copyFileSync(sourceFile, targetPath); fs.unlinkSync(sourceFile); - // Embed file - const workspace = await Workspace.get({ id: parseInt(workspaceId) }); - if (!workspace) throw new Error("Workspace not found"); const { failedToEmbed = [], errors = [], @@ -118,22 +123,19 @@ const WorkspaceParsedFiles = { parsedFile.userId ); - if (failedToEmbed.length > 0) { + if (failedToEmbed.length > 0) throw new Error(errors[0] || "Failed to embed document"); - } - - await this.delete({ id: parseInt(fileId) }); const document = await Document.get({ - workspaceId: parseInt(workspaceId), + workspaceId: workspace.id, docpath: embedded[0], }); - return { success: true, error: null, document }; } catch (error) { console.error("Failed to move and embed file:", error); return { success: false, error: error.message, document: null }; } finally { + // Always delete the file after processing await this.delete({ id: parseInt(fileId) }); } }, @@ -146,8 +148,8 @@ const WorkspaceParsedFiles = { try { if (!workspace) throw new Error("Workspace is required"); const files = await this.where({ - workspaceId: parseInt(workspace.id), - threadId: thread?.id ? parseInt(thread.id) : null, + workspaceId: workspace.id, + threadId: thread?.id || null, ...(user ? { userId: user.id } : {}), }); @@ -183,8 +185,8 @@ const WorkspaceParsedFiles = { getContextFiles: async function (workspace, thread = null, user = null) { try { const files = await this.where({ - workspaceId: parseInt(workspace.id), - threadId: thread?.id ? parseInt(thread.id) : null, + workspaceId: workspace.id, + threadId: thread?.id || null, ...(user ? { userId: user.id } : {}), }); From 1f97a01478eb21696d5efdc8b97bb00c4a9e7e57 Mon Sep 17 00:00:00 2001 From: timothycarambat Date: Fri, 8 Aug 2025 09:09:07 -0700 Subject: [PATCH 14/19] button borders --- .../PromptInput/AttachItem/ParsedFilesMenu/index.jsx | 4 ++-- server/endpoints/workspacesParsedFiles.js | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx index 31df15c1d91..d662895fff4 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx @@ -113,7 +113,7 @@ export default function ParsedFilesMenu({
-

+

Your workspace is using {numberWithCommas(tokenCount)} of{" "} {numberWithCommas(maxTokens)} available tokens. We recommend keeping - usage below 80% to ensure the best chat experience. Adding{" "} - {fileCount} more {pluralize("file", fileCount)} would exceed this - limit. Choose how you would like to proceed: + 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 d9bfd5a8c71..98c1abb10cf 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx @@ -5,7 +5,6 @@ import { useDropzone } from "react-dropzone"; import DndIcon from "./dnd-icon.png"; import Workspace from "@/models/workspace"; import showToast from "@/utils/toast"; -import useUser from "@/hooks/useUser"; import FileUploadWarningModal from "./FileUploadWarningModal"; import pluralize from "pluralize"; @@ -55,11 +54,10 @@ export function DnDFileUploaderProvider({ const [pendingFiles, setPendingFiles] = useState([]); const [tokenCount, setTokenCount] = useState(0); const [maxTokens, setMaxTokens] = useState(Number.POSITIVE_INFINITY); - const { user } = useUser(); useEffect(() => { System.checkDocumentProcessorOnline().then((status) => setReady(status)); - }, [user]); + }, []); useEffect(() => { window.addEventListener(REMOVE_ATTACHMENT_EVENT, handleRemove); @@ -158,8 +156,6 @@ export function DnDFileUploaderProvider({ 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, @@ -195,8 +191,6 @@ export function DnDFileUploaderProvider({ 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, @@ -435,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 index d662895fff4..b9a8392733e 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/ParsedFilesMenu/index.jsx @@ -6,6 +6,7 @@ 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, @@ -17,11 +18,16 @@ export default function ParsedFilesMenu({ 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 = - currentTokens >= contextWindow * Workspace.maxContextWindowLimit; + const [contextWindowLimitExceeded, setContextWindowLimitExceeded] = useState( + initialContextWindowLimitExceeded + ); async function handleRemove(e, file) { e.preventDefault(); @@ -44,7 +50,11 @@ export default function ParsedFilesMenu({ slug, threadSlug ); + const newContextWindowLimitExceeded = + currentContextTokenCount >= + contextWindow * Workspace.maxContextWindowLimit; setCurrentTokens(currentContextTokenCount); + setContextWindowLimitExceeded(newContextWindowLimitExceeded); } /** @@ -73,6 +83,10 @@ export default function ParsedFilesMenu({ threadSlug ); setCurrentTokens(currentContextTokenCount); + setContextWindowLimitExceeded( + currentContextTokenCount >= + contextWindow * Workspace.maxContextWindowLimit + ); showToast( `${files.length} ${pluralize("file", files.length)} embedded successfully`, "success" @@ -93,11 +107,27 @@ export default function ParsedFilesMenu({
Current Context ({files.length} files)
-
- {nFormatter(currentTokens)} / {nFormatter(contextWindow)} tokens +
+ {contextWindowLimitExceeded && ( + + )} +
+ {nFormatter(currentTokens)} / {nFormatter(contextWindow)} tokens +
- {contextWindowLimitExceeded && ( + {contextWindowLimitExceeded && canEmbed && (
{isEmbedding ? ( <> diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/index.jsx index 70b1b211ef3..cf3bbedb006 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/AttachItem/index.jsx @@ -18,7 +18,6 @@ import ParsedFilesMenu from "./ParsedFilesMenu"; */ export default function AttachItem() { const { t } = useTranslation(); - const { user } = useUser(); const { theme } = useTheme(); const { slug, threadSlug = null } = useParams(); const tooltipRef = useRef(null); @@ -80,7 +79,6 @@ export default function AttachItem() { }; }, [slug, threadSlug]); - if (!!user && user.role === "default") return null; return ( <>