diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index 3d85b439de..b1f4622eb3 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -8,8 +8,8 @@ const router = express.Router(); const isUUID = z.string().uuid(); -const isValidPath = (base, subfolder, filepath) => { - const normalizedBase = path.resolve(base, subfolder, 'temp'); +const isValidPath = (req, base, subfolder, filepath) => { + const normalizedBase = path.resolve(base, subfolder, req.user.id); const normalizedFilepath = path.resolve(filepath); return normalizedFilepath.startsWith(normalizedBase); }; @@ -20,7 +20,7 @@ const deleteFile = async (req, file) => { const subfolder = parts[1]; const filepath = path.join(publicPath, file.filepath); - if (!isValidPath(publicPath, subfolder, filepath)) { + if (!isValidPath(req, publicPath, subfolder, filepath)) { throw new Error('Invalid file path'); } @@ -40,6 +40,11 @@ router.delete('/', async (req, res) => { return isUUID.safeParse(file.file_id).success; }); + if (files.length === 0) { + res.status(204).json({ message: 'Nothing provided to delete' }); + return; + } + const file_ids = files.map((file) => file.file_id); const promises = []; promises.push(await deleteFiles(file_ids)); diff --git a/api/server/routes/files/images.js b/api/server/routes/files/images.js index da92b647b5..b8aaa6bc4d 100644 --- a/api/server/routes/files/images.js +++ b/api/server/routes/files/images.js @@ -33,7 +33,7 @@ router.post('/', upload.single('file'), async (req, res) => { uuidSchema.parse(metadata.file_id); metadata.temp_file_id = metadata.file_id; metadata.file_id = req.file_id; - await localStrategy({ res, file, metadata }); + await localStrategy({ req, res, file, metadata }); } catch (error) { console.error('Error processing file:', error); try { diff --git a/api/server/services/Files/images/convert.js b/api/server/services/Files/images/convert.js index 169a4ca5b1..2de0fd2177 100644 --- a/api/server/services/Files/images/convert.js +++ b/api/server/services/Files/images/convert.js @@ -1,16 +1,35 @@ const path = require('path'); const sharp = require('sharp'); -const fs = require('fs').promises; +const fs = require('fs'); const { resizeImage } = require('./resize'); -async function convertToWebP(inputFilePath, resolution = 'high') { +async function convertToWebP(req, file, resolution = 'high') { + const inputFilePath = file.path; const { buffer: resizedBuffer, width, height } = await resizeImage(inputFilePath, resolution); - const outputFilePath = inputFilePath.replace(/\.[^/.]+$/, '') + '.webp'; + const extension = path.extname(inputFilePath); + + const { imageOutput } = req.app.locals.config; + const userPath = path.join(imageOutput, req.user.id); + + if (!fs.existsSync(userPath)) { + fs.mkdirSync(userPath, { recursive: true }); + } + + const newPath = path.join(userPath, path.basename(inputFilePath)); + + if (extension.toLowerCase() === '.webp') { + const bytes = Buffer.byteLength(resizedBuffer); + await fs.promises.writeFile(newPath, resizedBuffer); + const filepath = path.posix.join('/', 'images', req.user.id, path.basename(newPath)); + return { filepath, bytes, width, height }; + } + + const outputFilePath = newPath.replace(extension, '.webp'); const data = await sharp(resizedBuffer).toFormat('webp').toBuffer(); - await fs.writeFile(outputFilePath, data); + await fs.promises.writeFile(outputFilePath, data); const bytes = Buffer.byteLength(data); - const filepath = path.posix.join('/', 'images', 'temp', path.basename(outputFilePath)); - await fs.unlink(inputFilePath); + const filepath = path.posix.join('/', 'images', req.user.id, path.basename(outputFilePath)); + await fs.promises.unlink(inputFilePath); return { filepath, bytes, width, height }; } diff --git a/api/server/services/Files/images/encode.js b/api/server/services/Files/images/encode.js index 76b1f3d932..90c14c051c 100644 --- a/api/server/services/Files/images/encode.js +++ b/api/server/services/Files/images/encode.js @@ -14,7 +14,7 @@ function encodeImage(imagePath) { }); } -async function encodeAndMove(req, file) { +async function updateAndEncode(req, file) { const { publicPath, imageOutput } = req.app.locals.config; const userPath = path.join(imageOutput, req.user.id); @@ -23,24 +23,16 @@ async function encodeAndMove(req, file) { } const filepath = path.join(publicPath, file.filepath); - if (!filepath.includes('temp')) { - const base64 = await encodeImage(filepath); - return [file, base64]; - } - - const newPath = path.join(userPath, path.basename(file.filepath)); - await fs.promises.rename(filepath, newPath); - const newFilePath = path.posix.join('/', 'images', req.user.id, path.basename(file.filepath)); const promises = []; - promises.push(updateFile({ file_id: file.file_id, filepath: newFilePath })); - promises.push(encodeImage(newPath)); + promises.push(updateFile({ file_id: file.file_id })); + promises.push(encodeImage(filepath)); return await Promise.all(promises); } async function encodeAndFormat(req, files) { const promises = []; for (let file of files) { - promises.push(encodeAndMove(req, file)); + promises.push(updateAndEncode(req, file)); } // TODO: make detail configurable, as of now resizing is done diff --git a/api/server/services/Files/localStrategy.js b/api/server/services/Files/localStrategy.js index 6238555911..0b711c7763 100644 --- a/api/server/services/Files/localStrategy.js +++ b/api/server/services/Files/localStrategy.js @@ -7,16 +7,18 @@ const { convertToWebP } = require('./images/convert'); * Files must be deleted from the server filesystem manually. * * @param {Object} params - The parameters object. + * @param {Express.Request} params.req - The Express request object. * @param {Express.Response} params.res - The Express response object. * @param {Express.Multer.File} params.file - The uploaded file. * @param {ImageMetadata} params.metadata - Additional metadata for the file. * @returns {Promise} */ -const localStrategy = async ({ res, file, metadata }) => { +const localStrategy = async ({ req, res, file, metadata }) => { const { file_id, temp_file_id } = metadata; - const { filepath, bytes, width, height } = await convertToWebP(file.path); + const { filepath, bytes, width, height } = await convertToWebP(req, file); const result = await createFile( { + user: req.user.id, file_id, temp_file_id, bytes, diff --git a/client/src/components/Chat/Input/Files/Images.tsx b/client/src/components/Chat/Input/Files/Images.tsx index c01bef68e7..421f1f69cd 100644 --- a/client/src/components/Chat/Input/Files/Images.tsx +++ b/client/src/components/Chat/Input/Files/Images.tsx @@ -2,6 +2,7 @@ import debounce from 'lodash/debounce'; import { useState, useEffect, useCallback } from 'react'; import type { BatchFile } from 'librechat-data-provider'; import { useDeleteFilesMutation } from '~/data-provider'; +import { useSetFilesToDelete } from '~/hooks'; import { ExtendedFile } from '~/common'; import Image from './Image'; @@ -14,6 +15,7 @@ export default function Images({ setFiles: React.Dispatch>>; setFilesLoading: React.Dispatch>; }) { + const setFilesToDelete = useSetFilesToDelete(); // eslint-disable-next-line @typescript-eslint/no-unused-vars const [_batch, setFileDeleteBatch] = useState([]); const files = Array.from(_files.values()); @@ -37,7 +39,7 @@ export default function Images({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [files]); - const deleteFiles = useDeleteFilesMutation({ + const { mutateAsync } = useDeleteFilesMutation({ onSuccess: () => { console.log('Files deleted'); }, @@ -49,10 +51,10 @@ export default function Images({ const executeBatchDelete = useCallback( (filesToDelete: BatchFile[]) => { console.log('Deleting files:', filesToDelete); - deleteFiles.mutate({ files: filesToDelete }); + mutateAsync({ files: filesToDelete }); setFileDeleteBatch([]); }, - [deleteFiles], + [mutateAsync], ); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -81,6 +83,8 @@ export default function Images({ const updatedFiles = new Map(currentFiles); updatedFiles.delete(file_id); updatedFiles.delete(temp_file_id); + const files = Object.fromEntries(updatedFiles); + setFilesToDelete(files); return updatedFiles; }); diff --git a/client/src/components/Chat/Menus/Endpoints/MenuItem.tsx b/client/src/components/Chat/Menus/Endpoints/MenuItem.tsx index 368d022e47..b300c12570 100644 --- a/client/src/components/Chat/Menus/Endpoints/MenuItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/MenuItem.tsx @@ -2,8 +2,9 @@ import { useState } from 'react'; import { Settings } from 'lucide-react'; import { EModelEndpoint } from 'librechat-data-provider'; import type { FC } from 'react'; -import { useLocalize, useUserKey, useNewConvo, useOriginNavigate } from '~/hooks'; +import { useLocalize, useUserKey, useOriginNavigate } from '~/hooks'; import { SetKeyDialog } from '~/components/Input/SetKeyDialog'; +import { useChatContext } from '~/Providers'; import { icons } from './Icons'; import { cn } from '~/utils'; @@ -27,8 +28,8 @@ const MenuItem: FC = ({ }) => { const Icon = icons[endpoint] ?? icons.unknown; const [isDialogOpen, setDialogOpen] = useState(false); + const { newConversation } = useChatContext(); const { getExpiry } = useUserKey(endpoint); - const { newConversation } = useNewConvo(); const navigate = useOriginNavigate(); const localize = useLocalize(); const expiryTime = getExpiry(); diff --git a/client/src/components/Chat/Messages/Content/Image.tsx b/client/src/components/Chat/Messages/Content/Image.tsx index 3062f36496..9043df30f9 100644 --- a/client/src/components/Chat/Messages/Content/Image.tsx +++ b/client/src/components/Chat/Messages/Content/Image.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, memo } from 'react'; +import React, { useState, useEffect } from 'react'; import { LazyLoadImage } from 'react-lazy-load-image-component'; import * as Dialog from '@radix-ui/react-dialog'; import DialogImage from './DialogImage'; @@ -19,7 +19,6 @@ const Image = ({ // n: number; // i: number; }) => { - const prevImagePathRef = useRef(null); const [isLoaded, setIsLoaded] = useState(false); const handleImageLoad = () => setIsLoaded(true); const [minDisplayTimeElapsed, setMinDisplayTimeElapsed] = useState(false); @@ -31,17 +30,14 @@ const Image = ({ } return () => clearTimeout(timer); }, [isLoaded]); - - useEffect(() => { - const prevImagePath = prevImagePathRef.current; - if (prevImagePath && prevImagePath?.startsWith('blob:') && prevImagePath !== imagePath) { - URL.revokeObjectURL(prevImagePath); - } - prevImagePathRef.current = imagePath; - }, [imagePath]); // const makeSquare = n >= 3 && i < 2; - const placeholderHeight = height > width ? '900px' : '288px'; + let placeholderHeight = '288px'; + if (height > width) { + placeholderHeight = '900px'; + } else if (height === width) { + placeholderHeight = width + 'px'; + } return ( @@ -82,4 +78,4 @@ const Image = ({ ); }; -export default memo(Image); +export default Image; diff --git a/client/src/components/Chat/Presentation.tsx b/client/src/components/Chat/Presentation.tsx index 0c2587bd57..ea9d77f3b1 100644 --- a/client/src/components/Chat/Presentation.tsx +++ b/client/src/components/Chat/Presentation.tsx @@ -1,8 +1,38 @@ +import { useEffect } from 'react'; +import type { ExtendedFile } from '~/common'; +import { useDragHelpers, useSetFilesToDelete } from '~/hooks'; import DragDropOverlay from './Input/Files/DragDropOverlay'; -import { useDragHelpers } from '~/hooks'; +import { useDeleteFilesMutation } from '~/data-provider'; export default function Presentation({ children }: { children: React.ReactNode }) { const { isOver, canDrop, drop } = useDragHelpers(); + const setFilesToDelete = useSetFilesToDelete(); + const { mutateAsync } = useDeleteFilesMutation({ + onSuccess: () => { + console.log('Temporary Files deleted'); + setFilesToDelete({}); + }, + onError: (error) => { + console.log('Error deleting temporary files:', error); + }, + }); + + useEffect(() => { + const filesToDelete = localStorage.getItem('filesToDelete'); + const map = JSON.parse(filesToDelete ?? '{}') as Record; + const files = Object.values(map) + .filter((file) => file.filepath) + .map((file) => ({ + file_id: file.file_id, + filepath: file.filepath as string, + })); + + if (files.length === 0) { + return; + } + mutateAsync({ files }); + }, [mutateAsync]); + const isActive = canDrop && isOver; return (
diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts index 66cb51971e..2cfa2a9f2b 100644 --- a/client/src/hooks/index.ts +++ b/client/src/hooks/index.ts @@ -29,4 +29,5 @@ export { default as useMessageHandler } from './useMessageHandler'; export { default as useOriginNavigate } from './useOriginNavigate'; export { default as useNavigateToConvo } from './useNavigateToConvo'; export { default as useSetIndexOptions } from './useSetIndexOptions'; +export { default as useSetFilesToDelete } from './useSetFilesToDelete'; export { default as useGenerationsByLatest } from './useGenerationsByLatest'; diff --git a/client/src/hooks/useChatHelpers.ts b/client/src/hooks/useChatHelpers.ts index 3eb8f857d2..a70fd7bb48 100644 --- a/client/src/hooks/useChatHelpers.ts +++ b/client/src/hooks/useChatHelpers.ts @@ -15,7 +15,8 @@ import type { TConversation, TGetConversationsResponse, } from 'librechat-data-provider'; -import type { TAskFunction, ExtendedFile } from '~/common'; +import type { TAskFunction } from '~/common'; +import useSetFilesToDelete from './useSetFilesToDelete'; import { useAuthContext } from './AuthContext'; import useNewConvo from './useNewConvo'; import useUserKey from './useUserKey'; @@ -23,8 +24,9 @@ import store from '~/store'; // this to be set somewhere else export default function useChatHelpers(index = 0, paramId: string | undefined) { - const [files, setFiles] = useState(new Map()); + const [files, setFiles] = useRecoilState(store.filesByIndex(index)); const [filesLoading, setFilesLoading] = useState(false); + const setFilesToDelete = useSetFilesToDelete(); const queryClient = useQueryClient(); const { isAuthenticated } = useAuthContext(); @@ -190,6 +192,7 @@ export default function useChatHelpers(index = 0, paramId: string | undefined) { if (reuseFiles && parentMessage.files?.length) { currentMsg.files = parentMessage.files; setFiles(new Map()); + setFilesToDelete({}); } else if (files.size > 0) { currentMsg.files = Array.from(files.values()).map((file) => ({ file_id: file.file_id, @@ -199,6 +202,7 @@ export default function useChatHelpers(index = 0, paramId: string | undefined) { width: file.width, })); setFiles(new Map()); + setFilesToDelete({}); } // construct the placeholder response message diff --git a/client/src/hooks/useFileHandling.ts b/client/src/hooks/useFileHandling.ts index 4ed82be631..21126a98f8 100644 --- a/client/src/hooks/useFileHandling.ts +++ b/client/src/hooks/useFileHandling.ts @@ -5,6 +5,7 @@ import type { ExtendedFile } from '~/common'; import { useToastContext } from '~/Providers/ToastContext'; import { useChatContext } from '~/Providers/ChatContext'; import { useUploadImageMutation } from '~/data-provider'; +import useSetFilesToDelete from './useSetFilesToDelete'; import { NotificationSeverity } from '~/common'; const sizeMB = 20; @@ -19,6 +20,7 @@ const useFileHandling = () => { const [errors, setErrors] = useState([]); const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]); const { files, setFiles, setFilesLoading } = useChatContext(); + const setFilesToDelete = useSetFilesToDelete(); const displayToast = useCallback(() => { if (errors.length > 1) { @@ -82,6 +84,11 @@ const useFileHandling = () => { } updatedFiles.set(fileId, { ...currentFile, ...updates }); + if (updates['filepath'] && updates['progress'] !== 1) { + const files = Object.fromEntries(updatedFiles); + setFilesToDelete(files); + } + return updatedFiles; }); }; @@ -94,6 +101,9 @@ const useFileHandling = () => { } else { console.warn(`File with id ${fileId} not found.`); } + + const files = Object.fromEntries(updatedFiles); + setFilesToDelete(files); return updatedFiles; }); }; @@ -107,14 +117,11 @@ const useFileHandling = () => { }); setTimeout(() => { - const file = files.get(data.temp_file_id); updateFileById(data.temp_file_id, { progress: 1, file_id: data.file_id, temp_file_id: data.temp_file_id, filepath: data.filepath, - // filepath: file?.preview, - preview: file?.preview, type: data.type, height: data.height, width: data.width, @@ -231,8 +238,7 @@ const useFileHandling = () => { replaceFile(extendedFile); await uploadFile(extendedFile); - // This gets cleaned up in the Image component, after receiving the server image - // URL.revokeObjectURL(preview); + URL.revokeObjectURL(preview); }; img.src = preview; } catch (error) { diff --git a/client/src/hooks/useNewConvo.ts b/client/src/hooks/useNewConvo.ts index 8964a1cf8b..c06768a783 100644 --- a/client/src/hooks/useNewConvo.ts +++ b/client/src/hooks/useNewConvo.ts @@ -1,8 +1,9 @@ import { useCallback } from 'react'; -import { useSetRecoilState, useResetRecoilState, useRecoilCallback } from 'recoil'; import { useGetEndpointsQuery } from 'librechat-data-provider'; +import { useSetRecoilState, useResetRecoilState, useRecoilCallback, useRecoilState } from 'recoil'; import type { TConversation, TSubmission, TPreset, TModelsConfig } from 'librechat-data-provider'; import { buildDefaultConvo, getDefaultEndpoint } from '~/utils'; +import { useDeleteFilesMutation } from '~/data-provider'; import useOriginNavigate from './useOriginNavigate'; import useSetStorage from './useSetStorage'; import store from '~/store'; @@ -10,12 +11,21 @@ import store from '~/store'; const useNewConvo = (index = 0) => { const setStorage = useSetStorage(); const navigate = useOriginNavigate(); - // const setConversation = useSetRecoilState(store.conversationByIndex(index)); const { setConversation } = store.useCreateConversationAtom(index); + const [files, setFiles] = useRecoilState(store.filesByIndex(index)); const setSubmission = useSetRecoilState(store.submissionByIndex(index)); const resetLatestMessage = useResetRecoilState(store.latestMessageFamily(index)); const { data: endpointsConfig = {} } = useGetEndpointsQuery(); + const { mutateAsync } = useDeleteFilesMutation({ + onSuccess: () => { + console.log('Files deleted'); + }, + onError: (error) => { + console.log('Error deleting files:', error); + }, + }); + const switchToConversation = useRecoilCallback( ({ snapshot }) => async ( @@ -66,21 +76,34 @@ const useNewConvo = (index = 0) => { modelsData?: TModelsConfig; buildDefault?: boolean; } = {}) => { - switchToConversation( - { - conversationId: 'new', - title: 'New Chat', - endpoint: null, - ...template, - createdAt: '', - updatedAt: '', - }, - preset, - modelsData, - buildDefault, - ); + const conversation = { + conversationId: 'new', + title: 'New Chat', + endpoint: null, + ...template, + createdAt: '', + updatedAt: '', + }; + + if (conversation.conversationId === 'new' && !modelsData) { + const filesToDelete = Array.from(files.values()) + .filter((file) => file.filepath) + .map((file) => ({ + file_id: file.file_id, + filepath: file.filepath as string, + })); + + setFiles(new Map()); + localStorage.setItem('filesToDelete', JSON.stringify({})); + + if (filesToDelete.length > 0) { + mutateAsync({ files: filesToDelete }); + } + } + + switchToConversation(conversation, preset, modelsData, buildDefault); }, - [switchToConversation], + [switchToConversation, files, mutateAsync, setFiles], ); return { diff --git a/client/src/hooks/useSetFilesToDelete.ts b/client/src/hooks/useSetFilesToDelete.ts new file mode 100644 index 0000000000..64f31f3f3d --- /dev/null +++ b/client/src/hooks/useSetFilesToDelete.ts @@ -0,0 +1,5 @@ +export default function useSetFilesToDelete() { + const setFilesToDelete = (files: Record) => + localStorage.setItem('filesToDelete', JSON.stringify(files)); + return setFilesToDelete; +} diff --git a/client/src/store/families.ts b/client/src/store/families.ts index 851a6a77b6..e4086a0736 100644 --- a/client/src/store/families.ts +++ b/client/src/store/families.ts @@ -7,7 +7,7 @@ import { useSetRecoilState, } from 'recoil'; import type { TMessage, TPreset, TConversation, TSubmission } from 'librechat-data-provider'; -import type { TOptionSettings } from '~/common'; +import type { TOptionSettings, ExtendedFile } from '~/common'; import { useEffect } from 'react'; const conversationByIndex = atomFamily({ @@ -15,6 +15,11 @@ const conversationByIndex = atomFamily({ default: null, }); +const filesByIndex = atomFamily, string | number>({ + key: 'filesByIndex', + default: new Map(), +}); + const conversationKeysAtom = atom<(string | number)[]>({ key: 'conversationKeys', default: [], @@ -99,6 +104,7 @@ function useCreateConversationAtom(key: string | number) { export default { conversationByIndex, + filesByIndex, presetByIndex, submissionByIndex, textByIndex,