🛠️ refactor: Handle .webp, Improve File Life Cycle 📁 (#1213)

* fix: handle webp images correctly

* refactor: use the userPath from the start of the filecycle to avoid handling the blob, whose loading may fail upon user request

* refactor: delete temp files on reload and new chat
This commit is contained in:
Danny Avila 2023-11-24 16:45:06 -05:00 committed by GitHub
parent 650759306d
commit cc39074e0a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 160 additions and 66 deletions

View file

@ -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));

View file

@ -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 {

View file

@ -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 };
}

View file

@ -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

View file

@ -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<void>}
*/
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,

View file

@ -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<React.SetStateAction<Map<string, ExtendedFile>>>;
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const setFilesToDelete = useSetFilesToDelete();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_batch, setFileDeleteBatch] = useState<BatchFile[]>([]);
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;
});

View file

@ -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<MenuItemProps> = ({
}) => {
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();

View file

@ -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<string | null>(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 (
<Dialog.Root>
@ -82,4 +78,4 @@ const Image = ({
);
};
export default memo(Image);
export default Image;

View file

@ -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<string, ExtendedFile>;
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 (
<div ref={drop} className="relative flex w-full grow overflow-hidden bg-white dark:bg-gray-800">

View file

@ -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';

View file

@ -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<string, ExtendedFile>());
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

View file

@ -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<string[]>([]);
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) {

View file

@ -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<TSubmission | null>(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(
{
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);
},
preset,
modelsData,
buildDefault,
);
},
[switchToConversation],
[switchToConversation, files, mutateAsync, setFiles],
);
return {

View file

@ -0,0 +1,5 @@
export default function useSetFilesToDelete() {
const setFilesToDelete = (files: Record<string, unknown>) =>
localStorage.setItem('filesToDelete', JSON.stringify(files));
return setFilesToDelete;
}

View file

@ -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<TConversation | null, string | number>({
@ -15,6 +15,11 @@ const conversationByIndex = atomFamily<TConversation | null, string | number>({
default: null,
});
const filesByIndex = atomFamily<Map<string, ExtendedFile>, 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,